회원이 소속 A에서 이미 승인됐다고 하자. 소속 B의 이메일 인증을 새로 시작한 화면이 회원의 APPROVED만 읽으면 링크를 누르기 전에도 완료로 판단할 수 있다.
“유효한 소속이 있는가”와 “이번 요청이 성공했는가”는 다른 질문이다. 과거 승인을 지우는 대신 현재 시도를 별도로 표현해야 두 상태를 함께 유지할 수 있다.
화면이 기다리는 단위를 만든다
membership
user_id, organization_id, approval_status, valid_until
verification_attempt
attempt_id, user_id, target_organization_id,
purpose, state, expires_at, completed_at
membership은 권한의 지속 상태, attempt는 인증 과정의 짧은 생명주기를 나타낸다. 화면은 자신이 시작한 attempt_id를 기준으로 결과를 기다린다. 시도 조회에도 현재 사용자와 대상의 접근 검사가 필요하다.
기존 승인 상태를 유지하는 것이 문제가 아니다. 그 값을 새 요청 성공에 연결하는 분기가 문제다.
콜백의 증명을 확인한 뒤 대기 상태를 전환한다
이메일 링크나 외부 콜백은 시도·회원·대상·목적·만료와 연결한다. 식별자를 알고 있다는 것만으로 증명이 완료되지는 않는다. 서버가 서명·토큰 등 제공자의 증명을 검증한 뒤 상태를 바꾼다.
UPDATE verification_attempt
SET state = 'VERIFIED', completed_at = CURRENT_TIMESTAMP
WHERE attempt_id = :attempt_id
AND user_id = :verified_user_id
AND purpose = :expected_purpose
AND state = 'PENDING'
AND expires_at > CURRENT_TIMESTAMP
RETURNING attempt_id, target_organization_id;
검증된 사용자와 기대 목적을 서버가 제공한다는 전제다. 반환된 대상에 권한을 반영하는 작업은 같은 로컬 트랜잭션에서 수행한다. 0행이면 만료·취소·기처리·대상 불일치를 구분한다. 이미 처리된 정상 콜백에는 저장된 결과를 반환하는 등 재전송 정책을 정한다.
이 조건부 UPDATE는 인증 프로토콜의 CSRF·재전송 방어를 대신하지 않는다. OAuth에서는 요청·응답 연결과 발급자 등 프로토콜 검증도 적용한다. RFC 9700의 보안 권고와 업무 시도 상태를 구분한다.
늦은 응답이 새 화면을 바꾸지 못하게 한다
사용자가 새 인증을 시작하면 이전 polling과 현재 활성 시도를 갱신한다.
if (response.attemptId !== activeAttemptId) return;
switch (response.state) {
case "VERIFIED":
showVerifiedResult();
break;
case "EXPIRED":
showRetryAction();
break;
case "PENDING":
keepWaiting();
break;
}
응답 계약을 검증한 뒤 적용하는 화면 분기 예시다. 이전 시도의 결과를 무시해 표시가 섞이지 않게 한다. 취소·오류 같은 실제 상태도 계약에 맞게 처리한다. 클라이언트 대기 종료는 서버의 인증 실패를 확정하지 않으며 새 권한은 서버의 검증 결과로만 부여한다.
동시에 여러 시도를 허용할지도 정한다. 새 시도에서 이전 것을 취소할지, 목적·대상이 다르면 병행할지에 따라 서버 전이 조건이 달라진다. 시각이 가장 최신이라는 이유만으로 콜백의 대상을 고르지 않는다.
검증에서는 기존 승인 회원의 새 인증, 두 탭, 만료 링크, 중복 콜백, 취소 뒤 늦은 응답을 나눈다. 현재 시도가 검증되기 전 새 대상의 권한이 생기지 않는지 확인한다. 다음 판단은 회원 정보를 더 자주 읽는 대신 이번 결과를 어느 시도와 연결했는지에 둔다.
