예매 시스템에서 가장 중요한 것은 신뢰성과 일관성이다. 좌석 예약이 중복되면 안 되며, 대량 조회 요청 시 시스템이 버벅이면 사용자 경험이 무너진다. 이 글에서는 실제 내가 설계한 API를 기준으로 도메인 기반 분리, 응답 최적화, 그리고 Redis를 활용한 성능 및 안정성 확보 전략까지 정리한다.
시스템은 세 가지 주요 도메인으로 나뉜다:
각 도메인에 따라 URL도 분리했다:
GET /api/v1/movies
POST /api/v1/reservations
GET /api/v1/reservations/me
이 구조는 서비스 모듈 간 책임 분리를 명확히 하며, 마이크로서비스 구조로 전환할 때도 그대로 가져갈 수 있다.
GET
: 조회POST
: 생성PUT
: 상태 전환 (취소, 확정)DELETE
: 삭제 (현재는 미사용)예약 API는 다음처럼 구성되어 있다:
POST /api/v1/reservations
Header: X-USER-ID: 1
Body:
{
"scheduleId": 302852,
"seatId": 175001
}
응답은 예약 ID와 상태(PENDING)를 포함한다:
{
"id": 1,
"userId": 1,
"scheduleId": 302852,
"seatId": 175001,
"status": "PENDING"
}
현재는 인증 시스템(JWT)을 구현하지 않고 X-USER-ID
를 사용하지만, 이후 쉽게 전환 가능하도록 설계했다.
200 OK
, 201 Created
401 Unauthorized
, 404 Not Found
, 409 Conflict
409 Conflict
로 응답 처리전역 예외 핸들러로 일관된 JSON 구조를 유지한다.
GET /api/v1/movies?title=듄&genreList=SF&movieStatusList=SHOWING
QueryMovieRequest
→ QueryMovieCommand
로 변환 후 처리@GetMapping("/v1/movies")
fun getMovies(
@Validated request: QueryMovieRequest,
pageable: Pageable
): ResponseEntity<Page<QueryMovieResponse>> {
val command = QueryMovieCommand(...)
val result = queryMovieUseCase.findAllMovies(command, pageable)
return ResponseEntity.ok(result)
}
@LocalCached
, @Layer3Cached
, @RateLimited
조합으로 설정 가능data class QueryMovieResponse(
val id: Long,
val title: String,
val genre: Genre,
val rating: String,
val releaseDate: LocalDate,
val runningTime: Int,
val status: MovieStatus,
val schedules: List<QueryScheduleResponse>
)
schedules
를 포함시킨 이유는 사용자가 "상영 중인 영화"뿐 아니라 "오늘 저녁 볼 수 있는 영화"를 찾는 흐름 때문이었다.
단순 영화 정보만 내려줄 수도 있지만, 상영 가능 시간 정보를 요약해서 함께 내려주는 게 UX 상 더 자연스럽다.
data class QueryScheduleResponse(
val id: Long,
val movieId: Long,
val theaterName: String,
val startTime: LocalTime,
val endTime: LocalTime
)
POST /api/v1/reservations
Header: X-USER-ID: 1
Body:
{
"scheduleId": 302852,
"seatId": 175001
}
ReserveSeatRequest
→ ReserveSeatCommand
로 변환 후 처리
@DistributedLock(key = "#seatId", waitTimeSeconds = 3)
@PostMapping("/api/v3/reservations")
fun reserveSeatDistributedLock(...) {
...
}
내부 처리 로직:
distributedLockPort.runWithLock("lock:seat:${command.seatId}") {
if (reservationRepository.existsByScheduleIdAndSeatId(...)) {
throw IllegalStateException("이미 예약된 좌석입니다.")
}
reservationRepository.save(Reservation(...))
}
DistributedLock
AOP가 Redisson 기반 락 획득을 보장409 Conflict
반환Reservation(id, userId, scheduleId, seatId, status = PENDING)
생성GET /api/v1/reservations/me
Header: X-USER-ID: 1
컨트롤러:
@GetMapping("/me")
fun getAllReservations(@RequestHeader("X-USER-ID") userId: Long): ResponseEntity<List<Reservation>> {
val reservations = reservationManagementUseCase.getAllReservations(userId)
return ResponseEntity.ok(reservations)
}
PUT /api/v1/reservations/{reservationId}/confirm
PUT /api/v1/reservations/{reservationId}/cancel
예약 상태는 다음 Enum으로 정의되어 있다:
enum class ReservationStatus {
PENDING, CONFIRMED, CANCELED
}
→ 상태 전환은 유즈케이스 내부에서 명확히 보장되며, 외부에서 직접 변경 불가