Skip to content

[P9] 토너먼트 매치 브래킷 서버 이관 - 페어 조합 강제와 2의 거듭제곱 정규화 - #835

Draft
sevineleven wants to merge 10 commits into
devfrom
refactor/683-tournament-match-migration
Draft

[P9] 토너먼트 매치 브래킷 서버 이관 - 페어 조합 강제와 2의 거듭제곱 정규화#835
sevineleven wants to merge 10 commits into
devfrom
refactor/683-tournament-match-migration

Conversation

@sevineleven

@sevineleven sevineleven commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Situation

매치 진행은 두 축으로 갈린다: 페어 구성("누가 누구와 붙나")과 진행 순서("지금 어느 페어 차례냐"). 이관 전에는 둘 다 프론트가 소유했고, 그래서 세 가지가 동시에 깨져 있었다.

  • 브래킷 무결성이 없었다. 매치 기록 API 는 소속, 미탈락, 라운드만 검증해서 클라이언트가 가격 인접이 아닌 임의 조합(최저가 vs 최고가 등)을 보내도 그대로 통과했다. 서버가 가격 정렬 규칙을 이미 갖고 있었는데도 강제하지 않아 SSOT 가 아니었다.
  • 진행 순서를 서버가 추적할 수 없었다. 프론트가 Math.random 으로 페어 순서를 셔플하고 그게 매치마다 재실행됐다. 시드 개념이 없어 재현이 불가능하고, 새로고침하면 다음에 뜰 매치가 바뀌었다. SSR 첫 렌더는 셔플을 건너뛰어야 해서 하이드레이션 방어 코드까지 붙어 있었다.
  • 브래킷이 표준 싱글 엘리미네이션이 아니었다. 라운드마다 인원을 절반씩 깎아 25명이면 25, 13, 7, 4, 2 로 흘렀다. 클라이언트가 라운드 인원수를 라벨에 그대로 쓰기 때문에 화면에 "13강 라운드 1" 이 떴다. 아이템 수 제약이 2에서 32 범위 검사뿐이라 2의 거듭제곱이 아닌 인원이 실제로 들어온다.

여기에 web, app, 향후 플랫폼이 각자 페어링과 셔플과 부전승을 재구현하는 중복 비용이 겹쳤다.

Task

생존 아이템에서 서버가 매치를 파생해 내려주고 클라이언트는 그리기만 하게 만든다. 스키마는 건드리지 않는다 (기존 매치 기록 테이블에서 전부 파생 가능).

착수 전에 저울질한 결정이 셋 있었다.

1. 진행 순서를 어떻게 재현 가능하게 만드나

방식 새로고침하면 스키마 채택
시드 랜덤 같은 순서 복원 변경 없음 채택
진짜 랜덤 + DB 저장 같은 순서 복원 컬럼 추가 미채택
매 요청 랜덤 (무상태) 순서가 또 바뀜 변경 없음 미채택

사용자 눈에는 시드 랜덤도 그냥 랜덤이다. 가격순도 아니고 예측도 안 된다. 차이는 서버가 같은 시드로 순서를 다시 만들 수 있다는 것뿐이라, 저장 없이 새로고침 복원이 따라온다. 세 번째는 서버가 내려준 매치를 다음 요청에서 스스로 뒤집게 되어 이관의 의미가 사라진다.

2. 검증을 어디까지 강제하나

강제 이유
페어 조합 한다 브래킷 무결성의 본질이 "누가 누구와 붙나" 다
좌우 순서 안 한다 조합이 본질이고 좌우는 표시 순서일 뿐이다
라운드 내 진행 순서 안 한다 라운드 내 매치는 서로 독립이라 순서가 최종 결과를 바꾸지 않는다. 강제하면 열린 탭, 뒤로가기, 재전송에서 오탐 400 만 는다

3. 부전승을 누가 먹나. 기존은 가격 최고가 고정이었다. 가격 편향을 없애려 시드 랜덤(중립)으로 바꿨다. 사용자에게 보이는 동작 변경이라 기획 확인 항목이다.

Action

브래킷 정규화

첫 라운드에서 인원을 2의 거듭제곱으로 맞춘 뒤 진행한다. 이후 라운드는 항상 2의 거듭제곱이라 부전승이 다시 생기지 않고, 라운드 인원수를 라벨에 쓰는 클라이언트가 "16강, 8강, 4강, 결승" 으로 저절로 맞아떨어진다.

인원이 2의 거듭제곱  -> 매치 n/2,         부전승 0
아니면 target = 2^floor(log2(n))
                     -> 매치 n - target,  부전승 2*target - n
인원 매치 부전승 이후 흐름
32 16 0 16, 8, 4, 2
25 9 7 16, 8, 4, 2
12 4 4 8, 4, 2
7 3 1 4, 2
5 1 3 4, 2
3 1 1 2

2의 거듭제곱 분기가 반드시 필요하다. n - highestOneBit(n) 은 n 이 이미 2의 거듭제곱일 때 0 이라, 분기가 없으면 매치 수가 0 이 되어 아무도 안 싸우고 라운드가 넘어간다.

구현

  • 파생을 순수 도메인으로 분리 (RoundBracket). 토너먼트 서비스가 이미 1100줄을 넘어 서비스에 얹지 않았다. Spring 과 DB 없이 단위 테스트로 분기를 망라할 수 있다.
  • 파생 순서는 가격 정렬, 시드 부전승, 인접 페어, 시드 셔플. 부전승을 먼저 빼고 남은 것을 묶어야 가격이 가까운 것끼리 붙는 성질이 유지된다. 같은 난수 인스턴스를 순차로 쓰므로 부전승 선정과 진행 순서가 함께 결정론이다.
  • 시드는 참여자 id 와 라운드로 매번 재구성한다. 저장하지 않는다. 참여자마다 순서가 다르고, 같은 참여자의 같은 라운드는 항상 같은 순서다.
  • 파생 입력은 라운드 시작 시점 집합이어야 한다. 이미 싸운 아이템이 빠진 축소 집합으로 매번 파생하면 인원이 달라져 부전승 대상과 순서가 흔들린다. 이전 라운드에서 탈락한 아이템만 제외하고, 현재 라운드에서 진 아이템은 남긴다.
  • 라운드 계산을 같은 매치 수 공식으로 교체했다. 브래킷 파생과 라운드 수학이 같은 공식을 써야 "서버가 지정한 매치" 와 "서버가 기대하는 라운드" 가 어긋나지 않는다. 기존 시그니처의 첫 라운드 매치 수 파라미터는 공식에서 파생 가능해져 제거했다.
  • 스냅샷 조회 범위를 전체 아이템으로 넓혔다. 브래킷 파생이 응답의 생존 목록보다 상위집합인 라운드 시작 시점 집합의 가격을 필요로 한다.

멱등 판정이 다른 두 검사보다 앞에 와야 한다

재시도 판정을 어디에 두는지가 이 PR 에서 가장 미묘했던 자리다. 검사 순서를 잘못 두면 정상 재시도가 엉뚱한 409 로 오인된다.

재시도 판정보다 뒤로 미룬 검사 앞에 두면 생기는 일
이미 탈락한 아이템인가 이미 기록된 매치의 패자는 탈락 집합에 있다. 정상 재시도가 "이미 탈락한 아이템" 409 로 막힌다
토너먼트가 진행 중인가 결승을 기록하면 그 자리에서 완료 상태로 바뀐다. 결승 재전송만 멱등 계약에서 빠져 "진행 중일 때만 할 수 있어요" 409 로 막힌다

두 번째는 CodeRabbit 리뷰에서 잡혔다. 구현 중 인지하고도 "이관 전에도 같은 409 였다" 는 이유로 넘겼는데, 이슈와 스펙의 멱등 계약에 결승 예외가 없으므로 스코프 밖이 아니라 빠뜨린 것이었다. 게다가 응답을 못 받고 재전송하는 상황은 결승에서 가장 흔하다 (타임아웃, 앱 재실행, 중복 탭). 하필 그 케이스만 새고 있었다.

진행 중 검사가 묻는 것은 "새 매치를 기록해도 되나" 이므로 재시도 판정 뒤가 제자리다. 분기를 새로 만들지 않고 검사를 옮기기만 해서, 같은 조건을 두 곳에서 판정하지 않는다.

멱등 히트 -> 승자 다름            -> 409 (이미 기록된 대결, 완료 여부 무관)
          -> 승자 같음 + 완료     -> 최초와 같은 순위 결과를 재구성해 반환
          -> 승자 같음 + 진행 중  -> 다음 매치 재파생해 반환
멱등 미스 -> 진행 중 아니면       -> 409 (진행 중 아님)

행 락을 이미 잡은 상태라 이 분기 안에서 추가 조회를 해도 레이스는 없다.

응답 계약

엔드포인트 변경 내용
토너먼트 조회 additive inProgress.currentMatch 추가. 기존 remainingItems, lastHistory, currentRound 는 유지
매치 기록 래퍼 전환 기존 응답이 완료 결과 단독이라 다음 매치를 실을 자리가 없어 {nextMatch, completed} 로 감쌌다

매치에는 id 대신 아이템을 통째로 담는다. 생존 목록에서 다시 찾게 하면 클라이언트에 조합 로직이 남는다.

nextMatch 가 있으면 클라이언트는 재조회 없이 다음 매치를 그린다. 없으면 라운드가 끝났다는 뜻이고 조회를 다시 불러 다음 라운드를 받는다.

nullable 필드는 null 로 내려가지 않고 키째 생략된다. 이 레포 응답 DTO 가 전부 NON_NULL 규약이라 nextMatch, completed, currentMatch 는 값이 없으면 키 자체가 빠지고, 두 필드가 다 비는 라운드 종료 응답은 "data": {} 다. 문서와 예제가 "null 이다" 로 안내하던 것을 실제 직렬화에 맞췄다 (통합 테스트는 처음부터 생략을 단언하고 있었으니 계약이 아니라 설명만 틀린 상태였다). 클라 타입도 optional 로 두도록 client#404 에 반영했다.

새 에러 코드 둘을 append-only 로 붙였다 (번호 재사용과 재배치 금지, 코드가 클라 계약이다).

코드 status 사유
TOURNAMENT-034 400 서버 브래킷에 없는 페어 조합
TOURNAMENT-035 409 이미 기록된 매치에 다른 승자 전송 (완료 여부 무관, 같은 승자는 멱등 성공)

문서도 함께 갱신했다. 조회 응답 설명에서 "생존 목록 순서가 클라이언트 페어링 순서를 결정한다" 는 서술을 걷어내고, 기록 API 에 조합 강제와 멱등 규칙을 명시했다. example 은 새 예외에서 detail 을 자동 추종하는 오버로드로 등록해 문구를 손으로 박지 않았다.

설계 문서 교정

정본 스펙에 두 곳을 고쳤다.

  • 라운드 루프의 결승 누락. 조건이 players > 2 라, 4강 2매치를 치른 뒤 인원이 2로 줄면 2 > 2 가 false 여서 결승을 반환하지 못하고 불변식 위반 에러로 떨어진다. 문서와 구현 양쪽을 >= 와 break 구조로 고쳤다.
  • 멱등에 결승 예외가 없다는 점. 원문은 "재요청은 멱등" 만 적어 결승 예외 없음이 암묵적이었고, 그 탓에 구현이 상태 검사를 앞에 두는 실수를 했다. 판정 순서표와 그 이유를 문서에 박아 다음 사람이 같은 실수를 하지 않게 했다.

기존 테스트 2건 교정

새 계약과 충돌하는 기존 테스트가 둘 있었다. 둘 다 이관이 바꾸려던 동작을 인코딩하고 있었다.

  • 가격 정렬 검증 테스트가 삽입 순서 인접 페어를 보냈다. 가격이 10k, 40k, 20k, 30k 순으로 삽입되어 있어 새 브래킷은 (10k, 20k)와 (30k, 40k)로 묶는데 테스트는 (10k, 40k)를 보내 400 이 된다. 유효 페어로 고치고, 그 자리에 위조 페어 400 단언을 함께 넣었다 (CodeRabbit nitpick). 전용 위조 페어 테스트는 삽입 순서와 가격 순서가 같아 "삽입 인접이지만 가격 인접이 아닌" 구분을 못 잡는데, 그게 이관이 막은 구멍 그 자체다.
  • "완료된 토너먼트면 409" 테스트가 실은 결승 재전송 케이스였다. 멱등 수정으로 이제 200 이다. 4아이템으로 완주시킨 뒤 한 번도 치르지 않은 조합을 보내도록 재구성해 원래 의도(완료 상태 충돌)를 그대로 지켰다.

검증

  • 단위 86건. 인원 2에서 32까지 전수로 두 불변식을 확인한다: 모든 아이템이 매치 또는 부전승에 정확히 한 번 배정된다, 2의 거듭제곱이 아닌 인원의 첫 라운드 통과 인원이 2의 거듭제곱이 된다. 그 밖에 스펙 공식 표 10행, 부전승 제외 후 인접 페어 유지, 시드 재현성과 참여자별 라운드별 상이, 가격 동률 시 id 정렬, 가격 없음 정렬, 입력 순서 무관.
  • 통합 10건. "조회가 내려준 매치를 기록에 그대로 되돌려주면 통과한다" 를 클라이언트와 같은 방식으로(조회해서 되돌려주기) 검증한다. 위조 페어 400, 멱등 성공과 기록 미증가, 승자 뒤집기 409, 좌우 뒤집어도 200, 라운드 내 순서 바꿔도 둘 다 200, 결승 시 완료 결과. 여기에 결승 재전송 2건을 더했다: 같은 승자면 최초와 같은 순위 결과와 기록 미증가, 다른 승자면 409. 실패 detail 은 코드 enum 에서 끌어와 손으로 박지 않았다.
  • 5명 시나리오로 정규화를 직접 고정했다. 정규화 전 로직은 절반씩 깎아 "3강" 이 됐는데 이제 1매치 후 4강이 된다.
  • CI 참고: 파싱 정원 동시성 테스트가 전체 실행 중 한 번 락 획득 실패로 떨어졌다. 배경 스케줄러와 같은 행을 다투는 기존 flakiness 이고 이 변경과 접점이 없다 (재실행 통과 확인).

Result

  • 서버 단독 배포가 불가하다. 정규화로 2의 거듭제곱이 아닌 인원의 첫 라운드 페어 구성이 구버전 클라이언트와 달라진다. 25명이면 구버전은 12페어와 최고가 부전승 1, 새 서버는 9페어와 시드 랜덤 부전승 7 이다. 2, 4, 8, 16, 32명만 구성이 같아 통과하고 그 외는 전부 400 이다. 호환 코드는 두지 않기로 했다 (복잡도 대비 실익이 없다).
  • 프론트 작업은 client#404 로 분리했다. 기존 TS 를 파일과 줄 단위로 대조해 담았다. 페어링 유틸 삭제, 낙관적 진행 제거, 하이드레이션 방어 제거, 새 응답 소비, 에러 코드 둘. 변경이 불필요한 것(부전승 경고 다이얼로그, 라운드 전환 판정)도 근거와 함께 구분해 적었다.
  • client#404 를 쓰다가 서버 설계의 구멍 하나를 찾았다. 프론트가 토너먼트 시작 직후 진행 상태를 손으로 조립하는데, 시작 API 응답에 현재 매치가 없어 첫 매치를 그릴 수 없다. 이슈 본문도 스펙도 이 경로를 언급하지 않았다. 프론트가 조회를 한 번 더 부르면 서버 변경이 없고(RSC 내부 왕복이라 브라우저 지연이 아니며, 기존 409 복구 경로가 이미 같은 패턴이다), 시작 응답에 매치를 추가하면 왕복이 줄지만 이 PR 스코프가 늘어난다. 이 PR 은 전자를 전제로 둔다. 후자로 가기로 하면 시작 응답에 필드를 얹는 커밋을 추가하면 된다.
  • 배포 순간 진행 중이던 토너먼트는 라운드 판정이 어긋난다. 조용히 깨지지는 않고 400 으로 드러나지만 그 사용자는 진행하던 토너먼트를 끝내지 못한다. 트래픽 적은 새벽에 배포해 노출을 줄인다.
  • 응답 코드가 한 자리에서 바뀐다. 참여자 조회가 상태 검사보다 앞으로 오면서, 미참여자가 완료된 토너먼트에 매치를 보내면 409 대신 403 이 나간다. 권한 검사가 상태 검사보다 앞서는 게 더 정확한 응답이라 의도적으로 두었다 (기존 403 테스트는 진행 중 기준이라 영향 없음).
  • 기획과 디자인 확인이 필요하다. 부전승 선정이 최고가 고정에서 시드 랜덤으로, 진행 순서가 매번 랜덤에서 새로고침 고정으로 바뀐다. 부전승이 첫 라운드에 몰려 25명이면 7개가 한 번에 통과하므로 "진행 중 부전승 표시" 가 지금보다 더 필요해진다. 이슈가 범위 밖으로 뺀 항목이고, 파생 함수가 부전승 목록을 이미 계산하므로 나중에 필드로 내려주기만 하면 된다. 첫 라운드 라벨도 25명이면 여전히 "25강" 이라 문구 재검토 대상이다.
  • 범위 밖으로 남긴 것. 매치 기록의 행 락 완화(참여자별 독립 매치인데 원본 토너먼트 행에 락을 걸어 동시 매치가 직렬화된다), 상태 매 요청 재구성(32강 상한이라 절대 비용이 작다), 진행 단계 필드 신설(라운드 값 파생으로 충분해 서버는 내리지 않는다).

연관 이슈

Summary by CodeRabbit

  • 새로운 기능
    • 진행 중 토너먼트에서 “현재 치를 매치” 정보를 응답에 제공합니다.
    • 매치 기록 후 다음 매치(nextMatch) 또는 라운드 종료/결승 완료 결과(completed)를 즉시 반환합니다.
  • 버그 수정
    • 결승을 재전송해도 멱등 규칙에 따라 동일 완료 결과를 다시 제공합니다(충돌로 실패하지 않음).
    • 서버가 정한 브래킷/페어 규칙과 다른 매치 조합은 거부하고, 이미 기록된 매치는 적절히 차단합니다.
  • 문서
    • API 응답 스키마/예제와 오류 케이스 설명을 갱신했습니다.

sevineleven and others added 6 commits July 30, 2026 00:45
- 브래킷을 표준 싱글 엘리미네이션으로 정규화하기로 결정. 현행 computeExpectedRound 는 라운드마다 인원을 절반씩 깎아 25명이 13강·7강 같은 어중간한 라운드를 만들고, 클라 getRoundLabel 이 인원수를 그대로 써서 화면에 "13강"이 뜬다. 첫 라운드에서 2의 거듭제곱으로 맞춰 이후를 16→8→4→2 로 고정
- 진행 순서는 시드 셔플(seed = tournamentUserId + round). 클라 Math.random 은 시드 개념이 없어 재현이 불가능해 "그대로 옮기기"가 성립하지 않았다. DB 저장·무상태 랜덤과 비교해 스키마 변경 0 으로 새로고침 복원까지 얻는 쪽을 택함
- 부전승 선정도 시드 랜덤. 현행은 가격 오름차순 마지막(최고가) 고정이라 가격 편향이 있다
- 페어 조합은 강제 검증하되 진행 순서는 검증하지 않기로. 라운드 내 매치는 서로 독립이라 순서가 최종 결과를 바꾸지 않는 반면, 강제하면 열린 탭·뒤로가기·재전송에서 오탐 400 만 는다
- 설계 도중 구버전 클라 호환이 불가능함을 발견. 브래킷 정규화로 비(2의 거듭제곱) 인원의 첫 라운드 페어 구성이 서버·클라 간 달라져, 당초 예상과 달리 서버 단독 배포가 불가하다. 클라 동반 배포를 전제로 고정하고 문서 8장에 명시
- 루프 조건이 `players > FINAL_ROUND_SIZE` 라 결승(2) 자체를 검사하지 않았다 - 4강 2매치를 치른 뒤 players 가 2로 줄면 `2 > 2` 가 false 여서 결승을 반환하지 못하고 루프를 빠져나가 error() 로 떨어진다
- `>=` 로 결승을 검사 범위에 넣고, 결승까지 완료된 경우만 break 로 빠져 기존과 같이 "모든 라운드 완료인데 IN_PROGRESS" 불변식 위반을 알리게 고침
- TournamentService 가 이미 1131줄이라 서비스에 얹지 않고 RoundBracket 순수 도메인으로 분리 - Spring·DB 없이 단위 테스트로 분기를 망라한다
- 파생 순서: 가격 정렬 -> 시드 부전승 -> 인접 페어 -> 시드 셔플. 부전승을 먼저 빼고 남은 것을 묶어야 가격이 가까운 것끼리 붙는 성질이 유지되고, 같은 Random 인스턴스를 순차 사용해 부전승 선정과 진행 순서가 함께 결정론이다
- seed = tournamentUserId + round 로 저장 없이 재구성한다 - 참여자마다 순서가 다르고 같은 참여자의 같은 라운드는 항상 같은 순서라 새로고침해도 복원된다
- 매치 수 공식에 2의 거듭제곱 분기를 반드시 둔다 - 없으면 n - highestOneBit(n) 이 0 이 되어 아무도 안 싸우고 라운드가 넘어간다
- isSamePair 로 좌/우가 뒤집힌 조합도 같은 매치로 본다 - 조합이 브래킷의 본질이고 좌/우는 표시 순서일 뿐이다
- 단위 테스트 86건: 인원 2~32 전수 배정 불변식(모든 아이템이 매치 또는 부전승에 정확히 한 번), 스펙 공식 표 10행, 비(2의 거듭제곱) 인원의 첫 라운드 통과 인원이 2의 거듭제곱임, 부전승 제외 후 인접 페어 유지, 시드 재현성·participant/round 별 상이, 가격 동률 시 id 정렬, price null-first, 입력 순서 무관, firstUnplayed
- TOURNAMENT-034 INVALID_MATCH_PAIR (INVALID_INPUT, 400) - 서버가 파생한 페어 집합에 없는 조합. 최신 클라는 서버가 준 currentMatch·nextMatch 를 그대로 되돌려주므로, 여기 닿는 건 조합을 임의로 구성한 요청 = 계약 위반
- TOURNAMENT-035 MATCH_ALREADY_RECORDED (CONFLICT, 409) - 이미 기록된 매치에 다른 승자 전송. 같은 승자면 멱등 성공이고 다른 승자는 결과 뒤집기라 409
- 번호는 append-only 로 033 뒤에 붙였다 (재사용·재배치 금지, code 가 클라 계약)
- message 는 사용자 대면 고정 문구 - 내부 식별자·구체 검증 사유를 담지 않는다. status 는 ErrorCategory 에서 파생돼 예외가 직접 들지 않는다
- recordMatch 가 소속·미탈락·라운드만 봐서 클라가 임의 조합([0]vs[3])을 보내도 통과했다 (브래킷 무결성 미보장). 서버가 파생한 페어 집합에 없는 조합을 400 으로 거부한다
- 진행 순서는 검증하지 않는다 - 라운드 내 매치는 서로 독립이라 순서가 최종 결과를 바꾸지 않고, 강제하면 열린 탭·뒤로가기·재전송에서 오탐 400 만 는다
- 멱등 검사를 탈락 검사보다 앞에 둔다 - 이미 기록된 매치의 패자가 탈락 집합에 있어 순서를 어기면 정상 재시도가 409 ELIMINATED 로 오인된다. 같은 승자는 성공(그 라운드로 nextMatch 재파생), 다른 승자는 409
- computeExpectedRound 를 RoundBracket.matchCountOf 로 교체 - 브래킷 파생과 라운드 수학이 같은 공식을 써야 "서버가 지정한 currentMatch" 와 "서버가 기대하는 라운드" 가 어긋나지 않는다. firstRoundMatchCount 파라미터는 파생 가능해져 제거
- 브래킷 파생 입력은 라운드 시작 시점 집합이어야 한다 - 이미 싸운 아이템이 빠진 축소 집합으로 매번 파생하면 인원이 달라져 부전승 대상과 진행 순서가 흔들린다. 이전 라운드 탈락만 제외하고 현재 라운드에서 진 아이템은 남긴다
- 응답: GET inProgress.currentMatch 추가(additive), POST 는 nextMatch 를 실을 자리가 없어 {nextMatch, completed} 래퍼로 전환. currentMatch 는 ID 대신 아이템을 통째로 담는다 - remainingItems 에서 다시 찾게 하면 클라에 조합 로직이 남는다
- snapshot 조회 범위를 전체 아이템으로 넓힘 - 브래킷 파생이 remainingItems 의 상위집합인 라운드 시작 시점 집합의 가격을 필요로 한다
- 스키마 변경 0 (전부 tournament_histories 에서 파생), *Api @ApiResponses·description 과 *ApiExamples 동반 갱신 (새 예외는 add(exception, name) 으로 detail 이 예외에서 자동 추종)
- 기존 통합 테스트 1건 교정 - 삽입 인접 페어(10k,40k)를 보내던 테스트가 새 브래킷(가격 인접 (10k,20k)·(30k,40k))에서 400 이 된다. 이관이 막으려던 구멍이라 유효 페어로 고치고, remainingItems 가격 오름차순 단언은 30k·40k 로 갱신
- 이 엔드포인트 쌍의 핵심 계약은 "GET 이 내려준 currentMatch 를 POST 에 그대로 되돌려주면 통과한다" 다. 클라이언트가 하는 일과 같은 방식으로(조회해서 되돌려주기) 8건을 검증
- currentMatch 배선(아이템 정보까지 포함), 5명 시작 시 1매치 후 currentRound 가 4 로 정규화(정규화 전 로직은 절반씩 깎아 "3강" 이었다), 위조 페어 400 TOURNAMENT-034, 멱등 성공 + 기록 미증가, 승자 뒤집기 409 TOURNAMENT-035, 좌우 뒤집어도 200, 라운드 내 순서 바꿔도 둘 다 200, 결승 시 nextMatch=null + completed 순위
- 응답 계약(code·detail·data 모양)을 단언에 포함하고, 실패 detail 은 TournamentErrorCode 엔트리에서 끌어와 손으로 박지 않는다
- 분기 망라는 RoundBracketTest(단위 86건)로 내리고 통합은 시나리오·계약 수준으로 유지
@sevineleven sevineleven added the refactor 구조 개선, 외부 동작 불변 label Jul 30, 2026
@sevineleven sevineleven self-assigned this Jul 30, 2026
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

서버가 결정론적 싱글 엘리미네이션 브래킷을 생성하고 현재·다음 매치를 제공하도록 변경했습니다. 매치 기록에는 페어 무결성 검증과 멱등 처리가 추가되었으며, 관련 DTO·OpenAPI 문서·통합 및 단위 테스트가 갱신되었습니다.

Changes

토너먼트 매치 로직 백엔드 이관

Layer / File(s) Summary
결정론적 브래킷과 라운드 계산
src/main/kotlin/.../domain/RoundBracket.kt, src/main/kotlin/.../service/TournamentService.kt, src/test/kotlin/.../domain/RoundBracketTest.kt, tournament-match-bracket-spec.md
가격과 아이템 ID로 페어를 구성하고, 시드 기반 부전승·진행 순서·2의 거듭제곱 라운드를 계산합니다.
현재 매치와 기록 결과 응답 계약
src/main/kotlin/.../service/dto/*, src/main/kotlin/.../controller/dto/*, src/main/kotlin/.../controller/TournamentApi.kt, src/main/kotlin/.../controller/TournamentApiExamples.kt
currentMatch를 추가하고, 매치 기록 응답을 nextMatchcompleted 구조로 노출합니다.
매치 기록 검증과 멱등 처리
src/main/kotlin/.../service/TournamentService.kt, src/main/kotlin/.../service/TournamentErrorCode.kt, src/main/kotlin/.../controller/TournamentController.kt, src/test/kotlin/.../controller/*
서버 브래킷에 없는 페어를 400으로 거부하고, 동일 승자 재전송은 성공 처리하며 다른 승자 재전송은 409로 반환합니다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant TournamentController
  participant TournamentService
  participant RoundBracket

  Client->>TournamentController: GET 토너먼트 상세
  TournamentController->>TournamentService: getTournamentById
  TournamentService->>RoundBracket: 현재 브래킷 파생
  RoundBracket-->>TournamentService: currentMatch
  TournamentService-->>Client: currentMatch 포함 응답

  Client->>TournamentController: POST 매치 결과
  TournamentController->>TournamentService: recordMatch
  TournamentService->>RoundBracket: 페어 검증 및 nextMatch 계산
  RoundBracket-->>TournamentService: 검증 결과
  TournamentService-->>Client: nextMatch 또는 completed
Loading

Assessment against linked issues

Objective Addressed Explanation
서버 브래킷 파생 및 2의 거듭제곱 라운드 정규화 [#683]
currentMatch·nextMatch 응답 제공 [#683]
페어 무결성 검증 및 멱등 처리 [#683]
결정론적 진행 순서와 관련 테스트 [#683]
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/683-tournament-match-migration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt (1)

1073-1088: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

브래킷 무결성의 실패 경로도 검증하세요.

Line 1073의 설명과 달리 현재는 유효 페어의 200만 확인합니다. 페어 검증이 제거돼도 통과하므로, 먼저 (10k, 40k) 요청이 TOURNAMENT-034과 함께 400인지 단언한 뒤 유효 요청을 보내세요.

검증 추가 예시
+        mockMvc
+            .perform(
+                post("/api/v1/tournaments/$tournamentId/matches")
+                    .header(HttpHeaders.AUTHORIZATION, authHeader(userId))
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content(
+                        """{"currentRound":4,"firstTournamentItemId":$ti10k,"secondTournamentItemId":$ti40k,"selectedTournamentItemId":$ti10k}""",
+                    ),
+            ).andExpect(status().isBadRequest)
+            .andExpect(jsonPath("$.code").value("TOURNAMENT-034"))
+
         mockMvc
             .perform(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt`
around lines 1073 - 1088, Update the tournament match integration test around
the existing items and mockMvc request to first submit the invalid adjacent pair
using ti10k and ti40k, asserting HTTP 400 and the TOURNAMENT-034 error code,
then submit the valid ti10k/ti20k pair and retain the HTTP 200 assertion.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/kotlin/com/depromeet/piki/tournament/controller/dto/RecordMatchResponse.kt`:
- Around line 7-13: API 문서와 예제가 실제 NON_NULL 직렬화 계약인 필드 생략을 명시하도록 일관되게 수정하세요.
RecordMatchResponse.kt 7-13의 nextMatch·completed, TournamentDetailResponse.kt
94-100의 currentMatch는 직접 변경 없이 생략 계약을 기준으로 문서화하고, TournamentApi.kt 106-110 및
578-580의 null 설명을 필드 미포함 표현으로 바꾸며, TournamentApiExamples.kt 191-253의 예제명과 소비자
안내를 실제 JSON payload와 일치시키세요.

In `@src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt`:
- Around line 587-626: Update the early tournament-state validation before the
existing isInProgress check so a completed tournament can recognize a previously
recorded final match by matching the pair in histories. For the same winner,
reconstruct and return the completed result; for a different winner, reject it
with the existing duplicate-match error. Preserve the current
notInProgressTournament behavior for unrelated requests and leave the existing
in-progress idempotency flow unchanged.

---

Nitpick comments:
In
`@src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt`:
- Around line 1073-1088: Update the tournament match integration test around the
existing items and mockMvc request to first submit the invalid adjacent pair
using ti10k and ti40k, asserting HTTP 400 and the TOURNAMENT-034 error code,
then submit the valid ti10k/ti20k pair and retain the HTTP 200 assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f36e48d-02fc-4d80-8bcb-b77a05e6a8a1

📥 Commits

Reviewing files that changed from the base of the PR and between 63c301b and a2e1e56.

📒 Files selected for processing (15)
  • src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt
  • src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt
  • src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt
  • src/main/kotlin/com/depromeet/piki/tournament/controller/dto/RecordMatchResponse.kt
  • src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt
  • src/main/kotlin/com/depromeet/piki/tournament/domain/RoundBracket.kt
  • src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt
  • src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt
  • src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt
  • src/main/kotlin/com/depromeet/piki/tournament/service/dto/RecordMatchResult.kt
  • src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt
  • src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/tournament/domain/RoundBracketTest.kt
  • tournament-match-bracket-spec.md

@github-actions
github-actions Bot requested a review from m-a-king July 30, 2026 02:57
@sevineleven sevineleven changed the title 토너먼트 매치 브래킷 서버 이관 - 페어 조합 강제와 2의 거듭제곱 정규화 [P9] 토너먼트 매치 브래킷 서버 이관 - 페어 조합 강제와 2의 거듭제곱 정규화 Jul 30, 2026
- 진행 중 검사(isInProgress)가 멱등 판정보다 앞에 있어, 결승 재전송만 409 TOURNAMENT-006 으로 떨어졌다. 결승을 기록하면 토너먼트가 즉시 COMPLETED 로 바뀌기 때문이다
- 결승 응답을 못 받고 재전송하는 경우(타임아웃·앱 재실행·중복 탭)가 가장 흔한 재시도인데, 하필 그 케이스만 멱등 계약에서 빠져 있었다. 클라는 최종 순위를 못 받고, 방금 선택을 마친 사용자에게 "토너먼트가 진행 중일 때만 할 수 있어요" 가 뜬다
- 진행 중 검사를 멱등 판정 뒤로 옮겼다 - 그 검사가 묻는 건 "새 매치를 기록해도 되나" 이므로 재시도 판정 뒤가 제자리다. 완료된 토너먼트의 같은 매치·같은 승자 재전송은 최초와 같은 순위 결과를 재구성해 돌려주고, 다른 승자는 그대로 409 TOURNAMENT-035 다
- 부수 효과로 미참여자가 완료된 토너먼트에 요청하면 409 대신 403 이 나간다 - 권한 검사가 상태 검사보다 앞서므로 더 정확한 응답이다
- 기존 통합 테스트 1건 재구성 - "COMPLETED 이면 409" 를 검증하던 테스트가 실은 결승 재전송 케이스라 이제 200 이다. 4아이템으로 완주시킨 뒤 한 번도 치르지 않은 조합을 보내 원래 의도(완료 상태 충돌)를 그대로 지켰다
- 통합 2건 추가 - 결승 재전송 시 같은 순위 결과 + 기록 미증가, 완료 후 다른 승자 재전송 시 409 TOURNAMENT-035
- NON_NULL 규약이라 nextMatch·completed·currentMatch 는 값이 없을 때 `"nextMatch": null` 이 아니라 키째 생략된다. 두 필드가 다 비는 라운드 종료 응답은 `"data": {}` 다
- 문서·example 이름은 "null 이다" 로 안내하고 있어 실제 직렬화와 어긋났다. 통합 테스트는 doesNotExist() 로 이미 생략을 단언하고 있었으니 계약이 아니라 설명만 틀린 상태였다
- 레포 전체가 NON_NULL 이고 클라 타입도 optional 로 선언돼 있어, null 을 명시적으로 내리는 대신 생략을 계약으로 확정하고 문서를 맞췄다
- 결승 재전송이 409 가 아니라 같은 completed 를 돌려준다는 점도 매치 기록 API 설명·409 사유에 반영
- 원문에는 "재요청 → 멱등" 만 있어 결승 예외가 없다는 게 암묵적이었고, 그 탓에 구현이 상태 검사를 멱등 판정보다 앞에 두는 실수를 했다 (CodeRabbit 지적, 51600e9 에서 수정)
- 진행 중 검사가 멱등 판정보다 뒤에 와야 하는 이유와 판정 순서표, 참여자 조회가 앞당겨지며 생기는 403/409 순서 변화를 함께 기록
- 가격 오름차순 응답을 검증하는 테스트가 "(10k,40k) 를 보내면 400" 이라고 주석만 달고 단언하지 않아, 페어 검증이 사라져도 통과했다 (CodeRabbit nitpick)
- 전용 위조 페어 테스트는 삽입 순서와 가격 순서가 같아 "삽입 인접이지만 가격 인접이 아닌" 구분을 못 잡는다. 이관이 막은 구멍 그 자체라 이 자리에서 단언한다
@sevineleven

Copy link
Copy Markdown
Collaborator Author

@coderabbitai CodeRabbit 리뷰 3건 전부 accept 했습니다. 인라인 thread 2건은 각 자리에 답변하고 resolve 했고, nitpick 은 resolve 대상이 아니라 여기에 처리 사실을 남깁니다.

지적 처리 커밋
결승 재전송이 isInProgress 검사에 막혀 멱등이 깨짐 (Major) accept 51600e9
NON_NULL 이라 필드가 null 이 아니라 생략되는데 문서·예제는 null 안내 (Minor) accept fa5863b
가격 정렬 테스트가 실패 경로(위조 페어 400)를 단언하지 않음 (Nitpick) accept 5e6df34

추가로 두 건을 더 손봤습니다.

  • 258adae - 스펙 문서 6절에 "멱등에 결승 예외를 두지 않는다" 를 명시했습니다. 원문에 결승 예외가 없다는 게 암묵적이라 구현이 상태 검사를 앞에 두는 실수를 했으므로, 판정 순서표와 그 이유를 문서에 박았습니다.
  • nitpick 은 "페어 검증이 제거돼도 통과한다" 는 전제만 보면 스위트 전체로는 정확하지 않습니다 (전용 위조 페어 테스트가 따로 있습니다). 다만 그 전용 테스트는 삽입 순서와 가격 순서가 같아 삽입 인접이지만 가격 인접이 아닌 케이스를 못 잡고, 그게 이관이 막은 구멍 그 자체라 지적하신 자리에 단언을 넣는 게 맞았습니다.

51600e9 의 부수 효과 하나를 리뷰어들이 알아야 해서 함께 적어둡니다. 참여자 조회가 상태 검사보다 앞으로 오면서, 미참여자가 완료된 토너먼트에 매치를 보내면 409 대신 403 이 나갑니다. 권한 검사가 상태 검사보다 앞서는 게 더 정확한 응답이라 의도적으로 두었습니다 (기존 403 테스트는 IN_PROGRESS 기준이라 영향 없음).

전체 1430건 통과.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@sevineleven 확인했습니다. 세 건 모두 적절히 반영됐고, 특히 결승 재전송의 멱등성 판정 순서를 스펙에 명시한 점이 재발 방지에 좋습니다. “암묵적 규칙은 결국 버그의 먹잇감”이니까요. 🐇

미참여자 요청을 상태 검사보다 먼저 권한 검사하여 403을 반환하는 것도 의도와 보안 관점에 부합합니다. 해당 API 동작을 이후 리뷰에서 전제로 삼겠습니다. 1,430건 통과도 확인했습니다.


✏️ Learnings added
Learnt from: sevineleven
URL: https://github.com/TeamPiKi/core/pull/835

Timestamp: 2026-07-30T04:54:23.313Z
Learning: 토너먼트 매치 기록 API에서는 참여자 권한 검사가 토너먼트 상태 검사보다 먼저 수행된다. 따라서 미참여자가 COMPLETED 상태의 토너먼트에 매치 기록을 요청하면 409가 아니라 403을 반환하는 것이 의도된 계약이다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@sevineleven

Copy link
Copy Markdown
Collaborator Author

draft 로 두는 이유 - 서버 단독 배포가 불가하다

구현·테스트는 끝났고 CI 도 초록입니다. 리뷰는 지금 받고 싶지만, 머지 자체를 보류합니다.

dev 에 push 되면 ci.yml 성공 직후 deploy.ymlworkflow_run 으로 자동 발동합니다. 게이트가 conclusion == 'success' && event == 'push' 뿐이라 승인 단계가 없습니다. 즉 이 PR 머지 = dev.api.piki.day 자동 배포입니다.

브래킷 정규화로 2의 거듭제곱이 아닌 인원의 첫 라운드 페어 구성이 구버전 클라와 달라지므로, 클라가 함께 나가지 않으면 dev 환경이 이렇게 됩니다.

참가 인원 dev 서버(새) ↔ dev 클라(구버전)
2 · 4 · 8 · 16 · 32 페어 구성 동일 -> 통과
그 외 전부 첫 매치부터 400 TOURNAMENT-034

프론트 작업(TeamPiKi/client#404)이 아직 착수 전이라, 지금 머지하면 dev 에서 비(2의 거듭제곱) 인원 토너먼트가 전부 막힙니다. QA·기획이 dev 로 확인 중이면 바로 걸립니다.

머지 조건 (셋 다 충족되면 draft 해제)

  • refactor: 매치 페어링·셔플·부전승 파생 제거 - 서버 브래킷 응답 소비로 전환 client#404 구현 준비 - 서버·클라를 함께 배포해야 합니다. 호환 코드는 두지 않기로 했습니다 (복잡도 대비 실익 없음)
  • 시작 응답 결정 - 프론트가 시작 직후 진행 상태를 손으로 조립하는데 POST /start 응답에 currentMatch 가 없습니다. 프론트가 조회를 한 번 더 부르는 쪽(서버 변경 0, 이 PR 의 전제)인지, TournamentStartResponse 에 필드를 얹는 쪽인지 정해야 합니다. 후자면 이 브랜치에 커밋을 추가해야 하므로 머지 전에 결론이 필요합니다
  • 동작 변경 승인 - 부전승이 가격 최고가 고정에서 시드 랜덤으로, 진행 순서가 매번 랜덤에서 새로고침 고정으로 바뀝니다. 첫 라운드 라벨은 25명이면 여전히 "25강" 이라 문구 재검토 대상입니다

배포 시 주의

dev 는 "Require branches to be up to date" 라 dev 가 갱신되면 이 PR 이 최신 dev 와 합쳐 CI 를 다시 통과해야 합니다. 오래 열려 있으면 재실행이 반복되니 리뷰는 먼저 받아두는 편이 좋습니다.

배포 순간 IN_PROGRESS 로 남아 있던 토너먼트는 라운드 판정이 어긋나 그 사용자가 진행을 끝내지 못합니다(400 으로 드러남). 트래픽 적은 새벽에 배포해 노출을 줄여야 합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor 구조 개선, 외부 동작 불변

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P9] 토너먼트 매치 로직 백엔드 이관 + 브래킷 무결성 서버 강제

1 participant