diff --git a/.dockerignore b/.dockerignore index 67a7e02..bcaab02 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,26 @@ .gradle build **/build + +# Gradle 빌드 설정을 이미지 빌드 컨텍스트에서 뺀다. +# +# 이 파일의 값(병렬 실행, 힙 2g)은 CI 러너와 개발 PC 를 상대로 잡은 것이다. +# 그런데 Dockerfile 의 빌드 스테이지는 컨텍스트 루트에서 ./gradlew bootJar 를 +# 돌리므로, 그냥 두면 그 값이 여기까지 따라 들어온다. +# +# 문제는 이 빌드 스테이지를 쓰는 곳이 어디냐다. CI 배포는 Dockerfile.ci 로 +# 미리 만든 jar 만 복사하니 Gradle 을 돌리지 않는다. 이 스테이지가 실제로 +# 도는 경로는 docker-compose.prod.yml 이 문서화한 두 번째 기동 +# ("CI 없이 급하게 올릴 때" up -d --build) 하나뿐이고, 그건 운영 EC2 +# (t3.large, 8GB) 위에서 돈다. compose 의 mem_limit 합이 7040m(6.88GiB)라 +# 여유가 1GB 남짓인데, 하필 장애 대응으로 그 경로를 탈 때 이미 떠 있는 +# 서비스 JVM 들과 메모리를 다투게 된다. +# +# CLAUDE.md 가 "운영 설정은 EC2 단일 호스트에 6개 JVM 이 함께 뜨는 전제로 +# 메모리 한도가 잡혀 있다"고 못박은 예산이 그것이다. 여기서는 Gradle 기본값 +# (보수적인 힙, 순차 실행)으로 빌드하게 둔다 - 이 경로에서 아껴야 할 것은 +# 빌드 시간이 아니라 메모리다. +gradle.properties .idea .vscode .claude diff --git a/.env.prod.example b/.env.prod.example index 9f88623..f318277 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -62,6 +62,16 @@ DEFAULT_PROFILE_IMAGE_BASE_URL=https://srv.comatching.site/api/public/profile-im # 켜도 AuthorizationHeaderFilter 가 붙어 있어 로그인 쿠키가 있어야 열린다. # SWAGGER_PATH_PREFIX= +# ---------- 모니터링 ---------- +# Grafana 관리자 비밀번호. SSH 터널(127.0.0.1:3001) 너머로만 닿지만 +# 익명 접근을 꺼둔 이상 계정은 있어야 한다. +GRAFANA_ADMIN_PASSWORD= + +# 인프라 알림(서비스 다운·5xx·힙·디스크)의 디스코드 웹훅은 환경변수가 +# 아니라 monitoring/alertmanager/alertmanager.yml 에 넣는다 - Alertmanager 가 +# 설정 파일에서 환경변수를 확장하지 못한다. 그 파일은 .gitignore 에 있다. +# DLT_ALERT_WEBHOOK_URL 과 반드시 다른 채널로 팔 것(아래 DLT 절 참고). + # ---------- DLT 적재 알림 ---------- # Discord 웹훅 URL(https://discord.com/api/webhooks//) 또는 Slack. # 비우면 로그만 남는다. 이 URL 자체가 비밀값이다. diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e9593b7 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,70 @@ +# PR 이 열리거나 갱신될 때 Claude 가 자동으로 코드 리뷰를 남긴다. +# +# 리뷰 기준은 CLAUDE.md 가 가리키는 docs/code-review-guidelines.md 한 곳에서 +# 관리한다 - 프롬프트에 기준을 복붙해 두면 워크플로와 문서가 따로 논다. +# +# 수동 호출(코멘트에 @claude)은 claude.yml 이 담당한다. +name: Code Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +# 같은 PR 에 연속으로 푸시하면 이전 리뷰는 취소한다. synchronize 가 푸시마다 +# 발화하므로, 없으면 커밋 3개를 연달아 밀 때 리뷰를 3번 돌린다. +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + # 액션의 기본 GitHub App 인증에 필요하다. + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + # API 크레딧이 아니라 Claude 구독으로 돌린다. + # 토큰은 `claude setup-token` 으로 만든다. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: "https://github.com/anthropics/claude-code.git" + plugins: "code-review@claude-code-plugins" + # 두 번째 문단이 없으면 리뷰가 조용히 증발한다. 실제로 겪은 실패: + # 모델이 리뷰를 백그라운드 서브에이전트에 넘기고 "완료 알림을 + # 기다리겠다"며 턴을 끝냈는데, Actions 헤드리스 실행에는 그 알림을 + # 받을 루프가 없어 프로세스가 그대로 죽었다. 코멘트 0개로 초록불. + prompt: | + /code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }} + + 리뷰는 이 턴 안에서 끝낸다. 서브에이전트를 백그라운드로 띄우고 + 완료 알림을 기다리지 마라 - Actions 에는 알림을 받을 루프가 없어 + 프로세스가 그대로 죽는다. + + 이 자동 리뷰는 diff 에 나온 코드만 본다. common-module 등 + diff 밖의 다른 서비스로 건너가 소비 코드를 찾아 읽는 하위 호환성 + 분석은 하지 마라 - 시간이 몇 배로 늘어난다. diff 가 + common-module 의 DTO, 서비스 간 내부 API, Kafka 이벤트 스키마를 + 건드린다면, 그 분석 대신 코멘트에 "cross-service 영향 확인이 + 필요합니다 - `@claude` 로 수동 리뷰를 요청하세요" 한 줄만 남겨라. + # allowedTools 는 화이트리스트다. 목록에 없는 도구는 permissionMode + # default + 비대화형 조합에서 그대로 거부된다. + # - Skill: 없으면 /code-review 스킬 본체가 로드되지 못한다. 모델은 + # 거부당한 뒤 프롬프트에 실린 요약만 보고 즉흥적으로 움직인다. + # - Bash/Read/Glob/Grep: 스킬이 diff 를 읽고 코드를 뒤지는 데 쓴다. + # - Agent: 스킬이 리뷰어를 병렬로 나눌 때 쓴다. 백그라운드 전환은 + # 위 프롬프트가 막는다. + # - mcp__github_inline_comment__*: 액션은 --allowedTools 가 이름을 + # 대야만 인라인 코멘트용 MCP 서버를 띄운다. 스킬 frontmatter 에 + # 같은 도구가 적혀 있어도 이 줄은 남겨야 한다. + # --max-turns: 리뷰 한 건이 파일 읽기·검색·코멘트로 10~20턴을 쓴다. + # 상한이 낮으면 리뷰를 끝내기 전에 잘리고, 없으면 폭주할 때 멈출 + # 지점이 없다. 실행 1회당 상한이지 하루 총량이 아니다. + claude_args: '--allowedTools "Skill,Bash,Read,Glob,Grep,Agent,mcp__github_inline_comment__create_inline_comment" --max-turns 30' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..56d9efb --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,40 @@ +# PR·이슈 코멘트에서 Claude 를 직접 부른다. 자동 리뷰(claude-code-review.yml)와 +# 달리 사람이 명시적으로 부를 때만 돈다. +# +# @claude ChatRoomPresenceTracker 가 동시성 안전한지 봐줘 +# @claude 이 테스트 깨지는 원인 찾아줘 +# +# common-module/내부 API/Kafka 스키마처럼 서비스 경계를 넘는 변경의 하위 +# 호환성 분석은 여기서 한다. claude-code-review.yml 은 diff 만 보는 얕은 +# 리뷰라 그 분석을 하지 않고, 대신 이 워크플로를 부르라는 코멘트만 남긴다. +# +# @claude 이 PR 의 common-module 변경이 다른 서비스에 미치는 영향 봐줘 +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + claude: + # 러너가 관계없는 코멘트마다 뜨는 것을 막는다. 액션 자체도 트리거 + # 문구를 다시 확인한다. + if: contains(github.event.comment.body, '@claude') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + # PR 의 CI 결과를 읽게 한다. + actions: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aded364..f516328 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,15 @@ name: deploy on: push: branches: [main] + # 문서·부하테스트 도구만 바뀐 머지에 6개 컨테이너를 전부 재기동할 이유가 + # 없다. 런타임이 읽지 않는 경로는 배포를 건너뛴다. + # 주의: 여기 걸린 경로는 EC2 의 git clone 에도 다음 "진짜 배포" 때까지 + # 반영되지 않는다 - 서버가 읽는 파일(compose, 설정)은 절대 넣지 말 것. + paths-ignore: + - 'docs/**' + - 'tools/**' + - '**.md' + - '.gitignore' # 코드 변경 없이 다시 배포하고 싶을 때 수동 실행 workflow_dispatch: @@ -60,7 +69,7 @@ jobs: # bootJar(실행 가능)와 별개로 jar 태스크가 -plain.jar 를 만들 수 있어, # 아래 이미지 잡에서 실행 가능한 쪽을 골라낸다. - name: jar 빌드 - run: ./gradlew bootJar --no-daemon + run: ./gradlew bootJar - name: jar 보관 uses: actions/upload-artifact@v4 @@ -172,11 +181,13 @@ jobs: # --no-build: EC2 에서 소스를 빌드하지 않는다. 이미지는 이미 만들어져 있다. docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build - # 배포마다 서비스 6개의 이미지가 새로 쌓인다. prune -f 는 태그 - # 없는 것만 지우는데, 옛 커밋 SHA 이미지는 태그가 붙어 있어 그대로 - # 남는다. -a 로 "컨테이너가 쓰지 않는 것"까지 대상에 넣되, 72시간은 - # 남겨 최근 버전으로 되돌릴 여지를 둔다. - docker image prune -af --filter "until=72h" + # 배포마다 서비스 6개의 이미지(~3GB 세트)가 새로 쌓인다. prune -f 는 + # 태그 없는 것만 지우는데, 옛 커밋 SHA 이미지는 태그가 붙어 있어 + # 그대로 남는다. -a 로 "컨테이너가 쓰지 않는 것"을 전부 지운다. + # 예전엔 until=72h 로 롤백 여지를 뒀지만, 머지가 잦은 날 세대가 + # 쌓여 29GB 디스크가 가득 차 배포 자체가 죽었다(2026-08-26). + # 롤백은 GHCR 에 남아 있는 이미지를 다시 pull 하면 된다. + docker image prune -af - name: 상태 확인 uses: appleboy/ssh-action@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1f32a9..49f2668 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,23 @@ # 사라진 게 아니라 시점이 앞당겨진 것이다 - main 에 머지된 뒤 배포를 # 붙잡고 있는 대신, PR 을 보고 있는 동안 백그라운드로 돌아간다. # +# 잡이 둘로 나뉜 이유: +# 단위 테스트 IO 가 붙지 않는다. 도커도 브로커도 필요 없어서 빠르다. +# 고쳐야 할 게 있으면 여기서 가장 먼저 알게 된다. +# 통합 테스트 EmbeddedKafka 로 브로커를, Testcontainers 로 MySQL 을 +# 실제로 띄운다. 느린 대신 목으로는 볼 수 없는 것을 본다 +# (재시도 소진 후 DLT 이동, 네이티브 MySQL 쿼리의 실제 결과). +# +# 둘 사이에 needs 를 걸지 않았다. 동시에 돌아야 전체 대기 시간이 느린 +# 쪽 하나로 수렴한다. 순서를 세우면 두 시간이 더해진다. +# +# 어느 쪽을 나누는지는 @Tag("integration") 이 정한다. 태스크 정의는 +# 루트 build.gradle 에 있다. +# # 이 검증이 실제로 게이트가 되려면 브랜치 보호가 필요하다: # Settings > Branches > main > Require status checks to pass -# 에서 "테스트" 체크를 필수로 지정한다. 없으면 빨간 PR 도 머지된다. +# 에서 "단위 테스트"와 "통합 테스트"를 둘 다 필수로 지정한다. +# 없으면 빨간 PR 도 머지된다. # ============================================================ name: test @@ -22,8 +36,8 @@ concurrency: cancel-in-progress: true jobs: - test: - name: 테스트 + unit: + name: 단위 테스트 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -37,10 +51,10 @@ jobs: - run: chmod +x gradlew - # matching-service 는 Testcontainers(MySQL)를, 카프카 IT 는 - # EmbeddedKafka 를 쓴다. 둘 다 ubuntu-latest 러너에서 돈다. - - name: 테스트 - run: ./gradlew test --no-daemon + # --no-daemon 을 쓰지 않는다. 데몬을 끄면 setup-gradle 이 이어주는 + # 빌드 캐시와 설정 캐시를 제대로 못 쓴다(Gradle 공식 권고사항이기도 하다). + - name: 단위 테스트 + run: ./gradlew test # 실패했을 때만이 아니라 항상 올린다. 통과했지만 오래 걸린 테스트를 # 찾아볼 때도 리포트가 필요하다. @@ -48,6 +62,35 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-reports - path: '*/build/reports/tests/**' + name: test-reports-unit + path: '*/build/reports/tests/test/**' + retention-days: 7 + + integration: + name: 통합 테스트 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - run: chmod +x gradlew + + # matching-service 는 Testcontainers(MySQL)를, 카프카 IT 는 + # EmbeddedKafka 를 쓴다. 둘 다 ubuntu-latest 러너에 이미 있는 + # 도커/JVM 으로 돈다 - 별도 서비스 컨테이너 선언이 필요 없다. + - name: 통합 테스트 + run: ./gradlew integrationTest + + - name: 테스트 리포트 보관 + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-integration + path: '*/build/reports/tests/integrationTest/**' retention-days: 7 diff --git a/.gitignore b/.gitignore index 2dd2160..d8a3325 100644 --- a/.gitignore +++ b/.gitignore @@ -47,7 +47,8 @@ bin/ .claudeignore .claude .mcp.json -CLAUDE.md +# CLAUDE.md 는 추적한다. 개인 메모가 아니라 팀이 공유하는 프로젝트 규칙이고, +# PR 리뷰 액션이 매 실행마다 읽어 docs/code-review-guidelines.md 로 간다. scripts .idea docker-compose.yml @@ -56,6 +57,15 @@ docker-compose.yml /database /docs/* !/docs/perf-log.md +# 설계 문서(spec)는 추적한다. /docs/* 가 디렉터리째 막고 있어서 하위 파일만 +# 되살릴 수 없다 - 상위 디렉터리를 먼저 열어줘야 한다. +!/docs/superpowers/ +# 플러그인이 남기는 상태 파일은 그 안에서도 제외한다. +/docs/superpowers/**/.omc/ + +# Alertmanager 실제 설정은 디스코드 웹훅 URL 을 담고 있어 EC2 에만 둔다. +# 예제(alertmanager.example.yml)만 추적한다 - .env.prod 와 같은 패턴. +/monitoring/alertmanager/alertmanager.yml application-docker.yml application-secret.yml serviceAccountKey.json @@ -86,3 +96,5 @@ _workspace id_rsa id_ed25519 comatching-ec2 +/AGENTS.md + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..81ed067 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# COMAtching Backend + +Spring 기반 MSA. 서비스는 gateway / user / matching / chat / item / notification 6개이며 +`common-module` 을 공유한다. 서비스 간 통신은 내부 REST(`/api/internal/**`)와 Kafka 이벤트. + +## 코드 리뷰 + +리뷰 기준은 [docs/code-review-guidelines.md](docs/code-review-guidelines.md) 에 있다. +코드 리뷰를 할 때는 그 문서를 먼저 읽고 거기 적힌 페르소나·언어·관점을 따른다. +요약하면: + +- **리뷰 코멘트는 한국어로 작성한다.** +- Spring MSA 실무 경험이 있는 Senior Back-end Engineer 관점으로 본다. +- "동작한다"는 이유만으로 승인하지 않고, 미래의 변경에 얼마나 버티는지를 본다. +- 문제를 지적할 때는 "왜 문제인지"와 "어떻게 개선할지"를 함께 쓴다. + +이 저장소 특유의 주의점: + +- **서비스 경계를 넘는 변경은 하위 호환성을 특히 주의해서 본다** — + `common-module` 의 DTO, 서비스 간 내부 API, Kafka 이벤트 스키마는 + 배포 순서가 어긋나는 순간 깨진다. + - **단, PR 자동 리뷰(`claude-code-review.yml`)는 diff 에 나온 코드만 보고 + 끝낸다.** 다른 서비스로 건너가 소비 코드를 찾아 읽는 하위 호환성 + 분석은 턴·시간을 크게 늘린다 — diff 가 `common-module`/내부 API/Kafka + 스키마를 건드리면 자동 리뷰는 코멘트에 "cross-service 영향 확인 필요" + 한 줄만 남기고, 실제 분석은 PR 코멘트에 `@claude` 로 명시적으로 + 요청했을 때(`claude.yml`)만 수행한다. +- 운영 설정(`docker-compose.prod.yml`, `application-aws.yml`)은 EC2 단일 호스트에 + 6개 JVM 이 함께 뜨는 전제로 메모리 한도가 잡혀 있다. 한도를 건드리는 변경은 + 전체 예산과 함께 본다. diff --git a/build.gradle b/build.gradle index c7b54bd..2813b65 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,56 @@ subprojects { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - tasks.named('test') { - useJUnitPlatform() + // 테스트를 둘로 나눈다. + // test IO 가 붙지 않는 단위 테스트. PR 게이트가 보는 것은 이쪽이다. + // integrationTest EmbeddedKafka·Testcontainers 처럼 브로커나 DB 를 실제로 띄우는 것. + // + // 가르는 기준은 @Tag("integration") 하나뿐이다. 클래스 이름(*IT)으로 나누지 않는 + // 이유는, 이름은 리팩터링에 딸려 바뀌지만 태그는 "이 테스트가 무엇을 필요로 하는가"를 + // 직접 적어둔 것이라 따라다니기 때문이다. + // + // 주의 - useJUnitPlatform() 을 하위 모듈에서 다시 부르면 여기서 건 excludeTags 가 + // 통째로 리셋된다. 통합 테스트가 조용히 단위 테스트에 섞여 들어오므로 하위 + // build.gradle 에는 이 호출을 두지 않는다. + tasks.named('test', Test) { + useJUnitPlatform { + excludeTags 'integration' + } + + // maxParallelForks 는 일부러 두지 않았다. org.gradle.parallel 이 이미 모듈 + // 7개를 동시에 돌리고 있어서 2~4코어 러너는 그것만으로 찬다. 여기에 포크를 + // 더 쪼개면 (병렬 모듈 × 포크) 만큼 JVM 이 뜨는데, 채울 유휴 코어는 없고 + // 포크마다 기동·클래스로딩 비용만 새로 낸다. 전체가 30초대인 스위트에서는 + // 그 비용이 이득보다 크다. + // + // 나중에 한 모듈에만 테스트가 몰려 다른 모듈이 놀게 되면, 그때 그 모듈의 + // build.gradle 에서만 붙이면 된다. + } + + tasks.register('integrationTest', Test) { + group = 'verification' + description = '브로커·DB 컨테이너가 필요한 통합 테스트를 실행한다.' + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + + useJUnitPlatform { + includeTags 'integration' + } + + // 통합 테스트가 없는 모듈(gateway, chat, item, notification)에서도 이 태스크는 + // 돈다. 대상이 0개라고 실패하면 CI 가 빨개지므로 끈다. + filter { + failOnNoMatchingTests = false + } + } + + tasks.withType(Test).configureEach { + // 통과한 테스트까지 표준 출력을 흘리면 CI 로그가 수천 줄이 되고, 정작 + // 실패했을 때 스택트레이스를 찾기 어려워진다. 볼 것만 본다. + testLogging { + events 'skipped', 'failed' + exceptionFormat = 'full' + } } } \ No newline at end of file diff --git a/chat-service/build.gradle b/chat-service/build.gradle index 9f7c4ee..001a77b 100644 --- a/chat-service/build.gradle +++ b/chat-service/build.gradle @@ -29,10 +29,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -test { - testLogging { - showStandardStreams = true - events "passed", "skipped", "failed" - } -} \ No newline at end of file diff --git a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java index d61b95e..dae26c4 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java @@ -7,4 +7,6 @@ public interface ChatRoomRepositoryCustom { boolean touchLastReadAt(String roomId, Long memberId, LocalDateTime readAt); boolean updateLastMessageIfLatest(String roomId, String previewContent, LocalDateTime sentAt); + + boolean activateRoom(String roomId); } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java index 867d6ff..322de46 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java @@ -62,4 +62,18 @@ public boolean updateLastMessageIfLatest(String roomId, String previewContent, L UpdateResult result = mongoTemplate.updateFirst(query, update, ChatRoom.class); return result.getMatchedCount() > 0; } + + // 활성화만 따로 떼어낸 이유: 요약 갱신(updateLastMessageIfLatest)이 활성화를 + // 겸하는데, 차단된 상대에게 보낸 메시지는 요약을 갱신하지 않아 방이 영구 + // WAITING 으로 남았다. 차단이 풀린 뒤에도 target 에게 방이 보이려면 + // 활성화는 요약과 독립적으로 일어나야 한다. + @Override + public boolean activateRoom(String roomId) { + UpdateResult result = mongoTemplate.updateFirst( + Query.query(Criteria.where("_id").is(roomId).and("status").is(ChatRoomStatus.WAITING)), + new Update().set("status", ChatRoomStatus.ACTIVE), + ChatRoom.class + ); + return result.getModifiedCount() > 0; + } } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java index 913c71a..8fa7b48 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java @@ -92,6 +92,11 @@ public ChatMessageResponse processMessage(ChatMessageRequest request) { if (!isBlockedByReceiver) { updateChatRoomInfo(chatRoom, request, savedMessage.getId(), sentAt); publishNotificationEvent(chatRoom, request, sentAt); + } else { + // 요약·알림은 차단 수신자에게 새 메시지 힌트를 주지 않으려 스킵하지만, + // 활성화까지 스킵하면 방이 영구 WAITING 으로 남아 차단을 풀어도 + // target 목록에 방이 나타나지 않는다. + chatRoomRepository.activateRoom(chatRoom.getId()); } return ChatMessageResponse.from(savedMessage, 1); diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java index f9987ac..014450f 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java @@ -3,6 +3,7 @@ import java.util.List; import com.comatching.chat.domain.dto.ChatRoomResponse; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.event.matching.MatchingSuccessEvent; @@ -17,4 +18,6 @@ public interface ChatRoomService { void validateRoomMember(String roomId, Long memberId); List getChatRoomReferencesByMatchingIds(List matchingIds); + + List ensureChatRooms(List requests); } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java index 181b5dd..19a61a6 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java @@ -7,6 +7,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ import com.comatching.chat.domain.service.block.BlockService; import com.comatching.chat.global.exception.ChatErrorCode; import com.comatching.chat.infra.client.MemberClient; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.event.matching.MatchingSuccessEvent; import com.comatching.common.dto.member.ProfileResponse; @@ -136,6 +138,57 @@ public List getChatRoomReferencesByMatchingIds(List ensureChatRooms(List requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + + List matchingIds = requests.stream() + .map(ChatRoomEnsureRequest::matchingId) + .toList(); + + Map roomsByMatchingId = chatRoomRepository.findByMatchingIdIn(matchingIds).stream() + .collect(Collectors.toMap(ChatRoom::getMatchingId, Function.identity(), (left, right) -> left)); + + return requests.stream() + .map(request -> { + ChatRoom room = roomsByMatchingId.get(request.matchingId()); + if (room == null) { + room = createMissingRoom(request); + } + return new ChatRoomReferenceResponse(request.matchingId(), room.getId()); + }) + .toList(); + } + + private ChatRoom createMissingRoom(ChatRoomEnsureRequest request) { + ChatRoom newRoom = ChatRoom.builder() + .matchingId(request.matchingId()) + .initiatorUserId(request.initiatorUserId()) + .targetUserId(request.targetUserId()) + .build(); + + try { + ChatRoom saved = chatRoomRepository.save(newRoom); + log.info("Lazily created chat room. matchingId={}, roomId={}", request.matchingId(), saved.getId()); + return saved; + } catch (DuplicateKeyException e) { + return chatRoomRepository.findByMatchingId(request.matchingId()) + .orElseThrow(() -> e); + } + } + private Map getUnreadCountsByRoom(List rooms, Long memberId) { if (rooms.isEmpty()) { return Map.of(); diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java b/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java new file mode 100644 index 0000000..6b758a9 --- /dev/null +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java @@ -0,0 +1,85 @@ +package com.comatching.chat.domain.service.presence; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +/** + * 이 인스턴스에 붙어 있는 WebSocket 세션이 어느 채팅방 토픽을 구독 중인지 기억한다. + * + * 읽음 처리는 원래 클라이언트가 방 입장 때 보내는 READ 한 번에만 의존했다. + * 방을 켜둔 채로 받은 메시지는 아무도 읽음 처리하지 않아 "1" 이 영원히 남았다. + * 이 추적기가 있으면 메시지를 뿌리는 시점에 수신자가 그 방을 보고 있는지 판단해 + * 서버가 대신 읽음 처리할 수 있다. + * + * 인스턴스별 메모리라는 점이 핵심이다. 메시지는 Redis 를 거쳐 모든 인스턴스에 + * 도착하므로, 각 인스턴스는 자기에게 붙은 세션만 책임지면 수평 확장에도 맞다. + */ +@Component +public class ChatRoomPresenceTracker { + + private static final String CHAT_ROOM_TOPIC_PREFIX = "/topic/chat.room."; + + private record RoomSubscription(String roomId, Long memberId) { + } + + // sessionId -> (subscriptionId -> 구독 정보). 구독 해지 이벤트에는 destination 이 + // 없고 subscriptionId 만 오므로 이 형태로 들고 있어야 지울 수 있다. + private final Map> subscriptionsBySession = new ConcurrentHashMap<>(); + + @EventListener + public void onSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + + String destination = accessor.getDestination(); + if (destination == null || !destination.startsWith(CHAT_ROOM_TOPIC_PREFIX)) { + return; + } + + Map sessionAttributes = accessor.getSessionAttributes(); + Object memberId = (sessionAttributes == null) ? null : sessionAttributes.get("memberId"); + String sessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (!(memberId instanceof Long) || sessionId == null || subscriptionId == null) { + return; + } + + String roomId = destination.substring(CHAT_ROOM_TOPIC_PREFIX.length()); + subscriptionsBySession + .computeIfAbsent(sessionId, key -> new ConcurrentHashMap<>()) + .put(subscriptionId, new RoomSubscription(roomId, (Long)memberId)); + } + + @EventListener + public void onUnsubscribe(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (sessionId == null || subscriptionId == null) { + return; + } + + Map sessionSubscriptions = subscriptionsBySession.get(sessionId); + if (sessionSubscriptions != null) { + sessionSubscriptions.remove(subscriptionId); + } + } + + @EventListener + public void onDisconnect(SessionDisconnectEvent event) { + subscriptionsBySession.remove(event.getSessionId()); + } + + public boolean isViewing(String roomId, Long memberId) { + return subscriptionsBySession.values().stream() + .flatMap(sessionSubscriptions -> sessionSubscriptions.values().stream()) + .anyMatch(subscription -> + subscription.roomId().equals(roomId) && subscription.memberId().equals(memberId)); + } +} diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java b/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java index 1d866ca..7ab2c6e 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java @@ -3,13 +3,17 @@ import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; import com.comatching.chat.domain.dto.ChatMessageResponse; import com.comatching.chat.domain.entity.ChatRoom; import com.comatching.chat.domain.repository.ChatRoomRepository; +import com.comatching.chat.domain.enums.MessageType; import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.domain.service.chat.ChatService; +import com.comatching.chat.domain.service.presence.ChatRoomPresenceTracker; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -25,6 +29,9 @@ public class RedisSubscriber implements MessageListener { private final SimpMessageSendingOperations messagingTemplate; private final ChatRoomRepository chatRoomRepository; private final BlockService blockService; + private final ChatRoomPresenceTracker presenceTracker; + private final ChatService chatService; + private final RedisPublisher redisPublisher; @Override public void onMessage(Message message, byte[] pattern) { @@ -49,11 +56,40 @@ public void onMessage(Message message, byte[] pattern) { messagingTemplate.convertAndSend("/topic/chat.room." + roomMessage.roomId(), roomMessage); + autoReadIfReceiverIsViewing(roomMessage, receiverId); + } catch (Exception e) { log.error("Redis 메시지 전달 오류: {}", e.getMessage()); } } + /** + * 수신자가 지금 이 인스턴스에서 그 방을 보고 있으면 서버가 대신 읽음 처리한다. + * + * 읽음 갱신이 클라이언트의 READ 전송에만 의존하면, 방을 켜둔 채 받은 메시지는 + * 수신자가 방을 나갔다 다시 들어올 때까지 "안 읽음" 으로 남는다. + * + * READ 는 sender 의 인스턴스가 아니라 여기(수신자 세션을 쥔 인스턴스)서 Redis 로 + * 발행한다. 그래야 발신자가 다른 인스턴스에 붙어 있어도 읽음 전파를 받는다. + * READ 메시지 자체에는 반응하지 않으므로 발행이 꼬리를 물지 않는다. + */ + private void autoReadIfReceiverIsViewing(ChatMessageResponse roomMessage, Long receiverId) { + if (roomMessage.type() == MessageType.READ) { + return; + } + if (!presenceTracker.isViewing(roomMessage.roomId(), receiverId)) { + return; + } + + try { + ChatMessageResponse readResponse = chatService.markAsRead(roomMessage.roomId(), receiverId); + redisPublisher.publish(new ChannelTopic("chatroom"), readResponse); + } catch (Exception e) { + // 자동 읽음은 편의 기능이다. 실패해도 메시지 전달을 되돌릴 이유는 없다. + log.warn("자동 읽음 처리 실패 - roomId: {}, receiverId: {}", roomMessage.roomId(), receiverId, e); + } + } + private Long getReceiverId(ChatRoom chatRoom, Long senderId) { return senderId.equals(chatRoom.getTargetUserId()) ? chatRoom.getInitiatorUserId() diff --git a/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java b/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java index 2780bd7..800a828 100644 --- a/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java +++ b/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java @@ -4,6 +4,9 @@ import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.web.bind.annotation.ControllerAdvice; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.exception.BusinessException; + @ControllerAdvice public class ChatErrorHandler { @@ -12,4 +15,13 @@ public class ChatErrorHandler { public String handleException(ChatException e) { return "ERROR: " + e.getMessage(); } + + // 채팅 코드가 실제로 던지는 예외는 BusinessException 이다. 위의 ChatException + // 핸들러는 던지는 곳이 없어서 한 번도 발화하지 않았고, 그동안 방이 없거나 + // 권한이 없어 실패한 전송이 클라이언트에 아무 신호도 남기지 못했다. + @MessageExceptionHandler(BusinessException.class) + @SendToUser("/queue/errors") + public ApiResponse handleBusinessException(BusinessException e) { + return ApiResponse.errorResponse(e.getErrorCode()); + } } diff --git a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java index b6c7764..1cc6d8d 100644 --- a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java +++ b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java @@ -1,6 +1,7 @@ package com.comatching.chat.infra.controller; import java.util.List; +import java.util.Map; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.listener.ChannelTopic; @@ -59,14 +60,20 @@ public void sendMessage( @Payload ChatMessageRequest request, SimpMessageHeaderAccessor headerAccessor) { - Long memberId = (Long)headerAccessor.getSessionAttributes().get("memberId"); - String nickname = headerAccessor.getSessionAttributes().get("nickname").toString(); + Map sessionAttributes = headerAccessor.getSessionAttributes(); + Long memberId = (sessionAttributes == null) ? null : (Long)sessionAttributes.get("memberId"); if (memberId == null) { log.error("인증되지 않은 사용자의 접근입니다."); return; } + // 닉네임은 알림 미리보기에만 쓴다. 프로필 완성 전에 발급된 토큰에는 + // nickname 클레임이 없어서 게이트웨이가 헤더를 안 보내는데, 예전에는 + // 여기서 바로 NPE 가 나 전송 자체가 죽었다. + Object rawNickname = sessionAttributes.get("nickname"); + String nickname = (rawNickname == null) ? "" : rawNickname.toString(); + ChatMessageRequest securedRequest = new ChatMessageRequest( request.roomId(), memberId, @@ -95,4 +102,17 @@ public ResponseEntity>> getChatMessages( List messages = chatService.getChatHistory(roomId, memberInfo.memberId(), pageable); return ResponseEntity.ok(ApiResponse.ok(messages)); } + + // 읽음 처리의 REST 경로. WebSocket 이 아직 안 붙었거나 끊긴 채로 방을 열면 + // 히스토리는 HTTP 로 읽히는데 읽음은 기록될 길이 없었다. READ 는 WS 때와 + // 같이 Redis 로 발행해 상대 화면의 "1" 도 같이 지운다. + @PostMapping("/api/chat/rooms/{roomId}/read") + public ResponseEntity> markAsRead( + @PathVariable String roomId, + @CurrentMember MemberInfo memberInfo) { + + ChatMessageResponse readResponse = chatService.markAsRead(roomId, memberInfo.memberId()); + redisPublisher.publish(topic, readResponse); + return ResponseEntity.ok(ApiResponse.ok()); + } } diff --git a/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java b/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java index fdafbdf..4c6da03 100644 --- a/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java +++ b/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import com.comatching.chat.domain.service.chatroom.ChatRoomService; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import lombok.RequiredArgsConstructor; @@ -23,4 +24,9 @@ public class InternalChatRoomController { public List getChatRoomReferences(@RequestBody List matchingIds) { return chatRoomService.getChatRoomReferencesByMatchingIds(matchingIds); } + + @PostMapping("/ensure") + public List ensureChatRooms(@RequestBody List requests) { + return chatRoomService.ensureChatRooms(requests); + } } diff --git a/chat-service/src/main/resources/application-aws.yml b/chat-service/src/main/resources/application-aws.yml index 5e0ed63..4df228a 100644 --- a/chat-service/src/main/resources/application-aws.yml +++ b/chat-service/src/main/resources/application-aws.yml @@ -66,7 +66,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/chat-service/src/main/resources/application.yml b/chat-service/src/main/resources/application.yml index 43d1369..69fed53 100644 --- a/chat-service/src/main/resources/application.yml +++ b/chat-service/src/main/resources/application.yml @@ -8,6 +8,10 @@ matching-service: url: http://localhost:9001 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: chat-service diff --git a/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java b/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java deleted file mode 100644 index e302a53..0000000 --- a/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.chat; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class ChatServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java index c68cb22..e4fe5bd 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java @@ -115,4 +115,27 @@ void updateLastMessageIfLatest_usesConditionalAtomicUpdate() { assertThat(update.get("$set", Document.class)).containsEntry("status", ChatRoomStatus.ACTIVE); assertThat(update.get("$max", Document.class)).containsEntry("updatedAt", sentAt); } + + @Test + @DisplayName("activateRoom은 WAITING인 방만 ACTIVE로 바꾼다") + void activateRoom_flipsOnlyWaitingRoom() { + // given + given(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(ChatRoom.class))) + .willReturn(UpdateResult.acknowledged(1, 1L, null)); + + // when + boolean result = repository.activateRoom(ROOM_ID); + + // then + assertThat(result).isTrue(); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(Query.class); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Update.class); + then(mongoTemplate).should().updateFirst(queryCaptor.capture(), updateCaptor.capture(), eq(ChatRoom.class)); + + Document query = queryCaptor.getValue().getQueryObject(); + Document update = updateCaptor.getValue().getUpdateObject(); + assertThat(query).containsEntry("_id", ROOM_ID); + assertThat(query).containsEntry("status", ChatRoomStatus.WAITING); + assertThat(update.get("$set", Document.class)).containsEntry("status", ChatRoomStatus.ACTIVE); + } } diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java deleted file mode 100644 index cdd4bcd..0000000 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java +++ /dev/null @@ -1,411 +0,0 @@ -package com.comatching.chat.domain.service.block; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.LongStream; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -import com.comatching.chat.domain.entity.UserBlock; -import com.comatching.chat.domain.repository.UserBlockRepository; - -@ExtendWith(MockitoExtension.class) -@DisplayName("BlockService 캐싱 성능 비교 테스트") -class BlockServiceCachePerformanceTest { - - @Mock - private UserBlockRepository userBlockRepository; - - @Mock - private RedisTemplate redisTemplate; - - @Mock - private ValueOperations valueOperations; - - private BlockServiceImpl blockService; - - private static final Long BLOCKER_USER_ID = 1L; - private static final int BLOCKED_USER_COUNT = 10; - private static final int ITERATION_COUNT = 1000; - - // 시뮬레이션용 지연 시간 (실제 환경 근사치) - private static final long SIMULATED_DB_LATENCY_MS = 10; // MongoDB: ~10ms - private static final long SIMULATED_REDIS_LATENCY_MS = 1; // Redis: ~1ms - - private List mockBlockedUsers; - - @BeforeEach - void setUp() { - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - blockService = new BlockServiceImpl(userBlockRepository, redisTemplate); - - // 차단된 사용자 목록 생성 - mockBlockedUsers = LongStream.rangeClosed(2, BLOCKED_USER_COUNT + 1) - .mapToObj(id -> UserBlock.builder() - .blockerUserId(BLOCKER_USER_ID) - .blockedUserId(id) - .build()) - .toList(); - } - - @Test - @DisplayName("캐싱 미적용 시나리오 - 매번 DB 조회") - void performanceTest_withoutCache() { - // given: 캐시 미스 시뮬레이션 (항상 null 반환) - given(valueOperations.get(anyString())).willReturn(null); - - // DB 조회 시 지연 시뮬레이션 - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - // when: 1000번 isBlocked 호출 - long startTime = System.currentTimeMillis(); - - for (int i = 0; i < ITERATION_COUNT; i++) { - Long targetUserId = (long) (i % BLOCKED_USER_COUNT) + 2; - blockService.isBlocked(BLOCKER_USER_ID, targetUserId); - } - - long endTime = System.currentTimeMillis(); - long totalTime = endTime - startTime; - - // then - System.out.println("==========================================="); - System.out.println("📊 캐싱 미적용 시나리오 결과"); - System.out.println("==========================================="); - System.out.println("호출 횟수: " + ITERATION_COUNT + "번"); - System.out.println("총 소요 시간: " + totalTime + "ms"); - System.out.println("평균 응답 시간: " + (totalTime / (double) ITERATION_COUNT) + "ms"); - System.out.println("DB 쿼리 횟수: " + ITERATION_COUNT + "번"); - System.out.println("===========================================\n"); - - // DB 조회가 매번 발생했는지 검증 - then(userBlockRepository).should(times(ITERATION_COUNT)).findByBlockerUserId(BLOCKER_USER_ID); - } - - @Test - @DisplayName("캐싱 적용 시나리오 - 첫 호출만 DB 조회, 이후 캐시 히트") - void performanceTest_withCache() { - // given: 첫 호출은 캐시 미스, 이후는 캐시 히트 - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - // 첫 번째 호출: 캐시 미스 → DB 조회 - // 두 번째 이후: 캐시 히트 - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - return null; // 첫 호출 - }) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - return cachedBlockedIds; // 이후 호출 - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - // when: 1000번 isBlocked 호출 - long startTime = System.currentTimeMillis(); - - for (int i = 0; i < ITERATION_COUNT; i++) { - Long targetUserId = (long) (i % BLOCKED_USER_COUNT) + 2; - blockService.isBlocked(BLOCKER_USER_ID, targetUserId); - } - - long endTime = System.currentTimeMillis(); - long totalTime = endTime - startTime; - - // then - System.out.println("==========================================="); - System.out.println("📊 캐싱 적용 시나리오 결과"); - System.out.println("==========================================="); - System.out.println("호출 횟수: " + ITERATION_COUNT + "번"); - System.out.println("총 소요 시간: " + totalTime + "ms"); - System.out.println("평균 응답 시간: " + (totalTime / (double) ITERATION_COUNT) + "ms"); - System.out.println("DB 쿼리 횟수: 1번 (첫 호출만)"); - System.out.println("캐시 히트 횟수: " + (ITERATION_COUNT - 1) + "번"); - System.out.println("===========================================\n"); - - // DB 조회는 1번만 발생했는지 검증 - then(userBlockRepository).should(times(1)).findByBlockerUserId(BLOCKER_USER_ID); - } - - @Test - @DisplayName("캐싱 적용 vs 미적용 성능 비교") - void performanceComparison() { - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════╗"); - System.out.println("║ BlockService 캐싱 성능 비교 테스트 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════╣"); - System.out.println("║ 설정값: ║"); - System.out.println("║ - 호출 횟수: " + ITERATION_COUNT + "번 ║"); - System.out.println("║ - 차단된 사용자 수: " + BLOCKED_USER_COUNT + "명 ║"); - System.out.println("║ - 시뮬레이션 DB 지연: " + SIMULATED_DB_LATENCY_MS + "ms ║"); - System.out.println("║ - 시뮬레이션 Redis 지연: " + SIMULATED_REDIS_LATENCY_MS + "ms ║"); - System.out.println("╚═══════════════════════════════════════════════════════════════╝"); - - // ========== 캐싱 미적용 테스트 ========== - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < ITERATION_COUNT; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // ========== 캐싱 적용 테스트 ========== - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - // 첫 호출만 캐시 미스 - List> responses = new ArrayList<>(); - responses.add(null); // 첫 호출: 캐시 미스 - for (int i = 1; i < ITERATION_COUNT; i++) { - responses.add(cachedBlockedIds); // 이후: 캐시 히트 - } - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0] < responses.size()) { - return responses.get(callCount[0]++); - } - return cachedBlockedIds; - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < ITERATION_COUNT; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - // ========== 결과 출력 ========== - double improvement = ((double)(noCacheTime - cacheTime) / noCacheTime) * 100; - double speedup = (double) noCacheTime / cacheTime; - - System.out.println("\n"); - System.out.println("┌───────────────────────────────────────────────────────────────┐"); - System.out.println("│ 📊 성능 비교 결과 │"); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ❌ 캐싱 미적용: %,10d ms (DB 쿼리 %d번) │%n", noCacheTime, ITERATION_COUNT); - System.out.printf("│ ✅ 캐싱 적용: %,10d ms (DB 쿼리 1번) │%n", cacheTime); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ 🚀 성능 향상: %.1f%% 감소 │%n", improvement); - System.out.printf("│ ⚡ 속도 향상: %.1fx 빠름 │%n", speedup); - System.out.println("└───────────────────────────────────────────────────────────────┘"); - - // 검증: 캐싱 적용 시 더 빨라야 함 - assertThat(cacheTime).isLessThan(noCacheTime); - System.out.println("\n✅ 테스트 통과: 캐싱 적용 시 " + String.format("%.1fx", speedup) + " 성능 향상 확인\n"); - } - - @Test - @DisplayName("다양한 호출 횟수에서의 성능 비교") - void performanceComparisonAtDifferentScales() { - int[] iterationCounts = {100, 500, 1000, 5000}; - - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════════════════╗"); - System.out.println("║ 다양한 호출 횟수에서의 캐싱 성능 비교 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════════════════╣"); - System.out.println("║ 호출 횟수 │ 캐싱 미적용 │ 캐싱 적용 │ 성능 향상 │ 속도 향상 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════════════════╣"); - - for (int iterations : iterationCounts) { - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 캐싱 미적용 테스트 - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < iterations; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 캐싱 적용 테스트 - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0]++ == 0) { - return null; // 첫 호출: 캐시 미스 - } - return cachedBlockedIds; // 이후: 캐시 히트 - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < iterations; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - double improvement = ((double)(noCacheTime - cacheTime) / noCacheTime) * 100; - double speedup = (double) noCacheTime / cacheTime; - - System.out.printf("║ %,6d회 │ %,8d ms │ %,7d ms │ %5.1f%% │ %5.1fx ║%n", - iterations, noCacheTime, cacheTime, improvement, speedup); - - assertThat(cacheTime).isLessThan(noCacheTime); - } - - System.out.println("╚═══════════════════════════════════════════════════════════════════════════╝"); - System.out.println("\n💡 결론: 호출 횟수가 많아질수록 캐싱의 효과가 더욱 커집니다.\n"); - } - - @Test - @DisplayName("실제 채팅 시나리오 시뮬레이션 - 1분간 활성 채팅") - void realWorldScenarioSimulation() { - // 시나리오: 1분간 초당 10개의 메시지가 오가는 활성 채팅방 - int messagesPerSecond = 10; - int durationSeconds = 60; - int totalMessages = messagesPerSecond * durationSeconds; - - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════╗"); - System.out.println("║ 🗨️ 실제 채팅 시나리오 시뮬레이션 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════╣"); - System.out.println("║ 시나리오: 1분간 활성 채팅 (초당 10개 메시지) ║"); - System.out.println("║ 총 메시지 수: " + totalMessages + "개 ║"); - System.out.println("║ 각 메시지마다 isBlocked() 호출 발생 ║"); - System.out.println("╚═══════════════════════════════════════════════════════════════╝"); - - // 캐싱 미적용 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < totalMessages; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // 캐싱 적용 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0]++ == 0) { - return null; - } - return cachedBlockedIds; - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < totalMessages; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - double savedTime = noCacheTime - cacheTime; - double speedup = (double) noCacheTime / cacheTime; - - System.out.println("\n"); - System.out.println("┌───────────────────────────────────────────────────────────────┐"); - System.out.println("│ 📊 시뮬레이션 결과 │"); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ❌ 캐싱 미적용 총 지연: %,d ms (%.1f초) │%n", - noCacheTime, noCacheTime / 1000.0); - System.out.printf("│ ✅ 캐싱 적용 총 지연: %,d ms (%.1f초) │%n", - cacheTime, cacheTime / 1000.0); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ⏱️ 절약된 시간: %,.0f ms (%.1f초) │%n", - savedTime, savedTime / 1000.0); - System.out.printf("│ 🚀 속도 향상: %.1fx │%n", speedup); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.println("│ 💡 의미: │"); - System.out.printf("│ - 캐싱 미적용 시 DB에 %d번 쿼리 발생 │%n", totalMessages); - System.out.println("│ - 캐싱 적용 시 DB에 1번만 쿼리 발생 │"); - System.out.printf("│ - 1분 채팅 동안 약 %.1f초의 지연 시간 절약 │%n", savedTime / 1000.0); - System.out.println("└───────────────────────────────────────────────────────────────┘"); - - assertThat(cacheTime).isLessThan(noCacheTime); - } - - private void simulateLatency(long milliseconds) { - try { - Thread.sleep(milliseconds); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java index d484aa8..317c85f 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java @@ -152,6 +152,28 @@ void processMessage_skipsRoomSummaryWhenSenderIsBlockedByReceiver() { then(chatEventProducer).shouldHaveNoInteractions(); } + @Test + @DisplayName("차단된 상대에게 보낸 첫 메시지도 방은 활성화한다 - 차단 해제 후 방이 보여야 한다") + void processMessage_activatesRoomEvenWhenSenderIsBlockedByReceiver() { + // given - 요약 갱신(updateLastMessageIfLatest)이 활성화를 겸하는데 차단 시 + // 그 경로가 통째로 스킵되어 방이 영구 WAITING 으로 남는 구멍이 있었다 + LocalDateTime createdAt = LocalDateTime.of(2026, 5, 16, 10, 6); + ChatRoom room = chatRoom(ROOM_ID, INITIATOR_ID, TARGET_ID); + ChatMessage savedMessage = savedMessage("message-3", INITIATOR_ID, "blocked", MessageType.TALK, createdAt); + + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(true); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + // when + chatService.processMessage(talkRequest(INITIATOR_ID, "sender", "blocked")); + + // then - 활성화만 하고 요약과 알림은 여전히 스킵한다 + then(chatRoomRepository).should().activateRoom(ROOM_ID); + then(chatRoomRepository).should(never()).updateLastMessageIfLatest(anyString(), anyString(), any()); + then(chatEventProducer).shouldHaveNoInteractions(); + } + @Test @DisplayName("READ 메시지는 메시지를 저장하지 않고 발신자의 readAt만 atomic update 한다") void processMessage_readOnlyTouchesLastReadAt() { diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java index 9dbdbd3..c37c7bf 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; @@ -29,6 +30,8 @@ import com.comatching.chat.domain.service.block.BlockService; import com.comatching.chat.global.exception.ChatErrorCode; import com.comatching.chat.infra.client.MemberClient; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; +import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.code.GeneralErrorCode; @@ -303,6 +306,74 @@ void getChatRoomReferencesByMatchingIds_returnsRoomIds() { ); } + @Test + @DisplayName("ensure는 방이 없는 매칭만 그 자리에서 만들고 전체 참조를 돌려준다") + void ensureChatRooms_createsMissingRoomsAndReturnsAllReferences() { + // given - 100번 매칭은 방이 있고 101번은 Kafka 유실 등으로 방이 없다 + ChatRoom existingRoom = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingIdIn(List.of(100L, 101L))) + .willReturn(List.of(existingRoom)); + given(chatRoomRepository.save(any(ChatRoom.class))).willAnswer(invocation -> { + ChatRoom saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", "room-101"); + return saved; + }); + + List requests = List.of( + new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID), + new ChatRoomEnsureRequest(101L, MEMBER_ID, SECOND_OTHER_MEMBER_ID) + ); + + // when + List result = chatRoomService.ensureChatRooms(requests); + + // then + assertThat(result).extracting("matchingId", "chatRoomId") + .containsExactlyInAnyOrder( + tuple(100L, "room-100"), + tuple(101L, "room-101") + ); + then(chatRoomRepository).should().save(argThat(room -> + room.getMatchingId().equals(101L) + && room.getInitiatorUserId().equals(MEMBER_ID) + && room.getTargetUserId().equals(SECOND_OTHER_MEMBER_ID))); + } + + @Test + @DisplayName("ensure는 방이 전부 있으면 아무것도 만들지 않는다") + void ensureChatRooms_createsNothingWhenAllRoomsExist() { + // given + ChatRoom existingRoom = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingIdIn(List.of(100L))) + .willReturn(List.of(existingRoom)); + + // when + List result = + chatRoomService.ensureChatRooms(List.of(new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID))); + + // then + assertThat(result).extracting("chatRoomId").containsExactly("room-100"); + then(chatRoomRepository).should(never()).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("ensure는 동시 생성 충돌이 나면 이미 만들어진 방을 다시 읽어 돌려준다") + void ensureChatRooms_recoversFromDuplicateKeyRace() { + // given - findByMatchingIdIn 과 save 사이에 Kafka 컨슈머가 먼저 방을 만든 경우 + given(chatRoomRepository.findByMatchingIdIn(List.of(100L))).willReturn(List.of()); + given(chatRoomRepository.save(any(ChatRoom.class))) + .willThrow(new DuplicateKeyException("matchingId dup")); + ChatRoom concurrentlyCreated = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingId(100L)).willReturn(Optional.of(concurrentlyCreated)); + + // when + List result = + chatRoomService.ensureChatRooms(List.of(new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID))); + + // then + assertThat(result).extracting("chatRoomId").containsExactly("room-100"); + } + private ChatRoom chatRoom(String id, Long matchingId, Long initiatorId, Long targetId, LocalDateTime readAt) { ChatRoom room = ChatRoom.builder() .matchingId(matchingId) diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java new file mode 100644 index 0000000..2fc9469 --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java @@ -0,0 +1,122 @@ +package com.comatching.chat.domain.service.presence; + +import static org.assertj.core.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +class ChatRoomPresenceTrackerTest { + + private static final String ROOM_ID = "room-1"; + private static final Long MEMBER_ID = 1L; + + private ChatRoomPresenceTracker tracker; + + @BeforeEach + void setUp() { + tracker = new ChatRoomPresenceTracker(); + } + + @Test + @DisplayName("채팅방 토픽을 구독하면 그 방을 보고 있는 것으로 기록한다") + void subscribe_marksMemberAsViewing() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isTrue(); + } + + @Test + @DisplayName("구독한 적 없으면 보고 있지 않다") + void isViewing_falseByDefault() { + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("채팅방 토픽이 아닌 구독은 무시한다") + void subscribe_ignoresNonChatRoomDestinations() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/user/queue/errors", MEMBER_ID)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("구독을 해지하면 더 이상 보고 있지 않다") + void unsubscribe_removesViewing() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + tracker.onUnsubscribe(unsubscribeEvent("session-1", "sub-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("연결이 끊기면 그 세션의 모든 구독이 사라진다") + void disconnect_removesAllSubscriptionsOfSession() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + tracker.onSubscribe(subscribeEvent("session-1", "sub-2", "/topic/chat.room.room-2", MEMBER_ID)); + + tracker.onDisconnect(disconnectEvent("session-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + assertThat(tracker.isViewing("room-2", MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("같은 방을 두 세션으로 보다가 한 세션만 끊기면 여전히 보고 있다") + void disconnect_keepsViewingWhileAnotherSessionRemains() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + tracker.onSubscribe(subscribeEvent("session-2", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + tracker.onDisconnect(disconnectEvent("session-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isTrue(); + } + + @Test + @DisplayName("memberId 없는 구독은 기록하지 않는다") + void subscribe_ignoresSessionWithoutMemberId() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, null)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + private SessionSubscribeEvent subscribeEvent(String sessionId, String subId, String destination, Long memberId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId(sessionId); + accessor.setSubscriptionId(subId); + accessor.setDestination(destination); + Map attributes = new HashMap<>(); + if (memberId != null) { + attributes.put("memberId", memberId); + } + accessor.setSessionAttributes(attributes); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionSubscribeEvent(this, message); + } + + private SessionUnsubscribeEvent unsubscribeEvent(String sessionId, String subId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.UNSUBSCRIBE); + accessor.setSessionId(sessionId); + accessor.setSubscriptionId(subId); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionUnsubscribeEvent(this, message); + } + + private SessionDisconnectEvent disconnectEvent(String sessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.DISCONNECT); + accessor.setSessionId(sessionId); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionDisconnectEvent(this, message, sessionId, null); + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java new file mode 100644 index 0000000..375338c --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java @@ -0,0 +1,177 @@ +package com.comatching.chat.domain.service.redis; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.chat.domain.dto.ChatMessageResponse; +import com.comatching.chat.domain.entity.ChatRoom; +import com.comatching.chat.domain.enums.MessageType; +import com.comatching.chat.domain.repository.ChatRoomRepository; +import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.domain.service.chat.ChatService; +import com.comatching.chat.domain.service.presence.ChatRoomPresenceTracker; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@ExtendWith(MockitoExtension.class) +class RedisSubscriberTest { + + private static final String ROOM_ID = "room-1"; + private static final Long INITIATOR_ID = 1L; + private static final Long TARGET_ID = 2L; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private SimpMessageSendingOperations messagingTemplate; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private BlockService blockService; + + @Mock + private ChatRoomPresenceTracker presenceTracker; + + @Mock + private ChatService chatService; + + @Mock + private RedisPublisher redisPublisher; + + private RedisSubscriber redisSubscriber; + + private final ObjectMapper objectMapper = createObjectMapper(); + + @BeforeEach + void setUp() { + redisSubscriber = new RedisSubscriber( + objectMapper, + redisTemplate, + messagingTemplate, + chatRoomRepository, + blockService, + presenceTracker, + chatService, + redisPublisher + ); + given(redisTemplate.getStringSerializer()).willReturn(new StringRedisSerializer()); + } + + @Test + @DisplayName("수신자가 방을 보고 있으면 브로드캐스트 후 서버가 대신 읽음 처리하고 READ를 발행한다") + void onMessage_autoReadsWhenReceiverIsViewingRoom() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + given(presenceTracker.isViewing(ROOM_ID, TARGET_ID)).willReturn(true); + + ChatMessageResponse readResponse = readResponse(TARGET_ID); + given(chatService.markAsRead(ROOM_ID, TARGET_ID)).willReturn(readResponse); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).should().markAsRead(ROOM_ID, TARGET_ID); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(readResponse)); + } + + @Test + @DisplayName("수신자가 방을 보고 있지 않으면 읽음 처리하지 않는다") + void onMessage_skipsAutoReadWhenReceiverIsNotViewing() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + given(presenceTracker.isViewing(ROOM_ID, TARGET_ID)).willReturn(false); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("READ 메시지에는 다시 읽음 처리하지 않는다 - 무한 발행을 막는다") + void onMessage_neverAutoReadsReadMessages() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + + redisSubscriber.onMessage(redisMessage(readResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("수신자가 발신자를 차단했으면 전달도 읽음 처리도 하지 않는다") + void onMessage_skipsEverythingWhenBlocked() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(true); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).shouldHaveNoInteractions(); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + private ChatRoom chatRoom() { + ChatRoom room = ChatRoom.builder() + .matchingId(100L) + .initiatorUserId(INITIATOR_ID) + .targetUserId(TARGET_ID) + .build(); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + return room; + } + + private ChatMessageResponse talkResponse(Long senderId) { + return new ChatMessageResponse( + "message-1", ROOM_ID, senderId, "hello", MessageType.TALK, + LocalDateTime.of(2026, 8, 26, 10, 0), 1 + ); + } + + private ChatMessageResponse readResponse(Long senderId) { + return new ChatMessageResponse( + null, ROOM_ID, senderId, null, MessageType.READ, + LocalDateTime.of(2026, 8, 26, 10, 1), 0 + ); + } + + private DefaultMessage redisMessage(ChatMessageResponse response) throws Exception { + byte[] body = objectMapper.writeValueAsBytes(response); + return new DefaultMessage("chatroom".getBytes(StandardCharsets.UTF_8), body); + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java b/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java new file mode 100644 index 0000000..5dcf5eb --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java @@ -0,0 +1,37 @@ +package com.comatching.chat.global.exception; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; + +class ChatErrorHandlerTest { + + private final ChatErrorHandler handler = new ChatErrorHandler(); + + @Test + @DisplayName("존재하지 않는 채팅방 예외를 에러 코드가 담긴 응답으로 바꿔 내려준다") + void handleBusinessException_carriesChatErrorCode() { + ApiResponse response = + handler.handleBusinessException(new BusinessException(ChatErrorCode.NOT_EXIST_CHATROOM)); + + assertThat(response.getCode()).isEqualTo(ChatErrorCode.NOT_EXIST_CHATROOM.getCode()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(response.getMessage()).isEqualTo(ChatErrorCode.NOT_EXIST_CHATROOM.getMessage()); + } + + @Test + @DisplayName("남의 방에 보낸 경우도 같은 형태로 내려준다") + void handleBusinessException_carriesGeneralErrorCode() { + ApiResponse response = + handler.handleBusinessException(new BusinessException(GeneralErrorCode.FORBIDDEN)); + + assertThat(response.getCode()).isEqualTo(GeneralErrorCode.FORBIDDEN.getCode()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java index 98eae72..a02167f 100644 --- a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java +++ b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java @@ -23,6 +23,7 @@ import com.comatching.chat.domain.service.chat.ChatService; import com.comatching.chat.domain.service.chatroom.ChatRoomService; import com.comatching.chat.domain.service.redis.RedisPublisher; +import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.service.S3Service; @ExtendWith(MockitoExtension.class) @@ -78,4 +79,60 @@ void sendMessage_publishesAfterProcessingMessage() { inOrder.verify(chatService).processMessage(securedRequest); inOrder.verify(redisPublisher).publish(any(ChannelTopic.class), eq(response)); } + + @Test + @DisplayName("세션에 nickname이 없어도 메시지를 정상 처리하고 발행한다") + void sendMessage_worksWhenSessionHasNoNickname() { + // given - 프로필 완성 전에 발급된 토큰이면 게이트웨이가 X-Member-Nickname 을 안 보낸다 + ChatMessageRequest request = new ChatMessageRequest(ROOM_ID, null, null, "hello", MessageType.TALK); + ChatMessageRequest securedRequest = + new ChatMessageRequest(ROOM_ID, MEMBER_ID, "", "hello", MessageType.TALK); + ChatMessageResponse response = new ChatMessageResponse( + "message-1", + ROOM_ID, + MEMBER_ID, + "hello", + MessageType.TALK, + LocalDateTime.of(2026, 5, 18, 0, 0), + 1 + ); + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); + headerAccessor.setSessionAttributes(new HashMap<>(Map.of("memberId", MEMBER_ID))); + + given(chatService.processMessage(securedRequest)).willReturn(response); + given(redisPublisher.publish(any(ChannelTopic.class), eq(response))).willReturn(1L); + + // when + chatController.sendMessage(request, headerAccessor); + + // then - 닉네임은 알림 미리보기용이라 없으면 빈 값으로 두고 전송은 살린다 + then(chatService).should().processMessage(securedRequest); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(response)); + } + + @Test + @DisplayName("REST로도 읽음 처리할 수 있고 READ 이벤트가 상대에게 전파된다") + void markAsRead_viaRestPublishesReadEvent() { + // given - WebSocket 이 아직 안 붙었거나 끊긴 상태에서 방을 연 경우 + ChatMessageResponse readResponse = new ChatMessageResponse( + null, + ROOM_ID, + MEMBER_ID, + null, + MessageType.READ, + LocalDateTime.of(2026, 8, 26, 10, 0), + 0 + ); + MemberInfo memberInfo = new MemberInfo(MEMBER_ID, "user@test.com", "ROLE_USER"); + + given(chatService.markAsRead(ROOM_ID, MEMBER_ID)).willReturn(readResponse); + given(redisPublisher.publish(any(ChannelTopic.class), eq(readResponse))).willReturn(1L); + + // when + chatController.markAsRead(ROOM_ID, memberInfo); + + // then + then(chatService).should().markAsRead(ROOM_ID, MEMBER_ID); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(readResponse)); + } } diff --git a/common-module/build.gradle b/common-module/build.gradle index 04f40d9..5285683 100644 --- a/common-module/build.gradle +++ b/common-module/build.gradle @@ -62,6 +62,10 @@ dependencies { // feign api 'org.springframework.cloud:spring-cloud-starter-openfeign' + // 서킷브레이커 (Resilience4j) — Feign 내부 호출 보호. + // 설정은 feign-resilience.yml, 브레이커 이름 규칙은 InternalFeignConfig 참고. + api 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + // kafka api 'org.springframework.kafka:spring-kafka' diff --git a/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java new file mode 100644 index 0000000..ac24b03 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java @@ -0,0 +1,58 @@ +package com.comatching.common.config; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.stereotype.Component; + +/** + * 서킷브레이커가 씌운 래퍼를 벗겨 Feign 원본 예외를 복원한다. + * + * spring.cloud.openfeign.circuitbreaker.enabled 를 켜면 폴백 없는 Feign 호출의 + * 모든 예외가 NoFallbackAvailableException 으로 래핑되어 나온다. 그대로 두면 + * 기존 호출부의 catch (FeignException) 분기(예: 404 를 비즈니스 에러로 변환)가 + * 전부 조용히 무력화된다 — 컴파일 에러도 없이 의미가 바뀌는 종류의 회귀다. + * + * 그래서 Feign 클라이언트 빈을 한 겹 감싸 래퍼의 cause(FeignException, + * CallNotPermittedException 등)를 그대로 다시 던진다. 호출부 입장에서는 + * 서킷브레이커 도입 전과 같은 예외 계약이 유지되고, 브레이커 open 만 + * CallNotPermittedException 이라는 새 예외로 추가된다. + */ +@Component +public class FeignCircuitBreakerExceptionUnwrapper implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (!isFeignClient(bean)) { + return bean; + } + + return Proxy.newProxyInstance( + bean.getClass().getClassLoader(), + bean.getClass().getInterfaces(), + (proxy, method, args) -> { + try { + return method.invoke(bean, args); + } catch (InvocationTargetException e) { + Throwable thrown = e.getTargetException(); + if (thrown instanceof NoFallbackAvailableException && thrown.getCause() != null) { + throw thrown.getCause(); + } + throw thrown; + } + } + ); + } + + private boolean isFeignClient(Object bean) { + for (Class itf : bean.getClass().getInterfaces()) { + if (itf.isAnnotationPresent(FeignClient.class)) { + return true; + } + } + return false; + } +} diff --git a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java index 5a7dedc..d983d6a 100644 --- a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java +++ b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java @@ -1,6 +1,7 @@ package com.comatching.common.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.openfeign.CircuitBreakerNameResolver; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.StringUtils; @@ -22,4 +23,18 @@ public RequestInterceptor internalServiceTokenRequestInterceptor( } }; } + + /** + * 서킷브레이커를 Feign 클라이언트(name) 단위로 묶는다. + * + * 기본 리졸버는 메서드 시그니처까지 붙여 브레이커를 만드는데, 그러면 + * 트래픽이 메서드별로 쪼개져 minimum-number-of-calls(feign-resilience.yml)에 + * 도달하지 못한 브레이커는 영영 열리지 않는다. 장애는 엔드포인트가 아니라 + * 프로세스 단위로 오므로(피호출 서비스의 GC 멈춤·커넥션 고갈은 그 서비스의 + * 모든 엔드포인트를 함께 느리게 만든다) 서비스 단위 판단이 맞다. + */ + @Bean + public CircuitBreakerNameResolver feignClientCircuitBreakerNameResolver() { + return (feignClientName, target, method) -> feignClientName; + } } diff --git a/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java b/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java new file mode 100644 index 0000000..4991195 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java @@ -0,0 +1,16 @@ +package com.comatching.common.dto.chat; + +/** + * 매칭에 대응하는 채팅방이 있는지 확인하고 없으면 만들어 달라는 요청. + * + * 방 생성은 원래 매칭 성공 Kafka 이벤트로만 일어났는데, 발행이 유실되면 + * 그 매칭은 영구히 방이 없었다. 조회 시점에 생성까지 맡기면 유실분이 + * 자동으로 복구된다. 생성에 필요한 참여자 정보는 매칭 이력이 알고 있으므로 + * matching-service 가 채워 보낸다. + */ +public record ChatRoomEnsureRequest( + Long matchingId, + Long initiatorUserId, + Long targetUserId +) { +} diff --git a/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java new file mode 100644 index 0000000..aa77eb9 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java @@ -0,0 +1,9 @@ +package com.comatching.common.dto.member; + +public record AdminGiftCardUserProfileDto( + Long id, + String email, + String realName, + String nickname +) { +} diff --git a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java index d1d8881..cac4d4c 100644 --- a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java +++ b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java @@ -30,6 +30,9 @@ public enum GeneralErrorCode implements ErrorCode { UNAUTHORIZED("GEN-010", HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."), FORBIDDEN("GEN-011", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + // 503 Service Unavailable + INTERNAL_SERVICE_UNAVAILABLE("GEN-105", HttpStatus.SERVICE_UNAVAILABLE, "내부 서비스가 일시적으로 응답할 수 없습니다. 잠시 후 다시 시도해주세요."), + // 500 Internal Server Error INTERNAL_SERVER_ERROR("GEN-099", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), DATABASE_ERROR("GEN-100", HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 오류가 발생했습니다."), diff --git a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java index d664fda..ee73e5b 100644 --- a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java +++ b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; @@ -23,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -138,6 +141,61 @@ public ResponseEntity> handleNotFoundException(Exception e) { .body(ApiResponse.errorResponse(GeneralErrorCode.NOT_FOUND)); } + /** + * 서킷브레이커 open (503) + * + * 피호출 서비스가 장애 판정을 받아 호출이 차단된 상태. 서버 오류(500)가 + * 아니라 일시적 불가(503)로 내려야 클라이언트·게이트웨이가 재시도 판단을 + * 할 수 있다. + */ + @ExceptionHandler(CallNotPermittedException.class) + public ResponseEntity> handleCallNotPermittedException(CallNotPermittedException e) { + log.error("[Circuit Breaker Open] Breaker: {}", e.getCausingCircuitBreakerName()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * Feign 연결 실패·타임아웃 (503) + * + * FeignException 의 하위 타입이지만 더 구체적이라 이 핸들러가 먼저 잡는다. + * HTTP 응답을 받은 게 아니므로 아래 핸들러처럼 응답 본문을 전달할 수 없고, + * 피호출 서비스의 일시적 불가(503)로 내린다. + */ + @ExceptionHandler(RetryableException.class) + public ResponseEntity> handleFeignRetryableException(RetryableException e) { + log.error("[Feign Timeout] {}", e.getMessage()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * 서킷브레이커 래퍼 언랩 (방어선) + * + * 정상 경로에서는 FeignCircuitBreakerExceptionUnwrapper 가 프록시 단계에서 + * 원본 예외로 복원하므로 여기까지 오지 않는다. 그 층을 우회한 호출이 + * 생기더라도 원인에 맞는 응답이 나가도록 한 번 더 푼다. + */ + @ExceptionHandler(NoFallbackAvailableException.class) + public ResponseEntity handleNoFallbackAvailableException(NoFallbackAvailableException e) { + Throwable cause = e.getCause(); + + if (cause instanceof CallNotPermittedException callNotPermitted) { + return handleCallNotPermittedException(callNotPermitted); + } + if (cause instanceof RetryableException retryable) { + return handleFeignRetryableException(retryable); + } + if (cause instanceof FeignException feignException) { + return handleFeignException(feignException); + } + return handleException(e); + } + /** * Feign Client 예외 처리 */ diff --git a/common-module/src/main/resources/feign-resilience.yml b/common-module/src/main/resources/feign-resilience.yml new file mode 100644 index 0000000..7e77868 --- /dev/null +++ b/common-module/src/main/resources/feign-resilience.yml @@ -0,0 +1,69 @@ +# Feign 내부 호출 공통 타임아웃 설정. +# Feign 클라이언트를 쓰는 서비스의 application.yml 에서 +# spring.config.import: classpath:feign-resilience.yml 로 불러온다. +# 값이 이 파일 한 곳에만 존재해야 서비스 간 설정 드리프트가 안 생긴다. +# +# 같은 EC2 호스트 안의 내부 REST 호출 전제라 공격적으로 잡는다. +# OpenFeign 기본값(connect 10s / read 60s)은 피호출 서비스가 멈추면 +# 호출 서비스의 톰캣 스레드를 60초씩 붙잡아 장애를 그대로 전파한다. +# +# 두 값은 항목별로 독립 상속된다. FeignClientFactoryBean 은 default 블록을 +# 먼저 적용해 필드를 채운 뒤 클라이언트별 블록을 덧씌우는데, 클라이언트 +# 블록에 없는 항목은 앞 단계 값을 그대로 유지한다 — +# read-timeout 만 적어도 connect 는 default 의 1s 가 남는다. +# 아래에서 항목마다 두 값을 다 적는 건 유효 타임아웃을 한눈에 읽기 위한 +# 관례일 뿐 동작상 필수는 아니다. (키는 @FeignClient 의 name/contextId, 단위 ms) +spring: + cloud: + openfeign: + # 모든 Feign 호출을 서킷브레이커로 감싼다. 브레이커 이름은 메서드 단위가 + # 아니라 Feign 클라이언트(name) 단위로 묶는다 — InternalFeignConfig 의 + # CircuitBreakerNameResolver 참고. + circuitbreaker: + enabled: true + client: + config: + default: + connect-timeout: 1000 + read-timeout: 3000 + # 배치성 호출은 읽기 시간을 더 준다. + user-service: # POST /profiles/bulk (matching·chat 공용) + connect-timeout: 1000 + read-timeout: 5000 + chat-service: # POST /references, /ensure — 매칭 이력 일괄 조회·생성 + connect-timeout: 1000 + read-timeout: 5000 + user-service-admin: # 관리자 페이징 조회 + connect-timeout: 1000 + read-timeout: 5000 + circuitbreaker: + resilience4j: + # 호출 스레드에서 그대로 실행한다(세마포어 방식). 스레드풀 실행을 켜두면 + # ① Spring Cloud 기본 TimeLimiter(1s)가 위의 read-timeout 보다 먼저 + # 호출을 끊고 ② EC2 한 대에 6개 JVM 이 뜨는 전제에서 서비스마다 + # 스레드풀이 하나씩 늘어난다. 끄면 Feign read-timeout 이 유일한 + # 시간 상한이 된다. + disable-thread-pool: true + +resilience4j: + circuitbreaker: + configs: + # 모든 브레이커(= Feign 클라이언트 단위)의 공통값. 클라이언트별로 바꾸려면 + # resilience4j.circuitbreaker.instances. 으로 오버라이드한다. + default: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + # 내부 호출은 클라이언트별 트래픽이 적다. 이 값에 못 미치면 브레이커가 + # 영영 안 열리므로 낮게 잡는다. + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + # read-timeout(3s)까지 가기 전에 "느려짐"을 먼저 감지한다. + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 80 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + # 4xx 는 피호출 서비스가 정상 동작하며 내린 판정(없는 리소스, 잘못된 + # 요청)이라 장애 신호가 아니다. 실패율에 넣으면 비즈니스 에러가 + # 브레이커를 열어 멀쩡한 서비스로 가는 호출까지 차단한다. + ignore-exceptions: + - feign.FeignException$FeignClientException diff --git a/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java b/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java deleted file mode 100644 index b703c38..0000000 --- a/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.common; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class CommonModuleApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java new file mode 100644 index 0000000..e089c6a --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java @@ -0,0 +1,225 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; + +import com.sun.net.httpserver.HttpServer; + +import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; + +/** + * Feign + 서킷브레이커 체인이 feign-resilience.yml 대로 실제 동작하는지 검증. + * + * 설정 바인딩만 확인해서는(FeignResilienceConfigTest) 잡히지 않는 것들을 + * 실제 HTTP 서버(JDK 내장, 루프백)를 상대로 본다: + * 1) 브레이커가 메서드가 아닌 Feign 클라이언트(name) 단위로 만들어지고 + * yml 의 공통값이 그 브레이커에 실제로 들어간다 + * 2) 5xx 가 쌓이면 브레이커가 열려 이후 호출은 서버에 닿지 않는다 + * 3) 4xx 는 실패로 세지 않는다 — 비즈니스 에러가 브레이커를 열면 안 된다 + * 4) Spring Cloud 기본 TimeLimiter(1s)가 개입하지 않는다 — 켜져 있다면 + * read-timeout(3s) 안쪽의 1s 초과 호출이 잘려나간다 + * 5) read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다 + * 6) 호출부에는 래퍼(NoFallbackAvailableException)가 아니라 원본 예외가 + * 도달한다 — 아래의 모든 예외 타입 단언이 곧 언랩퍼 + * (FeignCircuitBreakerExceptionUnwrapper) 검증이다 + * + * 브로커·DB 없이 루프백 소켓만 쓰므로 단위 테스트 태스크에서 돈다. + */ +@SpringBootTest( + classes = FeignCircuitBreakerTest.App.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + // 운영에 나가는 파일 그대로를 물려 테스트한다 + "spring.config.import=classpath:feign-resilience.yml", + // 타임아웃 검증용 클라이언트만 read 를 짧게 줄인다 (기본 3s 를 기다리지 않도록) + "spring.cloud.openfeign.client.config.cb-timeout.connect-timeout=1000", + "spring.cloud.openfeign.client.config.cb-timeout.read-timeout=500" + } +) +@DisplayName("Feign 서킷브레이커 동작") +class FeignCircuitBreakerTest { + + private static final HttpServer SERVER; + private static final AtomicInteger FAIL_HITS = new AtomicInteger(); + + private static final int SLOW_DELAY_MILLIS = 1500; + private static final String SLOW_BODY = "slow-ok"; + + static { + try { + SERVER = HttpServer.create(new InetSocketAddress(0), 0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + SERVER.createContext("/fail", exchange -> { + FAIL_HITS.incrementAndGet(); + respond(exchange, 500, "boom"); + }); + SERVER.createContext("/notfound", exchange -> respond(exchange, 404, "no such thing")); + SERVER.createContext("/slow", exchange -> { + try { + Thread.sleep(SLOW_DELAY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + respond(exchange, 200, SLOW_BODY); + }); + SERVER.start(); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain;charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + @DynamicPropertySource + static void serverUrl(DynamicPropertyRegistry registry) { + registry.add("cb-it.base-url", () -> "http://localhost:" + SERVER.getAddress().getPort()); + } + + @AfterAll + static void stopServer() { + SERVER.stop(0); + } + + @FeignClient(name = "cb-server-error", url = "${cb-it.base-url}") + interface ServerErrorClient { + @GetMapping("/fail") + String call(); + } + + @FeignClient(name = "cb-client-error", url = "${cb-it.base-url}") + interface ClientErrorClient { + @GetMapping("/notfound") + String call(); + } + + @FeignClient(name = "cb-slow", url = "${cb-it.base-url}") + interface SlowClient { + @GetMapping("/slow") + String call(); + } + + @FeignClient(name = "cb-timeout", url = "${cb-it.base-url}") + interface TimeoutClient { + @GetMapping("/slow") + String call(); + } + + @SpringBootConfiguration + @ImportAutoConfiguration({ + org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class, + org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration.class, + org.springframework.cloud.openfeign.FeignAutoConfiguration.class, + io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration.class, + io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterAutoConfiguration.class, + org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JAutoConfiguration.class + }) + @EnableFeignClients(clients = { + ServerErrorClient.class, ClientErrorClient.class, SlowClient.class, TimeoutClient.class + }) + @Import({InternalFeignConfig.class, FeignCircuitBreakerExceptionUnwrapper.class}) + static class App { + } + + @Autowired + Resilience4JCircuitBreakerFactory circuitBreakerFactory; + + @Autowired + ServerErrorClient serverErrorClient; + + @Autowired + ClientErrorClient clientErrorClient; + + @Autowired + SlowClient slowClient; + + @Autowired + TimeoutClient timeoutClient; + + private CircuitBreaker breakerOf(String name) { + return circuitBreakerFactory.getCircuitBreakerRegistry() + .find(name) + .orElseThrow(() -> new AssertionError("브레이커가 없다: " + name)); + } + + @Test + @DisplayName("5xx 가 최소 호출 수만큼 쌓이면 브레이커가 열려 서버에 닿지 않는다") + void opensOnServerErrors() { + int minimumCalls = 5; // feign-resilience.yml 의 minimum-number-of-calls + + for (int i = 0; i < minimumCalls; i++) { + assertThatThrownBy(serverErrorClient::call).isInstanceOf(FeignException.class); + } + + int hitsBeforeOpen = FAIL_HITS.get(); + assertThatThrownBy(serverErrorClient::call).isInstanceOf(CallNotPermittedException.class); + assertThat(FAIL_HITS.get()) + .as("차단된 호출은 서버에 도달하면 안 된다") + .isEqualTo(hitsBeforeOpen); + + // 브레이커 이름이 메서드가 아니라 Feign 클라이언트 name 그대로인지, + // yml 공통값이 그 브레이커에 들어갔는지도 여기서 같이 확인한다. + CircuitBreaker breaker = breakerOf("cb-server-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + assertThat(breaker.getCircuitBreakerConfig().getMinimumNumberOfCalls()).isEqualTo(minimumCalls); + assertThat(breaker.getCircuitBreakerConfig().getSlidingWindowSize()).isEqualTo(10); + } + + @Test + @DisplayName("4xx 는 실패로 세지 않는다 — 비즈니스 에러로 브레이커가 열리면 안 된다") + void ignoresClientErrors() { + for (int i = 0; i < 8; i++) { + assertThatThrownBy(clientErrorClient::call) + .isInstanceOf(FeignException.NotFound.class); + } + + CircuitBreaker breaker = breakerOf("cb-client-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(breaker.getMetrics().getNumberOfFailedCalls()).isZero(); + } + + @Test + @DisplayName("read-timeout 안쪽의 느린 호출은 성공한다 — 기본 TimeLimiter(1s) 미개입 증명") + void slowCallWithinReadTimeoutSucceeds() { + // disable-thread-pool 이 풀리면 Spring Cloud 기본 TimeLimiter(1s)가 + // 이 1.5s 호출을 read-timeout(3s) 전에 끊어 이 테스트가 깨진다. + assertThat(slowClient.call()).isEqualTo(SLOW_BODY); + } + + @Test + @DisplayName("read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다") + void timeoutCountsAsFailure() { + assertThatThrownBy(timeoutClient::call).isInstanceOf(RetryableException.class); + + assertThat(breakerOf("cb-timeout").getMetrics().getNumberOfFailedCalls()).isEqualTo(1); + } +} diff --git a/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java b/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java new file mode 100644 index 0000000..026a2a2 --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java @@ -0,0 +1,62 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.cloud.openfeign.FeignClientProperties; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +/** + * feign-resilience.yml 이 실제로 FeignClientProperties 에 바인딩되는지 검증. + * + * 이 파일은 각 서비스가 spring.config.import 로 불러 쓰는 공통 타임아웃의 + * 단일 원천인데, 키 경로나 필드명이 틀려도 Spring 은 조용히 무시하고 + * 기본값(connect 10s / read 60s)으로 돌아간다 — 즉 "타임아웃이 안 걸린 + * 상태"가 아무 에러 없이 운영까지 나간다. 여기서 파싱과 바인딩을 잡는다. + */ +@DisplayName("Feign 공통 타임아웃 설정 바인딩") +class FeignResilienceConfigTest { + + private FeignClientProperties load() throws IOException { + List> sources = new YamlPropertySourceLoader() + .load("feign-resilience", new ClassPathResource("feign-resilience.yml")); + return new Binder(ConfigurationPropertySources.from(sources.get(0))) + .bind("spring.cloud.openfeign.client", Bindable.of(FeignClientProperties.class)) + .orElseThrow(() -> new IllegalStateException("spring.cloud.openfeign.client 바인딩 실패")); + } + + @Test + @DisplayName("default 타임아웃이 짧게 잡혀 있다") + void defaultTimeouts() throws IOException { + FeignClientProperties.FeignClientConfiguration config = load().getConfig().get("default"); + + assertThat(config).isNotNull(); + assertThat(config.getConnectTimeout()).isEqualTo(1000); + assertThat(config.getReadTimeout()).isEqualTo(3000); + } + + @Test + @DisplayName("모든 클라이언트 항목은 connect·read 타임아웃을 둘 다 가진다") + void everyEntryHasBothTimeouts() throws IOException { + // 동작상 필수는 아니다 — FeignClientFactoryBean 은 default 블록을 먼저 + // 적용한 뒤 클라이언트 블록을 덧씌우고, 없는 항목은 앞 단계 값을 그대로 + // 유지하므로 read-timeout 만 적어도 connect 는 default 의 1s 가 남는다. + // 그래도 항목마다 두 값을 다 적게 강제하는 건, 유효 타임아웃을 상속을 + // 머릿속으로 계산하지 않고 그 줄에서 바로 읽기 위해서다. + load().getConfig().forEach((name, config) -> { + assertThat(config.getConnectTimeout()) + .as("%s 의 connect-timeout", name).isNotNull(); + assertThat(config.getReadTimeout()) + .as("%s 의 read-timeout", name).isNotNull(); + }); + } +} diff --git a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java index 4767848..e350b36 100644 --- a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java +++ b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java @@ -18,6 +18,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -63,6 +64,7 @@ "comatching.kafka.dlt-topics=" + KafkaDltRedriveIT.REDRIVE_TOPIC }) @DisplayName("Kafka DLT 재적재") +@Tag("integration") class KafkaDltRedriveIT { static final String REDRIVE_TOPIC = "redrive-it-topic"; diff --git a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java index a1b31d6..ef41705 100644 --- a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java +++ b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java @@ -17,6 +17,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -64,6 +65,7 @@ "spring.kafka.consumer.group-id=kafka-retry-dlt-it" }) @DisplayName("Kafka 재시도·DLT 정책") +@Tag("integration") class KafkaRetryAndDltIT { static final String ALWAYS_FAILS_TOPIC = "retry-it-always-fails"; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2a5b2ef..9ef9658 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -42,6 +42,8 @@ volumes: kafka-data: mongo-data: redis-data: + prometheus-data: + grafana-data: services: @@ -95,12 +97,24 @@ services: volumes: - kafka-data:/var/lib/kafka/data mem_limit: 1280m + # 힙(-Xmx1g) 밖 페이지캐시가 잠깐 넘칠 때를 위한 여유 256m. JVM 앱들과 + # 달리 약간의 스왑은 허용하되 무제한(기본값: mem_limit 의 2배)은 막는다. + memswap_limit: 1536m healthcheck: - test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:29092 >/dev/null 2>&1"] - interval: 10s - timeout: 10s - retries: 30 - start_period: 30s + # 예전엔 kafka-broker-api-versions.sh 였는데, 이 스크립트는 호출마다 + # JVM 을 새로 띄워 한 번에 3~6초를 태운다. interval 10s 와 만나면 + # 브로커 컨테이너가 유휴 상태에서도 시간의 30~40%를 헬스체크에 쓴다 + # (2026-08-26 실측 — 2 vCPU 호스트 유휴 CPU 버스트의 주범). + # TCP 접속 확인으로 바꾼다. 브로커는 기동 마지막에 리스너를 열므로 + # 포트가 열렸다 = 요청을 받는다이고, 이 헬스체크의 소비자는 + # compose 기동 순서(kafka-topic-init·앱들의 depends_on)뿐이다. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/kafka/29092"] + interval: 30s + timeout: 5s + retries: 10 + start_period: 60s + # 기동 중에만 2초 간격으로 촘촘히 본다(engine 25+). 안정 상태는 30s. + start_interval: 2s logging: *logging # auto-create 를 껐으므로 토픽을 누가 만들지 정해야 한다. @@ -154,12 +168,19 @@ services: volumes: - mongo-data:/data/db mem_limit: 768m + # WiredTiger 캐시(0.25GB) 밖 워킹셋이 잠깐 넘칠 때를 위한 여유 128m. + memswap_limit: 896m healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] - interval: 10s + # mongosh 는 Node.js 앱이라 호출마다 콜드스타트로 ~1초를 태운다. + # interval 10s 면 유휴 CPU 의 ~10%를 헬스체크가 먹는다 (2026-08-26 실측). + # mongod 는 스토리지 초기화가 끝난 뒤에야 리스너를 열므로 + # TCP 접속 성공이 곧 준비됐다는 신호다. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/27017"] + interval: 30s timeout: 5s - retries: 20 - start_period: 20s + retries: 10 + start_period: 30s + start_interval: 2s logging: *logging redis: @@ -185,6 +206,9 @@ services: volumes: - redis-data:/data mem_limit: 320m + # AOF rewrite 가 fork 하며 copy-on-write 로 페이지를 복제한다. 그 순간 + # 페이지가 스왑에 있으면 지연이 폭발하므로 스왑 사용을 0 으로 못박는다. + memswap_limit: 320m healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -209,6 +233,13 @@ services: redis: condition: service_healthy mem_limit: 768m + # mem_limit 과 같은 값 = 스왑 사용 0. memswap_limit 을 생략하면 docker 가 + # memory+swap 을 mem_limit 의 2배로 잡아서, 호스트에 스왑이 생기는 순간 + # 실효 상한이 조용히 두 배가 된다. JVM 은 full GC 가 힙 전체를 훑으므로 + # 스왑에 밀린 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 된다. + # 지금처럼 자기 한도에서 OOMKill 로 시끄럽게 죽는 편이 낫다 + # (Dockerfile 의 -XX:+ExitOnOutOfMemoryError 와 같은 철학). + memswap_limit: 768m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9000/actuator/health"] interval: 15s @@ -231,6 +262,7 @@ services: redis: condition: service_healthy mem_limit: 640m + memswap_limit: 640m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9001/actuator/health"] interval: 15s @@ -255,6 +287,7 @@ services: mongodb: condition: service_healthy mem_limit: 576m + memswap_limit: 576m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9003/actuator/health"] interval: 15s @@ -277,6 +310,7 @@ services: redis: condition: service_healthy mem_limit: 640m + memswap_limit: 640m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9006/actuator/health"] interval: 15s @@ -299,6 +333,7 @@ services: redis: condition: service_healthy mem_limit: 576m + memswap_limit: 576m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9005/actuator/health"] interval: 15s @@ -337,9 +372,109 @@ services: ports: - "127.0.0.1:8080:8080" mem_limit: 512m + memswap_limit: 512m healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"] + # 관리 포트가 8081 로 분리됐다 (application-aws.yml 참고). + test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health"] interval: 15s timeout: 5s retries: 10 start_period: 90s + + # ---------- 모니터링 ---------- + # 넷 다 comatching 네트워크 안이다. 호스트에 여는 건 grafana 의 + # 127.0.0.1:3001 하나뿐이고, 보는 방법은 SSH 터널이다: + # ssh -L 3001:localhost:3001 @ + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: comatching-prometheus + restart: unless-stopped + networks: [comatching] + volumes: + - ./monitoring/prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + - prometheus-data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # 시간과 크기 중 먼저 걸리는 쪽이 이긴다. 디스크가 차서 죽는 것보다 + # 오래된 데이터를 버리는 게 낫다. + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=2GB + mem_limit: 512m + memswap_limit: 512m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 + logging: *logging + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: comatching-alertmanager + restart: unless-stopped + networks: [comatching] + volumes: + # 실제 파일은 EC2 에서 example 을 복사해 만든다(웹훅 URL 이 비밀값). + # 없으면 이 마운트가 실패하므로, 만들기 전에는 이 서비스가 뜨지 않는다. + - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + mem_limit: 128m + memswap_limit: 128m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 + logging: *logging + + grafana: + image: grafana/grafana:11.2.0 + container_name: comatching-grafana + restart: unless-stopped + networks: [comatching] + # 루프백에만 바인딩한다. 게이트웨이 8080 과 같은 이유 - 보는 방법은 + # SSH 터널뿐이고, 외부에 열면 보안그룹 한 겹이 유일한 방어선이 된다. + ports: + - "127.0.0.1:3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + # 로컬 스택은 익명 Viewer 를 켜뒀지만 운영은 터널 너머라도 계정을 + # 요구한다. 터널 포트를 실수로 0.0.0.0 에 열었을 때의 마지막 겹이다. + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_USERS_DEFAULT_THEME: dark + volumes: + - ./monitoring/grafana/provisioning-prod:/etc/grafana/provisioning:ro + # baseline 만 올린다. loadtest 는 influxdb, mysql-queries 는 mysql + # 데이터소스를 참조해 운영에서 패널이 깨진다. + - ./monitoring/grafana/dashboards/comatching-baseline.json:/var/lib/grafana/dashboards/comatching-baseline.json:ro + - grafana-data:/var/lib/grafana + depends_on: + prometheus: + condition: service_healthy + mem_limit: 256m + memswap_limit: 256m + logging: *logging + + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: comatching-node-exporter + restart: unless-stopped + networks: [comatching] + # 호스트 지표(CPU·메모리·디스크·스왑)를 읽어야 해서 이 컨테이너만 + # 예외적으로 호스트 네임스페이스를 본다. 전부 읽기 전용이다. + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|var/lib/docker/.+)($$|/) + mem_limit: 64m + memswap_limit: 64m + logging: *logging diff --git a/.gemini/styleguide.md b/docs/code-review-guidelines.md similarity index 100% rename from .gemini/styleguide.md rename to docs/code-review-guidelines.md diff --git a/docs/perf-log.md b/docs/perf-log.md index da60ba8..3972e20 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -445,3 +445,149 @@ GROUP BY 임시 테이블 ~8ms 못 뽑아도 한 티어 아래가 나올 뿐인데, 애초에 그 그룹 안에서 아무나 주는 구조다. 품질 손실을 정량화해서 받아들일지 정하는 게 이 회차의 핵심이다. - 회차 4 이후: S2-read-mix — `default_batch_fetch_size` 오타, `open-in-view`, 풀 크기 + +--- + +## 회차 4 — 운영 첫 부하: S1 리허설, 시드 1만 (2026-08-26) + +**환경이 이전 회차와 완전히 다르다 — 절대 수치를 회차 1~3과 비교하지 말 것.** + +| 항목 | 값 | +|---|---| +| 부하 생성기 | 별도 Windows PC (JMeter 5.6.3) — **대상과 분리된 첫 측정** | +| 대상 | 운영 EC2 (srv.comatching.site, nginx→gateway) + RDS | +| 데이터 | 시드 1만 (리허설 — 본판은 10만) | +| 계단 | 50→100→200→400 RPS × 120초, 워밍업 60초 제외 | + +결과: + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 100% | 0 | 20 | 62 | 141 | 349 | +| 100 RPS | 100% | 0 | 18 | 64 | 243 | 1040 | +| 200 RPS | 100% | 0 | 20 | 147 | 398 | 1622 | +| 400 RPS | **77% (307)** | 0 | **522** | **1187** | 1647 | 3489 | + +**knee = 400 RPS 계단** (p95 1187ms > 500ms). 실질 상한 ~300 RPS. + +관측: +- 에러 0% 인 채로 p50 이 20ms→522ms 로 통째로 밀렸다 — 간헐 경합이 아니라 + **전면 포화**의 신호. 큐잉이 시작됐지만 무너지지는 않았다. +- EC2 실측(docker stats): user-service CPU 최대 118% (1.18코어) — 부하 경로의 + CPU 병목으로 추정. kafka 190%/mongodb 118% 도 찍혔으나 부하 직전 CI 배포로 + 전 컨테이너가 재시작된 직후라 워밍업 노이즈가 섞여 있다. +- 메모리는 전 구간 여유 (가용 4~6GB, 스왑 0). + +사고 기록: 1차 시도는 부하 도중 main 머지 → CI deploy 가 컨테이너를 전부 +재생성해 게이트웨이 다운, 에러 100% 로 폐기. **부하 전 deploy 워크플로 확인이 +체크리스트에 들어간 이유다.** + +다음: 시드 10만 본판. 1만에서 이미 300 RPS 에서 꺾였으므로 10만(행 10배)에서는 +더 이른 knee 예상 — 회차 1의 가설(COUNT 풀스캔)이 운영에서도 재현되는지 본다. + +--- + +## 회차 5 — 운영 본판: S1, 시드 10만 (2026-08-26) + +측정 조건: 회차 4와 동일, 시드만 1만 → 10만. + +결과: + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 99% | 0 | 120 | 472 | 731 | 1580 | +| 100 RPS | **59%** | 0 | 839 | 1078 | 1513 | 2100 | +| 200 RPS | 30% | 0 | 1678 | 2057 | 3201 | 4722 | +| 400 RPS | 15% | 0.01 | 3351 | 3800 | 6780 | 10011 | + +**knee = 100 RPS 계단. 실질 상한 ~60 RPS** (목표 1,667 대비 28배 부족). + +관측 — 세 가지가 하나의 그림을 그린다: + +1. **처리량이 59→59→60 으로 완전히 평평하다.** 부하를 4배로 올려도 처리량이 + 안 움직이고 p50 만 정비례로 늘었다(839→1678→3351ms). 단단한 단일 병목 뒤에 + 줄이 늘어서는 전형적 형태다. +2. **데이터 10배에 상한이 5분의 1 (300→60 RPS).** 요청당 비용이 행 수에 + 비례한다는 뜻 — 회차 1에서 잡았던 participants COUNT 풀스캔 가설이 운영에서 + 그대로 재현됐다. +3. **EC2 호스트(2 vCPU)가 포화였다** — load average 2.85. 단 user-service 는 + 최대 0.8코어로 여유가 있었고, kafka(1.8코어)·mongodb(1.1코어)가 부하와 무관한 + 버스트로 코어를 잠식했다. 유휴 시 kafka 2.4%인 걸 보면 상시는 아니고 간헐 버스트다. + +부하기 쪽 이상 없음: 부하기 PC 는 분리돼 있고 에러 0%, 소켓 타임아웃 1건뿐. + +추가 확인: RDS `innodb_buffer_pool_size` = **128MB (기본값)**. 10만 행 워킹셋과 +풀스캔이 겹치면 캐시 적중이 흔들린다 — 개선 회차에서 인덱스와 함께 볼 것. + +### 결정 후보 (다음 회차) + +**추가 실측 (운영 RDS EXPLAIN):** `idx_role_status` 인덱스는 **이미 존재**하고 +쿼리도 그걸 탄다 (`type: ref, Using index`). 그런데 **rows: 49,616** — 커버링 +인덱스여도 COUNT 는 조건에 맞는 엔트리를 전부 센다. 요청당 5만 행 × 60 RPS +≈ 초당 300만 행 스캔. 인덱스로 해결되는 단계는 지났다. + +| 선택지 | 기대 효과 | +|---|---| +| participants 응답 캐시 (TTL 수 초) | 부하 경로에서 DB 를 아예 제거 — 참가자 수는 실시간성이 필요 없다. **추천** | +| kafka/mongo 버스트 원인 조사 | 2 vCPU 에서 유휴 컨테이너가 코어를 뺏는 구조 해소 | +| RDS `innodb_buffer_pool_size` 상향 | 현재 기본값 128MB. 지금 병목은 아니나 데이터 증가 시 디스크 I/O 혼입 예방 | + +## 회차 6 — 캐시 적용 후 재측정: S1, 시드 10만 (2026-08-26) + +적용한 개선: `getActiveUserCount()` 에 Caffeine 로컬 캐시 TTL 10초 (PR #82). +COUNT 쿼리가 10초에 1번만 나간다. 측정 조건은 회차 5와 동일 +(부하기 분리, 시드 10만 유지, 배포 태그 691cc3d). + +결과 (`tools/perf/jmeter/results/S1-participants-20260826-161013`): + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 100% | 0 | 9 | 26 | 58 | 259 | +| 100 RPS | 100% | 0 | 8 | 21 | 52 | 274 | +| 200 RPS | 100% | 0 | 8 | 37 | 190 | 759 | +| 400 RPS | 83% (331) | 0 | 17 | 592 | 1103 | 4070 | + +**knee = 400 RPS 계단 (p95 592ms). 실질 상한 ~330 RPS — 회차 5의 5.5배.** + +관측: + +1. **60 RPS 평탄 상한이 사라졌다.** 회차 5에서 100 RPS 계단부터 p50 이 839ms 로 + 치솟던 것이 8ms 로 내려앉았다(100배). DB COUNT 병목 가설이 개선→재측정으로 + 확정됐다. +2. **병목이 DB 에서 호스트 CPU 로 이동했다.** 400 계단에서 컨테이너 CPU 피크: + kafka 179% / mongodb 114% / user-service 99% / gateway 79%. 2 vCPU 호스트라 + 이 넷이 겹치면 포화다. 회차 5에서 지적한 kafka·mongo 유휴 버스트가 이제 + 1순위 병목이 됐다. +3. 에러 0%, 메모리는 전 컨테이너 70% 이하로 여유. + +목표 1,667 RPS 대비 5.0배 부족. 다음 지렛대는 코드가 아니라 인프라 — +kafka/mongo 버스트 해소(조사 태스크 진행 중) 또는 스케일업/분리가 남은 카드다. + +## 회차 7 — kafka·mongo 유휴 CPU 버스트 원인 규명 (2026-08-26) + +회차 5·6에서 지적한 "유휴 상태의 kafka·mongo CPU 버스트"를 추적했다. +부하를 걸지 않은 서버에서 `docker stats` 를 2초 간격 100회 샘플링한 결과: + +| 컨테이너 | 평균 CPU | 피크 | 20% 초과 비율 | +|---|---|---|---| +| kafka | 34.4% | 188% | 29% | +| mongodb | 11.6% | 111% | 17% | +| 앱 6개 합계 | ~17% | — | — | + +**범인은 헬스체크였다.** 버스트 발생 시각이 `docker inspect` 의 헬스체크 +실행 기록과 초 단위로 일치한다: + +- **kafka**: `kafka-broker-api-versions.sh` 는 호출마다 JVM 을 새로 띄운다. + 1회 실측 2.9~5.7초, interval 10s → 브로커 컨테이너가 유휴 시간의 30~40%를 + 헬스체크 JVM 기동에 쓰고 있었다. +- **mongodb**: `mongosh --eval ping` 은 Node.js 콜드스타트로 1회 0.9~1.4초. + 로그에 10초마다 mongosh 접속 5~6개가 찍혀 있었다. + +둘이 합쳐 **2 vCPU 중 평균 ~0.46코어를 상시 소모** — 회차 6의 400 RPS 계단에서 +kafka 179% / mongo 114% 피크로 보이던 것의 정체다. 부하 중엔 CPU 경합으로 +헬스체크 JVM 기동이 더 느려져 버스트가 더 길고 겹치게 된다. + +개선: 두 헬스체크를 TCP 접속 확인(`bash /dev/tcp`, 실측 0.1초)으로 교체하고 +interval 30s + `start_interval: 2s`(기동 중에만 촘촘히)로 조정했다. +포트 개방 시점 = 요청 수신 시점이고, 이 헬스체크의 소비자는 compose 기동 +순서뿐이라 API 수준 검사를 유지할 이유가 없다. diff --git a/docs/superpowers/plans/2026-08-26-prod-monitoring.md b/docs/superpowers/plans/2026-08-26-prod-monitoring.md new file mode 100644 index 0000000..b74cc55 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-prod-monitoring.md @@ -0,0 +1,1047 @@ +# 운영 모니터링 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** EC2 운영 환경에 Prometheus/Grafana/Alertmanager/node-exporter 를 올리고 디스코드 알림까지 잇는다. + +**Architecture:** 모니터링 4종을 기존 `comatching` 브리지 네트워크에 넣고 호스트에는 Grafana `127.0.0.1:3001` 만 연다. 게이트웨이만 관리 포트를 8081로 분리해 `/actuator/prometheus` 의 외부 노출을 막는다. 호스트 스왑 도입에 대비해 전 컨테이너에 `memswap_limit` 을 명시한다. + +**Tech Stack:** Docker Compose, Prometheus v2.54.1, Grafana 11.2.0, Alertmanager v0.27.0, node-exporter v1.8.2, Spring Boot Actuator + Micrometer + +**Spec:** `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` + +## Global Constraints + +- 운영 컴포즈는 `docker-compose.prod.yml` 하나다. 모니터링용 별도 컴포즈 파일을 만들지 않는다 (로컬용 `docker-compose.monitoring.yml` 은 건드리지 않는다). +- 호스트에 새로 여는 포트는 Grafana `127.0.0.1:3001:3000` 하나뿐이다. Prometheus/Alertmanager/node-exporter 는 `ports` 를 아예 쓰지 않는다. +- 이미지 태그는 로컬 스택과 동일하게 고정: `prom/prometheus:v2.54.1`, `grafana/grafana:11.2.0`, `prom/alertmanager:v0.27.0`, `prom/node-exporter:v1.8.2`. +- mem_limit: prometheus 512m / grafana 256m / alertmanager 128m / node-exporter 64m. +- JVM 6종·redis·모니터링 4종은 `memswap_limit == mem_limit`. kafka 1536m, mongodb 896m. +- 운영 scrape/evaluation interval 은 15s (로컬의 5s 를 복사하지 않는다). +- 커밋은 태스크마다 하되 **푸시는 하지 않는다** (사용자 지시). +- 이 저장소의 주석 문화를 따른다: "무엇"이 아니라 "왜"를 한국어로 적는다. +- 로컬 검증에서 `.env.prod` 가 필요하면 `cp .env.prod.example .env.prod` 로 만든다(.gitignore 에 있어 커밋되지 않는다). 검증 후 지워도 되고 남겨도 된다. + +--- + +### Task 1: 게이트웨이 관리 포트 분리 + +라우트에 `/actuator/**` 가 없어서 그 경로는 게이트웨이 자신이 응답한다. 8080은 nginx가 TLS를 종단해 넘기는 대상이라, 이대로 prometheus 를 열면 `https://srv.comatching.site/actuator/prometheus` 가 인증 없이 공개된다. 관리 엔드포인트를 8081로 옮기고 호스트에 매핑하지 않는다. + +**Files:** +- Modify: `gateway-service/src/main/resources/application-aws.yml` (파일 끝 management 블록, 139행 부근) +- Modify: `docker-compose.prod.yml` (gateway-service 의 healthcheck) + +**Interfaces:** +- Produces: 컴포즈 네트워크 안에서 `gateway-service:8081/actuator/prometheus`, `gateway-service:8081/actuator/health`. Task 4 의 스크레이프 대상과 Task 6 의 기동 검증이 이 주소를 쓴다. + +- [ ] **Step 1: application-aws.yml 의 management 블록 교체** + +현재 블록: + +```yaml +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + # 게이트웨이의 8080 은 외부에 열려 있다. always 로 두면 인증 없이 + # DB/Redis 구성요소 상태와 접속 정보 일부가 그대로 드러난다. + show-details: never +``` + +다음으로 교체한다: + +```yaml +management: + server: + # 8080 은 nginx 가 TLS 를 종단해 넘겨주는 포트라, 라우트에 걸리지 않는 + # /actuator/** 를 게이트웨이 자신이 응답하면 그대로 외부에 공개된다. + # 관리 엔드포인트를 8081 로 옮기고 호스트에 매핑하지 않는다 - 컴포즈 + # 네트워크 안(Prometheus 스크레이프, 헬스체크)에서만 닿는다. + port: 8081 + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + # 8081 이라도 상세는 숨긴다. DB/Redis 구성요소 상태와 접속 정보 + # 일부가 드러나는 것은 내부라도 이득이 없다. + show-details: never +``` + +- [ ] **Step 2: 컴포즈 healthcheck 를 8081 로** + +`docker-compose.prod.yml` 의 gateway-service healthcheck 를 수정한다. 이걸 빼먹으면 기동 직후부터 healthcheck 가 영구 실패해 컨테이너가 unhealthy 로 남는다: + +```yaml + healthcheck: + # 관리 포트가 8081 로 분리됐다 (application-aws.yml 참고). + test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 90s +``` + +- [ ] **Step 3: 컴포즈 문법 검증** + +```powershell +if (-not (Test-Path .env.prod)) { cp .env.prod.example .env.prod } +docker compose -f docker-compose.prod.yml --env-file .env.prod config -q; echo "exit=$LASTEXITCODE" +``` + +Expected: `exit=0` (경고는 무시, 에러만 실패) + +- [ ] **Step 4: 게이트웨이 테스트가 여전히 통과하는지 확인** + +`application-aws.yml` 은 aws 프로파일 전용이라 기본 테스트에 영향이 없어야 정상이다: + +```powershell +.\gradlew.bat :gateway-service:test --no-daemon +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit** + +```bash +git add gateway-service/src/main/resources/application-aws.yml docker-compose.prod.yml +git commit -m "feat(monitoring): 게이트웨이 관리 엔드포인트를 8081 로 분리한다" +``` + +--- + +### Task 2: 백엔드 5종에 prometheus 노출 추가 + +user/matching/chat/item/notification 은 게이트웨이 라우트에도 없고 호스트 포트 매핑도 없어 외부 도달 경로가 아예 없다. 포트 분리 없이 `exposure.include` 에 `prometheus` 만 더한다. + +**Files:** +- Modify: `user-service/src/main/resources/application-aws.yml` +- Modify: `matching-service/src/main/resources/application-aws.yml` +- Modify: `chat-service/src/main/resources/application-aws.yml` +- Modify: `item-service/src/main/resources/application-aws.yml` +- Modify: `notification/src/main/resources/application-aws.yml` + +**Interfaces:** +- Produces: `user-service:9000` / `matching-service:9001` / `chat-service:9003` / `notification:9005` / `item-service:9006` 의 `/actuator/prometheus`. Task 4 의 스크레이프 대상이다. + +- [ ] **Step 1: 5개 파일에서 include 한 줄씩 수정** + +각 파일의 management 블록에서 아래 한 줄만 바꾼다 (블록 위치는 파일마다 다르다: chat 65행, item 95행, matching 84행, notification 42행, user 53행 부근): + +```yaml + include: health +``` + +→ + +```yaml + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus +``` + +- [ ] **Step 2: 5개 파일이 전부 바뀌었는지 확인** + +```bash +grep -l "health,prometheus" */src/main/resources/application-aws.yml | wc -l +``` + +Expected: `6` (Task 1 의 게이트웨이 포함) + +- [ ] **Step 3: Commit** + +```bash +git add */src/main/resources/application-aws.yml +git commit -m "feat(monitoring): aws 프로파일에서 prometheus 엔드포인트를 연다" +``` + +--- + +### Task 3: 전 컨테이너에 memswap_limit 명시 + +docker 는 `mem_limit` 만 주면 memory+swap 합계를 그 2배로 잡는다. 호스트에 스왑을 켜는 순간 컨테이너 9개의 실효 상한이 조용히 두 배가 된다. JVM(full GC 가 스왑 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초)과 redis(AOF rewrite 의 fork/COW)는 스왑을 아예 못 쓰게 못박는다. + +**Files:** +- Modify: `docker-compose.prod.yml` (기존 9개 서비스) + +**Interfaces:** +- Produces: 없음 (자기완결). EC2 에서 스왑을 켜는 런북(Task 8)이 이 핀을 전제한다. + +- [ ] **Step 1: 9개 서비스에 memswap_limit 추가** + +각 서비스의 `mem_limit` 바로 아래에 추가한다. 값 대응표: + +| 서비스 | mem_limit (기존) | memswap_limit (추가) | +|---|---|---| +| kafka | 1280m | 1536m | +| mongodb | 768m | 896m | +| redis | 320m | 320m | +| user-service | 768m | 768m | +| matching-service | 640m | 640m | +| chat-service | 576m | 576m | +| item-service | 640m | 640m | +| notification | 576m | 576m | +| gateway-service | 512m | 512m | + +JVM 6종과 redis 에는 이 주석을 한 번만(첫 등장인 user-service 에) 단다: + +```yaml + mem_limit: 768m + # mem_limit 과 같은 값 = 스왑 사용 0. memswap_limit 을 생략하면 docker 가 + # memory+swap 을 mem_limit 의 2배로 잡아서, 호스트에 스왑이 생기는 순간 + # 실효 상한이 조용히 두 배가 된다. JVM 은 full GC 가 힙 전체를 훑으므로 + # 스왑에 밀린 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 된다. + # 지금처럼 자기 한도에서 OOMKill 로 시끄럽게 죽는 편이 낫다 + # (Dockerfile 의 -XX:+ExitOnOutOfMemoryError 와 같은 철학). + memswap_limit: 768m +``` + +kafka 에는: + +```yaml + mem_limit: 1280m + # 힙(-Xmx1g) 밖 페이지캐시가 잠깐 넘칠 때를 위한 여유 256m. JVM 앱들과 + # 달리 약간의 스왑은 허용하되 무제한(기본값: mem_limit 의 2배)은 막는다. + memswap_limit: 1536m +``` + +mongodb 에는: + +```yaml + mem_limit: 768m + # WiredTiger 캐시(0.25GB) 밖 워킹셋이 잠깐 넘칠 때를 위한 여유 128m. + memswap_limit: 896m +``` + +redis 에는: + +```yaml + mem_limit: 320m + # AOF rewrite 가 fork 하며 copy-on-write 로 페이지를 복제한다. 그 순간 + # 페이지가 스왑에 있으면 지연이 폭발하므로 스왑 사용을 0 으로 못박는다. + memswap_limit: 320m +``` + +- [ ] **Step 2: 검증 — 9개 전부 들어갔는지, 값이 맞는지** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod config | Select-String "memswap" +``` + +Expected: 9줄. `config` 출력은 바이트 단위로 풀리므로 (예: 768m → 805306368) 값 검증은 원본 yml 에서 한다: + +```bash +grep -c "memswap_limit" docker-compose.prod.yml +``` + +Expected: `9` + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.prod.yml +git commit -m "feat(monitoring): 호스트 스왑 도입에 대비해 memswap_limit 을 못박는다" +``` + +--- + +### Task 4: 운영 Prometheus 설정 + 알림 규칙 + +로컬 [prometheus.yml](../../../monitoring/prometheus/prometheus.yml)은 `host.docker.internal` 을 보고 5s 간격이라 운영에 못 쓴다. 운영 전용 설정과 알림 규칙을 새로 만든다. + +**Files:** +- Create: `monitoring/prometheus/prometheus.prod.yml` +- Create: `monitoring/prometheus/rules/alerts.yml` + +**Interfaces:** +- Consumes: Task 1 의 `gateway-service:8081`, Task 2 의 서비스별 `/actuator/prometheus` +- Produces: Task 6 이 이 두 파일(및 rules 디렉터리)을 컨테이너에 마운트한다. Alertmanager 대상 이름은 `alertmanager:9093` (Task 6 의 서비스명과 일치해야 한다). + +- [ ] **Step 1: prometheus.prod.yml 작성** + +```yaml +# 운영(EC2) 전용. 로컬 prometheus.yml 과 두 가지가 다르다. +# - 같은 컴포즈 네트워크 안이라 host.docker.internal 이 아니라 컨테이너 +# 이름으로 간다. +# - 15s 간격. 5s 는 짧은 부하 테스트에서 데이터 포인트를 확보하려던 +# 값이라 상시 운영에는 과하다(TSDB 쓰기량과 카디널리티가 3배). +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다. 8080 은 nginx 뒤로 외부에 + # 노출되는 포트라 actuator 를 그쪽에 열 수 없다(application-aws.yml). + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] +``` + +- [ ] **Step 2: rules/alerts.yml 작성** + +```yaml +# 알림의 기준: "이게 울리면 하던 일을 멈춰야 하나". +# critical = 예(지금 본다), warning = 아니오(오늘 안에 본다). +groups: + - name: comatching + rules: + - alert: ServiceDown + expr: up{job="comatching-services"} == 0 + for: 2m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 스크레이프 실패 2분" + description: "컨테이너가 죽었거나 actuator 가 응답하지 않는다. docker compose ps 부터 본다." + + - alert: HighErrorRate + expr: > + sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + for: 5m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 5xx 비율 5% 초과 5분" + description: "값: {{ $value | humanizePercentage }}. 해당 서비스 로그부터 본다." + + - alert: JvmHeapHigh + expr: > + sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + for: 10m + labels: { severity: warning } + annotations: + summary: "{{ $labels.service }} 힙 90% 초과 10분" + description: "OOMKill(-XX:+ExitOnOutOfMemoryError 로 즉사·재시작) 전조다." + + - alert: KafkaConsumerLag + expr: kafka_consumer_fetch_manager_records_lag_max > 1000 + for: 10m + labels: { severity: warning } + annotations: + summary: "컨슈머 lag 1000 초과 10분" + description: "{{ $labels.service }} 의 컨슈머가 밀리고 있다. DLT 적재 여부도 확인." + + - alert: DiskSpaceLow + expr: > + 1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + for: 5m + labels: { severity: warning } + annotations: + summary: "루트 디스크 85% 초과" + description: "docker image prune, Prometheus retention, 로그부터 의심한다." + + # memswap_limit 핀 때문에 컨테이너는 스왑을 못 쓴다. 스왑이 실제로 + # 쓰인다는 것은 호스트(sshd, docker 데몬 등)가 메모리 압박을 받고 + # 있다는 뜻이다 - mem_limit 합 7,040m / 7.7GiB 예산의 조기 경보. + - alert: SwapInUse + expr: node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 + for: 10m + labels: { severity: warning } + annotations: + summary: "호스트 스왑 사용 256MB 초과 10분" + description: "컨테이너 mem_limit 합이 호스트를 압박하고 있다. spec 4.1 의 완화 순서대로." +``` + +- [ ] **Step 3: promtool 로 검증** + +promtool 이 rule_files 글롭을 설정 파일 기준으로 해석하므로, 디렉터리를 `/etc/prometheus` 에 그대로 마운트해야 규칙 파일까지 함께 검증된다: + +```powershell +docker run --rm --entrypoint promtool -v "${PWD}\monitoring\prometheus:/etc/prometheus" prom/prometheus:v2.54.1 check config /etc/prometheus/prometheus.prod.yml +``` + +Expected: `SUCCESS: 6 rules found` 와 `prometheus.prod.yml is valid` + +- [ ] **Step 4: Commit** + +```bash +git add monitoring/prometheus/prometheus.prod.yml monitoring/prometheus/rules/alerts.yml +git commit -m "feat(monitoring): 운영 스크레이프 설정과 알림 규칙 6종" +``` + +--- + +### Task 5: Alertmanager 디스코드 설정 + +Alertmanager 는 설정 파일에서 환경변수를 확장하지 않는다. 그래서 spec 5.1 의 `INFRA_ALERT_WEBHOOK_URL` 환경변수 방식 대신, `.env.prod` 와 같은 패턴을 쓴다: **예제 파일은 추적하고 실제 파일(웹훅 URL 포함)은 EC2 에만 둔다.** deploy.yml 의 `git reset --hard` 는 추적되지 않는 파일을 건드리지 않으므로 배포에도 살아남는다. + +**Files:** +- Create: `monitoring/alertmanager/alertmanager.example.yml` +- Modify: `.gitignore` (실제 파일 제외 규칙) +- Modify: `.env.prod.example` (Grafana 비밀번호 추가 + 웹훅 안내 주석) + +**Interfaces:** +- Produces: Task 6 이 `./monitoring/alertmanager/alertmanager.yml` 을 마운트한다 (EC2 에서 example 을 복사해 만드는 파일). `GRAFANA_ADMIN_PASSWORD` 환경변수를 Task 6 의 grafana 서비스가 쓴다. + +- [ ] **Step 1: alertmanager.example.yml 작성** + +```yaml +# ============================================================ +# 이 파일을 alertmanager.yml 로 복사해서 웹훅 URL 을 채운다. +# cp alertmanager.example.yml alertmanager.yml +# alertmanager.yml 은 .gitignore 에 있다 - 웹훅 URL 자체가 비밀값이다 +# (URL 만 알면 누구나 그 채널에 글을 쓸 수 있다). +# +# 웹훅은 DLT_ALERT_WEBHOOK_URL 과 다른 채널로 판다. DLT 알림은 잦고 안 +# 급하며, 인프라 알림은 드물고 급하다. 한 채널에 섞으면 잦은 쪽이 드문 +# 쪽을 묻어버려 결국 둘 다 안 읽는다. +# ============================================================ +route: + receiver: discord + group_by: [alertname, service] + group_wait: 30s + group_interval: 5m + # 해결 안 된 알림의 재전송 간격. 너무 짧으면 같은 알림이 도배되어 + # "잦은 쪽이 드문 쪽을 묻는" 문제를 스스로 만든다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url: 'https://discord.com/api/webhooks/CHANGE/ME' + send_resolved: true +``` + +- [ ] **Step 2: .gitignore 에 실제 파일 제외** + +`.gitignore` 의 `/docs/superpowers/**/.omc/` 규칙 아래에 추가한다: + +``` +# Alertmanager 실제 설정은 디스코드 웹훅 URL 을 담고 있어 EC2 에만 둔다. +# 예제(alertmanager.example.yml)만 추적한다 - .env.prod 와 같은 패턴. +/monitoring/alertmanager/alertmanager.yml +``` + +- [ ] **Step 3: .env.prod.example 에 추가** + +`# ---------- DLT 적재 알림 ----------` 절 앞에 추가한다: + +``` +# ---------- 모니터링 ---------- +# Grafana 관리자 비밀번호. SSH 터널(127.0.0.1:3001) 너머로만 닿지만 +# 익명 접근을 꺼둔 이상 계정은 있어야 한다. +GRAFANA_ADMIN_PASSWORD= + +# 인프라 알림(서비스 다운·5xx·힙·디스크)의 디스코드 웹훅은 환경변수가 +# 아니라 monitoring/alertmanager/alertmanager.yml 에 넣는다 - Alertmanager 가 +# 설정 파일에서 환경변수를 확장하지 못한다. 그 파일은 .gitignore 에 있다. +# DLT_ALERT_WEBHOOK_URL 과 반드시 다른 채널로 팔 것(아래 DLT 절 참고). +``` + +- [ ] **Step 4: amtool 로 검증** + +```powershell +docker run --rm --entrypoint amtool -v "${PWD}\monitoring\alertmanager:/work" prom/alertmanager:v0.27.0 check-config /work/alertmanager.example.yml +``` + +Expected: `Found: 1 receivers` 및 에러 없음. `discord_configs` 를 파서가 거부하면(버전 문제) spec 8장의 대체 경로(webhook_configs)로 전환하고 사용자에게 보고한다. + +- [ ] **Step 5: Commit** + +```bash +git add monitoring/alertmanager/alertmanager.example.yml .gitignore .env.prod.example +git commit -m "feat(monitoring): 인프라 알림용 Alertmanager 디스코드 설정" +``` + +--- + +### Task 6: 컴포즈에 모니터링 4종 + Grafana 운영 프로비저닝 + +**Files:** +- Create: `monitoring/grafana/provisioning-prod/datasources/prometheus.yml` +- Create: `monitoring/grafana/provisioning-prod/dashboards/dashboards.yml` +- Modify: `docker-compose.prod.yml` (서비스 4개 + 볼륨 2개 추가) + +**Interfaces:** +- Consumes: Task 4 의 `prometheus.prod.yml`·`rules/`, Task 5 의 `alertmanager.yml`(EC2 에서 생성)·`GRAFANA_ADMIN_PASSWORD` +- Produces: `prometheus:9090`, `alertmanager:9093`, `node-exporter:9100`, 호스트의 `127.0.0.1:3001`(Grafana) + +- [ ] **Step 1: Grafana 운영 데이터소스 프로비저닝** + +로컬 프로비저닝 디렉터리를 그대로 마운트하면 안 된다 — influxdb.yml(운영에 InfluxDB 없음)과 mysql.yml(`host.docker.internal:3307` 를 봄)이 기동마다 에러를 만든다. 운영 전용 디렉터리를 만든다. + +`monitoring/grafana/provisioning-prod/datasources/prometheus.yml`: + +```yaml +# 운영 전용 프로비저닝. 로컬(provisioning/)과 분리한 이유: +# - influxdb 데이터소스는 JMeter 전용이라 운영에 없다. +# - mysql 데이터소스는 host.docker.internal:3307 을 봐서 운영에서 깨진다. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus # 대시보드 JSON 이 이 uid 로 참조한다 + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s # prometheus.prod.yml 의 scrape_interval 과 일치 +``` + +- [ ] **Step 2: Grafana 대시보드 프로비저닝** + +`monitoring/grafana/provisioning-prod/dashboards/dashboards.yml`: + +```yaml +apiVersion: 1 + +providers: + - name: comatching + orgId: 1 + folder: Comatching + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false +``` + +- [ ] **Step 3: 컴포즈에 서비스 4개 추가** + +`docker-compose.prod.yml` 의 gateway-service 아래에 추가한다. 대시보드는 `comatching-baseline.json` 만 마운트한다 — loadtest 는 influxdb 를, mysql-queries 는 mysql 데이터소스를 참조해 운영에서 패널이 깨진다. + +```yaml + # ---------- 모니터링 ---------- + # 넷 다 comatching 네트워크 안이다. 호스트에 여는 건 grafana 의 + # 127.0.0.1:3001 하나뿐이고, 보는 방법은 SSH 터널이다: + # ssh -L 3001:localhost:3001 @ + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: comatching-prometheus + restart: unless-stopped + networks: [comatching] + volumes: + - ./monitoring/prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + - prometheus-data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # 시간과 크기 중 먼저 걸리는 쪽이 이긴다. 디스크가 차서 죽는 것보다 + # 오래된 데이터를 버리는 게 낫다. + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=2GB + mem_limit: 512m + memswap_limit: 512m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 + logging: *logging + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: comatching-alertmanager + restart: unless-stopped + networks: [comatching] + volumes: + # 실제 파일은 EC2 에서 example 을 복사해 만든다(웹훅 URL 이 비밀값). + # 없으면 이 마운트가 실패하므로, 만들기 전에는 이 서비스가 뜨지 않는다. + - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + mem_limit: 128m + memswap_limit: 128m + logging: *logging + + grafana: + image: grafana/grafana:11.2.0 + container_name: comatching-grafana + restart: unless-stopped + networks: [comatching] + # 루프백에만 바인딩한다. 게이트웨이 8080 과 같은 이유 - 보는 방법은 + # SSH 터널뿐이고, 외부에 열면 보안그룹 한 겹이 유일한 방어선이 된다. + ports: + - "127.0.0.1:3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + # 로컬 스택은 익명 Viewer 를 켜뒀지만 운영은 터널 너머라도 계정을 + # 요구한다. 터널 포트를 실수로 0.0.0.0 에 열었을 때의 마지막 겹이다. + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_USERS_DEFAULT_THEME: dark + volumes: + - ./monitoring/grafana/provisioning-prod:/etc/grafana/provisioning:ro + # baseline 만 올린다. loadtest 는 influxdb, mysql-queries 는 mysql + # 데이터소스를 참조해 운영에서 패널이 깨진다. + - ./monitoring/grafana/dashboards/comatching-baseline.json:/var/lib/grafana/dashboards/comatching-baseline.json:ro + - grafana-data:/var/lib/grafana + depends_on: + - prometheus + mem_limit: 256m + memswap_limit: 256m + logging: *logging + + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: comatching-node-exporter + restart: unless-stopped + networks: [comatching] + # 호스트 지표(CPU·메모리·디스크·스왑)를 읽어야 해서 이 컨테이너만 + # 예외적으로 호스트 네임스페이스를 본다. 전부 읽기 전용이다. + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|var/lib/docker/.+)($$|/) + mem_limit: 64m + memswap_limit: 64m + logging: *logging +``` + +볼륨 절에 추가: + +```yaml +volumes: + kafka-data: + mongo-data: + redis-data: + prometheus-data: + grafana-data: +``` + +- [ ] **Step 4: 컴포즈 문법 검증** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod config -q; echo "exit=$LASTEXITCODE" +``` + +Expected: `exit=0` + +- [ ] **Step 5: 로컬에서 모니터링 3종만 실기동 스모크** + +alertmanager 는 실제 설정 파일이 없으면 마운트가 실패하므로, 로컬에서만 example 을 복사해 흉내낸다: + +```powershell +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d prometheus alertmanager node-exporter +Start-Sleep -Seconds 10 +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +Expected: 셋 다 Up (prometheus 는 healthy). 앱 서비스는 띄우지 않으므로 스크레이프 대상은 down 으로 보이는 게 정상이다. + +```powershell +docker exec comatching-prometheus wget -qO- http://localhost:9090/api/v1/rules | Select-String "ServiceDown" +``` + +Expected: ServiceDown 규칙이 로드되어 있다 + +- [ ] **Step 6: 정리** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod down +rm monitoring/alertmanager/alertmanager.yml +rm .env.prod +``` + +- [ ] **Step 7: Commit** + +```bash +git add monitoring/grafana/provisioning-prod docker-compose.prod.yml +git commit -m "feat(monitoring): 운영 컴포즈에 모니터링 4종을 올린다" +``` + +--- + +### Task 7: 부하 도구의 운영 대응 (HTTPS + RDS) + +부하 테스트(spec 7장)는 모니터링이 뜬 뒤의 별도 작업이지만, 도구 수정은 코드라 지금 같이 커밋한다. 두 가지: run.sh 가 http 를 하드코딩해서 nginx 443 으로 못 가고, load_seed.sh 는 `docker exec` + 서버사이드 `LOAD DATA INFILE` 이라 RDS 에서 안 돈다. + +**Files:** +- Modify: `tools/perf/jmeter/run.sh:31-33,50` (SCHEME 도입) +- Modify: `tools/perf/jmeter/S1-participants.jmx:67` (protocol 을 프로퍼티로) +- Create: `tools/perf/seed/load_seed_rds.sh` (로컬용 load_seed.sh 는 건드리지 않는다) + +**Interfaces:** +- Consumes: 없음 +- Produces: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` 실행 계약. `load_seed_rds.sh` 는 `RDS_ENDPOINT`/`RDS_USERNAME`/`RDS_PASSWORD` 환경변수(.env.prod 와 동일한 이름)를 읽는다. + +- [ ] **Step 1: run.sh 에 SCHEME 도입** + +31~33행의 변수 선언에 한 줄 추가: + +```bash +HOST="${HOST:-localhost}" +PORT="${PORT:-8080}" +# 운영은 nginx 443 뒤라 https 로 가야 한다. 게이트웨이 8080 은 루프백 +# 바인딩이라 외부에서 직접 닿지 않는다. +SCHEME="${SCHEME:-http}" +INFLUX_HOST="${INFLUX_HOST:-localhost}" +``` + +50행의 사전 점검 curl 을 수정: + +```bash +code=$(curl -s -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) +``` + +114행의 JMeter 인자에 `-Jscheme` 추가: + +```bash + -Jhost="$HOST" -Jport="$PORT" -Jscheme="$SCHEME" -JinfluxHost="$INFLUX_HOST" \ +``` + +- [ ] **Step 2: S1 JMX 의 protocol 을 프로퍼티로** + +`S1-participants.jmx` 67행: + +```xml +http +``` + +→ + +```xml +${__P(scheme,http)} +``` + +- [ ] **Step 3: load_seed_rds.sh 작성** + +```bash +#!/usr/bin/env bash +# ============================================================================ +# 생성된 TSV 를 RDS 에 적재한다. EC2 호스트에서 실행한다. +# +# 로컬용 load_seed.sh 와 갈라놓은 이유 - 그쪽의 두 전제가 RDS 에서 깨진다: +# - docker exec comatching-mysql RDS 는 컨테이너가 아니다 +# - 서버사이드 LOAD DATA INFILE RDS 는 DB 서버 파일시스템에 파일을 +# 놓을 수 없다 (secure_file_priv) +# 대신 클라이언트가 파일을 읽어 보내는 LOAD DATA LOCAL INFILE 을 쓴다. +# +# 선결 조건: +# - RDS 파라미터 그룹에 local_infile=1 (기본 0. 콘솔에서 바꾸고 적용 대기) +# - EC2 에 mysql 클라이언트 (dnf install mariadb105 또는 mysql) +# - 시드 TSV: 로컬에서 generate_seed.py 로 만들어 scp 로 올린다 +# +# 사용법: +# RDS_ENDPOINT=... RDS_USERNAME=... RDS_PASSWORD=... ./load_seed_rds.sh +# 환경변수 이름은 .env.prod 와 같으므로 이렇게 부를 수도 있다: +# set -a; . ~/comatching/.env.prod; set +a; ./load_seed_rds.sh +# ============================================================================ +set -euo pipefail +cd "$(dirname "$0")" + +: "${RDS_ENDPOINT:?RDS_ENDPOINT 가 필요하다 (.env.prod 참고)}" +: "${RDS_USERNAME:?RDS_USERNAME 이 필요하다}" +: "${RDS_PASSWORD:?RDS_PASSWORD 가 필요하다}" +START_ID="${START_ID:-1000001}" +OUT_DIR="${OUT_DIR:-./out}" + +mysql_exec() { + mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" -p"$RDS_PASSWORD" \ + --local-infile=1 --default-character-set=utf8mb4 "$@" +} + +# ---------- 0. 사전 점검 ---------- +if [ ! -f "$OUT_DIR/members.tsv" ]; then + echo "❌ $OUT_DIR/members.tsv 가 없습니다. 로컬에서 generate_seed.py 를 돌려 scp 로 올리세요." + exit 1 +fi + +# 서버가 local_infile 을 거부하면 여기서 일찍 죽는 편이 낫다. +LI=$(mysql_exec -N -B -e "SELECT @@GLOBAL.local_infile;") +if [ "$LI" != "1" ]; then + echo "❌ RDS 의 local_infile 이 꺼져 있습니다 ($LI). 파라미터 그룹에서 local_infile=1 로 바꾸세요." + exit 1 +fi +echo "✅ RDS $RDS_ENDPOINT — local_infile=1" + +# ---------- 1. 기존 시드 삭제 ---------- +# 재실행 가능해야 한다. generate_seed.py 가 profile_id 를 member_id 와 1:1 로 +# 맞춰두므로 조인 없이 ID 범위로 지운다. +echo "🧹 기존 시드 삭제 (member_id >= $START_ID)..." +mysql_exec <= $START_ID; +DELETE FROM comatching_user.profile_tag WHERE profile_id >= $START_ID; +DELETE FROM comatching_user.profile WHERE member_id >= $START_ID; +DELETE FROM comatching_user.members WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.candidate_hobby_categories WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID; +DELETE FROM comatching_item.item WHERE member_id >= $START_ID; +SET foreign_key_checks = 1; +SQL + +# ---------- 2. 적재 ---------- +# unique_checks / foreign_key_checks 를 끄면 InnoDB 가 행마다 검증하지 않아 +# 벌크 적재가 크게 빨라진다. 시드는 이미 유일성이 보장돼 있다. +echo "⬇️ 적재 중..." +time mysql_exec <= $START_ID +UNION ALL SELECT 'profile', COUNT(*) FROM comatching_user.profile WHERE member_id >= $START_ID +UNION ALL SELECT 'matching_candidate', COUNT(*) FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID +UNION ALL SELECT 'item', COUNT(*) FROM comatching_item.item WHERE member_id >= $START_ID; +SQL + +echo "🔎 age NULL 검사 (0 이어야 정상 — NULL 이면 매칭이 전부 실패한다)" +mysql_exec -t -e "SELECT COUNT(*) AS age_is_null FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID AND age IS NULL;" + +echo "🏁 완료. 다음: 운영 시크릿으로 토큰 재발급 (generate_tokens.py --env ~/comatching/.env.prod)" +``` + +- [ ] **Step 4: 문법 검증** + +```bash +bash -n tools/perf/jmeter/run.sh && bash -n tools/perf/seed/load_seed_rds.sh && echo OK +``` + +Expected: `OK` + +- [ ] **Step 5: 로컬 스모크 — SCHEME 기본값이 기존 동작을 보존하는지** + +```bash +grep -n "SCHEME" tools/perf/jmeter/run.sh && grep -n "__P(scheme" tools/perf/jmeter/S1-participants.jmx +``` + +Expected: run.sh 3곳(선언·curl·-Jscheme), JMX 1곳. 기본값이 둘 다 `http` 라서 로컬 사용법은 그대로다. + +- [ ] **Step 6: Commit** + +주의: `.gitignore` 에 `tools` 줄이 작업 트리에 있다면 `load_seed_rds.sh` 가 무시될 수 있다. `git check-ignore` 로 확인하고, 무시되면 사용자에게 보고한다(강제 추가하지 않는다). + +```bash +git check-ignore tools/perf/seed/load_seed_rds.sh || echo "추적 가능" +git add tools/perf/jmeter/run.sh tools/perf/jmeter/S1-participants.jmx tools/perf/seed/load_seed_rds.sh +git commit -m "feat(perf): 부하 도구의 운영 대응 - HTTPS 스킴과 RDS 시드 적재" +``` + +--- + +### Task 8: EC2 배포 런북 + +코드가 아니라 EC2 에서 사람이 순서대로 치는 명령의 기록이다. spec 의 절차(4.2 스왑, 7.2 부하, 7.3 초기화)를 한 문서로 모아 순서를 고정한다. + +**Files:** +- Create: `docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md` + +**Interfaces:** +- Consumes: Task 1~7 의 산출물 전부 +- Produces: 없음 (사람이 읽는 문서) + +- [ ] **Step 1: 런북 작성** + +````markdown +# 운영 모니터링 롤아웃 런북 + +설계: `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` +전부 EC2(43.200.211.135)에 SSH 로 들어가서 순서대로 실행한다. + +## 1. 사전 확인 + +```bash +free -h; swapon --show; df -h / +stat -fc %T /sys/fs/cgroup # cgroup2fs 여야 한다 +docker info 2>/dev/null | grep -i "no swap limit" || echo "swap limit 지원 OK" +``` + +마지막 줄이 "swap limit 지원 OK" 가 아니면 **여기서 멈춘다** — +memswap_limit 이 조용히 무시되어 스왑을 켜면 안 되는 상태다. + +## 2. 스왑 2GB + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +free -h # Swap 2.0Gi 확인 +``` + +## 3. 베이스라인 실측 (모니터링 올리기 전) + +```bash +docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" | tee ~/baseline-$(date +%F).txt +``` + +## 4. 설정 준비 + +```bash +cd ~/comatching && git fetch origin main && git reset --hard origin/main +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +vi monitoring/alertmanager/alertmanager.yml # 웹훅 URL 교체 (DLT 와 다른 채널!) +vi .env.prod # GRAFANA_ADMIN_PASSWORD= 채움 +``` + +## 5. 기동 + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.prod pull +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +게이트웨이 관리 포트가 8081 로 바뀌었으므로 앱 이미지도 이 커밋으로 +빌드된 것이어야 한다(CI 배포를 먼저 태우거나 --build). + +## 6. 검증 + +```bash +# 스크레이프 6대상 전부 up=1 이어야 한다 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' | grep -o '"service":"[^"]*","value":\[[^]]*\]' + +# 외부에서 actuator 가 안 열리는지 (401/404 여야 하고 200 이면 사고다) +curl -s -o /dev/null -w '%{http_code}\n' https://srv.comatching.site/actuator/prometheus + +# 알림 경로: Alertmanager 가 디스코드로 쏘는지 임시 서비스 하나를 죽여 확인 +docker stop comatching-notification && sleep 180 # ServiceDown(2m) 발화 대기 +docker start comatching-notification # 디스코드에 알림+해제 확인 +``` + +로컬 PC 에서: + +```bash +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저 http://localhost:3001 → admin / GRAFANA_ADMIN_PASSWORD +``` + +## 7. 외부 업타임 감시 + +UptimeRobot 등에서 웹으로 등록 (EC2 작업 없음): +- URL: `https://srv.comatching.site/actuator/health`, 주기 5분 +- 알림: 인프라 디스코드 채널 + +## 8. 부하 테스트 (spec 7.2) + +```bash +# RDS 파라미터 그룹에서 local_infile=1 적용 후: +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh # 더미 10만 +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# 부하 중 실측 기록 +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv +``` + +부하는 로컬 PC 에서: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` + +## 9. 초기화 (런칭 전 1회) + +```bash +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # kafka/mongo/redis 볼륨 삭제 +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build + +# 전부 healthy 확인 후 ★반드시★ 즉시 되돌린다. create 인 채로 재기동하면 +# 실서비스 데이터가 전부 날아간다 (CI 배포와 restart 정책이 재기동을 만든다). +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` +```` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md +git commit -m "docs(monitoring): EC2 롤아웃 런북" +``` + +--- + +## Self-Review 기록 + +- **Spec coverage:** 3.1→Task 6, 3.2→Task 1, 3.3→Task 4, 4.2→Task 8(§1-2), 4.3→Task 3, 5장→Task 4·5, 6장→Task 8(§7), 7.1→Task 7, 7.2~7.3→Task 8(§8-9), 9장 구현 순서 = Task 1~7 순서와 일치. +- **Spec 과의 의도적 편차 1건:** spec 5.1 은 `INFRA_ALERT_WEBHOOK_URL` 환경변수를 제안했으나 Alertmanager 가 설정 파일에서 환경변수를 확장하지 못한다. `.env.prod` 패턴(example 추적 + 실제 파일 gitignore)으로 대체했다 — Task 5 서두에 근거 명시. +- **미검증 리스크(스펙 8장)와의 대응:** `discord_configs` → Task 5 Step 4 의 amtool 검증에서 걸러짐. `kafka_consumer_fetch_manager_records_lag_max` → EC2 기동 후 런북 6절 검증에서 확인, 없으면 규칙만 제거. +- **Type consistency:** 서비스명 `alertmanager`(Task 4 의 타깃 = Task 6 의 서비스명), 볼륨 `prometheus-data`/`grafana-data`, 환경변수 `GRAFANA_ADMIN_PASSWORD`(Task 5 정의 = Task 6 사용), 실행 계약 `SCHEME`(Task 7 내 3곳) 일치 확인. diff --git a/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md b/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md new file mode 100644 index 0000000..c75d9ba --- /dev/null +++ b/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md @@ -0,0 +1,102 @@ +# 운영 부하 1차 (S1 읽기 전용) — D-day 체크리스트 + +계획: 스모크 → 1만 리허설 → 10만 본판. 전부 **읽기 전용(S1)** — 매칭·구매는 안 친다. +부하기: 로컬 PC (JMeter 5.6.3, `~/apps/apache-jmeter-5.6.3`). 대상: `srv.comatching.site`. + +**이번 회차는 부하기와 대상이 분리된 첫 측정이다.** 이전 회차(단일 맥)와 절대 수치를 +직접 비교하지 말 것. 호스트 CPU 70% 폐기 기준은 이제 "부하기 PC CPU"에만 적용된다. + +## 0. 사전 완료된 것 (2026-08-26) + +- [x] 공개 스모크 6/6 통과 (`tools/perf/smoke.sh`) +- [x] JMeter 5.6.3 설치, `run.sh` Windows 호환 패치 +- [x] 로컬 InfluxDB(8086)·Grafana(**3002** — 3001 은 다른 프로젝트가 점유) 기동 +- [x] 1만 시드 TSV 생성 (`tools/perf/seed/out/`) +- [ ] RDS 파라미터 그룹 `local_infile=1` (콘솔에서 확인/적용) + +## 1. EC2 준비 (SSH: ubuntu@43.200.211.135) + +```bash +# 모니터링 6타깃 up=1 확인 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' + +# 디스코드에 "부하 테스트 공지" (또는 Alertmanager silence) +``` + +로컬에서 서버 Grafana 터널: `ssh -L 3001:localhost:3001 ubuntu@43.200.211.135` +→ 브라우저 `http://localhost:3001` (서버 지표) + `http://localhost:3002` (JMeter 클라 지표) + +## 2. 시드·토큰 (리허설은 1만으로) + +```bash +# [로컬] TSV 업로드 +scp -r tools/perf/seed/out ubuntu@43.200.211.135:~/comatching/tools/perf/seed/ + +# [EC2] RDS 적재 — 시드 ID 는 1000001 부터라 나중에 범위 삭제 가능 +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh +# 검사 3종(행 수 / age_is_null=0 / gender 5:5) 통과 확인 + +# [EC2] 토큰 발급 (운영 JWT_SECRET 필요 — 로컬 불가) +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# [로컬] 토큰 회수 +scp ubuntu@43.200.211.135:~/comatching/tools/perf/tokens/tokens.csv tools/perf/tokens/ +``` + +토큰 검증 + 인증 스모크 (로컬): + +```bash +TOKEN=$(head -1 tools/perf/tokens/tokens.csv | cut -d, -f2) +ACCESS_TOKEN=$TOKEN ./tools/perf/smoke.sh # 11개 전부 ✅ 여야 진행 +``` + +## 3. 실행 (사용자 없는 시간대 — 게이트웨이에 rate limiter 없음) + +**⚠️ 부하 직전에 배포 충돌 확인 (2026-08-26 실전에서 당한 것):** +main 에 머지가 일어나면 deploy 워크플로가 EC2 컨테이너를 통째로 재생성한다. +부하 도중 배포가 겹치면 게이트웨이가 내려가 에러 100% — 회차 전체 폐기다. + +```bash +gh run list --workflow deploy --limit 1 # in_progress 면 끝날 때까지 대기 +``` + +부하 도는 동안(±15분) main 머지는 보류할 것. + +```bash +# [EC2] 실측 기록 시작 (부하 끝나면 Ctrl-C) +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv + +# [로컬 Git Bash] 부하 실행 — 워밍업 60초 + 50→100→200→400→800 RPS +export PATH="$HOME/apps/apache-jmeter-5.6.3/bin:$PATH" +cd tools/perf/jmeter +SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh +``` + +**중단 기준 (하나라도 걸리면 Ctrl-C):** +- EC2 메모리: 컨테이너 합계가 한도(여유 860m) 육박, 스왑 급증, OOM 킬 +- 에러율이 계단 초반부터 10% 이상 (측정이 아니라 장애다) +- ServiceDown 알림 발화 + +## 4. 판정·기록 + +- knee 판정은 `summarize.py` 자동: p95 > 500ms / 에러율 > 1% / 2배 부하에 처리량 1.2배 미만 +- 부하기 PC CPU 를 작업관리자로 관찰 — 70% 초과 구간은 폐기 +- 클라 p95(Grafana 3002) vs 서버 p95(터널 3001) gap 확인 +- `docs/perf-log.md` 에 회차 기록 (환경: "부하기 분리, 대상 운영 EC2+RDS" 명시) + +## 5. 리허설 → 본판 + +리허설(1만)이 절차상 문제 없으면: + +```bash +# [로컬] 10만 재생성 후 2단계 반복 (load_seed_rds.sh 가 기존 시드를 지우고 새로 적재) +PYTHONIOENCODING=utf-8 python tools/perf/seed/generate_seed.py --count 100000 +``` + +## 6. 정리 + +- 시드 삭제는 급하지 않다 — 런칭 전 초기화(런북 §9 `down -v` + `create`)에서 일괄 소멸. + 그 전에 지우려면 ID ≥ 1000001 범위 삭제. +- `~/stats-*.csv` 백업, 디스코드에 부하 종료 공지, Alertmanager silence 해제 +- S1 은 읽기 전용이라 `matching_history`/`item.quantity` 오염 없음 — 리셋 불필요 diff --git a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md new file mode 100644 index 0000000..6271504 --- /dev/null +++ b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md @@ -0,0 +1,107 @@ +# 운영 모니터링 롤아웃 런북 + +설계: `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` +전부 EC2(43.200.211.135)에 SSH 로 들어가서 순서대로 실행한다. + +## 1. 사전 확인 + +```bash +free -h; swapon --show; df -h / +stat -fc %T /sys/fs/cgroup # cgroup2fs 여야 한다 +# 스트림을 합쳐서(2>&1) STDERR 로 나오는 경고가 버려지지 않게 한다 +docker info 2>&1 | grep -i "no swap limit" && echo "!! memswap_limit 미지원 - 여기서 멈춘다" || echo "swap limit 지원 OK" +``` + +마지막 줄이 "swap limit 지원 OK" 가 아니면 **여기서 멈춘다** — +memswap_limit 이 조용히 무시되어 스왑을 켜면 안 되는 상태다. + +## 2. 스왑 2GB + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +free -h # Swap 2.0Gi 확인 +``` + +## 3. 베이스라인 실측 (모니터링 올리기 전) + +```bash +docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" | tee ~/baseline-$(date +%F).txt +``` + +## 4. 설정 준비 + +```bash +cd ~/comatching && git fetch origin main && git reset --hard origin/main +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +vi monitoring/alertmanager/alertmanager.yml # 웹훅 URL 교체 (DLT 와 다른 채널!) +vi .env.prod # GRAFANA_ADMIN_PASSWORD= 채움 +``` + +## 5. 기동 + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.prod pull +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +게이트웨이 관리 포트가 8081 로 바뀌었으므로 앱 이미지도 이 커밋으로 +빌드된 것이어야 한다(CI 배포를 먼저 태우거나 --build). + +## 6. 검증 + +```bash +# 스크레이프 6대상 전부 up=1 이어야 한다 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' | grep -o '"service":"[^"]*","value":\[[^]]*\]' + +# 외부에서 actuator 가 안 열리는지 (401/404 여야 하고 200 이면 사고다) +curl -s -o /dev/null -w '%{http_code}\n' https://srv.comatching.site/actuator/prometheus + +# 알림 경로: Alertmanager 가 디스코드로 쏘는지 임시 서비스 하나를 죽여 확인 +docker stop comatching-notification && sleep 180 # ServiceDown(2m) 발화 대기 +docker start comatching-notification # 디스코드에 알림+해제 확인 +``` + +로컬 PC 에서: + +```bash +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저 http://localhost:3001 → admin / GRAFANA_ADMIN_PASSWORD +``` + +## 7. 외부 업타임 감시 + +UptimeRobot 등에서 웹으로 등록 (EC2 작업 없음): +- URL: `https://srv.comatching.site/api/auth/participants`, 주기 5분 + (actuator 는 관리 포트 8081 로 분리되어 외부에서 404 다 — 공개 엔드포인트로 감시한다) +- 알림: 인프라 디스코드 채널 + +## 8. 부하 테스트 (spec 7.2) + +```bash +# RDS 파라미터 그룹에서 local_infile=1 적용 후: +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh # 더미 10만 +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# 부하 중 실측 기록 +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv +``` + +부하는 로컬 PC 에서: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` + +## 9. 초기화 (런칭 전 1회) + +```bash +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # 명명 볼륨 5개 전부 삭제 - kafka/mongo/redis 는 물론 Prometheus TSDB(베이스라인·부하 기록)와 Grafana 상태까지 초기화된다. 남기고 싶은 지표가 있으면 §8 의 stats CSV 를 먼저 백업할 것. +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build + +# 전부 healthy 확인 후 ★반드시★ 즉시 되돌린다. create 인 채로 재기동하면 +# 실서비스 데이터가 전부 날아간다 (CI 배포와 restart 정책이 재기동을 만든다). +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` diff --git a/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md b/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md new file mode 100644 index 0000000..e2fad41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md @@ -0,0 +1,454 @@ +# 운영 모니터링 도입 설계 + +- 작성일: 2026-08-26 +- 대상: EC2 단일 인스턴스(t3.large, ap-northeast-2) 운영 환경 +- 상태: 설계 확정, 구현 계획 대기 + +## 1. 배경과 목적 + +운영 배포는 [docker-compose.prod.yml](../../../docker-compose.prod.yml)로 끝났지만 관측 수단이 없다. +지금 서버 상태를 아는 방법은 `docker compose ps` 와 `docker logs` 뿐이고, 둘 다 사람이 +직접 들어가서 봐야 알 수 있다. 즉 **문제가 생겨도 사용자가 먼저 안다.** + +이번 작업으로 얻으려는 것은 두 가지다. + +1. **장애 감지와 알림** — 서비스가 죽거나 5xx가 튀면 사람이 접속하지 않아도 디스코드로 알려준다. +2. **성능 추적 대시보드** — 지연·처리량·JVM 힙을 시계열로 본다. + +로그 수집(Loki)은 이번 범위에서 뺀다. 메모리 예산이 그만큼 없다(4장 참고). + +로컬에는 이미 [docker-compose.monitoring.yml](../../../docker-compose.monitoring.yml)로 같은 스택이 +있지만 **부하 테스트 전용**이다. `host.docker.internal` 로 스크레이프하고 MySQL 데이터소스가 +`localhost:3307` 을 본다. 운영에 그대로 쓸 수 없어 별도 구성이 필요하다. + +## 2. 범위 + +**포함** + +- `docker-compose.prod.yml` 에 Prometheus / Grafana / Alertmanager / node-exporter 추가 +- 운영 전용 `monitoring/prometheus/prometheus.prod.yml` 및 알림 규칙 파일 +- 게이트웨이 `management.server.port` 분리 (보안, 3.2절) +- `application-aws.yml` 6종에 `/actuator/prometheus` 노출 추가 +- 기존 컨테이너 9종에 `memswap_limit` 명시 +- 외부 업타임 감시 설정 +- 부하 테스트 절차와 초기화 절차 (7장) + +**제외** + +- 로그 수집(Loki/Promtail) — 메모리 예산 부족 +- 분산 추적(Tempo/Jaeger) — 현 단계에서 과하다 +- cAdvisor — 컨테이너별 자원은 node-exporter + JVM 지표로 갈음 +- Grafana 외부 공개 — SSH 터널로만 접근하므로 nginx·인증서 작업 없음 + +## 3. 아키텍처 + +### 3.1 구성 요소 + +전부 기존 `comatching` 브리지 네트워크 안에 들어간다. **호스트에 여는 포트는 +Grafana의 `127.0.0.1:3001` 하나뿐이다.** + +| 서비스 | 이미지 | 호스트 포트 | mem_limit | +|---|---|---|---| +| prometheus | `prom/prometheus:v2.54.1` | 없음 | 512m | +| grafana | `grafana/grafana:11.2.0` | `127.0.0.1:3001:3000` | 256m | +| alertmanager | `prom/alertmanager:v0.27.0` | 없음 | 128m | +| node-exporter | `prom/node-exporter:v1.8.2` | 없음 | 64m | + +이미지 태그는 로컬 모니터링 스택과 맞췄다. 버전이 갈리면 대시보드 JSON 호환 문제를 +운영에서 처음 만나게 된다. + +접근은 SSH 터널이다. + +``` +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저에서 http://localhost:3001 +``` + +Grafana의 익명 접근은 **끈다**(`GF_AUTH_ANONYMOUS_ENABLED: "false"`). 로컬 스택은 켜 두었지만 +운영은 터널 너머라도 계정을 요구하는 편이 낫다. 관리자 비밀번호는 `.env.prod` 로 넘긴다. + +node-exporter는 호스트 메트릭을 읽어야 하므로 `/proc`, `/sys`, `/` 를 읽기 전용으로 마운트하고 +`pid: host` 를 쓴다. 이 컨테이너만 예외적으로 호스트 네임스페이스를 본다. + +### 3.2 게이트웨이 관리 포트 분리 + +**이 설계에서 가장 중요한 보안 결정이다.** + +게이트웨이 라우트 목록([gateway-service/src/main/resources/application-aws.yml](../../../gateway-service/src/main/resources/application-aws.yml))에 +`/actuator/**` 가 없다. Spring Cloud Gateway는 라우트에 걸리지 않은 경로를 자신이 처리하므로, +`/actuator/prometheus` 를 그냥 열면 게이트웨이 자신의 actuator가 응답한다. 그런데 게이트웨이 +8080은 nginx가 TLS를 종단해 넘겨주는 대상이다. 결과적으로 + +``` +https://srv.comatching.site/actuator/prometheus +``` + +가 **인증 없이 공개된다.** Prometheus 노출 지표에는 엔드포인트별 URI 패턴, 호출량, JVM 내부 +상태가 다 들어 있어 내부 구조가 그대로 드러난다. + +뒤쪽 5개 서비스(user 9000, matching 9001, chat 9003, notification 9005, item 9006)는 다르다. +게이트웨이 라우트에 actuator 경로가 없고 컴포즈에 `ports` 매핑도 없어서 외부에서 도달할 방법이 +아예 없다. **위험한 것은 게이트웨이 하나뿐이다.** + +해결은 게이트웨이만 관리 엔드포인트를 별도 포트로 옮기고 그 포트를 호스트에 매핑하지 않는 것이다. + +```yaml +# gateway-service/src/main/resources/application-aws.yml +management: + server: + port: 8081 # 컴포즈에서 ports 로 열지 않는다 + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + show-details: never +``` + +이러면 `gateway-service:8081` 은 컴포즈 네트워크 안에서만 닿고, 8080으로 들어온 외부 요청은 +`/actuator/*` 를 찾지 못해 라우트 매칭 실패로 떨어진다. + +> **함께 고쳐야 할 것**: `docker-compose.prod.yml` 의 게이트웨이 healthcheck가 +> `http://localhost:8080/actuator/health` 를 찌른다. 8081로 바꾸지 않으면 기동 직후 +> healthcheck가 영구 실패하고, `depends_on: service_healthy` 가 걸린 것이 없더라도 +> 컨테이너가 계속 unhealthy로 남는다. + +나머지 5개 서비스는 포트를 분리하지 않는다. 외부 도달 경로가 없으므로 얻는 게 없고, +바꿀수록 healthcheck URL을 5군데 더 건드려야 한다. `exposure.include` 에 `prometheus` 만 더한다. + +```yaml +# user/matching/chat/item/notification 의 application-aws.yml +management: + endpoints: + web: + exposure: + include: health,prometheus +``` + +### 3.3 스크레이프 대상 + +`monitoring/prometheus/prometheus.prod.yml` 을 새로 만든다. 로컬용 +[prometheus.yml](../../../monitoring/prometheus/prometheus.yml)과 두 가지가 다르다. + +- `host.docker.internal` 이 아니라 **컨테이너 이름**으로 간다(같은 네트워크 안이므로). +- `scrape_interval` 을 5초가 아니라 **15초**로 둔다. 5초는 짧은 부하 테스트에서 데이터 포인트를 + 확보하려던 값이라 상시 운영에는 과하다. 카디널리티와 TSDB 쓰기량이 3배가 된다. + +```yaml +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다 (3.2절 참고) + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] +``` + +보존 기간은 15일, 크기 상한도 함께 건다. 디스크가 차서 죽는 것보다 오래된 데이터를 버리는 게 낫다. + +``` +--storage.tsdb.retention.time=15d +--storage.tsdb.retention.size=2GB +``` + +Grafana 데이터소스는 로컬의 [prometheus.yml](../../../monitoring/grafana/provisioning/datasources/prometheus.yml)을 +재사용하되 `timeInterval` 을 `15s` 로 맞춘다. InfluxDB·MySQL 데이터소스는 운영에 올리지 않는다 +(각각 JMeter 전용, 로컬 3307 전용). + +## 4. 메모리 설계 + +### 4.1 예산 + +t3.large는 8 GiB(커널 제외 실사용 약 7.7 GiB)다. 현재 `mem_limit` 합이 6,080m이고 +모니터링 960m을 더하면 **7,040m** 이 된다. 호스트 OS·docker 데몬·nginx 몫으로 약 860m이 남는다. + +동작은 하지만 여유가 크지 않다. `mem_limit` 은 상한이지 예약이 아니므로 실사용은 더 낮을 +가능성이 높지만, **추정으로 넘어가지 않고 7.2절에서 실측한다.** + +여유가 부족하면 순서대로 손댄다. + +1. Prometheus 보존 15일 → 7일 +2. Kafka `-Xmx1g` / `mem_limit 1280m` → `-Xmx512m` / `768m` + (단일 브로커·저트래픽 기준으로 현재 값이 후하다. 여기서만 512m이 확보된다) +3. 그래도 부족하면 t3.xlarge 승급 + +### 4.2 스왑 + +호스트에 **2 GB** 스왑 파일을 만들고 `vm.swappiness=1` 로 둔다. + +4 GB가 아니라 2 GB인 이유는, 4.3절의 핀을 걸고 나면 스왑을 쓰는 주체가 호스트 프로세스 +(sshd, systemd, docker 데몬)뿐이기 때문이다. 그쪽이 2 GB를 넘길 일이 없다. 크게 잡을수록 +루트 EBS 볼륨의 용량과 IOPS만 더 떼어준다. + +스왑을 아예 안 두지는 않는다. 메모리 압박이 걸렸을 때 **커널 OOM killer가 아무 프로세스나 +고르기 전에 차가운 페이지를 밀어내 sshd를 살려두는 것** — 즉 들어가서 고칠 수 있는 상태를 +유지하는 것이 목적이다. + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +``` + +**선결 확인**: `docker info` 에 `WARNING: No swap limit support` 가 뜨면 안 된다. +이 경고가 있으면 4.3절의 `memswap_limit` 이 조용히 무시되어 핀이 걸리지 않는다. +`stat -fc %T /sys/fs/cgroup` 이 `cgroup2fs` 면 대개 정상이다. `tmpfs`(cgroup v1)면 +커널 부팅 파라미터에 `swapaccount=1` 을 넣고 재부팅해야 한다. + +### 4.3 memswap_limit — 스왑보다 중요한 결정 + +Docker는 `--memory` 만 주고 `--memory-swap` 을 생략하면 **memory+swap 합계를 `--memory` 의 +2배**로 잡는다. 현재 `docker-compose.prod.yml` 은 전부 `mem_limit` 만 있다. 즉 호스트에 +스왑을 켜는 순간 9개 컨테이너의 실효 상한이 조용히 두 배가 된다. 한도를 늘린 게 아니라 +**한도를 무의미하게 만드는 것**에 가깝다. + +특히 JVM과 스왑은 상성이 나쁘다. full GC가 힙 전체를 훑는데 그 페이지가 EBS 스왑에 있으면 +GC 한 번이 수십 초로 늘어난다. healthcheck `timeout: 5s` 가 실패하고, 재시작이 다시 메모리를 +흔드는 악순환이 된다. EBS는 네트워크 스토리지라 대역폭을 9개 컨테이너가 공유하므로 +**한 컨테이너의 문제가 호스트 전체 브라운아웃**이 된다. + +Redis는 더 나쁘다. `appendonly yes` 라 AOF rewrite 때 fork하며 copy-on-write가 걸리는데, +그 순간 페이지가 스왑에 있으면 지연이 폭발한다. 그리고 이 코드베이스는 이미 +`maxmemory-policy noeviction` 을 "조용히 evict 되느니 시끄럽게 실패하는 게 낫다"는 이유로 +골랐다. 스왑은 정확히 그 반대 방향이다. + +그래서 **JVM 6종과 Redis는 스왑을 아예 쓰지 못하게 못박는다.** + +| 컨테이너 | mem_limit | memswap_limit | 근거 | +|---|---|---|---| +| gateway-service | 512m | **512m** | JVM — 스왑 금지 | +| user-service | 768m | **768m** | JVM — 스왑 금지 | +| matching-service | 640m | **640m** | JVM — 스왑 금지 | +| chat-service | 576m | **576m** | JVM — 스왑 금지 | +| item-service | 640m | **640m** | JVM — 스왑 금지 | +| notification | 576m | **576m** | JVM — 스왑 금지 | +| redis | 320m | **320m** | fork/COW — 스왑 금지 | +| kafka | 1280m | 1536m | 여유 256m | +| mongodb | 768m | 896m | 여유 128m | +| prometheus | 512m | 512m | 스왑 금지 | +| grafana | 256m | 256m | 스왑 금지 | +| alertmanager | 128m | 128m | 스왑 금지 | +| node-exporter | 64m | 64m | 스왑 금지 | + +`memswap_limit == mem_limit` 이면 해당 컨테이너의 스왑 사용량은 0이다. 지금과 똑같이 +자기 cgroup 한도에서 OOMKill로 시끄럽게 죽는다. [Dockerfile:52](../../../Dockerfile)의 +`-XX:+ExitOnOutOfMemoryError` 가 의도한 "좀비로 사느니 죽고 재시작한다"는 성질이 보존된다. + +## 5. 알림 + +### 5.1 채널 분리 + +Alertmanager는 **`DLT_ALERT_WEBHOOK_URL` 과 다른 디스코드 웹훅**을 쓴다. +`.env.prod` 에 `INFRA_ALERT_WEBHOOK_URL` 을 새로 둔다. + +두 알림은 성격이 반대다. DLT 알림은 "메시지 한 건이 처리에 실패했다"로 급하지 않고 잦다. +인프라 알림은 "서버가 아프다"로 지금 봐야 하고 드물다. 한 채널에 섞으면 잦은 쪽이 드문 쪽을 +묻어버리고, 결국 둘 다 안 읽게 된다. 나누는 비용은 채널 하나와 웹훅 URL 하나(2분)이고, +안 나누는 비용은 알림을 무시하는 습관이다. + +판단 기준은 **"이게 울리면 하던 일을 멈춰야 하나"** 다. DLT는 아니오, 인프라는 예다. + +Alertmanager는 v0.25.0부터 `discord_configs` 를 네이티브로 지원한다. 구현 시 실제 버전에서 +동작을 확인하고, 안 되면 `webhook_configs` + 얇은 변환 레이어로 대체한다. + +### 5.2 규칙 + +`monitoring/prometheus/rules/alerts.yml` 로 둔다. + +| 이름 | 조건 | for | 심각도 | +|---|---|---|---| +| ServiceDown | `up{job="comatching-services"} == 0` | 2m | critical | +| HighErrorRate | 5xx 비율 > 5% | 5m | critical | +| JvmHeapHigh | 힙 사용률 > 90% | 10m | warning | +| KafkaConsumerLag | `kafka_consumer_fetch_manager_records_lag_max > 1000` | 10m | warning | +| DiskSpaceLow | 루트 사용률 > 85% | 5m | warning | +| SwapInUse | 스왑 사용량 > 256MB | 10m | warning | + +`SwapInUse` 를 넣는 이유가 있다. 4.3절의 핀 때문에 컨테이너는 스왑을 못 쓰므로, +스왑이 실제로 쓰이고 있다는 것은 **호스트가 메모리 압박을 받고 있다**는 뜻이다. +7,040m / 7.7 GiB 예산의 조기 경보 역할을 한다. + +주요 PromQL: + +```promql +# HighErrorRate +sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + +# JvmHeapHigh +sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + +# DiskSpaceLow +1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + +# SwapInUse +node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 +``` + +`kafka_consumer_fetch_manager_records_lag_max` 는 Spring Kafka의 Micrometer 바인딩이 있어야 +나온다. 구현 시 `/actuator/prometheus` 응답에서 실제 노출 여부를 확인하고, 없으면 해당 규칙을 +빼거나 바인딩을 켠다. + +## 6. 외부 업타임 감시 + +셀프호스팅 모니터링에는 근본 약점이 하나 있다. **EC2가 통째로 죽으면 알림도 같이 죽는다.** +"서버가 내려갔다"는 가장 중요한 알림을 그 서버가 보내야 하는 구조다. + +이것만 외부 무료 서비스(UptimeRobot 등)로 메운다. + +- 대상: `https://srv.comatching.site/actuator/health` +- 주기: 5분 +- 알림: 인프라 디스코드 채널 (5.1절과 동일) + +EC2 쪽 작업은 없고 설정은 웹에서 5분이면 끝난다. 이 한 겹으로 셀프호스팅의 유일한 사각지대가 덮인다. + +## 7. 부하 테스트 절차 + +### 7.1 선결 과제 + +현재 도구를 운영에 그대로 쓸 수 없다. 세 가지를 고쳐야 한다. + +**(1) JMeter 러너가 HTTPS를 못 쏜다.** +[run.ps1](../../../tools/perf/jmeter/run.ps1)이 `http://${TargetHost}:$Port` 로 URL을 만든다. +EC2의 게이트웨이 8080은 `127.0.0.1` 바인딩이라 외부에서 닿지 않으므로 nginx 443으로 가야 한다. +스킴 파라미터를 추가한다. JMX의 HTTP Sampler도 프로토콜이 변수로 빠져 있는지 확인이 필요하다. + +**(2) 시드 로더가 RDS에서 동작하지 않는다.** +[load_seed.sh](../../../tools/perf/seed/load_seed.sh)는 두 가지를 전제한다. + +- `docker exec comatching-mysql` — RDS는 컨테이너가 아니다 +- 서버사이드 `LOAD DATA INFILE` — RDS는 DB 서버 파일시스템에 파일을 놓을 수 없고 + `secure_file_priv` 가 막혀 있다 + +RDS용으로는 **EC2 호스트에서 `mysql` 클라이언트로 `LOAD DATA LOCAL INFILE`** 을 쓰도록 고친다. +이때 RDS 파라미터 그룹에 `local_infile=1` 이 필요하고 클라이언트도 `--local-infile=1` 로 붙어야 한다. +`docker cp` / `chown` / `secure_file_priv` 탐지 단계는 통째로 빠진다. + +**(3) 토큰을 운영 시크릿으로 다시 발급해야 한다.** +[generate_tokens.py](../../../tools/perf/tokens/generate_tokens.py)는 기본으로 로컬 `.env` 의 +`JWT_SECRET` 을 읽는데, `.env.prod.example` 이 운영 시크릿은 로컬과 달라야 한다고 못박고 있다. +기존 `tokens.csv` 는 서명이 안 맞아 전부 401이다. `--env` 로 `.env.prod` 를 가리켜 재발급한다. + +> **참고**: 시드는 `matching_candidate` 를 SQL로 직접 넣으므로 **Kafka를 거치지 않는다.** +> 따라서 `profile-updates` 토픽에 더미 이벤트가 쌓이지 않고, 늦게 도착한 이벤트가 후보를 +> 되살리는 문제도 이 경로에서는 발생하지 않는다. + +### 7.2 절차 + +런칭 전이라 실사용자가 없다. 운영 환경에 더미를 직접 넣고 매칭 경로까지 포함해 측정한다. +별도 스냅샷 환경은 필요 없다. + +1. **베이스라인 측정** — 모니터링 올리기 전 평상시 실사용량 + ```bash + docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" + ``` +2. **스왑 + memswap_limit 적용** (4장) +3. **모니터링 스택 기동** (3장) — 대시보드로 볼 수 있어야 부하가 의미를 갖는다 +4. **더미 시드** — `START_ID=1000001` 로 10만 건. `generate_seed.py` → RDS용으로 고친 로더 +5. **토큰 재발급** — `python3 generate_tokens.py --env ../../../.env.prod` +6. **부하** — 사용자가 없는 시간대에 작은 램프부터. + 게이트웨이에 `RequestRateLimiter` 가 없어 부하가 걸러지지 않는다는 점에 유의 +7. **측정** — 부하 중 `docker stats` 를 CSV로 남기고 Grafana로 함께 본다 + ```bash + docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv + ``` +8. **판단** — 4.1절 예산과 대조. 부족하면 4.1의 완화 순서대로 + +### 7.3 초기화 + +측정이 끝나면 런칭 전에 전부 민다. **저장소 종류가 둘이라 방식도 둘이다.** + +`TRUNCATE` 는 쓰지 않는다. `profile.member_id` 가 `members` 를 FK로 참조하고 +`profile_hobby`/`profile_tag` 가 다시 `profile` 을 참조해서, MySQL InnoDB가 에러 1701로 거부한다. +`SET FOREIGN_KEY_CHECKS=0` 으로 우회할 수는 있지만 아래가 더 단순하다. + +```bash +# 1) 컨테이너 볼륨 — kafka/mongo/redis 상태 제거 +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v + +# 2) RDS — Hibernate가 세 스키마를 drop 후 재생성 +grep -q '^JPA_DDL_AUTO=' .env.prod \ + && sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod \ + || echo 'JPA_DDL_AUTO=create' >> .env.prod + +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +`down -v` 가 `kafka-data` / `mongo-data` / `redis-data` 를 날린다. RDS는 외부라 볼륨 삭제로 +지워지지 않으므로 `ddl-auto: create` 로 재생성한다. 스키마가 +`comatching_user` / `comatching_matching` / `comatching_item` 으로 분리돼 있어 서비스별로 +자기 테이블만 다시 만든다. FK 순서를 사람이 신경 쓸 일이 없다. + +> **대안**: `load_seed.sh` 는 적재 전에 `member_id >= START_ID` 인 행을 지우는 로직을 이미 +> 갖고 있다. 즉 시드 행만 ID 범위로 골라 지우는 것도 가능하다. 런칭 후에 다시 부하를 걸 +> 일이 생기면 이쪽을 써야 한다 — 그때는 `ddl-auto: create` 를 절대 쓸 수 없다. + +**그리고 반드시 되돌린다.** + +```bash +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` + +`ddl-auto` 는 부팅 시점에만 적용되므로 지금 떠 있는 컨테이너에는 영향이 없고 다음 재기동부터 +`validate` 로 돈다. **이걸 빠뜨리면 실서비스 데이터가 언젠가 조용히 전부 사라진다.** +CI가 main 푸시마다 `up -d` 를 하고, `restart: unless-stopped` 로 컨테이너가 스스로 재시작하기도 +한다. `.env.prod.example` 이 경고하는 바로 그 함정이며, 런칭 직전이라 특히 위험하다. + +## 8. 리스크와 미결 사항 + +| 항목 | 내용 | 대응 | +|---|---|---| +| 메모리 예산 | 7,040m / 7.7 GiB로 여유가 860m뿐 | 7.2절 실측 후 4.1절 완화 순서대로 | +| cgroup 스왑 계정 | v1이면 `memswap_limit` 이 무시됨 | 4.2절 선결 확인. 미지원이면 스왑 도입 보류 | +| Alertmanager 디스코드 | `discord_configs` 동작을 실제 버전에서 미검증 | 구현 시 확인, 실패하면 `webhook_configs` 대체 | +| Kafka lag 지표 | Micrometer 바인딩 노출 여부 미확인 | `/actuator/prometheus` 응답으로 확인 후 규칙 확정 | +| nginx 설정 | 저장소 밖이라 `/actuator` 처리 방식 미확인 | 3.2절이 관리 포트 분리로 nginx와 무관하게 해결 | +| 부하 커버리지 | 공개 엔드포인트만으로는 matching/chat/item 유휴 | 더미 시드로 매칭까지 포함(7.2절) | + +## 9. 구현 순서 + +1. 게이트웨이 관리 포트 분리 + `application-aws.yml` 6종 `prometheus` 노출 (3.2절) +2. `docker-compose.prod.yml` 에 `memswap_limit` 명시 (4.3절) +3. `monitoring/prometheus/prometheus.prod.yml` + `rules/alerts.yml` 작성 (3.3, 5.2절) +4. Alertmanager 설정 + `.env.prod.example` 에 `INFRA_ALERT_WEBHOOK_URL` 추가 (5.1절) +5. `docker-compose.prod.yml` 에 모니터링 4종 추가 (3.1절) +6. JMeter 러너 HTTPS 지원 + 시드 로더 RDS 대응 (7.1절) +7. 외부 업타임 감시 등록 (6장) + +1~5는 코드 변경이고 6은 도구 변경이라 서로 독립적이다. +스왑(4.2절)은 EC2에서 직접 수행하며 저장소 변경이 아니다. diff --git a/gateway-service/build.gradle b/gateway-service/build.gradle index 9081ea4..e1b72e0 100644 --- a/gateway-service/build.gradle +++ b/gateway-service/build.gradle @@ -16,6 +16,9 @@ dependencies { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'org.springframework.boot', module: 'spring-boot-starter-security' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' + // Feign 을 쓰지 않으므로 서킷브레이커 스타터도 제외한다. WebFlux 게이트웨이에 + // blocking 팩토리 자동설정이 뜨는 것을 막는 목적도 있다. + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' } implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' @@ -28,7 +31,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index 808f97d..edfa430 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -137,12 +137,18 @@ logging: org.springframework.cloud.gateway: INFO management: + server: + # 8080 은 nginx 가 TLS 를 종단해 넘겨주는 포트라, 라우트에 걸리지 않는 + # /actuator/** 를 게이트웨이 자신이 응답하면 그대로 외부에 공개된다. + # 관리 엔드포인트를 8081 로 옮기고 호스트에 매핑하지 않는다 - 컴포즈 + # 네트워크 안(Prometheus 스크레이프, 헬스체크)에서만 닿는다. + port: 8081 endpoints: web: exposure: - include: health + include: health,prometheus endpoint: health: - # 게이트웨이의 8080 은 외부에 열려 있다. always 로 두면 인증 없이 - # DB/Redis 구성요소 상태와 접속 정보 일부가 그대로 드러난다. - show-details: never \ No newline at end of file + # 8081 이라도 상세는 숨긴다. DB/Redis 구성요소 상태와 접속 정보 + # 일부가 드러나는 것은 내부라도 이득이 없다. + show-details: never diff --git a/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java b/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java deleted file mode 100644 index bf1e3cb..0000000 --- a/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.gateway; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class GatewayServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3f80a40 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# ============================================================ +# Gradle 빌드 설정. +# +# 여기 없던 시절에는 전부 기본값이었다. 기본값은 "모듈 7개를 한 줄로 세워 +# 차례로 돌린다"는 뜻이고, 서비스끼리 의존이 없는 이 저장소에서는 그만큼 +# 러너 시간을 그냥 버리는 것이었다. +# ============================================================ + +# common-module 을 뺀 서비스 6개는 서로 의존하지 않는다. 동시에 돌린다. +org.gradle.parallel=true + +# 입력이 그대로면 결과를 다시 만들지 않는다. PR 에 커밋을 여러 번 올릴 때 +# 손대지 않은 모듈의 테스트까지 매번 다시 도는 것을 막는다. +# gradle/actions/setup-gradle 이 이 캐시를 실행 간에 이어준다. +org.gradle.caching=true + +# 모듈을 병렬로 돌리면 JVM 도 그만큼 뜬다. 기본 힙으로는 부족해서 +# 컴파일 도중 OOM 이 나기 시작한다. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 + +# ============================================================ +# 이 값들이 닿는 범위는 CI 러너와 개발 PC 까지다. +# +# Dockerfile 의 빌드 스테이지도 컨텍스트 루트에서 gradlew 를 돌리기 때문에 +# 그냥 두면 여기까지 따라 들어가는데, 그 스테이지는 운영 EC2 위에서 +# (compose 의 up -d --build 경로) 서비스들이 이미 떠 있는 채로 도는 경우가 +# 있다. 남는 메모리가 1GB 남짓인 곳에서 힙 2g 를 잡고 7개 모듈을 동시에 +# 빌드하면 안 된다. 그래서 .dockerignore 로 이 파일을 컨텍스트에서 뺐다 +# (사유는 그쪽 주석에 적어 뒀다). +# +# 여기에 값을 더할 때는 그게 운영 호스트에서 돌아도 괜찮은지 한 번 보고, +# 아니라면 .dockerignore 의 그 항목이 여전히 살아 있는지 확인한다. +# ============================================================ diff --git a/item-service/build.gradle b/item-service/build.gradle index 8f6c725..3c7413a 100644 --- a/item-service/build.gradle +++ b/item-service/build.gradle @@ -27,7 +27,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index 8055318..54b9074 100644 --- a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -1,6 +1,8 @@ package com.comatching.item.domain.item.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,6 +30,13 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + Long memberId, + ItemType itemType, + int quantity, + LocalDateTime now); + @Query("SELECT i FROM Item i " + "WHERE i.memberId = :memberId " + "AND i.quantity > 0 " + diff --git a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java index f7a4827..a2bc200 100644 --- a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java @@ -81,6 +81,20 @@ long sumActivePendingQuantityByMemberIdAndItemType( @Param("now") LocalDateTime now ); + @Query(""" + SELECT COALESCE(SUM(o.expectedPrice), 0) + FROM Order o + WHERE o.memberId = :memberId + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.APPROVED + AND o.decidedAt >= :startAt + AND o.decidedAt < :endAt + """) + long sumApprovedPriceByMemberIdAndDecidedAtBetween( + @Param("memberId") Long memberId, + @Param("startAt") LocalDateTime startAt, + @Param("endAt") LocalDateTime endAt + ); + List findAllByStatusOrderByRequestedAtDesc(OrderStatus status); Page findAllByStatusAndExpiresAtAfter(OrderStatus status, LocalDateTime now, Pageable pageable); diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java new file mode 100644 index 0000000..e01dbfb --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java @@ -0,0 +1,34 @@ +package com.comatching.item.domain.roulette.dto.response; + +import java.time.LocalDateTime; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public record AdminGiftCardWinnerResponse( + Long historyId, + Long memberId, + String email, + String realName, + String nickname, + String rewardName, + RouletteType rouletteType, + LocalDateTime participatedAt +) { + public static AdminGiftCardWinnerResponse from( + RouletteHistory history, + AdminGiftCardUserProfileDto user + ) { + return new AdminGiftCardWinnerResponse( + history.getId(), + history.getMemberId(), + user.email(), + user.realName(), + user.nickname(), + history.getReward().getRewardName(), + history.getRouletteType(), + history.getParticipatedAt() + ); + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java new file mode 100644 index 0000000..e3ff3f3 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -0,0 +1,8 @@ +package com.comatching.item.domain.roulette.dto.response; + +public record RoulettePageResponse( + boolean isFreeParticipated, // 무료 참여 여부 + boolean isSpecialParticipated, // 스페셜 참여 여부 + long totalPay // 결제 금액 +) { +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java new file mode 100644 index 0000000..bbfc093 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.dto.response; + +public record RouletteSpinResponse( + String rewardName +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java new file mode 100644 index 0000000..5283173 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -0,0 +1,81 @@ +package com.comatching.item.domain.roulette.entity; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "roulette_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_roulette_history_member_type_date", + columnNames = {"member_id", "roulette_type", "participation_date"} + ) +) +public class RouletteHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reward_id", nullable = false) + private RouletteReward reward; + + @Enumerated(EnumType.STRING) + @Column(name = "roulette_type", nullable = false) + private RouletteType rouletteType; + + @Column(name = "participated_at",nullable = false) + private final LocalDateTime participatedAt = LocalDateTime.now(); + + @Column(name = "participation_date", nullable = false) + private LocalDate participationDate; + + @Column(name = "reward_granted", nullable = false) + // 일반 아이템과 풀세트는 룰렛 처리 중 지급이 끝나면 true가 된다. + // 상품권은 관리자가 실제 지급한 뒤 지급 완료 API를 호출하기 전까지 false로 남는다. + private boolean rewardGranted; + + @Builder + public RouletteHistory( + Long memberId, + RouletteReward reward, + RouletteType rouletteType, + boolean rewardGranted + ) { + this.memberId = memberId; + this.reward = reward; + this.rouletteType = rouletteType; + this.participationDate = participatedAt.toLocalDate(); + this.rewardGranted = rewardGranted; + } + + public void markRewardAsGranted() { + // 관리자가 상품권을 실제 지급한 뒤 지급 완료 상태로 변경한다. + this.rewardGranted = true; + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java new file mode 100644 index 0000000..ad73b0f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -0,0 +1,73 @@ +package com.comatching.item.domain.roulette.entity; + +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RouletteReward { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RouletteType rouletteType; + + @Column(nullable = false) + private String rewardName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RewardType rewardType; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private int rangeStart; + + @Column(nullable = false) + private int rangeEnd; + + private Integer remainingCount; + + @Builder + public RouletteReward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int quantity, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + this.rouletteType = rouletteType; + this.rewardName = rewardName; + this.rewardType = rewardType; + this.quantity = quantity; + this.rangeStart = rangeStart; + this.rangeEnd = rangeEnd; + this.remainingCount = remainingCount; + } + + public void decreaseRemainingCount() { + if (remainingCount != null && remainingCount > 0) { + remainingCount--; + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java new file mode 100644 index 0000000..5091d4c --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java @@ -0,0 +1,18 @@ +package com.comatching.item.domain.roulette.enums; + +import com.comatching.common.domain.enums.ItemType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RewardType { + MATCHING_TICKET(ItemType.MATCHING_TICKET), + OPTION_TICKET(ItemType.OPTION_TICKET), + FULL_SET(null), + GIFT_CARD(null), + NONE(null); + + private final ItemType itemType; +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java new file mode 100644 index 0000000..e58d33e --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.enums; + +public enum RouletteType { + FREE, + SPECIAL +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java new file mode 100644 index 0000000..c8d52f3 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -0,0 +1,41 @@ +package com.comatching.item.domain.roulette.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +public interface RouletteHistoryRepository extends JpaRepository { + boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + Long memberId, + RouletteType rouletteType, + LocalDateTime startAt, + LocalDateTime endAt + ); + + @EntityGraph(attributePaths = "reward") + // 상품권 당첨 이력 중 지급되지 않은 건만 당첨 시각 역순으로 조회한다. + List findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc( + RewardType rewardType + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + // 두 관리자가 같은 당첨 건을 동시에 지급 완료 처리하지 못하도록 이력 행을 잠근다. + @Query(""" + SELECT rh + FROM RouletteHistory rh + JOIN FETCH rh.reward + WHERE rh.id = :historyId + """) + Optional findByIdWithRewardForUpdate(@Param("historyId") Long historyId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java new file mode 100644 index 0000000..0b370ca --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -0,0 +1,28 @@ +package com.comatching.item.domain.roulette.repository; + +import java.util.Optional; + +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RouletteRewardRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT rr + FROM RouletteReward rr + WHERE rr.rouletteType = :rouletteType + AND rr.rangeStart <= :rouletteNumber + AND rr.rangeEnd >= :rouletteNumber + AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) + ORDER BY rr.id ASC + """) + Optional findAvailableByRouletteTypeAndRouletteNumber( + @Param("rouletteType") RouletteType rouletteType, + @Param("rouletteNumber") int rouletteNumber); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java new file mode 100644 index 0000000..f4bd9d2 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java @@ -0,0 +1,14 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; + +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; + +public interface AdminRouletteService { + + /** 아직 지급되지 않은 상품권 당첨자와 관리자 확인용 회원 정보를 반환한다. */ + List getUnpaidGiftCardWinners(); + + /** 관리자가 실제 상품권 지급을 끝낸 당첨 이력을 지급 완료 상태로 변경한다. */ + void markGiftCardAsGranted(Long historyId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java new file mode 100644 index 0000000..e7f2f07 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java @@ -0,0 +1,104 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminRouletteServiceImpl implements AdminRouletteService { + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final UserAdminClient userAdminClient; + + @Override + @Transactional(readOnly = true) + public List getUnpaidGiftCardWinners() { + // rewardType이 GIFT_CARD이고 rewardGranted가 false인 이력만 최신순으로 가져온다. + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + // 미지급 당첨자가 없으면 user-service를 호출하지 않고 빈 명단을 반환한다. + if (histories.isEmpty()) { return List.of(); } + + // user-service에서 받은 회원 목록을 memberId로 찾을 수 있게 만들어 각 당첨 이력에 회원 정보를 붙인다. + Map usersById = getUsersByMemberId(histories); + + // 가져온 유저 정보 당첨 정보와 매핑 + return histories.stream() + .map(history -> AdminGiftCardWinnerResponse.from( + history, + getUserOrThrow(usersById, history.getMemberId()) + )) + .toList(); + } + + @Override + @Transactional + public void markGiftCardAsGranted(Long historyId) { + // 같은 당첨 이력에 대한 동시 요청을 직렬화해 지급 완료 상태가 중복 처리되지 않게 한다. + RouletteHistory history = rouletteHistoryRepository.findByIdWithRewardForUpdate(historyId) + .orElseThrow(() -> new BusinessException(GeneralErrorCode.NOT_FOUND)); + + // 예외처리 + if (history.getReward().getRewardType() != RewardType.GIFT_CARD) { + // 일반 아이템, 풀세트, 꽝 이력은 상품권 지급 완료 대상으로 사용할 수 없다. + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); + } + + // 미지급 상태일경우 지급처리 완료 + if (!history.isRewardGranted()) { + history.markRewardAsGranted(); + } + } + + private Map getUsersByMemberId(List histories) { + // 같은 회원이 여러 상품권에 당첨됐어도 user-service에는 회원 ID를 한 번만 전달한다. + List memberIds = histories.stream() + .map(RouletteHistory::getMemberId) + .distinct() + .toList(); + + try { + // 당첨자별 단건 호출을 피하고 현재 미지급 명단의 회원 정보를 한 번에 조회한다. + return userAdminClient.getUsersByIds(memberIds).stream() + .collect(Collectors.toMap(AdminGiftCardUserProfileDto::id, Function.identity())); + } catch (DecodeException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } catch (FeignException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } + } + + // user-service 배치 응답에서 당첨 이력의 memberId와 일치하는 회원 정보를 반환한다. + // 활성 회원 정보가 누락된 경우 정확한 지급 대상 명단을 만들 수 없으므로 ITEM-004 예외를 발생시킨다. + private AdminGiftCardUserProfileDto getUserOrThrow( + Map usersById, + Long memberId + ) { + AdminGiftCardUserProfileDto user = usersById.get(memberId); + if (user == null) { + throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); + } + return user; + } + +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java new file mode 100644 index 0000000..382eb34 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -0,0 +1,12 @@ +package com.comatching.item.domain.roulette.service; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public interface RouletteService { + RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); + + RoulettePageResponse roulettePage(MemberInfo memberInfo); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java new file mode 100644 index 0000000..aa10937 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -0,0 +1,179 @@ +package com.comatching.item.domain.roulette.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@AllArgsConstructor +public class RouletteServiceImpl implements RouletteService { + + private static final long SPECIAL_ROULETTE_MINIMUM_PAYMENT = 3500L; + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final RouletteRewardRepository rouletteRewardRepository; + private final ItemRepository itemRepository; + private final ItemHistoryRepository itemHistoryRepository; + private final OrderRepository orderRepository; + + @Override + @Transactional + public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + // 오늘 참여한 결과 더이상 불가능 + boolean isParticipatedToday = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + if (isParticipatedToday) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } + + // 결제액이 3500미만이면 불가능 + if (rouletteType == RouletteType.SPECIAL + && orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart) < SPECIAL_ROULETTE_MINIMUM_PAYMENT) { + throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); + } + + // 난수로 추첨 + RouletteReward rouletteReward = drawAvailableReward(rouletteType); + + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + boolean rewardGranted = grantReward(memberInfo.memberId(), rouletteReward); + + // 제한 재고만 감소시키며, remainingCount가 null인 무제한 보상은 유지한다. + rouletteReward.decreaseRemainingCount(); + + // 기록 남기기 + try { + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .rewardGranted(rewardGranted) + .build()); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } + return new RouletteSpinResponse(rouletteReward.getRewardName()); + } + + + @Override + public RoulettePageResponse roulettePage(MemberInfo memberInfo) { + // 오늘 날짜 + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + // 오늘 무료 룰렛 참여 여부 + boolean isFreeParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + + // 오늘 유료 룰렛 참여 여부 + boolean isSpecialParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + + // 오늘 결제액 + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart); + + return new RoulettePageResponse(isFreeParticipated, isSpecialParticipated, totalPay); + } + + + + + + // 난수로 보상을 추첨하는 메서드 + private RouletteReward drawAvailableReward(RouletteType rouletteType) { + Optional rouletteReward; + + do { + // 1 ~ 10000 사이의 난수 생성 + int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + + // 무제한(null)이거나 남은 수량이 1 이상인 보상을 조회한다. + rouletteReward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); + + // 선택한 범위의 제한 재고가 소진됐으면 새 난수로 다시 추첨한다. + } while (rouletteReward.isEmpty()); + + // 상품권 외 보상은 무제한 재고로 운영하며, 전체 보상 소진 시 처리 정책은 확정되지 않아 예외 처리를 비활성화한다. +// if (rouletteReward.isEmpty()) { +// throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); +// } + + return rouletteReward.get(); + } + + private boolean grantReward(Long memberId, RouletteReward rouletteReward) { + RewardType rewardType = rouletteReward.getRewardType(); + + // 풀세트의 경우 + if (rewardType == RewardType.FULL_SET) { + saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); + saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); + + // 지급 완료 처리 + return true; + // 아닐경우 아이템 지급 + } else if (rewardType.getItemType() != null) { + saveRewardItem(memberId, rewardType.getItemType(), rouletteReward.getQuantity()); + + // 지급 완료 처리 + return true; + } + + // 상품권은 관리자 지급 전이고, 꽝은 지급된 보상이 없으므로 false로 기록한다. + return false; + } + + + + // 보상을 저장하고 기록을 남겨주는 메서드 + private void saveRewardItem(Long memberId, ItemType itemType, int quantity) { + itemRepository.save(Item.builder() + .memberId(memberId) + .itemType(itemType) + .quantity(quantity) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberId) + .itemType(itemType) + .historyType(ItemHistoryType.EVENT) + .quantity(quantity) + .description(itemType.getName()) + .build()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 1123123..63d4d6a 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -15,7 +15,9 @@ public enum ItemErrorCode implements ErrorCode { TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), - ; + ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), + NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, + "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."); private final String code; private final HttpStatus httpStatus; diff --git a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java index 0d524ef..fb8f055 100644 --- a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java +++ b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java @@ -5,8 +5,11 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -23,4 +26,7 @@ PagingResponse getUsers( @GetMapping("/{memberId}") AdminUserProfileDto getUserDetail(@PathVariable Long memberId); + + @PostMapping("/bulk") + List getUsersByIds(@RequestBody List memberIds); } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java new file mode 100644 index 0000000..4965ff4 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java @@ -0,0 +1,51 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.service.AdminRouletteService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin Roulette API", description = "관리자 전용 룰렛 상품권 관리") +@RestController +@RequestMapping("/api/v1/admin/roulette") +@RequiredArgsConstructor +public class AdminRouletteController { + + private final AdminRouletteService adminRouletteService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "미지급 상품권 당첨자 조회", description = "상품권에 당첨됐지만 아직 지급되지 않은 이력을 최신순으로 조회합니다.") + @GetMapping("/gift-cards/unpaid") + public ResponseEntity>> getUnpaidGiftCardWinners( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminRouletteService.getUnpaidGiftCardWinners())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "상품권 지급 완료 처리", description = "상품권 지급을 완료한 당첨 이력을 지급 완료 상태로 변경합니다.") + @PatchMapping("/gift-cards/{historyId}/grant") + public ResponseEntity> markGiftCardAsGranted( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long historyId + ) { + adminRouletteService.markGiftCardAsGranted(historyId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java new file mode 100644 index 0000000..44d3a8f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -0,0 +1,36 @@ +package com.comatching.item.infra.controller; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/items/roulette") +public class RouletteController { + private final RouletteService rouletteService; + + @PostMapping("/{rouletteType}/spins") + @Operation(summary = "룰렛 돌리는 api", description = "무료 룰렛과 유료 룰렛 (1일 1회 제한)") + public ResponseEntity> spinRoulette( + @CurrentMember MemberInfo memberInfo, + @PathVariable RouletteType rouletteType + ) { + return ResponseEntity.ok(ApiResponse.ok(rouletteService.spinRoulette(memberInfo, rouletteType))); + } + + @GetMapping + public ResponseEntity> roulettePage( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo))); + } +} diff --git a/item-service/src/main/resources/application-aws.yml b/item-service/src/main/resources/application-aws.yml index 2233ea0..c23f796 100644 --- a/item-service/src/main/resources/application-aws.yml +++ b/item-service/src/main/resources/application-aws.yml @@ -96,7 +96,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/item-service/src/main/resources/application.yml b/item-service/src/main/resources/application.yml index c47410b..9a18dc2 100644 --- a/item-service/src/main/resources/application.yml +++ b/item-service/src/main/resources/application.yml @@ -8,6 +8,10 @@ user-service: url: http://localhost:9000 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: item-service diff --git a/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java b/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java deleted file mode 100644 index 624d190..0000000 --- a/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.item; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class ItemServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java new file mode 100644 index 0000000..255a27a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -0,0 +1,226 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteServiceImpl 테스트") +class AdminRouletteServiceImplTest { + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + + @Mock + private UserAdminClient userAdminClient; + + @InjectMocks + private AdminRouletteServiceImpl adminRouletteService; + + @Test + @DisplayName("미지급 상품권 이력이 없으면 회원 조회 없이 빈 목록을 반환한다") + void shouldReturnEmptyListWithoutRequestingUsers() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of()); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + assertThat(responses).isEmpty(); + then(userAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("같은 회원의 여러 당첨 이력은 회원을 한 번만 조회하고 당첨 건별로 반환한다") + void shouldRequestDistinctUsersAndReturnEveryWinningHistory() { + RouletteHistory newestHistory = history(101L, 1L, "2만원권 상품권", false); + RouletteHistory sameMemberHistory = history(100L, 1L, "1만원권 상품권", false); + RouletteHistory anotherMemberHistory = history(99L, 2L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(newestHistory, sameMemberHistory, anotherMemberHistory)); + given(userAdminClient.getUsersByIds(List.of(1L, 2L))).willReturn(List.of( + new AdminGiftCardUserProfileDto(2L, "second@example.com", "두번째", "두번"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "첫번") + )); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + then(userAdminClient).should().getUsersByIds(List.of(1L, 2L)); + assertThat(responses).hasSize(3); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::historyId) + .containsExactly(101L, 100L, 99L); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::memberId) + .containsExactly(1L, 1L, 2L); + assertThat(responses.get(0).email()).isEqualTo("first@example.com"); + assertThat(responses.get(0).realName()).isEqualTo("첫번째"); + assertThat(responses.get(0).nickname()).isEqualTo("첫번"); + assertThat(responses.get(0).rewardName()).isEqualTo("2만원권 상품권"); + assertThat(responses.get(0).rouletteType()).isEqualTo(RouletteType.SPECIAL); + assertThat(responses.get(0).participatedAt()).isEqualTo(newestHistory.getParticipatedAt()); + } + + @Test + @DisplayName("당첨 회원 정보가 응답에서 누락되면 대상 사용자 없음 예외가 발생한다") + void shouldThrowWhenWinnerProfileIsMissing() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history)); + given(userAdminClient.getUsersByIds(List.of(1L))).willReturn(List.of()); + + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.TARGET_USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원 응답 디코딩에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertDecodeException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(DecodeException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("회원 서비스 호출에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertFeignException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(FeignException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("미지급 상품권 이력을 지급 완료로 변경한다") + void shouldMarkGiftCardAsGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("이미 지급된 상품권 이력은 그대로 유지한다") + void shouldKeepAlreadyGrantedGiftCardGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", true); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 룰렛 이력이면 찾을 수 없음 예외가 발생한다") + void shouldThrowWhenHistoryDoesNotExist() { + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(404L)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(404L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND)); + } + + @Test + @DisplayName("상품권이 아닌 룰렛 이력은 지급 완료로 변경할 수 없다") + void shouldRejectNonGiftCardHistory() { + RouletteHistory history = history( + 101L, + 1L, + "옵션권 1장", + RewardType.OPTION_TICKET, + true + ); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(101L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.INVALID_INPUT_VALUE)); + } + + private void givenGiftCardHistory() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history(101L, 1L, "1만원권 상품권", false))); + } + + private void assertUserQueryFailed() { + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.USER_QUERY_FAILED)); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + boolean rewardGranted + ) { + return history(historyId, memberId, rewardName, RewardType.GIFT_CARD, rewardGranted); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + RewardType rewardType, + boolean rewardGranted + ) { + RouletteReward reward = RouletteReward.builder() + .rouletteType(RouletteType.SPECIAL) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(rewardType == RewardType.GIFT_CARD ? 1 : null) + .build(); + RouletteHistory history = RouletteHistory.builder() + .memberId(memberId) + .reward(reward) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(rewardGranted) + .build(); + ReflectionTestUtils.setField(history, "id", historyId); + return history; + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java new file mode 100644 index 0000000..b87df75 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -0,0 +1,266 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@DataJpaTest +@ContextConfiguration(classes = RouletteConcurrencyTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@DisplayName("룰렛 동시성 테스트") +class RouletteConcurrencyTest { + + private static final int REQUEST_COUNT = 20; + + @Autowired + private RouletteService rouletteService; + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private ItemHistoryRepository itemHistoryRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + @MockitoBean + private OrderRepository orderRepository; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("단일 행 상품권은 동시 추첨 횟수만큼 재고가 차감된다") + void giftCardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + int stock = REQUEST_COUNT; + Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 10000, stock)).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween(any(), any(), any())) + .willReturn(3500L); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(index + 1L), RouletteType.SPECIAL)); + + assertThat(failures).isEmpty(); + RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); + assertThat(savedReward.getRewardType()).isEqualTo(RewardType.GIFT_CARD); + assertThat(savedReward.getRemainingCount()).isZero(); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) + .isEqualTo(REQUEST_COUNT); + assertThat(count("SELECT COUNT(*) FROM roulette_history")) + .isEqualTo(REQUEST_COUNT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") + void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 50L; + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.FREE, "옵션권 1장")).getId()); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + }); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isNull(); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 스페셜 룰렛 동시 요청은 하루에 한 번만 성공한다") + void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 51L; + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.SPECIAL, "옵션권 1장")).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(memberId), any(), any())) + .willReturn(3500L); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.SPECIAL)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + }); + assertThat(count( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + memberId, + RouletteType.SPECIAL.name())) + .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isNull(); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); + } + + private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int index = 0; index < threadCount; index++) { + int taskIndex = index; + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + return new AssertionError("동시 실행 시작 신호를 기다리다 시간 초과"); + } + try { + task.accept(taskIndex); + return null; + } catch (Throwable throwable) { + return throwable; + } + })); + } + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List failures = new ArrayList<>(); + for (Future future : futures) { + Throwable failure = future.get(30, TimeUnit.SECONDS); + if (failure != null) { + failures.add(failure); + } + } + return failures; + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + private RouletteReward itemReward( + RouletteType rouletteType, + String rewardName + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(RewardType.OPTION_TICKET) + .quantity(1) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(null) + .build(); + } + + private MemberInfo member(Long memberId) { + return new MemberInfo(memberId, "member" + memberId + "@example.com", "USER"); + } + + private long count(String sql, Object... args) { + Long result = jdbcTemplate.queryForObject(sql, Long.class, args); + return result == null ? 0L : result; + } + + private T inTransaction(java.util.concurrent.Callable callback) { + return new TransactionTemplate(transactionManager).execute(status -> { + try { + return callback.call(); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }); + } + + @FunctionalInterface + private interface ThrowingIntConsumer { + void accept(int value) throws Exception; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java new file mode 100644 index 0000000..c7209e3 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -0,0 +1,395 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; + +@DataJpaTest +@ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@DisplayName("Roulette Repository 및 트랜잭션 테스트") +class RouletteRewardRepositoryTest { + + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + + @Autowired + private TestEntityManager entityManager; + @Autowired + private RouletteHistoryRepository rouletteHistoryRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private RouletteServiceImpl rouletteService; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private ItemHistoryRepository itemHistoryRepository; + @MockitoBean + private OrderRepository orderRepository; + + @Test + @DisplayName("난수 범위의 양 끝 1과 10000에 해당하는 보상을 조회한다") + void shouldFindRewardsAtBothBoundaries() { + RouletteReward first = persist(reward(RouletteType.FREE, "첫 보상", 1, 5000, null)); + RouletteReward last = persist(reward(RouletteType.FREE, "마지막 보상", 5001, 10000, null)); + flushAndClear(); + + Optional numberOne = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); + Optional numberTenThousand = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); + + assertThat(numberOne.orElseThrow().getId()).isEqualTo(first.getId()); + assertThat(numberTenThousand.orElseThrow().getId()).isEqualTo(last.getId()); + } + + @Test + @DisplayName("풀세트 단일 행을 조회한다") + void shouldFindSingleFullSetReward() { + RouletteReward fullSet = persist(reward( + RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 8201, 9200, null)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 8500); + + assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); + } + + @Test + @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") + void shouldExcludeOutOfStockReward() { + persist(reward( + RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 9701, 9900, 0)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 9800); + + assertThat(reward).isEmpty(); + } + + @Test + @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") + void shouldSeparateRewardsByRouletteType() { + RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); + persist(reward(RouletteType.SPECIAL, "스페셜 보상", 1, 10000, null)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); + + assertThat(reward.orElseThrow().getId()).isEqualTo(freeReward.getId()); + } + + @Test + @DisplayName("미지급 상품권 이력만 당첨 시각 역순으로 조회한다") + void shouldFindOnlyUnpaidGiftCardHistoriesInLatestOrder() { + RouletteReward giftCard = persist(reward( + RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 9701, 9900, 3)); + RouletteReward item = persist(reward( + RouletteType.SPECIAL, "옵션권 2장", RewardType.OPTION_TICKET, 1, 3900, null)); + RouletteHistory olderUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(20L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory paidGiftCard = entityManager.persist(RouletteHistory.builder() + .memberId(21L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(true) + .build()); + entityManager.persist(RouletteHistory.builder() + .memberId(22L) + .reward(item) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory newerUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(23L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(10, 0)), + olderUnpaid.getId()); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(12, 0)), + newerUnpaid.getId()); + entityManager.clear(); + + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + assertThat(histories).extracting(RouletteHistory::getId) + .containsExactly(newerUnpaid.getId(), olderUnpaid.getId()); + assertThat(histories).allSatisfy(history -> { + assertThat(history.isRewardGranted()).isFalse(); + assertThat(history.getReward().getRewardType()).isEqualTo(RewardType.GIFT_CARD); + }); + assertThat(histories).extracting(RouletteHistory::getId) + .doesNotContain(paidGiftCard.getId()); + } + + @Test + @DisplayName("오늘 같은 회원의 FREE 참여 이력이 있으면 true를 반환한다") + void shouldFindTodayFreeHistoryForSameMember() { + persistHistory(1L, RouletteType.FREE); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("과거 FREE 참여 이력만 있으면 false를 반환한다") + void shouldNotFindPastFreeHistory() { + RouletteHistory history = persistHistory(1L, RouletteType.FREE); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().minusDays(1).atTime(12, 0)), + history.getId()); + entityManager.clear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 회원의 오늘 FREE 참여 이력은 조회하지 않는다") + void shouldNotFindAnotherMembersFreeHistory() { + persistHistory(2L, RouletteType.FREE); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("같은 회원의 오늘 SPECIAL 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindSpecialHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.SPECIAL); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원의 같은 날짜와 같은 타입 참여 이력은 한 행만 저장된다") + void shouldRejectDuplicateHistoryForSameMemberTypeAndDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(10L, RouletteType.FREE, rewardId); + + assertThatThrownBy(() -> saveHistoryInTransaction(10L, RouletteType.FREE, rewardId)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThat(countHistory(10L, RouletteType.FREE)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 같은 날짜에 무료와 스페셜 룰렛에 각각 참여할 수 있다") + void shouldAllowFreeAndSpecialHistoryOnSameDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + + saveHistoryInTransaction(11L, RouletteType.FREE, rewardId); + saveHistoryInTransaction(11L, RouletteType.SPECIAL, rewardId); + + assertThat(countHistory(11L, RouletteType.FREE)).isOne(); + assertThat(countHistory(11L, RouletteType.SPECIAL)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 날짜가 바뀌면 같은 타입 룰렛에 다시 참여할 수 있다") + void shouldAllowSameRouletteTypeOnNextDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + LocalDate yesterday = LocalDate.now().minusDays(1); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ?, participation_date = ? WHERE member_id = ?", + Timestamp.valueOf(yesterday.atTime(12, 0)), + yesterday, + 12L); + + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + + assertThat(countHistory(12L, RouletteType.FREE)).isEqualTo(2); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @DisplayName("아이템 이력 저장 실패 시 아이템과 룰렛 이력을 실제로 롤백한다") + void shouldRollbackSavedItemWhenItemHistorySaveFails() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, null))); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette( + new MemberInfo(1L, "member@example.com", "USER"), RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + assertThat(itemRepository.count()).isZero(); + assertThat(rouletteHistoryRepository.count()).isZero(); + assertThat(rouletteRewardRepository.findAll()) + .extracting(RouletteReward::getRemainingCount) + .containsExactly((Integer)null); + } + + private RouletteReward persist(RouletteReward reward) { + return entityManager.persist(reward); + } + + private Long saveRewardInTransaction(RouletteType rouletteType) { + return new TransactionTemplate(transactionManager).execute(status -> + rouletteRewardRepository.saveAndFlush( + reward(rouletteType, "기본 보상", 1, 10000, null)).getId()); + } + + private void saveHistoryInTransaction(Long memberId, RouletteType rouletteType, Long rewardId) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberId) + .reward(rouletteRewardRepository.findById(rewardId).orElseThrow()) + .rouletteType(rouletteType) + .build())); + } + + private long countHistory(Long memberId, RouletteType rouletteType) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + Long.class, + memberId, + rouletteType.name()); + return count == null ? 0L : count; + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { + RouletteReward historyReward = persist(reward( + RouletteType.FREE, "꽝", RewardType.NONE, 1, 10000, null)); + return entityManager.persist(RouletteHistory.builder() + .memberId(memberId) + .reward(historyReward) + .rouletteType(rouletteType) + .build()); + } + + private boolean existsHistoryToday(Long memberId, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + return rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberId, rouletteType, todayStart, todayStart.plusDays(1)); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return reward(rouletteType, rewardName, RewardType.NONE, rangeStart, rangeEnd, remainingCount); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType.getItemType() == null ? 0 : 1) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java new file mode 100644 index 0000000..6fca4d0 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -0,0 +1,458 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteServiceImpl 보상 처리 테스트") +class RouletteServiceImplTest { + + private static final MemberInfo MEMBER = new MemberInfo(1L, "member@example.com", "USER"); + private static final LocalDateTime REWARD_EXPIRED_AT = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + @Mock + private RouletteRewardRepository rouletteRewardRepository; + @Mock + private ItemRepository itemRepository; + @Mock + private ItemHistoryRepository itemHistoryRepository; + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private RouletteServiceImpl rouletteService; + + @Test + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 미참여 상태를 반환한다") + void shouldReturnFreeRouletteAsNotParticipated() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); + } + + @Test + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여 상태를 반환한다") + void shouldReturnFreeRouletteAsParticipated() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isTrue(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); + then(rouletteHistoryRepository).should() + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("오늘 결제액과 스페셜 룰렛 미참여 상태를 반환한다") + void shouldReturnPaymentAndSpecialRouletteAsNotParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3500L); + } + + @Test + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 참여 상태와 결제액을 반환한다") + void shouldReturnSpecialRouletteAsParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isTrue(); + assertThat(response.totalPay()).isEqualTo(10000L); + } + + @Test + @DisplayName("오늘 결제액이 3500원 미만이어도 결제액을 그대로 반환한다") + void shouldReturnPaymentBelowSpecialRouletteMinimum() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3499L); + } + + @Test + @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") + void shouldGrantItemRewardAndReturnRewardName() { + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", RewardType.OPTION_TICKET, 2, null); + givenReward(RouletteType.FREE, reward); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should().save(itemCaptor.capture()); + assertThat(itemCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemCaptor.getValue().getExpiredAt()).isEqualTo(REWARD_EXPIRED_AT); + + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.EVENT); + assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.OPTION_TICKET.getName()); + + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, true); + assertThat(response.rewardName()).isEqualTo("옵션권 2장"); + } + + @Test + @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") + void shouldGrantEveryFullSetReward() { + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, null); + givenReward(RouletteType.SPECIAL, fullSet); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); + + assertThat(fullSet.getRemainingCount()).isNull(); + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should(times(2)).save(itemCaptor.capture()); + assertThat(itemCaptor.getAllValues()) + .extracting(Item::getItemType, Item::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) + ); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should(times(2)).save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getAllValues()) + .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) + ); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL, true); + assertThat(response.rewardName()).isEqualTo("풀세트"); + } + + @Test + @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") + void shouldRecordGiftCardWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 1, 3); + givenReward(RouletteType.SPECIAL, reward); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); + + assertThat(reward.getRemainingCount()).isEqualTo(2); + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.SPECIAL, false); + assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); + } + + @Test + @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") + void shouldRecordNoPrizeWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + givenReward(RouletteType.FREE, reward); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, false); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") + void shouldRejectDuplicatedFreeRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + + then(itemRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("이미 스페셜 룰렛에 참여한 회원은 예외가 발생하고 결제액과 보상을 조회하지 않는다") + void shouldRejectDuplicatedSpecialRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + + then(orderRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("오늘 누적 결제액이 3500원 미만이면 예외가 발생하고 추첨하지 않는다") + void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE)); + + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") + void shouldRetryWhenSelectedRewardIsEmpty() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) + .willReturn(Optional.empty(), Optional.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(rouletteRewardRepository).should(times(2)) + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); + assertHistorySavedWith(reward, RouletteType.FREE, false); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemSaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); + givenReward(RouletteType.FREE, reward); + given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item save failed"); + + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemHistorySaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); + givenReward(RouletteType.FREE, reward); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + then(itemRepository).should().save(any(Item.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") + void shouldPropagateRouletteHistorySaveFailure() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + givenReward(RouletteType.FREE, reward); + given(rouletteHistoryRepository.saveAndFlush(any(RouletteHistory.class))) + .willThrow(new RuntimeException("roulette history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("roulette history save failed"); + } + + @Test + @DisplayName("제한 재고는 한 개 차감한다") + void shouldDecreaseLimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 2); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isEqualTo(1); + } + + @Test + @DisplayName("무제한 보상은 재고를 변경하지 않는다") + void shouldNotChangeUnlimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isNull(); + } + + @Test + @DisplayName("소진된 재고는 음수가 되지 않는다") + void shouldNotDecreaseRemainingCountBelowZero() { + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 0); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isZero(); + } + + private void givenReward(RouletteType rouletteType, RouletteReward reward) { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + if (rouletteType == RouletteType.SPECIAL) { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); + } + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) + .willReturn(Optional.of(reward)); + } + + private void assertHistorySavedWith( + RouletteReward reward, + RouletteType rouletteType, + boolean rewardGranted + ) { + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); + then(rouletteHistoryRepository).should().saveAndFlush(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(historyCaptor.getValue().getReward()).isSameAs(reward); + assertThat(historyCaptor.getValue().getRouletteType()).isEqualTo(rouletteType); + assertThat(historyCaptor.getValue().isRewardGranted()).isEqualTo(rewardGranted); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int quantity, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(quantity) + .rangeStart(1) + .rangeEnd(1) + .remainingCount(remainingCount) + .build(); + } + +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java new file mode 100644 index 0000000..feb4d4a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java @@ -0,0 +1,85 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ContextConfiguration; + +import com.comatching.item.domain.order.entity.Order; +import com.comatching.item.domain.order.entity.OrderItem; +import com.comatching.item.domain.order.repository.OrderRepository; + +@DataJpaTest +@ContextConfiguration(classes = SpecialRoulettePaymentRepositoryTest.JpaTestConfig.class) +@DisplayName("스페셜 룰렛 결제 금액 조회 테스트") +class SpecialRoulettePaymentRepositoryTest { + + @Autowired + private OrderRepository orderRepository; + + @Test + @DisplayName("오늘 승인된 본인 주문만 시작 포함 종료 제외 조건으로 합산한다") + void shouldSumOnlyMembersApprovedPaymentsWithinToday() { + Long memberId = 1L; + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + saveApprovedOrder(memberId, 1000, todayStart); + saveApprovedOrder(memberId, 2500, tomorrowStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, todayStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, tomorrowStart); + saveApprovedOrder(2L, 9000, todayStart.plusHours(1)); + saveRejectedOrder(memberId, 9000, todayStart.plusHours(1)); + + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberId, todayStart, tomorrowStart); + + assertThat(totalPay).isEqualTo(3500L); + assertThat(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + 99L, todayStart, tomorrowStart)).isZero(); + } + + private void saveApprovedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.approve(decidedAt); + orderRepository.save(order); + } + + private void saveRejectedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.reject(decidedAt); + orderRepository.save(order); + } + + private Order order(Long memberId, int expectedPrice, LocalDateTime requestedAt) { + return Order.builder() + .memberId(memberId) + .productId(1L) + .productCode("SPECIAL-ROULETTE-TEST") + .requestedItemName("테스트 상품") + .requesterRealName("테스트 회원") + .requesterUsername("test-user") + .requestedPrice(expectedPrice) + .expectedPrice(expectedPrice) + .requestedAt(requestedAt) + .expiresAt(requestedAt.plusMinutes(10)) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = {Order.class, OrderItem.class}) + @EnableJpaRepositories(basePackageClasses = OrderRepository.class) + static class JpaTestConfig { + } +} diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java new file mode 100644 index 0000000..574ee18 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -0,0 +1,133 @@ +package com.comatching.item.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteController API 계약 테스트") +class AdminRouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private AdminRouletteService adminRouletteService; + + @InjectMocks + private AdminRouletteController adminRouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminRouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/v1/admin/roulette/gift-cards/unpaid는 미지급 상품권 당첨자 목록을 반환한다") + void shouldReturnUnpaidGiftCardWinners() throws Exception { + LocalDateTime participatedAt = LocalDateTime.of(2026, 9, 10, 12, 30); + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of( + new AdminGiftCardWinnerResponse( + 101L, + 1L, + "winner@example.com", + "당첨자", + "행운", + "2만원권 상품권", + RouletteType.SPECIAL, + participatedAt + ) + )); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data[0].historyId").value(101)) + .andExpect(jsonPath("$.data[0].memberId").value(1)) + .andExpect(jsonPath("$.data[0].email").value("winner@example.com")) + .andExpect(jsonPath("$.data[0].realName").value("당첨자")) + .andExpect(jsonPath("$.data[0].nickname").value("행운")) + .andExpect(jsonPath("$.data[0].rewardName").value("2만원권 상품권")) + .andExpect(jsonPath("$.data[0].rouletteType").value("SPECIAL")) + .andExpect(jsonPath("$.data[0].participatedAt[0]").value(2026)) + .andExpect(jsonPath("$.data[0].participatedAt[1]").value(9)) + .andExpect(jsonPath("$.data[0].participatedAt[2]").value(10)) + .andExpect(jsonPath("$.data[0].participatedAt[3]").value(12)) + .andExpect(jsonPath("$.data[0].participatedAt[4]").value(30)); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("GET 미지급 상품권 당첨자 조회는 관리자 회원 헤더를 해석한다") + void shouldResolveAdminMemberHeaders() throws Exception { + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("PATCH /api/v1/admin/roulette/gift-cards/{historyId}/grant는 상품권 지급 완료를 위임한다") + void shouldMarkGiftCardAsGranted() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/{historyId}/grant", 101L) + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)); + + then(adminRouletteService).should().markGiftCardAsGranted(101L); + } + + @Test + @DisplayName("상품권 지급 완료 경로의 historyId가 숫자가 아니면 400을 반환한다") + void shouldRejectInvalidHistoryId() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/not-a-number/grant") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(adminRouletteService).shouldHaveNoInteractions(); + } +} diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java new file mode 100644 index 0000000..80af38d --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -0,0 +1,150 @@ +package com.comatching.item.infra.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteController API 계약 테스트") +class RouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private RouletteService rouletteService; + + @InjectMocks + private RouletteController rouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(rouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("POST /api/items/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + void shouldSpinRouletteAndReturnRewardName() throws Exception { + given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.SPECIAL))) + .willReturn(new RouletteSpinResponse("2만원권 상품권")); + + mockMvc.perform(post("/api/items/roulette/SPECIAL/spins") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.rewardName").value("2만원권 상품권")); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.SPECIAL)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("지원하지 않는 rouletteType 경로 값이면 400을 반환한다") + void shouldRejectUnsupportedRouletteType() throws Exception { + mockMvc.perform(post("/api/items/roulette/PAID/spins") + .header("X-Member-Id", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(rouletteService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("GET /api/items/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + void shouldReturnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(true, false, 3500L)); + + mockMvc.perform(get("/api/items/roulette") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isFreeParticipated").value(true)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(3500)); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture()); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/items/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") + void shouldReturnEachRouletteParticipationStatus() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, true, 3499L)); + + mockMvc.perform(get("/api/items/roulette") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(true)) + .andExpect(jsonPath("$.data.totalPay").value(3499)); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture()); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/items/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") + void shouldNotRequireRouletteTypeOnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, false, 0L)); + + mockMvc.perform(get("/api/items/roulette") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(0)); + } +} diff --git a/matching-service/build.gradle b/matching-service/build.gradle index dac2df6..12e8863 100644 --- a/matching-service/build.gradle +++ b/matching-service/build.gradle @@ -44,6 +44,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } -tasks.named('test') { - useJUnitPlatform() -} diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java index 8e8a2be..83cac21 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java @@ -55,6 +55,15 @@ * 연속한 N 건을 읽는다. '무작위 공간에서의 연속 구간'이므로 실제로는 무작위 표본이다. * 인덱스 시크 한 번이라 전체 인원과 무관하게 O(표본 크기) 다. *

+ * == 창이 비면 처음부터 다시 훑는다 == + * random_key 는 0~10 억, randomStart 는 0~9 억에서 뽑는다. 조건을 통과한 후보 + * 전원의 키가 randomStart 보다 작으면 창이 텅 빈다. 후보가 N 명일 때 그 확률이 + * 0.9^N/(N+1) 이라 N 이 작을수록 급격히 커진다 - 1 명이면 45%, 3 명이면 18%, + * 10 명이면 3% 다. 예전에는 이걸 그대로 '후보 없음'으로 돌려줘서, 후보 풀이 작은 + * 초기 서비스에서는 멀쩡한 상대를 두고도 매칭이 실패했다. + * 그래서 첫 조회가 빈손이면 randomStart 를 0 으로 낮춰 한 번 더 조회한다. + * 후보가 많을 때는 첫 조회가 거의 항상 성공하므로 추가 비용이 없다. + *

* == 필수 조건을 표본 '안'이 아니라 '밖'에서 거는 이유 == * 거꾸로다 — 필수 조건은 표본을 뽑을 때 함께 적용한다. * 뽑고 나서 거르면 5,000 명이 조건 통과분만 남아 수백 명으로 쪼그라든다. @@ -77,6 +86,18 @@ public class MatchingCandidateRepositoryImpl implements MatchingCandidateReposit @Override public Optional findBestCandidate(MatchingCandidateSearchCondition condition) { + Optional found = queryBestCandidate(condition); + + // 표본 창(random_key >= randomStart)이 비었다. 조건에 맞는 사람이 정말 없는 건지, + // 있는데 전원이 창 앞쪽에 몰린 건지 구분이 안 되므로 0 부터 한 번 더 훑는다. + // 이 두 번째 조회는 첫 조회가 빈손일 때만 나가므로 정상 경로의 비용은 그대로다. + if (found.isEmpty() && condition.randomStart() != 0) { + return queryBestCandidate(condition.withRandomStart(0)); + } + return found; + } + + private Optional queryBestCandidate(MatchingCandidateSearchCondition condition) { Map params = new LinkedHashMap<>(); diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java index d32c642..8d0dd44 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java @@ -29,4 +29,12 @@ public record MatchingCandidateSearchCondition( AgeOption scoreAgeOption, ContactFrequency scoreContactFrequency ) { + + /** randomStart 만 바꾼 사본. 표본 창이 비었을 때 처음부터 다시 훑기 위해 쓴다. */ + public MatchingCandidateSearchCondition withRandomStart(int newRandomStart) { + return new MatchingCandidateSearchCondition( + newRandomStart, sampleSize, targetGender, excludeMajor, excludeMemberIds, + minAge, maxAge, requiredMbtiTraits, requiredContactFrequency, requiredHobbyCategory, + myAge, scoreMbtiTraits, scoreHobbyCategory, scoreAgeOption, scoreContactFrequency); + } } diff --git a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java index 0553b0b..9e673c3 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java @@ -3,7 +3,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -11,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.matching.MatchingHistoryReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; @@ -76,18 +76,25 @@ public PagingResponse getMyMatchingHistory( return PagingResponse.from(resultPage); } + // 조회가 곧 보장이다. 방 생성은 매칭 성공 Kafka 이벤트에만 의존했는데, + // 발행이 유실되면 그 매칭은 영구히 방이 없었다. ensure 는 방이 없으면 + // chat-service 가 그 자리에서 만들므로 유실분이 이력 조회 시점에 복구된다. private Map getChatRoomIdMap(Page histories) { - List matchingIds = histories.stream() - .map(MatchingHistory::getId) - .filter(Objects::nonNull) + List ensureRequests = histories.stream() + .filter(history -> history.getId() != null) + .map(history -> new ChatRoomEnsureRequest( + history.getId(), + history.getMemberId(), + history.getPartnerId() + )) .distinct() .toList(); - if (matchingIds.isEmpty()) { + if (ensureRequests.isEmpty()) { return Map.of(); } - List chatRooms = chatRoomClient.getChatRoomReferences(matchingIds); + List chatRooms = chatRoomClient.ensureChatRooms(ensureRequests); if (chatRooms == null || chatRooms.isEmpty()) { return Map.of(); } diff --git a/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java b/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java index f0e4a39..dd0f4dd 100644 --- a/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java +++ b/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; @FeignClient(name = "chat-service", path = "/api/internal/chat/rooms", url = "${chat-service.url}") @@ -13,4 +14,9 @@ public interface ChatRoomClient { @PostMapping("/references") List getChatRoomReferences(@RequestBody List matchingIds); + + // 방이 없는 매칭은 chat-service 가 그 자리에서 만든다. Kafka 발행이 유실된 + // 매칭도 이력 조회 시점에 복구된다. + @PostMapping("/ensure") + List ensureChatRooms(@RequestBody List requests); } diff --git a/matching-service/src/main/resources/application-aws.yml b/matching-service/src/main/resources/application-aws.yml index a884974..85ae71c 100644 --- a/matching-service/src/main/resources/application-aws.yml +++ b/matching-service/src/main/resources/application-aws.yml @@ -85,7 +85,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/matching-service/src/main/resources/application.yml b/matching-service/src/main/resources/application.yml index c4d007e..46cc0ea 100644 --- a/matching-service/src/main/resources/application.yml +++ b/matching-service/src/main/resources/application.yml @@ -11,6 +11,10 @@ chat-service: url: http://localhost:9003 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: matching-service diff --git a/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java b/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java deleted file mode 100644 index 618e2c3..0000000 --- a/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.matching; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경 설정 필요 - Feign 클라이언트 설정 완료 후 활성화") -@SpringBootTest -class MatchingServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java index 10234c1..48bb1c7 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -44,6 +45,7 @@ @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration(classes = MatchingCandidateRepositoryIT.Config.class) @DisplayName("MatchingCandidateRepository 통합 테스트") +@Tag("integration") class MatchingCandidateRepositoryIT extends MySqlContainerSupport { private static final int MY_AGE = 24; @@ -289,7 +291,7 @@ void findsBestWhenSampleCoversAll() { } @Test - @DisplayName("randomStart 보다 앞선 키를 가진 후보는 표본에서 빠진다") + @DisplayName("창에 후보가 있으면 randomStart 보다 앞선 키는 표본에서 빠진다") void randomStartSkipsEarlierKeys() { save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); save(2L, "XXXX", 99, ContactFrequency.RARE, List.of()); @@ -302,6 +304,46 @@ void randomStartSkipsEarlierKeys() { .isEqualTo(3L); } + @Test + @DisplayName("창이 비면 처음부터 다시 훑어 후보를 찾아낸다") + void wrapsAroundWhenWindowIsEmpty() { + // random_key 는 삽입할 때 0~10억 중 하나로 정해지고, randomStart 는 요청마다 + // 0~9억 중 하나로 새로 뽑힌다. 후보 전원의 키가 randomStart 보다 작으면 + // 창이 비는데, 예전에는 그대로 '후보 없음'이 되어 요청이 실패했다. + // 후보 수가 적을수록 자주 걸린다 - 조건 통과자가 1명이면 45%, 3명이면 18%다. + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + save(2L, "XXXX", 99, ContactFrequency.RARE, List.of()); + fixRandomKeysByMemberId(); // random_key = 1, 2 + + assertThat(findBest(base().randomStart(10).build())).isPresent(); + } + + @Test + @DisplayName("다시 훑을 때도 필수 조건은 그대로 지킨다") + void wrapAroundStillRespectsFilters() { + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + save(2L, "XXXX", 99, ContactFrequency.FREQUENT, List.of()); + fixRandomKeysByMemberId(); + + assertThat(findBest(base() + .randomStart(10) + .requiredContactFrequency(ContactFrequency.FREQUENT) + .build())) + .get() + .extracting(MatchingCandidate::getMemberId) + .isEqualTo(2L); + } + + @Test + @DisplayName("다시 훑어도 조건에 맞는 후보가 없으면 빈 결과를 준다") + void wrapAroundStillEmptyWhenNothingMatches() { + // 재조회가 조건을 무시하고 아무나 집어오지 않는지 본다. + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + fixRandomKeysByMemberId(); + + assertThat(findBest(base().randomStart(10).minAge(200).build())).isEmpty(); + } + @Test @DisplayName("동점자가 여럿이면 매번 같은 사람만 나오지 않는다") void tiedCandidatesAreShuffled() { diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java b/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java index b53e0e2..9b9f34a 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java @@ -12,6 +12,7 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -50,6 +51,7 @@ @ContextConfiguration(classes = CandidateServiceImplIT.Config.class) @Import(CandidateServiceImpl.class) @DisplayName("CandidateService 탈퇴-갱신 순서 역전 가드") +@Tag("integration") class CandidateServiceImplIT extends MySqlContainerSupport { private static final Long MEMBER_ID = 777L; diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java deleted file mode 100644 index a7cc74a..0000000 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.comatching.matching.domain.service; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import com.comatching.common.domain.enums.ContactFrequency; -import com.comatching.common.domain.enums.Gender; -import com.comatching.common.domain.enums.HobbyCategory; -import com.comatching.common.dto.member.ProfileResponse; -import com.comatching.matching.domain.dto.MatchingRequest; -import com.comatching.matching.domain.entity.MatchingCandidate; -import com.comatching.matching.domain.repository.candidate.MatchingCandidateRepository; -import com.comatching.matching.infra.client.MemberClient; - -@Disabled("통합 테스트 환경 설정 필요 - Redis/Kafka 실행 후 활성화") -@SpringBootTest -class MatchingConcurrencyTest { - - @Autowired - private MatchingService matchingService; - - @Autowired - private MatchingCandidateRepository candidateRepository; - - @MockitoBean - private MemberClient memberClient; - - @Test - void matchingConcurrencyTest() throws InterruptedException { - // given - int threadCount = 2; - // 멀티스레드 환경을 만들기 위한 실행기 (32개 스레드 풀) - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - // 모든 스레드가 준비될 때까지 기다렸다가 동시에 '땅!' 출발시키기 위한 장치 - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(); - AtomicInteger failCount = new AtomicInteger(); - - Long memberId = 1L; // 테스트할 유저 ID - - MatchingCandidate partner = MatchingCandidate.create( - 2L, 20L, Gender.FEMALE, "ISTJ", "디자인학과", ContactFrequency.FREQUENT, - new ArrayList<>(Collections.singletonList(HobbyCategory.SPORTS)), LocalDate.of(2000, 11, 11), true - ); - candidateRepository.save(partner); - - MatchingRequest request = new MatchingRequest( - null, "IS", HobbyCategory.SPORTS, ContactFrequency.FREQUENT, false, null - ); - - given(memberClient.getProfile(anyLong())) - .willReturn(ProfileResponse.builder() - .memberId(memberId) - .gender(Gender.MALE) - .mbti("ISTJ") - .major("컴퓨터공학과") - .birthDate(LocalDate.of(2000, 1, 1)) - .build()); - - // when - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - // 매칭 요청 실행 - matchingService.match(memberId, request); - // 예외가 안 나면 성공 카운트 증가 - successCount.getAndIncrement(); - } catch (Exception e) { - // 락 획득 실패 등으로 예외가 나면 실패 카운트 증가 - System.out.println("매칭 실패: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); // 작업 끝났음을 알림 - } - }); - } - - latch.await(); // 모든 스레드가 끝날 때까지 대기 - - // then - System.out.println("=== 테스트 결과 ==="); - System.out.println("성공 횟수: " + successCount.get()); - System.out.println("실패 횟수: " + failCount.get()); - - // 검증: 성공은 무조건 1번이어야 함 (락이 제대로 동작했다면) - assertThat(successCount.get()).isEqualTo(1); - } - -} \ No newline at end of file diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java index 795c129..5ce5f81 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java @@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.matching.MatchingHistoryReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; @@ -104,7 +105,7 @@ void shouldReturnChatRoomIdInHistory() { given(historyRepository.searchHistory(memberId, null, null, pageable)) .willReturn(new PageImpl<>(List.of(history), pageable, 1)); given(memberClient.getProfiles(List.of(partnerId))).willReturn(List.of(partnerProfile)); - given(chatRoomClient.getChatRoomReferences(List.of(historyId))) + given(chatRoomClient.ensureChatRooms(List.of(new ChatRoomEnsureRequest(historyId, memberId, partnerId)))) .willReturn(List.of(new ChatRoomReferenceResponse(historyId, "room-100"))); // when @@ -112,7 +113,7 @@ void shouldReturnChatRoomIdInHistory() { memberId, null, null, pageable, false ); - // then + // then - 조회가 곧 보장이다: 방이 없던 매칭도 이 호출로 만들어진 방 ID 를 받는다 assertThat(response.content()).hasSize(1); assertThat(response.content().get(0).historyId()).isEqualTo(historyId); assertThat(response.content().get(0).chatRoomId()).isEqualTo("room-100"); diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java b/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java index 7e6781d..7240305 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -33,6 +34,7 @@ @ContextConfiguration(classes = WithdrawnMemberCleanupSchedulerIT.Config.class) @Import(WithdrawnMemberCleanupScheduler.class) @DisplayName("탈퇴 tombstone TTL 정리 배치") +@Tag("integration") class WithdrawnMemberCleanupSchedulerIT extends MySqlContainerSupport { private static final long RETENTION_DAYS = 2; diff --git a/monitoring/alertmanager/alertmanager.example.yml b/monitoring/alertmanager/alertmanager.example.yml new file mode 100644 index 0000000..e3334ce --- /dev/null +++ b/monitoring/alertmanager/alertmanager.example.yml @@ -0,0 +1,24 @@ +# ============================================================ +# 이 파일을 alertmanager.yml 로 복사해서 웹훅 URL 을 채운다. +# cp alertmanager.example.yml alertmanager.yml +# alertmanager.yml 은 .gitignore 에 있다 - 웹훅 URL 자체가 비밀값이다 +# (URL 만 알면 누구나 그 채널에 글을 쓸 수 있다). +# +# 웹훅은 DLT_ALERT_WEBHOOK_URL 과 다른 채널로 판다. DLT 알림은 잦고 안 +# 급하며, 인프라 알림은 드물고 급하다. 한 채널에 섞으면 잦은 쪽이 드문 +# 쪽을 묻어버려 결국 둘 다 안 읽는다. +# ============================================================ +route: + receiver: discord + group_by: [alertname, service] + group_wait: 30s + group_interval: 5m + # 해결 안 된 알림의 재전송 간격. 너무 짧으면 같은 알림이 도배되어 + # "잦은 쪽이 드문 쪽을 묻는" 문제를 스스로 만든다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url: 'https://discord.com/api/webhooks/CHANGE/ME' + send_resolved: true diff --git a/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml b/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml new file mode 100644 index 0000000..62329c1 --- /dev/null +++ b/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: comatching + orgId: 1 + folder: Comatching + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning-prod/datasources/prometheus.yml b/monitoring/grafana/provisioning-prod/datasources/prometheus.yml new file mode 100644 index 0000000..602b52a --- /dev/null +++ b/monitoring/grafana/provisioning-prod/datasources/prometheus.yml @@ -0,0 +1,15 @@ +# 운영 전용 프로비저닝. 로컬(provisioning/)과 분리한 이유: +# - influxdb 데이터소스는 JMeter 전용이라 운영에 없다. +# - mysql 데이터소스는 host.docker.internal:3307 을 봐서 운영에서 깨진다. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus # 대시보드 JSON 이 이 uid 로 참조한다 + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s # prometheus.prod.yml 의 scrape_interval 과 일치 diff --git a/monitoring/prometheus/prometheus.prod.yml b/monitoring/prometheus/prometheus.prod.yml new file mode 100644 index 0000000..a7ca870 --- /dev/null +++ b/monitoring/prometheus/prometheus.prod.yml @@ -0,0 +1,43 @@ +# 운영(EC2) 전용. 로컬 prometheus.yml 과 두 가지가 다르다. +# - 같은 컴포즈 네트워크 안이라 host.docker.internal 이 아니라 컨테이너 +# 이름으로 간다. +# - 15s 간격. 5s 는 짧은 부하 테스트에서 데이터 포인트를 확보하려던 +# 값이라 상시 운영에는 과하다(TSDB 쓰기량과 카디널리티가 3배). +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다. 8080 은 nginx 뒤로 외부에 + # 노출되는 포트라 actuator 를 그쪽에 열 수 없다(application-aws.yml). + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] diff --git a/monitoring/prometheus/rules/alerts.yml b/monitoring/prometheus/rules/alerts.yml new file mode 100644 index 0000000..5698dbe --- /dev/null +++ b/monitoring/prometheus/rules/alerts.yml @@ -0,0 +1,61 @@ +# 알림의 기준: "이게 울리면 하던 일을 멈춰야 하나". +# critical = 예(지금 본다), warning = 아니오(오늘 안에 본다). +groups: + - name: comatching + rules: + - alert: ServiceDown + expr: up{job="comatching-services"} == 0 + for: 2m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 스크레이프 실패 2분" + description: "컨테이너가 죽었거나 actuator 가 응답하지 않는다. docker compose ps 부터 본다." + + - alert: HighErrorRate + expr: > + sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + for: 5m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 5xx 비율 5% 초과 5분" + description: "값: {{ $value | humanizePercentage }}. 해당 서비스 로그부터 본다." + + - alert: JvmHeapHigh + expr: > + sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + for: 10m + labels: { severity: warning } + annotations: + summary: "{{ $labels.service }} 힙 90% 초과 10분" + description: "OOMKill(-XX:+ExitOnOutOfMemoryError 로 즉사·재시작) 전조다." + + - alert: KafkaConsumerLag + expr: kafka_consumer_fetch_manager_records_lag_max > 1000 + for: 10m + labels: { severity: warning } + annotations: + summary: "컨슈머 lag 1000 초과 10분" + description: "{{ $labels.service }} 의 컨슈머가 밀리고 있다. DLT 적재 여부도 확인." + + - alert: DiskSpaceLow + expr: > + 1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + for: 5m + labels: { severity: warning } + annotations: + summary: "루트 디스크 85% 초과" + description: "docker image prune, Prometheus retention, 로그부터 의심한다." + + # memswap_limit 핀 때문에 컨테이너는 스왑을 못 쓴다. 스왑이 실제로 + # 쓰인다는 것은 호스트(sshd, docker 데몬 등)가 메모리 압박을 받고 + # 있다는 뜻이다 - mem_limit 합 7,040m / 7.7GiB 예산의 조기 경보. + - alert: SwapInUse + expr: node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 + for: 10m + labels: { severity: warning } + annotations: + summary: "호스트 스왑 사용 256MB 초과 10분" + description: "컨테이너 mem_limit 합이 호스트를 압박하고 있다. spec 4.1 의 완화 순서대로." diff --git a/notification/build.gradle b/notification/build.gradle index 3c5340d..64409ad 100644 --- a/notification/build.gradle +++ b/notification/build.gradle @@ -9,6 +9,7 @@ description = 'notification' dependencies { implementation(project(':common-module')) { exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' } @@ -18,7 +19,3 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/notification/src/main/java/com/comatching/notification/NotificationApplication.java b/notification/src/main/java/com/comatching/notification/NotificationApplication.java index 9bcdc01..f4f35f8 100644 --- a/notification/src/main/java/com/comatching/notification/NotificationApplication.java +++ b/notification/src/main/java/com/comatching/notification/NotificationApplication.java @@ -11,8 +11,10 @@ import com.comatching.common.config.S3Config; import com.comatching.common.config.kafka.KafkaConsumerConfig; +import com.comatching.common.config.kafka.KafkaDltRedriveController; import com.comatching.common.config.kafka.KafkaDltTopicConfig; import com.comatching.common.config.kafka.KafkaProducerConfig; +import com.comatching.common.filter.InternalApiAuthenticationFilter; @SpringBootApplication( exclude = { @@ -26,7 +28,13 @@ // 이 서비스는 발행을 하지 않지만 DLT 발행에는 프로듀서가 필요하다. // 없으면 재시도를 다 쓴 메시지를 옮길 수단이 없어 컨텍스트가 뜨지 않는다. KafkaProducerConfig.class, - KafkaDltTopicConfig.class + KafkaDltTopicConfig.class, + // DLT 재적재 트리거와 그 인증 필터. 이 서비스는 common 을 컴포넌트 + // 스캔하지 않아 여기 안 적으면 빈이 안 생긴다. 컨트롤러만 넣고 필터를 + // 빠뜨리면 /api/internal/** 이 무인증으로 열리므로(SecurityConfig 는 + // 해당 경로를 permitAll 로 두고 필터에 위임) 반드시 둘을 같이 등록한다. + KafkaDltRedriveController.class, + InternalApiAuthenticationFilter.class }) public class NotificationApplication { diff --git a/notification/src/main/resources/application-aws.yml b/notification/src/main/resources/application-aws.yml index 85fd996..84aac07 100644 --- a/notification/src/main/resources/application-aws.yml +++ b/notification/src/main/resources/application-aws.yml @@ -43,7 +43,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 @@ -57,3 +59,8 @@ comatching: dlt-topics: member-signup,member-withdraw,chat-notification # DLT 적재를 알릴 웹훅(Discord/Slack 겸용). 비우면 로그만 남는다. dlt-alert-webhook-url: ${DLT_ALERT_WEBHOOK_URL:} + +# /api/internal/** 를 지키는 InternalApiAuthenticationFilter 의 검사 토큰. +# 비우면 내부 API 전체가 401 로 닫힌다(fail-closed). +internal: + service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/notification/src/main/resources/application.yml b/notification/src/main/resources/application.yml index 9d5bc2d..c986404 100644 --- a/notification/src/main/resources/application.yml +++ b/notification/src/main/resources/application.yml @@ -55,3 +55,8 @@ comatching: dlt-topics: member-signup,member-withdraw,chat-notification # DLT 적재를 알릴 웹훅(Discord/Slack 겸용). 비우면 로그만 남는다. dlt-alert-webhook-url: ${DLT_ALERT_WEBHOOK_URL:} + +# /api/internal/** 를 지키는 InternalApiAuthenticationFilter 의 검사 토큰. +# 비우면 내부 API 전체가 401 로 닫힌다(fail-closed). +internal: + service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java b/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java new file mode 100644 index 0000000..de2f432 --- /dev/null +++ b/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java @@ -0,0 +1,29 @@ +package com.comatching.notification; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import com.comatching.common.config.kafka.KafkaDltRedriveController; +import com.comatching.common.filter.InternalApiAuthenticationFilter; + +/** + * DLT 재적재 컨트롤러와 내부 인증 필터가 반드시 함께 등록되는지 검사한다. + * + * 이 서비스는 common 을 컴포넌트 스캔하지 않고 @Import 로 골라 쓰기 때문에, + * 컨트롤러만 넣고 필터를 빠뜨리면 /api/internal/** 이 무인증으로 열린다 + * (SecurityConfig 는 해당 경로를 permitAll 로 두고 검사를 필터에 위임한다). + * 실수로 한쪽만 지우는 회귀를 컴파일 수준이 아니라 테스트로 잡는다. + */ +class NotificationApplicationImportTest { + + @Test + void dltRedriveControllerMustBeImportedWithInternalAuthFilter() { + Import imports = NotificationApplication.class.getAnnotation(Import.class); + + assertThat(imports).isNotNull(); + assertThat(imports.value()) + .contains(KafkaDltRedriveController.class, InternalApiAuthenticationFilter.class); + } +} diff --git a/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java b/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java deleted file mode 100644 index a9b0df3..0000000 --- a/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.notification; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class NotificationApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/tools/perf/jmeter/S1-participants.jmx b/tools/perf/jmeter/S1-participants.jmx index f97b567..deecd17 100644 --- a/tools/perf/jmeter/S1-participants.jmx +++ b/tools/perf/jmeter/S1-participants.jmx @@ -64,7 +64,7 @@ ${__P(host,localhost)} ${__P(port,8080)} - http + ${__P(scheme,http)} 3000 diff --git a/tools/perf/jmeter/run.sh b/tools/perf/jmeter/run.sh index f408321..9fcaf1a 100755 --- a/tools/perf/jmeter/run.sh +++ b/tools/perf/jmeter/run.sh @@ -30,6 +30,9 @@ mkdir -p "$RUN_DIR" HOST="${HOST:-localhost}" PORT="${PORT:-8080}" +# 운영은 nginx 443 뒤라 https 로 가야 한다. 게이트웨이 8080 은 루프백 +# 바인딩이라 외부에서 직접 닿지 않는다. +SCHEME="${SCHEME:-http}" INFLUX_HOST="${INFLUX_HOST:-localhost}" echo "════════════════════════════════════════════════════════════" @@ -47,7 +50,7 @@ else echo "✅ jmeter $(jmeter --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" fi -code=$(curl -s -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/auth/participants" || echo 000) +code=$(curl -s -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) if [ "$code" = "200" ]; then echo "✅ 게이트웨이 $HOST:$PORT — 대상 엔드포인트 200" else @@ -74,9 +77,10 @@ fi # 메모리. 1회차를 통째로 날린 진짜 원인이 여기였다 — CPU 100% 는 결과였고, # 원인은 8GB 맥에서 압축 메모리 3.3GB / 스왑 5.9GB 로 스래싱이 난 것이었다. -JVMS=$(pgrep -f 'build/libs/.*\.jar' | wc -l | tr -d ' ') +# pgrep/sysctl 은 macOS/리눅스 전용 — 없는 호스트(Windows Git Bash)에서는 건너뛴다 +JVMS=$( (pgrep -f 'build/libs/.*\.jar' 2>/dev/null || true) | wc -l | tr -d ' ') CONTAINERS=$(docker ps --format '{{.Names}}' | wc -l | tr -d ' ') -SWAP_USED=$(sysctl -n vm.swapusage | sed -n 's/.*used = \([0-9.]*\)M.*/\1/p') +SWAP_USED=$( (sysctl -n vm.swapusage 2>/dev/null || true) | sed -n 's/.*used = \([0-9.]*\)M.*/\1/p') echo "ℹ️ JVM $JVMS개 / 컨테이너 $CONTAINERS개 / 스왑 사용 ${SWAP_USED:-?}M" if docker ps --format '{{.Names}}' | grep -qE 'comatching-(kafka|mongo)'; then @@ -92,11 +96,18 @@ fi # ---------- 2. CPU 샘플러 ---------- echo "" -../cpu_sampler.sh "$RUN_DIR/cpu.csv" 0 > "$RUN_DIR/cpu.log" 2>&1 & -SAMPLER_PID=$! -# 스크립트가 어떻게 끝나든(Ctrl-C 포함) 샘플러는 반드시 정리한다 -trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT -echo "🖥️ CPU 샘플러 시작 (pid $SAMPLER_PID)" +SAMPLER_PID="" +if [ "$(uname -s)" = "Darwin" ]; then + ../cpu_sampler.sh "$RUN_DIR/cpu.csv" 0 > "$RUN_DIR/cpu.log" 2>&1 & + SAMPLER_PID=$! + # 스크립트가 어떻게 끝나든(Ctrl-C 포함) 샘플러는 반드시 정리한다 + trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT + echo "🖥️ CPU 샘플러 시작 (pid $SAMPLER_PID)" +else + # sampler 는 macOS 전용(sysctl/top). 부하기와 대상이 다른 호스트면 + # 70% 폐기 기준의 대상은 부하기 CPU 뿐이므로 작업관리자로 관찰한다. + echo "ℹ️ cpu_sampler 는 macOS 전용 — 이 호스트에서는 건너뜀" +fi # ---------- 3. JMeter ---------- # 1회차엔 -Xmx2g 였는데, 8GB 맥에서는 부하기가 대상의 메모리를 뺏는 셈이었다. @@ -111,15 +122,17 @@ set +e jmeter -n -t "$PLAN" \ -l "$RUN_DIR/result.jtl" \ -j "$RUN_DIR/jmeter.log" \ - -Jhost="$HOST" -Jport="$PORT" -JinfluxHost="$INFLUX_HOST" \ + -Jhost="$HOST" -Jport="$PORT" -Jscheme="$SCHEME" -JinfluxHost="$INFLUX_HOST" \ -JtestTitle="$NAME-$STAMP" \ -Jjmeter.save.saveservice.output_format=csv JM_RC=$? set -e -kill "$SAMPLER_PID" 2>/dev/null || true -wait "$SAMPLER_PID" 2>/dev/null || true -trap - EXIT +if [ -n "$SAMPLER_PID" ]; then + kill "$SAMPLER_PID" 2>/dev/null || true + wait "$SAMPLER_PID" 2>/dev/null || true + trap - EXIT +fi if [ "$JM_RC" -ne 0 ]; then echo "⚠️ JMeter 종료 코드 $JM_RC — $RUN_DIR/jmeter.log 를 확인하세요." @@ -127,8 +140,15 @@ fi # ---------- 4. 요약 ---------- echo "" -cat "$RUN_DIR/cpu.log" | tail -5 -python3 summarize.py "$RUN_DIR/result.jtl" | tee "$RUN_DIR/summary.txt" +[ -f "$RUN_DIR/cpu.log" ] && tail -5 "$RUN_DIR/cpu.log" +# Windows Git Bash 는 python3 가 "스토어 스텁"이라 command -v 로는 못 거른다. +# 실제로 --version 이 도는 인터프리터를 고른다. cp949 콘솔 깨짐도 막는다. +PY="" +for c in python3 python; do + if "$c" --version >/dev/null 2>&1; then PY="$c"; break; fi +done +[ -n "$PY" ] || { echo "❌ python 이 없습니다"; exit 1; } +PYTHONIOENCODING=utf-8 "$PY" summarize.py "$RUN_DIR/result.jtl" | tee "$RUN_DIR/summary.txt" # ---------- 5. HTML 리포트 ---------- echo "" diff --git a/tools/perf/seed/load_seed_rds.sh b/tools/perf/seed/load_seed_rds.sh new file mode 100755 index 0000000..08e5380 --- /dev/null +++ b/tools/perf/seed/load_seed_rds.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ============================================================================ +# 생성된 TSV 를 RDS 에 적재한다. EC2 호스트에서 실행한다. +# +# 로컬용 load_seed.sh 와 갈라놓은 이유 - 그쪽의 두 전제가 RDS 에서 깨진다: +# - docker exec comatching-mysql RDS 는 컨테이너가 아니다 +# - 서버사이드 LOAD DATA INFILE RDS 는 DB 서버 파일시스템에 파일을 +# 놓을 수 없다 (secure_file_priv) +# 대신 클라이언트가 파일을 읽어 보내는 LOAD DATA LOCAL INFILE 을 쓴다. +# +# 선결 조건: +# - RDS 파라미터 그룹에 local_infile=1 (기본 0. 콘솔에서 바꾸고 적용 대기) +# - EC2 에 mysql 클라이언트 (dnf install mariadb105 또는 mysql) +# - 시드 TSV: 로컬에서 generate_seed.py 로 만들어 scp 로 올린다 +# +# 사용법: +# RDS_ENDPOINT=... RDS_USERNAME=... RDS_PASSWORD=... ./load_seed_rds.sh +# 환경변수 이름은 .env.prod 와 같으므로 이렇게 부를 수도 있다: +# set -a; . ~/comatching/.env.prod; set +a; ./load_seed_rds.sh +# ============================================================================ +set -euo pipefail +cd "$(dirname "$0")" + +: "${RDS_ENDPOINT:?RDS_ENDPOINT 가 필요하다 (.env.prod 참고)}" +: "${RDS_USERNAME:?RDS_USERNAME 이 필요하다}" +: "${RDS_PASSWORD:?RDS_PASSWORD 가 필요하다}" +START_ID="${START_ID:-1000001}" +OUT_DIR="${OUT_DIR:-./out}" + +mysql_exec() { + # 비밀번호를 -p 인자로 주면 ps 에서 다른 사용자에게 보인다. 로컬 스크립트는 + # 일회용 개발 비밀번호라 넘어갔지만 이쪽은 운영 RDS 자격증명이다. + MYSQL_PWD="$RDS_PASSWORD" mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" \ + --local-infile=1 --default-character-set=utf8mb4 "$@" +} + +# ---------- 0. 사전 점검 ---------- +if [ ! -f "$OUT_DIR/members.tsv" ]; then + echo "❌ $OUT_DIR/members.tsv 가 없습니다. 로컬에서 generate_seed.py 를 돌려 scp 로 올리세요." + exit 1 +fi + +# 서버가 local_infile 을 거부하면 여기서 일찍 죽는 편이 낫다. +LI=$(mysql_exec -N -B -e "SELECT @@GLOBAL.local_infile;") +if [ "$LI" != "1" ]; then + echo "❌ RDS 의 local_infile 이 꺼져 있습니다 ($LI). 파라미터 그룹에서 local_infile=1 로 바꾸세요." + exit 1 +fi +echo "✅ RDS $RDS_ENDPOINT — local_infile=1" + +# ---------- 1. 기존 시드 삭제 ---------- +# 재실행 가능해야 한다. generate_seed.py 가 profile_id 를 member_id 와 1:1 로 +# 맞춰두므로 조인 없이 ID 범위로 지운다. +echo "🧹 기존 시드 삭제 (member_id >= $START_ID)..." +mysql_exec <= $START_ID; +DELETE FROM comatching_user.profile_tag WHERE profile_id >= $START_ID; +DELETE FROM comatching_user.profile WHERE member_id >= $START_ID; +DELETE FROM comatching_user.members WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.candidate_hobby_categories WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID; +DELETE FROM comatching_item.item WHERE member_id >= $START_ID; +SET foreign_key_checks = 1; +SQL + +# ---------- 2. 적재 ---------- +# unique_checks / foreign_key_checks 를 끄면 InnoDB 가 행마다 검증하지 않아 +# 벌크 적재가 크게 빨라진다. 시드는 이미 유일성이 보장돼 있다. +echo "⬇️ 적재 중..." +time mysql_exec <= $START_ID +UNION ALL SELECT 'profile', COUNT(*) FROM comatching_user.profile WHERE member_id >= $START_ID +UNION ALL SELECT 'matching_candidate', COUNT(*) FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID +UNION ALL SELECT 'item', COUNT(*) FROM comatching_item.item WHERE member_id >= $START_ID; +SQL + +echo "🔎 age NULL 검사 (0 이어야 정상 — NULL 이면 매칭이 전부 실패한다)" +mysql_exec -t -e "SELECT COUNT(*) AS age_is_null FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID AND age IS NULL;" + +echo "🏁 완료. 다음: 운영 시크릿으로 토큰 재발급 (generate_tokens.py --env ~/comatching/.env.prod)" diff --git a/tools/perf/smoke.sh b/tools/perf/smoke.sh new file mode 100644 index 0000000..9444723 --- /dev/null +++ b/tools/perf/smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# ============================================================================ +# 운영 스모크 테스트 — 부하 전에 "문이 다 열리는지" 확인한다. +# +# 읽기 전용 GET 만 친다. 상태를 바꾸는 요청(가입·매칭·구매)은 없다. +# +# 사용법: +# ./smoke.sh # 공개 엔드포인트만 +# ACCESS_TOKEN= ./smoke.sh # 인증 엔드포인트까지 +# BASE_URL=http://localhost:8080 ./smoke.sh # 로컬 대상 +# +# 인증은 게이트웨이가 accessToken "쿠키"만 읽는다 (Authorization 헤더 아님). +# 토큰은 EC2 에서 tools/perf/tokens/generate_tokens.py 로 발급하거나 +# 브라우저 로그인 후 개발자도구에서 복사한다. +# ============================================================================ +set -uo pipefail + +BASE_URL="${BASE_URL:-https://srv.comatching.site}" +ACCESS_TOKEN="${ACCESS_TOKEN:-}" +TIMEOUT="${TIMEOUT:-10}" + +pass=0; fail=0 +declare -a rows + +check() { # <이름> <기대코드> <경로> [auth] + local name="$1" expect="$2" path="$3" auth="${4:-}" + local cookie=() + [ "$auth" = "auth" ] && cookie=(-b "accessToken=$ACCESS_TOKEN") + local out + out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time "$TIMEOUT" \ + "${cookie[@]}" "$BASE_URL$path" 2>/dev/null) || out="000 -" + local code="${out%% *}" time="${out##* }" + local mark="❌" + if [ "$code" = "$expect" ]; then mark="✅"; pass=$((pass+1)); else fail=$((fail+1)); fi + rows+=("$(printf '%s %-38s %s (기대 %s) %ss' "$mark" "$name" "$code" "$expect" "$time")") +} + +echo "════════════════════════════════════════════════════════" +echo " 스모크 대상: $BASE_URL" +echo "════════════════════════════════════════════════════════" + +# ---------- 공개 (인증 불필요) ---------- +check "참가자 수 조회" 200 "/api/auth/participants" +check "닉네임 중복 확인" 200 "/api/auth/signup/nickname/availability?nickname=smoke_test_probe" +check "취미 카테고리" 200 "/api/hobbies/categories" +check "프로필 태그" 200 "/api/profile/tags" + +# ---------- 인증 경계 (토큰 없이 401 이 나와야 정상) ---------- +check "매칭 히스토리(비인증)" 401 "/api/matching/history" +check "아이템 목록(비인증)" 401 "/api/items" + +# ---------- 인증 (읽기 전용) ---------- +if [ -n "$ACCESS_TOKEN" ]; then + check "매칭 히스토리" 200 "/api/matching/history" auth + check "아이템 목록" 200 "/api/items" auth + check "아이템 사용 이력" 200 "/api/items/history" auth + check "채팅방 목록" 200 "/api/chat/rooms" auth + check "채팅 안읽음 수" 200 "/api/chat/rooms/unread-count" auth +else + echo "ℹ️ ACCESS_TOKEN 미설정 — 인증 엔드포인트 5개는 건너뜀" +fi + +echo +for r in "${rows[@]}"; do echo "$r"; done +echo +echo "결과: ✅ $pass ❌ $fail" +[ "$fail" -eq 0 ] diff --git a/user-service/build.gradle b/user-service/build.gradle index 0729480..eddaab3 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -26,6 +26,10 @@ dependencies { // Kafka implementation 'org.springframework.kafka:spring-kafka' + // Cache (참여자 수 등 읽기 빈도가 높고 실시간성이 낮은 조회의 로컬 캐시) + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + // Mail implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java index 99544fd..51f570f 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -58,4 +59,15 @@ Optional findAdminMemberById( @Param("status") MemberStatus status, @Param("role") MemberRole role ); + + @Query("SELECT m FROM Member m " + + "JOIN FETCH m.profile p " + + "WHERE m.id IN :memberIds " + + "AND m.status = :status " + + "AND m.role = :role") + List findAdminMembersByIds( + @Param("memberIds") List memberIds, + @Param("status") MemberStatus status, + @Param("role") MemberRole role + ); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java index 6aca5a8..f06dbfc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java @@ -1,7 +1,10 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Pageable; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -10,4 +13,6 @@ public interface AdminMemberQueryService { PagingResponse getUsers(String keyword, Pageable pageable); AdminUserProfileDto getUserDetail(Long memberId); + + List getUsersByIds(List memberIds); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java index 63aefdd..6d697cc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java @@ -1,5 +1,7 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -8,6 +10,7 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; @@ -47,6 +50,29 @@ public AdminUserProfileDto getUserDetail(Long memberId) { return toAdminUserProfileDto(member); } + @Override + public List getUsersByIds(List memberIds) { + // 미지급 상품권 명단에 표시할 이메일, 실명, 닉네임을 회원 ID 목록으로 한 번에 조회한다. + // 반환 순서는 보장하지 않으며 호출 서비스가 회원 ID를 기준으로 원래 명단에 결합한다. + return memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ) + .stream() + .map(this::toAdminGiftCardUserProfileDto) + .toList(); + } + + private AdminGiftCardUserProfileDto toAdminGiftCardUserProfileDto(Member member) { + return new AdminGiftCardUserProfileDto( + member.getId(), + member.getEmail(), + member.getRealName(), + member.getProfile().getNickname() + ); + } + private AdminUserProfileDto toAdminUserProfileDto(Member member) { return new AdminUserProfileDto( member.getId(), diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java index 23353a8..0837d36 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.member.service; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -13,6 +14,7 @@ import com.comatching.user.domain.event.UserEventPublisher; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.config.CacheConfig; import com.comatching.user.global.config.ProfileImageProperties; import com.comatching.user.global.exception.UserErrorCode; @@ -86,6 +88,7 @@ public void withdrawMember(Long memberId) { } @Override + @Cacheable(CacheConfig.PARTICIPANT_COUNT) public long getActiveUserCount() { return memberRepository.countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE); } diff --git a/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java b/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java new file mode 100644 index 0000000..c7719f9 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java @@ -0,0 +1,32 @@ +package com.comatching.user.global.config; + +import java.time.Duration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; + +@Configuration +@EnableCaching +public class CacheConfig { + + public static final String PARTICIPANT_COUNT = "participantCount"; + + /** + * 참여자 수 COUNT 쿼리는 요청마다 ACTIVE 회원 수만큼 인덱스를 훑는다(회원 수에 비례). + * 값 자체는 표시용이라 몇 초 늦어도 되므로 짧은 TTL 로컬 캐시로 DB 부하를 끊는다. + * user-service 는 단일 인스턴스 전제라 인스턴스 간 정합성은 고려하지 않는다. + */ + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(PARTICIPANT_COUNT); + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(10)) + .maximumSize(100)); + return cacheManager; + } +} diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..bddbec8 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java @@ -1,14 +1,19 @@ package com.comatching.user.infra.controller; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.service.AdminMemberQueryService; @@ -34,4 +39,10 @@ public PagingResponse getUsers( public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { return adminMemberQueryService.getUserDetail(memberId); } + + @PostMapping("/bulk") + public List getUsersByIds(@RequestBody List memberIds) { + // item-service가 상품권 당첨자 명단을 만들 때 회원별 단건 조회를 반복하지 않도록 제공한다. + return adminMemberQueryService.getUsersByIds(memberIds); + } } diff --git a/user-service/src/main/resources/application-aws.yml b/user-service/src/main/resources/application-aws.yml index 88c0b3f..24dbc37 100644 --- a/user-service/src/main/resources/application-aws.yml +++ b/user-service/src/main/resources/application-aws.yml @@ -145,7 +145,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java b/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java index f236ff7..6df8ae2 100644 --- a/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java +++ b/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java @@ -17,6 +17,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.core.DefaultKafkaProducerFactory; @@ -43,6 +44,7 @@ */ @EmbeddedKafka(partitions = 3, topics = {"member-withdraw", "profile-updates"}) @DisplayName("UserEventPublisher 메시지 키 검증") +@Tag("integration") class UserEventPublisherIT { private static final Long MEMBER_A = 101L; diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java index 77a279e..d0f5f44 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import java.util.List; import java.util.Optional; @@ -19,6 +20,7 @@ import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.entity.Member; @@ -92,6 +94,60 @@ void shouldReturnAdminUserDetail() { assertThat(result.nickname()).isEqualTo("상세유저"); } + @Test + @DisplayName("회원 ID 목록으로 활성 일반 회원 정보를 한 번에 조회한다") + void shouldReturnUsersByIds() { + List memberIds = List.of(3L, 1L); + Member third = createMemberWithProfile( + 3L, + "third@test.com", + "세번째", + "셋", + Gender.MALE, + "https://third" + ); + Member first = createMemberWithProfile( + 1L, + "first@test.com", + "첫번째", + "하나", + Gender.FEMALE, + "https://first" + ); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of(third, first)); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + then(memberRepository).should().findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ); + assertThat(results).containsExactly( + new AdminGiftCardUserProfileDto(3L, "third@test.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@test.com", "첫번째", "하나") + ); + } + + @Test + @DisplayName("조회된 회원이 없으면 빈 사용자 목록을 반환한다") + void shouldReturnEmptyUsersByIds() { + List memberIds = List.of(404L); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of()); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + assertThat(results).isEmpty(); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java new file mode 100644 index 0000000..86af22d --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java @@ -0,0 +1,74 @@ +package com.comatching.user.domain.member.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.user.domain.event.UserEventPublisher; +import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.config.CacheConfig; +import com.comatching.user.global.config.ProfileImageProperties; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = MemberServiceCacheTest.TestConfig.class) +@DisplayName("참여자 수 캐시 테스트") +class MemberServiceCacheTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("TTL 안에서는 반복 조회해도 COUNT 쿼리는 한 번만 나간다") + void shouldHitDatabaseOnlyOnceWithinTtl() { + // given + given(memberRepository.countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE)).willReturn(50000L); + + // when + long first = memberService.getActiveUserCount(); + long second = memberService.getActiveUserCount(); + long third = memberService.getActiveUserCount(); + + // then + assertThat(first).isEqualTo(50000L); + assertThat(second).isEqualTo(50000L); + assertThat(third).isEqualTo(50000L); + then(memberRepository).should(times(1)).countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE); + } + + @Configuration + @Import({CacheConfig.class, MemberServiceImpl.class}) + static class TestConfig { + + @Bean + MemberRepository memberRepository() { + return mock(MemberRepository.class); + } + + @Bean + UserEventPublisher userEventPublisher() { + return mock(UserEventPublisher.class); + } + + @Bean + ProfileImageProperties profileImageProperties() { + return mock(ProfileImageProperties.class); + } + } +} diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java new file mode 100644 index 0000000..2f6e87f --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java @@ -0,0 +1,93 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.user.domain.member.service.AdminMemberQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InternalAdminUserController API 계약 테스트") +class InternalAdminUserControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private MockMvc mockMvc; + + @Mock + private AdminMemberQueryService adminMemberQueryService; + + @InjectMocks + private InternalAdminUserController internalAdminUserController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(internalAdminUserController).build(); + } + + @Test + @DisplayName("POST /api/internal/admin/users/bulk는 회원 ID 목록을 전달하고 사용자 정보를 반환한다") + void shouldReturnUsersByIds() throws Exception { + List memberIds = List.of(3L, 1L); + given(adminMemberQueryService.getUsersByIds(memberIds)).willReturn(List.of( + new AdminGiftCardUserProfileDto(3L, "third@example.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "하나") + )); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberIds))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(3)) + .andExpect(jsonPath("$[0].email").value("third@example.com")) + .andExpect(jsonPath("$[0].realName").value("세번째")) + .andExpect(jsonPath("$[0].nickname").value("셋")) + .andExpect(jsonPath("$[1].id").value(1)) + .andExpect(jsonPath("$[1].email").value("first@example.com")); + + then(adminMemberQueryService).should().getUsersByIds(memberIds); + } + + @Test + @DisplayName("POST 일괄 회원 조회는 빈 ID 목록도 서비스에 그대로 전달한다") + void shouldDelegateEmptyMemberIds() throws Exception { + given(adminMemberQueryService.getUsersByIds(List.of())).willReturn(List.of()); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("[]")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + + then(adminMemberQueryService).should().getUsersByIds(List.of()); + } + + @Test + @DisplayName("POST 일괄 회원 조회의 요청 본문이 배열이 아니면 400을 반환한다") + void shouldRejectMalformedMemberIds() throws Exception { + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberId\":1}")) + .andExpect(status().isBadRequest()); + + then(adminMemberQueryService).shouldHaveNoInteractions(); + } +}