[JPA] 연관관계, JOIN 시 NULL 처리의 함정! 삽질 방지 가이드

동재·2024년 12월 24일
0
post-thumbnail

1️⃣개요

이번에 프로젝트 진행하면서 JPA JOIN 때문에 삽질을 좀 했는데요.. 다음에 또 같은 짓할 것 같아서 기록해 두려 합니다!

2️⃣JPA 연관관계와 JOIN의 특징

JPA는 개발자가 SQL 쿼리를 직접 작성하지 않아도 되도록 추상화를 제공하며, 객체 간 연관관계를 활용해 JOIN을 자동 생성합니다.

그러나 이 자동화된 처리 방식은 항상 명시적 SQL과 동일한 결과를 보장하지는 않습니다.

특히 연관된 엔티티가 없는 경우나 NULL 값 처리는 예상과 다르게 동작할 수 있습니다.

3️⃣문제 상황 설명

예를 들어 User와 Report가 연관된 상황에서 User 삭제 로직에 빈틈이 있거나, 정책적으로 User는 삭제되었지만 Report는 남아 있는 경우가 있다고 가정해 봅시다.

(외래키 제약조건은 설정 되어 있다 않다고 가정합니다.)

이때 User가 없는 Report를 조회하려고 했을 때, 데이터가 조회되지 않는 문제가 발생했습니다.

Entity 가 아래와 같을 때:

public class User {
    @Id
    private long id;
    private String name;
}

public class Report {
    @Id
    private long id;
    private String content;

    @ManyToOne
    @JoinColumn(name = "user_id")
    User user;
}

JPQL은 다음과 같이 작성되었습니다:

select r from Report r where r.user is null

논리적으로, 이 쿼리는 Report에서 user_id로 User 테이블과 JOIN을 수행한 후, User가 없는 데이터를 조회할 것처럼 보입니다. 하지만 실제로는 그렇지 않았습니다.

4️⃣문제 원인 분석

select r from Report r where r.user is null 쿼리가 의도한대로 동작하지 않은 이유는, Hibernate가 생성하는 실제 SQL이 다음과 같기 때문입니다:

Hibernate: 
    select
        r1_0.id,
        r1_0.content,
        r1_0.user_id 
    from
        Report r1_0 
    where
        r1_0.user_id is null

이 쿼리는 Report 테이블에서 user_id가 NULL인 데이터만 필터링하며, 예상했던 것과 달리 JOIN을 포함하지 않습니다.

이 문제가 발생하는 이유는 JPA가 연관된 엔티티의 값이 null이거나 외래 키만으로 조건을 처리할 수 있는 경우, 불필요한 JOIN을 생략하고 외래 키 필드를 직접 조건으로 사용하는 쿼리를 생성하기 때문입니다.

예시

말이 좀 어려운데 예시를 통해 확인해 보겠습니다.

Entity :

public class User {
    @Id
    private long id;
    private String name;
}

public class Report {
    @Id
    private long id;
    private String content;

    @ManyToOne
    @JoinColumn(name = "user_id")
    User user;
}

Repository :

public interface ReportRepository extends JpaRepository<Report, Long> {
    // r.user 관련 조건 절
    @Query("select r from Report r where r.user is null")
    List<Report> getListByUserNull();

	// r.user.id 관련 조건 절
    @Query("select r from Report r where r.user.id = 2")
    List<Report> getListByUserId();

	// r.user.name 관련 조건 절
    @Query("select r from Report r where r.user.name = 'dongjae'")
    List<Report> getListByUserName();
}

테스트 코드 :

    @Test
    void getListByUserNull() {
        // given

        // when
        reportRepository.getListByUserNull();
        reportRepository.getListByUserId();
        reportRepository.getListByUserName();

        // then
    }

결과 (Hibernate가 생성한 SQL) :

  1. getListByUserNull() 호출:
select
    r1_0.id,
    r1_0.content,
    r1_0.user_id 
from
    Report r1_0 
where
    r1_0.user_id is null

: 조건 r.user is null은 Report 테이블의 user_id 외래 키만으로 판단 가능하므로 JOIN 없이 처리됩니다.


  1. getListByUserId() 호출:
select
    r1_0.id,
    r1_0.content,
    r1_0.user_id 
from
    Report r1_0 
where
    r1_0.user_id = 2
    

: 조건 r.user.id = 2 역시 외래 키 user_id로 직접 조회 가능하므로 JOIN 없이 처리됩니다.


  1. getListByUserName() 호출:
select
    r1_0.id,
    r1_0.content,
    r1_0.user_id 
from
    Report r1_0 
join
    User u1_0 
        on u1_0.id = r1_0.user_id 
where
    u1_0.name = 'dongjae'

조건 r.user.name = 'dongjae'는 User 테이블의 필드(name)에 접근해야 하므로, JOIN이 필요하며 실제로 JOIN 쿼리가 생성됩니다.

결론

JPA는 연관 관계 없이 조건을 만족할 수 있는 경우, JOIN을 수행하지 않습니다.

  • 외래 키만으로 조건을 처리할 수 있을 경우 (user_id):
    - JOIN 없이 Report 테이블에서 직접 조건을 필터링합니다.
  • 연관된 엔티티 필드가 필요한 경우 (user.name):
    - JOIN을 수행하여 조건을 만족시킵니다.

JPA는 최소한의 SQL 작업으로 쿼리를 최적화하여 불필요한 JOIN을 줄이고 성능을 개선합니다.

5️⃣해결 방법

의도한대로 JPQL을 실행시키려면 명시적으로 JOIN을 작성하면 됩니다.

예시:

select r from Report r LEFT JOIN r.user u where u is null

기존 SQL과 비교했을 때 LEFT JOIN r.user u가 추가된 것을 확인할 수 있습니다.

결과:

Hibernate: 
    select
        r1_0.id,
        r1_0.content,
        r1_0.user_id 
    from
        Report r1_0 
    left join
        User u1_0 
            on u1_0.id=r1_0.user_id 
    where
        u1_0.id is null

이 방법은 Report와 User를 LEFT JOIN하여 의도한대로 User가 없는 Report를 조회할 수 있습니다.

⚠️N+1 문제

다만, 위 방법을 사용할 경우, 기본적으로 @ManyToOne의 Lazy Loading(지연 로딩) 설정 때문에, Report에 연관된 User를 확인하기 위해 User 엔티티 수만큼 추가적인 쿼리가 실행됩니다.

예를 들어, 조회된 Report가 10개이고, 각 Report에 연관된 User를 로드하려 할 경우, 최대 10개의 추가 쿼리가 발생합니다.

해결책

1. LEFT JOIN FETCH 사용

@Query("select r from Report r LEFT JOIN FETCH r.user u where u is null")
List<Report> getListByUseJoinWithFetch();

LEFT JOIN FETCH를 사용하면 User 데이터를 함께 로드하므로 추가 쿼리를 방지할 수 있습니다:

2. EntityGraph 사용

@EntityGraph(attributePaths = "user")
@Query("select r from Report r where r.user is null")
List<Report> getListByUseJoinWithEntityGraph();

EntityGraph를 활용하면 JPQL 없이도 연관 엔티티를 함께 로드할 수 있습니다:

6️⃣결론

JPA는 SQL 추상화 계층을 제공하지만, 실제 SQL 동작 방식을 완전히 대체하지는 못합니다. 이 사례에서 알 수 있듯이, 예상과 다르게 동작하는 경우 JPA의 쿼리 생성 과정을 이해하고 명시적 조건을 사용하는 것이 중요한 것 같습니다.

profile
Backend Developer

0개의 댓글