반응형
250x250
Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
Tags
- bootstrap
- IntelliJ
- chart.js
- AWS CI/CD 구축하기
- docker
- codedeploy error
- redis
- JavaScript
- openlens
- java bigdecimal
- Jenkins
- kubeflow
- node
- codedeploy
- Spring
- PostgreSQL
- Flux
- codebuild
- codepipeline
- SQL
- Kafka
- aws cicd
- aws
- chartjs
- VPN
- Python
- COALESCE
- 도커
- Spring Error
- Airflow
Archives
- Today
- Total
Small Asteroid Blog
Spring Data R2DBC 패턴 가이드: Repository vs R2dbcEntityTemplate 비교 본문
백엔드/Spring
Spring Data R2DBC 패턴 가이드: Repository vs R2dbcEntityTemplate 비교
작은소행성☄️ 2025. 12. 17. 16:30728x90
작성 배경: Config 업데이트 기능 구현 시 동적 쿼리 필요성으로 인해 R2dbcEntityTemplate 도입
핵심 문제: Request에 값이 없는 필드는 업데이트하지 않고, 있는 필드만 선택적으로 업데이트
개요
Spring Data R2DBC는 리액티브 데이터베이스 접근을 위한 두 가지 주요 패턴을 제공합니다:
- Repository 인터페이스 - 선언적 쿼리 방식
- R2dbcEntityTemplate - 프로그래밍 방식의 동적 쿼리
Repository (@Query) 방식
특징
- 선언적(Declarative) 방식
- 인터페이스에
@Query어노테이션으로 SQL 작성 - 컴파일 타임에 쿼리가 고정됨
- Spring Data의 표준 패턴
코드 예시
public interface ConfigRepository extends R2dbcRepository<Config, Long> {
// 기본 CRUD는 자동 제공
// findById(), save(), delete() 등
// 커스텀 쿼리
@Modifying
@Query("""
UPDATE config
SET active = :active,
value = :value,
updated_at = :updatedAt
WHERE id = :id
""")
Mono<Void> updateConfig(
@Param("id") Long id,
@Param("active") Integer active,
@Param("value") String value,
@Param("updatedAt") LocalDateTime updatedAt
);
}
장점 ✅
- 가독성 높음 - 쿼리가 명확하게 보임
- 타입 안전성 - 파라미터 타입 검증
- 간결한 코드 - 인터페이스만 정의하면 됨
- 자동 구현 - Spring Data가 자동으로 구현체 생성
- 테스트 용이 - Mock 객체 생성 쉬움
단점 ❌
- 고정된 쿼리 - 런타임에 쿼리 변경 불가
- NULL 처리 어려움 - 파라미터가 NULL이면 DB에 NULL로 업데이트됨
- 복잡한 동적 쿼리 불가능 - 조건부 필드 업데이트 어려움
- CASE WHEN 필요 - 동적 처리 시 SQL이 복잡해짐
NULL 처리 문제
// 문제 상황
ConfigRequest request = new ConfigRequest();
request.setActive(1);
request.setValue(null); // value는 업데이트하고 싶지 않음
// Repository 호출
configRepository.updateConfig(id, 1, null, LocalDateTime.now());
// 실제 실행되는 SQL
UPDATE config
SET active = 1,
value = NULL, -- ❌ 의도하지 않은 NULL 업데이트!
updated_at = NOW()
WHERE id = ?
CASE WHEN을 사용한 해결책 (비추천)
@Query("""
UPDATE config
SET active = CASE WHEN :active IS NOT NULL THEN :active ELSE active END,
value = CASE WHEN :value IS NOT NULL THEN :value ELSE value END,
updated_at = :updatedAt
WHERE id = :id
""")
Mono<Void> updateConfig(...);
문제점:
- SQL이 복잡하고 가독성 떨어짐
- 필드가 많아지면 유지보수 어려움
- DB에 따라 동작이 다를 수 있음
R2dbcEntityTemplate 방식
특징
- 프로그래밍(Programmatic) 방식
- 런타임에 동적으로 쿼리 구성
- JPA의
EntityManager와 유사한 역할 - Spring Data R2DBC 핵심 API
코드 예시
@Service
@RequiredArgsConstructor
public class ConfigServiceImpl implements ConfigService {
private final R2dbcEntityTemplate r2dbcEntityTemplate;
private final ConfigRepository configRepository;
@Override
public Mono<Void> updateConfig(Long id, ConfigRequest request, String ldap) {
return configRepository.findById(id)
.switchIfEmpty(Mono.error(new CommonException(ErrorCodeEnum.DATA_NOT_FOUND)))
.flatMap(beforeData -> {
// 동적 Update 구성
Update update = Update.update("updatedAt", LocalDateTime.now());
// request에 값이 있을 때만 추가
if (request.getActive() != null) {
update = update.set("active", request.getActive());
}
if (request.getValue() != null) {
update = update.set("value", request.getValue());
}
// 동적 업데이트 실행
return r2dbcEntityTemplate.update(
Query.query(Criteria.where("id").is(id)),
update,
Config.class
);
});
}
}
장점 ✅
- 동적 쿼리 구성 - 런타임에 조건부로 필드 선택
- NULL 안전 - 값이 없는 필드는 쿼리에서 제외
- 유연성 - 복잡한 조건 처리 가능
- 하나의 메서드로 다양한 케이스 처리 - 메서드 중복 불필요
- 타입 안전 - Criteria API로 컴파일 타임 검증
단점 ❌
- 코드가 길어짐 - Repository보다 많은 코드 필요
- 가독성 저하 - SQL이 코드에 숨어있음
- 디버깅 어려움 - 실제 실행되는 SQL을 바로 확인하기 어려움
- 학습 곡선 - Criteria API 이해 필요
실행되는 SQL
// Case 1: active만 업데이트
ConfigRequest request = new ConfigRequest(1, null);
// 실제 실행 SQL
UPDATE config SET active = 1, updatedAt = NOW() WHERE id = ?
-- ✅ value는 쿼리에 포함되지 않음!
// Case 2: value만 업데이트
ConfigRequest request = new ConfigRequest(null, "new_value");
// 실제 실행 SQL
UPDATE config SET value = 'new_value', updatedAt = NOW() WHERE id = ?
-- ✅ active는 쿼리에 포함되지 않음!
// Case 3: 둘 다 업데이트
ConfigRequest request = new ConfigRequest(1, "new_value");
// 실제 실행 SQL
UPDATE config SET active = 1, value = 'new_value', updatedAt = NOW() WHERE id = ?
비교표
| 구분 | Repository (@Query) | R2dbcEntityTemplate |
| 쿼리 정의 | 선언적 (Declarative) | 프로그래밍 (Programmatic) |
| 유연성 | 낮음 (고정된 쿼리) | 높음 (동적 쿼리) |
| 가독성 | 높음 (SQL 명확) | 중간 (코드로 표현) |
| NULL 처리 | 어려움 (CASE WHEN 필요) | 쉬움 (조건부 추가) |
| 코드 복잡도 | 낮음 | 중간~높음 |
| 타입 안전성 | 높음 | 높음 (Criteria API) |
| 동적 조건 | 불가능 | 가능 |
| 디버깅 | 쉬움 (쿼리 직접 확인) | 어려움 (런타임 생성) |
| 학습 곡선 | 낮음 | 중간 |
| 성능 | 동일 | 동일 |
| 유지보수 | 쿼리가 단순하면 쉬움 | 복잡한 로직도 명확 |
언제 어떤 것을 사용할까?
Repository (@Query)를 사용하는 경우 ✅
1. 단순 CRUD 작업
// 기본 제공 메서드 활용
Mono<Config> findById(Long id);
Mono<Config> save(Config config);
Mono<Void> deleteById(Long id);
2. 고정된 검색 조건
@Query("SELECT * FROM config WHERE phase = :phase AND active = 1")
Flux<Config> findByPhaseAndActive(@Param("phase") String phase);
3. 전체 필드 업데이트
// save()로 충분
config.setActive(1);
config.setValue("new_value");
configRepository.save(config);
4. 복잡한 집계 쿼리
@Query(""" SELECT phase, COUNT(*) as cnt FROM config GROUP BY phase """)
Flux<PhaseCount> countByPhase();
R2dbcEntityTemplate을 사용하는 경우 ✅
1. 동적 필드 업데이트 ⭐ 가장 흔한 케이스
// request에 있는 필드만 업데이트
Update update = Update.update("updatedAt", now);
if (request.getField1() != null) update = update.set("field1", request.getField1());
if (request.getField2() != null) update = update.set("field2", request.getField2());
2. 복잡한 동적 검색 조건
Criteria criteria = Criteria.empty();
if (phase != null) criteria = criteria.and("phase").is(phase);
if (active != null) criteria = criteria.and("active").is(active);
if (startDate != null) criteria = criteria.and("createdAt").greaterThanOrEquals(startDate);
return r2dbcEntityTemplate .select(Query.query(criteria), Config.class);
3. 조건부 Bulk 업데이트
// 특정 조건의 레코드만 일괄 업데이트
Update update = Update.update("status", "INACTIVE");
Criteria criteria = Criteria.where("lastAccessDate").lessThan(thirtyDaysAgo);
return r2dbcEntityTemplate.update( Query.query(criteria), update, Config.class );
4. 트랜잭션 내 복잡한 로직
return r2dbcEntityTemplate.select(query1, Entity1.class)
.flatMap(entity1 -> {
Update update = buildDynamicUpdate(entity1);
return r2dbcEntityTemplate.update(query2, update, Entity2.class);
}
);
실전 예제
시나리오: 사용자 프로필 부분 업데이트
사용자가 프로필 수정 시 변경한 필드만 업데이트하고 싶은 경우
❌ Repository 방식 (문제 있음)
// Request
public class ProfileUpdateRequest {
private String nickname; // "newNick"
private String email; // null (변경 안 함)
private String phone; // "010-1234-5678"
private String avatar; // null (변경 안 함)
}
// Repository
@Modifying
@Query("""
UPDATE user_profile
SET nickname = :nickname,
email = :email,
phone = :phone,
avatar = :avatar,
updated_at = :updatedAt
WHERE user_id = :userId
""")
Mono<Void> updateProfile(
Long userId,
String nickname,
String email, // null이면 DB에 NULL로 저장됨 ❌
String phone,
String avatar, // null이면 DB에 NULL로 저장됨 ❌
LocalDateTime updatedAt
);
// 결과: email과 avatar가 NULL로 덮어써짐!
✅ R2dbcEntityTemplate 방식 (권장)
@Service
@RequiredArgsConstructor
public class UserProfileService {
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public Mono<Void> updateProfile(Long userId, ProfileUpdateRequest request) {
// 동적 Update 구성
Update update = Update.update("updatedAt", LocalDateTime.now());
// 값이 있는 필드만 추가
if (request.getNickname() != null) {
update = update.set("nickname", request.getNickname());
}
if (request.getEmail() != null) {
update = update.set("email", request.getEmail());
}
if (request.getPhone() != null) {
update = update.set("phone", request.getPhone());
}
if (request.getAvatar() != null) {
update = update.set("avatar", request.getAvatar());
}
// 실행
return r2dbcEntityTemplate.update(
Query.query(Criteria.where("userId").is(userId)),
update,
UserProfile.class
).then();
}
}
// 실제 실행 SQL:
// UPDATE user_profile
// SET nickname = 'newNick', phone = '010-1234-5678', updated_at = NOW()
// WHERE user_id = ?
// ✅ email과 avatar는 쿼리에 포함되지 않음!
시나리오: 복잡한 검색 조건
✅ R2dbcEntityTemplate 방식 (권장)
public Flux<Config> searchConfigs(ConfigSearchRequest request) {
// 동적 Criteria 구성
Criteria criteria = Criteria.empty();
// 각 조건을 선택적으로 추가
if (request.getPhase() != null) {
criteria = criteria.and("phase").like("%" + request.getPhase() + "%");
}
if (request.getGroup() != null) {
criteria = criteria.and("group").is(request.getGroup());
}
if (request.getActive() != null) {
criteria = criteria.and("active").is(request.getActive());
}
if (request.getStartDate() != null) {
criteria = criteria.and("createdAt").greaterThanOrEquals(request.getStartDate());
}
if (request.getEndDate() != null) {
criteria = criteria.and("createdAt").lessThanOrEquals(request.getEndDate());
}
// 페이징 + 정렬
Query query = Query.query(criteria)
.limit(request.getPageSize())
.offset(request.getPage() * request.getPageSize())
.sort(Sort.by(Sort.Direction.DESC, "createdAt"));
return r2dbcEntityTemplate.select(query, Config.class);
}
❌ Repository 방식 (복잡함)
// 모든 조합의 메서드를 만들어야 함
Flux<Config> findByPhase(String phase);
Flux<Config> findByPhaseAndGroup(String phase, String group);
Flux<Config> findByPhaseAndGroupAndActive(String phase, String group, Integer active);
// ... 32가지 조합 필요 (2^5)
// 또는 복잡한 동적 쿼리 (가독성 최악)
@Query("""
SELECT * FROM config
WHERE (:phase IS NULL OR phase LIKE CONCAT('%', :phase, '%'))
AND (:group IS NULL OR `group` = :group)
AND (:active IS NULL OR active = :active)
AND (:startDate IS NULL OR createdAt >= :startDate)
AND (:endDate IS NULL OR createdAt <= :endDate)
ORDER BY createdAt DESC
LIMIT :limit OFFSET :offset
""")
Flux<Config> searchDynamic(...); // 파라미터 많음
성능 고려사항
1. 쿼리 실행 성능
결론: 두 방식 모두 동일한 SQL을 생성하므로 성능 차이 없음
// Repository
@Query("UPDATE config SET active = :active WHERE id = :id")
Mono<Void> updateActive(Long id, Integer active);
// R2dbcEntityTemplate
Update update = Update.update("active", active);
r2dbcEntityTemplate.update(Query.query(Criteria.where("id").is(id)), update, Config.class);
// 둘 다 동일한 SQL 생성
// UPDATE config SET active = ? WHERE id = ?
2. 컴파일 타임 vs 런타임
| 구분 | Repository | R2dbcEntityTemplate |
| 쿼리 생성 시점 | 애플리케이션 시작 시 (컴파일 타임) | 요청 처리 시 (런타임) |
| 초기 로딩 시간 | 느림 | 빠름 |
| 요청 처리 시간 | 빠름 | 매우 약간 느림 (무시 가능) |
| 메모리 사용 | 쿼리가 메모리에 캐시됨 | 매번 생성 (GC 대상) |
실무 권장: 성능 차이는 무시할 수 있는 수준. 요구사항에 맞는 방식 선택
3. 벤치마크 (예상치)
단순 조회 (findById):
- Repository: 100ms
- EntityTemplate: 101ms
차이: 1% (무시 가능)
복잡한 동적 검색:
- Repository (고정 쿼리): 150ms
- EntityTemplate (동적 쿼리): 152ms
차이: 1.3% (무시 가능)
부분 업데이트:
- Repository (CASE WHEN): 120ms
- EntityTemplate (동적): 118ms
차이: -1.7% (오히려 약간 빠름)
결론: 성능보다는 코드 유지보수성과 요구사항 충족이 더 중요
혼합 사용 패턴 (Best Practice)
✅ 권장: Repository + EntityTemplate 조합
public interface ConfigRepository extends R2dbcRepository<Config, Long> {
// 단순 조회는 Repository로
Mono<Config> findById(Long id);
Flux<Config> findByPhase(String phase);
// 복잡한 집계는 @Query로
@Query("""
SELECT phase, COUNT(*) as cnt
FROM config
GROUP BY phase
""")
Flux<PhaseCount> countByPhase();
}
@Service
@RequiredArgsConstructor
public class ConfigServiceImpl implements ConfigService {
private final ConfigRepository configRepository;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
// 단순 조회는 Repository
public Mono<Config> getConfig(Long id) {
return configRepository.findById(id);
}
// 동적 업데이트는 EntityTemplate
public Mono<Void> updateConfig(Long id, ConfigRequest request) {
return configRepository.findById(id)
.flatMap(before -> {
Update update = buildDynamicUpdate(request);
return r2dbcEntityTemplate.update(
Query.query(Criteria.where("id").is(id)),
update,
Config.class
);
});
}
// 동적 검색은 EntityTemplate
public Flux<Config> searchConfigs(ConfigSearchRequest request) {
Criteria criteria = buildDynamicCriteria(request);
return r2dbcEntityTemplate.select(
Query.query(criteria),
Config.class
);
}
private Update buildDynamicUpdate(ConfigRequest request) {
Update update = Update.update("updatedAt", LocalDateTime.now());
if (request.getActive() != null) {
update = update.set("active", request.getActive());
}
if (request.getValue() != null) {
update = update.set("value", request.getValue());
}
return update;
}
private Criteria buildDynamicCriteria(ConfigSearchRequest request) {
Criteria criteria = Criteria.empty();
if (request.getPhase() != null) {
criteria = criteria.and("phase").is(request.getPhase());
}
if (request.getActive() != null) {
criteria = criteria.and("active").is(request.getActive());
}
return criteria;
}
}
결론 및 가이드라인
🎯 선택 기준
| 요구사항 | 선택 |
| 단순 CRUD | Repository |
| 전체 필드 업데이트 | Repository (save) |
| 부분 필드 업데이트 | R2dbcEntityTemplate ⭐ |
| 고정된 검색 조건 | Repository |
| 동적 검색 조건 | R2dbcEntityTemplate ⭐ |
| 복잡한 집계 쿼리 | Repository (@Query) |
| 복잡한 트랜잭션 로직 | R2dbcEntityTemplate |
💡 핵심 원칙
- 기본은 Repository - 80%의 경우 충분
- 동적 쿼리는 EntityTemplate - 유연성이 필요할 때
- 혼합 사용 권장 - 각각의 장점 활용
- 성능은 동일 - 요구사항과 유지보수성으로 판단
- NULL 처리 주의 - Repository는 CASE WHEN, EntityTemplate은 조건부 추가
📌 실무 적용 예시
// ConfigRepository: 기본 CRUD + 고정 검색
public interface ConfigRepository extends R2dbcRepository<Config, Long> {
Flux<Config> findByPhase(String phase);
@Query("SELECT DISTINCT phase FROM config")
Flux<String> findAllPhases();
}
// ConfigServiceImpl: 동적 업데이트/검색
@Service
public class ConfigServiceImpl {
// ✅ 동적 검색 - EntityTemplate
public Mono<PagedResponse> findConfig(SimpleSearchRequest request) {
Criteria criteria = buildDynamicCriteria(request);
// ...
}
// ✅ 부분 업데이트 - EntityTemplate
public Mono<Void> updateConfig(Long id, ConfigRequest request) {
Update update = buildDynamicUpdate(request);
// ...
}
}
참고 자료
728x90
반응형
'백엔드 > Spring' 카테고리의 다른 글
| WebFlux를 실무에 적용할 때 고려할 점 (0) | 2025.03.29 |
|---|---|
| Spring MVC와 WebFlux (1) | 2025.03.09 |
| [Spring] MongoTemplate 사용 시 @LastModifiedDate가 적용되지 않는 이유와 해결 방법 (0) | 2024.12.02 |
| [Spring] MongoDB - MongoTemplate 과 Auditing (0) | 2024.12.01 |
| [Spring] MongoDB 와 Redis 트랜잭션의 한계 와 대안 (0) | 2024.11.29 |