강의에서 설명한 Distributed Lock : Redis SETNX + DEL 기반
이번에 팀과제에서 했던 Lock 과 비슷하게 구현하고 싶어서 Redisson의 RLock 을 도입하여 사용했다
watchdog 자동 연장, 원자적 처리 (Lua script?) 가 이미 들어가있음
사용하며 공부했던 내용 정리
1. 분산락 의미
서버가 여러대일때 같은 자원에 동시 접근하는 것을 방지
단일 서버에서는 비관락으로도 동시성 제어가 가능하지만 서버가 여러대가 되면 각 서버 JVM 이 혼자서 동작하기 때문에 Local lock 의 의미가 없음 -> Redis 를 사용해서 모든 서버가 공유하는 Lock 을 사용하자!
동작 과정

Lock 키는 lock:stock:3 의 형식으로 결정했다: stock 말고 point 에도 걸어야하기 때문에 구분하기 위함
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = -1)
@DistributedLock(key = "'point:' + #memberId")
2. 문제 상황
K6 테스트를 돌렸을 때 30건의 동시 주문이 있으면 10건 성공, 20건 실패가 되어야하는데 30건 모두 성공함

락 해제 시점과 트랜잭션 커밋 시점이 달라서 그런 듯 하다
즉 ,,, 트랜잭션이 커밋되고 나서 Lock 을 획득해야 변경된 재고를 읽는데
트랜잭션이 커밋 되기도 전에 Lock 을 해제하고, 변경되지 않은 30개의 재고를 그대로 읽으니까 다 성공한걸로 됨
// OrderService
@Transactional
public CreateOrderResponse order(CreateOrderRequest request) {
...
Menu menu = stockService.decrease(item.menuId(), item.quantity());
...
}
// StockService
@DistributedLock(key = "'stock:' + #menuId")
@Transactional
public Menu decrease(Long menuId, int quantity) {
Menu menu = menuService.findById(menuId);
menu.minusStock(quantity);
return menu;
}
- Stock Service 에서 Lock 획득 + OrderService 의 order method 에서 사용하고 있는 Transaction에 참여
- menu.minusStock 실행
- return menu 를 하면서 Lock 이 해제됨
- 근데 아직 order 의 Transaction 은 안 끝남 -> DB 에 반영되기 전임
- order 의 Transaction 이 끝났을 때는 이미 다른 요청에서 재고 차감 전의 menu 상태를 읽어버리게 됨
3. 해결 과정
(1) Transactional annotation 제거 ...
order method 에서 Transactional 을 제거하면 decrease() 안에서 독립적인 transaction 이 생김 -> 끝나고 바로 커밋되기 때문에 Lock 해제 전에 DB 반영이 된다
근데 이렇게 하면 order 에 있는 다른 요청들에서 예외가 발생했을 때
ex) 잔액부족... DB 저장 오류, totalPrice update 에서 오류... 엄청 많음
Rollback 이 안 되는 문제가 생긴다 -> 주문 process 전체의 원자성이 깨지게 됨
(2) Transcation service 분리
decrease method 에 Lock 이랑 Transaction 을 둘 다 거는 게 아니라 별도로 분리했다
// StockService — 락만 담당
@Service
@RequiredArgsConstructor
public class StockService {
private final StockTxService stockTxService;
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = -1)
public Menu decrease(Long menuId, int quantity) {
return stockTxService.decrease(menuId, quantity);
}
}
// StockTxService — 트랜잭션만 담당
@Service
@RequiredArgsConstructor
public class StockTxService {
private final MenuService menuService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Menu decrease(Long menuId, int quantity) {
Menu menu = menuService.findById(menuId);
menu.minusStock(quantity);
return menu;
}
}
전파 방법을 REQUIRES_NEW 로 했으니까 새로운 Transaction 이 시작되고 Transaction B 가 커밋되면서 DB 에 잘 반영된 이후에 Lock 이 해제되어 업데이트 된 값을 잘 읽어오도록 했다
근데 성공을 이따구로 함.......
기다리는 시간이 부족한가? 해서 WaitTime 을 늘려봤는데도 여전히 주문 성공이 너무 없다 (3개...까지 성공하는듯)

와중에 재고가 부족해서가 아닌 비정상 실패가 발생함
-> Lock 획득 과정에서 뭔가 오류가 발생했다는 의미
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
}
if (!acquired) {
throw new ServiceException(ErrorCode.LOCK_ACQUISITION_FAILED, "잠시 후 다시 시도해주세요.");
}

왜 이러나 검색
4. 해결
HikariCP 커넥션 풀 데드락이 걸린것이였다.......
Connection pool DeadLock Error 체크 방법
application.yml 에다가
spring:
datasource:
hikari:
maximum-pool-size: 30
connection-timeout: 50000
leak-detection-threshold: 5000 # 얘 추가
leak-detection-threshold 추가하면 5초 이상 반납 안 한것에 대해 체크 가능

왜 그런가 생각해보니까 REQUIRES_NEW는 기존 트랜잭션을 보류하고 새 트랜잭션을 시작한다. 이때 새 DB 커넥션을 풀에서 추가로 가져와야 한다.
order 이 connection 하나를 점유하고 있는 상황에서
decrease 가 또 connection 을 점유하게 되면 VU 1 개 당 connection pool 이 2개 필요하게 됨
근데 maximum pool size 를 30으로 설정하게 되면
바깥 트랜잭션 (order 에 걸린 것) 이 Connection 을 다 가져간 채로 안쪽 트랜잭션의 커밋 (decrease) 를 기다림
근데 안쪽은 바깥 트랜잭션이 끝나야 Connection 을 반환하므로 서로를... 영원히....... 기다림
-> HikariCP maximum-pool-size 증가
동시 요청 30건 × 커넥션 2개 = 최대 60개 필요하므로, 여유를 두고 70으로 설정
maximum-pool-size: 70
해결되었다. .

5. 회고
분산락 + @Transactional 사용 시 체크해봐야 할 게 많은듯 하다
- Lock 안에서 Commit 이 완료되는지 확인해야함 -> 바깥 트랜잭션에 참여 (그냥 Transactional 만 쓰면 이렇게 됨...)하면 Lock 해제 이후에 Commit 이 되어 동시성이 깨짐
- REQUIRES_NEW로 독립 트랜잭션을 만들 경우에 커넥션 풀 사이즈를 고려하기
동시 요청사용자 보다 작으면 DeadLock 이 걸린다
무조건 두 배가 될 필요는 없다
40으로 해도 잘 됨 -> Lock 해제,,, 다시 풀린 커넥션 잡고...,, 시작하면 다 됨
풀 사이즈를 무한정 늘리는 것은 해결책이 아니다. MySQL의 max_connections 한도와 메모리 사용량을 함께 고려해야 하고 예상하는 동시 요청자 수? K6에서는 VU
를 고려해서 그것보다만 많이 넣으면 됨
추가: 기본 connection pool 은 10개
- REQUIRES_NEW를 쓰면 바깥 트랜잭션 롤백 시 보상 로직이 필요하다. 안쪽 트랜잭션은 이미 커밋되어 롤백되지 않으므로, catch에서 수동으로 restore를 호출해야 함
물론 이번에는 주문이 끝나고 결제를 해야 보상 해줄 게 생겨서 할 필요 없었다
6. 추가로 알아본 부분
클로드씨가 이번 트러블슈팅에 대해서 아래와 같은 조언을 해줬다

@DistributedLock
@Transactional
public void method1(Long userId) {
// business logic
}
무슨 말인가 보니까 위와 같은 로직이 있을 때 내가 생각하는 동작 흐름
Lock 획득 -> Transaction 시작 -> 비즈니스 로직 실행 -> Commit -> Lock 해제
근데 실제로는 Transcation 도 proxy 고 Lock 도 proxy 라서 누가 먼저 실행될지 모른다
즉 만약에 transcation 시작이 먼저 되면 DB connection 을 오래 점유하거나, 불필요한 Transaction 이 유지되거나 DeadLock 가능성이 증가하는 문제가 생김
-> Lock 담당 Bean 이랑 Transaction 담당 Bean 을 분리
(내부에서 클래스를 호출하는걸로 하면 동일클래스 내부메서드 호출 (this. 쓰는거) 는 AOP 가 안 먹어서 더 위험)
7. 최종 코드
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 락 키 (SpEL 지원)
long waitTime() default 5; // 락 대기 시간 (초)
long leaseTime() default 3; // 락 점유 시간 (초)
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String key = resolveKey(distributedLock.key(), joinPoint);
RLock lock = redissonClient.getLock("lock:" + key);
log.info("락 시도: key={}", "lock:" + key);
boolean acquired = false;
try {
if (distributedLock.leaseTime() == -1) {
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.timeUnit()
);
} else {
acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
}
if (!acquired) {
throw new ServiceException(ErrorCode.LOCK_ACQUISITION_FAILED,
"잠시 후 다시 시도해주세요.");
}
return joinPoint.proceed();
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("락 해제: key={}", "lock:" + key);
}
}
}
private String resolveKey(String keyExpression, ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return parser.parseExpression(keyExpression).getValue(context, String.class);
}
}
// OrderService
private final StockLockService stockService; // stock lock service 호출하기
@Transactional
public CreateOrderResponse order(CreateOrderRequest request) {
Member member = memberRepository.findById(request.memberId()).orElseThrow(
()-> new ServiceException(ErrorCode.MEMBER_NOT_FOUND)
);
Order order = orderRepository.save(new Order(request.memberId()));
long totalPrice = 0L;
for (OrderItemDto item : request.items()) {
Menu menu = stockService.decrease(item.menuId(), item.quantity());
orderItemRepository.save(
new OrderItem(order.getId(), menu.getId(), item.quantity(), menu.getPrice())
);
totalPrice += menu.getPrice() * item.quantity();
log.info("[OrderService] totalPrice: {}", totalPrice);
}
if (member.getPoint() < totalPrice) {
throw new ServiceException(ErrorCode.SHORT_POINT,
"잔액이 부족합니다. 현재 잔액: "+member.getPoint());
}
order.updateTotalPrice(totalPrice);
return CreateOrderResponse.from(order);
}
// StockLockService
@Service
@RequiredArgsConstructor
@Slf4j
public class StockLockService {
private final StockService stockService;
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = -1)
public Menu decrease(Long menuId, int quantity) {
return stockService.decrease(menuId, quantity);
}
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = -1)
public void restore(Long menuId, int quantity) {
stockService.restore(menuId, quantity);
}
}
//StockService
@Service
@RequiredArgsConstructor
public class StockService {
private final MenuService menuService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Menu decrease(Long menuId, int quantity) {
Menu menu = menuService.findById(menuId);
if (menu.getStatus() != MenuStatus.AVAILABLE) {
throw new ServiceException(ErrorCode.INVALID_STATUS,
"주문할 수 없는 메뉴입니다: " + menu.getName());
}
menu.minusStock(quantity);
return menu;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void restore(Long menuId, int quantity) {
Menu menu = menuService.findById(menuId);
menu.restoreStock(quantity);
}
}
'SPARTA 과제 > SPRING' 카테고리의 다른 글
| Project 3 생각해 볼만한 부분 정리 (0) | 2026.05.11 |
|---|---|
| Lock Service 분리 트러블슈팅 (0) | 2026.05.11 |
| RedisTemplate StackOverFlow Error (1) | 2026.05.03 |
| K6 테스트 (0) | 2026.04.29 |
| JOOQ 코드 생성 트러블슈팅 (1) | 2026.04.13 |