일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- mysql
- 디자인챌린지
- 환급챌린지
- 국비지원
- 내일배움캠프
- 디자인교육
- 오픈패스
- 내일배움카드
- Java
- 백준
- UXUIPrimary
- Spring
- OPENPATH
- baekjoon
- Be
- 백엔드개발자
- 자바
- UXUI챌린지
- 국비지원취업
- 백엔드 부트캠프
- 오픈챌린지
- 디자인강의
- 오블완
- 티스토리챌린지
- 부트캠프
- UXUI기초정복
- 객체지향
- KDT
- 국비지원교육
- 패스트캠퍼스
- Today
- Total
군만두의 IT 공부 일지
[AuraTalk] JPA N+1 해결: 채팅 앱 쿼리 96% 줄이고 성능 76% 개선하기 본문
⭐요약
- 채팅 서비스 개발 중 메시지 조회 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