Skip to content

feat(item/user): 룰렛 기능 및 관리자 API 이관 통합 - #96

Merged
choi-jin-wook merged 76 commits into
devfrom
integration/roulette-admin
Sep 12, 2026
Merged

choi-jin-wook merged 76 commits into
devfrom
integration/roulette-admin

Conversation

@choi-jin-wook

Copy link
Copy Markdown
Collaborator

PR 본문

개요

무료·스페셜 룰렛 기능과 관리자 사용자·공지 API 이관 작업을 하나의 브랜치로 통합합니다.

룰렛 추첨, 보상 지급, 상품권 당첨자 관리 기능을 추가하고 관리자 API를 실제 담당 서비스로 분리했습니다. 또한 Gateway 라우팅과 서비스 간 연동을 정리하고, 빈 사용자 조회 및 자정 경계에서
발생할 수 있는 데이터 정합성 문제를 보완했습니다.

Related: #71, #95

변경 사항

1. 무료·스페셜 룰렛 기능

  • 무료·스페셜 룰렛 타입 및 보상 도메인 구성
  • 확률 범위와 잔여 재고를 기반으로 보상 추첨
  • 일반 아이템, 풀세트, 상품권, 꽝 보상 처리
  • 스페셜 룰렛의 당일 승인 결제액 3,500원 조건 적용
  • 무료·스페셜 룰렛의 일일 참여 상태와 누적 결제액 조회
  • 룰렛 API 경로를 /api/items/roulette로 통일

2. 룰렛 동시성 및 날짜 정합성

  • 한정 보상 조회에 비관적 락 적용
  • 회원·룰렛 타입·참여 날짜 조합에 유일 제약 적용
  • 동시 중복 참여 요청을 참여 완료 예외로 변환
  • 제한 보상의 재고 초과 지급 방지
  • 요청 시작 시각을 기준으로 참여 날짜와 결제 조회 기간 고정
  • 참여 여부 조회와 DB 유일 제약을 participationDate 기준으로 통일
  • 자정 경계에서 다음 날 참여 기록으로 저장되는 문제 방지

3. 상품권 당첨자 관리

  • 미지급 상품권 당첨자 조회 API 추가
  • 상품권 지급 완료 처리 API 추가
  • 회원 정보를 user-service에서 일괄 조회하도록 내부 API 구성
  • 동일 회원의 복수 당첨 이력을 당첨 건별로 반환
  • 회원 정보가 누락된 당첨 이력은 응답에서 제외
  • 동일 당첨 건의 동시 지급 완료 요청을 비관적 락으로 직렬화

4. 관리자 사용자·공지 API 이관

  • 관리자 사용자 목록 및 상세 조회를 user-service로 이관
  • 관리자 인벤토리 수정 요청을 item-service 내부 API로 위임
  • 공지사항 등록·수정·삭제 및 활성 공지 조회를 user-service로 이관
  • 기존 item-service의 관리자 사용자·공지 API 구현 비활성화
  • 빈 사용자 목록에서는 item-service 인벤토리 조회를 생략하도록 개선

5. Gateway 라우팅 정리

  • 관리자 사용자·공지 API를 user-service로 라우팅
  • 관리자 상점·결제·룰렛 API를 item-service로 라우팅
  • 활성 공지 조회 경로를 user-service 보호 라우트에 추가
  • 기본 관리자 경로와 하위 경로를 모두 명시
  • 로컬 및 운영 환경의 라우팅 규칙 정합성 보완

테스트

  • 룰렛 보상 및 참여 상태 서비스 테스트
  • 룰렛 확률 범위와 재고 조회 테스트
  • 무료·스페셜 룰렛 동시 요청 정합성 테스트
  • 당일 승인 결제액 및 날짜 경계 테스트
  • 상품권 당첨자 조회와 지급 완료 테스트
  • 관리자 사용자·공지 API 테스트
  • Gateway 라우팅 테스트
  • ./gradlew test 전체 테스트 통과

choi-jin-wook and others added 30 commits August 12, 2026 23:44
- AdminMemberController에 사용자 목록/상세 조회, 인벤토리 수정 3개 엔드포인트를 501(Not Implemented)로 스텁 처리
- AdminMemberService/Impl 인터페이스와 메서드 뼈대 생성 (getUsers, getUserDetail, updateUserInventory), 몸통은 UnsupportedOperationException으로 스텁 처리

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- item-service에 내부 인벤토리 수량 조회 API 추가 (InternalAdminItemController, AdminItemQueryService)
- user-service에 인벤토리 관련 DTO 이관 (AdminInventoryAction/Counts/UpdateRequest, AdminUserDetailResponse, AdminUserSummaryResponse)
- user-service에 item-service 내부 API 호출용 ItemAdminClient(Feign) 신규 추가
- AdminMemberService/Impl을 domain/member에서 domain/admin으로 이동하고 인벤토리 수량 조회 로직 구현
- AdminMemberController.getUsers를 AdminMemberService에 연결 (기존 501 스텁 제거)
- UserServiceApplication에 @EnableFeignClients, application.yml에 item-service.url 추가
- AdminMemberControllerTest, AdminMemberServiceTest 신규 작성

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- AdminMemberServiceImpl#getUserDetail 구현 (로컬 회원 조회 + ItemAdminClient로 인벤토리 수량 합성)
- AdminUserDetailResponse#from 정적 팩토리 추가
- AdminMemberService/AdminMemberController 반환 타입을 AdminUserDetailResponse로 변경
- item-service AdminUserController Swagger 설명에 리팩토링 완료 표기
- gradlew 줄바꿈, chat-service/build.gradle 공백 정리
- .gitignore에 *.txt 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- item-service: PATCH /api/internal/admin/items/{memberId} 내부 API 구현 (AdminItemCommandService)
- item-service: dedupe/adjustment에 중복 구현돼 있던 검증 로직을 AdminInventoryRequestValidator로 통합해 요청당 한 번만 실행되도록 정리
- user-service: AdminMemberServiceImpl.updateUserInventory에서 ItemAdminClient.adjustInventory 호출 및 Feign 에러(409/400/기타) 매핑 추가
- user-service: AdminMemberController PATCH가 항상 501을 반환하던 버그 수정
- user-service: Feign 기본 클라이언트가 PATCH를 지원하지 않아 발생하던 500 오류 해결 (feign-hc5 추가)
- user-service: 사용자 상세 조회 실패 코드를 MEM-001에서 구경로와 동일한 ITEM-004로 통일
- user-service: UserErrorCode에 item-service와 동일한 ITEM-001/004/005/006 추가
- 관련 서비스/컨트롤러 단위 테스트 추가 및 갱신

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- item-service의 notice 도메인(entity/repository/service/dto/controller)을 user-service domain/admin/notice로 옮기고 AdminNoticeService(Impl)로 리네임
- /api/admin/notices(목록/등록/수정/삭제), /api/notices/active 엔드포인트를 AdminNoticeController로 추가
- 기존 admin 패키지를 domain/admin/user로 재정리해 notice와 계층 구조를 통일
- item-service NoticeServiceImplTest를 AdminNoticeServiceImplTest로 이관(9개 케이스)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
notification 은 common 을 컴포넌트 스캔하지 않고 @import 로 골라 쓰는
서비스라, PR #64 의 KafkaDltRedriveController 가 다른 소비 서비스에는
자동 등록됐지만 여기서만 빠졌다. 재적재 로직(KafkaDltRedriveService)은
이미 KafkaConsumerConfig 를 통해 빈으로 떠 있었으므로, 이 서비스가
소비하는 member-signup·member-withdraw·chat-notification 의 DLT 만
되돌릴 수단이 없는 상태였다.

컨트롤러와 InternalApiAuthenticationFilter 를 반드시 함께 등록한다 —
SecurityConfig 가 /api/internal/** 를 permitAll 로 두고 검사를 필터에
위임하므로, 컨트롤러만 넣으면 무인증 엔드포인트가 된다. 한쪽만 지우는
회귀는 테스트로 잡는다.

yml 에는 다른 서비스와 같은 관례로 internal.service-token 매핑을
추가한다. 값은 compose 의 env_file 로 이미 전달되는
INTERNAL_SERVICE_TOKEN 을 쓴다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(kafka): notification 에도 DLT 재적재 경로를 연다
후보 표본은 (gender, is_matchable, random_key) 인덱스에서 random_key >=
randomStart 지점으로 점프해 뽑는다. random_key 는 삽입할 때 0~10 억,
randomStart 는 요청마다 0~9 억에서 새로 뽑힌다. 조건을 통과한 후보 전원의
키가 randomStart 보다 작으면 창이 텅 비는데, 그걸 그대로 '후보 없음'으로
돌려주고 있었다.

창이 빌 확률은 조건 통과 후보가 N 명일 때 0.9^N/(N+1) 이다. N=1 이면 45%,
N=3 이면 18%, N=10 이면 3%, N=50 이면 0.01%. 5 만 명 규모를 가정한 원래
설계에서는 사실상 0 이라 드러나지 않았지만, 이성 50 명 규모에서 MBTI 를
필수 조건으로 걸면 N 이 3 명까지 떨어져 요청 4 번 중 1 번이 실패한다.
그 실패의 82% 는 조건에 맞는 상대가 실제로 있는데도 못 찾은 경우다.
후보 풀이 작은 초기 서비스일수록 심하다.

첫 조회가 빈손이면 randomStart 를 0 으로 낮춰 한 번 더 조회한다. 필수 조건은
그대로 유지되므로 조건에 맞는 사람이 정말 없으면 여전히 빈 결과가 나간다.
두 번째 조회는 첫 조회가 실패할 때만 나가고 그 상황은 애초에 조건 통과자가
적어 쿼리가 싸므로, 정상 경로의 비용은 달라지지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ound

fix(matching): 표본 창이 비면 처음부터 다시 훑어 후보를 찾는다
운영 배포는 끝났는데 관측 수단이 없다. 지금 상태를 아는 방법은 docker
compose ps 와 docker logs 뿐이고 둘 다 사람이 직접 들어가야 해서, 문제가
생겨도 사용자가 먼저 안다. Prometheus/Grafana/Alertmanager/node-exporter 를
EC2 에 올리고 "EC2 가 통째로 죽는" 사각지대만 외부 업타임 감시로 덮는 안이다.

설계에서 갈린 지점 셋을 남긴다.

  게이트웨이 관리 포트 분리. 라우트에 /actuator/** 가 없어서 그 경로는
  게이트웨이 자신이 처리하는데, 8080 은 nginx 가 TLS 를 종단해 넘겨주는
  대상이다. 그대로 prometheus 를 열면 인증 없이 공개된다. 관리 엔드포인트만
  8081 로 옮기고 호스트에 매핑하지 않는다. 뒤쪽 5개는 라우트도 포트 매핑도
  없어 외부 도달 경로가 아예 없으므로 건드리지 않는다.

  memswap_limit 명시. docker 는 mem_limit 만 주면 memory+swap 합계를 그
  2배로 잡는다. 호스트에 스왑을 켜는 순간 컨테이너 9개의 실효 상한이 조용히
  두 배가 된다는 뜻이다. JVM 은 full GC 가 힙 전체를 훑어서 스왑에 밀린
  페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 되고, redis 는 AOF
  rewrite 의 fork/COW 가 걸린다. 둘 다 mem_limit 과 같은 값으로 못박아
  지금처럼 자기 한도에서 시끄럽게 죽게 둔다.

  알림 채널 분리. DLT 알림은 잦고 안 급하며, 인프라 알림은 드물고 급하다.
  한 채널에 섞으면 잦은 쪽이 드문 쪽을 묻어버려 결국 둘 다 안 읽는다.

/docs/* 가 디렉터리째 막고 있어 하위 파일만 되살릴 수 없다. 상위 디렉터리를
열어주되 플러그인 상태 파일(.omc)은 그 안에서 다시 제외한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MessageExceptionHandler(ChatException.class) 는 발화할 수 없었다 -
ChatException 을 던지는 곳이 코드 어디에도 없고, 실제로 던져지는
BusinessException 과는 상속 관계도 없다. 그래서 방이 없거나 권한이 없어
실패한 전송이 클라이언트에 아무 신호도 남기지 못하고 증발했다.
BusinessException 을 잡아 REST 와 같은 ApiResponse 형태로
/user/queue/errors 에 내려준다.

닉네임 세션 속성은 프로필 완성 전에 발급된 토큰(클레임 없음)이면
존재하지 않는데, null 체크 없이 toString() 을 불러 전송 자체가 NPE 로
죽었다. 닉네임은 알림 미리보기용이므로 없으면 빈 값으로 두고 전송은
살린다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
읽음 갱신이 클라이언트의 READ 전송 한 번(방 입장 시)에만 의존해서,
방을 켜둔 채 받은 메시지의 "1" 은 상대가 방을 나갔다 다시 들어올
때까지 절대 사라지지 않았다.

ChatRoomPresenceTracker 가 이 인스턴스에 붙은 세션의 방 구독을 추적하고,
Redis 로 메시지를 뿌리는 시점에 수신자가 그 방을 보고 있으면 서버가
읽음 처리 후 READ 를 발행한다. READ 는 수신자 세션을 쥔 인스턴스가
Redis 로 발행하므로 발신자가 다른 인스턴스에 붙어 있어도 전파되고,
READ 메시지 자체에는 반응하지 않아 발행이 꼬리를 물지 않는다.

WebSocket 이 없는 상태를 위한 REST 폴백
(POST /api/chat/rooms/{roomId}/read)도 연다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
방 활성화(WAITING -> ACTIVE)는 요약 갱신이 겸하고 있었는데, 차단된
상대에게 보낸 메시지는 요약 갱신을 통째로 건너뛰어 방이 영구 WAITING
으로 남았다. WAITING 방은 target 목록에 보이지 않으므로 차단을 풀어도
방이 영영 나타나지 않는다.

활성화를 요약과 독립적인 원자 업데이트(activateRoom)로 떼어내 차단
경로에서도 수행한다. 요약과 알림은 차단 수신자에게 새 메시지 힌트를
주지 않도록 여전히 건너뛴다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
방 생성은 매칭 성공 Kafka 이벤트 하나에만 의존했다. 매칭 API 는 방이
생기기 전에 응답을 돌려주므로 직후 조회에는 방 ID 가 없고, 발행이
유실되면 그 매칭은 영구히 방이 없었다.

매칭 이력 조회가 chat-service 의 ensure 경로를 타게 한다. 방이 없는
매칭은 이력이 아는 참여자 정보로 그 자리에서 만들어지므로, 시간차든
유실이든 사용자가 이력을 여는 순간 복구된다. Kafka 컨슈머와의 동시
생성은 matchingId unique 인덱스가 막고, 충돌하면 먼저 만들어진 방을
다시 읽어 돌려준다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(chat): 채팅 신뢰성 4건 - 에러 전달, 자동 읽음, 방 지연 생성, 차단 활성화
feat(monitoring): 운영 모니터링 도입 - Prometheus/Grafana/Alertmanager
Gemini Code Assist 가 서비스를 종료해 .gemini/styleguide.md 가 아무 데도
읽히지 않는 파일이 됐다. 리뷰 기준 자체는 그대로 쓸 수 있으므로 벤더
색깔을 뺀 docs/code-review-guidelines.md 로 옮기고, opencode GitHub
Action 이 그 파일을 읽어 리뷰하도록 한다. 모델은 그대로 Gemini 를 쓴다
(google/gemini-3.7-flash) - 죽은 것은 Gemini 가 아니라 그 GitHub App
이므로, API 로는 계속 쓸 수 있다.

- opencode-review.yml: PR open/synchronize 시 자동 리뷰
- opencode.yml: 코멘트에 /oc 로 수동 호출

GEMINI_API_KEY 시크릿이 있어야 동작한다.
opencode 는 자격증명이 감지된 provider 만 로드한다. GEMINI_API_KEY 만
넣었더니 google provider 가 켜지지 않아 google/gemini-3.7-flash 를
못 찾았다(에러가 provider 접두사 없는 이름을 제안한 것이 단서였다).
popeye0618 and others added 29 commits August 26, 2026 15:33
운영 부하 측정(perf-log 5회차)에서 /api/auth/participants 의
COUNT 쿼리가 요청마다 ACTIVE 회원 수만큼 인덱스를 훑어
(10만 시드 기준 rows=49,616) 처리량 상한이 60 RPS 로 눌렸다.

참여자 수는 표시용이라 몇 초 늦어도 되므로 Caffeine
expireAfterWrite 10초 로컬 캐시로 DB 부하를 끊는다.
user-service 는 단일 인스턴스 전제.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
배포마다 커밋 SHA 태그 이미지 6개(~3GB)가 쌓이는데
prune 의 until=72h 필터 때문에 머지가 잦은 날에는 하나도
안 지워졌다. 13세대 24GB 가 쌓여 디스크 99% 로 배포가
pull 도중 죽는 사고가 났다(#82 배포 실패).

롤백은 GHCR 재-pull 로 가능하므로 로컬에 옛 세대를
남겨둘 이유가 없다. 필터를 빼서 실행 중인 세트만 남긴다.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: 운영 부하 1차(S1) 준비 — 스모크 스크립트·Windows 부하기 호환·체크리스트

- tools/perf/smoke.sh: 운영 읽기 전용 스모크 (공개 6종 + 인증 5종)
- run.sh: Windows Git Bash 호환 (python3 폴백, mac 전용 sysctl/pgrep/cpu_sampler 가드)
- runbooks/2026-08-26-prod-S1-loadtest-checklist.md: D-day 절차 (1만 리허설 → 10만 본판)
- 런북 §7: UptimeRobot URL 을 공개 엔드포인트로 정정 (actuator 는 외부 404 가 정상)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: 운영 첫 부하 리허설(시드 1만) 기록 — knee 300 RPS

- 회차 4 기록: 분리된 부하기, 에러 0%, knee=400 계단(p95 1187ms)
- run.sh: Windows python 스텁 감지 수정, 요약 UTF-8 강제
- 체크리스트: 부하 전 deploy 워크플로 확인 절차 추가 (실전에서 당함)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: 운영 본판(시드 10만) 기록 — 상한 60 RPS, COUNT 풀스캔 가설 재현

처리량이 부하 4배에도 59-60 RPS 로 평평, 데이터 10배에 상한 1/5.
EC2 2vCPU 포화(load avg 2.85), user-service 는 0.8코어로 여유.
다음 회차 결정 후보: 인덱스 / participants 캐시 / kafka·mongo 버스트 조사

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: EXPLAIN 실측 반영 — 인덱스는 이미 있고 COUNT 자체가 행수 비례

idx_role_status 커버링 스캔이지만 rows 49,616. 결정 후보에서 인덱스 제거,
캐시를 추천으로 승격, RDS 버퍼 풀 128MB 기본값 발견 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: perf-log 6회차 — 캐시 적용 후 상한 60→330 RPS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: 문서·도구만 바뀐 머지에는 배포를 건너뛴다

docs/tools/마크다운만 바뀐 push 에도 deploy 가 돌아
6개 컨테이너가 전부 재기동되고 있었다. 런타임이 읽지 않는
경로는 paths-ignore 로 배포를 생략한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
타임아웃(#78)만으로는 피호출 서비스가 죽어 있는 동안에도 모든 요청이
매번 타임아웃까지 기다린다. Resilience4j 서킷브레이커를 붙여 실패가
쌓이면 호출을 즉시 차단하고 503 으로 응답한다.

- 브레이커는 메서드가 아니라 Feign 클라이언트(name) 단위로 묶는다.
  내부 호출은 메서드별 트래픽이 적어 기본(메서드 단위) 네이밍으로는
  minimum-number-of-calls 에 도달하지 못해 브레이커가 열리지 않는다.
- 4xx 는 실패로 세지 않는다. 비즈니스 에러가 브레이커를 열면 멀쩡한
  서비스로 가는 호출까지 차단된다.
- 스레드풀 실행을 꺼서 Spring Cloud 기본 TimeLimiter(1s)가 read-timeout
  보다 먼저 호출을 끊는 것을 막는다.
- 폴백 없는 호출의 예외가 NoFallbackAvailableException 으로 래핑되는
  문제는 프록시 단계 언랩퍼로 원본 예외를 복원한다. 기존
  catch (FeignException) 분기가 그대로 동작한다.
- 브레이커 open / 타임아웃은 503(GEN-105) 으로 내린다.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
common-module 변경 PR(#85)의 자동 리뷰가 9분 넘게 걸렸다. CLAUDE.md 의
"서비스 경계를 넘는 변경은 하위 호환성을 특히 주의해서 본다" 지침을 따라
diff 밖의 다른 서비스 소비 코드까지 찾아 읽었기 때문으로 보인다 - diff
자체는 403줄로 크지 않았다.

자동 리뷰(claude-code-review.yml)는 diff 에 나온 코드만 보게 프롬프트로
범위를 좁히고, common-module/내부 API/Kafka 스키마 변경이 보이면 실제
분석 대신 "@claude 로 수동 리뷰를 요청하라"는 코멘트만 남기도록 한다.
cross-service 하위 호환성 분석은 claude.yml(@claude 수동 호출)의 역할로
문서화한다.

allowedTools 는 건드리지 않았다 - #79 에서 도구를 뺐다가 리뷰가 통째로
증발한 사고가 있어서, 속도 문제는 프롬프트로만 좁힌다.
유휴 서버에서 kafka 평균 34%/피크 188%, mongo 평균 12%/피크 111% CPU 를
확인했고, 버스트 시각이 docker inspect 의 헬스체크 실행 기록과 초 단위로
일치했다. kafka-broker-api-versions.sh 는 호출마다 JVM 을(1회 3~6초),
mongosh 는 Node.js 를(1회 ~1초) 새로 띄운다. interval 10s 와 만나
2 vCPU 중 ~0.46코어를 헬스체크가 상시 소모 - 회차 6 부하에서 본
kafka 179% 피크의 정체다.

TCP 접속 확인(실측 0.1초)으로 교체하고 interval 30s, 기동 중에만
start_interval 2s 로 촘촘히 본다. 이 헬스체크의 소비자는 compose 기동
순서뿐이라 API 수준 검사를 유지할 이유가 없다. 조사 기록은
docs/perf-log.md 회차 7 에 남겼다.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
  - Gateway에 관리자 사용자·공지 및 활성 공지 user-service 라우트 추가
  - user-service 관리자 사용자·공지 API 경로를 /api/v1으로 변경
  - item-service 기존 관리자 사용자·공지 구현 비활성화
- RouletteReward, RouletteHistory 엔티티 추가
- RouletteType, PrizeType enum 추가
  - FREE 룰렛 중복 참여 검증 및 PAID 룰렛 티켓 차감 처리
  - 확률 범위 기반 보상 추첨과 제한 재고 차감 로직 추가
  - 아이템 보상과 아이템 사용·획득 이력 저장
  - 상품권·꽝을 포함한 룰렛 참여 이력 저장
  - 당첨 보상명을 반환하는 응답 DTO 및 룰렛 API 추가
  - 보상 처리 예외와 난수 경계 조건 테스트 작성
  - PAID 룰렛 티켓 실제 차감 검증
  - FREE 당일 참여 조회 조건 검증
  - 풀세트 전체 재고 차감 검증
  - PAID 일일 제한 미적용 검증
  - 보상 처리 실패 시 트랜잭션 롤백 검증
  - 아이템 이력 설명과 룰렛 API 계약 검증
* test: S2 매칭 시나리오 JMeter 플랜 — 쓰기 경로 첫 계단(10~100 RPS)

POST /api/matching 을 VU 500계정 토큰 로테이션으로 때린다. 옵션권을
쓰지 않는 본문(점수 조건 4개만)으로 소모를 매칭권 1장에 고정하고,
계단은 S1 상한(331 RPS)과 요청당 비용을 감안해 10/25/50/100 으로 낮게
잡았다. 설계 근거와 실행 전 체크리스트는 jmx 머리 주석에 있다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: S2 인증을 accessToken 쿠키로, DNS clientHold 우회(정적 매핑·--resolve) 추가

스모크 실측: 게이트웨이 extractToken 은 Authorization 헤더가 아니라
accessToken 쿠키만 읽는다(Bearer 는 401 TOKEN_MISSING). 도메인이
2026-08-27 registrar clientHold 로 NXDOMAIN 이 돼 jmx 에 DNS 정적 매핑,
run.sh 에 RESOLVE 환경변수를 추가했다. E2E 스모크(매칭→Kafka→Mongo
채팅방 생성)까지 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: 회차 8 — S2 매칭 실측, 상한 30 RPS 의 정체는 OSIV 커넥션 점유

knee 25 RPS(p95 1086ms), 상한 ~30 RPS, 에러 0%. active 10/10 +
pending 38 + 점유 113→337ms 로 풀 수학(10÷0.33s≈30)이 상한을 정했다.
OSIV + 트랜잭션 없는 네이티브 쿼리가 커넥션을 Feign 까지 쥔다.
Kafka 체인은 11,592/11,592 손실 0, 지연 p95 0.04s — 병목 아님.
회차 7 헬스체크 수정도 부하에서 검증(kafka 평균 8~12%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
회차 8 실측: 이 네이티브 쿼리가 트랜잭션 없이 돌아 OSIV 세션이 닫힐
때까지 커넥션을 쥐었다(점유 113~337ms, SQL 은 ~25ms). 풀 10개 ÷ 0.33s
≈ 30 RPS 가 매칭 처리량 상한의 정체. 트랜잭션 경계를 쿼리에 맞춰
점유를 SQL 시간으로 되돌린다. OSIV 전면 비활성화는 컨트롤러 lazy 접근
전수 확인이 필요해 별도 작업으로 남긴다.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
회차 9 재측정: readOnly 트랜잭션을 붙여도 상한 29 RPS·점유 350ms 그대로.
체크아웃이 요청당 정확히 1회 — OSIV 가 첫 DB 접근에서 잡은 커넥션을
요청 끝까지 쥔다. 트랜잭션 종료는 커넥션을 OSIV 세션에 되돌릴 뿐
풀로 반납하지 않는다. 끄기 전 전수 확인: 웹 경로 트랜잭션 밖 lazy
접근 0건. 반증 과정은 perf-log 회차 9 에 기록.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* perf: 매칭 표본 5,000 → 2,000 — RDS CPU 병목 완화 (회차 10 진단)

상한 29 RPS 의 병목이 RDS CPU 로 확정됐다(스레드 덤프 + 동시성 실측,
perf-log 회차 10). 후보 쿼리의 temp table·정렬 비용이 표본 크기에
비례하므로 60% 줄인다. 트레이드오프: 80점 이상 확보율 99.9% → 94.2%
(회차 3 분포표) — 동점 그룹 무작위 선택 구조라 사용자 체감 없음.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: 표본 크기 단언을 2,000 으로 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    - 유료 룰렛 티켓 조회에 비관적 락 적용
    - 한정 보상 재고 조회에 비관적 락 적용
    - 무료 룰렛 참여 이력에 회원·유형·날짜 유일 제약 추가
    - 동시 중복 참여를 ITEM-007 예외로 변환
    - 무료·유료 룰렛 및 한정 보상 동시성 테스트 추가
    - 룰렛 보상을 단일 행으로 조회하도록 변경
    - 풀세트 한 행으로 옵션권과 매칭권을 함께 지급
    - 지급 아이템별 EVENT 이력 저장
    - 무료 룰렛 참여 날짜 저장 및 일일 유일 제약 적용
    - 반복 코드의 메서드 분리를 위한 TODO 추가
    - 당일 참여 검증과 보상 처리 테스트 수정
  - PAID 룰렛을 SPECIAL 룰렛으로 변경하고 룰렛 티켓 사용 로직 제거
  - 당일 승인 결제액 3,500원 이상인 회원만 스페셜 룰렛 참여 허용
  - 무료·스페셜 룰렛의 당일 중복 참여 예외 처리
  - 룰렛 참여 가능 여부와 당일 누적 결제액 조회 API 추가
  - 룰렛 실행 API를 타입별 경로 구조로 변경
  - 스페셜 룰렛과 참여 상태 조회 테스트 보강
    - FREE와 SPECIAL 참여 이력에 회원·타입·날짜 유일 제약 적용
    - 참여 이력을 즉시 flush하여 동시 중복 요청을 ITEM-007로 변환
    - 동일 회원의 FREE·SPECIAL 동시 요청에서 보상 중복 지급 방지 검증
    - 날짜별 참여 유일 제약과 당일 승인 결제액 합산 테스트 추가
  - 본인의 승인된 주문만 누적 결제액에 포함되는지 검증
  - 조회 시간 범위의 시작 포함·종료 제외 조건 검증
  - 다른 회원과 승인 거절 주문이 합산에서 제외되는지 검증
  - 결제 내역이 없는 회원의 합계가 0인지 검증
    - 룰렛 페이지에서 무료·스페셜 참여 여부와 당일 결제액을 함께 반환
    - 룰렛 타입별 조회 파라미터를 제거하고 응답 DTO 구조 변경
    - 보상 추첨과 지급 로직을 별도 메서드로 분리
    - 변경된 룰렛 조회 API 계약에 맞춰 서비스·컨트롤러 테스트 수정
  - RouletteReward의 itemType을 rewardType으로 교체
  - 일반 아이템·풀세트·상품권·꽝을 RewardType으로 구분
  - 기존 보상 지급 수량과 재고·이력 처리 로직 유지
  - 룰렛 테스트 데이터를 RewardType 구조에 맞게 수정
  - 룰렛 이력에 보상 지급 여부를 기록하고 즉시 지급 보상 상태 반영
  - 미지급 상품권 당첨자 조회 및 지급 완료 처리 관리자 API 추가
  - 상품권 당첨자용 사용자 DTO와 내부 일괄 조회 API 추가
  - 당첨자 회원 정보 조회를 위한 Feign 연동 추가
  - 동일 회원의 복수 당첨 이력을 당첨 건별로 반환
  - 상품권 외 보상을 무제한 재고로 처리하고 소진 범위 재추첨
  - 상품권 지급 및 회원 일괄 조회 테스트 보강
    - 룰렛 API 경로를 /api/roulette에서 /api/items/roulette로 변경
    - Gateway의 기존 /api/items/** 라우팅 규칙에 맞게 경로 통일
  - refactor/admin-notice 브랜치 변경사항 반영
  - 룰렛 상품권 당첨자 조회를 위한 사용자 일괄 조회 연동 유지
  - 빌드 설정과 내부 관리자 API 충돌 해결
    - 매칭 로직 및 성능 테스트 변경사항 반영
    - 충돌 파일을 origin/main 기준으로 해결
    - RouletteControllerTest 요청 경로를 /api/items/roulette로 변경
    - 테스트 표시명을 변경된 API 경로에 맞게 수정
    - 룰렛 API 경로 변경사항을 통합
    - RouletteControllerTest 충돌을 /api/items/roulette 기준으로 해결
    - 관리자 룰렛 서비스를 admin 도메인 패키지로 이동
    - 회원 정보가 누락된 당첨 이력은 응답 목록에서 제외
    - 서비스 패키지 이동에 맞춰 컨트롤러와 테스트 import 수정
@choi-jin-wook
choi-jin-wook merged commit a5dad64 into dev Sep 12, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants