일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 동시성 문제
- JWT
- 프로토콜
- JMeter
- EC2
- docker
- 코딩테스트
- 인메모리
- Java
- redis
- N+1 문제
- 알고리즘
- OSI 7계층
- 백준
- Spring Security
- TCP/IP
- 로그인
- RDS
- 스프링부트
- OOP
- 4-way-handshake
- 트러블 슈팅
- 네트워크
- 객체지향
- 성능 개선
- 형상관리
- 3-way-handshake
- AWS
- Spring
- Today
- Total
열공스토리
[Spring] N+1 문제 트러블 슈팅 본문
이번 포스팅은 최근 게시판 프로젝트를 진행하면서 발생한 N+1 문제 해결과정에 대한 회고입니다.
트러블 슈팅 과정을 보기에 앞서, N+1 문제란 무엇인지 간단하게 짚고 넘어가겠습니다.
N+1 문제란?
N+1 문제란 1:N 또는 N:1로 연관관계가 설정되어 있는 엔티티를 한 번 조회 시, 조회 쿼리 1개가 수행되는 것이 아닌 연관 되어있는 N개의 엔티티를 조회하기 위해 N만큼의 추가 조회 쿼리가 수행되는 일 때문에 발생하는 문제를 말합니다.
이게 왜 문제가 되냐 하면 예를 들어, 한 엔티티의 연관관계 엔티티가 100개라고 가정한다면 해당 엔티티를 한 번 조회한다고 했을 때, 추가적으로 조회 쿼리 100개가 더 수행되어 조회 쿼리만 총 101번 수행되어 불필요한 조회 쿼리가 수행되는 현상이 발생합니다.
그런데 이 현상이 만약 실제 배포하고 있는 서비스에서 몇 백, 몇 천명의 사용자가 해당 엔티티를 조회하여 각각 101번의 쿼리를 수행시킨다면? 서버에 엄청난 트래픽으로 인해 부하가 생기게 되고 결국 성능 저하 이슈가 발생할 것입니다.
N+1 문제 발생원인
그렇다면 N+1 문제는 왜 발생하는 것일까요? 이는 JPA와 관련이 있습니다.
JPA는 메소드를 분석하여 JPQL을 생성하고 실행한 다음, 이를 가지고 SQL문을 생성하고 생성한 쿼리를 DB에 날려 그 결과를 영속성 컨텍스트에 저장 후 반환합니다. 그런데 이 때, JPQL은 SQL문을 생성할 때 글로벌 fetch 전략을 무시하고 생성하기 때문에 N+1 문제가 발생하게 되는 것입니다.
밑에서 즉시 로딩(Eager)과 지연 로딩(Lazy)의 설명과 함께 더 자세히 살펴보겠습니다.
글로벌 fetch 전략이란?
@xToOne은 default가 Eager이고,
@xToMany는 default가 Lazy이다.
출처 - 링크텍스트
즉시 로딩(Eager)
즉시 로딩은 엔티티 조회 시, FetchType이 Eager로 설정 되어 있는 연관관계 엔티티를 그 즉시 함께 가져오는 fetch 전략입니다.
하지만 앞서 말했듯이, JPQL은 SQL문 생성 시, 글로벌 fetch 전략을 무시하고 생성한다고 하였죠? 따라서 다음과 같이 동작이 이루어집니다.
- JPA가 JPQL을 생성하고 실행하여 영속성 컨텍스트에 해당 엔티티가 존재하는지 확인 후 없으면 SQL문을 생성하여 쿼리를 DB에 날린다.
- 조회한 엔티티를 영속성 컨텍스트에 저장하고 Application에 반환한다.
- 이후 엔티티와 연관되어 있는 엔티티도 1번, 2번과 같은 방법으로 조회 하여 반환한다.(fetch 전략이 무시되어 연관관계 엔티티를 가져오지 못하였기 때문에 따로 조회를 수행한다.)
이러한 과정에서 만약 연관 되어 있는 데이터 갯수가 100개라면 100번 더 조회가 발생하여 결국 N+1 문제가 발생하게 되는것이죠.
지연 로딩(Lazy)
반면에 지연 로딩은 엔티티 조회 시, FetchType이 Lazy로 설정 되어 있는 연관관계 엔티티를 프록시(proxy) 객체로 가져오는 fetch 전략입니다.
마찬가지로 JPQL이 SQL문 생성 시, 글로벌 fetch 전략을 무시하고 생성합니다. 지연 로딩은 다음과 같이 동작됩니다.
- JPA가 JPQL을 생성하고 실행하여 영속성 컨텍스트에 해당 엔티티가 존재하는지 확인 후 없으면 SQL문을 생성하여 쿼리를 DB에 날린다.
- 조회한 엔티티를 영속성 컨텍스트에 저장하고 Application에 반환한다.
- 이후 엔티티와 연관되어 있는 엔티티가 프록시 객체로 반환된다.
음?
여기까지만 보면 지연 로딩은 N+1 문제가 발생하지 않는 것처럼 보일 수 있습니다. 하지만 지연 로딩은 프록시 객체 형태로 반환된 이 연관관계 엔티티 객체를 사용하는 시점에 데이터 갯수 만큼 추가 조회가 발생되어 즉시 로딩과 마찬가지로 N+1 문제가 발생하게 된답니다.
N+1 문제에 대한 설명은 여기까지구요, 이제 제가 겪은 N+1 문제에 대한 트러블 슈팅 과정을 보겠습니다.
문제 발견
먼저, 게시글과 태그에 대한 엔티티 관계는 다음과 같이 Board 엔티티가 있고 Tag 엔티티가 있으며 중간에 board id와 tag id를 식별자로 갖는 중간 테이블 BoardTag가 존재합니다.
문제는 게시글을 전체 조회하는 비즈니스 로직에서 response dto 객체를 queurydsl로 받아온 후, 게시글 태그에 대한 값을 매핑하는 부분을 따로 구현한 상황에서 response dto에 게시글의 태그 수 만큼 더 조회가 발생하는 문제를 발견하였습니다.
[BoardService.java]
@Transactional(readOnly = true)
public List<BoardReadResponseDto> readBoardList(Long memberId) {
List<BoardReadResponseDto> list = boardRepository.findAllBoardReadDto(memberId)
.orElseThrow(() -> new BoardNotFoundException(ErrorCode.BOARD_NOT_FOUND));
for (BoardReadResponseDto responseDto : list) {
responseDto.setTags(boardTagRepository.findAllByBoardId(responseDto.getBoardId())
.orElseThrow(() -> new BoardTagNotFoundException(ErrorCode.BOARD_TAG_NOT_FOUND))
.stream().map(e -> e.getTag().getName()).collect(Collectors.toList()));
}
return list;
}
[repository]
@Repository
public interface BoardTagRepository extends JpaRepository<BoardTag, BoardTagId> {
List<BoardTag> findAllByBoardId(Long boardId);
}
[실제 조회 된 쿼리]
Hibernate:
select
boardtag0_.board_id as board_id1_1_,
boardtag0_.tag_id as tag_id2_1_
from
board_tag boardtag0_
left outer join
board board1_
on boardtag0_.board_id=board1_.board_id
where
board1_.board_id=?
Hibernate:
select
tag0_.tag_id as tag_id1_5_0_,
tag0_.name as name2_5_0_
from
tag tag0_
where
tag0_.tag_id=?
Hibernate:
select
tag0_.tag_id as tag_id1_5_0_,
tag0_.name as name2_5_0_
from
tag tag0_
where
tag0_.tag_id=?
Hibernate:
select
tag0_.tag_id as tag_id1_5_0_,
tag0_.name as name2_5_0_
from
tag tag0_
where
tag0_.tag_id=?
보시면 먼저 Board와 연관된 BoardTag 엔티티 객체를 조회하고 해당 BoardTag 객체와 연관된 Tag 엔티티 갯수 3개 만큼 조회 쿼리가 발생하는 것을 볼 수 있습니다.
해결 방법
해결방법으로는 native query를 사용하여 fetch join 방법을 채택하였습니다.
사실 batch size를 설정하여 해결하는 방법도 고민해 보았으나 fetch join의 성능적 이점을 고려하여 다음과 같은 이유로 fetch join 방법을 채택하게 되었습니다.
1. 최대 10개로 한정된 게시글의 태그 엔티티만 조인하면 되는 상황이었기에 데이터가 많을수록 성능 저하가 발생할 수 있는 fetch join의 특성을 고려하여 사용하여도 문제가 우려되지 않았다.
2. @Query 어노테이션을 사용하면 직접 native queury를 작성하여 SQL 쿼리가 DB에서 바로 실행되기 때문에 보다 나은 성능을 기대하였다.
"default_batch_fetch_size 옵션으로 인해 최소한의 성능 보장이 된 것이지, 이게 최선은 아닙니다.
Fetch Join을 이용해 최대한의 성능 튜닝을 진행하고, Fetch Join으로 해결이 안되는 조회 쿼리에 대해서는 default_batch_fetch_size 옵션으로 최소한의 성능을 보장해준다고 보시면 됩니다."
- hibernate.default_batch_fetch_size를 글로벌 설정으로 사용해 N+1 문제를 최대한 in 쿼리로 기본적인 성능을 보장하게 한다.
- @OneToOne, @ManyToOne과 같이 1 관계의 자식 엔티티에 대해서는 모두 Fetch Join을 적용하여 한방 쿼리를 수행한다.
- @OneToMany, @ManyToMany와 같이 N 관계의 자식 엔티티에 관해서는 가장 데이터가 많은 자식쪽에 Fetch Join을 사용한다.
출처 - https://jojoldu.tistory.com/457
[repository]
@Repository
public interface BoardTagRepository extends JpaRepository<BoardTag, BoardTagId> {
@Query("select bt from BoardTag bt join fetch bt.tag")
List<BoardTag> findAllByBoardId(Long boardId);
}
보시는 것과 같이 기존 JPA Repository 메소드에 @Query 어노테이션으로 BoardTag 테이블과 연관된 Tag 테이블을 fetch join 하는 쿼리를 추가하였습니다.
결과
결과는??
Hibernate:
select
boardtag0_.board_id as board_id1_1_0_,
boardtag0_.tag_id as tag_id2_1_0_,
tag1_.tag_id as tag_id1_5_1_,
tag1_.name as name2_5_1_
from
board_tag boardtag0_
inner join
tag tag1_
on boardtag0_.tag_id=tag1_.tag_id
BoardTag 테이블에 Tag 테이블이 fetch join 되면서 쿼리문이 하나만 수행되었습니다.
해결!
마무리
이번 글을 포스팅하면서 N+1 문제에 대한 내용을 더 공부하며 자연스레 JPA와 JPQL에 대해서도 더 공부하게 되어 이전보다 더 많은 스프링 지식을 습득할 수 있었던 뜻 깊은 시간이었습니다.
요즘들어 이렇게 성장하는 느낌을 받으면서 더 열심히 하게 되는것 같아 뿌듯하네요:)
끝!
'Spring' 카테고리의 다른 글
[Spring] 이메일 발신 기능 성능 개선 (0) | 2024.09.24 |
---|---|
[Spring] 파일 저장 로직의 동시 요청에 의한 동시 저장을 방지하는 방법 (0) | 2024.08.17 |
[Spring] 게시판 좋아요 기능 동시성 문제 트러블 슈팅 (2) | 2023.07.28 |
[Spring] Redis 사용 해 보기 (0) | 2023.07.28 |
[Spring] Security + JWT 로그인 인증 방식 내부 동작 과정 (0) | 2023.07.28 |