[성능 최적화] K6로 발견한 N+1 문제, 84개 쿼리를 6개로 줄여 응답속도 93% 개선기 - 2편
서론
이번 2편에서는 지난 [1편 링크] 에서 K6 부하 테스트를 통해 채팅 메시지 조회 API에서 심각한 N+1 문제를 해결하기 위한 구체적인 코드 개선 과정과, 그로 인해 얼마나 성능이 향상되었는지를 데이터로 확인해 보겠습니다.
문제의 원인 : 반복 조회로 인한 N+1 쿼리
부하 테스트를 통해 확인한 병목의 근본 원인은, 쿼리가 N+1 로 실행되는 문제였습니다.
아래 코드 첫 번째 스크린샷에서 보시는 것처럼, 조회된 21개의 채팅 메시지 목록(List<ChatEntity>)을 스트림으로 처리하며 각 메시지에 대한 추가 정보를 개별적으로 조회하고 있었습니다.
즉, 하나의 메시지(ChatEntity)를 처리할 때마다 아래 4개의 추가 쿼리가 실행되었습니다.
- LOGIC 반응의 총 개수를 세는 쿼리 (1회)
- ATTITUDE 반응의 총 개수를 세는 쿼리 (1회)
- 현재 사용자가 LOGIC 반응을 했는지 확인하는 쿼리 (1회)
- 현재 사용자가 ATTITUDE 반응을 했는지 확인하는 쿼리 (1회)
채팅조회API 1회 호출에 21(메세지 수) * 4(추가정보) = 총 84개의 쿼리가 실행 되어 스트레스 테스트에서 병목현상이 발생한 주 원인중 하나 였습니다.
조회된 21개의 채팅 메시지 목록(List<ChatEntity>)을 스트림 코드

하나의 메시지(ChatEntity)를 처리할 때마다 아래 4개의 추가 쿼리가 실행되는 코드

1번,2번의 쿼리

3번,4번의 쿼리 (JPA)

해결과정
애플레케이션 로직 N+1 문제를 배치쿼리로 해결하기
많은 분들이 N+1 문제를 검색하면, 대부분 JPA 연관관계 매핑된 엔티티의 Lazy Loading 문제와 그 해결책인 JPQL에서 제공하는 join fetch에 대한 내용을 찾을 수 있었습니다.
하지만 저의 N+1 문제는 조금 달랐습니다. 엔티티 관계가 아닌, 서비스 로직의 반복문 안에서 직접 Repository를 여러 번 호출하여 발생하는 '애플리케이션 로직 기반 N+1' 문제였습니다.
(기존 방식)
메시지 1개 조회 → (DB 접근) 반응 수 요청 → DB 응답 → (DB 접근) 사용자 반응 여부 요청 → DB 응답 → 다음 메시지... (반복)
문제의 핵심은 1개의 메시지를 처리할 때마다 추가로 4번의 DB 접근이 발생한다는 점이었습니다.
해결 전략
저는 21개의 채팅메시지에서 필요한 추가 정보(전체 반응 수, 사용자 반응 여부)를 애플리케이션 단에서 미리 조회하여 자료구조에 저장하고, 이후 모든 로직을 메모리에서 처리할 수 있도록 했습니다.
그리고 아래에 테스트 결과에서 알 수 있겠지만, 위 해결 전략으로 다음 같은 효과를 얻을 수 있었습니다.
- DB 접근 횟수 최소화 → 커넥션 풀 부담 감소
- 네트워크 비용 절약 → DB 왕복 횟수 감소(커넥션 비용 감소)
- 메모리 기반 처리 → O(1) 조회로 성능 향상 (Set,Map 활용)
구현과정 : 배치쿼리 구현
1. DTO 설계
먼저 GROUP BY 로 구룹핑된 쿼리 결과를 받아오기 위해서 DTO를 생성해 주었습니다.
(편한것도 편한건데 Object[]로 받는 것보다 명시적인 타입을 사용하여 컴파일 타임에 오류를 잡을 수 있는 장점이 있습니다..!)

2. 배치 조회를 위한 chatId 추출
조회된 21개의 메시지에서 chatId만 추출하여 List<Long> chatIds로 저장합니다.
(이 chatIds가 배치 쿼리의 IN절에 들어갈 파라미터가 됩니다.)

3. 반응 수 배치 조회 구현
기존에는 메시지마다 개별 조회했지만, 이제는 IN절을 활용해 한 번에 모든 메시지의 반응 수를 조회합니다.

3-1. List를 Map으로 변환 (반응 수 배치 조회)
조회 결과를 Map<Long, Map<ReactionType, Long>> 구조로 변환하여 O(1) 성능으로 조회할 수 있도록 했습니다.
computeIfAbsent를 사용해 null-safe하게 처리했습니다.

3-2. JPQL 쿼리 작성 (반응 수 배치 조회)
SELECT 문 옆에 new 키워드로 JPQL 생성자 표현식을 사용하여 이전에 만들었던 DTO 를 사용할 수 있도록 하고, 파라미터로 받은 chatIds를 IN절에 넣어 21개 메시지를 한 번에 조회하고, GROUP BY로 채팅 ID와 반응 타입별로 집계합니다.
이 한 줄의 쿼리로 기존 메시지별로 반응을 조회하여 count한 쿼리를 완전히 대체했습니다.

4. 사용자 반응 여부 조회 로직

4-1. List를 Map으로 변환 (사용자 반응 여부 조회)
사용자 반응 여부 조회도 Map<Long, Set<ReactionType>> 구조로 저장해서 DB를 조회하지않고 Map 에서 조회할 수 있도록 했습니다.

4-2. JPQL 쿼리 작성 (사용자 반응 여부 조회)
WHERE절에 IN과 AND 조건을 함께 사용하여 특정 사용자의 반응만 필터링합니다.

5. 최종응답 - 메모리에서 처리
새로운 fromOptimized() 메서드: 이미 조회된 Map 데이터를 파라미터로 받아 메모리에서 처리

새로운 fromOptimized 메서드 구현
- Map에서 O(1) 조회
reactionCountsMap.getOrDefault()로 반응 수를 즉시 가져옵니다- 없으면 빈 Map을 반환하여 NullPointerException 방지
- Set의 contains() 활용
userReactions.contains()로 사용자 반응 여부를 O(1)로 확인- 기존 Optional 조회 대신 단순 boolean 체크로 변경
- null-safe 처리
getOrDefault(),emptyMap(),emptySet()사용으로 안전한 처리- 데이터가 없어도 정상 동작 보장
결과적으로 메시지 1개당 4번의 DB 접근이 완전히 사라지고, 모든 처리가 메모리에서 이루지도록 개선했습니다.
개선 후 K6 부하 테스트







결론
로직 기반 N+1 제거로 쿼리 84→6(−93%), 안정성(실패율 0%) 확보라는 1차 목표는 달성했습니다..!
아쉽게도 p(95)<500' p(95)=2.6초로 평균 500ms 응답시간은 달성하지 못했습니다...
이는 API 자체에 기능적 병목보다는 Springboot 에 설정된 최대 커넥션풀 개수를 5개로 설정된게 가장큰 원인
최종 후기
JPA의 Lazy N+1이 아니더라도, 서비스 로직에서 발생하는 N+1은 기능과 안정성에 큰 악영향을 준다. API 응답을 위해 루프마다 DB를 반복 조회하는 패턴은 지양하고, 필요한 데이터를 배치로 한 번에 조회해 Map/Set으로 메모리에서 조립하는 방식이 훨씬 효율적이다. 이번 개선으로 쿼리 84→6(−93%), 실패율 44%→0%를 확인 했습니다.
다음으로는 커넥션 풀을 늘려서 부하테스트를 진행해서 응답률이 개선되는지 확인할려고 합니다.
'프로젝트 > 토론철' 카테고리의 다른 글
| [성능 최적화] K6로 발견한 N+1 문제, 84개 쿼리를 6개로 줄여 응답속도 93% 개선기 - 1편 (3) | 2025.08.01 |
|---|