본문 바로가기
JPA

🙆🏻‍♂️ 너, Fetch Join 만능이야?

by sangyunpark99 2024. 9. 24.
N+1 문제를 해결하기 위한 방법 Fetch Join
사용하는 것이 무조건 정답일까?

 

결론부터 이야기해보자!

Fetch Join도 한계점을 가진다.

📍 Fetch Join의 한계점

🚫 페치 조인을 사용하면 별칭을 줄 수 없다.

🚫 컬렉션이 두개 이상인 경우 사용할 수 없다.

🚫 컬렉션 페치 조인시 페이징을 사용할 수 없다.

 

🔎  왜 페치 조인을 사용하면 별칭을 줄 수 없을까?

String query = "select u from User u join fetch u.team t where t.name = 'A';

 

 

이러한 쿼리문과 같이 별칭을 사용해서 조건을 추가하게 되는 경우, 기존 페치 조인으로 가져올 데이터의 일부가 누락될 수 있다.

 

🙆🏻‍♂️  좀 더 디테일한 상황 예시(비교)

 

개발자 A씨는 유저의 각 팀이름을 조회해주고 싶다. 별칭을 사용하는 것이 더 좋은 방법일까? 아니면 별칭을 사용하지 않는 것이 더 좋은 방법일까? 고민하고 있다.

 

⭐️ 별칭을 사용해 조건을 부여하는 상황을 가정한다.

 

별칭을 사용하지 않을 때의 코드

String query = "select u from User u join fetch u.team";
List<User> resultList = em.createQuery(query, User.class).getResultList();

 

 

쿼리문

 

1번의 쿼리문을 통해 팀 이름을 조회할 수 있다.

 

 

별칭을 사용할 때의 코드

String queryA = "select u from User u join fetch u.team t where t.name = 'A'";
String queryB = "select u from User u join fetch u.team t where t.name = 'B'";
String queryC = "select u from User u join fetch u.team t where t.name = 'C'";
List<User> resultA = em.createQuery(queryA, User.class).getResultList();
List<User> resultB = em.createQuery(queryB, User.class).getResultList();
List<User> resultC = em.createQuery(queryC, User.class).getResultList();

 

쿼리문

3번의 쿼리문을 통해 팀 이름을 조회할 수 있다.

 

개발자 A씨

"에? 별칭을 사용해서 조건을 달아주면, 결국 Lazy Loading과 다른게 없는데..? 난 Lazy Loading으로 인해 생기는 N+1 문제를 해결하고자 fetch join을 사용한건데, 결국 제자리 걸음이네? 별칭을 사용하지 않는 게 훨씬 좋겠군 🧐"

 

 

🙆🏻‍♂️ 결론

별칭을 사용하면 페치된 연관 엔티티에 대한 제약 조건을 걸거나 다른 필터링 로직을 추가하게 될 수 있다.

fetch join을 사용하는 궁극적인 목적은 Lazy Loading으로 인해 발생하는 N+1 문제를 해결하고자 연관된 엔티티를 즉시 로딩하는 것이다. 별칭을 사용한다는 것은 추가적인 조건을 달 수 있는 가능성을 열어주기 때문에, fetch join을 사용하는 목적에 맞지 않게 된다.

 

fetch Join 사용 시 별칭을 안쓰는게 맞다.
                                            - 김영한님 -

 

 

🔎  왜 컬렉션이 2개 이상인 경우 사용할 수 없을까?

컬렉션이 2개 이상인 경우 fetch join을 사용하게되면, 조인된 결과에서 중복된 데이터가 발생하고, 중복으로 인해 메모리 성능 저하와 예기치 않은 결과가 생길 수 있다.

 

🙆🏻‍♂️  좀 더 디테일한 상황 예시

 

기존에 팀과 유저가 일대다 관계를 가진것에 더해서,

주문 엔티티를 추가해 유저와 일대다 관계를 가진다고 가정해보자.

 

Order

@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

 

User

@Getter
@Entity
@NoArgsConstructor
public class User {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name ="team_id")
    private Team team;

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();

    public User(String name, Team team) {
        this.name = name;
        this.team = team;
        team.getUsers().add(this);
    }
}

 

 

간단하게 중복된 데이터가 출력되는지 확인해보자.

@Test
@Transactional
@Rollback(value = false)
void 페치조인_컬렉션2개이상_조회() throws Exception {

    // 팀 저장
    Team teamA = new Team("A");
    em.persist(teamA);

    // 회원 저장
    User user1 = new User("user1", teamA);
    em.persist(user1);

    // 주문 저장
    Order order1 = new Order("user1주문1", user1);
    Order order2 = new Order("user1주문2", user1);
    em.persist(order1);
    em.persist(order2);

    em.flush();
    em.clear();

    String query = "select u from User u \n"
            + "join fetch u.team \n"
            + "join fetch u.orders";
    List<User> resultList = em.createQuery(query, User.class).getResultList();

    System.out.println("Result Size: " + resultList.size());

    for (User user : resultList) {
        System.out.printf("username : %s, orderSize : %d \n", user.getName(), user.getOrders().size());
        for (Order order : user.getOrders()) {
            System.out.println("Order: " + order.getName());
        }
    }
}

 

중복되는 데이터가 발생하는지 확인해 보자.

 

우선 쿼리는 다음과 같이 나간다.

 

출력 결과를 확인해보자.

 

데이터의 중복 없이 출력이 된다.

컬렉션 두개 이상 fetch join하면 데이터 중복이 발생할 수 있다고 했는데 이상이 없다.

 

JPA에서는 자체적으로 중복되는 데이터를 제거해주니, JPA단에서 보내주는 쿼리문을 통해서 확인해보자.

 

 

Mysql Workbench에서 JPA에서 변환해준 쿼리문 그대로 입력을 한 후, 나온 조회 결과이다.

 

user1의 주문이 2개이므로 조회 결과 user1에 대한 정보가 주문 수 만큼 중복되어 출력된다.

 

데이터베이스는 Cartesian Product(카테시안 곱)를 통해 각 주문에 대한 사용자와 팀 정보를 결합한다.

그 결과, user1은 주문이 두 개이므로 두 번 중복되어 나타나고, 그에 속한 team A 정보도 중복된다.

 

그럼, JPA는 자체적으로 중복을 제거해주니, 2개 이상의 컬렉션을 사용해도
되지 않을까..? 라는 의문이 생긴다.

 

이 부분은 컴퓨터 리소스 차원에서 생각해봐야 한다.

 

JPA는 데이터베이스에서 가져온 결과를 메모리 상에서 엔티티로 매핑한 후 중복된 엔티티를 필터링한다.

데이터가 많을 경우 메모리 사용량이 증가할 수 있다. JPA에서 데이터를 메모리에 다 올린다음에 페이징 처리를하게 된다.

 

컬렉션 두개 이상으로 Fetch join해서 사용할 수 있지만, 성능의 문제를 고려하여 사용하는 것을 자제 하도록 해야겠다.

 

위 설명은 틀릴 수 있다. 아직 검증하는 단게이다

 

🔎  왜 페이징을 사용할 수 없을까?

@Test
@Transactional
@Rollback(value = false)
void 페이징_조회() throws Exception {

    // 팀 저장
    Team teamA = new Team("A");
    em.persist(teamA);

    // 회원 저장
    User user1 = new User("user1", teamA);
    User user2 = new User("user2", teamA);
    em.persist(user1);
    em.persist(user2);

    em.flush();
    em.clear();

    String query = "select t from Team t join fetch t.users";

    List<Team> resultList = em.createQuery(query, Team.class)
            .setFirstResult(0)
            .setMaxResults(1)
            .getResultList();

    System.out.println("Result Size: " + resultList.size());

    for (Team team : resultList) {
        System.out.println(team.toString());
    }
}

 

출력 결과를 확인해보자.

 

Team에 2명의 User를 할당했으므로 1:N 관계로 Team조회시 2개의 결과물이 나와야한다.

(MySql 기준)

 

이렇게 두개의 row가 생기는데, 페이징 처리로 결과물을 1개만 가져오다보니, 나머지 한개의 데이터가 누락되게된다.

 

페치조인으로 데이터를 땡겨올 경우, 페이징처리로 인해서 일부 데이터가 누락될 수 있다.

 

🙆🏻‍♂️ 김영한님

1. 페치 조인으로 모든 것을 해결할 수는 없다.
2. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
3. 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.