diff --git a/README.md b/README.md index 21f723c5..c5c2fdff 100644 --- a/README.md +++ b/README.md @@ -1386,3 +1386,436 @@ Mac은 Apple Silicon 환경이라 기본적으로 `arm64` 이미지가 빌드될 결제 API 실패 시 stack trace는 `application.log`에만 남기고, `audit.log`에는 요약 이벤트만 남기도록 분리했다. + +
+

성능 최적화

+ +## 1. 최적화 진행 배경 + +예매, 상영 시간표 조회, 만료 예약 처리 로직은 데이터가 누적될수록 조회 대상 row가 빠르게 늘어날 수 있다. + +따라서 DataGrip에서 `EXPLAIN ANALYZE`를 사용해 실제 실행 계획을 확인하고, 단일 컬럼 FK 인덱스만으로 충분하지 않은 쿼리에 복합 인덱스를 추가했다. + +테스트 데이터는 `reservations` 10,000건, `reservation_seats` 30,000건, `schedules` 300건을 기준으로 생성해 실행 계획을 비교했다. + +## 2. 예약 좌석 중복 체크 쿼리 + +예약 생성 시 같은 상영 일정에서 이미 선택된 좌석인지 확인하기 위해 아래 조건으로 조회한다. + +```sql +SELECT 1 +FROM reservation_seats rs +JOIN reservations r + ON rs.reservation_id = r.reservation_id +WHERE rs.schedule_id = 1 + AND rs.seat_row = 'A' + AND rs.seat_col = 1 + AND r.status <> 'CANCELED' +LIMIT 1; +``` + +### 인덱스 적용 전 + +![reservation seat before index](image/img_25.png) + +기존에는 `schedule_id`에 생성된 FK 인덱스만 사용했다. + +따라서 먼저 `schedule_id = 1` 조건으로 row를 찾은 뒤, `seat_row`, `seat_col` 조건은 별도의 Filter 단계에서 처리되었다. + +- 사용 인덱스: `schedule_id` FK 인덱스 +- 실행 방식: `schedule_id` 조회 후 좌석 행/열 조건 필터링 +- 실행 시간: 약 `3.39ms` + +### 인덱스 적용 + +```sql +CREATE INDEX idx_reservation_seats_schedule_seat +ON reservation_seats (schedule_id, seat_row, seat_col, reservation_id); +``` + +### 인덱스 적용 후 + +![reservation seat after index](image/img_26.png) + +복합 인덱스 적용 후에는 `schedule_id`, `seat_row`, `seat_col` 조건을 인덱스에서 한 번에 사용한다. + +또한 `reservation_id`까지 인덱스에 포함해 `reservations` 테이블과 조인할 때 필요한 값을 인덱스에서 바로 사용할 수 있도록 했다. + +- 사용 인덱스: `idx_reservation_seats_schedule_seat` +- 실행 방식: `schedule_id + seat_row + seat_col` 기반 Covering Index Lookup +- 실행 시간: 약 `0.0928ms` + +예약 좌석 중복 체크 쿼리는 약 `3.39ms`에서 `0.0928ms`로 감소했다. + +## 3. 만료 예약 조회 쿼리 + +결제 대기 상태인 예약 중 일정 시간이 지난 예약을 찾기 위해 스케줄러에서 아래 조건으로 조회한다. + +```sql +SELECT * +FROM reservations +WHERE status = 'PENDING' + AND reserved_at < NOW() - INTERVAL 10 MINUTE; +``` + +### 인덱스 적용 전 + +![expired reservation before index](image/img_27.png) + +![expired reservation before index detail](image/img_28.png) + +기존에는 조건에 맞는 예약을 찾기 위해 `reservations` 테이블 전체를 스캔했다. + +- 실행 방식: Table Scan +- 스캔 row 수: 10,000건 +- 실행 시간: 약 `4.29ms` + +### 인덱스 적용 + +```sql +CREATE INDEX idx_reservations_status_reserved_at +ON reservations (status, reserved_at); +``` + +`status = 'PENDING'`으로 먼저 대상을 좁히고, 그 안에서 `reserved_at < 특정 시간` 범위 조건을 사용할 수 있도록 복합 인덱스를 구성했다. + +인덱스 적용 후 실행 계획은 `Table Scan`에서 `Index Range Scan`으로 변경되었다. + +- 사용 인덱스: `idx_reservations_status_reserved_at` +- 실행 방식: `status` 동등 조건 + `reserved_at` 범위 조건 +- 실행 시간: 약 `0.047ms` + +만료 예약 조회 쿼리는 약 `4.29ms`에서 `0.047ms`로 감소했다. + +## 4. 상영 시간표 조회 쿼리 + +특정 영화와 극장에 해당하는 상영 시간표를 조회할 때 아래 조건을 사용한다. + +```sql +SELECT s.* +FROM schedules s +JOIN screens sc + ON s.screen_id = sc.screen_id +WHERE s.movie_id = 1 + AND sc.theater_id = 1; +``` + +### 인덱스 적용 전 + +![schedule before index](image/img_29.png) + +기존에는 `screens`에서 `theater_id` 조건으로 상영관을 찾은 뒤, `schedules`에서는 `screen_id` FK 인덱스만 사용했다. + +이후 `movie_id = 1` 조건은 Filter 단계에서 처리되었다. + +- 사용 인덱스: `screen_id` FK 인덱스 +- 실행 방식: `screen_id` 조회 후 `movie_id` 필터링 +- 실행 시간: 약 `3.03ms` + +### 인덱스 적용 + +```sql +CREATE INDEX idx_schedules_screen_movie +ON schedules (screen_id, movie_id); +``` + +실제 실행 계획에서 `screens.theater_id`로 먼저 상영관을 찾고, 그 결과인 `screen_id`로 `schedules`를 조회하고 있었다. + +따라서 조인 순서에 맞춰 `screen_id`, `movie_id` 순서의 복합 인덱스를 추가했다. + +### 인덱스 적용 후 + +![schedule after index](image/img_30.png) + +복합 인덱스 적용 후에는 `screen_id`와 `movie_id` 조건을 함께 사용해 필요한 상영 일정만 바로 조회한다. + +- 사용 인덱스: `idx_schedules_screen_movie` +- 실행 방식: `screen_id + movie_id` 기반 Index Lookup +- 실행 시간: 약 `0.15ms` + +상영 시간표 조회 쿼리는 약 `3.03ms`에서 `0.15ms`로 감소했다. + +## 5. 최적화 결과 + +| 대상 쿼리 | 적용 인덱스 | 개선 전 | 개선 후 | +| --- | --- | --- | --- | +| 예약 좌석 중복 체크 | `reservation_seats(schedule_id, seat_row, seat_col, reservation_id)` | 약 `3.39ms` | 약 `0.0928ms` | +| 만료 예약 조회 | `reservations(status, reserved_at)` | 약 `4.29ms` | 약 `0.047ms` | +| 상영 시간표 조회 | `schedules(screen_id, movie_id)` | 약 `3.03ms` | 약 `0.15ms` | + +이번 최적화를 통해 주요 조회 쿼리의 실행 계획을 `Table Scan` 또는 단일 FK 인덱스 조회 후 필터링 방식에서, 조건에 맞는 복합 인덱스를 직접 사용하는 방식으로 개선했다. + +
+ +
+

트랜잭션 분석 및 개선

+ +## 1. 문제 상황 + +매점 주문 생성 로직에서는 재고 차감과 결제 요청이 함께 처리된다. + +기존 `OrderServicePessimistic`의 주문 생성 흐름은 하나의 트랜잭션 안에서 재고에 비관적 락을 획득하고, 재고를 차감한 뒤 외부 결제 API까지 호출하는 구조였다. + +```text +트랜잭션 시작 +→ 재고 조회 및 PESSIMISTIC_WRITE 락 획득 +→ 재고 차감 +→ 외부 결제 API 호출 +→ 주문 저장 +→ 트랜잭션 커밋 +``` + +이 구조에서는 결제 API 응답을 기다리는 동안 DB 트랜잭션이 유지되고, 재고 row에 대한 락도 오래 점유될 수 있다. + +따라서 결제 서버 응답 지연이 발생하면 같은 재고를 주문하려는 다른 요청들이 락 대기 상태에 놓이고, 전체 주문 처리량이 낮아질 수 있다. + +## 2. 개선 방향 + +외부 API 호출은 DB 트랜잭션 밖에서 수행하도록 주문 생성 흐름을 분리했다. + +개선 후 흐름은 아래와 같다. + +```text +1. 짧은 DB 트랜잭션 + → 재고 비관적 락 획득 + → 재고 차감 + → PENDING 주문 생성 + → 커밋 + +2. 트랜잭션 밖 + → 외부 결제 API 호출 + +3. 짧은 DB 트랜잭션 + → 결제 성공 시 주문 상태를 PAID로 변경 + → 결제 실패 시 주문 상태를 CANCELED로 변경하고 재고 복구 +``` + +## 3. 적용 내용 + +주문 Entity에는 결제 진행 상태를 명확히 표현하기 위해 `PENDING` 주문 생성과 상태 전이 메서드를 추가했다. + +```java +public static Order createPending(User user, Store store, String paymentId, int totalPrice) +public void completePayment() +public void cancelPending() +``` + +`OrderServicePessimistic`에서는 `TransactionTemplate`을 사용해 트랜잭션 경계를 명시적으로 분리했다. + +```java +PendingOrder pendingOrder = transactionTemplate.execute(status -> + createPendingOrder(userId, storeId, request) +); + +paymentService.pay(pendingOrder.paymentId(), pendingOrder.orderName(), pendingOrder.totalPrice()); + +return transactionTemplate.execute(status -> + completePayment(pendingOrder.orderId()) +); +``` + +결제 실패 시에는 별도의 짧은 트랜잭션에서 재고를 다시 증가시키고 주문 상태를 `CANCELED`로 변경한다. + +## 4. 개선 결과 + +이번 변경으로 외부 결제 API 응답 시간만큼 DB 트랜잭션과 재고 락이 길게 유지되는 문제를 줄였다. + +재고 차감과 주문 상태 변경은 각각 짧은 트랜잭션으로 처리하고, 네트워크 지연 가능성이 있는 결제 API 호출은 트랜잭션 밖에서 수행하도록 분리했다. + +
+ +
+

트랜잭션 전파 속성 조사

+ +## 1. 트랜잭션 전파 속성이란 + +트랜잭션 전파 속성은 이미 트랜잭션이 존재하는 상황에서 다른 `@Transactional` 메서드가 호출될 때, 기존 트랜잭션에 참여할지, 새 트랜잭션을 만들지, 트랜잭션 없이 실행할지를 결정하는 설정이다. + +Spring에서는 `@Transactional(propagation = Propagation.XXX)` 형태로 지정한다. + +## 2. 전파 속성 종류 + +| 전파 속성 | 동작 방식 | 사용 예시 | +| --- | --- | --- | +| `REQUIRED` | 기존 트랜잭션이 있으면 참여하고, 없으면 새로 생성한다. 기본값이다. | 대부분의 일반적인 쓰기 로직 | +| `REQUIRES_NEW` | 항상 새 트랜잭션을 생성한다. 기존 트랜잭션이 있으면 잠시 중단된다. | 메인 트랜잭션과 독립적으로 커밋되어야 하는 로그, 이력 저장 | +| `SUPPORTS` | 기존 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행한다. | 트랜잭션이 필수는 아닌 조회성 로직 | +| `MANDATORY` | 반드시 기존 트랜잭션이 있어야 한다. 없으면 예외가 발생한다. | 상위 서비스 트랜잭션 안에서만 호출되어야 하는 내부 로직 | +| `NOT_SUPPORTED` | 기존 트랜잭션이 있으면 중단하고, 트랜잭션 없이 실행한다. | 트랜잭션에 묶을 필요가 없는 외부 API 호출, 오래 걸리는 작업 | +| `NEVER` | 트랜잭션이 있으면 예외가 발생하고, 없을 때만 실행한다. | 트랜잭션 안에서 실행되면 안 되는 작업 | +| `NESTED` | 기존 트랜잭션 안에서 savepoint를 만들고 중첩 트랜잭션처럼 실행한다. 기존 트랜잭션이 없으면 `REQUIRED`처럼 새로 시작한다. | 일부 작업만 롤백하고 전체 트랜잭션은 유지하고 싶을 때 | + +## 3. 주요 속성 비교 + +### `REQUIRED` + +가장 일반적인 기본 전파 속성이다. + +```java +@Transactional +public void createOrder() { + ... +} +``` + +상위 트랜잭션이 있으면 같은 트랜잭션에 참여하기 때문에, 내부 메서드에서 예외가 발생하면 전체 작업이 함께 롤백될 수 있다. + +### `REQUIRES_NEW` + +항상 독립적인 새 트랜잭션을 만든다. + +```java +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void saveAuditLog() { + ... +} +``` + +기존 트랜잭션과 커밋/롤백 범위가 분리된다. 예를 들어 주문 생성은 실패하더라도 감사 로그는 남겨야 하는 경우 사용할 수 있다. + +단, 새 트랜잭션을 만들기 위해 별도 DB connection이 필요할 수 있으므로 남용하면 connection pool 부담이 커질 수 있다. + +### `NOT_SUPPORTED` + +트랜잭션을 잠시 중단하고 트랜잭션 없이 실행한다. + +```java +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public void callExternalApi() { + ... +} +``` + +외부 API 호출처럼 DB 트랜잭션에 묶을 필요가 없고 오래 걸릴 수 있는 작업에 사용할 수 있다. + +이번 주문 트랜잭션 개선에서는 `TransactionTemplate`으로 트랜잭션 경계를 직접 나누어 결제 API 호출을 트랜잭션 밖으로 분리했다. 같은 문제를 선언형 트랜잭션으로 풀 때는 `NOT_SUPPORTED`도 고려할 수 있다. + +### `NESTED` + +기존 트랜잭션 안에서 savepoint를 만들어 일부 작업만 롤백할 수 있게 한다. + +```java +@Transactional(propagation = Propagation.NESTED) +public void applyOptionalBenefit() { + ... +} +``` + +전체 주문 트랜잭션은 유지하되, 부가 혜택 적용 실패만 되돌리는 식의 흐름에서 사용할 수 있다. + +다만 실제 동작은 사용하는 트랜잭션 매니저와 DB의 savepoint 지원 여부에 영향을 받는다. + +## 4. 프로젝트 적용 관점 + +현재 프로젝트에서는 대부분의 쓰기 로직에 기본값인 `REQUIRED`가 적합하다. + +예약의 Named Lock 구조에서는 외부 서비스가 락을 잡고, 내부 서비스가 DB 트랜잭션을 수행한다. 이때 내부 메서드에 `REQUIRES_NEW`를 사용하면 락 획득 흐름과 DB 작업 트랜잭션을 분리해서 표현할 수 있다. + +매점 주문 결제처럼 외부 API 호출이 포함된 흐름에서는 하나의 긴 트랜잭션으로 묶기보다, 트랜잭션을 짧게 나누거나 외부 API 구간을 트랜잭션 밖으로 빼는 것이 적절하다. + +참고 자료: [Spring Framework `Propagation` 공식 문서](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Propagation.html) + +
+ +
+

인덱스 종류와 특징 조사

+ +## 1. 인덱스란 + +인덱스는 테이블의 row를 더 빠르게 찾기 위한 자료구조다. + +인덱스가 없으면 조건에 맞는 데이터를 찾기 위해 테이블 전체를 훑는 `Table Scan`이 발생할 수 있다. 반대로 적절한 인덱스가 있으면 `Index Lookup`, `Index Range Scan`처럼 필요한 범위만 빠르게 탐색할 수 있다. + +다만 인덱스는 조회 성능을 높이는 대신, insert/update/delete 시 인덱스도 함께 갱신해야 하므로 쓰기 비용과 저장 공간이 증가한다. + +## 2. 대표적인 인덱스 종류 + +| 인덱스 종류 | 특징 | 사용 예시 | +| --- | --- | --- | +| Primary Key Index | 기본키에 생성되는 인덱스다. row를 고유하게 식별한다. | `users.user_id`, `orders.order_id` | +| Unique Index | 중복 값을 허용하지 않는 인덱스다. 조회 성능과 중복 방지를 함께 제공한다. | `users.email`, `orders.payment_id` | +| Single Column Index | 하나의 컬럼으로 구성된 인덱스다. | `reservations.status` | +| Composite Index | 여러 컬럼을 묶은 인덱스다. 컬럼 순서가 중요하다. | `(status, reserved_at)`, `(screen_id, movie_id)` | +| Covering Index | 쿼리에 필요한 컬럼을 모두 포함해 테이블 본문 조회를 줄일 수 있는 인덱스다. | `(schedule_id, seat_row, seat_col, reservation_id)` | +| B-Tree Index | MySQL InnoDB에서 일반적으로 사용하는 인덱스 구조다. 동등 조건과 범위 조건에 모두 활용된다. | `=`, `<`, `>`, `BETWEEN`, `ORDER BY` | +| Hash Index | 값을 해시로 찾는 방식이다. 동등 비교에 강하지만 범위 조회에는 적합하지 않다. MySQL에서는 주로 MEMORY 엔진에서 사용된다. | `key = ?` | +| Full-Text Index | 긴 문자열에서 단어 기반 검색을 빠르게 수행하기 위한 인덱스다. | 영화 리뷰 내용 검색 | +| Spatial Index | 위치, 좌표 같은 공간 데이터를 검색하기 위한 인덱스다. | 주변 영화관 검색 | + +## 3. 복합 인덱스 + +복합 인덱스는 여러 컬럼을 하나의 인덱스로 묶는 방식이다. + +```sql +CREATE INDEX idx_reservations_status_reserved_at +ON reservations (status, reserved_at); +``` + +이 인덱스는 아래 쿼리에 적합하다. + +```sql +SELECT * +FROM reservations +WHERE status = 'PENDING' + AND reserved_at < NOW() - INTERVAL 10 MINUTE; +``` + +`status`로 먼저 대상을 좁힌 뒤, `reserved_at` 범위 조건으로 만료 예약을 찾을 수 있기 때문이다. + +복합 인덱스에서는 컬럼 순서가 중요하다. 일반적으로 동등 조건으로 자주 쓰이는 컬럼을 앞에 두고, 범위 조건 컬럼을 뒤에 둔다. + +## 4. 커버링 인덱스 + +커버링 인덱스는 쿼리 실행에 필요한 컬럼이 모두 인덱스 안에 포함된 경우를 말한다. + +이번 좌석 중복 체크 쿼리에서는 아래 인덱스를 사용했다. + +```sql +CREATE INDEX idx_reservation_seats_schedule_seat +ON reservation_seats (schedule_id, seat_row, seat_col, reservation_id); +``` + +조회 조건에 필요한 `schedule_id`, `seat_row`, `seat_col`뿐 아니라, `reservations`와 조인할 때 필요한 `reservation_id`도 포함했다. + +그 결과 실행 계획에서 `Covering index lookup`이 나타났고, `reservation_seats` 테이블 본문을 추가로 조회하는 비용을 줄일 수 있었다. + +## 5. B-Tree 인덱스 + +MySQL InnoDB의 일반적인 인덱스는 B-Tree 기반이다. + +B-Tree 인덱스는 정렬된 구조를 유지하기 때문에 아래 조건에 잘 맞는다. + +- 동등 조건: `movie_id = 1` +- 범위 조건: `reserved_at < ?` +- 정렬: `ORDER BY start_at` +- 접두 검색: `name LIKE 'CGV%'` + +반면 컬럼을 함수로 감싸거나 앞쪽 와일드카드를 사용하는 조건은 인덱스를 제대로 활용하기 어렵다. + +```sql +-- 인덱스 활용이 어려울 수 있음 +WHERE DATE(reserved_at) = '2026-05-23' + +-- 앞쪽 와일드카드라 일반 B-Tree 인덱스 활용이 어려움 +WHERE name LIKE '%강남' +``` + +## 6. 인덱스 설계 시 주의점 + +인덱스는 많다고 무조건 좋은 것이 아니다. + +조회가 빨라지는 대신 쓰기 작업에서 인덱스 갱신 비용이 추가되고, 저장 공간도 더 사용한다. + +따라서 실제로 자주 실행되는 쿼리를 기준으로 `EXPLAIN ANALYZE`를 확인하고, `Table Scan`, `Using filesort`, `Using temporary`, 과도한 row scan이 발생하는 경우에 인덱스를 추가하는 것이 좋다. + +이번 프로젝트에서는 실제 실행 계획을 확인한 뒤 아래 인덱스를 추가했다. + +| 대상 쿼리 | 인덱스 | 목적 | +| --- | --- | --- | +| 예약 좌석 중복 체크 | `(schedule_id, seat_row, seat_col, reservation_id)` | 좌석 조건을 한 번에 찾고 조인 컬럼까지 인덱스에서 사용 | +| 만료 예약 조회 | `(status, reserved_at)` | 상태 조건과 예약 시간 범위 조건 최적화 | +| 상영 시간표 조회 | `(screen_id, movie_id)` | 상영관 기준 조회 후 영화 조건 필터링 제거 | + +참고 자료: [MySQL 8.4 공식 문서 - How MySQL Uses Indexes](https://dev.mysql.com/doc/refman/8.4/en/mysql-indexes.html), [MySQL 8.4 공식 문서 - CREATE INDEX](https://dev.mysql.com/doc/mysql/en/create-index.html) + +
diff --git a/image/img_25.png b/image/img_25.png new file mode 100644 index 00000000..73e33bf5 Binary files /dev/null and b/image/img_25.png differ diff --git a/image/img_26.png b/image/img_26.png new file mode 100644 index 00000000..68c6ed9a Binary files /dev/null and b/image/img_26.png differ diff --git a/image/img_27.png b/image/img_27.png new file mode 100644 index 00000000..d0f4b240 Binary files /dev/null and b/image/img_27.png differ diff --git a/image/img_28.png b/image/img_28.png new file mode 100644 index 00000000..4abe3153 Binary files /dev/null and b/image/img_28.png differ diff --git a/image/img_29.png b/image/img_29.png new file mode 100644 index 00000000..eaa5a5b2 Binary files /dev/null and b/image/img_29.png differ diff --git a/image/img_30.png b/image/img_30.png new file mode 100644 index 00000000..265dcce2 Binary files /dev/null and b/image/img_30.png differ diff --git a/src/main/java/com/ceos23/cgv_clone/global/response/ErrorCode.java b/src/main/java/com/ceos23/cgv_clone/global/response/ErrorCode.java index c3ffd8cb..029c2203 100644 --- a/src/main/java/com/ceos23/cgv_clone/global/response/ErrorCode.java +++ b/src/main/java/com/ceos23/cgv_clone/global/response/ErrorCode.java @@ -113,6 +113,9 @@ public enum ErrorCode { // 취소 권한 없음 (타 유저 주문) INVALID_ORDER_OWNER(HttpStatus.FORBIDDEN, "ORD003", "해당 주문을 취소할 권한이 없습니다."), + // 주문 상태 불일치 + INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, "ORD004", "주문 상태가 올바르지 않습니다."), + // 요청 매점 불일치 INVALID_INVENTORY(HttpStatus.BAD_REQUEST, "INV001", "잘못된 매점 요청입니다."), diff --git a/src/main/java/com/ceos23/cgv_clone/store/entity/Order.java b/src/main/java/com/ceos23/cgv_clone/store/entity/Order.java index 26aa2a0a..8fb2f6a2 100644 --- a/src/main/java/com/ceos23/cgv_clone/store/entity/Order.java +++ b/src/main/java/com/ceos23/cgv_clone/store/entity/Order.java @@ -64,6 +64,32 @@ public static Order createPaid(User user, Store store, String paymentId, int tot .build(); } + public static Order createPending(User user, Store store, String paymentId, int totalPrice) { + return Order.builder() + .paymentId(paymentId) + .orderStatus(OrderStatus.PENDING) + .totalPrice(totalPrice) + .user(user) + .store(store) + .build(); + } + + public void completePayment() { + if (orderStatus != OrderStatus.PENDING) { + throw new CustomException(ErrorCode.INVALID_ORDER_STATUS); + } + + this.orderStatus = OrderStatus.PAID; + } + + public void cancelPending() { + if (orderStatus != OrderStatus.PENDING) { + throw new CustomException(ErrorCode.INVALID_ORDER_STATUS); + } + + this.orderStatus = OrderStatus.CANCELED; + } + public void cancel() { if (orderStatus != OrderStatus.PAID) { throw new CustomException(ErrorCode.ALREADY_CANCELED_ORDER); diff --git a/src/main/java/com/ceos23/cgv_clone/store/service/impl/OrderServicePessimistic.java b/src/main/java/com/ceos23/cgv_clone/store/service/impl/OrderServicePessimistic.java index 61c3714f..5defc17b 100644 --- a/src/main/java/com/ceos23/cgv_clone/store/service/impl/OrderServicePessimistic.java +++ b/src/main/java/com/ceos23/cgv_clone/store/service/impl/OrderServicePessimistic.java @@ -16,10 +16,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Objects; import static java.time.LocalDate.*; import static java.time.format.DateTimeFormatter.*; @@ -34,10 +36,27 @@ public class OrderServicePessimistic implements OrderService { private final InventoryRepository inventoryRepository; private final OrderRepository orderRepository; private final PaymentService paymentService; + private final TransactionTemplate transactionTemplate; @Override - @Transactional public OrderResponse createOrder(Long userId, Long storeId, OrderRequest request) { + PendingOrder pendingOrder = Objects.requireNonNull(transactionTemplate.execute(status -> + createPendingOrder(userId, storeId, request) + )); + + try { + paymentService.pay(pendingOrder.paymentId(), pendingOrder.orderName(), pendingOrder.totalPrice()); + } catch (Exception e) { + transactionTemplate.executeWithoutResult(status -> cancelPendingOrder(pendingOrder.orderId())); + throw e; + } + + return Objects.requireNonNull(transactionTemplate.execute(status -> + completePayment(pendingOrder.orderId()) + )); + } + + private PendingOrder createPendingOrder(Long userId, Long storeId, OrderRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); Store store = storeRepository.findById(storeId) @@ -51,16 +70,31 @@ public OrderResponse createOrder(Long userId, Long storeId, OrderRequest request String paymentId = generatePaymentId(); String orderName = buildOrderName(inventories); - paymentService.pay(paymentId, orderName, totalPrice); - - Order order = Order.createPaid(user, store, paymentId, totalPrice); + Order order = Order.createPending(user, store, paymentId, totalPrice); addOrderItems(order, inventories, items); orderRepository.save(order); + return new PendingOrder(order.getId(), paymentId, orderName, totalPrice); + } + + private OrderResponse completePayment(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); + + order.completePayment(); + return OrderResponse.from(order); } + private void cancelPendingOrder(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); + + restoreInventories(order); + order.cancelPending(); + } + @Override @Transactional public OrderResponse cancelOrder(Long userId, Long orderId) { @@ -71,17 +105,7 @@ public OrderResponse cancelOrder(Long userId, Long orderId) { paymentService.cancel(order.getPaymentId()); - List items = order.getOrderItems().stream() - .sorted(Comparator.comparing(oi -> oi.getInventory().getId())) - .toList(); - - for (OrderItem item : items) { - Inventory inv = inventoryRepository.findByIdWithPessimisticLock(item.getInventory().getId()) - .orElseThrow(() -> new CustomException(ErrorCode.ITEM_NOT_FOUND)); - - inv.increase(item.getQuantity()); - } - + restoreInventories(order); order.cancel(); return OrderResponse.from(order); @@ -112,6 +136,19 @@ private void verifyOrderOwner(Long userId, Order order) { } } + private void restoreInventories(Order order) { + List items = order.getOrderItems().stream() + .sorted(Comparator.comparing(oi -> oi.getInventory().getId())) + .toList(); + + for (OrderItem item : items) { + Inventory inv = inventoryRepository.findByIdWithPessimisticLock(item.getInventory().getId()) + .orElseThrow(() -> new CustomException(ErrorCode.ITEM_NOT_FOUND)); + + inv.increase(item.getQuantity()); + } + } + private List validateAndDecreaseStock(Store store, List items) { List inventories = new ArrayList<>(); @@ -153,4 +190,7 @@ private String buildOrderName(List inventories) { return inventories.size() == 1 ? first : first + " 외 " + (inventories.size() - 1) + "건"; } + private record PendingOrder(Long orderId, String paymentId, String orderName, int totalPrice) { + } + }