간편인증 API(서버 주도) 모드
간편인증(eziok 표준창) 결과 수신을 표준창 대신 간편인증-API(서버 주도) 로 진행하는 대체 경로입니다. 플래그 siv_api_flow 로 켜지며, off 상태(기본값)에서는 기존 표준창 경로가 한 줄도 바뀌지 않고 그대로 동작합니다. 도입 배경·검토한 대안은 ADR-0012, 전체 설계는 docs/superpowers/specs/2026-09-16-siv-api-mwv-design.md 를 참고하세요.
진입 조건
useFeatureFlag('siv_api_flow') && useIsExpoApp() 일 때만 이 경로로 진입합니다(apps/app/src/app/(certified-user-only)/assignment/_component/SimpleIdentityVerificationView.tsx). 브라우저·구버전(v1) native 셸은 non-http 스킴 가로채기를 보장할 수 없어 대상에서 제외하고 항상 표준창을 씁니다.
흐름
[웹 SIV 화면 (native 셸 안 WebView)]
│ 인증사 선택 → POST /v1/auth/simple-identity-verification/api/transactions
▼
[백엔드] ──get-token / auth-enc-request──▶ [eziok 허브]
│ 거래 저장(status=waiting) + Celery 폴러 예약(3초 뒤)
▼
201 { transactionId, appLink, expiresAt }
│
▼
[웹] window.location.href = appLink
│
▼
[native 셸: onShouldStartLoadWithRequest 가 non-http 스킴을 OS 로 위임] ──▶ 인증사 앱
│ (승인 후 자동 복귀 없음 — 사용자가 수동으로 앱 전환)
▼
[웹: SIV 화면 포그라운드 복귀 / 재진입]
│ 2초 간격 폴링(visible 일 때) + visibilitychange/focus 즉시 조회
▼
GET /v1/auth/simple-identity-verification/api/transactions/{id}
│ ▲
▼ │
{ status, failReason, expiresAt } ◀── DB ── [Celery 폴러] ──auth-result(3초 간격)──▶ [eziok 허브]
2800=대기 / 2000=완료(복호화+명의대조+로그저장+할일완료)
/ 1013=RESULT_CONSUMED(1회성 재조회)재진입(콜드스타트·페이지 재로드) 시에는 마운트 직후 GET .../transactions/active?assignment_id= 로 최근 6분 내 waiting 거래를 조회해 복원합니다(siv_api_resumed_waiting).
상태 기계
배선: apps/app/src/features/eziok/api/machine.ts 의 reduce()(순수 함수, React·I/O 없음. 테스트 machine.test.ts) + useSivApiTransaction.ts(효과 배선: 시작 호출·2초 폴링·재진입 복원·계측).
상태(phase.kind) | 의미 | 진입 | 다음 상태로의 전이 |
|---|---|---|---|
select | 인증사 선택 화면(초기 상태) | 최초 마운트, 또는 RETRY/CHANGE_PROVIDER | SELECT_PROVIDER → starting · RESUME_WAITING(재진입 시 활성 거래 있음) → waiting(appLink 는 빈 문자열) |
telco | PASS 통신사 선택 시트 | starting 에서 START_FAILED(code: SIV_TELCO_REQUIRED) | TELCO_SELECTED → starting(telco 포함해 재시작) · 시트 닫기(CHANGE_PROVIDER) → select |
starting | 서버에 거래 시작 요청 중 | select/telco 에서 인증사(+통신사) 선택 | START_OK → waiting · START_FAILED(SIV_TELCO_REQUIRED) → telco · START_FAILED(그 외 코드) → fallback |
waiting | 인증사 앱에서 승인 대기, 2초 폴링 중 | starting 의 START_OK, 또는 select 의 RESUME_WAITING | STATUS(completed) → success · STATUS(failed) → failed · STATUS(superseded) → failed(SUPERSEDED) · STATUS(expired) → expired · STATUS(waiting) → waiting(pollCount+1, 오류 카운터 리셋) · STATUS_ERROR → 아래 “폴링 오류” 참고 · TICK(now > expiresAt + 35초) → expired · RETRY/CHANGE_PROVIDER → select |
success | 인증 완료 | waiting 의 STATUS(completed) | 종단 상태 — 이후 어떤 이벤트에도 바뀌지 않음 |
failed | 인증 실패(명의 불일치 등) | waiting 의 STATUS(failed)·STATUS(superseded)·폴링 오류 종료 | RETRY/CHANGE_PROVIDER → select |
expired | 시간 초과 | waiting 의 STATUS(expired), 또는 TICK 유예 초과 | RETRY/CHANGE_PROVIDER → select |
fallback | API 경로 중단, 표준창으로 즉시 전환 | starting 의 START_FAILED(텔코·아이덴티티 사유 제외) | 이 화면은 아무것도 그리지 않는다 — 부모(SimpleIdentityVerificationView)가 표준창 뷰로 즉시 교체 |
waiting 의 RETRY/CHANGE_PROVIDER(“다른 방법으로 인증”)와 telco 의 CHANGE_PROVIDER(시트를 선택 없이 닫음)는 둘 다 select 로 돌아갑니다. telco 가 빠져 있으면 시트를 그냥 닫았을 때 빈 화면에 갇힙니다.
폴링 오류(STATUS_ERROR)는 두 갈래입니다. 404(SIV_TRANSACTION_NOT_FOUND)면 더 기다려도 상태가 생기지 않으므로 즉시 failed(NOT_FOUND) 로 끝내고, 그 외 오류는 consecutiveErrors 를 올려 연속 5회(≈10초)에서 failed(POLL_ERROR) 로 끝냅니다. 성공 응답 1회가 카운터를 0으로 되돌립니다.
이미 완료된 할 일로 재진입하면(assignment.isCompleted) 훅을 통째로 끄고(네트워크 호출 0) 성공 화면을 바로 보여줍니다 — 인증이 끝난 사용자에게 인증사 목록을 다시 띄우지 않기 위해서입니다.
API 모드에서는 표준창 경로의 절전모드 안내·반복 실패 카운터·siv_returned_without_result 판정을 하지 않습니다(서버가 상태를 쥐고 있어 해당하지 않음). 표준창 전용 localStorage 표식(eziok.pendingAuth)도 쓰지 않습니다 — 공유하면 표준창이 그 표식을 “결과 미도달”로 오탐합니다. native 셸에 보내는 브리지 신호(SIMPLE_IDENTITY_VERIFICATION_STARTED)만 표준창과 동일하게 남깁니다.
다만 API 경로가 막혀 표준창으로 폴백할 때는 표준창의 handleStartAuthClick() 을 그대로 호출합니다 — 안드로이드 절전모드 안내(HMH-8665)를 폴백 사용자만 건너뛰지 않게 하기 위해서입니다.
에러코드 → 화면 행동
| 에러코드 | HTTP | 화면 행동 |
|---|---|---|
SIV_API_DISABLED | 503 | 표준창으로 즉시 자동 폴백(킬스위치 off). 상태코드가 503 인데 에러코드가 SIV_ 로 시작하지 않으면(게이트웨이·인프라 503) 같은 취급으로 접습니다 |
SIV_IDENTITY_UNAVAILABLE | 409 | 안내 토스트(“저장된 본인 정보로 인증할 수 없어 기본 방식으로 진행해요”) 후 표준창 폴백 |
SIV_TELCO_REQUIRED | 422 | 통신사 선택 시트(TelcoSelectBottomSheet) 노출, 선택 후 재시작. 선택 없이 시트를 닫으면 인증사 선택(select)으로 복귀 |
SIV_PROVIDER_UNSUPPORTED | 422 | 표준창으로 폴백(BE 키 파일에 없는 인증사로 시작을 시도한 경우) |
SIV_HUB_ERROR | 502 | 표준창으로 폴백. ⚠️ 공유 fetcher 가 502 를 본문 파싱 전에 ApiServer502Error(errorCode='GATEWAY_TIMEOUT')로 던지므로 웹은 에러코드가 아니라 상태코드 502 로 판별합니다 |
SIV_TRANSACTION_NOT_FOUND | 404 | 즉시 실패(failReason='NOT_FOUND') 처리, 폴링 중단 — 본인 거래가 아니거나 이미 정리된 거래 |
| 폴링 연속 오류 5회 | — | 즉시 실패(failReason='POLL_ERROR') 처리, 폴링 중단. 무한 대기와 2초 간격 Sentry 플러드를 막습니다 |
| 그 외 5xx / 네트워크 오류 | — | 표준창으로 폴백(reason 은 http_<status> 또는 network) |
서버 상태가 superseded(다른 거래로 대체됨)로 오면 만료가 아니라 failed(SUPERSEDED) 로 처리하고 전용 문구(“다른 방법으로 인증을 다시 시작해서 이 인증은 종료됐어요”)를 보여줍니다.
폴백으로 이어지는 사유는 전부 siv_api_fallback_to_std(RUM)로 남습니다 — 조기 경보 지표입니다. 배선: packages/api/lib/auth/schema.ts(에러 타입) · apps/app/src/features/eziok/api/machine.ts 의 classifyStartError().
만료 유예 35초
서버 폴러 마감은 issued_at + 330초이고, 웹이 표시하는 expiresAt 은 issued_at + 300초입니다. 이 30초 차이를 그대로 두면 서버가 뒤늦게(300~330초 사이) 완료 처리하는 거래를 웹이 먼저 만료로 표시해 버리는 창이 생깁니다. 그래서 웹은 expiresAt + 35초(EXPIRY_GRACE_MS, apps/app/src/features/eziok/api/machine.ts)가 지나야 TICK 이벤트로 expired 전이합니다.
expiresAt 타임존 가정
백엔드가 expires_at 을 오프셋 없는 naive datetime 으로 직렬화할 수 있습니다. 그대로 Date.parse 하면 기기 로컬 타임존으로 해석돼, 비-KST 기기(해외 로밍·타임존 수동 변경)에서는 만료 시각이 몇 시간씩 어긋납니다(즉시 만료되거나 영영 안 끝남). 그래서 parseServerDateMs() 가 오프셋이 없으면 서버 TZ 인 +09:00 을 붙여 해석하고, 그래도 해석할 수 없으면 now + 300초(거래 TTL)로 대체합니다. BE 후속 과제로 expires_at 을 tz-aware 로 직렬화하면 이 보정은 no-op 이 됩니다.
지원 인증사
BE 키 파일에 키가 있는 6종만 노출합니다(apps/app/src/features/eziok/api/providers.ts 의 SIV_API_PROVIDERS, 이용 분포 순):
카카오톡(kakao) · 네이버(naver) · PASS(passauth, 통신사) · 토스(toss) · 신한 SOL(shinhan) · 삼성패스(signgate)
kbstar(KB모바일인증서)·banksalad(뱅크샐러드)·kebhana 는 벤더 키 확인 대기 중입니다. 목록에 없는 provider 로 거래 시작을 시도하면 BE 가 SIV_PROVIDER_UNSUPPORTED(422)로 빠르게 거부합니다(인증 완료 후 복호화 실패보다 빠른 실패).
롤백 3겹
배포 없이 되돌릴 수 있는 순서대로입니다.
- Datadog 플래그
siv_api_flowoff — 수 초 내 반영. 웹이 즉시 표준창 경로(기존 코드 그대로)로 돌아갑니다. - 백엔드 킬스위치
HMH_EZIOK_API_ENABLED=false(env) — 거래 시작 요청이SIV_API_DISABLED(503)로 거부되고, 웹이 같은 자리에서 표준창startAuth()로 자동 폴백합니다. 허브 오류·5xx·네트워크 오류도 같은 경로로 자동 폴백됩니다. vercel promote <직전 배포>(웹) / 백엔드 이전 배포로 롤백 — 코드 자체를 되돌립니다.
롤백 시점에 이미 waiting 상태인 거래는 백엔드 폴러가 마감(330초)까지 그대로 처리합니다. 표준창으로 넘어간 사용자의 재시도는 별개 거래입니다.
관측 쿼리
Datadog RUM 액션 기준(컨텍스트는 @context.*, 배선 apps/app/src/features/eziok/logging.ts):
- 종료 상태 분포:
@action.name:siv_api_terminal→ group by@context.status(success/failed/expired) - 실패 사유 분포:
@action.name:siv_api_terminal @context.status:failed→ group by@context.failReason - 폴백 조기 경보:
@action.name:siv_api_fallback_to_std→ group by@context.reason - 거래 시작 실패 사유:
@action.name:siv_api_start_failed→ group by@context.code - 도달률(시도 대비 완료):
@action.name:siv_api_start_ok건수 대비@action.name:siv_api_terminal @context.status:success건수
읽을 때 주의할 점:
siv_view_opened는@context.mode:api로 필터하세요.useFeatureFlag는 첫 렌더에 기본값(off)을 주고 provider 준비 후 재평가하므로, API 모드 콜드스타트에서 표준창 뷰가 한 번 마운트되며mode없는siv_view_opened가 1회 섞입니다. 필터하지 않으면 API 모드 진입 수가 최대 2배로 보입니다. (provider 준비 여부 훅으로 게이팅하는 방안은 자격증명이 없는 환경에서 영원히 false 가 되는 위험 때문에 채택하지 않았습니다 — Ruling W11.)waitedMs는 “이 화면이 기다린 시간”이지 “인증 시작 후 경과 시간”이 아닙니다. 재진입으로 복원된 거래는 복원 시점부터 셉니다(앱이 종료됐다 돌아온 구간은 빠짐).expiresAt은 서버가 오프셋 없는 KST 를 줄 수 있어 웹이+09:00을 가정합니다(위 “expiresAt타임존 가정”) —expired분포를 볼 때 감안하세요.
이벤트 전체 목록과 표준창 경로 이벤트와의 관계는 로깅 문서의 “사례 — 간편인증(eziok) 계측”을 참고하세요.
개인정보 경계
인증에 쓰는 이름·생년월일·휴대폰은 서버가 auth_identity_verification 에서 직접 읽어 허브 공개키로 암호화합니다. 표준창 프리필처럼 BFF 가 조회해 클라이언트로 내려보내는 경로 자체가 없습니다 — ADR-0008 이 다루는 프리필 문제(BFF↔클라이언트 경계)가 이 모드에서는 애초에 발생하지 않습니다. 웹은 인증 명의를 한 번도 받지 않습니다.
- 거래 시작 응답에는
appLink가 담깁니다 — 웹이 인증사 앱을 열어야 하는 유일한 의도적 예외입니다. 그 외 이름·생년월일·휴대폰 평문과 허브·서버 토큰은 어떤 응답에도 싣지 않습니다.appLink자체도 로그·RUM 컨텍스트·Sentry 에는 남기지 않습니다. 남기는 것은transactionId(서버 상관키)·providerId·상태·소요시간·에러코드뿐입니다. - 완료 처리(명의 대조·로그 저장·할 일 완료)는 표준창 경로와 동일한 애플리케이션 서비스를 공유합니다 — 명의 불일치는
failed(OWNER_MISMATCH)로 기록만 되고 아무것도 쓰이지 않습니다. - 백엔드 구조화 로그(
[eziok-api])에도 개인정보·토큰·링크는 남기지 않습니다. 실패 진단은cause(예:txid_mismatch/hub_token/aes/birthday)와 결과 JSON 키 이름만 남깁니다. 자세한 내용은 스펙docs/superpowers/specs/2026-09-16-siv-api-mwv-design.md§4.7 을 참고하세요.
스테이징 수동 검증 체크리스트
플래그를 켜기 전에 실기기에서 확인합니다(코드로 막을 수 없는 항목만 남겼습니다).
- dev 백엔드 응답의
expires_at원문에 오프셋(Z/+09:00)이 있는지 확인. 없으면 위+09:00가정이 실제로 동작하는 전제이므로, BE 가 tz-aware 로 바꾸는 순간 함께 재확인해야 합니다. - 비-KST 기기 1회(기기 타임존을 예: America/Los_Angeles 로 바꾸고) 대기 화면의 남은 시간이 정상(5분 부근에서 시작)인지 확인.
- 상태 조회를 강제로 404 로 만들었을 때(없는
transaction_id) 대기 화면이 즉시 실패로 끝나는지, 폴링이 멈추는지 확인. - 인증사 앱이 설치되지 않은 기기에서 인증사를 선택했을 때 대기 화면 문구가 오해를 주지 않는지 확인(앱 링크가 열리지 않고 화면만 남는 경로).
- PASS 선택 → 통신사 시트를 선택 없이 닫으면 인증사 선택 화면으로 돌아오는지 확인(그 뒤 다시 PASS 를 눌러도 시트가 정상 동작하는지까지).
- 인증 완료 후 화면을 닫았다가 같은 할 일로 재진입하면 인증사 목록이 아니라 완료 화면이 뜨는지 확인.