C.W.K.
Stream
Lesson 09 of 09 · published

Anchor 9 — 세 개의 표시 모드: 미뤄둔 기능이 실제로 나왔을 때

~14 min · display-mode, dock, overlay, storage, v0.2, case-study

Level 0Extension 입덕
0 XP0/55 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Anchor 8 은 README 를 그대로 인용했어. dock 은 의도적으로 v1 밖이라고. 그리고 dock 이 나왔어 — overlay 까지 딸려서. 이 anchor 는 그 과정에서 확장이 배운 것."

표면 하나가 셋이 된 모양

ChromeEmbed v0.1 에서 피파가 나타날 수 있는 자리는 딱 하나였어. Chrome 이 직접 제공하는 side panel. v0.2 는 셋이고, 확장은 그걸 service worker 맨 위 배열 하나로 선언해.

이 배열이 생긴 것보다 하는 일이 많아. allowlist 이자 normalizer 의 기준점이고, 코드 읽는 사람이 제일 먼저 grep 할 이름이야. 버스를 타고 흐르는 mode 값, storage 에서 나온 값, popup 에서 들어온 값 전부 normalizeDisplayMode 를 통과하고 — 모르는 값은 에러가 아니라 'sidepanel' 이 돼. 몇 달째 깔려 있던 확장이 옛 버전이 써둔 mode 문자열을 읽었다고 죽어버리면 안 되니까.

렌더러가 하나가 아니라 둘

재밌는 건 세 모드가 한 구현의 세 변주가 아니라는 거야. 완전히 다른 두 경로로 갈려.

  • Side panel — Chrome 이 그려. 확장은 chrome.sidePanel.open({ windowId }) 를 부르고, 프레임도 너비도 닫기 버튼도 유지도 전부 브라우저 소관. Anchor 5 의 iframe bridge 그대로야.
  • Dock 과 overlaycontent script 가 그려. "페이지 위에 떠 있는 창" 용 Chrome API 는 없거든. 확장이 자기 요소를 주입하고, 좌표도 자기가 잡고, drag·resize 핸들도 자기가 달고, 크기도 자기가 기억해.

그래서 popup 의 토글 하나가 아키텍처 경계를 건너. Side panel 을 고르는 건 브라우저한테 하는 요청이고, dock 이나 overlay 를 고르는 건 남의 페이지 안에서 도는 스크립트한테 하는 요청이야. Anchor 3 이 만든 그 센서가, 이제 그 페이지에 그림도 그려.

Dock 은 페이지에 padding 을 물어주고 overlay 는 안 그러는 이유

Overlay 는 떠 있어. 사용자가 끌어다 놓은 자리에 페이지 위로 얹히고, 밑의 페이지는 그게 있는 줄도 몰라 — 근데 그래도 돼. 뭔가를 가리면 사용자가 옮기면 되니까.

Dock 은 아래 가장자리에 전체 너비로 붙어. 밑에서 끌어낼 수가 없어. 그냥 두면 모든 페이지의 아래 띠를 영구히 가리는데, 하필 거기가 제출 버튼이나 페이지네이션이 사는 자리야. 그래서 dock 경로는 자기 높이만큼 페이지에 bottom padding 을 주고, 사용자가 dock 을 늘리거나 줄일 때마다 ResizeObserver 로 그 padding 을 다시 맞춰.

그 observer 는 dock 모드일 때만 만들어지고 모드가 바뀌는 즉시 끊겨. 지금 보이지도 않는 기능 때문에 모든 페이지에서 ResizeObserver 하나가 계속 돌고 있으면, 그건 테스트엔 안 잡히고 사용자 배터리에는 잡히는 종류의 비용이야.

모드는 탭보다 오래 살아야 해

페이지 이동하면 초기화되는 표시 설정은 설정이 아니지. Dock 과 overlay 는 content script 안에 사는데 content script 는 페이지와 함께 죽어 — 그래서 모드는 chrome.storage.local 에 쓰고, service worker 는 그걸 promise 로 캐시해서 반복해서 읽을 때 storage 를 다시 치지 않아.

어느 storage 영역인지 봐. session 이 아니라 local. 같은 파일이 브라우저 session id 에는 chrome.storage.session 을 써. session id 는 브라우저 닫히면 죽어야 맞으니까. 표시 모드는 아니고. 같은 API, 반대 수명, 그리고 둘 중 뭘 고르냐는 storage 영역 이름을 뒤집어쓴 제품 결정이야.

Anchor 4 의 popup 이 된 것

모드가 셋이 되면 뭔가는 그중 하나를 골라야 하고 — 피파가 아직 안 열린 상태에서 사용자가 닿을 수 있는 표면은 툴바 popup 뿐이야. Anchor 4 의 여섯 줄짜리 doorway 가 커진 건 그래서지, 누가 popup 을 풍성하게 만들고 싶어서가 아니야. 모드 선택이 있을 자리가 거기밖에 없었어.

그래도 여전히 경험 표면이 되기를 거부해. 대화 없고, 메시지 미리보기 없고, 입력창 없어. 버튼 셋과 선택기 하나, 그리고 window.close(). Doorway 원리는 지켜졌고, 문 개수만 바뀌었어.

Anchor 8 이 가르칠 수 없었던 것

Anchor 8 은 dock 에 shadow root 와 높은 z-index, drag 핸들, 더 작은 route 를 가리키는 iframe 이 필요할 거라고 예측했어. 전부 맞았어 — Anchor 7 의 refactor 규율이 지켜졌고, 미뤄둔 기능은 스케치에 가깝게 착지했어.

스케치가 예측 못 한 건 overlay 야. Anchor 8 이 나열한 미뤄둔 기능 넷 중에 아예 없어. 그게 나온 건 content script 가 떠 있는 표면 하나를 그릴 수 있게 되는 순간 두 번째는 거의 공짜였기 때문이야 — 좌표 계산도, drag 핸들도, storage 층도, 모드 배선도 이미 다 있었거든. 비쌌던 건 "Chrome 이 우리 UI 를 그린다" 에서 "우리가 우리 UI 를 그린다" 로 건너가는 그 한 번이었어. 건너고 나면 모드는 싸.

이 퀘스트에서 들고 나갈 만한 모양이 이거야. 미뤄둔 기능 목록은 비용에 대한 추측이고, 비용은 움직여. 다른 이유로든 경계가 한 번 넘어간 순간, 그 목록을 다시 읽어봐. 거기 적힌 것 중 몇 개는 방금 거의 공짜가 됐고, 거기 안 적힌 것 중 몇 개는 이제 가질 만해졌을 수도 있어.

Code

모드 allowlist 와 normalizer (background.js)·javascript
const DISPLAY_MODES = ['sidepanel', 'dock', 'overlay'];
let displayModePromise = null;

function normalizeDisplayMode(value) {
  return DISPLAY_MODES.includes(value) ? value : 'sidepanel';
}

async function getDisplayMode() {
  if (!displayModePromise) {
    displayModePromise = (async () => {
      const storage = chrome.storage?.local;
      if (!storage) return 'sidepanel';
      const existing = await storage.get('pippaEmbedDisplayMode').catch(() => ({}));
      return normalizeDisplayMode(existing?.pippaEmbedDisplayMode);
    })();
  }
  return displayModePromise;
}

async function setStoredDisplayMode(mode) {
  const next = normalizeDisplayMode(mode);
  displayModePromise = Promise.resolve(next);
  await chrome.storage?.local?.set({ pippaEmbedDisplayMode: next }).catch(() => {});
  return next;
}
전환이 실제로 성공한 다음에만 저장 (background.js)·javascript
if (message?.type === 'pippa:set-display-mode') {
  const mode = normalizeDisplayMode(message.mode);
  setPanelModeForTab(mode, message.windowId, {
    openedByCaller: message.openedByCaller === true,
    allowReload: true,
  })
    .then(async (result) => {
      // 전환이 성공했을 때만 모드를 기억한다. 주입이 막힌 페이지에서
      // 확장이 자기가 dock 모드라고 착각한 채 남으면 안 되니까.
      if (result?.ok !== false) {
        await setStoredDisplayMode(mode);
      }
      sendResponse({ ...result, mode: result?.mode || mode });
    })
    .catch((err) => sendResponse({ ok: false, error: '...' }));
  return true;
}
Dock 은 아래에 붙고 페이지엔 padding 으로 갚는다 (content-script.js)·javascript
if (mode === 'dock') {
  embedRoot.style.left = '0';
  embedRoot.style.right = '0';
  embedRoot.style.top = 'auto';
  embedRoot.style.bottom = '0';
  embedRoot.style.borderRadius = '14px 14px 0 0';
  applyEmbedLayout();
  installEmbedHandles(mode);

  // Dock 이 페이지 아래 띠를 가리니까, 페이지엔 dock 높이만큼
  // padding 을 준다 — 드래그할 때마다 다시 맞춤.
  if (!dockResizeObserver && typeof ResizeObserver !== 'undefined') {
    dockResizeObserver = new ResizeObserver(() => {
      if (currentEmbedMode === 'dock' && embedRoot) applyDockPadding(embedRoot);
    });
    dockResizeObserver.observe(embedRoot);
  }
} else {
  // Dock 모드를 벗어나면 observer 를 끊는다. 안 보이는 기능 때문에
  // 도는 observer 는 이득 없는 비용.
  if (dockResizeObserver) {
    dockResizeObserver.disconnect();
    dockResizeObserver = null;
  }
}

External links

Exercise

지금 네 확장엔 UI 표면이 하나 있어. 미뤄두고 있는 두 번째 표면을 적어보고, 솔직하게 답해봐 — 사용자가 원하지 않아서 미룬 거야, 아니면 첫 번째가 만들기 싸서 미룬 거야? 그다음 세 번째 표면이 거의 공짜가 되려면 뭐가 한 번은 만들어져 있어야 하는지 나열해봐. 좌표, drag, storage, 모드 배선. 그 목록이 두 번째 표면의 진짜 값이야.
Hint
구분점은 이유가 사용자 얘기냐 네 얘기냐야. "사람들은 대체로 패널을 원해" 는 제품 이유고 1년 뒤에도 맞을 수 있어. "패널이 더 단순했어" 는 비용 이유고, 비용 이유는 누군가 다른 일 때문에 그 경계를 넘는 순간 유효기간이 끝나.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.