라이프사이클 & 통신

커스텀 필드의 라이프사이클, 통신 프로토콜, 데이터 흐름 이해하기

라이프사이클 & 통신

이 페이지에서는 커스텀 필드가 호스트 폼에 연결되는 방법, 수신하는 데이터, 그리고 iframe 로드부터 필드 제거까지의 통신 라이프사이클을 설명합니다.

연결

호스트 폼이 커스텀 필드를 로드하면 다음 순서로 진행됩니다:

  1. 호스트가 렌더 URL로 <iframe>을 생성하고 URL에 ?fieldId=xxx를 추가합니다
  2. iframe이 로드되면 SDK 생성자가 실행되어 MessagePort 수신을 대기합니다
  3. 호스트가 MessageChannel을 생성하고 postMessage를 통해 포트를 iframe에 전달합니다
  4. SDK가 포트를 수신하여 Cap'n Web RPC 세션을 수립합니다
  5. 호스트가 전체 초기화 페이로드와 함께 필드의 init()을 호출합니다

이 모든 과정은 자동으로 이루어집니다 — onInit 콜백만 등록하면 됩니다.

iframe loads
    │
    ▼
SDK waits for MessagePort ──── host transfers port via postMessage
    │
    ▼
RPC session established
    │
    ▼
host calls init(payload) ──── your onInit callback fires
    │
    ▼
Field is ready for interaction

핸드셰이크 계약

연결 과정 자체는 자동이지만, 필드는 세 가지 계약을 지켜야 합니다. 호스트는 init() 응답을 생존 신호로 사용합니다. 응답이 20초 ready 예산을 넘기면 정상 동작하는 필드에도 로딩 오류가 표시됩니다. 호스트는 전달한 포트를 유지하므로, 늦게라도 응답한 필드는 복구됩니다.

제한값
포트 재전송 간격5초 (최대 4회)
필드 ready 예산20초

WallaField는 모듈 최상위에서 생성하세요

호스트는 iframe의 load 이벤트에 맞춰 MessagePort를 전달합니다. postMessage는 나중에 등록된 리스너에게 재배달되지 않으므로, 프레임워크 마운트 훅(React useEffect, Vue onMounted) 안에서 SDK를 생성하면 포트를 놓칠 수 있습니다.

// ✅ 문서가 파싱되는 동안 실행됩니다 — load 이벤트 전에 리스너가 준비됩니다
const field = new WallaField();
createRoot(el).render(<App field={field} />);

// ❌ 첫 렌더 커밋 이후에 실행되므로 load 이벤트보다 늦을 수 있습니다
useEffect(() => { const field = new WallaField(); }, []);

생성자가 window.location을 읽고 message 리스너를 등록하므로 브라우저 전용입니다. SSR을 하는 프레임워크로 필드를 만든다면 이 모듈이 서버 렌더에 들어가지 않게 해야 합니다(client 전용 엔트리, SSR 끈 동적 import). typeof window 가드로 미루는 건 안 됩니다 — 리스너는 iframe load 이전에 붙어야 합니다.

호스트는 첫 전달을 포함해 최대 4회까지 5초 간격으로 시도하며, 필드가 응답하거나 20초 ready 마감에 도달하면 재시도를 멈춥니다. 첫 문서의 예산은 iframe의 src를 지정할 때 시작하므로 로드가 느릴수록 재시도 횟수가 줄어듭니다. 마감에는 로딩 오류를 표시하지만, 늦은 응답으로 복구할 수 있도록 전달한 포트는 유지합니다. 재시도는 안전망일 뿐 권장 패턴이 아닙니다.

onInit은 빨리 반환하세요

호스트의 init() 호출은 생존 확인을 겸합니다. SDK가 onInit 콜백을 동기로 호출하고, 그 호출이 반환해야 호스트가 필드를 살아있다고 판정합니다. onInit 안의 느린 동기 작업은 응답을 지연시킵니다. 20초 ready 예산이 지나면 호스트가 로딩 오류를 표시하지만, 같은 문서가 뒤늦게 응답하면 회복할 수 있도록 전달한 포트는 유지합니다.

// ✅ 필요한 값만 저장하고 반환한 뒤, 느린 작업은 그다음에
field.onInit(payload => {
  applyTheme(payload.theme);
  void loadCatalog().then(render); // 비동기 — ack를 붙잡지 않습니다
});

// ❌ 느린 동기 본문이 ack를 붙잡습니다
field.onInit(payload => {
  const rows = parseHugeDatasetSynchronously(payload.properties.data);
  renderEverything(rows);
});

비동기 작업을 시작하는 것은 무방합니다 — 콜백 안의 동기 작업만 문제입니다.

콜드 로드 예산

호스트는 iframe 로드 시작부터 init() 응답까지 20초를 줍니다. 실제 배포된 필드가 강하게 스로틀된 기기에서 콜드 로드 10.5초로 측정된 적이 있어 여유가 크지 않습니다 — 번들을 작게 유지하고 부팅 경로에서 외부 fetch를 기다리지 마세요.

Init 페이로드

onInit 콜백은 필드에 필요한 모든 정보가 담긴 FieldInitPayload 객체를 수신합니다:

field.onInit((payload) => {
  const {
    properties,   // Your custom field's configured properties
    value,        // Previously saved value (null if new)
    theme,        // Host form's visual theme
    locale,       // Current locale ('en', 'ko', 'ja', etc.)
    fieldInfo,    // { fieldId, formId, label }
    formContext,  // { fields: FieldSummary[], values: Record<string, unknown> }
    mode,         // 'live' or 'preview'
  } = payload;
});

모드

mode 필드는 필드가 렌더링되는 컨텍스트를 알려줍니다:

모드컨텍스트설명
live폼 응답자 화면실제 폼 작성 — 유효성 검사가 적용되고 값이 저장됩니다
preview대시보드 폼 에디터에디터 내 미리보기 — 시각적 확인 용도로만 사용됩니다

mode를 사용하여 기능을 조건부로 활성화하거나 비활성화할 수 있습니다:

field.onInit(({ mode }) => {
  if (mode === 'preview') {
    // Show placeholder content, disable heavy initialization
    return;
  }
  // Full initialization for live mode
});

값 흐름

커스텀 필드는 호스트 폼을 통해 값을 저장합니다. 값의 라이프사이클은 다음과 같습니다.

값 설정

// User interacts with your field → report the new value
field.setValue({ hex: '#FF6B6B', opacity: 0.8 });

setValue()를 호출할 때마다 호스트가 자동으로 저장합니다. 값은 JSON 직렬화 가능한 모든 타입 — 문자열, 숫자, 객체, 배열, null이 될 수 있습니다.

값 복원

폼이 다시 로드되거나 페이지 이동이 발생하거나 필드가 다시 렌더링되면, 호스트가 이전에 저장한 값과 함께 init()을 호출합니다:

field.onInit(({ value }) => {
  if (value) {
    // Restore your UI state from the saved value
    applyValue(value);
  }
});

외부 값 주입

호스트가 외부에서 값을 주입할 수도 있습니다 (예: URL 파라미터를 통한 사전 입력):

field.onValueSet((value) => {
  // Update your UI with the externally provided value
  applyValue(value);
});

호스트 측 제한

제한값
최대 값 크기64 KB (JSON 직렬화 기준)
디바운스 간격50 ms

50ms 이내의 연속 setValue() 호출은 디바운스되어 마지막 값만 저장됩니다. 대기 중인 값은 정리 시와 RPC 검증 전후에 반영됩니다.

유효성 검사

사용자가 폼을 제출하거나 다음 페이지로 이동하면 호스트가 유효성 검사를 요청합니다:

field.onValidate((submitType) => {
  // submitType: 'next' (page navigation) or 'submit' (final submission)

  const value = getCurrentValue();
  if (!value) {
    return {
      valid: false,
      errors: [{ message: 'This field is required' }]
    };
  }
  return { valid: true };
});

유효성 검사 규칙

  • 동기 및 비동기 콜백 모두 지원됩니다
  • onValidate 콜백이 등록되지 않으면 항상 유효한 것으로 처리됩니다
  • 콜백에서 예외가 발생하면 { valid: false }로 처리됩니다
  • 호스트는 10초 타임아웃을 적용합니다 — 콜백이 응답하지 않으면 유효성 검사에 실패합니다

비동기 유효성 검사

field.onValidate(async (submitType) => {
  const response = await fetch('/api/validate', {
    method: 'POST',
    body: JSON.stringify({ value: getCurrentValue() }),
  });
  const { ok, message } = await response.json();
  return {
    valid: ok,
    errors: ok ? undefined : [{ message }],
  };
});

높이 관리

커스텀 필드는 호스트에 높이를 명시적으로 알려야 합니다. iframe은 자동으로 리사이즈되지 않습니다.

// Set height after rendering content
field.setHeight(document.body.scrollHeight);

콘텐츠 크기가 변경될 때마다 setHeight()를 호출하세요 — 초기화 후, 섹션 확장/축소 시, 동적 콘텐츠 로드 후 등.

제한값
높이 범위0–50000 px
스로틀 간격100 ms

0–50000 범위를 벗어나는 값은 범위 내로 제한됩니다. 100ms 이내의 연속 호출은 스로틀됩니다.

파일 업로드

커스텀 필드는 호스트를 통해 바이너리 데이터를 업로드할 수 있습니다:

uploadBlob은 현재 호스트에 미구현 상태입니다

uploadBlob()은 아직 호출하지 마세요. 호출하면 런타임 에러가 발생합니다. 아래 예제는 의도된 사용법을 보여줍니다.

// Example: upload a canvas signature as PNG
const canvas = document.getElementById('signature');
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
const buffer = await blob.arrayBuffer();

const { url } = await field.uploadBlob(buffer, 'image/png', 'signature.png');
// url contains the uploaded file URL — store it in your value
field.setValue({ signatureUrl: url });

filename 파라미터는 선택사항입니다. 호스트가 실제 스토리지 업로드를 처리하고 공개 URL을 반환합니다.

소멸과 정리

필드가 폼에서 제거되거나 분기 로직에 의해 숨겨지면 호스트가 destroy()를 호출합니다:

field.onDestroy(() => {
  // Clean up resources: timers, event listeners, WebSocket connections, etc.
});

SDK 인스턴스를 수동으로 소멸시킬 수도 있습니다:

field.destroy();

이렇게 하면 MessagePort 리스너가 제거되고 RPC 세션이 종료됩니다.

조건부 렌더링

분기 로직으로 필드가 숨겨지면 iframe이 완전히 unmount됩니다 — CSS로 숨기는 것이 아닙니다. 필드가 다시 표시되면:

  1. 새 iframe이 생성됩니다
  2. SDK 생성자가 다시 실행됩니다
  3. 호스트가 이전에 저장된 value와 함께 init()을 호출합니다

이는 다음을 의미합니다:

  • 내부 UI 상태(커서 위치, 스크롤, 애니메이션)는 사라집니다
  • setValue()로 마지막에 전달한 시맨틱 value만 onInit을 통해 보존되고 복원됩니다

필드는 value만으로 완전히 재구성할 수 있도록 설계하세요.

포커스

호스트가 필드에 포커스를 요청할 수 있습니다 (예: 특정 페이지로 이동하거나 유효성 검사 실패 시):

field.onFocus(() => {
  document.getElementById('my-input').focus();
});

폼 컨텍스트

커스텀 필드는 다른 필드의 메타데이터와 값을 관찰할 수 있습니다. 제공되는 데이터는 커스텀 필드 버전에 설정된 observeFields 정책에 따라 달라집니다:

모드formContext.fieldsformContext.values활용 사례
none빈 배열빈 객체독립형 필드 (색상 선택기, 서명)
all전체 필드 (민감 필드 제외)전체 값요약, 계산 필드
configured작성자가 선택한 필드선택된 값특정 입력을 참조하는 필드

민감 필드 타입(SECRETS, PHONE_NUMBER, EMAIL)은 all 모드에서도 항상 폼 컨텍스트에서 제외됩니다.

Configured 모드 — 관찰할 필드 선언

커스텀 필드 버전의 observeFields.mode를 'configured'로 설정하고, properties 스키마에 _observedFields 배열을 선언하세요 (속성 스키마 → 다른 필드 참조 참조). 폼 작성 시점에 작성자가 어떤 필드를 관찰할지 선택하면, 런타임에 iframe은 그 결과를 타입이 지정된 ObservedFieldRef[]로 받습니다:

type ObservedFieldRef =
  | { kind: 'field';  id: string }     // 일반 필드, formContext.values[id]로 해석
  | { kind: 'hidden'; label: string }; // Hidden 필드, formContext.fields.find(f => f.label === label)로 해석

초기화 시점 활용 예시:

field.onInit(({ observedFields, formContext }) => {
  for (const ref of observedFields) {
    if (ref.kind === 'field') {
      const value = formContext.values[ref.id];
      // ...
    } else {
      const field = formContext.fields.find(f => f.label === ref.label);
      // ...
    }
  }
});

formContext로 받는 허용 집합은 observedFields보다 작을 수 있습니다 — 민감 타입은 자동으로 제외되고, 이미 삭제된 필드를 가리키는 참조는 해석되지 않습니다. 방어적으로 코딩하세요.

초기 컨텍스트 수신

field.onInit(({ formContext }) => {
  console.log('Fields:', formContext.fields);
  console.log('Values:', formContext.values);
});

값 변경에 반응

field.onFormValuesChanged((values) => {
  // Called when any observed field's value changes
  const total = Object.values(values)
    .filter(v => typeof v === 'number')
    .reduce((sum, v) => sum + v, 0);
  field.setValue(total);
});

에러 보고

호스트에 에러를 보고하여 폼 UI에 표시할 수 있습니다:

field.reportError({
  message: 'Failed to load external data',
  recoverable: true,  // true = user can retry, false = permanent failure
});

message는 호스트 측에서 500자로 잘립니다.

recoverable: false는 되돌릴 수 없습니다

호스트가 필드를 화면에서 제거하고 다시 살아나지 않습니다 — iframe 안에서 나중에 복구되더라도 그 결과는 응답자에게 표시되지 않습니다. 이 응답자에게 정말로 계속할 수 없을 때만 쓰세요. 재시도 가능한 문제(조회 실패, 일시적 네트워크 오류)는 recoverable: true로 보내면 필드 위에 안내만 뜨고 필드는 계속 동작합니다.

전체 라이프사이클 요약

1. iframe loads → SDK constructor waits for MessagePort
2. Host transfers MessagePort → RPC session established
3. Host calls init(payload) → onInit callback fires
4. User interacts → setValue() → host auto-saves
5. Other fields change → onFormValuesChanged(values)
6. Host requests focus → onFocus callback fires
7. Form submit/navigate → validate(submitType) → onValidate returns result
8. Field removed → destroy() → onDestroy fires → port closed

목차