본문으로 건너뛰기

2026-06-11 직원 초대 사고 체인 회고

Tier: B (production 사용자 차단 — 단일 기능 체인, 데이터 손실 없음) · 발단: 사용자 제보 1건 ("초대 메일 버튼 클릭 → 초대 링크 오류") · 결과: 연쇄 분석으로 서로 다른 결함 4건 발견·수정 (PR #2747 · #2748 · #2749 · #2750) + ops 운영 패널 2종 (#2751)

요지

직원 초대 메일의 "초대 수락하기" 클릭이 "유효하지 않은 초대 링크" dead-end 로 끝난다는 제보 하나에서 출발해, production Firestore·App Hosting API 를 직접 조회하며 원인을 추적했다. 표면 원인 (재발송의 토큰 회전) 아래에서 세 개의 독립 결함이 연달아 드러났다: 감사 로그의 전면 silent drop, 배포 skew 의 영어 raw 에러 노출, 구글 전용 계정의 수락 불가. 네 결함 모두 "조용히 실패하는" 패턴이었고, 단 하나도 기존 테스트·모니터링에 잡히지 않았다.

사고 1 — 재발송 토큰 회전 → 옛 메일 링크 dead-end (#2747)

증상: 수신자가 초대 메일의 버튼 클릭 → /staff-invite 가 "유효하지 않은 초대 링크입니다."

Root cause:

  • #2563 의 재발송 분기가 inviteToken/inviteCode새 값으로 회전 ("옛 링크 무효화 = 의도된 동작" 주석까지 존재)
  • 6/10 낮 초대 발송 → 밤 23:30 재발송 (resentAt 스탬프 실측) → 수신자는 첫 메일 클릭 → URL 토큰 ≠ DB 현재 토큰
  • 검증 쿼리가 status=="pending" 필터를 포함해 "수락 완료 재클릭"·"회전된 링크"·"가짜 링크" 가 전부 같은 한 줄 에러

Fix: 재발송 = 기존 토큰·코드 재사용 + 만료 연장 (수신함의 모든 메일이 유효). 링크 무효화의 명시 경로는 "구성원 제거 → 재초대". 검증 액션은 token-only 조회 후 errorCode (invalid/expired/accepted) 구조화 반환으로 분기 안내.

교훈: "재발송" 의 사용자 의도는 같은 초대를 다시 이지 기존 링크 폐기 가 아니다. 토큰류 재발급 설계 시 수신함에 남아 있는 옛 사본의 운명을 항상 따질 것.

사고 2 — caseless 활동 로그 전멸 (#2748)

증상: 사고 1 분석 중 — 초대·재발송이 4회 실행된 tenant 의 activityLogs 컬렉션이 아예 존재하지 않음.

Root cause:

  • writeActivityLog 가 caseNumber 미존재 시에도 caseNumber: undefined 를 명시 필드로 add() 에 전달
  • Admin SDK (ignoreUndefinedProperties 미설정) 가 동기 throw → bare catch {} 가 삼킴
  • caseId 없는 모든 활동 (member_invited/removed, role_changed, 사무소 설정 등) 이 production 에서 단 한 건도 기록된 적 없음. #2564 가 연결한 멤버 감사 로그도 실제로는 무동작
  • 단위 테스트의 mock add 가 undefined 를 수용해 결함이 비가시

Fix: payload 의 undefined 필드 전수 strip + catchconsole.error (ADR 0029 — fire-and-forget 계약은 유지하되 silent swallow 금지). 회귀 가드는 실제 SDK 처럼 undefined 를 거부하는 mock 으로 작성.

교훈: mock 이 실 SDK 의 제약 (undefined 거부) 을 흉내 내지 않으면 이 결함 클래스는 영원히 안 보인다. fire-and-forget 쓰기일수록 실패 관측 수단이 필수.

사고 3 — 배포 skew Server Action 404 영어 raw 노출 (#2749)

증상: 설정 화면에서 구성원 "제거" 클릭 → Server Action "408feb…" was not found on the server 영어 raw 에러 토스트.

Root cause:

  • 배포 (rollout 00:23 KST) 이전에 열려 있던 탭이 옛 빌드의 Server Action ID 를 보유 → 새 서버가 404
  • Next.js 액션 ID 는 빌드마다 회전 — 배포할 때마다 모든 열린 탭에서 재발하는 클래스 (변호사처럼 탭을 종일 열어두는 사용 패턴에 치명적)
  • 부수 발견: 같은 날 rollout 두 번 모두 직후 머지 커밋을 누락 (merge ≠ deploy, 수동 rollout 정책) — "fix 머지됨" 과 "fix 배포됨" 의 갭이 분석을 혼란시킴

Fix: isStaleServerActionError 감지 → toErrorMessage 가 "새 버전이 배포되어 … 새로고침" 한국어 안내로 전체 대체 + useServerAction 은 새로고침 원클릭 버튼 토스트. 자동 reload 는 의도적으로 배제 (편집기 미저장 상태 파괴 위험). 근본 완화 (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 고정) 는 후속.

교훈: merge ≠ deploy 환경에서는 "지금 무엇이 서빙 중인가" 가 일급 운영 정보다 → ops /health 배포 현황 패널 신설 (#2751).

사고 4 — 구글 전용 계정의 수락 불가 dead-end (#2750)

증상 (사용자 질문 "구글로 로그인을 이미 진행한 직원이면?" 에서 발견): 구글 로그인으로만 가입된 계정은 password provider 가 없어 — createUseremail-already-in-use → 비밀번호 로그인 → invalid-credential"비밀번호가 올바르지 않습니다" 오안내. 탈출구인 온보딩 초대 코드는 메일에 실리지 않아 실질 도달 불가.

Fix: ① "Google 계정으로 계속하기" (팝업 + 이메일 일치 선검사 + 서버 가드) ② 초대 이메일로 이미 로그인된 세션이면 비밀번호 없이 원클릭 수락 ③ 에러 카피에 구글 경로 안내. 부수: acceptStaffInviteByTokenAction 을 throw → ActionResult 전환 — Next.js production 은 Server Action throw 메시지를 generic 영어로 마스킹 (digest 만 전달) 하므로 사용자 노출 거부 사유는 구조화 반환이 필수.

교훈: 인증 수단이 N 개면 수락/가입 흐름도 N 개 경로를 모두 정의해야 한다. "비밀번호가 올바르지 않습니다" 는 비밀번호가 존재하는 계정에만 옳은 카피.

횡단 교훈 — 네 결함의 공통 형태

  1. 전부 "조용한 실패": dead-end 에러 한 줄 (사고 1·4), 빈 catch (사고 2), 마스킹된 메시지 (사고 3·4). fail-loud (ADR 0029) 는 서버 초기화만의 문제가 아니라 사용자 경로의 에러 카피까지 포함한다.
  2. 테스트가 있어도 mock 충실도가 낮으면 무용: 사고 2 의 mock add, 사고 4 의 "use server" throw 계약 모두 단위 테스트가 통과 중이었다.
  3. production 직접 조회가 가장 빠른 진단: Firestore 문서 (토큰 비교 · resentAt) 와 App Hosting API (rollout 시각 · build 커밋) 실측이 가설을 즉시 확정했다.
  4. 운영 가시성의 공백이 사고를 키운다: stuck 초대 (사고 1) 와 배포 드리프트 (사고 3) 는 ops 패널이 있었다면 사용자 제보 전에 보였을 신호 → /health 운영 패널 2종으로 회수.

재발 방지 체계

레이어장치PR
코드재발송 토큰 보존 + errorCode 구조화 + ActionResult 전환#2747 · #2750
코드writeActivityLog undefined strip + fail-loud catch#2748
UX배포 skew 한국어 안내 + 새로고침 CTA#2749
ops/health 배포 현황 (서빙 커밋·드리프트 P1) + 초대 헬스 (stuck 3일+)#2751
테스트SDK-충실 mock (undefined 거부) + e2e 3경로 (비밀번호·세션 원클릭·기존 회귀)#2748 · #2750 · #2751
docs직원 초대 runbook + 배포 & 릴리스 배포 후 체크리스트본 PR

미결 후속

  • NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 고정 — 액션 ID 의 빌드 간 안정화 (Secret Manager + apphosting.yaml, 별도 작업)
  • 초대 메일·구성원 목록에 초대 코드 병기 — 온보딩 코드 경로를 실제 탈출구로 (보조)