본문 바로가기
프로젝트/토론철

[성능 최적화] K6로 발견한 N+1 문제, 84개 쿼리를 6개로 줄여 응답속도 93% 개선기 - 1편

by natty_dev 2025. 8. 1.

[성능 최적화] K6로 발견한 N+1 문제, 84개 쿼리를 6개로 줄여 응답속도 93% 개선기 - 1편

서론

사이드 프로젝트 ‘토론철’에서 채팅 기능을 개발하며, 단순히 기능을 구현 하는데 그치지 않고 확장 가능한 성능까지 확장하고 개선하고 싶었습니다.
"내 채팅 API가 실제로 얼마나 많은 사용자를 감당할 수 있을까?"
“실제로 많은 사용자가 몰리면 어떤 문제가 발생할까?”
라는 궁금증이 생겼습니다.


이러한 문제의식을 설정하고, 실제 사용자 패턴을 가정해서 K6 부하 테스트로 API별 커스텀 메트릭을 수집했고, 특히 채팅 메시지 조회 API의 응답 시간을 집중적으로 추적했습니다.
그 결과, p95 기준 4.6초에서 최대 37초까지 치솟는 심각한 병목 현상을 발견하고, API 1회 호출에 84개의 SQL이 실행되는 N+1 쿼리를 발견했습니다.


제가 담당한 채팅 메시지API 기능을 집중해서 테스트 하여 병목 지점을 찾을 필요가 있는거같아 채팅 메시지 조회 API 테스트를 진행했습니다.


이 글은 단순 기능 구현을 넘어서 ‘구현한 기능을 성능 관점에서 문제를 발견하고 → 데이터로 검증 → 최적화 및 개선’ 하는 과정을 [성능 최적화]라는 시리즈를 통해서 기록하려고 합니다.




글 내용 요약

AWS ECS 환경에서 K6로 부하 테스트·병목 분석·최적화 까지

  • 원인분석 : 채팅 API p95 응답 37 s, 실패율 최대 65 %
  • 개선 : K6 부하 테스트 4회 + Hibernate 통계로 N+1 식별 (채팅메시지조회 API 1회 호출 -> 84번의 쿼리실행)
  • 검증 및 최적화 : 1번의 API 호출로 84번의 쿼리가 실행되던 API에 배치 쿼리 적용 후 n번의 쿼리실행으로 개선, 응답률은 (p95) 500 ms 이하로 개선 , 실패율 0 % 달성 (다음 편에서 작성예정)


테스트 인프라

  • dev 서버(부하 대상서버)는 운영서버와 동일한 EC2인스턴스를 사용하였습니다.


K6 테스트 시나리오

테스트 시나리오 : 로그인 (Access 토큰 추출) -> 채팅방 메시지 조회
임계치 : 채팅 조회 95% 500ms 미만 응답률 , 실패율 1% 미만
사전작업: 테스트유저 1000명 계정 데이터 생성, 채팅 메시지 10만건 이상 데이터 삽입

import { check, sleep } from 'k6';
import http from 'k6/http';
import { Trend } from 'k6/metrics';

//각 API 별 커스텀 메트릭  - 이렇게 하면 각 API 별로 처리 시간을 추적할 수 있음
export const loginTrend = new Trend('login_duration');
export const chatMessagesTrend = new Trend('chat_messages_duration');

export const options = {
stages: [
    { duration: '3m', target: 100 },   // 워밍업
    { duration: '5m', target: 200 },  // 부하 테스트
    { duration: '3m', target: 0 },    // 종료
  ],
  thresholds: {
    'chat_messages_duration': ['p(95)<500'],  // 채팅 조회 95% < 500ms
    http_req_failed: ['rate<0.01'],      // 실패율 1% 미만
  },
};

export default function () {
  const baseUrl = //baseUrl은 비공개 하겠습니다.


  const payload = JSON.stringify({
    identifier: `test_user_${__VU}_${__ITER}`, // 고유한 식별자로 변경
    socialType: 'kakao',
  });

  const params = {
    headers: {
      'Content-Type': 'application/json',
      accept: '*/*',
    },
  };
  /* 1. 로그인 */
  const loginRes = http.post(`${baseUrl}/api/v1/users/login`, payload, params);
  loginTrend.add(loginRes.timings.duration);

  check(loginRes, {
    'login status 200': (r) => r.status === 200,
    'accessToken exists': (r) => r.json('data.accessToken') !== undefined,
  });

  // accessToken 추출
  const accessToken = loginRes.json('data.accessToken');
  const authHeaders = {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  };

  /* 채팅방 메시지 조회*/
  const chatRes = http.get(`${baseUrl}/api/v1/chat/rooms/1/messages?cursor=${Math.floor(Math.random() * 1000)}`, authHeaders);
  check(chatRes, { 'chat status 200': (r) => r.status === 200 });
  chatMessagesTrend.add(chatRes.timings.duration);
  sleep(1);
}



총 4번의 테스트 결과 정리 ( “비정상 or 과부화” 패턴 )






총 4번의 테스트 결과 대시보드

1차

2차

3차

4차






테스트 결과가 매번 달랐던 이유는?

부하 테스트를 반복할수록, “왜 같은 조건인데 결과가 이렇게 다르지?”
라는 의문이 들었습니다.

처음엔 2회만 테스트할 생각이었지만,
이상하게도 어떤 때는 실패율이 60%를 넘고,
어떤 때는 모든 요청이 성공하면서도 응답 시간이 6초 이상 걸렸습니다.

그리고 1차/3차 테스트에 내부적으로 INTERNAL_ERROR를 발생시키며 클라이언트와의 연결을 강제로 종료한 로그를 확인하였습니다.

이 결과를 바탕으로 다음과 같이 판단했습니다,

  • 1차/3차는 이미 한 번 부하를 준 직후라 서버 리소스(커넥션 풀, GC 등)가 불안정한 상태에서 테스트가 진행되어 많은 요청이 실패했습니다.
  • 2차/4차는 서버를 재시작한 뒤 첫 테스트라, 실패는 없었지만 응답 시간이 비정상적으로 길었습니다.

즉,

서버의 “컨디션”과 “다른 병목현상”과 맞물려,
때로는 붕괴(실패율↑), 때로는 마비(응답시간↑) 현상이 반복된 것

임을 데이터로 확인할 수 있었습니다.




병목지점을 파악해 보자

1. 커넥션풀 최대 수 설정 확인

다른 병목사항도 있겠지만, 제일 먼저 생각난 병목지점의 원인은 커넥션풀이였습니다.
커넥션풀이 5개로 설정되어있는게 테스트에 많은 영향을 주었다고 생각하여 개선작업을 할때
제일 먼저 커넥션 최대 수를 10개로 수정하려고 합니다.

2. Hibernate 통계 생성 활성화

채팅메시지조회API 쿼리에서 병목현상이 있는지 확인하기 위해서,
application.ymal 설정에서 Hibernate 통계 생성 활성화를 하고 로깅수준을 DEBUG 로 하여 병목현상을 식별할수 있도록 하였습니다.

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true  # 실행되는 SQL을 보기 쉽게 포맷팅
        generate_statistics: true # 성능 측정을 위해 통계 정보 활성화
logging:
  level:
    org.hibernate.SQL: DEBUG # SQL과 파라미터를 로그로 확인
    org.hibernate.orm.jdbc.bind: TRACE
    org.hibernate.stat: DEBUG # 통계 정보 확인

기본적으로 채팅메시지API는 20개 + 1개(다음 채팅 확인여부) 총 21개의 채팅 메시지를 조회합니다.(저희 서비스에서 채팅방 입장시 채팅 메시지 조회를 20건 조회하는 것을 정책으로 했습니다.)

Hibernate 통계 생성 활성화를 하고 로깅수준을 DEBUG로 설정하고 채팅 메시지 조회 API를 한번 호출해 보았습니다.

그리고 로그를 확인해보니 총 84개의 쿼리가 실행되고 있었습니다.

다시말하면, 21개의 메시지를 한번 조회해는데 84개의 쿼리가 실행되었습니다...
즉, 84/21 =4 1개의 메시지를 조회 하는데 4개의 쿼리가 실행 되었다는것을 아래 로그를 통해 확인 할 수 있었습니다.

(알고도 당하는 N+1 이슈…)

한번의 메시지조회API를 호출하는데 84번의 쿼리가 실행된다는건..
100명이 호출하면 8400번 실행.. 500명이 호출하면 42000개의 쿼리가 실행..
통계 로그를 확인하고, 부하테스트 결과에 영향을 매우 많이 주었다 판단하고 개선의 필요을 느껴 문제의 원인과 개선방안을 찾아 보았습니다.

N+1 문제 원인 정리

왜 1개의 메시지를 조회하는데 4개의 쿼리가 실행되는지 원인과 이유를 파악해서 정리 했습니다.

원인: 전형적인 N+1 문제

이유: 각 채팅 메시지마다

  • 반응(LOGIC) 수 조회: 1회
  • 반응(ATTITUDE) 수 조회: 1회
  • 사용자 반응 여부 확인: 2회

채팅메시지 한 건 조회 할때마다 해당 채팅의 논리,태도 반응수를 각각 1회씩 총 2회 조회 + 사용자 반응여부 확인 2회조회 까지해서
1건의 메시지조회에 총 4개의 쿼리가 실행 되었던거였습니다.




N+1 쿼리 개선 방안

  • 배치 쿼리(Batch Query)로 쿼리 수 대폭 감소

기존에는 채팅 메시지 1건마다 4개의 쿼리(반응 수, 사용자 반응 여부 등)가 개별적으로 실행되어,
메시지 20개 조회 시 총 84개의 쿼리가 발생하는 전형적인 N+1 문제가 있었습니다.

이를 해결하기 위해,
여러 메시지의 반응 정보를 한 번에 조회하는 배치 쿼리를 도입했습니다.

  • chat_id IN (...) 조건으로 여러 메시지의 반응 수를 한 번에 집계하고
  • 결과를 Map 등으로 변환해 O(1)로 조회하도록 개선했습니다.

개선 후 재테스트 결과와 구체적인 쿼리/코드 구현은
다음 포스트에서 자세히 공유하겠습니다.




부하 테스트 후기

지금까지 k6로 부하 테스트를 해보고 병목지점을 파악하고 개선방안까지 도출해보는 과정을 정리해 보았습니다.

제가 부하 테스트를 해보고 느낀점은 다음과 같았습니다.

  1. 측정하지 않으면 개선할 수 없다..
  • 감이 아닌 데이터로 성능 측정의 중요성..
  1. N+1 문제는 트래픽이 많아 질수록 문제가 빠르게 들어난다.
  • 많은 분들이 N+1 문제를 알고있고 저도 알고있었지만 저도 모르게 작성한 N+1 코드를 부하 테스트를 통해서 알게 되었습니다. 부하테스트는 단순히 성능을 측정하는 것을 넘어 나도 모르게 작성하여 병목 현상이 생기는 부분을 알게 해주는것 같아 서비스 출시전 필수로 해야하는 과정이라고 느꼈습니다. 사이드 프로젝트 성능 최적화를 진행하면서 단순히 빠르게 기능을 만들기가 아니라 안정적이고 확장 가능한 서비스로 개선해 나가는 과정을 보여드리겠습니다.