feat(item): 무료·스페셜 룰렛 추첨 및 참여 상태 조회 추가 - #95
choi-jin-wook wants to merge 60 commits into
Conversation
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>
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 접두사 없는 이름을 제안한 것이 단서였다).
환경변수만으로는 google provider 가 로드되지 않아 google/gemini-3.7-flash 를 못 찾았다. opencode 는 models.dev 카탈로그 -> opencode.json -> 환경변수 순으로 provider 를 로드하므로, 설정 파일에 박아 두면 확실하다.
{env:GEMINI_API_KEY} 치환이 빈 값이 되면 빈 apiKey 가 환경변수 자동감지를
덮어써 'unregistered callers' 가 된다. provider 선언만 남기고 키는 env 로
넘긴다. 키가 러너까지 오는지 길이로 확인하는 단계도 임시로 넣는다.
Google 이 Gemini 3.6 Flash 이후 모델에서 'model 턴으로 끝나는 요청'을 막았다. opencode 는 툴 호출을 유도할 때 model 턴을 prefill 하므로 3.7 에서 400 이 난다. 제한 이전 모델인 3.5-flash 로 내린다(3.5-flash-lite 는 제한 대상이니 주의). opencode 가 prefill 을 걷어내면 다시 올릴 수 있다.
역할을 다했다. 원인은 빈 시크릿이었고 값을 채워 해결됐다. 환경변수 주석도 실제 원인(opencode.json 의 provider 선언)에 맞게 고친다.
ci: gemini 대신 opencode 로 PR 코드 리뷰를 돌린다
* 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>
- RouletteReward, RouletteHistory 엔티티 추가 - RouletteType, PrizeType enum 추가
- FREE 룰렛 중복 참여 검증 및 PAID 룰렛 티켓 차감 처리 - 확률 범위 기반 보상 추첨과 제한 재고 차감 로직 추가 - 아이템 보상과 아이템 사용·획득 이력 저장 - 상품권·꽝을 포함한 룰렛 참여 이력 저장 - 당첨 보상명을 반환하는 응답 DTO 및 룰렛 API 추가 - 보상 처리 예외와 난수 경계 조건 테스트 작성
- PAID 룰렛 티켓 실제 차감 검증 - FREE 당일 참여 조회 조건 검증 - 풀세트 전체 재고 차감 검증 - PAID 일일 제한 미적용 검증 - 보상 처리 실패 시 트랜잭션 롤백 검증 - 아이템 이력 설명과 룰렛 API 계약 검증
- 유료 룰렛 티켓 조회에 비관적 락 적용
- 한정 보상 재고 조회에 비관적 락 적용
- 무료 룰렛 참여 이력에 회원·유형·날짜 유일 제약 추가
- 동시 중복 참여를 ITEM-007 예외로 변환
- 무료·유료 룰렛 및 한정 보상 동시성 테스트 추가
- 룰렛 보상을 단일 행으로 조회하도록 변경
- 풀세트 한 행으로 옵션권과 매칭권을 함께 지급
- 지급 아이템별 EVENT 이력 저장
- 무료 룰렛 참여 날짜 저장 및 일일 유일 제약 적용
- 반복 코드의 메서드 분리를 위한 TODO 추가
- 당일 참여 검증과 보상 처리 테스트 수정
- PAID 룰렛을 SPECIAL 룰렛으로 변경하고 룰렛 티켓 사용 로직 제거 - 당일 승인 결제액 3,500원 이상인 회원만 스페셜 룰렛 참여 허용 - 무료·스페셜 룰렛의 당일 중복 참여 예외 처리 - 룰렛 참여 가능 여부와 당일 누적 결제액 조회 API 추가 - 룰렛 실행 API를 타입별 경로 구조로 변경 - 스페셜 룰렛과 참여 상태 조회 테스트 보강
- FREE와 SPECIAL 참여 이력에 회원·타입·날짜 유일 제약 적용
- 참여 이력을 즉시 flush하여 동시 중복 요청을 ITEM-007로 변환
- 동일 회원의 FREE·SPECIAL 동시 요청에서 보상 중복 지급 방지 검증
- 날짜별 참여 유일 제약과 당일 승인 결제액 합산 테스트 추가
- 본인의 승인된 주문만 누적 결제액에 포함되는지 검증 - 조회 시간 범위의 시작 포함·종료 제외 조건 검증 - 다른 회원과 승인 거절 주문이 합산에서 제외되는지 검증 - 결제 내역이 없는 회원의 합계가 0인지 검증
- 룰렛 페이지에서 무료·스페셜 참여 여부와 당일 결제액을 함께 반환
- 룰렛 타입별 조회 파라미터를 제거하고 응답 DTO 구조 변경
- 보상 추첨과 지급 로직을 별도 메서드로 분리
- 변경된 룰렛 조회 API 계약에 맞춰 서비스·컨트롤러 테스트 수정
| // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) | ||
| List<RouletteReward> rouletteRewards; | ||
| do { | ||
| int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); | ||
| rouletteRewards = rouletteRewardRepository | ||
| .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); | ||
| } while (rouletteRewards.isEmpty()); | ||
|
|
There was a problem hiding this comment.
버그 (무한 루프 가능성)
이 do-while 루프는 재시도 횟수 제한이 없습니다. 해당 rouletteType에 등록된 RouletteReward가 하나도 없거나, 모든 보상의 remainingCount가 소진되면 findAvailableByRouletteTypeAndRouletteNumber는 1~10000 사이 어떤 난수를 뽑아도 빈 리스트를 반환하므로 루프가 종료되지 않습니다.
@Transactional 메서드 안에서 발생하므로 DB 커넥션을 점유한 채 무한히 쿼리를 날리게 되고, PAID 타입의 경우 이미 티켓을 차감(52번째 줄)한 뒤라 응답은 영영 돌아오지 않으면서 티켓만 소모됩니다. 한정 수량 보상이 모두 소진되는 것은 프로모션이 정상적으로 끝나면 도달하는 상태이므로 실제로 발생 가능한 케이스입니다.
어떻게 고칠지: 루프에 최대 재시도 횟수를 두거나, 진입 전에 해당 타입에 사용 가능한 보상이 존재하는지 미리 확인해서 없으면 명시적인 비즈니스 예외를 던지는 방식으로 수정이 필요합니다.
| """) | ||
| List<RouletteReward> findAvailableByRouletteTypeAndRouletteNumber( | ||
| @Param("rouletteType") RouletteType rouletteType, | ||
| @Param("rouletteNumber") int rouletteNumber); | ||
| } |
There was a problem hiding this comment.
버그 (동시성 - 한정 수량 보상 초과 지급)
이 조회 쿼리에는 락이 없고, RouletteReward에는 @Version 필드도 없습니다. 감소 로직인 decreaseRemainingCount()도 단순 인메모리 감소 후 dirty checking으로 flush 되는 방식이라 동시성 제어가 전혀 없습니다.
동시에 두 트랜잭션이 remainingCount = 1인 같은 행을 읽으면 둘 다 > 0 조건을 통과해 각각 0으로 감소시킨 뒤 커밋하므로, 한정 수량 1개짜리 보상이 두 명에게 지급될 수 있습니다.
어떻게 고칠지: ItemRepository.findAllUsableItems처럼 @Lock(LockModeType.PESSIMISTIC_WRITE)를 추가하거나, UPDATE ... SET remaining_count = remaining_count - 1 WHERE id = ? AND remaining_count > 0 형태의 원자적 조건부 업데이트로 바꾸고 영향받은 row 수를 검증하는 방식을 권장합니다.
|
|
||
| Optional<Item> findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( | ||
| Long memberId, | ||
| ItemType itemType, | ||
| int quantity, | ||
| LocalDateTime now); |
There was a problem hiding this comment.
버그 (동시성 - 룰렛 티켓 이중 사용)
새로 추가된 이 조회 메서드에는 락이 없습니다. 바로 위에 있는 findAllUsableItems는 동일하게 "조회 후 수량 변경" 목적으로 @Lock(LockModeType.PESSIMISTIC_WRITE)를 쓰는데, 이 메서드는 같은 패턴을 따르지 않습니다. Item 엔티티에도 @Version 필드가 없어 낙관적 락 백업도 없습니다.
RouletteServiceImpl.spinRoulette의 PAID 분기에서 이 메서드로 티켓을 조회한 뒤 decrease(1)을 호출하는데, 동일 회원이 동시에 두 번 PAID 스핀을 요청하면 둘 다 같은 티켓 row(quantity=1)를 읽어 각각 0으로 감소시키고 커밋합니다. 결과적으로 티켓 1개로 2번의 스핀과 2개의 보상이 지급됩니다.
findAllUsableItems와 동일한 패턴으로 락을 추가하면 해결됩니다.
| Optional<Item> findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( | |
| Long memberId, | |
| ItemType itemType, | |
| int quantity, | |
| LocalDateTime now); | |
| @Lock(LockModeType.PESSIMISTIC_WRITE) | |
| Optional<Item> findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( | |
| Long memberId, | |
| ItemType itemType, | |
| int quantity, | |
| LocalDateTime now); |
| public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { | ||
| // 타입이 무료인 경우 참여여부 검증 + 예외처리 | ||
| if (rouletteType == RouletteType.FREE && rouletteHistoryRepository | ||
| .existsTodayByMemberIdAndRouletteType(memberInfo.memberId(), rouletteType)) { | ||
| throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); | ||
| } |
There was a problem hiding this comment.
버그 (동시성 - 무료 룰렛 하루 1회 제한 우회 가능)
무료 참여 여부 확인(41~44번째 줄)과 실제 참여 기록 저장이 같은 트랜잭션 안에서 check-then-act로 이루어지는데, existsTodayByMemberIdAndRouletteType 쿼리에는 락이 없고 RouletteHistory에는 (memberId, rouletteType, 날짜) 유니크 제약도 없습니다.
같은 회원이 동시에 두 번 무료 스핀을 요청하면 두 트랜잭션 모두 "오늘 아직 참여 안 함"으로 판정해 통과하므로, 하루 1회 제한을 우회해 두 번 당첨될 수 있습니다. 컨트롤러 쪽에도 idempotency key나 rate limit이 없어 중복 클릭이나 클라이언트 재시도만으로도 트리거 가능합니다.
어떻게 고칠지: RouletteHistory에 (memberId, rouletteType, date) 유니크 제약을 추가해 저장 시 위반을 잡아 예외로 변환하거나, 회원 단위 락을 거는 방식을 권장합니다.
Code review동시성 버그 4건에 대해 인라인 코멘트를 남겼습니다 (룰렛 추첨 재시도 무한 루프, 한정 수량 보상 초과 지급, 룰렛 티켓 이중 사용, 무료 룰렛 하루 1회 제한 우회 — 모두 lock 부재로 인한 check-then-act 레이스 컨디션입니다). 참고로 이 diff는 |
|
Claude finished @choi-jin-wook's task in 3m 13s —— View job 수동 리뷰 결과Todo
1. 이전 자동 리뷰가 지적한 동시성 버그 4건 — 모두 해결 확인됨이후 커밋(
2. Cross-service 영향 검토 (common-module 공유 코드)
3. 사소한 개선 포인트 (버그는 아님)
요약이전 자동 리뷰에서 지적된 동시성 버그 4건은 이후 커밋들에서 모두 정상적으로 수정되었고, 테스트로도 검증되어 있습니다. |
- RouletteReward의 itemType을 rewardType으로 교체 - 일반 아이템·풀세트·상품권·꽝을 RewardType으로 구분 - 기존 보상 지급 수량과 재고·이력 처리 로직 유지 - 룰렛 테스트 데이터를 RewardType 구조에 맞게 수정
- 룰렛 이력에 보상 지급 여부를 기록하고 즉시 지급 보상 상태 반영 - 미지급 상품권 당첨자 조회 및 지급 완료 처리 관리자 API 추가 - 상품권 당첨자용 사용자 DTO와 내부 일괄 조회 API 추가 - 당첨자 회원 정보 조회를 위한 Feign 연동 추가 - 동일 회원의 복수 당첨 이력을 당첨 건별로 반환 - 상품권 외 보상을 무제한 재고로 처리하고 소진 범위 재추첨 - 상품권 지급 및 회원 일괄 조회 테스트 보강
- 룰렛 API 경로를 /api/roulette에서 /api/items/roulette로 변경
- Gateway의 기존 /api/items/** 라우팅 규칙에 맞게 경로 통일
- RouletteControllerTest 요청 경로를 /api/items/roulette로 변경
- 테스트 표시명을 변경된 API 경로에 맞게 수정
| 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); | ||
| // } |
There was a problem hiding this comment.
버그: drawAvailableReward가 무한 루프에 빠질 수 있습니다
이번 리팩터링(bfe6bb9c78)에서 종료 가드였던 RouletteRewardRepository.existsAvailableByRouletteType과 ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD 예외가 제거되면서, 루프 종료 조건이 rouletteReward.isEmpty() 하나만 남았습니다.
해당 rouletteType에 발급 가능한 보상이 하나도 없는 경우(재고 소진, 110000 범위 미커버 등) 133번째 줄의 주석 처리된 예외 처리는 지금은 도달할 수 없는 죽은 코드가 되었습니다.findAvailableByRouletteTypeAndRouletteNumber가 계속 빈 값을 반환하고, @Transactional인 spinRoulette 요청 안에서 이 do/while 루프가 종료되지 않은 채 DB 커넥션과 락을 잡고 계속 돌게 됩니다. 아래 130
최소한의 재시도 횟수 제한이나, 진입 전에 소진 여부를 확인하는 가드를 복원하는 것을 제안합니다.
| private AdminGiftCardUserProfileDto getUserOrThrow( | ||
| Map<Long, AdminGiftCardUserProfileDto> usersById, | ||
| Long memberId | ||
| ) { | ||
| AdminGiftCardUserProfileDto user = usersById.get(memberId); | ||
| if (user == null) { | ||
| throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); | ||
| } | ||
| return user; |
There was a problem hiding this comment.
버그: 당첨자 1명 때문에 상품권 미지급 명단 전체 조회가 실패할 수 있습니다
getUserOrThrow는 usersById에서 memberId를 찾지 못하면 TARGET_USER_NOT_FOUND를 던지는데, 이 호출은 46~51번째 줄의 histories.stream().map(...) 안, 즉 전체 목록을 만드는 도중에 실행됩니다.
한편 user-service의 AdminMemberQueryServiceImpl.getUsersByIds는 MemberStatus.ACTIVE + MemberRole.ROLE_USER인 회원만 반환하므로, 탈퇴했거나 정지된 당첨자가 명단에 단 한 명이라도 있으면 그 memberId는 응답에서 조용히 빠지고 getUserOrThrow가 예외를 던져 GET /api/v1/admin/roulette/gift-cards/unpaid 전체가 실패합니다. 결과적으로 관리자는 나머지 정상 당첨자의 상품권도 조회·지급 처리를 할 수 없게 되고, 문제의 이력은 이 화면에서 영구히 확인이 안 됩니다.
회원 조회 실패를 개별 항목 단위로 허용하는 방식(예: 해당 항목은 표시용 fallback으로 처리하거나 건너뛰고 경고 로그만 남기는 방식)을 고려해보세요.
개요
무료 룰렛과 당일 결제 금액을 기준으로 참여할 수 있는 스페셜 룰렛 기능을 추가합니다. 확률 기반 보상 추첨부터
아이템 지급, 재고 차감, 참여 이력 저장까지 룰렛 처리 흐름을 구현하고 동시 요청에서도 중복 참여와 재고 초과
지급을 방지하도록 정합성을 보강했습니다.
변경 사항
1. 룰렛 도메인 구성
RouletteReward,RouletteHistory엔티티 추가RouletteType추가2. 룰렛 추첨 및 보상 지급
POST /api/roulette/{rouletteType}/spins룰렛 실행 API 추가EVENT아이템 이력 저장3. 무료·스페셜 룰렛 참여 조건
4. 룰렛 페이지 조회
GET /api/roulette룰렛 페이지 조회 API 추가isFreeParticipatedisSpecialParticipatedtotalPay5. 동시성 및 데이터 정합성
테스트