Skip to main content

Command Palette

Search for a command to run...

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

분산락을 어노테이션으로 사용할 수 있도록 해보자

Updated
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개 이상의 도메인에서 분산락 어노테이션을 이용한 방법을 적극적으로 실사용

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

느낀점

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

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

35 views

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ