Small Asteroid Blog

Spring Data R2DBC 패턴 가이드: Repository vs R2dbcEntityTemplate 비교 본문

백엔드/Spring

Spring Data R2DBC 패턴 가이드: Repository vs R2dbcEntityTemplate 비교

작은소행성☄️ 2025. 12. 17. 16:30
728x90

작성 배경: Config 업데이트 기능 구현 시 동적 쿼리 필요성으로 인해 R2dbcEntityTemplate 도입
핵심 문제: Request에 값이 없는 필드는 업데이트하지 않고, 있는 필드만 선택적으로 업데이트

 

개요

Spring Data R2DBC는 리액티브 데이터베이스 접근을 위한 두 가지 주요 패턴을 제공합니다:

  1. Repository 인터페이스 - 선언적 쿼리 방식
  2. 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
    );
}

장점 ✅

  1. 가독성 높음 - 쿼리가 명확하게 보임
  2. 타입 안전성 - 파라미터 타입 검증
  3. 간결한 코드 - 인터페이스만 정의하면 됨
  4. 자동 구현 - Spring Data가 자동으로 구현체 생성
  5. 테스트 용이 - Mock 객체 생성 쉬움

단점 ❌

  1. 고정된 쿼리 - 런타임에 쿼리 변경 불가
  2. NULL 처리 어려움 - 파라미터가 NULL이면 DB에 NULL로 업데이트됨
  3. 복잡한 동적 쿼리 불가능 - 조건부 필드 업데이트 어려움
  4. 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
                );
            });
    }
}

장점 ✅

  1. 동적 쿼리 구성 - 런타임에 조건부로 필드 선택
  2. NULL 안전 - 값이 없는 필드는 쿼리에서 제외
  3. 유연성 - 복잡한 조건 처리 가능
  4. 하나의 메서드로 다양한 케이스 처리 - 메서드 중복 불필요
  5. 타입 안전 - Criteria API로 컴파일 타임 검증

단점 ❌

  1. 코드가 길어짐 - Repository보다 많은 코드 필요
  2. 가독성 저하 - SQL이 코드에 숨어있음
  3. 디버깅 어려움 - 실제 실행되는 SQL을 바로 확인하기 어려움
  4. 학습 곡선 - 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

💡 핵심 원칙

  1. 기본은 Repository - 80%의 경우 충분
  2. 동적 쿼리는 EntityTemplate - 유연성이 필요할 때
  3. 혼합 사용 권장 - 각각의 장점 활용
  4. 성능은 동일 - 요구사항과 유지보수성으로 판단
  5. 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
반응형