들아가며
숙소 예약 서비스를 개발하며 여러 명의 유저가 같은 날짜, 같은 숙소를 동시에 예약 시 발생할 수 있는 동시성 문제를 해결하고자, Redis 분산 락을 통해 락을 선점하는 스레드가 먼저 예약을 진행할 수 있는 구조를 설계해 문제를 해결했습니다.

@Transactional
@RoleCheck(Role.USER)
public ReservationResponse booking(Long accommodationId, ReservationRequest request) {
Accommodation accommodation = getAccommodation(accommodationId);
User guest = getCurrentUser();
Reservation reservation = ReservationMapper.toEntity(request, guest, accommodation);
List<String> keys = createKeys(accommodationId, request);
Reservation newReservation = reserveUnderLock(reservation, accommodation, keys);
return ReservationMapper.toResponse(newReservation, accommodation);
}
예기치 못한 문제 발생
동시성 문제를 해결했다고 생각했지만, 한 가지 예상치 못한 문제가 발생했습니다.
바로 데이터 정합성 문제였습니다.
여러 사용자가 동시에 같은 날짜, 같은 숙소를 예약할 경우,
"가장 먼저 예약을 시도한 유저가 성공한다"는 시나리오로 테스트 코드를 작성했음에도,
간헐적으로 첫 번째 유저가 아닌 다른 유저가 예약에 성공하는 상황이 나타났습니다.
이 문제는 사용자가 예약 가능하다고 확인하고 예약 버튼을 눌렀음에도,
"이미 예약된 숙소입니다."라는 메시지를 받게 만들어 불필요한 불편을 초래합니다.
따라서 이 상황을 방지하기 위한 추가적인 해결책이 필요했습니다.
원인 분석
처음에는 락(lock) 자체의 문제를 의심했지만, 멀티 스레드 테스트와 로깅을 통해 락은 정상적으로 동작함을 확인했습니다.
문제의 핵심은 락 해제 시점과 DB 트랜잭션 커밋 시점이 다르다는 점이었습니다.
booking( ) 메서드 실행 시점부터 트랜잭션이 시작되는데,
- 유저 및 숙소 정보 조회
- 락을 선점한 뒤 예약 정보를 DB에 INSERT
- 락 해제 후 최종 반환값 return
이 모든 과정이 하나의 트랜잭션 안에서 처리되고 있었습니다.

예를 들어,
Thread 1이 락을 선점하고 INSERT 쿼리를 보낸 뒤 락을 해제하는 순간, Thread 2가 락을 선점하여 동일한 INSERT를 수행할 수 있었습니다.
이 경우 Thread 2의 커밋이 Thread 1보다 먼저 완료되어 원래 먼저 시도한 유저가 예약에 실패하게 되는 상황이 발생했습니다.
문제 해결
가장 큰 원인은 트랜잭션 범위를 적절히 분리하지 못한 것이었습니다.
한 트랜잭션 안에 너무 많은 작업을 포함시킨 결과, 락 해제와 커밋 사이의 간격이 생겼습니다.
이를 해결하기 위해 트랜잭션을 분리하여, 락이 해제되기 전에 커밋이 완료되도록 구조를 변경했습니다.
트랜잭션 분리
@Transactional
@RoleCheck(Role.USER)
public ReservationResponse booking(Long accommodationId, ReservationRequest reservationRequest) {
Accommodation accommodation = getAccommodation(accommodationId);
User guest = getCurrentUser();
Reservation reservation = ReservationMapper.toEntity(reservationRequest, guest, accommodation);
AtomicReference<Reservation> saved = new AtomicReference<>(new Reservation());
List<String> keys = createKeys(accommodationId, request);
try {
redisLockService.executeWithMultiLock(keys, () -> {
saved.set(reservationRepository.save(reservation));
availabilityService.saveReservationDates(accommodation, saved.get());
});
} catch (DataIntegrityViolationException e) {
throw new DuplicateReservationException(ErrorCode.DUPLICATE_RESERVATION);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockInterruptedException(ErrorCode.LOCK_INTERRUPTED);
}
return ReservationMapper.toResponse(saved.get(), accommodation);
}
@Transactional
protected Reservation reserveUnderLock(Reservation reservation, Accommodation accommodation, List<String> keys) {
AtomicReference<Reservation> saved = new AtomicReference<>(new Reservation());
try {
redisLockService.executeWithMultiLock(keys, () -> {
saved.set(reservationRepository.save(reservation));
availabilityService.saveReservationDates(accommodation, saved.get());
});
} catch (DataIntegrityViolationException e) {
throw new DuplicateReservationException(ErrorCode.DUPLICATE_RESERVATION);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockInterruptedException(ErrorCode.LOCK_INTERRUPTED);
}
return saved.get();
}
락을 선점 뒤 예약 정보를 DB 저장하는 작업을 한 트랜잭션으로 분리하여, 관리하도록 하여 락을 해제 한 뒤 트랜잭션을 종료하게 하여 전 보다 빠른 시점에 DB에서 커밋하게 하여 처음 락을 선점한 스레드가 예약을 할 수 있도록 하였습니다.
트랜잭션 전파 방식 설정
하지만, 이렇게 메서드를 분리하고 @Transactional 달아준다고 해서 트랜잭션은 의도한 대로 분리가 되지 않습니다.

별도의 설정 없이 @Transactional를 붙이는 경우, 트랙젠션 전파의 옵션은 REQUIRED이며 이는 상위 메서드에 트랜잭션이 존재할 경우 상위 트랜잭션에 참여하게 됩니다.
지금 상황에서는 booking( ) 메서드가 트랜잭션을 가지고 실행 중일 때, 내부에서 호출한 reserveUnderLock( )도 같은 트랜잭션으로 묶이게 됩니다.
이렇게 되면 락을 걸고 예약 데이터를 저장한 뒤 락을 해제해도 상위 트랜잭션 즉, booking( )의 모든 과정이 끝나야 커밋이 되기 때문에 실제 DB에는 아직 예약 데이터가 반영되지 않은 상태가 됩니다.
위에 의도한 대로 락을 걸고 예약 데이터를 저장하는 작업만을 독립된 트랜잭션으로 묶기 위해서는, 트랜잭션 전파방식 설정이 필요합니다.
@Transactional의 전파방식을 변경하기 위해서는 propagation의 옵션을 설정해줘야 합니다.
다음과 같이 propagation 옵션은 다양합니다.
| 값 | 설명 |
| REQUIRED (기본값) | 이미 트랜잭션이 있으면 참여, 없으면 새로 시작 |
| REQUIRES_NEW | 항상 새 트랜잭션 시작, 기존 트랜잭션은 잠시 중단 |
| SUPPORTS | 트랜잭션이 있으면 참여, 없으면 비트랜잭션으로 실행 |
| NOT_SUPPORTED | 항상 트랜잭션 없이 실행, 기존 트랜잭션은 잠시 중단 |
| MANDATORY | 반드시 트랜잭션 안에서 실행, 없으면 예외 발생 |
| NEVER | 트랜잭션이 있으면 예외 발생 |
| NESTED | 기존 트랜잭션 안에서 중첩 트랜잭션 생성 (savepoint) |
지금의 경우에는 항상 새 트랜잭션이 시작되고 기존 트랜잭션은 잠시 중단되어야 하기 때문에,
지금 같은 경우는 propagation = Propagation.REQUIRES_NEW 속성의 설정이 필요합니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
protected Reservation reserveUnderLock(Reservation reservation, Accommodation accommodation, List<String> keys) {
AtomicReference<Reservation> saved = new AtomicReference<>(new Reservation());
try {
redisLockService.executeWithMultiLock(keys, () -> {
saved.set(reservationRepository.save(reservation));
availabilityService.saveReservationDates(accommodation, saved.get());
});
} catch (DataIntegrityViolationException e) {
throw new DuplicateReservationException(ErrorCode.DUPLICATE_RESERVATION);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockInterruptedException(ErrorCode.LOCK_INTERRUPTED);
}
return saved.get();
}

이렇게 되면 booking( ) 트랜잭션에 묶여있던, reserveUnderLock()은 하위 트랜잭션 분리게 되어, 락 안에서 실행되는 예약 저장 로직만 수행하고 즉시 커밋되게 됩니다.
테스트 코드 작성 및 결과 검증
이제 요구사항대로 작동하는지 테스트 코드를 통해 검증해 보겠습니다.
“가장 먼저 예약을 시도한 유저가 예약을 성공해야만 합니다.”
요구 사항대로 테스트 코드를 작성해 보겠습니다.
@Test
@DisplayName("1번 유저가 먼저 예약을 요청하면 항상 1번 유저가 예약을 성공한다.")
void first_requester_should_always_win() throws InterruptedException {
// given
ReservationRequest reservationRequest = TestReservationFactory.createReservationRequest();
Accommodation accommodation = accommodationRepository.findById(1L).get();
int threadCount = 10;
CountDownLatch user1ReadyLatch = new CountDownLatch(1);
CountDownLatch othersReadyLatch = new CountDownLatch(threadCount - 1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
AtomicInteger successUserId = new AtomicInteger(0);
// when
// 1번 유저 가장 먼저 시작
new Thread(() -> {
try {
UserContext.set(new UserInfo(1L, Role.USER));
user1ReadyLatch.countDown(); // 1번 유저 준비 완료
Thread.sleep(1); // 아주 짧은 시간 먼저 시작
reservationService.booking(accommodation.getId(), reservationRequest);
successUserId.compareAndSet(0, 1); // 첫 번째 성공자만 기록
} catch (Exception e) {
System.err.println("User 1: " + e.getMessage());
} finally {
UserContext.clear();
endLatch.countDown();
}
}).start();
// 나머지 9명의 유저들은 1번 유저보다 약간 늦게 시작
for (int i = 2; i <= threadCount; i++) {
final long userId = i;
new Thread(() -> {
try {
user1ReadyLatch.await(); // 1번 유저가 준비될 때까지 대기
UserContext.set(new UserInfo(userId, Role.USER));
reservationService.booking(accommodation.getId(), reservationRequest);
successUserId.compareAndSet(0, (int)userId);
} catch (Exception e) {
System.err.println("User " + userId + ": " + e.getMessage());
} finally {
UserContext.clear();
othersReadyLatch.countDown();
endLatch.countDown();
}
}).start();
}
endLatch.await(10, TimeUnit.SECONDS);
// then
assertThat(successUserId.get()).isEqualTo(1);
}

테스트가 성공했습니다!

DB의 예약 정보를 저장하는 reservation 테이블을 조회해 보니 의도한 대로 1번 유저가 숙소를 예약한 것을 확인할 수 있었습니다.
K6를 통한 동시성 테스트
더 정확한 검증을 위해 K6 부하테스트를 통해 동시성 테스트를 해봤습니다.
import http from "k6/http";
import {check, sleep} from "k6";
...
// ======== k6 옵션 ========
export const options = {
scenarios: {
first_user: {
executor: "shared-iterations",
exec: "default",
vus: 1,
iterations: 1,
startTime: "0s",
maxDuration: "1s",
},
competing_users: {
executor: "shared-iterations",
exec: "default",
vus: 999,
iterations: 999,
startTime: "0s",
maxDuration: "1s",
},
},
};
export default function () {
const url = "http://localhost:8080/api/accommodations/1/reservations";
const isFirst = __VU === 1;
if (!isFirst) {
sleep(0.1); // 첫 번째 유저의 예약 시도 이후 100ms 간격을 두고 나머지 유저 시도
}
const token = isFirst
? firstUserToken
: otherTokens[(__VU - 2) % otherTokens.length];
const userType = isFirst ? "첫번째 유저" : `경쟁 유저 ${__VU}`;
const payload = JSON.stringify({
checkInDate: "2025-08-21",
checkOutDate: "2025-08-23",
numberOfGuests: 4,
timezone: "Seoul"
});
const params = {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
};
const res = http.post(url, payload, params);
...
}
1초 동안 1000명의 유저가 예약을 시도(TPS 1000)를 했을 때, 처음 시도한 유저(유저 id 1번)가 성공하도록 하는 시나리오로 테스트를 작성해 봤습니다.



확인해 본 결과 작성한 시나리오대로 첫째 예약 시도를 한 유저가 예약을 성공한 것을 확인할 수 있었습니다.
이로써 적절한 동시 숙소 예약 상황에서 트랜잭션 분리를 통해 데이터 정합성 문제 해결을 완료했습니다!