예약하기 기능 동시성 제어 - f-lab-edu/at_ticket GitHub Wiki
좌석 예약을 개발할 때, 같은 좌석을 여러명이 예약할 수 없도록 하여야 했습니다.
저희는 이에 대한 방지책으로, 어떤 공연인지를 나타내는 '공연 ID' 와 특정 좌석을 나타내는 '좌석 ID' ID 두개로 이루어진 복합키로 PK
를 걸어서 특정 공연의 특정 좌석에 해당 되는 데이터는 DB에 한 건 밖에 저장될 수 없도록 조치하였습니다.
1건 테스트를 해보았을 때는 잘 동작하였지만, 여러명이 동시에 접근하는 상황을 가정하고 테스트 하였을 때, 저희 생각대로 동작하지 않았습니다.
이는, 한 트랜잭션을 실행하는 도중에 다른 트랜잭션이 끼어들어서 발생할 수 있는 동시성 제어
문제였습니다.
동시성 제어
문제를 해결하기 위해서 여러 방식들을 찾아보고 테스트 해보면서 각각의 장단점과 성능 비교를 진행해 보았습니다.
우선 저희의 at ticket에서 좌석을 예약하는 방식은,
먼저 좌석을 선택한 사용자에게 결제 우선권이 부여되는 방식입니다. 이후 결제시간안에 결제가 완료되면 최종적으로 예약이 완료됩니다.
예약하기 기능의 프로세스에 대해서는 이전에 관련 글을 쓴 적이 있습니다. wiki 링크(예약하기 구현)
이번에는 유저가 좌석을 선택하고 결제 우선권을 부여받기 까지의 프로세스에 대해 설명하고, 어떤 문제가 발생하였는지 설명하겠습니다.
저희 at ticket의 예약하기 프로세스 중에서도 여러 유저가 동시에 접근하여 데이터를 변경시킬 가능성이 가장 높은 부분이기도 합니다.
유저가 좌석을 선택하고 결제 우선권을 부여받기까지에는 다음과 같은 프로세스를 거쳐야 합니다.
1.좌석 선택 가능 여부 판단
예약 좌석 정보와 예약 상태 데이터가 저장되는
Reservation
테이블과 임시로 좌석 정보를 저장하는Pre-reservation
테이블을 조회해서
두 테이블에 조건에 해당하는 정보가 없으면 해당 좌석을 선택할 수 있다고 판단합니다.2.임시 좌석 예약 정보 저장
Reservation
테이블에 예약 상태를 'WAIT_PAY'로 변경하고Pre-reservation
테이블에 임시 좌석 정보를 저장합니다.
Reservation
테이블과Pre-reservation
테이블을 나누어서 사용한 이유는?
결제/네트워크 오류 등으로 예약이 정상적으로 이루어지지 않는 경우에는 어느 정도 시간을 지나면 결제 우선권을 회수할 필요가 있습니다
두 테이블을 분리함으로써, 단순히 `Pre-reservation` 테이블의 데이터를 삭제하면 결제 우선권을 간단히 회수 할 수 있습니다.
왜냐하면 이미 결제가 완료된 건은 완료가된 타이밍에 `Pre-reservation` 테이블의 임시 좌석 정보가 지워지기 때문입니다.
따라서 `Pre-reservation`에 남아있는 데이터들은 예약이 정상적으로 이루어지지 않은 데이터이고, 결제 우선권을 회수하기 위해서는 그저 시간이 지난 데이터들을 삭제해주면 될 뿐입니다.
우선 좌석 예약 코드는 다음과 같습니다.
/**
* preReservedSeatService.java
* 좌석 예약
*/
public void registerPreReservedSeat(Long showId, List<Long> seatIds, String userId) {
// 1. 좌석 선택 가능 여부 판단
if (reservedSeatService.existsReservedSeat(showId, seatIds)
|| preReservedSeatRepository.existsByShowIdAndSeatIdIn(showId, seatIds)) {
throw new BaseException(BaseStatus.EXIST_RESERVED_SEAT);
}
// 2. 임시 좌석 예약 정보 저장
List<PreReservedSeat> preReservedSeats = seatIds.stream().map(seatId ->
PreReservedSeat.builder()
.showId(showId)
.seatId(seatId)
.userId(userId)
.time(LocalDateTime.now())
.build()
).collect(Collectors.toList());
preReservedSeatRepository.saveAll(preReservedSeats);
}
1.좌석 선택 가능 여부를 판단한 후, 조건이 일치한다면 2.임시 좌석 예약 정보를 Pre-reservation 테이블에 저장합니다.
상기 설명에서, Pre-reservation
테이블에 임시 좌석 정보를 저장하는 부분의 코드입니다.
만약 좌석을 예약할 수 없는 상태에서,
1.의 조회가 올바르게 이루어지면이미 예약된 좌석이 있습니다.
라는 Exception이 발생하게 됩니다.
1.이 올바르게 동작하지 않으면 2.에서 저장을 시도하여DB단에서 중복키 에러
가 발생할 것입니다.
멀티 쓰레드를 이용하여 여러 사람이 동시에 같은 좌석을 예매하는 상황의 테스트 코드를 작성하였습니다.
@Test
@DisplayName("100명이 동시에 같은 좌석을 예약하려 했을 경우")
@Transactional
void preRegisterReservationMultiTest() throws Exception {
//Given
Long showId = 2L;
List<Long> seatIds = List.of(1L, 2L, 3L);
//동시에 접근하는 사람 수
final int people = 100;
//쓰레드 풀 생성
ExecutorService executorService = Executors.newFixedThreadPool(people);
CountDownLatch countDownLatch = new CountDownLatch(people);
//시작 시간 측정
long startTime = System.nanoTime();
for (int i = 1; i <= people; i++) {
LocalTime ThreadStartTime = LocalTime.now();
System.out.println(
"Thread: " + Thread.currentThread().getName() + ", call idx: " + i + ", startTime: " + ThreadStartTime);
int finalI = i;
//쓰레드 실행
executorService.execute(() -> {
try {
System.out.println("예약자:" + finalI);
//임시 좌석 예약 정보 저장
preReservedSeatService.registerPreReservedSeat(showId, seatIds, String.valueOf(finalI));
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
//실행 시간 측정
long endTime = System.nanoTime();
long timeElapsed = endTime - startTime;
System.out.println("nano seconds :" + timeElapsed);
}
좌석 예약의 요구사항은 다음과 같습니다.
같은 공연의 같은 좌석을 여매하려고 했을 때,
같은 공연의 같은 좌석은 딱 한 좌석 밖에 없기 때문에, PK에가 걸려있는 DB에는 단 한 행만이 저장되어야 하고,
나머지 요청은 (좌석 예약 코드 1.좌석 선택 가능 여부 판단(좌석 예매 상황 조회로 판단)) 조건문에 걸려서 Exception이 발생되어야 합니다.
여러 유저가 동시에 접근하는 상황을 가정하여 쓰레개를 여러개(100개) 생성하여 동시에 같은 좌석을 선택하는 요청을 보내도록 하였습니다.

100건을 요청하였는데 성공한 1건을 제외한 99건이 나와야 할이미 예약된 좌석이 있습니다.
라는 Exception은 85개 밖에 발생하지 않았습니다.
그리고 이러한 로그가 찍혀있었습니다.

Exception으로 걸러지지 못하고 INSERT이 실행하는 바람에 DB에러가 발생한 것입니다 다행이 DB의 중복 PK방지 기능으로 1행 밖에 들어가진 않았지만, 이상합니다.
동시에 들어온 여러개의 요청이 읽고 쓰는 작업을 하려고 했기 때문입니다.
1. T1이 좌석 예약가능 여부를 확인함
2. T1이 좌석을 예약하기 전에 T2도 좌석 예약 가능 여부를 확인함
3. T1이 좌석 예약을 함
4. T2도 좌석 예약을 하려했지만이미 T1이 좌석 예약을 했기 때문에 중복키 에러가 발생함.
즉, T1의 트랜잭션이 끝나지 않았는데, T2의 트랜잭션이 끼어들었기 때문에 발생한 문제입니다.
이렇게 동시에 처리되는 여러개의 트랜잭션이 트랜잭션 간의 간섭으로 문제가 생기지 않고 성공적으로 마칠 수 있도록 트랜잭션의 실행 순서를 제어하는 것 을 바로 동시성 제어(Concurrency Control)
라고 합니다.
동시성 제어가 이루어지지 않는다면 갱신 분실(lost update)
, 연쇄 복귀(cascading rollback)
또는 회복 불가능(Unrecoverability)
, 불일치 분석(inconsistent analysis)
등과 같은 여러 문제들이 발생할 수 있습니다.
동시성 제어를 위해선 여러가지 방식이 있겠지만 저희는 아래의 방식으로 동시성 제어를 테스트 해보았습니다.
- 데이터베이스 레벨에서 제어 (PESSIMISTIC(비관적) Lock)
- 분산 락 (Distributed Lock) with Redis
- Kafka 사용
트랜젝션에서 충돌이 발생할 것이라고 생각하고 이를 방지하기 위해 트랜잭션이 시작될 때 Lock을 거는 방식을 의미합니다.
JPARepository Interface의 @Lock을 사용하면 Lock을 걸어줄 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Boolean existsByShowIdAndSeatIdIn(Long showId, List<Long> seatId);
LockModeType 중에서도 PESSIMISTIC_WRITE
락을 걸어주면 쿼리문에 for update
가 붙어서 실행되게 됩니다.
for update
문을 사용하게 되면 조회된 Row에 대해서는 트랜잭션이 종료될 때까지 CRUD가 차단됩니다.
만약 해당 ROW에 접근을 시도하면 Request Lock Wait라는 상황으로 응답하며, 트랜잭션이 종료될 때까지 기다리게 됩니다.
서로의 트랜잭션이 종료될 때까지 대기하게 됨으로, 트랜잭션이 사이에 끼어들어서 생기는 문제는 막을 수 있겠지만,
상황에 따라 Lock이 발생한 Row 에 접근하기 위해 무한히 대기하는 상황이 발생하여 DeadLock
이 발생할 수 있는 단점이 있습니다.

아니나 다를까, 저희 테스트에서도 다른 스레드들이 서로 락이 풀리기를 기다리는 DeadLock
이 발생했습니다.
다행이도, 무한 루프하며 테스트가 종료되는 상황에는 빠지지는 않았고, 시간이 경과하니 스스로 DeadLock
이 풀리는 모습을 볼 수 있었습니다.
아마도 mysql에서 DeadLock
감지하여 롤백 시킴으로써 DeadLock이 해소된 으로 추측됩니다.
https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-detection.html
서로 다른 프로세스가 DB등 공통된 데이터 저장소를 이용하여 자원이 사용 중인지 확인하여 동기화 처리를 하는 것.
In-memory DB인 Redis
는 Redis의 다양한 특징 중에서도 Single Threaded
한 특징 즉, 한 번에 하나의 명령만 처리할 수 있는 특징 때문에 동시성 문제를 해결하는데 많이 사용됩니다.
Redis
에 락을 세팅하고, 락을 획득할 수 있는지 여부를 묻게됩니다.
Java에서 Redis을 호출하기 위한 Redis Client에는 Lettuce
와 Redission
이 있습니다.
Lettus
는 스핀락 방식을 사용합니다. 지속적으로 락의 획득을 시도하는 방식이기 때문에 Redis에 부담이 줄 수 있습니다.
Redisson
은 pubsub 방식을 이용하여 메세지가 올 때까지 대기하다 락이 해제되었다는 메세지가 오면 대기를 풀고 락 획득을 시도합니다.
저희는 Redisson
방식으로 분산락을 구현하여 보았습니다.
다음과 같이 코드를 변경하였습니다
//의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'
/**
* * preReservedSeatService.java
* * 좌석 예약
*/
public void registerPreReservedSeat(Long showId, List<Long> seatIds, String userId) {
//특정 이름으로 락 정의
final String lockKey = showId + seatIds.toString();
final RLock lock = redissonClient.getLock(lockKey);
try {
// 락 획득을 시도한다(2초동안 시도를 할 예정이며 획득할 경우 1초안에 해제할 예정)
boolean available = lock.tryLock(2, 1, TimeUnit.SECONDS);
if (!available) {
log.info("lock 획득 실패");
return;
}
//예약 테이블 확인
if (reservedSeatService.existsReservedSeat(showId, seatIds)) {
throw new BaseException(BaseStatus.EXIST_RESERVED_SEAT);
}
//선좌석 테이블에서 여부 확인
if (preReservedSeatRepository.existsByShowIdAndSeatIdIn(showId, seatIds)) {
throw new BaseException(BaseStatus.EXIST_RESERVED_SEAT);
}
savePreReservedSeat(showId, seatIds, userId);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Redission
은 tryLock
메소드를 통해 락을 획득합니다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
첫 파라미터 시간이 지나면 false가 반환되며 락 획득에 실패합니다. 두번째 파라미터 만큼 시간이 지나면 락이 만료되어 사라집니다.
다음은 Reddsion
의 Lock 획득 프로세스 입니다.
- 대기없는 tryLock 오퍼레이션을 하여 락 획득에 성공하면 true를 반환합니다. 이는 경합이 없을 때 아무런 오버헤드 없이 락을 획득할 수 있도록 해줍니다.
- pubsub을 이용하여 메세지가 올 때까지 대기하다가 락이 해제되었다는 메세지가 오면 대기를 풀고 다시 락 획득을 시도합니다. 락 획득에 실패하면 다시 락 해제 메세지를 기다립니다. 이 프로세스를 타임아웃시까지 반복합니다.
- 타임아웃이 지나면 최종적으로 false를 반환하고 락 획득에 실패했음을 알립니다.
Redis
에 락이 존재하는지 확인하고, 락을 획득하고 나서 예약 프로세스를 진행하기 때문에, 다른 트랜잭션이 끼어들 염려가 없어집니다.

분산락 방식을 이용한 테스트 결과 1개를 제외한 99개의 exception이 정상적으로 떨어지는 것을 확인할 수 있었습니다.
분산락을 사용하는 것은 여러 서버간에 동기화하여 동시성을 제어하는데 유용하지만, 락을 건다는 것은 병목 지점을 만드는 것이기 때문에 속도가 저하될 수 있겠습니다.
또한 락을 관리하기 위한 네트워크 상의 데이터 동기화가 필요함으로 통신의 부하가 발생할 수 있겠습니다.
락을 관리하는 서버에 장애가 발생할 경우, 락의 해체가 올바르게 이루어지지 않을 수 있습니다. 이로 인한 데드락 등 다른 문제가 발생하는 경우에 대해서도 생각해보아야 할 것입니다.
카프카(Kafka)는 파이프라인, 스트리밍 분석, 데이터 통합 및 미션 크리티컬 애플리케이션을 위해 설계된 고성능 분산 이벤트 스트리밍 플랫폼
Pub-Sub 모델의 메시지 큐 형태로 동작하는 비동기 메세징 시스템
Kafka 관련 용어
Producer
: 이벤트(메시지)를 생산하는 애플리케이션Consumer
: 메세지를 가져와서 소비하는 애플리케이션Broker
: 메세지를 저장, 전달하는 역할Topic
:Kafka
에서 사용하는 이벤트를 구분하는 단위.Partition
: 메세지가 저장됨,
Partition
여러개는 한Topic
을 구성할 수 있음.
Kafka
는 메세지 큐 형태로 Producer
가 발행한 메세지를 Broker
를 통해 적재한 데이터를 Consumer
에서 가져가서 하나씩 처리합니다.
따라서 동시성과 관련된 데이터 정합성을 보장해야 하는 곳에 사용할 수 있습니다.
다음과 같이 코드를 변경하였습니다
Service 코드
이벤트를 발행하기 위해 producer를 호출합니다.
/** preReservedSeatService.java
* 좌석 예약 .
*/
public void registerPreReservedSeat(Long showId, List<Long> seatIds, String userId) throws ExecutionException,InterruptedException {
//예약 가능 여부 이벤트 발행
reservationProducer.checkReservation(CheckReservation.builder()
.seatIds(seatIds)
.showId(showId)
.userId(userId)
.build());
}
Producer 코드
CHECK_RESERVATION 이라는 이름의 Topic
으로 예약 가능 여부를 확인하는 이벤트를 발행합니다.
/*ReservationProducer
* 예약 가능 여부 확인 이벤트를 발행.
*/
public void checkReservation(CheckReservation data) throws ExecutionException, InterruptedException {
log.info(String.format("Reservation[checkReservation] 예약 가능 여부 확인 send -> %s", data.toString()));
Message<CheckReservation> message = MessageBuilder
.withPayload(data)
.setHeader(KafkaHeaders.TOPIC, Topic.CHECK_RESERVATION.name())
.build();
//전송 결과 확인
RecordMetadata metadata = kafkaTemplate.send(message).get().getRecordMetadata();
log.info(metadata.toString());
}
Consumer 코드
CHECK_RESERVATION Topic
에 해당하는 이벤트를 받아서 처리합니다.
이벤트를 처리하고 결과를 Callback으로 Producer
에게 돌려줍니다.
public class CheckReservationConsumer {
/** CheckReservationConsumer
* 예약 가능 여부를 확인하고, 저장한다.
* @param data
*/
@KafkaListener(topics = "CHECK_RESERVATION", groupId = "myGroup", errorHandler = "kafkaErrorHandler")
public void consumer(CheckReservation data) throws JsonMappingException, JsonProcessingException {
Long showId = data.getShowId();
List<Long> seatIds = data.getSeatIds();
String userId = data.getUserId();
log.info(String.format("Reservation[CHECK_RESERVATION] 좌석 예약 가능 여부 이벤트 received -> %s", data));
if (reservedSeatService.existsReservedSeat(showId, seatIds)) {
preReservationResponse(500, "이미 예약된 좌석이 포함되어 있습니다.", seatIds, showId, userId);
throw new IllegalStateException("해당 좌석은 예약 할 수 없습니다.");
}
if (preReservedSeatRepository.existsByShowIdAndSeatIdIn(showId, seatIds)) {
preReservationResponse(500, "실이미 예약된 좌석이 포함되어 있습니다.패", seatIds, showId, userId);
throw new IllegalStateException("해당 좌석은 예약 할 수 없습니다.");
}
log.info("Reservation[CHECK_RESERVATION] 예약 진행");
List<PreReservedSeat> preReservedSeats = seatIds.stream().map(seatId ->
PreReservedSeat.builder()
.showId(showId)
.seatId(seatId)
.userId(userId)
.time(LocalDateTime.now())
.build()
).collect(Collectors.toList());
preReservedSeatRepository.saveAll(preReservedSeats);
preReservationResponse(200, "성공", seatIds, showId, userId);
}
}
Producer의 callback Listener 코드
consumer
에서 돌려준 결과를 받습니다.
@KafkaListener(topics = "CHECK_RESERVATION_RESULT", groupId = "myGroup")
public void listen(CheckReserResponse result) {
log.info(String.format("Reservation[check_reservation_result] 저장 결과 received -> %s", result));
}
그림으로는 다음과 같이 설명할 수 있을 것입니다 .
Consumer
에서 이벤트를 순차적으로 하나하나씩 실행하기 때문에, 트랜잭션 사이에 간섭이 생기는 상황을 방지 할 수 있습니다.
정상적으로 1개를 제외한 99개 요청들에 대해 예약 불가능하다는 결과가 돌아온 것을 확인 할 수 있습니다.
Kafka
사용하게 되면 비동기로 메세지를 전달하는 kafka
의 특성과 따로 Lock
을 걸 필요가 없기 때문에 빠른 처리 속도를 기대할 수 있습니다.
주의할 점은, kafka
순서는 같은 Partition
에 들어가 있을 때만 보장됩니다.
복수의 Consumer
를 사용할 때는 동시성 제어로 인해 순서가 보장되어야 하는 로직들은 같은 Partition
에 들어 갈 수 있도록 로직을 만들어주는 것이 중요합니다.
Kafka
는 많은 트래픽을 처리하기 좋은 플랫폼임에는 틀림이 없지만,
따로 많은 학습이 필요하다는 점과, 카프카 서버를 따로 관리 할 필요가 있다는 점이 단점이 될 수 있겠습니다.
마지막으로 클라우드 환경에 배포되어 있는 서버에서 Redis를 이용한 분산락
과 Kafka
를 사용하여 각각 얼마나 많은 요청을 처리할 수 있을지 성능 테스트를 진행해보았습니다.
테스트 툴로는 ngrinder
를 사용하였습니다.
다음과 같이 2분의 동안 3000명의 가상 유저를 설정하고 테스트를 진행하였습니다.
TPS
(Test per Second)가 230언저리에서 더 이상 올라가지 못하는 모습을 볼 수 있습니다.
성능 테스트에서 이상적이라고 일겉어지는 log그래프를 그리며 TPS
700까지 상승하였고 유지되었습니다.
성능 테스트 결과
같은 환경에서도 사용 기술에 따라 성능이 달라질 수 있다는 것을 알 수 있었습니다.
이번 테스트 같은 경우에는 kafka
를 사용하는 것이 Redis
를 이용한 분산락을 사용하는 것보다 2배 이상의 응답 속도를 내줄 수 있다는 것을 확인 할 수 있었습니다.