Pink Spider/분산락(distributed lock) deep dive

Created Fri, 13 Feb 2026 11:14:05 +0900 Modified Fri, 13 Feb 2026 11:49:46 +0900
4148 Words 19 min

분산 시스템에서 여러 대의 서버가 동일한 자원에 동시에 접근할 때, 데이터의 일관성을 유지하기 위해 사용하는 기술이 바로 **분산락(Distributed Lock)**입니다.

단일 서버 환경에서는 synchronizedReentrantLock 같은 자바 표준 API로 동시성을 제어할 수 있지만, 서버가 여러 대라면 각 서버의 로컬 락은 서로를 알지 못합니다. 이때 모든 서버가 공통으로 바라보는 외부 저장소를 활용해 “누가 자원을 선점했는지"를 기록하는 것이 핵심입니다.


1. 분산락이 필요한 이유

동일한 DB 레코드에 대해 여러 인스턴스가 동시에 수정 요청을 보낼 때, **레이스 컨디션(Race Condition)**이 발생하여 데이터가 꼬일 수 있습니다.

  • 재고 관리: 한정판 운동화 재고가 1개인데, 서버 A와 B가 동시에 주문을 처리하면 재고가 -1이 될 수 있습니다.
  • 선착순 이벤트: 쿠폰 발급 등 정확한 수량 제한이 필요한 경우.
  • 중복 결제 방지: 동일한 결제 요청이 짧은 시간 내에 두 번 들어올 때 하나만 처리해야 하는 경우.

2. 분산락 구현의 3대장

보통 인메모리 DB인 Redis나 코디네이터 시스템인 ZooKeeper를 많이 사용합니다.

1) Redis (Redisson 라이브러리 방식)

가장 대중적인 방식입니다. 특히 RedissonPub/Sub 기능을 활용해 효율적으로 동작합니다.

  • 특징: 락이 해제될 때까지 계속 요청을 보내는(Spin Lock) 대신, 락이 해제되었다는 메시지를 받으면 그때 다시 시도합니다.
  • 장점: 구현이 쉽고 성능이 매우 빠릅니다.
  • 단점: Redis 노드가 다운될 경우를 대비해 Redlock 알고리즘 같은 복잡한 설정이 필요할 수 있습니다.

2) ZooKeeper

분산 코디네이터인 ZooKeeper의 Znode를 활용합니다.

  • 특징: 클라이언트가 세션을 유지하며 락용 노드를 생성합니다. 클라이언트 장애 시 세션이 끊기면 자동으로 락이 해제되어 안전합니다.
  • 장점: 매우 높은 안정성과 정밀함을 보장합니다.
  • 단점: Redis에 비해 상대적으로 무겁고 인프라 구축 비용이 큽니다.

3) MySQL (Named Lock)

별도의 인프라 없이 기존 DB를 활용하는 방식입니다.

  • 특징: GET_LOCK() 함수를 사용해 문자열 형태의 락을 획득합니다.
  • 장점: 추가 비용 없이 즉시 적용 가능합니다.
  • 단점: 커넥션 풀 관리가 까다롭고, 과도한 요청 시 DB 본연의 성능에 영향을 줄 수 있습니다.

3. 구현 시 주의사항 (Critical Points)

  • 타임아웃(Timeout): 락을 획득한 서버가 갑자기 죽어버리면 락이 영원히 풀리지 않을 수 있습니다. 반드시 **유효 시간(TTL)**을 설정해야 합니다.
  • 원자성(Atomicity): “락이 있는지 확인"하고 “락을 획득"하는 과정은 중간에 끊김 없이 한 번에(Atomic) 일어나야 합니다.
  • 락 해제 권한: 자신이 획득한 락은 반드시 자신만 해제해야 합니다. (A가 가진 락을 B가 풀어버리면 안 됨)

요약 비교 테이블

구분 Redis (Redisson) ZooKeeper MySQL (Named Lock)
성능 매우 빠름 보통 상대적으로 느림
신뢰성 보통 (데이터 유실 가능성 존재) 매우 높음 높음
난이도 낮음 높음 낮음
주사용처 일반적인 서비스 동시성 제어 금융, 메타데이터 관리 등 고신뢰성 인프라 추가가 부담스러운 소규모 서비스

Spring Boot 예시

Spring Boot 환경에서 분산락을 구현할 때 가장 많이 사용되는 Redisson 라이브러리를 기준으로 설명해 드릴게요.

Redisson은 Redis 기반의 클라이언트지만, 직접 SETNX 명령어를 사용하는 Lettuce와 달리 Pub/Sub 방식을 사용하여 Redis에 가해지는 부하를 줄이고, Lock 자동 갱신(Watchdog) 기능을 제공해 안정성이 높습니다.


1. 프로젝트 설정 (build.gradle)

가장 먼저 Redisson Spring Boot Starter 의존성을 추가합니다.

dependencies {
    // Redisson Spring Boot Starter
    implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
}

2. Redisson 설정 클래스 (RedissonConfig)

Redis 서버와의 연결 정보를 설정합니다. (기본값인 localhost:6379를 사용한다면 생략 가능하지만, 실무에서는 보통 커스텀 설정을 합니다.)

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host:127.0.0.1}")
    private String redisHost;

    @Value("${spring.data.redis.port:6379}")
    private int redisPort;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }
}

3. 서비스 계층에서의 사용 예시

가장 일반적인 try-finally 패턴을 사용한 방식입니다.

@Service
@RequiredArgsConstructor
public class InventoryService {

    private final RedissonClient redissonClient;
    private final InventoryRepository inventoryRepository;

    public void decreaseStock(Long productId, Long quantity) {
        // 1. 락의 키 이름을 지정 (예: 상품 ID별로 락을 검)
        RLock lock = redissonClient.getLock("PRODUCT_LOCK:" + productId);

        try {
            // 2. 락 획득 시도 (waitTime: 락을 기다리는 시간, leaseTime: 점유 시간)
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

            if (!available) {
                System.out.println("락 획득 실패");
                return;
            }

            // 3. 비즈니스 로직 수행 (예: 재고 감소)
            Inventory inventory = inventoryRepository.findById(productId).orElseThrow();
            inventory.decrease(quantity);
            inventoryRepository.save(inventory);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 4. 반드시 락을 해제 (본인이 획득한 락인지 확인 권장)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

4. 핵심 체크포인트 (Spring Boot 연동 시)

  1. 트랜잭션 시점 주의 (@Transactional): 가장 흔히 하는 실수입니다. @Transactional 안에서 락을 획득하고 해제하면, 락이 해제된 후 트랜잭션 커밋이 일어나기 전의 찰나에 다른 서버가 락을 획득하여 동시성 문제가 생길 수 있습니다.
  • 해결책: 락 획득 로직이 트랜잭션보다 바깥쪽(Facade 클래스 등)에서 실행되도록 설계해야 합니다.
  1. WaitTime vs LeaseTime:
  • waitTime: 이 시간 동안 락을 못 얻으면 false를 반환하고 포기합니다.
  • leaseTime: 락을 얻은 후 이 시간이 지나면 자동으로 풀립니다. 로직 수행 시간보다 넉넉하게 잡아야 합니다.
  1. AOP 활용: 매번 try-finally를 쓰기 번거롭다면, 커스텀 어노테이션(예: @DistributedLock)을 만들고 AOP로 락 획득/해제를 공통 처리하는 방식을 선호합니다.

@Transactional과 분산락을 안전하게 같이 사용하는 AOP 구현 코드

@Transactional과 분산락을 함께 사용할 때 가장 큰 함정은 데이터베이스 커밋 시점입니다.

트랜잭션이 끝나기 전에 락이 먼저 해제되면, DB에 데이터가 반영되기 직전의 찰나에 다른 스레드가 락을 채가서 변경 전 데이터를 읽는 문제가 발생합니다. 이를 해결하기 위해서는 락 획득 → 트랜잭션 시작 → 비즈니스 로직 → 트랜잭션 종료 → 락 해제 순서를 엄격히 지켜야 합니다.


1. 커스텀 어노테이션 정의

먼저 락의 이름이나 타임아웃 설정을 파라미터로 받을 어노테이션을 만듭니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key(); // 락의 식별자 (SpEL 지원용)
    long waitTime() default 5L; // 락 획득 대기 시간 (초)
    long leaseTime() default 3L; // 락 점유 시간 (초)
}

2. AOP를 통한 락 비즈니스 로직 분리

여기서 핵심은 AopForTransaction이라는 별도의 컴포넌트를 만들어 **새로운 트랜잭션(REQUIRES_NEW)**을 강제로 시작하는 것입니다. 이렇게 해야 부모 트랜잭션과 별개로 로직이 완료되자마자 DB 커밋이 일어나고, 그 이후에 비즈니스 로직 밖에서 락이 안전하게 해제됩니다.

@Aspect
@Component
@RequiredArgsConstructor
public class LockAspect {

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        // 1. 락 키 파싱 (Method Signature 및 파라미터 추출)
        String key = CustomSpringELParser.getDynamicValue(
            joinPoint.getSignature().getName(), 
            ((MethodSignature) joinPoint.getSignature()).getParameterNames(), 
            joinPoint.getArgs(), 
            distributedLock.key()
        );

        RLock rLock = redissonClient.getLock(key);

        try {
            // 2. 락 획득 시도
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), TimeUnit.SECONDS);
            if (!available) return false;

            // 3. 트랜잭션 분리 수행 (로직 실행 후 커밋까지 완료)
            return aopForTransaction.proceed(joinPoint);
            
        } finally {
            try {
                // 4. 로직이 완전히 커밋된 후 락 해제
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already Unlocked");
            }
        }
    }
}

/**
 * 별도의 트랜잭션을 보장하기 위한 컴포넌트
 */
@Component
public class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

3. 실제 사용 예시

이제 서비스 레이어에서 매우 깔끔하게 어노테이션 하나로 분산락을 적용할 수 있습니다.

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    @DistributedLock(key = "'PRODUCT:' + #productId")
    public void decreaseStock(Long productId, Long quantity) {
        Stock stock = stockRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("상품 없음"));
        
        stock.decrease(quantity);
        // 별도의 save() 호출 없이도 트랜잭션 종료 시 더티 체킹으로 커밋됨
    }
}

4. 왜 이렇게 해야 하나요?

  • 트랜잭션 격리 수준 준수: REQUIRES_NEW를 통해 비즈니스 로직이 끝나는 즉시 DB에 변경 사항을 물리적으로 기록(Commit)합니다. 그 직후 finally 블록에서 락이 해제되므로, 다음 스레드가 락을 얻었을 때는 항상 최신화된 데이터를 보게 됩니다.
  • 유지보수성: 비즈니스 로직 코드 안에 try-catch-finally와 Redisson 코드가 뒤섞이지 않아 가독성이 매우 좋아집니다.

주의: REQUIRES_NEW는 새로운 DB 커넥션을 점유하므로, 트래픽이 매우 높은 환경에서는 커넥션 풀(Connection Pool) 고갈에 유의해야 합니다.

MSA에서의 분산락

서비스별로 DB가 분리된 MSA(Microservices Architecture) 환경이라면, 상황이 조금 더 복잡해지고 “상관이 아주 많아집니다.”

단일 DB(Monolithic) 환경에서는 그나마 DB 트랜잭션으로 최후의 보루를 만들 수 있었지만, MSA에서는 데이터 일관성을 맞추는 난이도가 수직 상승하기 때문입니다.


1. MSA에서 분산락이 필요한 이유

MSA에서는 서비스 A가 DB A를, 서비스 B가 DB B를 가집니다. 이때 특정 비즈니스 로직이 두 서비스를 모두 거쳐야 한다면, 전역적인 동기화가 필요합니다.

  • 상황: ‘주문 서비스’에서 재고를 확인하고 ‘결제 서비스’에서 결제를 진행함.
  • 문제: 동일한 사용자가 아주 빠르게 결제 버튼을 두 번 누르면?
  • 두 요청이 서로 다른 인스턴스에 도달합니다.
  • 각 서비스는 자신의 DB만 보기 때문에, 상대방이 처리 중인지 알 수 없습니다.
  • 결국 중복 결제재고 초과 차감이 발생할 수 있습니다.

이때 Redis 같은 공통 저장소를 이용한 분산락은 여러 마이크로서비스 사이에서 “지금 이 작업은 내가 하고 있으니 기다려!“라고 말해주는 유일한 신호등 역할을 합니다.


2. MSA에서 주의해야 할 ‘트랜잭션의 한계’

앞서 설명한 @Transactional(propagation = Propagation.REQUIRES_NEW) 방식은 단일 서비스 내의 DB에만 해당됩니다. MSA에서는 다음과 같은 문제가 발생합니다.

  1. 원자성(Atomicity) 파괴: 서비스 A의 DB 커밋은 성공했는데, 네트워크 문제로 서비스 B의 API 호출이 실패한다면? 락은 풀렸는데 데이터는 꼬이게 됩니다.
  2. 커넥션 점유: 외부 API 호출(HTTP)은 DB 작업보다 훨씬 느립니다. 락을 잡은 채로 외부 API 응답을 기다리면, 전체 시스템의 처리량이 급격히 떨어집니다.

3. MSA 환경에서의 전략

MSA에서 분산락을 쓸 때는 단순히 코드 레벨의 락을 넘어 설계 패턴을 고민해야 합니다.

A. 멱등성(Idempotency) 설계

락에만 의존하지 말고, 여러 번 요청이 들어와도 결과가 같도록 설계하는 것이 우선입니다.

  • 예: 모든 요청에 request_id를 포함시켜, 이미 처리된 ID라면 무시합니다.

B. SAGA 패턴 활용

분산락으로 짧은 찰나의 동시성을 제어하고, 긴 비즈니스 흐름(주문->결제->배송)의 데이터 일관성은 Saga 패턴(보상 트랜잭션)을 통해 해결합니다.

C. 락의 범위 최소화

외부 API 호출을 락 내부에서 수행하지 마세요.

  1. 락 획득
  2. 내부 DB 로직 처리 (최단 시간)
  3. 락 해제
  4. 외부 서비스 호출 (락 해제 후 진행)

4. 결론: MSA에서 분산락은 ‘필수’인가?

  • 공유 자원이 있다면 필수: 여러 서비스가 동일한 Redis나 특정 공용 자원을 바라본다면 반드시 필요합니다.
  • 사용자 진입점 제어: 보통 MSA의 입구인 API Gateway최상위 오케스트레이터 서비스에서 분산락을 걸어 중복 진입을 막는 용도로 가장 많이 사용됩니다.

한 줄 요약: MSA에서는 DB가 찢어져 있어 서로를 모르기 때문에, 오히려 Redis 같은 제3의 중재자를 통한 분산락의 역할이 훨씬 더 중요해집니다.