Compare commits

..

11 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
5f2e44bb11 [#60] Trace Context의 초기화 오류로 발생하는 OOM 문제 해결 (#63)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- JVM Heapdump 분석 결과 Opentelemetry의 Context가 과도한 메모리를 사용하고 있었음.(Retain 925MB / 파드 메모리 할당 Limit 2GB 중)
- 원인은, 하나의 요청이 끝날 때 Trace Context가 초기화 되지 않았고, 동일한 Tomcat 스레드에 서로 다른 HTTP 요청의 SPAN이 계속 쌓인 것.
- Slow-query 를 기록하기 위한 Datasource-Proxy와 충돌이 그 이유였고, 슬로우쿼리 기록은 필요하기에 CurrentTraceContext를 이용하여 필터의 Finally 과정에서 컨텍스트를 정리하도록 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- JVM의 Old, Survivor Space에서의 메모리 사용량 감소 확인
- 동일한 환경 테스트에서 OOM 발생하지 않음.

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

Reviewed-on: #63
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:38:46 +00:00
bba3266f3f [#61] 커넥션 고갈 해결을 위한 로그인 이력 저장 비동기 처리 (#62)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 이력 저장을 비동기 + Batch Insert로 구현하여 기존의 '로그인 완료 - 이력 저장(동기)' 로직, 특히 이력 저장을 별도의 트랜잭션으로 진행하며 발생하던 커넥션 고갈 문제를 해결
- 이벤트를 수신하면 In-Memory Queue에 저장하게 되어, OOM 발생 가능성이 있다고 판단. => 100개가 넘어가는 순간 바로 Batch Insert를 수행하도록 함.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 로컬 환경에서 Login API만 별도로 성능 테스트 => 기존 로직에서는 70VU에서 다운, 개선 후 1000VU, 초당 558번의 요청에서도 정상 동작
- 테스트 결과 메모리 사용량의 큰 변화 없이 커넥션 고갈 문제 해결

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

Reviewed-on: #62
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-14 00:28:44 +00:00
135b13a9bf [#58] K6 성능 테스트 도입 (#59)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- K6 성능 테스트 스크립트 추가 및 배포 환경에서의 정상 동작 확인
- 정상 동작 과정 확인 중 발견된 slow-query 개선 => 커버링 인덱스를 생각했으나, 실제로 사용하지 않고 테이블 풀스캔을 하던 문제

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 스크립트는 크게 사용자가 예약할 수 있는 일정을 만드는 작업과 사용자가 예약하는 작업 두 가지로 구분
- 후자의 테스트는 40VU까지는 여유있게 처리 확인 => 다음 과정부터는 부하를 더 높여 진행할 예정

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

Reviewed-on: #59
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-11 07:38:26 +00:00
047e4a395b [#56] 예약 & 결제 프로세스 및 패키지 구조 재정의 (#57)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
<img width="1163" alt="스크린샷 2025-10-09 18.26.43.png" src="attachments/b1651431-c1c4-4198-84c8-2019bde70dd6">
- '결제 요청 API가 호출된 이상 사용자는 결제를 시도한 것으로 간주한다' + 'PG사 정상 응답만 오면 사용자는 결제를 성공한 것이다' 의 관점으로 구현
- 결제 요청 API가 호출되면, 이미 예약이 완료, 취소, 만료된 것이 아니라면 검증 후 해당 예약을 배치가 처리하지 못하게 PAYMENT_IN_PROGRESS로 변경
- PG사 결제가 성공하면, 이후의 결제 & 예약 정보 저장의 성공 여부와 무관하게 일단 API는 성공 응답 전송
- 매 결제 시도는 성공 / 실패 여부와 무관하게 이력 저장 => 결제 시도 횟수가 N번 이상이면 프론트엔드에서 특정 처리(=> 현장결제 페이지 안내 예정. 현재 바로는 구현 계획 X)
- 기존 배치와의 경합 + 데드락 방지를 위해 배치 작업을 조회 -> 수정 두 단계로 변경했고, 조회 단계에서는 SKIP LOCKED 사용.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 새로 통합한 Order 관련 API 테스트 및 기존 배치와의 경합 상황 테스트

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

Reviewed-on: #57
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-09 09:33:29 +00:00
88 changed files with 2149 additions and 2352 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
WORKDIR /app
COPY service/build/libs/service.jar app.jar
EXPOSE 8080
COPY --from=builder /app/service/build/libs/*.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

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.web.asepct.ControllerLoggingAspect
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.micrometer.tracing.CurrentTraceContext
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@ -17,9 +18,10 @@ class WebLoggingConfig {
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean(
webLogMessageConverter: WebLogMessageConverter
webLogMessageConverter: WebLogMessageConverter,
currentTraceContext: CurrentTraceContext
): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }

View File

@ -5,6 +5,7 @@ import com.sangdol.common.utils.MdcStartTimeUtil
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.micrometer.tracing.CurrentTraceContext
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
@ -15,7 +16,8 @@ import org.springframework.web.util.ContentCachingResponseWrapper
private val log: KLogger = KotlinLogging.logger {}
class HttpRequestLoggingFilter(
private val messageConverter: WebLogMessageConverter
private val messageConverter: WebLogMessageConverter,
private val currentTraceContext: CurrentTraceContext
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
@ -32,9 +34,12 @@ class HttpRequestLoggingFilter(
try {
filterChain.doFilter(cachedRequest, cachedResponse)
cachedResponse.copyBodyToResponse()
} catch (e: Exception) {
throw e
} finally {
MdcStartTimeUtil.clear()
MdcPrincipalIdUtil.clear()
currentTraceContext.maybeScope(null)
}
}
}

View File

@ -0,0 +1,12 @@
import apiClient from "@_api/apiClient";
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
export const confirm = async (
reservationId: string,
data: PaymentConfirmRequest,
): Promise<void> => {
return await apiClient.post<void>(
`/orders/${reservationId}/confirm`,
data
);
};

View File

@ -0,0 +1,5 @@
export interface OrderErrorResponse {
code: string;
message: string;
trial: number;
}

View File

@ -1,5 +1,5 @@
import { confirm } from '@_api/order/orderAPI';
import type { BookingErrorResponse } from '@_api/order/orderTypes';
import type { OrderErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css';
@ -83,7 +83,7 @@ const ReservationStep2Page: React.FC = () => {
});
})
.catch(err => {
const error = err as AxiosError<BookingErrorResponse>;
const error = err as AxiosError<OrderErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;

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
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
runtimeOnly("com.h2database:h2")
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.SpringApplication
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.*
@EnableAsync
@EnableCaching
@EnableScheduling
@SpringBootApplication(
scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"]
)

View File

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

View File

@ -1,6 +1,7 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.roomescape.admin.business.AdminService
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginCredentials
@ -12,8 +13,8 @@ import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.user.business.UserService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@ -25,36 +26,37 @@ const val CLAIM_STORE_ID_KEY = "store_id"
class AuthService(
private val adminService: AdminService,
private val userService: UserService,
private val loginHistoryService: LoginHistoryService,
private val jwtUtils: JwtUtils,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional(readOnly = true)
fun login(
request: LoginRequest,
context: LoginContext
): 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 event = LoginHistoryEvent(
id = credentials.id,
type = request.principalType,
ipAddress = context.ipAddress,
userAgent = context.userAgent
)
try {
verifyPasswordOrThrow(request, credentials)
val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims)
loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context)
eventPublisher.publishEvent(event.onSuccess())
return credentials.toResponse(accessToken).also {
log.info { "[login] 로그인 완료: account=${request.account}, context=${context}" }
}
} catch (e: Exception) {
loginHistoryService.createFailureHistory(credentials.id, request.principalType, context)
eventPublisher.publishEvent(event.onFailure())
when (e) {
is AuthException -> {
log.info { "[login] 로그인 실패: account = ${request.account}" }
throw e
}
is AuthException -> { throw e }
else -> {
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
@ -69,7 +71,7 @@ class AuthService(
credentials: LoginCredentials
) {
if (credentials.password != request.password) {
log.info { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
log.debug { "[login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" }
throw AuthException(AuthErrorCode.LOGIN_FAILED)
}
}

View File

@ -0,0 +1,92 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.mapper.toEntity
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PreDestroy
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
class LoginHistoryEventListener(
private val idGenerator: IDGenerator,
private val loginHistoryRepository: LoginHistoryRepository,
private val queue: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue()
) {
@Value(value = "\${spring.jpa.properties.hibernate.jdbc.batch_size:100}")
private var batchSize: Int = 0
@Async
@EventListener(classes = [LoginHistoryEvent::class])
fun onLoginCompleted(event: LoginHistoryEvent) {
log.debug { "[onLoginCompleted] 로그인 이력 저장 이벤트 수신: id=${event.id}, type=${event.type}" }
queue.add(event.toEntity(idGenerator.create())).also {
log.info { "[onLoginCompleted] 로그인 이력 저장 이벤트 큐 저장 완료: id=${event.id}, type=${event.type}" }
}
if (queue.size >= batchSize) {
flush()
}
}
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
fun flushScheduled() {
log.debug { "[flushScheduled] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flushScheduled] 큐에 있는 로그인 이력이 없음." }
return
}
flush()
log.info { "[flushScheduled] 큐에 저장된 로그인 이력 저장 완료: size=${queue.size}" }
}
@PreDestroy
fun flushAll() {
log.debug { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 시작: size=${queue.size}" }
while (!queue.isEmpty()) {
flush()
}
log.info { "[flushAll] 애플리케이션 종료. 큐에 있는 모든 이력 저장 완료: size=${queue.size}" }
}
private fun flush() {
log.debug { "[flush] 큐에 저장된 로그인 이력 저장 시작: size=${queue.size}" }
if (queue.isEmpty()) {
log.debug { "[flush] 큐에 있는 로그인 이력이 없음." }
return;
}
val batch = mutableListOf<LoginHistoryEntity>()
repeat(batchSize) {
val entity: LoginHistoryEntity? = queue.poll()
if (entity != null) {
batch.add(entity)
} else {
return@repeat
}
}
if (batch.isEmpty()) {
return
}
loginHistoryRepository.saveAll(batch).also {
log.info { "[flush] 큐에 저장된 로그인 이력 저장 완료: size=${batch.size}" }
}
}
}

View File

@ -1,63 +0,0 @@
package com.sangdol.roomescape.auth.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.business.domain.PrincipalType
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
private val log: KLogger = KotlinLogging.logger {}
@Service
class LoginHistoryService(
private val loginHistoryRepository: LoginHistoryRepository,
private val idGenerator: IDGenerator,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createSuccessHistory(
principalId: Long,
principalType: PrincipalType,
context: LoginContext
) {
createHistory(principalId, principalType, success = true, context = context)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createFailureHistory(
principalId: Long,
principalType: PrincipalType,
context: LoginContext
) {
createHistory(principalId, principalType, success = false, context = context)
}
private fun createHistory(
principalId: Long,
principalType: PrincipalType,
success: Boolean,
context: LoginContext
) {
log.info { "[createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" }
runCatching {
LoginHistoryEntity(
id = idGenerator.create(),
principalId = principalId,
principalType = principalType,
success = success,
ipAddress = context.ipAddress,
userAgent = context.userAgent,
).also {
loginHistoryRepository.save(it)
log.info { "[createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" }
}
}.onFailure {
log.warn { "[createHistory] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" }
}
}
}

View File

@ -0,0 +1,19 @@
package com.sangdol.roomescape.auth.business.domain
class LoginHistoryEvent(
val id: Long,
val type: PrincipalType,
var success: Boolean = true,
val ipAddress: String,
val userAgent: String
) {
fun onSuccess(): LoginHistoryEvent {
this.success = true
return this
}
fun onFailure(): LoginHistoryEvent {
this.success = false
return this
}
}

View File

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

View File

@ -0,0 +1,13 @@
package com.sangdol.roomescape.auth.mapper
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
fun LoginHistoryEvent.toEntity(id: Long) = LoginHistoryEntity(
id = id,
principalId = this.id,
principalType = this.type,
success = this.success,
ipAddress = this.ipAddress,
userAgent = this.userAgent
)

View File

@ -1,11 +1,12 @@
package com.sangdol.roomescape.auth.web.support.resolver
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.web.support.User
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.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -21,7 +22,6 @@ private val log: KLogger = KotlinLogging.logger {}
@Component
class UserContextResolver(
private val jwtUtils: JwtUtils,
private val userService: UserService,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
@ -38,9 +38,11 @@ class UserContextResolver(
val token: String? = request.accessToken()
try {
val id: Long = jwtUtils.extractSubject(token).toLong()
val id: Long = jwtUtils.extractSubject(token).also {
MdcPrincipalIdUtil.set(it)
}.toLong()
return userService.findContextById(id)
return CurrentUserContext(id = id)
} catch (e: Exception) {
log.info { "[UserContextResolver] 회원 조회 실패. message=${e.message}" }
throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND)

View File

@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {}
@Component
@Profile("local")
@Profile("!deploy & local")
class LocalDatabaseCleaner(
private val jdbcTemplate: JdbcTemplate
) {

View File

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

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,130 +1,69 @@
package com.sangdol.roomescape.order.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.roomescape.order.exception.OrderErrorCode
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.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.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.dto.ReservationStateResponse
import com.sangdol.roomescape.schedule.business.ScheduleService
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
private val log: KLogger = KotlinLogging.logger {}
@Service
class OrderService(
private val idGenerator: IDGenerator,
private val reservationService: ReservationService,
private val scheduleService: ScheduleService,
private val paymentService: PaymentService,
private val transactionExecutionUtil: TransactionExecutionUtil,
private val orderValidator: OrderValidator,
private val paymentAttemptRepository: PaymentAttemptRepository,
private val orderPostProcessorService: OrderPostProcessorService
private val eventPublisher: ApplicationEventPublisher
) {
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
val trial = paymentAttemptRepository.countByReservationId(reservationId)
val paymentKey = paymentConfirmRequest.paymentKey
log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
try {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
validateAndMarkInProgress(reservationId)
validateCanConfirm(reservationId)
reservationService.markInProgress(reservationId)
}
val paymentClientResponse: PaymentGatewayResponse =
requestConfirmPayment(reservationId, paymentConfirmRequest)
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
orderPostProcessorService.processAfterPaymentConfirmation(reservationId, paymentClientResponse)
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
} catch (e: Exception) {
val errorCode: ErrorCode = if (e is RoomescapeException) {
e.errorCode
} 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 validateAndMarkInProgress(reservationId: Long) {
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
private fun validateCanConfirm(reservationId: Long) {
log.debug { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" }
val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId)
val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId)
try {
orderValidator.validateCanConfirm(reservation, schedule)
log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" }
} catch (e: OrderException) {
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
throw OrderException(errorCode, e.message)
}
reservationService.markInProgress(reservationId)
}
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.roomescape.order.exception.OrderErrorCode
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.infrastructure.persistence.ReservationStatus
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
@ -16,18 +15,11 @@ import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class OrderValidator(
private val paymentAttemptRepository: PaymentAttemptRepository
) {
class OrderValidator {
fun validateCanConfirm(
reservation: ReservationStateResponse,
schedule: ScheduleStateResponse
) {
if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) {
log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
}
validateReservationStatus(reservation)
validateScheduleStatus(schedule)
}
@ -35,15 +27,12 @@ class OrderValidator(
private fun validateReservationStatus(reservation: ReservationStateResponse) {
when (reservation.status) {
ReservationStatus.CONFIRMED -> {
log.info { "[validateCanConfirm] 이미 확정된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED)
throw OrderException(OrderErrorCode.ORDER_ALREADY_CONFIRMED)
}
ReservationStatus.EXPIRED -> {
log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
}
ReservationStatus.CANCELED -> {
log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" }
throw OrderException(OrderErrorCode.CANCELED_RESERVATION)
}
else -> {}
@ -52,14 +41,14 @@ class OrderValidator(
private fun validateScheduleStatus(schedule: ScheduleStateResponse) {
if (schedule.status != ScheduleStatus.HOLD) {
log.info { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
log.debug { "[validateScheduleStatus] 일정 상태 오류: status=${schedule.status}" }
throw OrderException(OrderErrorCode.EXPIRED_RESERVATION)
}
val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now()
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)
}
}

View File

@ -9,11 +9,11 @@ enum class OrderErrorCode(
override val message: String
) : ErrorCode {
NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."),
BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
ORDER_ALREADY_CONFIRMED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."),
EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."),
CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."),
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(
override val errorCode: ErrorCode,
override val message: String = errorCode.message,
var trial: Long = 0
) : 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,26 +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
}

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
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.common.persistence.TransactionExecutionUtil
import com.sangdol.roomescape.payment.business.domain.UserFacingPaymentErrorCode
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.infrastructure.client.TosspayClient
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 io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -18,18 +22,20 @@ private val log: KLogger = KotlinLogging.logger {}
@Service
class PaymentService(
private val idGenerator: IDGenerator,
private val paymentClient: TosspayClient,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val paymentWriter: PaymentWriter,
private val transactionExecutionUtil: TransactionExecutionUtil,
private val eventPublisher: ApplicationEventPublisher
) {
fun requestConfirm(request: PaymentConfirmRequest): PaymentGatewayResponse {
log.info { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
fun requestConfirm(reservationId: Long, request: PaymentConfirmRequest): PaymentGatewayResponse {
log.debug { "[requestConfirm] 결제 요청 시작: paymentKey=${request.paymentKey}" }
try {
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) {
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) {
val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId)
@ -79,12 +72,17 @@ class PaymentService(
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.cancel(
userId = userId,
payment = payment,
requestedAt = request.requestedAt,
cancelResponse = clientCancelResponse
)
val payment = findByReservationIdOrThrow(request.reservationId).apply { this.cancel() }
clientCancelResponse.cancels.toEntity(
id = idGenerator.create(),
paymentId = payment.id,
cancelRequestedAt = request.requestedAt,
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
log.debug { "[cancel] 결제 취소 정보 저장 완료: payment.id=${payment.id}" }
}
}.also {
log.info { "[cancel] 결제 취소 완료: paymentId=${payment.id}" }
}
@ -92,7 +90,7 @@ class PaymentService(
@Transactional(readOnly = true)
fun findDetailByReservationId(reservationId: Long): PaymentResponse? {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
log.debug { "[findDetailByReservationId] 예약 결제 정보 조회 시작: reservationId=$reservationId" }
val payment: PaymentEntity? = findByReservationIdOrNull(reservationId)
val paymentDetail: PaymentDetailEntity? = payment?.let { findDetailByPaymentIdOrNull(it.id) }
@ -101,11 +99,13 @@ class PaymentService(
return payment?.toResponse(
detail = paymentDetail?.toResponse(),
cancel = cancelDetail?.toResponse()
)
).also {
log.info { "[findDetailByReservationId] 예약 결제 정보 조회 완료: reservationId=$reservationId" }
}
}
private fun findByReservationIdOrThrow(reservationId: Long): PaymentEntity {
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.info { "[findByReservationIdOrThrow] 결제 정보 조회 완료: reservationId=$reservationId, paymentId=${it.id}" } }
@ -116,7 +116,7 @@ class PaymentService(
}
private fun findByReservationIdOrNull(reservationId: Long): PaymentEntity? {
log.info { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
log.debug { "[findByReservationIdOrThrow] 결제 정보 조회 시작: reservationId=: $reservationId" }
return paymentRepository.findByReservationId(reservationId)
.also {
@ -129,7 +129,7 @@ class PaymentService(
}
private fun findDetailByPaymentIdOrNull(paymentId: Long): PaymentDetailEntity? {
log.info { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
log.debug { "[findDetailByPaymentIdOrThrow] 결제 상세 정보 조회 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId).also {
if (it != null) {
@ -141,7 +141,7 @@ class PaymentService(
}
private fun findCancelByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntity? {
log.info { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
log.debug { "[findDetailByReservationId] 취소 결제 정보 조회 시작: paymentId=${paymentId}" }
return canceledPaymentRepository.findByPaymentId(paymentId).also {
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.roomescape.auth.web.support.User
import com.sangdol.roomescape.auth.web.support.UserOnly
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.PaymentConfirmRequest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -16,13 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody
interface PaymentAPI {
@UserOnly
@Operation(summary = "결제 승인")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun confirmPayment(
@Valid @RequestBody request: PaymentConfirmRequest
): ResponseEntity<CommonApiResponse<PaymentGatewayResponse>>
@Operation(summary = "결제 취소")
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
fun cancelPayment(

View File

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

View File

@ -33,7 +33,7 @@ class TosspayClient(
amount: Int,
): PaymentGatewayResponse {
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)
.also {
@ -47,7 +47,7 @@ class TosspayClient(
cancelReason: String
): PaymentGatewayCancelResponse {
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 {
log.info { "[TosspayClient.cancel] 결제 취소 완료: duration_ms=${System.currentTimeMillis() - startTime}ms, paymentKey=$paymentKey" }

View File

@ -1,82 +1,14 @@
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.PaymentGatewayResponse
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
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
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(
id: Long,
paymentId: Long,
@ -94,3 +26,88 @@ fun CancelDetail.toEntity(
transferDiscountAmount = this.transferDiscountAmount,
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.docs.PaymentAPI
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 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
@RequestMapping("/payments")
class PaymentController(
private val paymentService: PaymentService
) : 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")
override fun cancelPayment(
@User user: CurrentUserContext,

View File

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

View File

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

View File

@ -1,17 +1,15 @@
package com.sangdol.roomescape.reservation.business
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.ReservationException
import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.schedule.dto.ScheduleStateResponse
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
import com.sangdol.roomescape.theme.dto.ThemeInfoResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.LocalDateTime
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 io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@ -15,7 +14,6 @@ import java.util.concurrent.TimeUnit
private val log: KLogger = KotlinLogging.logger {}
@Component
@EnableScheduling
class IncompletedReservationScheduler(
private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository
@ -24,10 +22,10 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredHoldSchedule() {
log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
log.debug { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
log.debug { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
}
scheduleRepository.releaseHeldSchedules(targets).also {
@ -38,7 +36,7 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional
fun processExpiredReservation() {
log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
log.debug { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" }
val targets: List<Long> = reservationRepository.findAllExpiredReservation().also {
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")
fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity?
@Query("""
@Query(
"""
SELECT
r.id
FROM
@ -27,7 +27,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
WHERE
r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
""", nativeQuery = true
)
fun findAllExpiredReservation(): List<Long>
@Modifying
@ -47,4 +48,23 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
""", nativeQuery = true
)
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)
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()
@ -44,14 +44,12 @@ class AdminScheduleService(
.sortedBy { it.time }
return schedules.toAdminSummaryResponse()
.also {
log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" }
}
.also { log.info { "[searchSchedules] ${it.schedules.size} 개의 일정 조회 완료" } }
}
@Transactional(readOnly = true)
fun findScheduleAudit(id: Long): AuditingInfo {
log.info { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
log.debug { "[findDetail] 일정 감사 정보 조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
@ -64,7 +62,7 @@ class AdminScheduleService(
@Transactional
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)
@ -79,14 +77,12 @@ class AdminScheduleService(
}
return ScheduleCreateResponse(schedule.id)
.also {
log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" }
}
.also { log.info { "[createSchedule] 일정 생성 완료: id=${it.id}" } }
}
@Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
log.debug { "[updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateSchedule] 일정 변경 사항 없음: id=$id" }
@ -104,7 +100,7 @@ class AdminScheduleService(
@Transactional
fun deleteSchedule(id: Long) {
log.info { "[deleteSchedule] 일정 삭제 시작: id=$id" }
log.debug { "[deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id).also {
scheduleValidator.validateCanDelete(it)
@ -116,7 +112,7 @@ class AdminScheduleService(
}
private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[findOrThrow] 일정 조회 시작: id=$id" }
log.debug { "[findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id)
?.also { log.info { "[findOrThrow] 일정 조회 완료: id=$id" } }

View File

@ -30,13 +30,12 @@ class ScheduleService(
) {
@Transactional(readOnly = true)
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 currentTime: LocalTime = KoreaTime.now()
if (date.isBefore(currentDate)) {
log.warn { "[getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" }
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
}
@ -44,6 +43,7 @@ class ScheduleService(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
.filter { it.date.isAfter(currentDate) || it.time.isAfter(currentTime) }
return schedules.toResponseWithTheme()
.also {
log.info { "[getStoreScheduleByDate] storeId=${storeId}, date=$date${it.schedules.size}개 일정 조회 완료" }
@ -52,7 +52,7 @@ class ScheduleService(
@Transactional
fun holdSchedule(id: Long) {
log.info { "[holdSchedule] 일정 Holding 시작: id=$id" }
log.debug { "[holdSchedule] 일정 Holding 시작: id=$id" }
val schedule = findForUpdateOrThrow(id).also {
scheduleValidator.validateCanHold(it)
@ -69,7 +69,7 @@ class ScheduleService(
@Transactional(readOnly = true)
fun findStateWithLock(id: Long): ScheduleStateResponse {
log.info { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
log.debug { "[findDateTimeById] 일정 개요 조회 시작 : id=$id" }
val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id)
?: run {
@ -95,7 +95,7 @@ class ScheduleService(
@Transactional
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 {
log.info { "[reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" }
@ -103,7 +103,7 @@ class ScheduleService(
}
private fun findForUpdateOrThrow(id: Long): ScheduleEntity {
log.info { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
log.debug { "[findForUpdateOrThrow] 일정 LOCK + 조회 시작: id=$id" }
return scheduleRepository.findByIdForUpdate(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) {
if (scheduleRepository.existsDuplicate(storeId, date, themeId, time)) {
log.info {
log.debug {
"[validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
@ -71,7 +71,7 @@ class ScheduleValidator(
val inputDateTime = LocalDateTime.of(date, time).truncatedTo(ChronoUnit.MINUTES)
if (inputDateTime.isBefore(now)) {
log.info {
log.debug {
"[validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
@ -82,7 +82,7 @@ class ScheduleValidator(
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date, themeId)
.firstOrNull { it.containsTime(time) }
?.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)
}
}

View File

@ -133,14 +133,14 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.id
FROM
schedule s
LEFT JOIN
reservation r
ON
r.schedule_id = s.id AND r.status IN ('PENDING', 'PAYMENT_IN_PROGRESS')
WHERE
s.status = 'HOLD'
AND s.hold_expired_at <= :now
AND NOT EXISTS (
SELECT 1
FROM reservation r
WHERE r.schedule_id = s.id AND (r.status = 'PENDING' OR r.status = 'PAYMENT_IN_PROGRESS')
)
AND r.id IS NULL
FOR UPDATE SKIP LOCKED
""", nativeQuery = true
)

View File

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

View File

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

View File

@ -0,0 +1,50 @@
package com.sangdol.roomescape.test
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/tests")
class TestSetupController(
private val testSetupService: TestSetupService
) {
@GetMapping("/themes")
fun findAllThemeWithTimes(): ThemeWithTimesList {
return testSetupService.findAllThemeWithTimes()
}
@GetMapping("/max-iterations")
fun maxIterations(): MaxIterations {
return testSetupService.calculateMaxIterations()
}
@GetMapping("/admin/accounts")
fun findAllStoreAdminAccounts(): AccountList {
return testSetupService.findAllStoreAdminAccounts()
}
@GetMapping("/schedules/available")
fun findAllAvailableSchedules(): ScheduleWithThemeIdList {
return testSetupService.findAllAvailableSchedule()
}
@GetMapping("/users")
fun findAllUsers(
@RequestParam("count") count: Long
): AccountList {
return testSetupService.findAllUserAccounts(count)
}
@GetMapping("/stores")
fun findAllStoreIds(): StoreIdList {
return testSetupService.findAllStores()
}
@GetMapping("/reservations-with-user")
fun findAllReservationsWithUser(): ReservationWithUserList {
return testSetupService.findAllReservationWithUser()
}
}

View File

@ -0,0 +1,57 @@
package com.sangdol.roomescape.test
import java.time.LocalTime
data class ThemeWithTimesList(
val results: List<ThemeWithTimes>
)
data class ThemeWithTimes(
val id: Long,
val times: List<ScheduleTime>
)
data class ScheduleTime(
val startFrom: LocalTime,
val endAt: LocalTime
)
data class AccountList(
val results: List<Account>
)
data class Account(
val account: String,
val password: String
)
data class ScheduleWithThemeIdList(
val results: List<ScheduleWithThemeId>
)
data class ScheduleWithThemeId(
val scheduleId: Long,
val themeId: Long
)
data class MaxIterations(
val count: Long
)
data class StoreIdList(
val results: List<StoreId>
)
data class StoreId(
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

@ -0,0 +1,94 @@
package com.sangdol.roomescape.test
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalTime
@Service
class TestSetupService(
private val themeRepository: ThemeRepository,
private val storeRepository: StoreRepository,
private val adminRepository: AdminRepository,
private val userRepository: TestSetupUserRepository,
private val scheduleRepository: TestSetupScheduleRepository,
private val reservationRepository: TestSetupReservationRepository
) {
@Transactional(readOnly = true)
fun findAllThemeWithTimes(): ThemeWithTimesList {
val storeOpenTime = LocalTime.of(10, 0)
val storeCloseTime = LocalTime.of(22, 0)
val timeGapMinutes = 10L
return ThemeWithTimesList(themeRepository.findAll()
.filter { it.isActive }
.map { theme ->
val times: MutableList<ScheduleTime> = mutableListOf()
var startTime: LocalTime = storeOpenTime
while (startTime.isBefore(storeCloseTime)) {
val themeAvailableMinute = theme.availableMinutes.toLong()
val endTime: LocalTime = startTime.plusMinutes(themeAvailableMinute)
if (endTime.isAfter(storeCloseTime)) {
break
}
times.add(ScheduleTime(startTime, endTime))
startTime = endTime.plusMinutes(timeGapMinutes)
}
ThemeWithTimes(theme.id, times)
}
)
}
@Transactional(readOnly = true)
fun calculateMaxIterations(): MaxIterations {
val max = findAllThemeWithTimes().results.sumOf { it.times.size }
val stores = storeRepository.findAll().size
val days = 6
return MaxIterations((max * stores * days).toLong())
}
@Transactional(readOnly = true)
fun findAllStoreAdminAccounts(): AccountList {
return AccountList(adminRepository.findAll()
.filter { it.permissionLevel == AdminPermissionLevel.FULL_ACCESS }
.filter { it.type == AdminType.STORE }
.map { Account(it.account, it.password) }
)
}
@Transactional(readOnly = true)
fun findAllUserAccounts(count: Long): AccountList {
return AccountList(userRepository.findUsersByCount(count)
.map { Account(it.email, it.password) }
)
}
@Transactional(readOnly = true)
fun findAllAvailableSchedule(): ScheduleWithThemeIdList {
return ScheduleWithThemeIdList(scheduleRepository.findAllAvailableSchedules())
}
@Transactional(readOnly = true)
fun findAllStores(): StoreIdList {
return StoreIdList(storeRepository.findAll().map {
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.roomescape.admin.business.AdminService
import com.sangdol.roomescape.common.types.AuditingInfo
import com.sangdol.roomescape.theme.dto.ThemeDetailResponse
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.dto.*
import com.sangdol.roomescape.theme.exception.ThemeErrorCode
import com.sangdol.roomescape.theme.exception.ThemeException
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
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.toNameListResponse
import com.sangdol.roomescape.theme.mapper.toSummaryListResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.cache.annotation.CacheEvict
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -34,7 +30,7 @@ class AdminThemeService(
) {
@Transactional(readOnly = true)
fun findThemeSummaries(): ThemeSummaryListResponse {
log.info { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
log.debug { "[findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" }
return themeRepository.findAll()
.toSummaryListResponse()
@ -43,7 +39,7 @@ class AdminThemeService(
@Transactional(readOnly = true)
fun findThemeDetail(id: Long): ThemeDetailResponse {
log.info { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
log.debug { "[findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
@ -57,7 +53,7 @@ class AdminThemeService(
@Transactional(readOnly = true)
fun findActiveThemes(): ThemeNameListResponse {
log.info { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
log.debug { "[findActiveThemes] open 상태인 모든 테마 조회 시작" }
return themeRepository.findActiveThemes()
.toNameListResponse()
@ -69,7 +65,7 @@ class AdminThemeService(
@Transactional
fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.info { "[createTheme] 테마 생성 시작: name=${request.name}" }
log.debug { "[createTheme] 테마 생성 시작: name=${request.name}" }
themeValidator.validateCanCreate(request)
@ -81,10 +77,10 @@ class AdminThemeService(
}
}
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional
fun deleteTheme(id: Long) {
log.info { "[deleteTheme] 테마 삭제 시작: id=${id}" }
log.debug { "[deleteTheme] 테마 삭제 시작: id=${id}" }
val theme: ThemeEntity = findOrThrow(id)
@ -93,9 +89,10 @@ class AdminThemeService(
}
}
@CacheEvict(cacheNames = ["theme-details"], key = "#id")
@Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
log.debug { "[updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[updateTheme] 테마 변경 사항 없음: id=${id}" }
@ -124,7 +121,7 @@ class AdminThemeService(
}
private fun findOrThrow(id: Long): ThemeEntity {
log.info { "[findOrThrow] 테마 조회 시작: id=$id" }
log.debug { "[findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(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 io.github.oshai.kotlinlogging.KLogger
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.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@ -21,13 +23,19 @@ private val log: KLogger = KotlinLogging.logger {}
@Service
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)
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" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
@ -38,7 +46,7 @@ class ThemeService(
@Transactional(readOnly = true)
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
log.info { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
log.debug { "[findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(KoreaDate.today())
val previousWeekSaturday = previousWeekSunday.plusDays(6)

View File

@ -1,7 +1,6 @@
package com.sangdol.roomescape.user.business
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.UserCreateRequest
import com.sangdol.roomescape.user.dto.UserCreateResponse
@ -28,20 +27,9 @@ class UserService(
private val userValidator: UserValidator,
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)
fun findCredentialsByAccount(email: String): UserLoginCredentials {
log.info { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
log.debug { "[findCredentialsByAccount] 회원 조회 시작: email=${email}" }
return userRepository.findByEmail(email)
?.let {
@ -49,14 +37,13 @@ class UserService(
it.toCredentials()
}
?: run {
log.info { "[findCredentialsByAccount] 회원 조회 실패" }
throw UserException(UserErrorCode.USER_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findContactById(id: Long): UserContactResponse {
log.info { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
log.debug { "[findContactById] 회원 연락 정보 조회 시작: id=${id}" }
val user = findOrThrow(id)
@ -68,7 +55,7 @@ class UserService(
@Transactional
fun signup(request: UserCreateRequest): UserCreateResponse {
log.info { "[signup] 회원가입 시작: request:$request" }
log.debug { "[signup] 회원가입 시작: request:$request" }
userValidator.validateCanSignup(request.email, request.phone)

View File

@ -15,14 +15,14 @@ class UserValidator(
) {
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)) {
log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
log.debug { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
}
if (userRepository.existsByPhone(phone)) {
log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
log.debug { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
}
}

View File

@ -11,6 +11,14 @@ spring:
active: ${ACTIVE_PROFILE:local}
jpa:
open-in-view: false
properties:
hibernate:
jdbc:
batch_size: ${JDBC_BATCH_SIZE:100}
order_inserts: true
cache:
type: caffeine
cache-names: ${CACHE_NAMES:theme-details}
management:
endpoints:
@ -23,7 +31,7 @@ management:
show-details: always
payment:
api-base-url: ${PAYMENT_SERVER_ENDPOINT:https://api.tosspayments.com}
api-base-url: ${PAYMENT_SERVER_ENDPOINT:http://localhost:8000}
springdoc:
swagger-ui:

View File

@ -1,236 +0,0 @@
create table if not exists region (
code varchar(10) primary key,
sido_code varchar(2) not null,
sigungu_code varchar(3) not null,
sido_name varchar(20) not null,
sigungu_name varchar(20) not null,
constraint uk_region__sido_sigungu_code unique (sido_code, sigungu_code)
);
create table if not exists store(
id bigint primary key,
name varchar(20) not null,
address varchar(100) not null,
contact varchar(50) not null,
business_reg_num varchar(12) not null,
region_code varchar(10) not null,
status varchar(20) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint uk_store__name unique (name),
constraint uk_store__contact unique (contact),
constraint uk_store__address unique (address),
constraint uk_store__business_reg_num unique (business_reg_num),
constraint fk_store__region_code foreign key (region_code) references region (code)
);
create table if not exists users(
id bigint primary key,
name varchar(50) not null,
email varchar(255) not null,
password varchar(255) not null,
phone varchar(20) not null,
region_code varchar(10) null,
status varchar(20) not null,
created_at datetime(6) not null,
created_by bigint not null,
updated_at datetime(6) not null,
updated_by bigint not null,
constraint uk__users_email unique (email),
constraint uk__users_phone unique (phone),
constraint fk__users_region_code foreign key (region_code) references region (code)
);
create table if not exists user_status_history(
id bigint primary key,
user_id bigint not null,
status varchar(20) not null,
reason varchar(255) not null,
created_at datetime(6) not null,
created_by bigint not null,
updated_at datetime(6) not null,
updated_by bigint not null,
constraint fk__user_status_history_user_id foreign key (user_id) references users (id)
);
create table if not exists admin(
id bigint primary key,
account varchar(20) not null,
password varchar(255) not null,
name varchar(20) not null,
phone varchar(20) not null,
type varchar(20) not null,
store_id bigint,
permission_level varchar(20) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint uk_admin__account unique (account),
constraint uk_admin__phone unique (phone),
constraint chk_admin__type check (type in ('HQ', 'STORE')),
constraint chk_admin__store_id check (
(type = 'HQ' AND store_id IS NULL) OR
(type = 'STORE' AND store_id IS NOT NULL)
),
constraint fk_admin__store_id foreign key (store_id) references store (id)
);
create table if not exists login_history(
id bigint primary key,
principal_id bigint not null,
principal_type varchar(20) not null,
success boolean not null,
ip_address varchar(45) not null,
user_agent varchar(255) not null,
created_at timestamp not null
);
create table if not exists theme (
id bigint primary key ,
name varchar(30) not null,
difficulty varchar(20) not null,
description varchar(255) not null,
thumbnail_url varchar(255) not null,
price int not null,
min_participants smallint not null,
max_participants smallint not null,
available_minutes smallint not null,
expected_minutes_from smallint not null,
expected_minutes_to smallint not null,
is_active boolean not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null
);
create table if not exists schedule (
id bigint primary key,
date date not null,
time time not null,
store_id bigint not null,
theme_id bigint not null,
status varchar(30) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
hold_expired_at timestamp null,
constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id),
constraint fk_schedule__store_id foreign key (store_id) references store (id),
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
);
create table if not exists reservation (
id bigint primary key,
user_id bigint not null,
schedule_id bigint not null,
reserver_name varchar(30) not null,
reserver_contact varchar(30) not null,
participant_count smallint not null,
requirement varchar(255) not null,
status varchar(30) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint fk_reservation__user_id foreign key (user_id) references users (id),
constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id)
);
create table if not exists canceled_reservation (
id bigint primary key,
reservation_id bigint not null,
canceled_by bigint not null,
cancel_reason varchar(50) not null,
canceled_at timestamp not null,
status varchar(30) not null,
constraint uk_canceled_reservations__reservation_id unique (reservation_id),
constraint fk_canceled_reservations__reservation_id foreign key (reservation_id) references reservation (id)
);
create table if not exists payment (
id bigint primary key,
reservation_id bigint not null,
type varchar(20) not null,
method varchar(30) not null,
payment_key varchar(255) not null unique,
order_id varchar(255) not null unique,
total_amount integer not null,
status varchar(20) not null,
requested_at timestamp not null,
approved_at timestamp not null,
constraint uk_payment__reservationId unique (reservation_id),
constraint fk_payment__reservationId foreign key (reservation_id) references reservation (id)
);
create table if not exists payment_detail(
id bigint primary key,
payment_id bigint not null unique,
supplied_amount integer not null,
vat integer not null,
constraint fk_payment_detail__paymentId foreign key (payment_id) references payment (id)
);
create table if not exists payment_bank_transfer_detail (
id bigint primary key,
bank_code varchar(20) not null,
settlement_status varchar(20) not null,
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
);
create table if not exists payment_card_detail (
id bigint primary key,
issuer_code varchar(20) not null,
card_type varchar(10) not null,
owner_type varchar(10) not null,
amount integer not null,
card_number varchar(20) not null,
approval_number varchar(8) not null, -- 실제로는 unique 이지만 테스트 결제 위젯에서는 항상 000000으로 동일한 값이 나옴.
installment_plan_months tinyint not null,
is_interest_free boolean not null,
easypay_provider_code varchar(20),
easypay_discount_amount integer,
constraint fk_payment_card_detail__id foreign key (id) references payment_detail (id)
);
create table if not exists payment_easypay_prepaid_detail(
id bigint primary key,
easypay_provider_code varchar(20) not null,
amount integer not null,
discount_amount integer not null,
constraint fk_payment_easypay_prepaid_detail__id foreign key (id) references payment_detail (id)
);
create table if not exists canceled_payment(
id bigint primary key,
payment_id bigint not null,
requested_at timestamp not null,
canceled_at timestamp not null,
canceled_by bigint not null,
cancel_reason varchar(255) not null,
cancel_amount integer not null,
card_discount_amount integer not null,
transfer_discount_amount integer not null,
easypay_discount_amount integer not null,
constraint uk_canceled_payment__paymentId unique (payment_id),
constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id)
);

View File

@ -1,16 +1,18 @@
package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
import com.sangdol.roomescape.auth.business.CLAIM_STORE_ID_KEY
import com.sangdol.roomescape.auth.business.LoginHistoryEventListener
import com.sangdol.roomescape.auth.business.domain.LoginHistoryEvent
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.UserFixture
@ -18,19 +20,31 @@ import com.sangdol.roomescape.supports.runTest
import com.sangdol.roomescape.user.exception.UserErrorCode
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import io.mockk.*
import io.restassured.response.ValidatableResponse
import org.hamcrest.CoreMatchers.equalTo
class AuthApiTest(
@SpykBean private val jwtUtils: JwtUtils,
private val loginHistoryRepository: LoginHistoryRepository
@MockkBean(relaxed = true) private val loginHistoryEventListener: LoginHistoryEventListener,
) : FunSpecSpringbootTest() {
lateinit var slot: CapturingSlot<LoginHistoryEvent>
init {
beforeTest {
slot = slot<LoginHistoryEvent>()
every {
loginHistoryEventListener.onLoginCompleted(capture(slot))
} just Runs
}
afterTest {
clearMocks(jwtUtils, loginHistoryEventListener)
}
context("로그인을 시도한다.") {
context("성공 응답") {
listOf(
@ -64,6 +78,7 @@ class AuthApiTest(
password = user.password,
type = PrincipalType.USER,
) {
val token: String = it.extract().path("data.accessToken")
jwtUtils.extractSubject(token) shouldBe user.id.toString()
}
@ -71,6 +86,16 @@ class AuthApiTest(
}
context("실패 응답") {
lateinit var slot: CapturingSlot<LoginHistoryEvent>
beforeTest {
slot = slot<LoginHistoryEvent>()
every {
loginHistoryEventListener.onLoginCompleted(capture(slot))
} just Runs
}
context("계정이 맞으면 로그인 실패 이력을 남긴다.") {
test("비밀번호가 틀린 경우") {
val admin = testAuthUtil.createAdmin(AdminFixture.default)
@ -88,9 +113,14 @@ class AuthApiTest(
body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode))
}
).also {
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
verify(exactly = 1) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
assertSoftly(slot.captured) {
this.id shouldBe admin.id
this.type shouldBe PrincipalType.ADMIN
this.success shouldBe false
this.principalType shouldBe PrincipalType.ADMIN
}
}
}
@ -115,9 +145,14 @@ class AuthApiTest(
body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode))
}
).also {
assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) {
verify(exactly = 1) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
assertSoftly(slot.captured) {
this.id shouldBe admin.id
this.type shouldBe PrincipalType.ADMIN
this.success shouldBe false
this.principalType shouldBe PrincipalType.ADMIN
}
}
}
@ -144,7 +179,9 @@ class AuthApiTest(
body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode))
}
).also {
loginHistoryRepository.findAll() shouldHaveSize 0
verify(exactly = 0) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
}
}
@ -168,7 +205,9 @@ class AuthApiTest(
body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode))
}
).also {
loginHistoryRepository.findAll() shouldHaveSize 0
verify(exactly = 0) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
}
}
}
@ -198,10 +237,13 @@ class AuthApiTest(
).also {
extraAssertions?.invoke(it)
assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history ->
history shouldHaveSize (1)
history[0].success shouldBe true
history[0].principalType shouldBe type
verify(exactly = 1) {
loginHistoryEventListener.onLoginCompleted(any<LoginHistoryEvent>())
}
assertSoftly(slot.captured) {
this.id shouldBe id
this.type shouldBe type
this.success shouldBe true
}
}
}

View File

@ -1,64 +0,0 @@
package com.sangdol.roomescape.auth
import com.ninjasquad.springmockk.MockkBean
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryRepository
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.supports.AdminFixture
import com.sangdol.roomescape.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.UserFixture
import com.sangdol.roomescape.supports.runTest
import io.mockk.clearMocks
import io.mockk.every
class FailOnSaveLoginHistoryTest(
@MockkBean private val loginHistoryRepository: LoginHistoryRepository
) : FunSpecSpringbootTest() {
init {
context("로그인 이력 저장 과정에서 예외가 발생해도 로그인 작업 자체는 정상 처리된다.") {
beforeTest {
clearMocks(loginHistoryRepository)
every {
loginHistoryRepository.save(any())
} throws RuntimeException("intended exception")
}
test("회원") {
val user = testAuthUtil.signup(UserFixture.createRequest)
val request = LoginRequest(user.email, user.password, PrincipalType.USER)
runTest(
using = {
body(request)
},
on = {
post("/auth/login")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
}
test("관리자") {
val admin = testAuthUtil.createAdmin(AdminFixture.default)
val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN)
runTest(
using = {
body(request)
},
on = {
post("/auth/login")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
}
}
}
}

View File

@ -0,0 +1,71 @@
package com.sangdol.roomescape.auth
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import com.sangdol.roomescape.auth.infrastructure.persistence.LoginHistoryEntity
import com.sangdol.roomescape.supports.IDGenerator
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldNotContainAnyOf
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
class LoginHistoryEventListenerTest : FunSpec() {
val histories: ConcurrentLinkedQueue<LoginHistoryEntity> = ConcurrentLinkedQueue((1..1000).map {
LoginHistoryEntity(
id = IDGenerator.create(),
principalId = IDGenerator.create(),
principalType = PrincipalType.USER,
success = true,
ipAddress = "127.0.0.1",
userAgent = "UserAgent"
)
})
init {
test("ConcurrentLinkedQueue에서 데이터를 꺼내는 작업을 여러 스레드에서 동시 호출해도 중복 처리되지 않는다.") {
withContext(Dispatchers.Default) {
val latch = CountDownLatch(2)
val flushJob1 = async {
latch.countDown()
latch.await()
flush()
}
val flushJob2 = async {
latch.countDown()
latch.await()
flush()
}
val flushJob1Result = flushJob1.await()
val flushJob2Result = flushJob2.await()
flushJob2Result shouldNotContainAnyOf flushJob1Result
flushJob1Result shouldNotContainAnyOf flushJob2Result
(flushJob1Result.size + flushJob2Result.size) shouldBe 1000
histories.shouldBeEmpty()
}
}
}
private fun flush(batchSize: Int = 500): MutableList<LoginHistoryEntity> {
val batch = mutableListOf<LoginHistoryEntity>()
repeat(batchSize) {
val entity: LoginHistoryEntity? = histories.poll()
if (entity != null) {
batch.add(entity)
} else {
return@repeat
}
}
return batch
}
}

View File

@ -1,46 +1,38 @@
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.KoreaTime
import com.sangdol.roomescape.auth.exception.AuthErrorCode
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.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.persistence.PaymentDetailRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
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.*
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.*
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
class OrderApiTest(
@SpykBean private val paymentService: PaymentService,
private val paymentAttemptRepository: PaymentAttemptRepository,
@MockkBean(relaxed = true) private val paymentClient: TosspayClient,
@MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
private val reservationRepository: ReservationRepository,
private val postOrderTaskRepository: PostOrderTaskRepository,
private val scheduleRepository: ScheduleRepository,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository
) : FunSpecSpringbootTest() {
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
@ -82,39 +74,52 @@ class OrderApiTest(
}
test("정상 응답") {
val reservation = dummyInitializer.createPendingReservation(user)
val reservationId = dummyInitializer.createPendingReservation(user).id
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
val paymentEventSlot = slot<PaymentEvent>()
every {
paymentService.requestConfirm(paymentRequest)
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
} returns expectedPaymentResponse
every {
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
} just runs
every {
reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot))
} just runs
runTest(
token = token,
using = {
body(paymentRequest)
},
on = {
post("/orders/${reservation.id}/confirm")
post("/orders/${reservationId}/confirm")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {
this.status shouldBe ScheduleStatus.RESERVED
this.holdExpiredAt shouldBe null
verify(exactly = 1) {
paymentEventListener.handlePaymentEvent(any())
}.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("검증 과정에서의 실패 응답") {
@ -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("이미 확정된 예약이면 실패한다.") {
val reservation = dummyInitializer.createConfirmReservation(user)
@ -223,68 +210,23 @@ class OrderApiTest(
}
context("결제 과정에서의 실패 응답.") {
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") {
val reservation = dummyInitializer.createPendingReservation(user)
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
val reservationId = dummyInitializer.createPendingReservation(user).id
every {
paymentService.requestConfirm(paymentRequest)
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR)
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
} throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
runExceptionTest(
token = token,
method = HttpMethod.POST,
endpoint = "/orders/${reservation.id}/confirm",
endpoint = "/orders/${reservationId}/confirm",
requestBody = paymentRequest,
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()
val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id }
assertSoftly(postOrderTask) {
it.shouldNotBeNull()
it.paymentKey shouldBe paymentRequest.paymentKey
it.trial shouldBe 1
assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) {
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
}
}
}

View File

@ -18,6 +18,7 @@ import com.sangdol.roomescape.supports.ReservationFixture
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.every
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -69,7 +70,7 @@ class OrderConcurrencyTest(
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
every {
paymentService.requestConfirm(paymentConfirmRequest)
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
} returns paymentGatewayResponse
withContext(Dispatchers.IO) {
@ -87,18 +88,13 @@ class OrderConcurrencyTest(
}
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
this.status shouldBe ReservationStatus.CONFIRMED
}
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.RESERVED
this.holdExpiredAt shouldBe null
this.status shouldNotBe ReservationStatus.EXPIRED
}
}
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
every {
paymentService.requestConfirm(paymentConfirmRequest)
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
} returns paymentGatewayResponse
withContext(Dispatchers.IO) {
@ -108,11 +104,11 @@ class OrderConcurrencyTest(
}
}
delay(10)
async {
assertThrows<OrderException> {
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.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.payment.business.PaymentService
import com.sangdol.roomescape.payment.business.domain.*
import com.sangdol.roomescape.payment.dto.*
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.business.domain.PaymentStatus
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
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.supports.FunSpecSpringbootTest
import com.sangdol.roomescape.supports.PaymentFixture
import com.sangdol.roomescape.supports.runExceptionTest
import com.sangdol.roomescape.supports.runTest
import com.sangdol.roomescape.payment.mapper.toDetailEntity
import com.sangdol.roomescape.payment.mapper.toEntity
import com.sangdol.roomescape.payment.mapper.toEvent
import com.sangdol.roomescape.supports.*
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
@ -28,211 +27,10 @@ class PaymentAPITest(
private val tosspayClient: TosspayClient,
private val paymentService: PaymentService,
private val paymentRepository: PaymentRepository,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository
) : FunSpecSpringbootTest() {
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("권한이 없으면 접근할 수 없다.") {
val endpoint = "/payments/cancel"
@ -245,16 +43,6 @@ class PaymentAPITest(
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("정상 취소") {
@ -262,7 +50,7 @@ class PaymentAPITest(
val reservation = dummyInitializer.createConfirmReservation(user = user)
val confirmRequest = PaymentFixture.confirmRequest
val paymentCreateResponse = createPayment(
val paymentEntity = createPayment(
request = confirmRequest,
reservationId = reservation.id
)
@ -289,10 +77,10 @@ class PaymentAPITest(
statusCode(HttpStatus.OK.value())
}
).also {
val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId)
val payment = paymentRepository.findByIdOrNull(paymentEntity.id)
?: throw AssertionError("Unexpected Exception Occurred.")
val canceledPayment =
canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId)
canceledPaymentRepository.findByPaymentId(paymentEntity.id)
?: throw AssertionError("Unexpected Exception Occurred.")
payment.status shouldBe PaymentStatus.CANCELED
@ -319,7 +107,7 @@ class PaymentAPITest(
private fun createPayment(
request: PaymentConfirmRequest,
reservationId: Long,
): PaymentCreateResponse {
): PaymentEntity {
every {
tosspayClient.confirm(request.paymentKey, request.orderId, request.amount)
} returns PaymentFixture.confirmResponse(
@ -331,49 +119,10 @@ class PaymentAPITest(
transferDetail = null,
)
val paymentResponse = paymentService.requestConfirm(request)
return paymentService.savePayment(reservationId, paymentResponse)
val paymentEvent = paymentService.requestConfirm(reservationId, request).toEvent(reservationId)
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
)
}
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
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -238,15 +220,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -315,15 +288,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
test("정상 응답") {
@ -377,15 +341,6 @@ class ReservationApiTest(
expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultHqAdminLogin().second,
method = HttpMethod.POST,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.ACCESS_DENIED
)
}
}
context("정상 응답") {

View File

@ -108,7 +108,7 @@ class ReservationConcurrencyTest(
private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
return reservationService.createPendingReservation(
user = CurrentUserContext(id = user.id, name = user.name),
user = CurrentUserContext(id = user.id),
request = PendingReservationCreateRequest(
scheduleId = schedule.id,
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
import com.sangdol.roomescape.payment.business.PaymentWriter
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
import com.sangdol.roomescape.payment.dto.*
import com.sangdol.roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentEntity
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
import com.sangdol.roomescape.payment.infrastructure.persistence.*
import com.sangdol.roomescape.payment.mapper.toDetailEntity
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.reservation.dto.PendingReservationCreateRequest
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity
@ -33,7 +33,8 @@ class DummyInitializer(
private val scheduleRepository: ScheduleRepository,
private val reservationRepository: ReservationRepository,
private val paymentRepository: PaymentRepository,
private val paymentWriter: PaymentWriter
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepository
) {
fun createStore(
@ -204,12 +205,10 @@ class DummyInitializer(
transferDetail = transferDetail
)
val payment = paymentWriter.createPayment(
reservationId = reservationId,
paymentGatewayResponse = clientConfirmResponse
)
val paymentEvent = clientConfirmResponse.toEvent(reservationId)
val payment = paymentRepository.save(paymentEvent.toEntity(IDGenerator.create()))
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)
}
@ -227,11 +226,14 @@ class DummyInitializer(
cancelReason = cancelReason,
)
return paymentWriter.cancel(
userId,
payment,
requestedAt = Instant.now(),
clientCancelResponse
)
return clientCancelResponse.cancels.toEntity(
id = IDGenerator.create(),
paymentId = payment.id,
cancelRequestedAt = Instant.now(),
canceledBy = userId
).also {
canceledPaymentRepository.save(it)
}
}
}

View File

@ -1,7 +1,8 @@
package com.sangdol.roomescape.supports
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.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
@ -43,13 +44,7 @@ abstract class FunSpecSpringbootTest(
}
}) {
@Autowired
private lateinit var userRepository: UserRepository
@Autowired
private lateinit var adminRepository: AdminRepository
@Autowired
private lateinit var storeRepository: StoreRepository
lateinit var testAuthUtil: TestAuthUtil
@Autowired
lateinit var dummyInitializer: DummyInitializer
@ -57,32 +52,40 @@ abstract class FunSpecSpringbootTest(
@LocalServerPort
var port: Int = 0
lateinit var testAuthUtil: TestAuthUtil
override suspend fun beforeSpec(spec: Spec) {
RestAssured.port = port
testAuthUtil = TestAuthUtil(userRepository, adminRepository, storeRepository)
}
}
@TestConfiguration
class TestConfig {
@Bean
fun testAuthUtil(
userRepository: UserRepository,
adminRepository: AdminRepository,
storeRepository: StoreRepository
): TestAuthUtil {
return TestAuthUtil(userRepository, adminRepository, storeRepository)
}
@Bean
fun dummyInitializer(
storeRepository: StoreRepository,
themeRepository: ThemeRepository,
scheduleRepository: ScheduleRepository,
reservationRepository: ReservationRepository,
paymentWriter: PaymentWriter,
paymentRepository: PaymentRepository
paymentRepository: PaymentRepository,
paymentDetailRepository: PaymentDetailRepository,
canceledPaymentRepository: CanceledPaymentRepository
): DummyInitializer {
return DummyInitializer(
themeRepository = themeRepository,
scheduleRepository = scheduleRepository,
reservationRepository = reservationRepository,
paymentWriter = paymentWriter,
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.supports.*
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_PARTICIPANTS
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.infrastructure.persistence.ThemeEntity
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.theme.dto.ThemeUpdateRequest
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpMethod
class AdminThemeApiTest(
private val themeRepository: ThemeRepository
private val themeRepository: ThemeRepository,
private val themeService: ThemeService,
private val cacheManager: CacheManager
) : FunSpecSpringbootTest() {
init {
@ -482,14 +489,19 @@ class AdminThemeApiTest(
}
}
test("정상 삭제") {
test("정상 삭제 및 캐시 제거 확인") {
val token = testAuthUtil.defaultHqAdminLogin().second
val createdTheme = initialize("테스트를 위한 테마 생성") {
dummyInitializer.createTheme()
}
initialize("테마 캐시 추가") {
themeService.findInfoById(createdTheme.id)
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).shouldNotBeNull()
}
runTest(
token = testAuthUtil.defaultHqAdminLogin().second,
token = token,
on = {
delete("/admin/themes/${createdTheme.id}")
},
@ -498,6 +510,7 @@ class AdminThemeApiTest(
}
).also {
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")
test("정상 수정 및 감사 정보 변경 확인") {
test("정상 수정 및 감사 정보 & 캐시 변경 확인") {
val createdThemeId: Long = initialize("테스트를 위한 관리자1의 테마 생성") {
runTest(
token = testAuthUtil.defaultHqAdminLogin().second,
@ -582,6 +595,11 @@ class AdminThemeApiTest(
).extract().path("data.id")
}
initialize("테마 캐시 추가") {
themeService.findInfoById(createdThemeId)
cacheManager.getCache("theme-details")?.get(createdThemeId, ThemeInfoResponse::class.java).shouldNotBeNull()
}
val (otherAdmin, otherAdminToken) = initialize("감사 정보 변경 확인을 위한 관리자2 로그인") {
testAuthUtil.adminLogin(
AdminFixture.createHqAdmin(permissionLevel = AdminPermissionLevel.WRITABLE)
@ -604,6 +622,12 @@ class AdminThemeApiTest(
updatedTheme.name shouldBe updateRequest.name
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.mapper.toEntity
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainInOrder
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.comparables.shouldBeLessThan
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.CoreMatchers.equalTo
import org.springframework.cache.CacheManager
import org.springframework.http.HttpMethod
import java.time.LocalDate
class ThemeApiTest(
private val themeRepository: ThemeRepository
private val themeRepository: ThemeRepository,
private val cacheManager: CacheManager
) : FunSpecSpringbootTest() {
init {
context("ID로 테마 정보를 조회한다.") {
test("정상 응답") {
test("정상 응답 및 캐시 저장 확인") {
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
dummyInitializer.createTheme()
}
cacheManager.getCache("theme-details")?.get(createdTheme.id, ThemeInfoResponse::class.java).also {
it shouldBe null
}
runTest(
on = {
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("테마가 없으면 실패한다.") {

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
)
}
test("관리자") {
runExceptionTest(
token = testAuthUtil.defaultStoreAdminLogin().second,
method = HttpMethod.GET,
endpoint = endpoint,
expectedErrorCode = AuthErrorCode.MEMBER_NOT_FOUND
)
}
}
test("정상 응답") {

View File

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

91
test-scripts/common.js Normal file
View File

@ -0,0 +1,91 @@
import http from 'k6/http';
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
export function generateRandomBase64String(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function parseIdToString(response) {
try {
const safeJsonString = response.body.replace(/"(\w*Id|id)"\s*:\s*(\d{16,})/g, '"$1":"$2"');
return JSON.parse(safeJsonString);
} catch (e) {
console.error(`JSON parsing failed for VU ${__VU}: ${e}`);
return null;
}
}
export function maxIterations() {
const maxIterationsRes = http.get(`http://localhost:8080/tests/max-iterations`)
if (maxIterationsRes.status !== 200) {
throw new Error('max-iterations 조회 실패')
}
return maxIterationsRes.json('count')
}
export function fetchUsers() {
const userCount = Math.round(maxIterations() * 0.5)
const userAccountRes = http.get(`http://localhost:8080/tests/users?count=${userCount}`)
if (userAccountRes.status !== 200) {
throw new Error('users 조회 실패')
}
return userAccountRes.json('results')
}
export function fetchStores() {
const storeIdListRes = http.get(`http://localhost:8080/tests/stores`)
if (storeIdListRes.status !== 200) {
throw new Error('stores 조회 실패')
}
return parseIdToString(storeIdListRes).results
}
export function login(account, password, principalType) {
const loginPayload = JSON.stringify({
account: account,
password: password,
principalType: principalType
})
const params = { headers: { 'Content-Type': 'application/json' }, tags: { name: '/auth/login' } }
const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params)
if (loginRes.status !== 200) {
throw new Error(`로그인 실패: ${__VU}`)
}
const body = parseIdToString(loginRes).data
if (principalType === 'ADMIN') {
return {
storeId: body.storeId,
accessToken: body.accessToken
}
} else {
return {
accessToken: body.accessToken
}
}
}
export function getHeaders(token, endpoint) {
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return { headers: headers, tags: { name: endpoint } };
}

View File

@ -0,0 +1,261 @@
import {
BASE_URL,
fetchStores,
fetchUsers,
generateRandomBase64String,
getHeaders,
login,
parseIdToString
} from "./common.js";
import {check, group, sleep} from 'k6';
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import http from 'k6/http';
export const options = {
scenarios: {
user_reservation: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '3m', target: 500 },
{ duration: '2m', target: 1000 },
{ duration: '2m', target: 1500 },
{ duration: '3m', target: 1500 },
{ duration: '3m', target: 0 },
]
}
},
thresholds: {
http_req_duration: ['p(95)<3000'],
http_req_failed: ['rate<0.15'],
},
};
function randomDayBetween(startFrom, endAt) {
const random = randomIntBetween(startFrom, endAt)
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + random)
return targetDate.toISOString().split('T')[0];
}
function countForFetchDetails(themeCount) {
const random = Math.random()
if (random < 0.5) {
return Math.min(0, themeCount)
}
if (random < 0.75) {
return Math.min(1, themeCount)
}
if (random < 0.9) {
return Math.min(2, themeCount)
}
return Math.min(randomIntBetween(3, themeCount), themeCount)
}
function extractRandomThemeForFetchDetail(themes) {
const count = countForFetchDetails(themes.length)
const shuffled = [...themes].sort(() => Math.random() - 0.5)
return shuffled.slice(0, count).map(t => t.id)
}
export function setup() {
const users = fetchUsers()
const stores = fetchStores()
console.log(`회원 수 = ${users.length} 조회 완료`)
console.log(`매장 수 = ${stores.length} 조회 완료`)
return { users, stores }
}
export default function (data) {
const { users, stores } = data;
const user = randomItem(users)
const accessToken = login(user.account, user.password, 'USER').accessToken
let storeId = randomItem(stores).storeId
if (!accessToken) {
console.log(`로그인 실패: token=${accessToken}`)
return
}
let targetDate
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
let searchTrial = 0
let schedules
while (searchTrial < 5) {
storeId = randomItem(stores).storeId
targetDate = randomDayBetween(1, 7)
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})
if (result !== true) {
continue
}
schedules = parseIdToString(res).data.schedules
if (schedules && schedules.length > 0) {
break
}
searchTrial++
sleep(10)
}
if (schedules.length <= 0) {
console.log(`5회 시도에도 일정 조회 실패`)
return;
}
group(`일부 테마는 상세 조회`, function () {
const themesByStoreAndDate = schedules.map(s => s.theme)
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
return
}
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
randomThemesForFetchDetail.forEach(id => {
http.get(`${BASE_URL}/themes/${id}`, getHeaders(accessToken, "/themes/${id}"))
sleep(10)
})
})
const availableSchedules = schedules.filter((s) => s.schedule.status === 'AVAILABLE')
if (availableSchedules.length > 0) {
const availableSchedule = randomItem(availableSchedules)
availableScheduleId = availableSchedule.schedule.id
selectedThemeId = availableSchedule.theme.id
}
})
if (!availableScheduleId) {
return;
}
let isScheduleHeld = false
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken, "/schedules/${id}/hold"))
const body = JSON.parse(holdRes.body)
if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) {
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`, { tag: { name: "/themes/${id}"}})
selectedThemeInfo = parseIdToString(themeInfoRes).data
isScheduleHeld = true
} else {
const errorCode = body.code
const errorMessage = body.message
console.log(`일정 점유 실패: code=${errorCode}, message=${errorMessage}`)
}
})
if (!isScheduleHeld || !selectedThemeInfo) {
return
}
let isPendingReservationCreated = false
group(`예약 정보 입력 페이지`, function () {
let userName, userContact
group(`회원 연락처 조회`, function () {
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken, "/users/contact"))
if (!check(userContactRes, {'회원 연락처 조회 성공': (r) => r.status === 200})) {
throw new Error("회원 연락처 조회 과정에서 예외 발생")
}
const responseBody = JSON.parse(userContactRes.body).data
userName = responseBody.name
userContact = responseBody.phone
})
// 20%의 유저는 예약 정보 입력창에서 나감 => 배치의 자동 활성화 테스트
if (Math.random() <= 0.2) {
return
}
sleep(20)
group(`예약 정보 입력 및 Pending 예약 생성`, function () {
const requirement = `${selectedThemeInfo.name}을 잘부탁드려요!`
const participants = randomIntBetween(selectedThemeInfo.minParticipants, selectedThemeInfo.maxParticipants)
totalAmount = Math.round(participants * selectedThemeInfo.price)
const payload = JSON.stringify({
scheduleId: availableScheduleId,
reserverName: userName,
reserverContact: userContact,
participantCount: participants,
requirement: requirement
})
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken, "/reservations/pending"))
const responseBody = parseIdToString(pendingReservationCreateRes)
if (pendingReservationCreateRes.status !== 200) {
const errorCode = responseBody.code
const errorMessage = responseBody.message
throw new Error(`Pending 예약 중 실패: code=${errorCode}, message=${errorMessage}`)
}
reservationId = responseBody.data.id
isPendingReservationCreated = true
})
})
if (!isPendingReservationCreated) {
return;
}
group(`결제 및 예약 확정`, function () {
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
if (Math.random() <= 0.2) {
return
}
const paymentKey = generateRandomBase64String(64)
const orderId = generateRandomBase64String(25)
const payload = JSON.stringify({
paymentKey: paymentKey,
orderId: orderId,
amount: totalAmount,
})
let trial = 0
let isConfirmed = false
while (trial < 2) {
sleep(30)
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken, "/orders/${reservationId}/confirm"))
if (check(confirmOrderRes, {'예약 확정 성공': (r) => r.status === 200})) {
isConfirmed = true
break
}
const errorResponse = JSON.parse(confirmOrderRes.body)
console.log(`예약 확정 실패: message=${errorResponse.message}`)
trial = errorResponse.trial + 1
}
// 예약이 확정되었으면 종료, 아니면 임시 확정
if (isConfirmed) {
return
}
sleep(10)
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
if (check(temporalConfirmRes, {'임시 예약 확정 성공': (r) => r.status === 200})) {
console.log("예약 확정 성공")
return
}
throw new Error('임시 예약 확정 실패')
})
}

View File

@ -0,0 +1,134 @@
import http from 'k6/http';
import {check} from 'k6';
import exec from 'k6/execution';
import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
const TOTAL_ITERATIONS = 200000;
export const options = {
scenarios: {
schedule_creation: {
executor: 'shared-iterations',
vus: 100,
iterations: TOTAL_ITERATIONS,
maxDuration: '30m',
},
},
thresholds: {
'http_req_duration': ['p(95)<3000'],
'http_req_failed': ['rate<0.1'],
},
};
export function setup() {
console.log('=== Setup: 테스트 데이터 및 작업 목록 준비 중 ===');
const themesRes = http.get(`${BASE_URL}/tests/themes`);
const themes = parseIdToString(themesRes).results
const accountsRes = http.get(`${BASE_URL}/tests/admin/accounts`);
const accounts = JSON.parse(accountsRes.body).results;
const dates = generateDates(7);
console.log(`총 매장 수: ${accounts.length}`);
console.log(`총 테마 수: ${themes.length}`);
console.log(`생성 기간: ${dates.length}`);
const tasks = [];
for (const account of accounts) {
const loginResult = login(account.account, account.password, 'ADMIN');
if (loginResult === null) {
console.error(`[Setup] 로그인 실패: ${account.account}`);
continue;
}
const {storeId, accessToken} = loginResult;
// 5 ~ ${themes.size} 인 random 숫자 생성
const selectedThemes = selectRandomThemes(themes, randomIntBetween(5, themes.length));
for (const theme of selectedThemes) {
for (const date of dates) {
for (const time of theme.times) {
tasks.push({
storeId,
accessToken,
date,
time: time.startFrom,
themeId: theme.id,
});
}
}
}
}
console.log(`총 생성할 스케줄 수(iterations): ${tasks.length}`);
return {tasks};
}
export default function (data) {
// 👈 3. 현재 반복 횟수가 준비된 작업 수를 초과하는지 확인
const taskIndex = exec.scenario.iterationInTest;
if (taskIndex >= data.tasks.length) {
// 첫 번째로 이 조건에 도달한 VU가 테스트를 종료
if (taskIndex === data.tasks.length) {
console.log('모든 스케쥴 생성 완료. 테스트 종료');
exec.test.abort();
}
return;
}
const task = data.tasks[taskIndex];
if (!task) {
console.log(`[VU ${__VU}] 알 수 없는 오류: task가 없습니다. (index: ${taskIndex})`);
return;
}
createSchedule(task.storeId, task.accessToken, task);
}
function createSchedule(storeId, accessToken, schedule) {
const payload = JSON.stringify({
date: schedule.date,
time: schedule.time,
themeId: schedule.themeId,
});
const params = getHeaders(accessToken, "/admin/stores/${id}/schedules")
const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params);
const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201});
if (!success) {
console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`);
}
return success;
}
function generateDates(days) {
const dates = [];
const today = new Date();
for (let i = 1; i <= days; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
dates.push(date.toISOString().split('T')[0]);
}
return dates;
}
function selectRandomThemes(themes, count) {
const shuffled = [...themes].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, themes.length));
}
export function teardown(data) {
if (data.tasks) {
console.log(`\n=== 테스트 완료: 총 ${data.tasks.length}개의 스케줄 생성을 시도했습니다. ===`);
} else {
console.log('\n=== 테스트 완료: setup 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ===');
}
}

View File

@ -0,0 +1,33 @@
import {fetchUsers, login} from "./common.js";
import {randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
export const options = {
scenarios: {
login: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 100 },
{ duration: '1m', target: 200 },
{ duration: '1m', target: 300 },
{ duration: '1m', target: 300 },
{ duration: '1m', target: 400 },
{ duration: '1m', target: 500 },
{ duration: '2m', target: 0 },
]
}
}
}
export function setup() {
const users = fetchUsers()
console.log(`${users.length}명의 회원 준비`)
return { users }
}
export default function (data) {
const user = randomItem(data.users)
const token = login(user.account, user.password, 'USER').accessToken
}