10.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 | 왼쪽 엔티티는 모두 포함, 오른쪽 없으면 null | LEFT 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:Order와Member를 내부 조인. 회원이 없는 주문은 결과에서 제외됩니다.- 생성되는 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:OrderItem→Product까지 한 번에 가져옵니다.
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 와 페이징 을 동시에 쓰는 것은 제한됩니다.
권장 패턴
- FETCH 없이 조회하고, @BatchSize 나 별도 쿼리 로 연관을 채우기:
Page<Order> findByMemberId(Long memberId, Pageable pageable);
// 서비스에서 필요 시 orderRepository.findByIdWithItems(id) 등 별도 호출 - 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); - 컬렉션 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 / 생성자 프로젝션 조합을 많이 사용합니다. 동적 조건·복잡한 검색 이 필요하면 10.7 Querydsl로 타입 세이프하게 조인·프로젝션을 구현할 수 있습니다.