외부 환경에서 실행되는 SDK를 설계하는 법

커맨드 큐, 그리고 해시 파일명의 딜레마

script 태그 하나로 배포하기

인앱약정 SDK 시리즈 4편. 번들러 없이 script 태그 하나로 배포되는 SDK를 운영하면서 겪은 로딩 타이밍 장애와 배포 구조 문제를 다룬다. 호출 시점과 파일명이라는 변수를 금융사에 맡기지 않기 위해 어떤 구조를 선택했는지 기록한다.


사무실에서는 재현되지 않는 장애

장애 제보는 늘 비슷한 형태였다.

“특정 금융사에서 약정 화면이 하얗게 뜹니다.”

이상한 점은 재현이 어렵다는 것이었다.

우리 개발 환경에서는 정상적으로 동작했다. 금융사 개발자가 자기 자리에서 확인해도 문제가 없었다. QA 환경에서도 문제가 나타나지 않았다.

처음에는 금융사 페이지 내부의 다른 스크립트와 충돌을 의심했다. 이전 편에서 다룬 CSS 충돌처럼, 이번에도 외부 환경이 원인일 가능성을 먼저 확인했다.

하지만 로그를 추가하고 나서 원인이 드러났다.

문제는 SDK 내부 코드가 아니었다.

SDK가 아직 준비되지 않은 순간에 금융사 코드가 SDK를 호출하고 있었다.

기존 연동 방식은 단순했다.

html
<script type="module" src="/static/loan-sdk.js"></script>

<script>
  LoanSDK.renderPage({
    page: "agreement"
  });
</script>

금융사 개발자 입장에서는 자연스러운 코드였다.

스크립트를 추가했고, 다음 줄에서 함수를 호출했다.

문제는 브라우저 실행 순서가 항상 동일하지 않다는 점이었다.

SDK 스크립트는 type="module"로 불러왔다. 모듈 스크립트는 병렬로 받아오고 문서 파싱이 끝난 뒤에 실행된다. 반면 그 아래 인라인 호출 코드는 일반 script라 파서가 만나는 즉시 실행된다. 둘 사이의 순서를 맞추는 장치는 없었다.

사내망이나 개발 환경, QA 환경에서는 이 문제가 나타나지 않았다.

하지만 실제 사용자의 환경, 특히 지하철 LTE나 느린 와이파이에서는 호출 코드가 SDK보다 먼저 실행되는 일이 실제로 벌어졌다.

결과는 단순했다.

text
LoanSDK is not defined

페이지는 그대로 멈췄다.

원인은 명확했다.

우리는 SDK 로딩 완료 시점을 금융사 개발자의 코드 작성 순서에 맡기고 있었다.

하지만 이 SDK는 여러 금융사의 페이지 안에서 실행된다. 우리가 제어할 수 없는 환경에서 동작하는 코드라면, 실행 순서 역시 우리가 책임져야 했다.


브라우저는 HTML을 위에서 아래로 읽는다

이 문제를 해결하려면 먼저 브라우저가 script를 어떻게 실행하는지 이해해야 한다.

브라우저는 HTML을 위에서 아래로 파싱한다.

일반적인 script 태그를 만나면 HTML 파싱을 멈춘다.

html
<script src="app.js"></script>

브라우저는 다음 순서로 동작한다.

text
HTML 파싱

script 발견

파일 다운로드

JavaScript 실행

HTML 파싱 재개

반면 async와 defer는 다르다.

방식 다운로드 실행 시점 실행 순서
script HTML 파싱 중단 다운로드 완료 즉시 보장
async 병렬 다운로드 완료 즉시 보장 안 됨
defer 병렬 HTML 파싱 완료 후 보장

처음에는 금융사 가이드로 풀려고 했다.

“SDK 호출은 DOMContentLoaded 이벤트 안에서 실행해주세요.”

하지만 가이드는 오래 유지되지 않았다.

금융사 담당자가 바뀌었고, 새로운 화면을 개발하는 사람이 기존 가이드를 모르는 경우가 있었다. 지키는 곳도 있었고 지키지 않는 곳도 있었다. 같은 장애가 반복해서 들어왔다.

특히 SDK는 우리가 만든 단독 서비스가 아니다. 다른 회사의 웹 페이지 안에서 실행되는 코드다.

그렇다면 안정성을 문서에 의존할 수 없다.

호출 순서를 지키는 책임은 금융사가 아니라 SDK가 가져가야 했다.


우편함 패턴: 커맨드 큐 로더

해결 방법은 Google Analytics 같은 SDK들이 오래전부터 사용하던 방식이었다.

진짜 SDK가 오기 전에 아주 작은 스텁(stub)을 먼저 제공한다.

이 스텁은 실제 기능을 수행하지 않는다.

대신 호출 내용을 저장한다.

금융사 페이지에는 무거운 SDK 대신 작은 함수 하나만 먼저 설치된다.

javascript
<script>
(function (w, d) {
  w.LoanSDK = w.LoanSDK || function () {
    (w.LoanSDK.q = w.LoanSDK.q || []).push(arguments);
  };

  var script = d.createElement('script');

  script.async = true;
  script.src = '/static/loan-sdk.js';

  d.head.appendChild(script);
})(window, document);
</script>

이제 금융사는 SDK 준비 여부를 신경 쓰지 않아도 된다.

text
LoanSDK('renderPage', {
  page: 'agreement'
});

SDK가 아직 로드되지 않았다면?

큐에 저장한다.

비유하면 우편함과 같다.

가게 문이 아직 열리지 않았어도 손님은 주문서를 우편함에 넣을 수 있다. 가게가 열리면 직원이 우편함을 확인하고 순서대로 처리한다.

실제 SDK가 로드되면 가장 먼저 큐를 가져온다.

javascript
function flushQueue() {
  const queue = window.LoanSDK.q || [];

  window.LoanSDK = dispatch;

  queue.forEach(args => {
    dispatch(...args);
  });
}

구조가 이렇게 바뀐다.

기존:

text
금융사 코드

SDK가 이미 준비되어 있다고 가정

변경:

text
금융사 코드

SDK 큐

SDK 준비 완료

실제 실행

호출 시점이라는 변수를 금융사 환경에서 SDK 내부로 가져왔다.


DOM 준비까지 SDK가 책임진 이유

여기서 하나의 문제가 더 있었다.

SDK 파일이 로드됐다고 바로 화면을 그릴 수 있는 것은 아니다.

금융사 페이지 상황에 따라 DOM이 아직 준비되지 않았을 수 있다.

그래서 실제 실행 조건을 두 가지로 나눴다.

  1. SDK 번들이 로드 완료됐는가
  2. DOM을 사용할 수 있는 상태인가

둘 다 만족했을 때만 실제 초기화를 실행했다.

typescript
function flushQueue() {
  const queue = window.LoanSDK.q || [];

  window.LoanSDK = dispatch;

  queue.forEach(args => {
    dispatch(...args);
  });
}

if (document.readyState === 'loading') {
  document.addEventListener(
    'DOMContentLoaded',
    flushQueue
  );
} else {
  flushQueue();
}

중요한 점은 이것이다.

DOMContentLoaded 이후에 호출하라는 가이드를 만든 것이 아니다.

DOMContentLoaded 처리를 SDK 내부로 가져왔다.

다만 이 게이트가 보장하는 것은 문서 전체가 파싱됐다는 사실뿐이다. DOMContentLoaded 이후에 새로 생기는 target이나 개별 targetId의 존재 여부는 여기서 보지 않는다. 그 확인은 2편에서 다룬 renderPage() 호출 시점의 검증이 맡는다.

금융사 개발자가 어떤 순서로 코드를 작성하든, 실제 실행 시점은 SDK가 결정한다.

이 구조를 적용한 이후 이전처럼 “특정 환경에서만 재현되는” 로딩 장애는 사라졌다.

문제의 원인이 스크립트 실행 순서였던 만큼, 환경이 달라도 실행 순서가 같아지는 구조가 필요했다.


그런데 배포가 더 큰 문제였다

로딩 타이밍 문제를 해결하고 나니 다른 문제가 보였다.

이번에는 사용자가 아니라 운영팀과 개발팀이 겪는 문제였다.

배포였다.

우리 SDK는 React 기반 애플리케이션이었다. 화면이 많아지면서 초기 로딩 비용을 줄이기 위해 코드 스플리팅을 적용했고, 하나의 번들이 아니라 여러 개의 청크 파일로 나뉘어 배포됐다.

그리고 프론트엔드 빌드의 기본 방식대로 파일명에는 해시가 붙었다.

text
loan-sdk.js

chunk.a1b2c3.js
agreement.d4e5f6.js
terms.g7h8i9.js

파일 내용이 바뀌면 해시도 바뀐다.

일반적인 웹 서비스에서는 좋은 구조다.

하지만 우리 SDK 환경에서는 문제가 있었다.

금융사 페이지가 직접 파일명을 알고 있어야 했기 때문이다.

기존 구조는 이랬다.

text
금융사 HTML

loan-sdk.a1b2c3.js

chunk.d4e5f6.js

우리가 새 버전을 배포하면:

text
loan-sdk.a1b2c3.js

loan-sdk.x7y8z9.js

파일명이 바뀐다.

그러면 금융사 HTML도 같이 변경해야 한다.

일반 서비스라면 CI/CD 한 번으로 끝날 일이다.

하지만 금융권에서는 그렇지 않았다.

변경 사항 검토, 테스트, 승인, 배포 일정 조율이 필요했다.

우리 쪽 버그 수정 하나가 15개 금융사의 배포 일정에 묶였다.

코드 수정 시간보다 배포 조율 시간이 길어지는 구조였다.

여기서 문제가 하나 더 있었다.

우리가 금융사에게 전달하는 것은 단순한 JavaScript 파일 하나가 아니었다.

React 앱, 청크 파일, 정적 리소스까지 포함된 하나의 배포 패키지였다.

즉 우리 배포 속도가 금융사의 운영 프로세스 속도를 따라가야 했다.

이 구조는 SDK라는 형태와 맞지 않았다.

SDK는 설치 이후 제공자가 빠르게 개선할 수 있어야 한다.

그런데 우리는 업데이트할 때마다 연동사를 다시 움직이고 있었다.


해시는 왜 붙이는가

이 문제를 풀려면 먼저 해시 파일명이 왜 필요한지부터 봐야 한다.

해시 파일명의 목적은 하나다.

캐시 무효화다.

브라우저는 정적 파일을 캐시한다.

문제는 파일 내용이 변경됐을 때다.

같은 파일명이라면 브라우저는 기존 캐시를 사용할 수 있다.

예를 들어:

text
loan-sdk.js

라는 파일이 있다.

기존:

javascript
console.log("version 1");

배포 후:

javascript
console.log("version 2");

로 변경했다.

하지만 브라우저 입장에서는 파일명이 동일하다.

캐시가 남아 있다면 사용자는 여전히 이전 버전을 볼 수 있다.

그래서 등장한 방식이 파일명에 콘텐츠 해시를 넣는 것이다.

text
loan-sdk.a1b2c3.js

파일 내용이 바뀌면:

text
loan-sdk.x7y8z9.js

새로운 이름이 된다.

브라우저 입장에서는 완전히 다른 파일이다.

기존 캐시와 충돌하지 않는다.

그래서 해시 파일명은 매우 강력하다.

하지만 한 가지 조건이 있다.

파일명을 변경할 수 있는 주체가 우리여야 한다.

우리 서비스라면 문제가 없다.

빌드하고 배포하면 된다.

하지만 SDK 소비자가 HTML을 관리하는 구조에서는 문제가 된다.

금융사 HTML이 변경 가능한 포인터 역할을 해야 하기 때문이다.


포인터를 한 단계 내리기

해결 방법은 간단했다.

금융사 HTML이 직접 변하는 파일을 가리키지 않게 만들었다.

변경 가능한 포인터를 한 단계 아래로 내렸다.

구조를 이렇게 바꿨다.

기존:

text
금융사 HTML

해시 파일

변경:

text
금융사 HTML

고정 이름 엔트리 파일

해시 청크

즉 금융사 HTML에는 항상 같은 파일만 넣는다.

html
<script src="/static/loan-sdk.js"></script>

이 파일명은 절대 바뀌지 않는다.

대신 내부에서 최신 청크를 바라본다.

text
loan-sdk.js



chunk.a1b2c3.js
agreement.d4e5f6.js
terms.g7h8i9.js

새 버전 배포:

text
loan-sdk.js



chunk.x7y8z9.js
agreement.k2l3m4.js
terms.n5o6p7.js

금융사 HTML은 그대로다.

바뀌는 것은 엔트리 파일 내부의 참조뿐이다.


캐시 전략을 파일 역할에 맞게 나누다

여기서 중요한 것은 모든 파일을 같은 방식으로 캐싱하면 안 된다는 점이다.

역할이 다르기 때문이다.

엔트리 파일:

text
loan-sdk.js

역할:

  • 최신 버전으로 연결하는 포인터
  • 내용이 자주 변경됨

따라서:

text
Cache-Control: no-cache

를 사용했다.

여기서 no-cache는 캐시를 사용하지 말라는 의미가 아니다.

정확히는:

“사용하기 전에 서버에 최신 상태인지 확인하라”

는 의미다.

브라우저는 ETag 등을 이용해 서버에 확인하고, 변경되지 않았다면 304 응답만 받는다.

매번 전체 파일을 다시 받는 것은 아니다.

반면 실제 코드 청크는 다르다.

text
chunk.a1b2c3.js

이 파일은 내용이 바뀌면 이름 자체가 바뀐다.

따라서:

text
Cache-Control: immutable

처럼 강한 캐시를 걸 수 있다.

정리하면:

text
금융사 HTML

loan-sdk.js
(no-cache)

해시 청크
(immutable)

이다.

캐시 무효화 전략을 파일 계층별로 다르게 적용한 것이다.


청크 경로 문제

여기서 또 하나의 문제가 있었다.

청크 파일을 어디서 가져올지 빌드 시점에는 알 수 없었다.

금융사마다 SDK 파일을 올리는 위치가 달랐다.

어떤 곳은:

text
/static/sdk/

어떤 곳은:

text
/resources/loan/

이었다.

빌드 시점에 절대 경로를 박아버리면 금융사마다 다른 빌드가 필요해진다.

그것은 다시 배포 지옥으로 돌아가는 길이었다.

그래서 청크 경로는 런타임에 결정하도록 했다.

엔트리 스크립트가 자기 자신이 어디에서 로드됐는지 확인하고, 그 위치를 기준으로 나머지 청크를 찾는다.

text
document.currentScript.src

loan-sdk.js 위치 확인

같은 경로 기준으로 chunk 로드

webpack에서는 publicPath 설정으로 처리했고, Vite 환경에서는 상대 경로 import 구조를 이용했다.

핵심은 같다.

배포 위치를 금융사 환경에 맞춰 고정하지 않고, 실행 시점에 알아내는 것.


가장 이상적인 구조는 CDN 로딩이었다

사실 가장 이상적인 구조는 따로 있었다.

금융사가 파일을 가지고 가지 않는 구조다.

html
<script src="https://cdn.company.com/loan-sdk.js"></script>

이 방식이면 금융사는 최초 연동 이후 다시 배포할 필요가 없다.

우리가 배포하면 모든 금융사에 즉시 반영된다.

실제로 많은 결제 SDK가 이런 방식을 사용한다.

하지만 우리 환경에서는 정적 리소스 제공 주체와 관련된 요구사항을 함께 검토해야 했다.

SDK 코드 자체가 금융사 도메인 환경에서 제공되어야 하는지, 외부 CDN 제공이 가능한지는 기술만으로 결정할 수 있는 문제가 아니었다.

그래서 당장 사용할 수 있는 구조는 아니었다.

대신 우리가 선택한 것은:

“금융사 HTML은 고정하고, 변경 가능한 부분만 우리 영역으로 가져오는 것”

이었다.


번들 밖으로 꺼내기

배포 부담을 줄이는 방법은 단순히 파일 구조만 바꾸는 것이 아니었다.

가장 좋은 배포는 배포하지 않아도 되는 구조다.

우리가 SDK를 운영하면서 발견한 것은, 모든 변경이 같은 성격을 가지고 있지 않다는 점이었다.

어떤 변경은 코드를 수정해야 한다.

예를 들면:

  • 새로운 화면 추가
  • 컴포넌트 구조 변경
  • 비즈니스 로직 수정
  • 인증 흐름 변경

이런 변경은 당연히 번들 배포가 필요하다.

하지만 실제 운영에서 자주 바뀌는 값은 따로 있었다.

  • 약정 안내 문구
  • 화면 노출 여부
  • 이벤트성 메시지
  • 금융사별 설정값
  • 기능 활성화 여부

이런 값까지 코드에 포함하면 작은 변경에도 새로운 번들을 만들어야 한다.

예를 들어:

javascript
const agreementMessage =
  "대출 약정 전 반드시 확인해주세요.";

이런 문구가 코드 안에 있다면 문구 하나를 바꾸기 위해:

text
코드 수정

빌드

번들 생성

금융사 배포

라는 전체 과정을 다시 거쳐야 한다.

그래서 변경 주기가 빠른 값은 번들 밖으로 꺼냈다.

SDK가 초기화될 때 서버에서 설정을 가져오도록 했다.

구조는 이렇게 바뀐다.

변경 전:

text
SDK 번들

├── React 코드
├── 컴포넌트
├── 화면 설정
└── 문구

변경 후:

text
SDK 번들

├── React 코드
└── 컴포넌트


API

├── 화면 설정
├── 문구
└── 기능 플래그

이렇게 하면 운영팀이 문구를 변경하거나 특정 기능을 켜고 끄는 경우 새로운 배포가 필요 없다.

물론 모든 것을 서버 설정으로 빼는 것은 좋은 설계가 아니다.

코드가 책임져야 하는 영역까지 설정으로 만들면 오히려 복잡해진다.

기준은 명확했다.

자주 바뀌지만 동작 방식은 바뀌지 않는 것만 설정으로 분리한다.

렌더링 방법은 코드가 결정하고, 렌더링할 내용과 활성화 여부는 서버가 결정하는 구조였다.

배포가 느린 환경에서는 이 경계가 중요하다.


로딩 속도를 위해 줄인 것들

script 태그 하나로 배포되는 SDK에서 중요한 것은 초기 로딩 비용이다.

일반적인 웹 서비스라면 사용자가 페이지 전체를 방문한다.

하지만 SDK는 다르다.

금융사 페이지 안에 필요한 순간에 삽입되고, 사용자가 약정 화면을 보기 위해 기다리는 구조다.

따라서 첫 화면까지 도달하는 시간이 중요했다.

우리가 줄인 지점은 크게 다섯 가지였다.


1. 스텁은 최대한 작게 유지했다

커맨드 큐 구조에서 금융사 HTML에 들어가는 코드는 항상 먼저 실행된다.

이 코드가 크면 의미가 없다.

우리가 먼저 로드하는 것은 애플리케이션이 아니다.

SDK가 준비되기 전까지 호출을 받아주는 작은 진입점이다.

그래서 스텁에는 다음만 남겼다.

text
- 전역 객체 생성
- 호출 큐 생성
- 실제 SDK script 삽입

React도, 화면 코드도, 라이브러리도 들어가지 않는다.

초기 비용은 최소화했다.


2. 페이지 단위 코드 스플리팅

SDK 내부에는 여러 화면이 존재했다.

약정 첫 화면, 상품 상세, 인증 화면, 완료 화면 등 모든 코드를 처음부터 받을 필요는 없었다.

기존:

text
loan-sdk.js

├── agreement
├── auth
├── complete
└── terms

변경:

text
loan-sdk.js

필요한 화면 청크만 로드

agreement.js
auth.js
complete.js

사용자가 약정 화면으로 들어왔다면 약정 관련 코드만 가져온다.

이 방식은 일반적인 SPA 코드 스플리팅과 같다.

하지만 SDK 환경에서는 효과가 더 컸다.

금융사 페이지에서 실행되는 코드는 사용자 입장에서 추가 비용이기 때문이다.


3. preconnect 적용

외부 도메인에서 SDK를 가져오는 경우 다운로드 자체보다 먼저 발생하는 비용이 있다.

DNS 조회.

TLS 연결.

HTTP 연결.

파일 다운로드 전에 이미 여러 단계가 필요하다.

그래서 금융사 연동 가이드에 preconnect를 포함했다.

html
<link rel="preconnect" href="https://cdn.example.com">

브라우저가 SDK 요청 전에 연결 준비를 할 수 있게 했다.

파일 크기를 줄이는 것만큼 중요한 것이 요청 시작 시간을 줄이는 것이었다.


4. 실행 환경에 맞춘 빌드

일반적인 웹 서비스는 최대한 많은 브라우저를 지원한다.

하지만 우리 SDK는 실행 환경이 명확했다.

금융사 앱 웹뷰와 최신 모바일 브라우저였다.

지원하지 않는 오래된 환경까지 모두 고려할 필요가 없었다.

그래서 레거시 브라우저 대응 코드를 제거했다.

대표적으로:

  • 불필요한 polyfill 제거
  • 낮은 브라우저 target 제거
  • 최신 JavaScript 문법 유지

이 작업만으로도 번들 크기를 줄일 수 있었다.

모든 사용자를 위한 코드는 항상 좋은 것이 아니다.

지원 환경이 명확하다면 그 환경에 맞춰 최적화하는 것이 더 나을 때가 있다.


5. 압축과 CDN

마지막은 기본적인 최적화였다.

정적 파일은 Brotli 압축을 적용했다.

그리고 금융사 위치와 관계없이 빠르게 받을 수 있도록 CDN을 사용했다.

SDK는 여러 금융사에서 사용되기 때문에 특정 서버 위치보다 안정적인 전달 구조가 중요했다.


로딩 구조를 다시 정리하면

최종 구조는 이렇게 정리된다.

text
금융사 HTML

<script>
  작은 스텁 설치
</script>



loan-sdk.js

- 고정 이름
- no-cache
- 최신 엔트리 역할



hash chunk

- immutable
- 장기 캐시



필요한 화면 코드만 로드

각 계층이 맡는 역할이 다르다.

금융사 HTML:

“SDK를 시작하는 방법만 알고 있다.”

엔트리 파일:

“최신 버전으로 연결한다.”

해시 청크:

“실제 애플리케이션 코드를 제공한다.”


정리

이번 편에서 다룬 두 문제는 겉보기에는 달랐다.

하나는 로딩 타이밍 문제였다.

다른 하나는 배포 문제였다.

하지만 원인은 같았다.

우리가 제어할 수 없는 영역에 중요한 결정을 맡기고 있었다.

첫 번째 문제에서는 호출 시점을 금융사 코드에 맡겼다.

그래서 네트워크 상황에 따라 장애가 발생했다.

해결 방법은 커맨드 큐였다.

호출을 SDK 내부에서 관리하도록 바꿨다.

두 번째 문제에서는 파일명 변경을 금융사 HTML에 맡겼다.

그래서 배포 때마다 금융사를 움직여야 했다.

해결 방법은 고정 엔트리 + 해시 청크.

변경되는 포인터를 우리 영역으로 가져왔다.

결국 두 문제의 해결 방향은 같았다.

외부 환경에 맡겨져 있던 변수를 SDK 내부 경계로 가져오는 것.

SDK는 일반 웹 애플리케이션과 다르다.

우리는 페이지의 주인이 아니다.

남의 페이지 안에서 실행되는 코드다.

그렇기 때문에 좋은 구조는 기능을 많이 제공하는 구조가 아니라, 우리가 통제할 수 없는 상황에서도 예측 가능한 결과를 만드는 구조다.

다음 편에서는 확장을 다룬다.

금융사가 하나에서 여럿으로 늘면서, 복사해서 시작한 코드를 계속 복사하는 방식이 어디까지 버틸 수 있었는지 이야기한다.