Skip to content

플레이 링크 공유가 두 번째부터 안 되는 문제 - POST 멱등화 + 만료 후 재발급 #980

Description

@sevineleven

증상

"토너먼트 공유하기가 두 번째부터 안 된다."

서버·클라 양쪽을 따라가 원인을 확정했다. 서버 계약 문제 둘, 클라 상태 관리 문제 둘이 겹쳐 있다. 서버만 고쳐도 증상은 사라지지만, 클라에 남는 별개 버그가 하나 더 있다.


원인

C1. 클라가 POST 후 상태를 갱신하지 않는다 (← 질문한 그 증상)

PlateShareDialog 는 이미 가드를 걸고 있다. 무작정 POST 하지 않는다.

// PlateShareDialog.tsx
const hasExistingPlayLink = Boolean(initialPlayLinkExpiresAt);

const handleSendPlayLink = async () => {
  if (!hasExistingPlayLink) {
    try { await postPlayLinkMutation(); }
    catch { toast.warning('공유 링크를 생성하지 못했어요. 다시 시도해주세요.'); return; }
  }
  await share({ url: buildPlayLinkUrl(tournamentId) });

문제는 initialPlayLinkExpiresAt 이 갱신되지 않는다는 것이다. usePostPlayLinkonSuccessinvalidateQueries 도 없다.

// usePostPlayLink.ts — 전문
export const usePostPlayLink = (tournamentId: number) => {
  const { mutateAsync: postPlayLinkMutation, isPending: isPostPlayLinkPending } = useMutation({
    mutationFn: () => postPlayLink(tournamentId),
  });
  return { postPlayLinkMutation, isPostPlayLinkPending };
};

그래서 이렇게 된다.

initialPlayLinkExpiresAt 동작 결과
1회차 공유 undefined POST → 200 정상
2회차 공유 (같은 세션) 여전히 undefined POST → 409 catch공유 중단

토스트는 "다시 시도해주세요" 라고 하지만, 다시 눌러도 계속 409 다. 페이지를 새로고침해 playLinkExpiresAt 을 다시 받아와야만 풀린다.

C2. 만료를 보지 않아 죽은 링크를 그대로 공유한다 (별개 버그)

Boolean(initialPlayLinkExpiresAt) 은 값의 존재만 본다. 시각을 비교하지 않는다.

14일이 지나 링크가 만료돼도 컬럼에는 과거 시각이 남아 있으므로 hasExistingPlayLink === true 가 되고, 클라는 POST 를 건너뛰고 죽은 링크를 그대로 공유한다. 주최자는 "링크를 성공적으로 공유했어요" 토스트까지 받는다.

받은 사람만 만료 에러를 본다. 주최자는 자기 링크가 죽은 줄 모른다.

S1. 만료돼도 재발급할 수 없다 (서버, 버그)

생성 가드와 유효성 검사가 서로 다른 것을 본다.

코드 무엇을 보나
생성 가드 tournament.playLinkExpiresAt?.let { throw playLinkAlreadyCreated() } (TournamentService.kt:913) null 인지 아닌지만
유효성 검사 now().isBefore(expiresAt) (Tournament.isPlayLinkValid) 시각 비교

playLinkExpiresAt 을 다시 null 로 되돌리는 코드도 없다 — 전수 조회 결과 쓰기는 두 곳뿐이고(createPlayLink 는 미래 시각, expirePlayLink 는 과거 시각), 정리 배치도 스케줄러도 없다.

그래서 14일(PLAY_LINK_DURATION_DAYS)이 지나면 그 토너먼트는 다시 공유할 수 없는 상태로 굳는다.

15일째, 주최자가 강제로 재발급을 시도하면

POST /{id}/play-link       →  409 TOURNAMENT-025 "이미 플레이 링크가 만들어진 토너먼트예요"
GET  /{id}/play-link-info  →  409 TOURNAMENT-027 "플레이 링크가 만료됐어요"

양쪽 다 막히고 두 문구가 서로를 부정한다. 마이그레이션 주석(V20260602220224)에는 NULL이면 플레이 링크 미생성 상태 라고 적혀 있는데, 한번 생성되면 그 상태로 돌아갈 수 없다.

유효기간이 링크를 죽이는 데는 쓰이고 되살리는 데는 안 쓰인다.

S2. 같은 개념인데 두 클론 경로의 계약이 다르다

경로 "이미 내 클론 있음" 코드
소셜 초대 클론 (멤버가 시작) 409 TOURNAMENT-029 ALREADY_CLONED TournamentService.kt:285
플레이 링크 클론 200 + 기존 클론 id (get-or-create) TournamentService.kt:952-960

둘 다 ownerTournamentUserId 기준으로 판별하는 같은 로직인데 한쪽만 예외를 던진다. 클라는 같은 개념을 두 방식으로 처리해야 한다.


상태 정리

playLinkExpiresAt 한 컬럼이 네 상태를 표현한다.

상태 컬럼 detail 응답 클라 판정 지금 POST 목표 POST
미생성 NULL 키 생략 없음 → POST 200 그대로
유효 미래 미래 시각 있음 → skip 409 -025 200 + 기존 만료시각
자연 만료 과거 과거 시각 있음 → skip (C2) 409 -025 200 + 새 14일
주최자 탈퇴 과거 (조회 불가) 403 403 유지

playLinkExpiresAt@JsonInclude(NON_NULL) 이라 미생성이면 키가 통째로 생략된다(null 이 아니다). 클라 타입도 playLinkExpiresAt?: string 로 optional 이다.


서버에서 할 것

동작

POST /{id}/play-link 를 항상 200 으로 만든다.

// 지금 — 값이 있으면 무조건 막는다
tournament.playLinkExpiresAt?.let { throw TournamentException.playLinkAlreadyCreated() }

// 목표 — 아직 살아 있으면 기존 값을 그대로, 죽었으면 새로 발급
if (tournament.isPlayLinkValid()) return tournament.playLinkExpiresAt!!
tournament.createPlayLink(now + PLAY_LINK_DURATION_DAYS)

유효한 링크는 연장하지 않는다. 공유 버튼을 다시 누른 것만으로 남의 접근 기간이 늘면 주최자가 의도하지 않은 노출이 생긴다. 갱신은 만료된 경우에만 한다.

Tournament.createPlayLinkcheck(isCompleted()) 는 그대로 둔다 — 재발급 시점에도 COMPLETED 이므로 통과한다.

이 변경만으로 C1 이 사라진다. 클라가 stale 한 상태로 POST 를 다시 보내도 200 이 오고 공유가 진행된다.

반드시 확인할 것

주최자 탈퇴 경로가 "만료됐으니 갱신" 분기를 타면 안 된다. COMPLETED 토너먼트에서 주최자가 나가면 expirePlayLink() 로 과거 시각이 박히는데(TournamentService.kt:831), 이 값은 자연 만료와 구분되지 않는다.

같은 트랜잭션에서 주최자 TU 를 소프트 삭제하므로 createPlayLink 앞단의 findByTournamentIdAndUserId 가 못 찾아 forbiddenTournament() 로 먼저 걸릴 것으로 보인다. 추론이고 실측하지 않았다. 테스트로 고정한 뒤 진행한다.

가드가 안 걸리면 두 만료를 구분할 장치가 필요하다 (play_link_revoked_at 컬럼 추가, 또는 상태 enum 승격). 가드가 확인되면 하지 않는다.

에러 코드

  • TOURNAMENT-025 PLAY_LINK_ALREADY_CREATED 는 더 이상 발생하지 않는다. 번호는 재사용하지 않고 결번으로 남긴다(코드가 클라 계약).
  • TOURNAMENT-026 · TOURNAMENT-027GET /play-link-infoPOST /from-play-link 에서 계속 쓰인다. 유지한다.

문서 (TournamentApi)

지금은 아래가 어디에도 없어 클라가 서버 코드를 읽어야 알 수 있다.

  • 플레이 링크는 토너먼트당 하나이고 주소가 tournamentId 로 결정된다(토큰 없음). 여러 개를 만들 수 없다
  • 참여 인원 제한이 없다. 링크 하나로 들어온 사람마다 클론이 하나씩 생긴다
  • 같은 사람이 여러 번 들어와도 자기 클론 하나로 이어진다(get-or-create)
  • 만료 후 다시 부르면 새 기간으로 갱신된다
  • 공유 버튼은 sourceTournamentId == null && isOwner 일 때만 노출한다
  • playLinkExpiresAtNON_NULL 이라 미생성이면 키가 생략된다
  • 초대 코드(시작 전, 30분, 같이 진행)와 플레이 링크(완료 후, 14일, 각자 플레이)는 다른 기능이고 동시에 열리는 구간이 없다

@ApiResponsesTournamentApiExamples 에서 사라진 409 를 함께 정리한다.

검증

  • 만료된 링크에 POST → 200 + 새 만료시각. 현재 코드에서 반드시 실패해야 한다 (negative control)
  • 유효한 링크에 POST → 200 + 기존 만료시각 그대로(연장 안 됨)
  • 연속 POST 2회 → 둘 다 200, 같은 만료시각 (C1 재현 방지)
  • 주최자 탈퇴로 무효화된 토너먼트에 POST → 403. 갱신되지 않는다
  • 클론에서 POST → 403 TOURNAMENT-024 (유지)
  • 주최자 아닌 참여자가 POST → 403 (유지)
  • 만료 후 재발급한 링크로 POST /from-play-link → 새 클론 생성. 만료 기간에 이미 클론을 만든 사람은 그 클론으로 이어진다

클라에서 할 것

서버 수정 후 가장 단순한 형태는 가드를 없애고 항상 POST 하는 것이다. 멱등해지므로 분기가 필요 없고, C1·C2 가 한 번에 사라진다.

// 목표
const handleSendPlayLink = async () => {
  try { await postPlayLinkMutation(); }          // 항상 호출 — 미생성이면 생성, 만료면 갱신, 유효면 그대로
  catch { toast.warning('...'); return; }
  await share({ url: buildPlayLinkUrl(tournamentId) });

postPlayLink.ts 의 주석이 이미 플레이 링크 생성/갱신 이라고 적혀 있다. 클라는 처음부터 이 계약을 기대하고 있었고, 서버만 그렇지 않았다.

서버 수정 전에 먼저 할 수 있는 것도 있다.

  • usePostPlayLinkonSuccess 로 토너먼트 상세 쿼리 무효화 또는 로컬 상태 갱신 → C1 완화
  • hasExistingPlayLinknew Date(initialPlayLinkExpiresAt) > new Date() 로 → C2 완화. 다만 만료 시 POST 하면 지금은 409 라, 서버 수정이 선행돼야 실효가 있다
  • 유효한 링크일 때 남은 기간("n일 남음")을 다이얼로그에 표시 → 아래 P2 완화. 서버 수정과 무관하게 지금 가능

유저 플로우

정상 흐름

[주최자]
1. 토너먼트 완료
2. 결과 화면에서 "공유하기"
3. 링크 복사 → 단톡방에 뿌림
                                   [받은 사람 — 회원·게스트 모두 가능]
                                   4. 링크 클릭 (/play/{tournamentId})
                                   5. 미리보기: 토너먼트 이름 + 아이템 수
                                   6. "나도 해보기"
                                   7. 내 판이 새로 생김 (새 tournamentId)
                                   8. 1:1 대결 진행 → 내 1등
[주최자]
9. "친구 결과 보기" → 모두의 1등 비교

7 에서 번호가 바뀌는 게 정상이다. 원본에 참여하는 게 아니라 각자 자기 판이 복사된다. 각자 대진표·기록·1등이 따로 있어야 9번 비교가 성립하기 때문이다. 복사본은 원본 아이템을 빌려 쓰고 아이템 추가가 막혀 있다(TOURNAMENT-032) — 후보가 달라지면 비교가 무의미해진다. 복사본 화면에서 원본을 가리키는 값은 응답의 sourceTournamentId 이고, 그룹 결과는 이 id 로 조회한다.

지금 유저가 막히는 지점

P1 — 같은 화면에서 두 번째 공유가 안 된다 (C1 + S1)
공유하고 나서 다이얼로그를 다시 열어 공유하면 "공유 링크를 생성하지 못했어요. 다시 시도해주세요" 가 뜨고 공유 자체가 중단된다. 다시 눌러도 같다. 새로고침해야 풀린다. 안내 문구가 사실과 다르다 — 다시 시도해도 안 된다.

P2 — 단톡방에 뿌린 링크가 조용히 죽는다 (C2)
14일 뒤 링크는 만료되는데 주최자에게 아무 신호가 없다. 만료 임박 표시도, 만료 알림도 없다. 그 상태에서 다시 공유하면 클라가 만료를 안 보고 죽은 링크를 "성공적으로 공유했어요" 와 함께 다시 뿌린다. 주최자는 "왜 아무도 안 들어오지" 라고만 생각하게 된다.

P3 — 만료된 링크를 받은 사람에게 해줄 안내가 없다 (S1, 가장 아픈 지점)
링크를 열면 409 PLAY_LINK_EXPIRED 다. 자연스러운 안내는 "주최자에게 다시 공유해 달라고 하세요" 인데, 주최자는 S1 때문에 다시 만들 수가 없다. 안내가 거짓말이 된다. 남는 선택지는 "새 토너먼트를 처음부터 다시 만드세요" 뿐이고, 그러면 아이템을 전부 다시 담아야 한다.

P4 — 같은 개념인데 처리 방식이 두 가지다 (S2)
"이미 내 판이 있다" 를 소셜 초대 경로는 409 로, 플레이 링크 경로는 200 으로 답한다. 클라는 같은 화면을 두 코드로 분기해야 한다.

고치고 나면

  • 공유 버튼은 하나가 된다. 상태와 무관하게 POST 한 번, 응답의 만료시각을 그대로 쓴다
  • 만료된 링크는 "다시 공유하기" 로 새 14일을 받는다. P1·P3 이 함께 사라진다
  • 만료 임박 표시를 더하면 P2 도 닫힌다
  • GET /play-link-info받은 사람의 미리보기(이름·아이템 수) 용도로 역할이 정리된다

함께 정할 것 (범위 밖일 수 있음)

  • 만료 임박 알림 — 서버가 도울지, 클라 표시로 충분한지
  • 소셜 클론 경로도 멱등으로 맞출지 (P4). 맞추면 TOURNAMENT-029 도 결번 처리 대상이 된다

배포 순서

동시 배포가 필요하지 않다. 서버를 먼저 내보내는 게 맞고, 그것만으로 신고된 증상(P1)이 사라진다.

변경 선행 조건 단독 배포 시
서버 멱등화 (S1) 없음 안전. C1 이 저절로 해소된다
클라 C1 (POST 후 무효화) 없음 안전. 서버와 무관하게 C1 해소
클라 C2 (만료 검사 / 가드 제거) 서버 선행 필수 회귀

서버만 먼저 나가는 경우 — 안전

지금 클라 동작이 그대로여도 깨지는 데가 없다. 바뀌는 건 409 이던 응답이 200 이 되는 것뿐이고, 클라는 POST 응답값을 쓰지 않는다(await postPlayLinkMutation(); 로 버린다). 응답 셰입도 그대로다.

케이스 지금 서버만 고친 뒤
미생성 → POST 200 → 공유 그대로
유효 → skip POST 공유 그대로
2회차 (stale) → POST 409 → 공유 중단 200 → 공유됨
만료 → skip POST 죽은 링크 공유 그대로 (C2 는 클라 몫)

즉 서버 단독 배포는 개선만 있고 회귀가 없다.

클라만 먼저 나가면 안 되는 것

가드를 없애고 항상 POST 하는 버전(C2 의 최종형)을 서버보다 먼저 내보내면, 지금 잘 되던 케이스가 깨진다.

유효한 링크 보유 → POST → 409 → catch → 공유 중단

가장 흔한 케이스가 회귀한다. 만료 검사만 추가하는 중간형도 마찬가지로, 만료 시 POST 가 409 로 떨어져 막다른 길이 된다.

반면 C1(무효화 추가)은 단독으로 안전하다. 가드 구조를 그대로 두고 상태만 갱신하므로 POST 가 한 번만 나간다.

롤백 주의

클라 C2 가 나간 뒤에 서버를 롤백하면 유효·만료 케이스가 409 로 깨진다. 클라 C2 배포 이후로는 서버 롤백이 불가능하다고 보고, 서버 변경을 충분히 검증한 뒤 클라를 붙인다.

마이그레이션

현재 설계엔 스키마 변경이 없다. 다만 위 "반드시 확인할 것" 에서 주최자 탈퇴 가드가 안 걸리는 것으로 드러나면 play_link_revoked_at 컬럼이 필요해진다. 그 경우 nullable 컬럼 추가라 additive 이고, 마이그레이션 → 서버 → 클라 순으로 나가면 된다.

권장 순서

  1. 서버 (멱등화 + 문서 + 테스트) — 단독 배포. 여기서 P1·P3 이 닫힌다
  2. 클라 (가드 제거, 항상 POST) — P2 까지 닫힌다
  3. 만료 임박 표시는 아무 때나 (서버 무관)

관찰 (범위 밖)

플레이 링크에 토큰이 없어 주소를 추측할 수 있다. tournamentId 가 순번이라, 공유를 켜둔 토너먼트는 id 를 바꿔가며 들어올 수 있다. 널리 뿌리는 게 목적인 링크라 당장 문제로 보지는 않지만, 비공개 공유가 요구사항이 되면 토큰 도입이 필요하다.

Metadata

Metadata

Assignees

Labels

fix외부 가시적 결함 수정

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions