@SpringBootTest
public class ReservationConcurrencyTest {
@Autowired
private ReservationService reservationService;
@Autowired
private SeatRepository seatRepository;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private EntityManager entityManager;
@Test
void testConcurrencyOnReserveSeat() throws InterruptedException {
int threadCount = 100; // 동시에 실행할 쓰레드 수
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 1; i <= threadCount; i++) {
final long userId = i; // userId를 final로 할당
executorService.submit(() -> {
try {
ReservationRequest request = new ReservationRequest(
userId, // 각 쓰레드마다 다른 userId 사용
1L, // screeningId
Arrays.asList("A1", "A2") // 예약할 좌석
);
reservationService.reserveSeat(request);
} catch (Exception e) {
// 예외 발생 시 로깅 또는 무시 (동시성 문제로 예외가 발생하는지 확인용)
System.err.println("예약 실패: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 쓰레드가 작업을 완료할 때까지 대기
// 예약된 수가 최대 한 번만 발생했는지 확인
long reservationCount = reservationRepository.count();
assertEquals(2 , reservationCount); // 동시에 실행했어도 하나의 예약만 성공해야 함
}
}
userId가 1~100인 100개의 스레드가 screeningId가 1인 상영 시간표의 좌석 'A1', 'A2'에 예매를 진행하는 테스트코드이다. 동시성 제어가 성공적으로 된다면, reservation 테이블에 단 두 개(A1, A2)의 예매 데이터만 들어있어야 한다.
Lock을 구현하기 전에는 10개의 스레드가 동일한 좌석에 대해 예매에 성공한 것을 확인할 수 있다.
Pessimistic Lock (비관적 락) 구현
문제. Lock 적용 안됨
위와 같이 예약하려는 좌석을 조회하는 쿼리에 X Lock을 적용하여 다른 트랜잭션에서 해당 좌석을 조회하려고 할 때, X Lock을 가진 트랜잭션이 예약을 종료할 때까지 접근을 못하도록 하였다.
하지만, 테스트코드를 돌려보면 여전히 동시성 제어에 실패한다.
원인. REPEATABLE READ 격리 수준
트랜잭션의 격리 수준은 READ_UNCOMMITTED, READ_COMMITED, REPEATABLE READ, SERIRALIZABLE로 나뉜다. 격리 수준마다 차이는 다음 시나리오에서 볼 수 있다.
즉, MySQL 서버의 기본 격리 수준인 REPEATABLE READ은 동일 트랜잭션 내에서 동일한 결과를 보여줄 수 있게 보장한다. 즉, MVCC를 이용해 COMMIT 되기 전의 데이터를 보여줌으로써 예매 내역이 없는 데이터를 보고 예매 검증이 제대로 이루어지지 않은 것이다.
해결. 격리 수준 READ_COMMITED로 변경하기
existsBySeatIdAndScreeningId에 Lock을 거는 해결방법(Lock을 걸기 위해 업데이트된 데이터를 조회)도 있지만, 이는 성능 상 문제가 될 것 같아 READ_COMMITED로 격리 수준을 변경하였다.
그 결과 성공!
💣 Pessimistic Lock의 단점 분산 환경에서는 락을 획득하기 위해 네트워크 요청이 오가야 하므로 Latency 가 발생한다. 분산된 여러 노드에서 락 상태를 관리하려면 중앙화된 락 매니저 등이 필요하며, 이는 추가적으로 관리 비용이 발생한다. 또한 한 트랜잭션이 락을 너무 오래 소유하고 있는 경우, 다른 트랜잭션은 락이 걸린 리소스에 접근할 수 없다. 즉, 성능적인 이슈가 발생할 수 있다.
💬 언제 비관락을 사용해야할까? - 트랜잭션 충돌 가능성이 높고, 데이터의 정합성이 무엇보다 중요할 때 - 데이터가 중앙화되어 있고 락 관리의 오버헤드가 크지 않은 경우 - 실시간 시스템에서 충돌 회피가 비용적으로 낙관적 락보다 더 유리한 경우 (e.g 금융 시스템, 좌석 예약 시스템 등 정합성이 중요한 환경) 경쟁이 높은 조건에서 비관적 락이 유리한 이유
Optimistic Lock (낙관적 락) 구현
낙관적 락은 실제로 Lock을 걸지 않고 Version을 통해 다른 트랜잭션이 이 트랜잭션이 사용한 데이터(읽기 또는 쓰기)를 수정했는지 확인한다.
공유 자원인 Seat 테이블을 순수하게 좌석 정보만 다루도록 설계했기 때문에, 낙관적 락으로 동시성 이슈를 해결하기 어려웠다. 다른 예제로 시도해보았다.
update가 이루어지는 Entity에 version 컬럼 추가조회 쿼리 메소드에 Optimistic Lock 적용실패 시 재시도 로직도 작성해주어야 한다.
🧑⚕️ 실무에서 낙관락 사용 시 주의할 점 일반적으로 분산 애플리케이션에서는 낙관적락만 적용하지 않는다. 낙관적 락은 경합 발생 시 하나의 트랜잭션을 제외하고 예외가 발생하기 때문에, 재시도(retry) 로직을 직접 작성해줘야하기 때문이다. (즉, 경쟁이 많으면 오히려 성능에 문제가 발생)
경쟁 조건이 발생한 경우, 트랜잭션 실패하고, 클라이언트는 작업을 다시 시도해야하므로 낙관적 락은 일반적으로 쇼핑몰 장바구니, 게시판 조회수 등 경쟁이 적은 환경에 유리하다. 경쟁이 적은 조건에서 낙관적 락이 유리한 이유트랜잭션이 커밋되기 전에 분산락이 해제되는 경우에 대비하여 낙관적 락을 추가로 사용하는 것이 좋다.
Distributed Lock (분산 락) 구현
⏱️waitTime과 leaseTime이란? - waitTime은 락을 획득하기 위해 기다릴 최대 시간이다. 이 시간이 지나면 락을 획득할 수 없다고 판단하고 예외를 발생시킨다. 보통 API 평균 처리 시간에 따라 설정된다. 평균 API 처리 시간을 모니터링 하고, 그 값을 기준으로 대기 시간을 설정한다. 예를 들어, API 평균 처리 시간이 100ms 라면, waitTime으로 500ms로 설정할 수 있다. - leaseTime이란 락을 보유할 수 있는 최대 시간이다. leaseTime이 지나면 자동으로 락이 해제된다. API의 가장 오래 걸린 처리 시간을 모니터링하고, 그 값을 기준으로 leaseTime을 설정한다. 예를 들어, 가장 오래 걸린 처리 시간이 2초라면, leaseTime을 3초로 설정하여 락을 잡은 프로세스가 작업을 완료할 수 있도록 할 수 있다.
문제 1. 테스트 실패
Redisson을 이용해 분산락을 구현했지만, 동시성 문제가 발생했다. 로그를 찍어보니, Lock 순서대로 insert를 하지만, 다음 Lock을 점유한 트랜잭션이 여전히 예매는 안되어있는 것으로 인식했다.
원인 1. 트랜잭션 격리 수준과 Lock 해제 시점
message.send() 이전에, 즉 트랜잭션이 끝나기 이전에 Lock을 해제함으로써, 동시성 문제가 해결이 안되었다. 또한, 트랜잭션 격리 수준이 REAPEATABLE READ로 되어있어서 이전 데이터를 읽은 것이다.
해결 1. 코드 수정
코드를 아래와 같이 수정하였다.
String lockKey = reservationRequest.screeningId().toString();
RLock lock = redissonClient.getLock(lockKey);
try {
boolean available = lock.tryLock(6,3, TimeUnit.SECONDS);
if (!available) throw new IllegalStateException("좌석 예약에 실패했습니다. 다시 시도해주세요.");
for (String position : reservationRequest.seats()) {
Seat findSeat = seatRepository.findByPositionAndScreeningId(position, screening.getId()).orElseThrow(() ->
new IllegalArgumentException(SEAT_NOT_FOUND));
if (reservationRepository.existsBySeatIdAndScreeningId(findSeat.getId(), screening.getId()))
throw new IllegalArgumentException(SEAT_ALREADY_BE_MADE_RESERVATION);
Reservation reservation = new Reservation();
reservation.reserve(findUser.getId(), findSeat.getId(), screening.getId());
reservationRepository.save(reservation);
messageService.send();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
문제 2. 메세지 전송이 실패하면 좌석 예약도 실패되는 오류
메세지 전송이 실패하면 좌석 예약은 성공해야하지만, 좌석 예약도 같이 실패하는 오류가 발생한다.
원인 2. 트랜잭션의 롤백 특성
트랜잭션의 원자성 특성 때문에, 메세지 전송이 실패하면 좌석 예약 작업까지 모두 롤백된다.
해결 2. 메시지 전송을 트랜잭션 외부로 분리
메시지 전송과 같은 외부 시스템 호출은 트랜잭션에 포함되지 않도록 하는게 좋다. 아래와 같이 메시지 전송은 비동기적으로 처리하였다.
비즈니스 로직 중 임계 영역에만 락을 거는 것이 성능 상 유리하다. 이러한 세밀한 제어는 AOP로 어렵기 때문에, 함수형 분사락을 사용하면 된다. 또한, 현재 나의 코드는 상영 ID 마다 Lock을 점유하고 있어 다른 좌석을 예매하는 과정에서도 경쟁을 한다. 함수형 분산 락을 활용하여 좌석 예매를 진행할 때에만 락을 점유하도록 하자.
기존 커스텀 Transaction에서 AOP 뿐만 아니라 람다식으로도 작업을 전달하여 락을 잡고 있는 동안 다른 트랜잭션과 독립적으로 작업을 수행하였다.
좌석별로 Lock을 점유하고, 예매하고자 하는 모든 좌석에 대한 Lock을 획득해야만 성공하도록 하기 위해 RedissonMultiLock 을 사용하였다.
ReservationService에서 distributedLockService를 활용하여 멀티 Lock을 점유한 후에, 예약을 진행하도록 하였다.