Home [football] N+1 쿼리 문제 해결을 위한 고민
Post
Cancel

[football] N+1 쿼리 문제 해결을 위한 고민

# 문제점


발견된 문제점

JPA를 활용하면 테이블 구조에 대한 고민을 자바 코드에선 하지 않고 객체간의 관계로써만 코드를 작성할 수 있도록 기능을 지원해준다는 장점이 있습니다.

저는 JPA를 가지고 연관관계를 가진 엔티티 객체들이 필요에 따라 쿼리문에 대한 고민 없이 연관관계 객체의 정보를 조회될 수 있도록 구현해보고자 했습니다.

하지만 이로 인해 서비스 성능에 매우 큰 문제를 야기할 수 있는 구조로 작성된 코드를 발견했고 그러한 문제를 N+1 쿼리라고 불리고 있었습니다.

N+1 쿼리란?

N+1 쿼리란 연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 되면서 추가적인 오버헤드가 발생하는 현상을 의미합니다.

가장 흔하게 발생하는 N+1 쿼리는 일대다 관계에 있는 객체 리스트 정보를 조회하기 위해 N개 만큼의 SELECT 쿼리가 추가로 발생하는 현상을 의미합니다. 하지만 제가 마주한 N+1 쿼리는 조금 다르게 일대일 관계에 있는 객체에 대한 SELECT문이 반복문으로 인해 N개만큼 실행된 상황이였습니다.

자세한 내용은 아래에서 확인할 수 있습니다.

N+1 쿼리 발생 코드 - 엔티티 객체

N+1 쿼리가 발생한 엔티티 객체는 아래 세 클래스 타입으로 객체간의 연관 관계는 아래와 같습니다.

  • ChannelParticipant는 일대다 관계입니다.
  • ParticipantUser는 일대일 관계입니다.

Channel 클래스

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
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "channel")
public class Channel {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  private int id;

  @Column(name = "name")
  private String name;

  @OneToMany(mappedBy = "channel")
  private List<Participant> participants;

  public void addParticipant(Participant participant) {

    this.participants.add(participant);

  }
  
}

Participant 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "participant")
public class Participant {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  private int id;
  
  @ManyToOne
  @JoinColumn(name = "channel_id", referencedColumnName = "id")
  private Channel channel;

  @OneToOne
  @JoinColumn(name = "user_id", referencedColumnName = "id")
  private User user;

}

User 클래스

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Setter
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "user")
public class User {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", unique = true, nullable = false)
  private int id;

  @Column(name = "email")
  private String email;

  @Column(name = "password")
  private String password;

  @Column(name = "name")
  private String name;

  @Column(name = "phone")
  private String phone;

  @Enumerated(EnumType.STRING)
  @Column(name = "gender")
  private Gender gender;

  @Enumerated(EnumType.STRING)
  @Column(name = "role")
  private Role role;

  /**
   * 회원 성별을 구분하기 위한 내부 enum 클래스.
   */

  @Getter
  @AllArgsConstructor
  public enum Gender {

    MALE("남성"), FEMALE("여성");

    final String text;

  }

  /**
   * 접근 권한을 구분하기 위한 Enum 클래스.
   */

  @Getter
  public enum Role {

    ROLE_USER,
    ROLE_MANAGER,
    ROLE_ADMIN

  }

}

N+1 쿼리 발생 코드 - 비즈니스 로직

N+1 쿼리가 발생한 비즈니스 로직은 위 세가지 객체를 사용해 메세지 전송 로직이 동작하는 과정에서 발생했습니다. 메세지 전송 로직은 다음과 같습니다.

메세지 전송 로직

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
  // 메세지 전송 로직
  @Override
  @Transactional
  public void sendMessage(int channelId, int sendUserId, String content) {
      // 채널 객체 조회
      Channel channel = channelRepository.findById(channelId)
              .orElseThrow(() -> new RuntimeException("채팅방 정보가 존재하지 않습니다."));
      // 발신자 정보 조회
      User sendUser = userRepository.findById(sendUserId)
              .orElseThrow(() -> new RuntimeException("회원 정보가 존재하지 않습니다."));
      // 메세지 객체 생성
      Message message = Message.builder()
              .type(Type.MESSAGE)
              .content(content)
              .createAt(LocalDateTime.now())
              .channel(channel)
              .user(sendUser)
              .build();
      // 메세지 저장
      messageRepository.save(message);
      // 메세지를 받을 채널 참가자 리스트 조회
      List<Participant> participants = participantRepository.findAllByChannelId(channelId); // N+1 쿼리 발생
      // 참가자 별 메세지 전송 요청
      participants.forEach(participant -> {

          int receiveUserId = participant.getUser().getId();

          if (receiveUserId != sendUserId) {

              PushMessageCommand command = PushMessageCommand.builder()
                      .channelId(channelId)
                      .sendUserId(sendUserId)
                      .receiveUserId(receiveUserId)
                      .content(content)
                      .build();

              chatPushService.pushMessage(command);

          }

      });
  }
}

N개 만큼 발생한 쿼리문

N+1 쿼리 이미지 1

위처럼 ChannelId를 가지고 Participant 객체를 반복문을 통해 N개 만큼 호출하는 과정에서 일대일 연관관계를 가진 User 객체에 대한 SELECT문이 N개 만큼 호출되는 문제가 발생했고 저는 이러한 문제 또한 로직적인 부분에서 발생한 N+1 쿼리 문제의 일종으로 파악했고, 보편적인 N+1 쿼리 해결을 위한 해결방법은 물론 제가 마주한 문제까지 해결할 방법을 모색해야 했습니다.

# 해결방안


1. Lazy Loading(지연 로딩)

지연 로딩이란 연관객체에 대한 정보를 DB에서 조회해오는 시점을 해당 연관객체가 사용되는 시점으로 미루는 것을 의미합니다.

설정 방법은 어노테이션의 설정값을 통해 간단하게 설정이 가능합니다.

수정된 Participant 객체

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
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "participant")
public class Participant {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  private int id;

  @ManyToOne
  @JoinColumn(name = "channel_id", referencedColumnName = "id")
  private Channel channel;

  @OneToOne(fetch = FetchType.LAZY) // 지연 로딩 설정, EAGER로 설정하면 즉시 로딩이 된다.
  @JoinColumn(name = "user_id", referencedColumnName = "id")
  private User user;

  public static List<Participant> listOf() {

    return new ArrayList<>();

  }

}

위와 같이 User 객체에 대해 지연 로딩을 설정해주면 Participant 객체가 조회되는 시점이 아닌 User 객체가 사용되는 시점에 SELECT문이 실행되도록 변경할 수 있으며 이를 통해 위에서 N+1 쿼리가 발생하는 지점에서 불필요한 쿼리문 실행을 방지할 수 있습니다.

2. Fetch Join

하지만 지연 로딩을 통해 N+1 쿼리의 근본적인 원인을 해결할 수는 없습니다. 쿼리문의 실행 시점을 미룬 것이지 쿼리문 자체가 생성되지 않도록 한 방법은 아니기 때문입니다.

불필요한 SELECT문이 발생하지 않도록 하기 위해서 적용해 볼 수 방법은 Fetch Join 조건을 추가하는 것이였습니다.

Fetch Join은 일반 Join과는 다르게 from 뒤에 오는 대상 엔티티와 함께 Fetch Join으로 지정된 엔티티를 포함해 함께 Select 해오는 특징이 있습니다.

  • 일반 Join : join 조건을 제외하고 실제 질의하는 대상 Entity에 대한 컬럼만 SELECT
  • Fetch Join : 실제 질의하는 대상 Entity와 Fetch join이 걸려있는 Entity를 포함한 컬럼 함께 SELECT

Fetch Join을 이용한다면 Participant 객체들을 조회해오는 과정에서 연관관계를 가진 User 객체들도 함께 조회해올 수 있고, 그렇다면 User 객체의 정보를 호출하는 과정에서 추가적인 쿼리문이 생성되지 않을 수 있습니다.

수정된 Participant Repository

1
2
3
4
5
6
7
@Repository
public interface ParticipantRepository extends JpaRepository<Participant, Integer> {
    
  @Query("SELECT p FROM Participant p join fetch p.user WHERE p.channel.id = :channelId") // fetch join을 통해 user 객체 정보도 함께 조회
  List<Participant> findAllByChannelId(@Param(value = "channelId") int channelId);
  
}

위 코드처럼 JpaRepository를 implements 하고 있는 Repository 레이어의 인터페이스에 선언된 N+1쿼리를 발생시키는 메소드에 @Query 어노테이션을 추가해주고 직접 JPQL문을 문자열로 선언해줍니다. 이렇게 되면 하나의 join 쿼리문으로 연관객체 정보도 함께 조회해 오기 때문에 N개의 SELECT문이 발생하지 않을 수 있습니다.

3. @EntityGraph

하지만 직접 Fetch Join문을 JPQL에 작성해주는 것은 JPA의 기능을 활용한다기 보단 개발자에게 책임이 있다고 볼 수 있습니다. 또한 JPQL문에 직접 Fetch Join을 선언해주었기 때문에 이후 FetchType에 대한 조건을 LAZY로 걸어주고 싶은 경우, 이미 join된 JPQL문으로 인해 EAGER로 밖에 선언할 수 없다는 단점이 있습니다.

이러한 문제를 해결해볼 수 있는 방법이 @EntityGraph 어노테이션을 활용하는 것입니다.

수정된 ParticipantRepository

1
2
3
4
5
6
7
8
@Repository
public interface ParticipantRepository extends JpaRepository<Participant, Integer> {

  @EntityGraph(attributePaths = "user")
  @Query("SELECT p FROM Participant p WHERE p.channel.id = :channelId")
  List<Participant> findAllByChannelId(@Param(value = "channelId") int channelId);
  
}

위처럼 @EntityGraph에 연관 객체 정보를 작성하면 fetch join과 같은 동작을 하지만 JPA에서 제공해주는 FetchType에 대한 설정값도 함께 활용할 수 있고, 하드코딩으로 인한 위험도도 줄여볼 수 있습니다.

하지만 left outer join으로 생성되는 점, 설정값이 복잡해 적용하는데 필요한 사전 지식이 상대적으로 필요하다는 점에서 사용에 대한 불편함을 느껴 따로 활용하지는 않았습니다.

4. 지연 로딩과 fetch join 로직의 분리

하지만 추후에 기능이 다양해지면서 PK 값이 아닌 다른 컬럼에 대한 데이터 조회도 할 수 있으므로 fetch join을 통해 연관 객체의 다른 데이터도 함께 가져오는 로직을 별도로 구현하기로 했습니다.

수정된 ParticipantRepository

1
2
3
4
5
6
7
8
9
@Repository
public interface ParticipantRepository extends JpaRepository<Participant, Integer> {
  // fetch join 사용 시
  @Query("SELECT p FROM Participant p join fetch p.user WHERE p.channel.id = :channelId")
  List<Participant> findAllByChannelIdFetchJoin(@Param(value = "channelId") int channelId);
  
  // 지연 로딩 사용 시
  List<Participant> findAllByChannelId(@Param(value = "channelId") int channelId);
}

5. 외래키 직접 사용

마지막으로 고려한 내용은 결국 외래키만을 사용하는 로직에서 객체 간의 연관관계를 이용해 불필요한 쿼리문을 발생시킬 수 있는 구조로 구현해야 하는 것에 대한 의문에서 시작했습니다.

연관 관계의 PK를 객체를 호출함으로써 확인하는 것이 아니라 주 객체의 외래키 값을 직접 사용한다면 같은 문제를 해결할 수 있을 것이라 생각했습니다.

수정한 Participant 클래스

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
32
33
34
35
36
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "participant")
public class Participant {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  private int id;

  // 외래키 값을 그대로 받아오는 형태
  @Column(name = "channel_id", insertable = false, updatable = false)
  private int channelId;

  @Column(name = "user_id", insertable = false, updatable = false)
  private int userId;
  
  @ManyToOne
  @JoinColumn(name = "channel_id", referencedColumnName = "id")
  private Channel channel;

  @OneToOne(fetch = FetchType.LAZY) // 지연 로딩 설정, EAGER로 설정하면 즉시 로딩이 된다.
  @JoinColumn(name = "user_id", referencedColumnName = "id")
  private User user;

  public static List<Participant> listOf() {

    return new ArrayList<>();

  }

}

위처럼 Participant 객체와 매핑되는 테이블에서 외래키로 가지고 있는 user_id 값을 그대로 변수로 가져오도록 구성한 다음, insert, update에 대해선 대상에서 제외가 되도록 insertable, updatable 값을 false로 수정했습니다.

수정한 메세지 전송 로직

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
  // 메세지 전송 로직
  @Override
  @Transactional
  public void sendMessage(int channelId, int sendUserId, String content) {
      // 채널 객체 조회 -> Participant에 대한 지연 로딩
      Channel channel = channelRepository.findById(channelId)
              .orElseThrow(() -> new RuntimeException("채팅방 정보가 존재하지 않습니다."));
      // 발신자 정보 조회
      User sendUser = userRepository.findById(sendUserId)
              .orElseThrow(() -> new RuntimeException("회원 정보가 존재하지 않습니다."));
      // 메세지 객체 생성
      Message message = Message.builder()
              .type(Type.MESSAGE)
              .content(content)
              .createAt(LocalDateTime.now())
              .channel(channel)
              .user(sendUser)
              .build();
      // 메세지 저장
      messageRepository.save(message);
      // 메세지를 받을 채널 참가자 리스트 조회
      List<Participant> participants = participantRepository.findAllByChannelId(channelId);
      // 참가자 별 메세지 전송 요청
      participants.forEach(participant -> {

          int receiveUserId = participant().getUserId(); // 외래키 값을 그대로 호출, 연관객체에 대한 접근 X

          if (receiveUserId != sendUserId) {

              PushMessageCommand command = PushMessageCommand.builder()
                      .channelId(channelId)
                      .sendUserId(sendUserId)
                      .receiveUserId(receiveUserId)
                      .content(content)
                      .build();

              chatPushService.pushMessage(command);

          }

      });
  }
}

그 다음, participant 객체에서 필요한 User 객체의 PK 값을 User를 거치지 않고 그대로 필드값으로 가져와 사용함으로써 필요한 데이터를 가져오도록 수정할 수 있었습니다.

이러한 구조는 연관객체로 인해 발생할 수 있는 문제를 테이블 관점으로 변경함으로써 해결한 방법이라 생각하며 필요에 따라서만 연관관계 객체를 사용하도록 구성할 수 있는 장점이 있다고 생각합니다.

# 마치며


사실 제가 마주한 N+1 쿼리 문제는 지연 로딩을 통해서 만으로도 해결됩니다.

왜냐하면 연관 객체에 대한 PK가 첫 SELECT문에서 FK으로 포함되어 조회가 가능하다면, 지연 로딩이 동작할 때 그 FK를 가지고 실제 엔티티 객체를 생성해 PC(영속성 컨텍스트)에 저장해둘 수 있습니다. 그리고 프록시 객체를 생성해 우선적으로 연관 객체 변수에 할당하는 방법으로 지연 로딩은 동작합니다. 그래서 연관객체에 대한 PK값에 대해서만 조회가 발생한다면 해당 값은 PC에 저장된 엔티티 객체에서 조회가 가능하기 때문에 추가적인 SELECT문은 발생하지 않습니다.

또한 객체 간의 관계성을 생각했을 때 Participant 객체 리스트에 대한 데이터를 메소드를 통해 조회오기 보단 처음에 호출한 Channel 객체에서 연관객체로 존재하는 Participants 리스트 객체를 사용하는 것이 논리적인 측면에서 적절하다 판단했습니다. 이 과정에서도 User에 대한 지연 로딩 처리가 되어 있다면 추가적인 SELECT문을 발생시키지 않고 N+1쿼리를 해결할 수 있었습니다.

User에 대한 지연 로딩 처리가 되어 있지 않으면 left outer join으로 User 객체 정보 전부를 조회해 옵니다. (외래키 정보가 있어 left outer join이 가능한 것으로 예상..좀 더 찾아봐야 하는 부분)

하지만 이 부분도 결국 불필요한 데이터를 함께 조회해오기 때문에 최적화를 할 부분은 있다고 볼 수 있습니다.

수정된 메세지 전송 로직

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
  // 메세지 전송 로직
  @Override
  @Transactional
  public void sendMessage(int channelId, int sendUserId, String content) {
      // 채널 객체 조회 -> Participant에 대한 지연 로딩
      Channel channel = channelRepository.findById(channelId)
              .orElseThrow(() -> new RuntimeException("채팅방 정보가 존재하지 않습니다."));
      // 발신자 정보 조회
      User sendUser = userRepository.findById(sendUserId)
              .orElseThrow(() -> new RuntimeException("회원 정보가 존재하지 않습니다."));
      // 메세지 객체 생성
      Message message = Message.builder()
              .type(Type.MESSAGE)
              .content(content)
              .createAt(LocalDateTime.now())
              .channel(channel)
              .user(sendUser)
              .build();
      // 메세지 저장
      messageRepository.save(message);
      // 메세지를 받을 채널 참가자 리스트 조회
      // List<Participant> participants = participantRepository.findAllByChannelId(channelId); // Participants 리스트를 메소드로 조회, User에 대한 지연 로딩
      List<Participant> participants = channel.getParticipants(); // Participants 리스트를 Channel에서 조회, 이 또한 User에 대한 지연 로딩 처리
      // 참가자 별 메세지 전송 요청
      participants.forEach(participant -> {

          int receiveUserId = participant.getUser().getId(); // User의 PK에 대한 조회만 해 쿼리문 발생 X

          if (receiveUserId != sendUserId) {

              PushMessageCommand command = PushMessageCommand.builder()
                      .channelId(channelId)
                      .sendUserId(sendUserId)
                      .receiveUserId(receiveUserId)
                      .content(content)
                      .build();

              chatPushService.pushMessage(command);

          }

      });
  }
}

이 외에도 N+1 쿼리 해결은 했지만 대량의 연관객체가 생성되는 경우 OOM 에러가 발생할 수도 있어 Batch Size를 지정하는 방법도 생각했지만 당장에 필요한 부분은 아니라 추가하지는 않았습니다.

그리고 해당 문제는 보편적인 N+1 쿼리가 발생하는 상황은 아니였고 로직적인 부분에서 반복문을 통해 일대일 연관 객체에 대한 접근으로 인해 발생한 불필요 쿼리를 제거하는 방법에 대해 고민한 내용이라고 생각합니다. 그래서 fetch join 방법에 그치지 않고 지연 로딩을 통해 해결해볼 수 있었고 이를 발전시켜 외래키를 직접 사용하도록 구조의 변환에 대한 고려도 할 수 있었습니다.

하지만 이와 같은 과정울 통해 JPA가 Spring에 DB 연동과 관련된 여러 문제점과 반복 코드를 줄여주고 객체 지향 관점에서의 설계를 도와주는 장점이 있지만, 자동으로 쿼리문을 생성해주는 과정속에서 개발자가 직접 통제할 수 없거나 발견하기 힘들 문제가 발생할 수 있다는 것을 느꼈고, 이를 통제하고 방지하기 위해 여러 고민을 해보고 직접 해결해보는 과정을 경험해 볼 수 있었습니다.

# 참고자료


This post is licensed under CC BY 4.0 by the author.

[football] JPA를 활용한 객체 지향적인 설계를 위한 고민

[football] Redis Cluster와 Replication 구조를 통한 분산 저장이 가능한 설계 구현