사건의 발단은 이랬다.
어드민은 현재 모든 Thread를 조회해야하는 시점에서, 그냥 List<Thread>를 그대로 호출해버리니 N+1 문제가 발생하였다.
사실상 어드민 기능에서는 성능적으로 크게 이슈가 없다 → 기존의 다른 서비스 같은 경우 어드민 기능을 하는 서버를 따로 띄운다. → 그래서 리소스 관련해서 크게 신경쓸 필요가 없는데 → 우리 서비스 같은 경우에는 한 서버에 다 띄우기에 만약에 다량의 쿼리가 발생해서 서버가 터져버리면? 이라는 생각으로 성능 개선을 해보려고 한다.
또한 어드민은 특정 사용자만 이용하기에 다른 서비스 기능에 비해선 크게 걱정할 필요가 없다고 생각한다. 하지만 기술적인 깊이와 코드에 깊이를 가지고, 직면한 문제를 직접 해결함으로써 성장하기 위해서 확인 차 기능을 제작했다.
현재로서는 데이터가 많이 존재하지 않기에 뭐 크게 문제가 아닐 수 있겠지만, 만약에 데이터가 가 많아지면 query가 감당이 안된다.
findAll() → 기능을 사용하면 해당 모든 연관된 데이터를 가지고 오기도 하면서, 불필요한 데이터까지, N+1 까지 문제가 발생한다
현재 3개의 데이터를 단지 불러오는 것인데 → query가 11번 발생한다.
(회원부 query (1방) → thread 데이터 찾기(1방) : 이 속에는 thread 개수가 존재한다 (현재 3개의 thread 개수 존재) → 각각의 데이터 마다 account와 imagelist를 조인해서 가진다 (각각 한방씩 해서 총 3방) →(thread 이미지 찾기 쿼리 1방 + 추천 찾기 1방)x 3 )
N+1의 문제의 발단은 Entity의 하위 호출함에 있어서 발생하는 문제라고 생각한다. 지연로딩 (Lazy Loading)으로 필요한 곳에서 사용되어 쿼리가 발생할 때의 문제를 N+1이라고 칭한다. (즉시 로딩인 경우에도 N+1 문제가 발생함)
- 지연 로딩 시 발생하는 N+1
- JPQL에서 만든 SQL을 이용해 데이터를 조회할 때, JP에서 fetch 전략을 가지지만, 지연 로딩이라 추가적인 조회는 발생하지 않으나, 하위 엔티티로 작업하시 된다면 추가 조회가 발생해서 N+1 문제가 발생
- 즉시 로딩 시 발생하는 N+1
- 동일하게 SQL을 이용해 데이터를 조회할ㄹ때, 지연로딩과 다르게 추가적인 조회가 발생해서 이로인해 N+1 문제가 발생한다.
해당 엔티티의 연관된 데이터를 긁어오려고 하다보니 그에 걸맞게 N+1이라는 문제가 발생한다.
( 의도하지 않는 N번의 쿼리가 추가로 실행 되는 것이 N+1 문제 )
( 지금 생각해보면 N+1이라고 이름을 명명한 사람은 칭찬상 줘야함)
이 N+1 문제는 일대다, 다대일 관게를 가진 엔티티를 조회할 때 발생하는데, JPA fetch 전략이 EAGER이거나, LAZY 전략이지만 다시 데이터 연관관계의 하위 엔티티를 조회하는 경우 발생한다.
N+1 자체가 한쪽 테이블 조회 시 연결된 다른 테이블은 따로 조회하지 않기 때문에 발생함
그럼 어떻게 연관관계를 맺고 있는 엔티티 데이터를 가지고 와야할까?
1. join fetch ( 페치조인 )
페치조인은 JPA에서만 지원해주는 기능 중 하나이다. SQL의 조인종류와 다르다. fetch join은 JPQL의 성능 최적화를 위해 사용하며 @Query 메서드를 이용해서 직접 쿼리문을 작성해야 사용할 수 있다.
@Query("select * from Member m join fetch m.team t join.fetch m.name")
List<Member> findAllWithMember();
이런 방식으로 데이터를 조회할 수 있는데, 이방법은 불필요한 추가 쿼리 문이 발생하는 단점이 있다.
그리고 페치조인을 사용해서 데이터를 조회한다면 inner join을 사용한다.
페치 조인의 단점은 우선 Pageable 객체의 도움을 받지 못한다. (페이징 객체) 그 이유는 쿼리 한번에 모든 데이터를 가지고 오기 때문이다. 또 페치 조인 대상에 별칭 (as)를 부여할 수 없다. 그리고 앞서 언급한 불필요한 추가 쿼리문을 작성해야 한다는 점,
마지막으로 일대다 관계가 두 개 이상인 경우 사용이 불가능하다.
2. @EntityGraph ( 엔티티 그래프 )
@EntityGraph(attributePaths = "subjects")
@Query("selct m from Member m")
List<Member> findAllEntityGraph();
이런 방식으로 해당 쿼리 수행 시 가저올 필드명을 attributePaths에 지정하면 Lazy가 아닌 Eager로 조회해서 가지고 온다. 불필요한 추가 쿼리가 발생하는 것 없고, 원본 쿼리의 변형 없이 가지고 올 수 있다.
엔티티 그래프를 통해 데이터를 조회하면 outer join을 사용한다.
그래서 1번과 2번 방법은 카테시안 곱(Cartesian Product)이 발생하는데, 카테시안 곱은 두 개 이상의 기준 테이블에 대해서 연결 가능한 행을 모두 결압하는 조인 방식이다. 그래서 카테시안 곱이 발생하면 각 테이블의 행의 수를 곱한 만큼의 조인 결과가 생성된다. 이로인해 속도의 저하가 발생하는 단점이 있다.
그리고 성능적으로도 inner join과 outer join을 비교했을 때, 이너조인이 성능 최적화에 유리하다.
이런 문제점의 해결방안은 다음과 같다.
1. 일대다 필드 타입을 set 타입으로 선언 ( set은 중복을 허용하지 않은 자료구조 )
(Set은 순서가 보장되지 않아서 LinedHashSet을 사용해서 순서를 보장해야 한다.)
2. distinct 속성을 이용해 중복을 제거하는 것
< 내 생각 >
만약에 나는 N+1의 문제를 해결해야하는 상황에서 선택을 하자면 아마 페치조인을 사용할 것 같다.
직접 쿼리를 짜서 필요한 데이터를 가지고 오려고 할 것이며, 되도록이면 Entity 자체를 호출하지 않을 것이다.
또한 불필요하다고 생각하는 연관관계는 맺지 않아서 이러한 문제점이 발생하기 전에 예방하고자 노력할 것이다.
맨 처음 언급한 admin 관련 문제점을 나는 이렇게 해결했다.
우선 데이터를 가지고 올 때 -> Entity를 조회하지 않고 dto로 변환해서 필요한 데이터만 매핑해서 가지고 온다.
또한 Entity의 불필요한 연관관계를 수정함으로써 불필요한 하위 데이터는 조회하지 않도록 한다.
그래서 원하는 데이터를 가지고 올 수 있도록 각각의 쿼리를 발생해서 해당 문제를 해결했다.
참고 레퍼런스
'기술면접 관련 및 참고하기' 카테고리의 다른 글
SSR(Server Side Rendering) 그리고 CSR(Client Side Rendering) (0) | 2023.03.22 |
---|---|
Java 8 vs Java 11 ? (0) | 2023.03.05 |
비관적 락 (Pessimistic Lock )vs 낙관적 락 (Optimistic Lock) (0) | 2023.03.04 |
도커(Dokcer)가 뭔데? 도커 사용기 (2) | 2023.03.03 |
[ 10분 테코톡 ] - Stream 그리고 For Loop (0) | 2022.12.26 |