들어가기 앞서 . . .
Tickle 서비스의 티켓 이벤트 응모 시스템은 수천 명의 동시 사용자가 참여할 수 있도록 설계되어야했다.
응모 과정에서 사용자 포인트 차감, 좌석 상태 변경, 목표 금액 달성 여부 판단 등 여러 임계영역 연산이 필요했고,
이를 Redis Stream을 활용해 처리하고 있었다.
하지만, 초기 부하 테스트 결과에서 1000 VU 이상 시 응답 지연이 급증하며 병목이 발생했고,
이 문제를 해결하기 위해 애플리케이션 레이어(Tomcat, 스레드풀, 커넥션 풀 등)부터 Redis 컨슈머 구조까지
전방위적인 튜닝 및 구조 개선 작업을 진행하게 되었다.
1차 테스트
1차 테스트 지표
1차적으로 부하테스트를 진행하였을 때 아래와같은 성능 지표가 도출되었다.
각 테스트의 성능 지표
VUser 100명일 때 1분간 요청 시 성능 지표

VUser 500명일 때 1분간 요청 시 성능 지표

VUser 1000명일 때 1분간 요청

1000 VU 테스트에서 처리량은 거의 1000 rps 달성했지만,
응답 지연(p95) 이 3초 이상으로 급격히 늘었으므로
서버/DB 스케일링, 커넥션 풀, 락 경합 등을 집중 점검할 필요가 있다고 생각하였다.

스프링/ JVM 모니터링 비교 결과
각각의 테스트케이스를 테스트하며 모니터링을 하며 병목지점을 찾아내고자했다.
1000 VU에서 p95가 3.29s로 급증했지만, DB는 한가하여 지연 대부분이 애플리케이션 레이어(웹서버/스레드풀/락/대기큐) 에서 발생했을 가능성이 크다고 생각했다.
PostgreSQL 모니터링 비교 결과
- Active sessions: 1–2 수준(둘 다). 동시 DB 세션이 거의 없음.
- Max connections=100 전혀 근접 X.
- CPU/메모리: 완만. CPU time 약간 증가(≈1.8s → ≈2.3s)지만 포화 아님.
- Cache hit rate: 99.93%대로 매우 좋음.
- Locks: 순간 스파이크(특히 1000명에서 accessShare/rowExclusive가 잠깐 치솟음) 있으나 지속적인 락 대기는 보이지 않음. Deadlock 0.
- Checkpoint/Bgwriter: 드문 스파이크만 보임(수 초). p95=3.29s를 설명할 만큼 장시간 지속 X.
- Fetch/Insert/Update 그래프: 짧은 시간에 계단식 증가 후 plateau → 워크로드가 DB에 지속적으로 몰리지 않음.
PostgreSQL 대시보드를 비교해봤을 때 DB가 병목은 아닌 것 같았다. (500명/1000명 모두 동일한 패턴)
스프링/ JVM 모니터링 비교 결과
- HikariCP: Max size=10, Active 0~6, Pending=0, Acquire/Usage time 몇 ms 미만 → 풀 병목은 아님
- GC/메모리: G1 minor GC만 짧게, STW 30ms 이하 스파이크 후 안정. Eden/Survivor/Old도 안정 → GC로 3s p95 설명 불가.
- 스레드:
- 500VU: 데몬 스레드 ≈280.
- 1000VU: 일시적으로 ~800까지 증가 후 급락 → 스레드 생성·수거는 있었으나 지속 과부하 지표는 아님.
- CPU/로드 (병목 지점): CPU 사용률 낮지만 1분 Load 평균이 코어 수를 초과하는 구간 존재 → 실행 대기/Runnable 큐에 작업이 쌓였을 가능성.
- Open files: 순간 3k까지 상승(소켓 포함). OS의 FD 한도/커넥션 큐가 병목일 수 있음.
- DB: Active sessions 1–2, Lock 지속 없음, Hit rate 좋음 → DB 여유.


스프링과 JVM 모니터링 비교 결과 병목이 Hikari나 GC, JVM 메모리에서 일어나는 것이 아닌,
요청이 서버 레이어(스레드/큐/소켓)에서 대기한 흔적이 컸다.
DB는 여유, Hikari도 여유가 있었으므로 Tomcat/네트워크 핸들링, OS FD 한도 쪽을 먼저 손보자.
성능개선 방안1. 웹 서버(톰캣) 큐 & 스레드 상향
성능 개선 방안으로 제일 먼저 웹 톰캣의 튜닝이 필요할 것 같다고 생각을 했따.
현재 Redis Stream을 써도 "클라이언트 -> 톰캣 -> Redis Stream" 흐름이므로, 동시 접속자가 늘어나면 토캣의 소켓 대기 큐, 스레드, 커넥션 한도가 먼저 병목이 된다.
부하 테스트 결과 CPU는 낮은데 Load Average가 코어 수를 초과한 걸 보면, 요청이 OS/Tomcat 큐에서 대기를 한것이 아닌가 라는 생각을 하게 되어서 웹 서버 톰캣의 큐와 스레드를 상향해주었다.
수정 전
server:
tomcat:
accept-count: 500
connection-timeout: 30s
max-connections: 5000
threads:
max: 100
min-spare: 50
수정 후
server:
tomcat:
accept-count: 2000
connection-timeout: 30s
max-connections: 10000
threads:
max: 800
min-spare: 50
개선 후 결과
톰켓의 설정을 변경해준 뒤, 부하 테스트를 진행하였다.

개선 후 비교

- 응답속도: 평균, p90, p95 모두 30~47배 이상 빨라짐 → 대기 시간 대폭 감소
- 최대 VU 사용량: 3,344 → 166으로 약 20배 절감 → 훨씬 적은 리소스로 동일 부하 처리
- 처리량(RPS): 요청/초는 거의 동일 유지 → 효율만 크게 향상
- 대기 시간.
- p95가 3.29초 → 0.11초로 떨어져 고부하 상황에서도 안정적인 응답 유지.
- 동시 사용자 효율
- 동일한 RPS(≈1000/s)를 처리하는데 필요한 실제 VU가 3,344 → 166으로 감소.
- Tomcat 튜닝 효과
- accept-count, max-connections, threads.max 확대로 큐잉 지연을 해소.
- 커넥션 풀/스레드가 충분히 대기하며 스케줄링 지연 없이 처리.
2차 테스트
2차 테스트 지표
2차적으로 부하테스트를 진행하였을 때 아래와같은 성능 지표가 도출되었다.
각 테스트의 성능 지표
VUser 100명일 때 1분간 요청 시 성능 지표

VUser 500명일 때 1분간 요청 시 성능 지표

VUser 1000명일 때 1분간 요청

VUser 2000명일 때 1분간 요청

1차 테스트를 거쳐 동시 요청 사용자(VUser)가 1000명일 때 까지, 목표 응답시간과 에러율 등을 만족할 수 있었다.
이후 VUser를 늘려 2000명의 요청을 보내보니 응답속도가 11초나 걸리는 것을 확인할 수 있었다.
스프링/ JVM 모니터링 비교 결과
각각의 테스트케이스를 테스트하며 모니터링을 하며 병목지점을 찾아내고자했다.
PostgreSQL 모니터링 비교 결과


CPU / Memory
- 평균 CPU 사용량이 1.3~2.4 s 수준으로 1000 VU 때보다 전체적으로 높게 유지됨.
- 메모리(Resident Mem)도 수백 KB 단위에서 안정적. → DB 자체가 메모리나 CPU로 병목이 걸린 흔적은 없음.
Transactions / Update
- Commit/Update 총량은 1000 VU 때보다 비례해 늘어났지만, 단위 시간 대비 급격한 스파이크는 없음.
- Insert / Delete / Return data 모두 정상적으로 상승 후 plateau.
Locks
- RowExclusiveLock, AccessShareLock이 순간적으로 치솟는 구간이 있지만 짧게 끝남.
- Deadlock/Conflict 는 0.
DB 레벨에서는 CPU·메모리 병목이나 Deadlock 같은 치명적인 장애는 관찰되지 않았다.
즉, DB가 직접적으로 응답 지연을 일으킨 주범은 아닐 가능성이 높다.
스프링/ JVM 모니터링 비교 결과
- HikariCP
- 1,000 VU: 풀 사이즈 10에서 active가 낮고 pending=0, acquire time도 매우 낮음 → 여유.
- 2,000 VU: 풀 사이즈 30으로 보이는데 pending이 급증(수백 단위 스파이크), acquire time이 최대 0.2~0.25s까지 치솟음 → 풀 고갈로 스레드가 DB 커넥션을 기다림.
- JVM/GC
- 둘 다 소폭의 minor GC와 짧은 STW(수~수십 ms)만 보임. 메모리/GC는 병목 아님.
- PostgreSQL
- Lock/FD/Cache hit 모두 정상 범주, CPU Time도 완만. DB 자체는 여유.
- k6 결과(2,000 VU)
- avg ~ 8s, p95 ~ 11.8s, dropped_iterations 49K → 서버가 요청을 수용하되 처리 큐에서 오래 기다림
성능 향상 2: POOL_SIZE를 1로 조정해보자
Redisson Stream 컨슈머의 POOL_SIZE는 동시에 Redis로부터 메시지를 읽어와 처리할 스레드 수이다.
이를 높이면 성능도 자연스레 증가할 것이라 기대했지만, 오히려 다음과 같은 문제가 발생했다
1. DB 커넥션 풀 고갈
16개의 컨슈머가 각기 메시지를 처리하면서 동시에 DB 트랜잭션을 요청 → HikariCP 풀을 초과
→ 커넥션 대기 지연 → 전체 응답시간 증가
2. Lock 경합 증가
applyCore() 내부 로직이 포인트 차감, 이벤트 금액 누적, 좌석 상태 변경 등 공유 자원에 접근
→ 병렬 컨슈머 간 Redis Lock/DB Lock 경합 발생
3. 컨텍스트 스위칭 비용
스레드가 많아질수록 자원을 공유하는 구조에서 스케줄링, 문맥 전환 비용이 증가
POOL_SIZE를 1로 조정해보자
문제를 해결하기 위해 우리는 다음 조치를 취했다.
- POOL_SIZE = 1로 줄여, 단일 스레드 직렬 처리로 변경
- 동일한 Redis Stream key에 대해 한 번에 하나의 메시지 처리
- 결과적으로 DB 커넥션 경쟁, 락 충돌, 스레드 전환 비용 모두 제거
개선 후 결과
톰켓의 설정을 변경해준 뒤, 부하 테스트를 진행하였다.

개선 후 비교

- 대량 동시 접속 시 커넥션 경쟁 병목 제거 → 평균 응답 시간 수백 배 향상
- 대기열 큐 쌓임 → 커넥션 풀 고갈 → Pending 증가 문제 해소
- 결과적으로 안정적 처리율 + 0% 실패율 + 리소스 효율성까지 확보
마무리하며
이번 성능 개선 작업을 통해 단순한 Redis 분산락 적용만으로는 대규모 트래픽을 견디기 어렵다는 것을 체감했다.
앞으로도 대규모 동시성 처리가 필요한 서비스에서는 단순한 락 적용을 넘어서,
전체 시스템의 흐름과 자원 경쟁 구간을 정량적으로 진단하고, 작게 직렬화하며,
병목을 줄이는 방식이 필요하다는 것을 이번 경험을 통해 실감했습니다.