화면만 그리면 되는 일이라고 생각했다

첫 금융사 연동에서 가장 오래 걸린 일은 코드를 작성하는 일이 아니었다.
무엇을 만들어야 하는지 합의하는 일이었다. “우리 앱 안에서 금융사 약정 화면을 띄운다”는 문장은 있었다. 하지만 그 화면이 어느 도메인에서 실행되는지, 누가 무엇을 호출하는지, 금융사 개발자가 무엇을 해줘야 하는지는 정리되어 있지 않았다. 우리와 금융사와 디자인팀은 각자 다른 그림을 갖고 있었다. 회의에서 같은 단어를 쓰면서 서로 다른 것을 가리키는 일이 반복됐다.
그 그림을 맞추고 나니 남은 요구사항은 단순해 보였다. 금융사가 함수를 호출하면 우리가 화면을 그린다. 두 달 안에 화면 스물몇 개를 만들면 되는 일이었다.
첫 금융사를 내보낸 뒤 내가 내린 결론도 그랬다. 이 SDK는 화면을 그리는 일만 책임지면 된다.
이 시리즈의 나머지는 그 결론이 어디서부터 틀렸는지를 기록한다.
화면 렌더에서 시작한 질문
금융사가 늘고 운영 기간이 길어져도 겉으로 보이는 제품 요구사항은 달라지지 않았다. 처음부터 하나였다. 우리 앱 안에서 대출 약정을 끝내게 한다.
달라진 것은 그 문장을 지키기 위해 우리가 소유해야 하는 범위였다.
금융사 페이지 안에서 화면 스물몇 개를 띄울 수 있는가
→ 같은 화면을 열 번 복사하지 않고 금융사를 늘릴 수 있는가
→ 다음 화면을 우리가 정할 수 있는가
→ 우리가 볼 수 없는 실패를 관측할 수 있는가
→ 우리가 띄운 로딩을 사용자가 봤다고 말할 수 있는가
→ 뒤로가기를 눌렀을 때 어디로 가야 하는가
→ 우리가 배포한 것이 실제로 적용됐는가질문은 UI 구현에서 시작해 실행 순서의 소유권과 실패의 관측, 배포된 결과의 확인으로 넓어졌다. 각 편은 이 과정에서 마주친 문제 하나를 다룬다.
이번 편은 첫 번째 질문에서 시작한다. 아직 문제가 단순해 보이던 시점이다. 그 단순함이 무엇을 보지 못하게 했는지도 함께 정리한다.
처음 합의한 다섯 가지
합의 과정을 거친 뒤 조건은 다섯 가지로 정리됐다.
1. 화면 20~30개를 우리가 모두 그린다
2. 실행 위치는 금융사 도메인이다
3. 금융사는 TypeScript 프로젝트에 import하지 않고 `<script>` 태그로 불러 쓴다
4. 금융사 API의 요청·응답 형태를 우리는 모른다
5. 기간은 두 달이고, 이 방식이 통하는지는 아직 검증되지 않았다이 목록이 처음부터 있었다면 설계는 훨씬 빨랐을 것이다. 실제로는 이 다섯 줄을 확정하는 것이 첫 작업이었다. 특히 두 번째와 네 번째 조건은 금융사 개발자와 이야기하고 나서야 정확한 형태가 잡혔다.
네 번째 조건이 문제의 성격을 정했다. 대출 심사 결과를 판단할 수 있는 것은 금융사 서버뿐이었고, SDK는 그 API를 직접 호출하지 않았다. 따라서 이 SDK는 UI를 그리는 라이브러리인 동시에, 우리가 내용을 모르는 금융사의 업무 로직과 화면을 이어 붙이는 접착층이어야 했다.
세 번째 조건은 API를 설계하는 방식에 영향을 줬다. 금융사의 연동 코드는 타입 힌트나 컴파일 단계의 검증을 기대하기 어려웠다. 인자 이름 하나를 잘못 적어도 실행하기 전에는 알 수 없었다.
다섯 번째 조건은 초기 범위를 정했다. 이 SDK 자체가 하나의 가설이었다. 신청 과정에서 금융사 페이지로 이동할 때 사용자가 이탈한다는 관찰은 있었지만, 그 화면을 우리 앱 안으로 가져오면 이탈이 줄어드는지는 실제로 붙여 봐야 알 수 있었다.
SDK가 들어간 자리
전체 구조는 1편에서 다뤘지만, 초기 설계를 설명하려면 한 번 더 볼 필요가 있다.
flowchart TD
N["우리 네이티브 앱"] --> W["WebView"]
W --> P["금융사 웹페이지 · 금융사 도메인"]
P --> HJ["금융사 JavaScript"]
P --> S["우리 UI SDK"]
S --> R["React 화면"]
스타일을 어떻게 격리했는지는 3편에서, 이 번들을 어떻게 배달했는지는 4편에서 다뤘다. 시점을 정확히 적어두면 첫 금융사에는 Shadow DOM이 없었다. 3편에서 다룬 격리 방식은 이보다 나중에 도입됐다. 여기서 볼 것은 SDK가 페이지 안에서 맡은 범위다.
일반적인 임베드 SDK는 페이지 한구석을 차지한다. 결제 버튼, 채팅 위젯, 지도 같은 것들이다. 사용자는 호스트 사이트를 이용하고 있다고 인식하고, 위젯은 자신이 차지한 영역만 책임진다.
우리가 만든 SDK는 화면 전체를 그렸다. 금융사 페이지에는 SDK가 그린 것 외에 사실상 아무것도 없었다. 로딩 인디케이터, 에러 팝업, 뒤로가기, 스크롤처럼 위젯이라면 호스트 페이지에 맡길 수 있는 동작까지 우리 설계 범위에 들어왔다.
화면을 그리기 위해 정한 다섯 가지
1. React를 사용한다
프레임워크를 여러 개 비교해서 고르지는 않았다. 사내 디자인시스템이 React 기반이었기 때문이다.
두 달 안에 화면 스물몇 개를 만들려면 버튼, 입력 필드, 체크박스, 바텀시트를 처음부터 만들 수 없었다. 당장 재사용할 수 있는 컴포넌트가 React로 되어 있었고, 그래서 React를 사용했다.
그 대가로 <script> 태그로 배포하는 SDK 번들에 React 런타임이 함께 들어갔다. 버튼 하나를 띄우는 위젯이라면 부담이 큰 선택이다. 하지만 우리가 만들려던 것은 화면 스물몇 개로 이루어진 흐름이었다. 당시에는 디자인시스템을 다시 만드는 비용보다 React 런타임을 포함하는 비용이 작다고 판단했다.
디자인시스템 컴포넌트를 재사용한다는 결정은 그 컴포넌트가 가진 생명주기 가정도 함께 가져오는 일이었다. 이 가정은 나중에 로딩 처리에서 문제로 드러났다. 8편에서 다룬다.
2. 하나의 document 안에서 화면을 바꾼다
초기 설계는 SPA를 전제로 했다. 금융사 페이지가 한 번 열리면 같은 document 안에서 SDK가 화면을 바꿔 그리는 방식이었다.
이유는 세 가지였다.
먼저 화면 스물몇 개를 지나는 동안 페이지 이동이 반복되면 document를 새로 받을 때마다 로딩이 생긴다. 이탈을 줄이기 위해 만든 SDK가 오히려 반복되는 로딩으로 이탈을 만들 수 있었다.
화면 전환도 우리가 설계할 수 있었다. 디자이너가 정의한 전환 효과가 있었고, document가 유지되면 그 흐름을 그대로 구현할 수 있었다.
document와 JavaScript 실행 컨텍스트가 유지된다는 점도 중요했다. 입력값, 진행 단계, 세션 정보를 메모리에 두고 다음 화면에서도 이어서 쓸 수 있었다.
하지만 모든 금융사가 같은 구조를 쓰지는 않았다. 이후 JSP 페이지 단위로 document가 교체되는 MPA 금융사가 등장했다. 그때부터 cleanup, 로딩, 내부 히스토리를 다시 정의해야 했다. 초기 구조에는 document가 사라졌다가 다시 만들어지는 상황을 다룰 자리가 없었다.
3. 렌더링 진입점을 하나로 둔다
금융사 개발자가 마주하는 SDK는 이런 모양이었다.
<script src="https://.../bank-a-sdk.js"></script>
<script>
sdk.configure({
partner: {
name: 'BANK_A',
logo: 'https://.../bank-a-logo.png'
}
})
sdk.renderPage({
pageKey: 'terms',
targetId: 'loan-sdk-root',
props: {
// 금융사 callback과 페이지별 값
}
})
</script>renderPage()의 인자 세 개는 각각 하나씩 정했다. pageKey는 어떤 화면을 그릴지, targetId는 어디에 그릴지, props는 금융사가 넘기는 콜백과 페이지별 값이었다.
화면이 스물몇 개여도 금융사가 알아야 할 렌더링 함수는 하나였다. 타입 검증을 기대하기 어려운 환경에서는 API 표면이 넓어질수록 잘못 사용할 수 있는 자리도 늘어난다. 두 달 안에 연동을 끝내려면 금융사에 설명해야 할 개념도 적어야 했다.
호출을 받은 SDK는 다음 순서로 움직였다.
flowchart TD
A["renderPage({ pageKey, targetId, props })"] --> B{"configure 완료됐나"}
B -- 아니오 --> E1["명시적 오류"]
B -- 예 --> C{"targetId에 해당하는 DOM이 있나"}
C -- 아니오 --> E2["명시적 오류"]
C -- 예 --> D{"pageKey → 컴포넌트<br/>레지스트리에 있나"}
D -- 아니오 --> E3["명시적 오류"]
D -- 예 --> F["페이지 컴포넌트 조회"]
F --> G["금융사 콜백 래핑"]
G --> H["React root 조회 또는 생성 후 렌더"]
앞의 세 조건은 하나라도 맞지 않으면 렌더링을 시작할 수 없다. 컴파일러가 실수를 잡아주지 않는 환경에서는 실패했을 때 “동작하지 않는다”가 아니라 “무엇이 잘못됐다”를 알려주는 편이 연동 시간을 줄인다. targetId를 잘못 적은 금융사 개발자에게 필요한 것은 null 참조 TypeError가 아니라, 해당 ID를 가진 DOM이 없다는 설명이었다.
throw new Error(
`[loan-sdk] target element not found: #${targetId}. ` +
'renderPage()를 호출하기 전에 대상 DOM이 존재하는지 확인하세요.'
)DOMContentLoaded 이후라는 조건만으로 대상 DOM의 존재가 보장되지는 않는다. 반대로 script보다 앞에서 대상 DOM이 이미 만들어졌다면 DOMContentLoaded 전에도 찾을 수 있다. SDK에 필요한 조건은 이벤트의 발생 여부가 아니라, renderPage()를 호출하는 시점에 대상 DOM이 존재하는지였다.
props로 받은 콜백을 그대로 호출하지 않고 한 겹 감싼 것도 같은 이유였다. 금융사 콜백은 Promise를 반환했고, 완료되면 resolve({ status: 'success' }) 같은 형태로 결과를 돌려주는 호출 계약이 있었다. SDK는 래퍼에서 콜백의 호출 시점뿐 아니라 완료와 실패도 관측할 수 있었다. 이 자리는 나중에 계약과 관측을 설계하는 출발점이 됐다.
React root는 React 18의 createRoot로 만들었다. 초기 구현에서는 targetId에 대응하는 root를 한 번 만든 뒤 보관했고, 다음 화면은 같은 root의 render()를 다시 호출해 그렸다. 다만 root가 같다고 모든 컴포넌트 상태가 유지되는 것은 아니다. 이전과 같은 컴포넌트가 트리의 같은 위치에 남아 있을 때만 React가 그 상태를 보존한다.
4. 그릴 자리는 금융사에게 받는다
targetId를 받는다는 것은 SDK가 자신의 DOM 노드를 직접 만들지 않는다는 뜻이다. 금융사가 마련한 자리를 받아 그 안에 화면을 그렸다.
세입자와 비슷한 구조다. 그 자리에 무엇을 그릴지는 우리가 정하지만, 그 자리가 언제까지 남아 있을지는 금융사가 정한다.
이렇게 한 이유는 금융사 페이지의 레이아웃을 우리가 알 수 없었기 때문이다. 헤더나 공통 스크립트가 붙은 페이지에 SDK가 임의로 노드를 만들면 어느 위치에 붙일지부터 협의해야 한다. 금융사가 자리를 지정하게 하면 그 협의를 HTML 한 줄로 줄일 수 있었다.
대신 DOM의 수명은 금융사가 갖게 됐다. 이후 금융사 라우터가 해당 영역을 innerHTML로 비우는 상황이 문제가 됐고, 출발점은 이 결정이었다. 9편에서 다룬다.
5. 금융사 정보는 런타임에 받는다
화면 전체를 그리다 보니 헤더에는 금융사 로고가, 안내 문구에는 금융사 이름이 들어갔다. 이 값을 빌드에 넣으면 금융사마다 별도 컴포넌트나 번들을 만들어야 했다. 그래서 금융사별 정보는 configure()에서 받았다.
지금 이 API를 보면 빠진 것이 하나 있다. URL 정보다.
당시에는 필요하지 않았다. 초기 SDK는 같은 document 안에서 pageKey로 화면을 바꿨고, 화면마다 별도의 URL을 두지 않았다. 화면 전환과 내부 히스토리는 SDK가 관리했으며, 외부로 이동할 주소라는 개념도 필요하지 않았다.
이후 configure()에 pageKey → URL 매핑이 들어왔다. document가 교체되는 MPA 금융사가 등장했고, 다음 화면을 SDK가 정하는 구조로 바뀐 뒤였다. 겉으로는 설정값 하나가 늘어난 변화였지만, 실제로는 실행 환경과 결정 권한이 함께 바뀐 결과였다.
여섯 번째 결정: 다음 화면은 금융사가 고른다
앞의 다섯 가지는 화면을 어떻게 그릴지에 관한 결정이었다. 여섯 번째는 달랐다. 어떤 화면을 그릴지 결정하는 권한을 나누는 일이었다.
초기 SDK에서는 플로우 중간에도 금융사가 renderPage(pageKey)를 직접 호출했다.
sequenceDiagram
participant U as 사용자
participant R as SDK React 화면
participant H as 금융사 JavaScript
participant A as 금융사 API
U->>R: CTA 클릭
R->>H: props로 받은 콜백 실행
H->>A: 업무 API 호출
A-->>H: 심사 결과
H->>H: 다음 pageKey 결정
H->>R: sdk.renderPage(다음 pageKey)
R->>U: 다음 화면 표시
책임을 나누면 다음과 같았다.
flowchart LR
subgraph HOST["금융사"]
A1["업무 API 호출"]
A2["API 결과 해석"]
A3["다음 화면 결정"]
end
subgraph SDK["우리 SDK"]
B1["화면 렌더"]
B2["화면 전환"]
B3["내부 히스토리 관리"]
end
이렇게 나눈 데에는 세 가지 이유가 있었다.
금융사는 이미 자신의 업무 로직을 갖고 있었다. 기존 대출 신청 페이지를 운영하고 있었으니 자연스러운 일이었다. 그 업무 판단을 SDK 방식에 맞춰 다시 구현하라고 요구하면 연동 비용이 커진다.
다음 화면은 심사 결과에 따라 달라졌다. 승인이면 상품 선택으로, 거절이면 결과 화면으로, 추가 인증이 필요하면 인증 화면으로 가야 했다. 그 결과를 해석할 수 있는 쪽은 금융사뿐이었다.
무엇보다 이 방식은 SDK가 처음부터 책임져야 할 범위를 줄였다. 금융사의 응답을 해석하고 다음 화면을 고르는 규칙까지 SDK가 만들 필요는 없었다. 요청받은 화면을 정확히 그리면 됐다.
다만 다음 화면을 고르는 결정과 화면 전환을 실행하는 일은 서로 다른 곳에 있었다. 같은 document 안에서 화면을 바꿨기 때문에 전환과 내부 히스토리를 실행하는 쪽은 SDK였다. 금융사가 가진 것은 다음 pageKey를 고르는 결정이었다.
이 분리가 초기 구조의 성격을 정했다. 실행은 우리가 하고, 결정은 금융사가 했다. 초기 SDK는 렌더러에 가까웠다.
렌더러가 볼 수 없었던 것
renderPage(pageKey)는 “이 화면을 그려라”라는 명령이다.
명령을 받는 쪽은 그 명령이 왜 왔는지 알 수 없다. SDK에 renderPage('rejection')이 들어왔을 때 알 수 있는 것은 거절 화면을 그려야 한다는 사실뿐이다. 사용자가 실제로 심사를 통과하지 못했는지, 금융사 코드가 실수로 그 화면을 호출했는지, 앞의 필수 단계를 건너뛰었는지는 구분할 수 없었다.
구조를 다시 펼쳐 보면 어긋난 지점이 보인다.
플로우 설계
→ 우리 디자이너
화면 렌더링과 전환
→ 우리 SDK
내부 히스토리 관리
→ 우리 SDK
다음 화면 결정
→ 금융사 JavaScript
전환의 이유와 유효성
→ SDK는 알 수 없음전환을 실행하는 쪽과 결정하는 쪽이 달랐다. 우리는 화면을 바꾸는 동작을 갖고 있었지만, 그 동작을 언제 왜 해야 하는지는 몰랐다.
렌더 이벤트를 기록하면 사용자가 어떤 화면을 거쳤는지는 재구성할 수 있다. 하지만 사용자가 왜 그 화면에 도착했는지, 그 전환이 업무 규칙에 맞는지는 설명할 수 없다. 플로우의 기준이 SDK 밖에 있었기 때문이다.
금융사가 한 곳이고 그곳의 플로우를 우리가 모두 알고 있을 때는 이 어긋남이 큰 비용으로 드러나지 않았다. 금융사가 늘고 각자 다른 분기를 갖기 시작하면서 문제가 됐다. 이 구조를 어떻게 바꿨는지는 6편에서 다룬다.
두 달 안에 정하지 않은 것
초기 SDK의 API에는 세 가지가 없었다.
전환 중에 사용자에게 보여줄 것
금융사 콜백이 응답하지 않을 때의 처리
사용자가 지나온 화면의 기록첫 번째가 없었기 때문에 화면이 바뀌는 동안 사용자는 흰 화면을 봤다.
두 번째가 없었기 때문에 콜백에서 응답이 돌아오지 않으면 화면은 그대로 멈췄다. SDK는 Promise가 끝나기를 기다렸지만, 얼마나 기다리고 언제 실패로 판단할지 기준이 없었다.
세 번째가 없었기 때문에 실제로 사용자가 어떤 경로를 거쳤는지 운영 중에 확인할 수 없었다. 경로 기록은 이후 관측 설계를 보완하면서 추가했다. 앞에서 설명했듯 렌더 기록을 남기더라도 전환의 이유까지 알 수 있는 구조는 아니었다.
이 문제들은 이후 6~8편으로 이어진다. 당시 이 목록을 알고도 뺀 것은 아니었다. 우선 화면을 띄우는 데 집중하는 동안, 아직 질문으로 떠오르지 않았던 것들이다.
첫 금융사 이후, 책임 경계가 남았다
두 달 뒤 1금융권 은행 한 곳에 SDK가 적용됐다. 약정률은 30% 가까이 개선됐다.
신청 화면을 우리 앱 안으로 가져오면 이탈을 줄일 수 있다는 가설을 뒷받침하는 결과였다. 첫 번째 목표는 달성했지만, 구조적으로 정리하지 못한 문제가 하나 남아 있었다. 책임 경계였다.
첫 금융사를 연동하는 동안에는 구현에 집중했다. 화면이 정확히 뜨고, 콜백이 연결되고, 금융사 개발자가 막히지 않도록 하는 데 시간을 썼다. 그 결과 무엇을 우리 책임으로 두고 무엇을 금융사 책임으로 둘지에 대한 정의는 비어 있었다.
이 공백이 어떤 문제를 만드는지는 실행 구조에서 드러났다. 화면은 금융사 도메인에서 실행되지만 사용자가 보고 있는 것은 우리 앱이었다. 금융사 서버 문제로 SDK JavaScript가 아예 실행되지 않아도 사용자에게는 우리 앱의 대출 화면이 열리지 않은 사건이었다. 원인을 소유한 쪽과 문제를 사용자에게 설명해야 하는 쪽이 달랐다.
미리 정하지 않은 책임 경계가 사라지는 것은 아니었다. 문제가 생기면 그 자리에서 누군가 책임져야 했고, 사용자는 내부의 원인보다 눈앞의 앱을 기준으로 판단했다.
이후 우리는 책임을 다시 정의했다. 통제할 수 없는 구간까지 사용자에게 설명해야 한다면, 적어도 그 구간에서 무슨 일이 일어났는지는 관측할 수 있어야 한다는 방향이었다. 이 판단이 호출 계약과 에러 관측 설계로 이어졌다.
정리
초기 구조에서 정한 것은 여섯 가지였다.
| 결정 | 근거 | 나중에 문제가 된 지점 |
|---|---|---|
| React 18 | 사내 디자인시스템이 React 기반 | 컴포넌트에 포함된 생명주기 가정 (8편) |
| 하나의 document 안에서 전환 | 반복 로딩 회피, 전환 설계, 상태 유지 | MPA 금융사 등장 (8·9편) |
| 렌더링 진입점 하나 | 타입 검증을 기대하기 어려운 환경, 두 달이라는 기한 | 큰 변경 없이 유지 |
| 그릴 자리를 금융사에게 받음 | 금융사 페이지 레이아웃을 모름 | DOM 수명을 금융사가 소유 (9편) |
| 금융사 정보를 런타임에 받음 | 금융사별 빌드 분기 회피 | 이후 URL 정보 추가 (6편) |
| 다음 화면을 금융사가 결정 | 금융사 업무 흐름 유지, 심사 판단의 소유권, 초기 범위 축소 | 결정과 실행의 분리, 전환 이유의 가시성 상실 (6편) |
앞의 다섯 가지는 화면을 그리는 구조에 관한 결정이었다. 기본 방향을 유지하면서 주변 계약을 보완할 수 있었다. 여섯 번째는 달랐다. 화면을 그리는 계약만 손보는 것으로는 부족했고, 플로우의 결정 권한을 다시 나눠야 했다.
화면을 그리는 일만 책임지면 된다는 판단이 틀린 이유는 그 일을 작게 봤기 때문만은 아니었다. 화면을 그리기 위해 무엇을 알아야 하는지 세지 않았기 때문이다. 다음 화면이 무엇인지 모르면 전환을 준비할 수 없고, Promise가 끝나지 않을 때 언제 실패로 판단할지 정하지 않으면 로딩을 끝낼 수 없고, 사용자가 어디까지 왔는지 모르면 어디서 실패했는지 설명할 수 없다. 뒤에 이어지는 편들은 이 목록을 하나씩 채워가는 과정이다.
다음 편에서는 금융사 전역 스타일이 우리 화면을 덮는 문제를 다룬다. 검증할 조합이 금융사 수와 화면 수의 곱으로 늘면서, 폐쇄망에 노트북을 들고 들어가 DevTools를 열어야 하는 일이 반복됐다.