💭 들어가며
새로운 프로젝트를 진행하게 됐다. 프로젝트는 교내에서 열리는 스포츠 경기들의 결과를 실시간으로 확인할 수 있도록 하는 서비스이다.
경기들의 목록을 조회할 때, QueryDSL 을 사용하여 페이징 쿼리를 작성했다.
프로젝트에서의 컨벤션 상, 경기에 참여하는 팀들의 데이터를 순정렬하여 반환해야 했다. 그래서 다음과 같이 코드를 작성했다.
List<Game> games = gameDynamicRepository.findAllByLeagueAndStateAndSports(leagueId, state, sportIds,
pageRequest);
return games.stream()
.map(game -> new GameResponseDto(game,
gameTeamRepository.findAllByGameWithTeam(game).stream()
.sorted(comparingLong(GameTeam::getId)).toList(),
game.getSport()))
.toList();
다음과 같은 코멘트를 받았다.
"이렇게 되면 N+1 이 발생하겠군요 승희님.."
N+1 문제에 대해서는 익히 알고 있었지만, 정작 내가 코드를 짤 때는 별다른 의식을 하지 않거나 문제 지점을 찾지 못한다는 생각을 하게 되어 이 기회에 제대로 잡아보자! 하고 생각하게 됐다.
✅ 현재 요구사항
- games: 경기
- game_teams : games, teams 을 연결하는 중개 테이블
- teams : 팀의 정보
조건에 해당하는 경기를
1. 경기에 참여하는 팀들은 id 순으로
2. 경기들은 페이징하여 반환되어야 한다.
📝 N+1 문제는 무엇일까?
우선, games 와 game_teams 는 위와 같다.
games : game_teams = 1:N 의 관계를 맺고 있으며 games 쪽에서는 game_teams 를 참조하지 않고 있다.
또한, game_teams 는 teams 와 games 를 연결하는 중개테이블이다. 팀의 정보는 teams 에 담겨 있다.
List<Game> games = gameDynamicRepository.findAllByLeagueAndStateAndSports(leagueId, state, sportIds,
pageRequest);
return games.stream()
.map(game -> new GameResponseDto(game,
gameTeamRepository.findAllByGameWithTeam(game).stream()
.sorted(comparingLong(GameTeam::getId)).toList(),
game.getSport()))
.toList();
앞서 언급한 코드를 실행한 쿼리는 다음과 같았다.
내가 예상했던 상황은 경기 중 조건에 맞는 경기들을 찾는 쿼리 하나가 나가는 것이었다.
그러나, 실제로 확인해보니 GameTeam 객체를 조회하는 쿼리가 수십개가 나간 것을 볼 수 있었다. 🤯🤯🤯
N+1 문제는 이와 같이 의도하지 않은 쿼리가 수십개가 나가는 것을 의미한다.
이는 1:N 조인 상황에서 1 쪽에서 조회를 할 경우, 의도한 쿼리 한개(실제로 객체를 조회하는 쿼리)와 N 개만큼 쿼리가 나가는 것이다.
나의 경우를 예시로 들면, 게임의 개수만큼 팀을 조회하는 쿼리가 N개만큼 발생한다.
집사와 반려동물과의 관계로 예를 들자면 집사의 모든 반려동물을 조회하려고 했더니 N개만큼 쿼리가 나가는 것이다.
지금 이 문제는 왜 발생하고 있을까?
📝 N+1 이 발생하는 이유
return games.stream()
.map(game -> new GameResponseDto(game,
gameTeamRepository.findAllByGameWithTeam(game).stream()
.sorted(comparingLong(GameTeam::getId)).toList(),
game.getSport()))
.toList();
👀 왜 이 코드에서는 N+1 이 발생하는가?
위의 코드를 보면, GameTeam 의 id 를 조회하는 것을 확인할 수 있다.
현재 games, 즉 경기들에 대해서 반복적으로 해당 경기에 참여하고 있는 팀들을 찾아서 id 순으로 정렬하여 dto 의 인자에 넣고 있다. 이 과정에서 GameTeam 의 id 를 조회하게 되니, 경기 하나 당 (1) 경기에 참여하는 팀의 개수만큼 (N) 쿼리가 여러개 나가게 되는 것이다.
games 는 여러개의 game_teams 를 가지고 있고, 경기 목록(games)을 조회할 때는 프록시 객체가 생성된다. 그러나, 실제로 game_teams 객체에 접근하여 id 를 조회하는 순간 쿼리가 나가게 되는 것이다.
👀 N+1 문제는 근본적으로 왜 발생하는가?
이렇게 우리의 예상과는 다르게 쿼리가 나가는 이유는 객체지향과 관계형 데이터베이스 간의 패러다임 차이로 인해서이다.
객체는 A 가 B 를 참조하고 있다면 직접적으로 접근이 가능하다. 예를 들어, GameTeam 이 Team 을 참조하고 있으니 GameTeam.team 을 통해 바로 team 으로의 접근이 가능하다. 레퍼런스만 갖고 있다면 메모리 내에서 Random Access 를 통해 바로 접근할 수 있는 것이다.
그러나 관계형 데이터베이스의 경우에는 SQL 쿼리만을 통해서 필요한 정보를 조회할 수 있다.
📝 N+1 문제를 해결할 수 있는 방법
fetch join 을 사용한다.
fetch join 은 연관된 엔티티나 컬렉션들을 한번에 가져올 수 있도록 JPQL 에서 제공하는 기능이다.
예를 들어서, game 을 조회할 때 연관된 game_teams 객체들도 하나의 쿼리로 조회해오는 것이다.
SELECT g FROM Game g JOIN FETCH g.gameTeams WHERE g.id = :gameId
그러나 fetch join 을 사용할 때는 몇 가지의 주의할 점들이 존재한다.
Distinct 절을 사용해야 한다.
레코드 | Game | GameTeam |
1 | Game1 | GameTeam1 |
2 | Game1 | GameTeam2 |
3 | Game2 | GameTeam3 |
fetch join 을 실행한 결과의 예시는 위와 같다. 이는 중복되어서 존재한다. 따라서 반드시 distinct 를 해줘야 한다.
SQL 에서의 distinct 는 join 되어서 발생한 결과에 대해 중복을 제거하기 때문에 우리가 의도한 대로 중복이 제거되지 않을 수 있다. SQL 상으로는 Game1 + GameTeam1 이 있는 row 와 Game1과 GameTeam2 가 있는 row 는 동일하지 않기 때문이다.
그러나 JPQL 상으로의 distinct 는 객체 자체에 대한 중복을 제거하기 때문에 우리가 의도한 대로 중복을 제거할 수 있다.
Paging 이 불가능하다.
fetch join 을 사용하게 되면, 메모리 상에 모든 객체가 존재하게 되고 그 상태에서 페이징을 실행하게 된다.
만약, fetch join 을 사용하여 Game 을 기준으로 페이징을 하게 되면, 테이블에 존재하는 모든 연관된 데이터를 메모리로 로딩해야 한다.
이로 인해 메모리 내부에서 페이징이 일어나게 되고, 이는 메모리 과부하로 이어져 장애 요인이 될 수 있다.
❌ 선택하지 않은 이유
1. 페이징이 필요한 현재 상황에서 적합하지 않다.
2. 양방향 연관관계가 맺어진 경우에만 가능하다. 그러나 양방향 연관관계를 맺지 않기로 내부적으로 논의했기에 위의 방법 역시도 제외했다. 왜 양방향 연관관계를 맺지 않기로 했는지는 뒤의 선택지에서 더 자세히 설명하겠다.
BatchSize 를 활용한다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "game")
private List<GameTeam> gameTeams = new ArrayList<>();
BatchSize 는 위와 같이 활용할 수 있다. 이는 Game (1) 쪽에서 양방향 연관관계를 맺으면 정해둔 개수(size) 만큼 in 절로 GameTeam(N) 의 객체를 가져온다.
spring:
jpa:
properties:
default_batch_fetch_size: 100
이는 위처럼 직접 엔티티에 사이즈를 정의할 수도 있고, 전역적으로 설정하기 위해 위처럼 yaml 에 기입하는 것도 가능하다.
위의 방법을 적용하게 되면 쿼리가 다음과 같이 나갈 것이다.
// games 조회 쿼리
select * from game_teams where game_teams.game IN (games 조회 쿼리로 알게 된 game 의 id)
BatchSize 와 fetch join 비교
BatchSize 는 설정해놓은 사이즈에 비해 조회되어야 하는 엔티티의 개수가 더 많다면, 쿼리가 여러개가 나갈 수 있다. 이로 인해 쿼리 개수의 관점에서는 fetch join 이 유리하다. 예를 들어, 만약 batch size 가 10인데 조회되어야 하는 엔티티의 개수가 100개라면 10번의 쿼리가 나가야 한다.
그러나, fetch join 은 join 을 먼저 하고 데이터를 가져오기 때문에 중복된 데이터가 존재하지만 batch size 의 경우에는 join 을 하지 않고 조회하기 때문에 중복되는 데이터가 존재하지 않는다. 따라서 데이터 전송량의 관점에서는 BatchSize 가 유리하다.
❌ 선택하지 않은 이유
해당 기능을 구현하기 이전에, 팀 내에서 API 스펙 상으로 필요한 응답 형태에 맞춰서 반환하기 위해서 양방향 매핑을 맺지 않기로 논의를 마친 상태였다. 이는 API 스펙 상의 변경사항이 (ex. 경기의 목록을 반환할 때 경기에 참여하는 팀의 목록은 반환할 필요가 없어질 경우) 연관관계에 영향을 미치는 것은 옳지 않은 의존관계라고 생각했기 때문이었다.
비지니스 로직 상으로 해당 객체가 분명히 양방향 연관관계를 맺고 있다거나, 생명 주기를 같이 해서 객체를 동시에 관리할 필요성이 느껴지는 경우에만 양방향 연관관계를 맺도록 하기로 했다.
Projection 을 이용해 dto 로 변환하기
select new 패키지 경로.GameResponseDto(원하는 필드)
from Game g
join g.game_teams gt
where gt.game_id = g.id
위와 같이 직접적으로 필요한 필드들을 명시하여 바로 dto 로 변환하게 되면, 필요한 컬럼만 명시하여 조회할 수 있다.
그러나 이가 동시에 의미하는 것은, 필요한 컬럼을 모두 명시해야 하기에 API 스펙이 바뀌게 되면 위의 코드도 수정되어야 하는 것이다.
즉, DTO 의 변경이 DAO 까지 변경을 전파하게 되어 좋지 않은 방법이 될 수 있다.
이를 활용하기 위해서는, DTO 의 변경이 DAO 까지 변경을 전파하게 되는 쿼리와 그렇지 않은 쿼리를 분리하여 관리해야 한다.
❌ 선택하지 않은 이유
위의 방법 역시도, 쿼리를 보면 알겠지만 양방향 연관관계를 맺어야 한다.
그렇기에 이 방법 역시도 선택하지 않았다.
N+1 문제를 고려하여 코드를 짜기
List<Game> games = gameDynamicRepository.findAllByLeagueAndStateAndSports(queryRequestDto, pageRequest);
List<GameTeam> gameTeams = gameTeamQueryRepository.findAllByGameIds(
games.stream()
.map(Game::getId)
.toList()
);
Map<Game, List<GameTeam>> groupedByGame = gameTeams.stream()
.collect(groupingBy(GameTeam::getGame));
return games.stream()
.map(game -> new GameResponseDto(game, groupedByGame.getOrDefault(game, new ArrayList<>()),
game.getSport()))
.toList();
fetch join 은 페이징으로 인해 사용할 수 없고, BatchSize 는 양방향 관계를 맺지 않아 활용할 수 없기에 결국 N+1 문제를 고려해 코드를 짜기로 결정했다. 위에서 언급한 방법들은 모두 양방향 연관관계를 맺어야 가능한 방법들이었기에 해당 문제를 염두에 두고 코드를 짜는 것을 통해 해결할 수 있었다.
우선, 경기 전체를 조회해 games 에 담는다. 그리고 GameTeam 전체 중에 해당 경기에 참여하고 있는 팀들을 전부 조회해온다. 이때 경기의 id 들은 stream 을 이용해 리스트로 반환한다. 이를 통해, 조건에 맞는 경기 객체들과 경기에 참여하는 팀 객체들은 전부 영속성 컨텍스트의 1차 캐시에 담기게 되었다. 즉, 더 이상 쿼리가 나갈 일은 없다. 이후 groupedByGame 에 경기에 참여하는 팀들을 경기를 key 로 하여 그룹화한다.
이를 통해 쿼리를 두번까지 줄일 수 있게 되었다.
🌝 참고 자료
https://velog.io/@xogml951/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%B4%9D%EC%A0%95%EB%A6%AC
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 이벤트 기반 아키텍처를 알아보고 스프링부트의 이벤트를 구현해보자 (0) | 2024.03.01 |
---|---|
[SpringBoot] Spring REST Docs 로 API 명세를 문서화 하자 (1) | 2024.01.23 |
[SpringBoot] 동시성 문제를 해결하자 (Synchronized, MySQL, Redis) (2) | 2024.01.10 |
[SpringBoot] 확장성을 고려하여 OAuth2.0 로 Kakao 소셜 로그인 구현하기 (0) | 2023.09.27 |
[SpringBoot] @ConfigurationProperties 로 프로퍼티들을 바인딩하기 (0) | 2023.09.08 |