[친구하자] JPQL에서 매핑되지 않은 관계의 LEFT JOIN 한계

Call과 CallEmotionResult를 SQL처럼 LEFT JOIN하고 싶지만 표준 JPQL은 연관관계 기반 JOIN이 기본이라 막히는 문제, Hibernate HQL 확장과의 차이, 네이티브 쿼리·두 쿼리 분리 후 Java 병합, 구현 시 주의점까지 정리합니다.

CallCallEmotionResult를 SQL처럼 LEFT JOIN하고 싶은데, 표준 JPQL은 연관 경로가 없으면 객체 그래프 기반 조인을 쓰기 어렵다. Hibernate HQL은 버전에 따라 엔티티 JOIN … ON이 열리기도 하지만 포터빌리티와 트레이드오프가 있다. SQL·JPQL의 차이와, 우리가 택한 두 쿼리 + 병합, 구현 시 주의점까지 정리한다.


들어가며

JPA를 쓰다 보면 이런 요구를 만난다.

  • “메인 테이블과 부가 기록 테이블을 LEFT JOIN해서, 부가 행이 없는 메인 행도 목록에 넣고 싶다.”

SQL로는 대략 다음처럼 쓸 수 있다.

SELECT p.*, e.*
FROM parent_records p
LEFT JOIN extension_records e
  ON p.id = e.parent_id AND e.user_id = ?
WHERE p.participant_a_id = ? OR p.participant_b_id = ?

(위 SQL의 테이블·컬럼명은 실제 스키마와 무관한 예시용 익명이다.)

그런데 이걸 JPQL로 그대로 옮기려 하면, 포터블한 JPQL만으로는 예상과 다른 벽에 부딪히는 경우가 많다.


1. JPQL LEFT JOIN의 제약

표준 JPQL에서 JOIN은 보통 엔티티에 선언된 연관관계(@OneToMany, @ManyToOne 등)를 따라간다.

// ✅ 연관관계가 있으면 JPQL LEFT JOIN 가능
@Entity
public class Call {
    @OneToMany(mappedBy = "call")
    private List<CallEmotionResult> emotionResults;
}
@Query("""
    SELECT c FROM Call c
    LEFT JOIN c.emotionResults cer
    WHERE (c.user1.id = :userId OR c.user2.id = :userId)
    """)
List<Call> findWithEmotionResults(@Param("userId") Long userId);

연관관계가 없으면 c.emotionResults 같은 경로 자체가 없어서, 위와 같은 객체 그래프 기반 LEFT JOIN을 쓸 수 없다.


2. 연관관계를 선언하지 않은 이유

Call@OneToMany List<CallEmotionResult>를 붙이지 않은 데는 실무적인 이유가 있다.

  1. 결합도 — 통화 엔티티가 분석 결과 컬렉션을 직접 들고 있으면, 통화 흐름만 다룰 때도 분석 도메인이 끼어들 여지가 커진다.
  2. N+1·실수 여지@OneToMany는 기본이 LAZY지만, fetch 없이 접근하면 N+1이 나기 쉽다.
  3. 방향성 — “분석 결과 조회”는 분석 쪽 책임으로 두고, CallCallEmotionResult를 알 필요가 없다고 본다.

의도적으로 JPA 연관을 끊어 두는 선택은 흔하다. 대신 그만큼 JPQL 한 줄로 SQL식 LEFT JOIN을 재현하기 어려워진다.


3. 임의 조건 JOIN을 JPQL로 옮기려 할 때

연관관계 없이 SQL처럼 ON 절만으로 붙이려는 시도 예시는 다음과 같다.

// 표준 JPQL(Jakarta Persistence)만 놓고 보면: 이 형태를 그대로 보장하지 않는다.
// Hibernate를 쓴다면: 버전에 따라 HQL에서 "엔티티 조인 + ON"이 지원되기도 한다.
@Query("""
    SELECT c FROM Call c
    LEFT JOIN CallEmotionResult cer ON c.id = cer.callId AND cer.userId = :userId
    WHERE c.user1.id = :userId OR c.user2.id = :userId
    """)

표준 JPQL은 조인 대상을 FROM 엔티티에서 출발한 연관 경로로 잡는 경우가 대부분이다. 매핑이 없으면 c.emotionResults 같은 탐색 경로 자체가 없어서, SQL에서 당연한 “두 테이블을 FK만으로 LEFT JOIN”을 이식한 JPQL 한 줄로 끝내기 어렵다.

Hibernate HQL은 구현체 확장이라 버전마다 다르다. Hibernate 6 계열에서는 HQL에 연관관계 없이 두 엔티티를 JOIN … ON으로 묶는 문법이 강화된 편이라, 위와 비슷한 쿼리가 동작할 수도 있다. 다만 그 순간 쿼리는 “표준 JPQL”이라기보다 Hibernate에 묶인 HQL이 되고, 다른 구현체로 바꾸거나 스펙만 맞춰야 할 때는 다시 막힐 수 있다.

그래서 팀에서 포터빌리티를 중시하면 여전히 “연관 추가 / 네이티브 / 두 번 조회 + 병합” 쪽으로 가는 경우가 많다.


4. 해결 방법 1: 네이티브 쿼리

가장 직접적인 방법은 @Query(nativeQuery = true)로 SQL을 그대로 쓰는 것이다.

@Query(value = """
    SELECT p.id, p.started_at, p.ended_at, p.duration_seconds,
           e.detail_payload, e.summary_label
    FROM parent_records p
    LEFT JOIN extension_records e
      ON p.id = e.parent_id AND e.user_id = :userId
    WHERE (p.participant_a_id = :userId OR p.participant_b_id = :userId)
      AND p.lifecycle_state = 'COMPLETED'
    """, nativeQuery = true)
List<Object[]> findMonthlyRaw(@Param("userId") Long userId);

단점·주의 요약

  • 기본적으로는 List<Object[]>처럼 행 단위 튜플에 가깝게 받게 되기 쉽다. Spring Data JPA에서는 컬럼 별칭·매핑을 맞추면 인터페이스/클래스 프로젝션으로 감쌀 수도 있지만, JPQL 생성자 표현식만큼 단순하진 않다.
  • DB 방언·테이블/컬럼 이름에 직접 의존한다.
  • 리팩터링 시 컴파일러가 SQL 문자열 오류를 잡아주지 못한다.
  • JSON 컬럼 등은 타입 변환·역직렬화를 애플리케이션에서 책임져야 할 수 있다.

5. 해결 방법 2: 두 쿼리 분리 후 Java에서 병합

이 프로젝트에서 택한 방식이다. 레포지토리에서 각각 조회한 뒤 서비스에서 합친다.

Step 1 — 완료 통화만 JPQL로 조회

@Query("""
    SELECT c FROM Call c
    WHERE (c.user1.id = :userId OR c.user2.id = :userId)
      AND c.callStatus = 'COMPLETED'
      AND c.endAt >= :startDate AND c.endAt < :endDate
    ORDER BY c.endAt, c.id
    """)
List<Call> findCompletedCallsByUserInDateRange(
        @Param("userId") Long userId,
        @Param("startDate") LocalDateTime startDate,
        @Param("endDate") LocalDateTime endDate);

Step 2 — 감정 결과만 별도 조회

List<CallEmotionResult> findByCallIdInAndUserId(List<Long> callIds, Long userId);

Step 3 — 서비스에서 Map으로 매칭 (LEFT JOIN과 같은 효과)

public List<DailyEmotionSummaryResponse> getMonthlyEmotionResults(
        Long userId, int year, int month) {

    LocalDateTime startDate = LocalDate.of(year, month, 1).atStartOfDay();
    LocalDateTime endDate = startDate.plusMonths(1);

    List<Call> calls = callRepository.findCompletedCallsByUserInDateRange(
            userId, startDate, endDate);
    List<Long> callIds = calls.stream().map(Call::getId).toList();

    Map<Long, CallEmotionResult> resultByCallId = callIds.isEmpty()
            ? Map.of()
            : callEmotionResultRepository.findByCallIdInAndUserId(callIds, userId).stream()
                    .collect(Collectors.toMap(
                            CallEmotionResult::getCallId,
                            r -> r,
                            (a, b) -> a)); // 동일 callId·userId에 행이 둘 이상이면 정책 필요

    return buildDailySummaries(calls, resultByCallId);
}

분석이 없는 통화는 resultByCallId.get(callId)null이 되어, SQL LEFT JOIN과 같은 “한쪽만 있어도 행이 살아 있는” 형태를 애플리케이션에서 재현한다. (위 예시는 callIds가 비었을 때 두 번째 쿼리를 생략하고 빈 맵을 쓰는 형태다.)


6. 두 접근 방식 비교

항목DB JOIN (네이티브)두 쿼리 + Java 병합
쿼리 횟수1회2회
반환 타입Object[]엔티티(타입 안전)
DB 방언 의존JPQL 쪽은 상대적으로 작음
스키마 변경SQL 수정엔티티·쿼리 조정
데이터 규모DB가 조인 최적화callIds 많으면 메모리 부담
테스트통합 비중 큼단위·통합 모두 구성하기 쉬움

7. 어떤 경우에 어떤 방식을 선택할까

두 쿼리 + Java 병합이 나은 경우

  • 한 유저·한 기간 등으로 건수가 수백~수천 수준이고, 각 쿼리가 인덱스를 잘 탄다.
  • 도메인을 느슨하게 유지하고 싶다.
  • 엔티티 매핑·타입 안전성을 유지하고 싶다.

네이티브 쿼리 또는 연관관계 추가가 나은 경우

  • 조인·집계가 커서 DB 한 번에 처리하는 편이 확실히 유리하다.
  • 두 테이블이 항상 함께 쓰이고, 분리 조회 이점이 크지 않다.

8. 실무에서 비슷한 패턴

“한쪽은 없어도 다른 쪽은 나와야 한다(LEFT JOIN 의미)”인데, “JPA로 두 도메인을 굳이 묶고 싶지 않다”가 겹칠 때 자주 나온다.

  • OrderReview: 리뷰 없는 주문도 목록에 넣을 때
  • UserPushNotificationToken: 토큰이 없을 수 있을 때
  • PostReadHistory: 읽음을 별도 테이블로 둘 때

이때 두 쿼리 + Map 매칭은 단순하면서 타입 안전성을 지키기 쉬운 실용적인 선택이다.


9. 구현 시 알아두면 좋은 것

Collectors.toMap과 중복 키

(callId, userId) 조합당 결과가 한 행이라는 전제가 깨지면 toMap은 런타임 예외를 낸다. DB에 유니크가 없거나 버그로 중복이 생길 수 있다면 merge 함수를 두거나, 조회 직후 검증·로그를 두는 편이 안전하다.

IN (...) 리스트

callIds가 비어 있는데도 IN :ids만 던지면 구현체·DB에 따라 이상한 SQL이 나가거나 비용만 드는 경우가 있다. 위 예시처럼 비었으면 두 번째 쿼리를 호출하지 않기가 흔한 패턴이다.

IN 크기 한도

callIds가 매우 클 때(수만 건) DB나 드라이버에서 IN 리스트 크기 제한에 걸릴 수 있다. 필요하면 배치(예: 500~1000건씩)로 나눠 조회한 뒤 맵에 합친다.

연관관계를 “읽기 전용”으로만 두는 타협

조회 한정으로 @ManyToOne(fetch = LAZY)CallEmotionResult 쪽에 두고, Call에는 컬렉션을 노출하지 않는 식으로 단방향만 두면 JPQL JOIN 경로는 생기면서도 Call 애그리게이트가 컬렉션을 들고 다니지 않게 할 수 있다. 다만 팀 규칙·경계 설계와 맞는지 함께 본다.

LEFT JOIN FETCH는 연관이 있을 때

LEFT JOIN FETCH c.emotionResults처럼 한 번에 끌어오려면 결국 엔티티 그래프에 연관이 있어야 한다. “매핑 없이 fetch join”은 불가에 가깝다.


마무리

JPQL의 LEFT JOIN연관관계가 엔티티에 열려 있을 때 가장 자연스럽다. 설계상 연관을 두지 않았다면, Hibernate 버전에 맞는 HQL 확장을 쓸지, 네이티브로 감수할지, 조회를 쪼개고 서비스에서 병합할지를 트레이드오프로 선택하면 된다.

“SQL로는 되는데 JPQL로는 왜 안 되지?”라는 순간에, 표준 JPQL은 객체 그래프 탐색용에 가깝고, SQL과 1:1 대응이 안 되는 구간이 있다는 점을 떠올리면 원인 정리와 다음 설계 결정이 빨라진다.