분산 락은 멀티 인스턴스 환경에서의 일관된 락을 제공하기 위한 방식이며,
DB 락만으로는 처리하기 어려운 병목이나 분산 환경의 동시성 제어 문제를 해결할 수 있다.
실무에서는 다음과 같은 이유로 Redis를 활용한 분산 락이 사용된다.
- 키-값 기반 명령으로 구현이 단순하다.
- 인메모리 기반으로 매우 빠른 응답 속도를 제공한다.
- TTL(Time-To-Live) 설정을 통해 데드락 상황을 예방할 수 있다.
- Redisson, Lettuce 등 다양한 라이브러리를 통해 적용이 용이하다.
단, TTL을 설정해도 락 소유자 검증, 재진입 제어 등은 별도 구현이 필요하므로 주의해야 한다.
락 획득은 트랜잭션 외부에서 처리하고, 작업 종료 후 명시적으로 락을 해제해야 데이터 정합성을 보장할 수 있다.
- 분산락은 Simple Lock, Spin Lock, Pub/Sub Lock 세 가지 주요 유형으로 나뉘며, 각각 다른 방식으로 분산 환경에서의 동시성 문제를 해결
- 작동 방식: SET 명령어와 NX, PX 옵션을 활용해 락을 획득하고 일정 시간 후 자동 해제
- 장점: 구현이 간단하고 직관적이며 데드락 방지를 위한 TTL 설정 가능
- 단점: 락 소유자 검증 기능이 없어 다른 프로세스가 락을 해제할 위험이 있음
- 적합한 사용 사례: 짧은 시간 동안 간단한 리소스 보호가 필요한 경우
- 작동 방식: 락 획득 실패 시 일정 간격으로 계속 재시도하는 방식
- 장점: 락이 해제되는 즉시 작업 진행 가능하여 처리량 향상
- 단점: 지속적인 재시도로 서버에 부하 발생 가능, CPU 자원 소모가 큼
- 적합한 사용 사례: 락 획득 대기 시간이 짧고 예측 가능한 경우
- 작동 방식: 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();
}- 분산 락이 필요한 메서드에 적용하는 커스텀 어노테이션입니다. key: 락의 고유 식별자를 지정합니다(SpEL 표현식 사용 가능). waitTime: 락 획득을 위해 대기할 최대 시간을 설정합니다. leaseTime: 락이 자동으로 해제되는 시간을 설정하여 데드락을 방지합니다. timeUnit: 시간 단위를 지정합니다.
- @Order(1)로 트랜잭션 처리보다 먼저 실행되도록 우선순위를 부여합니다. SpEL(Spring Expression Language)을 사용하여 동적으로 락 키를 생성합니다.
- Redisson 클라이언트를 사용하여 분산 락을 구현합니다. @Around 어드바이스로 메서드 실행을 가로채 락 처리 로직을 적용합니다.
- 여러 키에 대한 다중 락(MultiLock) 기능을 지원합니다. finally 블록에서 락을 해제하여 예외 발생 시에도 락 해제를 보장합니다.
- @DistributedLock과 @Transactional을 함께 사용하여 동시성 제어와 트랜잭션 처리를 동시에 수행합니다. PROPAGATION.REQUIRES_NEW로 새로운 트랜잭션에서 실행되도록 하여 락 획득과 트랜잭션 처리를 분리합니다. 쿠폰 수량 감소 로직에 동시성 제어를 적용한 예시입니다.
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)으로 우선순위를 높게 설정하여 실제 락 획득이나 트랜잭션 처리 로직보다 먼저 실행되도록 했습니다.
테스트 결과
- LOCK_ACQUIRED - 분산락 획득
- TRANSACTION_START - 트랜잭션 시작
- TRANSACTION_END - 트랜잭션 종료
- LOCK_RELEASED - 분산락 해제