Redis 분산락

김주영·2025년 11월 7일

cs

목록 보기
8/8

1. 왜 분산락이 필요한가?

문제 상황멀티 인스턴스 환경에서 공유 자원에 대한 동시 접근 시 발생하는 문제들

  • 재고 관리: 여러 서버에서 동시에 재고를 차감할 때 정합성 깨짐
  • 쿠폰 발급: 100장 한정 쿠폰에 1000명이 동시 요청 시 중복 발급
  • 중복 발주 방지: 카프카로 동일 발주 데이터가 중복 수신
  • 이동 출고: 여러 작업자가 동시에 CTA 클릭 시 잘못된 재고 트랜잭션

💡 왜 로컬 락으로는 안 되는가?

// ❌ 이런 코드는 멀티 인스턴스에서 무용지물
public synchronized void decreaseStock(Long productId) {
    Product product = repository.findById(productId);
    product.decrease();
}

문제점: synchronized는 JVM 레벨 락이므로 서버 A와 서버 B가 각각 다른 락을 잡음 → 동시성 문제 발생
해결책: 중앙 집중식 락 스토어 필요 → Redis 선택

2. 기본 구현: SET NX + Lua 스크립트

락 획득

# NX: Not Exists (키가 없을 때만 세팅)
# PX: 밀리초 단위 TTL (자동 만료)
SET resource:lock <random-uuid> NX PX 30000

핵심 포인트

  • random-uuid: 락 소유자 식별용 (해제 시 검증)
  • TTL 설정: 클라이언트 죽어도 eventually 자동 해제

🔓 안전한 락 해제 (Lua 스크립트)

-- KEYS[1] = 락 키
-- ARGV[1] = 클라이언트의 UUID
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

왜 Lua를 써야 하는가?

// ❌ 이렇게 하면 안 됨 (race condition)
String value = redis.get("resource:lock");
if (myUuid.equals(value)) {
    redis.del("resource:lock");  // 이 사이에 다른 클라이언트가 락 획득 가능!
}

Lua 스크립트는 원자적(atomic)으로 실행되어 위 문제 해결


3. 단일 노드의 한계와 레플리케이션 문제

⚠️ SPOF (Single Point of Failure)

단일 Redis 노드 사용 시:

  • Redis 서버 다운 → 모든 락 기능 마비
  • 비핵심 자원이라면 감수 가능하지만, 금융/결제 등에선 치명적

🔄 Master-Slave 복제의 함정

시간 순서:
t0: Client A가 Master에서 락 획득
t1: Master → Slave 복제 전에 Master 장애 발생
t2: Slave가 Master로 승격 (Failover)
t3: Client B가 새 Master에서 동일한 락 획득
t4: Client A와 B 모두 락을 보유하고 있다고 믿음 💥

근본 원인: Redis의 비동기 복제(Asynchronous Replication)


4. RedLock 알고리즘 상세

🎲 기본 아이디어

N개의 독립적인 Redis 단일 노드를 사용하여, 과반(N/2+1) 이상에서 락을 획득해야 성공으로 간주

알고리즘 절차

1. 현재 시간(ms) 기록: start_time

2. N개 노드에 순차적으로 SET NX PX 시도
   - 각 노드마다 짧은 타임아웃 사용 (ex: 5~50ms)
   - 다운된 노드에서 오래 블로킹되는 것 방지

3. 경과 시간 계산: elapsed_time = now() - start_time

4. 성공 조건 체크
   - 획득한 락 개수 >= N/2 + 1
   - elapsed_time < 락 유효 시간(TTL)
   
5. 성공 시
   - 유효 시간 = TTL - elapsed_time
   
6. 실패 시
   - 모든 노드에서 락 해제 시도
   - 랜덤 지연 후 재시도

개념적 구현

public boolean acquireRedLock(String resource, String token, long ttlMs) {
    long startTime = System.currentTimeMillis();
    int successCount = 0;
    
    // N개 노드에 동시 요청 (이상적으로는 멀티플렉싱)
    for (RedisNode node : redisNodes) {
        boolean acquired = node.setNX(resource, token, ttlMs, shortTimeout);
        if (acquired) successCount++;
    }
    
    long elapsedTime = System.currentTimeMillis() - startTime;
    int quorum = redisNodes.size() / 2 + 1;
    
    if (successCount >= quorum && elapsedTime < ttlMs) {
        long validityTime = ttlMs - elapsedTime;
        return true;  // 락 획득 성공
    } else {
        // 실패 시 모든 노드 해제
        releaseAll(resource, token);
        return false;
    }
}

📊 예시: 5개 노드 환경

Redis Nodes: A, B, C, D, E
Quorum: 3 (5/2 + 1)

Case 1: 성공
- A ✅, B ✅, C ✅, D ❌, E ❌
- 3개 >= Quorum → 락 획득 성공

Case 2: 실패
- A ✅, B ❌, C ✅, D ❌, E ❌  
- 2개 < Quorum → 락 획득 실패 → 모든 노드 해제

5. RedLock의 한계 (필독)

⏰ 1) Clock Drift 문제

상황: 노드 C의 시계가 빨라지는 경우

시간 순서:
t0: Client 1이 A, B, C에서 락 획득 (D, E는 네트워크 이슈)
t1: 노드 C의 시계가 점프 → TTL 조기 만료
t2: Client 2가 C, D, E에서 락 획득 (A, B는 네트워크 이슈)
t3: Client 1과 2 모두 락 보유하고 있다고 믿음 💥

문제의 본질

RedLock은 "로컬 시간이 거의 동일한 속도로 흐른다"는 가정에 의존
현실에서는 NTP 동기화 실패, 가상화 환경의 클록 drift 등 발생

🛑 2) GC Stop-The-World 문제

시나리오: 파일 업데이트 로직

// ❌ 이 코드는 안전하지 않음
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) throw 'Failed to acquire lock';

    try {
        var file = storage.readFile(filename);      // ⬅️ 여기서 GC 발생 가능!
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

문제 발생 과정:

시간 순서:
t0: Client 1이 RedLock 획득 (TTL = 10초)
t1: Client 1이 파일 읽기
t2: Client 1에서 GC Stop-The-World 발생 (15초 지속)
t10: 락 TTL 만료
t11: Client 2가 RedLock 획득 및 파일 업데이트
t17: Client 1의 GC 종료 후 파일 업데이트 💥

네트워크 지연도 동일한 문제

  • 쓰기 요청이 네트워크 지연으로 락 만료 후 도착
  • 만료 체크와 쓰기 사이에도 GC 발생 가능

3) 펜싱 토큰(Fencing Token) 부재

해결책: 단조 증가하는 토큰으로 요청 순서 보장

// ✅ 펜싱 토큰 사용
function writeDataSafe(filename, data) {
    var lockResult = lockService.acquireLockWithToken(filename);
    var lock = lockResult.lock;
    var token = lockResult.token;  // 33, 34, 35... (증가하는 숫자)

    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        
        // 토큰과 함께 쓰기 요청
        storage.writeFile(filename, updated, token);
        // 스토리지가 작은 토큰의 쓰기를 거부
    } finally {
        lock.release();
    }
}

RedLock의 문제

  • 펜싱 토큰 생성 기능이 없음
  • 단일 노드에 카운터 → SPOF
  • 다중 노드에 카운터 → 동기화 불가
    대안: ZooKeeper, etcd 같은 합의 시스템 사용

6. Redisson 실전 구현 (Spring Boot + AOP)

Redisson vs Lettuce

구분LettuceRedisson
구현 방식setnx/setex 직접 구현Lock 인터페이스 제공
락 획득 방식Spin Lock(지속 폴링)Pub/Sub(이벤트 기반)
Redis 부하높음낮음
Retry/Timeout직접 구현내장 지원
사용 편의성낮음높음

의존성 추가

dependencies {
    implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
}

Redisson 설정

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

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

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

어노테이션 정의

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    /**
     * 락 이름 (SpEL 지원)
     * 예: "#couponName", "#model.getName().concat('-').concat(#model.getShipmentNumber())"
     */
    String key();

    /**
     * 시간 단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락 대기 시간 (기본 5초)
     * 이 시간 동안 락 획득 시도
     */
    long waitTime() default 5L;

    /**
     * 락 임대 시간 (기본 3초)
     * 이 시간이 지나면 자동 해제
     */
    long leaseTime() default 3L;
}

AOP 구현 (핵심)

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

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(DistributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);

        // 1️⃣ SpEL로 동적 키 생성
        String key = REDISSON_LOCK_PREFIX + 
            CustomSpringELParser.getDynamicValue(
                signature.getParameterNames(), 
                joinPoint.getArgs(), 
                annotation.key()
            );

        // 2️⃣ RLock 인스턴스 획득
        RLock rLock = redissonClient.getLock(key);

        try {
            // 3️⃣ 락 획득 시도 (waitTime 동안 대기, leaseTime 후 자동 해제)
            boolean available = rLock.tryLock(
                annotation.waitTime(), 
                annotation.leaseTime(), 
                annotation.timeUnit()
            );

            if (!available) {
                log.warn("Failed to acquire lock: {}", key);
                return false;  // 또는 커스텀 예외
            }

            // 4️⃣ 별도 트랜잭션으로 비즈니스 로직 실행
            return aopForTransaction.proceed(joinPoint);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw e;
        } finally {
            // 5️⃣ 락 해제 (반드시 실행)
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Already unlocked or not owner: {}", key);
            }
        }
    }
}

트랜잭션 분리 컴포넌트

@Component
public class AopForTransaction {
    /**
     * REQUIRES_NEW: 부모 트랜잭션과 무관하게 새 트랜잭션 시작
     * 이렇게 해야 커밋 → unlock 순서 보장
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

SpEL 파서 구현

public class CustomSpringELParser {
    public static Object getDynamicValue(
        String[] parameterNames, 
        Object[] args, 
        String expression
    ) {
        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(expression)
                     .getValue(context, Object.class);
    }
}

💼 실제 사용 예시

@Service
@RequiredArgsConstructor
public class CouponService {
    private final CouponRepository couponRepository;

    // 예시 1: 단순 파라미터
    @DistributedLock(key = "#couponName", waitTime = 5, leaseTime = 3)
    public void issueCoupon(String couponName, Long userId) {
        Coupon coupon = couponRepository.findByName(couponName)
            .orElseThrow(() -> new IllegalArgumentException("쿠폰 없음"));
        
        coupon.decrease();
        coupon.addUser(userId);
    }

    // 예시 2: 복합 키 (SpEL)
    @DistributedLock(
        key = "#model.getName() + '-' + #model.getShipmentNumber()",
        waitTime = 10,
        leaseTime = 5
    )
    public void processShipment(ShipmentModel model) {
        // 출고 처리 로직
    }

    // 예시 3: 재고 차감
    @DistributedLock(key = "'product:' + #productId", leaseTime = 2)
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("상품 없음"));
        
        product.decreaseStock(quantity);
    }
}

7. 트랜잭션 커밋 순서가 중요한 이유

❌ 잘못된 순서: 락 해제 → 커밋

시간 순서 (재고 10개):

t0: Client1과 Client2가 동시에 요청
t1: Client1이 락 획득 → 재고 조회(10개)
t2: Client1이 재고 차감(10-1=9) → 락 해제 ❌
t3: Client2가 락 획득 → 재고 조회(10개) ⚠️ 아직 커밋 안 됨!
t4: Client1의 트랜잭션 커밋(DB에 9 반영)
t5: Client2가 재고 차감(10-1=9) → 락 해제 → 커밋(DB에 9 반영)

결과: 2명이 차감했는데 재고는 1개만 줄어듦 💥

✅ 올바른 순서: 커밋 → 락 해제

시간 순서 (재고 10개):

t0: Client1과 Client2가 동시에 요청
t1: Client1이 락 획득 → 재고 조회(10개)
t2: Client1이 재고 차감(10-1=9) → 트랜잭션 커밋 ✅
t3: Client1이 락 해제
t4: Client2가 락 획득 → 재고 조회(9개) ✅ 최신 데이터!
t5: Client2가 재고 차감(9-1=8) → 커밋 → 락 해제

결과: 재고가 정확히 2개 줄어듦 ✨

📊 시각화

잘못된 순서 (데이터 정합성 깨짐):
Client1: [락획득] → [조회:10] → [차감:9] → [락해제] → [커밋]
Client2:                              [락획득] → [조회:10] → [차감:9] → [커밋]
                                      ↑ 여기서 조회하면 10개!

올바른 순서 (데이터 정합성 보장):
Client1: [락획득] → [조회:10] → [차감:9] → [커밋] → [락해제]
Client2:                                            [락획득] → [조회:9] → [차감:8] → [커밋]
                                                    ↑ 여기서 조회하면 9개!

💡 구현 포인트

// ❌ 잘못된 구현
@Transactional
public void decreaseStock(Long productId) {
    RLock lock = redissonClient.getLock("product:" + productId);
    lock.lock();
    
    try {
        // 비즈니스 로직
    } finally {
        lock.unlock();  // 트랜잭션 커밋 전에 unlock!
    }
}

// ✅ 올바른 구현 (AOP 사용)
@DistributedLock(key = "'product:' + #productId")
public void decreaseStock(Long productId) {
    // AOP가 알아서 처리:
    // 1. 락 획득
    // 2. 별도 트랜잭션으로 이 메서드 실행
    // 3. 트랜잭션 커밋
    // 4. 락 해제
}

8. 실전 테스트 시나리오

🎟️ Case 1: 쿠폰 차감 테스트

시나리오: KURLY_001 쿠폰 100장을 100명이 동시에 발급 요청
엔티티

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Coupon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Long availableStock;

    public Coupon(String name, Long availableStock) {
        this.name = name;
        this.availableStock = availableStock;
    }

    public void decrease() {
        validateStockCount();
        this.availableStock -= 1;
    }

    private void validateStockCount() {
        if (availableStock < 1) {
            throw new IllegalArgumentException("재고 부족");
        }
    }
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final CouponRepository couponRepository;

    // 분산락 O
    @DistributedLock(key = "#couponName")
    public void decreaseWithLock(String couponName, Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow();
        coupon.decrease();
    }

    // 분산락 X
    @Transactional
    public void decreaseWithoutLock(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow();
        coupon.decrease();
    }
}

테스트 코드

@SpringBootTest
class CouponConcurrencyTest {
    @Autowired CouponService couponService;
    @Autowired CouponRepository couponRepository;

    private Coupon coupon;

    @BeforeEach
    void setUp() {
        coupon = new Coupon("KURLY_001", 100L);
        couponRepository.save(coupon);
    }

    @Test
    @DisplayName("분산락 적용 - 100명 동시 요청 시 정확히 100개 차감")
    void withLock() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    couponService.decreaseWithLock(
                        coupon.getName(), 
                        coupon.getId()
                    );
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Coupon result = couponRepository.findById(coupon.getId()).orElseThrow();
        
        // ✅ 정확히 0개
        assertThat(result.getAvailableStock()).isZero();
        System.out.println("잔여 쿠폰: " + result.getAvailableStock());
    }

    @Test
    @DisplayName("분산락 미적용 - 100명 동시 요청 시 정합성 깨짐")
    void withoutLock() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    couponService.decreaseWithoutLock(coupon.getId());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Coupon result = couponRepository.findById(coupon.getId()).orElseThrow();
        
        // ❌ 79개 남음 (21개만 차감됨)
        System.out.println("잔여 쿠폰: " + result.getAvailableStock());
        assertThat(result.getAvailableStock()).isNotZero();
    }
}

테스트 결과

✅ 분산락 적용:
잔여 쿠폰: 0 (정확히 100개 차감)

❌ 분산락 미적용:
잔여 쿠폰: 79 (21개만 차감, 동시성 문제 발생)

Case 2: 중복 발주 방지 테스트

시나리오: KURLY_001 발주 10건이 동시에 중복 수신
엔티티

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)  // DB 레벨 방어선
    private String code;

    public Purchase(String code) {
        this.code = code;
    }
}

서비스

@Service
@RequiredArgsConstructor
public class PurchaseService {
    private final PurchaseRepository purchaseRepository;

    // 분산락 O
    @DistributedLock(key = "#code")
    public void registerWithLock(String code) {
        if (purchaseRepository.existsByCode(code)) {
            throw new IllegalArgumentException("중복 발주");
        }
        purchaseRepository.save(new Purchase(code));
    }

    // 분산락 X
    @Transactional
    public void registerWithoutLock(String code) {
        if (purchaseRepository.existsByCode(code)) {
            throw new IllegalArgumentException("중복 발주");
        }
        purchaseRepository.save(new Purchase(code));
    }
}

테스트 코드

@SpringBootTest
class PurchaseConcurrencyTest {
    @Autowired PurchaseService purchaseService;
    @Autowired PurchaseRepository purchaseRepository;

    @Test
    @DisplayName("분산락 적용 - 10건 중복 요청 시 1건만 등록")
    void withLock() throws InterruptedException {
        String code = "KURLY_001";
        int threadCount = 10;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    purchaseService.registerWithLock(code);
                } catch (Exception e) {
                    // 락 획득 실패 또는 중복 예외
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        long count = purchaseRepository.countByCode(code);
        
        // ✅ 정확히 1건
        assertThat(count).isOne();
        System.out.println("등록된 발주: " + count);
    }

    @Test
    @DisplayName("분산락 미적용 - 10건 중복 요청 시 여러 건 등록")
    void withoutLock() throws InterruptedException {
        String code = "KURLY_001";
        int threadCount = 10;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    purchaseService.registerWithoutLock(code);
                } catch (Exception e) {
                    // 일부는 DB unique 제약으로 실패
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        long count = purchaseRepository.countByCode(code);
        
        // ❌ 4~7건 등록됨 (환경에 따라 다름)
        System.out.println("등록된 발주: " + count);
        assertThat(count).isGreaterThan(1);
    }
}

테스트 결과

✅ 분산락 적용:
등록된 발주: 1

❌ 분산락 미적용:
등록된 발주: 5 (connection pool 크기에 따라 다름)

9. 베스트 프랙티스

락 설계 원칙

  1. 키 네이밍 전략
// ✅ Good: 자원 단위로 세분화
LOCK:coupon:{couponId}
LOCK:product:{productId}:stock
LOCK:purchase:{code}
LOCK:user:{userId}:payment

// ❌ Bad: 너무 넓은 범위
LOCK:all-products  // 모든 상품 차감이 직렬화됨
  1. TTL 설정
// 공식: TTL = 최악의 처리 시간 + 여유 버퍼
@DistributedLock(
    key = "#productId",
    leaseTime = 5,  // 평균 1초 + 버퍼 4초
    waitTime = 10   // 대기 허용 시간
)

TTL 너무 짧으면: 작업 중간에 락 만료 → 중복 처리
TTL 너무 길면: 장애 시 복구 지연

3. Watchdog 활용 (Redisson)

// leaseTime을 -1로 설정하면 자동 갱신
RLock lock = redissonClient.getLock("myLock");
lock.lock();  // Watchdog가 30초마다 자동 갱신
try {
    // 긴 작업도 안전
} finally {
    lock.unlock();
}

주의: 무한 갱신 방지를 위해 최대 시간 설정 필요

정합성 확보 전략

다층 방어 (Defense in Depth)

@Service
public class PaymentService {
    
    // 1단계: 분산락
    @DistributedLock(key = "'payment:' + #orderId")
    public void processPayment(Long orderId) {
        
        // 2단계: 낙관적 잠금 (DB)
        Order order = orderRepository.findByIdWithLock(orderId);
        if (order.getVersion() != expectedVersion) {
            throw new OptimisticLockException();
        }
        
        // 3단계: 멱등성 키
        String idempotencyKey = generateKey(orderId);
        if (paymentRepository.existsByIdempotencyKey(idempotencyKey)) {
            return;  // 이미 처리됨
        }
        
        // 4단계: DB 유니크 제약
        // CREATE UNIQUE INDEX ON payments(order_id);
        
        Payment payment = new Payment(orderId, idempotencyKey);
        paymentRepository.save(payment);  // unique 제약 위반 시 예외
    }
}

DB 제약과의 조합

-- 유니크 제약으로 2차 방어선
CREATE UNIQUE INDEX idx_purchase_code ON purchase(code);
CREATE UNIQUE INDEX idx_payment_order ON payments(order_id);

-- 체크 제약으로 음수 방지
ALTER TABLE product ADD CONSTRAINT chk_stock CHECK (stock >= 0);

모니터링 전략

@Aspect
@Component
@Slf4j
public class LockMonitoringAspect {
    
    private final MeterRegistry meterRegistry;
    
    @Around("@annotation(DistributedLock)")
    public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
        String lockName = getLockName(pjp);
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            Object result = pjp.proceed();
            
            // 성공 메트릭
            meterRegistry.counter("distributed.lock.acquired",
                "lock", lockName,
                "result", "success"
            ).increment();
            
            return result;
        } catch (Exception e) {
            // 실패 메트릭
            meterRegistry.counter("distributed.lock.acquired",
                "lock", lockName,
                "result", "failed"
            ).increment();
            throw e;
        } finally {
            // 대기 시간 기록
            sample.stop(meterRegistry.timer("distributed.lock.wait.time",
                "lock", lockName
            ));
        }
    }
}

모니터링 지표

  • 락 획득 성공/실패율
  • 락 대기 시간 (P50, P95, P99)
  • TTL 만료로 인한 자동 해제 비율
  • 핫키 감지 (특정 락의 높은 경합)

⚙️ 운영 체크리스트

✅ 락 획득 실패 시 재시도 전략

  • Exponential backoff with jitter
  • 최대 재시도 횟수 제한

✅ 락 해제 실패 대응

  • IllegalMonitorStateException 핸들링
  • TTL 기반 자동 해제 보장

✅ 데드락 방지

  • 락 순서 일관성 유지
  • 타임아웃 반드시 설정

✅ Redis 장애 대응

  • Fallback 전략 (graceful degradation)
  • Circuit Breaker 패턴 적용

✅ 부하 테스트

  • nGrinder로 동시성 시나리오 검증
  • Chaos Engineering (Redis 다운 시뮬레이션)

📋 의사결정 매트릭스

요구사항단일 RedisRedLockZooKeeper
구현 복잡도낮음⭐⭐ 중간⭐⭐⭐ 높음
운영 복잡도낮음⭐⭐ 높음 (N개 노드)⭐⭐⭐ 높음
성능⭐⭐⭐⭐ 빠름⭐⭐ 중간느림 (합의 오버헤드)
안전성⭐⭐ 중간⭐⭐⭐ 높음⭐⭐⭐⭐ 매우 높음
SPOF 위험있음낮음없음 (Quorum)
클럭 의존성낮음높음낮음 (논리 시계)
펜싱 토큰없음없음있음

실무 권장사항

  1. 대부분의 경우: 단일 Redis + Redisson + 다층 방어
// 이 조합이면 99% 케이스 해결
@DistributedLock(key = "#orderId")
public void processOrder(Long orderId) {
    // + DB 유니크 제약
    // + 낙관적 잠금
    // + 멱등성 키
}
  1. 높은 가용성 필요: RedLock (한계 인지 필수)
    적용 대상
  • 페일오버 중에도 락 유지 필요
  • 단일 장애점을 용납할 수 없는 서비스
  • 다만 금융급 정확성은 아닌 경우

주의사항

  • Clock drift 모니터링 필수
  • NTP 동기화 상태 체크
  • GC 튜닝 (STW 최소화)
  • 네트워크 파티션 시나리오 테스트
  1. 절대적 정확성: ZooKeeper + 펜싱 토큰
  • 적용 대상
  • 금융 거래
  • 결제 처리
  • 재고 오차 0 요구
  • 중복 절대 불가

📚 참고 자료

https://github.com/redisson/redisson
https://helloworld.kurly.com/blog/distributed-redisson-lock/

0개의 댓글