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로 반환하는 것이 효과적이다.
'JPA' 카테고리의 다른 글
동시성 문제 해결방법(Update Query편) (0) | 2025.01.29 |
---|---|
save()와 SELECT의 관계 (0) | 2025.01.26 |
JPA와 DB는 어떻게 동기화가 되는걸까? (0) | 2025.01.26 |
🙆🏻♂️ 너, Fetch Join 좀 알고 싶다? (0) | 2024.09.22 |
@DataJapTest를 사용하는 이유 (0) | 2024.07.17 |