[설계] 예매 API 구조

y001·2025년 4월 14일
0
post-thumbnail

예매 시스템에서 가장 중요한 것은 신뢰성과 일관성이다. 좌석 예약이 중복되면 안 되며, 대량 조회 요청 시 시스템이 버벅이면 사용자 경험이 무너진다. 이 글에서는 실제 내가 설계한 API를 기준으로 도메인 기반 분리, 응답 최적화, 그리고 Redis를 활용한 성능 및 안정성 확보 전략까지 정리한다.


1. API 설계 원칙

1.1 도메인 중심 분리

시스템은 세 가지 주요 도메인으로 나뉜다:

  • Movie 도메인: 영화, 상영 스케줄, 극장, 좌석 등 정보 조회 전담
  • Reservation 도메인: 좌석 예약 처리 및 상태 전환
  • Membership 도메인: 사용자 인증 및 조회 (단, 실습에선 생략)

각 도메인에 따라 URL도 분리했다:

GET    /api/v1/movies
POST   /api/v1/reservations
GET    /api/v1/reservations/me

이 구조는 서비스 모듈 간 책임 분리를 명확히 하며, 마이크로서비스 구조로 전환할 때도 그대로 가져갈 수 있다.

1.2 HTTP 메서드 설계 기준

  • 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를 사용하지만, 이후 쉽게 전환 가능하도록 설계했다.

1.3 상태 코드 및 에러 처리

  • 성공: 200 OK, 201 Created
  • 예외: 401 Unauthorized, 404 Not Found, 409 Conflict
  • 락 미획득 시: 409 Conflict로 응답 처리

전역 예외 핸들러로 일관된 JSON 구조를 유지한다.


2. 영화 정보 API 설계

2.1 영화 목록 조회

요청 예시

GET /api/v1/movies?title=듄&genreList=SF&movieStatusList=SHOWING

쿼리 구조

  • 페이징 처리
  • 검색어(title), 장르, 상태 필터
  • QueryMovieRequestQueryMovieCommand로 변환 후 처리

실제 컨트롤러

@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 조합으로 설정 가능

2.2 영화 응답 구조

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
)

3. 좌석 예약 API 설계

3.1 예약 요청 구조

POST /api/v1/reservations
Header: X-USER-ID: 1
Body:
{
  "scheduleId": 302852,
  "seatId": 175001
}

ReserveSeatRequestReserveSeatCommand로 변환 후 처리


3.2 예약 흐름 요약

@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) 생성

3.3 전체 흐름 시퀀스


4. 사용자 예약 조회 및 상태 전환

4.1 내 예약 조회

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)
}

4.2 예약 상태 전환

PUT /api/v1/reservations/{reservationId}/confirm
PUT /api/v1/reservations/{reservationId}/cancel

예약 상태는 다음 Enum으로 정의되어 있다:

enum class ReservationStatus {
    PENDING, CONFIRMED, CANCELED
}

→ 상태 전환은 유즈케이스 내부에서 명확히 보장되며, 외부에서 직접 변경 불가

0개의 댓글