Skip to Content
Certificate간편인증 API(서버 주도) 모드

간편인증 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.tsreduce()(순수 함수, React·I/O 없음. 테스트 machine.test.ts) + useSivApiTransaction.ts(효과 배선: 시작 호출·2초 폴링·재진입 복원·계측).

상태(phase.kind)의미진입다음 상태로의 전이
select인증사 선택 화면(초기 상태)최초 마운트, 또는 RETRY/CHANGE_PROVIDERSELECT_PROVIDERstarting · RESUME_WAITING(재진입 시 활성 거래 있음) → waiting(appLink 는 빈 문자열)
telcoPASS 통신사 선택 시트starting 에서 START_FAILED(code: SIV_TELCO_REQUIRED)TELCO_SELECTEDstarting(telco 포함해 재시작) · 시트 닫기(CHANGE_PROVIDER) → select
starting서버에 거래 시작 요청 중select/telco 에서 인증사(+통신사) 선택START_OKwaiting · START_FAILED(SIV_TELCO_REQUIRED)telco · START_FAILED(그 외 코드) → fallback
waiting인증사 앱에서 승인 대기, 2초 폴링 중startingSTART_OK, 또는 selectRESUME_WAITINGSTATUS(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_PROVIDERselect
success인증 완료waitingSTATUS(completed)종단 상태 — 이후 어떤 이벤트에도 바뀌지 않음
failed인증 실패(명의 불일치 등)waitingSTATUS(failed)·STATUS(superseded)·폴링 오류 종료RETRY/CHANGE_PROVIDERselect
expired시간 초과waitingSTATUS(expired), 또는 TICK 유예 초과RETRY/CHANGE_PROVIDERselect
fallbackAPI 경로 중단, 표준창으로 즉시 전환startingSTART_FAILED(텔코·아이덴티티 사유 제외)이 화면은 아무것도 그리지 않는다 — 부모(SimpleIdentityVerificationView)가 표준창 뷰로 즉시 교체

waitingRETRY/CHANGE_PROVIDER(“다른 방법으로 인증”)와 telcoCHANGE_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_DISABLED503표준창으로 즉시 자동 폴백(킬스위치 off). 상태코드가 503 인데 에러코드가 SIV_ 로 시작하지 않으면(게이트웨이·인프라 503) 같은 취급으로 접습니다
SIV_IDENTITY_UNAVAILABLE409안내 토스트(“저장된 본인 정보로 인증할 수 없어 기본 방식으로 진행해요”) 후 표준창 폴백
SIV_TELCO_REQUIRED422통신사 선택 시트(TelcoSelectBottomSheet) 노출, 선택 후 재시작. 선택 없이 시트를 닫으면 인증사 선택(select)으로 복귀
SIV_PROVIDER_UNSUPPORTED422표준창으로 폴백(BE 키 파일에 없는 인증사로 시작을 시도한 경우)
SIV_HUB_ERROR502표준창으로 폴백. ⚠️ 공유 fetcher 가 502 를 본문 파싱 전에 ApiServer502Error(errorCode='GATEWAY_TIMEOUT')로 던지므로 웹은 에러코드가 아니라 상태코드 502 로 판별합니다
SIV_TRANSACTION_NOT_FOUND404즉시 실패(failReason='NOT_FOUND') 처리, 폴링 중단 — 본인 거래가 아니거나 이미 정리된 거래
폴링 연속 오류 5회즉시 실패(failReason='POLL_ERROR') 처리, 폴링 중단. 무한 대기와 2초 간격 Sentry 플러드를 막습니다
그 외 5xx / 네트워크 오류표준창으로 폴백(reasonhttp_<status> 또는 network)

서버 상태가 superseded(다른 거래로 대체됨)로 오면 만료가 아니라 failed(SUPERSEDED) 로 처리하고 전용 문구(“다른 방법으로 인증을 다시 시작해서 이 인증은 종료됐어요”)를 보여줍니다.

폴백으로 이어지는 사유는 전부 siv_api_fallback_to_std(RUM)로 남습니다 — 조기 경보 지표입니다. 배선: packages/api/lib/auth/schema.ts(에러 타입) · apps/app/src/features/eziok/api/machine.tsclassifyStartError().

만료 유예 35초

서버 폴러 마감은 issued_at + 330초이고, 웹이 표시하는 expiresAtissued_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.tsSIV_API_PROVIDERS, 이용 분포 순):

카카오톡(kakao) · 네이버(naver) · PASS(passauth, 통신사) · 토스(toss) · 신한 SOL(shinhan) · 삼성패스(signgate)

kbstar(KB모바일인증서)·banksalad(뱅크샐러드)·kebhana 는 벤더 키 확인 대기 중입니다. 목록에 없는 provider 로 거래 시작을 시도하면 BE 가 SIV_PROVIDER_UNSUPPORTED(422)로 빠르게 거부합니다(인증 완료 후 복호화 실패보다 빠른 실패).

롤백 3겹

배포 없이 되돌릴 수 있는 순서대로입니다.

  1. Datadog 플래그 siv_api_flow off — 수 초 내 반영. 웹이 즉시 표준창 경로(기존 코드 그대로)로 돌아갑니다.
  2. 백엔드 킬스위치 HMH_EZIOK_API_ENABLED=false(env) — 거래 시작 요청이 SIV_API_DISABLED(503)로 거부되고, 웹이 같은 자리에서 표준창 startAuth()로 자동 폴백합니다. 허브 오류·5xx·네트워크 오류도 같은 경로로 자동 폴백됩니다.
  3. 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 를 눌러도 시트가 정상 동작하는지까지).
  • 인증 완료 후 화면을 닫았다가 같은 할 일로 재진입하면 인증사 목록이 아니라 완료 화면이 뜨는지 확인.

관련 문서

Last updated on