Skip to content

Latest commit

 

History

History
272 lines (207 loc) · 9.78 KB

File metadata and controls

272 lines (207 loc) · 9.78 KB

분산락 보고서


1. 분산락

분산 락은 멀티 인스턴스 환경에서의 일관된 락을 제공하기 위한 방식이며,
DB 락만으로는 처리하기 어려운 병목이나 분산 환경의 동시성 제어 문제를 해결할 수 있다.

실무에서는 다음과 같은 이유로 Redis를 활용한 분산 락이 사용된다.

  • 키-값 기반 명령으로 구현이 단순하다.
  • 인메모리 기반으로 매우 빠른 응답 속도를 제공한다.
  • TTL(Time-To-Live) 설정을 통해 데드락 상황을 예방할 수 있다.
  • Redisson, Lettuce 등 다양한 라이브러리를 통해 적용이 용이하다.

단, TTL을 설정해도 락 소유자 검증, 재진입 제어 등은 별도 구현이 필요하므로 주의해야 한다.

락 획득은 트랜잭션 외부에서 처리하고, 작업 종료 후 명시적으로 락을 해제해야 데이터 정합성을 보장할 수 있다.

2. 분산락의 종류

  • 분산락은 Simple Lock, Spin Lock, Pub/Sub Lock 세 가지 주요 유형으로 나뉘며, 각각 다른 방식으로 분산 환경에서의 동시성 문제를 해결

2-1. Simple Lock

  • 작동 방식: SET 명령어와 NX, PX 옵션을 활용해 락을 획득하고 일정 시간 후 자동 해제
  • 장점: 구현이 간단하고 직관적이며 데드락 방지를 위한 TTL 설정 가능
  • 단점: 락 소유자 검증 기능이 없어 다른 프로세스가 락을 해제할 위험이 있음
  • 적합한 사용 사례: 짧은 시간 동안 간단한 리소스 보호가 필요한 경우

2-2. Spin Lock

  • 작동 방식: 락 획득 실패 시 일정 간격으로 계속 재시도하는 방식
  • 장점: 락이 해제되는 즉시 작업 진행 가능하여 처리량 향상
  • 단점: 지속적인 재시도로 서버에 부하 발생 가능, CPU 자원 소모가 큼
  • 적합한 사용 사례: 락 획득 대기 시간이 짧고 예측 가능한 경우

2-3. Pub/Sub Lock

  • 작동 방식: Redis의 발행-구독 모델을 활용해 락 해제 시 대기 중인 클라이언트에게 알림
  • 장점: 불필요한 재시도 없이 효율적인 대기가 가능하고 서버 부하 감소
  • 단점: 구현이 복잡하며 메시지 전달 과정에서 추가 지연 발생 가능
  • 적합한 사용 사례: 락 경쟁이 심하고 대기 시간이 길 수 있는 고부하 환경

구현 방법 (어노테이션과 AOP를 활용한 Redis 분산 락 적용)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();
    long waitTime() default 5L;
    long leaseTime() default 3L;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}


@Aspect
@Component
@Order(1)
public class DistributedLockAspect {
    private static final String LOCK_PREFIX = "lock:";
    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();

    public DistributedLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Around("@annotation(distributedLock)")
    public Object executeWithLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        StandardEvaluationContext context = new StandardEvaluationContext();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] parameterNames = signature.getParameterNames();
        Object[] args = joinPoint.getArgs();

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

        List<String> keys = parser.parseExpression(distributedLock.key()).getValue(context, List.class);

        List<RLock> locks = keys.stream()
                .map(key -> LOCK_PREFIX + key)
                .map(redissonClient::getLock)
                .toList();

        RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
        try {
            boolean isLocked = multiLock.tryLock(
                    distributedLock.waitTime(),
                    distributedLock.leaseTime(),
                    distributedLock.timeUnit()
            );

            if (!isLocked) throw new RuntimeException("LOCK_ACQUISITION_FAILED");

            return joinPoint.proceed();
        } finally {
            multiLock.unlock();
        }
    }
}

@DistributedLock(key = DECREASE_COUPON_LOCK)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decreaseCouponQuantityAfterCheckLock(long couponId) {
    CouponEntity coupon = couponRepository.findCouponById(couponId);
    coupon.validateForPublish();
    coupon.decreaseQuantity();
}

@DistributedLock 어노테이션

  • 분산 락이 필요한 메서드에 적용하는 커스텀 어노테이션입니다. key: 락의 고유 식별자를 지정합니다(SpEL 표현식 사용 가능). waitTime: 락 획득을 위해 대기할 최대 시간을 설정합니다. leaseTime: 락이 자동으로 해제되는 시간을 설정하여 데드락을 방지합니다. timeUnit: 시간 단위를 지정합니다.

DistributedLockAspect 클래스

  • @Order(1)로 트랜잭션 처리보다 먼저 실행되도록 우선순위를 부여합니다. SpEL(Spring Expression Language)을 사용하여 동적으로 락 키를 생성합니다.
  • Redisson 클라이언트를 사용하여 분산 락을 구현합니다. @Around 어드바이스로 메서드 실행을 가로채 락 처리 로직을 적용합니다.
  • 여러 키에 대한 다중 락(MultiLock) 기능을 지원합니다. finally 블록에서 락을 해제하여 예외 발생 시에도 락 해제를 보장합니다.

실제 비즈니스 메서드

  • @DistributedLock과 @Transactional을 함께 사용하여 동시성 제어와 트랜잭션 처리를 동시에 수행합니다. PROPAGATION.REQUIRES_NEW로 새로운 트랜잭션에서 실행되도록 하여 락 획득과 트랜잭션 처리를 분리합니다. 쿠폰 수량 감소 로직에 동시성 제어를 적용한 예시입니다.

3. 트랜잭션, 락 순서 테스트

package kr.hhplus.be.server.concurrency;

import kr.hhplus.be.server.config.ApplicationContext;
import kr.hhplus.be.server.domain.coupon.CouponService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import java.util.List;

import static org.junit.Assert.assertEquals;

public class AopSequenceTest extends ApplicationContext {
    @Autowired
    private CouponService couponService;

    @Autowired
    private ExecutionOrderRecorder recorder;

    @TestConfiguration
    @EnableAspectJAutoProxy
    static class TestConfig {

        @Bean
        public ExecutionOrderRecorder executionOrderRecorder() {
            return new ExecutionOrderRecorder();
        }

        @Bean
        public ExecutionOrderAspect executionOrderAspect(ExecutionOrderRecorder recorder) {
            return new ExecutionOrderAspect(recorder);
        }
    }

    @BeforeEach
    void setUp() {
        recorder.clear();
    }

    @Test
    void 분산락과_트랜잭션_실행_순서_검증() {
        // when
        couponService.decreaseCouponQuantityAfterCheckLock(1L);

        // then
        List<String> executionOrder = recorder.getExecutionOrder();
        // 실행 순서 검증
        assertEquals("LOCK_ACQUIRED", executionOrder.get(0));
        assertEquals("TRANSACTION_START", executionOrder.get(1));
        assertEquals("TRANSACTION_END", executionOrder.get(2));
        assertEquals("LOCK_RELEASED", executionOrder.get(3));
    }
}
package kr.hhplus.be.server.concurrency;

import kr.hhplus.be.server.domain.support.DistributedLock;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Aspect
@Component
@Order(0)
class ExecutionOrderAspect {

    private final ExecutionOrderRecorder recorder;

    public ExecutionOrderAspect(ExecutionOrderRecorder recorder) {
        this.recorder = recorder;
    }
    @Around("@annotation(distributedLock)")
    public Object aroundDistributedLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        try {
            recorder.recordEvent("LOCK_ACQUIRED");
            return joinPoint.proceed();
        } finally {
            recorder.recordEvent("LOCK_RELEASED");
        }
    }

    @Around("@annotation(transactional)")
    public Object aroundTransactional(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable {
        try {
            recorder.recordEvent("TRANSACTION_START");
            return joinPoint.proceed();
        } finally {
            recorder.recordEvent("TRANSACTION_END");
        }
    }

}
@Component
class ExecutionOrderRecorder {
    private final List<String> executionOrder = new ArrayList<>();

    public void recordEvent(String eventName) {
        executionOrder.add(eventName);
    }

    public List<String> getExecutionOrder() {
        return new ArrayList<>(executionOrder);
    }

    public void clear() {
        executionOrder.clear();
    }
}
  • Aspect는 @DistributedLock과 @Transactional 어노테이션이 적용된 메서드의 실행 전후에 이벤트를 기록합니다.
  • @Order(0)으로 우선순위를 높게 설정하여 실제 락 획득이나 트랜잭션 처리 로직보다 먼저 실행되도록 했습니다.

테스트 결과

  1. LOCK_ACQUIRED - 분산락 획득
  2. TRANSACTION_START - 트랜잭션 시작
  3. TRANSACTION_END - 트랜잭션 종료
  4. LOCK_RELEASED - 분산락 해제