<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>blanc</title>
    <link>https://1yoouoo.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 23 Aug 2026 16:59:34 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>여행 가고싶다</managingEditor>
    <image>
      <title>blanc</title>
      <url>https://tistory1.daumcdn.net/tistory/6261395/attach/d52d7bca42da49fbbcb1fa15d16248b5</url>
      <link>https://1yoouoo.tistory.com</link>
    </image>
    <item>
      <title>DPR(Device Pixel Ratio) 고해상도 이미지 처리</title>
      <link>https://1yoouoo.tistory.com/88</link>
      <description>&lt;p&gt;&amp;quot;Figma 이미지와 스크린샷 크기가 안 맞아서 비교가 안 돼요.&amp;quot;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;문제의 시작&lt;/h2&gt;
&lt;p&gt;pixelDiff는 디자인(Figma)과 실제 구현(스크린샷)을 픽셀 단위로 비교하는 도구다. 그런데 개발 초기부터 골치 아픈 문제가 있었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Figma에서 내보낸 이미지: 5760×3600px&lt;/li&gt;
&lt;li&gt;크롬 익스텐션으로 캡처한 스크린샷: 2880×1800px&lt;/li&gt;
&lt;li&gt;사용자가 직접 업로드한 이미지: 1440×900px&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;세 이미지 모두 &amp;quot;1440×900 프레임&amp;quot;을 캡처한 건데, 실제 픽셀 크기는 전부 다르다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;왜 이런 일이 발생하는가&lt;/h2&gt;
&lt;h3&gt;Figma API의 고해상도 내보내기&lt;/h3&gt;
&lt;p&gt;Figma API로 이미지를 요청할 때 &lt;code&gt;scale&lt;/code&gt; 파라미터를 지정할 수 있다. scale=4로 요청하면 원래 크기의 4배 해상도로 내보내진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Figma API 이미지 요청
const response = await figma.getImage(nodeId, {
  format: &amp;#39;png&amp;#39;,
  scale: 4  // 4x 해상도로 내보내기
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;1440×900 프레임을 scale=4로 요청하면 5760×3600px 이미지가 된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;브라우저 DPR과 스크린샷&lt;/h3&gt;
&lt;p&gt;브라우저에서 스크린샷을 찍으면 모니터의 DPR이 적용된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Chrome Extension - 스크린샷 캡처 서비스
export async function captureAndCrop(
  tabId: number,
  bounds: Bounds,
  devicePixelRatio: number,  // 2 (일반 레티나), 3 (아이폰), 1 (일반 모니터)
  viewport: Viewport
): Promise&amp;lt;CaptureResult&amp;gt; {
  // ...
  const dpr = devicePixelRatio;
  const cropX = bounds.x * dpr;
  const cropY = bounds.y * dpr;
  const cropWidth = bounds.width * dpr;
  const cropHeight = bounds.height * dpr;
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DPR=2인 맥북에서 1440×900 영역을 캡처하면 2880×1800px 이미지가 된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;수동 업로드는 원본 그대로&lt;/h3&gt;
&lt;p&gt;사용자가 직접 올린 이미지는 별도 처리 없이 원본 크기 그대로 저장된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;선택지 분석&lt;/h2&gt;
&lt;p&gt;이 불일치를 해결하는 방법은 크게 세 가지가 있다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;방식&lt;/th&gt;
      &lt;th&gt;장점&lt;/th&gt;
      &lt;th&gt;한계&lt;/th&gt;
      &lt;th&gt;적합한 상황&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;저장 시 정규화&lt;/td&gt;
      &lt;td&gt;비교가 단순해짐&lt;/td&gt;
      &lt;td&gt;원본 품질 손실, 되돌릴 수 없음&lt;/td&gt;
      &lt;td&gt;저장 공간이 제한적일 때&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;비교 시 정규화&lt;/td&gt;
      &lt;td&gt;원본 품질 유지, 유연함&lt;/td&gt;
      &lt;td&gt;비교 로직이 복잡해짐&lt;/td&gt;
      &lt;td&gt;고품질 원본이 필요할 때&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;메타데이터로 분리 관리&lt;/td&gt;
      &lt;td&gt;표시/비교 목적별 최적화 가능&lt;/td&gt;
      &lt;td&gt;스키마 변경 필요&lt;/td&gt;
      &lt;td&gt;다양한 소스를 다룰 때&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;선택: 메타데이터 기반 정규화&lt;/h2&gt;
&lt;p&gt;pixelDiff의 핵심 요구사항은 두 가지다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;사이드바에는 &amp;quot;논리적 크기&amp;quot;를 표시해야 한다 (1440×900)&lt;/li&gt;
&lt;li&gt;비교 시에는 정확한 픽셀 매칭이 필요하다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;저장 시 정규화를 선택하면 Figma의 4x 고해상도 이미지를 다운스케일해야 한다. 나중에 &amp;quot;고해상도로 비교하고 싶다&amp;quot;는 요구가 생기면 원본이 없어서 대응할 수 없다.&lt;/p&gt;
&lt;p&gt;반면 메타데이터로 scale 값을 따로 저장하면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;원본은 최대 해상도로 유지&lt;/li&gt;
&lt;li&gt;표시할 때는 scale로 나눠서 논리적 크기 계산&lt;/li&gt;
&lt;li&gt;비교할 때는 scale이 다르면 다운스케일로 맞춤&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;model Layer {
  width   Int    // 논리 픽셀 (표시용)
  height  Int    // 논리 픽셀 (표시용)
  scale   Float  @default(1)  // 배율 (Figma=4, 스냅샷=DPR, 수동=1)
  // 실제 이미지 크기 = width × scale, height × scale
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;구현: 소스별 처리&lt;/h2&gt;
&lt;h3&gt;Figma 이미지&lt;/h3&gt;
&lt;p&gt;Figma API는 &lt;code&gt;absoluteBoundingBox&lt;/code&gt;로 프레임의 논리적 크기를 알려준다. 이 값을 그대로 저장하고, scale=4를 기록한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;논리 크기: 1440×900 (DB 저장)
scale: 4
실제 이미지: 5760×3600&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;브라우저 스크린샷&lt;/h3&gt;
&lt;p&gt;익스텐션에서 캡처할 때 &lt;code&gt;window.devicePixelRatio&lt;/code&gt;를 함께 전송한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 익스텐션 → 웹앱 메시지
{
  imageData: &amp;quot;data:image/png;base64,...&amp;quot;,
  width: 2880,   // 실제 이미지 크기
  height: 1800,
  devicePixelRatio: 2
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;서버에서는 논리 크기로 변환해서 저장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// 저장 로직
const logicalWidth = Math.round(width / devicePixelRatio);  // 1440
const logicalHeight = Math.round(height / devicePixelRatio);  // 900
const scale = devicePixelRatio;  // 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;수동 업로드&lt;/h3&gt;
&lt;p&gt;사용자가 올린 이미지는 scale=1로 가정한다. 논리 크기 = 실제 이미지 크기.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;구현: Diff 비교 로직&lt;/h2&gt;
&lt;p&gt;두 이미지의 scale이 다를 때가 핵심이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Figma (scale=4): 5760×3600
스냅샷 (scale=2): 2880×1800&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;업스케일 vs 다운스케일 중 어떤 걸 선택해야 할까?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;업스케일의 문제&lt;/strong&gt;: 2880×1800 이미지를 5760×3600으로 키우면 없던 픽셀을 보간으로 생성해야 한다. 이 보간된 픽셀이 원본과 미세하게 달라서 &amp;quot;가짜 차이&amp;quot;가 발생할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;다운스케일 선택 이유&lt;/strong&gt;: 5760×3600을 2880×1800으로 줄이면 원본 정보만 사용한다. 정보 손실은 있지만, 없던 정보를 만들어내지는 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// useDiffCalculation.ts - 이미지 영역 추출
async function extractImageData(
  imageUrl: string,
  layerPosition: { x: number; y: number },
  layerSize: { width: number; height: number },  // 논리 크기
  intersectionRect: Rect
): Promise&amp;lt;ImageData&amp;gt; {
  // ...
  const img = new Image();

  img.onload = () =&amp;gt; {
    // 실제 이미지 크기와 논리 크기의 비율 = scale
    const scaleX = img.naturalWidth / layerSize.width;
    const scaleY = img.naturalHeight / layerSize.height;

    // 출력 크기는 논리적 교차 영역 크기 (다운스케일 대상)
    const outputWidth = Math.round(intersectionRect.width);
    const outputHeight = Math.round(intersectionRect.height);
    canvas.width = outputWidth;
    canvas.height = outputHeight;

    // 실제 이미지에서 추출할 영역 (고해상도)
    const srcWidth = Math.round(intersectionRect.width * scaleX);
    const srcHeight = Math.round(intersectionRect.height * scaleY);

    // 고해상도 → 논리 크기로 다운스케일하며 그리기
    ctx.drawImage(
      img,
      srcX, srcY, srcWidth, srcHeight,  // 고해상도 소스
      0, 0, outputWidth, outputHeight    // 논리 크기 대상
    );
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;scale이 다른 두 이미지를 비교할 때:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;둘 다 논리 크기(교차 영역)로 다운스케일&lt;/li&gt;
&lt;li&gt;같은 크기가 된 후 픽셀 비교&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;구현: 분할 캡처와 DPR&lt;/h2&gt;
&lt;p&gt;전체 페이지 캡처는 뷰포트 단위로 여러 타일을 찍어서 이어붙인다. 이때도 DPR 처리가 필요하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// tile-stitcher.ts
export async function stitchTiles(
  tiles: TileData[],
  viewport: Viewport,
  fullWidth: number,
  fullHeight: number
): Promise&amp;lt;string&amp;gt; {
  // 첫 번째 타일에서 DPR 역산
  const devicePixelRatio = tileImages[0].image.height / viewport.height;

  // 전체 캔버스 크기 = 논리 크기 × DPR
  const actualWidth = fullWidth * devicePixelRatio;
  const actualHeight = fullHeight * devicePixelRatio;

  // 타일 간 겹침 영역 계산도 DPR 적용
  const overlapPhysical = overlap * devicePixelRatio;
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;타일 스티칭에서 DPR을 잘못 처리하면 이미지가 어긋나거나 경계선이 보인다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;구현: Pixi.js 캔버스 렌더링&lt;/h2&gt;
&lt;p&gt;캔버스에 이미지를 렌더링할 때도 DPR을 고려해야 선명하게 보인다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// usePixiApp.ts
const app = new Application();

await app.init({
  resolution: window.devicePixelRatio || 1,  // DPR 적용
  autoDensity: true,  // CSS 크기와 캔버스 해상도 자동 매칭
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;autoDensity: true&lt;/code&gt;가 핵심이다. 이 옵션이 없으면 레티나 디스플레이에서 이미지가 흐릿하게 보인다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;핵심은 &amp;quot;논리 픽셀&amp;quot;과 &amp;quot;물리 픽셀&amp;quot;의 분리&lt;/h2&gt;
&lt;p&gt;DPR 문제를 다루면서 깨달은 건, 결국 두 개념을 명확히 분리하는 게 핵심이라는 것이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;개념&lt;/th&gt;
      &lt;th&gt;용도&lt;/th&gt;
      &lt;th&gt;예시&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;논리 픽셀&lt;/td&gt;
      &lt;td&gt;UI 표시, 좌표 계산, 사용자 인식&lt;/td&gt;
      &lt;td&gt;1440×900&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;물리 픽셀&lt;/td&gt;
      &lt;td&gt;실제 이미지 저장, 픽셀 비교&lt;/td&gt;
      &lt;td&gt;5760×3600&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;이 둘을 연결하는 게 &lt;code&gt;scale&lt;/code&gt; 값이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;물리 픽셀 = 논리 픽셀 × scale
논리 픽셀 = 물리 픽셀 ÷ scale&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style4&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;이 관계만 일관되게 유지하면 어떤 해상도의 이미지가 들어와도 대응할 수 있다. Figma가 scale=8을 지원하게 되어도, 새로운 디바이스가 DPR=4를 사용하게 되어도, scale 값만 제대로 기록하면 된다.&lt;/p&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FrontEnd</category>
      <category>Canvas</category>
      <category>chrome extension</category>
      <category>dpr</category>
      <category>Pixi.js</category>
      <category>이미지 처리</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/88</guid>
      <comments>https://1yoouoo.tistory.com/88#entry88comment</comments>
      <pubDate>Sat, 21 Mar 2026 18:00:57 +0900</pubDate>
    </item>
    <item>
      <title>Turborepo, 작은 프로젝트에서 쓸 이유가 있을까?</title>
      <link>https://1yoouoo.tistory.com/87</link>
      <description>&lt;h2&gt;Turborepo가 해결하려는 문제&lt;/h2&gt;
&lt;p&gt;Turborepo 공식 문서를 보면 이런 말이 나온다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;Turborepo is a high-performance build system for JavaScript and TypeScript codebases.&amp;quot;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;핵심 기능은 세 가지다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기능&lt;/th&gt;
      &lt;th&gt;설명&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;로컬/리모트 캐싱&lt;/td&gt;
      &lt;td&gt;변경 없는 패키지는 빌드 스킵&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;병렬 실행&lt;/td&gt;
      &lt;td&gt;의존성 없는 태스크 동시 실행&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;증분 빌드&lt;/td&gt;
      &lt;td&gt;변경된 패키지만 다시 빌드&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;대규모 모노레포에서 빌드 시간이 대폭 단축되는 사례가 많다. 팀원 간 리모트 캐시를 공유하면 CI 비용도 절감된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;근데 나는 그 규모가 아니다&lt;/h2&gt;
&lt;p&gt;pixelDiff의 현실을 보자.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pixelDiff/
├── apps/web          # Next.js 웹앱
├── extension         # Chrome 확장
├── packages/core     # 공유 유틸리티
├── packages/api-contracts  # API 타입
└── packages/scripts  # DB 스크립트&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;패키지 3개, 앱 2개. CI 기준 전체 빌드에 약 2분. 그 중 Next.js 빌드가 대부분이다.&lt;/p&gt;
&lt;p&gt;솔직히 말하면:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;캐싱? web 코드를 건드리면 어차피 web 빌드가 돌아간다. 그게 2분 중 대부분이다&lt;/li&gt;
&lt;li&gt;리모트 캐시? 혼자 개발하는데 누구와 공유하나&lt;/li&gt;
&lt;li&gt;병렬 실행 최적화? 패키지 3개에서 의미 없다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Turborepo가 자랑하는 핵심 기능들이 이 규모에서는 &amp;quot;있으면 좋은&amp;quot; 정도였다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;그럼에도 모노레포를 선택한 이유&lt;/h2&gt;
&lt;p&gt;Turborepo의 캐싱이 아니라, 모노레포 구조 자체가 해결해주는 문제가 있었다.&lt;/p&gt;
&lt;p&gt;혼자서 프론트엔드, 백엔드, 인프라를 다 만지는 상황. 가장 비싼 비용은 빌드 시간이 아니라 &lt;strong&gt;컨텍스트 스위칭&lt;/strong&gt;이었다.&lt;/p&gt;
&lt;p&gt;레포가 분리되어 있다고 가정해보자.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;프론트 작업 중
→ API 응답 타입 바꿔야 함
→ 백엔드 레포 열기
→ 타입 수정, npm publish  &amp;lt;&amp;lt; 좀 과장해서 (타입을 패키지로 관리했다고 가정)
→ 프론트 레포로 돌아와서 npm install
→ &amp;quot;아 뭐하고 있었지?&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;모노레포에서는:&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;프론트 작업 중
→ 옆 폴더 열어서 타입 수정
→ 바로 import해서 확인
→ 끝&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;팀이 있으면 역할이 나뉜다. 백엔드 담당자가 API를 관리하고, 프론트 담당자가 UI를 관리한다. 컨텍스트가 분산된다.&lt;/p&gt;
&lt;p&gt;혼자 하면 모든 컨텍스트가 내 머릿속에 있어야 한다. 모노레포는 그 머릿속을 물리적으로 한 폴더에 모아주는 것이다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;실제로 체감한 것들&lt;/h2&gt;
&lt;h3&gt;타입 변경이 즉시 반영된다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;api-contracts&lt;/code&gt; 패키지에 API 응답 타입을 Zod 스키마로 정의해뒀다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// packages/api-contracts/src/projects/schemas.ts
export const ProjectSchema = z.object({
  id: z.string(),
  name: z.string(),
  devUrl: z.string(),
  // ...
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;이걸 &lt;code&gt;apps/web&lt;/code&gt;의 API route와 프론트 컴포넌트에서 둘 다 import한다.&lt;/p&gt;
&lt;p&gt;스키마를 수정하는 순간, API route에서 타입 에러가 뜨고, 프론트 컴포넌트에서도 타입 에러가 뜬다. &amp;quot;배포했는데 타입 안 맞음&amp;quot; 사고가 구조적으로 막힌다.&lt;/p&gt;
&lt;h3&gt;원자적 커밋이 가능하다&lt;/h3&gt;
&lt;p&gt;API 엔드포인트를 바꾸면 프론트 호출부도 같이 바꿔야 한다. 모노레포에서는 이게 한 커밋이다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fix:[all] 프로젝트 상태 API 응답 구조 변경

- api-contracts: ProjectState 스키마 수정
- web/api: 응답 형식 변경
- web/components: 호출부 수정&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;레포가 분리되어 있으면 &amp;quot;백엔드 먼저 배포하고, 프론트 나중에 배포&amp;quot;라는 순서를 관리해야 한다. 혼자 하면 이거 실수하기 쉽다.&lt;/p&gt;
&lt;h3&gt;전체 검색이 진짜 전체 검색이다&lt;/h3&gt;
&lt;p&gt;&amp;quot;이 함수 어디서 쓰지?&amp;quot; → Cmd+Shift+F 한 번이면 프론트, 백엔드, extension 전부 나온다.&lt;/p&gt;
&lt;p&gt;레포가 나뉘어 있으면 각각 열어서 검색해야 한다. 사소해 보이지만, 하루에 수십 번 하는 동작이다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style4&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style4&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;Turborepo는 대규모 모노레포의 빌드 최적화를 위해 만들어졌다. 나는 그 규모가 아니다.&lt;/p&gt;
&lt;p&gt;그래도 괜찮다. 도구의 본래 목적과 내 사용 목적이 다를 수 있다.&lt;/p&gt;
&lt;p&gt;pnpm workspace로 모노레포 구조를 잡으면서 Turborepo를 얹는 건 거의 공짜다. &lt;code&gt;turbo.json&lt;/code&gt; 파일 하나 추가하면 끝이다. 캐싱이 당장 필요 없어도, 나중에 프로젝트가 커지면 그때 효과를 볼 수 있다.&lt;/p&gt;
&lt;p&gt;핵심은 Turborepo가 아니라 모노레포 구조 자체다. 혼자 개발할 때 컨텍스트 스위칭을 줄여주고, 타입 동기화를 강제하고, 원자적 커밋을 가능하게 해준다.&lt;/p&gt;
&lt;p&gt;&amp;quot;이 규모에서 모노레포가 필요할까?&amp;quot;라는 질문에 대한 내 답은: 캐싱 때문이 아니라 &lt;strong&gt;흐름을 유지하기 위해&lt;/strong&gt; 필요하다.&lt;/p&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FrontEnd</category>
      <category>1인개발</category>
      <category>pnpm workspace</category>
      <category>turborepo</category>
      <category>모노레포</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/87</guid>
      <comments>https://1yoouoo.tistory.com/87#entry87comment</comments>
      <pubDate>Thu, 19 Mar 2026 18:00:04 +0900</pubDate>
    </item>
    <item>
      <title>Zustand 복합 스토어 설계 패턴</title>
      <link>https://1yoouoo.tistory.com/86</link>
      <description>&lt;p&gt;상태 관리 라이브러리를 고를 때 Redux, Recoil, Jotai, Zustand 사이에서 고민하는 경우가 많다. 하나의 전역 스토어에 모든 상태를 넣을지, 아토믹하게 쪼갤지, 아니면 그 중간 어딘가를 선택할지는 프로젝트 규모와 팀 상황에 따라 다르다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;pixelDiff를 개발하면서 Zustand를 선택하고, &amp;quot;도메인 기반 복합 스토어&amp;quot; 패턴을 적용했다. 단순한 투두 앱이 아니라 캔버스 에디터 수준의 복잡도를 가진 프로젝트에서 상태 관리를 어떻게 설계했는지 정리한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;왜 Zustand인가&lt;/h2&gt;
&lt;p&gt;상태 관리 라이브러리 선택지를 비교해보면 이렇다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;라이브러리&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;적합한 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Redux&lt;/td&gt;
&lt;td&gt;단일 스토어, 보일러플레이트 많음&lt;/td&gt;
&lt;td&gt;대규모 팀, 엄격한 구조 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recoil&lt;/td&gt;
&lt;td&gt;아토믹, React 종속&lt;/td&gt;
&lt;td&gt;Facebook 생태계, Suspense 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jotai&lt;/td&gt;
&lt;td&gt;아토믹, 미니멀&lt;/td&gt;
&lt;td&gt;작은 단위 상태, 컴포넌트 로컬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zustand&lt;/td&gt;
&lt;td&gt;유연한 스토어, 보일러플레이트 적음&lt;/td&gt;
&lt;td&gt;중소규모, 빠른 프로토타이핑&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;pixelDiff는 캔버스 조작, 레이어 관리, 모드 전환, 키보드 단축키 등 여러 도메인이 얽혀 있다. 이런 프로젝트에서 아토믹 방식은 상태 간 관계 추적이 어렵고, 단일 거대 스토어는 관심사 분리가 안 된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Zustand는 &amp;quot;필요한 만큼만 분리하고, 필요할 때 조합한다&amp;quot;는 중간 지점을 제공한다. 보일러플레이트 없이 도메인별 스토어를 만들고, 필요하면 여러 스토어를 조합해서 쓸 수 있다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;도메인 기반 스토어 분리&lt;/h2&gt;
&lt;p&gt;pixelDiff의 스토어 구조는 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/index.ts
export { useLayerStore } from &amp;#39;./layerStore&amp;#39;;    // 레이어 CRUD + undo/redo
export { useCanvasStore } from &amp;#39;./canvasStore&amp;#39;;  // pan, zoom, preset
export { useModeStore } from &amp;#39;./modeStore&amp;#39;;      // comparison/edit/diff mode
export { useSelectionStore } from &amp;#39;./selectionStore&amp;#39;; // 선택 상태
export { useUIStore } from &amp;#39;./uiStore&amp;#39;;          // UI 임시 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;분리 기준은 &amp;quot;누가 이 상태를 소유하는가&amp;quot;다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;스토어&lt;/th&gt;
&lt;th&gt;소유 도메인&lt;/th&gt;
&lt;th&gt;영속성&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LayerStore&lt;/td&gt;
&lt;td&gt;캔버스 위 레이어들&lt;/td&gt;
&lt;td&gt;DB 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CanvasStore&lt;/td&gt;
&lt;td&gt;뷰포트 조작&lt;/td&gt;
&lt;td&gt;DB 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ModeStore&lt;/td&gt;
&lt;td&gt;비교/편집 모드&lt;/td&gt;
&lt;td&gt;DB 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SelectionStore&lt;/td&gt;
&lt;td&gt;현재 선택된 요소&lt;/td&gt;
&lt;td&gt;세션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UIStore&lt;/td&gt;
&lt;td&gt;드롭다운, 패널 열림&lt;/td&gt;
&lt;td&gt;세션&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;DB에 저장할 상태와 세션에서만 유지할 상태를 명확히 나눠두면, 영속성 로직이 단순해진다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;단일 스토어의 기본 구조&lt;/h2&gt;
&lt;p&gt;각 스토어는 동일한 패턴을 따른다. Types, State, Actions를 명확히 분리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/canvasStore.ts
import { create } from &amp;#39;zustand&amp;#39;;

// ──────────────────────────────────────
// Types
// ──────────────────────────────────────

interface CanvasState {
  pan: { x: number; y: number };
  zoom: number;
  preset: { id: string; width: number };
}

interface CanvasActions {
  setPan: (pan: Partial&amp;lt;{ x: number; y: number }&amp;gt;) =&amp;gt; void;
  setZoom: (zoom: number) =&amp;gt; void;
  setTransform: (transform: { x?: number; y?: number; zoom?: number }) =&amp;gt; void;
  reset: () =&amp;gt; void;
}

// ──────────────────────────────────────
// Initial State
// ──────────────────────────────────────

const initialState: CanvasState = {
  pan: { x: 0, y: 0 },
  zoom: 1,
  preset: { id: &amp;#39;desktop-fhd&amp;#39;, width: 1920 },
};

// ──────────────────────────────────────
// Store
// ──────────────────────────────────────

export const useCanvasStore = create&amp;lt;CanvasState &amp;amp; CanvasActions&amp;gt;()(
  (set, get) =&amp;gt; ({
    ...initialState,

    setPan: (pan) =&amp;gt;
      set((state) =&amp;gt; ({ pan: { ...state.pan, ...pan } })),

    setZoom: (zoom) =&amp;gt; set({ zoom }),

    setTransform: (transform) =&amp;gt;
      set((state) =&amp;gt; ({
        pan: {
          x: transform.x ?? state.pan.x,
          y: transform.y ?? state.pan.y,
        },
        zoom: transform.zoom ?? state.zoom,
      })),

    reset: () =&amp;gt; set(initialState),
  })
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;이 구조의 장점은 세 가지다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;타입 안전성&lt;/strong&gt; - State와 Actions 인터페이스가 분리되어 있어 자동완성이 잘 된다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;초기화 용이&lt;/strong&gt; - initialState를 분리해두면 reset 구현이 간단하다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;테스트 용이&lt;/strong&gt; - 순수 함수처럼 동작해서 테스트하기 쉽다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;스토어 간 상호작용&lt;/h2&gt;
&lt;p&gt;복잡한 앱에서는 스토어 간 조합이 필수다. 두 가지 패턴을 사용한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;패턴 1: 훅에서 조합&lt;/h3&gt;
&lt;p&gt;여러 스토어의 상태를 읽어서 하나의 기능을 구현하는 경우다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// hooks/canvas/usePixiDragPan.ts
export function usePixiDragPan(containerRef, stage) {
  // 여러 스토어에서 필요한 것만 구독
  const dragTarget = useSelectionStore((s) =&amp;gt; s.dragTarget);
  const effectiveTool = useUIStore(selectEffectiveTool);
  const setPan = useCanvasStore((s) =&amp;gt; s.setPan);
  const setDraggingCanvas = useUIStore((s) =&amp;gt; s.setDraggingCanvas);

  // 조합해서 기능 구현
  const handleMouseDown = useCallback((e) =&amp;gt; {
    if (effectiveTool === &amp;#39;hand&amp;#39;) {
      setDraggingCanvas(true);
      // ...
    }
  }, [effectiveTool, setDraggingCanvas]);

  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;핵심은 &lt;strong&gt;필요한 상태만 구독&lt;/strong&gt;하는 것이다. &lt;code&gt;useUIStore()&lt;/code&gt; 전체를 구독하면 관계없는 상태 변경에도 리렌더된다. 셀렉터 함수를 써서 필요한 값만 뽑아야 한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;패턴 2: Selector로 파생 상태 정의&lt;/h3&gt;
&lt;p&gt;여러 상태를 조합한 파생 값이 여러 곳에서 필요할 때다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/index.ts
type UIStoreState = ReturnType&amp;lt;typeof useUIStore.getState&amp;gt;;

/**
 * 실제 적용되는 캔버스 도구
 * Space 홀드 중이면 hand, 아니면 선택된 도구
 */
export const selectEffectiveTool = (state: UIStoreState): &amp;#39;move&amp;#39; | &amp;#39;hand&amp;#39; | &amp;#39;diff&amp;#39; =&amp;gt;
  state.isSpaceHolding ? &amp;#39;hand&amp;#39; : state.canvasTool;

// 사용
const tool = useUIStore(selectEffectiveTool);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Selector를 스토어 파일에 정의해두면, 파생 로직이 한 곳에 모이고 재사용이 쉬워진다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;History 패턴 (Undo/Redo)&lt;/h2&gt;
&lt;p&gt;캔버스 에디터에서 Undo/Redo는 필수다. Zustand에서 구현하는 방법이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/layerStore.ts
interface HistoryState {
  past: Record&amp;lt;string, Layer&amp;gt;[];
  future: Record&amp;lt;string, Layer&amp;gt;[];
}

interface LayerState {
  items: Record&amp;lt;string, Layer&amp;gt;;
  _history: HistoryState;
}

export const useLayerStore = create&amp;lt;LayerState &amp;amp; LayerActions&amp;gt;()((set, get) =&amp;gt; ({
  items: {},
  _history: { past: [], future: [] },

  // ────── History Helper ──────
  _pushHistory: () =&amp;gt; {
    const { items, _history } = get();
    set({
      _history: {
        past: [..._history.past.slice(-49), structuredClone(items)],
        future: [],
      },
    });
  },

  // ────── Undoable Action ──────
  setPosition: (id, position, options) =&amp;gt; {
    if (!options?.skipHistory) {
      get()._pushHistory();
    }
    set((state) =&amp;gt; ({
      items: {
        ...state.items,
        [id]: { ...state.items[id], position },
      },
    }));
  },

  // ────── Undo/Redo ──────
  undo: () =&amp;gt; {
    const { items, _history } = get();
    if (_history.past.length === 0) return;

    const previous = _history.past[_history.past.length - 1];
    set({
      items: previous,
      _history: {
        past: _history.past.slice(0, -1),
        future: [structuredClone(items), ..._history.future],
      },
    });
  },

  redo: () =&amp;gt; {
    const { items, _history } = get();
    if (_history.future.length === 0) return;

    const next = _history.future[0];
    set({
      items: next,
      _history: {
        past: [..._history.past, structuredClone(items)],
        future: _history.future.slice(1),
      },
    });
  },
}));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;주목할 점이 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;skipHistory 옵션&lt;/strong&gt; - 드래그 중에는 매 프레임마다 히스토리를 쌓으면 안 된다. 드래그 시작 시에만 pushHistory를 호출하고, 중간 업데이트는 skipHistory: true로 처리한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;structuredClone&lt;/strong&gt; - 깊은 복사로 스냅샷을 저장한다. 참조를 그대로 저장하면 이후 변경에 영향받는다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;past 제한&lt;/strong&gt; - &lt;code&gt;.slice(-49)&lt;/code&gt;로 히스토리 길이를 제한한다. 무한히 쌓으면 메모리 문제가 생긴다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;외부 동기화 (영속성)&lt;/h2&gt;
&lt;p&gt;Zustand 상태를 DB에 저장하고 복원하는 패턴이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// hooks/stores/usePersistence.ts
export function usePersistence(projectId: string, options = {}) {
  const { initialState, layers } = options;

  // ────── 초기 로드: DB → Zustand ──────
  useEffect(() =&amp;gt; {
    if (!initialState || !layers) return;

    // 여러 스토어에 상태 분배
    useLayerStore.getState().setItems(restoredLayers);
    useCanvasStore.getState().setPan(initialState.workspace.canvas.pan);
    useCanvasStore.getState().setZoom(initialState.workspace.canvas.zoom);
    useModeStore.getState().setUrl(initialState.active.url);
    // ...
  }, [initialState, layers]);

  // ────── 저장: Zustand → DB ──────
  const getPersistedState = useCallback(() =&amp;gt; {
    // 스토어에서 직접 최신 상태 읽기
    const currentLayers = useLayerStore.getState().items;
    const currentCanvas = useCanvasStore.getState();
    const currentMode = useModeStore.getState();

    return {
      active: { url: currentMode.url, mode: currentMode.comparisonMode },
      workspace: {
        preset: currentCanvas.preset,
        canvas: { pan: currentCanvas.pan, zoom: currentCanvas.zoom },
      },
      layers: extractLayerStates(currentLayers),
    };
  }, []);

  const saveState = useCallback(async () =&amp;gt; {
    const state = getPersistedState();
    await fetch(`/api/projects/${projectId}/state`, {
      method: &amp;#39;PUT&amp;#39;,
      body: JSON.stringify({ state }),
    });
  }, [projectId, getPersistedState]);

  return { saveState };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;핵심은 &lt;strong&gt;getState()로 스토어에 직접 접근&lt;/strong&gt;하는 것이다. 리액트 훅 밖에서도 스토어 상태를 읽을 수 있다. 저장할 때마다 모든 스토어에서 필요한 값을 모아서 하나의 API 호출로 처리한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;영속성 대상과 세션 상태를 명확히 분리해두면, 어떤 값을 저장할지 판단하기 쉬워진다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;영속성&lt;/th&gt;
&lt;th&gt;세션&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;레이어 위치/크기/투명도&lt;/td&gt;
&lt;td&gt;현재 선택된 레이어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캔버스 pan/zoom&lt;/td&gt;
&lt;td&gt;드래그 중 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비교 모드/URL&lt;/td&gt;
&lt;td&gt;열린 드롭다운&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;


&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Multi-Selection과 Backward Compatibility&lt;/h2&gt;
&lt;p&gt;기능이 확장되면서 기존 API를 유지해야 할 때가 있다. 단일 선택에서 다중 선택으로 확장한 사례다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/selectionStore.ts
interface SelectionState {
  /** Multi-selection: all selected layer IDs */
  selectedIds: Set&amp;lt;string&amp;gt;;
  /** Primary selected layer */
  primarySelectedId: string | null;
  /** @deprecated Use selectedIds. Kept for backward compatibility */
  dragTarget: string | null;
}

interface SelectionActions {
  select: (id: string) =&amp;gt; void;
  addToSelection: (id: string) =&amp;gt; void;
  toggleSelection: (id: string) =&amp;gt; void;
  selectMultiple: (ids: string[]) =&amp;gt; void;
  clearSelection: () =&amp;gt; void;
  /** @deprecated Use select/clearSelection */
  setDragTarget: (id: string | null) =&amp;gt; void;
}

export const useSelectionStore = create&amp;lt;SelectionState &amp;amp; SelectionActions&amp;gt;()(
  (set, get) =&amp;gt; ({
    selectedIds: new Set(),
    primarySelectedId: null,
    dragTarget: null,  // backward compatibility

    select: (id) =&amp;gt;
      set({
        selectedIds: new Set([id]),
        primarySelectedId: id,
        dragTarget: id,  // backward compatibility
      }),

    // Legacy: 새 API로 위임
    setDragTarget: (id) =&amp;gt; {
      if (id === null) {
        get().clearSelection();
      } else {
        get().select(id);
      }
    },
    // ...
  })
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;새 기능(다중 선택)을 추가하면서 기존 API(dragTarget)를 유지했다. 레거시 API 호출은 내부적으로 새 API로 위임한다. 기존 코드를 한꺼번에 수정하지 않아도 점진적으로 마이그레이션할 수 있다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;테스트 전략&lt;/h2&gt;
&lt;p&gt;Zustand 스토어는 순수 함수처럼 동작해서 테스트하기 쉽다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// lib/stores/selectionStore.test.ts
import { useSelectionStore } from &amp;#39;./selectionStore&amp;#39;;

describe(&amp;#39;SelectionStore&amp;#39;, () =&amp;gt; {
  beforeEach(() =&amp;gt; {
    useSelectionStore.getState().reset();
  });

  it(&amp;#39;select()는 단일 선택으로 교체한다&amp;#39;, () =&amp;gt; {
    const { select, selectedIds } = useSelectionStore.getState();

    select(&amp;#39;layer-1&amp;#39;);
    expect(useSelectionStore.getState().selectedIds.has(&amp;#39;layer-1&amp;#39;)).toBe(true);

    select(&amp;#39;layer-2&amp;#39;);
    expect(useSelectionStore.getState().selectedIds.size).toBe(1);
    expect(useSelectionStore.getState().selectedIds.has(&amp;#39;layer-2&amp;#39;)).toBe(true);
  });

  it(&amp;#39;addToSelection()은 기존 선택에 추가한다&amp;#39;, () =&amp;gt; {
    const { select, addToSelection } = useSelectionStore.getState();

    select(&amp;#39;layer-1&amp;#39;);
    addToSelection(&amp;#39;layer-2&amp;#39;);

    const ids = useSelectionStore.getState().selectedIds;
    expect(ids.size).toBe(2);
    expect(ids.has(&amp;#39;layer-1&amp;#39;)).toBe(true);
    expect(ids.has(&amp;#39;layer-2&amp;#39;)).toBe(true);
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;getState()&lt;/code&gt;로 직접 액션을 호출하고, 변경된 상태를 검증한다. React 컴포넌트 없이 순수하게 로직만 테스트할 수 있다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;핵심 정리&lt;/h2&gt;
&lt;p&gt;Zustand로 복잡한 상태를 관리할 때 적용한 패턴을 정리하면 이렇다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;도메인 기반 분리&lt;/strong&gt; - 소유자가 명확한 단위로 스토어를 나눈다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Types/State/Actions 분리&lt;/strong&gt; - 파일 내에서 구조를 명확히 한다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Selector로 파생 상태&lt;/strong&gt; - 여러 곳에서 쓰는 조합 로직은 한 곳에서 정의한다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;훅에서 조합&lt;/strong&gt; - 여러 스토어를 조합한 기능은 커스텀 훅으로 캡슐화한다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;영속성 분리&lt;/strong&gt; - DB 저장 대상과 세션 상태를 명확히 나눈다&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backward Compatibility&lt;/strong&gt; - 레거시 API는 새 API로 위임하며 점진적으로 마이그레이션한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;이 패턴이 적합한 조건은 &amp;quot;여러 도메인이 얽혀 있지만, 각 도메인의 소유권이 명확한 프로젝트&amp;quot;다. 모든 상태가 서로 강하게 결합되어 있다면 단일 스토어가 나을 수 있고, 완전히 독립적인 상태들이라면 아토믹 방식이 나을 수 있다.&lt;/p&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FrontEnd/React</category>
      <category>React</category>
      <category>zustand</category>
      <category>상태관리</category>
      <category>아키텍처</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/86</guid>
      <comments>https://1yoouoo.tistory.com/86#entry86comment</comments>
      <pubDate>Tue, 17 Mar 2026 15:49:55 +0900</pubDate>
    </item>
    <item>
      <title>Chrome Extension Manifest V3에서 웹앱-iframe 간 4단계 메시지 릴레이 구현하기</title>
      <link>https://1yoouoo.tistory.com/85</link>
      <description>&lt;p&gt;웹앱에서 iframe 내부의 정보를 가져오려면 어떻게 해야 할까? 일반적으로 &lt;code&gt;postMessage&lt;/code&gt;를 쓰면 되지만, cross-origin iframe이라면 Content Security Policy에 막힌다. pixelDiff는 디자인 시안과 실제 웹사이트를 비교하는 서비스라서, 사용자가 입력한 URL을 iframe으로 띄워야 한다. 당연히 cross-origin이고, iframe 내부의 높이, URL 변화, 스크린샷까지 가져와야 했다.&lt;/p&gt;
&lt;h2&gt;선택지: 직접 통신 vs 중계자&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;동작&lt;/th&gt;
&lt;th&gt;한계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;postMessage 직접&lt;/td&gt;
&lt;td&gt;iframe ↔ parent&lt;/td&gt;
&lt;td&gt;cross-origin이면 수신 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome Extension&lt;/td&gt;
&lt;td&gt;content script가 양쪽에 주입&lt;/td&gt;
&lt;td&gt;구조가 복잡해짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 중계&lt;/td&gt;
&lt;td&gt;WebSocket으로 연결&lt;/td&gt;
&lt;td&gt;실시간성 부족, 인프라 부담&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;cross-origin iframe과 통신하려면 중계자가 필요하다. 서버를 거치면 지연이 생기고, 스크린샷처럼 대용량 데이터를 실시간으로 주고받기 어렵다. Chrome Extension은 content script를 모든 페이지에 주입할 수 있어서, iframe 내부와 parent 페이지 양쪽에 &amp;quot;중계자&amp;quot;를 심을 수 있다.&lt;/p&gt;
&lt;h2&gt;Manifest V3 아키텍처: 왜 4단계인가&lt;/h2&gt;
&lt;p&gt;MV2에서 MV3로 넘어오면서 background page가 service worker로 바뀌었다. DOM 접근이 불가능해지고, 메시지 기반 통신이 필수가 됐다.&lt;/p&gt;
&lt;p&gt;pixelDiff의 메시지 흐름은 이렇다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Webapp]
    ↕ postMessage
[Content Script - Parent]
    ↕ chrome.runtime.sendMessage / onMessage
[Background (Service Worker)]
    ↕ chrome.tabs.sendMessage / onMessage
[Content Script - iframe]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;각 계층이 필요한 이유:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;계층&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;왜 필요한가&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Webapp&lt;/td&gt;
&lt;td&gt;UI, 비즈니스 로직&lt;/td&gt;
&lt;td&gt;React/Next.js 앱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parent Content Script&lt;/td&gt;
&lt;td&gt;webapp ↔ background 중계&lt;/td&gt;
&lt;td&gt;webapp은 chrome API 접근 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background&lt;/td&gt;
&lt;td&gt;탭 간 통신, 스크린샷&lt;/td&gt;
&lt;td&gt;captureVisibleTab은 background에서만 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iframe Content Script&lt;/td&gt;
&lt;td&gt;iframe 내부 정보 수집&lt;/td&gt;
&lt;td&gt;cross-origin이라 parent에서 접근 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;직접 접근할 수 없는 영역이 있어서 중계가 필요하다. webapp은 chrome API를 못 쓰고, parent는 cross-origin iframe 내부를 못 보고, background는 DOM을 못 읽는다.&lt;/p&gt;
&lt;h2&gt;manifest.json 설정&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;manifest_version&amp;quot;: 3,
  &amp;quot;background&amp;quot;: {
    &amp;quot;service_worker&amp;quot;: &amp;quot;src/background/index.ts&amp;quot;,
    &amp;quot;type&amp;quot;: &amp;quot;module&amp;quot;
  },
  &amp;quot;permissions&amp;quot;: [
    &amp;quot;activeTab&amp;quot;,
    &amp;quot;declarativeNetRequest&amp;quot;,
    &amp;quot;declarativeNetRequestWithHostAccess&amp;quot;
  ],
  &amp;quot;host_permissions&amp;quot;: [&amp;quot;&amp;lt;all_urls&amp;gt;&amp;quot;],
  &amp;quot;externally_connectable&amp;quot;: {
    &amp;quot;matches&amp;quot;: [
      &amp;quot;https://pixeldiff.turtle-tail.com/*&amp;quot;,
      &amp;quot;http://localhost:3000/*&amp;quot;
    ]
  },
  &amp;quot;content_scripts&amp;quot;: [
    {
      &amp;quot;matches&amp;quot;: [&amp;quot;https://pixeldiff.turtle-tail.com/*&amp;quot;, &amp;quot;http://localhost:3000/*&amp;quot;],
      &amp;quot;js&amp;quot;: [&amp;quot;src/content-scripts/parent/index.ts&amp;quot;],
      &amp;quot;run_at&amp;quot;: &amp;quot;document_end&amp;quot;
    },
    {
      &amp;quot;matches&amp;quot;: [&amp;quot;&amp;lt;all_urls&amp;gt;&amp;quot;],
      &amp;quot;js&amp;quot;: [&amp;quot;src/content-scripts/iframe/index.ts&amp;quot;],
      &amp;quot;all_frames&amp;quot;: true,
      &amp;quot;run_at&amp;quot;: &amp;quot;document_idle&amp;quot;
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;핵심 설정:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;all_frames: true&lt;/code&gt;: iframe 내부에도 content script 주입&lt;/li&gt;
&lt;li&gt;&lt;code&gt;externally_connectable&lt;/code&gt;: webapp에서 background로 직접 메시지 전송 가능&lt;/li&gt;
&lt;li&gt;&lt;code&gt;declarativeNetRequest&lt;/code&gt;: CSP 헤더 제거용 (나중에 설명)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;메시지 타입 중앙화&lt;/h2&gt;
&lt;p&gt;확장 프로그램 규모가 커지면 메시지 타입이 수십 개로 늘어난다. 타입 안전성을 위해 모든 메시지 타입을 한 곳에서 관리했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// shared/types/messages.ts
export const MessageType = {
  // Background &amp;lt;-&amp;gt; Content Script
  IFRAME_INFO: &amp;#39;IFRAME_INFO&amp;#39;,
  IFRAME_UPDATE: &amp;#39;IFRAME_UPDATE&amp;#39;,

  // Capture
  CAPTURE_WITH_CROP: &amp;#39;CAPTURE_WITH_CROP&amp;#39;,
  CAPTURE_REQUEST_BOUNDS: &amp;#39;PIXELDIFF_CAPTURE_REQUEST_BOUNDS&amp;#39;,
  CAPTURE_COMPLETE: &amp;#39;PIXELDIFF_CAPTURE_COMPLETE&amp;#39;,

  // ... 30개 이상의 메시지 타입
} as const;

export interface IframeInfoMessage {
  type: typeof MessageType.IFRAME_INFO;
  url: string;
  height: number;
  timestamp: number;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;as const&lt;/code&gt;로 리터럴 타입을 유지하고, 인터페이스에서 &lt;code&gt;typeof MessageType.IFRAME_INFO&lt;/code&gt;로 참조하면 오타를 컴파일 타임에 잡을 수 있다.&lt;/p&gt;
&lt;h2&gt;iframe 정보 수집: Height Reporter&lt;/h2&gt;
&lt;p&gt;iframe 내부 content script가 페이지 높이를 측정해서 background로 보낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// content-scripts/iframe/handlers/height-reporter.ts
export function setupHeightReporter(): void {
  const reportHeight = () =&amp;gt; {
    const height = Math.max(
      document.body.scrollHeight,
      document.documentElement.scrollHeight
    );

    chrome.runtime.sendMessage({
      type: &amp;#39;IFRAME_INFO&amp;#39;,
      url: window.location.href,
      height,
      timestamp: Date.now(),
    });
  };

  // 초기 보고
  reportHeight();

  // ResizeObserver로 변화 감지
  const observer = new ResizeObserver(debounce(reportHeight, 100));
  observer.observe(document.body);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Background: 메시지 릴레이&lt;/h2&gt;
&lt;p&gt;Background가 iframe에서 온 메시지를 parent content script로 전달한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// background/handlers/message-handler.ts
export function setupMessageHandler(): void {
  chrome.runtime.onMessage.addListener(
    (message, sender, sendResponse) =&amp;gt; {
      const tabId = sender.tab?.id;

      if (message.type === &amp;#39;IFRAME_INFO&amp;#39;) {
        if (!tabId) {
          sendResponse({ success: false, error: &amp;#39;No tab ID&amp;#39; });
          return;
        }

        // Parent content script로 전달
        chrome.tabs.sendMessage(tabId, {
          type: &amp;#39;IFRAME_UPDATE&amp;#39;,
          url: message.url,
          height: message.height,
          frameId: sender.frameId,
          timestamp: message.timestamp,
        });

        sendResponse({ success: true });
        return true;
      }

      return true;
    }
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sender.tab.id&lt;/code&gt;로 어느 탭에서 온 메시지인지 알 수 있고, &lt;code&gt;sender.frameId&lt;/code&gt;로 어느 iframe인지 구분한다.&lt;/p&gt;
&lt;h2&gt;Parent Content Script: Webapp으로 전달&lt;/h2&gt;
&lt;p&gt;Parent content script가 background에서 온 메시지를 webapp으로 postMessage한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// content-scripts/parent/handlers/message-relay.ts
export function setupMessageRelay(): void {
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) =&amp;gt; {
    if (message.type === &amp;#39;IFRAME_UPDATE&amp;#39;) {
      // Webapp으로 전달
      window.postMessage({
        type: &amp;#39;PIXELDIFF_IFRAME_UPDATE&amp;#39;,
        source: &amp;#39;pixeldiff-extension&amp;#39;,
        url: message.url,
        height: message.height,
        frameId: message.frameId,
        timestamp: message.timestamp,
      }, &amp;#39;*&amp;#39;);

      sendResponse({ success: true });
    }
    return true;
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;source: &amp;#39;pixeldiff-extension&amp;#39;&lt;/code&gt;을 넣어서 webapp이 확장 프로그램에서 온 메시지인지 구분한다.&lt;/p&gt;
&lt;h2&gt;MV3에서 스크린샷: OffscreenCanvas&lt;/h2&gt;
&lt;p&gt;MV2에서는 background page에서 &lt;code&gt;document.createElement(&amp;#39;canvas&amp;#39;)&lt;/code&gt;를 쓸 수 있었다. MV3에서는 DOM이 없어서 &lt;code&gt;OffscreenCanvas&lt;/code&gt;를 써야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// background/services/capture-service.ts
export async function captureAndCrop(
  tabId: number,
  bounds: Bounds,
  devicePixelRatio: number
): Promise&amp;lt;CaptureResult&amp;gt; {
  return new Promise((resolve) =&amp;gt; {
    chrome.tabs.captureVisibleTab({ format: &amp;#39;png&amp;#39; }, async (dataUrl) =&amp;gt; {
      if (chrome.runtime.lastError) {
        resolve({ success: false, error: chrome.runtime.lastError.message });
        return;
      }

      // dataUrl → Blob → ImageBitmap
      const res = await fetch(dataUrl);
      const blob = await res.blob();
      const imageBitmap = await createImageBitmap(blob);

      // DPR 고려한 크롭 영역 계산
      const dpr = devicePixelRatio;
      const cropX = bounds.x * dpr;
      const cropY = bounds.y * dpr;
      const cropWidth = bounds.width * dpr;
      const cropHeight = bounds.height * dpr;

      // OffscreenCanvas로 크롭
      const canvas = new OffscreenCanvas(cropWidth, cropHeight);
      const ctx = canvas.getContext(&amp;#39;2d&amp;#39;);

      ctx.drawImage(
        imageBitmap,
        cropX, cropY, cropWidth, cropHeight,
        0, 0, cropWidth, cropHeight
      );

      // Blob → base64 dataURL
      const croppedBlob = await canvas.convertToBlob({ type: &amp;#39;image/png&amp;#39; });
      const reader = new FileReader();
      reader.onloadend = () =&amp;gt; {
        resolve({ success: true, imageData: reader.result as string });
      };
      reader.readAsDataURL(croppedBlob);
    });
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;captureVisibleTab&lt;/code&gt;은 전체 화면을 캡처하고, iframe 영역만 잘라내야 한다. Parent content script가 iframe의 &lt;code&gt;getBoundingClientRect()&lt;/code&gt;를 보내주면, background에서 해당 영역만 크롭한다.&lt;/p&gt;
&lt;h2&gt;CSP 우회: declarativeNetRequest&lt;/h2&gt;
&lt;p&gt;일부 사이트는 &lt;code&gt;Content-Security-Policy: frame-ancestors &amp;#39;self&amp;#39;&lt;/code&gt;로 iframe 삽입을 막는다. 이걸 우회하려면 응답 헤더에서 CSP를 제거해야 한다.&lt;/p&gt;
&lt;p&gt;MV2에서는 &lt;code&gt;webRequest&lt;/code&gt; API로 실시간으로 헤더를 수정했다. MV3에서는 &lt;code&gt;declarativeNetRequest&lt;/code&gt;로 선언적 규칙을 등록해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// background/services/csp-analyzer.ts
export async function addCSPRemovalRule(domain: string): Promise&amp;lt;void&amp;gt; {
  const ruleId = DYNAMIC_RULE_BASE_ID + ++dynamicRuleCounter;

  const rule: chrome.declarativeNetRequest.Rule = {
    id: ruleId,
    priority: 2,
    action: {
      type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
      responseHeaders: [
        {
          header: &amp;#39;Content-Security-Policy&amp;#39;,
          operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
        },
        {
          header: &amp;#39;Content-Security-Policy-Report-Only&amp;#39;,
          operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
        },
      ],
    },
    condition: {
      urlFilter: `||${domain}`,
      resourceTypes: [chrome.declarativeNetRequest.ResourceType.SUB_FRAME],
    },
  };

  await chrome.declarativeNetRequest.updateDynamicRules({
    addRules: [rule],
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 도메인에 CSP 제거 규칙을 미리 등록하면 보안 위험이 있다. 그래서 webapp이 iframe을 로드하기 전에 해당 도메인의 CSP를 분석하고, 필요한 경우에만 동적으로 규칙을 추가한다.&lt;/p&gt;
&lt;h2&gt;외부 메시지: externally_connectable&lt;/h2&gt;
&lt;p&gt;Webapp이 content script를 거치지 않고 background에 직접 메시지를 보내는 방법도 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// background/handlers/external-handler.ts
export function setupExternalHandler(): void {
  chrome.runtime.onMessageExternal.addListener(
    (message, sender, sendResponse) =&amp;gt; {
      // Origin 검증
      const senderOrigin = new URL(sender.url || &amp;#39;&amp;#39;).origin;
      if (!ALLOWED_ORIGINS.includes(senderOrigin)) {
        console.warn(&amp;#39;Rejected message from unauthorized origin:&amp;#39;, senderOrigin);
        return;
      }

      if (message.type === &amp;#39;PIXELDIFF_PING&amp;#39;) {
        sendResponse({
          type: &amp;#39;PIXELDIFF_PONG&amp;#39;,
          version: chrome.runtime.getManifest().version,
        });
        return true;
      }

      if (message.type === &amp;#39;ANALYZE_CSP&amp;#39;) {
        handleCSPAnalysis(message.url).then(sendResponse);
        return true;
      }
    }
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webapp에서는 이렇게 호출한다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// webapp에서
chrome.runtime.sendMessage(
  EXTENSION_ID,
  { type: &amp;#39;PIXELDIFF_PING&amp;#39; },
  (response) =&amp;gt; {
    console.log(&amp;#39;Extension version:&amp;#39;, response.version);
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;externally_connectable&lt;/code&gt;에 등록된 origin만 이 방식을 쓸 수 있다.&lt;/p&gt;
&lt;h2&gt;핵심은 계층 분리&lt;/h2&gt;
&lt;p&gt;MV3 Chrome Extension에서 복잡한 통신 구조를 만들 때 중요한 건 &amp;quot;누가 뭘 할 수 있는가&amp;quot;를 명확히 구분하는 것이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Webapp: chrome API 접근 불가&lt;/li&gt;
&lt;li&gt;Content Script: chrome.runtime만 가능, tabs 접근 불가&lt;/li&gt;
&lt;li&gt;Background: DOM 접근 불가, OffscreenCanvas 사용&lt;/li&gt;
&lt;li&gt;iframe Content Script: cross-origin이라 parent와 직접 통신 불가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;각 계층의 한계를 인식하고, 메시지 릴레이로 연결하면 된다. 메시지 타입을 중앙에서 관리하면 규모가 커져도 추적이 가능하다.&lt;/p&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>토이프로젝트</category>
      <category>chrome extension</category>
      <category>content script</category>
      <category>CSP</category>
      <category>declarativeNetRequest</category>
      <category>Manifest V3</category>
      <category>MV3</category>
      <category>OffscreenCanvas</category>
      <category>Service Worker</category>
      <category>메시지 통신</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/85</guid>
      <comments>https://1yoouoo.tistory.com/85#entry85comment</comments>
      <pubDate>Sat, 14 Mar 2026 18:00:10 +0900</pubDate>
    </item>
    <item>
      <title>postMessage로 다중 iframe 스크롤 동기화 구현하기</title>
      <link>https://1yoouoo.tistory.com/84</link>
      <description>&lt;p&gt;여러 디바이스 프레임에서 동시에 웹사이트를 미리보기 하는 기능을 만들었다. iPhone, iPad, Galaxy 등 다양한 해상도의 iframe이 나란히 배치되고, 사용자가 하나를 스크롤하면 나머지도 따라 움직여야 한다.&lt;/p&gt;
&lt;p&gt;문제는 iframe이 cross-origin이라는 것이다. 보안상 다른 origin의 iframe 내부에 직접 접근할 수 없다. DOM을 읽을 수도, 스크롤 위치를 설정할 수도 없다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;선택지&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;th&gt;한계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iframe.contentWindow.scrollTo()&lt;/td&gt;
&lt;td&gt;직접 스크롤 제어&lt;/td&gt;
&lt;td&gt;cross-origin 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SharedWorker&lt;/td&gt;
&lt;td&gt;탭 간 통신&lt;/td&gt;
&lt;td&gt;iframe에서 사용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BroadcastChannel&lt;/td&gt;
&lt;td&gt;탭 간 통신&lt;/td&gt;
&lt;td&gt;same-origin만 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postMessage&lt;/td&gt;
&lt;td&gt;윈도우 간 메시지 전달&lt;/td&gt;
&lt;td&gt;origin 검증 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;cross-origin 환경에서 유일하게 작동하는 방식은 &lt;code&gt;postMessage&lt;/code&gt;다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;아키텍처&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│                    웹 애플리케이션 (Hub)               │
│                                                     │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐        │
│   │  iPhone  │  │   iPad   │  │  Galaxy  │        │
│   │  iframe  │  │  iframe  │  │  iframe  │        │
│   │          │  │          │  │          │        │
│   │ Extension│  │ Extension│  │ Extension│        │
│   │ (content │  │ (content │  │ (content │        │
│   │  script) │  │  script) │  │  script) │        │
│   └────┬─────┘  └────┬─────┘  └────┬─────┘        │
│        │             │             │              │
│        └─────────────┼─────────────┘              │
│                      │                            │
│              postMessage 통신                      │
└─────────────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;웹앱이 허브 역할을 한다. 각 iframe 내부에는 Chrome Extension의 content script가 주입되어 스크롤 이벤트를 감지하고, 부모 웹앱에게 전달한다. 웹앱은 이를 받아서 다른 모든 iframe에 브로드캐스트한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;핵심 문제: 해상도가 다르다&lt;/h2&gt;
&lt;p&gt;iPhone과 iPad의 뷰포트 높이가 다르다. 픽셀 단위로 동기화하면 안 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;iPhone (375x812):  scrollY = 500px → 전체 컨텐츠의 어디쯤?
iPad (1024x1366): scrollY = 500px → 완전히 다른 위치&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;해결책은 &lt;strong&gt;비율 기반 동기화&lt;/strong&gt;다. 절대 픽셀이 아니라 &amp;quot;전체 스크롤 가능 영역 중 몇 퍼센트 위치&amp;quot;로 표현한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function getScrollRatio(): { x: number; y: number } {
  const scrollWidth = document.documentElement.scrollWidth - window.innerWidth;
  const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;

  return {
    x: scrollWidth &amp;gt; 0 ? window.scrollX / scrollWidth : 0,
    y: scrollHeight &amp;gt; 0 ? window.scrollY / scrollHeight : 0,
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;scrollRatio&lt;/code&gt;는 0~1 사이 값이다. 0.5면 정확히 중간 위치다. 이 비율을 받은 쪽에서는 자신의 뷰포트에 맞게 실제 픽셀로 변환한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function getPositionFromRatio(ratio: { x: number; y: number }): { x: number; y: number } {
  const scrollWidth = document.documentElement.scrollWidth - window.innerWidth;
  const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;

  return {
    x: Math.round(ratio.x * Math.max(0, scrollWidth)),
    y: Math.round(ratio.y * Math.max(0, scrollHeight)),
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;무한 루프 방지&lt;/h2&gt;
&lt;p&gt;A가 스크롤 → B에 전달 → B가 스크롤 → A에 전달 → A가 스크롤 → ...&lt;/p&gt;
&lt;p&gt;양방향 동기화에서 반드시 발생하는 문제다. 두 가지 방어 장치를 사용했다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;1. 동기화 플래그&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let isSyncing = false;

// 스크롤 이벤트 발생 시
const handleScroll = () =&amp;gt; {
  if (isSyncing) return; // 동기화 중이면 무시

  // 부모에게 스크롤 위치 전송
  window.parent.postMessage({ scrollRatio: getScrollRatio() }, &amp;#39;*&amp;#39;);
};

// 동기화 명령 수신 시
function handleScrollTo(scrollRatio) {
  isSyncing = true; // 플래그 설정

  window.scrollTo({
    left: position.x,
    top: position.y,
    behavior: &amp;#39;instant&amp;#39;,
  });

  // 쿨다운 후 플래그 해제
  setTimeout(() =&amp;gt; {
    isSyncing = false;
  }, 50);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;명령을 받아서 스크롤하는 동안에는 &lt;code&gt;isSyncing&lt;/code&gt;이 true다. 이 상태에서 발생하는 스크롤 이벤트는 무시된다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2. 쿨다운&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let lastSyncTime = 0;
const SYNC_COOLDOWN = 100; // ms

const broadcastScrollCommand = (scrollRatio, sourceDeviceId) =&amp;gt; {
  const now = Date.now();
  if (now - lastSyncTime &amp;lt; SYNC_COOLDOWN) return; // 쿨다운 중

  lastSyncTime = now;
  // 브로드캐스트 실행
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;100ms 이내 연속 호출은 무시한다. 빠른 스크롤에서 불필요한 메시지 폭탄을 방지한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;소스 디바이스 제외&lt;/h2&gt;
&lt;p&gt;스크롤을 시작한 디바이스에게 다시 명령을 보내면 안 된다. 자기 자신은 이미 스크롤된 상태니까.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const broadcastScrollCommand = (scrollRatio, sourceDeviceId) =&amp;gt; {
  const iframes = document.querySelectorAll(&amp;#39;iframe[data-device-iframe=&amp;quot;true&amp;quot;]&amp;#39;);

  iframes.forEach((iframe) =&amp;gt; {
    // 소스 디바이스는 제외
    if (sourceDeviceId &amp;amp;&amp;amp; iframe.dataset.deviceId === sourceDeviceId) {
      return;
    }

    iframe.contentWindow?.postMessage(
      { type: &amp;#39;PIXELDIFF_SYNC_COMMAND&amp;#39;, command: &amp;#39;scrollTo&amp;#39;, payload: { scrollRatio } },
      &amp;#39;*&amp;#39;
    );
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 iframe에 &lt;code&gt;data-device-id&lt;/code&gt; 속성을 부여하고, 메시지에 &lt;code&gt;sourceDeviceId&lt;/code&gt;를 포함시킨다. 브로드캐스트 시 이 ID와 일치하는 iframe은 건너뛴다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;iframe 내부 Content Script&lt;/h2&gt;
&lt;p&gt;Extension의 content script는 iframe 내부에서 실행된다. 스크롤 이벤트를 감지하고 부모에게 전달하는 역할이다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// iframe 내부에서만 실행
if (window !== window.top) {
  const handleScroll = throttle(() =&amp;gt; {
    if (isSyncing) return;

    window.parent.postMessage({
      type: &amp;#39;PIXELDIFF_SYNC&amp;#39;,
      source: &amp;#39;pixeldiff-extension&amp;#39;,
      event: &amp;#39;scroll&amp;#39;,
      payload: {
        scrollRatio: getScrollRatio(),
        deviceId: window.name, // iframe의 name 속성으로 식별
      },
    }, &amp;#39;*&amp;#39;);
  }, 16); // ~60fps

  window.addEventListener(&amp;#39;scroll&amp;#39;, handleScroll, { passive: true });
  window.addEventListener(&amp;#39;message&amp;#39;, handleMessage);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;window.name&lt;/code&gt;을 deviceId로 사용한다. iframe에 &lt;code&gt;name&lt;/code&gt; 속성을 설정하면 내부에서 &lt;code&gt;window.name&lt;/code&gt;으로 접근할 수 있다. cross-origin에서도 작동하는 몇 안 되는 속성 중 하나다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;URL 동기화&lt;/h2&gt;
&lt;p&gt;스크롤뿐 아니라 URL 변경도 동기화해야 한다. 한 디바이스에서 링크를 클릭하면 다른 디바이스도 같은 페이지로 이동해야 한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let lastUrl = location.href;

function checkUrlChange(): void {
  const currentUrl = location.href;

  if (currentUrl !== lastUrl) {
    lastUrl = currentUrl;

    window.parent.postMessage({
      type: &amp;#39;PIXELDIFF_SYNC&amp;#39;,
      event: &amp;#39;url-change&amp;#39;,
      payload: { url: currentUrl },
    }, &amp;#39;*&amp;#39;);
  }
}

// 500ms마다 체크 + popstate/hashchange 이벤트 리스너
setInterval(checkUrlChange, 500);
window.addEventListener(&amp;#39;popstate&amp;#39;, checkUrlChange);
window.addEventListener(&amp;#39;hashchange&amp;#39;, checkUrlChange);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;popstate&lt;/code&gt;와 &lt;code&gt;hashchange&lt;/code&gt;만으로는 모든 경우를 커버할 수 없다. SPA에서 &lt;code&gt;history.pushState&lt;/code&gt;를 직접 호출하면 이벤트가 발생하지 않는다. 그래서 500ms 폴링을 병행한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;보안 고려사항&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;postMessage&lt;/code&gt;에서 origin 검증은 필수다. 누구나 메시지를 보낼 수 있기 때문이다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// 메시지 수신 시 검증
function handleMessage(event: MessageEvent): void {
  // 유효한 동기화 메시지인지 확인
  if (
    data?.type !== &amp;#39;PIXELDIFF_SYNC&amp;#39; ||
    data?.source !== &amp;#39;pixeldiff-extension&amp;#39;
  ) {
    return; // 무시
  }

  // 처리 로직
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;type&lt;/code&gt;과 &lt;code&gt;source&lt;/code&gt; 필드로 메시지 출처를 검증한다. 실제 서비스라면 &lt;code&gt;event.origin&lt;/code&gt;도 화이트리스트와 대조해야 한다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bX4g9X/dJMcabJJpXx/wvkvsoodVWQyWyys9qxI90/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bX4g9X/dJMcabJJpXx/wvkvsoodVWQyWyys9qxI90/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bX4g9X/dJMcabJJpXx/wvkvsoodVWQyWyys9qxI90/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bX4g9X/dJMcabJJpXx/wvkvsoodVWQyWyys9qxI90/img.gif&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2&gt;핵심은&lt;/h2&gt;
&lt;p&gt;iframe 동기화의 핵심은 세 가지다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;비율 기반 좌표&lt;/strong&gt; - 해상도가 달라도 동일한 &amp;quot;상대 위치&amp;quot;로 동기화&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;루프 방지&lt;/strong&gt; - &lt;code&gt;isSyncingRef&lt;/code&gt; + 쿨다운으로 이중 방어&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;소스 제외&lt;/strong&gt; - 자기 자신에게 재전송하지 않기&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 패턴은 다중 뷰를 동기화해야 하는 모든 상황에 적용할 수 있다. 코드 에디터의 분할 뷰, 발표 자료의 발표자/청중 뷰, 협업 도구의 실시간 커서 등.&lt;/p&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>토이프로젝트</category>
      <category>chrome extension</category>
      <category>cross-origin</category>
      <category>iframe</category>
      <category>PostMessage</category>
      <category>스크롤 동기화</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/84</guid>
      <comments>https://1yoouoo.tistory.com/84#entry84comment</comments>
      <pubDate>Thu, 12 Mar 2026 18:00:11 +0900</pubDate>
    </item>
    <item>
      <title>Spring Physics로 오뚜기 애니메이션 구현하기</title>
      <link>https://1yoouoo.tistory.com/83</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;canvas 위에서 레이어를 비교할 때 opacity 조절이 핵심이다. 문제는 이 UI가 원래 좌측 하단에 고정되어 있었다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;canvas 작업은 상호작용이 많다. 레이어를 드래그하고, 확대/축소하고, 위치를 맞추는 동안 시선은 canvas 중앙에 머문다. 그런데 opacity 값을 확인하려면 시선을 좌측 하단으로 옮겨야 했다. 작업 흐름이 끊긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 opacity 숫자를 슬라이더 thumb 바로 위에 표시하는 것이었다. 드래그하는 손가락 근처에서 값을 바로 확인할 수 있다. 화면도 덜 가린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 단순히 숫자만 띄우면 밋밋했다. 드래그하는 방향으로 살짝 기울어졌다가 손을 떼면 다시 원위치로 돌아오는 애니메이션을 추가했다. 오뚜기처럼. 실용적인 이유도 있지만, 솔직히 만들면서 재밌었다. 슬라이더를 빠르게 움직이면 숫자가 휘청거리는 게 자꾸 해보고 싶어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Physics란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오뚜기가 흔들리다 멈추는 동작은 스프링 물리로 구현한다. 핵심 공식은 하나다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;F = -kx - cv&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;k&lt;/code&gt; (stiffness): 복원력. 높을수록 빠르게 원위치로 돌아온다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x&lt;/code&gt; (displacement): 현재 위치와 목표 위치의 차이.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c&lt;/code&gt; (damping): 저항력. 낮을수록 오래 흔들린다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;v&lt;/code&gt; (velocity): 현재 속도.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링이 늘어나면 &lt;code&gt;-kx&lt;/code&gt;가 원래 위치로 당기고, 움직이면 &lt;code&gt;-cv&lt;/code&gt;가 브레이크를 건다. 이 두 힘의 균형으로 &quot;흔들리다 멈추는&quot; 자연스러운 동작이 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 &lt;code&gt;mass&lt;/code&gt;(질량)를 추가하면 가속도가 결정된다. &lt;code&gt;a = F / mass&lt;/code&gt;. 무거울수록 느리게 반응하고, 가벼울수록 민첩하게 움직인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 방식 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 애니메이션을 구현하는 방법은 크게 두 가지다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;th&gt;적합한 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framer Motion `useSpring`&lt;/td&gt;
&lt;td&gt;코드 간결, 검증된 구현&lt;/td&gt;
&lt;td&gt;DOM 기반만 가능&lt;/td&gt;
&lt;td&gt;React 컴포넌트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;직접 구현&lt;/td&gt;
&lt;td&gt;어디서든 사용 가능&lt;/td&gt;
&lt;td&gt;물리 공식 이해 필요&lt;/td&gt;
&lt;td&gt;Canvas, WebGL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pixelDiff는 기본적으로 PixiJS(WebGL)로 canvas를 렌더링한다. 성능 때문이다. 그런데 WebGL을 지원하지 않는 환경도 있다. 이 경우 DOM 기반 렌더링으로 폴백한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 같은 오뚜기 애니메이션을 두 번 구현해야 했다. PixiJS용은 스프링 물리를 직접 계산하고, DOM 폴백용은 Framer Motion을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현: Framer Motion&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Framer Motion의 &lt;code&gt;useSpring&lt;/code&gt;은 스프링 물리를 알아서 계산해준다. 파라미터만 넘기면 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const rotateSpring = useSpring(0, {
  stiffness: 40,   // 복원력
  damping: 3,      // 저항력
  mass: 0.6,       // 질량
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오뚜기 동작의 핵심은 세 단계다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;드래그 시작&lt;/b&gt;: 속도와 가속도로 회전 각도 계산&lt;/li&gt;
&lt;li&gt;&lt;b&gt;즉시 기울이기&lt;/b&gt;: &lt;code&gt;rotateSpring.set(angle)&lt;/code&gt;로 목표 각도 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;놓으면 복귀&lt;/b&gt;: 짧은 딜레이 후 &lt;code&gt;rotateSpring.set(0)&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  // 속도 + 가속도 &amp;rarr; 회전 각도
  const angle = velocity * 0.3 + acceleration * 0.02;
  const clamped = Math.max(-90, Math.min(90, angle));

  rotateSpring.set(clamped);

  // 50ms 후 원위치로
  const timer = setTimeout(() =&amp;gt; rotateSpring.set(0), 50);
  return () =&amp;gt; clearTimeout(timer);
}, [velocity, acceleration]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회전축은 배지 하단 중앙이다. &lt;code&gt;transformOrigin&lt;/code&gt;으로 설정한다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;&amp;lt;motion.div
  style={{
    rotate: rotateSpring,
    transformOrigin: 'center calc(100% + 16px)', // thumb 중심
  }}
&amp;gt;
  {value}
&amp;lt;/motion.div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현: PixiJS 직접 계산&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PixiJS에서는 Framer Motion을 쓸 수 없다. 스프링 물리를 직접 계산해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 프레임마다 호출되는 &lt;code&gt;update&lt;/code&gt; 함수에서 세 가지를 계산한다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;const SPRING_CONFIG = {
  stiffness: 70,  // 복원력
  damping: 2.5,   // 저항력
  mass: 0.5,      // 질량
};

public update(deltaTime: number) {
  // 1. 현재 위치와 목표의 차이
  const displacement = this.currentRotation - this.targetRotation;

  // 2. 스프링 힘 = -kx - cv
  const springForce = -SPRING_CONFIG.stiffness * displacement;
  const dampingForce = -SPRING_CONFIG.damping * this.rotationVelocity;

  // 3. 가속도 = 힘 / 질량
  const acceleration = (springForce + dampingForce) / SPRING_CONFIG.mass;

  // 4. 속도와 위치 업데이트
  this.rotationVelocity += acceleration * deltaTime;
  this.currentRotation += this.rotationVelocity * deltaTime;

  // 5. 실제 회전 적용
  this.container.rotation = (this.currentRotation * Math.PI) / 180;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수를 PixiJS의 &lt;code&gt;Ticker&lt;/code&gt;에 등록한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;constructor() {
  this.tickerBound = this.onTick.bind(this);
  Ticker.shared.add(this.tickerBound);
}

private onTick() {
  const deltaTime = Ticker.shared.deltaMS / 1000;
  this.update(deltaTime);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Framer Motion이 내부적으로 하는 일을 직접 하는 셈이다. 코드가 길어지지만, canvas 환경에서는 이 방법밖에 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;파라미터 튜닝&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 파라미터의 조합이 애니메이션 느낌을 결정한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;파라미터&lt;/th&gt;
&lt;th&gt;낮으면&lt;/th&gt;
&lt;th&gt;높으면&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stiffness&lt;/td&gt;
&lt;td&gt;느리게 복귀, 부드러움&lt;/td&gt;
&lt;td&gt;빠르게 복귀, 탱탱함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;damping&lt;/td&gt;
&lt;td&gt;오래 흔들림&lt;/td&gt;
&lt;td&gt;빨리 멈춤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mass&lt;/td&gt;
&lt;td&gt;가볍고 민첩&lt;/td&gt;
&lt;td&gt;무겁고 둔함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pixelDiff에서 쓴 설정:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// React (Framer Motion)
{ stiffness: 40, damping: 3, mass: 0.6 }

// PixiJS (직접 구현)
{ stiffness: 70, damping: 2.5, mass: 0.5 }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 쪽이 조금 더 부드럽고 느리다. PixiJS 쪽은 더 탱탱하고 빠르다. 같은 공식인데 왜 다를까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Framer Motion은 내부적으로 &lt;code&gt;deltaTime&lt;/code&gt;을 고정값으로 처리한다. 직접 구현할 때는 실제 프레임 간격을 쓴다. 같은 파라미터라도 결과가 다르다. 눈으로 보면서 맞춰야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;튜닝 팁:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;stiffness 먼저&lt;/b&gt;: 복귀 속도를 정한다. 40~100 사이에서 시작.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;damping 다음&lt;/b&gt;: 흔들림 횟수를 조절한다. 낮추면 2-3번 더 흔들린다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;mass 마지막&lt;/b&gt;: 전체 느낌을 미세 조정한다. 대부분 0.5~1.0 사이.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 직접 구현해야 하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리를 쓸 수 있으면 쓰는 게 낫다. Framer Motion은 검증된 구현이고, 코드도 짧다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 구현이 필요한 경우:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;렌더링 환경이 다를 때&lt;/b&gt;: Canvas, WebGL, PixiJS, Three.js 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;라이브러리가 닿지 않을 때&lt;/b&gt;: Web Worker, 게임 엔진, 네이티브 브릿지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능이 중요할 때&lt;/b&gt;: DOM 조작은 비싸다. PixiJS는 GPU로 렌더링해서 수십 개 레이어도 60fps를 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pixelDiff가 PixiJS를 쓰는 이유도 성능이다. 여러 레이어를 동시에 드래그하고, 확대/축소하고, opacity를 조절한다. DOM으로는 버벅인다. 애니메이션까지 PixiJS 안에서 처리하면 모든 렌더링이 GPU에서 끝난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;393&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/A8Pai/dJMcagdbny7/F66LlmLNDKV8LWax9SR0q0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/A8Pai/dJMcagdbny7/F66LlmLNDKV8LWax9SR0q0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/A8Pai/dJMcagdbny7/F66LlmLNDKV8LWax9SR0q0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/A8Pai/dJMcagdbny7/F66LlmLNDKV8LWax9SR0q0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;393&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;393&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 구현하면 좋은 점도 있다. 물리 공식을 이해하게 된다. &lt;code&gt;F = -kx - cv&lt;/code&gt;가 무슨 뜻인지 몸으로 알게 된다. 다음에 비슷한 상황이 오면 라이브러리 없이도 해결할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 애니메이션의 핵심은 공식 하나다. &lt;code&gt;F = -kx - cv&lt;/code&gt;. 이것만 알면 어떤 환경에서든 오뚜기를 만들 수 있다. Framer Motion이 있으면 편하게 쓰고, 없으면 직접 계산하면 된다. 어렵지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FrontEnd</category>
      <category>framer-motion</category>
      <category>pixijs</category>
      <category>spring-animation</category>
      <category>물리엔진</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/83</guid>
      <comments>https://1yoouoo.tistory.com/83#entry83comment</comments>
      <pubDate>Tue, 10 Mar 2026 18:00:25 +0900</pubDate>
    </item>
    <item>
      <title>Zod로 API 스키마 검증하기</title>
      <link>https://1yoouoo.tistory.com/82</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;런타임에 터진 버그&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;타입 정의 완벽하게 했는데 왜 undefined 에러가...?&quot;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;type User = {
  name: string;
  age: number;
};

// 컴파일은 통과하지만...
const data: User = JSON.parse(response);
// response가 { name: 123, age: &quot;스물&quot; }이면?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 응답이 예상과 달랐다. TypeScript는 아무 경고 없이 통과시켰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TypeScript는 &lt;b&gt;컴파일 타임&lt;/b&gt;에만 동작한다. API 응답, 폼 입력, 외부 데이터는 런타임에 들어오기 때문에 타입을 아무리 정교하게 정의해도 &lt;b&gt;런타임에는 무력하다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;code&gt;if (!data.name || typeof data.age !== 'number')&lt;/code&gt; 같은 검증 코드를 직접 작성하게 되는데, 이건 타입 정의와 중복이고 유지보수가 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Zod란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Zod는 &lt;b&gt;스키마 기반 런타임 검증 라이브러리&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 특징:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스키마를 정의하면 &lt;b&gt;런타임 검증&lt;/b&gt;과 &lt;b&gt;TypeScript 타입&lt;/b&gt;이 동시에 생성된다&lt;/li&gt;
&lt;li&gt;검증 실패 시 상세한 에러 메시지 제공&lt;/li&gt;
&lt;li&gt;체이닝으로 복잡한 조건도 표현 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npm install zod&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기본 사용법&lt;/h2&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;import { z } from 'zod';

// 스키마 정의
const UserSchema = z.object({
  name: z.string().min(1, '이름은 필수입니다'),
  age: z.number().positive('나이는 양수여야 합니다'),
  email: z.string().email('올바른 이메일 형식이 아닙니다'),
});

// TypeScript 타입 자동 추론
type User = z.infer&amp;lt;typeof UserSchema&amp;gt;;
// { name: string; age: number; email: string; }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마 하나로 &lt;b&gt;런타임 검증&lt;/b&gt;과 &lt;b&gt;타입 정의&lt;/b&gt;가 동시에 해결된다. 타입을 별도로 정의할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검증 방식: parse vs safeParse&lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// parse: 실패 시 에러 throw
try {
  const user = UserSchema.parse(unknownData);
} catch (error) {
  // ZodError
}

// safeParse: 에러를 throw하지 않고 결과 객체 반환
const result = UserSchema.safeParse(unknownData);

if (!result.success) {
  console.log(result.error.errors);
  // [{ path: ['age'], message: '나이는 양수여야 합니다' }]
} else {
  console.log(result.data); // 검증된 데이터
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;parse&lt;/code&gt;는 간단한 경우에, &lt;code&gt;safeParse&lt;/code&gt;는 에러 핸들링이 필요할 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에러 핸들링 심화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;safeParse&lt;/code&gt;의 에러 객체는 여러 방식으로 가공할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;const result = UserSchema.safeParse({
  name: '',
  age: -5,
  email: 'invalid',
});

if (!result.success) {
  // flatten(): 필드별로 에러 메시지 그룹화
  const flattened = result.error.flatten();
  // {
  //   fieldErrors: {
  //     name: ['이름은 필수입니다'],
  //     age: ['나이는 양수여야 합니다'],
  //     email: ['올바른 이메일 형식이 아닙니다']
  //   }
  // }

  // format(): 중첩 객체 구조 유지
  const formatted = result.error.format();
  // {
  //   name: { _errors: ['이름은 필수입니다'] },
  //   age: { _errors: ['나이는 양수여야 합니다'] },
  //   ...
  // }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폼 검증에서는 &lt;code&gt;flatten()&lt;/code&gt;이, 중첩된 객체에서는 &lt;code&gt;format()&lt;/code&gt;이 유용하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;타입 강제 변환: coerce&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL 쿼리 파라미터나 폼 데이터는 모두 문자열로 들어온다. &lt;code&gt;z.coerce&lt;/code&gt;로 자동 변환할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 일반 스키마: &quot;123&quot;은 number가 아니므로 실패
z.number().parse(&quot;123&quot;); // ❌ ZodError

// coerce 스키마: 자동 변환 후 검증
z.coerce.number().parse(&quot;123&quot;);   // ✅ 123
z.coerce.boolean().parse(&quot;true&quot;); // ✅ true
z.coerce.date().parse(&quot;2024-01-01&quot;); // ✅ Date 객체&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API Route에서 쿼리 파라미터를 다룰 때 특히 유용하다.&lt;/p&gt;
&lt;pre class=&quot;roboconf&quot;&gt;&lt;code&gt;const QuerySchema = z.object({
  page: z.coerce.number().positive().default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
});

// ?page=2&amp;amp;limit=50 &amp;rarr; { page: 2, limit: 50 }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실전 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. API 응답 스키마&lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 공통 에러 응답
const ErrorResponseSchema = z.object({
  error: z.string(),
  code: z.string().optional(),
  statusCode: z.number().optional(),
});

// 공통 성공 응답
const SuccessResponseSchema = z.object({
  success: z.literal(true),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Discriminated Union&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 타입을 구분해야 할 때 유용하다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// Figma에서 가져온 레이어
const FigmaLayerSchema = z.object({
  source: z.literal('figma'),
  nodeId: z.string(),
  imageUrl: z.string(),
});

// 스크린샷 레이어
const SnapshotLayerSchema = z.object({
  source: z.literal('snapshot'),
  deviceId: z.string(),
  imageUrl: z.string(),
});

// 통합 스키마
const LayerSchema = z.discriminatedUnion('source', [
  FigmaLayerSchema,
  SnapshotLayerSchema,
]);

type Layer = z.infer&amp;lt;typeof LayerSchema&amp;gt;;
// { source: 'figma'; nodeId: string; ... } | { source: 'snapshot'; deviceId: string; ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;source&lt;/code&gt; 필드 값에 따라 다른 타입으로 좁혀진다. TypeScript의 타입 가드가 자동으로 작동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Request/Response 분리&lt;/h3&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;// 요청 스키마
export const CreateProjectRequestSchema = z.object({
  url: z.string().url('Invalid URL format'),
});

export type CreateProjectRequest = z.infer&amp;lt;typeof CreateProjectRequestSchema&amp;gt;;

// 응답 스키마
export const ProjectResponseSchema = z.object({
  id: z.string(),
  name: z.string(),
  updatedAt: z.string(),
  layers: z.array(LayerSchema),
});

export type ProjectResponse = z.infer&amp;lt;typeof ProjectResponseSchema&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마와 타입을 함께 export하면 프론트엔드와 백엔드에서 동일한 타입을 공유할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스키마 재사용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마를 조합하고 변형해서 재사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
  createdAt: z.string(),
});

// pick: 특정 필드만 선택
const UserPublicSchema = UserSchema.pick({ id: true, name: true });
// { id: string; name: string; }

// omit: 특정 필드 제외
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
// { name: string; email: string; role: 'admin' | 'user'; }

// partial: 모든 필드를 optional로
const UpdateUserSchema = UserSchema.partial();
// { id?: string; name?: string; ... }

// extend: 필드 추가
const UserWithTokenSchema = UserSchema.extend({
  accessToken: z.string(),
});

// merge: 두 스키마 합치기
const AuditSchema = z.object({ updatedBy: z.string() });
const UserWithAuditSchema = UserSchema.merge(AuditSchema);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CRUD API를 만들 때 기본 스키마 하나로 Create, Update, Response 스키마를 파생시킬 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;strict vs passthrough&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 데이터에 예상치 못한 필드가 있을 때의 동작을 제어한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const Schema = z.object({ name: z.string() });

const data = { name: 'Kim', extra: 'field' };

// 기본 동작: 알 수 없는 필드 무시 (strip)
Schema.parse(data); // { name: 'Kim' }

// strict: 알 수 없는 필드가 있으면 에러
Schema.strict().parse(data); // ❌ ZodError

// passthrough: 알 수 없는 필드 유지
Schema.passthrough().parse(data); // { name: 'Kim', extra: 'field' }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안이 중요한 API에서는 &lt;code&gt;strict()&lt;/code&gt;로 예상치 못한 데이터를 차단하고, 프록시 패턴에서는 &lt;code&gt;passthrough()&lt;/code&gt;로 데이터를 그대로 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API Route에서 사용&lt;/h2&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/api/projects/route.ts
import { CreateProjectRequestSchema } from '@pixeldiff/api-contracts';

export async function POST(request: NextRequest) {
  const body = await request.json();

  // 검증
  const result = CreateProjectRequestSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      { error: result.error.errors[0].message },
      { status: 400 }
    );
  }

  // result.data는 이미 타입이 보장됨
  const { url } = result.data;

  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증을 통과한 &lt;code&gt;result.data&lt;/code&gt;는 TypeScript가 정확한 타입으로 추론한다. 추가 타입 캐스팅이 필요 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;유용한 메서드들&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 기본값
z.string().default('unnamed')

// 변환
z.string().transform((val) =&amp;gt; val.toLowerCase())

// 조건부 검증
z.number().refine((n) =&amp;gt; n % 2 === 0, '짝수여야 합니다')

// nullable vs optional
z.string().nullable()  // string | null
z.string().optional()  // string | undefined

// enum
z.enum(['draft', 'published', 'archived'])

// 범위 제한
z.number().min(0).max(100)
z.string().min(1).max(255)
z.array(z.string()).min(1).max(10)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style2&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Zod의 핵심은 &lt;b&gt;Single Source of Truth&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마 하나로 런타임 검증과 TypeScript 타입을 동시에 관리한다. 타입 정의와 검증 로직이 분리되어 싱크가 안 맞는 문제를 원천 차단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 경계에서 &lt;code&gt;z.infer&lt;/code&gt;로 타입을 추출하고, &lt;code&gt;safeParse&lt;/code&gt;로 검증하면 된다. 외부 데이터를 다루는 모든 곳에 적용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Zod는 다양한 라이브러리와 통합된다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;React Hook Form&lt;/b&gt; + &lt;code&gt;@hookform/resolvers&lt;/code&gt;: 폼 검증을 Zod 스키마로 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;tRPC&lt;/b&gt;: API 엔드포인트의 입출력을 Zod로 정의하고 타입 안전한 API 호출&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Next.js Server Actions&lt;/b&gt;: 서버 액션의 입력값 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 데이터를 다루는 곳이라면 어디든 Zod를 적용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FrontEnd/TypeScript</category>
      <category>API 검증</category>
      <category>TypeScript</category>
      <category>ZOD</category>
      <category>런타임 타입</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/82</guid>
      <comments>https://1yoouoo.tistory.com/82#entry82comment</comments>
      <pubDate>Sat, 7 Mar 2026 18:00:21 +0900</pubDate>
    </item>
    <item>
      <title>사이드 프로젝트 DB로 Supabase를 선택한 이유</title>
      <link>https://1yoouoo.tistory.com/81</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;DB를 어디에 둘까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드 프로젝트를 시작할 때 가장 먼저 부딪히는 질문이 있다. DB를 어디에 둘 것인가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택지는 크게 두 가지다. 서버에 직접 PostgreSQL을 설치하거나, 관리형 서비스를 쓰거나.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 설치하면 비용은 아끼지만 백업, 모니터링, 버전 업그레이드를 전부 직접 해야 한다. 사이드 프로젝트는 본업 끝나고 틈틈이 하는 건데, DB 관리까지 신경 쓰고 싶지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리형 서비스로 방향을 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;선택지 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL을 지원하는 관리형 서비스 중 무료 티어가 있는 것들을 추렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서비스&lt;/th&gt;
&lt;th&gt;DB 엔진&lt;/th&gt;
&lt;th&gt;무료 티어&lt;/th&gt;
&lt;th&gt;Prisma 호환&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Supabase&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;500MB, 무제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Auth, Storage 등 부가 기능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PlanetScale&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;Hobby 플랜 종료&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Vitess 기반, 브랜칭 기능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Neon&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;512MB, 무제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Serverless, 자동 스케일링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Railway&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;$5 크레딧/월&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;간편한 배포, 크레딧 소진 시 중단&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlanetScale은 MySQL 기반이라 제외했다. Prisma는 PostgreSQL과 MySQL 모두 지원하지만, 이미 PostgreSQL에 익숙했고 굳이 바꿀 이유가 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Railway는 무료 크레딧이 소진되면 서비스가 멈춘다. 사이드 프로젝트라 트래픽 예측이 어려워서 불안했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neon과 Supabase가 남았다. 둘 다 PostgreSQL 기반이고 무료 티어도 비슷하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Supabase를 선택한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면 Neon과 Supabase는 큰 차이가 없다. 둘 다 PostgreSQL이고, 무료 티어 용량도 비슷하고, Prisma도 잘 붙는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정적인 건 레퍼런스 양이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Supabase Prisma 연결&quot; &quot;Supabase 마이그레이션 오류&quot;로 검색하면 스택오버플로우, 블로그, GitHub 이슈가 많이 나온다. Neon은 상대적으로 적다. 사이드 프로젝트는 삽질 시간을 줄이는 게 중요하다. 문제가 생겼을 때 빠르게 해결책을 찾을 수 있는 쪽을 택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부가적인 이유도 있다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Auth, Storage 확장 가능성&lt;/b&gt;: 지금은 DB만 쓰지만, 나중에 소셜 로그인이나 파일 업로드가 필요하면 같은 프로젝트 안에서 추가할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대시보드 UI&lt;/b&gt;: 테이블 구조 확인, 데이터 조회, SQL 실행을 웹에서 바로 할 수 있다. Neon도 가능하지만 Supabase 쪽이 조금 더 익숙했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 운영: Dev/Prod 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase 프로젝트를 두 개 만들어서 Dev와 Prod를 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;환경&lt;/th&gt;
&lt;th&gt;리전&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development&lt;/td&gt;
&lt;td&gt;Seoul (ap-northeast-2)&lt;/td&gt;
&lt;td&gt;로컬 개발, 테스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Singapore (ap-southeast-1)&lt;/td&gt;
&lt;td&gt;실서비스&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prod를 싱가포르에 둔 이유는 웹 서버가 DigitalOcean 싱가포르 리전에 있어서다. DB와 서버가 같은 리전에 있어야 레이턴시가 낮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dev는 서울에 뒀다. 로컬에서 개발할 때 응답이 빨라야 작업이 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;연결 방식: Session Pooler&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase Free Tier에서는 Direct Connection(포트 5432 직접 연결)이 방화벽으로 막혀 있다. IPv4 Add-on($4/월)을 붙여야 열린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 Session Pooler를 통해 연결한다. Pooler는 DB 앞단에서 연결을 관리해주는 프록시다. 클라이언트가 많아져도 DB 연결 수를 일정하게 유지해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prisma에서는 이렇게 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;datasource db {
  provider = &quot;postgresql&quot;
  url      = env(&quot;DATABASE_URL&quot;)
  // directUrl = env(&quot;DIRECT_URL&quot;)  // Free Tier에서는 사용 불가
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;에는 Pooler URL을 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션도 이 URL로 잘 돌아간다. 처음엔 Direct Connection이 필요한 줄 알았는데 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;여담: 데이터를 날린 이야기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supabase를 1년 가까이 잘 쓰다가 한 번 크게 데이터를 날렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블 두 개를 하나로 통합하는 마이그레이션을 진행했다. Dev DB에서는 데이터 이관 스크립트를 돌리고 기존 테이블을 DROP했다. 잘 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 Production이었다. 마이그레이션 SQL만 배포하고, 데이터 이관 스크립트를 깜빡했다. DROP TABLE이 실행됐고, 데이터가 전부 날아갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복구하려고 Supabase 대시보드를 열었다. Backups 메뉴로 들어갔는데 이런 문구가 떴다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Backups are available on the Pro plan.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Free Tier에는 백업이 없다. 복구 불가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 사고 이후로 두 가지를 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 데이터 이관은 SQL 마이그레이션 파일에 포함시킨다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 스크립트로 분리하면 Production에서 까먹을 수 있다. &lt;code&gt;INSERT INTO ... SELECT&lt;/code&gt;를 마이그레이션 SQL에 직접 넣어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- migration.sql
-- 1. 새 테이블 생성
CREATE TABLE &quot;Layer&quot; (...);

-- 2. 기존 데이터 복사 (DROP 전에!)
INSERT INTO &quot;Layer&quot; (id, &quot;projectId&quot;, source, ...)
SELECT id, &quot;projectId&quot;, 'figma', ...
FROM &quot;FigmaItem&quot;;

-- 3. 기존 테이블 삭제
DROP TABLE &quot;FigmaItem&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 위험한 마이그레이션 전에는 수동 백업&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Free Tier라도 &lt;code&gt;pg_dump&lt;/code&gt;로 백업할 수 있다. 귀찮지만 데이터 날리는 것보다 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;pg_dump &quot;postgresql://...&quot; &amp;gt; backup_$(date +%Y%m%d).sql&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 Supabase가 적합한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Supabase가 맞는 경우:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL + Prisma 조합을 쓴다&lt;/li&gt;
&lt;li&gt;DB 관리에 시간 쓰고 싶지 않다&lt;/li&gt;
&lt;li&gt;무료로 시작하고 싶다&lt;/li&gt;
&lt;li&gt;나중에 Auth/Storage 확장 가능성을 열어두고 싶다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다른 선택이 나은 경우:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MySQL이 필요하다 &amp;rarr; PlanetScale (유료) 또는 직접 설치&lt;/li&gt;
&lt;li&gt;Serverless 스케일링이 중요하다 &amp;rarr; Neon&lt;/li&gt;
&lt;li&gt;자동 백업이 필수다 &amp;rarr; Supabase Pro($25/월) 또는 다른 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Free Tier로 시작해서 서비스가 커지면 Pro로 올리면 된다. 다만 &quot;Free Tier에는 백업이 없다&quot;는 건 꼭 기억해두자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style2&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>토이프로젝트</category>
      <category>PostgreSQL</category>
      <category>prisma</category>
      <category>supabase</category>
      <category>데이터베이스</category>
      <category>사이드프로젝트</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/81</guid>
      <comments>https://1yoouoo.tistory.com/81#entry81comment</comments>
      <pubDate>Thu, 5 Mar 2026 18:00:24 +0900</pubDate>
    </item>
    <item>
      <title>pnpm Workspace로 웹앱과 Chrome Extension 모노레포 구성하기</title>
      <link>https://1yoouoo.tistory.com/80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱과 Chrome Extension을 따로 관리하면 타입 정의가 중복되고, API 계약이 어긋나기 쉽다. 프로젝트 초반에는 각각 독립 레포로 시작했지만, 공유 코드가 늘어나면서 복사-붙여넣기의 한계가 드러났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포로 통합하면 해결될 문제였다. 선택지는 크게 세 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패키지 매니저 선택&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;패키지 매니저&lt;/th&gt;
&lt;th&gt;Workspace 지원&lt;/th&gt;
&lt;th&gt;디스크 효율&lt;/th&gt;
&lt;th&gt;속도&lt;/th&gt;
&lt;th&gt;특이사항&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;v7+&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;느림&lt;/td&gt;
&lt;td&gt;기본 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yarn (classic)&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;td&gt;호이스팅 이슈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yarn berry (PnP)&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;node_modules 없음, 호환성 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;심볼릭 링크 기반, 엄격한 의존성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;yarn classic의 호이스팅 이슈는 의존성이 node_modules 루트로 끌어올려져서 명시하지 않은 패키지도 import할 수 있는 문제다. 나중에 의존성을 정리할 때 어떤 패키지가 실제로 필요한지 파악하기 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pnpm은 심볼릭 링크로 의존성을 관리한다. 각 패키지가 명시한 의존성만 접근할 수 있어서 유령 의존성(phantom dependency) 문제가 없다. yarn berry의 PnP 모드도 이 문제를 해결하지만, Next.js와 Prisma에서 호환성 이슈가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pnpm을 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;빌드 시스템 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포에서 태스크 관리도 고려해야 했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;도구&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;th&gt;설정 복잡도&lt;/th&gt;
&lt;th&gt;캐싱&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Turborepo&lt;/td&gt;
&lt;td&gt;태스크 러너, 증분 빌드&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;로컬/원격&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nx&lt;/td&gt;
&lt;td&gt;풀 프레임워크, 코드 생성&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;로컬/원격&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lerna&lt;/td&gt;
&lt;td&gt;버전 관리 특화&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Nx는 기능이 많지만 설정이 복잡하다. pixelDiff 규모에서는 과하다. Turborepo는 기존 pnpm 워크스페이스에 &lt;code&gt;turbo.json&lt;/code&gt; 하나만 추가하면 된다. 태스크 의존성과 캐싱을 선언적으로 관리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pnpm workspace가 패키지 구조를, Turborepo가 태스크 실행을 담당하는 구조다. 각자 역할이 명확하고 조합이 깔끔하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디렉토리 구조 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 구조는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;pixelDiff/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
├── apps/
│   └── web/                    # Next.js 웹앱
├── packages/
│   ├── api-contracts/          # API 타입 정의 (Zod)
│   ├── core/                   # 공유 로직
│   └── scripts/                # DB 스크립트
└── extension/                  # Chrome Extension (Vite)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;apps/&lt;/code&gt;에는 배포 가능한 애플리케이션, &lt;code&gt;packages/&lt;/code&gt;에는 공유 라이브러리를 둔다. 일반적인 모노레포 컨벤션이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;extension/&lt;/code&gt;은 &lt;code&gt;apps/extension/&lt;/code&gt;이 아닌 루트에 두었다. Chrome Extension은 Vite 기반이고, 빌드 결과물을 웹스토어에 업로드해야 한다. apps 패턴과 맞지 않아서 별도로 뺐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;워크스페이스 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;haml&quot;&gt;&lt;code&gt;packages:
  - 'apps/*'
  - 'packages/*'
  - 'extension'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 패키지의 &lt;code&gt;package.json&lt;/code&gt;에서 &lt;code&gt;name&lt;/code&gt; 필드가 중요하다. 다른 패키지에서 참조할 때 이 이름을 사용한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;@pixeldiff/api-contracts&quot;,
  &quot;main&quot;: &quot;./src/index.ts&quot;,
  &quot;types&quot;: &quot;./src/index.ts&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 없이 TypeScript 소스를 직접 참조하도록 &lt;code&gt;main&lt;/code&gt;과 &lt;code&gt;types&lt;/code&gt;를 &lt;code&gt;./src/index.ts&lt;/code&gt;로 지정했다. 개발 중에는 빌드 단계 없이 바로 타입 체크가 된다. 프로덕션 빌드 시에는 Next.js가 트랜스파일한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱에서 내부 패키지를 참조할 때는 &lt;code&gt;workspace:*&lt;/code&gt; 프로토콜을 쓴다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;dependencies&quot;: {
    &quot;@pixeldiff/api-contracts&quot;: &quot;workspace:*&quot;,
    &quot;@pixeldiff/core&quot;: &quot;workspace:*&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;workspace:*&lt;/code&gt;는 &quot;워크스페이스 내 해당 패키지를 참조하라&quot;는 의미다. 버전 번호 대신 쓰면 항상 로컬 코드를 참조한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Turborepo 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;turbo.json&lt;/code&gt;에서 태스크 파이프라인을 정의한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
  &quot;pipeline&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;.next/**&quot;, &quot;!.next/cache/**&quot;, &quot;dist/**&quot;]
    },
    &quot;dev&quot;: {
      &quot;cache&quot;: false,
      &quot;persistent&quot;: true
    },
    &quot;lint&quot;: {
      &quot;dependsOn&quot;: [&quot;^lint&quot;]
    },
    &quot;type-check&quot;: {
      &quot;dependsOn&quot;: [&quot;^type-check&quot;]
    },
    &quot;test&quot;: {
      &quot;outputs&quot;: [&quot;coverage/**&quot;],
      &quot;cache&quot;: false
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;dependsOn: [&quot;^build&quot;]&lt;/code&gt;에서 &lt;code&gt;^&lt;/code&gt;는 의존하는 패키지를 뜻한다. &lt;code&gt;@pixeldiff/web&lt;/code&gt;이 &lt;code&gt;@pixeldiff/core&lt;/code&gt;에 의존하면, core의 build가 먼저 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;dev&lt;/code&gt; 태스크는 &lt;code&gt;persistent: true&lt;/code&gt;로 설정한다. 개발 서버는 계속 실행 중이어야 하기 때문이다. &lt;code&gt;cache: false&lt;/code&gt;는 개발 서버 결과를 캐싱하지 않는다는 의미다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;루트 스크립트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루트 &lt;code&gt;package.json&lt;/code&gt;에서 워크스페이스 전체를 제어하는 스크립트를 정의한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;turbo run dev&quot;,
    &quot;build&quot;: &quot;turbo run build&quot;,
    &quot;lint&quot;: &quot;turbo run lint&quot;,
    &quot;test&quot;: &quot;turbo run test&quot;,
    &quot;type-check&quot;: &quot;turbo run type-check&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pnpm dev&lt;/code&gt;를 실행하면 Turborepo가 모든 패키지의 dev 태스크를 병렬로 실행한다. 웹앱과 Extension 개발 서버가 동시에 뜬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 패키지만 실행하려면 &lt;code&gt;--filter&lt;/code&gt; 플래그를 쓴다.&lt;/p&gt;
&lt;pre class=&quot;golo&quot;&gt;&lt;code&gt;pnpm --filter @pixeldiff/web dev
pnpm --filter @pixeldiff/extension build&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공유 패키지 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@pixeldiff/api-contracts&lt;/code&gt;에는 API 요청/응답 타입을 Zod 스키마로 정의한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { z } from 'zod';

export const ProjectSchema = z.object({
  id: z.string(),
  name: z.string(),
  createdAt: z.date(),
});

export type Project = z.infer&amp;lt;typeof ProjectSchema&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱과 Extension 모두 같은 타입을 사용한다. API 응답 형식이 바뀌면 한 곳만 수정하면 된다. 타입 불일치는 빌드 타임에 잡힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Extension 분리의 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chrome Extension을 &lt;code&gt;apps/extension/&lt;/code&gt;이 아닌 루트 &lt;code&gt;extension/&lt;/code&gt;에 둔 이유가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;빌드 도구가 다르다.&lt;/b&gt; 웹앱은 Next.js, Extension은 Vite다. &lt;code&gt;@crxjs/vite-plugin&lt;/code&gt;으로 manifest.json을 처리한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배포 플로우가 다르다.&lt;/b&gt; 웹앱은 서버 배포, Extension은 Chrome 웹스토어 업로드다. 빌드 결과물을 zip으로 패키징해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;의존성이 독립적이다.&lt;/b&gt; Extension은 현재 내부 패키지를 참조하지 않는다. 웹앱과 메시지를 주고받지만, 코드 레벨 의존성은 없다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Extension이 &lt;code&gt;@pixeldiff/api-contracts&lt;/code&gt;를 참조해야 한다면 &lt;code&gt;apps/extension/&lt;/code&gt;으로 옮기는 것이 맞다. 현재는 그렇지 않아서 루트에 두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pnpm workspace + Turborepo 조합은 설정이 단순하면서도 효과적이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;공유 코드는 packages/로 분리한다.&lt;/b&gt; 타입 정의, 유틸리티, 공통 로직.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;workspace:* 프로토콜로 내부 패키지를 참조한다.&lt;/b&gt; 버전 관리 부담이 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빌드 도구나 배포 플로우가 다르면 별도 디렉토리로 뺀다.&lt;/b&gt; 무리하게 패턴에 맞추지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소규모 프로젝트에서 시작해도 모노레포 구조를 잡아두면 나중에 코드 공유가 쉬워진다. 다만 패키지를 너무 잘게 쪼개면 오버헤드가 생긴다. 실제로 공유할 코드가 생겼을 때 분리해도 늦지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style2&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>토이프로젝트</category>
      <category>chrome extension</category>
      <category>next.js</category>
      <category>pnpm</category>
      <category>turborepo</category>
      <category>workspace</category>
      <category>모노레포</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/80</guid>
      <comments>https://1yoouoo.tistory.com/80#entry80comment</comments>
      <pubDate>Tue, 3 Mar 2026 18:00:42 +0900</pubDate>
    </item>
    <item>
      <title>서비스 웹앱과 Chrome Extension 간 버전 호환성 관리하기</title>
      <link>https://1yoouoo.tistory.com/79</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱과 Chrome Extension이 함께 동작하는 서비스에서 버전 관리는 단순하지 않다. 두 컴포넌트가 독립적으로 배포되기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱은 배포 즉시 모든 사용자에게 반영된다. 반면 Chrome Extension은 Chrome 웹스토어 심사를 거쳐야 하고, 사용자가 수동으로 업데이트하거나 Chrome이 자동 업데이트할 때까지 구버전이 남아 있을 수 있다. 이 시간차가 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pixelDiff는 웹앱에서 Extension으로 캡처 명령을 보내고, Extension이 스크린샷을 찍어 돌려주는 구조다. 웹앱이 새 메시지 포맷을 사용하는데 Extension은 구버전이라면? 통신이 깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 정의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요구사항:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹앱이 Extension 설치 여부를 감지&lt;/li&gt;
&lt;li&gt;Extension 버전이 웹앱 요구 버전보다 낮으면 업데이트 안내&lt;/li&gt;
&lt;li&gt;사용자가 Extension을 설치/업데이트하면 즉시 감지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제약 조건:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Extension은 웹앱 코드에 직접 접근 불가 (Chrome 보안 정책)&lt;/li&gt;
&lt;li&gt;웹앱은 Extension이 설치되어 있는지 직접 확인하는 API가 없음&lt;/li&gt;
&lt;li&gt;사용자가 여러 탭을 열어두고 다른 탭에서 Extension을 설치할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;버전 호환성 전략 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Extension 감지 방법을 고민하기 전에, 더 근본적인 질문이 있었다. &lt;b&gt;구버전 Extension 사용자를 어떻게 처리할 것인가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처음 고민: 버전별 웹앱 분기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 앱처럼 접근하려 했다. Extension 버전을 확인하고, 그 버전에 맞는 웹앱 코드를 보여주는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;Extension v1.1.0 &amp;rarr; 웹앱 v1.1.x 코드 제공
Extension v1.2.0 &amp;rarr; 웹앱 v1.2.x 코드 제공&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론상 모든 사용자가 자신의 Extension 버전에 맞는 웹앱을 사용하니 호환성 문제가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 현실은 달랐다.&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;레거시 버전의 동작 보장 불가&lt;/b&gt;: 과거 버전 웹앱이 현재 인프라(API, DB 스키마)와 호환되는지 매번 확인해야 한다. 백엔드가 바뀌면 프론트엔드 레거시도 깨질 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;QA 부담 폭발&lt;/b&gt;: 웹앱 버전 N개 &amp;times; Extension 버전 M개 = N&amp;times;M 조합을 테스트해야 한다. 버전이 쌓일수록 감당이 안 된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버그 수정의 악몽&lt;/b&gt;: 보안 취약점이 발견되면 모든 레거시 버전에 패치를 배포해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 앱은 클라이언트가 모든 로직을 들고 있어서 이 방식이 가능하다. 웹앱은 서버 의존성이 높아서 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론: 단일 버전 + 업데이트 강제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;웹앱은 항상 최신 버전만 제공하고, Extension이 구버전이면 업데이트를 안내한다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;호환성 매트릭스 관리 불필요&lt;/li&gt;
&lt;li&gt;QA는 최신 버전 조합만 테스트&lt;/li&gt;
&lt;li&gt;버그 수정은 한 곳에서&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트레이드오프는 있다. 구버전 Extension 사용자는 업데이트 전까지 일부 기능이 제한될 수 있다. 하지만 Chrome Extension은 자동 업데이트가 기본이라 대부분 24시간 내 최신 버전으로 갱신된다. 감수할 만한 수준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Extension 감지 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전 전략이 정해졌으니, 이제 Extension을 어떻게 감지할지 결정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방법&lt;/th&gt;
&lt;th&gt;원리&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;한계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content Script 주입 감지&lt;/td&gt;
&lt;td&gt;Extension이 페이지에 스크립트 주입 &amp;rarr; 전역 변수 확인&lt;/td&gt;
&lt;td&gt;구현 간단&lt;/td&gt;
&lt;td&gt;페이지 로드 후에만 감지, 새로고침 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;externally_connectable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extension의 background script에 직접 메시지 전송&lt;/td&gt;
&lt;td&gt;페이지 새로고침 없이 감지&lt;/td&gt;
&lt;td&gt;manifest에 허용 도메인 명시 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Messaging&lt;/td&gt;
&lt;td&gt;로컬 앱을 통한 브릿지&lt;/td&gt;
&lt;td&gt;완전한 제어&lt;/td&gt;
&lt;td&gt;설치 복잡도 높음, 웹 서비스에 부적합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;용어 정리:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Content Script&lt;/b&gt;: Extension이 웹페이지에 주입하는 JavaScript. 페이지의 DOM에 접근할 수 있지만, 페이지가 로드될 때 주입된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;externally_connectable&lt;/code&gt;&lt;/b&gt;: Extension이 특정 도메인의 웹페이지에서 직접 메시지를 받을 수 있게 허용하는 manifest 설정.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선택:&lt;/b&gt; Content Script + &lt;code&gt;externally_connectable&lt;/code&gt; 하이브리드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Content Script만 쓰면 사용자가 Extension 설치 후 페이지를 새로고침해야 한다. 다른 탭에서 Extension을 설치하고 돌아와도 감지가 안 된다. Content Script는 페이지 로드 시점에 주입되기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;externally_connectable&lt;/code&gt;이 해결책이 됐다. 이 설정을 추가하면 웹앱이 Extension의 background script에 직접 메시지를 보낼 수 있다. Content Script 주입 여부와 무관하게 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PING-PONG 프로토콜&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전 확인의 핵심은 &quot;Extension이 응답할 수 있는가?&quot;다. 웹앱이 PING을 보내고, Extension이 PONG과 함께 버전을 응답하는 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 웹앱: Extension에 PING 전송 (Content Script 경유)
const pingExtension = (): Promise&amp;lt;{ installed: boolean; version?: string }&amp;gt; =&amp;gt; {
  return new Promise((resolve) =&amp;gt; {
    const timeout = setTimeout(() =&amp;gt; {
      cleanup();
      resolve({ installed: false });
    }, 1000);

    const handler = (event: MessageEvent) =&amp;gt; {
      if (
        event.data?.type === 'PIXELDIFF_PONG' &amp;amp;&amp;amp;
        event.data?.source === 'pixeldiff-extension'
      ) {
        cleanup();
        resolve({
          installed: true,
          version: event.data.version,
        });
      }
    };

    window.addEventListener('message', handler);
    window.postMessage({
      type: 'PIXELDIFF_PING',
      source: 'pixeldiff-webapp',
    }, '*');
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1초 내 응답이 없으면 미설치로 판단한다. 응답이 오면 버전 정보를 추출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Semantic Version 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Extension 버전이 웹앱 요구 버전보다 낮은지 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;function isVersionOutdated(current: string, required: string): boolean {
  const [cMajor = 0, cMinor = 0, cPatch = 0] = current.split('.').map(Number);
  const [rMajor = 0, rMinor = 0, rPatch = 0] = required.split('.').map(Number);

  if (cMajor &amp;lt; rMajor) return true;
  if (cMajor === rMajor &amp;amp;&amp;amp; cMinor &amp;lt; rMinor) return true;
  if (cMajor === rMajor &amp;amp;&amp;amp; cMinor === rMinor &amp;amp;&amp;amp; cPatch &amp;lt; rPatch) return true;

  return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱의 요구 버전은 &lt;code&gt;package.json&lt;/code&gt; version을 &lt;code&gt;next.config.js&lt;/code&gt;에서 환경변수로 주입한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// next.config.js
const packageJson = require('./package.json');

const nextConfig = {
  env: {
    NEXT_PUBLIC_APP_VERSION: packageJson.version,
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값이 &lt;code&gt;MIN_EXTENSION_VERSION&lt;/code&gt;이 된다. 웹앱 버전 = Extension 최소 요구 버전이라는 단순한 규칙이다. 별도 호환성 매트릭스를 관리하지 않아도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;탭 전환 시 재감지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Content Script 방식의 한계를 &lt;code&gt;externally_connectable&lt;/code&gt;로 해결한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Extension의 &lt;code&gt;manifest.json&lt;/code&gt;에 허용 도메인을 명시한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;externally_connectable&quot;: {
    &quot;matches&quot;: [
      &quot;https://pixeldiff.turtle-tail.com/*&quot;,
      &quot;http://localhost:3000/*&quot;
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 웹앱은 &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;로 Extension의 background script에 직접 메시지를 보낼 수 있다. Content Script가 주입되지 않은 상태에서도 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 웹앱: background script로 직접 PING
const pingExtensionBackground = async () =&amp;gt; {
  const chromeRuntime = window.chrome?.runtime;
  if (!chromeRuntime?.sendMessage) {
    return { installed: false };
  }

  return new Promise((resolve) =&amp;gt; {
    chromeRuntime.sendMessage(
      EXTENSION_ID,
      { type: 'PIXELDIFF_PING' },
      (response) =&amp;gt; {
        if (response?.type === 'PIXELDIFF_PONG') {
          resolve({ installed: true, version: response.version });
        } else {
          resolve({ installed: false });
        }
      }
    );
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;visibilitychange&lt;/code&gt; 이벤트와 조합하면 탭 전환 시 자동 재확인이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;document.addEventListener('visibilitychange', async () =&amp;gt; {
  if (document.visibilityState === 'visible') {
    // 디바운스: 빠른 탭 전환 시 과도한 ping 방지
    await delay(500);

    const result = await pingExtensionBackground();
    if (result.installed &amp;amp;&amp;amp; previousStatus !== 'OK') {
      showExtensionInstalledToast(); // 설치 성공 알림
    }
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;500ms 디바운스를 추가한 이유가 있다. 사용자가 탭을 빠르게 전환할 때 Extension에 과도한 요청이 가는 것을 방지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태 관리와 캐싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Extension 상태는 세 가지로 구분한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;th&gt;UX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT_INSTALLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PING 응답 없음&lt;/td&gt;
&lt;td&gt;설치 안내 토스트 + 웹스토어 링크&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OUTDATED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;버전 &amp;lt; 요구 버전&lt;/td&gt;
&lt;td&gt;업데이트 안내 토스트 (기능은 허용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;버전 &amp;gt;= 요구 버전&lt;/td&gt;
&lt;td&gt;정상 동작&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;OUTDATED&lt;/code&gt; 상태에서 기능을 완전히 차단하지 않은 이유가 있다. Minor/Patch 업데이트는 보통 하위 호환성을 유지한다. 경고만 표시하고 사용자 판단에 맡기는 게 UX 측면에서 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스트 스팸을 방지하기 위해 두 가지 캐시를 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// 상태 캐시: 3초간 재확인 생략
const healthCache = {
  status: 'NOT_INSTALLED',
  timestamp: 0,
  cacheMs: 3000,
};

// 토스트 쿨다운: 10초간 중복 토스트 방지
let lastToastTime = 0;
const TOAST_COOLDOWN_MS = 10000;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캡처 버튼을 연타해도 토스트가 쏟아지지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹앱-Extension 버전 호환성 관리에서 중요한 점은 세 가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;버전별 분기보다 단일 버전&lt;/b&gt;: 레거시 호환을 포기하는 대신 유지보수 비용을 줄인다. 모바일 앱과 웹앱은 서버 의존도가 다르다. 웹앱에서 버전별 분기는 QA 지옥으로 가는 길이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이중 감지 채널&lt;/b&gt;: Content Script(&lt;code&gt;postMessage&lt;/code&gt;)와 &lt;code&gt;externally_connectable&lt;/code&gt;(&lt;code&gt;runtime.sendMessage&lt;/code&gt;)을 조합. 페이지 로드 시와 탭 전환 시 모두 감지 가능하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Graceful Degradation&lt;/b&gt;: 구버전이라고 무조건 차단하지 않는다. Breaking Change가 있는 Major 업데이트만 차단하고, Minor/Patch는 경고로 처리한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 웹앱과 Extension이 메시지 기반으로 협력하는 모든 서비스에 적용할 수 있다. 핵심은 &quot;Extension이 버전을 응답하게 하고, 웹앱이 판단한다&quot;는 단방향 의존성이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한&lt;br /&gt;&quot; ALL IN ONE &quot;&amp;nbsp; &amp;nbsp;QA 서비스&lt;/span&gt;&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nWhaO/dJMb99ZpQoO/KVOoKGV8YqsWPGfuyetWj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnWhaO%2FdJMb99ZpQoO%2FKVOoKGV8YqsWPGfuyetWj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;380&quot; data-filename=&quot;Screenshot 2026-01-12 at 6.39.41 PM.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;760&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;PixelDIff&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://pixeldiff.turtle-tail.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://pixeldiff.turtle-tail.com&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>토이프로젝트</category>
      <category>chrome extension</category>
      <category>externally_connectable</category>
      <category>PING-PONG 프로토콜</category>
      <category>Semantic Versioning</category>
      <category>버전 관리</category>
      <author>여행 가고싶다</author>
      <guid isPermaLink="true">https://1yoouoo.tistory.com/79</guid>
      <comments>https://1yoouoo.tistory.com/79#entry79comment</comments>
      <pubDate>Sat, 28 Feb 2026 18:00:11 +0900</pubDate>
    </item>
  </channel>
</rss>