728x90
반응형
발단
k6 부하 테스트 중 Zipkin 트레이스에서 이상한 스팬을 발견했다.
coupon-service: connection 295.079ms ← ??
omc: query 10.144ms
omc: result-set 4.771ms
coupon-service: coupon-redis-repository#is-already-issued 13.852ms
coupon-service: coupon-redis-repository#has-stock 5.658ms
coupon-service: coupon-redis-repository#decrement-stock 3.992ms
omc: query 2.871ms
omc: result-set 480μs
omc: query 5.308ms
omc: result-set 1.597ms
omc: query 2.278ms
omc: query 6.348ms
omc: result-set 163μs
coupon-service: coupon-redis-repository#mark-issued 6.103ms
omc: query 2.329ms
coupon-service: security filterchain after 603μs
connection 스팬의 295ms는 커넥션 획득에 걸린 시간이 아니다. 커넥션이 **열려있던 총 시간(유지 시간)**이다.
트레이스를 보면 connection 스팬이 DB 쿼리뿐 아니라 Redis 호출들까지 전부 감싸고 있다. 트랜잭션이 열린 시점부터 종료될 때까지 커넥션이 계속 점유되고 있었던 것.
원인 분석
구조적 문제: DB 커넥션과 Redis 작업이 트랜잭션 안에 혼재
@Transactional이 걸린 서비스 메서드 안에서 Redis 호출이 함께 실행되고 있었다.
// 현재 패턴 (문제)
@Transactional
public void issueCoupon(...) {
// ❶ 트랜잭션 시작 = DB 커넥션 풀에서 획득
redisRepo.isAlreadyIssued(); // Redis 호출 — 커넥션 점유 중
redisRepo.hasStock(); // Redis 호출 — 커넥션 점유 중
redisRepo.decrementStock(); // Redis 호출 — 커넥션 점유 중
userCouponRepo.save(coupon); // 실제 DB 작업
redisRepo.markIssued(); // Redis 호출 — 커넥션 점유 중
// ❷ 트랜잭션 종료 = 커넥션 풀에 반환
}
커넥션 실제 점유 시간 = Redis 작업 전체 + DB 쿼리 시간
실제 점유 = 13ms + 5ms + 4ms + 20ms(DB) + 6ms = ~48ms
반면 커넥션이 실제로 필요한 시간은 DB 쿼리 구간인 ~20ms 뿐이다.
핵심 개념: 점유시간 × 동시성
Little's Law: L = λ × W
항목 의미
| L | 시스템 내 동시 요청 수 (커넥션 풀 사이즈) |
| λ | 처리율 (RPS) |
| W | 점유 시간 (커넥션 홀드 타임) |
풀 사이즈(L)가 고정이면, W(점유시간)가 길어질수록 처리 가능한 RPS(λ)가 낮아진다.
풀 사이즈 = 10
현재 (Redis 혼재): 10 / 0.048s ≈ 208 RPS 한계
개선 후 (분리): 10 / 0.020s ≈ 500 RPS 한계
단순 계산이지만, Redis 작업이 트랜잭션 안에 있다는 것만으로 처리 한계가 절반 이하로 떨어진다.
부하가 커지면 커넥션 풀이 고갈되고:
- connectionTimeout 에러 발생
- 요청이 대기 큐에 쌓임
- 응답 시간 급등 → 에러율 급증
해결 방향: Redis 작업을 트랜잭션 밖으로 분리
// 개선 패턴
public void issueCoupon(...) {
// 트랜잭션 밖 — 커넥션 미점유
redisRepo.isAlreadyIssued();
redisRepo.hasStock();
redisRepo.decrementStock();
saveUserCoupon(coupon); // 트랜잭션 짧게 시작/종료
// 트랜잭션 밖 — 커넥션 미점유
redisRepo.markIssued();
}
@Transactional
private void saveUserCoupon(UserCoupon coupon) {
userCouponRepo.save(coupon); // DB 작업만 트랜잭션 안에
}
주의: mark-issued를 트랜잭션 밖으로 빼면 DB 저장 실패 시 Redis 상태와 불일치가 생길 수 있다. 이 부분은 보상 트랜잭션(compensation) 또는 멱등성 처리로 별도 대응이 필요하다.
배운 것
- DB 커넥션은 쿼리를 실행하는 시간이 아니라 트랜잭션이 열려 있는 시간 동안 점유된다.
- @Transactional 범위 안에 Redis 같은 외부 I/O가 섞이면, 불필요하게 커넥션 점유 시간이 늘어난다.
- 트레이스에서 connection 스팬이 길게 나온다면, 트랜잭션 범위 안에 DB 외의 작업이 섞인 것은 아닌지 확인할 것.
- 부하 테스트는 이런 구조적 문제를 단일 요청 테스트에서 보이지 않던 병목으로 드러내 준다.
반응형
'Study' 카테고리의 다른 글
| TIL - 로또 게임 쇼츠 자동화 파이프라인 구축기 (0) | 2026.08.13 |
|---|---|
| TIL - JVM Cold Start: 왜 첫 요청은 항상 느린가 (0) | 2026.08.07 |
| TIL - ExecutorService와 CountDownLatch로 동시성 테스트하기 (0) | 2026.07.29 |
| TIL - HikariCP 커넥션 풀과 Cold Start (0) | 2026.07.24 |
| TIL - MSA에서 HTTP vs Kafka, Spring Cloud Gateway (0) | 2026.07.23 |