본문 바로가기

Study

TIL - DB 커넥션 점유시간과 Redis 혼재로 인한 동시성 병목

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 외의 작업이 섞인 것은 아닌지 확인할 것.
  • 부하 테스트는 이런 구조적 문제를 단일 요청 테스트에서 보이지 않던 병목으로 드러내 준다.
반응형