실서버에서 사용자가 잡아 준 것들을 고쳤다. 대부분은 화면과 서버 중 어느 쪽도 거짓말을 하지 않는데 둘을 잇는 자리가 없어서 난 것들이다.

🔴 먼저 — 커밋 하나를 정정한다

7cc608a(「카드 사진을 실제로 저장한다」)에 시연용 더미 팀 시드 (fastapi/scripts/seed_showcase.py, 164줄)가 함께 들어갔다. 커밋 메시지에는 그 얘기가 없다. 변경을 한꺼번에 담다가 섞인 것이고, 코드 자체는 임시 PostgreSQL 로 검증을 끝낸 것이라 기능에는 영향이 없다 — 분류만 틀렸다.

히스토리를 다시 쓰지 않는다(루트 CLAUDE.md 의 「rebase·force-push 금지」 — 내 브랜치도 마찬가지다). 그래서 지우는 대신 여기 적어 둔다. 그 커밋에서 시드를 보게 되면 이 글을 떠올리면 된다.

그 시드가 하는 일은 따로다: 시연용 상대 팀 셋(3:3 · 5:5 · 7:7)을 꽉 찬 스쿼드와 경기 조건까지 갖춰 넣는다. 「비슷한 팀」이 늘 비어 있던 이유가 후보 하드 필터의 「상대 스쿼드가 인원만큼 찼을 것」이라, 갓 만든 팀끼리는 서로가 서로를 전부 탈락시키기 때문이다.

카드 사진 — 자리를 정했다

여태 카드 사진은 브라우저가 파일을 읽어 이 세션 동안만 들고 있었다. 새로고침하면 사라졌고, 계약이 사진을 일부러 빼 두었기 때문이다(“저장 위치부터 정해야 한다”, 보내면 422).

자리를 정했다 — 바이트는 S3, style 에는 키만.

style 에 data URL 로 담는 길도 있었지만 접었다. 카드는 아주 자주, 여러 장씩 읽힌다 — 스쿼드 판 하나가 자리마다 카드를 부르므로 5~7장이 매번 함께 나간다. 거기에 사진이 실리면 그만큼이 매번 DB 에서 나와 JSON 으로 나간다. 그리고 storage_port.py 가 세워 둔 PER-002(「재생도 앱 서버를 지나지 않는다」) 와 어긋난다 — 영상은 전부 브라우저↔S3 직통인데 사진만 DB 로 가면 같은 종류를 두 방식으로 다루게 된다.

  • PhotoStoragePort + S3 어댑터 + 스텁. 🔴 analysis 의 StoragePort 를 쓰지 않는다 — 컨텍스트끼리 임포트를 아키텍처 시험이 막는다
  • POST /me/card/photo-upload-url → 사전 서명 PUT. 두 단계다(올린 뒤 PATCH /me/card 의 style.photo_key 로 되돌려 보내야 붙는다)
  • 화면은 올리기 전에 줄인다(긴 변 768px). 🔴 투명한 그림(PNG·WebP)은 JPEG 로 안 바꾼다 — 「사람만 오려서」는 배경 없는 PNG 가 요점이라 바꾸면 투명한 자리가 검게 칠해진다

🔴 막은 것 셋: 남의 키를 못 붙인다(저장 시점 접두사 대조가 유일한 방어선이다) · 이미지 타입 화이트리스트(image/svg+xml 은 일부러 뺐다 — 스크립트를 담는다) · 같은 카드의 두 번째 사진이 첫 번째를 안 덮는다.

⚠️ 마이그레이션은 0건이다 — player_card.style 이 JSON 컬럼이다. ⚠️ 배포는 백엔드가 먼저다. 옛 서버는 extra="forbid" 라 새 화면이 보내는 사진 칸을 받으면 422 이고, 그러면 한 줄·색·붓자국까지 카드 저장이 통째로 막힌다.

🔴 스스로 빠져나올 수 없던 고리

실서버 심사위원 계정에서 AI 추천이 계속 0명이었다. 사용자가 그 이유를 물었고, 서버 코드를 읽어 후보 규칙을 확인했다 — 셋뿐이다.

  1. 이미 이 스쿼드에 앉은 사람 제외
  2. 이 팀의 현재 소속 제외
  3. 팀이 경기 시간을 등록해 뒀으면 그 시간과 겹치는 사람만 (안 등록했으면 이 필터는 통째로 건너뛴다)

그런데 그 시간을 고치는 자리가 「팀 매칭」 판 안에만 있었고, 그 판은 스쿼드가 다 차야 열린다. 3번에 걸려 후보가 0명이 되면 → 스쿼드를 못 채우고 → 고칠 판도 못 연다. 한 번 잘못 저장하면 화면 어디로도 되돌릴 수 없는 구조였다.

사용자가 그 고리를 짚어 줬다: 「심사위원계정에서 팀 매칭 누르려면 일단 AI 추천판에 더미가 보여야 … 더미부터가 없다」. 앞서 내가 「시간을 맞추거나 지우세요」라고 안내한 것은 틀렸다 — 지울 자리가 애초에 없었다.

프로필의 팀 「수정」에서 조건을 고칠 수 있게 했다(여기는 스쿼드가 비어도 열린다). 그리고 「시간 조건 지우기」를 따로 뒀다 — 조건 판의 「팀 찾기」는 지역 ≥ 1 && 시간 ≥ 1 이어야 눌려서 그 판으로는 시간을 없앨 수가 없다. 🔴 지역은 남긴다: 지역까지 지우면 우리 팀이 남의 「비슷한 팀」 후보에서도 빠진다.

경기 시각을 직접 고른다 — 시연을 위해

고를 수 있는 시각은 조건(요일+시간대)에서 「다음에 오는 그 요일」로 만들어진다. 조건이 토요일뿐이면 아무리 빨라도 다음 토요일이라, 대기 화면 → 경기 끝내기 → 리뷰를 한자리에서 보여 줄 수가 없었다.

신청 칸에 달력+시각을 더했다. 적으면 그 값이 제안을 이기고, 비우면 전처럼 제안을 쓴다 — 제안을 없애지 않는다. 평소에는 그쪽이 맞다.

⛔ 조건 판(「어떤 경기를 찾으세요?」)에는 달력을 안 넣었다. 거기는 실제 경기 시각이 아니라 반복되는 가능 시간이고, 계약도 weekday + start_time/end_time 으로 받는다.

🔴 덤으로 구멍 하나: 제안이 0개면 신청 칸이 통째로 닫혀 있었다. 그런데 시간 조건을 비우는 것은 위에서 만든 정상적인 길이라, 그 상태에서 경기를 못 걸면 앞뒤가 안 맞는다. 이제 제안이 없어도 직접 골라 신청된다.

그 밖에 — 사용자가 화면으로 잡아 준 것들

  • 수락한 사람이 자기 판에서 자기를 못 봤다. seatsFromSquad 의 세 단계가 전부 me 를 흘려보냈다 — 칸이 있어야 잡는 0단계, me 를 건너뛰는 1·2단계. 계약 60이 「수락하면 칸은 null 로 등재」를 만들면서 수락해서 들어온 사람 전부에게 걸리게 됐다. ⛔ 사용자의 추측(「팀원들이 다 팀장이 되어 겹쳐 있는 것 아니냐」)은 아니었다 — 수락은 role=MEMBER 로 만든다
  • 마지막 주장이 팀을 버릴 수 없었다. 계약은 2026-09-17에 이미 DELETE /teams/{id} 를 냈는데 화면이 그 길로 잇지를 않았다(미결 paik 35번). 나가기가 409 LAST_OWNER 로 막히면 그 자리에 해체를 낸다
  • 해체했는데 팀이 목록에 남았다. router.refresh() 하나로는 모자랐다 — 개발 모드에서 Next 는 라우트 핸들러와 서버 컴포넌트를 다른 모듈 그래프로 묶는다(1.11 회차가 적어 둔 함정). 화면이 결과를 직접 반영하게 고쳤다
  • 카드를 끌면 사람 사진이 따라 나왔다. .ss-squad-seat 주석은 「그림· 글자를 대신 끌어가지 않게 한다」인데 실제로 건 것은 user-select 뿐이고, 그건 글자 선택만 막는다. 주석과 구현이 어긋나 있어 둘 다 고쳤다
  • 오른쪽 MF 에 초대했는데 새로고침하면 왼쪽으로 갔다. 초대 본문에 칸이 없는 것이 뿌리다 — 수락될 때까지만 브라우저가 다리를 놓고, 제대로 된 자리는 미결 paik 44번으로 올렸다
  • 프로필에 로그아웃을 회원 탈퇴 옆에 뒀다(여태 홈 구석에만 있었다). 단추 간격은 두 번 만져서 왔다 — 붙어 있다고 벌렸더니 오른쪽 정렬이라 왼쪽 것만 멀리 나갔다

다음

  • 배포 — 백엔드 먼저. 그 뒤라야 사진 저장도, 위의 고리 끊기도 쓸 수 있다
  • 심사위원 계정의 추천 0명이 정말 시간 때문이었는지는 배포 뒤에 확정된다. 「수정」을 열면 지금 시간이 눈에 보인다 — 비어 있다면 원인은 2번(더미가 이미 팀원)이다
  • 미결 paik 35번(팀 해체)은 계약도 화면도 됐다 — 닫을 차례다