요약: 웹 채팅 상담 SaaS의 임베드 위젯을 설계하면서 정한 구조를 정리했다. 고객사 페이지에는 5KB 이하 loader.js와 Shadow DOM 런처만 두고, 토큰·대화·API는 전부 별도 오리진의 iframe에 가둔다. Shadow DOM은 CSS 격리용일 뿐 보안 경계가 아니라는 점, postMessage의 origin+source 검증, 낙관적 UI의 중복 제거 키를 설계 단계에서 못 박은 이유를 다룬다.
이 글은 설계 편이다. 구현 전에 docs/06-widget-and-console.md에 정리한 결정과 그 이유를 기록한다. 구현 후기는 다음 글에서 다룬다.
문제: 남의 집에 코드를 심는다
우리가 만드는 것은 고객사 웹사이트에 위젯을 심어 방문자와 상담원이 실시간으로 채팅하는 SaaS다. 위젯은 고객사 페이지 안에서 돈다. 그래서 세 가지가 동시에 문제가 된다.
- 고객사의 전역 CSS가 우리 버튼을 깨뜨린다.
- 고객사 페이지에 XSS가 있으면 우리 세션 토큰이 같이 털린다.
- 위젯이 무거우면 고객사 페이지 로딩이 느려진다.
여기서 사고가 나면 계약이 날아가기 때문에 설계 원칙을 하나로 정했다. 격리를 최우선으로 한다.
고객사가 붙이는 설치 코드는 한 줄이다.
<script src="https://cdn.aifid.io/loader.js"
data-key="pub_xxxxxxxx"
async></script>
해결 1: 2단 구조 — loader.js와 iframe 앱
호스트 페이지에서 직접 실행되는 코드는 loader.js 하나로 제한했다. 번들은 gzip 5KB 이하가 목표이고, 하는 일은 넷뿐이다.
data-key읽기- Shadow DOM 컨테이너를 만들고 런처 버튼 렌더
- 런처 클릭 시 iframe 삽입
postMessage릴레이 (미읽음 배지, 창 크기 조절, 페이지 URL 전달)
실제 채팅 UI는 전부 별도 오리진의 iframe 앱(Preact, 60KB gzip 이하 목표)에서 실행된다.

Shadow DOM은 보안 경계가 아니다
처음엔 Shadow DOM으로 격리하면 된다고 생각하기 쉽다. 하지만 이건 CSS 격리용이다.
const host = document.createElement('div');
host.id = 'aifid-root';
const shadow = host.attachShadow({ mode: 'closed' });
document.body.appendChild(host);
고객사의 * { box-sizing: ... }나 div { font-size: 20px } 같은 전역 스타일이 런처를 깨뜨리는 것은 막아 준다. mode: 'closed'는 host.shadowRoot를 null로 만들어 고객사 스크립트가 실수로 내부를 건드리는 사고도 줄인다.
그러나 loader.js는 호스트 페이지와 같은 JS realm에서 실행된다. 고객사 스크립트가 Element.prototype.attachShadow를 먼저 후킹하거나 우리 클로저에 접근하면 우회된다. 그래서 closed는 사고 방지용이지 방어선이 아니다.
여기서 나온 규칙이 이 설계의 핵심이다.
- 실제 보안 경계는 별도 오리진 iframe 하나뿐이다.
- 런처 버튼 외의 어떤 것도 Shadow DOM에 두지 않는다. 토큰이나 메시지가 호스트 realm에 잠깐이라도 존재하면 그 순간 격리가 끝난다.
해결 2: postMessage 규약과 두 겹 검증
loader와 iframe은 메시지 타입을 고정해 두고만 통신한다.
// loader → iframe
{ type: 'aifid:init', payload: { key, identity, context, pageUrl, referrer, locale } }
{ type: 'aifid:visibility', payload: { visible: boolean } }
// iframe → loader
{ type: 'aifid:resize', payload: { width, height } }
{ type: 'aifid:unread', payload: { count } }
{ type: 'aifid:close' }
수신 쪽은 origin만 보지 않고 발신 창까지 대조한다.
// loader.js — iframe에서 오는 메시지 수신
window.addEventListener('message', (e) => {
if (e.origin !== WIDGET_ORIGIN) return; // 오리진
if (e.source !== iframeEl.contentWindow) return; // 발신 창까지 대조
// ...
});
origin만으로 부족한 이유는 두 가지다. 고객사가 위젯을 두 번 심는 실수처럼, 호스트 페이지에 우리와 같은 오리진의 iframe이 여러 개 있을 수 있다. 또 공격자가 우리 오리진의 다른 페이지를 팝업으로 열어 메시지를 보낼 수도 있다. event.source까지 확인하면 "내가 만든 그 iframe이 맞는지"가 확정되어 이 경로가 닫힌다.
발신할 때도 대상 오리진을 와일드카드로 두지 않는다.
iframeEl.contentWindow.postMessage(msg, WIDGET_ORIGIN); // '*' 금지'*'로 보내면 iframe이 리다이렉트된 상황에서 identity(회원 ID, 이메일, user_hash)가 제3자 오리진으로 넘어간다.
해결 3: 고객사가 넘기는 데이터 — identity와 context
로그인 회원을 식별하려면 고객사가 window.AIFid.identity를 설정한다. user_hash는 고객사 서버에서 HMAC-SHA256으로 만든다. 브라우저에서 만든 값은 믿을 수 없기 때문이다.
상담 1건에만 관련된 데이터(방문 중이던 상품, 송장 정보)는 window.AIFid.context로 받는다.
<script>
window.AIFid = window.AIFid || {};
window.AIFid.context = [
{ category: "shipping",
data: { order_id: "A20260728-1", invoice_no: "123456789", carrier: "cj" } },
{ category: "product",
data: { id: "P-1001", name: "무선 이어폰" } }
];
</script>
설계 판단은 두 가지다.
- 느슨한 검증.
product,shipping,order를 추천 카테고리로 문서화하되, 임의 이름도 그대로 저장한다. 상담원 콘솔은 구분 없이 key-value로 보여 준다. 고객사마다 필드가 다르기 때문에 필드를 강제하지 않았다. - 대신 크기는 엄격하게. 상담당 카테고리 10개,
category1~50자,data는 비어 있지 않은 JSON 객체이며 하나당 4KB 이하다. 하나라도 어기면422로 응답하고 상담 자체를 만들지 않는다.
context는 새 상담을 시작할 때만 실리고 기존 상담을 이어갈 때는 무시된다. 전역 변수는 window.AIFid 하나만 노출한다.
해결 4: 방문자 식별과 스토리지 폴백
visitor_uid는 호스트가 아니라 iframe 오리진의 localStorage에 둔다. 같은 방문자가 여러 고객사 사이트를 다녀도 우리 쪽에서는 일관되게 식별된다.
문제는 Safari ITP 같은 서드파티 스토리지 차단이다. 폴백 순서를 정했다.
localStorage- 실패 시
sessionStorage - 그것도 실패하면 메모리 (새로고침하면 대화 이력 소실)
3번에서는 "브라우저 설정으로 인해 이전 대화를 불러올 수 없습니다"라고 명시적으로 안내한다. 조용히 새 대화를 만들면 사용자가 혼란스러워하기 때문이다.
해결 5: 세션 수립 흐름
① POST /api/v1/widget/session
{ widget_key, visitor_uid, identity? }
→ { session_token, visitor, inbox_config }
② POST /api/v1/widget/realtime-token
→ { token, ws_url }
③ WebSocket 연결 → room.join(t{tid}:conv:{id})
④ GET /conversations/{id}/messages?after_seq=0 → 초기 로드
①의 session_token(위젯 REST 호출용)과 ②의 실시간 토큰은 서로 다른 토큰이다. 대체할 수 없게 나눈 것은 한쪽이 유출돼도 피해 범위를 줄이기 위해서다. 새 상담을 만드는 경우에만 ①과 ② 사이에 POST /widget/conversations가 끼어든다.
③에서 흔한 실수도 문서에 박아 뒀다. Socket.IO 기본 path는 /socket.io/라서 /ws 프록시에 닿지 않는다. 그래서 ws_url과 ws_path를 응답에서 받아 그대로 쓰고 하드코딩하지 않는다.
해결 6: 낙관적 UI는 "어느 쪽이 먼저 와도 같은 결과"
REST 쓰기 지연을 감추려고 낙관적 UI를 쓴다. 전송하면 client_msg_id(UUID)를 만들어 즉시 "전송중"으로 그리고, 응답이 오면 서버 id·seq로 교체한다. 그런데 같은 메시지가 WebSocket으로도 한 번 더 온다. 이 순서가 보장되지 않는다.

중복 제거 키는 반드시 client_msg_id다. 서버 id로는 안 된다. 화면에 이미 그려진 낙관적 메시지에는 서버 id가 아직 없기 때문이다. 두 경로가 하나의 병합 함수를 공유한다.
function upsertMessage(list: Message[], incoming: Message): Message[] {
const idx = list.findIndex((m) =>
(incoming.client_msg_id && m.client_msg_id === incoming.client_msg_id) ||
(incoming.id && m.id === incoming.id)
);
if (idx === -1) {
return [...list, incoming].sort((a, b) => a.seq - b.seq);
}
// 이미 있으면 교체 — 낙관적(pending) 항목이 서버 확정본으로 승격된다
const next = [...list];
next[idx] = { ...next[idx], ...incoming, pending: false };
return next.sort((a, b) => a.seq - b.seq);
}
client_msg_id를 먼저 보고 없으면 id로 떨어지는 순서가 중요하다. 남이 보낸 메시지에는 나에게 의미 있는 client_msg_id가 없으므로 id로 처리된다.
여기에는 계약 측 조건이 하나 붙는다. 서버가 message.created 이벤트 페이로드에 client_msg_id를 실어 주지 않으면 클라이언트가 아무리 잘 짜도 이 버그를 못 막는다. 그래서 이 필드를 docs/02-contracts.md에 명시했다.
설계 단계에서 정해 둔 위젯 체크리스트
- 번들 60KB gzip 이하 (Preact + 자체 코드), loader.js는 5KB 이하
- 호스트 페이지에 노출하는 전역은
window.AIFid하나 prefers-reduced-motion존중- 키보드 접근성: Tab 순환, ESC로 닫기, 포커스 트랩
- 모바일 가상 키보드가 입력창을 가리지 않을 것 (
visualViewport) - 오프라인/재연결 상태 표시
- 영업시간 외 안내 메시지 (인박스 설정 기반)
배운 점
- 임베드 위젯 설계는 "무엇을 호스트 realm에 둘 것인가"를 정하는 일이다. 답은 거의 아무것도 두지 않는다였다.
- Shadow DOM은 스타일 방패다. 보안은 오리진이 한다. 두 개를 섞어 생각하면 토큰을 잘못된 곳에 두게 된다.
- postMessage는
origin,source,targetOrigin세 가지를 모두 지켜야 닫힌다. - 낙관적 UI의 중복 제거는 클라이언트만의 문제가 아니라 서버 계약(
client_msg_id를 이벤트에 싣기)까지 걸친 문제다.
다음 글에서는 구현하면서 설계와 달라진 부분과 loader.js 크기 예산을 지킨 과정을 다룬다.

댓글 0개