웹뷰 브릿지 프로토콜, SSOT로 관리하기

웹뷰 브릿지 프로토콜, SSOT로 관리하기

웹뷰와 네이티브 앱 사이의 브릿지 프로토콜을 YAML SSOT + Zod 검증 + Code Generator로 체계적으로 관리했던 경험을 공유해요.

9 min read

웹뷰와 네이티브 앱이 통신할 때, 흔히 이런 코드로 시작해요.

sendToNativeApp('paymentComplete', {
  status: 'success',
  orderId: order.id,
  paymentKey: key,
})

근데 이 문자열 하나가 잘못되면 어떻게 될까요?
에러도 없고, 경고도 없어요. 컴파일 단계에서 TypeScript는 이걸 잡아줄 수가 없어요.

프로토콜이 문자열로만 존재하면, 계약이 깨진걸 런타임에 가야 알 수 있어요.

저는 이 문제를 YAML SSOT + Zod 검증 + Code Generator 조합으로 해결했는데, 그 과정을 공유해보려 해요. SSOT -> Single Source of Truth, 단일 진실 원천

웹뷰와 네이티브 앱이 통신할 일은 생각보다 많아요.
예를 들어 PG사 결제를 웹뷰로 처리하면, 결제 완료 시점에 앱에 알려야 구독 갱신이나 화면 전환이 이어져요.

그러다 보면 웹뷰 하나에 paymentStarted, paymentComplete, requestCameraPermission... 이런 action들이 쌓여가고,
양쪽 코드베이스가 같은 문자열을 암묵적으로 공유하는 구조가 만들어져요.

기존 코드는 이런 구조예요.

// 웹앱
sendToNativeApp('paymentStarted', { orderId: order.id })
// 네이티브 앱
if (action == 'paymentStarted') {
  startPaymentFlow(data['orderId']);
}

웹앱과 앱이 같은 문자열을 암묵적으로 공유하는 구조예요.

근데 만약에 여기서 오타가 발생한다면?!

sendToNativeApp('paymentComplete', {
  status: 'success',
  ordreId: order.id, // orderId → ordreId 오타
  paymentKey: key,
})

앱은 paymentComplete를 받았지만 orderId가 없어요.
결제 성공은 도착했는데 어느 주문인지 알 수 없어요.
에러도 없고, 경고도 없어요. 조용히 넘어가다 나중에야 발견돼요.

물론 가장 먼저 생각나는 해결책인, action 문자열을 상수로 관리하는 방법도 있어요.

const BRIDGE_ACTIONS = {
  PAYMENT_COMPLETE: 'paymentComplete',
} as const

근데 이걸로는 부족해요.
이 브릿지 통신의 책임은 웹앱과 네이티브 앱 둘 다에 있어요.
한쪽에서 action을 바꾸면 다른 쪽도 같이 바꿔야 하는데, 그 둘은 다른 코드베이스예요.

결국 "payload 구조 바꿉니다~~" 같은 걸 노션 문서로 공유하거나 슬랙으로 알려야 하고,
그 과정에서 소통이 엇갈리면 바로 큰 문제가 생겨요.(휴먼에러를 항상 경계하라..)

그렇다면 웹앱도, 네이티브 앱도, 딱 한 곳만 바라보게 하면 되지 않을까요?!

어떻게,,?
프로토콜 원본을 한 곳에 두고, 거기서 TypeScript와 네이티브 코드를 뽑아내는 거예요.

원본 파일은 YAML로 정의했어요.

# bridge-contract.yaml
version: 1
wireFormat: actionData
messages:
  saveImage:
    data:
      url:
        type: string
        required: true
      fileName:
        type: string
        required: false
  paymentComplete:
    data:
      status:
        type: enum
        required: true
        values:
          - success
          - fail
      orderId:
        type: string
        required: false

data: none은 payload가 없는 action이에요.
payload가 있으면 data 아래에 field를 선언하고, typerequired를 명시해요.
typestring, number, boolean, enum 네 가지를 지원해요.

처음엔 yaml 패키지로 파싱만 해도 충분하다고 생각했어요.

import yaml from 'yaml'

const parsed = yaml.parse(source)

근데 ...

messages:
  saveImage:
    data:
      url:
        type: string
        required: true
        requred: true # 오타 -> 무시됨

YAML parser는 문법이 맞는지만 확인해요.
requred 같은 오타도 유효한 YAML이라 그냥 통과되고, 아무런 에러도 발생하지 않아요.

그래서 Zod 스키마 검증을 추가했어요.

// bridge-contract-core.mjs
import { z } from 'zod'

const identifierPattern = /^[a-z][a-zA-Z0-9]*$/

const fieldSchema = z
  .object({
    type: z.enum(['string', 'number', 'boolean', 'enum']),
    required: z.boolean(),
    values: z.array(z.string().min(1)).optional(),
  })
  .strict()
  .superRefine((field, context) => {
    if (field.type === 'enum' && (!field.values || field.values.length === 0)) {
      context.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['values'],
        message: 'enum field must declare non-empty values',
      })
    }
    if (field.type !== 'enum' && field.values !== undefined) {
      context.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['values'],
        message: 'values can be declared only for enum type',
      })
    }
  })

const contractSchema = z
  .object({
    version: z.literal(1),
    wireFormat: z.literal('actionData'),
    messages: z.record(z.string().regex(identifierPattern), messageSchema),
  })
  .strict()

.strict()를 통해 requred 같은 알 수 없는 key는 바로 에러로 떨어져요.

action 이름과 field 이름도 identifierPattern으로 camelCase만 허용하도록 했어요.
이제 잘못된 계약 파일이 들어오면 생성 전에 이런 메시지가 떠요.

Invalid bridge-contract.yaml:
- messages.saveImage.data.url.requred: Unrecognized key(s) in object: 'requred'

YAML이 문법적으로 유효하다고 해서 팀에서 사용중인 컨벤션과도 유효한가?

이건 아니기 때문에, DX를 위해서 이런 검증 단계가 꼭 필요하다고 생각해요.

이제 YAML 파일 하나에서 TypeScript와 네이티브 코드를 동시에 뽑아낼 수 있어요.

TypeScript 생성물은 이렇게 나와요.

// nativeAppBridge.generated.ts

export type NativeAppBridgeAction =
  | "saveImage"
  | "paymentComplete"
  | /* ... */;

export type NativeAppBridgePayloadMap = {
  saveImage: {
    url: string;
    fileName?: string;
  };
  paymentComplete: {
    status: "success" | "fail";
    orderId?: string;
  };
  // ...
};

sendToNativeApp은 요런 타입을 받게됩니다.

export const sendToNativeApp = <TAction extends NativeAppBridgeAction>(
  action: TAction,
  ...args: NativeAppBridgePayloadMap[TAction] extends undefined
    ? [data?: undefined]
    : [data: NativeAppBridgePayloadMap[TAction]]
): void => {
  /* ... */
}

이제 자동으로 생성된 파일을 활용하면 잘못된 호출은 컴파일 타임에 바로 잡혀요.

sendToNativeApp('helloWorld') // ❌ Type error
sendToNativeApp('saveImage', { wrongKey: '...' }) // ❌ Type error
sendToNativeApp('saveImage', { url: '...' }) // ✅

action 이름 오타와

action 이름 오타를 TypeScript가 잡아주는 화면

payload 값 오타도 컴파일 단계에서 바로 잡히는걸 볼 수 있어요.

payload enum 값 오타를 TypeScript가 잡아주는 화면

네이티브 앱 쪽도 같은 프로토콜 파일에서 코드를 생성해요.
웹앱과 네이티브 앱이 같은 원본을 바라보니까, 어느 한쪽이 바뀌면 생성물도 같이 바뀌어요.

Code Generator는 크게 두 부분으로 나뉘어요.
YAML을 파싱하고 코드를 생성하는 core와, 파일을 읽고 결과를 쓰는 CLI예요.

처음엔 이 둘이 스크립트 하나에 전부 뒤섞여 있었어요.

테스트를 붙이려다 보니 CLI side effect랑 순수 로직이 얽혀서 분리가 까다로웠어요.
그래서 둘로 분리하게됐어요.

scripts/
  bridge-contract-core.mjs       # parseContract, generateTypeScript, generateDart
  generate-bridge-contract.mjs   # CLI — 파일 읽기/쓰기, 경로, 터미널 출력

core는 string을 받아서 string을 내뱉는 순수 함수들이에요.
덕분에 Node 내장 test runner로 바로 테스트를 붙일 수 있었어요.

// bridge-contract-core.test.mjs
test('유효한 계약은 정상 파싱된다', () => {
  const contract = parseContract(validYaml)
  assert.strictEqual(contract.messages.length, 9)
})

test('알 수 없는 key는 에러를 낸다', () => {
  assert.throws(() => parseContract(yamlWithUnknownKey))
})

test('TypeScript 생성물에 action union이 포함된다', () => {
  const ts = generateTypeScript(contract, '1.0.0')
  assert.ok(ts.includes('NativeAppBridgeAction'))
})

추가 의존성 없이 node --test로 돌아가요.

웹뷰 브릿지를 예로 들었지만, YAML → 검증 → codegen 패턴이 닿는 범위는 더 넓어요.

저는 이 외에도 API 클라이언트 생성, 컴포넌트 생성에도 자주 활용중이에요.

정확한 프로토콜을 정해두지 않으면 브릿지 통신 같은 부분은 휴먼에러가 발생하기 쉬워요.(앱, 웹 개발자간) 프로토콜을 파일 하나에 모아두면, 깨졌다는 걸 런타임 전에 알 수 있어요.