이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • JPA 쿼리를 작성할 때 인덱스는 어떻게 함께 설계해야 하는가?
  • Query Method, JPQL, Querydsl, native query는 어떤 기준으로 선택하는가?
  • ORM 코드 리뷰에서 DB 관점으로 무엇을 확인해야 하는가?

개요

JPA 쿼리는 Java 코드 안에 있지만 실행은 DB에서 한다. 따라서 JPA 쿼리 리뷰는 객체 모델뿐 아니라 SQL, 인덱스, 실행 계획 리뷰여야 한다.

Query Method가 편하다고 모든 조회를 메서드 이름으로 해결하면 복잡한 조건, 정렬, pagination, fetch 전략이 숨어버릴 수 있다.

원리

JPA 쿼리는 객체 필드와 메서드 이름으로 작성되지만 DB는 결국 SQL의 WHERE, JOIN, ORDER BY, LIMIT, COUNT를 실행한다. 좋은 JPA 쿼리는 Java에서 읽기 쉬운 것과 DB에서 실행하기 좋은 것 사이의 균형이다.

따라서 쿼리 작성 기준은 “메서드 하나로 표현 가능한가”가 아니라 “데이터량이 늘어도 인덱스와 실행 계획이 유지되는가”여야 한다. ORM 선택은 쿼리 모양을 숨기지 않고 드러내는 방향으로 해야 한다.

Query Method

단순 조건에는 Query Method가 좋다.

Optional<Member> findByEmail(String email);

이 메서드에는 Unique 인덱스가 자연스럽다.

CREATE UNIQUE INDEX uq_members_email ON members (email);

하지만 조건이 길어지면 의도가 흐려질 수 있다.

List<Order> findByMemberIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
    Long memberId,
    OrderStatus status,
    LocalDateTime from,
    LocalDateTime to
);

이 경우 실제 SQL과 인덱스를 명시적으로 문서화하는 편이 좋다.

복합 인덱스는 조건 순서와 정렬까지 함께 생각한다.

CREATE INDEX idx_orders_member_status_created_id
ON orders (member_id, status, created_at DESC, id DESC);

member_id, status로 좁힌 뒤 created_at, id 순서로 정렬하는 API라면 위 인덱스가 후보가 된다. 반대로 status만으로 전체 주문을 검색하는 관리자 화면에는 같은 인덱스가 충분하지 않을 수 있다.

Query Method가 길어지는 순간에는 “메서드 이름이 표현 가능한가”보다 “리뷰 가능한가”를 기준으로 본다. 조건이 3개 이상이고 정렬, pagination, fetch 전략이 섞이면 명시적 JPQL/Querydsl/DTO projection으로 SQL 의도를 드러내는 편이 낫다.

JPQL과 DTO Projection

화면에 필요한 데이터만 가져오려면 DTO projection을 쓴다.

@Query("""
    select new com.example.OrderSummary(o.id, o.status, o.createdAt, m.email)
    from Order o
    join o.member m
    where o.member.id = :memberId
    order by o.createdAt desc, o.id desc
""")
List<OrderSummary> findSummaries(Long memberId, Pageable pageable);

DB 관점에서는 다음 인덱스가 후보가 된다.

CREATE INDEX idx_orders_member_created_id
ON orders (member_id, created_at DESC, id DESC);

Page 반환이 필요하면 Spring Data JPA가 count 쿼리를 만들 수 있다. 조인이 복잡하면 count 쿼리는 직접 분리하는 것이 안전하다.

@Query(
    value = """
        select new com.example.OrderSummary(o.id, o.status, o.createdAt, m.email)
        from Order o
        join o.member m
        where o.member.id = :memberId
        order by o.createdAt desc, o.id desc
    """,
    countQuery = """
        select count(o)
        from Order o
        where o.member.id = :memberId
    """
)
Page<OrderSummary> findSummaries(Long memberId, Pageable pageable);

Querydsl

동적 조건이 많으면 Querydsl 같은 타입 안전 쿼리 빌더가 유용하다. 그러나 동적 조건은 인덱스 설계를 어렵게 만든다.

BooleanBuilder condition = new BooleanBuilder();
if (status != null) {
    condition.and(order.status.eq(status));
}
if (from != null) {
    condition.and(order.createdAt.goe(from));
}
if (keyword != null) {
    condition.and(order.memo.contains(keyword));
}

관리자 검색은 조건 조합이 많아 모든 조합에 인덱스를 만들 수 없다. 자주 쓰는 조건과 SLA를 기준으로 우선순위를 정해야 한다.

동적 조건은 “모든 조건을 한 화면에 넣기” 전에 사용 시나리오를 나누는 것이 좋다.

  • 고객센터가 이메일로 단건을 찾는 흐름
  • 운영자가 최근 7일 주문을 상태별로 보는 흐름
  • 정산 담당자가 월별 금액 범위를 보는 흐름
  • 보안 담당자가 IP나 기기 식별자로 추적하는 흐름

각 흐름은 필요한 인덱스와 허용 지연 시간이 다르다. 하나의 만능 검색 API로 합치면 가장 느린 조건 조합이 전체 API의 위험이 된다.

정렬도 whitelist로 제한한다.

Sort sort = switch (request.sort()) {
    case LATEST -> Sort.by(desc("createdAt"), desc("id"));
    case PRICE_ASC -> Sort.by(asc("totalPrice"), desc("id"));
    default -> throw new InvalidSortException();
};

클라이언트가 임의 컬럼명을 보내고 서버가 그대로 Pageable에 반영하면, 인덱스가 없는 정렬이 운영 DB에서 바로 실행될 수 있다. 동적 검색 API의 자유도는 제품 기능이면서 동시에 DB 부하 권한이다.

Native Query

DB 고유 기능이 필요하면 native query를 사용할 수 있다.

@Query(value = """
    SELECT id, title
    FROM posts
    WHERE search_vector @@ plainto_tsquery(:keyword)
    ORDER BY created_at DESC
    LIMIT 20
""", nativeQuery = true)
List<PostSearchRow> search(String keyword);

Native query는 강력하지만 DB 종속성이 생긴다. 테스트와 migration, 실행 계획 문서화가 더 중요하다.

Native query는 다음 조건 중 하나가 명확할 때 선택한다.

  • DB 고유 인덱스나 연산자를 사용해야 한다.
  • JPQL로 표현하면 SQL이 지나치게 비효율적이다.
  • window function, CTE, full-text search, upsert 등 DB 기능이 핵심이다.
  • 성능상 SQL 형태를 고정해야 한다.

단, native query가 많아지면 DB 교체 비용보다 “팀원이 SQL을 이해하고 테스트할 수 있는가”가 더 현실적인 제약이 된다.

코드 리뷰 체크리스트

JPA 쿼리 리뷰에서 다음을 확인한다.

  • 실제 SQL은 무엇인가?
  • 호출 빈도와 데이터량은 어느 정도인가?
  • WHERE와 ORDER BY를 지원하는 인덱스가 있는가?
  • count 쿼리가 필요한가?
  • N+1 가능성이 있는가?
  • DTO로 충분한데 엔티티를 과하게 로딩하지 않는가?
  • 동적 조건 중 인덱스를 망가뜨리는 패턴이 있는가?
  • 클라이언트가 요청한 sort와 size가 서버에서 제한되는가?
  • count 쿼리와 data 쿼리가 각각 어떤 인덱스를 쓰는가?

리뷰 기록은 짧아도 좋다.

Repository: OrderQueryRepository.findAdminOrders
주요 조건: status + createdAt range
정렬: createdAt desc, id desc만 허용
pagination: Slice, count 없음
후보 인덱스: orders(status, created_at desc, id desc)
제한: size max 100, 기간 기본 30일

실전 팁

  • 단순 조회는 Query Method, 복잡한 조회는 명시적 JPQL/Querydsl/DTO를 검토한다.
  • JPA 쿼리와 인덱스는 같은 PR에서 설명한다.
  • Page 대신 Slice가 충분한지 검토한다.
  • 관리자 검색은 모든 조건을 빠르게 만들려고 하지 말고 사용 빈도를 기준으로 설계한다.
  • Native query는 DB 종속성을 문서화한다.
  • 복합 인덱스는 WHERE 동등 조건, 범위 조건, ORDER BY, tie-breaker 순서까지 함께 설계한다.
  • Page가 꼭 필요한지 확인하고, count가 비싸면 Slice, cursor, 별도 비동기 count를 검토한다.
  • 동적 검색은 업무 흐름별 대표 조건을 정하고 그 조건을 기준으로 인덱스와 SLA를 잡는다.
  • Pageable sort와 size는 controller/service 경계에서 허용 목록과 상한을 강제한다.
  • JPA 쿼리 리뷰 기록에는 Repository 이름, SQL 의도, pagination 방식, 인덱스, 제한값을 남긴다.

위험 신호!

  • Repository 메서드 이름이 지나치게 길다.
  • contains 검색이 %keyword% SQL을 만들고 있는데 인덱스 고려가 없다.
  • Page count 쿼리가 대형 테이블을 스캔한다.
  • DTO로 충분한 목록에 엔티티와 컬렉션을 모두 로딩한다.
  • Native query에 테스트와 실행 계획이 없다.
  • Querydsl을 쓰면 동적 검색 성능 문제가 자동 해결된다고 생각한다.
  • count 쿼리가 데이터 조회 쿼리보다 더 비싼데 같은 API 지연으로만 본다.
  • 인덱스가 있다는 말만 있고 그 인덱스가 WHERE와 ORDER BY를 어떻게 지원하는지 설명하지 못한다.
  • 클라이언트 sort 파라미터가 곧 DB ORDER BY 컬럼이 된다.

확인 질문

  • JPA 쿼리 리뷰에서 실제 SQL을 봐야 하는 이유는 무엇인가?
    • Java 코드가 아니라 DB가 실행하는 SQL의 조건, 조인, 정렬, limit, count가 성능을 결정하기 때문이다.
  • 동적 검색 API에서 인덱스 설계가 어려운 이유는 무엇인가?
    • 조건 조합이 많아 모든 경우를 인덱스로 최적화할 수 없으므로 자주 쓰는 조건과 SLA를 기준으로 선택해야 하기 때문이다.
  • Native query를 사용할 때 추가로 필요한 것은 무엇인가?
    • DB 종속성 문서화, 실행 계획 확인, 통합 테스트, migration과 호환성 검증이 필요하다.
  • Page 대신 Slice를 검토할 만한 상황은 언제인가?
    • 전체 페이지 수가 필요 없고 다음 페이지 존재 여부만 알면 되는 목록에서 count 쿼리 비용을 줄이고 싶을 때다.
  • JPA 동적 검색에서 sort whitelist가 필요한 이유는 무엇인가?
    • 임의 정렬 컬럼이 DB에 전달되면 인덱스 없는 정렬, filesort, temporary table을 만들어 목록 API 장애로 이어질 수 있기 때문이다.

참고 문서