문제 발생
옛날에 Distributed Lock 할 때 재고가 차감되기 전에 읽어버려 모든 주문이 성공하는 문제가 발생하다가
나중에는 모든 요청이 실패하는 문제로 바뀌어서
모든 주문이 성공하는 문제는 짧게 짚고 넘어갔었다 .........
그 때는 connection pool 이 부족해서 생긴 문제였음
이번에는 제대로 .... Lock Service 를 분리해야하는 이유를 확인하기

해결 과정
spring 의 transactional 은 proxy 가 열고 닫아줌
// OrderService.java
@Transactional
@DistributedLock
public void order() {}
락과 트랜잭션을 같은 메서드에 두면
1. 트랜잭션 시작 (프록시가 열어줌)
2. 락 획득
3. 재고 차감 로직
4. 락 해제
// 이 때 커밋 전 다른 요청이 락을 잡게 됨
5. 트랜잭션 커밋 (프록시가 닫아줌)
위와 같은 동작순서를 따르는데 이렇게 되면
4번에서 락을 해제하자마자 다른 요청에서 락을 잡기 때문에 커밋 전 상태를 읽게됨 -> 동시성 부서져요. . .
-> 서비스를 분리하기
방법들
| 방법 A | 방법 B | 방법 C | |
| @DistributedLock | O | O | X RedissonMultiLock |
| Service 개수 | 5 | 3 | 3 |
| 호출 순서 | Order -> OrderLock -> OrderTx -> StockLock -> Stock |
Order -> StockLock -> Stock |
Lock -> Order -> StockTx |
| 장점 | 원자성 보장 보상로직 불필요 인프라 재사용 |
락 점유 시간 짧음 인프라 재사용 |
주문단위 락 구조 단순 |
| 단점 | 복잡, 락을 오래 잡음 데드락 가능성 |
원자성 깨짐 restore 필수 (보상 로직) |
@DistributedLock 사용 불가 재사용성 낮음 |
방법 A
락이 트랜잭션을 감싸는 구조 (5개 서비스)
주문 단위로 필요한 메뉴 락을 모두 잡은 뒤, 하나의 트랜잭션 안에서 재고 차감 + 주문 생성을 처리 -> 실패 시 트랜잭션 롤백으로 전부 자동으로 원상복구되므로 restore 로직이 필요 없다.
동작 흐름

코드
// 1. OrderService — 위임만
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderLockService orderLockService;
public CreateOrderResponse order(CreateOrderRequest request) {
return orderLockService.execute(request);
}
}
// 2. OrderLockService — 락 획득/해제 (트랜잭션 없음)
@Service
@RequiredArgsConstructor
public class OrderLockService {
private final OrderTransactionService txService;
private final RedissonClient redissonClient;
public CreateOrderResponse execute(CreateOrderRequest request) {
// menuId 정렬 → 데드락 방지
List<RLock> locks = request.items().stream()
.map(item -> item.menuId())
.distinct().sorted()
.map(id -> redissonClient.getLock("stock:" + id))
.toList();
for (RLock lock : locks) {
try {
if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
throw new ServiceException(ErrorCode.LOCK_ACQUISITION_FAILED);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException(ErrorCode.LOCK_INTERRUPTED);
}
}
try {
return txService.processOrder(request); // 트랜잭션 서비스 호출
} finally {
locks.forEach(lock -> {
if (lock.isHeldByCurrentThread()) lock.unlock();
});
}
}
}
// 3. OrderTransactionService — @Transactional, 비즈니스 로직
@Service
@RequiredArgsConstructor
public class OrderTransactionService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final StockLockService stockLockService;
@Transactional
public CreateOrderResponse processOrder(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 = stockLockService.decrease(item.menuId(), item.quantity());
orderItemRepository.save(
new OrderItem(order.getId(), menu.getId(), item.quantity(), menu.getPrice())
);
totalPrice += menu.getPrice() * item.quantity();
}
if (member.getPoint() < totalPrice) {
throw new ServiceException(ErrorCode.SHORT_POINT,
"잔액이 부족합니다. 현재 잔액: " + member.getPoint());
}
order.updateTotalPrice(totalPrice);
return CreateOrderResponse.from(order);
}
}
// 4. StockLockService — @DistributedLock AOP (기존 인프라)
@Service
@RequiredArgsConstructor
public class StockLockService {
private final StockService stockService;
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = 30)
public Menu decrease(Long menuId, int quantity) {
return stockService.decrease(menuId, quantity);
}
}
// 5. StockService — 순수 재고 DB 작업
@Service
@RequiredArgsConstructor
public class StockService {
private final MenuService menuService;
@Transactional // 외부 트랜잭션에 합류 (REQUIRED)
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;
}
}
주의점
- OrderLockService에서 이미 메뉴별 락을 잡고 있으므로, StockLockService의 @DistributedLock과 이중 락이 된다. 이 경우 Redisson의 RLock이 reentrant(재진입 가능)하기 때문에 동작은 하지만, 불필요한 오버헤드가 있다.
- 락을 순차 획득하므로 menuId 정렬이 필수다. 정렬하지 않으면 두 스레드가 A→B, B→A 순서로 잡아 데드락이 발생한다.
- 구조가 깊어서 (depth 6) 디버깅이 어려울 수 있다.
방법 B
REQUIRES_NEW로 재고만 즉시 커밋 (3개 서비스)
메뉴별로 락을 잡고 재고를 즉시 커밋한 뒤 바로 락을 해제한다. 락 점유 시간이 최소화되어 동시 처리량이 높지만, 원자성이 깨져 보상 로직이 필요하다.
최종으로 선택했는데....... 막 더 좋은진 모르겠는데 일단 가지고 있던 코드 ( @DistributedLock , service 도 3개) 에서 변경이 최소화 되는 방법이라서 선택했다.......
추가 장점으로는 재고 차감 + 커밋만 하고 바로 락을 해제하므로, 동시 주문이 많을 때 처리량이 높고
... 간결하다 서비스 5개는 너무 많음
동작 흐름

코드
// 1. OrderService — @Transactional, 비즈니스 흐름 조율
@Service
@RequiredArgsConstructor
public class OrderService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final StockLockService stockLockService;
@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;
List<OrderItemDto> decreasedItems = new ArrayList<>();
try {
for (OrderItemDto item : request.items()) {
Menu menu = stockLockService.decrease(item.menuId(), item.quantity());
decreasedItems.add(item);
orderItemRepository.save(
new OrderItem(order.getId(), menu.getId(), item.quantity(), menu.getPrice())
);
totalPrice += menu.getPrice() * item.quantity();
}
if (member.getPoint() < totalPrice) {
throw new ServiceException(ErrorCode.SHORT_POINT,
"잔액이 부족합니다. 현재 잔액: " + member.getPoint());
}
} catch (Exception e) {
// 보상 트랜잭션: 이미 커밋된 재고 복구
for (OrderItemDto item : decreasedItems) {
stockLockService.restore(item.menuId(), item.quantity());
}
throw e;
}
order.updateTotalPrice(totalPrice);
return CreateOrderResponse.from(order);
}
}
// 2. StockLockService — @DistributedLock AOP
@Service
@RequiredArgsConstructor
public class StockLockService {
private final StockService stockService;
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = 30)
public Menu decrease(Long menuId, int quantity) {
return stockService.decrease(menuId, quantity);
}
@DistributedLock(key = "'stock:' + #menuId", waitTime = 10, leaseTime = 30)
public void restore(Long menuId, int quantity) {
stockService.restore(menuId, quantity);
}
}
// 3. StockService — REQUIRES_NEW, 독립 트랜잭션
@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);
}
}
주의점
- 원자성 보장 x: 재고는 REQUIRES_NEW로 이미 커밋되었으므로, 이후 잔액 검증 등에서 실패하면 재고만 차감된 상태가 된다. 반드시 catch에서 restore()를 호출해야 한다. restore 실패 가능성 또한 존재 보상 트랜잭션 자체도 실패할 수 있다. 프로덕션에서는 재시도 로직이나 이벤트 기반 보상 패턴(Saga)을 고려해야 한다.
- 주문의 트랜잭션 안에서 외부 트랜잭션의 OrderItem INSERT와 내부 트랜잭션의 재고 UPDATE가 서로 다른 커넥션을 쓰므로 HikariCP 커넥션 풀 사이즈에 주의해야 한다.
이 부분은 K6 Distributed Lock Trouble shooting 에 작성되어있다
방법 C
RedissonMultiLock (3개 서비스)
주문 단위로 필요한 메뉴 락을 MultiLock으로 한번에 잡고 하나의 트랜잭션으로 처리한다. 방법 A와 원자성은 동일하지만 서비스 수가 적다.
동작 흐름

코드
// 1. OrderLockService — MultiLock 획득/해제
@Service
@RequiredArgsConstructor
public class OrderLockService {
private final OrderTransactionService txService;
private final RedissonClient redissonClient;
public CreateOrderResponse execute(CreateOrderRequest request) {
List<RLock> locks = request.items().stream()
.map(item -> item.menuId())
.distinct().sorted()
.map(id -> redissonClient.getLock("stock:" + id))
.toList();
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
try {
if (!multiLock.tryLock(10, 30, TimeUnit.SECONDS)) {
throw new ServiceException(ErrorCode.LOCK_ACQUISITION_FAILED);
}
return txService.processOrder(request);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException(ErrorCode.LOCK_INTERRUPTED);
} finally {
multiLock.unlock();
}
}
}
// 2. OrderTransactionService — @Transactional, 비즈니스 전체
@Service
@RequiredArgsConstructor
public class OrderTransactionService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final StockService stockService;
@Transactional
public CreateOrderResponse processOrder(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();
}
if (member.getPoint() < totalPrice) {
throw new ServiceException(ErrorCode.SHORT_POINT,
"잔액이 부족합니다. 현재 잔액: " + member.getPoint());
}
order.updateTotalPrice(totalPrice);
return CreateOrderResponse.from(order);
}
}
// 3. StockService — 순수 재고 DB 작업
@Service
@RequiredArgsConstructor
public class StockService {
private final MenuService menuService;
@Transactional // 외부 트랜잭션에 합류
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;
}
}
장단점
- @DistributedLock AOP를 사용하지 않으므로, 락 로직을 직접 작성해야 한다.
- StockLockService가 없으므로 다른 곳에서 메뉴 단위 락이 필요하면 별도로 구현해야 한다.
- MultiLock이 전부 잡거나 전부 실패를 보장하므로 데드락 위험이 없다.
개념 정리
1. 언제 Lock Service 를 분리해야하는가?
| 사용하는 Lock 종류 | 분리 여부 | 이유 |
| 분산 락(Redisson RLock) | 항상 분리 | 락의 범위가 트랜잭션보다 넓어야 함 |
| DB 비관적 락 (SELECT FOR UPDATE) | 분리 불필요 | 락과 트랜잭션이 같은 DB 커넥션 안에서 함께 살고 죽음 |
| 낙관적 락(@Version) | 분리 불필요 | 충돌 시 예외로 처리하는 방식이라 생명주기 문제 X |
Transactional 전용 Service 분리가 필요해진 이유
만약에 order method 를 Controller 에서 호출한다고 생각해보자
그럼 재고 확인, 차감, 생성, 저장 . . .등등의 모든 로직이 atomic 하게 이루어져야한다 (Transactional 을 붙이는 이유)
근데 Lock 이 외부, Transaction 이 외부가 되려면 Lock 을 호출하는 method 가 원자성을 잃게 됨
그리고 Lock 문제를 해결하기 위해 기존 Service 의 transactional 을 삭제하는 것은 말이 안 된다....
2. 메서드만 분리하면 안 되나?
@Service
public class StockService {
public void decreaseWithLock(Long id, int qty) {
// 락 획득
decrease(id, qty); // this 호출 → @Transactional 무시됨
// 락 해제
}
@Transactional
public void decrease(Long id, int qty) {
// 재고 차감
}
}
위처럼 작성하는 경우 같은 클래스 내에서 this.method()를 호출하는 형태이기 때문에 @Transactional 이 무시됨
-> Transaction 이 필요한 로직을 별도 빈으로 빼줘야함
@Service
@RequiredArgsConstructor
public class LockService {
private final StockTransactionService txService;
public void decreaseWithLock(Long id, int qty) {
lock.tryLock(...);
try {
txService.decrease(id, qty); // 다른 빈 호출 → 프록시 동작
} finally {
lock.unlock();
}
}
}
@Service
public class StockTransactionService {
@Transactional
public void decrease(Long id, int qty) {
// 재고 차감
}
}
3. 언제 Transaction Service (Tx Service) 를 분리하는가?
분산 락 + 트랜잭션 조합일 때 LockService 가 TxService 를 호출하는 구조가 필수가 됨
한 서비스 안에서 트랜잭션 경계를 다르게 가져가야 할 때 -> 분리 필요
Scheduler에서 겪었던 것처럼, 배치 단위로 독립 트랜잭션이 필요한데 같은 클래스의 @Transactional(propagation = REQUIRES_NEW) 메서드를 this로 호출하면 안 먹혔던 기억 -> TransactionTemplate 을 쓰거나 배치 실행 로직을 별도 빈으로 분리해줬어야했다
트랜잭션 범위를 최소화하고 싶을 때 -> 외부 API 호출, 파일 I/O, 락 대기 같은 느린 작업을 트랜잭션 밖에 두고, 순수 DB 작업만 트랜잭션으로 감싸면 됨
'SPARTA 과제 > SPRING' 카테고리의 다른 글
| Grafana, Prometheus 도입 (1) | 2026.05.12 |
|---|---|
| Project 3 생각해 볼만한 부분 정리 (0) | 2026.05.11 |
| Distributed Lock 트러블슈팅 (0) | 2026.05.07 |
| RedisTemplate StackOverFlow Error (1) | 2026.05.03 |
| K6 테스트 (0) | 2026.04.29 |