Skip to content
메모장
Go back

쿼리 - 효율적인 처리 와 N+1

개요

프로젝트의 조회 관련해서 해결해야할 두가지 문제가 있다.

이에 대한 해답으로 CQRS 패턴을 참고하였다.


1. light-CQRS

기본 원칙은 presentation → app → domain ← infra 방향의 DDD 레이어링이다.

// Command 서비스 — 정상 레이어링 (domain repository만 의존)
@Service
class BlockUserService(
    private val blockRepository: BlockRepository,   // domain 인터페이스
    private val domainEventPublisher: DomainEventPublisher,
) : BlockUserUseCase { ... }

그런데 UserQueryService, TermQueryService, ReviewQueryService, HelpQueryService 같은 조회 전용 서비스들은 이 원칙에서 의도적으로 벗어난다.

// Query 서비스 — 예외 (infra를 직접 의존)
@Service
class UserQueryService(
    private val userQuerydslRepository: UserQuerydslRepository  // infra 구현체
) {
    @Transactional(readOnly = true)
    fun findTestUser(testUserProviderIds: Set<String>): List<TestUserQueryResult> =
        userQuerydslRepository.findTestUserByProviderId(testUserProviderIds)
}

왜 허용하는가

조회는 도메인 불변식을 변경하지 않는다.

QueryDSL로 필요한 컬럼만 뽑아 바로 조회 결과 DTO를 만드는 게, domain repository → 도메인 엔티티 → 다시 DTO로 변환하는 것보다 레이어도 적고 오버페칭도 적다.

CQRS(Command/Query Responsibility Segregation)를 가볍게 적용한 설계 선택이다.

진짜 위반은

상태를 변경하는 Command 서비스가 infra를 직접 참조하거나, domain repository가 아닌 JPA repository를 직접 주입받는 경우다.

판단 기준은 “이 서비스가 저장/수정/삭제를 하는가”다.

코드 리뷰에서 새 XxxQueryService를 볼 때 확인할 것도 하나뿐이다

  • @Transactional(readOnly = true)가 아닌 쓰기 메서드가 몰래 끼어 있으면 그 메서드만이라도 domain repository를 거치도록 분리해야 한다.

2. N+1 — @BatchSize 대신 QueryDSL

N+1이 발생하는 지점은 Hibernate의 @BatchSize 같은 최적화 힌트에 기대지 않고, QueryDSL로 필요한 형태를 직접 조회ㅐ하도록 하였다.

대표 사례가 BlockedUserProfileAdapter다.

// Before — 전체 엔티티 조회, N+1 위험 + 오버페칭
userJpaRepository.findAllById(userIds)

// After — 필요한 컬럼만 Projection
queryFactory
    .select(Projections.constructor(
        BlockedUserProfile::class.java,
        userJpaEntity.id, userJpaEntity.userProfile.nickName, userJpaEntity.userProfile.profileImageKey
    ))
    .from(userJpaEntity)
    .where(userJpaEntity.id.`in`(userIds))
    .fetch()

@BatchSize를 쓰면 N+1은 줄어들지만 여전히 엔티티 전체를 끌고 오고, 배치 크기 튜닝이라는 또 다른 변수가 생긴다.

Projection으로 직접 조회하면 필요한 컬럼만 SQL 레벨에서 뽑아오니 오버페칭도 없고, “N+1이 왜 안 나는지”가 힌트 설정이 아니라 쿼리 자체에서 명시적으로 드러난다.

QueryDSL이 만능은 아니다. 쿼리가 복잡해질 경우 query 코드 자체가 너무 길어져 가독성이 떨어질 수 있다. 그 경우 에는 raw sql을 사용하는 것을 고려해야한다.


3. 번외 - 정렬 기준이 여러 개인 커서 페이지네이션

List API는 offset이 아니라 커서 기반으로 구현하는데, 정렬 기준이 여러 개일 수 있는 경우(최신순/오래된순/가격순/거리순 등)는 정렬마다 “이 커서 이후” 조건을 만드는 헬퍼를 따로 둔다.

// HelpCursorConditions — HelpSort별로 afterXxxCursor() 함수 분리
fun afterCursor(sort: HelpSort, cursor: Help?): BooleanExpression =
    when (sort) {
        HelpSort.LATEST -> afterLatestCursor(cursor)
        HelpSort.PRICE -> afterPriceCursor(cursor)
        HelpSort.DISTANCE -> afterDistanceCursor(cursor)
    }

이 커서 조건 빌더 자체(2단/3단 튜플 비교, haversine 거리 계산 등)는 모듈마다 미묘하게 다른 로직이라 공통화하지 않았다

반면 “size+1로 조회한 결과에서 hasNext/nextCursor를 계산하는” 부분은 공통화했다

data class CursorPage<T, ID>(
    val items: List<T>,
    val nextCursor: ID?,
    val hasNext: Boolean
) {
    companion object {
        fun <T, ID> of(rows: List<T>, size: Int, idOf: (T) -> ID): CursorPage<T, ID>
    }
}

“뭘 공통화하고 뭘 안 하는가”의 기준은 결국 하나였다

  • 여러 곳에서 진짜로 동일한 로직인가, 아니면 표면적으로만 비슷해 보이는 서로 다른 로직인가.
  • 커서 조건 빌더는 후자였고, hasNext 계산은 전자였다.

Share this post:

Previous Post
Redis vs RDB
Next Post
FK 없는 스키마 설계 — 데이터 무결성을 애플리케이션에 넘긴 이유