본문으로 건너뛰기
Advertisement

9.6 JPQL 조인(JOIN)과 고급 조회

연관된 엔티티를 한 번에 가져오거나, 여러 테이블을 조합해 조회할 때 JPQL(Java Persistence Query Language)JOIN 문법을 사용합니다. 여기서는 INNER/LEFT JOIN, FETCH JOIN, @EntityGraph, 페이징 시 주의점, DTO 프로젝션까지 실무에서 자주 쓰는 패턴을 정리합니다.

작성 기준: Spring Data JPA, Hibernate (JPA 3.x / Jakarta Persistence)


1. JPQL JOIN 종류 개요

JPQL은 엔티티를 대상으로 쿼리합니다(테이블명이 아니라 엔티티 클래스명 사용). JOIN은 연관관계가 맺어진 엔티티끼리 묶을 때 사용합니다.

JOIN 유형JPQL 키워드설명생성되는 SQL
내부 조인JOIN 또는 INNER JOIN연관 데이터가 있는 행만 반환INNER JOIN
외부 조인LEFT JOIN 또는 LEFT OUTER JOIN왼쪽 엔티티는 모두 포함, 오른쪽 없으면 nullLEFT OUTER JOIN
페치 조인JOIN FETCH연관 엔티티를 같은 SELECT로 로딩(N+1 방지)INNER JOIN + 한 번에 조회

2. 기본 JOIN (INNER / LEFT)

@Query로 JPQL을 작성할 때, 연관관계 필드를 기준으로 JOIN을 걸 수 있습니다.

예제 엔티티

@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String orderNo;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}

@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}

INNER JOIN

public interface OrderRepository extends JpaRepository<Order, Long> {

@Query("SELECT o FROM Order o JOIN o.member m WHERE m.name = :name")
List<Order> findByMemberName(@Param("name") String name);
}
  • JOIN o.member m: OrderMember내부 조인. 회원이 없는 주문은 결과에서 제외됩니다.
  • 생성되는 SQL: SELECT o.* FROM orders o INNER JOIN members m ON o.member_id = m.id WHERE m.name = ?

LEFT JOIN (회원 없어도 주문은 포함)

@Query("SELECT o FROM Order o LEFT JOIN o.member m WHERE m.name = :name OR m.name IS NULL")
List<Order> findByMemberNameOrNoMember(@Param("name") String name);
  • LEFT JOIN o.member m: 주문은 모두 나오고, 연관 회원이 없으면 m 필드는 null로 채워집니다.
  • 주의: JOIN만 쓰고 FETCH를 안 쓰면, Order만 조회되고 member별도 쿼리(Lazy) 로 나갈 수 있습니다. 연관 엔티티를 한 번에 가져오려면 아래 JOIN FETCH를 사용하세요.

3. JOIN FETCH — N+1 해결의 핵심

JOIN FETCH는 “연관 엔티티를 이 쿼리 한 번에 같이 조회”하라는 의미입니다. Lazy 로딩으로 인한 N+1 문제를 제거할 때 가장 많이 씁니다.

문법

@Query("SELECT o FROM Order o JOIN FETCH o.member WHERE o.id = :id")
Optional<Order> findByIdWithMember(@Param("id") Long id);
  • JOIN FETCH o.member: Order를 조회할 때 member까지 같은 SELECT에 포함합니다.
  • 생성되는 SQL: SELECT o.*, m.* FROM orders o INNER JOIN members m ON o.member_id = m.id WHERE o.id = ?
  • 이렇게 하면 서비스에서 order.getMember().getName()을 호출해도 추가 쿼리가 나가지 않습니다.

컬렉션(OneToMany) FETCH 시 distinct

일대다 연관(List<Comment> 등)을 FETCH하면, SQL 상으로 행이 댓글 수만큼 늘어나서 Order가 중복되어 반환될 수 있습니다. 이때 JPQL에 distinct를 넣어 중복 엔티티를 제거합니다.

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems WHERE o.member.id = :memberId")
List<Order> findByMemberIdWithItems(@Param("memberId") Long memberId);
  • DISTINCT: JPQL 레벨에서 같은 Order 인스턴스가 여러 번 나오는 것을 한 번만 남깁니다.
  • SQL에 DISTINCT가 붙지만, 행 수가 이미 늘어나 있어서 DB 레벨 중복 제거만으로는 부족할 수 있으므로, JPA가 결과를 엔티티 단위로 중복 제거해 줍니다.

여러 연관 한 번에 FETCH

한 쿼리에서 여러 연관을 FETCH할 수 있습니다(같은 레벨에서).

@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.member " +
"JOIN FETCH o.orderItems " +
"WHERE o.id = :id")
Optional<Order> findByIdWithMemberAndItems(@Param("id") Long id);
  • 주의: orderItems가 컬렉션이면 카테시안 곱으로 행이 불어나므로, 데이터가 많을 때는 FETCH는 하나만 쓰고 나머지는 별도 쿼리 또는 배치 크기(@BatchSize)로 처리하는 전략도 고려합니다.

4. @EntityGraph — 선언적으로 FETCH 지정

@EntityGraph는 메서드 이름 쿼리나 findById 등에 “이 연관은 EAGER처럼 한 번에 가져와라”라고 선언만 해두는 방식입니다. JPQL을 쓰지 않고도 FETCH JOIN과 같은 효과를 낼 수 있습니다.

attributePaths에 연관 필드 지정

@EntityGraph(attributePaths = {"member"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdWithMember(@Param("id") Long id);
  • attributePaths = {"member"}: Order 조회 시 member를 같이 조인해서 가져옵니다.
  • 메서드 이름만으로 조회하는 경우:
@EntityGraph(attributePaths = {"member", "orderItems"})
List<Order> findByMemberId(Long memberId);
  • 이렇게 하면 findByMemberId가 생성하는 SELECT에 member, orderItems가 JOIN FETCH로 포함됩니다.

중첩 연관 (연관의 연관)

@EntityGraph(attributePaths = {"member", "orderItems.product"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdWithMemberAndItemProducts(@Param("id") Long id);
  • orderItems.product: OrderItemProduct까지 한 번에 가져옵니다.

5. ON 조건이 있는 JOIN

JPQL에서 조인 조건을 추가하고 싶을 때는 ON 절을 사용합니다. (JPA 2.1+)

@Query("SELECT o FROM Order o LEFT JOIN o.member m ON m.status = 'ACTIVE' WHERE o.createdAt >= :since")
List<Order> findOrdersWithActiveMemberSince(@Param("since") LocalDateTime since);
  • 일반적으로 연관관계 매핑이 있으면 ON 없이도 기본 FK 조건이 붙고, ON은 “추가 조건”(예: 회원 상태, 기간)을 걸 때 사용합니다.

6. 페이징과 JOIN FETCH — 주의사항

JOIN FETCH를 사용한 쿼리에 Pageable을 그대로 넘기면, Hibernate가 WARN을 내보내거나 메모리 페이징으로 동작할 수 있습니다. 컬렉션 FETCH페이징을 동시에 쓰는 것은 제한됩니다.

권장 패턴

  1. FETCH 없이 조회하고, @BatchSize별도 쿼리로 연관을 채우기:
    Page<Order> findByMemberId(Long memberId, Pageable pageable);
    // 서비스에서 필요 시 orderRepository.findByIdWithItems(id) 등 별도 호출
  2. DTO 프로젝션으로 필요한 컬럼만 조회하고, 페이징은 일반 쿼리로 처리:
    @Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.orderNo, m.name) " +
    "FROM Order o JOIN o.member m WHERE o.member.id = :memberId")
    Page<OrderSummaryDto> findOrderSummariesByMember(@Param("memberId") Long memberId, Pageable pageable);
  3. 컬렉션 FETCH가 꼭 필요하면, 페이징 없이 List로 조회하고 애플리케이션에서 잘라 쓰는 방식(또는 커서 기반)을 고려합니다.

7. DTO 프로젝션과 JOIN

API 응답용으로 엔티티 전체가 아니라 일부 필드만 담은 DTO가 필요할 때, JPQL 생성자 표현식으로 JOIN 결과를 DTO에 바로 넣을 수 있습니다.

public record OrderSummaryDto(Long orderId, String orderNo, String memberName) {}

@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.orderNo, m.name) " +
"FROM Order o JOIN o.member m WHERE o.member.id = :memberId")
List<OrderSummaryDto> findOrderSummariesByMemberId(@Param("memberId") Long memberId);
  • new 패키지.클래스(생성자 인자들): 조인 결과를 DTO 생성자로 넘깁니다.
  • 장점: 연관 엔티티를 전부 로딩하지 않아도 되고, 필요한 컬럼만 선택하므로 메모리와 쿼리 비용을 줄일 수 있습니다. 페이징과도 함께 사용하기 좋습니다.

8. Native Query에서 JOIN (참고)

특수한 SQL 함수나 DB 전용 문법을 쓸 때만 nativeQuery = true를 사용합니다. 가능하면 JPQL + DTO 프로젝션을 우선하고, 네이티브는 꼭 필요할 때만 제한적으로 사용하는 것이 유지보수에 유리합니다.

@Query(value = "SELECT o.*, m.name as member_name FROM orders o " +
"LEFT JOIN members m ON o.member_id = m.id WHERE o.id = :id",
nativeQuery = true)
Optional<Object[]> findOrderWithMemberNameNative(@Param("id") Long id);
  • 반환 타입이 복잡해지므로, 보통 Projection 인터페이스SqlResultSetMapping과 함께 사용합니다.

9. 요약 표

목적추천 방법비고
N+1 제거 (단일 연관)JOIN FETCH 또는 @EntityGraph(attributePaths = {"member"})Lazy 대신 한 번에 로딩
N+1 제거 (컬렉션)JOIN FETCH + DISTINCT 또는 @BatchSize카테시안 곱·메모리 주의
페이징 + 연관 데이터DTO 프로젝션 + 일반 JOIN, 또는 별도 배치 조회컬렉션 FETCH와 페이징 동시 사용 지양
응답용 최소 데이터SELECT new ... DTO(...) FROM ... JOIN ...엔티티 노출·비용 감소
추가 조인 조건LEFT JOIN o.member m ON m.status = 'ACTIVE'ON 절 활용

실무에서는 연관이 하나일 때@EntityGraph, 조건이 복잡하거나 DTO로 바로 넘길 때@Query + JPQL JOIN / 생성자 프로젝션 조합을 많이 사용합니다. 동적 조건·복잡한 검색이 필요하면 9.7 Querydsl로 타입 세이프하게 조인·프로젝션을 구현할 수 있습니다.

Advertisement