오류의 원인을 볼 수 없는데 화면은 어떻게 책임질 것인가

금융사가 삼킨 예외의 원인은 볼 수 없다
가장 다루기 어려운 오류는 이 세 줄에서 시작했다.
try {
await bankApi.submit()
} catch (error) {
console.log(error)
}이 코드가 금융사 콜백 안에 있으면 API가 실패해도 예외는 콜백 밖으로 전파되지 않는다. 그렇다고 SDK가 아무것도 관측하지 못하는 것은 아니다. 이 콜백이 async 함수라면 Promise는 undefined로 fulfilled 되고, SDK는 약속한 결과 객체가 오지 않았다는 사실까지는 알 수 있다.
볼 수 없는 것은 그 앞에서 무슨 일이 있었는지다. API가 실패했는지, 금융사 코드가 분기를 빠뜨렸는지, 의도적으로 아무 결과도 반환하지 않았는지는 알 수 없다.
실제로 두 경우를 모두 겪었다. 어떤 콜백은 예외를 잡은 뒤 아무 결과도 반환하지 않았다. 이 경우에는 결과 형식이 잘못됐다는 사실은 알 수 있었다. 다른 콜백은 예외를 잡은 뒤 형식상 올바른 성공 결과를 반환했다. 이 경우에는 SDK가 보고받은 성공과 실제 업무 성공을 구분할 수 없었다.
1. SDK와 금융사 JavaScript가 같은 실행 컨텍스트에서 동작한다
2. 금융사가 처리한 예외는 SDK의 try/catch나 전역 미처리 오류 이벤트까지 전파되지 않는다
3. SDK는 금융사 API의 요청·응답 형식과 처리 상태를 모른다
4. 사용자에게는 원인과 무관하게 우리 앱에서 발생한 장애로 보인다
5. 일부 금융사는 자체 JavaScript 모니터링 체계가 충분하지 않을 수 있었다6편에서 금융사는 업무 결과를 반환하고 SDK가 다음 화면을 정한다는 약속을 세웠다. 그 약속의 끝에는 여전히 한계가 남았다. SDK는 결과의 형식과 허용 범위는 검증할 수 있지만, 금융사가 보고한 업무 결과가 서버의 실제 상태와 일치하는지는 증명할 수 없었다.
이건 모니터링 도구의 설정 문제가 아니었다
처음에는 전역 핸들러를 더 붙이거나 수집 범위를 넓히면 잡을 수 있지 않을까 생각했다.
예외가 전파되는 경로를 그려보니 그 방향으로 해결할 수 있는 문제와 없는 문제가 갈렸다.
flowchart TD
A["금융사 API 실패"] --> B["예외 또는 Promise rejection 발생"]
B --> C{"금융사 코드가<br/>처리했나"}
C -- 아니오 --> D["호출 경로 또는 미처리 rejection으로 전파"]
D --> E["SDK try/catch"]
D --> F["window error 또는<br/>unhandledrejection"]
E --> G["수집 가능"]
F --> G
C -- 예 --> H["catch 블록에서 처리"]
H --> I["원래 예외는 더 이상 전파되지 않음"]
I --> J["SDK는 반환값과 이후 상태만 관측 가능"]
catch에서 처리된 예외는 바깥의 try/catch로 다시 전달되지 않는다. Promise rejection도 처리된 상태라면 unhandledrejection의 대상이 아니다. 리스너 수를 늘려도 이미 처리된 예외의 원인이 되살아나지는 않는다.
같은 실행 컨텍스트에 있으니 금융사 코드를 가로채면 되지 않느냐는 접근도 가능했다. window.console을 감싸거나 Promise 관련 API를 덮어쓰는 방식이다. 하지 않았다. 콘솔 호출을 가로채도 예외의 생명주기를 완전하게 복원할 수 없고, Promise API를 패치해도 모든 try/catch와 await 경로를 관측할 수 있는 것은 아니다. 무엇보다 우리가 통제하지 않는 페이지의 실행 방식을 바꾸는 일이었다. 금융사 페이지에는 우리가 모르는 공통 스크립트와 보안 스크립트도 함께 있었다.
얻는 것은 불완전한 로그인데, 잃는 것은 남의 페이지가 원래대로 실행된다는 예측 가능성이었다.
그래서 목표를 바꿨다.
이전 목표
→ 금융사가 삼킨 예외를 찾아낸다
바꾼 목표
→ 금융사와 우리가 한 약속이 어디까지 완료됐는지 관측한다예외는 원인이고 약속의 완료는 결과다. 원인을 볼 수 없는 자리에서는 우리가 소유한 경계의 결과를 봐야 했다.
하나의 전환을 여러 사건으로 나눴다
먼저 한 덩어리로 취급하던 사건을 나눴다. 아래 이름은 실제 운영 이벤트 스키마를 그대로 옮긴 것이 아니라, 이 글에서 관측 경계를 설명하기 위해 붙인 이름이다.
CALLBACK_SETTLED
→ 금융사 콜백의 Promise가 fulfilled 또는 rejected 됐다
HOST_RESULT_ACCEPTED
→ 금융사가 제출한 업무 결과가 약속된 형식과 허용 범위를 통과했다
PAGE_READY
→ SDK가 다음 Page의 React commit 이후 Effect까지 실행했다이 셋은 함께 일어날 것 같지만 각각 갈라질 수 있다.
콜백이 내부 비동기 작업을 await하지 않으면 API가 진행 중인데도 Promise는 먼저 fulfilled 될 수 있다. CALLBACK_SETTLED은 왔지만 업무가 끝났다고 볼 수 없다.
콜백이 undefined나 허용하지 않은 outcome을 반환하면 Promise는 fulfilled 됐어도 HOST_RESULT_ACCEPTED에는 도달하지 못한다. 이 경우 SDK는 원래 예외의 원인은 모르지만 계약 위반은 알 수 있다.
금융사가 APPROVED를 반환한 뒤 이동이 실패할 수도 있다. HOST_RESULT_ACCEPTED는 왔지만 PAGE_READY가 오지 않는다.
그래서 하나의 전환을 다음 신호의 사슬로 정리했다.
flowchart TD
A["ACTION_STARTED"] -->|금융사 콜백 구간| B["HOST_RESULT_ACCEPTED"]
B -->|SDK가 다음 Page 결정| C["NAVIGATION_DISPATCHED"]
C -->|금융사 라우터·document 교체 구간| D["PAGE_RENDER_REQUESTED"]
D -->|React commit·Effect 구간| E["PAGE_READY"]
정상 흐름을 모두 기록하는 것이 목적은 아니었다. 어디까지 왔고 어디서 멈췄는지 알려주는 것이 이 사슬의 역할이었다. 예외가 하나도 남지 않아도 신호가 중간에서 끊기면 운영상 조사해야 할 사건이 된다.
구간을 나눈 이유가 하나 더 있었다. 전환이 느릴 때 어디가 느린지 답할 수 있어야 했다. 금융사 API가 느린 것과 금융사 라우터가 느린 것, 우리 Page가 commit되지 못한 것은 대응 주체가 다르다.
신호는 더 잘게 나눌 수 있었다. 콜백 Promise가 settle된 시점과 결과 검증을 통과한 시점, 렌더가 commit된 시점과 Effect가 실행된 시점도 각각 다른 사건이다. 모든 차이를 운영 이벤트로 남기지는 않았다. 당시 기준은 이랬다.
이 신호와 다음 신호 사이에서 실제로 멈출 수 있는가
멈췄을 때 담당 영역이나 대응 방법이 달라지는가예를 들어 결과를 받은 뒤 허용 범위를 검사하는 구간은 동기 코드였다. 검증에 실패하면 조용히 멈추는 대신 계약 위반 경로로 보냈다. 별도 정상 신호를 하나 더 추가해도 새롭게 나눌 수 있는 운영 구간이 없었다.
HOST_CALLBACK_REJECTED 콜백이 예외 또는 rejection으로 종료됨
INVALID_HOST_RESULT 콜백은 끝났지만 약속한 결과로 해석할 수 없음
ACTION_OUTCOME_UNKNOWN 제한 시간 시점에 결과 미확정 · action은 계속 pending
NAVIGATION_WITHOUT_RENDER 이동을 요청했지만 렌더 요청이 없음
PAGE_READY_TIMEOUT 관측 기준 시간 안에 PAGE_READY가 없음금융사 열 곳, Page 스물몇 개, 전환마다 붙는 문맥 메타데이터를 곱하면 이벤트 수는 빠르게 늘어난다. 운영에서 구분할 수 있는 실패 구간이 생기지 않는다면 신호만 늘리지 않았다.
timeout은 실패 처리가 아니라 관측 기준이었다
콜백이 제한 시간 안에 돌아오지 않으면 무엇으로 처리해야 할지가 다음 문제였다.
실패로 처리하는 것이 자연스러워 보였지만 그럴 수 없었다. SDK는 금융사 API를 호출하지 않았고 요청·응답 형식도 몰랐다. 처리 상태를 조회할 방법도 없었다. 콜백이 끝나지 않은 이유는 여러 가지일 수 있다.
API 요청이 서버에 도달하지 못했다
서버가 처리 중이지만 응답이 늦다
서버는 처리를 완료했지만 응답이 유실됐다
금융사 코드가 응답을 받고도 결과를 반환하지 않았다첫 번째와 마지막 경우에 필요한 처리, 서버 처리가 이미 끝난 경우의 처리는 서로 다르다. SDK는 어느 경우인지 판별할 근거가 없었다.
그래서 timeout을 실패 상태로 만들지 않았다. 제한 시간을 넘겼다는 이유만으로 사용자 흐름을 바꾸면, SDK가 모르는 업무 상태를 추측해 제품 동작에 반영하는 셈이 된다.
stateDiagram-v2
[*] --> IDLE
IDLE --> ACTION_PENDING: action 시작
ACTION_PENDING --> ACTION_FAILED: 명시적 실패 결과 수신
ACTION_PENDING --> CONTRACT_VIOLATION: 결과 형식 또는 허용 범위 위반
ACTION_PENDING --> ACTION_PENDING: timeout · 관측 이벤트만 기록
ACTION_PENDING --> TRANSITION_PENDING: 정상 업무 결과 수신
TRANSITION_PENDING --> IDLE: PAGE_READY
ACTION_FAILED --> [*]
CONTRACT_VIOLATION --> [*]
timeout이 발생해도 오류 팝업을 띄우거나 WebView를 종료하지 않았다. 콜백을 취소하거나 늦게 도착한 결과를 버리지도 않았다. 사용자는 기존 흐름에 그대로 있었고, 결과가 나중에 도착하면 원래 전환이 이어졌다.
결과를 모른다는 사실만으로 사용자에게 오류를 보여주는 것은 오히려 경험을 해칠 수 있다고 봤다. timeout은 사용자에게 실패를 알리는 장치가 아니라, 일정 시간 안에 결과가 오지 않는 일이 실제로 얼마나 발생하는지 운영자가 알기 위한 기준이었다.
timeout 전
→ 콜백 결과를 기다린다
timeout 시점
→ partner · sdkVersion · pageKey · action 문맥과 함께 관측 이벤트를 남긴다
→ 사용자 UI와 action 상태는 바꾸지 않는다
timeout 후
→ 콜백 결과가 도착하면 원래 흐름을 계속한다타임아웃이 없으면 예외 없이 오래 걸리는 콜백과 영원히 끝나지 않는 콜백이 모두 단순한 침묵으로 남는다. 당시에는 장애가 얼마나 자주 발생하는지도 모르는 상태로 두지 않는 것이 먼저였다. timeout은 복구 정책보다 관측의 시작점에 가까웠다.
그래서 공통 자동 retry를 만들지 않았다
무응답에 대한 전형적인 대응은 재시도다. SDK 공통 계층에 retry를 두면 금융사마다 따로 구현할 필요도 없어 보인다.
만들지 않았다. 같은 콜백을 다시 실행하면 그 안의 API도 다시 호출된다. SDK는 그 API가 멱등한지 알지 못했다. 대출 신청이나 계약 체결처럼 중복 실행의 의미가 큰 요청이라면, 자동 retry는 사용자가 요청하지 않은 두 번째 처리를 만들 수 있다.
SDK가 알아야 재시도할 수 있는 것
├─ 이 API가 멱등한가
├─ 멱등 키를 어떻게 만들고 서버가 어떻게 보장하는가
├─ 처리 상태를 조회하는 API가 있는가
└─ 중복 처리됐을 때 복구하거나 취소하는 방법이 있는가
SDK가 실제로 아는 것
└─ 없음여기서 공통화의 경계가 정해졌다. 공통 계층은 모든 금융사에 같은 판단을 적용하는 곳이다. 금융사마다 답이 다른 질문에 임의의 기본값을 두면 안 됐다. 재시도가 필요하다면 API 스펙과 서버의 멱등성 계약을 아는 쪽에서 결정해야 했다.
만들지 않은 것도 목록으로 남겼다.
SDK 공통 계층이 한 것
├─ 콜백 시작과 timeout 감시
├─ 결과 형식과 현재 action의 허용 범위 검증
├─ timeout 발생 시 실행 문맥과 관측 이벤트 기록
└─ 결과가 도착하면 기존 action 흐름 계속
하지 않은 것
├─ timeout을 업무 실패로 확정
├─ timeout만으로 오류 팝업 표시 또는 WebView 종료
├─ 콜백 자동 재실행
├─ 금융사 API 상태 재조회
├─ 이미 시작된 금융사 요청 취소
└─ 업무 보상 처리PAGE_READY가 증명하는 것과 못 하는 것
신호 사슬의 마지막은 PAGE_READY였다. 이 신호를 어디서 보낼지가 생각보다 중요했다.
각 Page 컴포넌트가 mount된 뒤 useEffect 안에서 발행했다. React는 commit 뒤 Effect의 setup 함수를 실행한다. 다만 useEffect와 browser paint의 순서는 항상 같지 않다. 상호작용에서 시작된 Effect는 paint 전에 실행될 수 있고, 상호작용이 원인이 아니어도 React 문서는 브라우저가 먼저 그리도록 일반적으로 허용한다고 표현한다.
따라서 PAGE_READY는 paint 완료 신호가 아니다. 사용자가 실제 화면을 봤다는 증거도 아니다.
PAGE_READY가 말하는 것
→ React가 이 Page를 commit했고 PAGE_READY를 보내는 Effect가 실행됐다
PAGE_READY가 말하지 않는 것
→ 브라우저가 해당 프레임을 실제로 그렸다
→ WebView가 사용자에게 노출돼 있었다
→ 사용자가 볼 만한 데이터가 준비됐다
→ 사용자가 화면을 인지할 만큼 오래 봤다그렇다고 신호가 쓸모없는 것은 아니었다. 렌더 요청과 React Page의 준비 지점을 구분하고, 전환이 우리 렌더 구간까지 도달했는지 확인하는 lifecycle 신호로 사용했다. 다만 이 신호 하나로 화면 표시 성능까지 설명하지는 않았다.
Page가 mount된 뒤 자체적으로 데이터를 불러오는 구조라면 PAGE_READY 시점에 사용자가 보는 것은 빈 껍데기나 로딩 UI일 수 있다. 실제 내용을 기준으로 보려면 화면별 CONTENT_READY 같은 신호가 따로 필요하다. 무엇이 준비돼야 사용자에게 유효한 화면인지는 Page마다 달라서 공통 계층이 일괄적으로 정할 수 없었다.
사용자가 지난 경로를 기록할 때 PAGE_READY를 기준으로 삼은 이유도 여기에 있다. 퍼널에는 React Page 준비 지점까지 도달한 화면만 남기고, 렌더 요청은 도달로 세지 않았다. 요청과 도달을 같은 사건으로 집계하면 실제로 준비되지 못한 Page가 통과한 것으로 기록되고, 이탈 지점이 뒤로 밀린다.
다만 이 퍼널 역시 “사용자가 화면을 봤다”가 아니라 “Page가 PAGE_READY까지 도달했다”는 퍼널이었다. 지표의 이름과 해석도 그 범위를 넘지 않게 맞춰야 했다.
Error Boundary와 전역 리스너의 자리
관측 목표를 바꿨다고 예외 수집을 뺀 것은 아니다. 볼 수 있는 범위를 정하고 각 장치를 그 범위 안에서 사용했다.
React Error Boundary는 하위 컴포넌트가 렌더 중 던진 오류를 잡아 fallback UI를 보여준다. SDK가 Page 전체를 그리는 구조에서는 이 자리가 특히 중요했다. 위젯이라면 일부만 사라질 수 있지만, 우리는 Page 전체가 사라질 수 있었다.
다만 Error Boundary가 모든 JavaScript 오류를 잡는 것은 아니다. React 공식 문서가 명시하듯 이벤트 핸들러, 서버 렌더링, Error Boundary 자체에서 발생한 오류, setTimeout이나 requestAnimationFrame 같은 임의의 비동기 콜백 오류는 범위 밖이다. 이 영역은 전역 리스너나 각 실행 경로의 명시적인 오류 처리로 보완해야 한다.
window의 error는 처리되지 않은 동기 실행 오류 등을, unhandledrejection은 처리되지 않은 Promise rejection을 관측하는 자리다. 금융사 코드에서 시작된 오류도 끝까지 처리되지 않고 전파되면 이곳에 도달할 수 있다. 반대로 금융사 코드가 이미 처리한 예외는 오지 않는다.
문제는 오류의 소유자를 판별하는 일이었다. SDK와 금융사 코드가 같은 실행 컨텍스트에 있으므로 stack만으로 출처를 항상 확정하기 어려웠다. 그래서 수집 이벤트에 당시 문맥을 함께 붙였다.
partner 어느 금융사인가
sdkVersion 어느 버전인가
pageKey 어느 Page인가
pageType 어느 업무 단계인가
action 어느 전환 중이었나
source sdk · host · bridge · unknownsource는 조사 범위를 좁히기 위한 분류값일 뿐, 진짜 발생 주체를 보증하는 값은 아니었다. 특히 같은 실행 컨텍스트의 오류 출처를 metadata만으로 증명할 수는 없다. 이 값을 보안 판단이나 책임 판정에 사용하지 않도록 범위를 정해뒀다.
금융사가 열 곳이면 grouping이 문제가 된다
Sentry를 사용했다. 금융사가 늘어나자 예외 메시지 자체보다 어떤 오류를 같은 문제로 묶을지가 중요해졌다.
공통 코어의 버그
→ 금융사 수만큼 다른 이슈로 쪼개지면 같은 원인을 놓친다
금융사별 연동 구현의 문제
→ 하나로 합쳐지면 담당 범위를 좁히기 어렵다
특정 Page·variant의 문제
→ 화면 문맥이 없으면 재현하기 어렵다세 경우에 필요한 묶음 기준은 서로 달랐다. fingerprint에 금융사 이름을 항상 넣는 것도, 항상 빼는 것도 답이 아니었다. 원인의 종류에 따라 grouping 기준을 다르게 두고 운영 중 조정해야 했다.
Sentry Replay도 사용했다. stack trace만으로는 약관에서 바텀시트를 열고 인증으로 이동한 뒤 오류가 발생한 맥락을 복원하기 어려웠다. Page 전체가 SDK인 만큼 사용자가 어떤 UI 흐름에 있었는지가 원인 분석에 도움이 됐다.
정확한 설정 이름과 샘플링 비율은 지금 기억나지 않는다. 다만 당시 Replay에서 확인한 화면은 모든 글자가 가려지고, 실제 문구 대신 추상화된 레이아웃만 보이는 형태였다. 수집량도 전체 세션이 아니라 일부만 남도록 낮게 설정했다. 이 정도로도 어느 Page에서 어떤 UI를 거쳐 오류가 발생했는지 확인하는 데는 도움이 됐다.
여기서 5편의 Page 이름이 다시 쓰였다. 금융사마다 연동 코드는 달라도 IDENTITY_VERIFICATION이라는 같은 단계 이름을 붙일 수 있었다. 특정 금융사의 특정 업무 단계에서만 실패가 늘어나는지도 같은 축으로 볼 수 있었다. 코드 재사용을 위해 시작한 표준화가 관측의 언어가 된 셈이다.
SDK JavaScript가 아예 실행되지 않는 구간
여기까지는 SDK가 실행되고 있을 때의 이야기다.
실제로 겪은 인프라 장애는 금융사 서버 문제였다. 서버가 응답하지 않아 금융사 Page의 HTML이 오지 않았고, HTML에 포함된 SDK <script>도 실행되지 않았다.
이 구간에서 작동하지 않는 것을 세어보면 이렇다.
Sentry 초기화 실행 안 됨
Error Boundary 실행 안 됨
window 오류 리스너 등록 안 됨
renderPage() 호출 안 됨
PAGE_READY 발생 안 됨우리가 만든 관측 장치 전체가 SDK JavaScript의 실행을 전제로 하고 있었다. 그 전제가 깨지는 구간에서는 Web 안에 아무 신호도 남지 않았다.
사용자에게는 원인이 금융사 서버인지 우리 코드인지 구분되지 않는다. 우리 앱에서 대출 화면이 열리지 않은 사건으로 보일 뿐이다.
이 구간을 보려면 Web 밖에 관측 지점이 있어야 했다. WebView를 띄운 Native가 로딩 상태를 관측하고 SDK의 준비 신호를 기다리는 구조까지 설계했지만 적용하지는 못했다. 앱 배포 주기와 bridge 문제가 있었고, 이 이야기는 10편에서 다룬다.
그래서 관측 범위를 세 층으로 구분하고 각 층이 무엇을 볼 수 없는지도 함께 적어뒀다.
flowchart TD
subgraph L1["1. 약속 계층"]
A1["콜백 시작·settle"]
A2["업무 결과 검증"]
A3["timeout"]
end
subgraph L2["2. Web Runtime 계층"]
B1["Error Boundary"]
B2["전역 오류 리스너"]
B3["Page lifecycle"]
end
subgraph L3["3. Native·WebView 계층"]
C1["HTML 로드 실패"]
C2["SDK ready timeout"]
C3["미적용"]
end
L1 --> N1["업무 결과의 진위와<br/>삼킨 예외의 원인은 모름"]
L2 --> N2["JavaScript가 실행되지 않으면 못 봄"]
L3 --> N3["앱 배포와 bridge 호환이 필요함"]
정리
문제는 우리가 책임지는 화면에서 발생한 실패를 우리가 온전히 볼 수 없다는 것이었다.
볼 수 없는 것을 억지로 복원하려 하지는 않았다. 금융사가 처리한 예외의 원인을 전역 리스너로 되살릴 수는 없다. SDK가 모르는 금융사 API의 실제 처리 상태를 추측할 수도 없다. 대신 관측 대상을 예외 자체에서 우리가 맺은 약속의 진행 상태로 바꿨다.
| 알고 싶은 것 | 볼 수 있는가 | 대신 관측한 것 |
|---|---|---|
| 금융사 API가 실패한 원인 | 아니오 | 콜백이 settle됐는지, 약속한 결과를 반환했는지 |
| 금융사가 처리한 예외 | 원인은 볼 수 없음 | 결과 검증과 제한 시간 안의 완료 여부 |
| 정상 형식으로 보고된 업무 결과의 진위 | 아니오 | 없음. 금융사 서버 상태를 아는 쪽의 책임 |
| 금융사 라우터의 조용한 실패 | 직접은 어려움 | 이동 요청 뒤 렌더 요청과 PAGE_READY가 왔는지 |
| 사용자가 실제로 화면을 봤는지 | 아니오 | PAGE_READY 기준 Page 도달 퍼널 |
| JavaScript가 실행되지 않은 구간 | Web에서는 아니오 | Native 보완안을 설계했지만 미적용 |
오른쪽 열은 모두 우리가 통제하는 경계에 있다. 남의 코드 안을 들여다보는 대신, 우리가 소유한 경계에서 어떤 신호가 지나갔고 어디서 끊겼는지를 기록했다.
이 설계가 실패의 원인을 모두 알려준 것은 아니다. 대신 모른다는 사실을 업무 실패나 사용자 오류 UI로 바꾸지 않았다. 증명할 수 없는 업무 결과를 재시도로 덮지도 않았다. 사용자 흐름은 건드리지 않으면서, 어느 계층에서 얼마나 자주 신호가 끊기는지는 알 수 있게 했다.
다음 편에서는 로딩을 다룬다. 전환 중에 로딩을 띄우는 코드를 실행했는데도 사용자가 보지 못한 경우가 두 종류 있었다. 하나는 로딩이 화면에서 사라진 경우였고, 다른 하나는 브라우저가 그릴 기회를 얻지 못한 경우였다.