예 사실 극악무도하게 해결하진 않았지만
실제 프로젝트를 진행하는 중에 쿼리가 무지막지하게 나오는 어떠한 문제를 마주했고,
(과장이 약간 섞인) 66.7퍼센트의 성능 개선을 어떻게 했는지,
결국 단서를 찾아내 해결한 과정을 정리한 글입니다.
비슷한 경험이 있는 분들에게는 도움이 될 것 같아 글을 작성합니다!
(언제든 틀린 부분 있다면 알려주시면 감사하겠습니다)
문제 상황
현재 TrueEcho라는 이름의 졸업 프로젝트 겸 ~ 캡스톤 디자인 겸 ~ 실제 앱으로 출시해 유저들을 모아볼 나름 큰 프로젝트를 백엔드를 맡아서 진행 중입니다.
그러던 중에 User 엔티티를 수정하고 이를 조회하는 로직을 작성하던 중이었습니다.
이때, 위 스크린샷과 같이 어떤 api를 통해서 User 엔티티를 호출해도 항상 다른 엔티티 2개(NotiTimeQ)와 SuspendedUser 엔티티를 조회하는 쿼리가 같이 나가는 문제가 있었습니다.
그래서 이를 도저히 참지 못했던 저... 가 아니라 제 자랑스러운 팀원이 분개했고, 저와 함께 이 문제를 파헤치기 시작했습니다.
우선 User 엔티티와 다른 엔티티들 간에 연관관계를 확인했습니다.
결국, 다른 엔티티들과 다르게 문제가 있는 NotiTimeQ와 SuspendedUser 엔티티는 OneToOne 관계인 것을 발견해 버렸습니다(이 자식 ㅋ)
그리고 현재 연관관계의 주인이 User가 아니라 저 두 엔티티라는 사실마저도 알아버렸죠. 후후
문제 원인
두 엔티티가 ToOne 관계여서 Fetch 전략을 Lazy로 설정했음에도 왜 User 엔티티를 조회할 때마다 마치 Eager 전략처럼 동시에 두 엔티티도 같이 조회를 하는 걸까요?
열심히 구글링을 하면서 원인을 찾던 와중에 과거에 영한님의 강의를 수강하며 배운 내용이 제 두뇌를 스치고 지나갔습니다.
그리고 그 이유는 OneToOne 관계에서는 연관관계의 주인이 아닌 곳에서 엔티티를 호출하는 경우 지연 로딩이 아니라 즉시 로딩으로 동작한다는 사실을요!! (위와 한 얘기랑 같은 말인가..)
이유를 말씀드리자면, 지연로딩은
- 로딩되는 시점에서 Fetch 전략이 Lazy로 설정되어 있는 엔티티를 프록시 객체로 가져온다.
- 이후 프록시 객체로 되어있는 엔티티를 실제로 사용할 때 쿼리가 실행
- 우리의 경우 NotiTimeQ와 SuspendedUser에서 getUser()와 같은 메서드로 엔티티를 사용할 때를 의미합니다.
- 하지만 프록시 객체는 Null 값을 가질 수 없는 한계점 때문에, OneToOne 관계에서 양방향 매핑일 때 외래 키가 없는 엔티티(관계의 주인이 아닌 곳)에서 호출하면 Lazy 전략을 사용하지 못하는 것입니다.
- 쉽게 풀어서 말씀드리자면, User 엔티티를 호출할 때 서로 양방향 매핑이기 때문에 NotiTimeQ와 SuspendedUser를 프록시 객체로 만들어 참조하고 싶어도, 외래키가 없기 때문에 해당 엔티티들이 null인지 아닌지 확실할 수 없기 때문입니다.
- 그러므로 프록시 객체를 만드는 Lazy 전략이 아니라 Eager 전략을 사용하는 것입니다.
문제해결
이 부분을 김영한 님의 강의에서 배운 내용을 기억을 더듬어 해결했습니다.
컬렉션이나 ToMany 관계가 아니라 ToOne 관계는 join을 하더라도 1:N으로 row가 늘어나지 않으므로,
fetch join을 통해서 미리 User을 조회할 때 NotiTImeQ와 SuspendedUser를 조회해도 괜찮습니다.
그래서 원래 코드인
@Override
public User findUserByEmail(String email) {
try{
return em.createQuery("select u from User u where u.email=: email" , User.class)
.setParameter("email", email)
.getSingleResult();
}catch (NoResultException e){
return null;
}
}
에서 fetch join을 이용한 아래의 코드로 수정했습니다.
@Override
public User findUserByEmail(String email) {
try {
return em.createQuery(
"select distinct u from User u " +
"left join fetch u.suspendedUser " +
"left join fetch u.notiTimeQ " +
"where u.email = :email", User.class)
.setParameter("email", email)
.getSingleResult();
} catch (NoResultException e) {
return null;
}
}
결과
그래서 결론적으로 같은 api를 호출했을 때 아래의 결과와 같이 쿼리가 N+1에서 1로 획기적으로 줄였습니다.
이는 친구 한 명을 조회할 때마다
- 1명의 친구를 조회하는 쿼리(1)
- 각각 두 개의 엔티티를 조회하는 쿼리(2)
를 조회하므로, 100명을 조회하면 300개의 쿼리를 전송하는 것입니다.
결국 300개를 100개로 줄임으로써 성능을 (단순 계산, 약간의 오버를 섞어) 66.7프로 향상한 것입니다.
User는 프로젝트에서 가장 많이 사용하는 엔티티로써 문제를 해결함으로써 전체 시스템의 성능 개선에 기여할 수 있어서 좋았습니다.
다음 편 예고
이전 글들에 비해서 조금 전문적인 내용들이어서 재밌을지는 모르겠지만! 사실 상관없습니다.
어차피 개발 블로그니까 전문적인 내용도 있어야죠ㅎㅎ
다음은 em.remove()가 도대체 왜 안 되는 거야!?(가제)라는 주제로 글을 작성할 예정입니다.
이전에는 코딩테스트와 강의만 주야장천 들어서 글을 쓸 흥미로운 주제가 적었는데, 실제로 프로젝트를 하니 끝없는 디버깅과 고민이 함께해서 글을 쓸 주제가 넘쳐나네요ㅎㅎ
그럼 오늘도 파이팅입니다!!