diff --git a/README.md b/README.md index 66c3d586..436bc85f 100644 --- a/README.md +++ b/README.md @@ -297,19 +297,19 @@ ## 1. 동시성 해결 방법 조사 및 적용 -영화 예매 서비스에서 가장 중요한 동시성 문제는 **같은 상영 회차의 동일 좌석에 대해 여러 사용자가 동시에 예약을 시도하는 상황**이다. +영화 예매 서비스에서 가장 중요한 동시성 문제는 **같은 상영 회차의 동일 좌석에 대해 여러 사용자가 동시에 예약을 시도하는 상황**이다. 이번 과제에서는 이 문제를 해결하기 위해 여러 동시성 제어 방식을 비교했다. ### 1. 비관적 락 (Pessimistic Lock) -비관적 락은 충돌이 자주 발생할 것이라고 가정하고, 데이터를 조회하는 시점부터 락을 걸어 다른 트랜잭션의 접근을 제한하는 방식이다. +비관적 락은 충돌이 자주 발생할 것이라고 가정하고, 데이터를 조회하는 시점부터 락을 걸어 다른 트랜잭션의 접근을 제한하는 방식이다. 정합성을 강하게 보장할 수 있다는 장점이 있지만, 락 범위가 넓어질수록 대기 시간이 길어지고 처리량이 줄어들 수 있다. -이 방식은 **이미 DB에 존재하는 row**를 기준으로 적용할 수 있다. +이 방식은 **이미 DB에 존재하는 row**를 기준으로 적용할 수 있다. 따라서 경쟁이 치열하고, 충돌 가능성이 높으며, 데이터 정합성이 매우 중요한 경우에 적합하다. -이번 과제에서 고민한 방식은 `reserve()` 메서드 내에서 `schedule` 조회 시점에 비관적 락을 거는 방법이었다. -하지만 이 경우 동일한 `schedule`에 대한 예약 요청 전체가 직렬화된다. 즉, 같은 상영 회차 안에서 서로 다른 좌석을 예약하는 요청도 함께 대기하게 된다. +이번 과제에서 고민한 방식은 `reserve()` 메서드 내에서 `schedule` 조회 시점에 비관적 락을 거는 방법이었다. +하지만 이 경우 동일한 `schedule`에 대한 예약 요청 전체가 직렬화된다. 즉, 같은 상영 회차 안에서 서로 다른 좌석을 예약하는 요청도 함께 대기하게 된다. 정합성은 확보할 수 있지만, **좌석 단위가 아니라 스케줄 단위로 경쟁을 묶어버린다는 점에서 락 범위가 너무 넓다**고 판단했다. | 항목 | 내용 | @@ -321,10 +321,10 @@ ### 2. 낙관적 락 (Optimistic Lock) -낙관적 락은 충돌이 자주 발생하지 않는다고 가정하고, 실제 저장 시점에 버전 정보를 비교하여 충돌 여부를 판단하는 방식이다. +낙관적 락은 충돌이 자주 발생하지 않는다고 가정하고, 실제 저장 시점에 버전 정보를 비교하여 충돌 여부를 판단하는 방식이다. 평상시에는 성능상 이점이 있지만, 충돌이 발생했을 때 재시도 로직이 필요하다. -즉, **읽기 비중이 높고 충돌 빈도가 상대적으로 낮은 환경**에서는 적합하지만, 좌석 예매처럼 같은 자원에 대한 동시 요청이 자주 몰릴 수 있는 상황에서는 재시도 비용이 커질 수 있다. +즉, **읽기 비중이 높고 충돌 빈도가 상대적으로 낮은 환경**에서는 적합하지만, 좌석 예매처럼 같은 자원에 대한 동시 요청이 자주 몰릴 수 있는 상황에서는 재시도 비용이 커질 수 있다. 특히 영화 예매는 인기 상영 시간대나 인기 좌석으로 요청이 집중될 수 있어, 현재 문제를 해결하는 핵심 방식으로는 적합도가 낮다고 판단했다. | 항목 | 내용 | @@ -336,14 +336,14 @@ ### 3. DB 유니크 제약조건 (Unique Constraint) -DB 차원에서 중복 데이터를 허용하지 않도록 제약조건을 설정하는 방식이다. +DB 차원에서 중복 데이터를 허용하지 않도록 제약조건을 설정하는 방식이다. 이번 과제에서는 `(schedule_id, seat_row, seat_col)` 조합에 유니크 제약조건을 두어, 동일한 상영 회차의 동일 좌석이 중복 저장되지 않도록 했다. -이 방식은 구현이 비교적 단순하고, 애플리케이션 로직과 무관하게 DB가 최종적으로 데이터 무결성을 보장한다는 장점이 있다. -다만 애플리케이션 레벨에서 경쟁을 미리 제어하는 것이 아니라, **실제로 insert를 시도한 뒤에야 예외가 발생한다**는 한계가 있다. +이 방식은 구현이 비교적 단순하고, 애플리케이션 로직과 무관하게 DB가 최종적으로 데이터 무결성을 보장한다는 장점이 있다. +다만 애플리케이션 레벨에서 경쟁을 미리 제어하는 것이 아니라, **실제로 insert를 시도한 뒤에야 예외가 발생한다**는 한계가 있다. 즉, 이미 커넥션과 쿼리 비용이 발생한 이후라는 점에서 사전 제어 방식보다는 비효율적일 수 있다. -그럼에도 불구하고 이번 과제 범위에서는 구현 난이도와 실용성을 모두 고려했을 때 가장 현실적인 방식이라고 판단했다. +그럼에도 불구하고 이번 과제 범위에서는 구현 난이도와 실용성을 모두 고려했을 때 가장 현실적인 방식이라고 판단했다. 현재 좌석 선점 로직은 이 유니크 제약조건을 기반으로 중복 예약을 방지하고, 충돌 시 `DataIntegrityViolationException`을 비즈니스 예외로 변환해 처리하고 있다. | 항목 | 내용 | @@ -355,14 +355,14 @@ DB 차원에서 중복 데이터를 허용하지 않도록 제약조건을 설 ### 4. Redis 분산 락 -Redis를 이용하면 DB row가 없어도 좌석별 key를 기준으로 락을 걸 수 있다. +Redis를 이용하면 DB row가 없어도 좌석별 key를 기준으로 락을 걸 수 있다. 예를 들어 `schedule:{scheduleId}:seat:{seatRow}:{seatCol}` 같은 key를 사용하면, 좌석 단위로 세밀하게 락을 제어할 수 있다. -이 방식의 가장 큰 장점은 **아직 DB에 존재하지 않는 예약 대상에도 락을 걸 수 있다**는 점이다. -즉, 현재처럼 `reserved_seat`가 예약 시점에 새로 생성되는 구조에서도 좌석 단위 경쟁을 자연스럽게 제어할 수 있다. +이 방식의 가장 큰 장점은 **아직 DB에 존재하지 않는 예약 대상에도 락을 걸 수 있다**는 점이다. +즉, 현재처럼 `reserved_seat`가 예약 시점에 새로 생성되는 구조에서도 좌석 단위 경쟁을 자연스럽게 제어할 수 있다. 또한 `schedule` 전체가 아니라 좌석 단위로 락을 걸 수 있어, 같은 상영 회차 내에서도 서로 다른 좌석 예약 요청은 병렬로 처리할 수 있다. -다만 Redis 도입, 락 해제 처리, TTL 설정, 장애 상황 대응 등 운영 복잡도가 커진다. +다만 Redis 도입, 락 해제 처리, TTL 설정, 장애 상황 대응 등 운영 복잡도가 커진다. 이번 과제에서는 학습 및 구현 범위를 고려해 도입을 보류했지만, **장기적으로 실제 서비스 수준으로 확장한다면 가장 먼저 고려할 방식**이라고 판단했다. | 항목 | 내용 | @@ -374,7 +374,7 @@ Redis를 이용하면 DB row가 없어도 좌석별 key를 기준으로 락을 ### 현재 적용한 방식과 판단 -이번 과제에서는 **DB 유니크 제약조건을 활용해 동일 상영 회차의 동일 좌석 중복 예약을 방지**하고 있다. +이번 과제에서는 **DB 유니크 제약조건을 활용해 동일 상영 회차의 동일 좌석 중복 예약을 방지**하고 있다. 이 방식은 애플리케이션 단계에서 미리 경쟁을 제어하지는 못하지만, 최소한의 구현으로도 데이터 무결성을 보장할 수 있다는 장점이 있다. 정리하면 현재 판단은 다음과 같다. @@ -387,13 +387,13 @@ Redis를 이용하면 DB row가 없어도 좌석별 key를 기준으로 락을 ## 2. Feign Client / Http Client 장단점 조사 -외부 결제 서버(PortOne)와 통신하기 위해 사용할 수 있는 HTTP 클라이언트 방식도 함께 정리했다. -Spring 환경에서 많이 사용하는 방식은 `Feign Client`, `RestClient`, `WebClient`, 그리고 보다 저수준의 `HttpClient` 계열로 나눌 수 있다. +외부 결제 서버(PortOne)와 통신하기 위해 사용할 수 있는 HTTP 클라이언트 방식도 함께 정리했다. +Spring 환경에서 많이 사용하는 방식은 `Feign Client`, `RestClient`, `WebClient`, 그리고 보다 저수준의 `HttpClient` 계열로 나눌 수 있다. 각 방식의 특징과 장단점은 다음과 같다. ### 1. Feign Client -Feign Client는 선언형 HTTP 클라이언트로, 인터페이스에 메서드와 어노테이션을 정의하면 구현체를 자동으로 생성해 주는 방식이다. +Feign Client는 선언형 HTTP 클라이언트로, 인터페이스에 메서드와 어노테이션을 정의하면 구현체를 자동으로 생성해 주는 방식이다. 코드가 간결하고, 외부 API 명세를 인터페이스 형태로 분리할 수 있어 가독성이 좋다. 반면 세부 요청/응답 제어가 필요할 때는 설정이 많아질 수 있고, 실제 요청이 추상화되어 보여 디버깅이 다소 불편할 수 있다. @@ -406,7 +406,7 @@ Feign Client는 선언형 HTTP 클라이언트로, 인터페이스에 메서드 ### 2. RestClient -`RestClient`는 Spring 6부터 제공되는 동기식 HTTP 클라이언트로, 기존 `RestTemplate`보다 현대적인 API를 제공한다. +`RestClient`는 Spring 6부터 제공되는 동기식 HTTP 클라이언트로, 기존 `RestTemplate`보다 현대적인 API를 제공한다. 요청 URL, 헤더, 바디, 응답 파싱 과정을 코드에서 명시적으로 확인할 수 있어 흐름을 이해하기 쉽다. 요청과 응답 흐름이 코드에 직접 드러나기 때문에, 예외 처리나 응답 파싱을 세밀하게 다루기 좋다. @@ -419,7 +419,7 @@ Feign Client는 선언형 HTTP 클라이언트로, 인터페이스에 메서드 ### 3. WebClient -`WebClient`는 비동기/논블로킹 기반의 HTTP 클라이언트다. +`WebClient`는 비동기/논블로킹 기반의 HTTP 클라이언트다. 대량의 외부 요청을 효율적으로 처리하거나, 반응형 프로그래밍이 필요한 환경에서 강점을 가진다. 반면 동기식 MVC 구조에서는 코드 복잡도가 증가할 수 있고, 프로젝트 전반이 반응형 구조가 아닐 경우 장점을 충분히 활용하기 어렵다. @@ -432,7 +432,7 @@ Feign Client는 선언형 HTTP 클라이언트로, 인터페이스에 메서드 ### 4. HttpClient (저수준 클라이언트) -Java 기본 `HttpClient`나 Apache HttpClient 같은 저수준 HTTP 클라이언트는 요청과 응답을 가장 세밀하게 제어할 수 있다. +Java 기본 `HttpClient`나 Apache HttpClient 같은 저수준 HTTP 클라이언트는 요청과 응답을 가장 세밀하게 제어할 수 있다. 반면 Spring 애플리케이션에서 사용하는 경우, 예외 처리, 직렬화/역직렬화, 공통 설정 등을 직접 더 많이 관리해야 한다. | 항목 | 내용 | @@ -642,7 +642,7 @@ k6를 사용해 두 가지 부하테스트를 진행했습니다. ### 1. 적용 내용 -영화/극장 조회 API는 반복 호출될 가능성이 높고, 데이터 변경 빈도는 상대적으로 낮다고 판단했습니다. +영화/극장 조회 API는 반복 호출될 가능성이 높고, 데이터 변경 빈도는 상대적으로 낮다고 판단했습니다. 특히 전체 목록 조회와 상세 조회는 같은 요청이 여러 번 들어올 수 있기 때문에, 매번 DB를 조회하는 대신 Redis 캐시를 두는 것이 더 효율적이라고 보았습니다. 이번 프로젝트에서는 다음 조회 기능에 캐싱을 적용했습니다. @@ -674,7 +674,7 @@ public MovieResponse findMovieById(Long id) { 캐싱 전략은 `Look-aside(Cache-aside)` 방식을 기준으로 적용했습니다. -이 방식은 애플리케이션이 먼저 캐시를 확인하고, 캐시에 값이 없을 때만 DB를 조회한 뒤 결과를 캐시에 저장하는 구조입니다. +이 방식은 애플리케이션이 먼저 캐시를 확인하고, 캐시에 값이 없을 때만 DB를 조회한 뒤 결과를 캐시에 저장하는 구조입니다. 이번 프로젝트에서는 Spring Cache의 `@Cacheable`을 사용해, Redis 조회/저장 로직을 직접 작성하지 않고 Spring의 캐시 추상화를 활용했습니다. 영화 상세 조회 기준 흐름은 다음과 같습니다. @@ -780,7 +780,7 @@ public record MovieResponse( ) { } ``` -타입 정보가 충분히 포함되지 않아 캐시 hit 시 Redis 값을 다시 DTO로 복원하는 과정에서 문제가 발생했습니다. +타입 정보가 충분히 포함되지 않아 캐시 hit 시 Redis 값을 다시 DTO로 복원하는 과정에서 문제가 발생했습니다. 이를 해결하기 위해 `ObjectMapper`에 타입 정보를 포함하도록 설정했고, `JavaTimeModule`도 함께 등록해 `LocalDate`와 같은 시간 타입까지 안정적으로 처리하도록 구성했습니다. ```java @@ -805,7 +805,7 @@ docker exec -it spring-redis redis-cli monitor ``` Image -영화 상세 조회 API를 처음 호출했을 때는 `GET` 후 `SET`이 발생했고, 같은 API를 다시 호출했을 때는 `GET`만 발생했습니다. +영화 상세 조회 API를 처음 호출했을 때는 `GET` 후 `SET`이 발생했고, 같은 API를 다시 호출했을 때는 `GET`만 발생했습니다. 즉 첫 번째 요청에서는 DB 조회 후 Redis에 캐시가 저장되었고, 두 번째 요청부터는 Redis 캐시를 사용한다는 것을 확인할 수 있었습니다. --- @@ -959,4 +959,299 @@ AUDIT payment succeeded. reservationId=3, paymentId=pay_123, provider=PORTONE --- + + +--- + +
+8주차 미션 관련 내용 정리 + +
+ +## 1. 트랜잭션 전파 속성 조사 + +Spring의 `@Transactional`은 `propagation` 옵션을 통해 현재 트랜잭션이 존재할 때 새 메서드가 어떤 방식으로 트랜잭션에 참여할지 결정할 수 있습니다. + +| 전파 속성 | 설명 | 사용 예시 | +| --- | --- | --- | +| `REQUIRED` | 기존 트랜잭션이 있으면 참여하고, 없으면 새로 생성한다. 기본값이다. | 일반적인 서비스 계층의 저장/수정 로직 | +| `REQUIRES_NEW` | 항상 새로운 트랜잭션을 생성한다. 기존 트랜잭션은 잠시 중단된다. | 감사 로그 저장, 실패 이력 저장처럼 본 작업과 독립적으로 커밋해야 하는 로직 | +| `SUPPORTS` | 기존 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행한다. | 트랜잭션이 필수는 아닌 조회 로직 | +| `NOT_SUPPORTED` | 트랜잭션 없이 실행한다. 기존 트랜잭션이 있으면 잠시 중단한다. | 외부 API 호출, 긴 네트워크 I/O | +| `MANDATORY` | 반드시 기존 트랜잭션이 있어야 한다. 없으면 예외가 발생한다. | 상위 서비스 트랜잭션 내부에서만 호출되어야 하는 내부 메서드 | +| `NEVER` | 트랜잭션이 있으면 예외가 발생한다. | 트랜잭션 안에서 실행되면 안 되는 작업 | +| `NESTED` | 기존 트랜잭션 내부에 savepoint를 만들고 중첩 트랜잭션처럼 동작한다. | 일부 작업만 롤백하고 전체 트랜잭션은 유지해야 하는 경우 | + +이번 개선에서는 외부 결제 API 호출을 DB 트랜잭션 밖으로 분리하기 위해 `NOT_SUPPORTED`를 사용했고, DB 상태 변경 구간은 `TransactionTemplate`으로 필요한 부분만 짧게 트랜잭션 처리했습니다. + +--- + +## 2. CGV 서비스 트랜잭션 분석 및 개선 + +### 기존 문제 + +기존 예매 결제 흐름은 `ReservationService.pay()`에서 하나의 `@Transactional` 안에 예약 검증, 결제 생성, 외부 결제 API 호출, 예약 확정이 모두 포함되어 있었습니다. + +```java +@Transactional +public PaymentResponse pay(Long userId, Long reservationId) { + Reservation reservation = getOwnedReservation(userId, reservationId); + validateReservationPayable(reservation); + + PaymentResponse response = paymentService.payReservation(...); + reservation.confirm(); + return response; +} +``` + +예매 취소 흐름도 `ReservationService.cancel()`의 트랜잭션 안에서 외부 환불 API를 호출하고 있었습니다. + +```java +@Transactional +public void cancel(Long userId, Long reservationId) { + Reservation reservation = getOwnedReservation(userId, reservationId); + reservation.cancel(); + paymentService.cancelReservationPayment(reservation); + reservedSeatRepository.deleteByReservation(reservation); +} +``` + +이 구조는 다음 문제가 있습니다. + +- 외부 결제 API 응답이 늦어지면 DB 트랜잭션과 커넥션이 오래 점유된다. +- 외부 결제는 성공했지만 DB 커밋이 실패하면 결제 상태와 예약 상태가 불일치할 수 있다. +- 외부 API는 DB 트랜잭션으로 롤백할 수 없기 때문에 트랜잭션 범위에 포함하는 것이 적절하지 않다. + +### 개선 방향 + +결제와 취소 메서드에 `Propagation.NOT_SUPPORTED`를 적용하여 전체 메서드는 트랜잭션 없이 실행되도록 변경했습니다. + +```java +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public PaymentResponse pay(Long userId, Long reservationId) { + PaymentReadyResult payment = transactionTemplate.execute(status -> preparePayment(userId, reservationId)); + + try { + PaymentResponse response = paymentService.requestPayment(payment); + transactionTemplate.executeWithoutResult(status -> completePayment(userId, payment, response)); + return response; + } catch (BusinessException e) { + transactionTemplate.executeWithoutResult(status -> paymentService.markPaymentFailed(payment.paymentPk(), e)); + throw e; + } +} +``` + +결제 흐름은 다음처럼 분리했습니다. + +1. `preparePayment()` + 짧은 트랜잭션 안에서 예약 검증, 결제 금액 계산, `Payment READY` 저장을 처리한다. +2. `requestPayment()` + 트랜잭션 밖에서 외부 결제 API를 호출한다. +3. `completePayment()` + 짧은 트랜잭션 안에서 `Payment PAID` 처리와 `Reservation RESERVED` 처리를 수행한다. +4. 외부 결제 실패 시 + 짧은 트랜잭션 안에서 `Payment FAILED`로 변경한다. + +취소 흐름도 같은 방식으로 분리했습니다. + +```java +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public void cancel(Long userId, Long reservationId) { + PaymentCancelResult payment = transactionTemplate.execute(status -> prepareCancel(userId, reservationId)); + + if (payment != null) { + paymentService.requestPaymentCancel(payment); + transactionTemplate.executeWithoutResult(status -> completeCancel(userId, reservationId, payment)); + } +} +``` + +취소 흐름은 다음처럼 변경했습니다. + +1. `prepareCancel()` + 짧은 트랜잭션 안에서 본인 예약과 결제 상태를 확인한다. +2. `requestPaymentCancel()` + 트랜잭션 밖에서 외부 환불 API를 호출한다. +3. `completeCancel()` + 짧은 트랜잭션 안에서 `Payment CANCELLED`, `Reservation CANCELLED`, 예약 좌석 삭제를 처리한다. + +이렇게 변경하면서 외부 API 호출로 인해 DB 트랜잭션이 길어지는 문제를 줄이고, DB 상태 변경 구간을 명확하게 나눌 수 있었습니다. + +--- + +## 3. 인덱스 종류 조사 + +| 인덱스 종류 | 특징 | 예시 | +| --- | --- | --- | +| 단일 컬럼 인덱스 | 하나의 컬럼을 기준으로 검색을 빠르게 한다. | `movie(title)` | +| 복합 인덱스 | 여러 컬럼을 조합해 검색을 빠르게 한다. 컬럼 순서가 중요하다. | `reservation(status, expires_at)` | +| 유니크 인덱스 | 중복을 방지하면서 조회 성능도 높인다. | `payment(payment_id)` | +| 커버링 인덱스 | 쿼리에 필요한 컬럼을 인덱스만으로 모두 처리할 수 있어 테이블 접근을 줄인다. | `reservation(status, expires_at, res_id)` | +| 클러스터드 인덱스 | 실제 데이터가 인덱스 순서에 맞게 저장된다. MySQL InnoDB에서는 PK가 클러스터드 인덱스다. | `PRIMARY KEY` | +| 세컨더리 인덱스 | PK 외에 추가로 생성하는 보조 인덱스다. 세컨더리 인덱스는 PK 값을 함께 들고 있다. | `reserved_seat(res_id)` | +| Full-text 인덱스 | 긴 문자열에서 자연어 검색을 빠르게 하기 위한 인덱스다. | 영화 제목/줄거리 검색 | +| 해시 인덱스 | 동등 비교에 강하지만 범위 검색에는 적합하지 않다. | `key = value` 형태의 조회 | + +복합 인덱스는 왼쪽 컬럼부터 순서대로 활용됩니다. 예를 들어 `(schedule_id, seat_row, seat_col)` 인덱스는 `schedule_id` 조건이 있을 때 효율적으로 사용할 수 있지만, `seat_col`만 조건으로 주는 쿼리에는 효과가 제한적입니다. + +--- + +## 4. 성능 최적화 + +이번 최적화는 현재 서비스에서 실제로 사용 중인 Repository 메서드를 기준으로 최소한의 인덱스를 적용했습니다. + +### 4-1. 만료 예약 조회 최적화 + +예약 만료 스케줄러는 결제 대기 상태이면서 만료 시간이 지난 예약을 조회합니다. + +```java +List findAllByStatusAndExpiresAtBefore( + ReservationStatus status, + LocalDateTime time +); +``` + +실행 계획 확인 쿼리는 다음과 같습니다. + +```sql +EXPLAIN ANALYZE +SELECT * +FROM reservation +WHERE status = 'PENDING_PAYMENT' + AND expires_at < NOW(); +``` + +적용한 인덱스는 다음과 같습니다. + +```java +@Table(indexes = { + @Index(name = "idx_reservation_status_expires_at", columnList = "status, expires_at") +}) +public class Reservation extends BaseEntity { +} +``` + +`status`는 동등 조건이고 `expires_at`은 범위 조건이므로 `(status, expires_at)` 순서의 복합 인덱스를 적용했습니다. 이를 통해 전체 예약 테이블을 스캔하지 않고 결제 대기 상태의 만료 예약 범위만 탐색할 수 있습니다. + +--- + +### 4-2. 좌석 중복 조회 최적화 + +예매 생성 시 같은 상영 일정의 같은 좌석이 이미 선점되었는지 확인합니다. + +```java +boolean existsByScheduleIdAndSeatRowAndSeatCol( + Long scheduleId, + String seatRow, + int seatCol +); +``` + +실행 계획 확인 쿼리는 다음과 같습니다. + +```sql +EXPLAIN ANALYZE +SELECT 1 +FROM reserved_seat +WHERE schedule_id = 1 + AND seat_row = 'A' + AND seat_col = 1 +LIMIT 1; +``` + +기존 유니크 제약은 좌석 컬럼이 먼저 오는 순서였습니다. + +```java +@UniqueConstraint( + name = "uk_reserved_seat_per_schedule", + columnNames = {"seat_row", "seat_col", "schedule_id"} +) +``` + +하지만 실제 조회 조건은 `schedule_id`를 먼저 기준으로 사용하므로, 다음처럼 인덱스 순서를 변경했습니다. + +```java +@UniqueConstraint( + name = "uk_reserved_seat_per_schedule", + columnNames = {"schedule_id", "seat_row", "seat_col"} +) +``` + +이 인덱스는 좌석 중복 방지 역할을 유지하면서, 특정 상영 일정에서 특정 좌석을 찾는 조회에도 더 적합합니다. + +--- + +### 4-3. 예매 좌석 count/delete 최적화 + +결제 금액 계산 시 예매에 포함된 좌석 수를 조회하고, 예약 취소나 만료 시 해당 예약의 좌석을 삭제합니다. + +```java +long countByReservationId(Long reservationId); + +void deleteByReservation(Reservation reservation); +``` + +실행 계획 확인 쿼리는 다음과 같습니다. + +```sql +EXPLAIN ANALYZE +SELECT COUNT(*) +FROM reserved_seat +WHERE res_id = 1; +``` + +```sql +EXPLAIN ANALYZE +DELETE FROM reserved_seat +WHERE res_id = 1; +``` + +적용한 인덱스는 다음과 같습니다. + +```java +@Table( + indexes = { + @Index(name = "idx_reserved_seat_reservation_id", columnList = "res_id") + } +) +public class ReservedSeat extends BaseEntity { +} +``` + +`reserved_seat`는 좌석 단위로 데이터가 쌓이기 때문에 예약 ID 기준의 count/delete가 반복되면 전체 스캔 비용이 커질 수 있습니다. `res_id` 인덱스를 추가해 특정 예약에 연결된 좌석만 빠르게 찾도록 개선했습니다. + +--- + +### 추가 최적화. 매점 재고 조회 최적화 + +매점 주문 시 영화관과 상품 기준으로 재고를 조회합니다. + +```java +Optional findByTheaterIdAndItemId(Long theaterId, Long itemId); +``` + +실행 계획 확인 쿼리는 다음과 같습니다. + +```sql +EXPLAIN ANALYZE +SELECT * +FROM store_inventory +WHERE theater_id = 1 + AND item_id = 1; +``` + +적용한 인덱스는 다음과 같습니다. + +```java +@Table(indexes = { + @Index(name = "idx_store_inventory_theater_item", columnList = "theater_id, item_id") +}) +public class StoreInventory extends BaseEntity { +} +``` + +영화관별 재고를 상품 단위로 조회하는 패턴이 명확하므로 `(theater_id, item_id)` 복합 인덱스를 적용했습니다. + +
diff --git a/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentCancelResult.java b/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentCancelResult.java new file mode 100644 index 00000000..91ad9130 --- /dev/null +++ b/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentCancelResult.java @@ -0,0 +1,8 @@ +package com.cgv.spring_boot.domain.payment.dto; + +public record PaymentCancelResult( + Long paymentPk, + Long reservationId, + String paymentId +) { +} diff --git a/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentReadyResult.java b/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentReadyResult.java new file mode 100644 index 00000000..b044bca2 --- /dev/null +++ b/src/main/java/com/cgv/spring_boot/domain/payment/dto/PaymentReadyResult.java @@ -0,0 +1,12 @@ +package com.cgv.spring_boot.domain.payment.dto; + +public record PaymentReadyResult( + Long paymentPk, + Long reservationId, + String paymentId, + String orderName, + int totalAmount, + String currency, + String customData +) { +} diff --git a/src/main/java/com/cgv/spring_boot/domain/payment/service/PaymentService.java b/src/main/java/com/cgv/spring_boot/domain/payment/service/PaymentService.java index 598c6389..6e5d8429 100644 --- a/src/main/java/com/cgv/spring_boot/domain/payment/service/PaymentService.java +++ b/src/main/java/com/cgv/spring_boot/domain/payment/service/PaymentService.java @@ -1,6 +1,8 @@ package com.cgv.spring_boot.domain.payment.service; import com.cgv.spring_boot.domain.payment.dto.request.PaymentCreateRequest; +import com.cgv.spring_boot.domain.payment.dto.PaymentCancelResult; +import com.cgv.spring_boot.domain.payment.dto.PaymentReadyResult; import com.cgv.spring_boot.domain.payment.dto.response.PaymentResponse; import com.cgv.spring_boot.domain.payment.entity.Payment; import com.cgv.spring_boot.domain.payment.entity.PaymentStatus; @@ -25,7 +27,7 @@ public class PaymentService { private final PaymentIdGenerator paymentIdGenerator; @Transactional - public PaymentResponse payReservation(Reservation reservation, int totalAmount, String orderName, String customData) { + public PaymentReadyResult createReadyPayment(Reservation reservation, int totalAmount, String orderName, String customData) { if (paymentRepository.existsByReservationId(reservation.getId())) { log.warn("payment rejected. reservationId={}, reason=payment_already_exists", reservation.getId()); throw new BusinessException(PaymentErrorCode.PAYMENT_ALREADY_EXISTS); @@ -38,37 +40,67 @@ public PaymentResponse payReservation(Reservation reservation, int totalAmount, Payment.createReady(reservation, paymentId, orderName, totalAmount, DEFAULT_CURRENCY, customData) ); - try { - PaymentResponse response = portOnePaymentClient.instantPay(paymentId, new PaymentCreateRequest( - orderName, - totalAmount, - DEFAULT_CURRENCY, - customData - )); - payment.markPaid(response.pgProvider(), response.paidAt()); - log.info("AUDIT payment succeeded. reservationId={}, paymentId={}, provider={}", - reservation.getId(), response.paymentId(), response.pgProvider()); - return response; - } catch (BusinessException e) { - payment.markFailed(); - log.warn("AUDIT payment failed. reservationId={}, paymentId={}, reason={}", - reservation.getId(), paymentId, e.getErrorCode().getMessage()); - throw e; - } + return new PaymentReadyResult( + payment.getId(), + payment.getReservation().getId(), + payment.getPaymentId(), + payment.getOrderName(), + payment.getTotalAmount(), + payment.getCurrency(), + payment.getCustomData() + ); + } + + public PaymentResponse requestPayment(PaymentReadyResult payment) { + return portOnePaymentClient.instantPay(payment.paymentId(), new PaymentCreateRequest( + payment.orderName(), + payment.totalAmount(), + payment.currency(), + payment.customData() + )); + } + + @Transactional + public void markPaymentPaid(Long paymentPk, PaymentResponse response) { + Payment payment = paymentRepository.findById(paymentPk) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND)); + payment.markPaid(response.pgProvider(), response.paidAt()); + log.info("AUDIT payment succeeded. reservationId={}, paymentId={}, provider={}", + payment.getReservation().getId(), response.paymentId(), response.pgProvider()); + } + + @Transactional + public void markPaymentFailed(Long paymentPk, BusinessException e) { + Payment payment = paymentRepository.findById(paymentPk) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND)); + payment.markFailed(); + log.warn("AUDIT payment failed. reservationId={}, paymentId={}, reason={}", + payment.getReservation().getId(), payment.getPaymentId(), e.getErrorCode().getMessage()); } @Transactional - public void cancelReservationPayment(Reservation reservation) { + public PaymentCancelResult getPaidPaymentForCancel(Reservation reservation) { Payment payment = paymentRepository.findByReservationId(reservation.getId()) .orElse(null); if (payment == null || payment.getStatus() != PaymentStatus.PAID) { - return; + return null; } - portOnePaymentClient.cancel(payment.getPaymentId()); + return new PaymentCancelResult(payment.getId(), payment.getReservation().getId(), payment.getPaymentId()); + } + + public void requestPaymentCancel(PaymentCancelResult payment) { + portOnePaymentClient.cancel(payment.paymentId()); + } + + @Transactional + public void markPaymentCancelled(Long paymentPk) { + Payment payment = paymentRepository.findById(paymentPk) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.PAYMENT_NOT_FOUND)); payment.cancel(); log.info("AUDIT payment cancelled. reservationId={}, paymentId={}", - reservation.getId(), payment.getPaymentId()); + payment.getReservation().getId(), payment.getPaymentId()); } + } diff --git a/src/main/java/com/cgv/spring_boot/domain/reservation/entity/Reservation.java b/src/main/java/com/cgv/spring_boot/domain/reservation/entity/Reservation.java index 73673235..7927d219 100644 --- a/src/main/java/com/cgv/spring_boot/domain/reservation/entity/Reservation.java +++ b/src/main/java/com/cgv/spring_boot/domain/reservation/entity/Reservation.java @@ -16,6 +16,9 @@ @Entity @Getter @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@Table(indexes = { + @Index(name = "idx_reservation_status_expires_at", columnList = "status, expires_at") +}) public class Reservation extends BaseEntity { @Id @@ -27,7 +30,7 @@ public class Reservation extends BaseEntity { @Column(nullable = false) private ReservationStatus status; // BOOKED, CANCELED - @Column(nullable = false) + @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/cgv/spring_boot/domain/reservation/entity/ReservedSeat.java b/src/main/java/com/cgv/spring_boot/domain/reservation/entity/ReservedSeat.java index 2804bef2..3e247145 100644 --- a/src/main/java/com/cgv/spring_boot/domain/reservation/entity/ReservedSeat.java +++ b/src/main/java/com/cgv/spring_boot/domain/reservation/entity/ReservedSeat.java @@ -11,8 +11,11 @@ @Getter @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) @Table( + indexes = { + @Index(name = "idx_reserved_seat_reservation_id", columnList = "res_id") + }, uniqueConstraints = { - @UniqueConstraint(name = "uk_reserved_seat_per_schedule", columnNames = {"seat_row", "seat_col", "schedule_id"}) + @UniqueConstraint(name = "uk_reserved_seat_per_schedule", columnNames = {"schedule_id", "seat_row", "seat_col"}) } ) public class ReservedSeat extends BaseEntity { diff --git a/src/main/java/com/cgv/spring_boot/domain/reservation/service/ReservationService.java b/src/main/java/com/cgv/spring_boot/domain/reservation/service/ReservationService.java index 9bb9291c..ad680d5c 100644 --- a/src/main/java/com/cgv/spring_boot/domain/reservation/service/ReservationService.java +++ b/src/main/java/com/cgv/spring_boot/domain/reservation/service/ReservationService.java @@ -1,6 +1,8 @@ package com.cgv.spring_boot.domain.reservation.service; import com.cgv.spring_boot.domain.payment.dto.response.PaymentResponse; +import com.cgv.spring_boot.domain.payment.dto.PaymentCancelResult; +import com.cgv.spring_boot.domain.payment.dto.PaymentReadyResult; import com.cgv.spring_boot.domain.payment.service.PaymentService; import com.cgv.spring_boot.domain.reservation.dto.ReservationRequest; import com.cgv.spring_boot.domain.reservation.exception.ReservationErrorCode; @@ -22,7 +24,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; @@ -39,6 +43,7 @@ public class ReservationService { private final UserRepository userRepository; private final ReservedSeatRepository reservedSeatRepository; private final PaymentService paymentService; + private final TransactionTemplate transactionTemplate; /** * 예매 좌석 선점 @@ -117,8 +122,21 @@ private void validateAlreadyReservedSeats(Schedule schedule, List /** * 예매 결제 및 확정 */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public PaymentResponse pay(Long userId, Long reservationId) { + PaymentReadyResult payment = transactionTemplate.execute(status -> preparePayment(userId, reservationId)); + + try { + PaymentResponse response = paymentService.requestPayment(payment); + transactionTemplate.executeWithoutResult(status -> completePayment(userId, payment, response)); + return response; + } catch (BusinessException e) { + transactionTemplate.executeWithoutResult(status -> paymentService.markPaymentFailed(payment.paymentPk(), e)); + throw e; + } + } + + private PaymentReadyResult preparePayment(Long userId, Long reservationId) { Reservation reservation = getOwnedReservation(userId, reservationId); validateReservationPayable(reservation); @@ -128,11 +146,16 @@ public PaymentResponse pay(Long userId, Long reservationId) { String orderName = schedule.getMovie().getTitle() + " 예매"; String customData = "{\"reservationId\":" + reservationId + ",\"seatCount\":" + seatCount + "}"; - PaymentResponse response = paymentService.payReservation(reservation, totalAmount, orderName, customData); + return paymentService.createReadyPayment(reservation, totalAmount, orderName, customData); + } + + private void completePayment(Long userId, PaymentReadyResult payment, PaymentResponse response) { + Reservation reservation = reservationRepository.findById(payment.reservationId()) + .orElseThrow(() -> new BusinessException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + paymentService.markPaymentPaid(payment.paymentPk(), response); reservation.confirm(); log.info("AUDIT reservation paid. userId={}, reservationId={}, paymentId={}, totalAmount={}", - userId, reservationId, response.paymentId(), totalAmount); - return response; + userId, payment.reservationId(), response.paymentId(), payment.totalAmount()); } /** 결제 가능 예약 검증 */ @@ -154,14 +177,37 @@ private void validateReservationPayable(Reservation reservation) { /** * 예매 취소 */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void cancel(Long userId, Long reservationId) { + PaymentCancelResult payment = transactionTemplate.execute(status -> prepareCancel(userId, reservationId)); + + if (payment != null) { + paymentService.requestPaymentCancel(payment); + transactionTemplate.executeWithoutResult(status -> completeCancel(userId, reservationId, payment)); + } + } + + private PaymentCancelResult prepareCancel(Long userId, Long reservationId) { Reservation reservation = getOwnedReservation(userId, reservationId); - reservation.cancel(); - paymentService.cancelReservationPayment(reservation); + PaymentCancelResult payment = paymentService.getPaidPaymentForCancel(reservation); + if (payment == null) { + cancelReservation(userId, reservation); + } + + return payment; + } + + private void completeCancel(Long userId, Long reservationId, PaymentCancelResult payment) { + Reservation reservation = getOwnedReservation(userId, reservationId); + paymentService.markPaymentCancelled(payment.paymentPk()); + cancelReservation(userId, reservation); + } + + private void cancelReservation(Long userId, Reservation reservation) { + reservation.cancel(); reservedSeatRepository.deleteByReservation(reservation); - log.info("AUDIT reservation cancelled. userId={}, reservationId={}", userId, reservationId); + log.info("AUDIT reservation cancelled. userId={}, reservationId={}", userId, reservation.getId()); } /** 본인 예약 조회 */ diff --git a/src/main/java/com/cgv/spring_boot/domain/store/entity/StoreInventory.java b/src/main/java/com/cgv/spring_boot/domain/store/entity/StoreInventory.java index a09478ec..09fef09a 100644 --- a/src/main/java/com/cgv/spring_boot/domain/store/entity/StoreInventory.java +++ b/src/main/java/com/cgv/spring_boot/domain/store/entity/StoreInventory.java @@ -14,6 +14,9 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = { + @Index(name = "idx_store_inventory_theater_item", columnList = "theater_id, item_id") +}) public class StoreInventory extends BaseEntity { @Id diff --git a/src/test/java/com/cgv/spring_boot/domain/reservation/service/ReservationServiceTest.java b/src/test/java/com/cgv/spring_boot/domain/reservation/service/ReservationServiceTest.java index 760953b7..5aa6f6aa 100644 --- a/src/test/java/com/cgv/spring_boot/domain/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/cgv/spring_boot/domain/reservation/service/ReservationServiceTest.java @@ -2,11 +2,12 @@ import com.cgv.spring_boot.domain.reservation.dto.ReservationRequest; import com.cgv.spring_boot.domain.reservation.entity.Reservation; -import com.cgv.spring_boot.domain.reservation.entity.ReservedSeat; import com.cgv.spring_boot.domain.reservation.repository.ReservationRepository; import com.cgv.spring_boot.domain.reservation.repository.ReservedSeatRepository; import com.cgv.spring_boot.domain.schedule.entity.Schedule; import com.cgv.spring_boot.domain.schedule.repository.ScheduleRepository; +import com.cgv.spring_boot.domain.theater.entity.Hall; +import com.cgv.spring_boot.domain.theater.entity.HallType; import com.cgv.spring_boot.domain.user.entity.User; import com.cgv.spring_boot.domain.user.repository.UserRepository; import com.cgv.spring_boot.domain.reservation.exception.ReservationErrorCode; @@ -48,12 +49,21 @@ void reserve_success() { User user = mock(User.class); Schedule schedule = mock(Schedule.class); + Hall hall = mock(Hall.class); + HallType hallType = HallType.builder() + .typeName("일반관") + .rowCount(10) + .colCount(10) + .build(); Reservation reservation = Reservation.builder().build(); given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(scheduleRepository.findById(request.scheduleId())).willReturn(Optional.of(schedule)); - given(reservedSeatRepository.findAllByScheduleAndRowsAndCols(anyLong(), anyList(), anyList())) - .willReturn(List.of()); + given(schedule.getId()).willReturn(request.scheduleId()); + given(schedule.getHall()).willReturn(hall); + given(hall.getHallType()).willReturn(hallType); + given(reservedSeatRepository.existsByScheduleIdAndSeatRowAndSeatCol(anyLong(), anyString(), anyInt())) + .willReturn(false); given(reservationRepository.save(any(Reservation.class))).willReturn(reservation); // when @@ -61,7 +71,7 @@ void reserve_success() { // then verify(reservationRepository, times(1)).save(any(Reservation.class)); - verify(reservedSeatRepository, times(1)).saveAll(anyList()); + verify(reservedSeatRepository, times(1)).saveAllAndFlush(anyList()); } @Test @@ -70,12 +80,21 @@ void reserve_fail_already_reserved() { // given Long userId = 1L; ReservationRequest request = new ReservationRequest(10L, List.of(new ReservationRequest.SeatRequest("A", 1))); + Schedule schedule = mock(Schedule.class); + Hall hall = mock(Hall.class); + HallType hallType = HallType.builder() + .typeName("일반관") + .rowCount(10) + .colCount(10) + .build(); given(userRepository.findById(userId)).willReturn(Optional.of(mock(User.class))); - given(scheduleRepository.findById(anyLong())).willReturn(Optional.of(mock(Schedule.class))); - - given(reservedSeatRepository.findAllByScheduleAndRowsAndCols(anyLong(), anyList(), anyList())) - .willReturn(List.of(mock(ReservedSeat.class))); + given(scheduleRepository.findById(anyLong())).willReturn(Optional.of(schedule)); + given(schedule.getId()).willReturn(request.scheduleId()); + given(schedule.getHall()).willReturn(hall); + given(hall.getHallType()).willReturn(hallType); + given(reservedSeatRepository.existsByScheduleIdAndSeatRowAndSeatCol(anyLong(), anyString(), anyInt())) + .willReturn(true); // when(then) assertThatThrownBy(() -> reservationService.reserve(userId, request))