N + 1 문제가 무엇인지와 그 해결 방법에 대해서 알아보도록 하겠습니다.
우선 N + 1이 일어나는 상황을 만들기 위해서 ERD와 스프링 프로젝트를 생성해 보도록 하겠습니다.
팀에 여러 명의 팀원이 속할 수 있고, 이 각각의 팀원이 여러 개의 게시글을 쓸 수 있는 상황을 가정하여 ERD를 설계하였습니다. 이제 이에 맞는 스프링 프로젝트를 생성해 보도록 하겠습니다.
아래는 순서대로 팀, 유저, 게시글의 엔티티입니다.
@Entity
@Table(name = "team")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long teamId;
@Column(name = "name")
private String name;
@OneToMany(mappedBy = "team", orphanRemoval = true)
@JsonIgnore
private List<User> users = new ArrayList<>();
}
@Entity
@Table(name = "user")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Column(name = "name")
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
@OneToMany(mappedBy = "user", orphanRemoval = true)
@JsonIgnore
private List<Post> posts = new ArrayList<>();
}
@Entity
@Table(name = "post")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long postId;
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
아래는 이번 게시글에서 사용할 예시 데이터입니다.
이제 N + 1 문제가 발생하는 것을 확인하기 위해서 상황을 만들어보겠습니다.
관리자가 어느 팀에 어느 유저가 있는지 확인하기 위해서 모든 팀과 그 팀에 속한 유저들의 리스트를 보고 싶은 상황을 가정해 보도록 하겠습니다.
위와 같은 상황에서 우리는 우선 팀의 목록을 불러오고 각각의 팀에 속한 유저들의 id를 DB에서 찾아올 것입니다.
이에 해당하는 코드를 작성해보도록 하겠습니다.
@Test
void showTeamAndUserList() {
List<Team> teams = teamRepository.findAll();
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
log.info("{}", teamUserList);
}
간단하게 N + 1 문제에 대해서 다룰 생각이기 때문에 위와 같이 team에 해당하는 유저들을 같은 List에 저장했습니다.
위와 같은 코드를 실행시킨 결과는 아래와 같습니다.
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
2024-02-14T10:52:20.178+09:00 INFO 32052 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5], []]
위의 로그를 보시면 가장 위의 쿼리는 team의 정보들을 얻어오기 위한 쿼리입니다. 그런데 그다음 3개의 쿼리는 비슷한 형식을 가지고 있는 쿼리가 반복해서 나간 것을 확인하실 수 있습니다.
위와 같이 3개의 쿼리가 더 나가는 이유는 team 테이블에 팀이 3개 존재하기 때문입니다.
위와 같이 1번의 쿼리가 나갔을 때 각각의 row에 대해서 추가로 조회하기 위해서 N 번의 쿼리가 나가는 현상을 N + 1 문제라고 합니다.
현재 3번의 쿼리만 더 나가니 크게 상관없지만 더 많은 데이터가 존재하면 위와 같은 상황은 큰 문제를 야기할 것입니다.
따라서 위의 상황을 어떻게 해결할 수 있을지 알아보도록 하겠습니다.
우선 N + 1 을 해결할 수 없는 상황을 먼저 말씀드리겠습니다.
FetchType을 LAZY로 설정해 놓아서 지연 로딩을 했으니, EAGER로 즉시 로딩을 하면 위와 같은 상황이 발생하지 않을 수 있다고 생각하실 수 있습니다.
따라서 엔티티에서 FetchType을 EAGER로 바꾼 후 위의 코드를 다시 실행해 보도록 하겠습니다.
@Entity
@Table(name = "user")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Column(name = "name")
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;
@OneToMany(mappedBy = "user", orphanRemoval = true)
@JsonIgnore
private List<Post> posts = new ArrayList<>();
}
위와 같이 User에서 Team에 FetchType을 EAGER로 바꾸었습니다.
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id=?
2024-02-14T11:20:46.816+09:00 INFO 24372 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5], []]
위의 실행결과를 보시면 똑같이 N + 1 문제가 발생하는 것을 보실 수 있습니다.
FetchType을 EAGER로 바꾸어도 즉시 N + 1 문제가 발생할 뿐 N + 1 문제를 해결할 수 없습니다.
이제 N + 1을 해결하는 방법들을 살펴보도록 하겠습니다.
Fetch Join
N + 1 문제를 해결할 수 있는 첫 번째 방법은 Fetch Join을 이용하는 방법입니다.
이 방식은 미리 Team과 관련된 테이블인 User를 불러오는 방식입니다.
코드와 실행 결과를 보면서 추가로 설명드리도록 하겠습니다.
@Query("select t from Team t join fetch t.users")
List<Team> findAllFetchJoin();
위와 같은 코드를 TeamRepository에 작성하여 줍니다.
그 후 아래와 같은 테스트 코드를 작성하여 줍니다.
@Test
void showTeamAndUserListFetchJoin() {
List<Team> teams = teamRepository.findAllFetchJoin();
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
log.info("{}", teamUserList);
}
위와 같은 코드를 실행시키면 아래와 같은 실행 결과가 나옵니다.
Hibernate:
select
t1_0.team_id,
t1_0.name,
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
team t1_0
join
user u1_0
on t1_0.team_id=u1_0.team_id
2024-02-14T11:16:15.972+09:00 INFO 47596 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5]]
위의 로그를 보시면 쿼리가 N번이 추가로 나가지 않고 join을 이용하여 1번의 쿼리로 결과를 확인하실 수 있습니다.
하지만 위의 방식을 사용하실 때 주의하셔야 하는 부분이 있습니다.
Fetch Join을 사용하실 때 OneToMany에서 One에 해당하는 부분에서 select를 하실 때는 JPA에서 제공하는 Paging 기능을 사용할 때 조심해야 합니다.
@Query("select t from Team t join fetch t.users")
Page<Team> findAllFetchJoin(Pageable pageable);
위와 같은 쿼리를 TeamRepository에 추가해 보았습니다. 이는 JPA에서 제공하는 Paging을 사용하기 위한 코드입니다.
이후 아래와 같은 테스트 코드를 실행해보겠습니다.
void paginationWithFetchJoin() {
Pageable pageable = PageRequest.of(0, 2);
Page<Team> teams = teamRepository.findAllFetchJoin(pageable);
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
log.info("{}", teamUserList);
}
위의 코드의 결과로 아래와 같은 로그가 발생합니다.
2024-02-14T11:39:04.243+09:00 WARN 35528 --- [ Test worker] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate:
select
t1_0.team_id,
t1_0.name,
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
team t1_0
join
user u1_0
on t1_0.team_id=u1_0.team_id
Hibernate:
select
count(t1_0.team_id)
from
team t1_0
join
user u1_0
on t1_0.team_id=u1_0.team_id
2024-02-14T11:39:04.353+09:00 INFO 35528 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5]]
맨 위의 로그의 의미는 처음, 마지막 결과를 모두 가져와서 메모리에서 paging을 진행했다는 것입니다.
이와 같이 모든 데이터를 메모리에 가져와서 paging을 진행하면 OOM(Out Of Memory)의 위험이 존재합니다.
왜 위와 같은 결과가 발생하는지 알아보도록 하겠습니다. 이를 위하여 아래와 같은 테스트 코드를 작성합니다.
@Test
void showTeamAndUserListFetchJoin() {
List<Team> teams = teamRepository.findAllFetchJoin();
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
List<Long> teamIdList = teams.stream()
.map(Team::getTeamId)
.collect(Collectors.toList());
log.info("{}", teamIdList);
log.info("{}", teamUserList);
}
이는 위에서 Fetch Join을 이용하여 N + 1 문제를 해결했을 때에서 Team의 id 리스트를 출력하는 코드입니다.
위를 실행시키면 아래와 같은 결과가 나오게 됩니다.
Hibernate:
select
t1_0.team_id,
t1_0.name,
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
team t1_0
join
user u1_0
on t1_0.team_id=u1_0.team_id
2024-02-14T11:42:35.230+09:00 INFO 39732 --- [ Test worker] example.test.service.TeamServiceTest : [1, 2]
2024-02-14T11:42:35.230+09:00 INFO 39732 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5]]
1, 2, 3번 팀이 존재했지만 join 한 결과에서 1, 2번 팀만 남게 되기 때문에 team이 1, 2만 나오게 됩니다.
위의 쿼리를 mysql에서 실행해 보도록 하겠습니다.
위와 같이 스프링부트에서 실행했을 때와는 team_id의 개수가 다른 것을 보실 수 있습니다.
저는 현재 스프링부트 3.2.2 버전을 사용하고 있고 hibernate의 버전은 6.4.1 final을 사용하고 있습니다.
이전의 hibernate에서는 mysql에서 실행한 결과와 동일한 결과가 나오지만 현재의 버전에서는 중복을 제거한 결과가 나오게 됩니다.(hibernate 5.6.5 final 버전에서 실험한 결과 동일한 결과가 나왔습니다.)
위와 같이 OneToMany에서 One 쪽에서 Fetch Join 한 결과를 findAll() 하여 가져와서 페이징을 하면 중복이 있기 때문에 메모리에서 이 중복을 제거하여 가져오게 됩니다. 따라서 JPA에서 제공하는 Paging을 사용하기 어렵습니다.
또한 Fetch Join을 사용하면 JPQL을 직접 사용하기 때문에 영속성 컨텍스트와 관련된 문제도 존재하고 fetch join을 하는 depth가 깊어지면 이와 관련된 문제도 발생하며, Fetch Join을 해야 하는 List가 2개 이상이면 Exception이 발생합니다.
위와 같은 문제가 존재하기 때문에 Fetch Join을 JPQL로 사용하는 것은 조심해서 사용해야 합니다.
@EntityGraph
N + 1 문제를 해결하는 2번째 방법은 @EntityGraph를 사용하는 방식입니다.
이 방식을 사용하기 위해서 TeamReposity에 다음과 같은 코드를 추가합니다.
@Override
@EntityGraph(attributePaths = {"users"})
List<Team> findAll();
이를 테스트하기 위해서 아래와 같은 테스트 코드를 작성합니다.
@Test
void showTeamAnsUserWithEntityGraph() {
List<Team> teams = teamRepository.findAll();
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
List<Long> teamIdList = teams.stream()
.map(Team::getTeamId)
.collect(Collectors.toList());
log.info("{}", teamIdList);
log.info("{}", teamUserList);
}
위 코드의 결과는 아래와 같습니다.
Hibernate:
select
t1_0.team_id,
t1_0.name,
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
team t1_0
left join
user u1_0
on t1_0.team_id=u1_0.team_id
2024-02-14T11:59:00.264+09:00 INFO 20672 --- [ Test worker] example.test.service.TeamServiceTest : [1, 2, 3]
2024-02-14T11:59:00.264+09:00 INFO 20672 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5], []]
@EntityGraph를 사용하는 방식은 left outer join을 사용하였기 때문에 team이 1, 2 뿐만 아니라 3도 조회가 됩니다.
이 방식을 사용하면 Fetch Join을 사용했을 때 발생하는 여러 개의 List에 대해서 Fetch Join을 하면 Exception이 발생하는 문제를 해결해 줍니다.
@EntityGraph는 Fetch Join을 annotation으로 만들어서 사용할 수 있게 했다고 생각하시면 좋을 거 같습니다.
하지만 @EntityGraph는 여전히 JPA에서 제공하는 Paging을 사용할 수 없다는 단점이 있습니다.
@BatchSize, default_batch_fetch_size
이 방식은 entity를 만들 때 @BatSize를 주는 방식과 applacation.yml 파일에 설정을 주는 방식이 존재합니다.
Team을 아래와 같이 수정해 줍니다.
@Entity
@Table(name = "team")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long teamId;
@Column(name = "name")
private String name;
@JsonIgnore
@BatchSize(size = 10)
@OneToMany(mappedBy = "team", orphanRemoval = true)
private List<User> users = new ArrayList<>();
}
TeamRepository도 @EntityGraph를 사용하기 위해서 findAll()을 @Override한 코드를 제거해 아래와 같이 만들어줍니다.
@Repository
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select t from Team t join fetch t.users")
List<Team> findAllFetchJoin();
@Query("select t from Team t join fetch t.users")
Page<Team> findAllFetchJoin(Pageable pageable);
}
이후 아래와 같이 테스트 코드를 작성해 줍니다.
@Test
void showTeamAndUserWithBatch() {
List<Team> teams = teamRepository.findAll();
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
List<Long> teamIdList = teams.stream()
.map(Team::getTeamId)
.collect(Collectors.toList());
log.info("{}", teamIdList);
log.info("{}", teamUserList);
}
이 테스트의 결과는 아래와 같습니다.
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2024-02-14T12:15:17.856+09:00 INFO 10620 --- [ Test worker] example.test.service.TeamServiceTest : [1, 2, 3]
2024-02-14T12:15:17.856+09:00 INFO 10620 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5], []]
위의 로그를 보시면 in 뒤에 조건을 주어 한번에 쿼리를 진행합니다.
이제 JPA에서 제공하는 Paging을 이용하기 위한 코드도 작성해 보도록 하겠습니다.
@Test
void paginationWithBatch() {
Pageable pageable = PageRequest.of(0, 2);
Page<Team> teams = teamRepository.findAll(pageable);
List<List<Long>> teamUserList = new ArrayList<>();
for (Team team : teams) {
teamUserList.add(team.getUsers().stream()
.map(User::getUserId)
.toList());
}
log.info("{}", teamUserList);
}
위의 결과는 아래와 같습니다.
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
limit
?, ?
Hibernate:
select
count(t1_0.team_id)
from
team t1_0
Hibernate:
select
u1_0.team_id,
u1_0.user_id,
u1_0.name
from
user u1_0
where
u1_0.team_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2024-02-14T12:17:14.392+09:00 INFO 40772 --- [ Test worker] example.test.service.TeamServiceTest : [[1, 2, 3], [4, 5]]
이전과 같은 OOM 관련 메시지가 없는 것을 확인하실 수 있습니다.
다만 이 방식은 @BatchSize에서 지정해 둔 size를 벗어나면 추가로 쿼리가 나간다는 문제가 존재하지만 위에서 Fetch Join, @EntityGraph 등에서 발생하던 문제가 발생하지 않습니다.
entity에서 @BatchSize를 적용하는 것 외에도 전체 설정으로 BatchSize를 지정하는 것이 가능합니다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/testdb
username: root
password: test
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
show_sql: true
format_sql: true
default_batch_fetch_size: 10
위와 같이 BatchSize를 설정할 수 있습니다.
DTO 사용
위에 설명드린 방법 외에도 DTO를 사용하여 필요한 정보들만 DB에서 가져오는 방법도 있습니다.
DTO를 사용하여 DB에서 필요한 정보들을 가져오기 위한 DTO interface를 만들어줍니다.
public interface TeamAndUserDto {
Long getTeamId();
Long getUserId();
String getName();
}
이제 쿼리를 사용하기 위해서 아래와 같은 메소드를 TeamRepository에 만들어줍니다.
@Query("select t.teamId as teamId, u.name as name, u.userId as userId " +
"from Team t join User u on t = u.team")
List<TeamAndUserDto> findAllWithDto();
이제 이 메소드가 정상적으로 동작하는지를 확인하기 위한 테스트 코드를 만들어 보겠습니다.
아래와 같은 테스트 코드를 이용하여 이 메소드가 정상적으로 동작하는지 확인해 봅니다.
@Test
void showTeamAndUserWithDto() {
List<TeamAndUserDto> teamUserList = teamRepository.findAllWithDto();
for (TeamAndUserDto teamAndUserDto : teamUserList) {
log.info("teamId = {}, userId = {}", teamAndUserDto.getTeamId(), teamAndUserDto.getUserId());
}
}
해당 테스트의 실행 결과는 아래와 같습니다.
Hibernate:
select
t1_0.team_id,
u1_0.name,
u1_0.user_id
from
team t1_0
join
user u1_0
on t1_0.team_id=u1_0.team_id
2024-03-18T09:45:08.179+09:00 INFO 7356 --- [ Test worker] example.test.service.TeamServiceTest : teamId = 1, userId = 1
2024-03-18T09:45:08.179+09:00 INFO 7356 --- [ Test worker] example.test.service.TeamServiceTest : teamId = 1, userId = 2
2024-03-18T09:45:08.179+09:00 INFO 7356 --- [ Test worker] example.test.service.TeamServiceTest : teamId = 1, userId = 3
2024-03-18T09:45:08.180+09:00 INFO 7356 --- [ Test worker] example.test.service.TeamServiceTest : teamId = 2, userId = 4
2024-03-18T09:45:08.180+09:00 INFO 7356 --- [ Test worker] example.test.service.TeamServiceTest : teamId = 2, userId = 5
각각의 팀에 맞는 user의 정보가 정상적으로 출력되는 것을 확인하실 수 있습니다.
interface를 사용하는 방식 외에도 class를 사용하거나 record를 사용하는 방식으로도 dto를 구현할 수 있습니다.
@Query("select new example.test.dto.TeamAndUserClass(t.teamId, t.name, u.userId)" +
"from Team t join User u on t = u.team")
List<TeamAndUserClass> findAllWithClass();
@Query("select new example.test.dto.TeamAndUserRecord(t.teamId, t.name, u.userId)" +
"from Team t join User u on t = u.team")
List<TeamAndUserRecord> findAllWithRecord();
위의 코드에서 사용하는 class와 record는 아래와 같습니다.
package example.test.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class TeamAndUserClass {
Long teamId;
String name;
Long userId;
}
package example.test.dto;
public record TeamAndUserRecord(
Long teamId,
String name,
Long userId
) {
}
위의 코드들에 대한 테스트 코드는 아래와 같습니다.
@Test
void showTeamAndUserWithClass() {
List<TeamAndUserClass> teamUserList = teamRepository.findAllWithClass();
for (TeamAndUserClass teamAndUserClass : teamUserList) {
log.info("teamId = {}, userId = {}", teamAndUserClass.getTeamId(), teamAndUserClass.getUserId());
}
}
@Test
void showTeamAndUserWithRecord() {
List<TeamAndUserRecord> teamUserList = teamRepository.findAllWithRecord();
for (TeamAndUserRecord teamAndUserRecord : teamUserList) {
log.info("teamId = {}, userId = {}", teamAndUserRecord.teamId(), teamAndUserRecord.userId());
}
}
위의 두 코드도 interface에서의 결과와 동일한 결과가 나오게 됩니다.
record나 class를 이용하여 dto를 구현하실 때는 new 키워드를 사용하셔서 객체를 만들어 주셔야 하는데 이때 해당 패키지명을 전부 명시해주어야 합니다.
위에 소개드린 여러 방식들을 이용하여 상황에 맞게 N + 1 문제를 해결하시면 좋을 것 같습니다.
'CS > 스프링' 카테고리의 다른 글
synchnonized, 비관적 락, 낙관적 락, 분산락(named lock)을 사용한 데드락 처리 (0) | 2024.07.26 |
---|---|
QueryDSL을 활용한 동적 쿼리 처리 (0) | 2024.07.17 |
join 제거, dto projection, covering index를 활용한 성능 최적화 (0) | 2024.07.04 |
spring 프로젝트에서 더미 데이터 사용 & 테스트용 fixture 사용하기 (0) | 2024.06.16 |
spring security 적용시 @WebMvcTest 코드에서 csrf() 없애기 (0) | 2024.06.06 |
댓글