Redis를 활용한 분산 락(Distributed Lock) 구현하기
분산 락은 분산된 서버에서 공유 자원에 동시에 접근하는 것을 제어하기 위해 사용되는 방식으로, 락에 대한 정보를 어딘가에 공통적으로 보관하고 여러대의 서버들은 이 공통된 보관소를 바라보며 임계 영역에 접근할 수 있는지 확인하게 되는데, 공통된 보관소로는 주로 Redis, Zookeeper, MySQL 등이 사용됩니다. 그 중 Redis를 사용해 분산 락을 구현해보도록 하겠습니다.
Lettuce vs Redisson
Redis를 활용한 분산 락에 구현되는 대표적인 RedisClient는 Lettuce와 Redisson이 존재합니다.
- Lettuce : Spin Lock 방식으로 락에 대한 획득 시도를 지속적으로 수행하며 락에 대한 타임아웃 지정을 할 수 없다.
- Redisson : pub/sub 방식으로 동작하며 락에 타임 아웃을 지정할 수 있어 레디스 서버의 부하를 줄여줄 수 있고, 별도의 Lock 인터페이스를 제공해 편하게 분산 락을 구현할 수 있다.
이러한 특징을 고려해 Redisson을 이용해 동시성 문제를 해결해보겠습니다.
+분산락을 사용하는 비즈니스에 Redisson의 락을 획득하고 풀어주는 코드들이 추가된다는 단점을 해결하고, 재사용성을 높이기 위해 AOP를 이용해 어노테이션 기반으로 분산락을 구현해보겠습니다.
Gradle 설정 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
Redisson을 사용하기 위해 의존성을 추가해줍니다.
RedissonConfig.java
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redissonClient = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
redissonClient = Redisson.create(config);
return redissonClient;
}
}
RedissonClient 사용을 위해 Config를 빈으로 등록해줍니다.
DistributedLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 3L;
}
분산락을 통해 동시성 문제를 해결할 메서드에 붙일 어노테이션을 만들어줍니다.
- key : 락의 이름
- waitTime : 락을 획득하기까지 대기하는 최대 시간(시간을 초과하면 false 반환)
- leaseTime : 락의 타임아웃을 설정한다.(시간이 지나면 락이 만료되어 해제됨)
- timeUnit : 락 설정에 사용될 시간 타입
- @Target(ElementType.METHOD) : 어노테이션이 적용될 수 있는 대상을 지정
- @Retention(RetentionPolicy.RUNTIME) : 어노테이션이 유지되는 시점
DistributedLockKeyGenerator.java
public class DistributedLockKeyGenerator {
public static Object generate(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);
}
}
Lock에 사용할 키를 생성하는 클래스로 전달받은 메서드 이름과, 파싱한 값을 통해서 분산 락에 이용할 키 값을 생성합니다.
DistributedLockAop.java
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(kr.doridos.dosticket.global.redis.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 key = REDISSON_LOCK_PREFIX + DistributedLockKeyGenerator.generate(signature.getParameterNames(),
joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
if (!rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit())) {
throw new LockFailException(ErrorCode.LOCK_ACQUISITION_FAILED);
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new LockInterruptedException(ErrorCode.LOCK_INTERRUPTED);
} finally {
rLock.unlock();
}
}
}
어노테이션을 통해 수행할 AOP 클래스로 어노테이션의 파라미터 값을 가져와 분산락 획득을 시도하고 메서드를 실행합니다.
- Lock에 사용될 키를 생성한다.
- RLock 인스턴스를 가져온다.
- waitTime까지 락 획득을 시도하고 leaseTime이 지나면 잠금을 해제한다.
- 종료시 무조건 락을 해제한다.
@Around : 메서드 실행 전 후 특정 로직을 실행할 수 있는 어드바이스를 정의한다. 여기서는 @DistributedLock이 적용된 메서드의 실행 전 후 에 락을 설정하고 해제하는 역할을 수행
ProceedingJointPoint : 대상 메서드에 접근할 수 있도록 해주는 객체
@DistributedLock(key = "#request.seatIds")
public RegisterReservationResponse registerReservation(final Long userId, final ReservationRequest request) {
final List<ScheduleSeat> seats = scheduleSeatRepository.findAllById(request.getSeatIds());
validateSeatsSize(request.getSeatIds(), seats);
validateSeatsIsReserve(seats);
seats.forEach(ScheduleSeat::reserveSeatStatus);
final Reservation reservation = Reservation.builder()
.scheduleId(request.getScheduleId())
.ticketId(request.getTicketId())
.seats(seats)
.userId(userId)
.build();
reservationRepository.save(reservation);
return RegisterReservationResponse.of(reservation);
}
비즈니스 로직에 해당 어노테이션을 적용해줍니다.
AopForTransaction.java
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
새로운 트랜잭션을 만들어 로직을 진행하는 클래스입니다. 해당 AOP를 통해서 트랜잭션이 커밋된 후 락이 해제되도록 처리됩니다.
트랜잭션 커밋 후 락이 해제되어야하는 이유는 무엇일까요?
- 트랜잭션 1이 락을 획득하고 좌석을 조회한다.
- 비즈니스로직이 실행되고 락이 반납된다.
- 트랜잭션 1이 커밋되기 전에 트랜잭션 2가 반납된 락을 획득한다.
- 한 좌석 2개의 예매가 생성되는 대참사가 일어난다.
위와 같이 락의 해제 시점이 트랜잭션 커밋 시점보다 빠른 상황이 발생하게 된다면 데이터 정합성이 깨지는 상황이 발생할 수 있습니다. 그렇기 때문에 분산락을 통해 동작하는 로직을 새로운 트랜잭션 내에서 수행하도록 변경하게 되면 락을 반납하기 전 트랜잭션의 커밋이 이루어져 데이터의 정합성을 지킬 수 있습니다.
테스트
100개의 스레드가 동시에 1번 좌석에 예매를 시도한다고 가정하고 테스트를 진행해보도록 하겠습니다.
@Test
void 티켓예매시_좌석_동시성_테스트를_진행한다() throws InterruptedException {
List<Long> seatIds = List.of(1L);
ReservationRequest request = new ReservationRequest(1L, 1L, seatIds);
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
reservationService.registerReservation(1L, request);
} catch (Exception e) {
e.printStackTrace();
} finally{
latch.countDown();
}
});
}
executorService.shutdown();
latch.await();
List<Reservation> reservations = reservationRepository.findAll();
Assertions.assertThat(reservations.size()).isEqualTo(1);
}
테스트 코드를 돌려보면 정상적으로 한 건의 예매만 성공하는 것을 볼 수 있습니다.
참고
https://helloworld.kurly.com/blog/distributed-redisson-lock/
'Backend > 프로젝트' 카테고리의 다른 글
nGrinder Docker 설치 및 사용방법 (0) | 2024.10.25 |
---|---|
[스프링] 토스 간편결제 기능 구현하기 (0) | 2024.09.10 |
스프링에서 동시성 문제 해결하기(낙관적 락, 비관적 락) (0) | 2024.08.14 |
QueryDsl을 활용한 카테고리별 조회(페이징) 기능 만들기 (0) | 2024.07.30 |
Github Actions를 이용한 CI 구성 (0) | 2024.07.10 |