좌석 예약
@Transactional
public ReservationInfo creatTempReserve(ReservationCommand info) {
User user = userRepository.findUser(info.userId());
Concert concert = concertRepository.findConcert(info.concertId());
Schedule schedule = scheduleRepository.findSchedule(info.scheduleId());
Seat seat = seatRepository.findSeat(info.seatId());
// 해당 좌석에 대한 예약건 조회
reservationRepository.getSeatReserve(seat.getId())
.ifPresent(reservation -> {
throw new ReservationException(ReservationErrorCode.ALREADY_RESERVED);
});
Reservation reservation = Reservation.createTemp(user, concert, schedule, seat);
return ReservationInfo.from(reservationRepository.createTempReserve(reservation));
}
동시성 발생 상황 & 제어 방식
현재 좌석 상태를 따로 관리하지 않고 있다.
예약을 생성할 때 좌석에 대한 예약건이 있는지 먼저 조회하고, 없다면 정상적으로 예약을 생성하도록 구현했다.
좌석 상태를 관리하지 않아 update 포인트가 줄어든다는 장점이 있지만 그로 인해 적용할 수 있는 락의 선택지가 줄어들었다.
낙관적 락
예약 동시성 제어는 한 번에 많은 사용자가 동일한 좌석에 대해 예약을 요청할 때, 한 명만 해당 좌석을 예약할 수 있어야 한다.
단 한 건만 성공하면 되기 때문에 낙관적 락을 적용해 볼 수 있겠지만 좌석 상태를 관리하지 않아 낙관적 락이 동작할 포인트가 없다. 낙관적 락을 적용하려면 좌석 상태를 추가하고 도메인 로직을 구현해줘야 한다.
// ReservationService
@Transactional
public ReservationInfo creatTempReserve(ReservationCommand info) {
// ...
Seat seat = seatRepository.findSeat(info.seatId());
seat.reserve();
Reservation reservation = Reservation.createTemp(user, concert, schedule, seat);
return ReservationInfo.from(reservationRepository.createTempReserve(reservation));
}
// Seat
public class Seat {
// ...
private SeatStatus status; // AVAILAVLE, UNAVAILVALE
@Version
private int version;
public void reserve() {
if (this.status == SeatStatus.UNAVAILVALE) {
throw new SeatException("이미 예약된 좌석입니다.");
}
this.status = SeatStatus.UNAVAILVALE;
}
}
예약뿐만 아니라 좌석 목록 조회에서도 예약건 조회하여 동적으로 연산하는 방법을 사용하고 있다.
좌석 상태를 추가하게 되면 테스트를 포함해, 변경에 대한 비용이 굉장히 커진다.
만약 추가한다해도 좌석 예약 특성상 요청 충돌이 많이 발생할 것으로 예상된다.
충돌이 많은 상황에서 낙관적 락을 적용한다면 트랜잭션을 롤백하는 데 리소스가 많이 사용되어 성능 이슈가 발생할 수 있다.
현재로서는 낙관적 락을 적용하기 어려운 상황이다.
비관적 락
비관적 락을 적용할 수 있는 포인트는 예약할 좌석 조회와 해당 좌석에 대한 예약건 조회이다.
먼저 좌석 데이터에 락을 걸고 동시성 테스트를 진행했다.
// SeatJpaRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.id = :seatId")
Optional<Seat> findByIdWithLock(@Param("seatId") Long seatId);
좌석 예약이 모두 성공하면서 테스트는 실패했다.
For Update로 한 트랜잭션이 좌석 데이터를 점유하고 있지만, 좌석에 대한 예약 데이터에는 락이 걸리지 않아 다른 트랜잭션이 대기 없이 읽을 수 있다.
n개의 트랜잭션 모두 같은 예약 데이터를 읽었기 때문에 예약 데이터 여부에 따라 좌석 예약에 모두 성공하거나 실패한다.
좌석 상태를 관리하지 않아, 좌석에 대한 예약건 여부를 확인하고 예약을 생성하기 때문에 예약 데이터에 락을 적용하는 것이 적합하다고 판단된다.
// ReservationJpaRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select r from Reservation r " +
"where r.seat.id = :seatId " +
"and (r.status = 'COMPLETE' " +
"or (r.status = 'TEMP' and r.expiredAt >= CURRENT_TIMESTAMP))")
Optional<Reservation> getSeatReserve(@Param("seatId") Long seatId);
좌석 데이터에는 모든 트랜잭션이 접근할 수 있도록 하고, 예약 데이터에는 한 트랜잭션만 접근할 수 있도록 락을 적용했다.
예약 데이터를 점유하고 있던 트랜잭션이 커밋되고 다른 트랜잭션이 접근하는 경우,
이미 해당 좌석에 대한 예약건이 생성되었기 때문에 예외가 발생한다.
데이터 정합성은 해결되었지만 동시 요청이 많은 경우, 성능 이슈가 발생할 수 있다.
10000명이 한 좌석에 대해 동시 요청을 보낸다면 9999개의 트랜잭션이 락 대기 상태에 들어간다.
많은 트랜잭션이 락이 해제되기를 기다리는 동안 DB 커넥션을 점유한다. 대기 중인 트랜잭션을 관리하기 위해 DB가 평소보다 더 많은 메모리를 사용하게 되어 성능 저하가 발생할 수 있다.
분산 락
분산 환경에서 여러 프로세스가 공유 리소스에 대한 동시 접근을 조율하기 위해 사용된다.
분산 락을 사용하면 데이터 일관성이 유지되고, 경쟁 상태를 방지할 수 있다.
단일 서버라면 굳이 적용할 필요는 없으나, 분산 환경과 대규모 트래픽을 가정하고 진행되는 프로젝트이기 때문에
Redis를 활용한 분산 락을 사용하려 한다.
동시 요청이 많은 상황에서 비관적 락을 사용했을 때 많은 트랜잭션이 DB 커넥션을 유지한다. DB 커넥션 비용이 크기 때문에 인메모리 방식인 Redis로 DB 부하를 최소화할 수 있다.
Redis의 분산락 종류
Lettuce
스핀 락으로 지속적으로 레디스 서버에 요청을 보내 lock여부 확인. 분산 락을 직접 구현해줘야 한다.
timeout이 지정되지 않으면 무한 루프에 빠질 수 있다.
Redisson
Lock 인터페이스를 지원하여 분산 락을 직접 구현할 필요가 없다. pub/sub 방식을 사용하여 락이 해제되면 락을 sub 하는 클라이언트는 그 신호를 받고 락 획득을 시도한다.
Lettuce는 스핀 락 형태로 구현한다는 점에서 Redis 부하가 클 것을 예상된다.
Redisson은 Lock 인터페이스를 지원하는 것뿐만 아니라, 락에 대한 timeout 설정을 지원하기 때문에 락을 보다 안전하게 사용할 수 있다.
하지만 락 처리를 Redis에만 의존하게 되면 위험할 수 있다. Redis 부하가 커져 서버가 다운되는 경우, 동시성 제어를 할 수 없게 된다. DB 락을 적용하여 보완하는 방법도 있지만 두 가지의 락 메커니즘을 병행하게 되면 복잡성이 증가할 수 있다.
좌석 예약 같은 시나리오는 데이터 일관성과 락 관리 모두 중요하다.
Redis 락을 주요 락 메커니즘으로 사용하고 Redis 락이 장애가 발생하면 데이터 무결성을 유지할 수 있도록 비관적 락을 보조 역할로 사용했다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락 이름
*/
String key();
/**
* 락 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락 대기 시간
* 락 획득을 위해 waitTime 만큼 대기한다.
*/
long waitTime() default 5L;
/**
* 락 임대 시간
* 락 확득한 이후 leaseTime 이 지나면 락을 해제한다.
*/
long leaseTime() default 3L;
}
@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(com.hhconcert.server.global.common.lock.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 + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key); // 락 이름으로 RLock 인스턴스를 가져온다.
try {
// 정의된 waitTime 획득 시도. 정의된 leaseTime이 지나면 잠금을 해제한다.
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); // (2)
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint); // DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행.
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // 종료 시 무조건 락을 해제.
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}", method.getName(), key);
}
}
}
}
@DistributedLock(key = "#info.seatId()")
public ReservationInfo creatTempReserve(ReservationCommand info) {
// ...
}
결제
@Transactional
public PaymentInfo payment(PaymentCommand command) {
// 예약 조회
Reservation reservation = reservationRepository.findReserve(command.reserveId());
if (reservation.isNotMatchAmount(command.amount())) {
throw new PaymentException(PaymentErrorCode.NOT_MATCH_PAYMENT_AMOUNT);
}
// 예약 확정
reservation.updateForComplete();
User user = reservation.getUser();
user.usePoint(command.amount());
tokenRepository.dropTokenByUserId(command.userId());
Payment payment = Payment.create(user, reservation, reservation.getPrice());
return PaymentInfo.from(paymentRepository.payment(payment));
}
동시성 발생 상황 & 제어 방식
한 예약건에 대해 동시에 결제 요청이 들어오는 경우, 중복 결제가 발생할 수 있다.
공유 자원이 아닌 독립적으로 존재하는 자원에 대한 요청으로, 동시 요청이 발생해도 충돌 빈도가 낮을 것으로 예상된다.
분산 환경으로 예약 테이블과 결제 테이블이 각각 다른 서버에 존재한다고 해도, 현재 결제 로직에서는 예약 상태를 먼저 확정하기 때문에 예약 조회 시 낙관적 락을 적용해도 동시성 제어가 가능할 것으로 판단된다.
낙관적 락
// Reservation.java
@Entity
public class Reservation extends BaseEntity {
// ...
@Version
private int version;
}
// ReservationJpaRepository.java
@Lock(LockModeType.OPTIMISTIC)
@Query("select r from Reservation r where r.id = :reserveId")
Optional<Reservation> findByIdWithLock(@Param("reserveId") Long reserveId);
테스트는 성공하지만 결제에 대한 중복 키 오류가 발생했다. 좌석 예약 상태 변경에서 낙관적 락이 적용되지 않은 것으로 보인다.
낙관적 락은 데이터 수정 시점에 충돌을 감지한다.
updateForComplete 메서드에서 엔티티의 상태를 변경하면, 트랜잭션 커밋 시점에 @Version 필드가 업데이트되며 충돌이 발생한다.
쿼리를 확인했을 때, 결제 생성 쿼리가 먼저 실행되고 나서 예약 상태 변경 쿼리가 실행된다.
낙관적 락으로 충돌이 발생하기 전에, 결제 데이터에 대한 중복 키 오류가 발생하고 있었다.
단순하게 쿼리 순서를 변경해 주면 어떨까?
@Transactional
public PaymentInfo payment(PaymentCommand command) {
// 예약 조회
Reservation reservation = reservationRepository.findReserve(command.reserveId());
if (reservation.isNotMatchAmount(command.amount())) {
throw new PaymentException(PaymentErrorCode.NOT_MATCH_PAYMENT_AMOUNT);
}
// 예약 확정
reservation.updateForComplete();
reservationRepository.flush(); // 상태 변경 후 DB에 바로 flush.
User user = reservation.getUser();
user.usePoint(command.amount());
tokenRepository.dropTokenByUserId(command.userId());
Payment payment = Payment.create(user, reservation, reservation.getPrice());
return PaymentInfo.from(paymentRepository.payment(payment));
}
트랜잭션 커밋 시점에 update 되는 쿼리를 flush 메서드를 사용해 앞당겼다.
하지만 flush 사용에는 큰 문제가 있다.
flush는 트랜잭션 커밋 전에 DB에 즉시 쿼리를 반영하는데 만약 트랜잭션이 롤백되면 이미 반영된 DB 데이터는 롤백되지 않는다.
flush를 호출한 후, 결제 생성에 예외가 발생하고 트랜잭션이 롤백되면, 예약 상태는 이미 업데이트되었으나 결제 관련 데이터는 롤백된다.
이런 상황에서 예약 상태는 반영되었지만 결제 정보는 반영되지 않아 데이터 불일치 문제가 발생한다.
flush를 사용해야 한다면 트랜잭션 롤백 시 flush 이후의 상태를 다시 롤백할 수 있도록 하는 로직을 추가해야 한다.
예약 상태 변경과 결제 생성 트랜잭션을 분리해 준다면?
// ReservationService
@Transactional
public Reservation updateForComplete(Long reserveId, int amount) {
// 낙관적 락 적용
Reservation reservation = reservationRepository.findReserve(reserveId);
if (reservation.isNotMatchAmount(amount)) {
throw new PaymentException(PaymentErrorCode.NOT_MATCH_PAYMENT_AMOUNT);
}
reservation.updateForComplete();
return reservation;
}
// PaymentService
@Transactional
public PaymentInfo payment(PaymentCommand command, Reservation reservation) {
// ...
Payment payment = Payment.create(user, reservation, reservation.getPrice());
return PaymentInfo.from(paymentRepository.payment(payment));
}
// PaymentFacade
private final PaymentService paymentService;
private final ReservationService reservationService;
public PaymentResult payment(PaymentRequest request) {
Reservation reservation = reservationService.updateForComplete(request.reserveId(), request.amount());
return PaymentResult.from(paymentService.payment(request.toCommand(), reservation));
}
facade에서 예약 상태 변경을 먼저 해주고 결제 내역 생성을 하는 경우, 결제 요청 자체가 실패하게 된다.
안전한 방법일 수 있지만 사용자가 결제 요청을 다시 해야 한다. → 네트워크 비용 ↑
비관적 락
상황으로 보면, 낙관적 락이 적합할지도 모르겠다.
하지만 현재 비즈니스 로직에 적용하려면 쿼리 순서나 트랜잭션 분리 등 고려해야 할 사항들이 많았다.
비관적 락의 경우, Lock 설정 변경만으로 구현이 끝난다.
// ReservationJpaRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select r from Reservation r where r.id = :reserveId")
Optional<Reservation> findByIdWithLock(@Param("reserveId") Long reserveId);
예약 데이터에 베타락을 걸리면서 다른 트랜잭션이 해당 데이터에 접근하지 못한다.
낙관적 락을 적용했을 때보다 실행 쿼리가 적었고, 사이드 이펙트도 적었다.
동시에 많은 요청이 들어오는 경우 락 경합이 발생할 수 있다는 부담은 있지만,
결제에 대한 동시 요청이 많지 않을 것이라 예상되고 데이터 일관성과 안정성도 보장될 것이라 판단된다.
포인트 충전
@Transactional
public PointInfo chargePoint(PointCommand command) {
User user = userRepository.findUser(command.userId());
user.chargePoint(command.amount());
return PointInfo.from(user);
}
동시성 발생 상황 & 제어 방식
정책을 어떻게 잡느냐에 따라 제어 방식도 달라질 것 같다.
1. 동시에 여러 번 충전 요청이 들어오는 경우, 한 번만 충전되도록 제어.
이런 경우, 낙관적 락을 적용해야 한다.
포인트 충전 로직은 사용자 포인트 조회, 기존 포인트에서 충전 금액 추가로 이루어져 있는데 예외가 발생할 수 있는 상황은 등록되지 않은 사용자 요청인 경우밖에 없다.
포인트 충전 자체에서 예외가 발생하는 상황이 없기 때문에, 비관적 락을 적용하게 되면 들어오는 요청 모두 성공시켜 테스트에 실패한다.
여러 요청 중, 한 번만 성공시켜야 하는 테스트 케이스라면 낙관적 락을 적용해야 한다.
// User
public class User extends BaseEntity {
// ...
@Version
private int version;
}
// UserJpaRepository
@Lock(LockModeType.OPTIMISTIC)
@Query("select u from User u where u.userId = :userId")
Optional<User> findByUserIdWithLock(@Param("userId") String userId);
2. 동시에 n 번 충전 요청이 들어오는 경우, n번 모두 충전.
비관적 락을 사용하여 순차적으로 요청을 처리하고, n번 모두 성공시켜야 한다.
낙관적 락 적용 시, 한 번만 성공하고 나머지 요청은 롤백하기 때문에 테스트가 실패한다.
// UserJpaRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.userId = :userId")
Optional<User> findByUserIdWithLock(@Param("userId") String userId);
결제, 충전 같은 요청은 일명 ‘따닥’이 발생할 수 있기 때문에 하나의 요청만 성공시켜야 한다고 생각된다.
사용자마다 독립적인 데이터를 가지고 있고, 동시 요청도 많지 않을 거라 판단되어 1번을 선택하고 낙관적 락을 적용했다.
https://github.com/scars97/concert-reservation-service
GitHub - scars97/concert-reservation-service
Contribute to scars97/concert-reservation-service development by creating an account on GitHub.
github.com
https://helloworld.kurly.com/blog/distributed-redisson-lock/
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.
helloworld.kurly.com
자주 하는 질문
인프런 스프링, JPA 강의 자주 하는 질문 목차 목차 질문하기 질문하는 방법 질문용 파일 업로드 - 구글 드라이브 업로드 공통 강의 코스 문의 학습 방법 문의 블로그 정리, 깃허브 업로드 실행중
docs.google.com
https://www.baeldung.com/spring-boot-redis-testcontainers
Spring Boot – Testing Redis With Testcontainers | Baeldung
Learn how to use Testcontainers while testing a Spring Boot application that uses Redis.
www.baeldung.com