Initialize projecet
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./gradlew compileKotlin:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/gradlew text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.jar binary
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
HELP.md
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Kotlin ###
|
||||||
|
.kotlin
|
||||||
52
build.gradle.kts
Normal file
52
build.gradle.kts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.2.21"
|
||||||
|
kotlin("plugin.spring") version "2.2.21"
|
||||||
|
id("org.springframework.boot") version "4.0.1"
|
||||||
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.myoauniverse.engineering"
|
||||||
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
description = "Demo project for Spring Boot"
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
compileOnly {
|
||||||
|
extendsFrom(configurations.annotationProcessor.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-mongodb")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
|
implementation("tools.jackson.module:jackson-module-kotlin")
|
||||||
|
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
|
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-data-mongodb-test")
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-mongodb-test")
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
228
docs/20260121_195722_mongodb_nanosecond_precision_poc.md
Normal file
228
docs/20260121_195722_mongodb_nanosecond_precision_poc.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# MongoDB Nanosecond Precision POC
|
||||||
|
|
||||||
|
> 작성일: 2026-01-21
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
MongoDB BSON Date는 millisecond 정밀도만 지원하므로, nanosecond 정밀도를 보존하기 위한 POC 구현.
|
||||||
|
|
||||||
|
## 최종 저장 형식
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// MongoDB Document
|
||||||
|
{
|
||||||
|
"_id": "...",
|
||||||
|
"eventId": "event-001",
|
||||||
|
"userId": "user-001",
|
||||||
|
"participatedAt": ISODate("2024-01-21T06:30:45.123Z"), // BSON Date (ms)
|
||||||
|
"nanoAdjustment": 456789, // Int (0-999999)
|
||||||
|
"metadata": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `participatedAt` | BSON Date | 밀리초 정밀도, UTC 저장 |
|
||||||
|
| `nanoAdjustment` | Int | 0-999999 (서브밀리초 나노) |
|
||||||
|
|
||||||
|
## 설계 결정 사항
|
||||||
|
|
||||||
|
### 1. Offset 저장 안함
|
||||||
|
- Client가 i18n parameter (country code)를 요청 시 전달
|
||||||
|
- Server에서 응답 시 해당 timezone으로 변환하여 반환
|
||||||
|
- 별도 offset 저장 불필요
|
||||||
|
|
||||||
|
### 2. BSON Date 사용 이유
|
||||||
|
- Native MongoDB 타입 → Shell 쿼리 용이 (`ISODate()`)
|
||||||
|
- 효율적인 인덱싱 및 범위 쿼리
|
||||||
|
- Custom Converter 불필요 (Zero overhead)
|
||||||
|
|
||||||
|
### 3. 나노초 분리 저장
|
||||||
|
- BSON Date는 ms까지만 지원
|
||||||
|
- `nanoAdjustment` 필드로 sub-ms 나노초 보존
|
||||||
|
- 복원: `Instant.ofEpochMilli(ms).plusNanos(nanoAdjustment)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요청 시간 캡처 아키텍처
|
||||||
|
|
||||||
|
### 문제
|
||||||
|
- Controller나 Service에서 `Instant.now()` 사용 시 실제 클라이언트 클릭 시간과 차이 발생
|
||||||
|
- 클레임 발생 가능성
|
||||||
|
|
||||||
|
### 해결: 계층별 타임스탬프 캡처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client Click │
|
||||||
|
│ │ │
|
||||||
|
│ ~50-500ms 지연 │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Ingress/LB (Nginx, ALB) │ │
|
||||||
|
│ │ → X-Request-Start-Time: $msec 헤더 추가 ⭐ 가장 정확 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ RequestTimestampFilter (Servlet Filter) │ │
|
||||||
|
│ │ 1. X-Request-Start-Time 헤더 파싱 │ │
|
||||||
|
│ │ 2. 없으면 Instant.now() (차선책) │ │
|
||||||
|
│ │ 3. ThreadLocal + MDC 저장 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Service │ │
|
||||||
|
│ │ → RequestTimestampHolder.get() 으로 시간 조회 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter vs Interceptor
|
||||||
|
|
||||||
|
| 구분 | Filter | Interceptor |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| **실행 시점** | DispatcherServlet **이전** | DispatcherServlet **이후** |
|
||||||
|
| **소속** | Servlet 스펙 | Spring MVC |
|
||||||
|
| **정확도** | ⭐⭐⭐ 더 정확 | ⭐⭐ 덜 정확 |
|
||||||
|
|
||||||
|
**결론: Filter가 더 빠르므로 Filter 사용 권장**
|
||||||
|
|
||||||
|
### Nginx 설정
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Request-Start-Time $msec;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes Ingress
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||||
|
proxy_set_header X-Request-Start-Time $msec;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **POST** | `/api/v1/event/participation` | 이벤트 참여 등록 |
|
||||||
|
| GET | `/api/v1/event/participation/{id}` | 참여 정보 조회 |
|
||||||
|
| GET | `/api/v1/event/participation/event/{eventId}` | 이벤트별 참여 목록 |
|
||||||
|
| GET | `/api/v1/event/participation/user/{userId}` | 사용자별 참여 목록 |
|
||||||
|
| GET | `/api/v1/event/participation/check` | 참여 여부 확인 |
|
||||||
|
| GET | `/api/v1/event/participation/count/{eventId}` | 참여자 수 |
|
||||||
|
|
||||||
|
### POST Request 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/event/participation \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Timezone: Asia/Seoul" \
|
||||||
|
-d '{
|
||||||
|
"eventId": "event-001",
|
||||||
|
"userId": "user-001",
|
||||||
|
"metadata": {
|
||||||
|
"source": "mobile"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response 예시
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "678f...",
|
||||||
|
"eventId": "event-001",
|
||||||
|
"userId": "user-001",
|
||||||
|
"participatedAt": "2024-01-21T15:30:45.123456789+09:00[Asia/Seoul]",
|
||||||
|
"participatedAtEpochMilli": 1705819845123,
|
||||||
|
"nanoAdjustment": 456789,
|
||||||
|
"metadata": { "source": "mobile" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MongoDB Shell 쿼리 예시
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 이벤트별 참여자 조회
|
||||||
|
db.event_participations.find({ eventId: "event-123" })
|
||||||
|
|
||||||
|
// 사용자별 참여 이벤트 조회
|
||||||
|
db.event_participations.find({ userId: "user-456" })
|
||||||
|
|
||||||
|
// 특정 기간 참여자 조회
|
||||||
|
db.event_participations.find({
|
||||||
|
participatedAt: {
|
||||||
|
$gte: ISODate("2024-01-21T00:00:00Z"),
|
||||||
|
$lt: ISODate("2024-01-22T00:00:00Z")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 참여 시간순 정렬
|
||||||
|
db.event_participations.find({ eventId: "event-123" }).sort({ participatedAt: 1 })
|
||||||
|
|
||||||
|
// 이벤트별 참여자 수
|
||||||
|
db.event_participations.countDocuments({ eventId: "event-123" })
|
||||||
|
|
||||||
|
// 전체 나노초 계산 (JavaScript)
|
||||||
|
db.event_participations.find().forEach(function(doc) {
|
||||||
|
var msNanos = (doc.participatedAt.getTime() % 1000) * 1000000;
|
||||||
|
var fullNanos = msNanos + doc.nanoAdjustment;
|
||||||
|
print(doc.userId + ": " + fullNanos + " ns");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/kotlin/com/myoauniverse/lab/mongodb/
|
||||||
|
├── MongodbNanosecApplication.kt
|
||||||
|
├── config/
|
||||||
|
│ └── MongoConfig.kt # MongoDB 설정 + Custom Converters
|
||||||
|
├── domain/
|
||||||
|
│ └── EventParticipation.kt # 도메인 엔티티
|
||||||
|
├── repository/
|
||||||
|
│ └── EventParticipationRepository.kt # Repository
|
||||||
|
├── service/
|
||||||
|
│ └── EventParticipationService.kt # 비즈니스 로직
|
||||||
|
├── filter/
|
||||||
|
│ └── RequestTimestampFilter.kt # 요청 시간 캡처 Filter
|
||||||
|
└── controller/
|
||||||
|
├── EventParticipationController.kt # REST Controller
|
||||||
|
└── dto/
|
||||||
|
└── EventParticipationDto.kt # Request/Response DTO
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MongoDB 실행 필요 (localhost:27017)
|
||||||
|
./gradlew test --tests "NanosecondPrecisionTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 성능 고려사항
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 필드 수 | 2개 (participatedAt + nanoAdjustment) |
|
||||||
|
| 메모리 오버헤드 | ~12 bytes (Date 8 + Int 4) |
|
||||||
|
| Custom Converter | 불필요 (native BSON types) |
|
||||||
|
| 대상 TPS | 10,000 ~ 50,000 |
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "mongodb-nanosec"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
|
import org.springframework.boot.runApplication
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableConfigurationProperties
|
||||||
|
class MongodbNanosecApplication
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
runApplication<MongodbNanosecApplication>(*args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.config
|
||||||
|
|
||||||
|
import com.mongodb.ConnectionString
|
||||||
|
import com.mongodb.MongoClientSettings
|
||||||
|
import com.mongodb.client.MongoClient
|
||||||
|
import com.mongodb.client.MongoClients
|
||||||
|
import com.mongodb.connection.ConnectionPoolSettings
|
||||||
|
import com.mongodb.connection.SocketSettings
|
||||||
|
import com.mongodb.management.JMXConnectionPoolListener
|
||||||
|
import org.bson.types.Decimal128
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.core.convert.converter.Converter
|
||||||
|
import org.springframework.data.convert.ReadingConverter
|
||||||
|
import org.springframework.data.convert.WritingConverter
|
||||||
|
import org.springframework.data.mongodb.MongoDatabaseFactory
|
||||||
|
import org.springframework.data.mongodb.MongoTransactionManager
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate
|
||||||
|
import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory
|
||||||
|
import org.springframework.data.mongodb.core.convert.*
|
||||||
|
import org.springframework.data.mongodb.core.mapping.MongoMappingContext
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MongoDB configuration with custom converters.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Removes _class field from documents (DefaultMongoTypeMapper(null))
|
||||||
|
* - BigDecimal ↔ Decimal128 conversion
|
||||||
|
* - ZonedDateTime ↔ Date conversion (for compatibility)
|
||||||
|
*
|
||||||
|
* Note: For nanosecond precision, use Event entity with nanoAdjustment field
|
||||||
|
* instead of relying on ZonedDateTime converter (which loses nanoseconds).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(MongoDBProperties::class)
|
||||||
|
class MongoConfig(
|
||||||
|
private val mongoDBProperties: MongoDBProperties
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CONNECTION_POOL_MAX_SIZE = 100
|
||||||
|
private const val CONNECTION_POOL_MIN_SIZE = 10
|
||||||
|
private const val CONNECTION_POOL_MAX_LIFE_TIME = 30L
|
||||||
|
private const val CONNECTION_POOL_TIMEOUT = 2000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default timezone for ZonedDateTime conversion.
|
||||||
|
* Used when reading Date from MongoDB back to ZonedDateTime.
|
||||||
|
*/
|
||||||
|
val DEFAULT_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean("mongoTransactionManager")
|
||||||
|
fun mongoTransactionManager(mongoDatabaseFactory: MongoDatabaseFactory): MongoTransactionManager {
|
||||||
|
return MongoTransactionManager(mongoDatabaseFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun mongoTemplate(
|
||||||
|
mongoDatabaseFactory: MongoDatabaseFactory,
|
||||||
|
mongoCustomConversions: MongoCustomConversions,
|
||||||
|
mongoMappingContext: MongoMappingContext
|
||||||
|
): MongoTemplate {
|
||||||
|
val resolver: DbRefResolver = DefaultDbRefResolver(mongoDatabaseFactory)
|
||||||
|
val converter = MappingMongoConverter(resolver, mongoMappingContext)
|
||||||
|
converter.setTypeMapper(DefaultMongoTypeMapper(null))
|
||||||
|
converter.customConversions = mongoCustomConversions
|
||||||
|
converter.afterPropertiesSet()
|
||||||
|
return MongoTemplate(mongoDatabaseFactory, converter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun mongoDatabaseFactory(
|
||||||
|
mongoClient: MongoClient,
|
||||||
|
connectionString: ConnectionString
|
||||||
|
): MongoDatabaseFactory {
|
||||||
|
return SimpleMongoClientDatabaseFactory(mongoClient, connectionString.database!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun mongoClient(connectionString: ConnectionString): MongoClient {
|
||||||
|
val clientSettings = MongoClientSettings.builder()
|
||||||
|
.retryWrites(true)
|
||||||
|
.applyConnectionString(connectionString)
|
||||||
|
.applyToConnectionPoolSettings { builder: ConnectionPoolSettings.Builder ->
|
||||||
|
builder.maxSize(CONNECTION_POOL_MAX_SIZE)
|
||||||
|
.minSize(CONNECTION_POOL_MIN_SIZE)
|
||||||
|
.maxConnectionLifeTime(CONNECTION_POOL_MAX_LIFE_TIME, TimeUnit.MINUTES)
|
||||||
|
.addConnectionPoolListener(JMXConnectionPoolListener())
|
||||||
|
}
|
||||||
|
.applyToSocketSettings { builder: SocketSettings.Builder ->
|
||||||
|
builder.connectTimeout(CONNECTION_POOL_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
return MongoClients.create(clientSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun connectionString(): ConnectionString {
|
||||||
|
return ConnectionString(mongoDBProperties.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Custom Converters ====================
|
||||||
|
@Bean
|
||||||
|
fun mongoCustomConversions(): MongoCustomConversions {
|
||||||
|
val converters = listOf(
|
||||||
|
BigDecimalToDecimal128Converter(),
|
||||||
|
Decimal128ToBigDecimalConverter(),
|
||||||
|
ZonedDateTimeToDateConverter(),
|
||||||
|
DateToZonedDateTimeConverter()
|
||||||
|
)
|
||||||
|
return MongoCustomConversions(converters)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun mappingMongoConverter(
|
||||||
|
mongoDatabaseFactory: MongoDatabaseFactory,
|
||||||
|
mongoCustomConversions: MongoCustomConversions
|
||||||
|
): MappingMongoConverter {
|
||||||
|
val dbRefResolver: DbRefResolver = DefaultDbRefResolver(mongoDatabaseFactory)
|
||||||
|
val mappingContext = MongoMappingContext()
|
||||||
|
mappingContext.setSimpleTypeHolder(mongoCustomConversions.simpleTypeHolder)
|
||||||
|
mappingContext.afterPropertiesSet()
|
||||||
|
|
||||||
|
val converter = MappingMongoConverter(dbRefResolver, mappingContext)
|
||||||
|
converter.setTypeMapper(DefaultMongoTypeMapper(null)) // Remove _class field
|
||||||
|
converter.customConversions = mongoCustomConversions
|
||||||
|
converter.afterPropertiesSet()
|
||||||
|
return converter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BigDecimal → Decimal128 (Writing)
|
||||||
|
*/
|
||||||
|
@WritingConverter
|
||||||
|
class BigDecimalToDecimal128Converter : Converter<BigDecimal, Decimal128> {
|
||||||
|
override fun convert(source: BigDecimal): Decimal128 {
|
||||||
|
return Decimal128(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimal128 → BigDecimal (Reading)
|
||||||
|
*/
|
||||||
|
@ReadingConverter
|
||||||
|
class Decimal128ToBigDecimalConverter : Converter<Decimal128, BigDecimal> {
|
||||||
|
override fun convert(source: Decimal128): BigDecimal {
|
||||||
|
return source.bigDecimalValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZonedDateTime → Date (Writing)
|
||||||
|
*
|
||||||
|
* WARNING: This loses nanosecond precision (only milliseconds preserved).
|
||||||
|
* For nanosecond precision, use Event entity with nanoAdjustment field.
|
||||||
|
*/
|
||||||
|
@WritingConverter
|
||||||
|
class ZonedDateTimeToDateConverter : Converter<ZonedDateTime, Date> {
|
||||||
|
override fun convert(source: ZonedDateTime): Date {
|
||||||
|
return Date.from(source.toInstant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date → ZonedDateTime (Reading)
|
||||||
|
*
|
||||||
|
* Uses DEFAULT_ZONE_ID since BSON Date doesn't store timezone info.
|
||||||
|
* WARNING: Original timezone is lost; uses configured default.
|
||||||
|
*/
|
||||||
|
@ReadingConverter
|
||||||
|
class DateToZonedDateTimeConverter : Converter<Date, ZonedDateTime> {
|
||||||
|
override fun convert(source: Date): ZonedDateTime {
|
||||||
|
return ZonedDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(source.time),
|
||||||
|
DEFAULT_ZONE_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.config
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "event.config.persistence.mongodb")
|
||||||
|
data class MongoDBProperties(
|
||||||
|
var uri: String = "",
|
||||||
|
var autoIndexCreation: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.controller
|
||||||
|
|
||||||
|
import com.myoauniverse.lab.mongodb.controller.dto.*
|
||||||
|
import com.myoauniverse.lab.mongodb.filter.RequestTimestampHolder
|
||||||
|
import com.myoauniverse.lab.mongodb.service.EventParticipationService
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/event/participation")
|
||||||
|
class EventParticipationController(
|
||||||
|
private val eventParticipationService: EventParticipationService
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val isoFormatter = DateTimeFormatter.ISO_INSTANT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/event/participation
|
||||||
|
* 이벤트 참여 등록
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
fun participate(
|
||||||
|
@RequestBody request: EventParticipationRequest,
|
||||||
|
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
|
||||||
|
): ResponseEntity<EventParticipationWithTimingResponse> {
|
||||||
|
// Controller 진입 시간 캡처
|
||||||
|
val controllerTime = Instant.now()
|
||||||
|
val filterTime = RequestTimestampHolder.get()
|
||||||
|
val clientTime = RequestTimestampHolder.getClientTime()
|
||||||
|
|
||||||
|
logger.info {
|
||||||
|
"Controller timing - filterTime: $filterTime, controllerTime: $controllerTime, " +
|
||||||
|
"diff: ${controllerTime.toEpochMilli() - filterTime.toEpochMilli()}ms"
|
||||||
|
}
|
||||||
|
|
||||||
|
val participation = eventParticipationService.participate(
|
||||||
|
eventId = request.eventId,
|
||||||
|
userId = request.userId,
|
||||||
|
metadata = request.metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
val participationResponse = EventParticipationResponse.from(participation, zoneId)
|
||||||
|
|
||||||
|
val timing = TimingInfo(
|
||||||
|
clientRequestTime = clientTime?.let { isoFormatter.format(it) },
|
||||||
|
clientRequestTimeEpochMilli = clientTime?.toEpochMilli(),
|
||||||
|
serverFilterTime = isoFormatter.format(filterTime),
|
||||||
|
serverFilterTimeEpochMilli = filterTime.toEpochMilli(),
|
||||||
|
serverControllerTime = isoFormatter.format(controllerTime),
|
||||||
|
serverControllerTimeEpochMilli = controllerTime.toEpochMilli(),
|
||||||
|
filterToControllerDiffMs = controllerTime.toEpochMilli() - filterTime.toEpochMilli(),
|
||||||
|
clientToFilterDiffMs = clientTime?.let { filterTime.toEpochMilli() - it.toEpochMilli() }
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = EventParticipationWithTimingResponse(participationResponse, timing)
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/event/participation/{id}
|
||||||
|
* 참여 정보 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: String,
|
||||||
|
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
|
||||||
|
): ResponseEntity<EventParticipationResponse> {
|
||||||
|
val participation = eventParticipationService.findById(id)
|
||||||
|
?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
return ResponseEntity.ok(EventParticipationResponse.from(participation, zoneId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/event/participation/event/{eventId}
|
||||||
|
* 이벤트별 참여 목록 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/event/{eventId}")
|
||||||
|
fun getByEventId(
|
||||||
|
@PathVariable eventId: String,
|
||||||
|
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
|
||||||
|
): ResponseEntity<List<EventParticipationResponse>> {
|
||||||
|
val participations = eventParticipationService.findByEventId(eventId)
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
|
||||||
|
val responses = participations.map { EventParticipationResponse.from(it, zoneId) }
|
||||||
|
return ResponseEntity.ok(responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/event/participation/user/{userId}
|
||||||
|
* 사용자별 참여 목록 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/{userId}")
|
||||||
|
fun getByUserId(
|
||||||
|
@PathVariable userId: String,
|
||||||
|
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
|
||||||
|
): ResponseEntity<List<EventParticipationResponse>> {
|
||||||
|
val participations = eventParticipationService.findByUserId(userId)
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
|
||||||
|
val responses = participations.map { EventParticipationResponse.from(it, zoneId) }
|
||||||
|
return ResponseEntity.ok(responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/event/participation/check
|
||||||
|
* 참여 여부 확인
|
||||||
|
*/
|
||||||
|
@GetMapping("/check")
|
||||||
|
fun checkParticipation(
|
||||||
|
@RequestParam eventId: String,
|
||||||
|
@RequestParam userId: String,
|
||||||
|
@RequestHeader("X-Timezone", defaultValue = "Asia/Seoul") timezone: String
|
||||||
|
): ResponseEntity<ParticipationCheckResponse> {
|
||||||
|
val participation = eventParticipationService.findByEventIdAndUserId(eventId, userId)
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
|
||||||
|
val response = ParticipationCheckResponse(
|
||||||
|
eventId = eventId,
|
||||||
|
userId = userId,
|
||||||
|
hasParticipated = participation != null,
|
||||||
|
participation = participation?.let { EventParticipationResponse.from(it, zoneId) }
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/event/participation/count/{eventId}
|
||||||
|
* 이벤트 참여자 수 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/count/{eventId}")
|
||||||
|
fun getParticipationCount(
|
||||||
|
@PathVariable eventId: String
|
||||||
|
): ResponseEntity<ParticipationCountResponse> {
|
||||||
|
val count = eventParticipationService.countByEventId(eventId)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ParticipationCountResponse(eventId, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception handler for duplicate participation
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalStateException::class)
|
||||||
|
fun handleIllegalState(e: IllegalStateException): ResponseEntity<Map<String, String>> {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CONFLICT)
|
||||||
|
.body(mapOf("error" to (e.message ?: "Conflict")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.controller.dto
|
||||||
|
|
||||||
|
import com.myoauniverse.lab.mongodb.domain.EventParticipation
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for event participation.
|
||||||
|
*/
|
||||||
|
data class EventParticipationRequest(
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val metadata: Map<String, Any>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for event participation.
|
||||||
|
* Includes formatted participatedAt with full nanosecond precision.
|
||||||
|
*/
|
||||||
|
data class EventParticipationResponse(
|
||||||
|
val id: String,
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val participatedAt: String, // ISO8601 with nanoseconds
|
||||||
|
val participatedAtEpochMilli: Long,
|
||||||
|
val nanoAdjustment: Int,
|
||||||
|
val metadata: Map<String, Any>?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME
|
||||||
|
|
||||||
|
fun from(
|
||||||
|
participation: EventParticipation,
|
||||||
|
zoneId: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
): EventParticipationResponse {
|
||||||
|
val zonedDateTime = participation.toZonedDateTime(zoneId)
|
||||||
|
|
||||||
|
return EventParticipationResponse(
|
||||||
|
id = participation.id!!,
|
||||||
|
eventId = participation.eventId,
|
||||||
|
userId = participation.userId,
|
||||||
|
participatedAt = zonedDateTime.format(formatter),
|
||||||
|
participatedAtEpochMilli = participation.participatedAt.toEpochMilli(),
|
||||||
|
nanoAdjustment = participation.nanoAdjustment,
|
||||||
|
metadata = participation.metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for participation check.
|
||||||
|
*/
|
||||||
|
data class ParticipationCheckResponse(
|
||||||
|
val eventId: String,
|
||||||
|
val userId: String,
|
||||||
|
val hasParticipated: Boolean,
|
||||||
|
val participation: EventParticipationResponse?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for participation count.
|
||||||
|
*/
|
||||||
|
data class ParticipationCountResponse(
|
||||||
|
val eventId: String,
|
||||||
|
val count: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timing information for debugging/analysis.
|
||||||
|
* Shows the time difference between client, filter, and controller.
|
||||||
|
*/
|
||||||
|
data class TimingInfo(
|
||||||
|
val clientRequestTime: String?, // Client에서 보낸 시간 (ISO8601)
|
||||||
|
val clientRequestTimeEpochMilli: Long?, // Client epoch millis
|
||||||
|
val serverFilterTime: String, // Filter에서 캡처한 시간
|
||||||
|
val serverFilterTimeEpochMilli: Long,
|
||||||
|
val serverControllerTime: String, // Controller에서 캡처한 시간
|
||||||
|
val serverControllerTimeEpochMilli: Long,
|
||||||
|
val filterToControllerDiffMs: Long, // Filter → Controller 차이 (ms)
|
||||||
|
val clientToFilterDiffMs: Long? // Client → Filter 차이 (ms)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO with timing information.
|
||||||
|
*/
|
||||||
|
data class EventParticipationWithTimingResponse(
|
||||||
|
val participation: EventParticipationResponse,
|
||||||
|
val timing: TimingInfo
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.domain
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventParticipation entity with nanosecond precision.
|
||||||
|
*
|
||||||
|
* Storage format in MongoDB:
|
||||||
|
* {
|
||||||
|
* "_id": "...",
|
||||||
|
* "eventId": "event-123",
|
||||||
|
* "userId": "user-456",
|
||||||
|
* "participatedAt": ISODate("2024-01-21T06:30:45.123Z"), // BSON Date (ms)
|
||||||
|
* "nanoAdjustment": 456789, // 0-999999 (sub-ms nanos)
|
||||||
|
* "metadata": { ... }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Note: Instant is used instead of Date for cleaner domain model.
|
||||||
|
* Spring Data MongoDB automatically converts Instant ↔ BSON Date.
|
||||||
|
* However, BSON Date only stores milliseconds, so nanoAdjustment is still needed.
|
||||||
|
*/
|
||||||
|
@Document(collection = "event_participations")
|
||||||
|
data class EventParticipation(
|
||||||
|
@Id
|
||||||
|
val id: String? = null,
|
||||||
|
@Indexed
|
||||||
|
val eventId: String,
|
||||||
|
@Indexed
|
||||||
|
val userId: String,
|
||||||
|
@Indexed
|
||||||
|
val participatedAt: Instant, // Spring Data MongoDB가 자동으로 BSON Date로 변환
|
||||||
|
val nanoAdjustment: Int, // 0-999999 (나노초 중 밀리초 이하 부분 보존)
|
||||||
|
val metadata: Map<String, Any>? = null
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(nanoAdjustment in 0..999_999) {
|
||||||
|
"nanoAdjustment must be between 0 and 999999, was: $nanoAdjustment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs Instant with full nanosecond precision.
|
||||||
|
*
|
||||||
|
* participatedAt에서 ms 정보 + nanoAdjustment에서 sub-ms 정보를 합침
|
||||||
|
*/
|
||||||
|
fun toFullPrecisionInstant(): Instant {
|
||||||
|
// participatedAt은 ms 정밀도로 저장되어 있으므로, nano 부분을 다시 더해줌
|
||||||
|
val truncatedToMillis = Instant.ofEpochMilli(participatedAt.toEpochMilli())
|
||||||
|
return truncatedToMillis.plusNanos(nanoAdjustment.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts to ZonedDateTime with specified zone and full nanosecond precision.
|
||||||
|
*/
|
||||||
|
fun toZonedDateTime(zoneId: ZoneId): ZonedDateTime {
|
||||||
|
return ZonedDateTime.ofInstant(toFullPrecisionInstant(), zoneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns full nanoseconds (0-999999999).
|
||||||
|
*/
|
||||||
|
fun getNano(): Int {
|
||||||
|
val msNanos = (participatedAt.toEpochMilli() % 1000).toInt() * 1_000_000
|
||||||
|
return msNanos + nanoAdjustment
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Creates EventParticipation from Instant with nanosecond precision preserved.
|
||||||
|
*/
|
||||||
|
fun create(
|
||||||
|
eventId: String,
|
||||||
|
userId: String,
|
||||||
|
participatedAt: Instant = Instant.now(),
|
||||||
|
metadata: Map<String, Any>? = null,
|
||||||
|
id: String? = null
|
||||||
|
): EventParticipation {
|
||||||
|
// Instant을 ms로 truncate하고, sub-ms 나노초는 별도 저장
|
||||||
|
val truncatedToMillis = Instant.ofEpochMilli(participatedAt.toEpochMilli())
|
||||||
|
val nanoAdjustment = participatedAt.nano % 1_000_000
|
||||||
|
|
||||||
|
return EventParticipation(
|
||||||
|
id = id,
|
||||||
|
eventId = eventId,
|
||||||
|
userId = userId,
|
||||||
|
participatedAt = truncatedToMillis, // ms 정밀도로 저장
|
||||||
|
nanoAdjustment = nanoAdjustment, // sub-ms 나노초 보존
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates EventParticipation from ZonedDateTime.
|
||||||
|
*/
|
||||||
|
fun create(
|
||||||
|
eventId: String,
|
||||||
|
userId: String,
|
||||||
|
participatedAt: ZonedDateTime,
|
||||||
|
metadata: Map<String, Any>? = null,
|
||||||
|
id: String? = null
|
||||||
|
): EventParticipation = create(eventId, userId, participatedAt.toInstant(), metadata, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.filter
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.servlet.Filter
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.ServletRequest
|
||||||
|
import jakarta.servlet.ServletResponse
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.core.Ordered
|
||||||
|
import org.springframework.core.annotation.Order
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servlet Filter to capture request timestamp at the earliest application level.
|
||||||
|
*
|
||||||
|
* Execution order:
|
||||||
|
* Request → Filter.doFilter() → DispatcherServlet → Interceptor → Controller
|
||||||
|
*
|
||||||
|
* This filter:
|
||||||
|
* 1. Checks for upstream timestamp header (from Ingress/LB)
|
||||||
|
* 2. Falls back to capturing time at filter entry
|
||||||
|
* 3. Stores in both MDC and ThreadLocal for access anywhere
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE) // 가장 먼저 실행
|
||||||
|
class RequestTimestampFilter : Filter {
|
||||||
|
|
||||||
|
override fun doFilter(
|
||||||
|
request: ServletRequest,
|
||||||
|
response: ServletResponse,
|
||||||
|
chain: FilterChain
|
||||||
|
) {
|
||||||
|
val httpRequest = request as HttpServletRequest
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Ingress/LB에서 주입한 헤더 확인 (가장 정확)
|
||||||
|
val upstreamTimestamp = httpRequest.getHeader(HEADER_REQUEST_START_TIME)
|
||||||
|
?: httpRequest.getHeader(HEADER_X_REQUEST_TIMESTAMP)
|
||||||
|
|
||||||
|
// Client에서 보낸 시간 (JavaScript Date.now())
|
||||||
|
val clientTimestamp = httpRequest.getHeader(HEADER_CLIENT_TIMESTAMP)
|
||||||
|
val clientInstant = clientTimestamp?.let { parseClientTimestamp(it) }
|
||||||
|
|
||||||
|
val requestInstant = if (upstreamTimestamp != null) {
|
||||||
|
parseUpstreamTimestamp(upstreamTimestamp)
|
||||||
|
} else {
|
||||||
|
// 2. Filter 진입 시점 캡처 (차선책)
|
||||||
|
Instant.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Filter captured: filterTime=$requestInstant, clientTime=$clientInstant" }
|
||||||
|
|
||||||
|
// 3. ThreadLocal과 MDC에 저장
|
||||||
|
RequestTimestampHolder.set(requestInstant)
|
||||||
|
clientInstant?.let { RequestTimestampHolder.setClientTime(it) }
|
||||||
|
MDC.put(MDC_KEY_REQUEST_TIMESTAMP, requestInstant.toString())
|
||||||
|
MDC.put(MDC_KEY_REQUEST_EPOCH_MILLI, requestInstant.toEpochMilli().toString())
|
||||||
|
MDC.put(MDC_KEY_REQUEST_NANO, requestInstant.nano.toString())
|
||||||
|
|
||||||
|
// Request attribute에도 저장 (JSP 등에서 접근용)
|
||||||
|
request.setAttribute(ATTR_REQUEST_TIMESTAMP, requestInstant)
|
||||||
|
|
||||||
|
chain.doFilter(request, response)
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// 4. 정리
|
||||||
|
RequestTimestampHolder.clear()
|
||||||
|
MDC.remove(MDC_KEY_REQUEST_TIMESTAMP)
|
||||||
|
MDC.remove(MDC_KEY_REQUEST_EPOCH_MILLI)
|
||||||
|
MDC.remove(MDC_KEY_REQUEST_NANO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseUpstreamTimestamp(value: String): Instant {
|
||||||
|
return try {
|
||||||
|
// Nginx $msec 형식: "1705819845.123" (초.밀리초)
|
||||||
|
if (value.contains(".")) {
|
||||||
|
val parts = value.split(".")
|
||||||
|
val seconds = parts[0].toLong()
|
||||||
|
val millis = parts.getOrElse(1) { "0" }.take(3).padEnd(3, '0').toInt()
|
||||||
|
Instant.ofEpochSecond(seconds, millis * 1_000_000L)
|
||||||
|
} else {
|
||||||
|
// epoch millis 형식: "1705819845123"
|
||||||
|
Instant.ofEpochMilli(value.toLong())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 파싱 실패 시 현재 시간
|
||||||
|
Instant.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseClientTimestamp(value: String): Instant? {
|
||||||
|
return try {
|
||||||
|
// JavaScript Date.now() epoch millis 형식
|
||||||
|
Instant.ofEpochMilli(value.toLong())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn { "Failed to parse client timestamp: $value" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Upstream headers (Ingress/LB에서 설정)
|
||||||
|
const val HEADER_REQUEST_START_TIME = "X-Request-Start-Time"
|
||||||
|
const val HEADER_X_REQUEST_TIMESTAMP = "X-Request-Timestamp"
|
||||||
|
|
||||||
|
// Client timestamp header (JavaScript Date.now())
|
||||||
|
const val HEADER_CLIENT_TIMESTAMP = "X-Client-Timestamp"
|
||||||
|
|
||||||
|
// MDC keys
|
||||||
|
const val MDC_KEY_REQUEST_TIMESTAMP = "requestTimestamp"
|
||||||
|
const val MDC_KEY_REQUEST_EPOCH_MILLI = "requestEpochMilli"
|
||||||
|
const val MDC_KEY_REQUEST_NANO = "requestNano"
|
||||||
|
|
||||||
|
// Request attribute
|
||||||
|
const val ATTR_REQUEST_TIMESTAMP = "requestTimestamp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThreadLocal holder for request timestamp.
|
||||||
|
* Use this to access the timestamp from anywhere in the request context.
|
||||||
|
*/
|
||||||
|
object RequestTimestampHolder {
|
||||||
|
private val filterTimeHolder = ThreadLocal<Instant>()
|
||||||
|
private val clientTimeHolder = ThreadLocal<Instant>()
|
||||||
|
|
||||||
|
fun set(instant: Instant) {
|
||||||
|
filterTimeHolder.set(instant)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setClientTime(instant: Instant) {
|
||||||
|
clientTimeHolder.set(instant)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(): Instant {
|
||||||
|
return filterTimeHolder.get() ?: Instant.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getClientTime(): Instant? {
|
||||||
|
return clientTimeHolder.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOrNull(): Instant? {
|
||||||
|
return filterTimeHolder.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
filterTimeHolder.remove()
|
||||||
|
clientTimeHolder.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.repository
|
||||||
|
|
||||||
|
import com.myoauniverse.lab.mongodb.domain.EventParticipation
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for EventParticipation entity.
|
||||||
|
*
|
||||||
|
* MongoDB Shell query examples:
|
||||||
|
* db.event_participations.find({ eventId: "event-123" })
|
||||||
|
* db.event_participations.find({ userId: "user-456" })
|
||||||
|
* db.event_participations.find({ participatedAt: { $gte: ISODate("2024-01-21") } })
|
||||||
|
*/
|
||||||
|
interface EventParticipationRepository : MongoRepository<EventParticipation, String> {
|
||||||
|
|
||||||
|
fun findByEventId(eventId: String): List<EventParticipation>
|
||||||
|
|
||||||
|
fun findByUserId(userId: String): List<EventParticipation>
|
||||||
|
|
||||||
|
fun findByEventIdAndUserId(eventId: String, userId: String): EventParticipation?
|
||||||
|
|
||||||
|
fun findByParticipatedAtBetween(start: Instant, end: Instant): List<EventParticipation>
|
||||||
|
|
||||||
|
fun findByEventIdAndParticipatedAtBetween(
|
||||||
|
eventId: String,
|
||||||
|
start: Instant,
|
||||||
|
end: Instant
|
||||||
|
): List<EventParticipation>
|
||||||
|
|
||||||
|
fun countByEventId(eventId: String): Long
|
||||||
|
|
||||||
|
fun existsByEventIdAndUserId(eventId: String, userId: String): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb.service
|
||||||
|
|
||||||
|
import com.myoauniverse.lab.mongodb.domain.EventParticipation
|
||||||
|
import com.myoauniverse.lab.mongodb.filter.RequestTimestampHolder
|
||||||
|
import com.myoauniverse.lab.mongodb.repository.EventParticipationRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class EventParticipationService(
|
||||||
|
private val eventParticipationRepository: EventParticipationRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 참여 등록
|
||||||
|
*
|
||||||
|
* 참여 시간은 RequestTimestampHolder에서 가져옴:
|
||||||
|
* 1. Ingress/LB에서 주입한 시간 (가장 정확)
|
||||||
|
* 2. Filter 진입 시점 (차선책)
|
||||||
|
*/
|
||||||
|
fun participate(
|
||||||
|
eventId: String,
|
||||||
|
userId: String,
|
||||||
|
metadata: Map<String, Any>? = null
|
||||||
|
): EventParticipation {
|
||||||
|
// 중복 참여 체크
|
||||||
|
if (eventParticipationRepository.existsByEventIdAndUserId(eventId, userId)) {
|
||||||
|
throw IllegalStateException("User $userId already participated in event $eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestTimestampHolder에서 요청 시간 가져오기
|
||||||
|
// (Filter에서 Ingress 헤더 또는 Filter 진입 시점으로 설정됨)
|
||||||
|
val participatedAt = RequestTimestampHolder.get()
|
||||||
|
|
||||||
|
val participation = EventParticipation.create(
|
||||||
|
eventId = eventId,
|
||||||
|
userId = userId,
|
||||||
|
participatedAt = participatedAt,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
return eventParticipationRepository.save(participation)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findById(id: String): EventParticipation? {
|
||||||
|
return eventParticipationRepository.findById(id).orElse(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findByEventId(eventId: String): List<EventParticipation> {
|
||||||
|
return eventParticipationRepository.findByEventId(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findByUserId(userId: String): List<EventParticipation> {
|
||||||
|
return eventParticipationRepository.findByUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findByEventIdAndUserId(eventId: String, userId: String): EventParticipation? {
|
||||||
|
return eventParticipationRepository.findByEventIdAndUserId(eventId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun countByEventId(eventId: String): Long {
|
||||||
|
return eventParticipationRepository.countByEventId(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasParticipated(eventId: String, userId: String): Boolean {
|
||||||
|
return eventParticipationRepository.existsByEventIdAndUserId(eventId, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main/resources/application.yaml
Normal file
10
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: mongodb-nanosec
|
||||||
|
|
||||||
|
event:
|
||||||
|
config:
|
||||||
|
persistence:
|
||||||
|
mongodb:
|
||||||
|
uri: mongodb://fancommerce:fancommerce@localhost:27017/fan_event?authSource=admin
|
||||||
|
auto-index-creation: true
|
||||||
326
src/main/resources/static/index.html
Normal file
326
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Event Participation - Timing Test</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
color: #555;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.timing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.timing-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.timing-item .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.timing-item .value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.timing-item .epoch {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.diff-section {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e8f4fd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.diff-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.diff-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.diff-label {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.diff-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
.response-section {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.response-json {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: #155724;
|
||||||
|
background: #d4edda;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
#result {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#result.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Event Participation - Timing Test</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Participation Form</h2>
|
||||||
|
<form id="participationForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eventId">Event ID</label>
|
||||||
|
<input type="text" id="eventId" name="eventId" value="event-001" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="userId">User ID</label>
|
||||||
|
<input type="text" id="userId" name="userId" value="user-001" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submitBtn">Submit Participation</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result" class="card">
|
||||||
|
<h2>Result</h2>
|
||||||
|
<div id="successMsg" class="success"></div>
|
||||||
|
|
||||||
|
<h3 style="margin-bottom: 10px; color: #555;">Timing Information</h3>
|
||||||
|
<div class="timing-grid">
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="label">Client Request Time</div>
|
||||||
|
<div class="value" id="clientTime">-</div>
|
||||||
|
<div class="epoch" id="clientTimeEpoch">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="label">Server Filter Time</div>
|
||||||
|
<div class="value" id="filterTime">-</div>
|
||||||
|
<div class="epoch" id="filterTimeEpoch">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="label">Server Controller Time</div>
|
||||||
|
<div class="value" id="controllerTime">-</div>
|
||||||
|
<div class="epoch" id="controllerTimeEpoch">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diff-section">
|
||||||
|
<div class="diff-item">
|
||||||
|
<span class="diff-label">Client → Filter</span>
|
||||||
|
<span class="diff-value" id="clientToFilterDiff">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="diff-item">
|
||||||
|
<span class="diff-label">Filter → Controller</span>
|
||||||
|
<span class="diff-value" id="filterToControllerDiff">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-section">
|
||||||
|
<h3 style="margin-bottom: 10px; color: #555;">Full Response</h3>
|
||||||
|
<pre class="response-json" id="responseJson"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorSection" class="card" style="display: none;">
|
||||||
|
<h2>Error</h2>
|
||||||
|
<div id="errorMsg" class="error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('participationForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const resultSection = document.getElementById('result');
|
||||||
|
const errorSection = document.getElementById('errorSection');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
resultSection.classList.remove('show');
|
||||||
|
errorSection.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Submitting...';
|
||||||
|
|
||||||
|
const eventId = document.getElementById('eventId').value;
|
||||||
|
const userId = document.getElementById('userId').value;
|
||||||
|
|
||||||
|
// Capture client time right before the request
|
||||||
|
const clientTimestamp = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/event/participation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Timezone': 'Asia/Seoul',
|
||||||
|
'X-Client-Timestamp': clientTimestamp.toString()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
eventId: eventId,
|
||||||
|
userId: userId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display success
|
||||||
|
document.getElementById('successMsg').textContent =
|
||||||
|
`Participation registered! ID: ${data.participation.id}`;
|
||||||
|
|
||||||
|
// Display timing info
|
||||||
|
const timing = data.timing;
|
||||||
|
|
||||||
|
// Client time
|
||||||
|
if (timing.clientRequestTime) {
|
||||||
|
document.getElementById('clientTime').textContent = formatTime(timing.clientRequestTime);
|
||||||
|
document.getElementById('clientTimeEpoch').textContent = `Epoch: ${timing.clientRequestTimeEpochMilli}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('clientTime').textContent = 'Not captured';
|
||||||
|
document.getElementById('clientTimeEpoch').textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter time
|
||||||
|
document.getElementById('filterTime').textContent = formatTime(timing.serverFilterTime);
|
||||||
|
document.getElementById('filterTimeEpoch').textContent = `Epoch: ${timing.serverFilterTimeEpochMilli}`;
|
||||||
|
|
||||||
|
// Controller time
|
||||||
|
document.getElementById('controllerTime').textContent = formatTime(timing.serverControllerTime);
|
||||||
|
document.getElementById('controllerTimeEpoch').textContent = `Epoch: ${timing.serverControllerTimeEpochMilli}`;
|
||||||
|
|
||||||
|
// Differences
|
||||||
|
document.getElementById('clientToFilterDiff').textContent =
|
||||||
|
timing.clientToFilterDiffMs !== null ? `${timing.clientToFilterDiffMs} ms` : 'N/A';
|
||||||
|
document.getElementById('filterToControllerDiff').textContent =
|
||||||
|
`${timing.filterToControllerDiffMs} ms`;
|
||||||
|
|
||||||
|
// Full response JSON
|
||||||
|
document.getElementById('responseJson').textContent = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
resultSection.classList.add('show');
|
||||||
|
|
||||||
|
// Auto-increment userId for next test
|
||||||
|
const currentUserId = document.getElementById('userId').value;
|
||||||
|
const match = currentUserId.match(/^(.+-)(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
document.getElementById('userId').value = match[1] + (parseInt(match[2]) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('errorMsg').textContent = error.message;
|
||||||
|
errorSection.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Submit Participation';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(isoString) {
|
||||||
|
if (!isoString) return '-';
|
||||||
|
// Convert ISO string to readable format
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toISOString().replace('T', ' ').replace('Z', ' UTC');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class MongodbNanosecApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.myoauniverse.lab.mongodb
|
||||||
|
|
||||||
|
import com.myoauniverse.lab.mongodb.domain.EventParticipation
|
||||||
|
import com.myoauniverse.lab.mongodb.repository.EventParticipationRepository
|
||||||
|
import org.junit.jupiter.api.*
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MongoDB nanosecond precision storage test for EventParticipation.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
|
||||||
|
class NanosecondPrecisionTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var mongoTemplate: MongoTemplate
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var eventParticipationRepository: EventParticipationRepository
|
||||||
|
|
||||||
|
private val testInstant = Instant.parse("2024-01-21T06:30:45.123456789Z")
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
fun setup() {
|
||||||
|
mongoTemplate.dropCollection(EventParticipation::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
@DisplayName("나노초 정밀도 보존 테스트")
|
||||||
|
fun `nanosecond precision is preserved`() {
|
||||||
|
// Given
|
||||||
|
val participation = EventParticipation.create(
|
||||||
|
eventId = "event-001",
|
||||||
|
userId = "user-001",
|
||||||
|
participatedAt = testInstant
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val saved = eventParticipationRepository.save(participation)
|
||||||
|
val retrieved = eventParticipationRepository.findById(saved.id!!).orElseThrow()
|
||||||
|
val reconstructedInstant = retrieved.toFullPrecisionInstant()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
println("=== 나노초 정밀도 테스트 ===")
|
||||||
|
println("원본 Instant: $testInstant")
|
||||||
|
println("원본 나노초: ${testInstant.nano}")
|
||||||
|
println("저장된 필드:")
|
||||||
|
println(" - participatedAt (Instant→BSON Date): ${retrieved.participatedAt}")
|
||||||
|
println(" - nanoAdjustment: ${retrieved.nanoAdjustment}")
|
||||||
|
println("복원된 Instant: $reconstructedInstant")
|
||||||
|
println("복원된 나노초: ${reconstructedInstant.nano}")
|
||||||
|
|
||||||
|
assertEquals(testInstant.nano, reconstructedInstant.nano)
|
||||||
|
assertEquals(testInstant, reconstructedInstant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
@DisplayName("다양한 나노초 값 테스트")
|
||||||
|
fun `various nanosecond values are preserved`() {
|
||||||
|
val testCases = listOf(0, 1, 999_999, 1_000_000, 123_456_789, 999_999_999)
|
||||||
|
|
||||||
|
println("\n=== 다양한 나노초 값 테스트 ===")
|
||||||
|
|
||||||
|
for (nanos in testCases) {
|
||||||
|
val instant = Instant.parse("2024-01-21T12:00:00Z").plusNanos(nanos.toLong())
|
||||||
|
val participation = EventParticipation.create(
|
||||||
|
eventId = "event-nano-$nanos",
|
||||||
|
userId = "user-nano",
|
||||||
|
participatedAt = instant
|
||||||
|
)
|
||||||
|
val saved = eventParticipationRepository.save(participation)
|
||||||
|
val retrieved = eventParticipationRepository.findById(saved.id!!).orElseThrow()
|
||||||
|
|
||||||
|
println("입력: $nanos ns → 복원: ${retrieved.toFullPrecisionInstant().nano} ns")
|
||||||
|
assertEquals(nanos, retrieved.toFullPrecisionInstant().nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ 모든 나노초 값이 정확히 보존됨")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(10)
|
||||||
|
@DisplayName("클라이언트 타임존 변환 테스트")
|
||||||
|
fun `client timezone conversion works correctly`() {
|
||||||
|
// Given
|
||||||
|
val participation = EventParticipation.create(
|
||||||
|
eventId = "event-tz",
|
||||||
|
userId = "user-tz",
|
||||||
|
participatedAt = testInstant
|
||||||
|
)
|
||||||
|
val saved = eventParticipationRepository.save(participation)
|
||||||
|
val retrieved = eventParticipationRepository.findById(saved.id!!).orElseThrow()
|
||||||
|
|
||||||
|
// When
|
||||||
|
val koreaTime = retrieved.toZonedDateTime(ZoneId.of("Asia/Seoul"))
|
||||||
|
val utcTime = retrieved.toZonedDateTime(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
// Then
|
||||||
|
println("\n=== 클라이언트 타임존 변환 테스트 ===")
|
||||||
|
println("저장된 시간 (UTC): $testInstant")
|
||||||
|
println("Korea: $koreaTime (hour: ${koreaTime.hour})")
|
||||||
|
println("UTC: $utcTime (hour: ${utcTime.hour})")
|
||||||
|
|
||||||
|
assertEquals(testInstant, koreaTime.toInstant())
|
||||||
|
assertEquals(testInstant, utcTime.toInstant())
|
||||||
|
assertEquals(15, koreaTime.hour) // +09:00
|
||||||
|
assertEquals(6, utcTime.hour)
|
||||||
|
|
||||||
|
println("✅ 타임존 변환 정상 동작")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(20)
|
||||||
|
@DisplayName("이벤트/사용자 조회 테스트")
|
||||||
|
fun `find by eventId and userId works`() {
|
||||||
|
// Given
|
||||||
|
mongoTemplate.dropCollection(EventParticipation::class.java)
|
||||||
|
|
||||||
|
eventParticipationRepository.saveAll(listOf(
|
||||||
|
EventParticipation.create("event-A", "user-1", Instant.now()),
|
||||||
|
EventParticipation.create("event-A", "user-2", Instant.now()),
|
||||||
|
EventParticipation.create("event-B", "user-1", Instant.now())
|
||||||
|
))
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertEquals(2, eventParticipationRepository.findByEventId("event-A").size)
|
||||||
|
assertEquals(2, eventParticipationRepository.findByUserId("user-1").size)
|
||||||
|
assertNotNull(eventParticipationRepository.findByEventIdAndUserId("event-A", "user-1"), "Should find participation")
|
||||||
|
assertNull(eventParticipationRepository.findByEventIdAndUserId("event-A", "user-3"), "Should not find participation")
|
||||||
|
|
||||||
|
println("\n=== 이벤트/사용자 조회 테스트 ===")
|
||||||
|
println("event-A 참여자: 2명")
|
||||||
|
println("user-1 참여 이벤트: 2개")
|
||||||
|
println("✅ 조회 정상 동작")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(30)
|
||||||
|
@DisplayName("MongoDB 저장 데이터 형식 확인")
|
||||||
|
fun `verify MongoDB data format`() {
|
||||||
|
// Given
|
||||||
|
mongoTemplate.dropCollection(EventParticipation::class.java)
|
||||||
|
val participation = EventParticipation.create(
|
||||||
|
eventId = "event-format",
|
||||||
|
userId = "user-format",
|
||||||
|
participatedAt = testInstant,
|
||||||
|
metadata = mapOf("source" to "test", "version" to 1)
|
||||||
|
)
|
||||||
|
val saved = eventParticipationRepository.save(participation)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val rawDoc = mongoTemplate.findById(saved.id!!, org.bson.Document::class.java, "event_participations")
|
||||||
|
|
||||||
|
// Then
|
||||||
|
println("\n=== MongoDB 저장 데이터 형식 ===")
|
||||||
|
println("Raw Document:")
|
||||||
|
println(rawDoc?.toJson())
|
||||||
|
|
||||||
|
assertNotNull(rawDoc, "Document should not be null")
|
||||||
|
// Instant은 BSON Date로 저장됨
|
||||||
|
assertTrue(rawDoc!!.get("participatedAt") is Date, "participatedAt should be BSON Date")
|
||||||
|
assertEquals(456789, rawDoc.get("nanoAdjustment"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(31)
|
||||||
|
@DisplayName("MongoDB Shell 쿼리 예시")
|
||||||
|
fun `print MongoDB shell query examples`() {
|
||||||
|
println("\n=== MongoDB Shell 쿼리 예시 ===")
|
||||||
|
println("""
|
||||||
|
// 이벤트별 참여자 조회
|
||||||
|
db.event_participations.find({ eventId: "event-123" })
|
||||||
|
|
||||||
|
// 사용자별 참여 이벤트 조회
|
||||||
|
db.event_participations.find({ userId: "user-456" })
|
||||||
|
|
||||||
|
// 특정 기간 참여자 조회
|
||||||
|
db.event_participations.find({
|
||||||
|
participatedAt: {
|
||||||
|
${'$'}gte: ISODate("2024-01-21T00:00:00Z"),
|
||||||
|
${'$'}lt: ISODate("2024-01-22T00:00:00Z")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 참여 시간순 정렬
|
||||||
|
db.event_participations.find({ eventId: "event-123" }).sort({ participatedAt: 1 })
|
||||||
|
|
||||||
|
// 이벤트별 참여자 수
|
||||||
|
db.event_participations.countDocuments({ eventId: "event-123" })
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user