SPARTA 과제/SPRING

Distributed Lock 트러블슈팅

kjw81024 2026. 5. 7. 12:37

강의에서 설명한 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;
}

 

  1. Stock Service 에서 Lock 획득 + OrderService 의 order method 에서 사용하고 있는 Transaction에 참여
  2. menu.minusStock 실행 
  3. return menu 를 하면서 Lock 이 해제됨 
  4. 근데 아직 order 의 Transaction 은 안 끝남 -> DB 에 반영되기 전임 
  5. order 의 Transaction 이 끝났을 때는 이미 다른 요청에서 재고 차감 전의 menu 상태를 읽어버리게 됨 
  6.  

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