배경

현재 간단한 게임 친구 매칭 및 커뮤니티 앱을 운영하고 있다.
초기에는 사용자 수가 많지 않아서 EC2 t2.micro 단일 인스턴스와 RDS만으로도 큰 문제 없이 서비스를 운영할 수 있었다.
그런데 최근 들어 유저 수가 빠르게 늘어나면서 어느덧 100명을 넘기게 되었고, 지금은 약 120명 정도까지 증가한 상태다. 아직까지는 서비스가 크게 느려지거나 장애가 발생한 적은 없지만, 이 상태로 계속 유저가 늘어나면 언젠가는 문제가 생길 것 같다는 불안이 들기 시작했다.
이전에 부하 테스트를 간단히 해본 적은 있다. 특정 API를 하나 정해서 요청을 계속 보내보고, TPS가 어느 정도 나오는지 확인하는 식이었다. 하지만 지금 돌아보면 실제 사용자 흐름과는 거리가 있는 테스트였고, 단순히 수치만 보는 수준에 그쳤던 것 같다.
모니터링도 일단 붙여놓기는 했지만, 막상 어떤 지표를 봐야 하는지, 어떤 상황을 문제로 판단해야 하는지에 대해서는 잘 모르는 상태다. CPU나 메모리 그래프를 보긴 하는데, 그게 의미 있는 수준인지 아닌지 판단이 잘 되지 않는다.
그래서 이번 기회에 K6와 Grafana를 활용해서 부하 테스트를 조금 더 제대로 해보려고 한다. 단순히 API 하나를 때리는 게 아니라, 실제 사용자들이 사용하는 흐름을 기반으로 시나리오를 만들어보고, 그 과정에서 서버가 어떤 반응을 보이는지 확인해보고 싶다.
또한 테스트 결과를 보면서 어떤 지표를 중심으로 모니터링을 해야 하는지, 그리고 현재 구조에서 병목이 어디에서 발생하는지도 같이 파악해보는 것이 목표다. 이를 통해 이후 트래픽이 더 늘어나더라도 안정적으로 서비스할 수 있는 방향을 고민해보려고 한다.
환경
- VM: t3.micro (vCPU 2개, 메모리 1GB), Ubuntu 22.04 LTS
- 서버 1대: Spring Boot 3.25, Java 17
- VM DB 1대: db.t3.micro(vCPU 2개, 메모리 1GB), Mysql 8.4.7
- 모니터링: Grafana, Prometheus
- 부하 테스트: K6
테스트 도구로는 k6를 선택했다.
이전에 한 번 구축해본 경험이 있어서 비교적 빠르게 환경을 구성할 수 있었고, 코드 기반으로 테스트를 작성할 수 있다는 점도 큰 장점이었다. 특히 이 방식은 AI의 도움을 받아 테스트 시나리오를 작성하거나 수정하기에도 용이하다고 생각했다.

대시보드는 기본으로 제공해주는 Spring Boot 2.1 System Monitor를 사용했다.
tmi : 갑자기 든 생각인데 회사에서 사용하는 데이터독이 참 좋은 것 같다.. b(비싸서 내 서비스에선 사용 불가)
사용자 시나리오
부하 테스트를 진행하기 전에, 먼저 가상의 사용자가 어떤 흐름으로 서비스를 이용할지 정의하는 과정이 필요하다고 생각했습니다.
저희 서비스는 게임 친구를 찾고, 게시글을 통해 매칭을 요청하며, 매칭까지 이어지는 게임 듀오 매칭 서비스입니다.
그래서 실제 사용자가 서비스를 이용하는 흐름을 기준으로 다음과 같은 시나리오를 만들었습니다.
- 사용자가 로그인 이후 메인 화면에 들어와 추천 유저 목록을 확인한다.
- 사용자가 게시글 목록을 조회하고, 관심 있는 게시글의 상세 내용을 확인한다.
- 사용자가 게시글에 댓글이나 좋아요를 남긴다.
- 사용자가 마음에 드는 유저에게 매칭 요청을 보낸다.
- 상대 사용자가 매칭 요청을 수락하면 채팅방이 생성된다.
그리고 각 시나리오에 맞게 부하 테스트 스크립트를 작성했습니다.
부하 테스트 진행
사용자 시나리오에 맞는 스크립트를 작성한 뒤 부하 테스트를 진행했습니다.
테스트 결과의 신뢰성을 높이기 위해 본격적인 부하 테스트를 진행하기 전에 웜업 테스트를 먼저 수행.
첫 번째 시나리오는 사용자가 서비스를 처음 이용하거나, 로그인 후 주요 기능을 탐색하는 흐름으로 구성했다
- 사용자가 로그인한다.
- 메인 화면에서 추천 유저 목록을 조회한다. Select 쿼리 발생
- 게시글 목록을 조회한다. Select 쿼리 발생
- 게시글 상세 내용을 확인한다. Select 쿼리 발생
- 게시글에 댓글을 작성하거나 좋아요를 누른다. Save/Update 쿼리 발생
- 게시글 작성자 또는 추천 유저에게 매칭 요청을 보낸다. Save 쿼리 발생
- 상대 사용자가 받은 매칭 요청을 확인한다. Select 쿼리 발생
- 매칭 요청을 수락한다. Update 쿼리 및 채팅방 Save 쿼리 발생
이처럼 실제 사용자가 서비스를 이용하는 흐름과 유사하게 시나리오를 구성함으로써, 단순히 특정 API 하나에 부하를 주는 것이 아니라 서비스의 핵심 기능들이 연속적으로 호출되는 상황을 테스트할 수 있었습니다.
참고로 처음에는 부하 테스트를 단순하게 진행하기 위해 각 API의 인증을 임시로 해제하는 방식도 고려했다.
하지만 JWT 검증 과정이나 SecurityContext 생성 또한 실제 요청 처리 과정에서 발생하는 비용이라는 점을 생각하면, 이를 제외한 테스트는 실제 서비스 환경과 괴리가 있을 것 같았다.
그래서 이번 테스트에서는 인증 로직을 그대로 유지한 상태에서 진행하기로 했다. 대신 테스트용 계정을 여러 개 생성하고, 각 계정으로 로그인하여 발급받은 JWT를 사용하는 방식으로 구성했다. 이렇게 하면 실제 사용자 요청 흐름과 유사한 환경에서 보다 현실적인 부하 테스트를 진행할 수 있다고 판단했다
응답시간이 늦는 API들
우선 10명의 동시 사용자를 가정하고 테스트를 진행하고 점차 늘려갔습니다. 테스트를 진행하며 응답시간을 측정해보니 특정 API가 오래걸리는 것을 확인할 수 있었다.

매칭 요청 API의 응답 시간이 유독 높게 측정되어 원인을 확인해보니, 매칭 데이터 저장 자체보다 매칭 이후 수행되는 알림 전송 로직의 영향이 컸다. 알림 전송을 임시로 분리했을 때 응답 시간이 크게 개선되었고, 이를 통해 병목 지점이 매칭 생성 로직이 아닌 알림 로직임을 확인할 수 있었다.
이를 개선하기 위해 알림 전송 로직을 @Async를 활용해 비동기 처리로 분리했다. 사용자의 매칭 요청은 기존처럼 즉시 저장하되, 알림 전송은 별도의 스레드에서 후처리되도록 변경했다.

개선 후 다시 부하 테스트를 진행한 결과, POST /api/match API의 p95 응답 시간은 약 8초에서 약 325ms 수준으로 감소했다. 이를 통해 핵심 요청 처리와 부가적인 후처리 로직을 분리하는 것만으로도 API 응답 시간을 크게 개선할 수 있다는 점을 확인할 수 있었다.
그러나 아직 GET /api/match/requests가 p95가 1000ms 이상 나오고 있어 확인해보니 매칭 목록을 가져오는 과정에서 user profile을 N+1 문제로 가져오고 있었었다.

최종적으로 핵심 API는 500ms 아래로 늘렸다. 그리고 다시 사용자를 늘려가며 테스트를 진행했다.
처음보는 에러 발생
2026-05-09T10:55:01.710+09:00 ERROR 43304 --- [nio-8080-exec-3] o.s.t.s.TransactionSynchronizationUtils : TransactionSynchronization.afterCompletion threw exception
Caused by: java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@6b87081[Not completed, task = org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda$2338/0x0000009001dfbd88@184b5284] rejected from org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1@123bee68[Running, pool size = 8, active threads = 8, queued tasks = 100, completed tasks = 130]
at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2065) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:833) ~[na:na]
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1365) ~[na:na]
에러를 확인해보니 다음과 같은 정보가 있는걸 확인 할 수 있었다
pool size = 8
active threads = 8
queued tasks = 100
rejected
의미를 파악해보니 다음과 같았다.
- 비동기 스레드 최대 8개 사용 중
- 대기 큐 100개도 꽉 참
- 그래서 비동기 작업을 거절함
- 기본 정책이 AbortPolicy라서 RejectedExecutionException 발생
알림 로직을 @Async로 분리하면서 API 응답 시간은 크게 줄었지만, 부하 테스트 중 비동기 스레드풀의 큐가 가득 차 RejectedExecutionException이 발생했다. 이는 비동기 처리가 요청 흐름에서 분리되었을 뿐, 처리해야 할 작업량 자체가 사라진 것은 아니기 때문이었다..

코드에서 임의로 설정한 값에서 문제가 발생하고 있었다.
가장 좋은 방법은 큐를 도입하는 것이지만 오버엔지니어링이라고 판단하여 스레드풀 크기와 큐 용량을 명시적으로 조정하고, 큐가 가득 찼을 때의 처리 정책도 함께 설정할 필요가 있었다.
@Async는 단순히 메서드를 비동기로 실행하는 기능처럼 보이지만, 내부적으로는 별도의 스레드에서 작업을 처리한다. 이때 매 요청마다 새로운 스레드를 생성하는 것은 비용이 크기 때문에, 일반적으로는 미리 생성해둔 스레드들을 재사용하는 Thread Pool 기반으로 동작한다.
따라서 비동기 처리를 사용할 때는 단순히 @Async만 붙이는 것이 아니라, 스레드 개수와 큐 크기, 작업 거절 정책 등을 함께 고려해야 안정적으로 운영할 수 있다는 점을 확인할 수 있었다.
이 과정에서 스레드 개수와 큐 크기는 어떻게 설정해야할까 고민이 들었다.
흔하디 흔한
쓰레드 풀 크기 = CPU 코어 수 + 1
공식이 과연 맞을까 호기심이 들었다.
그래서 무시하고 일단 쓰레드 풀을 엄청 많이 설정하려고 한다.
설정하기 위한 세팅값이 Async에서 3가지 정도있는데 찾아보니
corePoolSize = 기본 직원 수
maxPoolSize = 알바 포함 최대 직원 수
queueCapacity = 대기 손님 줄
로 생각하면 될 것 같다.
maxPoolsize로 설정한다고 바로 사용되는 건 아니고 대기 손님 줄(queueCapacity)가 전부 찼을 경우에 maxPoolsize의 수만큼 최대로 스레드를 생성한다고 한다. (그만큼 스레드 생성 비용이 비싸다고 한다.)
하지만 나는 그냥 직원 수를 무식하게 늘리면 좋지 않을까란 생각이 들어서 기본 직원 수를 100명으로 두고 테스트를 진행해봤다. 통크게 알바도 100명 대기 손님은 1000명
executor.setCorePoolSize(100);
executor.setMaxPoolSize(200);
executor.setQueueCapacity(1000);
무슨일이 발생할까 테스트를 해보자

cpu가 순간 사용률 96퍼센트를 달성했었다.. ㄷㄷ
확실히 적절하게 선택해야될 것 같아 보수적으로
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(300);
로 잡아봤으나.. 상대는 t3.micro cpu 코어 2개
기본 사용량은 전체적으로 낮아졌으나 순간적으로 요청이 몰릴때는 똑같이 cpu 사용량 100퍼에 근접한 것을 볼 수 있었다.

그에 반해 RDS cpu 사용량은 제일 높은 cpu 사용량은 31퍼로 db에서는 병목이 없음을 확인하였다.

다시 한 번 보수적으로
executor.setCorePoolSize(2);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
로 해도 똑같이 100퍼에 근접했다..
그냥 maxPoolsize를 2로 해도 여전히 100%에 가까이 되었다

executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(500);
최종적으로 가장 작게 테스트를 진행해봤다.
흥미로웠던 점은 비동기 스레드 수를 늘린다고 해서 반드시 성능이 좋아지는 것은 아니라는 점이었다. 실제 테스트에서는 스레드풀을 2개로 설정했을 때보다, 오히려 단일 스레드(core=1, max=1)로 순차 처리했을 때 TPS가 더 높게 측정되었다.
현재 테스트 환경은 t3.micro 기반으로, 작은 burstable 인스턴스이다. 이 환경에서는 동시에 여러 비동기 작업을 처리할수록 CPU 사용량, 컨텍스트 스위칭, DB 접근 경쟁 등이 증가할 수 있다. 그 결과 단순히 병렬성을 높이는 것보다, 제한된 자원 안에서 작업을 안정적으로 순차 처리하는 방식이 더 효율적으로 동작한 것으로 보인다.
이를 통해 스레드 수를 무작정 늘리는 것보다, 현재 서버 자원과 작업 특성에 맞는 적절한 병렬성을 찾는 것이 중요하다는 점을 확인할 수 있었다.
또 다른 풀 에러 발생
이어서 사용자를 늘리며 테스트 도중 다음과 같은 에러를 확인할 수 있었다.
2026-05-09T12:54:41.771Z ERROR 5441 --- [io-8080-exec-42] o.h.engine.jdbc.spi.SqlExceptionHelper :
HikariPool-1 - Connection is not available, request timed out after 30000ms.
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
!!이번엔 스레드풀 문제가 아니라 DB 커넥션 풀 고갈 문제가 발생했다!!

40명 규모의 부하 테스트를 진행했을 때, HikariCP의 pending 수가 50 이상까지 증가하는 현상을 확인할 수 있었다. 당시 별도의 커넥션 풀 설정을 하지 않았기 때문에 기본값인 최대 10개의 커넥션을 사용하고 있었다.
하지만 테스트 대상 API들은 비즈니스 로직이 복잡하지 않았고, 무거운 쿼리를 수행하는 API도 아니었기 때문에 단순히 40명의 부하만으로 커넥션 풀이 고갈되는 현상이 쉽게 납득되지는 않았다.
우선 K6를 통해 응답 시간 지표를 확인해보았다. 테스트 초반에는 대부분의 API가 500ms 이하로 안정적으로 응답하고 있었지만, 시간이 지나면서 일부 요청의 응답 시간이 10초 이상까지 증가하는 현상이 발생했다.
처음에는 커넥션이 반환되지 않는 커넥션 누수(Connection Leak)를 의심했다. 하지만 실제 동작을 살펴보면 처음 몇 개의 요청만 처리되는 것이 아니라, 10개 이상의 요청이 지속적으로 정상 처리되고 있었다.
만약 실제로 커넥션 누수가 발생하고 있었다면, 최대 커넥션 개수인 10개가 모두 사용된 이후에는 반환되는 커넥션이 없어 대부분의 요청이 즉시 실패하는 형태가 나타났어야 한다. 그러나 테스트에서는 지속적으로 요청이 처리되고 있었기 때문에 단순한 커넥션 누수 가능성은 낮다고 판단했다.
그렇다면 혹시 하나의 API 요청이 내부적으로 여러 개의 커넥션을 동시에 사용하는 것은 아닐까 하는 의문이 들었다. 이를 확인하기 위해 DataSource#getConnection()을 감싸 커넥션 획득 시 로그를 출력하도록 코드를 작성했고, 다시 테스트를 진행해보았다. 동시에 단순히 서버 자체의 처리 한계에 도달한 것은 아닌지도 함께 확인해보려고 했다.

테스트 결과 대부분의 API는 요청당 하나의 커넥션만 사용하는 것을 확인할 수 있었다. 또한 이전 최적화를 통해 주요 API들의 응답 시간을 대부분 500ms 이하로 줄여둔 상태였기 때문에, 특정 API 하나가 지나치게 오랜 시간 커넥션을 점유하고 있는 상황도 아니었다.
결국 단순히 커넥션 개수 부족이나 커넥션 누수 문제가 아니라, 현재 구조 자체에서 커넥션 풀이 병목이 되는 근본적인 원인이 존재한다고 판단하게 되었다.
적절한 커넥션풀 설정
좀 더 근본적인 해결 방법은 결국 현재 서버 환경에 맞는 적절한 커넥션 풀 크기를 설정하는 것이었다.
이번 문제에서 커넥션 대기가 발생한 이유는, 모든 요청이 동시에 DB 커넥션을 필요로 했고 제한된 커넥션 자원을 서로 기다리는 상황이 발생했기 때문이다. 그렇다면 반대로 생각해보면, 커넥션에 어느 정도 여유가 있다면 요청들이 정상적으로 처리될 수 있고, 커넥션 대기로 인해 발생하는 병목이나 데드락 가능성 또한 줄어들 수 있다.
그렇다면 단순히 커넥션 풀 크기를 무조건 크게 늘리면 해결되는 걸까?
HikariCP 공식 Wiki에는 인상적인 문장이 하나 적혀 있다.
“We seem to have understood in other parts of computing recently that less is more.”
그리고 이어서 다음과 같은 질문을 던진다.
“왜 nginx는 단 4개의 스레드만으로도, 100개의 프로세스를 사용하는 Apache보다 더 높은 성능을 보여줄 수 있을까?”
결국 핵심은 단순히 리소스를 많이 사용하는 것이 성능 향상으로 이어지지는 않는다는 점이다.
커넥션 풀에 커넥션이 많다는 것은, 그만큼 DB와 연결된 자원을 계속 점유하고 있다는 의미이기도 하다. 커넥션 수가 과도하게 많아지면 DB 입장에서도 관리해야 하는 세션과 리소스가 증가하게 되고, 오히려 전체 성능이 저하될 수 있다. 또한 커넥션 수가 많아질수록 동시에 처리되는 작업 수도 증가하기 때문에 컨텍스트 스위칭 비용이 커지고 CPU 효율 역시 떨어질 수 있다.
그렇다면 적절한 커넥션 풀 크기는 어느 정도일까?
HikariCP 공식 문서에서는 다음과 같은 공식을 제시하고 있다.
connections = ((core_count * 2) + effective_spindle_count)
해당 공식은 PostgreSQL 환경에서 권장하는 계산 방식이지만, 공식 문서에서도 다양한 DB 환경에서 참고할 수 있다고 설명하고 있었기 때문에 MySQL 환경에서도 하나의 기준으로 활용해볼 수 있다고 판단했다.
처음에는 AWS t3.micro 환경이 2 vCPU로 표시되고 있었기 때문에, 단순히 계산상 (2 * 2) + 1 = 5 정도의 커넥션 풀이 적절하다고 생각했다. 그래서 실제로 minimum-idle과 maximum-pool-size를 5 수준으로 맞춰 다시 테스트를 진행해보았다.
그런데 예상과는 다르게, 동일하게 다음과 같은 예외가 계속 발생하고 있었다.
HikariPool - Connection is not available
Could not open JPA EntityManager for transaction
처음에는 단순히 커넥션 수가 아직 부족한 것인가 싶었지만, 테스트를 반복할수록 조금 이상하다는 느낌이 들었다. 단순히 커넥션 수가 부족했다면 커넥션 개수를 늘릴수록 점진적으로 안정화되어야 하는데, 실제로는 특정 순간부터 여전히 커넥션 대기가 급격하게 증가하고 있었다.
이후 서버 정보를 다시 자세히 확인해보니, 중요한 사실 하나를 발견할 수 있었다.

cpu cores : 1
siblings : 2
즉 AWS에서 표시하는 2 vCPU는 실제 물리 코어 2개가 아니라, 하나의 물리 코어에서 하이퍼스레딩으로 분리된 논리 CPU에 가까운 구조였던 것이다.
결국 애플리케이션 입장에서는 “2개의 독립적인 CPU”처럼 동작하는 것이 아니라, 사실상 하나의 물리 코어 자원을 서로 나눠 사용하는 형태에 가까웠다.
현재 사용 중인 t3.micro 인스턴스의 실제 물리 CPU 코어 수는 1개였고(cpu cores = 1), effective_spindle_count를 1이라고 가정하면 다음과 같은 값이 나온다.
(1 * 2) + 1 = 3
즉 현재 서버 환경에서는 약 3개 정도의 커넥션 풀이 적절할 수 있다는 결론을 얻을 수 있었다.
새롭게 커넥션 수 설정하기
적절한 커넥션 수에 대한 기준을 어느 정도 찾았으니, 이제 다시 부하 테스트를 진행해보기로 했다.
추가로 idle-timeout 값도 30초(30,000ms)로 설정했다.
idle-timeout은 사용이 끝난 유휴 커넥션을 얼마 동안 유지할지 결정하는 옵션인데, 기본값은 10분으로 설정되어 있다.
하지만 현재와 같은 작은 서버 환경에서 사용하지 않는 커넥션을 10분 동안 유지하는 것은 다소 비효율적이라고 느껴졌다. 오랜 시간 유휴 커넥션을 유지하기보다는, 필요하지 않은 커넥션은 빠르게 정리해 리소스를 확보하는 편이 더 적절하다고 판단했다. 따라서 기본값이던 10분 대신 30초로 줄여 불필요한 리소스 점유를 최소화했다.
부하 테스트 다시 진행
이후 동일하게 40명의 동시 사용자를 기준으로 다시 부하 테스트를 진행해보았다.
그 결과, 이전과 달리 더 이상 커넥션 부족으로 인한 에러가 발생하지 않는 것을 확인할 수 있었다.
또한 평균 TPS 역시 기존 약 32 TPS 수준에서 36.4 TPS 수준으로 증가하며 약 14% 정도의 성능 개선 효과도 확인할 수 있었다.
즉 단순히 커넥션 수를 크게 늘리는 것이 아니라, 현재 서버 자원에 맞게 적절한 크기의 커넥션 풀을 유지하고 불필요한 유휴 커넥션을 빠르게 정리하는 방향이 오히려 더 안정적이고 효율적인 결과로 이어졌던 것이다!!


결과적으로 이번 테스트를 통해, 단순히 애플리케이션 로직만 보는 것이 아니라 APM과 모니터링 지표를 함께 확인하면서 병목 지점을 분석하는 과정이 얼마나 중요한지 체감할 수 있었다.
'Project' 카테고리의 다른 글
| [Project] 분산환경에서 캐시는 어떻게 적용해야할까? (0) | 2025.04.23 |
|---|---|
| [Project] 인스턴스 서버에 너무 많은 부하가 쏠리다면? (0) | 2025.04.17 |
| [Project] 적절한 풀 사이즈 설정하기 (1) | 2025.03.26 |
| [Project] Spring 직렬화, 역직렬화 문제 - getWriter() has already been called for this response (1) | 2025.02.28 |
| [Project] 게시물 N+1 문제 해결기 (쿼리 40 -> 11 -> 1) (0) | 2025.01.31 |