코드 구현
💡기존 코드
@Component
@RequiredArgsConstructor
public class RedissonStockLockManager implements StockLockManager {
private static final int MAX_RETRY_COUNT = 3;
private static final long WAIT_TIME_SECONDS = 3L;
private static final long LEASE_TIME_SECONDS = 5L;
private static final long RETRY_BACKOFF_MILLIS = 100L;
private final RedissonClient redissonClient;
@Override
public <T> T executeWithLock(String lockKey, Supplier<T> supplier) {
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
for (int retryCount = 0; retryCount < MAX_RETRY_COUNT; retryCount++) {
locked = lock.tryLock(WAIT_TIME_SECONDS, LEASE_TIME_SECONDS, TimeUnit.SECONDS);
if (locked) {
return supplier.get();
}
Thread.sleep(RETRY_BACKOFF_MILLIS);
}
throw new GlobalException(STOCK_LOCK_TIMEOUT);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GlobalException(STOCK_LOCK_TIMEOUT);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
💡Timer 추가한 코드
@Component
public class RedissonStockLockManager implements StockLockManager {
private static final int MAX_RETRY_COUNT = 3;
private static final long WAIT_TIME_SECONDS = 3L;
private static final long LEASE_TIME_SECONDS = 5L;
private static final long RETRY_BACKOFF_MILLIS = 100L;
private final RedissonClient redissonClient;
private final MeterRegistry meterRegistry;
private final Timer waitAcquiredTimer;
private final Timer waitTimeoutTimer;
private final Timer waitInterruptedTimer;
private final Timer waitErrorTimer;
private final Timer holdSuccessTimer;
private final Timer holdErrorTimer;
private final Timer holdOwnershipLostTimer;
public RedissonStockLockManager(RedissonClient redissonClient, MeterRegistry meterRegistry) {
this.redissonClient = redissonClient;
this.meterRegistry = meterRegistry;
this.waitAcquiredTimer = createTimer("stock.lock.wait", "acquired");
this.waitTimeoutTimer = createTimer("stock.lock.wait", "timeout");
this.waitInterruptedTimer = createTimer("stock.lock.wait", "interrupted");
this.waitErrorTimer = createTimer("stock.lock.wait", "error");
this.holdSuccessTimer = createTimer("stock.lock.hold", "success");
this.holdErrorTimer = createTimer("stock.lock.hold", "error");
this.holdOwnershipLostTimer = createTimer("stock.lock.hold", "ownership_lost");
}
@Override
public <T> T executeWithLock(String lockKey, Supplier<T> supplier) {
RLock lock = redissonClient.getLock(lockKey);
Timer.Sample waitSample = Timer.start(meterRegistry);
boolean waitRecorded = false;
try {
for (int retryCount = 0; retryCount < MAX_RETRY_COUNT; retryCount++) {
boolean locked = lock.tryLock(WAIT_TIME_SECONDS, LEASE_TIME_SECONDS, TimeUnit.SECONDS);
if (locked) {
waitSample.stop(waitAcquiredTimer);
waitRecorded = true;
return executeWhileHoldingLock(lock, supplier);
}
Thread.sleep(RETRY_BACKOFF_MILLIS);
}
waitSample.stop(waitTimeoutTimer);
waitRecorded = true;
throw new GlobalException(STOCK_LOCK_TIMEOUT);
} catch (InterruptedException e) {
waitSample.stop(waitInterruptedTimer);
waitRecorded = true;
Thread.currentThread().interrupt();
throw new GlobalException(STOCK_LOCK_TIMEOUT);
} catch (RuntimeException e) {
if (!waitRecorded) {
waitSample.stop(waitErrorTimer);
}
throw e;
}
}
private <T> T executeWhileHoldingLock(RLock lock, Supplier<T> supplier) {
Timer.Sample holdSample = Timer.start(meterRegistry);
boolean supplierSucceeded = false;
boolean unlockSucceeded = false;
boolean ownershipLost = false;
try {
T result = supplier.get();
supplierSucceeded = true;
return result;
} finally {
try {
// 다른 요청이 임계 구역에 진입했을 수 있는 락 소유권 상실을 별도로 기록한다.
if (lock.isHeldByCurrentThread()) {
lock.unlock();
unlockSucceeded = true;
} else {
ownershipLost = true;
}
} finally {
holdSample.stop(resolveHoldTimer(ownershipLost, supplierSucceeded, unlockSucceeded));
}
}
}
private Timer resolveHoldTimer(
boolean ownershipLost, boolean supplierSucceeded, boolean unlockSucceeded) {
if (ownershipLost) {
return holdOwnershipLostTimer;
}
if (!supplierSucceeded || !unlockSucceeded) {
return holdErrorTimer;
}
return holdSuccessTimer;
}
private Timer createTimer(String name, String result) {
return Timer.builder(name).tag("result", result).register(meterRegistry);
}
}
코드 구조
💡핵심 로직
executeWithLock()
→ 락 획득을 책임진다.
supplier.get()
→ 락 안에서 실행할 실제 비즈니스 작업이다.
executeWhileHoldingLock()
→ 작업 실행, 락 해제, 보유 시간 기록을 책임진다.
wait Timer
→ 락을 얻기 전까지의 시간을 측정한다.
hold Timer
→ 락을 얻은 후 해제할 때까지의 시간을 측정한다.
💡전체 흐름
executeWithLock()
│
├─ Redis 락 객체 가져오기
│
├─ wait Timer 시작
│
├─ 최대 3번 tryLock()
│ │
│ ├─ 성공
│ │ ├─ wait acquired 기록
│ │ └─ executeWhileHoldingLock()
│ │ ├─ hold Timer 시작
│ │ ├─ supplier.get()
│ │ ├─ 락 해제
│ │ └─ success/error/ownership_lost 기록
│ │
│ └─ 모두 실패
│ ├─ wait timeout 기록
│ └─ 예외 발생
│
├─ 인터럽트
│ ├─ wait interrupted 기록
│ └─ 예외 발생
│
└─ 기타 오류
├─ wait error 기록
└─ 원래 예외 다시 발생
Timer를 추가한 이유
기존 코드에서도 분산 락을 이용한 재고 정합성은 보장할 수 있었다. 하지만 API 응답시간이 느려졌을 때 실제로 어느 구간에서 시간이 오래 걸렸는지는 확인할 수 없었다.
전체 재고 차감 응답시간
=
락 획득 대기시간
+ 락 내부 작업시간
+ 기타 요청 처리시간
따라서 전체 응답시간만 제공하는 k6 결과와 함께 애플리케이션 내부의 락 대기시간과 점유시간을 확인하기 위해 Micrometer Timer를 추가했다.
💡변경 전과 변경 후
| 구분 | 변경 전 | 변경 후 |
| 락 대기시간 | 측정할 수 없음 | stock.lock.wait로 측정 |
| 락 점유시간 | 측정할 수 없음 | stock.lock.hold로 측정 |
| 실패 원인 | 예외 결과로만 확인 | Timer의 result 태그로 분류 |
| 락 소유권 상실 | 별도 기록 없음 | ownership_lost로 별도 기록 |
Timer 측정 구간
💡락 대기시간
stock.lock.wait
Timer 시작
→ tryLock() 및 재시도
→ 락 획득 성공 또는 실패 결과 확정
→ Timer 종료
| result 태그 | 의미 |
acquired |
락 획득 성공 |
timeout |
설정된 재시도 횟수를 모두 소진 |
interrupted |
락 대기 중 스레드 인터럽트 발생 |
error |
Redis 호출 등 예상하지 못한 오류 발생 |
💡락 점유시간
stock.lock.hold
락 획득 성공
→ Timer 시작
→ supplier.get() 실행
→ 락 소유권 확인
→ unlock() 완료
→ Timer 종료
| result 태그 | 의미 |
success |
비즈니스 작업과 락 해제 모두 성공 |
error |
비즈니스 작업 또는 락 해제 실패 |
ownership_lost |
락 해제 전에 현재 스레드가 락 소유권을 잃은 상태 |
중복 기록 방지
executeWithLock() 외부의 catch (RuntimeException e)는 락 획득 과정뿐 아니라 supplier.get()이나 unlock()에서 발생한 예외도 받을 수 있다.
따라서 락을 이미 획득해 wait acquired를 기록한 후 비즈니스 로직에서 예외가 발생했을 때, 동일한 대기시간이 wait error로 다시 기록되지 않도록 waitRecorded 값을 사용했다.
if (locked) {
waitSample.stop(waitAcquiredTimer);
waitRecorded = true;
return executeWhileHoldingLock(lock, supplier);
}
catch (RuntimeException e) {
if (!waitRecorded) {
waitSample.stop(waitErrorTimer);
}
throw e;
}
waitRecorded = false
→ 아직 락 대기 결과를 기록하지 않음
→ RuntimeException 발생 시 wait error 기록
waitRecorded = true
→ acquired 또는 timeout 결과를 이미 기록함
→ wait error를 중복 기록하지 않음
Actuator를 통한 측정값 확인
loadtest 프로필에서는 Actuator의 metrics 엔드포인트를 통해 Timer 측정값을 확인할 수 있다.
curl 'http://localhost:8085/actuator/metrics/stock.lock.wait?tag=result:acquired'
curl 'http://localhost:8085/actuator/metrics/stock.lock.hold?tag=result:success'
| 측정값 | 의미 |
COUNT |
Timer에 기록된 요청 수 |
TOTAL_TIME |
기록된 전체 누적 시간 |
MAX |
참고용 최대 시간 |
💡본 측정 구간의 평균 계산
Micrometer Timer는 애플리케이션 시작 이후의 값을 누적한다. 따라서 warmup 데이터가 본 측정 결과에 포함되지 않도록 warmup 종료 후 지표 A와 compare 종료 후 지표 B를 저장한다.
본 측정 요청 수
= B.COUNT - A.COUNT
본 측정 누적 시간
= B.TOTAL_TIME - A.TOTAL_TIME
본 측정 평균 시간
= (B.TOTAL_TIME - A.TOTAL_TIME)
/ (B.COUNT - A.COUNT)
내부 락 시간은 위 방식으로 평균을 계산하고, 전체 API의 평균, p95, p99 및 성공 TPS는 k6 결과를 사용한다.
검증
MySQL이나 Redis에 직접 연결하지 않는 단위 테스트를 작성하고 Mockito와 SimpleMeterRegistry를 사용해 다음 경로를 검증했다.
1. 락 즉시 획득
2. 재시도 후 락 획득
3. 재시도 소진 후 타임아웃
4. 락 대기 중 인터럽트
5. tryLock() 실행 중 RuntimeException
6. supplier 실행 중 RuntimeException
7. unlock() 실행 중 RuntimeException
8. 락 소유권 상실
또한 loadtest 프로필로 애플리케이션을 실행하고 재고 차감 API를 호출하여 다음 결과를 확인했다.
wait acquired COUNT: 0 → 1
hold success COUNT: 0 → 1
wait timeout: 0
wait interrupted: 0
wait error: 0
hold error: 0
hold ownership_lost: 0
정리
이번 변경에서는 기존 분산 락의 재시도, 타임아웃, 예외 처리 정책을 변경하지 않고 관측을 위한 Timer만 추가했다.
이를 통해 k6에서 확인한 전체 API 응답시간과 애플리케이션 내부의 락 대기시간 및 점유시간을 함께 비교할 수 있게 되었고, 이후 락 점유 구간 리팩토링의 필요성과 개선 결과를 수치로 검증할 수 있는 기반을 마련했다.
'Projects > hub-eleven' 카테고리의 다른 글
| [트러블슈팅] 재고 API - (3) 동시성 처리 최적화 : k6 자동 측정 스크립트의 jq 문법 오류 해결 (1) | 2026.08.04 |
|---|---|
| [리팩토링] 재고 API - (3) 동시성 처리 최적화 : Micrometer 란? (0) | 2026.07.30 |
| [부하테스트] 재고 API - (2) k6 부하 테스트 환경과 개발 환경 분리 (0) | 2026.07.29 |
| [부하테스트] 재고 API - (1) k6로 테스트 해보기 (0) | 2026.07.28 |
| [동시성 처리] Redisson은 어떻게 Redis Pub/Sub을 이용해 재시도(Retry) 로직을 구현할 수 있을까? (0) | 2026.06.24 |