분산락 어노테이션으로 재고의 중복 반영을 막고 보일러 코드를 제거한 경험 공유

조현준조현준
4 min read

문제 상황

작업을 완료할 때에 어떤 재고를 몇개의 수량으로 완료를 했다고 보내는 기능이 있는데 해당 기능에서 중복 요청이 발생하는 문제가 있었습니다.

  • 작업 완료 이벤트가 2번 씩 중복 요청됨 → 재고도 중복으로 반영됨

분석

비지니스 로직 상에서는 요청된 수량보다 완료된 수량을 더 많이 진행할 수 없도록 방어처리되고 있었지만 동시성 문제로 서로 다른 스레드에서 db에 반영되기 전에 병렬적으로 처리하다보니 중복적으로 반영하는 문제였습니다.

따라서 중복 요청에 대해서 방어할 수 있는 방법이 필요해진 상황입니다.

로컬 락을 건다

장점

  • 구현이 단순- 트랜잭션 단위 내에서 강력한 제어 가능

단점

  • 멀티 인스턴스 환경에서는 무력화

  • WAS 재시작 시 Lock 초기화

  • 수평 확장 환경 부적합

분산 락을 건다

장점

  • 다중 인스턴스 환경에서도 안전한 동기화 가능

  • 타임아웃 설정으로 데드락 방지 가능

단점

  • 구현 복잡도 높음

  • 락 획득 실패 시 재시도 로직 필요

  • 외부 장애에 민감 (Redis, ZK 장애 시 영향)

카프카를 통해서 비동기로 처리한다

장점

  • 이벤트 기반 비동기 처리 가능

  • 중복 방지 처리를 Kafka Consumer에서 집중 제어 가능

단점

  • 중복 메시지 방지 로직을 애플리케이션에서 직접 구현해야 함

  • Kafka 자체의 At least once 특성으로 멱등성 보장 필요

DB를 이용한 낙관적 락을 건다

장점

  • DB 레벨에서 트랜잭션 충돌 방지 가능

  • 재시도 전략과 함께 쓰면 신뢰성 높음

  • 추가 인프라 필요 없음

단점

  • 버전 충돌 시 로직 복잡도 증가

  • 대량 이벤트 동시 처리 시 성능 병목 가능성

  • 개발자가 충돌 처리 전략을 명확히 설계해야 함

개선 방안

결과적으로 제가 선택한 방법은 분산락을 사용하는 것 입니다.

그 이유는 다음과 같습니다.

  • 먼저 로컬 락을 사용하는 것은 MSA 환경에서 동시성을 해결해줄 수 있지 않기 때문에 제외

  • 카프카를 이용한 방법의 경우 비지니스 기능이 부분 완료가 가능한 형태여서 단순 key로는 멱등성을 보장할 수 없어서 사용할 수 없다고 판단

  • 낙관적락의 경우 작업의 영역과 재고의 영역은 도메인이 다르기 때문에 트랜잭션인 분리되어 있는데 현재 분산트랜잭션을 구현하기에는 너무 큰 비용이라고 판단

  • 그 당시에 재고가 간혈적으로 틀어지고 있었기 때문에 따르게 대응하기 위해서 분산락을 사용하는 것으로 결정

제한된 시간과 자원 내에서 선택할 수 있는 방법은 분산락을 사용하는 것이였고 저는 이러한 분산락을 더 간편하게 사용할 수 있으면 좋겠다고 생각해서 다음과 같이 구현을 했습니다.

리서치를 해보니 컬리에서 저와 비슷한 고민을 했었고 분산락을 어노테이션으로 구현하는 것을 보고서 굉장히 좋은 방법이라고 생각을 해서 참고하게되었습니다.

Link : 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

  • DistributedLock.java
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String[] keys();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 0L;

    long leaseTime() default 3L;
}

저는 락 사용을 좀 더 편하게 하기 위해서 커스텀을 했습니다. 먼저 key를 여러개 넣을 수 있도록 해서 사용의 편의성을 높였는데 각각의 key가 멀티로 락을 거는 것이 아니라 concat의 형태로 하나의 key를 만들어서 동작하도록 되어 있습니다.

  • DistributedLockAop.java
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedisLockExecutor redisLockExecutor;

    @Around("@annotation(com.techtaka.argo.wms.job.common.util.lock.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String totalKey = REDISSON_LOCK_PREFIX + Arrays.stream(distributedLock.keys())
            .map(key -> getDynamicValueOrPlainValue(signature.getParameterNames(), joinPoint.getArgs(), key))
            .collect(Collectors.joining("-"));

        log.info("try lock : (key : {})", totalKey);
        RedisLockExecutor.ExecutionResult<Object> result = redisLockExecutor.execute(
            distributedLock.waitTime(),
            distributedLock.leaseTime(),
            distributedLock.timeUnit(),
            totalKey,
            joinPoint::proceed
        );
        if (result.hasException()) {
            throw result.getException();
        }

        return result.getData();
    }

    private String getDynamicValueOrPlainValue(String[] parameterNames, Object[] args, String key) {
        try {
            return String.valueOf(CustomSpringELParser.getDynamicValue(parameterNames, args, key));
        } catch (Exception exception) {
            return key;
        }
    }
}
  • CustomSpringELParser.java
public class CustomSpringELParser {
    private CustomSpringELParser() {
    }

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

CustomSpringELParser 에서는 파라미터의 값을 동적으로 가지고 오기 위해서 문자열을 실제 파라미터 값으로 변환하는 작업을 처리합니다.

"#request.firstParam" -> request class에서 firstParam 필드 값

간단한 예시로는 이렇게 동작하도록 되어 있습니다.

결과

문제 해결 성과

기존 문제: 피킹 완료 이벤트가 간헐적으로 중복 요청되어 재고가 이중 반영되는 이슈가 발생

적용 조치: Redis 기반 분산 락(Distributed Lock) 적용

결과: 중복 요청이 완전히 사라짐 → 모니터링 그래프를 통해 명확하게 확인됨


구현 방식 개선을 통한 개발 생산성 향상

변경 전: 직접 Executor 방식변경 전

         ... 다른 로직
         RedisLockExecutor.ExecutionResult<Object> result = redisLockExecutor.execute(
            distributedLock.waitTime(),
            distributedLock.leaseTime(),
            distributedLock.timeUnit(),
            totalKey,
            joinPoint::proceed
        );
        if (result.hasException()) {
            throw result.getException();
        }

        return result.getData();
  • 구현 복잡도 높음

  • 재사용 어려움

변경 후: 어노테이션 방식 간소화

    @DistributedLock(keys = {"#someId"})
    @PostMapping("/{someId}")
    public SomeResponse somMethod(@PathVariable String someId) {
    ...
  • 한 줄로 분산 락 적용 가능

  • 개발자가 비즈니스 로직 구현에 집중 가능

  • 코드 일관성과 유지보수성 개선

  • 현재 WMS의 4개 이상의 도메인에서 분산락 어노테이션을 이용한 방법을 적극적으로 실사용

종합적으로 정리하면 다음과 같습니다

  • 문제가 되었던 피킹 완료 중복 이벤트 완전 방지

  • 개발 생산성 향상 → 4개 이상의 도메인에서 분산락 어노테이션을 이용한 방법을 적극적으로 실사용

  • 고객 경험 개선 (정확한 재고 처리로 품질 신뢰성 확보)

느낀점

이번 기회를 통해서 락의 종류와 문제를 해결하기위해서는 락을 사용하지 않는 방법도 많이 고민을 했었습니다.

학교에서 이론으로 공부를 할 때에는 여러가지 시도를 해볼 수 있었지만 실제 현업에서는 이미 만들어진 거대한 성이 있어서 현실적인 제약 조건들이 많다는 것을 느꼈고 그 와중에서도 트레이드 오프를 생각해서 최선의 선택을 하는 것이 엔지니어로서의 역할이 아닌가 생각하게 되네요.

0
Subscribe to my newsletter

Read articles from 조현준 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

조현준
조현준