리팩토링을 하기로 결심하게 된 계기
기존에는 Cabinet 조회 → 조회한 Cabinet을 순회하며 LentHistory 조회 → 현재 대여 중인 LentHistory의 User 조회 순으로 로직이 작성되어 있는데,
이게 왜 문제가 되는가 하면 Cabinet 조회 쿼리 1번 → 각 사물함에서 LentHistory 조회 N번 → LentHistory가 대여 중인 경우에 User 조회 쿼리 @번으로, 총 1 + N + @ 번 쿼리를 반복적으로 쿼리를 날리기 때문에 느리다고 판단했다고 되어있다.
그런 상황에서 위와 같이 쿼리를 바꾸었더니 아래와 같이 join을 통해서 쿼리를 단 1번만 날려 성능이 훨씬 개선되었다고 생각을 했다.
여기서 든 의문점이 단순히 join을 했는데, 어떻게 이걸 한 번에 조회해오는거지? 였다. 내가 공부했던 바로는 단순 join을 사용하게되면 즉시로딩과 지연로딩 모두 1+N 문제가 발생을 하고, 이를 해결하기 위해서는 fetch join을 사용해야 한다고 알고있었다. 하지만 위의 JPQL에는 fetch join을 사용하지 않았지만, 실제 Hibernate 쿼리를 조회를 해보면 단 한번만 쿼리가 발생한다.
즉시 로딩과 지연 로딩
위의 JPQL로 조회했을 때 발생하는 쿼리
혼자서 원인을 찾아보다가 결국 현직자들에게 도움을 받아 이유를 알게 되었다. 해당 JPQL에서 한번에 쿼리를 날려 조회해오는 이유는 select 뒤에 c, lh, u를 통해서 조회하는 주체가 되는 Entity를 Cabinet, LentHistory, User로 모두 지정해놓았기 때문이였다.
다만 저 방법은 권장되지 않기 때문에 조금 더 공부해보고 리팩토링해보라는 추가적인 조언을 해주었다. 이에 후술할 이유들로 리팩토링을 하고자 결심했다.
리팩토링을 하는 이유(문제점)
1.
하나의 레포지토리에서는 하나의 엔티티만 조회하자
레포지토리의 목적은 엔티티를 조회하고 저장하는 것이다. 레포지토리의 이러한 목적과 더불어 유지보수의 용이성을 위해 CabinetRepository라면 Cabinet만 조회를 하고 LentHistoryRepoository에서는 LentHistory만 조회하는 것이 맞다고 생각했다.
위의 select c, lh, u를 통해서 Cabinet, LentHistory, User를 모두 조회하여 반환하는 것은 Cabinet을 조회하고 저장하는 CabinetRepository의 목적에 맞지않고 유지보수나 확장성을 고려했을 때 수정되어야 한다고 판단했다.
또한 위의 코드에서처럼 여러 Entity를 조회하게되면 지연 로딩을 적용이 되지 않는다.
2.
타입 안전성을 고려하지 않은 반환값
위의 JPQL의 반환값은 List<Object[]>로 말 그대로 아무 값이나 넣을 수 있어, 타입 안전성이 좋지않고 런타임 중에 에러를 발생 시킬 수 있다.(Effective Java 5장 제네릭 참조)
이 또한 위의 1번 문제점과 이어지는데, CabinetRepository의 목적에 따라 CabinetRepository에서 조회하는 값은 Cabinet을 반환해야한다고 생각한다.
리팩토링 결과
위에서 언급한 문제점들을 위주로 수정하는 JPQL 코드를 아래와 같이 작성했다.
실상은 크게 다를 바 없지만, fetch 조인을 통해 처음 조회 시 모든 값을 join으로 묶어 한번에 쿼리를 날린다. 아래의 쿼리를 살펴보면 리팩토링 전과 크게 차이가 없다.
수정 후의 JPQL 쿼리
리팩토링 이후의 문제점은 속도가 다시 느려졌다는 점이다.
리팩토링 전 테스트 결과
리팩토링 후 테스트 결과
20ms에서 90ms로 약 4.5배 정도 증가했는데, 왜 조회하는 쿼리는 거의 똑같은데 속도가 훨씬 느리지?에 대한 의문이 발생했다.
그 이유에 대해 추측해보자면 리팩토링 전의 JPQL에서는 쿼리의 주체가 되는 Entity가 Cabinet, LentHistory, User이고 그에 대한 조건을 ON절을 통해서 걸어주었기 때문에 DB에서는 Cabient에서 조회, LentHistory에서 조회, User에서 조회하는 식으로 따로 조회를 해오고 값을 맞춰 정리한다. 반면 리팩토링 이후에는 Cabinet을 조회한 후 LentHistory를 조회해 JPA 내부에서 FK에 맞춰 정렬 및 중복 제거를 진행하고, 그 후 User를 조회해 또 LentHistory와 FK를 맞추고 정렬하고 중복 제거를 하기 때문에 느린 것 같다.
그래도 4.5배 느려졌지만 기존의 문제점들을 개선했으니 된 건가?라고 생각했지만, 추가적으로 조사를 하다보니 리팩토링 후의 JPQL에도 문제가 있다는 것을 발견했다.
Fetch Join에도 한계가 있다.
1.
데이터 무결성을 위해 Where 절을 사용해서는 안된다.
2.
컬렉션 * 컬렉션의 경우에는 Fetch Join을 사용해서는 안된다.
이 중 1번 문항은 fetch join의 경우에는 entity 주체를 조건으로 거는 Where절은 사용해도 되지만, 그 외의 연관관계로 묶여 Join 되는 entity들은 where절을 걸어서는 안된다는 의미이다. cabinet 입장에서 LentHistory를 전부 가져오길 기대하고 fetch join을 사용하는데, 그에 대한 조건을 where 절로 주게되면 무결성이 깨지기 때문에 사용하면 안된다는 의미로 이해했다.
데이터 무결성이 깨지는 이유
그래서 아래와 같이 Where 절을 제거하여 다시 테스트를 돌려보았다.
where 절 제거 후 테스트 결과
LentHistory에 대해 where 절을 제거하고 나니, 테스트 시간이 237ms로 늘어난 것을 볼 수 있다.
이러면 코드 리팩토링 하기 전이 더 좋은거 아닌가?라는 생각이 들어 다시 한번 현직자에게 조언을 구해보니, 무작정 빠르다고 좋은건 아니며 안정성과 확장성을 고려하면서 작성한 코드가 훨씬 좋은 코드라고 한다. 사실 지금 코드에서 속도가 안나오는 이유는 Cabinet 조회 후에 DBMS 내부에서 여러 Join마다 매번 정렬하고 중복제거하는 부분들이 너무 많아 느린 것 같고 LentHistory 특성상 사물함마다 대여 기록이 누적되므로 데이터가 많아질수록 점점 더 느려지게 될 것 같다는 이야기를 들었다. 실제로도 리팩토링 과정에서 진행한 테스트의 경우 로컬에 대한 테스트였는데, main이나 dev에는 사물함마다 더 많은 대여기록이 존재할테니 실제로 저것보다 훨씬 느릴 것으로 예상된다.
Cabinet마다 LentHistory로 대여 기록을 확인해야하는 지금의 구조상의 문제로 쿼리를 통해서는 더 이상 개선이 힘들어보이니 구조를 바꾸던지 Cabinet 조회 따로하고 ActiveLentHistory + User를 fetch Join으로 가져오는 쿼리를 따로 두는게 좋아보인다는 이야기도 들었다.
그래서 조언해준대로 다시한번 리팩토링을 진행해 성능을 비교해보기로 했다.
2차 리팩토링 결과
이렇게 LentRepository에 cabinetId 기준으로 해당 사물함의 현재 대여 기록 및 대여자 정보를 받아오는 쿼리를 만들었고,
서비스 로직에서는 단순히 해당 건물, 층의 모든 사물함 정보를 받아온 후 그 리스트를 돌며 해당 사물함의 대여 정보를 읽어오고 대여정보가 있다면 유저 수와 이름을 저장하는 로직으로 수정했다.
2차 리팩토링 후 테스트 결과
2차 리팩토링 후 테스트 결과를 보면 166ms로 조금 줄어든 것을 볼 수 있다.
결론
동작 시간 측면에서는 20ms에서 166ms로 늘어났지만,
1.
2차 리팩토링 결과만 보자면 cabinetId를 기준으로 해당 사물함의 대여 기록과 대여자 정보를 한번에 긁어오는 메서드는 재사용할 확률이 높고
2.
CabinetRepository의 목적에 맞게 cabinet만 반환하도록 수정하였고
3.
List<Object[]>와 같은 타입 안전성이 낮은 코드 또한 수정할 수 있었다.
4.
추가적으로 서비스 로직에서의 가독성이 좋아졌다.
하지만 리팩토링한 코드에도
1.
여전히 N+1 문제가 발생하고,
2.
성능 개선의 여지가 남아있다.