SPARTA 과제/SPRING

Lock Service 분리 트러블슈팅

kjw81024 2026. 5. 11. 22:22

문제 발생

옛날에 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