Skip to Content
DecisionsADR-0008: 간편인증 표준창 자동 채우기는 BFF에서만 조립하고, 평문은 라우트를 벗어나지 않는다

ADR-0008: 간편인증 표준창 자동 채우기는 BFF에서만 조립하고, 평문은 라우트를 벗어나지 않는다

  • Status: Accepted (2026-09-09 버전 번들·카나리로 재구성 — 아래 「재시도 구조」) (Amended by ADR-0012)
  • Date: 2026-09-08
  • 관련: HMH-9148 (FE 배선 — 롤백됨), HMH-9153 (배선 제거), HMH-9184 (v1/v2 번들·카나리), HMH-9145 (BE 조회 API), HMH-9126 (진행중 기록) · ADR-0005가 개선한 구간의 상류를 다룬다
  • 배선: apps/app/src/app/api/eziok/request/route.ts, apps/app/lib/eziok/prefill.ts

⚠️ 정정 (ADR-0012) — 이 프리필 결정은 표준창 경로에서는 그대로 유효합니다. 다만 간편인증-API(서버 주도) 모드(ADR-0012)에서는 서버가 인증 요청 자체를 직접 암호화해 보내므로 BFF 프리필이라는 개념이 성립하지 않습니다 — 그 모드에서는 이 프리필이 아예 쓰이지 않을 뿐, 표준창 폴백 경로에서는 지금처럼 그대로 동작합니다.

Context

ADR-0004·ADR-0005로 App2App이 켜졌고, 결과 도달률은 시도 기준 43% → 60%로 올랐습니다. 그런데도 40%가 최소 두 번 이상 시도하고 있었습니다(9/5~9/7, 사용자 261명 중 1회 성공 157명 = 60%).

남은 미도달을 계측으로 분해했습니다(같은 기간, 시도 292건 기준).

버킷정의건수미도달 중
A. 표준창 이탈거래는 발급됐는데 siv_native_external_app_open 이 없음8658%
B. 프로세스 종료인증앱에 갔으나 복귀 이벤트 없음3927%
C. 결과 유실복귀했는데 결과가 도착하지 않음2215%

가장 큰 버킷은 인증앱에 도달하기도 전이었습니다. App2App은 “인증앱을 어떻게 띄우는가”를 개선한 것이므로 원리적으로 이 구간에 닿지 않습니다. A 구간에서 사용자가 하는 일은 하나입니다 — 표준창에서 이름·생년월일·휴대폰·통신사를 입력하는 것.

그 값은 이미 우리가 알고 있습니다. 가입 시 본인인증(auth_identity_verification)으로 검증해 두었고, 간편인증 결과를 받을 때도 그 값과 일치하는지 다시 검증합니다. 맞는 값을 알면서 사용자에게 다시 입력하게 하고 있었던 셈입니다.

eziok 표준창은 protocolVersion: "v2" + preparedEncryptedUserInfo로 이 값들을 미리 채울 수 있습니다. 단 가이드 본문 표에는 필드명이 없고, 5절의 주석 처리된 예제 코드가 유일한 출처입니다.

검토한 대안

대안채택 안 한 이유
클라이언트에서 조회해 조립이름·생년월일·휴대폰이 브라우저 번들과 RUM resource에 노출된다. 암호화 키(eziok_Key_Manager)도 클라이언트로 나가야 한다. 표준창 요청은 이미 BFF가 만들고 있으므로 굳이 경계를 넓힐 이유가 없다
명부(estate_owner) 표기를 채운다명부는 조합이 등록한 표기이고 간편인증은 본인 명의로만 통과한다. 표기가 한 글자라도 다르면 프리필이 인증을 실패시킨다
telco(통신사)도 채운다가이드 전체에서 허용값이 예제 "SKT" 하나뿐이다. 우리 값은 Literal["KT","LGT","SKT"] + is_mvno 인데 LGT/LGU+·알뜰폰 코드가 무엇인지 확인할 수 없다. 틀린 통신사는 프리필이 오히려 해가 된다
일부만 채운다(휴대폰 없으면 이름·생년월일만)반쯤 채워 두면 고령 사용자가 어느 칸이 남았는지 헷갈려 아예 비어 있는 화면보다 못할 수 있다
응답에 CI·성별·주민번호 앞자리까지 실어 두고 골라 쓴다쓰지 않는 개인정보를 경계 밖으로 내보내는 것은 그 자체가 위험이다. 서버가 3필드만 내려주고, 그 개수를 테스트로 고정했다

Decision

프리필은 BFF route handler(/api/eziok/request)에서만 조립하고, 평문은 그 함수 스코프를 벗어나지 않습니다.

// 평문은 이 함수 안에만 존재한다. 나가는 것은 암호문뿐. const preparedEncryptedUserInfo = await buildEncryptedPrefill( clientTxIdUnique, plainText => eziok.RSAEncrypt(plainText), ); if (preparedEncryptedUserInfo) { body.protocolVersion = EZIOK_PREFILL_PROTOCOL_VERSION; body.preparedEncryptedUserInfo = preparedEncryptedUserInfo; }

경계 규칙 네 가지:

  • 로그·resultUrl/returnUrl 쿼리·클라이언트 응답 body·Sentry·RUM 컨텍스트에 평문 금지. 허용 계측은 적용 여부와 필드 수, 그리고 clientTxId(10분 TTL 단회용 거래 식별자)뿐입니다
  • 부분 프리필 금지. 한 칸이라도 형식이 어긋나면 전부 비웁니다(buildPrefillUserInfonull)
  • 전 칸 수정 가능(editableFields). 개명·번호 변경으로 명의가 달라진 사용자가 화면에서 고칠 수 없으면 인증 자체가 막혀, 지금 줄이려는 이탈보다 나쁜 상태가 됩니다
  • fail-open + 유한 타임아웃. 조회 실패·타임아웃·형식 불일치·암호화 실패 모두 프리필 없이 인증을 진행합니다. packages/api의 fetcher는 signal을 설정하지 않으므로(fetcher/fetch.ts) try/catch만으로는 fail-open이 성립하지 않습니다 — 백엔드가 응답하지 않으면 await가 무한히 매달리고 catch에 도달하지 못합니다. 그래서 Promise.race로 1초 상한을 겁니다(HMH-9126에서 같은 결함을 리뷰로 잡은 뒤 정한 패턴)

형식 검증(yyyyMMdd·숫자만)은 서버가 이미 맞춰 내려주지만 BFF에서 한 번 더 합니다. 표준창이 형식을 거부하면 오류코드만 돌아오고 어느 칸이 문제인지 알 수 없어, 그 상황을 미리 걸러내는 편이 낫습니다.

Consequences

좋아지는 것

  • A 버킷(미도달의 58%)에 처음으로 손이 닿습니다. 입력 칸 3개가 채워진 상태로 표준창이 열립니다
  • 평문 개인정보의 이동 경로가 BE → BFF 함수 스코프 → 암호문으로 한 줄입니다. 감사할 지점이 하나입니다
  • 프리필 실패가 인증 실패로 번지지 않습니다. BE가 아직 배포되지 않아 404가 오는 구간도 같은 경로로 흡수됩니다
  • clientTxId가 프리필 로그에 남아 “프리필된 거래가 인증앱까지 더 잘 도달하는가”를 측정할 수 있습니다

감수하는 것

  • 효과를 직접 측정할 수 없습니다. 표준창은 cert.ez-iok.com이라 우리 RUM·세션 리플레이가 닿지 않습니다. siv_auth_startsiv_native_external_app_open 전환율로 간접 측정만 가능합니다
  • 가이드에 명시되지 않은 필드명에 기대고 있습니다. 주석 처리된 예제가 출처이므로 벤더가 스펙을 바꾸면 조용히 무시될 수 있습니다(오류가 아니라 프리필만 빠진 화면)
  • telco는 여전히 사용자가 채웁니다. 4칸 중 3칸만 줄였습니다
  • 표준창 요청 한 건마다 BE 호출이 하나 늘었습니다(진행중 기록 포함 두 개). 둘 다 상한이 걸려 있어 인증 시작을 최대 3초 늦출 수 있습니다

이 결정을 다시 열어야 할 때

  • prefill_applied는 쌓이는데 siv_native_external_app_open 전환율이 그대로일 때 → A 버킷의 원인이 입력 부담이 아니다. 다음 후보는 인증사 선택 화면(어느 앱을 쓸지 고르는 단계)이며, 그 구간은 표준창 안이라 벤더 협의가 필요하다
  • eziok가 telco 허용값을 회신할 때 → 4번째 칸을 추가한다
  • 벤더가 protocolVersion 체계나 userInfo 필드명을 변경할 때

실패 기록 — 2026-09-08 프로덕션 장애

이 결정의 방향(BFF 조립·평문 경계·fail-open)은 유효하지만, 첫 구현은 프로덕션에서 실패해 롤백했습니다.

장애 창17:30:09 배포 → 17:38 롤백, 약 8분
영향간편인증 시도 24건 중 결과 도달 0건 (직전 기준선 시도 대비 ~52%)
표시된 오류표준창 2999 시스템에러
조치Vercel 직전 프로덕션 배포로 별칭 재지정(2초), 이후 request_issued 35 / result_ok 15 로 회복

원인

preparedEncryptedUserInfo일반표준창 라이브러리(eziokManager-Nodejs_1.0.7)의 RSAEncrypt 로 만들었습니다. Autofill-in 은 별도 라이브러리(eziokManager-Nodejs_2.0.2) 를 요구하고, 두 빌드는 두 가지가 다릅니다.

1.0.7 (일반표준창)2.0.2 (Autofill-in)
RSAEncrypt있음있음
aesRsaEncrypt없음있음
RSA 패딩RSA_PKCS1_PADDINGRSA_PKCS1_OAEP_PADDING

즉 (1) 자동 채우기는 하이브리드 암호화(aesRsaEncrypt)를 써야 하는데 RSA 단독으로 만들었고, (2) 같은 이름의 RSAEncrypt 조차 패딩이 달라 v2 표준창이 복호화할 수 없는 암호문이었습니다. 표준창 서버가 복호화에 실패하며 2999(시스템에러)를 냈고, 프리필이 적용된 모든 거래가 실패했습니다.

정정 (2026-09-10, HMH-9213): 위 (1) 은 사실이 아니었습니다. 자동 채우기는 aesRsaEncrypt 가 아니라 RSAEncrypt 를 씁니다(가이드 5절 예제). 실제 원인은 (2) 하나였고, 라이브러리 교체만 하면 됐습니다 — 2.0.2 의 RSAEncrypt 는 이미 OAEP 입니다.

원문을 지우지 않는 이유: 이 오판이 그럴듯했던 근거(“패딩이 다르다 → 스킴도 다를 것이다”)가 남아 있어야 같은 추론을 반복하지 않습니다. 실제로 aesRsaEncrypt 로 바꿔 재시도했다가 1024(사전입력정보가 유효하지 않습니다)를 받고서야 알았습니다. 가이드에 예제가 있으면 추론하지 말고 예제를 따르는 것이 정답이었습니다.

왜 사전에 못 걸렀나

  • 가이드 본문 표에 userInfo 필드명이 없어 주석 처리된 예제에서 스펙을 복원했는데, 그 예제가 2.0.x 라이브러리 기준이라는 점을 놓쳤습니다. “Node.js 는 버전 무관 동일 파일”이라고 판단했으나 사실이 아닙니다 — eziokManager-Nodejs_2.0.2.zip 이 별도로 배포됩니다.
  • 통합 환경(개발 서버 scert.ez-iok.com) 사전 검증을 건너뛰고 프로덕션에서 판정하기로 했습니다. 프리필 실패는 fail-open 이지만 v2 요청 자체의 거부는 fail-open 으로 흡수되지 않는다는 것을 알고 있었는데도, develop 머지가 프로덕션에 닿지 않는다는 사실을 근거로 위험을 낮게 잡았습니다. 릴리즈를 곧바로 진행하면 같은 위험이었습니다.
  • CodeRabbit 도 같은 지점(v2 서비스 ID 호환성을 통합 환경에서 확인)을 지적했으나 Trivial 로 분류돼 넘겼습니다.

재시도 조건 (하나라도 미충족이면 배포 금지)

  1. eziok_Key_Manager_v2.0.2.js 적용, preparedEncryptedUserInfoRSAEncrypt 로 생성 (2026-09-10 정정 — 처음에는 aesRsaEncrypt 로 적었으나 그것은 전자서명 원문용입니다)
  2. v2 키 파일 필요 여부를 eziok 에 확인 — 가이드가 원문 암호화에 “v2 키 적용 필요”라고 명시한다. 2.0.2 에만 있는 serverPublicKey·providerKeyInfoMap 로 보아 키 파일 형식이 다를 수 있다
  3. ⚠️ 라이브러리 교체는 encClientTxId 의 암호문 형식까지 바꾼다(패딩 변경). 프리필과 무관한 기존 간편인증이 함께 깨질 수 있으므로, 개발 서버에서 프리필 없는 일반 인증부터 통과를 확인한다
  4. 개발 서버에서 실제 인증 1회 완주 확인 후에만 프로덕션 릴리즈

재시도 구조 — 2026-09-09

eziok 회신으로 자동 채우기가 별도 서비스 등록임이 확인됐다. 첫 구현이 실패한 이유가 “필드 두 개를 잘못 만들었다”가 아니라 다른 서비스로 요청을 보냈다는 것이었다.

v1 (일반표준창)v2 (Auto fill-in)
serviceId04def90a-…d37b55e4-… (다름)
키 파일eziok_keyInfo.dateziok_keyInfo_v2.dat
라이브러리eziok_Key_Manager_v1.0.7.jseziok_Key_Manager_v2.0.2.js
프리필 암호화없음RSAEncrypt (OAEP)

Decision (추가)

버전을 필드가 아니라 번들로 다룬다. EziokBundle = {라이브러리, 키, 비밀번호, serviceId, 프리필 가능 여부}. serviceId 는 키 파일에서 읽으므로 버전을 고르면 자동으로 따라온다.

혼용이 위험하지 않다는 것이 실측으로 확인됐다 — v1 라이브러리에 v2 키를 물리면 keyInit즉시 던진다(키 포맷이 달라 복호화 결과가 JSON 이 아니다). 잘못된 조합이 잘못된 암호문을 만들어 나가는 경로는 없고, 로드 단계에서 감지해 강등할 수 있다.

통제 수단을 두 층으로 둔다. 요청 경로에 폴백을 넣을 수 없다는 것이 이번 장애의 핵심 교훈이다 — 표준창 거부는 우리가 200 을 응답한 뒤 벤더 서버에서 일어나므로 try/catch 로 감지 불가다.

  1. 카나리(옵트인 쿠키)/api/eziok/protocol?token=…&v=v2 로 심는 30분 httpOnly 쿠키. 쿠키가 env 를 이긴다. v2 키가 운영 도메인용으로만 발급되어 개발 서버에서 검증할 수 없으므로, 프로덕션에서 검증하면서 폭발 반경을 시도자 한 명으로 묶는 유일한 방법이다
  2. 킬스위치EZIOK_PROTOCOL_VERSION. 코드 변경·리뷰·머지 없이 되돌린다. 이 레포의 기능 플래그는 클라이언트 전용이라 route handler 를 못 가린다(ADR-0003)

결과 경로에는 진짜 런타임 폴백을 넣는다. 여기서는 실패를 우리가 감지할 수 있다(getResult 가 던진다). 결과 토큰은 거래를 발급한 번들의 키로만 풀리므로 state 에 버전을 실어 왕복시키고, 어긋나면 다른 버전으로 한 번 재시도하고 result_version_fallback 을 남긴다. 구버전 형식 state(버전 없음)는 v1 으로 간주한다 — 이 장치가 없으면 버전을 켜거나 끄는 순간 인증앱에 가 있던 거래가 전부 유실된다 (사용자는 인증을 마쳤는데 결과만 사라진다).

검토한 대안 (추가)

대안채택 안 한 이유
env 스위치만 두고 카나리 없이 전면 전환이번에 그렇게 했고 8분간 전면 중단됐다. 요청 경로는 실패를 감지할 수 없으므로 “켜보고 로그를 본다”가 곧 전체 사용자 대상 실험이 된다
사용자 ID 화이트리스트로 카나리라우트가 사용자 ID 를 모른다(프리필 응답에 없음). getMe() 를 더 부르면 인증 크리티컬 패스에 호출이 하나 늘어난다
클라이언트 쿼리 파라미터로 카나리useEziokAuth 변경이 필요하고, 네이티브 웹뷰는 URL 을 앱이 만들어 시도자가 넣을 수 없다
결과 경로에서 두 버전을 항상 병렬 시도정상 케이스에서도 실패 복호화를 한 번 거치게 되고, 어느 버전으로 나갔는지 기록이 남지 않는다

Consequences (추가)

  • 롤백이 환경변수 하나다. 2026-09-08 에는 revert PR + 릴리즈 + vercel promote 가 필요했다
  • 버전 전환 중 진행중 거래가 보호된다
  • 감수하는 것: 환경변수가 4개 늘고, v1/v2 두 벌의 키·라이브러리를 관리해야 한다. 어느 쪽으로 나갔는지는 protocol_selected 로만 알 수 있으므로 이 로그가 결과 해석의 전제가 된다
  • 여전히 개발 서버에서 v2 를 검증할 수 없다. 개발용 v2 키를 받으면 그때 카나리 없이 검증 가능

결과 — 2026-09-10 전면 적용

오류코드가 네 단계를 거쳐 성공했습니다. 각 단계가 서로 다른 원인이었고, 한 번에 하나씩만 드러났습니다.

시도코드원인해결
9/82999 시스템에러1.0.7(PKCS1)로 암호화2.0.2 라이브러리로 교체
9/93006 미등록 서비스IDdev 키를 운영 서버에 사용운영 키 수령
9/103006운영 serviceId 미활성eziok 측 활성화 요청
9/101024 사전입력정보 무효aesRsaEncrypt 오선택RSAEncrypt 로 정정 (HMH-9213)
9/10성공실제 인증 1건 완주 (요청→결과 32초)

최종 조합:

// eziokManager-Nodejs_2.0.2 + 운영 v2 키 + eziok 측 serviceId 활성화 body.protocolVersion = 'v2'; body.preparedEncryptedUserInfo = manager.RSAEncrypt(JSON.stringify(userInfo));
  • RSAEncrypt 평문 상한 190 bytes 실측(2048비트 + OAEP-SHA256). userInfo 125, telco 포함 140
  • telcoSKT / KT / LGU+ (알뜰폰 동일). 원본 extra.carrier 를 매핑하며, 공동인증서용 MobileCarrierCode(S/K/L)와는 체계가 다릅니다 — HMH-9209 · HMH-9211

카나리 설계가 실제로 한 일

재시도 구조에서 만든 장치의 실적입니다.

  • 롤백 2초vercel promote <직전 배포>. 9/8(구조 이전)은 revert PR + 릴리즈 + promote 로 8분, 9/9·9/10 은 각 1~3분이었습니다. 빌드를 미리 끝내 두고 별칭만 켜고 끄면 노출 창이 곧 테스트 시간입니다
  • 버전 왕복이 두 번 사용자를 구했습니다 — 전환 창 동안 v1 배포에서 발급된 거래의 결과가 v2 배포에 도착해 정상 복호화됐습니다(result_version_fallback 0건 = state 의 버전으로 첫 시도에 성공). 이 장치가 없으면 인증을 마친 사용자의 결과만 사라집니다
  • serviceId 응답값이 배포 전 판별자였습니다 — 미인증 요청으로도 어느 번들이 물렸는지, keyInit 이 성공했는지 확인할 수 있습니다

남은 것

  • 효과 판정은 최소 7일 뒤. 기준선·지표 정의·조회는 로깅 문서의 「자동 채우기 효과 추적」에 동결해 두었습니다
  • 개발 환경 검증 경로는 아직 미확립입니다. dev 키로 dev-app.howmuchhome.co(preview/develop)에 v2 를 켜 두었지만 실제 인증까지 완주해 본 적은 없습니다. 다음 벤더 연동 전에 이 경로를 세워야 프로덕션에서 검증하는 일을 반복하지 않습니다
Last updated on