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 이 없음 | 86 | 58% |
| B. 프로세스 종료 | 인증앱에 갔으나 복귀 이벤트 없음 | 39 | 27% |
| C. 결과 유실 | 복귀했는데 결과가 도착하지 않음 | 22 | 15% |
가장 큰 버킷은 인증앱에 도달하기도 전이었습니다. 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 단회용 거래 식별자)뿐입니다 - 부분 프리필 금지. 한 칸이라도 형식이 어긋나면 전부 비웁니다(
buildPrefillUserInfo→null) - 전 칸 수정 가능(
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_start→siv_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_PADDING | RSA_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 로 분류돼 넘겼습니다.
재시도 조건 (하나라도 미충족이면 배포 금지)
eziok_Key_Manager_v2.0.2.js적용,preparedEncryptedUserInfo는RSAEncrypt로 생성 (2026-09-10 정정 — 처음에는aesRsaEncrypt로 적었으나 그것은 전자서명 원문용입니다)- v2 키 파일 필요 여부를 eziok 에 확인 — 가이드가 원문 암호화에 “v2 키 적용 필요”라고 명시한다.
2.0.2 에만 있는
serverPublicKey·providerKeyInfoMap로 보아 키 파일 형식이 다를 수 있다 - ⚠️ 라이브러리 교체는
encClientTxId의 암호문 형식까지 바꾼다(패딩 변경). 프리필과 무관한 기존 간편인증이 함께 깨질 수 있으므로, 개발 서버에서 프리필 없는 일반 인증부터 통과를 확인한다 - 개발 서버에서 실제 인증 1회 완주 확인 후에만 프로덕션 릴리즈
재시도 구조 — 2026-09-09
eziok 회신으로 자동 채우기가 별도 서비스 등록임이 확인됐다. 첫 구현이 실패한 이유가 “필드 두 개를 잘못 만들었다”가 아니라 다른 서비스로 요청을 보냈다는 것이었다.
| v1 (일반표준창) | v2 (Auto fill-in) | |
|---|---|---|
| serviceId | 04def90a-… | d37b55e4-… (다름) |
| 키 파일 | eziok_keyInfo.dat | eziok_keyInfo_v2.dat |
| 라이브러리 | eziok_Key_Manager_v1.0.7.js | eziok_Key_Manager_v2.0.2.js |
| 프리필 암호화 | 없음 | RSAEncrypt (OAEP) |
Decision (추가)
버전을 필드가 아니라 번들로 다룬다. EziokBundle = {라이브러리, 키, 비밀번호, serviceId, 프리필 가능 여부}. serviceId 는 키 파일에서 읽으므로 버전을 고르면 자동으로 따라온다.
혼용이 위험하지 않다는 것이 실측으로 확인됐다 — v1 라이브러리에 v2 키를 물리면 keyInit 이 즉시
던진다(키 포맷이 달라 복호화 결과가 JSON 이 아니다). 잘못된 조합이 잘못된 암호문을 만들어 나가는
경로는 없고, 로드 단계에서 감지해 강등할 수 있다.
통제 수단을 두 층으로 둔다. 요청 경로에 폴백을 넣을 수 없다는 것이 이번 장애의 핵심 교훈이다 —
표준창 거부는 우리가 200 을 응답한 뒤 벤더 서버에서 일어나므로 try/catch 로 감지 불가다.
- 카나리(옵트인 쿠키) —
/api/eziok/protocol?token=…&v=v2로 심는 30분 httpOnly 쿠키. 쿠키가 env 를 이긴다. v2 키가 운영 도메인용으로만 발급되어 개발 서버에서 검증할 수 없으므로, 프로덕션에서 검증하면서 폭발 반경을 시도자 한 명으로 묶는 유일한 방법이다 - 킬스위치 —
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/8 | 2999 시스템에러 | 1.0.7(PKCS1)로 암호화 | 2.0.2 라이브러리로 교체 |
| 9/9 | 3006 미등록 서비스ID | dev 키를 운영 서버에 사용 | 운영 키 수령 |
| 9/10 | 3006 | 운영 serviceId 미활성 | eziok 측 활성화 요청 |
| 9/10 | 1024 사전입력정보 무효 | 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).userInfo125,telco포함 140telco는SKT/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_fallback0건 =state의 버전으로 첫 시도에 성공). 이 장치가 없으면 인증을 마친 사용자의 결과만 사라집니다 serviceId응답값이 배포 전 판별자였습니다 — 미인증 요청으로도 어느 번들이 물렸는지,keyInit이 성공했는지 확인할 수 있습니다
남은 것
- 효과 판정은 최소 7일 뒤. 기준선·지표 정의·조회는 로깅 문서의 「자동 채우기 효과 추적」에 동결해 두었습니다
- 개발 환경 검증 경로는 아직 미확립입니다. dev 키로
dev-app.howmuchhome.co(preview/develop)에 v2 를 켜 두었지만 실제 인증까지 완주해 본 적은 없습니다. 다음 벤더 연동 전에 이 경로를 세워야 프로덕션에서 검증하는 일을 반복하지 않습니다