왜
CLONE 이라는 개념이 도메인에 없다. 코드에만 있다.
토너먼트를 공유받아 플레이하면 tournaments 테이블에 행이 하나 더 생기고, 그 행은 source_tournament_id 로 원본을 가리킨다. 이 행을 우리는 CLONE 이라 부른다. 그런데 이 행은 토너먼트가 아니다. 이름도 원본과 같고, 아이템도 원본 것을 그대로 쓰고, 혼자서는 아무 의미가 없다.
이건 취향 문제가 아니라 애그리거트 경계를 잘못 그은 것이고, 그 대가를 코드 곳곳에서 치르고 있다. 아래는 그 판단의 근거다.
1. 애그리거트 경계란 무엇인가
DDD 에서 애그리거트(Aggregate) 는 "함께 변해야 하는 것들의 묶음" 이다. 묶는 기준은 데이터의 모양이 아니라 불변식(invariant) 이다. 이 규칙들이 항상 동시에 참이어야 한다면, 그것들은 한 애그리거트 안에 있어야 한다.
애그리거트에는 대표가 하나 있고 그것을 애그리거트 루트라 부른다. 규칙은 단순하다.
- 외부는 루트만 참조한다. 애그리거트 내부의 엔티티를 밖에서 직접 붙잡지 않는다.
- 다른 애그리거트는 ID 로만 참조한다. 객체 참조로 엮어 한 트랜잭션에서 같이 수정하지 않는다.
- 불변식은 애그리거트 안에서 지켜진다. 경계를 넘어가야 지킬 수 있는 규칙이 있다면 경계가 잘못 그어진 것이다.
그래서 "이게 애그리거트 루트인가" 를 판정하는 실무적 질문은 세 가지다.
| 질문 |
의미 |
| 혼자 존재할 수 있나 |
다른 것이 사라지면 의미를 잃는다면 루트가 아니라 내부 엔티티다 |
| 자기 데이터로 자기 일을 하나 |
자기 일을 하려고 남의 테이블을 읽어야 한다면 경계가 틀렸다 |
| 불변식이 경계 안에서 지켜지나 |
경계 밖에서 우회로 뚫린다면 경계가 아니라 관습이다 |
우리 CLONE 은 세 질문에 전부 "아니오" 로 답한다. 하나씩 근거를 본다.
2. 근거 - 경계를 어떻게 넘고 있나
수치는 전부 prod 실측(2026-09-04, 살아있는 행 기준)이다.
2-1. 혼자 존재할 수 없다
CLONE 은 자기 아이템을 하나도 갖고 있지 않다.
clone_owned_items = 0 / 54
54 개 클론 전부, tournament_items 에 자기 행이 0 개다. 이름도 마찬가지다.
name = ROOT 의 name → 54 / 54
즉 CLONE 은 이름도 내용물도 전부 남의 것이다. 원본을 지우면 빈 껍데기가 된다. 독립적으로 존재할 수 없는 것은 애그리거트 루트가 아니다.
2-2. 자기 일을 하려고 남의 애그리거트로 손을 뻗는다
가장 직접적인 증거다.
// CLONE 토너먼트는 DB 에 아이템 행이 없고, ROOT 의 아이템을 sourceTournamentId 로 공유한다.
private fun getEffectiveTournamentItems(tournament: Tournament) =
tournamentItemRepository.findAllByTournamentId(tournament.sourceTournamentId ?: tournament.getId())
Tournament 는 애그리거트 루트인데, 자기 아이템을 돌려주지 못해 다른 Tournament 애그리거트의 내부 데이터를 직접 읽는다. 애그리거트가 다른 애그리거트의 내부를 들여다보는 것은 경계 위반의 교과서적 형태다.
이 ?: 는 한 단계만 거슬러 오른다. 그래서 클론의 클론을 허용하려면(#1013) "새 클론의 source 는 항상 ROOT 로 평탄화" 라는 규칙을 하나 더 얹어야 한다. 경계가 맞았다면 필요 없을 규칙이다.
2-3. 불변식이 우회되고 있다
tournament_users 에는 이런 제약이 있다.
UNIQUE KEY uk_tournament_users (tournament_id, user_id)
"한 사람은 한 토너먼트에 한 행" 이라는 불변식이다. 그런데 실제로는 이렇다.
|
수 |
| 클론 소유자가 원본에도 참여 행을 가진 경우 |
24 |
| 클론만 가진 경우 (링크로 온 사람) |
29 |
24 명이 한 토너먼트 안에 두 행으로 존재한다. 클론이 별도 토너먼트라서 유니크 키에 걸리지 않는다. DB 는 막으려 했고, 모델이 우회로를 열어준 것이다.
그 결과는 코드 주석에 이렇게 남아 있다.
record 가 아니라 userId 로 센다 - 같은 사용자가 ROOT TU 와 자기 CLONE 을 모두 가질 수 있어서다. 그대로 record 를 세면 1 명이 2 로 잡혀 solo 가 그룹으로 오인된다.
집계 곳곳에 distinctBy { it.userUUID } 같은 방어가 붙어 있다. 제약으로 표현됐어야 할 것을 매번 손으로 세고 있다.
2-4. 정책으로 막고 있는 것들이 곧 증상이다
지금 CLONE 전용 금지 규칙이 5 개 있다.
| 코드 |
금지 |
TOURNAMENT-024 |
클론은 공유 링크를 만들 수 없다 |
TOURNAMENT-029 |
이미 이 링크로 만든 것이 있다 |
TOURNAMENT-031 |
클론에서는 친구 결과를 볼 수 없다 |
TOURNAMENT-032 |
클론에는 아이템을 추가할 수 없다 |
TOURNAMENT-038 |
클론의 아이템을 수정할 수 없다 |
이 목록이 말하는 바는 하나다. CLONE 은 토너먼트가 할 수 있는 일의 대부분을 하면 안 된다. 모델이 허용하는 것을 정책이 되돌려 막고 있다.
경계가 맞았다면 이 규칙들은 존재할 수 없다. 플레이에는 아이템도 초대 코드도 공유 링크도 컬럼 자체가 없어서 애초에 표현 불가능하기 때문이다.
2-5. 죽은 값이 전역 자원을 소모한다
invite_code 는 NOT NULL 이고 active_invite_code 에 전역 유니크 인덱스가 걸려 있다.
클론 중 실제 초대 코드가 발급된 것 = 54 / 54
초대로 들어올 수 없는 행이 전부 초대 코드를 쥐고, 진짜 토너먼트와 같은 코드 공간에서 슬롯을 태운다. 게다가 클론 5 개가 PENDING 인데, checkJoinable 은 isPending() 만 보므로 생성 후 30 분간 코드로 합류가 구조적으로 열려 있다. 의도한 적 없는 입구다.
2-6. 지표가 20% 부풀려져 있다
|
순진하게 센 값 |
실제 |
왜곡 |
| 토너먼트 수 |
312 |
259 |
+20% |
| 완료된 토너먼트 |
201 |
159 |
+26% |
SELECT COUNT(*) FROM tournaments 가 토너먼트 수가 아니다. 집계를 뽑을 때마다 source_tournament_id IS NULL 을 기억해야 하고, 한 번 빠뜨리면 조용히 틀린다.
3. 왜 이렇게 됐나 - 두 PR 의 나흘
이건 누가 잘못 설계한 이야기가 아니다. 맞는 판단 두 개가 겹쳐서 생긴 어긋남이다.
#370 (2026-06-04) - 여기서는 CLONE 이 정확한 이름이었다
플레이 링크 기능이 들어왔다. 마이그레이션 주석이 당시 의도를 그대로 남기고 있다.
-- source_tournament_id: 플레이 링크로 복제된 경우 원본 토너먼트 ID. NULL이면 원본.
이때는 아이템을 실제로 DB 에 복사했다. PR 본문에도 "원본 아이템을 그대로 복사한 PENDING 토너먼트 생성" 이라고 적혀 있다. 진짜 복제였고, 새 토너먼트가 맞았고, CLONE 이라는 이름도 맞았다.
#456 (2026-06-08) - 나흘 뒤, 다른 문제에 재사용됐다
문제는 공유와 아무 관계가 없었다.
소셜 토너먼트에서 여러 참여자가 하나의 tournamentId 를 공유해 플레이하는 구조였다. 공유 구조에서는 누가 어떤 매치를 선택했는지 구분할 수 없어, 참여자별 독립 진행이 불가능했다.
그리고 이 PR 은 지금 우리가 하려는 방향을 먼저 시도했다.
처음에는 히스토리에 tournamentUserId 를 붙이고 TournamentUser.completedAt 을 추가해 공유 구조 안에서 분리하려 했으나, "하나의 토너먼트에서 여러 참여자의 선택이 뒤섞인다" 는 근본 문제는 해결되지 않았다.
그 시도는 왜 멈췄나. tournament_users 를 보면 답이 보인다.
CREATE TABLE tournament_users (
id, tournament_id, user_id,
completed_at, -- #456 이 추가한 것
UNIQUE KEY uk_tournament_users (tournament_id, user_id)
)
completed_at 은 옮겼는데 status 를 못 옮겼다. 그래서 플레이 상태가 두 테이블로 쪼개졌다.
"내가 언제 끝냈나" -> tournament_users.completed_at (사람별)
"지금 어느 단계냐" -> tournaments.status (토너먼트별) <- 여기서 막힘
토너먼트 행은 status 를 하나만 가질 수 있다. 세 명이 각자 진행하면 그 하나를 누구 것으로 둘 것인가. 답이 없었다. 그래서 사람마다 토너먼트 행을 하나씩 만들어줬고, 새 개념을 만드는 대신 나흘 전에 생긴 source_tournament_id 를 재사용했다.
CLONE 은 status 를 담을 그릇이 없어서 태어났다. 나머지 컬럼(이름, 초대 코드, 공유 링크)은 토너먼트 행에 딸려 온 것일 뿐 아무도 원한 적이 없다.
같은 PR 에서 아이템이 복사에서 참조로 바뀌었다. 이 순간 CLONE 은 복제이기를 그만뒀다. 이름만 남았다.
지금의 어긋남
|
태어난 이유 (#370) |
실제 하는 일 (#456 이후) |
| 개념 |
남에게 넘긴 사본 |
한 사람의 판 |
| 아이템 |
복사해서 소유 |
참조만, 0 행 |
| 만드는 주체 |
링크 받은 외부인 |
주로 초대받은 멤버 |
CLONE 은 분기(fork)의 모델이다. 받은 토너먼트를 내 식대로 고쳐 다시 굴린다면 그건 진짜로 다른 토너먼트고, source_tournament_id 는 계보를 뜻하고, 이름도 맞다. 그런데 우리는 TOURNAMENT-032, TOURNAMENT-038 로 분기를 명시적으로 금지했다. 제품은 이미 "플레이는 분기하지 않는다" 를 결정했는데, 분기할 때만 말이 되는 모델을 유지하고 있다.
4. status 의 겸직 - 이게 진짜 뿌리다
한 걸음 더 들어가면, tournaments.status 는 지금 서로 다른 두 가지를 겸하고 있다.
| 값 |
실제 의미 |
누구의 상태인가 |
PENDING |
아직 아이템 모으는 중, 구성 미확정 |
정의 |
IN_PROGRESS |
구성이 확정돼 플레이 가능 |
정의 |
COMPLETED |
주최자가 끝까지 했다 |
플레이 |
앞의 둘은 토너먼트 자체의 상태다. 실제로 "ROOT 가 IN_PROGRESS 여야 멤버가 참가할 수 있다" 는 규칙이 이걸 정의의 상태로 쓴다. 그런데 COMPLETED 만 갑자기 주최자 개인의 진행 상태로 튄다.
이 겸직이 클론을 낳은 근본 원인이다. status 가 정의의 상태이면서 동시에 한 사람의 상태이니, 두 번째 사람이 오는 순간 담을 곳이 사라진다.
지금 이 모순은 특수 분기로 메워져 있다. ROOT 가 COMPLETED 로 바뀐 뒤 뒤늦게 들어온 멤버를 위해 buildMemberPendingOnRoot 라는 전용 응답 경로가 따로 있다. 모델이 말하지 못하는 것을 코드가 대신 말하고 있다.
무엇을
목표 모델
플레이 테이블을 새로 만들 필요가 없다. 이미 있다 - tournament_users 다.
이미 갖춘 것: (tournament_id, user_id) 유니크 키, completed_at, 그리고 tournament_histories.tournament_user_id 가 이미 이 행을 부모로 가리킨다(고아 이력 0 건 실측 확인).
빠진 건 컬럼 하나다.
ALTER TABLE tournament_users ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT 'PENDING';
이 한 줄이 #456 의 미완성 부분이다. 이게 들어가는 순간 클론이 붙들고 있던 마지막 정보가 제자리를 찾고, 클론 행은 들 것이 없어진다.
|
지금 |
이후 |
tournaments |
정의 + 한 사람의 진행 |
정의만 |
tournament_users |
명단 + 완료시각 |
명단 + 진행 전체 |
| CLONE |
status 를 담는 껍데기 |
없음 |
겸직도 함께 푼다.
tournaments.status 구성 미확정 -> 확정(플레이 가능)
tournament_users.status PENDING -> IN_PROGRESS -> COMPLETED
이러면 "주최자가 완주했는데 토너먼트는 아직 열려 있다" 가 자연스럽게 표현되고, buildMemberPendingOnRoot 같은 특수 분기가 사라진다.
단계
배포 가능한 단위로 넷으로 쪼갠다. 3 단계까지 클라를 안 건드린다.
1 단계 - 컬럼 추가. tournament_users.status 추가. 읽는 곳이 아직 없어 배포해도 아무 일도 일어나지 않는다.
2 단계 - 백필.
ROOT 주최자의 참여 행 <- ROOT.status 복사
ROOT 에 참여만 한 사람의 행 <- PENDING
클론 소유자의 참여 행 <- 클론.status 복사 + tournament_id 를 ROOT 로 재지향
유일한 난점은 재지향 시 유니크 키 충돌이다.
| 대상 |
수 |
처리 |
| 링크로 온 사람 (원본 참여 행 없음) |
29 |
tournament_id 만 ROOT 로 갱신 |
| 초대받은 멤버 (원본 참여 행 있음) |
24 |
병합 - 클론 행의 status·completed_at 을 원본 행으로 옮기고, 그 사람의 이력을 원본 행으로 재지향한 뒤 클론 행 삭제 |
병합 방향은 명확하다. 원본 참여 행이 먼저 생긴 기록이고 클론 행이 나중에 파생된 것이므로 원본을 살리고 클론을 흡수한다. tournament_histories.tournament_id 도 클론 id 에서 ROOT id 로 함께 갱신한다.
덤으로 클론.created_at 은 그 사람이 플레이를 시작한 시각이라 의미가 있다. started_at 으로 보존하면 지금 못 뽑는 "시작에서 완료까지 걸린 시간" 이 생긴다.
3 단계 - 읽기 경로 전환. status 를 토너먼트가 아니라 요청자의 참여 행에서 읽는다. 응답의 tournamentId 는 ROOT id 로 통일. 여기서 대부분의 코드가 사라진다.
| 지금 |
이후 |
findBySourceTournamentId 로 클론 모아 완료자 집계 |
그 토너먼트의 참여 행 중 COMPLETED 세기 |
distinctBy { userUUID } self-clone 중복 제거 |
불필요 (유니크 키가 보장) |
목록 쿼리의 NOT EXISTS 상관 서브쿼리 |
내 참여 행을 조인하면 끝 |
sourceTournamentId ?: getId() |
아이템은 언제나 그 토너먼트 것 |
4 단계 - 제거. source_tournament_id 드롭, 클론 행 제거, 금지 코드 5 종 폐기. (파괴적 단계라 db/migration/CLAUDE.md 의 단계 배포 규약을 따른다.)
클라 호환
유일한 실질 리스크는 클라가 클론 id 를 tournamentId 로 들고 있다는 점이다.
POST /tournaments/{id}/start -> 응답에 새 tournamentId (= 클론 id)
이후 모든 플레이가 그 id 로 진행
다행히 노출 규모가 작다. 진행 중인 클론이 11 개(IN_PROGRESS 6 + PENDING 5)뿐이고, 완료된 42 개는 결과 조회라 ROOT id 로 바꿔도 같은 것을 보여줄 수 있다.
그래서 4 단계를 미룬다. 3 단계 배포 시점에는 클론 행을 리다이렉트 용도로 남겨두고, 클론 id 요청이 오면 source_tournament_id 로 ROOT 를 해소해 요청자의 진행을 응답한다. 플레이 링크 수명(14 일)이 지난 뒤 4 단계로 실제 제거한다.
isRoot · sourceTournamentId 응답 필드도 당분간 유지하되 값만 새 모델에서 계산한다(isRoot = 항상 true, sourceTournamentId = null). 클라가 이 값으로 공유 버튼을 분기하는데, 모든 사람이 ROOT 를 보게 되므로 공유 버튼이 전원에게 열린다. 이건 마침 #1013 이 원하던 동작이다.
마이그레이션 규모 (prod 실측)
| 항목 |
값 |
함의 |
| 옮길 클론 |
54 |
그대로 참여 행 54 |
| ROOT |
308 |
정의 유지 + 주최자 참여 행 |
tournament_users |
409 |
유지 |
tournament_histories |
1,006 |
부모 재지향 |
| 고아 이력 |
0 |
매달 곳이 애매한 행이 없다 |
| 클론당 참가자 |
정확히 1 |
변환에 판단이 끼지 않는다 |
총 1,400 행. Flyway 한 장이면 된다. 배치도 다운타임도 필요 없다.
검증
- 백필 후 사람 수가 보존되는지 (병합 24 건에서 이력이 유실되지 않는지)
- 병합 대상 24 명의
tournament_histories 가 전부 살아남고 부모가 정확한지
- 그룹 결과가 마이그레이션 전후 동일한지 (같은 사람이 같은 순위)
- 진행 중이던 플레이(11 건)가 이어서 진행 가능한지
- 클론 id 로 오는 요청이 리다이렉트로 정상 응답하는지
- 목록 조회에서 한 사람이 한 토너먼트에 한 번만 나오는지
- negative control:
distinctBy 방어를 제거해도 중복이 안 생기는지 (유니크 키가 실제로 일하는지 확인)
왜 지금인가
#1013(코드 입장 + 클론 재공유)보다 먼저 해야 한다.
지금은 클론당 참가자가 정확히 1 이고 고아 이력이 0 이라 백필이 기계적이다. #1013 으로 재공유가 열리면 클론 트리가 깊어지고, 그때는 평탄화된 데이터를 되돌리는 작업이 백필에 얹힌다.
그리고 이 작업이 끝나면 #1013 이 별도 작업이 아니게 된다. 공유는 정의에 붙고 사람은 플레이를 하나씩 가지므로, "누가 공유했든 새로 온 사람은 그 정의의 플레이를 하나 만든다" 가 전부다. 평탄화 규칙도 필요 없다.
이 결정의 유효 조건
이 판단은 무조건이 아니라 조건부다. 언젠가 "받은 토너먼트를 내 식대로 고쳐서 다시 굴린다" 가 제품 방향이 되면, 그때는 플레이가 자기 아이템 집합을 가져야 하고 CLONE 이 다시 옳은 모델이 된다.
#1013 이 가려는 방향(같은 구성을 더 널리 퍼뜨린다)은 정확히 그 반대쪽이다. 그래서 지금 시점에서는 없애는 것이 맞다.
참고
왜
CLONE이라는 개념이 도메인에 없다. 코드에만 있다.토너먼트를 공유받아 플레이하면
tournaments테이블에 행이 하나 더 생기고, 그 행은source_tournament_id로 원본을 가리킨다. 이 행을 우리는 CLONE 이라 부른다. 그런데 이 행은 토너먼트가 아니다. 이름도 원본과 같고, 아이템도 원본 것을 그대로 쓰고, 혼자서는 아무 의미가 없다.이건 취향 문제가 아니라 애그리거트 경계를 잘못 그은 것이고, 그 대가를 코드 곳곳에서 치르고 있다. 아래는 그 판단의 근거다.
1. 애그리거트 경계란 무엇인가
DDD 에서 애그리거트(Aggregate) 는 "함께 변해야 하는 것들의 묶음" 이다. 묶는 기준은 데이터의 모양이 아니라 불변식(invariant) 이다. 이 규칙들이 항상 동시에 참이어야 한다면, 그것들은 한 애그리거트 안에 있어야 한다.
애그리거트에는 대표가 하나 있고 그것을 애그리거트 루트라 부른다. 규칙은 단순하다.
그래서 "이게 애그리거트 루트인가" 를 판정하는 실무적 질문은 세 가지다.
우리 CLONE 은 세 질문에 전부 "아니오" 로 답한다. 하나씩 근거를 본다.
2. 근거 - 경계를 어떻게 넘고 있나
수치는 전부 prod 실측(2026-09-04, 살아있는 행 기준)이다.
2-1. 혼자 존재할 수 없다
CLONE 은 자기 아이템을 하나도 갖고 있지 않다.
54 개 클론 전부,
tournament_items에 자기 행이 0 개다. 이름도 마찬가지다.즉 CLONE 은 이름도 내용물도 전부 남의 것이다. 원본을 지우면 빈 껍데기가 된다. 독립적으로 존재할 수 없는 것은 애그리거트 루트가 아니다.
2-2. 자기 일을 하려고 남의 애그리거트로 손을 뻗는다
가장 직접적인 증거다.
Tournament는 애그리거트 루트인데, 자기 아이템을 돌려주지 못해 다른 Tournament 애그리거트의 내부 데이터를 직접 읽는다. 애그리거트가 다른 애그리거트의 내부를 들여다보는 것은 경계 위반의 교과서적 형태다.이
?:는 한 단계만 거슬러 오른다. 그래서 클론의 클론을 허용하려면(#1013) "새 클론의 source 는 항상 ROOT 로 평탄화" 라는 규칙을 하나 더 얹어야 한다. 경계가 맞았다면 필요 없을 규칙이다.2-3. 불변식이 우회되고 있다
tournament_users에는 이런 제약이 있다."한 사람은 한 토너먼트에 한 행" 이라는 불변식이다. 그런데 실제로는 이렇다.
24 명이 한 토너먼트 안에 두 행으로 존재한다. 클론이 별도 토너먼트라서 유니크 키에 걸리지 않는다. DB 는 막으려 했고, 모델이 우회로를 열어준 것이다.
그 결과는 코드 주석에 이렇게 남아 있다.
집계 곳곳에
distinctBy { it.userUUID }같은 방어가 붙어 있다. 제약으로 표현됐어야 할 것을 매번 손으로 세고 있다.2-4. 정책으로 막고 있는 것들이 곧 증상이다
지금 CLONE 전용 금지 규칙이 5 개 있다.
TOURNAMENT-024TOURNAMENT-029TOURNAMENT-031TOURNAMENT-032TOURNAMENT-038이 목록이 말하는 바는 하나다. CLONE 은 토너먼트가 할 수 있는 일의 대부분을 하면 안 된다. 모델이 허용하는 것을 정책이 되돌려 막고 있다.
경계가 맞았다면 이 규칙들은 존재할 수 없다. 플레이에는 아이템도 초대 코드도 공유 링크도 컬럼 자체가 없어서 애초에 표현 불가능하기 때문이다.
2-5. 죽은 값이 전역 자원을 소모한다
invite_code는NOT NULL이고active_invite_code에 전역 유니크 인덱스가 걸려 있다.초대로 들어올 수 없는 행이 전부 초대 코드를 쥐고, 진짜 토너먼트와 같은 코드 공간에서 슬롯을 태운다. 게다가 클론 5 개가
PENDING인데,checkJoinable은isPending()만 보므로 생성 후 30 분간 코드로 합류가 구조적으로 열려 있다. 의도한 적 없는 입구다.2-6. 지표가 20% 부풀려져 있다
SELECT COUNT(*) FROM tournaments가 토너먼트 수가 아니다. 집계를 뽑을 때마다source_tournament_id IS NULL을 기억해야 하고, 한 번 빠뜨리면 조용히 틀린다.3. 왜 이렇게 됐나 - 두 PR 의 나흘
이건 누가 잘못 설계한 이야기가 아니다. 맞는 판단 두 개가 겹쳐서 생긴 어긋남이다.
#370 (2026-06-04) - 여기서는 CLONE 이 정확한 이름이었다
플레이 링크 기능이 들어왔다. 마이그레이션 주석이 당시 의도를 그대로 남기고 있다.
-- source_tournament_id: 플레이 링크로 복제된 경우 원본 토너먼트 ID. NULL이면 원본.이때는 아이템을 실제로 DB 에 복사했다. PR 본문에도 "원본 아이템을 그대로 복사한 PENDING 토너먼트 생성" 이라고 적혀 있다. 진짜 복제였고, 새 토너먼트가 맞았고, CLONE 이라는 이름도 맞았다.
#456 (2026-06-08) - 나흘 뒤, 다른 문제에 재사용됐다
문제는 공유와 아무 관계가 없었다.
그리고 이 PR 은 지금 우리가 하려는 방향을 먼저 시도했다.
그 시도는 왜 멈췄나.
tournament_users를 보면 답이 보인다.completed_at은 옮겼는데status를 못 옮겼다. 그래서 플레이 상태가 두 테이블로 쪼개졌다.토너먼트 행은
status를 하나만 가질 수 있다. 세 명이 각자 진행하면 그 하나를 누구 것으로 둘 것인가. 답이 없었다. 그래서 사람마다 토너먼트 행을 하나씩 만들어줬고, 새 개념을 만드는 대신 나흘 전에 생긴source_tournament_id를 재사용했다.CLONE 은
status를 담을 그릇이 없어서 태어났다. 나머지 컬럼(이름, 초대 코드, 공유 링크)은 토너먼트 행에 딸려 온 것일 뿐 아무도 원한 적이 없다.같은 PR 에서 아이템이 복사에서 참조로 바뀌었다. 이 순간 CLONE 은 복제이기를 그만뒀다. 이름만 남았다.
지금의 어긋남
CLONE 은 분기(fork)의 모델이다. 받은 토너먼트를 내 식대로 고쳐 다시 굴린다면 그건 진짜로 다른 토너먼트고,
source_tournament_id는 계보를 뜻하고, 이름도 맞다. 그런데 우리는TOURNAMENT-032,TOURNAMENT-038로 분기를 명시적으로 금지했다. 제품은 이미 "플레이는 분기하지 않는다" 를 결정했는데, 분기할 때만 말이 되는 모델을 유지하고 있다.4. status 의 겸직 - 이게 진짜 뿌리다
한 걸음 더 들어가면,
tournaments.status는 지금 서로 다른 두 가지를 겸하고 있다.PENDINGIN_PROGRESSCOMPLETED앞의 둘은 토너먼트 자체의 상태다. 실제로 "ROOT 가 IN_PROGRESS 여야 멤버가 참가할 수 있다" 는 규칙이 이걸 정의의 상태로 쓴다. 그런데
COMPLETED만 갑자기 주최자 개인의 진행 상태로 튄다.이 겸직이 클론을 낳은 근본 원인이다. status 가 정의의 상태이면서 동시에 한 사람의 상태이니, 두 번째 사람이 오는 순간 담을 곳이 사라진다.
지금 이 모순은 특수 분기로 메워져 있다. ROOT 가
COMPLETED로 바뀐 뒤 뒤늦게 들어온 멤버를 위해buildMemberPendingOnRoot라는 전용 응답 경로가 따로 있다. 모델이 말하지 못하는 것을 코드가 대신 말하고 있다.무엇을
목표 모델
플레이 테이블을 새로 만들 필요가 없다. 이미 있다 -
tournament_users다.이미 갖춘 것:
(tournament_id, user_id)유니크 키,completed_at, 그리고tournament_histories.tournament_user_id가 이미 이 행을 부모로 가리킨다(고아 이력 0 건 실측 확인).빠진 건 컬럼 하나다.
이 한 줄이 #456 의 미완성 부분이다. 이게 들어가는 순간 클론이 붙들고 있던 마지막 정보가 제자리를 찾고, 클론 행은 들 것이 없어진다.
tournamentstournament_users겸직도 함께 푼다.
이러면 "주최자가 완주했는데 토너먼트는 아직 열려 있다" 가 자연스럽게 표현되고,
buildMemberPendingOnRoot같은 특수 분기가 사라진다.단계
배포 가능한 단위로 넷으로 쪼갠다. 3 단계까지 클라를 안 건드린다.
1 단계 - 컬럼 추가.
tournament_users.status추가. 읽는 곳이 아직 없어 배포해도 아무 일도 일어나지 않는다.2 단계 - 백필.
유일한 난점은 재지향 시 유니크 키 충돌이다.
tournament_id만 ROOT 로 갱신병합 방향은 명확하다. 원본 참여 행이 먼저 생긴 기록이고 클론 행이 나중에 파생된 것이므로 원본을 살리고 클론을 흡수한다.
tournament_histories.tournament_id도 클론 id 에서 ROOT id 로 함께 갱신한다.덤으로
클론.created_at은 그 사람이 플레이를 시작한 시각이라 의미가 있다.started_at으로 보존하면 지금 못 뽑는 "시작에서 완료까지 걸린 시간" 이 생긴다.3 단계 - 읽기 경로 전환. status 를 토너먼트가 아니라 요청자의 참여 행에서 읽는다. 응답의
tournamentId는 ROOT id 로 통일. 여기서 대부분의 코드가 사라진다.findBySourceTournamentId로 클론 모아 완료자 집계COMPLETED세기distinctBy { userUUID }self-clone 중복 제거NOT EXISTS상관 서브쿼리sourceTournamentId ?: getId()4 단계 - 제거.
source_tournament_id드롭, 클론 행 제거, 금지 코드 5 종 폐기. (파괴적 단계라db/migration/CLAUDE.md의 단계 배포 규약을 따른다.)클라 호환
유일한 실질 리스크는 클라가 클론 id 를
tournamentId로 들고 있다는 점이다.다행히 노출 규모가 작다. 진행 중인 클론이 11 개(IN_PROGRESS 6 + PENDING 5)뿐이고, 완료된 42 개는 결과 조회라 ROOT id 로 바꿔도 같은 것을 보여줄 수 있다.
그래서 4 단계를 미룬다. 3 단계 배포 시점에는 클론 행을 리다이렉트 용도로 남겨두고, 클론 id 요청이 오면
source_tournament_id로 ROOT 를 해소해 요청자의 진행을 응답한다. 플레이 링크 수명(14 일)이 지난 뒤 4 단계로 실제 제거한다.isRoot·sourceTournamentId응답 필드도 당분간 유지하되 값만 새 모델에서 계산한다(isRoot= 항상 true,sourceTournamentId= null). 클라가 이 값으로 공유 버튼을 분기하는데, 모든 사람이 ROOT 를 보게 되므로 공유 버튼이 전원에게 열린다. 이건 마침 #1013 이 원하던 동작이다.마이그레이션 규모 (prod 실측)
tournament_userstournament_histories총 1,400 행. Flyway 한 장이면 된다. 배치도 다운타임도 필요 없다.
검증
tournament_histories가 전부 살아남고 부모가 정확한지distinctBy방어를 제거해도 중복이 안 생기는지 (유니크 키가 실제로 일하는지 확인)왜 지금인가
#1013(코드 입장 + 클론 재공유)보다 먼저 해야 한다.
지금은 클론당 참가자가 정확히 1 이고 고아 이력이 0 이라 백필이 기계적이다. #1013 으로 재공유가 열리면 클론 트리가 깊어지고, 그때는 평탄화된 데이터를 되돌리는 작업이 백필에 얹힌다.
그리고 이 작업이 끝나면 #1013 이 별도 작업이 아니게 된다. 공유는 정의에 붙고 사람은 플레이를 하나씩 가지므로, "누가 공유했든 새로 온 사람은 그 정의의 플레이를 하나 만든다" 가 전부다. 평탄화 규칙도 필요 없다.
이 결정의 유효 조건
이 판단은 무조건이 아니라 조건부다. 언젠가 "받은 토너먼트를 내 식대로 고쳐서 다시 굴린다" 가 제품 방향이 되면, 그때는 플레이가 자기 아이템 집합을 가져야 하고 CLONE 이 다시 옳은 모델이 된다.
#1013 이 가려는 방향(같은 구성을 더 널리 퍼뜨린다)은 정확히 그 반대쪽이다. 그래서 지금 시점에서는 없애는 것이 맞다.
참고