다음 화면은 누가 정하는가

5편에서 화면에 이름을 붙였다. TERMS_AGREEMENT, IDENTITY_VERIFICATION, UNDERWRITING 같은 것들이다.

이름이 생기자 다음 질문이 보였다.

이 화면들 사이를 누가 잇는가.

2편에서 정리한 초기 구조에서는 금융사가 그 일을 했다. 금융사 JavaScript가 자기 API를 호출하고, 응답을 해석해 다음 pageKey를 고른 뒤 renderPage()를 호출했다. 화면을 실제로 바꾸는 쪽은 SDK였지만, 무엇으로 바꿀지를 결정하는 쪽은 금융사였다.

text
1. 화면 렌더와 전환은 SDK가 하지만, 다음 화면은 금융사가 고른다
2. 금융사는 plain JavaScript로 SDK를 호출한다. 컴파일 단계의 검사가 없다
3. 심사 결과를 판단할 수 있는 것은 금융사 서버뿐이다
4. 금융사가 늘어나면서 분기도 금융사마다 달라졌다
5. 번들은 우리가 만들지만 적용 시점은 금융사가 정한다

이 설계는 한 번에 모든 금융사에 적용되지 않았다. 계약을 바꾸려면 금융사 쪽 연동 코드도 함께 바꿔야 했고, 그래서 신규 금융사나 협의가 가능한 금융사부터 순서대로 적용했다. 기존 방식을 쓰는 금융사와는 한동안 두 계약이 함께 운영됐다.

세 번째 조건이 이 문제를 어렵게 만들었다. 다음 화면을 우리가 정하려면 심사 결과를 알아야 하는데, 그 결과를 판단할 수 있는 쪽은 금융사뿐이었다.

그 판단을 금융사에 남겨두면서 화면을 잇는 규칙만 SDK로 가져와야 했다.

이벤트를 더 모아도 기준은 생기지 않았다

처음에는 관측의 문제로 보였다. 사용자가 어떤 경로를 지났는지 모르니 화면이 뜰 때마다 이벤트를 남기면 되지 않을까 생각했다.

실제로 남는 것은 이런 목록이었다.

text
PAGE_RENDERED  terms
PAGE_RENDERED  identity
PAGE_RENDERED  rejection

목록은 있었지만 세 질문에 답할 수 없었다.

사용자는 왜 rejection에 도달했는가. 실제 심사 결과가 거절이었는지, 금융사 코드가 잘못된 분기를 탔는지 구분할 수 없었다.

identity 다음에는 원래 무엇이 와야 했는가. SDK에는 기대하는 다음 화면이라는 개념이 없었다. 요청받은 것을 그리는 구조에서는 오지 않은 것을 알 수 없다.

필수 단계를 건너뛰지는 않았는가. 동의 화면을 지나지 않고 심사 화면을 요청해도 SDK는 그대로 그렸다.

셋은 같은 문제였다. 플로우의 기준이 SDK 밖에 있었다. 기준 없이 쌓인 이벤트는 사용자가 본 화면의 나열이지, 전환이 유효했음을 설명하는 경로는 아니었다.

이벤트를 더 붙이는 것으로는 해결되지 않았다. 누가 무엇을 결정하는지부터 다시 나눠야 했다.

하나였던 결정을 둘로 나눴다

renderPage('rejection')이라는 호출에는 성격이 다른 두 결정이 섞여 있었다.

text
결정 A. 이 사용자는 심사에서 거절됐다        ← 금융사만 판단할 수 있다
결정 B. 이 결과 다음에는 rejection을 보여준다 ← 플로우를 설계한 쪽이 정할 수 있다

초기 구조에서는 금융사가 A를 판단한 뒤 B까지 결정해 화면 이름을 넘겼다.

둘을 분리했다. 금융사는 자기 서버의 응답을 업무 결과로 바꾸고, SDK는 현재 화면과 그 결과를 이용해 다음 화면을 정한다.

sequenceDiagram
    participant U as 사용자
    participant R as SDK 화면
    participant H as 금융사 JavaScript
    participant A as 금융사 API
    participant F as SDK Flow Definition

    U->>R: CTA 클릭
    R->>H: 금융사 콜백 실행
    H->>A: 업무 API 호출
    A-->>H: 응답
    H-->>R: return ctx.resolve({ status: "success", outcome: "REJECTED" })
    R->>R: 현재 action에서 허용한 결과인지 검증
    R->>F: UNDERWRITING.resolve + REJECTED
    F-->>R: 다음 pageKey는 rejection
    R->>U: 다음 화면으로 전환

금융사가 SDK에 알리는 값은 화면 이름에서 업무 결과로 바뀌었다.

text
이전
→ 'rejection'  금융사가 고른 UI 이름

이후
→ { status: 'success', outcome: 'REJECTED' }
   금융사가 판단한 업무 결과

값의 포장보다 중요한 것은 계약의 방향이었다. rejection은 우리 UI의 이름이다. 화면을 합치거나 나누면 바뀔 수 있다. REJECTED는 금융사가 자기 응답을 해석한 결과다. 화면 구성이 바뀌어도 같은 의미를 유지할 수 있다.

금융사가 우리 화면 구조까지 알아야 했던 계약을, 자기 업무 결과만 보고하면 되는 계약으로 바꾼 것이다.

다만 여기에도 경계가 있다. REJECTED가 모든 금융사에서 저절로 같은 뜻이 되는 것은 아니다. 금융사 응답을 어떤 SDK outcome으로 바꿀지는 연동 계약으로 합의해야 했다. SDK가 회수한 것은 금융사의 심사 판단이 아니라, 합의된 결과를 화면에 연결하는 규칙이었다.

모든 결과가 다음 업무 Page로 이어지는 것도 아니었다. 금융사 API 호출 실패처럼 사용자에게 실패를 설명해야 하는 경우가 있었다. 금융사 개발자는 다음처럼 우리가 미리 정한 error state를 반환했다.

javascript
return ctx.resolve({
  status: 'error',
  errorType: 'API_ERROR'
})

SDK는 현재 Page와 action, errorType을 함께 보고 미리 정한 오류 정책을 찾았다. 같은 API_ERROR라도 어느 Page의 어떤 action에서 발생했는지에 따라 공통 오류 UI로 전환할 수도 있고, 현재 Page 위에 에러 팝업을 띄울 수도 있었다.

text
정상 업무 결과
→ return ctx.resolve({ status: 'success', outcome })
→ SDK가 다음 Page 결정

예상 가능한 API 실패
→ return ctx.resolve({ status: 'error', errorType })
→ 현재 Page · action · errorType에 정의된 정책 조회
→ 공통 오류 UI로 전환하거나 에러 팝업 표시

어느 Page에서 어떤 오류 UI를 보여줄지는 Figma와 Storybook에 미리 정의했다. 팝업의 문구와 CTA, 팝업 이후의 동작도 팝업 컴포넌트가 직접 소유했다. 금융사가 런타임 configure()로 팝업 동작을 주입하지는 않았다.

금융사는 지금 어떤 실패가 발생했는지만 보고했다. 사용자에게 전체 오류 UI를 보여줄지, 어떤 팝업을 띄울지, 그 팝업의 CTA가 무엇을 할지는 SDK가 정했다. 화면 이름 대신 업무 결과를 받았던 것과 같은 책임 분리였다.

화면을 잇는 규칙은 빌드에 넣었다

결과를 다음 화면으로 바꾸려면 매핑이 필요하다. 이 규칙은 런타임 설정으로 받지 않고 번들을 만들 때 함께 넣었다.

javascript
defineFlow({
  initialPage: 'terms',
  pages: {
    terms: {
      type: 'TERMS_AGREEMENT',
      variant: 'default',
      actions: {
        submit: {
          outcomes: {
            ACCEPTED: 'identity',
            ADDITIONAL_TERMS_REQUIRED: 'additional-terms'
          }
        }
      }
    },
    underwriting: {
      type: 'UNDERWRITING',
      actions: {
        resolve: {
          outcomes: {
            APPROVED: 'loan-offer',
            REJECTED: 'rejection',
            ADDITIONAL_AUTH_REQUIRED: 'additional-auth'
          }
        }
      }
    },
    rejection: {
      type: 'FAILURE',
      terminal: true
    }
  }
})

선언의 단위는 Page다. type은 5편에서 붙인 업무 단계 이름이고, variant는 그 화면의 표현이다. actions에는 그 Page에서 시작할 수 있는 업무와 결과별 목적지가 들어간다.

이 선언을 펼치면 플로우 그래프가 된다.

flowchart TD
    T["TERMS_AGREEMENT"] -- ACCEPTED --> I["IDENTITY_VERIFICATION"]
    T -- ADDITIONAL_TERMS_REQUIRED --> AT["ADDITIONAL_TERMS"]
    AT --> I
    I --> C["CUSTOMER_INFORMATION"]
    C --> U["UNDERWRITING"]
    U -- APPROVED --> L["LOAN_OFFER"]
    U -- REJECTED --> RJ["FAILURE"]
    U -- ADDITIONAL_AUTH_REQUIRED --> AA["ADDITIONAL_AUTH"]
    AA --> U
    L --> CT["CONTRACT"]
    CT --> S["SUCCESS"]

빌드에 넣은 이유는 이 그래프가 금융사와 협의해 확정한 플로우였기 때문이다. 운영 중 임의로 바뀌어야 하는 설정이 아니었다. 협의 내용이 달라지면 정의를 고치고 번들을 다시 만들었다.

별도의 그래프 validator는 만들지 않았다.

플로우는 자주 바뀌었다. 모든 사용자가 하나의 성공 terminal에 도달해야 하는 구조도 아니었다. 심사 거절로 끝나기도 하고, 추가 인증 뒤 다시 심사로 돌아가는 순환도 정상 플로우였다. cycle 자체를 오류로 막지 않았다.

당시 우리가 맞춰야 했던 기준은 그래프 이론상의 완결성보다 금융사와 합의한 Figma 플로우였다. 각 outcome이 정해진 화면이나 에러 팝업으로 이어지는지, 실제 금융사 환경에서 처음부터 끝까지 진행되는지는 QA에서 확인했다.

데이터로 선언한 것과 자동으로 검증한 것은 다른 이야기다. Flow Definition은 다음 화면을 결정하기 위한 실행 데이터였고, 그 데이터 전체를 순회해 도달 가능성이나 terminal을 검사하는 도구까지 만들지는 않았다.

validator가 가치 없다는 뜻은 아니다. 목적지 Page의 오타나 누락은 자동으로 더 일찍 찾을 수도 있었다. 당시에는 구조적으로 검사할 수 있는 일부보다 자주 바뀌는 업무 플로우 전체를 실제 환경에서 확인하는 QA에 우선순위를 뒀고, 그만큼 설정 오류가 QA까지 늦게 발견될 수 있는 비용은 남았다.

금융사마다 다른 값은 런타임으로 분리했다

플로우를 빌드에 넣으면 금융사마다 다른 값은 다른 곳에 둬야 한다. 그래서 configure()의 역할이 커졌다.

2편의 configure()는 금융사 이름과 로고만 받았다. 같은 document 안에서 화면을 바꿨기 때문에 Page마다 URL을 가질 필요가 없었다.

이후 document가 교체되는 MPA 금융사가 생겼다. SDK가 정한 다음 화면으로 이동하려면 그 금융사에서 해당 Page가 어느 주소에 있는지, 이동을 어떤 함수로 실행해야 하는지도 알아야 했다.

javascript
sdk.configure({
  partner: {
    name: 'BANK_A',
    logo: 'https://.../logo.png'
  },
  routes: {
    terms: '/loan/terms.jsp',
    identity: '/loan/identity.jsp',
    underwriting: '/loan/underwriting.jsp',
    rejection: '/loan/rejection.jsp'
  },
  navigation: {
    navigate(url) {
      bankRouter.navigate(url)
    }
  }
})

경계는 다음처럼 나뉘었다.

text
Build-time Flow Definition
→ 어떤 Page와 업무 결과가 존재하는가
→ 현재 action에서 어떤 결과를 받을 수 있는가
→ 각 결과가 어느 pageKey로 이어지는가
→ Page · action · errorType별로 어떤 오류 UI를 보여주는가
→ 어디가 terminal인가

Runtime configure()
→ 이 금융사의 이름과 로고는 무엇인가
→ 각 pageKey의 실제 주소는 무엇인가
→ 이 금융사에서는 이동을 어떻게 실행하는가

URL을 플로우 정의에 직접 넣지 않은 것이 중요했다. 금융사마다 JSP 경로가 달라도 SDK 안의 전환 규칙은 pageKey로 통일할 수 있었다. 금융사가 URL 체계를 바꾸더라도 업무 플로우까지 함께 바꿀 필요는 없었다.

MPA에서 다음 Page를 결정할 때 SDK는 현재 번들에 들어 있는 Flow Definition에서 목적지 pageKey를 찾고, configure()로 받은 route에서 실제 URL을 꺼냈다. 런타임에 받는 URL과 빌드할 때 정한 플로우를 pageKey가 이어주는 구조였다.

같은 document에서 SDK가 화면을 바꾸는 경우, 금융사 라우터가 DOM 일부를 교체하는 경우, document 자체가 바뀌는 경우는 수명과 cleanup 방식도 달랐다. 이 차이는 9편에서 다룬다.

타입이 없는 환경에서는 결과를 런타임에 해석했다

금융사는 TypeScript 프로젝트에 SDK를 import하지 않았다. <script> 태그로 불러 썼기 때문에 타입 정의를 제공해도 컴파일 단계에서 호출을 막을 수 없었다.

금융사 콜백은 Promise를 반환했고, 정상적인 완료는 return ctx.resolve(...) 형태였다. ctx.resolve()는 SDK가 해석할 수 있는 결과 객체를 만들었다.

연동 과정에서 중요하게 본 것은 현재 action의 결과를 SDK가 해석할 수 있는지였다.

text
현재 action에서 허용되지 않은 outcome을 보고한다
정의되지 않은 error state를 보고한다
같은 action이 진행 중인데 다시 실행한다
Page가 바뀐 뒤 이전 action의 결과가 늦게 도착한다

return을 빠뜨린 경우만을 위한 별도 검사는 두지 않았다. 반환값이 없으면 다음 전환도 일어나지 않았고, 금융사 개발자가 연동 과정에서 바로 확인할 수 있는 실수라고 봤다.

그래프 전체를 검사하는 validator도 없었다. 대신 action이 완료된 시점에는 현재 Page와 action이 허용한 outcome인지, SDK가 정의한 error state인지 런타임에서 확인했다. 허용하지 않은 값이라면 임의로 다음 화면을 추측하지 않았다.

컴파일러가 없는 경계에서 모든 실수를 자동으로 막으려 한 것은 아니었다. 문서에는 action별로 반환할 수 있는 outcome과 error state를 적었고, 실제 플로우가 합의한 Figma대로 움직이는지는 QA에서 검증했다.

CTA를 누른 순간부터 action의 수명이 시작됐다

결과값만큼 먼저 문제가 된 것은 CTA 중복 클릭이었다.

사용자가 CTA를 누르면 버튼을 disabled 상태로 바꿨다. 금융사 콜백이 끝나기 전에 같은 action이 다시 시작되면 같은 API가 중복 호출되고, 서로 다른 완료 결과가 같은 화면의 전환을 두고 경쟁할 수 있었다.

text
CTA 클릭
→ ref에서 현재 action 실행 여부 확인
→ 실행 중이면 반환
→ ref를 즉시 잠금
→ CTA disabled 상태 반영
→ 금융사 콜백 실행
→ 결과 검증
→ Page 전환 또는 오류 정책 실행

중복 실행 차단을 React 상태에만 기대지는 않았다. setDisabled(true)를 호출해도 그 렌더가 끝나기 전까지 이벤트 핸들러가 참고하는 값은 이전 상태일 수 있다. 금융사 코드가 무거운 작업을 시작하더라도 같은 action이 다시 들어오지 않게 하려면, 렌더를 기다리지 않고 동기적으로 바뀌는 잠금이 필요했다.

그래서 ref에 현재 action의 실행 여부를 먼저 기록했다. disabled는 사용자에게 진행 중임을 보여주는 UI 상태였고, ref는 같은 금융사 콜백이 두 번 호출되지 않게 하는 실행 상태였다. 정확성을 React 렌더 시점에 맡기지 않은 것이다.

중복 클릭을 막아도 늦게 도착한 결과는 남는다. action을 시작한 Page가 이미 사라졌거나 더 새로운 action이 시작된 뒤 이전 Promise가 완료될 수 있었다. 이 결과를 그대로 사용하면 현재 화면과 관계없는 전환이 다시 실행된다.

따라서 완료 결과를 받았을 때는 단지 Promise가 끝났는지만 보지 않고, ref가 여전히 그 action을 현재 실행으로 가리키는지도 확인했다. Page가 사라지거나 현재 action이 달라졌다면 늦게 도착한 결과를 화면 전환에 사용하지 않았다.

오류 표시는 업무 Page 그래프 밖의 정책이었다

action이 status: 'error'로 끝나면 SDK는 errorType만 보고 하나의 전역 팝업을 고르지 않았다. 현재 Page와 action까지 함께 봤다. Page마다 같은 오류를 설명하는 방법이 달랐고, 팝업 종류마다 닫기와 CTA 이후 동작도 달랐기 때문이다.

오류 정책은 Figma와 Storybook에서 Page별 상태로 미리 맞췄다. 어떤 오류는 현재 Page 위에 팝업을 띄웠고, 어떤 오류는 더 진행할 수 없어 공통 오류 UI로 화면을 전환했다. 공통 오류 UI는 전체 화면을 차지했지만 대출 업무의 다음 단계를 나타내는 Page는 아니었으므로 정상 업무 그래프에는 넣지 않았다.

팝업의 이후 동작도 금융사 설정으로 받지 않았다. 팝업이 닫힐 때 현재 Page를 유지할지, CTA가 어떤 동작을 할지는 SDK의 팝업 컴포넌트 안에 들어 있었다. 금융사 개발자가 전달한 것은 errorType까지였다.

현재 action이 허용하지 않은 outcome이나 정의되지 않은 error state는 합의한 계약으로 해석할 수 없는 값이었다. 이 경우에도 다음 Page를 추측하거나 아무 반응 없이 멈추지 않고, 정상 플로우를 중단해 공통 오류 UI로 전환했다.

text
예상 가능한 API 실패
→ return ctx.resolve({ status: 'error', errorType })
→ Page · action · errorType 정책 조회
→ 공통 오류 UI로 전환하거나 에러 팝업 표시
→ 팝업 이후 동작은 팝업 컴포넌트가 실행

허용하지 않은 outcome · 정의되지 않은 error state
→ SDK가 계약 위반으로 판단
→ 정상 그래프 중단
→ 그래프 밖 공통 오류 UI

에러 팝업과 공통 오류 UI는 대출 업무의 다음 단계를 나타내는 Page가 아니었다. 그래프에는 합의된 업무 결과와 다음 Page의 관계를 두고, 실패를 어떤 UI와 동작으로 설명할지는 별도의 오류 정책으로 관리했다.

사용자에게 오류를 보여주는 일과 금융사 개발자에게 원인을 설명하는 일도 분리했다. 사용자에게는 내부 outcome 이름을 노출하지 않았지만, 개발자에게는 현재 Page와 action, 받은 값과 허용된 값을 확인할 수 있는 메시지가 필요했다.

javascript
console.error(
  '[loan-sdk] UNDERWRITING.resolve()가 허용하지 않는 결과를 반환했습니다: "DONE"\n' +
  '허용되는 값: APPROVED · REJECTED · ADDITIONAL_AUTH_REQUIRED'
)

계약 위반은 콘솔에만 남지 않았다. 사용자의 진행을 막는 사건이었기 때문에 공통 오류 UI로도 전환했다. 다만 금융사 코드가 API 예외를 잡고 정의된 error state를 반환하지 않으면 SDK는 그 실패 자체를 알 수 없었다. 이 빈틈이 7편에서 다룰 관측 문제로 이어졌다.

Symbol marker는 보안 장치가 아니었다

ctx.resolve()가 만든 결과에는 SDK가 제공한 정상 경로를 거쳤다는 표시가 필요했다. 금융사 개발자가 비슷한 객체를 직접 만들어 반환하는 실수를 찾기 위해, 결과 객체에 모듈 내부 Symbol을 붙였다.

javascript
const SDK_RESULT = Symbol('SDK_RESULT')

function createResolvedResult(result) {
  return {
    [SDK_RESULT]: true,
    ...result
  }
}

이 표시는 비밀 키가 아니다. 같은 JavaScript realm에서 결과 객체를 한 번 얻으면 Object.getOwnPropertySymbols()로 Symbol 키를 확인할 수 있다. 객체를 복사해 같은 모양을 만드는 일을 막으려면 SDK가 만든 객체를 클로저 내부 WeakSet에 등록하고 객체의 identity를 확인하는 편이 더 강하다.

하지만 WeakSet을 써도 업무 결과가 참이라는 사실은 증명할 수 없다. 금융사 코드는 SDK가 제공한 함수를 직접 호출할 수 있고, 같은 realm에서 location도 바꿀 수 있다. 이 구조에는 금융사 JavaScript를 격리하는 보안 경계가 없었다.

우리가 막으려던 것은 공격이 아니라 연동 실수였다. Symbol marker는 정상 호출 경로를 구분하고 잘못 만든 반환값을 일찍 발견하기 위한 브랜드에 가까웠다.

그리고 이 방식으로는 막을 수 없는 일이 하나 있었다. 금융사 코드가 API 실패를 잡은 뒤 { status: 'error', errorType: 'API_ERROR' } 대신 { status: 'success', outcome: 'APPROVED' }를 반환하면, 형식과 허용 범위가 모두 맞기 때문에 SDK는 그 보고를 받아들인다. 결과 객체의 모양이나 출처를 검사하는 일과 업무 성공을 증명하는 일은 다르다.

이 한계가 7편의 출발점이 됐다.

renderPage()는 명령에서 도착 신고로 바뀌었다

계약이 바뀌면서 renderPage()의 의미도 달라졌다.

text
이전
renderPage(pageKey)
→ 금융사가 이 화면을 선택했다는 명령

이후
renderPage(pageKey)
→ 실행 환경이 이 pageKey에 도착했다는 신고
→ SDK가 선언된 Page인지 확인
→ 화면 렌더
→ PAGE_READY 기록

같은 document 안에서는 SDK 런타임이 현재 Page와 자신이 결정한 다음 Page를 메모리에 갖고 있었다. 따라서 요청된 pageKey가 직전 action에서 정한 목적지와 같은지 대조할 수 있었다.

MPA에서도 다음 Page를 정하는 시점은 document가 사라지기 전이었다. 각 금융사에 전달한 SDK 번들에는 그 Page에서 가능한 action과 outcome별 다음 pageKey가 빌드 시점에 들어 있었다. 콜백이 완료되면 SDK는 번들 안의 Flow Definition에서 다음 pageKey를 찾고, configure()로 받은 URL로 이동했다.

text
현재 Page의 SDK 실행
→ 금융사 callback 완료
→ return ctx.resolve(result)
→ 번들 안의 Flow Definition에서 nextPage 결정
→ runtime routes에서 URL 조회
→ 금융사 navigation 실행
→ 기존 document 종료

새 document는 이전 실행 컨텍스트를 이어받아 다음 Page를 다시 결정하지 않았다. 목적지는 이미 나가기 전에 정해졌다. 새 Page에서는 금융사 JavaScript가 그 Page의 pageKeyrenderPage()를 호출했고, SDK는 해당 Page를 렌더했다.

각 Page 컴포넌트는 마운트된 뒤 useEffect에서 PAGE_READY를 발행했다. 따라서 단순히 renderPage() 요청이 들어온 시점보다 뒤의 사건이었다. React가 해당 Page를 commit하고 Effect를 실행할 수 있는 상태에 도달했다는 뜻이었다.

사용자가 지나온 Page는 이 PAGE_READY 시점에 sessionStorage 배열로 남겼다. 같은 origin의 같은 페이지 세션이라는 전제에서는 document가 바뀌어도 기록을 이어 볼 수 있었다.

다만 이 배열은 플로우를 복원하거나 다음 전환을 결정하는 상태로 쓰지 않았다. 퍼널을 이어 보기 위한 관측 기록이었다. 플로우 정의는 허용 규칙이고, 현재 Page는 런타임 상태이며, PAGE_READY 배열은 지나온 결과다. 셋은 같은 것이 아니다.

기록 시점을 렌더 요청이 아니라 PAGE_READY로 잡은 이유도 요청과 컴포넌트 준비가 서로 다른 사건이기 때문이다. 그렇다고 useEffect가 실행됐다는 사실만으로 사용자가 화면을 실제로 봤다고 단정할 수는 없다. WebView가 화면에 노출됐는지, 브라우저가 그 상태를 얼마나 오래 그렸는지는 다른 측정이 필요하다. 그 사이의 차이는 뒤의 관측 편에서 다룬다.

이 방식은 일부 금융사에만 적용됐다

여기까지가 설계이고, 실제 적용 범위는 달랐다.

이 변경에는 금융사 코드 수정이 필요했다. 콜백이 화면 이름 대신 업무 결과를 보고해야 했고, MPA라면 configure()에 route 매핑과 navigation 구현도 추가해야 했다. 새 번들을 전달하는 것만으로 끝나는 변경이 아니었다.

우리는 번들을 만들었지만 적용 일정은 금융사가 정했다. 검수 일정과 개발 리소스, 우선순위가 달랐고 협조를 얻지 못한 곳도 적지 않았다.

그래서 v1과 v2가 함께 운영됐다. 어떤 금융사는 여전히 다음 화면을 직접 골랐고, 어떤 금융사는 업무 결과만 반환했다.

공통 코어는 두 계약을 모두 지원해야 했다. 장애를 볼 때도 금융사 이름만으로는 부족했다. 그 금융사가 어느 계약을 사용하는지까지 알아야 했다.

SDK 버전이 코드 번호가 아니라 금융사와 맺은 계약이 되는 지점이었다. 이 문제는 10편에서 다룬다.

정리

SDK 최종 설계 구조를 손으로 그린 그림. 금융사 영역이 업무 API 응답을 해석해 ctx.resolve로 결과만 보고하면, SDK 영역이 결과 검증 → Flow Definition → Navigation Resolver → renderPage 순으로 다음 화면을 정하고 오류는 별도 Error Policy로 갈라진다

문제는 화면을 모두 그리는 SDK가 화면 순서를 설명할 기준을 갖고 있지 않다는 것이었다.

해결은 하나였던 결정을 둘로 나누는 데서 시작했다. 금융사만 할 수 있는 업무 판단은 금융사에 남겼다. 그 결과를 어느 화면으로 연결할지는 SDK의 플로우 정의로 가져왔다.

무엇 어디에 두었나
금융사 응답을 업무 결과로 해석 금융사 SDK는 금융사 API와 응답 의미를 모른다
업무 결과를 다음 Page로 연결 SDK의 빌드 시점 그래프 화면 구조와 플로우를 설계한 쪽이 관리한다
예상 가능한 API 실패 상태 판단 금융사 실제 API 실패 원인은 금융사 코드가 안다
error state를 오류 UI 정책으로 연결 SDK의 공통 오류 계층 Page별 UI와 팝업 이후 동작은 우리가 정의한다
금융사 이름·로고·주소·이동 방식 런타임 configure() 금융사와 실행 환경마다 다르다
결과 형식과 현재 action의 허용 범위 검사 런타임 컴파일 단계에서 금융사 호출을 검사할 수 없다
중복 실행 차단과 늦은 완료 판별 SDK action 수명 같은 요청과 오래된 결과가 전환을 반복하지 않게 한다
전체 플로우 검증 QA 자주 바뀌는 합의된 Figma 플로우를 실제 금융사 환경에서 확인한다

이 구조가 모든 것을 증명한 것은 아니다.

금융사가 APPROVED를 반환했다는 사실만으로 실제 API 성공을 증명할 수는 없다. Flow Definition은 다음 Page를 결정하는 규칙이지 사용자가 실제로 거쳐온 경로의 증거가 아니다. PAGE_READY도 Page 컴포넌트의 Effect가 실행됐다는 기록이지 사용자가 실제로 봤다는 증거는 아니다.

흐름을 소유한다는 말은 화면 이름을 SDK가 고른다는 뜻에 그치지 않았다. 누가 업무 사실을 판단하고, 누가 그 사실을 화면에 연결하며, 각 단계가 어디까지 증명할 수 있는지를 나누는 일이었다.

다음 편에서는 그 증명 사이의 틈을 다룬다. 금융사 코드에 catch (e) { console.log(e) } 한 줄이 있으면 예외는 우리 쪽까지 오지 않을 수 있다. 예외를 찾는 대신 무엇을 관측해야 하는지 다시 정해야 했다.