문제
@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)을 측정하였다.
작성된 스크립트는 아래 순서로 동작하며, 결과적으로 세 가지 아키텍처에서 일관성 도달 특성을 비교할 수 있다 :
- 좋아요 API 요청
- ACK 측정
- 카운트 증가 확인
- 가시성 확보까지 걸린 시간 (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)

- SYNC는 거의 0.7초 → 체감이 느림.
- ASYNC_EVENT / REDIS_SCHED은 0.1초 수준 → 사용자는 즉시 반응처럼 느낄 수 있음.
이벤트 비동기 처리로 얻을 수 있는 명확한 UX 개선 효과 확인됨.
데이터 반영 지연(TTV)

- SYNC는 0.6초 이내로 안정적.
- ASYNC_EVENT는 평균적으론 빠르지만 tail(p99)이 2.2초 이상 → 일부 케이스에서 심각한 지연.
- REDIS_SCHED은 tail이 상대적으로 균일하지만 평균이 1.5초 이상 → 실시간성이 크게 떨어짐.
안정성 vs 속도의 trade-off가 뚜렷하게 보임.
HTTP 레벨 요청 지연

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

- SYNC / REDIS_SCHED: 0% → 데이터 일관성 보장.
- ASYNC_EVENT: 5.18% → 사용자가 데이터가 안 보이는 순간을 경험할 확률 존재.
실무에서는 작은 퍼센트라도 치명적일 수 있음.
성능/부하 관점의 해석
- 핫키(동일한 productId) 집중이 발생하면 어떤 방식이든 같은 row UPDATE는 사실상 직렬화 된다.
- 스레드를 늘려도 row-level-lock 경합은 사라지지 않는다.
- RPS이 높아질 수록 큐잉 이론상 tail latency (예를 들어, p99)가 급격히 상승한다.
- 어느 지점에서든 병목은 DB UPDATE로 수렴한다.
SYNC

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

- 병목 지점
- 플러시 주기 + 배치 처리 시간
- 최종 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가 가장 합리적일 수 있다. 실시간 성은 시스템의 요구사항에 따라 배치 주기를 조절하면 충족시킬 수 있을것 같다.