Compare commits

..

7 Commits

Author SHA1 Message Date
5e572c842c Merge pull request '[#72] 로그 레벨 재조정' (#73) from refactor/#72 into main
Reviewed-on: #73
2025-11-08 06:02:09 +00:00
7a236a8196 chore: 미사용 마크다운 제거 2025-10-21 09:44:34 +09:00
0756e21b63 refactor: 로그 레벨 재조정 2025-10-21 08:05:20 +09:00
162e5bbc79 [#70] 중복 조회 로직에 로컬 캐시 도입 (#71)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #70

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Spring Cache + Caffeine 도입
- 테마 조회 및 수정 / 삭제 로직에 캐시 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- P95 응답 시간 약 14% 개선
- Hikari Pool Connection & Tomcat Threads에서의 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #71
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 10:47:15 +00:00
be2e6c606e [#68] ArgumentResolver에서의 불필요한 DB 요청 로직 제거 (#69)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #68

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- ArgumentResolver에서의 DB 조회 요청 제거

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 성능 테스트 결과 Hikari Pending 커넥션의 최대 개수가 80 -> 5로 대폭 감소
- Tomcat 스레드도 기존은 최대 200개까지 활성화 되었으나, 개선 후 최대 80까지만 처리됨.
- P95 응답 시간 235 -> 141ms로 40% 개선

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #69
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 06:18:50 +00:00
06f7faf7f9 [#66] 결제 & 예약 확정 로직 수정 (#67)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #66

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이전 #64 의 작업 이후, 결제 & 예약 확정 API 로직은 총 3개의 독립된 트랜잭션을 사용함.
- 검증 & 배치 충돌 방지를 위한 첫 번째 트랜잭션 이외의 다른 트랜잭션은 불필요하다고 판단함. -> PG사 성공 응답이 오면 나머지 작업은 \@Async 처리 후 바로 성공 응답

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 변경된 로직에 대한 통합 테스트 완료
- 성능 테스트 결과 P95 응답 시간 327.01ms -> 235.52ms / 평균 응답 시간 77.85 -> 68.16ms /  최대 응답 시간 5.26 -> 4.08초 단축

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #67
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-17 04:59:12 +00:00
79de5c9c63 [#64] 결제 & 예약 확정 API에서의 트랜잭션 범위 수정 (#65)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #64

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 기존의 결제 시도 이력 테이블 조회 & 검증 -> 예약 / 일정 조회 및 검증을 하나의 트랜잭션으로 통합
- 예약 / 일정 LOCK 조회를 가장 먼저 수행 -> 배치와의 충돌을 방지하기 위함

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 동일 조건에서 테스트했을 때 P95 응답 시간 749 -> 327ms로 50% 가량 개선 확인
- 커넥션 대기로 길어진 최대 API 응답 시간 7.70 -> 2.88초로 대폭 감소

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #65
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-15 02:24:18 +00:00
76 changed files with 1174 additions and 2025 deletions

View File

@ -1,29 +1,9 @@
FROM gradle:8-jdk17 AS dependencies
WORKDIR /app
COPY gradlew settings.gradle build.gradle.kts /app/
COPY gradle /app/gradle
COPY service/build.gradle.kts /app/service/
COPY tosspay-mock/build.gradle.kts /app/tosspay-mock/
COPY common/log/build.gradle.kts /app/common/log/
COPY common/persistence/build.gradle.kts /app/common/persistence/
COPY common/types/build.gradle.kts /app/common/types/
COPY common/utils/build.gradle.kts /app/common/utils/
COPY common/web/build.gradle.kts /app/common/web/
RUN ./gradlew dependencies --no-daemon
FROM dependencies AS builder
WORKDIR /app
COPY . .
RUN ./gradlew :service:bootjar --no-daemon
FROM amazoncorretto:17 FROM amazoncorretto:17
WORKDIR /app WORKDIR /app
COPY service/build/libs/service.jar app.jar
EXPOSE 8080 EXPOSE 8080
COPY --from=builder /app/service/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -1,3 +0,0 @@
# issue-pr-template
공통으로 사용하게 될 이슈, PR 템플릿 저장소

6
build.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
IMAGE_NAME="roomescape-backend"
IMAGE_TAG=$1
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push

845
query.md
View File

@ -1,845 +0,0 @@
## Auth
**로그인**
```sql
-- 회원
-- 이메일로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처로 회원 조회
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 회원 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 회원 상태 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
### Payment
**결제 승인 & 저장**
```sql
-- 결제 정보 추가
INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 결제 상세 정보 추가
INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id
) VALUES ( ?, ?, ?, ?
);
-- 카드 결제 상세 정보 추가
INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**결제 취소**
SQL
```sql
-- 예약 ID로 결제 정보 조회
SELECT
p.id,
p.approved_at,
p.method,
p.order_id,
p.payment_key,
p.requested_at,
p.reservation_id,
p.status,
p.total_amount,
p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 추가
-- 취소된 결제 정보 추가
INSERT INTO canceled_payment (
cancel_amount, cancel_reason, canceled_at, canceled_by,
card_discount_amount, easypay_discount_amount, payment_id,
requested_at, transfer_discount_amount, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
### Region
**모든 시/도 조회**
```sql
SELECT DISTINCT
r.sido_code,
r.sido_name
FROM
region r
ORDER BY
r.sido_name;
```
**시/군/구 조회**
```sql
SELECT
r.sigungu_code,
r.sigungu_name
FROM
region r
WHERE
r.sido_code = ?
GROUP BY
r.sigungu_code, r.sigungu_name
ORDER BY
r.sigungu_name;
```
**지역 코드 조회**
```sql
SELECT
r.code
FROM
region r
WHERE
r.sido_code = ? AND r.sigungu_code = ?;
```
### Reservation
**Pending 예약 생성**
```sql
-- schedule 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- theme 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 예약 추가
INSERT INTO reservation (
created_at, created_by, participant_count, requirement,
reserver_contact, reserver_name, schedule_id, status,
updated_at, updated_by, user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**확정**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 예약 확정
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- Schedule 확정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**취소**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 취소 예약 추가
INSERT INTO canceled_reservation (
cancel_reason, canceled_at, canceled_by,
reservation_id, status, id
) VALUES (
?, ?, ?, ?, ?, ?
);
-- 예약 취소
UPDATE
reservation
SET
participant_count = ?, requirement = ?, reserver_contact = ?,
reserver_name = ?, schedule_id = ?, status = ?,
updated_at = ?, updated_by = ?, user_id = ?
WHERE
id = ?;
-- 일정 활성화
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**회원 예약 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.user_id = ? AND r.status IN (?, ?);
-- 일정 조회 -> 각 예약별 1개씩(N개)
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id
JOIN store st ON st.id = s.store_id
WHERE
s.id = ?;
```
**예약 상세 조회**
```sql
-- 예약 조회
SELECT
r.id, r.created_at, r.created_by, r.participant_count, r.requirement,
r.reserver_contact, r.reserver_name, r.schedule_id, r.status,
r.updated_at, r.updated_by, r.user_id
FROM
reservation r
WHERE
r.id = ?;
-- 회원 연락처 정보 조회
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
-- 결제 정보 조회
SELECT
p.id, p.approved_at, p.method, p.order_id, p.payment_key,
p.requested_at, p.reservation_id, p.status, p.total_amount, p.type
FROM
payment p
WHERE
p.reservation_id = ?;
-- 결제 상세 정보 조회
SELECT
pd.id,
CASE
WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer
WHEN pcd.id IS NOT NULL THEN 2 -- card
WHEN pep.id IS NOT NULL THEN 3 -- easypay
WHEN pd.id IS NOT NULL THEN 0 -- etc
END AS payment_type,
pd.payment_id, pd.supplied_amount, pd.vat,
pbt.bank_code, pbt.settlement_status,
pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type,
pcd.easypay_discount_amount, pcd.easypay_provider_code,
pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code,
pcd.owner_type,
pep.amount AS easypay_amount,
pep.discount_amount AS easypay_discount_amount,
pep.easypay_provider_code AS easypay_provider
FROM
payment_detail pd
LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id
LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id
LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id
WHERE
pd.payment_id = ?;
-- 취소 결제 정보 조회
SELECT
cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at,
cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount,
cp.payment_id, cp.requested_at, cp.transfer_discount_amount
FROM
canceled_payment cp
WHERE
cp.payment_id = ?;
```
### Schedule
**날짜, 시간, 테마로 조회**
```sql
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
```
**감사 정보 조회**
```sql
-- 일정 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 작업자 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**일정 생성**
```sql
-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인
SELECT EXISTS (
SELECT 1
FROM schedule s
WHERE
s.store_id = ?
AND s.date = ?
AND s.theme_id = ?
AND s.time = ?
);
-- 시간이 겹치는 같은 날의 일정이 있는지 확인
SELECT
s.id,
st.id AS store_id,
st.name AS store_name,
s.date,
s.time,
t.id AS theme_id,
t.name AS theme_name,
t.difficulty,
t.available_minutes,
s.status
FROM
schedule s
JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?)
JOIN store st ON st.id = s.store_id AND st.id = ?
WHERE
s.date = ?
-- 일정 추가
INSERT INTO schedule (
created_at, created_by, date, status, store_id,
theme_id, time, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**일정 수정**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**일정 삭제**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 삭제
DELETE FROM schedule
WHERE id = ?;
```
**상태 → HOLD 변경**
```sql
-- 조회
SELECT
s.id, s.created_at, s.created_by, s.date, s.status, s.store_id,
s.theme_id, s.time, s.updated_at, s.updated_by
FROM
schedule s
WHERE
s.id = ?;
-- 수정
UPDATE
schedule
SET
date = ?, status = ?, store_id = ?, theme_id = ?, time = ?,
updated_at = ?, updated_by = ?
WHERE
id = ?;
```
### Store
**매장 상세 조회**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 지역 정보 조회
SELECT
r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name
FROM
region r
WHERE
r.code = ?;
-- 감사 정보 조회(createdBy, updatedBy)
SELECT
a.id, a.account, a.created_at, a.created_by, a.name, a.password,
a.permission_level, a.phone, a.store_id, a.type, a.updated_at,
a.updated_by
FROM
admin a
WHERE
a.id = ?;
```
**매장 등록**
```sql
-- 이름 중복 확인
SELECT s.id FROM store s WHERE s.name = ? LIMIT 1;
-- 연락처 중복 확인
SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1;
-- 주소 중복 확인
SELECT s.id FROM store s WHERE s.address = ? LIMIT 1;
-- 사업자번호 중복 확인
SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1;
-- 추가
INSERT INTO store (
address, business_reg_num, contact, created_at, created_by,
name, region_code, status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**매장 수정**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**비활성화(status = DISABLE)**
```sql
-- 조회
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
-- 수정
UPDATE
store
SET
address = ?, business_reg_num = ?, contact = ?, name = ?,
region_code = ?, status = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**모든 매장 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.status = 'ACTIVE'
AND (? IS NULL OR s.region_code LIKE ?);
```
**개별 매장 상세 조회**
```sql
SELECT
s.id, s.address, s.business_reg_num, s.contact, s.created_at,
s.created_by, s.name, s.region_code, s.status, s.updated_at,
s.updated_by
FROM
store s
WHERE
s.id = ? AND s.status = 'ACTIVE';
```
### Theme
**생성**
```sql
-- 이름으로 조회
SELECT
t.id
FROM
theme t
WHERE
t.name = ?
LIMIT 1;
-- 추가
INSERT INTO theme (
available_minutes, created_at, created_by, description, difficulty,
expected_minutes_from, expected_minutes_to, is_active, max_participants,
min_participants, name, price, thumbnail_url, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
```
**Active인 모든 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.is_active = TRUE;
```
**테마 목록 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t;
```
**감사 정보 포함 개별 테마 상세 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**개별 테마 조회**
```sql
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
```
**삭제**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 삭제
DELETE FROM theme WHERE id = ?;
```
**수정**
```sql
-- 조회
SELECT
t.id, t.available_minutes, t.created_at, t.created_by, t.description,
t.difficulty, t.expected_minutes_from, t.expected_minutes_to,
t.is_active, t.max_participants, t.min_participants, t.name,
t.price, t.thumbnail_url, t.updated_at, t.updated_by
FROM
theme t
WHERE
t.id = ?;
-- 수정
UPDATE
theme
SET
available_minutes = ?, description = ?, difficulty = ?,
expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?,
max_participants = ?, min_participants = ?, name = ?, price = ?,
thumbnail_url = ?, updated_at = ?, updated_by = ?
WHERE
id = ?;
```
**인기 테마 조회**
```sql
SELECT
t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price,
t.min_participants, t.max_participants,
t.available_minutes, t.expected_minutes_from, t.expected_minutes_to
FROM
theme t
JOIN (
SELECT
s.theme_id, count(*) as reservation_count
FROM
schedule s
JOIN
reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED'
WHERE
s.status = 'RESERVED'
AND (s.date BETWEEN :startFrom AND :endAt)
GROUP BY
s.theme_id
ORDER BY
reservation_count desc
LIMIT :count
) ranked_themes ON t.id = ranked_themes.theme_id
```
### User
**회원가입**
```sql
-- 이메일 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.email = ?
LIMIT 1;
-- 연락처 중복 확인
SELECT
u.id
FROM
users u
WHERE
u.phone = ?
LIMIT 1;
-- 추가
INSERT INTO users (
created_at, created_by, email, name, password, phone, region_code,
status, updated_at, updated_by, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- 상태 변경 이력 추가
INSERT INTO user_status_history (
created_at, created_by, reason, status, updated_at, updated_by,
user_id, id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
);
```
**연락처 정보 조회**
```sql
SELECT
u.id, u.created_at, u.created_by, u.email, u.name, u.password,
u.phone, u.region_code, u.status, u.updated_at, u.updated_by
FROM
users u
WHERE
u.id = ?;
```

View File

@ -8,6 +8,10 @@ dependencies {
// API docs // API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// Cache
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
// DB // DB
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.mysql:mysql-connector-j")

View File

@ -3,8 +3,14 @@ package com.sangdol.roomescape
import org.springframework.boot.Banner import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.* import java.util.*
@EnableAsync
@EnableCaching
@EnableScheduling
@SpringBootApplication( @SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"] scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
) )

View File

@ -20,7 +20,7 @@ class AdminService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials { fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.info { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" } log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account) return adminRepository.findByAccount(account)
?.let { ?.let {
@ -28,14 +28,14 @@ class AdminService(
it.toCredentials() it.toCredentials()
} }
?: run { ?: run {
log.info { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" } log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findOperatorOrUnknown(id: Long): Auditor { fun findOperatorOrUnknown(id: Long): Auditor {
log.info { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" } log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)?.let { admin -> return adminRepository.findByIdOrNull(id)?.let { admin ->
Auditor(admin.id, admin.name).also { Auditor(admin.id, admin.name).also {

View File

@ -33,7 +33,7 @@ class AuthService(
request: LoginRequest, request: LoginRequest,
context: LoginContext context: LoginContext
): LoginSuccessResponse { ): LoginSuccessResponse {
log.info { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } log.debug { "[login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" }
val (credentials, extraClaims) = getCredentials(request) val (credentials, extraClaims) = getCredentials(request)
val event = LoginHistoryEvent( val event = LoginHistoryEvent(
@ -56,10 +56,7 @@ class AuthService(
} catch (e: Exception) { } catch (e: Exception) {
eventPublisher.publishEvent(event.onFailure()) eventPublisher.publishEvent(event.onFailure())
when (e) { when (e) {
is AuthException -> { is AuthException -> { throw e }
log.info { "[login] 로그인 실패: account = ${request.account}" }
throw e
}
else -> { else -> {
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" } log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
@ -74,7 +71,7 @@ class AuthService(
credentials: LoginCredentials credentials: LoginCredentials
) { ) {
if (credentials.password != request.password) { if (credentials.password != request.password) {
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED) throw AuthException(AuthErrorCode.LOGIN_FAILED)
} }
} }

View File

@ -11,8 +11,6 @@ import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@ -21,8 +19,6 @@ import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
@EnableAsync
@EnableScheduling
class LoginHistoryEventListener( class LoginHistoryEventListener(
private val idGenerator: IDGenerator, private val idGenerator: IDGenerator,
private val loginHistoryRepository: LoginHistoryRepository, private val loginHistoryRepository: LoginHistoryRepository,
@ -35,7 +31,7 @@ class LoginHistoryEventListener(
@Async @Async
@EventListener(classes = [LoginHistoryEvent::class]) @EventListener(classes = [LoginHistoryEvent::class])
fun onLoginCompleted(event: LoginHistoryEvent) { fun onLoginCompleted(event: LoginHistoryEvent) {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" } log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
queue.add(event.toEntity(idGenerator.create())).also { queue.add(event.toEntity(idGenerator.create())).also {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" } log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
@ -48,10 +44,10 @@ class LoginHistoryEventListener(
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS) @Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
fun flushScheduled() { fun flushScheduled() {
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" } log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) { if (queue.isEmpty()) {
log.info { "[flushScheduled] 큐에 있는 로그인 이력이 없음." } log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
return return
} }
flush() flush()
@ -60,7 +56,7 @@ class LoginHistoryEventListener(
@PreDestroy @PreDestroy
fun flushAll() { fun flushAll() {
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" } log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
while (!queue.isEmpty()) { while (!queue.isEmpty()) {
flush() flush()
} }
@ -68,10 +64,10 @@ class LoginHistoryEventListener(
} }
private fun flush() { private fun flush() {
log.info { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" } log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) { if (queue.isEmpty()) {
log.info { "[flush] 큐에 있는 로그인 이력이 없음." } log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
return; return;
} }

View File

@ -50,7 +50,7 @@ class JwtUtils(
val claims = extractAllClaims(token) val claims = extractAllClaims(token)
return claims.subject ?: run { return claims.subject ?: run {
log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" } log.debug { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" }
throw AuthException(AuthErrorCode.INVALID_TOKEN) throw AuthException(AuthErrorCode.INVALID_TOKEN)
} }
} }

View File

@ -6,7 +6,7 @@ import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.accessToken import com.sangdol.roomescape.auth.web.support.accessToken
import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.common.types.CurrentUserContext
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -22,7 +22,6 @@ private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class UserContextResolver( class UserContextResolver(
private val jwtUtils: JwtUtils, private val jwtUtils: JwtUtils,
private val userService: UserService,
) : HandlerMethodArgumentResolver { ) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean { override fun supportsParameter(parameter: MethodParameter): Boolean {
@ -43,7 +42,7 @@ class UserContextResolver(
MdcPrincipalIdUtil.set(it) MdcPrincipalIdUtil.set(it)
}.toLong() }.toLong()
return userService.findContextById(id) return CurrentUserContext(id = id)
} catch (e: Exception) { } catch (e: Exception) {
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" } log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)

View File

@ -1,6 +1,5 @@
package com.sangdol.roomescape.common.types package com.sangdol.roomescape.common.types
data class CurrentUserContext( data class CurrentUserContext(
val id: Long, val id: Long
val name: String,
) )

View File

@ -1,60 +0,0 @@
package com.sangdol.roomescape.order.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskEntity
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.reservation.business.ReservationService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Service
class OrderPostProcessorService(
private val idGenerator: IDGenerator,
private val reservationService: ReservationService,
private val paymentService: PaymentService,
private val postOrderTaskRepository: PostOrderTaskRepository,
private val transactionExecutionUtil: TransactionExecutionUtil
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun processAfterPaymentConfirmation(
reservationId: Long,
paymentResponse: PaymentGatewayResponse
) {
val paymentKey = paymentResponse.paymentKey
try {
log.info { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
val paymentCreateResponse = paymentService.savePayment(reservationId, paymentResponse)
reservationService.confirmReservation(reservationId)
log.info {
"[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 완료: reservationId=${reservationId}, paymentKey=${paymentKey}, paymentId=${paymentCreateResponse.paymentId}, paymentDetailId=${paymentCreateResponse.detailId}"
}
} catch (e: Exception) {
log.warn(e) { "[processAfterPaymentConfirmation] 결제 정보 저장 및 예약 확정 처리 실패. 작업 저장 시작: reservationId=${reservationId}, paymentKey=$paymentKey}" }
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
PostOrderTaskEntity(
id = idGenerator.create(),
reservationId = reservationId,
paymentKey = paymentKey,
trial = 1,
nextRetryAt = Instant.now().plusSeconds(30),
).also {
postOrderTaskRepository.save(it)
}
}
log.info { "[processAfterPaymentConfirmation] 작업 저장 완료" }
}
}
}

View File

@ -1,146 +1,69 @@
package com.sangdol.roomescape.order.business package com.sangdol.roomescape.order.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.roomescape.order.exception.OrderErrorCode import com.sangdol.roomescape.order.exception.OrderErrorCode
import com.sangdol.roomescape.order.exception.OrderException import com.sangdol.roomescape.order.exception.OrderException
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.ReservationService
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class OrderService( class OrderService(
private val idGenerator: IDGenerator,
private val reservationService: ReservationService, private val reservationService: ReservationService,
private val scheduleService: ScheduleService, private val scheduleService: ScheduleService,
private val paymentService: PaymentService, private val paymentService: PaymentService,
private val transactionExecutionUtil: TransactionExecutionUtil, private val transactionExecutionUtil: TransactionExecutionUtil,
private val orderValidator: OrderValidator, private val orderValidator: OrderValidator,
private val paymentAttemptRepository: PaymentAttemptRepository, private val eventPublisher: ApplicationEventPublisher
private val orderPostProcessorService: OrderPostProcessorService
) { ) {
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
var trial: Long = 0
val paymentKey = paymentConfirmRequest.paymentKey val paymentKey = paymentConfirmRequest.paymentKey
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
try { try {
trial = transactionExecutionUtil.withNewTransaction(isReadOnly = false) { transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
getTrialAfterValidateCanConfirm(reservationId).also { validateCanConfirm(reservationId)
reservationService.markInProgress(reservationId) reservationService.markInProgress(reservationId)
} }
} ?: run {
log.warn { "[confirm] 모든 paymentAttempts 조회 과정에서의 예상치 못한 null 응답: reservationId=${reservationId}" }
throw OrderException(OrderErrorCode.BOOKING_UNEXPECTED_ERROR)
}
val paymentClientResponse: PaymentGatewayResponse = paymentService.requestConfirm(reservationId, paymentConfirmRequest)
requestConfirmPayment(reservationId, paymentConfirmRequest) eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse) log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
} catch (e: Exception) { } catch (e: Exception) {
val errorCode: ErrorCode = if (e is RoomescapeException) { val errorCode: ErrorCode = if (e is RoomescapeException) {
e.errorCode e.errorCode
} else { } else {
OrderErrorCode.BOOKING_UNEXPECTED_ERROR OrderErrorCode.ORDER_UNEXPECTED_ERROR
} }
throw OrderException(errorCode, e.message ?: errorCode.message, trial) throw OrderException(errorCode, e.message ?: errorCode.message)
} }
} }
private fun getTrialAfterValidateCanConfirm(reservationId: Long): Long { private fun validateCanConfirm(reservationId: Long) {
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
try { try {
orderValidator.validateCanConfirm(reservation, schedule) orderValidator.validateCanConfirm(reservation, schedule)
return getTrialIfSuccessAttemptNotExists(reservationId).also {
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
}
} catch (e: OrderException) { } catch (e: OrderException) {
val errorCode = OrderErrorCode.NOT_CONFIRMABLE val errorCode = OrderErrorCode.NOT_CONFIRMABLE
throw OrderException(errorCode, e.message) throw OrderException(errorCode, e.message)
} }
} }
private fun getTrialIfSuccessAttemptNotExists(reservationId: Long): Long {
val paymentAttempts: List<PaymentAttemptEntity> = paymentAttemptRepository.findAllByReservationId(reservationId)
if (paymentAttempts.any { it.result == AttemptResult.SUCCESS }) {
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservationId}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
}
return paymentAttempts.size.toLong()
}
private fun requestConfirmPayment(
reservationId: Long,
paymentConfirmRequest: PaymentConfirmRequest
): PaymentGatewayResponse {
log.info { "[requestConfirmPayment] 결제 및 이력 저장 시작: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" }
val paymentResponse: PaymentGatewayResponse
var attempt: PaymentAttemptEntity? = null
try {
paymentResponse = paymentService.requestConfirm(paymentConfirmRequest)
attempt = PaymentAttemptEntity(
id = idGenerator.create(),
reservationId = reservationId,
result = AttemptResult.SUCCESS,
)
} catch (e: Exception) {
val errorCode: String = if (e is PaymentException) {
log.info { "[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}" }
e.errorCode.name
} else {
log.warn {
"[requestConfirmPayment] 결제 요청 실패: reservationId=${reservationId}, paymentKey=${paymentConfirmRequest.paymentKey}"
}
OrderErrorCode.BOOKING_UNEXPECTED_ERROR.name
}
attempt = PaymentAttemptEntity(
id = idGenerator.create(),
reservationId = reservationId,
result = AttemptResult.FAILED,
errorCode = errorCode,
message = e.message
)
throw e
} finally {
val savedAttempt: PaymentAttemptEntity? = attempt?.let {
log.info { "[requestPayment] 결제 요청 이력 저장 시작: id=${it.id}, reservationId=${it.reservationId}, result=${it.result}, errorCode=${it.errorCode}, message=${it.message}" }
paymentAttemptRepository.save(it)
}
savedAttempt?.also {
log.info { "[requestPayment] 결제 요청 이력 저장 완료: id=${savedAttempt.id}" }
} ?: run {
log.info { "[requestPayment] 결제 요청 이력 저장 실패: reservationId=${reservationId}" }
}
}
return paymentResponse
}
} }

View File

@ -3,7 +3,6 @@ package com.sangdol.roomescape.order.business
import com.sangdol.common.utils.KoreaDateTime import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.roomescape.order.exception.OrderErrorCode import com.sangdol.roomescape.order.exception.OrderErrorCode
import com.sangdol.roomescape.order.exception.OrderException import com.sangdol.roomescape.order.exception.OrderException
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
@ -28,15 +27,12 @@ class OrderValidator {
private fun validateReservationStatus(reservation: ReservationStateResponse) { private fun validateReservationStatus(reservation: ReservationStateResponse) {
when (reservation.status) { when (reservation.status) {
ReservationStatus.CONFIRMED -> { ReservationStatus.CONFIRMED -> {
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" } throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
} }
ReservationStatus.EXPIRED -> { ReservationStatus.EXPIRED -> {
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
} }
ReservationStatus.CANCELED -> { ReservationStatus.CANCELED -> {
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.CANCELED_RESERVATION) throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
} }
else -> {} else -> {}
@ -45,14 +41,14 @@ class OrderValidator {
private fun validateScheduleStatus(schedule: ScheduleStateResponse) { private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) { if (schedule.status != ScheduleStatus.HOLD) {
log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" } log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
} }
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom) val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now() val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) { if (scheduleDateTime.isBefore(nowDateTime)) {
log.info { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" } log.debug { "[validateScheduleStatus] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}(KST)" }
throw OrderException(OrderErrorCode.PAST_SCHEDULE) throw OrderException(OrderErrorCode.PAST_SCHEDULE)
} }
} }

View File

@ -9,11 +9,11 @@ enum class OrderErrorCode(
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."), NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."), ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."), PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."),
BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") ORDER_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
; ;
} }

View File

@ -6,11 +6,4 @@ import com.sangdol.common.types.exception.RoomescapeException
class OrderException( class OrderException(
override val errorCode: ErrorCode, override val errorCode: ErrorCode,
override val message: String = errorCode.message, override val message: String = errorCode.message,
var trial: Long = 0
) : RoomescapeException(errorCode, message) ) : RoomescapeException(errorCode, message)
class OrderErrorResponse(
val code: String,
val message: String,
val trial: Long
)

View File

@ -1,45 +0,0 @@
package com.sangdol.roomescape.order.exception
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice
class OrderExceptionHandler(
private val messageConverter: WebLogMessageConverter
) {
@ExceptionHandler(OrderException::class)
fun handleOrderException(
servletRequest: HttpServletRequest,
e: OrderException
): ResponseEntity<OrderErrorResponse> {
val errorCode: ErrorCode = e.errorCode
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = OrderErrorResponse(
code = errorCode.errorCode,
message = if (httpStatus.isClientError()) e.message else errorCode.message,
trial = e.trial
)
log.info {
messageConverter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = httpStatus,
responseBody = errorResponse,
exception = if (errorCode.message == e.message) null else e
)
}
return ResponseEntity
.status(httpStatus.value())
.body(errorResponse)
}
}

View File

@ -1,38 +0,0 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "payment_attempts")
class PaymentAttemptEntity(
id: Long,
val reservationId: Long,
@Enumerated(value = EnumType.STRING)
val result: AttemptResult,
@Column(columnDefinition = "VARCHAR(50)")
val errorCode: String? = null,
@Column(columnDefinition = "TEXT")
val message: String? = null,
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: Instant
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
}
enum class AttemptResult {
SUCCESS, FAILED
}

View File

@ -1,28 +0,0 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface PaymentAttemptRepository: JpaRepository<PaymentAttemptEntity, Long> {
fun countByReservationId(reservationId: Long): Long
@Query(
"""
SELECT
CASE
WHEN COUNT(pa) > 0
THEN TRUE
ELSE FALSE
END
FROM
PaymentAttemptEntity pa
WHERE
pa.reservationId = :reservationId
AND pa.result = com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult.SUCCESS
"""
)
fun isSuccessAttemptExists(reservationId: Long): Boolean
fun findAllByReservationId(reservationId: Long): List<PaymentAttemptEntity>
}

View File

@ -1,16 +0,0 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.Entity
import jakarta.persistence.Table
import java.time.Instant
@Entity
@Table(name = "post_order_tasks")
class PostOrderTaskEntity(
id: Long,
val reservationId: Long,
val paymentKey: String,
val trial: Int,
val nextRetryAt: Instant
) : PersistableBaseEntity(id)

View File

@ -1,6 +0,0 @@
package com.sangdol.roomescape.order.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface PostOrderTaskRepository : JpaRepository<PostOrderTaskEntity, Long> {
}

View File

@ -1,5 +1,6 @@
package com.sangdol.roomescape.payment.business package com.sangdol.roomescape.payment.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
import com.sangdol.roomescape.payment.dto.* import com.sangdol.roomescape.payment.dto.*
@ -8,9 +9,12 @@ import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.payment.mapper.toResponse import com.sangdol.roomescape.payment.mapper.toResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -18,18 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class PaymentService( class PaymentService(
private val idGenerator: IDGenerator,
private val paymentClient: TosspayClient, private val paymentClient: TosspayClient,
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository, private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository, private val canceledPaymentRepository: CanceledPaymentRepository,
private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil, private val transactionExecutionUtil: TransactionExecutionUtil,
private val eventPublisher: ApplicationEventPublisher
) { ) {
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse { fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" } log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
try { try {
return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also { return paymentClient.confirm(request.paymentKey, request.orderId, request.amount).also {
log.info { "[requestConfirm] 결제 완료: paymentKey=${request.paymentKey}" } eventPublisher.publishEvent(it.toEvent(reservationId))
log.info { "[requestConfirm] 결제 및 이벤트 발행 완료: paymentKey=${request.paymentKey}" }
} }
} catch (e: Exception) { } catch (e: Exception) {
when(e) { when(e) {
@ -56,19 +62,6 @@ class PaymentService(
} }
} }
fun savePayment(
reservationId: Long,
paymentGatewayResponse: PaymentGatewayResponse
): PaymentCreateResponse {
val payment: PaymentEntity = paymentWriter.createPayment(
reservationId = reservationId,
paymentGatewayResponse = paymentGatewayResponse
)
val detail: PaymentDetailEntity = paymentWriter.createDetail(paymentGatewayResponse, payment.id)
return PaymentCreateResponse(paymentId = payment.id, detailId = detail.id)
}
fun cancel(userId: Long, request: PaymentCancelRequest) { fun cancel(userId: Long, request: PaymentCancelRequest) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
@ -79,12 +72,17 @@ class PaymentService(
) )
transactionExecutionUtil.withNewTransaction(isReadOnly = false) { transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.cancel( val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
userId = userId,
payment = payment, clientCancelResponse.cancels.toEntity(
requestedAt = request.requestedAt, id = idGenerator.create(),
cancelResponse = clientCancelResponse paymentId = payment.id,
) cancelRequestedAt = request.requestedAt,
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}.also { }.also {
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" } log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
} }
@ -92,7 +90,7 @@ class PaymentService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentResponse? { fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" } log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId) val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) } val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
@ -101,11 +99,13 @@ class PaymentService(
return payment?.toResponse( return payment?.toResponse(
detail = paymentDetail?.toResponse(), detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toResponse() cancel = cancelDetail?.toResponse()
) ).also {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
}
} }
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity { private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId) return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } } ?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
@ -116,7 +116,7 @@ class PaymentService(
} }
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? { private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" } log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId) return paymentRepository.findByReservationId(reservationId)
.also { .also {
@ -129,7 +129,7 @@ class PaymentService(
} }
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? { private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" } log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also { return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) { if (it != null) {
@ -141,7 +141,7 @@ class PaymentService(
} }
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? { private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" } log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also { return canceledPaymentRepository.findByPaymentId(paymentId).also {
if (it == null) { if (it == null) {

View File

@ -1,80 +0,0 @@
package com.sangdol.roomescape.payment.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.dto.PaymentGatewayCancelResponse
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.mapper.toCardDetailEntity
import com.sangdol.roomescape.payment.mapper.toEasypayPrepaidDetailEntity
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toTransferDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.*
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriter(
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val idGenerator: IDGenerator,
) {
fun createPayment(
reservationId: Long,
paymentGatewayResponse: PaymentGatewayResponse
): PaymentEntity {
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${paymentGatewayResponse.paymentKey}" }
return paymentGatewayResponse.toEntity(id = idGenerator.create(), reservationId = reservationId).also {
paymentRepository.save(it)
log.info { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, payment.id=${it.id}" }
}
}
fun createDetail(
paymentGatewayResponse: PaymentGatewayResponse,
paymentId: Long,
): PaymentDetailEntity {
val method: PaymentMethod = paymentGatewayResponse.method
val id = idGenerator.create()
if (method == PaymentMethod.TRANSFER) {
return paymentDetailRepository.save(paymentGatewayResponse.toTransferDetailEntity(id, paymentId))
}
if (method == PaymentMethod.EASY_PAY && paymentGatewayResponse.card == null) {
return paymentDetailRepository.save(paymentGatewayResponse.toEasypayPrepaidDetailEntity(id, paymentId))
}
if (paymentGatewayResponse.card != null) {
return paymentDetailRepository.save(paymentGatewayResponse.toCardDetailEntity(id, paymentId))
}
throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
fun cancel(
userId: Long,
payment: PaymentEntity,
requestedAt: Instant,
cancelResponse: PaymentGatewayCancelResponse
): CanceledPaymentEntity {
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 시작: payment.id=${payment.id}" }
paymentRepository.save(payment.apply { this.cancel() })
return cancelResponse.cancels.toEntity(
id = idGenerator.create(),
paymentId = payment.id,
cancelRequestedAt = requestedAt,
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}
}

View File

@ -0,0 +1,38 @@
package com.sangdol.roomescape.payment.business.domain
abstract class PaymentDetail
class BankTransferPaymentDetail(
val bankCode: BankCode,
val settlementStatus: String,
): PaymentDetail()
class CardPaymentDetail(
val issuerCode: CardIssuerCode,
val number: String,
val amount: Int,
val cardType: CardType,
val ownerType: CardOwnerType,
val isInterestFree: Boolean,
val approveNo: String,
val installmentPlanMonths: Int
): PaymentDetail()
class EasypayCardPaymentDetail(
val issuerCode: CardIssuerCode,
val number: String,
val amount: Int,
val cardType: CardType,
val ownerType: CardOwnerType,
val isInterestFree: Boolean,
val approveNo: String,
val installmentPlanMonths: Int,
val easypayProvider: EasyPayCompanyCode,
val easypayDiscountAmount: Int,
): PaymentDetail()
class EasypayPrepaidPaymentDetail(
val provider: EasyPayCompanyCode,
val amount: Int,
val discountAmount: Int,
): PaymentDetail()

View File

@ -0,0 +1,22 @@
package com.sangdol.roomescape.payment.business.event
import com.sangdol.roomescape.payment.business.domain.PaymentDetail
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.business.domain.PaymentType
import java.time.Instant
class PaymentEvent(
val reservationId: Long,
val paymentKey: String,
val orderId: String,
val type: PaymentType,
val status: PaymentStatus,
val totalAmount: Int,
val vat: Int,
val suppliedAmount: Int,
val method: PaymentMethod,
val requestedAt: Instant,
val approvedAt: Instant,
val detail: PaymentDetail
)

View File

@ -0,0 +1,44 @@
package com.sangdol.roomescape.payment.business.event
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.payment.mapper.toDetailEntity
import com.sangdol.roomescape.payment.mapper.toEntity
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentEventListener(
private val idGenerator: IDGenerator,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository
) {
@Async
@EventListener
@Transactional
fun handlePaymentEvent(event: PaymentEvent) {
val reservationId = event.reservationId
log.debug { "[handlePaymentEvent] 결제 정보 저장 이벤트 수신: reservationId=${reservationId}, paymentKey=${event.paymentKey}" }
val paymentId = idGenerator.create()
val paymentEntity: PaymentEntity = event.toEntity(paymentId)
paymentRepository.save(paymentEntity)
val paymentDetailId = idGenerator.create()
val paymentDetailEntity: PaymentDetailEntity = event.toDetailEntity(id = paymentDetailId, paymentId = paymentId)
paymentDetailRepository.save(paymentDetailEntity)
log.info { "[handlePaymentEvent] 결제 정보 저장 이벤트 처리 완료: reservationId=${reservationId}, paymentId=${paymentId}, paymentDetailId=${paymentDetailId}" }
}
}

View File

@ -2,11 +2,8 @@ package com.sangdol.roomescape.payment.docs
import com.sangdol.common.types.web.CommonApiResponse import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.web.support.User import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -16,13 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody
interface PaymentAPI { interface PaymentAPI {
@UserOnly
@Operation(summary = "결제 승인")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun confirmPayment(
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
@Operation(summary = "결제 취소") @Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment( fun cancelPayment(

View File

@ -8,11 +8,6 @@ data class PaymentConfirmRequest(
val amount: Int, val amount: Int,
) )
data class PaymentCreateResponse(
val paymentId: Long,
val detailId: Long
)
data class PaymentCancelRequest( data class PaymentCancelRequest(
val reservationId: Long, val reservationId: Long,
val cancelReason: String, val cancelReason: String,

View File

@ -33,7 +33,7 @@ class TosspayClient(
amount: Int, amount: Int,
): PaymentGatewayResponse { ): PaymentGatewayResponse {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" } log.debug { "[TosspayClient.confirm] 결제 승인 요청: paymentKey=$paymentKey, orderId=$orderId, amount=$amount" }
return confirmClient.request(paymentKey, orderId, amount) return confirmClient.request(paymentKey, orderId, amount)
.also { .also {
@ -47,7 +47,7 @@ class TosspayClient(
cancelReason: String cancelReason: String
): PaymentGatewayCancelResponse { ): PaymentGatewayCancelResponse {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
log.info { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" } log.debug { "[TosspayClient.cancel] 결제 취소 요청: paymentKey=$paymentKey, amount=$amount, cancelReason=$cancelReason" }
return cancelClient.request(paymentKey, amount, cancelReason).also { return cancelClient.request(paymentKey, amount, cancelReason).also {
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" } log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }

View File

@ -1,82 +1,14 @@
package com.sangdol.roomescape.payment.mapper package com.sangdol.roomescape.payment.mapper
import com.sangdol.roomescape.payment.business.domain.*
import com.sangdol.roomescape.payment.business.event.PaymentEvent
import com.sangdol.roomescape.payment.dto.CancelDetail import com.sangdol.roomescape.payment.dto.CancelDetail
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.persistence.* import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import java.time.Instant import java.time.Instant
fun PaymentGatewayResponse.toEntity(
id: Long,
reservationId: Long,
) = PaymentEntity(
id = id,
reservationId = reservationId,
paymentKey = this.paymentKey,
orderId = this.orderId,
totalAmount = this.totalAmount,
requestedAt = this.requestedAt.toInstant(),
approvedAt = this.approvedAt.toInstant(),
type = this.type,
method = this.method,
status = this.status,
)
fun PaymentGatewayResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity {
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return PaymentCardDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = this.suppliedAmount,
vat = this.vat,
issuerCode = cardDetail.issuerCode,
cardType = cardDetail.cardType,
ownerType = cardDetail.ownerType,
amount = cardDetail.amount,
cardNumber = cardDetail.number,
approvalNumber = cardDetail.approveNo,
installmentPlanMonths = cardDetail.installmentPlanMonths,
isInterestFree = cardDetail.isInterestFree,
easypayProviderCode = this.easyPay?.provider,
easypayDiscountAmount = this.easyPay?.discountAmount,
)
}
fun PaymentGatewayResponse.toEasypayPrepaidDetailEntity(
id: Long,
paymentId: Long
): PaymentEasypayPrepaidDetailEntity {
val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return PaymentEasypayPrepaidDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = this.suppliedAmount,
vat = this.vat,
easypayProviderCode = easyPayDetail.provider,
amount = easyPayDetail.amount,
discountAmount = easyPayDetail.discountAmount
)
}
fun PaymentGatewayResponse.toTransferDetailEntity(
id: Long,
paymentId: Long
): PaymentBankTransferDetailEntity {
val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return PaymentBankTransferDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = this.suppliedAmount,
vat = this.vat,
bankCode = transferDetail.bankCode,
settlementStatus = transferDetail.settlementStatus
)
}
fun CancelDetail.toEntity( fun CancelDetail.toEntity(
id: Long, id: Long,
paymentId: Long, paymentId: Long,
@ -94,3 +26,88 @@ fun CancelDetail.toEntity(
transferDiscountAmount = this.transferDiscountAmount, transferDiscountAmount = this.transferDiscountAmount,
easypayDiscountAmount = this.easyPayDiscountAmount easypayDiscountAmount = this.easyPayDiscountAmount
) )
fun PaymentGatewayResponse.toEvent(reservationId: Long): PaymentEvent {
return PaymentEvent(
reservationId = reservationId,
paymentKey = this.paymentKey,
orderId = this.orderId,
type = this.type,
status = this.status,
totalAmount = this.totalAmount,
vat = this.vat,
suppliedAmount = this.suppliedAmount,
method = this.method,
requestedAt = this.requestedAt.toInstant(),
approvedAt = this.approvedAt.toInstant(),
detail = this.toDetail()
)
}
fun PaymentGatewayResponse.toDetail(): PaymentDetail {
return when (this.method) {
PaymentMethod.TRANSFER -> this.toBankTransferDetail()
PaymentMethod.CARD -> this.toCardDetail()
PaymentMethod.EASY_PAY -> {
if (this.card != null) {
this.toEasypayCardDetail()
} else {
this.toEasypayPrepaidDetail()
}
}
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
}
private fun PaymentGatewayResponse.toBankTransferDetail(): BankTransferPaymentDetail {
val bankTransfer = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return BankTransferPaymentDetail(
bankCode = bankTransfer.bankCode,
settlementStatus = bankTransfer.settlementStatus
)
}
private fun PaymentGatewayResponse.toCardDetail(): CardPaymentDetail {
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return CardPaymentDetail(
issuerCode = cardDetail.issuerCode,
number = cardDetail.number,
amount = cardDetail.amount,
cardType = cardDetail.cardType,
ownerType = cardDetail.ownerType,
isInterestFree = cardDetail.isInterestFree,
approveNo = cardDetail.approveNo,
installmentPlanMonths = cardDetail.installmentPlanMonths
)
}
private fun PaymentGatewayResponse.toEasypayCardDetail(): EasypayCardPaymentDetail {
val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return EasypayCardPaymentDetail(
issuerCode = cardDetail.issuerCode,
number = cardDetail.number,
amount = cardDetail.amount,
cardType = cardDetail.cardType,
ownerType = cardDetail.ownerType,
isInterestFree = cardDetail.isInterestFree,
approveNo = cardDetail.approveNo,
installmentPlanMonths = cardDetail.installmentPlanMonths,
easypayProvider = easypay.provider,
easypayDiscountAmount = easypay.discountAmount
)
}
private fun PaymentGatewayResponse.toEasypayPrepaidDetail(): EasypayPrepaidPaymentDetail {
val easypay = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
return EasypayPrepaidPaymentDetail(
provider = easypay.provider,
amount = easypay.amount,
discountAmount = easypay.discountAmount
)
}

View File

@ -0,0 +1,91 @@
package com.sangdol.roomescape.payment.mapper
import com.sangdol.roomescape.payment.business.domain.BankTransferPaymentDetail
import com.sangdol.roomescape.payment.business.domain.CardPaymentDetail
import com.sangdol.roomescape.payment.business.domain.EasypayCardPaymentDetail
import com.sangdol.roomescape.payment.business.domain.EasypayPrepaidPaymentDetail
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentBankTransferDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEasypayPrepaidDetailEntity
fun BankTransferPaymentDetail.toEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int
): PaymentDetailEntity {
return PaymentBankTransferDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = suppliedAmount,
vat = vat,
bankCode = this.bankCode,
settlementStatus = this.settlementStatus
)
}
fun CardPaymentDetail.toEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int
): PaymentDetailEntity {
return PaymentCardDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = suppliedAmount,
vat = vat,
issuerCode = issuerCode,
cardType = cardType,
ownerType = ownerType,
amount = amount,
cardNumber = this.number,
approvalNumber = this.approveNo,
installmentPlanMonths = installmentPlanMonths,
isInterestFree = isInterestFree,
easypayProviderCode = null,
easypayDiscountAmount = null
)
}
fun EasypayCardPaymentDetail.toEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int
): PaymentDetailEntity {
return PaymentCardDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = suppliedAmount,
vat = vat,
issuerCode = issuerCode,
cardType = cardType,
ownerType = ownerType,
amount = amount,
cardNumber = this.number,
approvalNumber = this.approveNo,
installmentPlanMonths = installmentPlanMonths,
isInterestFree = isInterestFree,
easypayProviderCode = this.easypayProvider,
easypayDiscountAmount = this.easypayDiscountAmount
)
}
fun EasypayPrepaidPaymentDetail.toEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int
): PaymentDetailEntity {
return PaymentEasypayPrepaidDetailEntity(
id = id,
paymentId = paymentId,
suppliedAmount = suppliedAmount,
vat = vat,
easypayProviderCode = this.provider,
amount = this.amount,
discountAmount = this.discountAmount
)
}

View File

@ -0,0 +1,53 @@
package com.sangdol.roomescape.payment.mapper
import com.sangdol.roomescape.payment.business.domain.*
import com.sangdol.roomescape.payment.business.event.PaymentEvent
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
fun PaymentEvent.toEntity(id: Long) = PaymentEntity(
id = id,
reservationId = this.reservationId,
paymentKey = this.paymentKey,
orderId = this.orderId,
totalAmount = this.totalAmount,
requestedAt = this.requestedAt,
approvedAt = this.approvedAt,
type = this.type,
method = this.method,
status = this.status
)
fun PaymentEvent.toDetailEntity(id: Long, paymentId: Long): PaymentDetailEntity {
val suppliedAmount = this.suppliedAmount
val vat = this.vat
return when (this.method) {
PaymentMethod.TRANSFER -> {
(this.detail as? BankTransferPaymentDetail)
?.toEntity(id, paymentId, suppliedAmount, vat)
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
PaymentMethod.EASY_PAY -> {
when (this.detail) {
is EasypayCardPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
is EasypayPrepaidPaymentDetail -> { this.detail.toEntity(id, paymentId, suppliedAmount, vat) }
else -> {
throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
}
}
PaymentMethod.CARD -> {
(this.detail as? CardPaymentDetail)
?.toEntity(id, paymentId, suppliedAmount, vat)
?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR)
}
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
}

View File

@ -6,27 +6,18 @@ import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.docs.PaymentAPI import com.sangdol.roomescape.payment.docs.PaymentAPI
import com.sangdol.roomescape.payment.dto.PaymentCancelRequest import com.sangdol.roomescape.payment.dto.PaymentCancelRequest
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@RequestMapping("/payments") @RequestMapping("/payments")
class PaymentController( class PaymentController(
private val paymentService: PaymentService private val paymentService: PaymentService
) : PaymentAPI { ) : PaymentAPI {
@PostMapping("/confirm")
override fun confirmPayment(
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>> {
val response = paymentService.requestConfirm(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/cancel") @PostMapping("/cancel")
override fun cancelPayment( override fun cancelPayment(
@User user: CurrentUserContext, @User user: CurrentUserContext,

View File

@ -17,7 +17,7 @@ class RegionService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun readAllSido(): SidoListResponse { fun readAllSido(): SidoListResponse {
log.info { "[readAllSido] 모든 시/도 조회 시작" } log.debug { "[readAllSido] 모든 시/도 조회 시작" }
val result: List<Pair<String, String>> = regionRepository.readAllSido() val result: List<Pair<String, String>> = regionRepository.readAllSido()
if (result.isEmpty()) { if (result.isEmpty()) {
@ -32,7 +32,7 @@ class RegionService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findSigunguBySido(sidoCode: String): SigunguListResponse { fun findSigunguBySido(sidoCode: String): SigunguListResponse {
log.info { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" } log.debug { "[findSigunguBySido] 시/군/구 조회 시작: sidoCode=${sidoCode}" }
val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode) val result: List<Pair<String, String>> = regionRepository.findAllSigunguBySido(sidoCode)
if (result.isEmpty()) { if (result.isEmpty()) {
@ -47,7 +47,7 @@ class RegionService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse { fun findRegionCode(sidoCode: String, sigunguCode: String): RegionCodeResponse {
log.info { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } log.debug { "[findRegionCode] 지역 코드 조회 시작: sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let { return regionRepository.findRegionCode(sidoCode, sigunguCode)?.let {
log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" } log.info { "[findRegionCode] 지역 코드 조회 완료: code=${it} sidoCode=${sidoCode} / sigunguCode=${sigunguCode}" }
@ -60,7 +60,7 @@ class RegionService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findRegionInfo(regionCode: String): RegionInfoResponse { fun findRegionInfo(regionCode: String): RegionInfoResponse {
log.info { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" } log.debug { "[findRegionInfo] 지역 정보 조회 시작: regionCode=${regionCode}" }
return regionRepository.findByCode(regionCode)?.let { return regionRepository.findByCode(regionCode)?.let {
log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" } log.info { "[findRegionInfo] 지역 정보 조회 완료: code=${it} regionCode=${regionCode}" }

View File

@ -45,7 +45,7 @@ class ReservationService(
user: CurrentUserContext, user: CurrentUserContext,
request: PendingReservationCreateRequest request: PendingReservationCreateRequest
): PendingReservationCreateResponse { ): PendingReservationCreateResponse {
log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } log.debug { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" }
run { run {
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId) val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(request.scheduleId)
@ -64,7 +64,7 @@ class ReservationService(
@Transactional @Transactional
fun confirmReservation(id: Long) { fun confirmReservation(id: Long) {
log.info { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" } log.debug { "[confirmReservation] Pending 예약 확정 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
run { run {
@ -81,7 +81,7 @@ class ReservationService(
@Transactional @Transactional
fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) { fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) {
log.info { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" } log.debug { "[cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" }
val reservation: ReservationEntity = findOrThrow(reservationId) val reservation: ReservationEntity = findOrThrow(reservationId)
@ -100,7 +100,7 @@ class ReservationService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse { fun findAllUserReservationOverview(user: CurrentUserContext): ReservationOverviewListResponse {
log.info { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" } log.debug { "[findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn( val reservations: List<ReservationEntity> = reservationRepository.findAllByUserIdAndStatusIsIn(
userId = user.id, userId = user.id,
@ -125,7 +125,7 @@ class ReservationService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findDetailById(id: Long): ReservationAdditionalResponse { fun findDetailById(id: Long): ReservationAdditionalResponse {
log.info { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" } log.debug { "[findDetailById] 예약 상세 조회 시작: reservationId=${id}" }
val reservation: ReservationEntity = findOrThrow(id) val reservation: ReservationEntity = findOrThrow(id)
val user: UserContactResponse = userService.findContactById(reservation.userId) val user: UserContactResponse = userService.findContactById(reservation.userId)
@ -141,7 +141,7 @@ class ReservationService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findStatusWithLock(id: Long): ReservationStateResponse { fun findStatusWithLock(id: Long): ReservationStateResponse {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" } log.debug { "[findStatusWithLock] 예약 LOCK + 상태 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdForUpdate(id)?.let { return reservationRepository.findByIdForUpdate(id)?.let {
log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" } log.info { "[findStatusWithLock] 예약 LOCK + 상태 조회 완료: reservationId=${id}" }
@ -154,7 +154,7 @@ class ReservationService(
@Transactional @Transactional
fun markInProgress(reservationId: Long) { fun markInProgress(reservationId: Long) {
log.info { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." } log.debug { "[markInProgress] 예약 상태 ${ReservationStatus.PAYMENT_IN_PROGRESS} 변경 시작." }
findOrThrow(reservationId).apply { findOrThrow(reservationId).apply {
this.status = ReservationStatus.PAYMENT_IN_PROGRESS this.status = ReservationStatus.PAYMENT_IN_PROGRESS
@ -164,7 +164,7 @@ class ReservationService(
} }
private fun findOrThrow(id: Long): ReservationEntity { private fun findOrThrow(id: Long): ReservationEntity {
log.info { "[findOrThrow] 예약 조회 시작: reservationId=${id}" } log.debug { "[findOrThrow] 예약 조회 시작: reservationId=${id}" }
return reservationRepository.findByIdOrNull(id) return reservationRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } } ?.also { log.info { "[findOrThrow] 예약 조회 완료: reservationId=${id}" } }

View File

@ -1,17 +1,15 @@
package com.sangdol.roomescape.reservation.business package com.sangdol.roomescape.reservation.business
import com.sangdol.common.utils.KoreaDateTime import com.sangdol.common.utils.KoreaDateTime
import com.sangdol.common.utils.toKoreaDateTime import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}

View File

@ -0,0 +1,5 @@
package com.sangdol.roomescape.reservation.business.event
class ReservationConfirmEvent(
val reservationId: Long
)

View File

@ -0,0 +1,34 @@
package com.sangdol.roomescape.reservation.business.event
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationEventListener(
private val reservationRepository: ReservationRepository
) {
@Async
@EventListener
@Transactional
fun handleReservationConfirmEvent(event: ReservationConfirmEvent) {
val reservationId = event.reservationId
log.debug { "[handleReservationConfirmEvent] 예약 확정 이벤트 수신: reservationId=${reservationId}" }
val modifiedRows = reservationRepository.confirmReservation(Instant.now(), reservationId)
if (modifiedRows == 0) {
log.warn { "[handleReservationConfirmEvent] 예상치 못한 예약 확정 실패 - 변경된 row 없음: reservationId=${reservationId}" }
}
log.info { "[handleReservationConfirmEvent] 예약 확정 이벤트 처리 완료" }
}
}

View File

@ -5,7 +5,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -15,7 +14,6 @@ import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
@EnableScheduling
class IncompletedReservationScheduler( class IncompletedReservationScheduler(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository private val reservationRepository: ReservationRepository
@ -24,10 +22,10 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional @Transactional
fun processExpiredHoldSchedule() { fun processExpiredHoldSchedule() {
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } log.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also { val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" } log.debug { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
} }
scheduleRepository.releaseHeldSchedules(targets).also { scheduleRepository.releaseHeldSchedules(targets).also {
@ -38,7 +36,7 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional @Transactional
fun processExpiredReservation() { fun processExpiredReservation() {
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" } log.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also { val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" } log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }

View File

@ -16,8 +16,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
@Query("SELECT r FROM ReservationEntity r WHERE r._id = :id") @Query("SELECT r FROM ReservationEntity r WHERE r._id = :id")
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity? fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
@Query(
@Query(""" """
SELECT SELECT
r.id r.id
FROM FROM
@ -27,7 +27,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
WHERE WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
""", nativeQuery = true) """, nativeQuery = true
)
fun findAllExpiredReservation(): List<Long> fun findAllExpiredReservation(): List<Long>
@Modifying @Modifying
@ -47,4 +48,23 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
""", nativeQuery = true """, nativeQuery = true
) )
fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List<Long>): Int
@Modifying
@Query(
"""
UPDATE
reservation r
JOIN
schedule s ON r.schedule_id = s.id AND s.status = 'HOLD'
SET
r.status = 'CONFIRMED',
r.updated_at = :now,
s.status = 'RESERVED',
s.hold_expired_at = NULL
WHERE
r.id = :id
AND r.status = 'PAYMENT_IN_PROGRESS'
""", nativeQuery = true
)
fun confirmReservation(@Param("now") now: Instant, @Param("id") id: Long): Int
} }

View File

@ -34,7 +34,7 @@ class AdminScheduleService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse { fun searchSchedules(storeId: Long, date: LocalDate?, themeId: Long?): AdminScheduleSummaryListResponse {
log.info { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" } log.debug { "[searchSchedules] 일정 검색 시작: storeId=$storeId, date=$date, themeId=$themeId" }
val searchDate = date ?: KoreaDate.today() val searchDate = date ?: KoreaDate.today()
@ -44,14 +44,12 @@ class AdminScheduleService(
.sortedBy { it.time } .sortedBy { it.time }
return schedules.toAdminSummaryResponse() return schedules.toAdminSummaryResponse()
.also { .also { log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } }
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditingInfo { fun findScheduleAudit(id: Long): AuditingInfo {
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" } log.debug { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id) val schedule: ScheduleEntity = findOrThrow(id)
@ -64,7 +62,7 @@ class AdminScheduleService(
@Transactional @Transactional
fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse { fun createSchedule(storeId: Long, request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" } log.debug { "[createSchedule] 일정 생성 시작: storeId=${storeId}, date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(storeId, request) scheduleValidator.validateCanCreate(storeId, request)
@ -79,14 +77,12 @@ class AdminScheduleService(
} }
return ScheduleCreateResponse(schedule.id) return ScheduleCreateResponse(schedule.id)
.also { .also { log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } }
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
}
} }
@Transactional @Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" } log.debug { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) { if (request.isAllParamsNull()) {
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" } log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
@ -104,7 +100,7 @@ class AdminScheduleService(
@Transactional @Transactional
fun deleteSchedule(id: Long) { fun deleteSchedule(id: Long) {
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" } log.debug { "[deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id).also { val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it) scheduleValidator.validateCanDelete(it)
@ -116,7 +112,7 @@ class AdminScheduleService(
} }
private fun findOrThrow(id: Long): ScheduleEntity { private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[findOrThrow] 일정 조회 시작: id=$id" } log.debug { "[findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id) return scheduleRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } } ?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }

View File

@ -30,13 +30,12 @@ class ScheduleService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
log.info { "[getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } log.debug { "[getStoreScheduleByDate] 매장 일정 조회 시작: storeId=${storeId}, date=$date" }
val currentDate: LocalDate = KoreaDate.today() val currentDate: LocalDate = KoreaDate.today()
val currentTime: LocalTime = KoreaTime.now() val currentTime: LocalTime = KoreaTime.now()
if (date.isBefore(currentDate)) { if (date.isBefore(currentDate)) {
log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
} }
@ -44,6 +43,7 @@ class ScheduleService(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) } .filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
return schedules.toResponseWithTheme() return schedules.toResponseWithTheme()
.also { .also {
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" } log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
@ -52,7 +52,7 @@ class ScheduleService(
@Transactional @Transactional
fun holdSchedule(id: Long) { fun holdSchedule(id: Long) {
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" } log.debug { "[holdSchedule] 일정 Holding 시작: id=$id" }
val schedule = findForUpdateOrThrow(id).also { val schedule = findForUpdateOrThrow(id).also {
scheduleValidator.validateCanHold(it) scheduleValidator.validateCanHold(it)
@ -69,7 +69,7 @@ class ScheduleService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findStateWithLock(id: Long): ScheduleStateResponse { fun findStateWithLock(id: Long): ScheduleStateResponse {
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" } log.debug { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
?: run { ?: run {
@ -95,7 +95,7 @@ class ScheduleService(
@Transactional @Transactional
fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) { fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) {
log.info { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } log.debug { "[reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also { scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also {
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
@ -103,7 +103,7 @@ class ScheduleService(
} }
private fun findForUpdateOrThrow(id: Long): ScheduleEntity { private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" } log.debug { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
return scheduleRepository.findByIdForUpdate(id) return scheduleRepository.findByIdForUpdate(id)
?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } } ?.also { log.info { "[findForUpdateOrThrow] 일정 조회 완료: id=$id" } }

View File

@ -59,7 +59,7 @@ class ScheduleValidator(
private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) { private fun validateAlreadyExists(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) { if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.info { log.debug {
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" "[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
} }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS) throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
@ -71,7 +71,7 @@ class ScheduleValidator(
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES) val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
if (inputDateTime.isBefore(now)) { if (inputDateTime.isBefore(now)) {
log.info { log.debug {
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" "[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
} }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
@ -82,7 +82,7 @@ class ScheduleValidator(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId) scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
.firstOrNull { it.containsTime(time) } .firstOrNull { it.containsTime(time) }
?.let { ?.let {
log.info { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" } log.debug { "[validateTimeNotConflict] 시간이 겹치는 일정 존재: conflictSchedule(Id=${it.id}, time=${it.time}~${it.getEndAt()})" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT) throw ScheduleException(ScheduleErrorCode.SCHEDULE_TIME_CONFLICT)
} }
} }

View File

@ -1,7 +1,6 @@
package com.sangdol.roomescape.schedule.infrastructure.persistence package com.sangdol.roomescape.schedule.infrastructure.persistence
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
import com.sangdol.roomescape.test.ScheduleWithThemeId
import jakarta.persistence.LockModeType import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Lock
@ -160,18 +159,4 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
""" """
) )
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
/**
* for test
*/
@Query("""
SELECT
s.id, s.theme_id
FROM
schedule s
WHERE
s.status = 'AVAILABLE'
AND s.date > CURRENT_DATE
""", nativeQuery = true)
fun findAllAvailableSchedules(): List<ScheduleWithThemeId>
} }

View File

@ -35,7 +35,7 @@ class StoreService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getDetail(id: Long): StoreDetailResponse { fun getDetail(id: Long): StoreDetailResponse {
log.info { "[getDetail] 매장 상세 조회 시작: id=${id}" } log.debug { "[getDetail] 매장 상세 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id) val store: StoreEntity = findOrThrow(id)
val region = regionService.findRegionInfo(store.regionCode) val region = regionService.findRegionInfo(store.regionCode)
@ -47,7 +47,7 @@ class StoreService(
@Transactional @Transactional
fun register(request: StoreRegisterRequest): StoreRegisterResponse { fun register(request: StoreRegisterRequest): StoreRegisterResponse {
log.info { "[register] 매장 등록 시작: name=${request.name}" } log.debug { "[register] 매장 등록 시작: name=${request.name}" }
storeValidator.validateCanRegister(request) storeValidator.validateCanRegister(request)
@ -70,7 +70,7 @@ class StoreService(
@Transactional @Transactional
fun update(id: Long, request: StoreUpdateRequest) { fun update(id: Long, request: StoreUpdateRequest) {
log.info { "[update] 매장 수정 시작: id=${id}, request=${request}" } log.debug { "[update] 매장 수정 시작: id=${id}, request=${request}" }
storeValidator.validateCanUpdate(request) storeValidator.validateCanUpdate(request)
@ -83,7 +83,7 @@ class StoreService(
@Transactional @Transactional
fun disableById(id: Long) { fun disableById(id: Long) {
log.info { "[inactive] 매장 비활성화 시작: id=${id}" } log.debug { "[inactive] 매장 비활성화 시작: id=${id}" }
findOrThrow(id).apply { findOrThrow(id).apply {
this.disable() this.disable()
@ -94,7 +94,7 @@ class StoreService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse { fun getAllActiveStores(sidoCode: String?, sigunguCode: String?): StoreNameListResponse {
log.info { "[getAllActiveStores] 전체 매장 조회 시작" } log.debug { "[getAllActiveStores] 전체 매장 조회 시작" }
val regionCode: String? = when { val regionCode: String? = when {
sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED) sidoCode == null && sigunguCode != null -> throw StoreException(StoreErrorCode.SIDO_CODE_REQUIRED)
@ -108,7 +108,7 @@ class StoreService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findStoreInfo(id: Long): StoreInfoResponse { fun findStoreInfo(id: Long): StoreInfoResponse {
log.info { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" } log.debug { "[findStoreInfo] 매장 정보 조회 시작: id=${id}" }
val store: StoreEntity = findOrThrow(id) val store: StoreEntity = findOrThrow(id)
@ -117,7 +117,7 @@ class StoreService(
} }
private fun getAuditInfo(store: StoreEntity): AuditingInfo { private fun getAuditInfo(store: StoreEntity): AuditingInfo {
log.info { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" } log.debug { "[getAuditInfo] 감사 정보 조회 시작: storeId=${store.id}" }
val createdBy = adminService.findOperatorOrUnknown(store.createdBy) val createdBy = adminService.findOperatorOrUnknown(store.createdBy)
val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy) val updatedBy = adminService.findOperatorOrUnknown(store.updatedBy)
@ -132,7 +132,7 @@ class StoreService(
} }
private fun findOrThrow(id: Long): StoreEntity { private fun findOrThrow(id: Long): StoreEntity {
log.info { "[findOrThrow] 매장 조회 시작: id=${id}" } log.debug { "[findOrThrow] 매장 조회 시작: id=${id}" }
return storeRepository.findActiveStoreById(id) return storeRepository.findActiveStoreById(id)
?.also { ?.also {

View File

@ -31,21 +31,21 @@ class StoreValidator(
private fun validateDuplicateNameExist(name: String) { private fun validateDuplicateNameExist(name: String) {
if (storeRepository.existsByName(name)) { if (storeRepository.existsByName(name)) {
log.info { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" } log.debug { "[StoreValidator.validateDuplicateNameExist] 이름 중복: name=${name}" }
throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED) throw StoreException(StoreErrorCode.STORE_NAME_DUPLICATED)
} }
} }
private fun validateDuplicateContactExist(contact: String) { private fun validateDuplicateContactExist(contact: String) {
if (storeRepository.existsByContact(contact)) { if (storeRepository.existsByContact(contact)) {
log.info { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" } log.debug { "[StoreValidator.validateDuplicateContact] 연락처 중복: contact=${contact}" }
throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED) throw StoreException(StoreErrorCode.STORE_CONTACT_DUPLICATED)
} }
} }
private fun validateDuplicateAddressExist(address: String) { private fun validateDuplicateAddressExist(address: String) {
if (storeRepository.existsByAddress(address)) { if (storeRepository.existsByAddress(address)) {
log.info { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" } log.debug { "[StoreValidator.validateDuplicateAddress] 주소 중복: address=${address}" }
throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED) throw StoreException(StoreErrorCode.STORE_ADDRESS_DUPLICATED)
} }
} }

View File

@ -42,4 +42,9 @@ class TestSetupController(
fun findAllStoreIds(): StoreIdList { fun findAllStoreIds(): StoreIdList {
return testSetupService.findAllStores() return testSetupService.findAllStores()
} }
@GetMapping("/reservations-with-user")
fun findAllReservationsWithUser(): ReservationWithUserList {
return testSetupService.findAllReservationWithUser()
}
} }

View File

@ -45,3 +45,13 @@ data class StoreIdList(
data class StoreId( data class StoreId(
val storeId: Long val storeId: Long
) )
data class ReservationWithUser(
val account: String,
val password: String,
val reservationId: Long
)
data class ReservationWithUserList(
val results: List<ReservationWithUser>
)

View File

@ -0,0 +1,50 @@
package com.sangdol.roomescape.test
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface TestSetupUserRepository: JpaRepository<UserEntity, Long> {
/**
* for test
*/
@Query("""
SELECT * FROM users u LIMIT :count
""", nativeQuery = true)
fun findUsersByCount(count: Long): List<UserEntity>
}
interface TestSetupScheduleRepository: JpaRepository<ScheduleEntity, Long> {
/**
* for test
*/
@Query("""
SELECT
s.id, s.theme_id
FROM
schedule s
WHERE
s.status = 'AVAILABLE'
AND s.date > CURRENT_DATE
""", nativeQuery = true)
fun findAllAvailableSchedules(): List<ScheduleWithThemeId>
}
interface TestSetupReservationRepository: JpaRepository<ReservationEntity, Long> {
/**
* for test
*/
@Query(
"""
SELECT
u.email, u.password, r.id
FROM
reservation r
JOIN users u ON u.id = r.user_id
""", nativeQuery = true
)
fun findAllReservationWithUser(): List<ReservationWithUser>
}

View File

@ -3,10 +3,8 @@ package com.sangdol.roomescape.test
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalTime import java.time.LocalTime
@ -16,8 +14,9 @@ class TestSetupService(
private val themeRepository: ThemeRepository, private val themeRepository: ThemeRepository,
private val storeRepository: StoreRepository, private val storeRepository: StoreRepository,
private val adminRepository: AdminRepository, private val adminRepository: AdminRepository,
private val userRepository: UserRepository, private val userRepository: TestSetupUserRepository,
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: TestSetupScheduleRepository,
private val reservationRepository: TestSetupReservationRepository
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -85,4 +84,11 @@ class TestSetupService(
StoreId(it.id) StoreId(it.id)
}) })
} }
@Transactional(readOnly = true)
fun findAllReservationWithUser(): ReservationWithUserList {
return ReservationWithUserList(
reservationRepository.findAllReservationWithUser()
)
}
} }

View File

@ -3,22 +3,18 @@ package com.sangdol.roomescape.theme.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.theme.dto.ThemeDetailResponse import com.sangdol.roomescape.theme.dto.*
import com.sangdol.roomescape.theme.dto.ThemeSummaryListResponse
import com.sangdol.roomescape.theme.dto.ThemeNameListResponse
import com.sangdol.roomescape.theme.dto.ThemeCreateRequest
import com.sangdol.roomescape.theme.dto.ThemeCreateResponse
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.mapper.toDetailResponse import com.sangdol.roomescape.theme.mapper.toDetailResponse
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.theme.mapper.toEntity
import com.sangdol.roomescape.theme.mapper.toNameListResponse import com.sangdol.roomescape.theme.mapper.toNameListResponse
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.cache.annotation.CacheEvict
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -34,7 +30,7 @@ class AdminThemeService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemeSummaries(): ThemeSummaryListResponse { fun findThemeSummaries(): ThemeSummaryListResponse {
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } log.debug { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll() return themeRepository.findAll()
.toSummaryListResponse() .toSummaryListResponse()
@ -43,7 +39,7 @@ class AdminThemeService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemeDetail(id: Long): ThemeDetailResponse { fun findThemeDetail(id: Long): ThemeDetailResponse {
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" } log.debug { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id) val theme: ThemeEntity = findOrThrow(id)
@ -57,7 +53,7 @@ class AdminThemeService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findActiveThemes(): ThemeNameListResponse { fun findActiveThemes(): ThemeNameListResponse {
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" } log.debug { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes() return themeRepository.findActiveThemes()
.toNameListResponse() .toNameListResponse()
@ -69,7 +65,7 @@ class AdminThemeService(
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse { fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" } log.debug { "[createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request) themeValidator.validateCanCreate(request)
@ -81,10 +77,10 @@ class AdminThemeService(
} }
} }
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" } log.debug { "[deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id) val theme: ThemeEntity = findOrThrow(id)
@ -93,9 +89,10 @@ class AdminThemeService(
} }
} }
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional @Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) { fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" } log.debug { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) { if (request.isAllParamsNull()) {
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" } log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
@ -124,7 +121,7 @@ class AdminThemeService(
} }
private fun findOrThrow(id: Long): ThemeEntity { private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[findOrThrow] 테마 조회 시작: id=$id" } log.debug { "[findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id) return themeRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } } ?.also { log.info { "[findOrThrow] 테마 조회 완료: id=$id" } }

View File

@ -10,6 +10,8 @@ import com.sangdol.roomescape.theme.mapper.toInfoResponse
import com.sangdol.roomescape.theme.mapper.toListResponse import com.sangdol.roomescape.theme.mapper.toListResponse
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@ -21,13 +23,19 @@ private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class ThemeService( class ThemeService(
private val themeRepository: ThemeRepository private val themeRepository: ThemeRepository,
meterRegistry: MeterRegistry
) { ) {
private val themeDetailQueryRequestCount = meterRegistry.counter("theme.detail.query.requested")
@Cacheable(cacheNames = ["theme-details"], key="#id")
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findInfoById(id: Long): ThemeInfoResponse { fun findInfoById(id: Long): ThemeInfoResponse {
log.info { "[findInfoById] 테마 조회 시작: id=$id" } log.debug { "[findInfoById] 테마 조회 시작: id=$id" }
val theme = themeRepository.findByIdOrNull(id) ?: run { val theme = themeRepository.findByIdOrNull(id)?.also {
themeDetailQueryRequestCount.increment()
} ?: run {
log.warn { "[updateTheme] 테마 조회 실패: id=$id" } log.warn { "[updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
} }
@ -38,7 +46,7 @@ class ThemeService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse { fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
log.info { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" } log.debug { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today()) val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
val previousWeekSaturday = previousWeekSunday.plusDays(6) val previousWeekSaturday = previousWeekSunday.plusDays(6)

View File

@ -1,7 +1,6 @@
package com.sangdol.roomescape.user.business package com.sangdol.roomescape.user.business
import com.sangdol.common.persistence.IDGenerator import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.user.dto.UserContactResponse import com.sangdol.roomescape.user.dto.UserContactResponse
import com.sangdol.roomescape.user.dto.UserCreateRequest import com.sangdol.roomescape.user.dto.UserCreateRequest
import com.sangdol.roomescape.user.dto.UserCreateResponse import com.sangdol.roomescape.user.dto.UserCreateResponse
@ -28,20 +27,9 @@ class UserService(
private val userValidator: UserValidator, private val userValidator: UserValidator,
private val idGenerator: IDGenerator private val idGenerator: IDGenerator
) { ) {
@Transactional(readOnly = true)
fun findContextById(id: Long): CurrentUserContext {
log.info { "[findContextById] 현재 로그인된 회원 조회 시작: id=${id}" }
val user: UserEntity = findOrThrow(id)
return CurrentUserContext(user.id, user.name)
.also {
log.info { "[findContextById] 현재 로그인된 회원 조회 완료: id=${id}" }
}
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findCredentialsByAccount(email: String): UserLoginCredentials { fun findCredentialsByAccount(email: String): UserLoginCredentials {
log.info { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" } log.debug { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
return userRepository.findByEmail(email) return userRepository.findByEmail(email)
?.let { ?.let {
@ -49,14 +37,13 @@ class UserService(
it.toCredentials() it.toCredentials()
} }
?: run { ?: run {
log.info { "[findCredentialsByAccount] 회원 조회 실패" }
throw UserException(UserErrorCode.USER_NOT_FOUND) throw UserException(UserErrorCode.USER_NOT_FOUND)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findContactById(id: Long): UserContactResponse { fun findContactById(id: Long): UserContactResponse {
log.info { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" } log.debug { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
val user = findOrThrow(id) val user = findOrThrow(id)
@ -68,7 +55,7 @@ class UserService(
@Transactional @Transactional
fun signup(request: UserCreateRequest): UserCreateResponse { fun signup(request: UserCreateRequest): UserCreateResponse {
log.info { "[signup] 회원가입 시작: request:$request" } log.debug { "[signup] 회원가입 시작: request:$request" }
userValidator.validateCanSignup(request.email, request.phone) userValidator.validateCanSignup(request.email, request.phone)

View File

@ -15,14 +15,14 @@ class UserValidator(
) { ) {
fun validateCanSignup(email: String, phone: String) { fun validateCanSignup(email: String, phone: String) {
log.info { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" } log.debug { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" }
if (userRepository.existsByEmail(email)) { if (userRepository.existsByEmail(email)) {
log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" } log.debug { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS) throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
} }
if (userRepository.existsByPhone(phone)) { if (userRepository.existsByPhone(phone)) {
log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" } log.debug { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS) throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
} }
} }

View File

@ -1,21 +1,12 @@
package com.sangdol.roomescape.user.infrastructure.persistence package com.sangdol.roomescape.user.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface UserRepository : JpaRepository<UserEntity, Long> { interface UserRepository : JpaRepository<UserEntity, Long> {
fun existsByEmail(email: String): Boolean fun existsByEmail(email: String): Boolean
fun existsByPhone(phone: String): Boolean fun existsByPhone(phone: String): Boolean
fun findByEmail(email: String): UserEntity? fun findByEmail(email: String): UserEntity?
/**
* for test
*/
@Query("""
SELECT * FROM users u LIMIT :count
""", nativeQuery = true)
fun findUsersByCount(count: Long): List<UserEntity>
} }
interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long> interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long>

View File

@ -16,6 +16,9 @@ spring:
jdbc: jdbc:
batch_size: ${JDBC_BATCH_SIZE:100} batch_size: ${JDBC_BATCH_SIZE:100}
order_inserts: true order_inserts: true
cache:
type: caffeine
cache-names: ${CACHE_NAMES:theme-details}
management: management:
endpoints: endpoints:
@ -28,7 +31,7 @@ management:
show-details: always show-details: always
payment: payment:
api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com} api-base-url: ${PAYMENT_SERVER_ENDPOINT:http://localhost:8000}
springdoc: springdoc:
swagger-ui: swagger-ui:

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.auth.business package com.sangdol.roomescape.auth
import com.sangdol.roomescape.auth.business.domain.PrincipalType import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity

View File

@ -1,46 +1,38 @@
package com.sangdol.roomescape.order package com.sangdol.roomescape.order
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.MockkBean
import com.sangdol.common.utils.KoreaDate import com.sangdol.common.utils.KoreaDate
import com.sangdol.common.utils.KoreaTime import com.sangdol.common.utils.KoreaTime
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.order.exception.OrderErrorCode import com.sangdol.roomescape.order.exception.OrderErrorCode
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.business.domain.PaymentMethod import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.business.event.PaymentEvent
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
class OrderApiTest( class OrderApiTest(
@SpykBean private val paymentService: PaymentService, @MockkBean(relaxed = true) private val paymentClient: TosspayClient,
private val paymentAttemptRepository: PaymentAttemptRepository, @MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
private val reservationRepository: ReservationRepository, private val reservationRepository: ReservationRepository,
private val postOrderTaskRepository: PostOrderTaskRepository,
private val scheduleRepository: ScheduleRepository,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
@ -82,39 +74,52 @@ class OrderApiTest(
} }
test("정상 응답") { test("정상 응답") {
val reservation = dummyInitializer.createPendingReservation(user) val reservationId = dummyInitializer.createPendingReservation(user).id
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
val paymentEventSlot = slot<PaymentEvent>()
every { every {
paymentService.requestConfirm(paymentRequest) paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
} returns expectedPaymentResponse } returns expectedPaymentResponse
every {
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
} just runs
every {
reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot))
} just runs
runTest( runTest(
token = token, token = token,
using = { using = {
body(paymentRequest) body(paymentRequest)
}, },
on = { on = {
post("/orders/${reservation.id}/confirm") post("/orders/${reservationId}/confirm")
}, },
expect = { expect = {
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
} }
) )
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { verify(exactly = 1) {
this.status shouldBe ScheduleStatus.RESERVED paymentEventListener.handlePaymentEvent(any())
this.holdExpiredAt shouldBe null }.also {
assertSoftly(paymentEventSlot.captured) {
this.paymentKey shouldBe expectedPaymentResponse.paymentKey
this.reservationId shouldBe reservationId
} }
reservationRepository.findByIdOrNull(reservation.id)!!.status shouldBe ReservationStatus.CONFIRMED
assertSoftly(paymentRepository.findByReservationId(reservation.id)) {
this.shouldNotBeNull()
this.status shouldBe expectedPaymentResponse.status
paymentDetailRepository.findByPaymentId(this.id)!!.shouldNotBeNull()
} }
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() verify(exactly = 1) {
reservationEventListener.handleReservationConfirmEvent(any())
}.also {
assertSoftly(reservationConfirmEventSlot.captured) {
this.reservationId shouldBe reservationId
}
}
} }
context("검증 과정에서의 실패 응답") { context("검증 과정에서의 실패 응답") {
@ -128,24 +133,6 @@ class OrderApiTest(
) )
} }
test("이미 결제가 완료된 예약이면 실패한다.") {
val reservation = dummyInitializer.createPendingReservation(user)
paymentAttemptRepository.save(PaymentAttemptEntity(
id = IDGenerator.create(),
reservationId = reservation.id,
result = AttemptResult.SUCCESS
))
runExceptionTest(
token = token,
method = HttpMethod.POST,
endpoint = "/orders/${reservation.id}/confirm",
requestBody = paymentRequest,
expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE
)
}
test("이미 확정된 예약이면 실패한다.") { test("이미 확정된 예약이면 실패한다.") {
val reservation = dummyInitializer.createConfirmReservation(user) val reservation = dummyInitializer.createConfirmReservation(user)
@ -223,68 +210,23 @@ class OrderApiTest(
} }
context("결제 과정에서의 실패 응답.") { context("결제 과정에서의 실패 응답.") {
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") { test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
val reservation = dummyInitializer.createPendingReservation(user) val reservationId = dummyInitializer.createPendingReservation(user).id
every { every {
paymentService.requestConfirm(paymentRequest) paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR) } throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
runExceptionTest( runExceptionTest(
token = token, token = token,
method = HttpMethod.POST, method = HttpMethod.POST,
endpoint = "/orders/${reservation.id}/confirm", endpoint = "/orders/${reservationId}/confirm",
requestBody = paymentRequest, requestBody = paymentRequest,
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
).also {
it.extract().path<Long>("trial") shouldBe 0
}
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
}
val paymentAttempt = paymentAttemptRepository.findAll().first { it.reservationId == reservation.id }
assertSoftly(paymentAttempt) {
it.shouldNotBeNull()
it.result shouldBe AttemptResult.FAILED
it.errorCode shouldBe PaymentErrorCode.PAYMENT_CLIENT_ERROR.name
}
}
}
context("결제 성공 이후 실패 응답.") {
test("결제 이력 저장 과정에서 예외가 발생하면 해당 작업을 저장하며, 사용자는 정상 응답을 받는다.") {
val reservation = dummyInitializer.createPendingReservation(user)
every {
paymentService.requestConfirm(paymentRequest)
} returns expectedPaymentResponse
every {
paymentService.savePayment(reservation.id, expectedPaymentResponse)
} throws RuntimeException("결제 저장 실패!")
runTest(
token = token,
using = {
body(paymentRequest)
},
on = {
post("/orders/${reservation.id}/confirm")
},
expect = {
statusCode(HttpStatus.OK.value())
}
) )
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue() assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) {
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id }
assertSoftly(postOrderTask) {
it.shouldNotBeNull()
it.paymentKey shouldBe paymentRequest.paymentKey
it.trial shouldBe 1
} }
} }
} }

View File

@ -18,6 +18,7 @@ import com.sangdol.roomescape.supports.ReservationFixture
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every import io.mockk.every
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -69,7 +70,7 @@ class OrderConcurrencyTest(
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") { test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
every { every {
paymentService.requestConfirm(paymentConfirmRequest) paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
} returns paymentGatewayResponse } returns paymentGatewayResponse
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -87,18 +88,13 @@ class OrderConcurrencyTest(
} }
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldBe ReservationStatus.CONFIRMED this.status shouldNotBe ReservationStatus.EXPIRED
}
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.RESERVED
this.holdExpiredAt shouldBe null
} }
} }
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") { test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
every { every {
paymentService.requestConfirm(paymentConfirmRequest) paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
} returns paymentGatewayResponse } returns paymentGatewayResponse
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -113,8 +109,6 @@ class OrderConcurrencyTest(
async { async {
assertThrows<OrderException> { assertThrows<OrderException> {
orderService.confirm(reservation.id, paymentConfirmRequest) orderService.confirm(reservation.id, paymentConfirmRequest)
}.also {
it.trial shouldBe 0
} }
} }
} }

View File

@ -4,22 +4,21 @@ import com.ninjasquad.springmockk.MockkBean
import com.sangdol.common.types.web.HttpStatus import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.payment.business.PaymentService import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.business.domain.* import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.dto.* import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.exception.ExternalPaymentException import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.exception.PaymentErrorCode import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.payment.mapper.toDetailEntity
import com.sangdol.roomescape.supports.PaymentFixture import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.supports.runExceptionTest import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.supports.runTest import com.sangdol.roomescape.supports.*
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
@ -28,211 +27,10 @@ class PaymentAPITest(
private val tosspayClient: TosspayClient, private val tosspayClient: TosspayClient,
private val paymentService: PaymentService, private val paymentService: PaymentService,
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository private val canceledPaymentRepository: CanceledPaymentRepository
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
context("결제를 승인한다.") {
context("권한이 없으면 접근할 수 없다.") {
val endpoint = "/payments/confirm"
test("비회원") {
runExceptionTest(
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
val amount = 100_000
context("간편결제 + 카드로 ${amount}원을 결제한다.") {
context("일시불") {
test("토스페이 + 토스뱅크카드(신용)") {
runConfirmTest(
amount = amount,
cardDetail = PaymentFixture.cardDetail(
amount = amount,
issuerCode = CardIssuerCode.TOSS_BANK,
cardType = CardType.CREDIT,
),
easyPayDetail = PaymentFixture.easypayDetail(
amount = 0,
provider = EasyPayCompanyCode.TOSSPAY
)
)
}
test("삼성페이 + 삼성카드(법인)") {
runConfirmTest(
amount = amount,
cardDetail = PaymentFixture.cardDetail(
amount = amount,
issuerCode = CardIssuerCode.SAMSUNG,
cardType = CardType.CREDIT,
ownerType = CardOwnerType.CORPORATE
),
easyPayDetail = PaymentFixture.easypayDetail(
amount = 0,
provider = EasyPayCompanyCode.SAMSUNGPAY
)
)
}
}
context("할부") {
val installmentPlanMonths = 12
test("네이버페이 + 신한카드 / 12개월") {
runConfirmTest(
amount = amount,
cardDetail = PaymentFixture.cardDetail(
amount = amount,
issuerCode = CardIssuerCode.SHINHAN,
installmentPlanMonths = installmentPlanMonths
),
easyPayDetail = PaymentFixture.easypayDetail(
amount = 0,
provider = EasyPayCompanyCode.NAVERPAY
)
)
}
}
context("간편결제사 포인트 일부 사용") {
val point = (amount * 0.1).toInt()
test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") {
runConfirmTest(
amount = amount,
cardDetail = PaymentFixture.cardDetail(
amount = (amount - point),
issuerCode = CardIssuerCode.KOOKMIN,
cardType = CardType.CHECK
),
easyPayDetail = PaymentFixture.easypayDetail(
amount = 0,
provider = EasyPayCompanyCode.TOSSPAY,
discountAmount = point
)
)
}
}
}
context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") {
test("토스페이 + 토스페이머니 / 전액") {
runConfirmTest(
easyPayDetail = PaymentFixture.easypayDetail(
amount = amount,
provider = EasyPayCompanyCode.TOSSPAY
)
)
}
val point = (amount * 0.05).toInt()
test("카카오페이 + 카카오페이머니 / $point 사용") {
runConfirmTest(
easyPayDetail = PaymentFixture.easypayDetail(
amount = (amount - point),
provider = EasyPayCompanyCode.KAKAOPAY,
discountAmount = point
)
)
}
}
context("계좌이체로 결제한다.") {
test("토스뱅크") {
runConfirmTest(
transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK)
)
}
}
context("결제 처리중 오류가 발생한다.") {
lateinit var token: String
val commonRequest = PaymentFixture.confirmRequest
beforeTest {
token = testAuthUtil.defaultUserLogin().second
}
afterTest {
clearMocks(tosspayClient)
}
test("예외 코드가 UserFacingPaymentErrorCode에 있으면 결제 실패 메시지를 같이 담는다.") {
val statusCode = HttpStatus.BAD_REQUEST.value()
val message = "거래금액 한도를 초과했습니다."
every {
tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount)
} throws ExternalPaymentException(
httpStatusCode = statusCode,
errorCode = UserFacingPaymentErrorCode.EXCEED_MAX_AMOUNT.name,
message = message
)
runTest(
token = token,
using = {
body(commonRequest)
},
on = {
post("/payments/confirm")
},
expect = {
statusCode(statusCode)
body("code", equalTo(PaymentErrorCode.PAYMENT_CLIENT_ERROR.errorCode))
body("message", containsString(message))
}
)
}
context("예외 코드가 UserFacingPaymentErrorCode에 없으면 Client의 상태 코드에 따라 다르게 처리한다.") {
mapOf(
HttpStatus.BAD_REQUEST.value() to PaymentErrorCode.PAYMENT_CLIENT_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR.value() to PaymentErrorCode.PAYMENT_PROVIDER_ERROR
).forEach { (statusCode, expectedErrorCode) ->
test("statusCode=${statusCode}") {
val message = "잘못된 시크릿키 연동 정보 입니다."
every {
tosspayClient.confirm(commonRequest.paymentKey, commonRequest.orderId, commonRequest.amount)
} throws ExternalPaymentException(
httpStatusCode = statusCode,
errorCode = "INVALID_API_KEY",
message = message
)
runTest(
token = token,
using = {
body(commonRequest)
},
on = {
post("/payments/confirm")
},
expect = {
statusCode(statusCode)
body("code", equalTo(expectedErrorCode.errorCode))
body("message", equalTo(expectedErrorCode.message))
}
)
}
}
}
}
}
context("결제를 취소한다.") { context("결제를 취소한다.") {
context("권한이 없으면 접근할 수 없다.") { context("권한이 없으면 접근할 수 없다.") {
val endpoint = "/payments/cancel" val endpoint = "/payments/cancel"
@ -245,16 +43,6 @@ class PaymentAPITest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
requestBody = PaymentFixture.cancelRequest,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
} }
test("정상 취소") { test("정상 취소") {
@ -262,7 +50,7 @@ class PaymentAPITest(
val reservation = dummyInitializer.createConfirmReservation(user = user) val reservation = dummyInitializer.createConfirmReservation(user = user)
val confirmRequest = PaymentFixture.confirmRequest val confirmRequest = PaymentFixture.confirmRequest
val paymentCreateResponse = createPayment( val paymentEntity = createPayment(
request = confirmRequest, request = confirmRequest,
reservationId = reservation.id reservationId = reservation.id
) )
@ -289,10 +77,10 @@ class PaymentAPITest(
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
} }
).also { ).also {
val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) val payment = paymentRepository.findByIdOrNull(paymentEntity.id)
?: throw AssertionError("Unexpected Exception Occurred.") ?: throw AssertionError("Unexpected Exception Occurred.")
val canceledPayment = val canceledPayment =
canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) canceledPaymentRepository.findByPaymentId(paymentEntity.id)
?: throw AssertionError("Unexpected Exception Occurred.") ?: throw AssertionError("Unexpected Exception Occurred.")
payment.status shouldBe PaymentStatus.CANCELED payment.status shouldBe PaymentStatus.CANCELED
@ -319,7 +107,7 @@ class PaymentAPITest(
private fun createPayment( private fun createPayment(
request: PaymentConfirmRequest, request: PaymentConfirmRequest,
reservationId: Long, reservationId: Long,
): PaymentCreateResponse { ): PaymentEntity {
every { every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} returns PaymentFixture.confirmResponse( } returns PaymentFixture.confirmResponse(
@ -331,49 +119,10 @@ class PaymentAPITest(
transferDetail = null, transferDetail = null,
) )
val paymentResponse = paymentService.requestConfirm(request) val paymentEvent = paymentService.requestConfirm(reservationId, request).toEvent(reservationId)
return paymentService.savePayment(reservationId, paymentResponse)
return paymentRepository.save(paymentEvent.toEntity(IDGenerator.create())).also {
paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), it.id))
} }
fun runConfirmTest(
cardDetail: CardDetailResponse? = null,
easyPayDetail: EasyPayDetailResponse? = null,
transferDetail: TransferDetailResponse? = null,
paymentKey: String = "paymentKey",
amount: Int = 10000,
) {
val token = testAuthUtil.defaultUserLogin().second
val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount)
val method = if (easyPayDetail != null) {
PaymentMethod.EASY_PAY
} else if (cardDetail != null) {
PaymentMethod.CARD
} else if (transferDetail != null) {
PaymentMethod.TRANSFER
} else {
throw AssertionError("결제타입 확인 필요.")
}
val clientResponse = PaymentFixture.confirmResponse(
paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail
)
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} returns clientResponse
runTest(
token = token,
using = {
body(request)
},
on = {
post("/payments/confirm")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
} }
} }

View File

@ -0,0 +1,54 @@
package com.sangdol.roomescape.payment
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentCardDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.PaymentFixture
import com.sangdol.roomescape.supports.initialize
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class PaymentEventListenerTest(
private val paymentEventListener: PaymentEventListener,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
) : FunSpecSpringbootTest() {
init {
test("결제 완료 이벤트를 처리한다.") {
val reservationId = initialize("FK 제약조건 해소를 위한 예약 생성") {
val user = testAuthUtil.defaultUser()
dummyInitializer.createPendingReservation(user)
}.id
val paymentExternalAPIResponse = PaymentFixture.confirmResponse(
paymentKey = "paymentKey",
amount = 100_000,
method = PaymentMethod.CARD
)
paymentEventListener.handlePaymentEvent(paymentExternalAPIResponse.toEvent(reservationId)).also {
Thread.sleep(100)
}
val payment = paymentRepository.findByReservationId(reservationId)
assertSoftly(payment!!) {
this.paymentKey shouldBe paymentExternalAPIResponse.paymentKey
this.totalAmount shouldBe paymentExternalAPIResponse.totalAmount
this.method shouldBe paymentExternalAPIResponse.method
}
val paymentDetail = paymentDetailRepository.findByPaymentId(payment.id)
assertSoftly(paymentDetail) {
this.shouldNotBeNull()
this::class shouldBe PaymentCardDetailEntity::class
(this as PaymentCardDetailEntity).amount shouldBe paymentExternalAPIResponse.totalAmount
}
}
}
}

View File

@ -0,0 +1,185 @@
package com.sangdol.roomescape.payment
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.business.domain.*
import com.sangdol.roomescape.payment.business.event.PaymentEvent
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.exception.PaymentException
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.PaymentFixture
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.junit.jupiter.api.assertThrows
class PaymentServiceTest(
private val paymentService: PaymentService,
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
@MockkBean(relaxed = true) private val tosspayClient: TosspayClient
) : FunSpecSpringbootTest() {
init {
afterTest {
clearAllMocks()
}
context("결제를 승인한다.") {
val request = PaymentFixture.confirmRequest
context("결제 정상 승인 및 이벤트 발행 확인") {
test("간편결제 + 카드") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = PaymentMethod.EASY_PAY,
cardDetail = PaymentFixture.cardDetail(100_000),
easyPayDetail = PaymentFixture.easypayDetail(0)
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe EasypayCardPaymentDetail::class
}
}
}
test("간편결제 - 충전식") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = PaymentMethod.EASY_PAY,
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe EasypayPrepaidPaymentDetail::class
}
}
}
test("카드") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = PaymentMethod.CARD,
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe CardPaymentDetail::class
}
}
}
test("계좌이체") {
val tosspayAPIResponse = PaymentFixture.confirmResponse(
paymentKey = request.paymentKey,
amount = request.amount,
orderId = request.orderId,
method = PaymentMethod.TRANSFER,
)
runSuccessTest(request, tosspayAPIResponse) {
assertSoftly(it.detail) {
this::class shouldBe BankTransferPaymentDetail::class
}
}
}
}
context("외부 API 요청 과정에서 예외가 발생하면, 예외를 PaymentException으로 변환한 뒤 던진다.") {
test("외부 API가 4xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_CLIENT_ERROR}로 변환하여 예외를 던진다.") {
val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
val exception = ExternalPaymentException(400, "INVALID_REQUEST", "잘못된 요청입니다.")
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} throws exception
assertThrows<PaymentException> {
paymentService.requestConfirm(12345L, request)
}.also {
it.errorCode shouldBe expectedErrorCode
it.message shouldBe expectedErrorCode.message
}
}
test("외부 API가 5xx 응답을 보내면 ${PaymentErrorCode.PAYMENT_PROVIDER_ERROR}로 변환하여 예외를 던진다.") {
val expectedErrorCode = PaymentErrorCode.PAYMENT_PROVIDER_ERROR
val exception = ExternalPaymentException(500, "UNKNOWN_PAYMENT_ERROR", "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요.")
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} throws exception
assertThrows<PaymentException> {
paymentService.requestConfirm(12345L, request)
}.also {
it.errorCode shouldBe expectedErrorCode
it.message shouldBe expectedErrorCode.message
}
}
test("외부 API의 에러코드가 ${UserFacingPaymentErrorCode::class.simpleName}에 있으면 해당 예외 메시지를 담아 던진다.") {
val expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
val exception = ExternalPaymentException(400, "EXCEED_MAX_CARD_INSTALLMENT_PLAN", "설정 가능한 최대 할부 개월 수를 초과했습니다.")
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} throws exception
assertThrows<PaymentException> {
paymentService.requestConfirm(12345L, request)
}.also {
it.errorCode shouldBe expectedErrorCode
it.message shouldBe "${expectedErrorCode.message}(${exception.message})"
}
}
test("외부 API에서 예상치 못한 예외가 발생한 경우 ${PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR}로 변환한다.") {
val expectedErrorCode = PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} throws Exception("unexpected")
assertThrows<PaymentException> {
paymentService.requestConfirm(12345L, request)
}.also {
it.errorCode shouldBe expectedErrorCode
it.message shouldBe expectedErrorCode.message
}
}
}
}
}
private fun runSuccessTest(request: PaymentConfirmRequest, tosspayAPIResponse: PaymentGatewayResponse, additionalAssertion: (PaymentEvent) -> Unit): PaymentEvent {
val paymentEventSlot = slot<PaymentEvent>()
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} returns tosspayAPIResponse
every {
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
} just runs
paymentService.requestConfirm(12345L, request)
assertSoftly(paymentEventSlot.captured) {
this.paymentKey shouldBe request.paymentKey
this.orderId shouldBe request.orderId
this.totalAmount shouldBe request.amount
this.method shouldBe tosspayAPIResponse.method
}
return paymentEventSlot.captured
}
}

View File

@ -49,15 +49,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
} }
@ -185,15 +176,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
} }
test("정상 응답") { test("정상 응답") {
@ -238,15 +220,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
} }
test("정상 응답") { test("정상 응답") {
@ -315,15 +288,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
} }
test("정상 응답") { test("정상 응답") {
@ -377,15 +341,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
} }
context("정상 응답") { context("정상 응답") {

View File

@ -108,7 +108,7 @@ class ReservationConcurrencyTest(
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long { private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
return reservationService.createPendingReservation( return reservationService.createPendingReservation(
user = CurrentUserContext(id = user.id, name = user.name), user = CurrentUserContext(id = user.id),
request = PendingReservationCreateRequest( request = PendingReservationCreateRequest(
scheduleId = schedule.id, scheduleId = schedule.id,
reserverName = user.name, reserverName = user.name,

View File

@ -0,0 +1,46 @@
package com.sangdol.roomescape.reservation
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.data.repository.findByIdOrNull
class ReservationEventListenerTest(
private val reservationEventListener: ReservationEventListener,
private val reservationRepository: ReservationRepository,
private val scheduleRepository: ScheduleRepository
) : FunSpecSpringbootTest() {
init {
test("예약 확정 이벤트를 처리한다.") {
val pendingReservation = dummyInitializer.createPendingReservation(testAuthUtil.defaultUser()).also {
it.status = ReservationStatus.PAYMENT_IN_PROGRESS
reservationRepository.saveAndFlush(it)
}
val reservationConfirmEvent = ReservationConfirmEvent(pendingReservation.id)
reservationEventListener.handleReservationConfirmEvent(reservationConfirmEvent).also {
Thread.sleep(100)
}
assertSoftly(reservationRepository.findByIdOrNull(pendingReservation.id)) {
this.shouldNotBeNull()
this.status shouldBe ReservationStatus.CONFIRMED
}
assertSoftly(scheduleRepository.findByIdOrNull(pendingReservation.scheduleId)) {
this.shouldNotBeNull()
this.status shouldBe ScheduleStatus.RESERVED
this.holdExpiredAt shouldBe null
}
}
}
}

View File

@ -1,11 +1,11 @@
package com.sangdol.roomescape.supports package com.sangdol.roomescape.supports
import com.sangdol.roomescape.payment.business.PaymentWriter
import com.sangdol.roomescape.payment.business.domain.PaymentMethod import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.dto.* import com.sangdol.roomescape.payment.dto.*
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity import com.sangdol.roomescape.payment.mapper.toDetailEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.payment.mapper.toResponse import com.sangdol.roomescape.payment.mapper.toResponse
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
@ -33,7 +33,8 @@ class DummyInitializer(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository, private val reservationRepository: ReservationRepository,
private val paymentRepository: PaymentRepository, private val paymentRepository: PaymentRepository,
private val paymentWriter: PaymentWriter private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository
) { ) {
fun createStore( fun createStore(
@ -204,12 +205,10 @@ class DummyInitializer(
transferDetail = transferDetail transferDetail = transferDetail
) )
val payment = paymentWriter.createPayment( val paymentEvent = clientConfirmResponse.toEvent(reservationId)
reservationId = reservationId, val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create()))
paymentGatewayResponse = clientConfirmResponse
)
val detail = paymentWriter.createDetail(clientConfirmResponse, payment.id) val detail = paymentDetailRepository.save(paymentEvent.toDetailEntity(IDGenerator.create(), payment.id))
return payment.toResponse(detail = detail.toResponse(), cancel = null) return payment.toResponse(detail = detail.toResponse(), cancel = null)
} }
@ -227,11 +226,14 @@ class DummyInitializer(
cancelReason = cancelReason, cancelReason = cancelReason,
) )
return paymentWriter.cancel(
userId, return clientCancelResponse.cancels.toEntity(
payment, id = IDGenerator.create(),
requestedAt = Instant.now(), paymentId = payment.id,
clientCancelResponse cancelRequestedAt = Instant.now(),
) canceledBy = userId
).also {
canceledPaymentRepository.save(it)
}
} }
} }

View File

@ -1,7 +1,8 @@
package com.sangdol.roomescape.supports package com.sangdol.roomescape.supports
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.payment.business.PaymentWriter import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
@ -43,13 +44,7 @@ abstract class FunSpecSpringbootTest(
} }
}) { }) {
@Autowired @Autowired
private lateinit var userRepository: UserRepository lateinit var testAuthUtil: TestAuthUtil
@Autowired
private lateinit var adminRepository: AdminRepository
@Autowired
private lateinit var storeRepository: StoreRepository
@Autowired @Autowired
lateinit var dummyInitializer: DummyInitializer lateinit var dummyInitializer: DummyInitializer
@ -57,32 +52,40 @@ abstract class FunSpecSpringbootTest(
@LocalServerPort @LocalServerPort
var port: Int = 0 var port: Int = 0
lateinit var testAuthUtil: TestAuthUtil
override suspend fun beforeSpec(spec: Spec) { override suspend fun beforeSpec(spec: Spec) {
RestAssured.port = port RestAssured.port = port
testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository)
} }
} }
@TestConfiguration @TestConfiguration
class TestConfig { class TestConfig {
@Bean
fun testAuthUtil(
userRepository: UserRepository,
adminRepository: AdminRepository,
storeRepository: StoreRepository
): TestAuthUtil {
return TestAuthUtil(userRepository, adminRepository, storeRepository)
}
@Bean @Bean
fun dummyInitializer( fun dummyInitializer(
storeRepository: StoreRepository, storeRepository: StoreRepository,
themeRepository: ThemeRepository, themeRepository: ThemeRepository,
scheduleRepository: ScheduleRepository, scheduleRepository: ScheduleRepository,
reservationRepository: ReservationRepository, reservationRepository: ReservationRepository,
paymentWriter: PaymentWriter, paymentRepository: PaymentRepository,
paymentRepository: PaymentRepository paymentDetailRepository: PaymentDetailRepository,
canceledPaymentRepository: CanceledPaymentRepository
): DummyInitializer { ): DummyInitializer {
return DummyInitializer( return DummyInitializer(
themeRepository = themeRepository, themeRepository = themeRepository,
scheduleRepository = scheduleRepository, scheduleRepository = scheduleRepository,
reservationRepository = reservationRepository, reservationRepository = reservationRepository,
paymentWriter = paymentWriter,
paymentRepository = paymentRepository, paymentRepository = paymentRepository,
storeRepository = storeRepository storeRepository = storeRepository,
paymentDetailRepository = paymentDetailRepository,
canceledPaymentRepository = canceledPaymentRepository
) )
} }
} }

View File

@ -6,21 +6,28 @@ import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.supports.*
import com.sangdol.roomescape.supports.ThemeFixture.createRequest import com.sangdol.roomescape.supports.ThemeFixture.createRequest
import com.sangdol.roomescape.theme.business.AdminThemeService
import com.sangdol.roomescape.theme.business.MIN_DURATION import com.sangdol.roomescape.theme.business.MIN_DURATION
import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS import com.sangdol.roomescape.theme.business.MIN_PARTICIPANTS
import com.sangdol.roomescape.theme.business.MIN_PRICE import com.sangdol.roomescape.theme.business.MIN_PRICE
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import com.sangdol.roomescape.theme.exception.ThemeErrorCode import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
class AdminThemeApiTest( class AdminThemeApiTest(
private val themeRepository: ThemeRepository private val themeRepository: ThemeRepository,
private val themeService: ThemeService,
private val cacheManager: CacheManager
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
@ -482,14 +489,19 @@ class AdminThemeApiTest(
} }
} }
test("정상 삭제") { test("정상 삭제 및 캐시 제거 확인") {
val token = testAuthUtil.defaultHqAdminLogin().second val token = testAuthUtil.defaultHqAdminLogin().second
val createdTheme = initialize("테스트를 위한 테마 생성") { val createdTheme = initialize("테스트를 위한 테마 생성") {
dummyInitializer.createTheme() dummyInitializer.createTheme()
} }
initialize("테마 캐시 추가") {
themeService.findInfoById(createdTheme.id)
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).shouldNotBeNull()
}
runTest( runTest(
token = testAuthUtil.defaultHqAdminLogin().second, token = token,
on = { on = {
delete("/admin/themes/${createdTheme.id}") delete("/admin/themes/${createdTheme.id}")
}, },
@ -498,6 +510,7 @@ class AdminThemeApiTest(
} }
).also { ).also {
themeRepository.findByIdOrNull(createdTheme.id) shouldBe null themeRepository.findByIdOrNull(createdTheme.id) shouldBe null
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java) shouldBe null
} }
} }
@ -566,7 +579,7 @@ class AdminThemeApiTest(
val updateRequest = ThemeUpdateRequest(name = "modified") val updateRequest = ThemeUpdateRequest(name = "modified")
test("정상 수정 및 감사 정보 변경 확인") { test("정상 수정 및 감사 정보 & 캐시 변경 확인") {
val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") { val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") {
runTest( runTest(
token = testAuthUtil.defaultHqAdminLogin().second, token = testAuthUtil.defaultHqAdminLogin().second,
@ -582,6 +595,11 @@ class AdminThemeApiTest(
).extract().path("data.id") ).extract().path("data.id")
} }
initialize("테마 캐시 추가") {
themeService.findInfoById(createdThemeId)
cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java).shouldNotBeNull()
}
val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") { val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") {
testAuthUtil.adminLogin( testAuthUtil.adminLogin(
AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE) AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE)
@ -604,6 +622,12 @@ class AdminThemeApiTest(
updatedTheme.name shouldBe updateRequest.name updatedTheme.name shouldBe updateRequest.name
updatedTheme.updatedBy shouldBe otherAdmin.id updatedTheme.updatedBy shouldBe otherAdmin.id
// 캐시 제거 확인
assertSoftly(cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java)) {
this shouldBe null
}
} }
} }

View File

@ -10,23 +10,32 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.mapper.toEntity import com.sangdol.roomescape.theme.mapper.toEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.collections.shouldContainInOrder
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.comparables.shouldBeLessThan import io.kotest.matchers.comparables.shouldBeLessThan
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import java.time.LocalDate import java.time.LocalDate
class ThemeApiTest( class ThemeApiTest(
private val themeRepository: ThemeRepository private val themeRepository: ThemeRepository,
private val cacheManager: CacheManager
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
context("ID로 테마 정보를 조회한다.") { context("ID로 테마 정보를 조회한다.") {
test("정상 응답") { test("정상 응답 및 캐시 저장 확인") {
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") { val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
dummyInitializer.createTheme() dummyInitializer.createTheme()
} }
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).also {
it shouldBe null
}
runTest( runTest(
on = { on = {
get("/themes/${createdTheme.id}") get("/themes/${createdTheme.id}")
@ -43,6 +52,15 @@ class ThemeApiTest(
) )
} }
) )
assertSoftly(cacheManager.getCache("theme-details")) {
this.shouldNotBeNull()
val themeFromCache = this.get(createdTheme.id, ThemeInfoResponse::class.java)
themeFromCache.shouldNotBeNull()
themeFromCache.id shouldBe createdTheme.id
}
} }
test("테마가 없으면 실패한다.") { test("테마가 없으면 실패한다.") {

View File

@ -0,0 +1,68 @@
package com.sangdol.roomescape.theme
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.IDGenerator
import com.sangdol.roomescape.supports.initialize
import com.sangdol.roomescape.theme.business.ThemeService
import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.springframework.data.repository.findByIdOrNull
import java.util.concurrent.CountDownLatch
class ThemeConcurrencyTest(
private val themeService: ThemeService,
@MockkBean private val themeRepository: ThemeRepository,
) : FunSpecSpringbootTest() {
init {
test("동일한 테마에 대한 반복 조회 요청시, DB 요청은 1회만 발생한다.") {
val entity = ThemeEntity(
id = IDGenerator.create(),
name = "테스트입니다.",
description = "테스트에요!",
thumbnailUrl = "http://localhost:8080/hello",
difficulty = Difficulty.VERY_EASY,
price = 10000,
minParticipants = 3,
maxParticipants = 5,
availableMinutes = 90,
expectedMinutesFrom = 70,
expectedMinutesTo = 80,
isActive = true
)
every {
themeRepository.findByIdOrNull(entity.id)
} returns entity
initialize("캐시 등록") {
themeService.findInfoById(entity.id)
}
val requestCount = 64
withContext(Dispatchers.IO) {
val latch = CountDownLatch(requestCount)
(1..requestCount).map {
async {
latch.countDown()
latch.await()
themeService.findInfoById(entity.id)
}
}
}
verify(exactly = 1) {
themeRepository.findByIdOrNull(entity.id)
}
}
}
}

View File

@ -148,15 +148,6 @@ class UserApiTest(
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
) )
} }
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultStoreAdminLogin().second,
method = HttpMethod.GET,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
} }
test("정상 응답") { test("정상 응답") {

View File

@ -18,6 +18,9 @@ spring:
init: init:
mode: always mode: always
schema-locations: classpath:schema/schema-mysql.sql schema-locations: classpath:schema/schema-mysql.sql
cache:
type: caffeine
cache-names: ${CACHE_NAMES:theme-details}
security: security:
jwt: jwt:

View File

@ -22,7 +22,7 @@ export function parseIdToString(response) {
} }
export function maxIterations() { export function maxIterations() {
const maxIterationsRes = http.get(`${BASE_URL}/tests/max-iterations`) const maxIterationsRes = http.get(`http://localhost:8080/tests/max-iterations`)
if (maxIterationsRes.status !== 200) { if (maxIterationsRes.status !== 200) {
throw new Error('max-iterations 조회 실패') throw new Error('max-iterations 조회 실패')
} }
@ -57,7 +57,7 @@ export function login(account, password, principalType) {
password: password, password: password,
principalType: principalType principalType: principalType
}) })
const params = { headers: { 'Content-Type': 'application/json' } } const params = { headers: { 'Content-Type': 'application/json' }, tags: { name: '/auth/login' } }
const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params) const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params)
@ -78,7 +78,7 @@ export function login(account, password, principalType) {
} }
} }
export function getHeaders(token) { export function getHeaders(token, endpoint) {
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
@ -87,5 +87,5 @@ export function getHeaders(token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
return { headers: headers }; return { headers: headers, tags: { name: endpoint } };
} }

View File

@ -15,10 +15,13 @@ export const options = {
scenarios: { scenarios: {
user_reservation: { user_reservation: {
executor: 'ramping-vus', executor: 'ramping-vus',
startVUs: 1500, startVUs: 0,
stages: [ stages: [
{ duration: '10m', target: 1500 }, { duration: '3m', target: 500 },
{ duration: '1m', target: 0 } { duration: '2m', target: 1000 },
{ duration: '2m', target: 1500 },
{ duration: '3m', target: 1500 },
{ duration: '3m', target: 0 },
] ]
} }
}, },
@ -92,7 +95,8 @@ export default function (data) {
while (searchTrial < 5) { while (searchTrial < 5) {
storeId = randomItem(stores).storeId storeId = randomItem(stores).storeId
targetDate = randomDayBetween(1, 7) targetDate = randomDayBetween(1, 7)
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) const params = getHeaders(accessToken, "/stores/${storeId}/schedules?date=${date}")
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`, params)
const result = check(res, {'일정 조회 성공': (r) => r.status === 200}) const result = check(res, {'일정 조회 성공': (r) => r.status === 200})
if (result !== true) { if (result !== true) {
continue continue
@ -118,7 +122,7 @@ export default function (data) {
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate) const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
randomThemesForFetchDetail.forEach(id => { randomThemesForFetchDetail.forEach(id => {
http.get(`${BASE_URL}/themes/${id}`) http.get(`${BASE_URL}/themes/${id}`, getHeaders(accessToken, "/themes/${id}"))
sleep(10) sleep(10)
}) })
}) })
@ -137,11 +141,11 @@ export default function (data) {
let isScheduleHeld = false let isScheduleHeld = false
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken, "/schedules/${id}/hold"))
const body = JSON.parse(holdRes.body) const body = JSON.parse(holdRes.body)
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) { if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`, { tag: { name: "/themes/${id}"}})
selectedThemeInfo = parseIdToString(themeInfoRes).data selectedThemeInfo = parseIdToString(themeInfoRes).data
isScheduleHeld = true isScheduleHeld = true
} else { } else {
@ -160,7 +164,7 @@ export default function (data) {
group(`예약 정보 입력 페이지`, function () { group(`예약 정보 입력 페이지`, function () {
let userName, userContact let userName, userContact
group(`회원 연락처 조회`, function () { group(`회원 연락처 조회`, function () {
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken)) const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken, "/users/contact"))
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) { if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
throw new Error("회원 연락처 조회 과정에서 예외 발생") throw new Error("회원 연락처 조회 과정에서 예외 발생")
@ -191,7 +195,7 @@ export default function (data) {
requirement: requirement requirement: requirement
}) })
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken)) const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken, "/reservations/pending"))
const responseBody = parseIdToString(pendingReservationCreateRes) const responseBody = parseIdToString(pendingReservationCreateRes)
if (pendingReservationCreateRes.status !== 200) { if (pendingReservationCreateRes.status !== 200) {
@ -229,7 +233,7 @@ export default function (data) {
let isConfirmed = false let isConfirmed = false
while (trial < 2) { while (trial < 2) {
sleep(30) sleep(30)
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken)) const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken, "/orders/${reservationId}/confirm"))
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) { if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
isConfirmed = true isConfirmed = true

View File

@ -1,17 +1,17 @@
import http from 'k6/http'; import http from 'k6/http';
import {check, sleep} from 'k6'; import {check} from 'k6';
import exec from 'k6/execution'; import exec from 'k6/execution';
import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js"; import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
const TOTAL_ITERATIONS = 85212; const TOTAL_ITERATIONS = 200000;
export const options = { export const options = {
scenarios: { scenarios: {
schedule_creation: { schedule_creation: {
executor: 'shared-iterations', executor: 'shared-iterations',
vus: 263, vus: 100,
iterations: TOTAL_ITERATIONS, iterations: TOTAL_ITERATIONS,
maxDuration: '30m', maxDuration: '30m',
}, },
@ -97,7 +97,7 @@ function createSchedule(storeId, accessToken, schedule) {
time: schedule.time, time: schedule.time,
themeId: schedule.themeId, themeId: schedule.themeId,
}); });
const params = getHeaders(accessToken) const params = getHeaders(accessToken, "/admin/stores/${id}/schedules")
const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params); const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params);
const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201}); const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201});
@ -105,7 +105,6 @@ function createSchedule(storeId, accessToken, schedule) {
if (!success) { if (!success) {
console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`); console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`);
} }
sleep(5)
return success; return success;
} }