Projects/hub-eleven

[리팩토링] 재고 API - (3) 동시성 처리 최적화 : Micrometer 도입

annovation 2026. 8. 2. 23:59

코드 구현

💡기존 코드

@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 응답시간과 애플리케이션 내부의 락 대기시간 및 점유시간을 함께 비교할 수 있게 되었고, 이후 락 점유 구간 리팩토링의 필요성과 개선 결과를 수치로 검증할 수 있는 기반을 마련했다.