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

  • Spring Data Repository는 어떤 반복을 줄이고 어떤 판단은 여전히 개발자에게 남기는가?
  • 메서드 이름 쿼리, @Query, Projection, EntityGraph는 각각 언제 선택하는가?
  • Repository에 비즈니스 로직이 스며드는 신호는 무엇인가?

개요

Spring Data Repository는 데이터 접근 반복을 크게 줄인다. 인터페이스를 선언하면 CRUD 구현, 단순 쿼리 생성, pagination, sorting 같은 기능을 프레임워크가 제공한다. 그러나 Repository가 만들어 준다고 해서 데이터 접근 설계가 끝나는 것은 아니다.

조회 요구가 복잡해질수록 어떤 쿼리가 실행되는지, 어떤 연관관계를 함께 가져오는지, 어떤 DTO로 반환할지, 페이지 수 count 쿼리가 부담되지 않는지 같은 판단이 중요해진다.

Repository 기본

public interface MemberRepository extends JpaRepository<Member, Long> {
 
    Optional<Member> findByEmail(String email);
 
    Page<Member> findByStatus(MemberStatus status, Pageable pageable);
}

이런 단순 쿼리는 메서드 이름만으로 충분히 의도가 드러난다. Spring Data JPA는 메서드 이름을 분석해 JPQL 쿼리를 만들고 파라미터를 바인딩한다. 단, findById처럼 base repository가 이미 정의한 reserved method는 식별자 기준으로 동작하므로, 이름이 항상 엔티티의 임의 필드명으로 파싱된다고 보면 안 된다.

하지만 이름이 길어지기 시작하면 경고 신호다. findByStatusAndCreatedAtBetweenAndNameContainingAndGradeInOrderByCreatedAtDesc 같은 메서드는 컴파일은 되지만 읽기 어렵고 쿼리 의도도 흐린다. 프로퍼티 경로가 모호한 경우에는 _로 탐색 지점을 분리할 수 있지만, 그런 상황 자체가 명시적 쿼리로 옮길 신호일 수 있다.

@Query와 Projection

public interface OrderQueryRepository extends JpaRepository<Order, Long> {
 
    @Query("""
            select new com.example.order.OrderSummary(o.id, o.number, m.name, o.totalPrice)
            from Order o
            join o.member m
            where o.status = :status
            order by o.createdAt desc
            """)
    List<OrderSummary> findSummaries(@Param("status") OrderStatus status);
}

복잡한 조회에서는 @Query가 의도를 더 잘 드러낸다. Entity 전체가 필요 없는 목록 화면은 DTO projection으로 필요한 컬럼만 가져오는 편이 성능과 외부 계약 분리에 좋다.

Projection을 사용할 때는 정렬, pagination, count query, fetch join 제한을 함께 고려한다. 단순히 Entity를 덜 가져온다는 이유만으로 모든 조회를 projection으로 바꾸면 도메인 로직과 읽기 모델이 뒤섞일 수 있다. Page를 반환하면 count query 비용도 함께 발생할 수 있으므로, 무한 스크롤이나 다음 페이지 존재 여부만 필요하면 Slice 같은 선택지도 검토한다.

EntityGraph와 Fetch Plan

Spring Data JPA는 @EntityGraph로 특정 조회의 fetch plan을 지정할 수 있다. 기본 연관관계는 lazy로 두고, 특정 조회에서 필요한 연관만 함께 가져오고 싶을 때 유용하다.

@EntityGraph(attributePaths = "members")
Optional<Group> findByName(String name);

이 접근은 전역 EAGER보다 안전하다. 조회 유스케이스별로 필요한 그래프를 지정하기 때문이다.

Repository 책임

Repository는 저장소에 질문하고 저장하는 객체다. “이 회원이 주문할 수 있는가” 같은 규칙은 Repository가 아니라 Domain이나 Service의 책임이다. Repository 메서드가 validateMemberCanOrder처럼 변하기 시작하면 데이터 접근과 규칙 판단이 섞인 것이다.

좋은 Repository 메서드는 데이터 관점의 질문을 한다. existsByEmail, findById, findActiveOrdersByMemberId는 자연스럽다. 나쁜 Repository 메서드는 유스케이스 결정을 수행한다. checkSignupPolicyAndReturnMessage는 Service 책임에 가깝다.

실전 팁

  • 단순 조건은 메서드 이름 쿼리, 복잡한 조인과 projection은 @Query를 우선 검토한다.
  • reserved method와 프로퍼티 경로 파싱 규칙을 모른 채 이름만으로 쿼리 의도를 추측하지 않는다.
  • 긴 메서드 이름은 명시적 쿼리나 전용 query repository로 옮길 신호다.
  • 목록 화면은 Entity 반환보다 DTO projection을 검토한다.
  • 연관관계 fetch는 전역 EAGER보다 fetch join, EntityGraph, batch size를 조회별로 선택한다.
  • Repository 메서드 이름은 저장소에 던지는 질문처럼 짓는다.

위험 신호!

  • Repository 메서드 이름이 한 줄을 크게 넘어간다.
  • Repository가 사용자 메시지나 HTTP 상태를 결정한다.
  • 모든 조회가 Entity를 그대로 반환하고 Controller까지 전달된다.
  • Pagination 쿼리의 count 비용을 확인하지 않는다.
  • findById 같은 reserved method를 일반 프로퍼티 기반 derived query로 오해한다.
  • N+1을 해결하려고 연관관계를 전부 EAGER로 바꾼다.

확인 질문

확인 질문

  • 메서드 이름 쿼리가 적합한 경우는 언제인가?
    • 조건이 단순하고 이름만으로 조회 의도가 명확할 때다.
  • @EntityGraph는 어떤 문제를 해결하는 데 도움을 주는가?
    • 특정 조회에서 필요한 연관관계를 함께 가져오도록 fetch plan을 조정하는 데 도움을 준다.
  • Repository에 넣지 말아야 할 로직은 무엇인가?
    • 유스케이스 정책과 핵심 비즈니스 의사결정이다.

참고 문서