Skip to main content

내가 겪은 N + 1 문제

· 8 min read
Dongle

JPA를 쓰면서 우리가 일반적으로 발생하여서 해볼 건 다 해봐도 안됐었던 N+1 문제

모든 소스는 이곳에서 확인 가능합니다 :)

N + 1 문제

JPA를 쓰면서 우리가 일반적으로 발생하여서 해볼 건 다 해봐도 안됐었던 N+1 문제
일반적으로 우리가 흔히 N+1문제가 발생을 할 수 있는데 이미 많은 사람이 알겠지만 킹갓영환님의 책을 보거나 강의를 보면 진짜 다 해결이 가능했는데
내가 마주한 문제는 해결이 안 됐었다. 일반적으로 해결을 위해 그에 상황마다 다르겠지만 흔하게 가장 먼저 lazy로 변경, fetch join, fetch size조정 등 여러 작업을 했는데
해결이 안 됐었다.

코드가 많이 다르고 도메인도 다르지만 그나마 비슷하게 코드를 재현 해보자면

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id
@Column(name = "member_id")
private Long id;

private String memberEmail;

@OneToMany(mappedBy = "Member", cascacde = CascadeType.ALL, orphanRemoval = true)
private Set<Order> orders = LinkedHashSet<>();

...
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Order {
@ID
@Column(name = "order_id")
priate Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "memberEmail", referencedColumnName = "memberEmail", nullable = false, updatable = false)
private Set<Order> order = LinkedHashSet<>();

...
}

이런 느낌의 Entity였다.
이때의 나 빼고 엔티티가 어떤 문제가 있는지 알겠지만 이때까지는 나는 꿈에도 몰랐다. 5시간을 버릴 줄은 몰랐다!

기획: 회원으로 주문 정보를 볼 수 있게 해주세요
나: members 기준으로 Order를 다 가져오면 되겠구나! 음 그러면 가져올 때 속도를 위해 fetch join 해서 가져와야지!
Untitled

그렇게 다섯시간을 잡아먹을 줄은 몰랐었다.
아니 진짜 핑계를 대자면 이렇게 엔티티를 구성한 건 내가 아니라 기존 코드를 가지고 다른 곳에서 쓰다가 이렇고 저렇고.. 구구절절 할말하안

Untitled

내가 그린 그린 쿼리

select a.*, b.*
from member m
inner join order o on m.memberEmail = o.memberEmail;

이런 느낌의 (너도 한방 나도한방)쿼리를 원해서

Fetch를 쓰자


public interface ProblemRepository extends JpaRepository<Member, Long> {
private final JPAQueryFactory queryFactory;

@Query("SELECT distinct m FROM Member m join fetch m.orders")
List<Member> findAllFetch();
}

이렇게 조회를 했는데

@Test
void save() {
// Given

for (int i = 0; i < 2; i++) {
Member memFixture = new Member( i + "@naver.com");
Order orderFixture = new Order(i + "delivery");
memFixture.addOrder(orderFixture);
problemRepository.save(memFixture);
em.flush();
em.clear();
}

List<Member> members = problemRepository.findAllFetch();

assertThat(members.size()).isEqualTo(2);
assertThat(members.get(0).getOrders().size()).isEqualTo(1);
assertThat(members.get(1).getOrders().size()).isEqualTo(1);
}

편의를 위해서 대충 이렇게 했다 가정하고.. 테스트를 돌려서 확인해보니

Hibernate: 

select
distinct member0_.member_id as member_i1_0_0_,
orders1_.order_id as order_id1_1_1_,
member0_.member_email as member_e2_0_0_,
orders1_.member_email as member_e3_1_1_,
orders1_.order_status as order_st2_1_1_,
orders1_.member_email as member_e3_1_0__,
orders1_.order_id as order_id1_1_0__
from
t_member member0_
inner join
t_order orders1_
on member0_.member_email=orders1_.member_email

음 .. 예상한 쿼리네

Hibernate: 
/* load com.example.tiljpa.problem.Member */ select
member0_.member_id as member_i1_0_0_,
member0_.member_email as member_e2_0_0_
from
t_member member0_
where
member0_.member_email=?
Hibernate:
/* load com.example.tiljpa.problem.Member */ select
member0_.member_id as member_i1_0_0_,
member0_.member_email as member_e2_0_0_
from
t_member member0_
where
member0_.member_email=?

?????? 이런 쿼리들이 조회한 멤버 수만큼 다시 쿼리가 쫘라라라라라라라라라라라라라락 나가버렸다..

이때 엥..? 왜 fetch join을 하고 쿼리를 가져왔는데 다시 member쿼리가 나가지??

이떄부터 멘붕의 시작이였다.

디버깅하면서의 추측

  1. 일단 fetch mode를 eager로 바꿔보자..
    • 실패
  2. querydsl로 join을 하면 다를까?
    • 실패
  3. equals랑 hashcode를 넣어보자
    • 실패
  4. 아 이건아닌거같은데 yml에 batch size를 넣어보자
    • 당연히 실패
  5. transacton(readonly=true) 때문에 영속성 컨텍스트가 일을 안하나? 좋아 transaction으로만 하자
    • 실패
  6. 아 좋아 일단 디버깅을 해보자
    - 디버깅 하면서 보다보니 최초 fetch로 받은 member 객체의 주소와 order가 가지고 있는 member객체의 주소가 확연히 달랐다.  
    ![Untitled](./2022-05-28/Untitled%202.png)
    어 뭐야 왜달라... 이떄부터 무한 챗바퀴 돌듯 그러면 order의 mode를 eager로 바꾸자! (사실 이미 해봤던거였다) 당연히 실패
    Untitled

도저히 좋은 생각이 떠오르지 않아 산책을하고 온 후

문득

문득 우리가 join을 하면 rdb에서 당연히 여러 개 나오는 문제를 우리는 흔히 distnct를 하는데 이때 jpa에서는 Entity의 @Id로 중복을 제거하는 게 생각이 지나가는데 아 그러면 fetchjoin한 이후 가져왔었을 때 영속성 컨테스트에서 **@Id** 로 구분을 한다고 생각이 들어 기존 JoinColumn에 member_email로 되어있는 코드를 member_id로 변경했다.


@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Order {
@ID
@Column(name = "order_id")
priate Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", referencedColumnName = "member_id", nullable = false, updatable = false)
private Set<Member> members = LinkedHashSet<>();

...
}

눈 수술 후 건조한 눈 때문에 눈을 개스츠름한 눈빛으로 모니터를 뚫어지게 보며 생각 중에 스쳐 가는 생각이 있었는데, Hibernate 입장에서 생각(뇌 코딩)을 해본다면 당연 할 수 밖에 없구나라는 생각이 들었다.
쿼리를 jqpl로 날려 보내고 온 후에 영속성 컨텍스트에 친구를 memberId를 기준으로 컨텍스트 테이블에 값을 넣어놓고, order를 보는데 안에 Lazy가 떡하니 선언되어 있는 걸 보고 허겁지겁 다시 찾으려고 컨텍스트에서 찾았는데 안보였던 것이다.
그래서 1차 캐시에 없네(Joincolumn에 선언된 member_email을 가지고 key를 찾다 보니 실제로는 fetchjoin으로 넣었던 key는 memberId이였어서) 그러면 db에서 조회해야겠다고 판단하고 다시 lazy조인을 날려 발생한 문제인 거지 않을까?

참고

Inflearn 영환님 강의
JPA 프로그래밍 docs