Compare commits

...

15 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
8215492eea [#54] 애플리케이션 배포 (#55)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 애플리케이션 배포
- 1차 배포에서 각 Service의 Trace가 구분이 되지 않아 XxxService 클래스에 \@Observation을 적용하는 AOP 추가
- 불필요하게 느껴지는 Prometheus Actuator 요청과 스케쥴링 작업 Tracing 제외
- 애플리케이션이 UTC로 배포됨에 따라 발생하는 문제 해결을 위해 LocalDateTime, OffsetDateTime -> Instant 타입 변경 및 LocalDate, LocalTime은 KST로 비교하도록 수정
- 기존 로그의 가독성이 좋지 않아, 로그 메시지가 가장 먼저 보이도록 형식 수정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 실제 웹에 접속하여 전체적인 기능 점검
- 예약 처리 로직에서 미숙한 부분이 발견되어 다음 작업은 예약 처리 로직 개선 예정

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

Reviewed-on: #55
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-06 02:42:13 +00:00
186d6e118c [#52] 만료 예약 / 일정 스케쥴링 작업 추가 및 동시성 처리를 위한 일부 코드 수정 (#53)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 예약 페이지에서 일정 조회시, 현 시간 이후부터 조회되도록 수정
- 사용자의 schedule 수정은 \@LastModified JPA Auditing이 적용되지 않도록 UPDATE 쿼리를 바로 전송하도록 수정
- 매 1분마다 Pending 예약이 되지 않은 일정, 결제가 되지 않은 Pending 예약 만료 처리 스케쥴링 작업 추가
- 스케쥴링 작업으로 인해 발생할 수 있는 'Pending 예약은 생성했으나 해당 일정이 재활성화' 되는 문제 해결을 위해 schedule 조회에 pessimistic lock 적용

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- LocalTime.plusHours()로 인해 특정 시간대 이후로는 실패하는 테스트 수정
- Pessimistic Lock 적용 후 해당 문제 상황 동시성 테스트 추가
- 하나의 일정에 대한 동시 HOLD 요청 상황 테스트 추가

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

Reviewed-on: #53
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-04 08:40:37 +00:00
d056e12278 [#50] Tosspay API Mocking 서버 구현 (#51)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 입력된 paymentKey 등을 담아, 랜덤한 결제 정보를 반환하도록 구현
- 전체 중 50%는 간편결제 + 카드, 25%는 카드, 20%는 간편결제(선불 충전액), 나머지 5%는 계좌이체 결과를 반환
- micrometer 적용으로 Main Service와의 Tracing 처리
- 해당 서비스는 GCP에 배포 예정

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 전체 기능 테스트 완료

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

Reviewed-on: #51
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-10-02 01:13:07 +00:00
97c3e1598c [#48] Tosspay mocking 서버 구현을 위한 멀티모듈 전환 (#49)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

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

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- Tosspay mocking 서버를 해당 프로젝트 내 구현할 때 각 서비스간 구분이 수월하도록 모듈 분리
- 분리하는 과정에서 추후 공통적으로 활용될 수 있다고 판단한 기능들은 common 모듈로 분리
- 일부 테스트 보완

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
<img width="612" alt="스크린샷 2025-09-29 22.05.25.png" src="attachments/d401a48a-3dd5-4ced-9315-b9d8aff16bf9">

- 전체 테스트 후 클래스 커버리지 기준 100% 달성 확인

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

Reviewed-on: #49
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-30 00:39:13 +00:00
352 changed files with 10625 additions and 5738 deletions

6
.gitignore vendored
View File

@ -36,4 +36,8 @@ out/
### VS Code ###
.vscode/
logs
.kotlin
.kotlin
### sql
data/*.sql
data/*.txt

View File

@ -1,10 +1,9 @@
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
FROM amazoncorretto:17
WORKDIR /app
COPY service/build/libs/service.jar app.jar
EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

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

View File

@ -1,99 +1,58 @@
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0"
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
kotlin("kapt") version kotlinVersion
id("io.spring.dependency-management") version "1.1.7" apply false
id("org.springframework.boot") version springBootVersion apply false
kotlin("jvm") version kotlinVersion apply false
kotlin("kapt") version kotlinVersion apply false
kotlin("plugin.spring") version kotlinVersion apply false
kotlin("plugin.jpa") version kotlinVersion apply false
}
group = "com.sangdol"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
allprojects {
repositories {
mavenCentral()
}
}
tasks.jar {
enabled = false
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.kapt")
apply(plugin = "io.spring.dependency-management")
kapt {
keepJavacAnnotationProcessors = true
}
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
dependencies {
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
tasks.withType<Test> {
useJUnitPlatform()
}
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}

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

@ -0,0 +1,11 @@
dependencies {
api("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.20.0")
implementation(project(":common:utils"))
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -0,0 +1,9 @@
package com.sangdol.common.log.constant
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
SUCCEED,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}

View File

@ -1,4 +1,4 @@
package roomescape.common.log
package com.sangdol.common.log.message
import ch.qos.logback.classic.pattern.MessageConverter
import ch.qos.logback.classic.spi.ILoggingEvent
@ -7,13 +7,14 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TextNode
import roomescape.common.config.JacksonConfig
private const val MASK: String = "****"
private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone")
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
abstract class AbstractLogMaskingConverter(
val sensitiveKeys: Set<String>,
val objectMapper: ObjectMapper
) : MessageConverter() {
val mask: String = "****"
class RoomescapeLogMaskingConverter : MessageConverter() {
override fun convert(event: ILoggingEvent): String {
val message: String = event.formattedMessage
@ -35,13 +36,13 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
.toString()
private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)")
val keys: String = sensitiveKeys.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*[:=]\\s*)([^(,|\"|?)]+)")
return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1]
val delimiter = matchResult.groupValues[2]
val maskedValue = maskValue(matchResult.groupValues[3])
val maskedValue = maskValue(matchResult.groupValues[3].trim())
"${key}${delimiter}${maskedValue}"
}
@ -51,7 +52,7 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
node?.forEachEntry { key, childNode ->
when {
childNode.isValueNode -> {
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, maskValue(childNode.asText()))
if (key in sensitiveKeys) (node as ObjectNode).put(key, maskValue(childNode.asText()))
}
childNode.isObject -> maskRecursive(childNode)
@ -72,10 +73,6 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
}
private fun maskValue(value: String): String {
return if (value.length <= 2) {
MASK
} else {
"${value.first()}$MASK${value.last()}"
}
return "${value.first()}$mask${value.last()}"
}
}

View File

@ -1,4 +1,4 @@
package roomescape.common.log
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.ExecutionInfo
import net.ttddyy.dsproxy.QueryInfo

View File

@ -0,0 +1,20 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import javax.sql.DataSource
object SlowQueryDataSourceFactory {
fun create(dataSource: DataSource, loggerName: String, logLevel: String, thresholdMs: Long): DataSource {
val mdcAwareListener = MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(logLevel.uppercase()),
thresholdMs = thresholdMs
)
return ProxyDataSourceBuilder.create(dataSource)
.name(loggerName)
.listener(mdcAwareListener)
.buildProxy()
}
}

View File

@ -0,0 +1,57 @@
package com.sangdol.common.log
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
class TestLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("account", "address"),
objectMapper = jacksonObjectMapper()
)
class AbstractLogMaskingConverterTest : FunSpec({
val converter = TestLogMaskingConverter()
val event: ILoggingEvent = mockk()
val account = "sangdol@example.com"
val address = "서울특별시 강북구 수유1동 123-456"
context("sensitiveKeys=${converter.sensitiveKeys}에 있는 항목은 가린다.") {
context("평문 로그를 처리할 때, 여러 key / value가 있는 경우 서로 간의 구분자는 trim 처리한다.") {
listOf(":", "=", " : ", " = ").forEach { keyValueDelimiter ->
listOf(",", ", ").forEach { valueDelimiter ->
test("key1${keyValueDelimiter}value1${valueDelimiter}key2${keyValueDelimiter}value2 형식을 처리한다.") {
every {
event.formattedMessage
} returns "account$keyValueDelimiter$account${valueDelimiter}address$keyValueDelimiter$address"
assertSoftly(converter.convert(event)) {
this shouldBe "account${keyValueDelimiter}${account.first()}${converter.mask}${account.last()}${valueDelimiter}address${keyValueDelimiter}${address.first()}${converter.mask}${address.last()}"
}
}
}
}
}
context("JSON 로그") {
test("정상 처리") {
val json = "{\"request_body\":{\"account\":\"%s\",\"address\":\"%s\"}}"
every {
event.formattedMessage
} returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format(
"${account.first()}${converter.mask}${account.last()}",
"${address.first()}${converter.mask}${address.last()}"
)
}
}
}
})

View File

@ -1,5 +1,7 @@
package roomescape.common.log
package com.sangdol.common.log
import com.sangdol.common.log.sql.SlowQueryPredicate
import com.sangdol.common.log.sql.SqlLogFormatter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
@ -18,9 +20,9 @@ class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
assertSoftly(slowQueryPredicate) {
it.test(slowQueryThreshold) shouldBe true
it.test(slowQueryThreshold + 1) shouldBe true
it.test(slowQueryThreshold - 1) shouldBe false
this.test(slowQueryThreshold) shouldBe true
this.test(slowQueryThreshold + 1) shouldBe true
this.test(slowQueryThreshold - 1) shouldBe false
}
}
})

View File

@ -0,0 +1,29 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-data-jpa")
api("com.github.f4b6a3:tsid-creator:5.2.6")
implementation(project(":common:utils"))
implementation(project(":common:types"))
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,14 +1,14 @@
package roomescape.common.entity
package com.sangdol.common.persistence
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import kotlin.jvm.Transient
import java.time.Instant
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
@ -17,7 +17,7 @@ abstract class AuditingBaseEntity(
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: LocalDateTime
lateinit var createdAt: Instant
@Column(updatable = false)
@CreatedBy
@ -25,29 +25,9 @@ abstract class AuditingBaseEntity(
@Column
@LastModifiedDate
lateinit var updatedAt: LocalDateTime
lateinit var updatedAt: Instant
@Column
@LastModifiedBy
var updatedBy: Long = 0L
}
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -0,0 +1,13 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
interface IDGenerator {
fun create(): Long
}
class TsidIDGenerator(
private val tsidFactory: TsidFactory
) : IDGenerator {
override fun create(): Long = tsidFactory.create().toLong()
}

View File

@ -0,0 +1,25 @@
package com.sangdol.common.persistence
import jakarta.persistence.*
import org.springframework.data.domain.Persistable
import kotlin.jvm.Transient
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -0,0 +1,44 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
import com.sangdol.common.utils.MdcPrincipalIdUtil
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.transaction.PlatformTransactionManager
import java.util.*
@Configuration
@EnableJpaAuditing
class PersistenceConfig {
@Value("\${POD_NAME:app-0}")
private lateinit var podName: String
@Bean
fun auditorAware(): AuditorAware<Long> = MdcAuditorAware()
@Bean
@Primary
fun idGenerator(): IDGenerator {
val node = podName.substringAfterLast("-").toInt()
val tsidFactory = TsidFactory.builder().withNode(node).build()
return TsidIDGenerator(tsidFactory)
}
@Bean
fun transactionExecutionUtil(
transactionManager: PlatformTransactionManager
): TransactionExecutionUtil {
return TransactionExecutionUtil(transactionManager)
}
}
class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<Long> = MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty()
}

View File

@ -1,31 +1,19 @@
package roomescape.common.util
package com.sangdol.common.persistence
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionTemplate
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.RoomescapeException
private val log: KLogger = KotlinLogging.logger {}
@Component
class TransactionExecutionUtil(
private val transactionManager: PlatformTransactionManager
) {
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T): T {
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T?): T? {
val transactionTemplate = TransactionTemplate(transactionManager).apply {
this.isReadOnly = isReadOnly
this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
return transactionTemplate.execute { action() }
?: run {
log.error { "[TransactionExecutionUtil.withNewTransaction] 트랜잭션 작업 중 예상치 못한 null 반환 " }
throw RoomescapeException(CommonErrorCode.UNEXPECTED_SERVER_ERROR)
}
}
}

View File

@ -0,0 +1,53 @@
package com.sangdol.common.persistence
import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.shouldBeBefore
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import java.time.LocalDateTime
@SpringBootTest
class BaseEntityTest(
private val testPersistableBaseEntityRepository: TestPersistableBaseEntityRepository,
private val testAuditingBaseEntityRepository: TestAuditingBaseEntityRepository,
private val idGenerator: IDGenerator
) : FunSpec({
context("TestPersistableBaseEntityRepository") {
test("PK를 지정하여 INSERT 쿼리를 한번만 전송한다.") {
val entity = TestPersistableBaseEntity(idGenerator.create(), "hello").also {
testPersistableBaseEntityRepository.saveAndFlush(it)
}
testPersistableBaseEntityRepository.findById(entity.id).also {
it.shouldNotBeNull()
it.get() shouldBeEqualUsingFields entity
}
}
}
context("TestAuditingBaseEntityRepository") {
test("Entity 저장 후 Audit 정보를 확인한다.") {
val id = idGenerator.create()
.also {
MdcPrincipalIdUtil.set(it.toString())
}.also {
testAuditingBaseEntityRepository.saveAndFlush(TestAuditingBaseEntity(it, "hello"))
}
testAuditingBaseEntityRepository.findById(id).also {
it.shouldNotBeNull()
assertSoftly(it.get()) {
this.createdAt shouldBeBefore LocalDateTime.now()
this.updatedAt shouldBeBefore LocalDateTime.now()
this.createdBy shouldBe id
this.updatedBy shouldBe id
}
}
}
}
})

View File

@ -0,0 +1,6 @@
package com.sangdol.common.persistence
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
class TestApplication

View File

@ -0,0 +1,12 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestAuditingBaseEntity(
id: Long,
val name: String
) : AuditingBaseEntity(id)
interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>

View File

@ -0,0 +1,12 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestPersistableBaseEntity(
id: Long,
val name: String
) : PersistableBaseEntity(id)
interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>

View File

@ -0,0 +1,71 @@
package com.sangdol.common.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.DefaultTransactionDefinition
class TransactionExecutionUtilTest() : FunSpec() {
private val transactionManager = mockk<PlatformTransactionManager>(relaxed = true)
private val transactionExecutionUtil = TransactionExecutionUtil(transactionManager)
init {
context("withNewTransaction") {
beforeTest {
clearMocks(transactionManager)
}
val body = TestPersistableBaseEntity(123458192L, "hello")
test("지정한 action이 성공하면, 해당 값을 반환하고 트랜잭션을 커밋한다.") {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
body
}.also {
it.shouldNotBeNull()
it shouldBeEqualUsingFields body
verify { transactionManager.commit(any()) }
verify(exactly = 0) { transactionManager.rollback(any()) }
}
}
test("지정한 action 실행 도중 예외가 발생하면, 예외를 던지고 트랜잭션을 롤백한다.") {
assertThrows<RuntimeException> {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
throw RuntimeException()
}
}.also {
verify { transactionManager.rollback(any()) }
verify(exactly = 0) { transactionManager.commit(any()) }
}
}
test("isReadOnly=true 지정시 읽기 전용 트랜잭션으로 실행한다.") {
val transactionStatus = mockk<TransactionStatus>(relaxed = true)
val transactionDefinitionSlot = slot<TransactionDefinition>()
every {
transactionManager.getTransaction(capture(transactionDefinitionSlot))
} returns transactionStatus
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
"hello"
}.also {
assertSoftly(transactionDefinitionSlot.captured) {
this.isReadOnly shouldBe true
this.propagationBehavior shouldBe DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW
}
verify { transactionManager.commit(any()) }
}
}
}
}
}

View File

@ -0,0 +1,18 @@
spring:
jpa:
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
username: sa
password:

View File

@ -0,0 +1,3 @@
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,6 +1,6 @@
package roomescape.common.exception
package com.sangdol.common.types.exception
import org.springframework.http.HttpStatus
import com.sangdol.common.types.web.HttpStatus
enum class CommonErrorCode(
override val httpStatus: HttpStatus,

View File

@ -1,6 +1,6 @@
package roomescape.common.exception
package com.sangdol.common.types.exception
import org.springframework.http.HttpStatus
import com.sangdol.common.types.web.HttpStatus
interface ErrorCode {
val httpStatus: HttpStatus

View File

@ -1,6 +1,6 @@
package roomescape.common.exception
package com.sangdol.common.types.exception
open class RoomescapeException(
open val errorCode: ErrorCode,
override val message: String = errorCode.message
) : RuntimeException(message)
) : RuntimeException(message)

View File

@ -1,9 +1,7 @@
package roomescape.common.dto.response
package com.sangdol.common.types.web
import com.fasterxml.jackson.annotation.JsonInclude
import roomescape.common.exception.ErrorCode
import com.sangdol.common.types.exception.ErrorCode
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CommonApiResponse<T>(
val data: T? = null,
)

View File

@ -0,0 +1,24 @@
package com.sangdol.common.types.web
enum class HttpStatus(
val code: Int
) {
OK(200),
CREATED(201),
NO_CONTENT(204),
BAD_REQUEST(400),
UNAUTHORIZED(401),
FORBIDDEN(403),
NOT_FOUND(404),
CONFLICT(409),
INTERNAL_SERVER_ERROR(500)
;
fun isClientError(): Boolean {
return code in 400..<500
}
fun value(): Int {
return code
}
}

View File

@ -0,0 +1,7 @@
dependencies {
implementation("org.slf4j:slf4j-api:2.0.17")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -0,0 +1,21 @@
package com.sangdol.common.utils
import java.time.*
import java.time.temporal.ChronoUnit
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
object KoreaDate {
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
}
object KoreaTime {
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
}
object KoreaDateTime {
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
}
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()

View File

@ -1,11 +1,10 @@
package roomescape.common.util
package com.sangdol.common.utils
import org.slf4j.MDC
import java.util.*
private const val MDC_PRINCIPAL_ID_KEY = "principal_id"
object MdcPrincipalId {
object MdcPrincipalIdUtil {
const val MDC_PRINCIPAL_ID_KEY = "principal_id"
fun extractAsLongOrNull(): Long? {
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()

View File

@ -0,0 +1,25 @@
package com.sangdol.common.utils
import org.slf4j.MDC
object MdcStartTimeUtil {
const val MDC_START_TIME_KEY = "start_time"
fun extractDurationMsOrNull(): Long? {
return extractOrNull()?.let { System.currentTimeMillis() - it }
}
fun setCurrentTime() {
extractOrNull() ?: run {
MDC.put(MDC_START_TIME_KEY, System.currentTimeMillis().toString())
}
}
fun clear() {
MDC.remove(MDC_START_TIME_KEY)
}
private fun extractOrNull(): Long? {
return MDC.get(MDC_START_TIME_KEY)?.toLong()
}
}

View File

@ -0,0 +1,45 @@
package com.sangdol.common.utils
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.*
class KoreaDateTimeExtensionsTest : FunSpec({
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
assertSoftly(KoreaTime.now()) {
val utcNow = LocalTime.now(ZoneId.of("UTC"))
this.hour shouldBe utcNow.hour.plus(9)
this.minute shouldBe utcNow.minute
this.second shouldBe 0
this.nano shouldBe 0
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.now()) {
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.nowWithOffset()) {
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
.withSecond(0).withNano(0)
}
}
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
val now = Instant.now()
val kstConverted = now.toKoreaDateTime()
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
}
})

View File

@ -0,0 +1,28 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.util.*
class MdcPrincipalIdUtilTest : StringSpec({
val id = 1872847943L
"값을 설정한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.of(id)
}
}
"값을 제거한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
}
MdcPrincipalIdUtil.clear().also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe null
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.empty()
}
}
})

View File

@ -0,0 +1,34 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class MdcStartTimeUtilTest : FunSpec({
test("기존에 등록된 startTime 값을 기준으로 duration_ms를 구한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("기존에 등록된 startTime 값이 없으면 duration_ms는 null이다.") {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
test("현재 시간을 등록한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("등록된 시간을 지운다.") {
MdcStartTimeUtil.setCurrentTime().also {
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
}
MdcStartTimeUtil.clear().also {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
}
})

View File

@ -0,0 +1,28 @@
import org.gradle.kotlin.dsl.named
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-aop")
api("com.fasterxml.jackson.module:jackson-module-kotlin")
api(project(":common:log"))
api(project(":common:utils"))
api(project(":common:types"))
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,5 +1,7 @@
package roomescape.common.log
package com.sangdol.common.web.asepct
import com.sangdol.common.log.constant.LogType
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
@ -9,7 +11,6 @@ import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
import org.aspectj.lang.reflect.MethodSignature
import org.slf4j.MDC
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
@ -21,19 +22,17 @@ private val log: KLogger = KotlinLogging.logger {}
@Aspect
class ControllerLoggingAspect(
private val messageConverter: ApiLogMessageConverter,
private val messageConverter: WebLogMessageConverter,
) {
@Pointcut("execution(* roomescape..web..*Controller*.*(..))")
@Pointcut("execution(* com.sangdol..web..*Controller*.*(..))")
fun allController() {
}
@Around("allController()")
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
val servletRequest: HttpServletRequest = servletRequest()
val controllerPayload: Map<String, Any> = parseControllerPayload(joinPoint)
log.info {
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
@ -41,29 +40,22 @@ class ControllerLoggingAspect(
try {
return joinPoint.proceed()
.also { logSuccess(servletRequest.getEndpoint(), startTime, it) }
.also { logSuccess(servletRequest, it as ResponseEntity<*>) }
} catch (e: Exception) {
throw e
}
}
private fun logSuccess(endpoint: String, startTime: Long, result: Any) {
val responseEntity = result as ResponseEntity<*>
var convertResponseMessageRequest = ConvertResponseMessageRequest(
type = LogType.CONTROLLER_SUCCESS,
endpoint = endpoint,
httpStatus = responseEntity.statusCode.value(),
startTime = startTime,
private fun logSuccess(servletRequest: HttpServletRequest, result: ResponseEntity<*>) {
val body: Any? = if (log.isDebugEnabled()) result.body else null
val logMessage = messageConverter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = result.statusCode.value(),
responseBody = body,
)
if (log.isDebugEnabled()) {
convertResponseMessageRequest = convertResponseMessageRequest.copy(
body = responseEntity.body
)
}
val logMessage = messageConverter.convertToResponseMessage(convertResponseMessageRequest)
log.info { logMessage }
}
@ -71,14 +63,16 @@ class ControllerLoggingAspect(
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
}
private fun parsePayload(joinPoint: JoinPoint): Map<String, Any> {
private fun parseControllerPayload(joinPoint: JoinPoint): Map<String, Any> {
val signature = joinPoint.signature as MethodSignature
val args = joinPoint.args
val payload = mutableMapOf<String, Any>()
payload["controller_method"] = joinPoint.signature.toShortString()
val payload = mutableMapOf<String, Any>(
"controller_method" to joinPoint.signature.toShortString()
)
val requestParams: MutableMap<String, Any> = mutableMapOf()
val pathVariables: MutableMap<String, Any> = mutableMapOf()
signature.method.parameters.forEachIndexed { index, parameter ->
val arg = args[index]
@ -93,9 +87,10 @@ class ControllerLoggingAspect(
parameter.getAnnotation(RequestParam::class.java)?.let {
requestParams[parameter.name] = arg
}
}.also {
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
}
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
return payload
}

View File

@ -0,0 +1,26 @@
package com.sangdol.common.web.asepct
import io.micrometer.observation.Observation
import io.micrometer.observation.ObservationRegistry
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
@Aspect
class ServiceObservationAspect(
private val observationRegistry: ObservationRegistry
) {
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
fun allServices() {
}
@Around("allServices()")
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
val methodName: String = joinPoint.signature.toShortString()
return Observation.createNotStarted(methodName, observationRegistry)
.observe<Any?> { joinPoint.proceed() }
}
}

View File

@ -0,0 +1,50 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
companion object {
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm")
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
LocalDate::class.java,
LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addDeserializer(
LocalDate::class.java,
LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addSerializer(
LocalTime::class.java,
LocalTimeSerializer(LOCAL_TIME_FORMATTER)
)
.addDeserializer(
LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule
}

View File

@ -0,0 +1,41 @@
package com.sangdol.common.web.config
import com.sangdol.common.web.asepct.ServiceObservationAspect
import io.micrometer.observation.ObservationPredicate
import io.micrometer.observation.ObservationRegistry
import io.micrometer.observation.aop.ObservedAspect
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.server.observation.ServerRequestObservationContext
@Configuration
class ObservationConfig(
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
) {
@Bean
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
return ObservedAspect(observationRegistry)
}
@Bean
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
return ServiceObservationAspect(observationRegistry)
}
@Bean
fun excludeActuatorPredicate(): ObservationPredicate {
return ObservationPredicate { _, context ->
if (context !is ServerRequestObservationContext) {
return@ObservationPredicate true
}
val servletRequest: HttpServletRequest = context.carrier
val requestUri = servletRequest.requestURI
!requestUri.contains(actuatorPath)
}
}
}

View File

@ -0,0 +1,40 @@
package com.sangdol.common.web.config
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
import org.springframework.context.annotation.DependsOn
import org.springframework.core.Ordered
import org.springframework.web.filter.OncePerRequestFilter
@Configuration
class WebLoggingConfig {
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean(
webLogMessageConverter: WebLogMessageConverter,
currentTraceContext: CurrentTraceContext
): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
}
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun apiLoggingAspect(webLogMessageConverter: WebLogMessageConverter): ControllerLoggingAspect {
return ControllerLoggingAspect(webLogMessageConverter)
}
@Bean
fun webLogMessageConverter(objectMapper: ObjectMapper): WebLogMessageConverter {
return WebLogMessageConverter(objectMapper)
}
}

View File

@ -1,27 +1,25 @@
package roomescape.common.exception
package com.sangdol.common.web.exception
import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.CommonErrorResponse
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.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import roomescape.auth.exception.AuthException
import roomescape.common.dto.response.CommonErrorResponse
import roomescape.common.log.ApiLogMessageConverter
import roomescape.common.log.ConvertResponseMessageRequest
import roomescape.common.log.LogType
import roomescape.common.log.getEndpoint
private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice
class ExceptionControllerAdvice(
private val messageConverter: ApiLogMessageConverter
class GlobalExceptionHandler(
private val messageConverter: WebLogMessageConverter
) {
@ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomException(
@ -32,17 +30,10 @@ class ExceptionControllerAdvice(
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
logException(
type = type,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus)
.status(httpStatus.value())
.body(errorResponse)
}
@ -51,29 +42,24 @@ class ExceptionControllerAdvice(
servletRequest: HttpServletRequest,
e: Exception
): ResponseEntity<CommonErrorResponse> {
val message: String = if (e is MethodArgumentNotValidException) {
if (e is MethodArgumentNotValidException) {
e.bindingResult.allErrors
.mapNotNull { it.defaultMessage }
.joinToString(", ")
} else {
e.message!!
}.also {
log.warn { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $it" }
}
log.debug { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $message" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
logException(
type = LogType.APPLICATION_FAILURE,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus)
.status(httpStatus.value())
.body(errorResponse)
}
@ -88,40 +74,26 @@ class ExceptionControllerAdvice(
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
logException(
type = LogType.UNHANDLED_EXCEPTION,
servletRequest = servletRequest,
httpStatus = httpStatus.value(),
errorResponse = errorResponse,
exception = e
)
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus)
.status(httpStatus.value())
.body(errorResponse)
}
private fun logException(
type: LogType,
private fun convertExceptionLogMessage(
servletRequest: HttpServletRequest,
httpStatus: Int,
httpStatus: HttpStatus,
errorResponse: CommonErrorResponse,
exception: Exception
) {
val commonRequest = ConvertResponseMessageRequest(
type = type,
endpoint = servletRequest.getEndpoint(),
): String {
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
return messageConverter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = httpStatus,
startTime = MDC.get("startTime")?.toLongOrNull(),
body = errorResponse,
responseBody = errorResponse,
exception = actualException
)
val logMessage = if (errorResponse.message == exception.message) {
messageConverter.convertToResponseMessage(commonRequest)
} else {
messageConverter.convertToResponseMessage(commonRequest.copy(exception = exception))
}
log.warn { logMessage }
}
}

View File

@ -1,20 +1,23 @@
package roomescape.common.log
package com.sangdol.common.web.servlet
import com.sangdol.common.utils.MdcPrincipalIdUtil
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
import org.slf4j.MDC
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {}
class HttpRequestLoggingFilter(
private val messageConverter: ApiLogMessageConverter
private val messageConverter: WebLogMessageConverter,
private val currentTraceContext: CurrentTraceContext
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
@ -26,15 +29,17 @@ class HttpRequestLoggingFilter(
val cachedRequest = ContentCachingRequestWrapper(request)
val cachedResponse = ContentCachingResponseWrapper(response)
val startTime = System.currentTimeMillis()
MDC.put("startTime", startTime.toString())
MdcStartTimeUtil.setCurrentTime()
try {
filterChain.doFilter(cachedRequest, cachedResponse)
cachedResponse.copyBodyToResponse()
} catch (e: Exception) {
throw e
} finally {
MDC.remove("startTime")
MdcPrincipalId.clear()
MdcStartTimeUtil.clear()
MdcPrincipalIdUtil.clear()
currentTraceContext.maybeScope(null)
}
}
}

View File

@ -0,0 +1,75 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilder(
private val type: LogType,
private val servletRequest: HttpServletRequest,
private val payload: MutableMap<String, Any> = mutableMapOf("type" to type)
) {
fun endpoint(): LogPayloadBuilder {
payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}"
return this
}
fun clientIp(): LogPayloadBuilder {
servletRequest.remoteAddr?.let { payload["client_ip"] = it }
return this
}
fun userAgent(): LogPayloadBuilder {
servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it }
return this
}
fun queryString(): LogPayloadBuilder {
servletRequest.queryString?.let { payload["query_params"] = it }
return this
}
fun httpStatus(statusCode: Int?): LogPayloadBuilder {
statusCode?.let { payload["status_code"] = it }
return this
}
fun responseBody(body: Any?): LogPayloadBuilder {
body?.let { payload["response_body"] = it }
return this
}
fun durationMs(): LogPayloadBuilder {
MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it }
return this
}
fun principalId(): LogPayloadBuilder {
MdcPrincipalIdUtil.extractAsLongOrNull()
?.let { payload["principal_id"] = it }
?: run { payload["principal_id"] = "UNKNOWN" }
return this
}
fun exception(exception: Exception?): LogPayloadBuilder {
exception?.let {
payload["exception"] = mapOf(
"class" to it.javaClass.simpleName,
"message" to it.message
)
}
return this
}
fun additionalPayloads(payload: Map<String, Any>): LogPayloadBuilder {
this.payload.putAll(payload)
return this
}
fun build(): Map<String, Any> = payload
}

View File

@ -0,0 +1,68 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter(
private val objectMapper: ObjectMapper
) {
fun convertToHttpRequestMessage(servletRequest: HttpServletRequest): String {
val payload = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.queryString()
.clientIp()
.userAgent()
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToControllerInvokedMessage(
servletRequest: HttpServletRequest,
controllerPayload: Map<String, Any>
): String {
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint()
.principalId()
.additionalPayloads(controllerPayload)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToResponseMessage(
type: LogType,
servletRequest: HttpServletRequest,
httpStatusCode: Int,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val payload = LogPayloadBuilder(type = type, servletRequest = servletRequest)
.endpoint()
.httpStatus(httpStatusCode)
.durationMs()
.principalId()
.responseBody(responseBody)
.exception(exception)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToErrorResponseMessage(
servletRequest: HttpServletRequest,
httpStatus: HttpStatus,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val type = if (httpStatus.isClientError()) {
LogType.APPLICATION_FAILURE
} else {
LogType.UNHANDLED_EXCEPTION
}
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
}
}

View File

@ -1,4 +1,4 @@
package roomescape.common.config
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.exc.InvalidFormatException
@ -6,11 +6,12 @@ import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.time.*
import java.time.LocalDate
import java.time.LocalTime
class JacksonConfigTest(
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
) : FunSpec({
class JacksonConfigTest : FunSpec({
val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
context("날짜는 yyyy-mm-dd 형식이다.") {
val date = "2025-07-14"
@ -51,38 +52,4 @@ class JacksonConfigTest(
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed"
}
}
context("Long 타입은 문자열로 (역)직렬화된다.") {
val number = 1234567890L
val serialized: String = objectMapper.writeValueAsString(number)
val deserialized: Long = objectMapper.readValue(serialized, Long::class.java)
test("Long 직렬화") {
serialized shouldBe "$number"
}
test("Long 역직렬화") {
deserialized shouldBe number
}
}
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
val date = LocalDate.of(2025, 7, 14)
val time = LocalTime.of(12, 30, 0)
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("OffsetDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("LocalDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
})

View File

@ -0,0 +1,233 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilderTest : FunSpec({
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("endpoint") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "$method $requestUri"
}
test("ServletRequest에서 null이 반환되면 그대로 들어간다.") {
every { servletRequest.method } returns null
every { servletRequest.requestURI } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "null null"
}
}
context("clientIp") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe remoteAddr
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.remoteAddr } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe null
}
}
context("userAgent") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe userAgent
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.getHeader("User-Agent") } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe null
}
}
context("queryString") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe queryString
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.queryString } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe null
}
}
context("httpStatus") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(200)
.build()
result["status_code"] shouldBe 200
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(null)
.build()
result["status_code"] shouldBe null
}
}
context("responseBody") {
test("정상 응답") {
val body = mapOf("key" to "value")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(body)
.build()
result["response_body"] shouldBe body
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(null)
.build()
result["response_body"] shouldBe null
}
}
context("durationMs") {
test("정상 응답") {
MdcStartTimeUtil.setCurrentTime()
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"].shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"] shouldBe null
}
}
context("principalId") {
test("정상 응답") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe principalId
MdcPrincipalIdUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe "UNKNOWN"
}
}
context("exception") {
test("정상 응답") {
val exception = RuntimeException("hello")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(exception)
.build()
result["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(null)
.build()
result["exception"] shouldBe null
}
}
context("additionalPayloads") {
test("정상 응답") {
val payload = mapOf(
"key1" to "value1",
"key2" to "value2"
)
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.additionalPayloads(payload)
.build()
result["key1"] shouldBe "value1"
result["key2"] shouldBe "value2"
}
}
})

View File

@ -0,0 +1,194 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverterTest : FunSpec({
val objectMapper = jacksonObjectMapper()
val converter = WebLogMessageConverter(objectMapper)
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("Http 요청 메시지를 변환한다.") {
test("정상 응답") {
val result = converter.convertToHttpRequestMessage(servletRequest)
result shouldBe """
{"type":"${LogType.INCOMING_HTTP_REQUEST.name}","endpoint":"$method $requestUri","query_params":"$queryString","client_ip":"$remoteAddr","user_agent":"$userAgent"}
""".trimIndent()
}
}
context("Controller 요청 메시지를 변환한다") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
test("정상 응답") {
val controllerPayload: Map<String, Any> = mapOf(
"controller_method" to "ThemeController.findThemeById(..)",
"path_variable" to mapOf("id" to "7599801744469560667")
)
val result = converter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
result shouldBe """
{"type":"${LogType.CONTROLLER_INVOKED.name}","endpoint":"$method $requestUri","principal_id":$principalId,"controller_method":"${controllerPayload["controller_method"]}","path_variable":{"id":"${7599801744469560667}"}}
""".trimIndent()
}
}
context("응답 메시지를 변환한다.") {
val principalId = 7599801744469560666
val body = mapOf(
"id" to 7599801744469560667,
"name" to "sangdol"
)
val exception = RuntimeException("hello")
beforeTest {
MdcPrincipalIdUtil.set(principalId.toString())
MdcStartTimeUtil.setCurrentTime()
}
afterTest {
MdcPrincipalIdUtil.clear()
MdcStartTimeUtil.clear()
}
test("응답 본문을 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe null
}
}
test("예외를 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외 + 응답 본문을 모두 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body,
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외, 응답 본문 모두 제외한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe null
}
}
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.BAD_REQUEST,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
}
}
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
}
}
}
})

View File

@ -0,0 +1,14 @@
services:
mysql-local:
image: mysql:8.4
container_name: mysql-local
restart: always
ports:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local
TZ: UTC
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

@ -1,6 +1,18 @@
node_modules
.git
.DS_Store
.gitignore
# Node.js
node_modules
npm-debug.log
# Build output
build
dist
build
# Editor/OS specific
.vscode
.idea
.DS_Store
# Environment variables
.env*

View File

@ -1,18 +1,17 @@
# Stage 1: Build the React app
FROM node:24 AS builder
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --frozen-lockfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# Stage 2: Serve with Nginx
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

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

@ -2,7 +2,6 @@ export interface PaymentConfirmRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType;
}
export interface PaymentCancelRequest {

View File

@ -1,5 +1,3 @@
import type { Difficulty } from '@_api/theme/themeTypes';
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = {
@ -40,16 +38,35 @@ export interface AdminScheduleSummaryListResponse {
}
// Public
export interface ScheduleResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
status: ScheduleStatus;
}
export interface ScheduleThemeInfo {
id: string;
name: string;
}
export interface ScheduleStoreInfo {
id: string;
name: string;
}
export interface ScheduleWithStoreAndThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo,
store: ScheduleStoreInfo,
}
export interface ScheduleWithThemeResponse {
id: string,
startFrom: string,
endAt: string,
themeId: string,
themeName: string,
themeDifficulty: Difficulty,
status: ScheduleStatus
schedule: ScheduleResponse,
theme: ScheduleThemeInfo
}
export interface ScheduleWithThemeListResponse {
schedules: ScheduleWithThemeResponse[];
}
}

View File

@ -1,28 +0,0 @@
import React from 'react';
import {Navigate, useLocation} from 'react-router-dom';
import {useAuth} from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>; // Or a proper spinner component
}
if (!loggedIn) {
// Not logged in, redirect to login page. No alert needed here
// as the user is simply redirected.
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role !== 'ADMIN') {
// Logged in but not an admin, show alert and redirect.
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
return <Navigate to="/" replace />;
}
return children;
};
export default AdminRoute;

View File

@ -1,17 +1,17 @@
import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
import {getStores} from '@_api/store/storeAPI';
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
import {fetchThemeById} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import { type ReservationData } from '@_api/reservation/reservationTypes';
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import { getStores } from '@_api/store/storeAPI';
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
import { fetchThemeById } from '@_api/theme/themeAPI';
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css';
import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {type ReservationData} from '@_api/reservation/reservationTypes';
import {formatDate} from 'src/util/DateTimeFormatter';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@ -76,7 +76,7 @@ const ReservationStep1Page: React.FC = () => {
fetchSchedules(selectedStore.id, dateStr)
.then(res => {
const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.themeName;
const key = schedule.theme.name;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
@ -111,11 +111,11 @@ const ReservationStep1Page: React.FC = () => {
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id)
holdSchedule(selectedSchedule.schedule.id)
.then(() => {
fetchThemeById(selectedSchedule.themeId).then(res => {
fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = {
scheduleId: selectedSchedule.id,
scheduleId: selectedSchedule.schedule.id,
store: {
id: selectedStore!.id,
name: selectedStore!.name,
@ -128,8 +128,8 @@ const ReservationStep1Page: React.FC = () => {
maxParticipants: res.maxParticipants,
},
date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.startFrom,
endAt: selectedSchedule.endAt,
startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.schedule.endAt,
};
navigate('/reservation/form', {state: reservationData});
}).catch(handleError);
@ -248,23 +248,23 @@ const ReservationStep1Page: React.FC = () => {
<h3>3. </h3>
<div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
<div key={themeName} className="theme-schedule-group">
<div className="theme-header">
<h4>{themeName}</h4>
<button onClick={() => openThemeModal(schedules[0].themeId)}
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button">
</button>
</div>
<div className="time-slots">
{schedules.map(schedule => (
{scheduleAndTheme.map(schedule => (
<div
key={schedule.id}
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{`${schedule.startFrom} ~ ${schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.status)}</span>
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div>
))}
</div>
@ -313,8 +313,8 @@ const ReservationStep1Page: React.FC = () => {
<div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.themeName}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
<p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div>
<div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>

View File

@ -1,8 +1,9 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { confirmPayment } from '@_api/payment/paymentAPI';
import { type PaymentConfirmRequest, PaymentType } from '@_api/payment/PaymentTypes';
import { confirm } from '@_api/order/orderAPI';
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';
import type { AxiosError } from 'axios';
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
@ -21,17 +22,6 @@ const ReservationStep2Page: React.FC = () => {
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
if (!reservationId) {
alert('잘못된 접근입니다.');
@ -66,7 +56,7 @@ const ReservationStep2Page: React.FC = () => {
const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
@ -77,13 +67,8 @@ const ReservationStep2Page: React.FC = () => {
paymentKey: data.paymentKey,
orderId: data.orderId,
amount: totalPrice,
paymentType: data.paymentType || PaymentType.NORMAL,
};
confirmPayment(reservationId, paymentData)
.then(() => {
return confirmReservation(reservationId);
})
confirm(reservationId, paymentData)
.then(() => {
alert('결제가 완료되었어요!');
navigate('/reservation/success', {
@ -97,10 +82,50 @@ const ReservationStep2Page: React.FC = () => {
}
});
})
.catch(handleError);
.catch(err => {
const error = err as AxiosError<OrderErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;
if (errorCode === 'B000') {
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
navigate('/reservation');
return;
}
const trial = error.response?.data?.trial || 0;
if (trial < 2) {
alert(errorMessage);
return;
}
alert(errorMessage);
setTimeout(() => {
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
if (agreeToOnsitePayment) {
confirmReservation(reservationId)
.then(() => {
navigate('/reservation/success', {
state: {
storeName,
themeName,
date,
time,
participantCount,
totalPrice,
},
});
});
} else {
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
navigate('/');
}
}, 100);
});
}).catch((error: any) => {
console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다.");
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
});
};

View File

@ -10,10 +10,11 @@ import {
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
import {getStores} from '@_api/store/storeAPI';
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
import {fetchActiveThemes, fetchThemeById} from '@_api/theme/themeAPI';
import {fetchActiveThemes} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-schedule-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
@ -53,8 +54,8 @@ const AdminSchedulePage: React.FC = () => {
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedThemeDetails, setSelectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails, setIsLoadingThemeDetails] = useState<boolean>(false);
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
@ -332,10 +333,10 @@ const AdminSchedulePage: React.FC = () => {
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.createdAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
</p>
<p>
<strong>:</strong> {new Date(detailedSchedules[schedule.id].audit!.updatedAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
</p>
<p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})

View File

@ -1,17 +1,18 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import type { SidoResponse, SigunguResponse } from '@_api/region/regionTypes';
import { createStore, deleteStore, getStoreDetail, getStores, updateStore } from '@_api/store/storeAPI';
import {isLoginRequiredError} from '@_api/apiClient';
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
import type {SidoResponse, SigunguResponse} from '@_api/region/regionTypes';
import {createStore, deleteStore, getStoreDetail, getStores, updateStore} from '@_api/store/storeAPI';
import {
type SimpleStoreResponse,
type StoreDetailResponse,
type StoreRegisterRequest,
type UpdateStoreRequest
} from '@_api/store/storeTypes';
import { useAdminAuth } from '@_context/AdminAuthContext';
import {useAdminAuth} from '@_context/AdminAuthContext';
import '@_css/admin-store-page.css';
import React, { Fragment, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
const AdminStorePage: React.FC = () => {
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
@ -297,10 +298,10 @@ const AdminStorePage: React.FC = () => {
:</strong> {detailedStores[store.id].region.code}
</p>
<p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.createdAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.createdAt)}
</p>
<p>
<strong>:</strong> {new Date(detailedStores[store.id].audit.updatedAt).toLocaleString()}
<strong>:</strong> {formatDisplayDateTime(detailedStores[store.id].audit.updatedAt)}
</p>
<p>
<strong>:</strong> {detailedStores[store.id].audit.createdBy.name}({detailedStores[store.id].audit.createdBy.id})

View File

@ -9,7 +9,8 @@ import {
import React, {useEffect, useState} from 'react';
import {useLocation, useNavigate, useParams} from 'react-router-dom';
import '@_css/admin-theme-edit-page.css';
import type { AuditInfo } from '@_api/common/commonTypes';
import type {AuditInfo} from '@_api/common/commonTypes';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
interface ThemeFormData {
name: string;
@ -256,8 +257,8 @@ const AdminThemeEditPage: React.FC = () => {
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p><strong>:</strong> {new Date(auditInfo.createdAt).toLocaleString()}</p>
<p><strong>:</strong> {new Date(auditInfo.updatedAt).toLocaleString()}</p>
<p><strong>:</strong> {formatDisplayDateTime(auditInfo.createdAt)}</p>
<p><strong>:</strong> {formatDisplayDateTime(auditInfo.updatedAt)}</p>
<p><strong>:</strong> {auditInfo.createdBy.name}</p>
<p><strong>:</strong> {auditInfo.updatedBy.name}</p>
</div>

View File

@ -19,7 +19,6 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,

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 = ?;
```

57
service/build.gradle.kts Normal file
View File

@ -0,0 +1,57 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
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")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
// etc
implementation("org.apache.poi:poi-ooxml:5.2.3")
// submodules
implementation(project(":common:persistence"))
implementation(project(":common:web"))
}
tasks.named<Jar>("jar") {
enabled = false
}

View File

@ -0,0 +1,26 @@
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"]
)
class RoomescapeApplication
fun main(args: Array<String>) {
System.setProperty("user.timezone", "UTC")
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
val springApplication = SpringApplication(RoomescapeApplication::class.java)
springApplication.setBannerMode(Banner.Mode.OFF)
springApplication.run()
}

View File

@ -0,0 +1,49 @@
package com.sangdol.roomescape.admin.business
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.mapper.toCredentials
import com.sangdol.roomescape.admin.exception.AdminErrorCode
import com.sangdol.roomescape.admin.exception.AdminException
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.common.types.Auditor
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
private val log: KLogger = KotlinLogging.logger {}
@Service
class AdminService(
private val adminRepository: AdminRepository,
) {
@Transactional(readOnly = true)
fun findCredentialsByAccount(account: String): AdminLoginCredentials {
log.debug { "[findCredentialsByAccount] 관리자 조회 시작: account=${account}" }
return adminRepository.findByAccount(account)
?.let {
log.info { "[findCredentialsByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" }
it.toCredentials()
}
?: run {
log.debug { "[findCredentialsByAccount] 관리자 조회 실패: account=${account}" }
throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND)
}
}
@Transactional(readOnly = true)
fun findOperatorOrUnknown(id: Long): Auditor {
log.debug { "[findOperatorById] 작업자 정보 조회 시작: id=${id}" }
return adminRepository.findByIdOrNull(id)?.let { admin ->
Auditor(admin.id, admin.name).also {
log.info { "[findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" }
}
} ?: run {
log.warn { "[findOperatorById] 작업자 정보 조회 실패. id=${id}" }
Auditor.UNKNOWN
}
}
}

View File

@ -0,0 +1,29 @@
package com.sangdol.roomescape.admin.dto
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.auth.dto.LoginCredentials
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
data class AdminLoginCredentials(
override val id: Long,
override val password: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
val permissionLevel: AdminPermissionLevel,
) : LoginCredentials() {
override fun toResponse(accessToken: String) = AdminLoginSuccessResponse(
accessToken = accessToken,
name = name,
type = type,
storeId = storeId
)
}
data class AdminLoginSuccessResponse(
override val accessToken: String,
override val name: String,
val type: AdminType,
val storeId: Long?,
) : LoginSuccessResponse()

View File

@ -1,8 +1,8 @@
package roomescape.admin.exception
package com.sangdol.roomescape.admin.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.HttpStatus
class AdminException(
override val errorCode: AdminErrorCode,

View File

@ -1,8 +1,8 @@
package roomescape.admin.infrastructure.persistence
package com.sangdol.roomescape.admin.infrastructure.persistence
import com.sangdol.common.persistence.AuditingBaseEntity
import jakarta.persistence.*
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import roomescape.common.entity.AuditingBaseEntity
@Entity
@Table(name = "admin")

View File

@ -1,4 +1,4 @@
package roomescape.admin.infrastructure.persistence
package com.sangdol.roomescape.admin.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository

View File

@ -0,0 +1,13 @@
package com.sangdol.roomescape.admin.mapper
import com.sangdol.roomescape.admin.dto.AdminLoginCredentials
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity
fun AdminEntity.toCredentials() = AdminLoginCredentials(
id = this.id,
password = this.password,
name = this.name,
type = this.type,
storeId = this.storeId,
permissionLevel = this.permissionLevel
)

View File

@ -1,19 +1,20 @@
package roomescape.auth.business
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
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
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.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
import roomescape.admin.business.AdminService
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.LoginContext
import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginSuccessResponse
import roomescape.common.dto.LoginCredentials
import roomescape.common.dto.PrincipalType
import roomescape.user.business.UserService
private val log: KLogger = KotlinLogging.logger {}
@ -25,39 +26,40 @@ 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 { "[AuthService.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 { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" }
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 { "[AuthService.login] 로그인 실패: account = ${request.account}" }
throw e
}
is AuthException -> { throw e }
else -> {
log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" }
log.warn { "[login] 로그인 실패: message=${e.message} account = ${request.account}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)
}
}
@ -69,7 +71,7 @@ class AuthService(
credentials: LoginCredentials
) {
if (credentials.password != request.password) {
log.info { "[AuthService.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

@ -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

@ -0,0 +1,5 @@
package com.sangdol.roomescape.auth.business.domain
enum class PrincipalType {
USER, ADMIN
}

View File

@ -1,5 +1,11 @@
package roomescape.auth.docs
package com.sangdol.roomescape.auth.docs
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.Public
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
@ -8,12 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginSuccessResponse
import roomescape.auth.web.support.Public
import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
interface AuthAPI {

View File

@ -0,0 +1,27 @@
package com.sangdol.roomescape.auth.dto
import com.sangdol.roomescape.auth.business.domain.PrincipalType
data class LoginContext(
val ipAddress: String,
val userAgent: String,
)
data class LoginRequest(
val account: String,
val password: String,
val principalType: PrincipalType
)
abstract class LoginSuccessResponse {
abstract val accessToken: String
abstract val name: String
}
abstract class LoginCredentials {
abstract val id: Long
abstract val password: String
abstract val name: String
abstract fun toResponse(accessToken: String): LoginSuccessResponse
}

View File

@ -1,7 +1,7 @@
package roomescape.auth.exception
package com.sangdol.roomescape.auth.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.web.HttpStatus
enum class AuthErrorCode(
override val httpStatus: HttpStatus,

View File

@ -1,6 +1,6 @@
package roomescape.auth.exception
package com.sangdol.roomescape.auth.exception
import roomescape.common.exception.RoomescapeException
import com.sangdol.common.types.exception.RoomescapeException
class AuthException(
override val errorCode: AuthErrorCode,

View File

@ -1,5 +1,7 @@
package roomescape.auth.infrastructure.jwt
package com.sangdol.roomescape.auth.infrastructure.jwt
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.jsonwebtoken.Claims
@ -8,8 +10,6 @@ import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import java.util.*
import javax.crypto.SecretKey
@ -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

@ -1,11 +1,11 @@
package roomescape.auth.infrastructure.persistence
package com.sangdol.roomescape.auth.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import com.sangdol.roomescape.auth.business.domain.PrincipalType
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import roomescape.common.dto.PrincipalType
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.Instant
@Entity
@Table(name = "login_history")
@ -24,5 +24,5 @@ class LoginHistoryEntity(
@Column(updatable = false)
@CreatedDate
var createdAt: LocalDateTime? = null,
var createdAt: Instant? = null,
) : PersistableBaseEntity(id)

View File

@ -1,4 +1,4 @@
package roomescape.auth.infrastructure.persistence
package com.sangdol.roomescape.auth.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository

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,16 +1,19 @@
package roomescape.auth.web
package com.sangdol.roomescape.auth.web
import com.sangdol.common.types.web.CommonApiResponse
import com.sangdol.roomescape.auth.business.AuthService
import com.sangdol.roomescape.auth.docs.AuthAPI
import com.sangdol.roomescape.auth.dto.LoginContext
import com.sangdol.roomescape.auth.dto.LoginRequest
import com.sangdol.roomescape.auth.dto.LoginSuccessResponse
import com.sangdol.roomescape.auth.web.support.User
import com.sangdol.roomescape.common.types.CurrentUserContext
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.business.AuthService
import roomescape.auth.docs.AuthAPI
import roomescape.auth.web.support.User
import roomescape.common.dto.CurrentUserContext
import roomescape.common.dto.response.CommonApiResponse
@RestController
@RequestMapping("/auth")
@ -36,3 +39,8 @@ class AuthController(
return ResponseEntity.ok().build()
}
}
fun HttpServletRequest.toLoginContext() = LoginContext(
ipAddress = this.remoteAddr,
userAgent = this.getHeader("User-Agent")
)

View File

@ -1,7 +1,7 @@
package roomescape.auth.web.support
package com.sangdol.roomescape.auth.web.support
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)

View File

@ -1,4 +1,4 @@
package roomescape.auth.web.support
package com.sangdol.roomescape.auth.web.support
import jakarta.servlet.http.HttpServletRequest

View File

@ -1,5 +1,16 @@
package roomescape.auth.web.support.interceptors
package com.sangdol.roomescape.auth.web.support.interceptors
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.admin.infrastructure.persistence.Privilege
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import com.sangdol.roomescape.auth.business.CLAIM_PERMISSION_KEY
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.AdminOnly
import com.sangdol.roomescape.auth.web.support.accessToken
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -7,17 +18,6 @@ import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
import roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import roomescape.admin.infrastructure.persistence.AdminType
import roomescape.admin.infrastructure.persistence.Privilege
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.business.CLAIM_PERMISSION_KEY
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.AdminOnly
import roomescape.auth.web.support.accessToken
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {}
@ -38,7 +38,7 @@ class AdminInterceptor(
try {
run {
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
val type: AdminType = validateTypeAndGet(token, annotation.type)
val permission: AdminPermissionLevel = validatePermissionAndGet(token, annotation.privilege)
@ -47,7 +47,10 @@ class AdminInterceptor(
return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
is AuthException -> {
throw e
}
else -> {
log.warn { "[AdminInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,12 @@
package roomescape.auth.web.support.interceptors
package com.sangdol.roomescape.auth.web.support.interceptors
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
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.UserOnly
import com.sangdol.roomescape.auth.web.support.accessToken
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -7,13 +14,6 @@ import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
import roomescape.auth.business.CLAIM_ADMIN_TYPE_KEY
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.UserOnly
import roomescape.auth.web.support.accessToken
import roomescape.common.util.MdcPrincipalId
private val log: KLogger = KotlinLogging.logger {}
@ -33,7 +33,7 @@ class UserInterceptor(
val token: String? = request.accessToken()
try {
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalId.set(it) }
val id: String = jwtUtils.extractSubject(token).also { MdcPrincipalIdUtil.set(it) }
/**
* CLAIM_ADMIN_TYPE_KEY 존재하면 관리자 토큰임
@ -47,7 +47,10 @@ class UserInterceptor(
return true
} catch (e: Exception) {
when (e) {
is AuthException -> { throw e }
is AuthException -> {
throw e
}
else -> {
log.warn { "[UserInterceptor] 예상치 못한 예외: message=${e.message}" }
throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR)

View File

@ -1,5 +1,12 @@
package roomescape.auth.web.support.resolver
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.common.types.CurrentUserContext
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -9,19 +16,12 @@ import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtUtils
import roomescape.auth.web.support.User
import roomescape.auth.web.support.accessToken
import roomescape.user.business.UserService
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

@ -0,0 +1,33 @@
package com.sangdol.roomescape.common.config
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PreDestroy
import jakarta.transaction.Transactional
import org.springframework.context.annotation.Profile
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {}
@Component
@Profile("!deploy & local")
class LocalDatabaseCleaner(
private val jdbcTemplate: JdbcTemplate
) {
@PreDestroy
@Transactional
fun clearAll() {
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" }
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
jdbcTemplate.query("SHOW TABLES") { rs, _ ->
rs.getString(1).lowercase()
}.forEach {
jdbcTemplate.execute("TRUNCATE TABLE $it")
}
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1")
log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" }
}
}

View File

@ -0,0 +1,9 @@
package com.sangdol.roomescape.common.config
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import com.sangdol.common.web.config.JacksonConfig
class RoomescapeLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("password", "accessToken", "phone"),
objectMapper = JacksonConfig().objectMapper()
)

View File

@ -1,8 +1,7 @@
package roomescape.common.log
package com.sangdol.roomescape.common.config
import com.sangdol.common.log.sql.SlowQueryDataSourceFactory
import com.zaxxer.hikari.HikariDataSource
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
@ -10,11 +9,9 @@ import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Profile
import javax.sql.DataSource
@Configuration
@Profile("deploy")
@EnableConfigurationProperties(SlowQueryProperties::class)
class ProxyDataSourceConfig {
@ -23,15 +20,12 @@ class ProxyDataSourceConfig {
fun dataSource(
@Qualifier("actualDataSource") actualDataSource: DataSource,
properties: SlowQueryProperties
): DataSource = ProxyDataSourceBuilder.create(actualDataSource)
.name(properties.loggerName)
.listener(
MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(properties.logLevel.uppercase()),
thresholdMs = properties.thresholdMs
)
)
.buildProxy()
): DataSource = SlowQueryDataSourceFactory.create(
dataSource = actualDataSource,
loggerName = properties.loggerName,
logLevel = properties.logLevel,
thresholdMs = properties.thresholdMs
)
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari")
@ -40,7 +34,6 @@ class ProxyDataSourceConfig {
.build()
}
@Profile("deploy")
@ConfigurationProperties(prefix = "slow-query")
data class SlowQueryProperties(
val loggerName: String,

View File

@ -0,0 +1,14 @@
package com.sangdol.roomescape.common.config
import io.swagger.v3.oas.models.OpenAPI
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SwaggerConfig {
@Bean
fun openAPI(): OpenAPI {
return OpenAPI()
}
}

View File

@ -0,0 +1,20 @@
package com.sangdol.roomescape.common.config
import io.micrometer.observation.ObservationPredicate
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class TraceConfig {
companion object {
val scheduleTaskName = "tasks.scheduled.execution"
}
@Bean
fun excludeSchedulerPredicate(): ObservationPredicate {
return ObservationPredicate { name, context ->
!name.equals(scheduleTaskName)
}
}
}

View File

@ -1,12 +1,12 @@
package roomescape.common.config
package com.sangdol.roomescape.common.config
import com.sangdol.roomescape.auth.web.support.interceptors.AdminInterceptor
import com.sangdol.roomescape.auth.web.support.interceptors.UserInterceptor
import com.sangdol.roomescape.auth.web.support.resolver.UserContextResolver
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import roomescape.auth.web.support.interceptors.AdminInterceptor
import roomescape.auth.web.support.interceptors.UserInterceptor
import roomescape.auth.web.support.resolver.UserContextResolver
@Configuration
class WebMvcConfig(

View File

@ -0,0 +1,19 @@
package com.sangdol.roomescape.common.types
import java.time.Instant
data class Auditor(
val id: Long,
val name: String,
) {
companion object {
val UNKNOWN = Auditor(0, "Unknown")
}
}
data class AuditingInfo(
val createdAt: Instant,
val createdBy: Auditor,
val updatedAt: Instant,
val updatedBy: Auditor,
)

View File

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

View File

@ -0,0 +1,69 @@
package com.sangdol.roomescape.order.business
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.payment.business.PaymentService
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
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 reservationService: ReservationService,
private val scheduleService: ScheduleService,
private val paymentService: PaymentService,
private val transactionExecutionUtil: TransactionExecutionUtil,
private val orderValidator: OrderValidator,
private val eventPublisher: ApplicationEventPublisher
) {
fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) {
val paymentKey = paymentConfirmRequest.paymentKey
log.debug { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" }
try {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
validateCanConfirm(reservationId)
reservationService.markInProgress(reservationId)
}
paymentService.requestConfirm(reservationId, paymentConfirmRequest)
eventPublisher.publishEvent(ReservationConfirmEvent(reservationId))
log.info { "[confirm] 결제 처리 및 예약 확정 이벤트 발행 완료: reservationId=${reservationId}, paymentKey=${paymentKey}" }
} catch (e: Exception) {
val errorCode: ErrorCode = if (e is RoomescapeException) {
e.errorCode
} else {
OrderErrorCode.ORDER_UNEXPECTED_ERROR
}
throw OrderException(errorCode, e.message ?: errorCode.message)
}
}
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)
} catch (e: OrderException) {
val errorCode = OrderErrorCode.NOT_CONFIRMABLE
throw OrderException(errorCode, e.message)
}
}
}

Some files were not shown because too many files have changed in this diff Show More