멋쟁이사자처럼 중앙 해커톤 출품작입니다. 짧은 기간 안에 팀에서 가장 복잡한 도메인을 맡았습니다. 사람이 세상을 떠난 뒤에야 실행되는 서비스라 되돌릴 방법이 없고, 순서 하나가 어긋나면 가족사진이 인계되기 전에 클라우드가 지워지는 식의 사고가 납니다.
그래서 “무엇을 만들까”보다 “무엇이 절대 일어나면 안 되는가”를 먼저 정하고 코드를 짰습니다. 이 프로젝트에서 상태 전이를 서버가 강제한다는 것이 무슨 뜻인지 처음으로 제대로 이해했습니다.
멋쟁이사자처럼 중앙 해커톤 · 팀 「늑대와 함께 춤을」
생전에 “가족사진은 아내가 보관하고, 민수에게 프로젝트를 넘긴 뒤 지수가 클라우드를 정리해 줬으면 한다”처럼 말로 남긴 뜻을, 담당자와 순서가 있는 인계 계획으로 바꿉니다. 앞 단계가 끝나기 전에는 다음 행동을 공개하지 않습니다.
멋쟁이사자처럼 중앙 해커톤 출품작입니다. 짧은 기간 안에 팀에서 가장 복잡한 도메인을 맡았습니다. 사람이 세상을 떠난 뒤에야 실행되는 서비스라 되돌릴 방법이 없고, 순서 하나가 어긋나면 가족사진이 인계되기 전에 클라우드가 지워지는 식의 사고가 납니다.
그래서 “무엇을 만들까”보다 “무엇이 절대 일어나면 안 되는가”를 먼저 정하고 코드를 짰습니다. 이 프로젝트에서 상태 전이를 서버가 강제한다는 것이 무슨 뜻인지 처음으로 제대로 이해했습니다.
사건(release-case)을 최상위 자원으로 두고 그 아래에 증빙·이의제기·단계·대기상태를 중첩했습니다. 로그인한 작성자가 쓰는 경로와, 이메일 링크로 들어오는 익명 접근자가 쓰는 경로를 분리한 것이 핵심입니다.
| 경로 | 접근 주체 | 역할 |
|---|---|---|
| /api/plans/{planId}/… | 작성자 (JWT) | 계획·생활영역·항목·담당자·확인자 관리 |
| /api/release-cases/{caseId}/evidence | 익명 토큰 | 증빙 제출 — 토큰은 경로가 아닌 멀티파트 본문으로 전달 |
| /api/release-cases/{caseId}/disputes | 익명 토큰 | 이의 제기, 접수 시 자동 발송 중지 |
| /api/release-cases/{caseId}/waiting | 링크 토큰 | 로그인 없이 대기 상태만 공개 조회 |
| /api/posthumous-access/{token} | 링크 토큰 + OTP | 사후 인계 링크 진입 → OTP 인증 |
| /api/partner/reviews/{reviewId} | 외부 파트너 | 할당된 증빙만 열람 — 파트너 간 접근 격리 |
| /api/admin/… | 관리자 | 감사 로그, 증빙 삭제 관리 |
권한 규칙은 SecurityConfig에서 익명 토큰(permitAll) → 작성자 본인(authenticated) → 관리자 전용 순으로 매칭되도록 정렬했습니다. 관리자 규칙이 먼저 잡히면 익명 접근자가 403을 받기 때문입니다.
운영 중 “OTP 인증 메일이 몇 초마다 계속 오고, 심지어 같은 코드가 반복된다”는 제보. 60초 쿨다운이 있는데도 발송이 멈추지 않았습니다.
sendOtp()가 행 잠금 없이 otpSentAt을 읽어, 동시 요청이 모두 갱신 전 값을 보고 쿨다운을 통과.SENT인 단계를 함께 넘겨, 발송 성공 후 stage.send()가 예외를 던짐.dispatchPending()이 배치 전체를 하나의 @Transactional로 묶어, 위 예외 한 건이 같은 배치에서 이미 성공한 다른 행의 markSent()까지 롤백. 행이 영원히 PENDING으로 남아 주기마다 같은 본문을 재발송.주석에는 “행 단위로 예외를 격리한다”고 적혀 있었지만 실제 코드에는 try/catch가 없었습니다. 같은 패턴의 EvidenceDeletionScheduler에는 격리가 구현돼 있어, 두 스케줄러를 비교해 차이를 확정했습니다.
findByTokenHashForUpdate()(PESSIMISTIC_WRITE)로 쿨다운 확인 구간만 직렬화 — 잠금이 불필요한 조회는 기존 그대로 유지dispatchOne()을 예외 격리 래퍼와 실제 로직으로 분리해, 실패 건은 로그·감사기록만 남기고 나머지는 커밋되도록 변경같은 토큰으로 여러 요청이 동시에 들어와도 메일은 한 번만 발송되고, 실패한 한 건이 같은 배치의 정상 건을 되돌리지 않게 됐습니다.
생전에 “담당자 역할을 수락해 달라”고 보낸 초대 링크의 토큰 하나가, 이후 사망 신고·증빙 제출·이의 제기 화면까지 전부 열 수 있었습니다. 만료도 폐기도 없는 사실상의 영구 키였습니다.
SecurityToken을 신설해 ACCEPT_ROLE / REPORT_DEATH / UPLOAD_EVIDENCE / RAISE_OBJECTION 네 목적을 분리하고, 각각 대상·사건에 바인딩inviteToken 필드를 제거하고, 각 단계가 완료되는 시점에 다음 목적의 토큰만 새로 발급 + 안내 메일 발송링크 하나가 열 수 있는 범위가 그 링크의 목적으로 좁혀졌고, 사용이 끝난 토큰은 즉시 무효가 됩니다.
담당자 초대·확인자 지정·사후 인계 발송 중 하나라도 해 본 계정은 탈퇴 시 DataIntegrityViolationException으로 실패했습니다. 신규 계정으로만 확인해 놓친 결함입니다.
추측 대신 pg_constraint를 조회해 운영 스키마의 FK 제약을 전수 확인했습니다. 전부 ON DELETE 지정이 없는(NO ACTION) FK였고, 자식 행을 지우지 않고 부모부터 삭제하는 지점이 5곳 있었습니다.
| 자식 테이블 | 부모 테이블 |
|---|---|
| email_outbox | email_logs |
| posthumous_access_tokens | handover_stages |
| package_action_completions | handover_stages |
| security_tokens | role_assignees 외 3 |
| evidence_download_tokens | evidences |
role_assignees.backup_for_id가 자기 자신을 가리키는 자기참조 FK여서, 대체 담당자를 주 담당자보다 먼저 지우도록 정렬 추가기본키가 Long/IDENTITY라 사후 인계 링크나 증빙 다운로드 주소에 순번이 그대로 드러났습니다. 값 하나를 알면 인접한 다른 사건도 추측할 수 있는 구조였습니다.
@Id를 UUID로 전환하고 Repository·Controller·DTO·JWT·저장소 키 생성까지 연쇄 영향 범위를 전부 반영식별자 추측으로 남의 사건에 접근할 여지를 없앴습니다.



화면은 프론트엔드 팀원이 구현했습니다. 저는 위 흐름의 서버 측 상태 전이·토큰 발급·메일 발송·증빙 접근 제어를 담당했습니다.