군만두의 IT 공부 일지

[AuraTalk] JPA N+1 해결: 채팅 앱 쿼리 96% 줄이고 성능 76% 개선하기 본문

개발일지

[AuraTalk] JPA N+1 해결: 채팅 앱 쿼리 96% 줄이고 성능 76% 개선하기

mandus 2025. 7. 10. 18:19

⭐요약

  • 채팅 서비스 개발 중 메시지 조회 API에서 103개의 쿼리가 실행되는 N+1 문제를 발견했다.
  • QueryCountInterceptor를 구현하여 쿼리 수를 모니터링하고, JOIN FETCH를 활용하여 쿼리 수를 약 96% 감소시켰다.
  • 최종적으로 메시지 조회 성능을 200ms → 48ms (약 76% 개선), 채팅방 목록 조회를 66ms → 35ms (약 47% 개선) 달성했다.

⭐N+1 문제 발견

채팅 서비스를 개발하던 중, 성능이 예상보다 느린 것을 느꼈다. 채팅방 목록과 메시지를 조회할 때 다른 기능보다 응답 시간이 긴 것을 발견했다. 문제를 정확히 파악하기 위해 QueryCountInterceptor를 구현하여 API별 쿼리 수를 측정했다.

@Slf4j
@Component
public class QueryCountInterceptor implements HandlerInterceptor, StatementInspector {

    private static final ThreadLocal<QueryInfo> queryInfo = new ThreadLocal<>();

    @Override
    public String inspect(String sql) {
        QueryInfo info = queryInfo.get();
        if (info != null) {
            info.queryCount++;
            log.debug("Query #{}: {}", info.queryCount, sql);
        }
        return sql;
    }
}

이 인터셉터를 통해 다음 결과를 확인했다. 메시지 50개를 조회하는데 103개의 쿼리가 실행되는 것을 보아 N+1 문제가 확실한 것 같다.

  • 채팅방 목록 조회: 23개 쿼리
  • 메시지 목록 조회: 103개 쿼리

⭐문제 분석

1. N+1 문제 원인 분석

기존 코드를 분석해보니 다음과 같은 패턴으로 쿼리가 실행되고 있었다.

@Query("SELECT cr FROM ChatRoom cr WHERE cr.id IN :chatRoomIds")
List<ChatRoom> findChatRooms(@Param("chatRoomIds") List<Long> chatRoomIds);

// 사용 시 각 채팅방마다 추가 쿼리 발생
for (ChatRoom chatRoom : chatRooms) {
    List<User> users = chatRoomUserRepository.findUsersByChatRoomId(chatRoom.getId());
    // 각 사용자마다 프로필 이미지 조회 -> N+1 문제
}

2. 해결 방안 모색

1) @EntityGraph 활용 ❌

  • 장점: 코드가 간결하다.
  • 단점: 복잡한 중첩 관계에서 한계가 있다.
  • 결과: ChatRoom → ChatRoomUser → User → UserProfileImage 같은 관계에서는 효과적이지 않다.

2) EAGER Loading ❌

  • 장점: 구현이 간단하다.
  • 단점: 필요 없는 데이터까지 항상 로딩되어 오히려 성능 악화를 유발한다.
  • 결과: 전체적인 성능이 더 나빠졌다.

3) FETCH JOIN ✅

  • 장점: 필요한 데이터만 정확히 한 번에 조회 가능하다.
  • 단점: JPQL을 직접 작성해야 한다.
  • 결과: 가장 효과적이다.

⭐FETCH JOIN을 활용한 최적화

FETCH JOIN을 활용하여 Repository를 최적화했다.

1. 채팅방 목록 조회

@Query("SELECT DISTINCT cr FROM ChatRoom cr " +
       "LEFT JOIN FETCH cr.owner o " +
       "LEFT JOIN FETCH o.userProfileImage " +
       "JOIN ChatRoomUser cru ON cr.id = cru.chatRoom.id " +
       "WHERE cru.user.id = :userId " +
       "ORDER BY cr.lastMessageAt DESC")
List<ChatRoom> findActiveByUserIdWithOwner(@Param("userId") Long userId);

채팅방 정보와 함께 owner 정보, 그리고 owner의 프로필 이미지까지 한 번에 가져온다. 기존에 23개였던 쿼리가 3개로 감소했다.

2. 메시지 목록 조회

@Query("SELECT cm FROM ChatMessage cm " +
       "LEFT JOIN FETCH cm.sender s " +
       "LEFT JOIN FETCH s.userProfileImage " +
       "WHERE cm.chatRoom.id = :chatRoomId AND cm.isDeleted = false " +
       "ORDER BY cm.createdAt DESC")
Page<ChatMessage> findByChatRoomIdWithSender(@Param("chatRoomId") Long chatRoomId, 
                                           Pageable pageable);

메시지와 함께 발신자 정보, 프로필 이미지를 한 번에 조회하도록 변경했다. 103개였던 쿼리가 4개로 감소했다.

3. 서비스 레이어 리팩토링

private ChatRoomResponseDto buildChatRoomResponse(ChatRoom chatRoom, Long currentUserId) {
    ChatRoomResponseDto dto = ChatRoomResponseDto.from(chatRoom, currentUserId);

    // 채팅방 사용자 목록을 프로필 이미지와 함께 조회
    List<ChatRoomUser> roomUsers = chatRoomUserRepository
            .findAllByChatRoomIdWithUserAndProfile(chatRoom.getId());

    List<ChatUserResponseDto> users = roomUsers.stream()
            .map(roomUser -> {
                User user = roomUser.getUser();
                String thumbnailUrl = user.getUserProfileImage() != null ?
                        user.getUserProfileImage().getThumbnailImageUrl() : null;
                return ChatUserResponseDto.from(user, thumbnailUrl);
            })
            .toList();

    dto.setUsers(users);
    return dto;
}

⭐성능 테스트 결과

1) 채팅방 목록 조회

▲ 리팩토링 전
▲ 리팩토링 후

2) 메시지 목록 조회

▲ 리팩토링 전
▲ 리팩토링 후

1. 쿼리 수 감소

  • 채팅방 목록: 23개 → 3개 (약 87% 감소)
  • 메시지 목록: 103개 → 4개 (약 96% 감소)

2. 응답 시간 개선

  • 채팅방 목록: 66ms → 35ms (약 47% 개선)
  • 메시지 목록: 200ms → 48ms (약 76% 개선)
// 리팩토링 전 요약
2025-07-10 18:17:23 - ========================================
2025-07-10 18:17:23 - API 요청 완료: GET /api/chatrooms
2025-07-10 18:17:23 - 총 실행 시간: 66 ms
2025-07-10 18:17:23 - 실행된 쿼리 수: 23 개
2025-07-10 18:17:23 - ========================================

2025-07-10 18:24:13 - ========================================
2025-07-10 18:24:13 - API 요청 완료: GET /api/chats/1
2025-07-10 18:24:13 - 총 실행 시간: 200 ms
2025-07-10 18:24:13 - 실행된 쿼리 수: 103 개
2025-07-10 18:24:13 - ========================================

// 리팩토링 후 요약
2025-07-10 18:53:28 - ========================================
2025-07-10 18:53:28 - API 요청 완료: GET /api/chatrooms
2025-07-10 18:53:28 - 총 실행 시간: 35 ms
2025-07-10 18:53:28 - 실행된 쿼리 수: 3 개
2025-07-10 18:53:28 - ========================================

2025-07-10 18:55:52 - ========================================
2025-07-10 18:55:52 - API 요청 완료: GET /api/chats/1
2025-07-10 18:55:52 - 총 실행 시간: 48 ms
2025-07-10 18:55:52 - 실행된 쿼리 수: 4 개
2025-07-10 18:55:52 - ========================================

⭐후기

  • N+1 문제는 JPA를 사용하면서 반드시 마주치는 문제라는 것을 알고 있었다. 어떻게 성능을 고려하는 것이 좋은지 '주니어 백엔드 개발자가 반드시 알아야 할 실무 지식' 책을 통해 도움을 많이 얻었다.
  • QueryCountInterceptor 같은 모니터링 도구를 미리 구축함으로써 문제를 확인할 수 있었다. FETCH JOIN을 상황에 맞게 적절히 사용하는 방법이 연습이 필요할 것 같다.
  • 자주 조회화는 데이터(사용자 프로필, 채팅방 정보 등)에 대해 Redis 캐싱을 도입하는 것도 DB 부하를 줄이는 데 괜찮을 것 같다.

⭐참고자료

1) Vlad Mihalcea, "The best way to fix the Hibernate N+1 problem", 2022.11.20, https://vladmihalcea.com/n-plus-1-query-problem/

2) Spring Data JPA Reference Documentation, "Fetching", https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.entity-graph

Comments