문제

@Transactional
public void like(String loginId, Long productId) {

    // 1. 유저 조회
    User user = userService.getByLoginId(loginId);

    // 2. 상품-좋아요 Insert
    boolean isInserted = productLikeRepository
      .insertIgnoreDuplicateKey(user.getUserId(), productId);
    
    if (!isInserted) return;
    // 3. 좋아요 수 집계 Update
    productRepository.incrementLikeCount(productId);

}
  • 좋아요 기능에서 처음엔 모든 로직을 하나의 트랜잭션 안에서 동기적(Sync)으로 처리
  • 안정적이지만 트랜잭션 크기가 커지고, 상품 핫키에 경합으로 인한 병목이 발생
  • 그래서, 이벤트 기반 설계를 통해 결합도를 낮추고(loosely coupled), 책임을 분리하려는 시도

Github PR : https://github.com/gukin-han/commercial-service/pull/7

테스트 방법

동일한 “상품-좋아요 등록 → 좋아요 수 집계 처리” 시나리오를 아래 세 가지 방식으로 구현 :

  • SYNC
    • 설명 : 한 트랜잭션 안에서 동기 실행
    • 기대 효과 : 단순함, 일관성↑, 결합도↑
  • ASYNC_EVENT
    • 설명 : Spring ApplicationEventPublisher + @TransactionalEventListener
    • 기대효과 : 비즈니스 로직 분리, 느슨한 결합
  • REDIS_SCHEDULED
    • 설명 : Redis 큐에 적재 → 스케줄러가 플러시
    • 기대효과 : 완전한 비동기, 외부 스토어 통한 decoupling

k6 부하 테스트 스크립트

이번 테스트에서 k6를 사용해 “좋아요(like)” API의 응답 속도와 최종 일관성 도달 시간 (TTV)을 측정하였다.

작성된 스크립트는 아래 순서로 동작하며, 결과적으로 세 가지 아키텍처에서 일관성 도달 특성을 비교할 수 있다 :

  1. 좋아요 API 요청
  2. ACK 측정
  3. 카운트 증가 확인
  4. 가시성 확보까지 걸린 시간 (TTV)을 자동 기록

주요 설정값

변수 설명 기본값
BASE_URL 대상 서버 주소 http://localhost:8080
MODE 실행 모드 (SYNC, ASYNC_EVENT, REDIS_SCHEDULED) SCHEDULED_REDIS
PROD_MAX 상품 개수 (랜덤 요청 분포용) 100
HOT_KEY_ID 특정 인기 상품 ID 1
HOT_RATIO 특정 상품 집중 비율 (0~1) 0.9
LOGIN_HEADER 사용자 식별 헤더 X-USER-ID
POLL_MS 가시성 확인 폴링 주기(ms) 50
TIMEOUT_MS 가시성 확인 최대 대기(ms) 3000

SLA 기준값

Metric SLA 기준
ack_ms (ACK 지연) p95 < 80ms
ttv_ms (TTV) p95 < 350ms
not_visible (타임아웃 비율) < 1%

측정 지표

  • ACK (ack_ms) : POST /likes/{mode} 호출이 완료되기까지 걸린 시간
  • TTV (ttv_ms) : 좋아요 요청 후, 실제 GET /likes/count 응답에서 증가가 확인될 때까지 걸린 시간
  • Not Visible 비율 : 타임아웃 내에서 가시성이 확보되지 않은 요청 비율

실행 시나리오

  • 부하 형태 : constant-arrival-rate (일정 요청률 유지)
  • 요청률 : 초당 1000회 (RATE)
  • 지속시간 : 기본 60초 (DURATION)
  • 동시 사용자 수 : 최소 800 VU, 최대 1200 VU

결과 및 인사이트

결과 분석

사용자 응답성(ack_ms)

ACK

  • SYNC는 거의 0.7초 → 체감이 느림.
  • ASYNC_EVENT / REDIS_SCHED은 0.1초 수준 → 사용자는 즉시 반응처럼 느낄 수 있음.

이벤트 비동기 처리로 얻을 수 있는 명확한 UX 개선 효과 확인됨.

데이터 반영 지연(TTV)

TTV

  • SYNC는 0.6초 이내로 안정적.
  • ASYNC_EVENT는 평균적으론 빠르지만 tail(p99)이 2.2초 이상 → 일부 케이스에서 심각한 지연.
  • REDIS_SCHED은 tail이 상대적으로 균일하지만 평균이 1.5초 이상 → 실시간성이 크게 떨어짐.

안정성 vs 속도의 trade-off가 뚜렷하게 보임.

HTTP 레벨 요청 지연

Latency

  • 비동기 계열(ASYNC_EVENT, REDIS_SCHED)은 0.1초 내외 → API 수준에서 빠른 응답성 확보 가능.
  • SYNC는 0.7초 이상 → 체감 차이가 확실.

안정성(not_visible 비율)

Not Visible 비율

  • SYNC / REDIS_SCHED: 0% → 데이터 일관성 보장.
  • ASYNC_EVENT: 5.18% → 사용자가 데이터가 안 보이는 순간을 경험할 확률 존재.

실무에서는 작은 퍼센트라도 치명적일 수 있음.

성능/부하 관점의 해석

  • 핫키(동일한 productId) 집중이 발생하면 어떤 방식이든 같은 row UPDATE는 사실상 직렬화 된다.
  • 스레드를 늘려도 row-level-lock 경합은 사라지지 않는다.
  • RPS이 높아질 수록 큐잉 이론상 tail latency (예를 들어, p99)가 급격히 상승한다.
  • 어느 지점에서든 병목은 DB UPDATE로 수렴한다.

SYNC

SYNC 모드

  • 병목 지점
    • DB 트랜잭션 내부의 INSERT + UPDATE (핫키 row lock)
  • 증상
    • ACK = TTV가 동반하여 상승한다 (즉각적인 반영)
  • 한계
    • 트랜잭션 경계 안에서 모든 작업을 끝내므로 확장성이 낮고, 핫키에 취약하다
  • 개선 포인트
    • 인덱스 최소화 / 커버링 인덱스
    • 트랜잭션을 짧게
    • 구조적 개선 없이는 수직 확장만 가능

ASYNC_EVENT

ASYNC_EVENT 모드

  • 병목 지점
    • JVM 내부 스레드 폴 큐 + DB 업데이트 직렬화
  • 증상
    • ACK는 수십 ms로 빨라지지만, TTV의 taile이 2.2s까지 높아진다
    • 반영되지 않는 데이터가 있음 (not_visible 5.18%)
  • 원인
    • 큐가 차면 Listener 시작도 늦어진다 (큐 대기시간 = TTV에 가산)
    • 즉, REQUIRES_NEW 1건 1커밋이라 핫키 row UPDATE 직렬화가 누적
  • 개선 포인트
    • Hikari 풀 사이즈를 Listener 동시성보다 크거나 같게하기
    • 실패/중복 방지 (outbox, 재시도, 멱등성)

REDIS_SCHED

REDIS_SCHED 모드

  • 병목 지점
    • 플러시 주기 + 배치 처리 시간
    • 최종 DB UPDATE 직렬화
  • 증상
    • ACK는 가장 빠름 (= 메모리 write)
    • TTV p95 =1.5s로 느림 (스케줄러 주기에 결정)
    • not_visible 0%
  • 특성
    • 실시간성 대신 예측 가능한 지연(스케줄러 주기)로 안정성 확보
    • 집계 row UPDATE 경합을 크게 줄여준다
  • 개선 포인트
    • 플러시 주기 단축 (예: 200ms -> 50~100ms)

결론

모드 ACK (p95/p99) TTV (p95/p99) Latency (p95/p99) NOT_VISIBLE
SYNC 699 / 766 ms 638 / 704 ms 661 / 741 ms 0.00%
ASYNC_EVENT 109 / 176 ms 182 / 2240 ms 92.9 / 161 ms 5.18%
REDIS_SCHED 88 / 137 ms 1490 / 1634 ms 79 / 124 ms 0.00%
  • SYNC 방식은 데이터 반영 지연(TTV) 빠르지만 확장성에 취약
  • ASYNC_EVENT 방식은 확장성 좋지만 꼬리 지연과 반영 지연 문제
  • REDIS_SCHEDULED 방식은 두 가지의 균형을 맞춘 현실적인 선택지.

즉, 실시간성이 절대적으로 필요하지 않다면 REDIS_SCHEDULED가 가장 합리적일 수 있다. 실시간 성은 시스템의 요구사항에 따라 배치 주기를 조절하면 충족시킬 수 있을것 같다.