영화 예매 프로젝트를 진행하는 과정에서 만약에 1000명이 동시에 같은 좌석을 예약을 한다면 어떻게 할까?
나는 여기서 synchronized를 사용하면 되지않을까? 라고 생각을 했다.
public synchronized void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {
final User user = userService.findUser(username);
final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);
// 이미 예약이 된 좌석인지 확인
if (existReservation.isPresent()) {
if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
throw new BadRequestException("이미 예약이 된 좌석입니다.");
} else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
return;
}
}
// 예약 정보가 없는 경우 새로운 예약 생성
final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
final Seat seat = seatService.findSeat(screenId, seatNumber);
final Reservation reservation = Reservation.builder()
.seat(seat)
.user(user)
.screenTime(screenTime)
.reservationStatus(ReservationStatus.CONFIRMED)
.build();
reservationRepository.save(reservation);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
}
원하는대로 단 하나의 유저만 좌석예약은 성공한다.
하지만 synchronized를 사용하면 단일 서버가 아닌 각기 다른 서버를 이용하는 프로젝트이거나 단일 서버 프로젝트를 더 고도화 시킬 때 문제를 야기할 수 있다고 느꼈다.
다른 방식을 선택해야한다.
적용 했을 시 버전 관리를 통해 데이터베이스에서 자원에 대한 동시성 제어를 할 수 있다. 하지만 하나의 트랜잭션이 끝날 때까지 다른 트랜잭션 접근을 차단하는 방식이다.
기본적인 Lettuce는 스핀 락(spin Lock)을 제공한다. 하지만 스핀 락은 무한루프를 돌며 계속 키를 요청하기 때문에 많은 비용이 발생한다.
기본적인 Redisson은 분산 락을 제공한다. 분산 락은 무한 루프를 돌지 않고
Pub/Sub기능을 통해 클라이언트에게 준비되었단 신호를 주기에 적은 비용이 생긴다.
public void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {
// 분산 락을 위한 고유한 키 설정 (screenTimeId와 seatNumber 기준으로 설정)
String lockKey = "reserveSeat:" + screenTimeId + ":" + seatNumber;
RLock lock = redissonClient.getLock(lockKey);
try {
// 락을 얻기 위해 대기 시간을 5초, 락이 활성화되는 시간을 10초로 설정
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
final User user = userService.findUser(username);
final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);
// 이미 예약된 좌석인지 확인
if (existReservation.isPresent()) {
if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
throw new BadRequestException("이미 예약이 된 좌석입니다.");
} else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
return;
}
}
// 새 예약 생성
final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
final Seat seat = seatService.findSeat(screenId, seatNumber);
final Reservation reservation = Reservation.builder()
.seat(seat)
.user(user)
.screenTime(screenTime)
.reservationStatus(ReservationStatus.CONFIRMED)
.build();
reservationRepository.save(reservation);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
} else {
throw new RuntimeException("좌석 예약 중에 문제가 발생했습니다. 다시 시도해 주세요.");
}
} catch (InterruptedException e) {
throw new RuntimeException("좌석 예약 중에 문제가 발생했습니다.", e);
} finally {
// 락 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
try-catch문과 if문이 너무 적나라하게 많이 들어있고 하나의 메서드가 너무 부담이 크다고 느꼈다.
따라서 Lock에 대한 로직과 비즈니스 로직을 분리하기로 했다.
// DistributedLock.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후로 leaseTime이 지나면 락을 해제한다.
*/
long leaseTime() default 3L;
}
DistributedLock.java는 분산락을 이용할 메서드에 어노테이션을 달아서 사용할 수 있게 만들었다. 파라미터 값은 key는 필수, 나머지는 커스텀 할 수 있게 만들었다.
// DistributedLockAop.java
@Aspect
@Component
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
public DistributedLockAop(RedissonClient redissonClient, AopForTransaction aopForTransaction) {
this.redissonClient = redissonClient;
this.aopForTransaction = aopForTransaction;
}
@Around("@annotation(com.movie.reservation.global.lock.DistributedLock.class)")
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 + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key); // (1)
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); // (2)
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint); // (3)
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // (4)
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
method.getName(), key
);
}
}
}
}
DistributedLockAop.java는 DistributedLock 어노테이션 사용 시 수행되는 aop클래스이다.
과정은
1. 락의 이름으로 RLock 인스턴스를 가져온다.
2. 정의된 waitTime까지 획득을 시도한다 하지만 정의된 leaseTime이 지나면 락을 해제한다.
3. DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행된다.
4. 종료시 무조건 락을 해제한다. (finally)
여기까지는 Redisson으로 분산락을 제공할 때 나오는 기본 메서드이다.
중점적으로 보아야 할 클래스들이 이제 나온다.
// CustomSpringELParser.java
public class CustomSpringELParser {
private CustomSpringELParser() {
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
if (parameterNames == null || parameterNames.length == 0) {
throw new BadRequestException("Method parameter names cannot be null or empty.");
}
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);
}
}
CustomSpringELParser 는 전달받은 Lock의 이름을 Spring Expression Language 로 파싱하여 읽어온다.
Spring Expression Language를 사용하면 Lock의 이름을 자유롭게 가져올 수 있다.
예제코드
@DistributedLock(key = "#회원가입락")
public void 회원가입 (String 회원가입락){
----비즈니스로직---
}
@DistributedLock(key = "#좌석예약락이름")
public void 좌석예약 (String 좌석예약락이름){
----비즈니스로직---
}
이런식으로 활용 가능하다.
// AopForTransaction.java
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
스프링 프레임 워크에서 제공하는 트랜잭션 어노테이션에서 전파전략을 REQUIRES_NEW를 옵션을 제공하여 부모 트랜잭션 유무에 관계없이 새로 트랜잭션을 시작하고 트랜잭션의 커밋이나 롤백시 무조건 락이 해제되게 만들었다.

위 그림대로 안전하게 유지되어야 한다.
하지만 트랜잭션 커밋 or 롤백이 이루어지지 않을 때 밑에 그림처럼 이루어 질 수 있다.

위 그림처럼 트랜잭션 커밋도 전에 락이 해제되면 다음에 락을 받은 유저도 같이 같은 좌석을 예약할 수 있다.
좌석은 하나인데 2명이 앉으면 많은 문제를 야기할것이다.
@Transactional
@DistributedLock(key = "#screenTimeId + ':' + #seatNumber")
public void reserveSeat(Long screenId, int seatNumber, Long screenTimeId, String username) {
final User user = userService.findUser(username);
final Optional<Reservation> existReservation = reservationRepository.findReservation(screenTimeId, screenId);
if (existReservation.isPresent()) {
if (existReservation.get().getReservationStatus().equals(ReservationStatus.CONFIRMED)) {
throw new BadRequestException("이미 예약이 된 좌석 입니다.");
} else if (existReservation.get().getReservationStatus().equals(ReservationStatus.CANCELLED)) {
existReservation.get().updateReservation(ReservationStatus.CONFIRMED, user);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, existReservation);
log.info("예약 성공한 유저 : {}", username);
return;
}
}
final ScreenTime screenTime = screenTimeService.findScreenTime(screenTimeId);
final Seat seat = seatService.findSeat(screenId, seatNumber);
final Reservation reservation = Reservation.builder()
.seat(seat)
.user(user)
.screenTime(screenTime)
.reservationStatus(ReservationStatus.CONFIRMED)
.build();
reservationRepository.save(reservation);
simpMessagingTemplate.convertAndSend("/topic/seats/" + screenTimeId, reservation);
log.info("예약 성공한 유저 : {}", username);
}
분산 락에 필요한 로직을 분리함으로써 기존의 비즈니스 로직을 그대로 쓸 수 있게 되었다.