우테코 레벨2에서 방탈출 예약 프로그램을 Springboot + JPA + MySQL로 만들었고, DB 다이어그램은 아래와 같다. 일반 사용자가 방탈출 예약을 생성하는 API를 만들던 중 이 기능의 핵심이 무엇일까 생각해보았다. 무수한 티켓팅 경험을 되짚어 보면, 동시에 여러 사용자가 예약을 시도했을 때 한 사람만이 예약을 성공하고, 나머지는 대기가 (무사히) 되는게 가장 중요한 핵심이 아닐까?
미션 요구 사항엔 없었지만 내 새끼(예약 프로그램)를 조금 더 완전하게 만들기 위해 고민한 노력을 담아보았다. 어떤 원리를 다루는 게시글도 아니고, 정답을 소개하는 게시글도 아니기 때문에 이런 해결 방법을 생각해볼 수 있구나 정도로만 봐주시길 😉
예약과 예약 대기가 존재하는 프로그램의 서비스 정책은 아래와 같다. (참고로 예약/예약 대기는 reservation 테이블의 status 컬럼으로 구별된다. 서버 어플리케이션에서는 enum으로 정의되어 있다.)
이 부분에 해당하는 서비스 로직만을 간략하게 써보자면 아래와 같다.
@Transactional
public Reservation create(Reservation reservation) {
boolean existInSameTime = reservationRepository.existsByDateAndTimeAndThemeAndStatus(
reservation.getDate(), reservation.getTime(), reservation.getTheme(), ReservationStatus.BOOKING);
if (existInSameTime) {
reservation.changeToWaiting();
}
return reservationRepository.save(reservation);
}
해당 방탈출이 인기가 많아서 동시에 여러 사용자가 예약 생성을 하려고 한다면? 같은 시점에 existsByDateAndTimeAndTheme
쿼리의 결과는 모두 false
로 동일할 것이고, db에 여러 예약이 생성된다. 🥲
아래와 같이 RestAssured로 테스트해보면 테스트가 터진다.
// when
for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) {
new Thread(() -> RestAssured.given()
.contentType(ContentType.JSON)
.cookie(cookies.get(threadIndex))
.body(request).log().all()
.when().post("/reservations")
.then().log().all()
).start();
}
// then
Thread.sleep(1000);
List<ReservationResponse> reservationResponses = findAllReservations(adminCookie);
List<ReservationResponse> waitingResponses = findAllWaitingReservations(adminCookie);
assertSoftly(softly -> {
softly.assertThat(reservationResponses).hasSize(1);
softly.assertThat(waitingResponses).hasSize(threadCount - 1);
});
synchronized
를 사용하는게 가장 먼저 떠오른 방법이다. id 생성 전략이 GenerationType.IDENTITY
이기 때문에 reservationRepository.save()
를 호출하면 쓰기 지연이 되지 않고 바로 insert문이 db에 날아간다. 트랜잭션 범위와 synchronized
범위가 일치한다는 뜻. 범위가 다르다면 트랜잭션을 사용하지 않는 상위 계층인 Controller에서 synchronized
를 사용해주면 된다.
@Transactional
synchronized public Reservation create(Reservation reservation) {
boolean existInSameTime = reservationRepository.existsByDateAndTimeAndThemeAndStatus(
reservation.getDate(), reservation.getTime(), reservation.getTheme(), ReservationStatus.BOOKING);
if (existInSameTime) {
reservation.changeToWaiting();
}
return reservationRepository.save(reservation);
}
분산 환경(서버) 간에서는 사용할 수 없다. 하지만 내 어플리케이션은 서버 하나다. 추가로 내가 생각한 단점들은 아래와 같다.
synchronized
의 단점
synchronized
인스턴스 메소드가 있다면, 모두 lock이 걸린다.synchronized
클래스 메소드는 클래스 단위로 lock이 걸린다.synchronized
인스턴스 메소드와 synchronized
클래스 메소드가 모두 있을 때, 인스턴스 메소드를 실행하고 클래스 메소드를 실행하면 동시에 실행된다. (lock의 적용 범위가 다름)synchronized
의 특성인데 모든 스레드가 공정하게 작업을 할당 받을 기회를 받는 걸 보장하지 않는다. 즉, starvation이 발생할 수 있다. ReentrantLock
, ReentrantReadWriteLock
등 Lock으로 대체하면 된다. 공정성, 타임아웃 설정 등을 할 수 있다.그러면 Lock을 사용하면 될까? (하나의 서버라고 가정하고) 어플리케이션 레벨에 Lock을 걸면 데이터 정합성을 항상 지킬 수 있을까?
아주 중요한 서비스 정책이어서 해당 정합성이 1순위라면 Java의 Lock을 거는 것이 베스트라고 생각하지 않는다. 정합성을 지키고자 하는 row에 따라 다르겠지만 기능이 계속 추가된다면 해당 row를 변경하는 쿼리가 어딘가에서 사용될 수 있고 이 모든 관계를 파악하며 어플리케이션에서 Lock을 거는 건 힘들다.
물론 정합성이 강력하게 지켜질 수록 비용도 커지기 때문에 저울질을 잘 해야 한다. DB lock을 사용하기 전 한 가지 더 사용할 수 있는 방법이 있다.
Database Lock을 사용하지 않고 JPA 엔티티의 버전으로 동시성을 관리한다. 쉽게 말해서
이 과정을 JPA에서 수행해 준다. 아래처럼 엔티티에 버전 필드를 정의할 수 있다. 더해서 @Lock
옵션도 줄 수 있다.
@Version
private Long version;
DB Lock을 사용하지 않는다. 따라서 충돌이 일어나지 않는 동시 요청**에 대해서 성능이 좋다. 트랜잭션을 커밋하는 시점에 충돌을 확인하기 때문에 롤백이 되지 않고 예외를 뱉고 끝난다. 이 때 트랜잭션 안에서 변경 사항이 있었다면 어플리케이션에서 직접 롤백(update 혹은 delete)을 해야 하는데, 추가 비용이 들고 골치 아플 수 있다.
충돌이 일어나지 않는 동시 요청**은 동시에 요청 했음에도 충돌이 일어나지 않는 경우일 것이다. 다른 id의 row를 insert, update하는 경우를 생각하면 이해가 쉽다.
나의 경우 예약을 생성하는 insert 작업에 대해서 동시성 제어가 필요하므로 낙관적 락은 적절하지 않다.
DB Lock을 사용한 방법이다.
InnoDB 기준이다. row 및 record level로 Shared lock과 Exclusive lock을 제공한다. Shared(S) lock은 읽기는 허용하지만 쓰기를 허용하지 않는 lock으로 여러 트랜잭션이 획득할 수 있다. Exclusive(X) lock은 읽기 쓰기 모두 허용하지 않고 하나의 트랜잭션만이 획득할 수 있다.
데이터를 변경(insert, update, delete)할 때 Exclusive lock을 획득한다. 혹은 select for update
로 읽을 때도 lock을 걸 수 있다.
JPA에서는 아래와 같이 쿼리메소드에 사용할 수 있다. 타임아웃도 설정 가능하다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
boolean existsByDateAndTimeAndThemeAndStatus(LocalDate date, ReservationTime time, Theme theme, ReservationStatus status);
쿼리는 아래와 같이 select for update
로 나간다.
select
r1_0.id
from
reservation r1_0
where
r1_0.date=?
and r1_0.time_id=?
and r1_0.theme_id=?
and r1_0.status=?
limit
? for update
나는 실제로 현재 DB 구조에서 이 비관적 락 방식을 사용해서 동시성 문제는 해결했다. (삽질을 좀 했지만..)
하지만 처음에 데드락을 마주쳤다. 서비스 메소드를 다시 보면
@Transactional
public Reservation create(Reservation reservation) {
boolean existInSameTime = reservationRepository.existsByDateAndTimeAndThemeAndStatus(
reservation.getDate(), reservation.getTime(), reservation.getTheme(), ReservationStatus.BOOKING);
if (existInSameTime) {
reservation.changeToWaiting();
}
return reservationRepository.save(reservation);
}
existsByDateAndTimeAndThemeAndStatus
에서 비관적 락을 사용하고 save
로 데이터를 insert한다. 문제상황의 테스트를 돌려보면 5개의 동시 요청에서 한 건의 예약은 저장됐지만 나머지 대기 예약들이 저장되지 않았음을 확인할 수 있다. 왜냐하면 insert에서 데드락이 걸렸기 때문이다. 이해가 되지 않아서 show engine innodb status
로 데드락 로그를 확인했다. Record lock에 대한 로그가 보인다. "insert intention waiting"이라는 부분에서 Insert Intention Lock 때문에 데드락이 걸린 것을 확인할 수 있다. 이 Lock은 다수의 트랜잭션이 같은 인덱스 갭에 삽입을 시도할 때 서로의 작업을 방해하지 않도록 하기 위해 사용된다.
db 데이터가 없어서 가장 끝(로그에 suprenum라고 적혀 있음)에 데이터를 넣는 과정에서 데드락이 발생한 듯하다. 정확한 동작 원인이 궁금해지지만 땅굴 파는 것 같아서 멈췄다.
실제로 테스트 코드 상에서도 이미 예약이 한 건 이상 존재하면 데드락이 발생하지 않는 것을 확인했다.
@Test
@DisplayName("동시 요청으로 동일한 시간대에 예약을 추가한다.")
void createDuplicatedReservationInMultiThread() throws InterruptedException {
// 나머지 given 절은 생략
int threadCount = 5;
// 예약 2건 미리 생성하기
createTestReservation(TOMMY_RESERVATION_DATE, timeId, themeId, cookies.get(0).getValue(), BOOKING);
createTestReservation(LocalDate.of(2030, 9, 1), timeId, themeId, cookies.get(0).getValue(), BOOKING);
// when
for (int i = 0; i < threadCount; i++) {
int threadIndex = i;
new Thread(() -> RestAssured.given()
.contentType(ContentType.JSON)
.cookie(cookies.get(threadIndex))
.body(request).log().all()
.when().post("/reservations")
.then().log().all()
).start();
}
// then
Thread.sleep(1000);
List<ReservationResponse> reservationResponses = findAllReservations(adminCookie);
List<ReservationResponse> waitingResponses = findAllWaitingReservations(adminCookie);
assertSoftly(softly -> {
softly.assertThat(reservationResponses).hasSize(3); // 기존 2건 + 새로운 1건의 예약
softly.assertThat(waitingResponses).hasSize(4); // 새로운 4건의 예약 대기
});
}
DB에 항상 더미 데이터가 한 건 이상 존재하면 해당 기능은 잘 작동한다. 하지만 이런 로직이 잘 작성되었다고 볼 수 있을까? 잘 모르겠다. 교체하는 데 비용이 크다면 그냥 이 로직을 사용하는게 나을 수도 있겠지만 온전하지는 못하다.
+++ 6년 전 스택오버플로우 글을 발견했다.
Solution for Insert Intention Locks in MySQL
어떤 분도(세 번째 답글) 테이블 데이터가 없을 때 가장 끝 노드(suprenum)에 lock이 걸려서 어떤 insert도 실행되지 않는 것 같다고 달아 주셨다. 한 건이라도 데이터가 존재하면 gap lock이 잘 작동해서 데드락이 발생하지 않는다.
어떤 분(두 번째 댓글)은 code를 수정하는게 오히려 다른 부작용이(더 느려지는) 일어날 수 있으니 그냥 냅두라고 한다. 나 같은 경우도 이것 하나를 위해서 아래처럼 insert하고 update하는 식으로 구현했는데 위 언급했던 "교체하는 데 비용이 큰" 경우인 것 같다.
그나저나 내 사고 흐름이랑 이 글이 너무 똑같아서 소름 돋았다. ㅎㅎ;;
존재하는 데이터에 대해서 lock을 걸면 예방할 수 있다.
// Repository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
List<Reservation> findAllByDateAndTimeAndThemeAndStatus(LocalDate date, ReservationTime time, Theme theme, ReservationStatus status);
// Service
@Transactional
public Reservation create(Reservation reservation) {
validateReservationDate(reservation);
validateDuplicatedReservation(reservation);
return reservationRepository.save(reservation);
}
@Transactional
public Reservation scheduleRecentReservation(Reservation reservation) {
List<Reservation> bookings = reservationRepository.findAllByDateAndTimeAndThemeAndStatus(
reservation.getDate(), reservation.getTime(), reservation.getTheme(), ReservationStatus.BOOKING);
correctReservationStatus(bookings.size(), reservation);
return reservation;
}
우선 데이터를 삽입하고, 잘못된 삽입 건에 한해서 예약 상태를 '예약'에서 '예약 대기'로 업데이트한다. 업데이트 하는 로직은 correctReservationStatus()
을 말한다. 업데이트할 때 lock을 걸 것이기 때문에 자연스레 두 로직의 트랜잭션이 분리되었다.
발생할 수 있는 문제점은 첫 번째 트랜잭션은 성공하고, 두 번째 트랜잭션이 실패한다면 2건 이상의 예약이 될 수 있는데 정기적으로 배치 업데이트를 해서 실패를 복구할 수 있겠다. 아니면 수동으로 롤백 로직을 작성해준다던가.
위 모든 고민을 거치고 가장 베스트라고 생각했던 해결 방법이다. 처음부터 계속 함께했던 결론이지만 최대한 현재 DB 구조를 고치지 않는 선에서 도전해보고 싶어 쉽고 간단한 해결책을 뒤로 미루었다.
문제를 푸는 여러 방법 중 하나를 택해야 할 때 중요한 근거 중 하나는 '명료하고 간단한가'라고 생각한다. 복잡해질 수록 코너 케이스가 계속 생기는 것 같다.
현재 DB 구조는 아래와 같다. 처음에 나왔던 사진이다. 예약과 예약 대기가 모두 reservation table에 담기는데 이 두 개가 같은 테이블에 담을 만한 같은 성질을 가진 데이터의 집합이라고 볼 수 있을까? 방탈출 프로그램의 '예약'은 같은 테마, 시간당 한 건만 가능하고 '대기'는 제약이 없다. 다른 제약 조건을 가지고, 다른 어플리케이션 로직이 존재하는 다른 도메인(관심사)라고 볼 수도 있다.
이렇게 대기와 예약에 대해 다른 테이블에 저장하면 예약 테이블은 date, time_id, theme_id로 unique index를 걸 수 있다. 동시 요청에서도 한 건만 예약에 성공한다. 나머지는 duplicated key exception을 잡아서 대기로 저장하면 된다.
여기에 쓴 방법 외에도 이런저런 고민을 많이 했다. lock을 저장하는 테이블을 만들어 lock을 직접 구현하기, 격리 수준을 serializable로 변경하기 등.. 처음에는 "테이블을 나누고 index를 만들면 가장 빨리 해결되겠지만.. DB에 lock 걸면 되는 것 아닌가?" 라는 생각으로 시작했다. 데드락이 생기는게 이해되지 않아서 의도치 않게 MySQL 공부를 꽤 하게 됐다. 테스트할 때 사용하던 h2에서는 내가 오늘 적은 내용들이 전부 적용되는 것도 아니다. DB 벤더와 engine에 따라 lock이 적용되는 방식이 다르다. 골치 아픈 일이었지만 재미있는 경험이었다. 그리고 도메인 규칙을 위반하는 문제를 해결할 때, 기술적 접근에만 의존하지 않고 문제의 근본적인 원인을 도메인 규칙을 중심으로 탐색하는 것이 중요함을 깨달았다.
[1]
자바 ORM 표준 JPA 프로그래밍 - 낙관적 락 부분
[2]
저도 같은 상황에서 테이블 구조에 대해 고민이 많았습니다. 단순히 테이블을 분리하는 게 동시성 관리에서 더 유리할 거 같지만 직접 겪어보진 못해서 왜 유리할 거 같은지 의문이 들었었는데, 직접 경험해보신 내용을 공유해주신 덕에 더 명료히 이유를 알게 되었네요! 결론은 "락을 직접 걸어서 해결하는 방법들은 다 한계가 있고 복잡한데, 제일 명료하게 해결하는 법은 db 구조에 위임하기다"라고 들리는 데 제대로 읽은 게 맞을까요? db엔 동시성이란 상황이 없으니까요! 테이블을 분리할 근거로 적절하다 생각됩니다:)
한 가지 더 궁금한 점은 만일 db가 여러 대라면 ...? db 단에서도 동시성이란 상황이 발생할 거 같은데 이때엔 어떻게 동시성에 대처하면 좋을지도 고민해보셨는지 궁금합니다!