<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>메로그</title>
    <link>https://white-blank.tistory.com/</link>
    <description>기록하고 싶은 것을 메모하는 블로그</description>
    <language>ko</language>
    <pubDate>Sat, 8 Aug 2026 00:57:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>링재호</managingEditor>
    <image>
      <title>메로그</title>
      <url>https://tistory1.daumcdn.net/tistory/4928686/attach/1484bbce3ff6411da55ca78eeb5c19f5</url>
      <link>https://white-blank.tistory.com</link>
    </image>
    <item>
      <title>Vite 8 마이그레이션으로 빌드 시간 3분의 1로 줄이기</title>
      <link>https://white-blank.tistory.com/263</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 커지면서 빌드 시간이 조금씩 늘어나는 게 계속 신경 쓰였습니다.&lt;br /&gt;8.5초 정도면 참을 만한 수준이긴 하지만, CI에서 여러 번 돌고 배포 파이프라인에 끼어 있다 보면 이 몇 초가 누적되어 꽤 큰 비용이 됩니다.&lt;br /&gt;마침 Vite 8이 Rolldown을 기본 번들러로 채택하며 출시됐고, 빌드 시간 단축을 목표로 Vite 6에서 8로 마이그레이션을 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과부터 말하면 빌드 시간이 8.5초에서 2.7초로, 3배 이상 단축됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 메이저 버전을 두 단계 건너뛰는 마이그레이션이다 보니 중간에 여러 문제를 겪었고, 그 과정을 기록으로 남깁니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%99%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%EC%9D%84-%ED%96%88%EB%8A%94%EA%B0%80&quot;&gt;왜 마이그레이션을 했는가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#vite-8%EA%B3%BC-rolldown&quot;&gt;Vite 8과 Rolldown&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%EA%B3%BC%EC%A0%95&quot;&gt;마이그레이션 과정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B2%AA%EC%9D%80-%EB%AC%B8%EC%A0%9C%EB%93%A4&quot;&gt;겪은 문제들&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B2%B0%EA%B3%BC&quot;&gt;결과&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%B0%B0%EC%9A%B4-%EC%A0%90&quot;&gt;배운 점&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&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;p data-ke-size=&quot;size16&quot;&gt;Vite 8의 핵심 변화는 번들러 교체입니다. 기존 Vite는 개발 서버에서는 esbuild로 사전 번들링을 하고, 프로덕션 빌드에서는 Rollup을 사용하는 이중 구조였습니다. Vite 8은 이 두 가지를 Rust 기반의 Rolldown 하나로 통합했고, 공식적으로 최대 10~30배 빠른 빌드 성능을 이야기합니다.&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;Linear: 46초 &amp;rarr; 6초&lt;/li&gt;
&lt;li&gt;Ramp: 57% 단축&lt;/li&gt;
&lt;li&gt;Mercedes-Benz.io: 최대 38% 단축&lt;/li&gt;
&lt;li&gt;Beehiiv: 64% 단축&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커뮤니티에서도 4분에서 30초로, 12분에서 2분으로 줄었다는 보고가 이어졌습니다. 이 정도 수치라면 마이그레이션 비용을 감수할 가치가 충분하다고 판단했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Vite 8과 Rolldown&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rolldown은 Vite 팀이 직접 만든 Rust 기반 번들러입니다. Rollup의 플러그인 API와 호환되도록 설계됐기 때문에 기존 Rollup 생태계의 플러그인을 대부분 그대로 사용할 수 있습니다.&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;코드 변환(transform)이 esbuild에서 Oxc 기반으로 교체&lt;/li&gt;
&lt;li&gt;@vitejs/plugin-react v6부터 Babel 없이 Oxc 기반 React Refresh 동작&lt;/li&gt;
&lt;li&gt;CSS 압축에 lightningcss 사용&lt;/li&gt;
&lt;li&gt;TypeScript 경로(paths) 자동 해석 및 alias 지원&lt;/li&gt;
&lt;li&gt;Vite Devtools로 프로젝트 상태 시각화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지 용량은 Vite 7 대비 약 15MB 늘었습니다. lightningcss가 약 10MB, Rolldown 바이너리가 약 5MB를 차지하는데, 빌드 성능을 생각하면 충분히 납득할 만한 트레이드오프입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마이그레이션 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에서는 대부분의 프로젝트가 설정 변경 없이 업그레이드 가능하다고 안내합니다. 기존 esbuild 설정과 rollupOptions 설정도 자동으로 변환해 줍니다. 다만 저는 6에서 8로 두 단계를 건너뛰는 상황이었기 때문에, 중간에 낀 Vite 7의 breaking change까지 함께 감당해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 의존성을 업데이트했습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;pnpm add -D vite@^8 @vitejs/plugin-react@^6&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 마이그레이션 전후로 빌드 시간을 비교하기 위해 기준 수치를 먼저 측정해 뒀습니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# 마이그레이션 전 (Vite 6)
time pnpm build  # 약 8.5초

# 마이그레이션 후 (Vite 8)
time pnpm build  # 약 2.7초&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 파일 자체는 크게 손댈 것이 없었습니다. 문제는 설정보다 그 주변에서 터졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;겪은 문제들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Node.js 버전 요구사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vite 7부터 Node 18 지원이 중단됐습니다. Vite 8은 Node 20.19+ 또는 22.12+가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 Node 24 버전이었기 때문에 큰 문제는 없었습니다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 레거시한 플러그인들은 문제가 될 수 있다고 하니 참고바랍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;esbuild 옵션 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 설정에 esbuild 관련 옵션이 남아 있었습니다. Vite 8이 상당 부분 자동 변환해 주지만, 자동 변환에 의존하기보다 명시적으로 정리하는 쪽을 택했습니다. 예를 들어 의존성 사전 번들링 옵션은 다음과 같이 바뀝니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 기존 (Vite 6)
export default defineConfig({
  optimizeDeps: {
    esbuildOptions: {
      target: 'es2020',
    },
  },
})

// 변경 (Vite 8)
export default defineConfig({
  optimizeDeps: {
    rollupOptions: {
      // Rolldown 기반 옵션으로 정리
    },
  },
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;청크 분리 전략 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rollup의 &lt;code&gt;manualChunks&lt;/code&gt;로 벤더 청크를 나누고 있었다면 Rolldown에서는 &lt;code&gt;advancedChunks&lt;/code&gt;라는 새로운 API가 권장됩니다. 기존 설정도 동작은 하지만 경고가 출력되어 새 API로 옮겼습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 기존 manualChunks 방식에서
build: {
  rollupOptions: {
    output: {
      advancedChunks: {
        groups: [{ name: 'vendor', test: /node_modules/ }],
      },
    },
  },
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;빌드 산출물 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번들러가 통째로 바뀌었기 때문에 빌드가 성공했다고 끝이 아니었습니다. 청크 분리 결과와 번들 크기가 이전과 달라질 수 있어서, 빌드 산출물을 배포 전에 실제로 띄워 보고 주요 페이지가 정상 동작하는지 확인하는 과정을 거쳤습니다. 결과적으로 큰 차이는 없었지만, 번들러 교체 수준의 마이그레이션에서는 이 검증을 생략하면 안 된다고 생각합니다.&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 data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&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;Vite 6 (esbuild + Rollup)&lt;/td&gt;
&lt;td&gt;약 8.5초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vite 8 (Rolldown)&lt;/td&gt;
&lt;td&gt;약 2.7초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;약 3.1배 단축됐습니다. 로컬에서 체감되는 것은 물론이고, CI에서 빌드가 포함된 파이프라인 전체 시간도 눈에 띄게 줄었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 서버의 콜드 스타트와 HMR 반응 속도도 함께 개선됐습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 메이저 버전을 건너뛰는 마이그레이션은 중간 버전의 breaking change까지 한 번에 감당해야 합니다. Vite 6에서 8로 가는 비용의 상당 부분은 사실 Vite 7의 변경 사항(Node 버전, 기본 브라우저 타깃 등)이었습니다. 마이그레이션 가이드는 건너뛰는 버전까지 전부 읽어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &quot;설정 변경 없이 업그레이드 가능&quot;이라는 안내는 설정 파일에 한정된 이야기입니다. 실제 비용은 CI 환경, 플러그인 생태계, 빌드 산출물 검증처럼 설정 바깥에서 발생했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 번들러 교체 수준의 변화는 수치 측정을 전후로 남겨야 합니다. 마이그레이션 전에 기준 빌드 시간을 재두지 않았다면 &quot;빨라진 것 같다&quot;는 감상으로 끝났을 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vite 8은 esbuild와 Rollup의 이중 구조를 Rolldown 하나로 통합하면서 빌드 성능을 크게 끌어올렸고, 실제 프로젝트에서도 8.5초에서 2.7초로 3배 이상의 단축 효과를 확인했습니다. 마이그레이션 과정에서 Node 버전, 플러그인 호환성, 청크 분리 API 변경 등의 문제를 겪었지만, 얻은 성능 개선을 생각하면 충분히 남는 투자였습니다. Vite 6이나 7을 사용 중이라면 마이그레이션 가이드를 순서대로 확인하면서 올려볼 것을 권합니다.&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;a href=&quot;https://news.hada.io/topic?id=27485&quot;&gt;Vite 8.0 출시 - GeekNews&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vite.dev/guide/migration&quot;&gt;Vite 공식 마이그레이션 가이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rolldown.rs&quot;&gt;Rolldown 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발..</category>
      <category>vite8</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/263</guid>
      <comments>https://white-blank.tistory.com/263#entry263comment</comments>
      <pubDate>Sat, 11 Jul 2026 23:27:54 +0900</pubDate>
    </item>
    <item>
      <title>WXT로 크롬 확장 프로그램 만들기</title>
      <link>https://white-blank.tistory.com/262</link>
      <description>&lt;p&gt;최근에 평소 쓰던 React 스택으로 크롬 확장 프로그램을 만들 일이 생겼습니다. 막상 시작하려니 &amp;quot;내가 쓰던 TanStack Query나 Tailwind를 그대로 써도 되나&amp;quot;, &amp;quot;API는 어떻게 호출해야 하나&amp;quot; 같은 질문이 계속 걸렸습니다. 직접 부딪혀 보니 결론은 의외로 단순했습니다. UI 로직은 평소 React와 거의 똑같고, 다른 건 브라우저와 플랫폼 API 레이어뿐이었습니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 과정에서 헷갈렸던 지점을 정리한 것입니다. WXT를 왜 쓰는지부터 시작해서, 확장을 만들 때 실제로 막히는 부분인 API 호출 방식, 스타일 격리, 스토리지를 차례로 다룹니다. 저처럼 React는 익숙하지만 확장 개발은 처음인 분께 도움이 되길 바랍니다.&lt;/p&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#wxt%EB%A5%BC-%EC%93%B0%EB%8A%94-%EC%9D%B4%EC%9C%A0&quot;&gt;WXT를 쓰는 이유&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B7%B8%EB%8C%80%EB%A1%9C-%EC%93%B0%EB%8A%94-%EA%B2%83%EA%B3%BC-%EB%B0%94%EA%BF%94%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B2%83&quot;&gt;그대로 쓰는 것과 바꿔야 하는 것&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#api-%ED%98%B8%EC%B6%9C%EC%9D%80-%EC%96%B4%EB%94%94%EC%84%9C-%EB%B6%80%EB%A5%B4%EB%8A%90%EB%83%90%EA%B0%80-%EC%A0%84%EB%B6%80&quot;&gt;API 호출은 어디서 부르느냐가 전부&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%96%B9%ED%9E%88%EB%8A%94-ui%EB%8A%94-shadow-dom%EC%9C%BC%EB%A1%9C-%EA%B0%90%EC%8B%B8%EA%B8%B0&quot;&gt;얹히는 UI는 Shadow DOM으로 감싸기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%BF%A0%ED%82%A4-%EB%8C%80%EC%8B%A0-chromestorage-%EC%93%B0%EA%B8%B0&quot;&gt;쿠키 대신 chrome.storage 쓰기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;WXT를 쓰는 이유&lt;/h2&gt;
&lt;p&gt;예전 방식인 바닐라 MV3는 React 개발자 입장에서 손이 많이 갔습니다. WXT는 그 불편함을 거의 다 걷어내 줍니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;기존 방식 (바닐라 MV3)&lt;/th&gt;
&lt;th&gt;WXT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;빌드&lt;/td&gt;
&lt;td&gt;webpack 직접 설정&lt;/td&gt;
&lt;td&gt;Vite 기반, 설정 거의 0 (HMR 빠름)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;manifest.json&lt;/td&gt;
&lt;td&gt;손으로 작성·관리&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wxt.config.ts&lt;/code&gt;에서 코드로 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React 연동&lt;/td&gt;
&lt;td&gt;로더·플러그인 직접 셋업&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@wxt-dev/module-react&lt;/code&gt; 한 줄&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;엔트리포인트&lt;/td&gt;
&lt;td&gt;manifest에 일일이 경로 등록&lt;/td&gt;
&lt;td&gt;&lt;code&gt;entrypoints/&lt;/code&gt; 폴더에 두면 자동 인식&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;브라우저 호환&lt;/td&gt;
&lt;td&gt;Chrome/Firefox 분기 직접&lt;/td&gt;
&lt;td&gt;&lt;code&gt;browser.*&lt;/code&gt; 통합 API + 타겟별 빌드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;content script UI&lt;/td&gt;
&lt;td&gt;DOM·스타일 격리 수동&lt;/td&gt;
&lt;td&gt;&lt;code&gt;createShadowRootUi&lt;/code&gt;로 한 번에&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HMR/리로드&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;체감 차이가 큽니다. 확장 인프라를 세팅하는 데 드는 시간을 거의 0으로 만들어 주기 때문에, 결국 그냥 React 앱을 짜는 느낌으로 개발할 수 있습니다.&lt;/p&gt;
&lt;h2&gt;그대로 쓰는 것과 바꿔야 하는 것&lt;/h2&gt;
&lt;p&gt;WXT로 확장을 만들 때도 React 코드를 짜는 법은 똑같습니다. 다른 건 플랫폼 API 레이어뿐입니다.&lt;/p&gt;
&lt;p&gt;먼저 바꿀 필요가 없는 것부터 보겠습니다. 평소 쓰던 React 도구는 그대로 가져갑니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TanStack Query, React Hook Form, Tailwind, shadcn/Radix 같은 React 라이브러리 전부&lt;/li&gt;
&lt;li&gt;컴포넌트·훅·JSX 작성 방식&lt;/li&gt;
&lt;li&gt;Vite 플러그인과 HMR&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;반대로 확장 환경에 맞춰 바꿔야 하는 것은 다음과 같습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;일반 React&lt;/th&gt;
&lt;th&gt;WXT 확장에서&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;code&gt;BrowserRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HashRouter&lt;/code&gt; / memory router&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chrome-extension://&lt;/code&gt; 환경엔 history API가 안 맞음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chrome.storage&lt;/code&gt; (WXT &lt;code&gt;storage&lt;/code&gt; API)&lt;/td&gt;
&lt;td&gt;실행 영역마다 메모리가 분리됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전역 상태&lt;/td&gt;
&lt;td&gt;Zustand/Jotai 메모리 유지&lt;/td&gt;
&lt;td&gt;영속이 필요하면 &lt;code&gt;chrome.storage&lt;/code&gt; 병행&lt;/td&gt;
&lt;td&gt;팝업을 닫으면 메모리가 날아감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크 fetch&lt;/td&gt;
&lt;td&gt;컴포넌트에서 직접 호출&lt;/td&gt;
&lt;td&gt;경우에 따라 background 경유&lt;/td&gt;
&lt;td&gt;content script는 CSP/CORS 영향을 받음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 스크립트&lt;/td&gt;
&lt;td&gt;CDN &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt; 가능&lt;/td&gt;
&lt;td&gt;전부 번들에 포함&lt;/td&gt;
&lt;td&gt;MV3 CSP가 외부/인라인·eval을 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;주입 UI 스타일&lt;/td&gt;
&lt;td&gt;그냥 CSS/Tailwind&lt;/td&gt;
&lt;td&gt;&lt;code&gt;createShadowRootUi&lt;/code&gt;로 격리&lt;/td&gt;
&lt;td&gt;페이지 스타일과 충돌 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;플랫폼 API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window&lt;/code&gt;/&lt;code&gt;document&lt;/code&gt; 직접&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chrome.*&lt;/code&gt; API로 대체&lt;/td&gt;
&lt;td&gt;권한·실행 영역 모델이 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;API 호출은 어디서 부르느냐가 전부&lt;/h2&gt;
&lt;p&gt;가장 많이 헷갈리는 부분입니다. 무조건 background에서 fetch해야 하는 것이 아닙니다. UI가 어느 영역에서 실행되느냐에 따라 갈리는데, 결론부터 말하면 Shadow DOM을 써서 남의 페이지에 얹는 경우에만 background에서 fetch하면 됩니다.&lt;/p&gt;
&lt;p&gt;먼저 확장 UI를 띄우는 방식은 크게 두 가지입니다.&lt;/p&gt;
&lt;p&gt;첫째는 별도로 뜨는 UI입니다. 확장 아이콘을 누르면 나오는 작은 창인 팝업, 브라우저 오른쪽에 붙는 사이드패널, 확장 전용 탭 같은 것입니다. 말 그대로 크롬이 내 확장에게 따로 내준 별도 공간입니다. 이 화면의 주소는 chrome-extension://로 시작하고, 그 안에서는 내가 주인이라 CSP도 CORS도 내 규칙을 따릅니다. 그래서 평소 웹 개발하듯 컴포넌트에서 백엔드로 직접 fetch하면 됩니다.&lt;/p&gt;
&lt;p&gt;둘째는 남의 페이지에 얹히는 UI입니다. 유튜브 영상 옆에 내 요약 버튼을 박거나, 지메일 위에 패널을 띄우는 것처럼 이미 떠 있는 다른 사이트 화면 안에 내 UI를 끼워 넣는 방식입니다. 이걸 content script라고 부릅니다. 이 경우 스타일 격리를 위해 Shadow DOM으로 UI를 감싸게 되는데, 그렇다고 실행되는 영역이 내 것이 되는 건 아닙니다. 코드는 여전히 그 페이지(예: youtube.com)의 영역 안에서 돌기 때문에, fetch를 그대로 날리면 그 페이지의 origin과 CSP에 막힙니다. 그래서 background를 거쳐야 합니다.&lt;/p&gt;
&lt;p&gt;정리하면 별도로 뜨는 UI는 크롬이 내준 내 공간이라 직접 호출하면 되고, 얹히는 UI는 남의 공간이라 background를 거칩니다. 화면상 비슷해 보여도 코드가 실행되는 보안 영역이 완전히 다르기 때문입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;별도로 뜨는 UI (팝업/사이드패널)&lt;/th&gt;
&lt;th&gt;얹히는 UI (content script)&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;그 웹페이지(예: youtube.com) 안쪽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코드의 origin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chrome-extension://너의확장id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://youtube.com&lt;/code&gt; (페이지 origin을 빌림)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM&lt;/td&gt;
&lt;td&gt;나만의 독립 DOM&lt;/td&gt;
&lt;td&gt;남의 페이지 DOM 위에 끼어듦&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSP 적용 주체&lt;/td&gt;
&lt;td&gt;내 manifest&lt;/td&gt;
&lt;td&gt;그 페이지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;직접 fetch&lt;/td&gt;
&lt;td&gt;평소대로 직접 호출&lt;/td&gt;
&lt;td&gt;background 경유 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;집에 빗대 보면 이해가 쉽습니다. 별도로 뜨는 UI는 내 집인 독채입니다. 내 맘대로 할 수 있고 CSP와 CORS도 내 규칙입니다. 반면 얹히는 UI는 남의 집에 세든 방입니다. 인테리어인 UI는 내가 하지만, 주소와 문단속인 CSP, 우편 규칙인 origin은 모두 집주인 것입니다. 겉모습은 둘 다 내 React UI라 비슷해 보여도, 누구의 보안 영역에서 코드가 실행되느냐가 다릅니다.&lt;/p&gt;
&lt;h3&gt;별도로 뜨는 형태는 평소대로 직접 호출&lt;/h3&gt;
&lt;p&gt;팝업이나 사이드패널, 전용 페이지라면 평소 쓰던 API 방식이 그대로 다 됩니다. TanStack Query로 GET/POST/mutation을 그대로 쓰고, fetch나 axios 클라이언트, 인터셉터, 토큰 갱신도 그대로입니다. 백엔드로 직접 호출해도 문제가 없어서 background를 거칠 필요가 없습니다.&lt;/p&gt;
&lt;p&gt;딱 하나만 챙기면 됩니다. &lt;code&gt;wxt.config.ts&lt;/code&gt;의 &lt;code&gt;manifest.host_permissions&lt;/code&gt;에 백엔드 도메인을 추가하면 됩니다.&lt;/p&gt;
&lt;p&gt;다음은 호출할 도메인을 manifest에 등록하는 설정입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// wxt.config.ts
export default defineConfig({
  manifest: {
    host_permissions: [&amp;#39;https://api.myapp.com/*&amp;#39;],
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;얹히는 형태는 background 경유&lt;/h3&gt;
&lt;p&gt;content script는 CORS와 CSP 제약, 토큰 탈취 위험이 있어서 직접 fetch하지 않습니다. 대신 background에게 대신 호출해 달라고 메시지를 보내고, 실제 fetch는 background가 합니다.&lt;/p&gt;
&lt;p&gt;흐름을 그림으로 보면 다음과 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;content script                 background (service worker)
&amp;quot;API 좀 호출해줘&amp;quot;  ──메시지──&amp;gt;   실제 fetch 실행
  (직접 fetch X)               (직접 fetch O)
                  &amp;lt;──응답────   결과 돌려줌&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다음은 background에서 메시지를 받아 실제 fetch를 수행하는 코드입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// background.ts
export default defineBackground(() =&amp;gt; {
  browser.runtime.onMessage.addListener(async (msg) =&amp;gt; {
    if (msg.type === &amp;#39;API_CALL&amp;#39;) {
      const res = await fetch(`https://api.myapp.com${msg.path}`, {
        method: msg.method,
        headers: { Authorization: `Bearer ${token}` }, // 토큰은 여기에만
        body: msg.body ? JSON.stringify(msg.body) : undefined,
      });
      return res.json();
    }
  });
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;content script 쪽에서는 fetch 대신 메시지만 보냅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// content script
const data = await browser.runtime.sendMessage({
  type: &amp;#39;API_CALL&amp;#39;,
  method: &amp;#39;GET&amp;#39;,
  path: &amp;#39;/messages&amp;#39;,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;background가 호출을 맡는 이유는 두 가지입니다. 먼저 background는 &lt;code&gt;host_permissions&lt;/code&gt;에 도메인을 넣으면 페이지 origin 제약 없이 호출할 수 있어 CORS와 CSP를 우회합니다. 그리고 토큰을 background에만 두면 남의 페이지 JS가 훔쳐볼 수 없어 안전합니다.&lt;/p&gt;
&lt;p&gt;매번 &lt;code&gt;sendMessage&lt;/code&gt;를 직접 쓰면 번거로우니, API 클라이언트 함수 안에 숨겨 두는 편이 좋습니다.&lt;/p&gt;
&lt;p&gt;다음은 메시지 전송을 감싼 API 클라이언트입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// api.ts (content script에서 import)
export const api = {
  get: (path: string) =&amp;gt;
    browser.runtime.sendMessage({ type: &amp;#39;API_CALL&amp;#39;, method: &amp;#39;GET&amp;#39;, path }),
  post: (path: string, body: unknown) =&amp;gt;
    browser.runtime.sendMessage({ type: &amp;#39;API_CALL&amp;#39;, method: &amp;#39;POST&amp;#39;, path, body }),
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 감싸 두면 TanStack Query에서는 &lt;code&gt;queryFn&lt;/code&gt;만 이 api로 바꾸면 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;useQuery({
  queryKey: [&amp;#39;messages&amp;#39;],
  queryFn: () =&amp;gt; api.get(&amp;#39;/messages&amp;#39;),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;컴포넌트나 React Query 입장에서는 평소 fetch 클라이언트를 쓰는 것과 똑같이 보입니다. 안에서만 메시지로 바뀔 뿐입니다.&lt;/p&gt;
&lt;h2&gt;얹히는 UI는 Shadow DOM으로 감싸기&lt;/h2&gt;
&lt;p&gt;남의 페이지에 내 UI를 얹을 때 또 하나 꼭 챙길 것이 있습니다. 바로 스타일 충돌입니다.&lt;/p&gt;
&lt;p&gt;그냥 페이지에 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;를 붙이면 양쪽으로 스타일이 새어 망가집니다. 그 사이트의 CSS가 내 UI로 침범해서 버튼 스타일이나 폰트, reset.css 같은 것이 내 컴포넌트를 깨뜨립니다. 반대로 내 Tailwind 전역 스타일이 그 사이트로 침범해서 남의 페이지 레이아웃을 망가뜨리기도 합니다.&lt;/p&gt;
&lt;p&gt;이걸 막는 것이 Shadow DOM입니다. 내 UI 주위에 보이지 않는 차단막을 쳐서, 안과 밖의 스타일이 서로 넘어오지 못하게 합니다. 그래서 그 사이트가 무엇을 하든 내 UI는 내가 만든 모습 그대로 뜹니다.&lt;/p&gt;
&lt;p&gt;WXT에서는 &lt;code&gt;createShadowRootUi&lt;/code&gt;가 이걸 자동으로 해 줍니다. 직접 &lt;code&gt;attachShadow&lt;/code&gt;를 할 필요가 없습니다.&lt;/p&gt;
&lt;p&gt;다음은 content script에서 Shadow DOM 안에 React를 마운트하는 코드입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// entrypoints/example.content.ts
export default defineContentScript({
  matches: [&amp;#39;*://*.youtube.com/*&amp;#39;],
  cssInjectionMode: &amp;#39;ui&amp;#39;, // 내 CSS(Tailwind 포함)를 차단막 안에만 주입

  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: &amp;#39;my-widget&amp;#39;,
      position: &amp;#39;inline&amp;#39;,
      anchor: &amp;#39;body&amp;#39;,
      onMount: (container) =&amp;gt; {
        // 이 container가 차단막(shadow root) 안 → 여기에 React 마운트
        const root = ReactDOM.createRoot(container);
        root.render(&amp;lt;App /&amp;gt;);
        return root;
      },
      onRemove: (root) =&amp;gt; root?.unmount(),
    });

    ui.mount();
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주의할 점이 두 가지 있습니다.&lt;/p&gt;
&lt;p&gt;첫째, &lt;code&gt;cssInjectionMode: &amp;#39;ui&amp;#39;&lt;/code&gt;를 꼭 켜야 합니다. 이게 내 Tailwind와 전역 CSS를 차단막 안쪽으로 넣어 줍니다. 켜지 않으면 스타일이 막 밖에 남아 UI에 적용되지 않습니다.&lt;/p&gt;
&lt;p&gt;둘째, 드롭다운이나 모달 같은 포털 컴포넌트를 주의해야 합니다. Radix와 shadcn의 Dropdown, Dialog, Tooltip은 기본적으로 &lt;code&gt;document.body&lt;/code&gt;에 띄워서 차단막 밖으로 빠져나가 스타일이 깨집니다. 포털의 &lt;code&gt;container&lt;/code&gt;를 차단막 안쪽 엘리먼트로 지정해 주어야 합니다.&lt;/p&gt;
&lt;p&gt;다음처럼 포털의 &lt;code&gt;container&lt;/code&gt;를 shadow root 안쪽으로 지정합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;DropdownMenu.Portal container={shadowContainer}&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정리하면 별도로 뜨는 UI인 팝업과 사이드패널은 Shadow DOM이 필요 없고, 남의 페이지에 얹히는 UI만 Shadow DOM으로 감싸면 됩니다. &lt;code&gt;createShadowRootUi&lt;/code&gt;와 &lt;code&gt;cssInjectionMode: &amp;#39;ui&amp;#39;&lt;/code&gt;로 거의 자동화되며, 포털 컴포넌트만 따로 챙기면 됩니다.&lt;/p&gt;
&lt;p&gt;이 모든 게 합쳐지는 대표 사례가 플로팅 챗봇입니다. 오른쪽 아래에 동그란 버튼을 띄우고 누르면 챗봇이 펼쳐지는, 채널톡이나 인터컴 같은 형태입니다. 이 경우 &lt;code&gt;createShadowRootUi&lt;/code&gt;로 차단막 안에 React를 마운트해서 스타일을 격리하고, CSS로 &lt;code&gt;position: fixed&lt;/code&gt;를 줘서 floating 처리하고, API 호출은 background를 거치며 토큰은 background에만 보관하게 됩니다.&lt;/p&gt;
&lt;h2&gt;쿠키 대신 chrome.storage 쓰기&lt;/h2&gt;
&lt;p&gt;웹에서는 쿠키와 &lt;code&gt;localStorage&lt;/code&gt;를 쓰지만, 확장에서는 보통 &lt;code&gt;chrome.storage&lt;/code&gt;를 씁니다. 쿠키를 아예 못 쓰는 것은 아닙니다. 팝업이나 사이드패널에서는 &lt;code&gt;fetch(..., { credentials: &amp;#39;include&amp;#39; })&lt;/code&gt;로 쿠키 세션도 그대로 쓸 수 있고 &lt;code&gt;chrome.cookies&lt;/code&gt; API도 있습니다. 다만 content script와 background에서는 origin과 SameSite가 꼬여서, 토큰을 직접 들고 &lt;code&gt;chrome.storage&lt;/code&gt;에 두는 방식을 더 많이 씁니다.&lt;/p&gt;
&lt;p&gt;보통 무엇을 저장하는지 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;table&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;access token, refresh token, 로그인 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 설정&lt;/td&gt;
&lt;td&gt;다크모드, 언어, 알림 on/off, 단축키&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;&amp;quot;튜토리얼 봤음&amp;quot; 같은 1회성 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;chrome.storage&lt;/code&gt;는 영역이 나뉘어 있어서 용도에 맞게 골라 써야 합니다.&lt;/p&gt;
&lt;table&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;code&gt;storage.local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;기기에만 저장, 용량 큼(~10MB+)&lt;/td&gt;
&lt;td&gt;토큰, 캐시, 대부분의 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage.sync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;구글 계정으로 기기 간 동기화, 용량 작음(~100KB)&lt;/td&gt;
&lt;td&gt;사용자 설정(테마/언어)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage.session&lt;/code&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;인증 토큰은 일반적으로 &lt;code&gt;storage.local&lt;/code&gt;에 둡니다. 편하고 영속되기 때문입니다. 보안을 더 챙기려면 refresh token은 휘발성인 &lt;code&gt;storage.session&lt;/code&gt;에 두고 access token은 메모리에만 두면 디스크에 남지 않습니다. 확장 storage는 그 확장 코드만 접근할 수 있어서 웹 &lt;code&gt;localStorage&lt;/code&gt;보다 안전합니다. 다른 사이트의 JS가 읽을 수 없습니다.&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;WXT로 크롬 확장을 만들 때 핵심은 단순합니다. UI 로직과 React 라이브러리는 그대로 가져가고, 라우팅과 저장소, fetch 같은 플랫폼 레이어만 확장 방식으로 교체하면 됩니다. 그 교체 작업조차 WXT가 대부분 자동화해 주기 때문에, 평소 React 개발하던 흐름을 거의 그대로 유지할 수 있습니다.&lt;/p&gt;
&lt;p&gt;특히 API 호출은 UI가 별도 영역에 뜨는지, 남의 페이지에 얹히는지만 구분하면 됩니다. 별도로 뜨면 직접 호출하고, 얹히면 background를 거치면서 Shadow DOM으로 스타일을 격리하면 됩니다. 이 한 가지 기준만 잡아 두면 나머지는 익숙한 React 개발과 다르지 않습니다.&lt;/p&gt;
&lt;p&gt;참고 자료&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://wxt.dev&quot;&gt;WXT 공식 문서 (wxt.dev)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://wxt.dev/guide/essentials/content-scripts.html&quot;&gt;createShadowRootUi 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발..</category>
      <category>WXT</category>
      <category>크롬 확장프로그램</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/262</guid>
      <comments>https://white-blank.tistory.com/262#entry262comment</comments>
      <pubDate>Sun, 21 Jun 2026 18:11:44 +0900</pubDate>
    </item>
    <item>
      <title>OpenAI API에서 Claude Agent SDK로 변경하기</title>
      <link>https://white-blank.tistory.com/261</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드 모델 API 를 기반으로 프로젝트를 진행 중, 토큰에 대한 비용이 계속 빠져나가면서 고민에 빠졌습니다.&lt;br /&gt;그러다 생각해보니 Claude Code가 깔려 있는데 이걸 그대로 쓰면 어떨까 하는 생각이 들었습니다.&lt;br /&gt;그러면 토큰 비용도 없고, 원격 서버를 왕복하지 않으니 속도도 더 낫고 무엇보다 퀄리티가 더 좋지 않을까 하는 생각이 있었습니다.&lt;br /&gt;그래서 챗봇의 응답 생성부를 OpenAI API에서 Anthropic의 Claude Agent SDK로 통째로 갈아끼웠습니다.&lt;br /&gt;이 글은 설치부터 챗봇용 설정, 스트리밍, 도구 연결까지 실제로 동작시킨 코드로 정리한 기록입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#openai-api%EB%A5%BC-%EA%B1%B7%EC%96%B4%EB%82%B8-%EC%9D%B4%EC%9C%A0&quot;&gt;OpenAI API를 걷어낸 이유&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%ED%98%B8%EC%B6%9C-%EB%B0%A9%EC%8B%9D%EC%9D%B4-%EA%B7%BC%EB%B3%B8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%8B%A4%EB%A6%85%EB%8B%88%EB%8B%A4&quot;&gt;호출 방식이 근본적으로 다릅니다&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%84%A4%EC%B9%98&quot;&gt;설치&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B8%B0%EB%B3%B8-%EC%82%AC%EC%9A%A9%EB%B2%95&quot;&gt;기본 사용법&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%B1%97%EB%B4%87%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0&quot;&gt;챗봇용으로 설정하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%ED%86%A0%ED%81%B0-%EB%B0%9B%EA%B8%B0&quot;&gt;스트리밍 토큰 받기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%ED%95%84%EC%9A%94%ED%95%9C-%EB%8F%84%EA%B5%AC%EB%A7%8C-%EA%B3%A8%EB%9D%BC-%EB%B6%99%EC%9D%B4%EA%B8%B0&quot;&gt;필요한 도구만 골라 붙이기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%A0%84%EC%B2%B4-%EC%BD%94%EB%93%9C&quot;&gt;전체 코드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;OpenAI API를 걷어낸 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 세 가지였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 비용입니다. OpenAI API는 호출할 때마다 토큰 단위로 과금됩니다. 실서비스라면 당연한 비용이지만, 학습용이나 사이드 프로젝트에서는 &quot;켜놓고 이것저것 찔러보는&quot; 행위 자체가 부담이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 이미 가지고 있는 자원입니다. 개발하는 동안 매일 Claude Code를 켜서 쓰고 있었습니다. 즉 로그인된 구독이 로컬에 이미 있다는 뜻입니다. 이걸 챗봇 백엔드로 그대로 쓰면 별도의 API 키도, 추가 과금도 필요 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 클로드 코드를 그대로 쓸 수 있다면 높은 퀄리티가 보장되어 있다라는 것 입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;호출 방식이 근본적으로 다릅니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 이해해야 할 건, OpenAI API와 Agent SDK가 &quot;호출하는 방식 자체&quot;가 다르다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI는 &lt;code&gt;new OpenAI({ apiKey })&lt;/code&gt;로 HTTP 클라이언트를 만들어 원격 서버로 요청을 보냅니다. API 키가 곧 인증이자 과금 수단입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Agent SDK의 &lt;code&gt;query()&lt;/code&gt;는 HTTP 호출이 아닙니다. 내 컴퓨터에 설치된 &lt;code&gt;claude&lt;/code&gt; CLI를 자식 프로세스로 띄워서 일을 시킵니다. 인증은 그 CLI가 이미 로그인된 구독으로 알아서 처리하므로, 코드에 API 키를 넣을 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;OpenAI API&lt;/th&gt;
&lt;th&gt;Claude Agent SDK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;호출 방식&lt;/td&gt;
&lt;td&gt;원격 HTTP API&lt;/td&gt;
&lt;td&gt;로컬 &lt;code&gt;claude&lt;/code&gt; CLI 프로세스 스폰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인증&lt;/td&gt;
&lt;td&gt;API 키&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;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 위치&lt;/td&gt;
&lt;td&gt;어디서든 (서버리스 가능)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude&lt;/code&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;마지막 줄이 핵심 제약입니다. 로컬 CLI를 띄우는 구조라서 서버리스로 배포하거나 불특정 다수에게 서비스하기는 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본인이나 팀 내부에서 쓰는 로컬 용도에 맞는 선택입니다.&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;css&quot;&gt;&lt;code&gt;npm install @anthropic-ai/claude-agent-sdk
# pnpm 사용 시
pnpm add @anthropic-ai/claude-agent-sdk&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, SDK는 내부적으로 &lt;code&gt;claude&lt;/code&gt; CLI를 실행하므로 두 가지 전제가 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;# 1) Claude Code CLI 설치
npm install -g @anthropic-ai/claude-code

# 2) 한 번 로그인 (구독 인증)
claude login&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점이 하나 있습니다. &lt;b&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; 환경 변수를 설정하지 마세요.&lt;/b&gt; 이 값이 있으면 SDK가 구독 대신 API 키로 동작해서 과금이 시작됩니다. 비워둬야 로그인된 구독으로 무료로 돕니다.&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;query()&lt;/code&gt;는 메시지를 비동기로 스트리밍하는 이터러블을 돌려줍니다. 가장 단순한 형태는 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { query } from '@anthropic-ai/claude-agent-sdk'

for await (const message of query({
  prompt: '안녕, 한 줄로 인사해줘',
  options: { model: 'sonnet' }
})) {
  console.log(message)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;model&lt;/code&gt;에는 &lt;code&gt;'sonnet'&lt;/code&gt;, &lt;code&gt;'opus'&lt;/code&gt; 같은 별칭을 쓰면 설치된 CLI가 현재 모델로 알아서 해석해줍니다. 풀 모델명을 박는 것보다 버전 호환에 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;챗봇용으로 설정하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본값 그대로 쓰면 Claude Code는 파일을 읽고 명령을 실행하는 &quot;코딩 에이전트&quot;로 동작합니다. 일반 대화 챗봇으로 쓰려면 도구를 끄고 환경을 격리해야 합니다. 실제로 사용한 옵션은 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;import { query } from '@anthropic-ai/claude-agent-sdk'

const CHAT_SYSTEM_PROMPT = [
  '당신은 친절하고 명확한 한국어 대화형 어시스턴트입니다.',
  '아래는 사용자와의 대화 기록입니다. 마지막 User 메시지에 자연스럽게 이어서 답하세요.',
  '코드 실행이나 로컬 파일 접근은 하지 않습니다.'
].join('\n')

const abortController = new AbortController()

const response = query({
  prompt,
  options: {
    model,                          // 'sonnet' 등
    systemPrompt: CHAT_SYSTEM_PROMPT, // 문자열을 주면 코딩용 기본 프롬프트를 대체
    allowedTools: [],               // 순수 챗봇: 모든 도구 차단
    settingSources: [],             // 프로젝트 CLAUDE.md / 설정 미로드 (격리)
    includePartialMessages: true,   // 토큰 단위 스트리밍 켜기
    maxTurns: 16,                   // 에이전트 턴 상한
    permissionMode: 'bypassPermissions',
    abortController                 // 중단(취소) 연동
  }
})&lt;/code&gt;&lt;/pre&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;code&gt;systemPrompt&lt;/code&gt;에 문자열을 넘기면 Claude Code의 기본(코딩용) 시스템 프롬프트를 대체합니다. 챗봇 페르소나를 여기서 정합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allowedTools: []&lt;/code&gt;는 자동 승인되는 도구 목록을 비워 모든 도구를 막습니다. 순수 대화만 하게 만드는 핵심입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;settingSources: []&lt;/code&gt;를 주면 사용자/프로젝트/로컬 설정과 &lt;code&gt;CLAUDE.md&lt;/code&gt;를 읽지 않습니다. 챗봇이 주변 환경에 오염되지 않도록 격리합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;includePartialMessages: true&lt;/code&gt;를 켜야 토큰 단위 스트리밍 이벤트가 들어옵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;abortController&lt;/code&gt;로 클라이언트가 응답을 중간에 멈출 수 있게 연결합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;maxTurns&lt;/code&gt;에서 한 번 데었습니다. 처음에 &quot;한 번 답하고 끝&quot;이라는 생각으로 &lt;code&gt;maxTurns: 1&lt;/code&gt;을 줬더니, 조금만 긴 답변이면 &lt;code&gt;error_max_turns&lt;/code&gt;로 실패했습니다. 측정해보니 도구가 하나도 없어도 긴 응답은 내부적으로 3턴 정도를 씁니다. 짧은 인사는 1턴이라 됐지만 긴 답변은 전부 터졌던 것입니다. 도구가 없으면 무한 루프 위험도 없으니 여유 있게 16으로 올렸습니다.&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;includePartialMessages: true&lt;/code&gt;를 켜면 부분 응답이 &lt;code&gt;stream_event&lt;/code&gt; 타입 메시지로 들어옵니다. 그 안의 &lt;code&gt;content_block_delta&lt;/code&gt; 이벤트에서 텍스트 조각을 꺼내면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;let full = ''
for await (const message of response) {
  if (message.type === 'stream_event') {
    const event = message.event
    if (
      event.type === 'content_block_delta' &amp;amp;&amp;amp;
      event.delta.type === 'text_delta'
    ) {
      full += event.delta.text
      onToken(event.delta.text) // 클라이언트로 토큰 전송 (SSE 등)
    }
    continue
  }

  // 인증 실패&amp;middot;레이트리밋&amp;middot;잘못된 모델 등은 예외가 아니라 result 로 통보된다.
  // 잡지 않으면 빈 응답이 '성공'으로 처리되니 반드시 확인한다.
  if (message.type === 'result' &amp;amp;&amp;amp; message.is_error) {
    throw new Error(`Claude 응답 실패: ${message.subtype}`)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 &lt;code&gt;result&lt;/code&gt; 처리가 중요합니다. OpenAI는 오류가 나면 예외를 던지지만, Agent SDK는 인증 실패나 레이트리밋 같은 상황을 예외가 아니라 &lt;code&gt;is_error: true&lt;/code&gt;인 &lt;code&gt;result&lt;/code&gt; 메시지로 흘려보냅니다. 이걸 잡지 않으면 빈 응답이 정상 완료로 둔갑하니, &lt;code&gt;result&lt;/code&gt;에서 반드시 오류를 확인해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 OpenAI 시절의 같은 부분은 이렇게 생겼었습니다. 비교해보면 &quot;함수 속만 바뀌었다&quot;는 게 보입니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 이전: OpenAI 스트리밍
const stream = await openai.chat.completions.create({
  model,
  messages,
  stream: true
})
for await (const chunk of stream) {
  const token = chunk.choices[0]?.delta?.content ?? ''
  if (token) onToken(token)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;필요한 도구만 골라 붙이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK가 제공하는 기본 도구는 이렇게 분류됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table 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;&lt;code&gt;Read&lt;/code&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;code&gt;Write&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&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;code&gt;Glob&lt;/code&gt;, &lt;code&gt;Grep&lt;/code&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;code&gt;Bash&lt;/code&gt;, &lt;code&gt;Monitor&lt;/code&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;code&gt;WebSearch&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&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;code&gt;Agent&lt;/code&gt;, &lt;code&gt;TodoWrite&lt;/code&gt;, &lt;code&gt;TaskCreate&lt;/code&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;code&gt;MCP&lt;/code&gt;, &lt;code&gt;ListMcpResources&lt;/code&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;code&gt;WebSearch&lt;/code&gt;와 &lt;code&gt;WebFetch&lt;/code&gt;&lt;/b&gt;입니다. 둘 다 읽기 전용이라 안전하고, 평범한 챗봇의 가장 큰 약점(최신 정보, 링크 내용 모름)을 메워줍니다. 반면 &lt;code&gt;Bash&lt;/code&gt;나 &lt;code&gt;Write&lt;/code&gt;는 서버에서 실제로 명령이 돌고 파일이 바뀌므로, 코딩 에이전트를 만들 게 아니라면 켜지 않는 편이 낫습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 검색만 켜는 설정은 &lt;code&gt;allowedTools&lt;/code&gt;에 도구 이름을 넣어주면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const response = query({
  prompt,
  options: {
    model,
    systemPrompt: CHAT_SYSTEM_PROMPT,
    // 웹 검색/가져오기만 허용 (읽기 전용). 파일&amp;middot;명령 도구는 계속 차단.
    allowedTools: ['WebSearch', 'WebFetch'],
    settingSources: [],
    includePartialMessages: true,
    maxTurns: 16,
    permissionMode: 'bypassPermissions',
    abortController
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 주의할 점은 &lt;code&gt;permissionMode&lt;/code&gt;입니다. &lt;code&gt;'bypassPermissions'&lt;/code&gt;는 허용된 도구를 사람 승인 없이 자동 실행합니다. 웹 검색처럼 읽기 전용이면 괜찮지만, &lt;code&gt;Bash&lt;/code&gt;나 &lt;code&gt;Write&lt;/code&gt; 같은 위험한 도구에는 이 모드가 위험합니다. 위험한 도구를 켜야 한다면 &lt;code&gt;canUseTool&lt;/code&gt; 콜백으로 실행 전에 게이트를 거는 편이 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &quot;오늘 서울 날씨 검색해서 알려줘&quot;라고 물으니, 검색을 돌리고 출처 링크까지 붙여서 답했습니다.&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;typescript&quot;&gt;&lt;code&gt;import { query } from '@anthropic-ai/claude-agent-sdk'

const CHAT_SYSTEM_PROMPT = [
  '당신은 친절하고 명확한 한국어 대화형 어시스턴트입니다.',
  '아래는 사용자와의 대화 기록입니다. 마지막 User 메시지에 자연스럽게 이어서 답하세요.',
  '최신 정보나 특정 URL 내용이 필요하면 웹 검색(WebSearch)&amp;middot;가져오기(WebFetch)를 사용하세요.',
  '코드 실행이나 로컬 파일 접근은 하지 않습니다.'
].join('\n')

type Callbacks = {
  onToken: (token: string) =&amp;gt; void
  onComplete: (content: string) =&amp;gt; Promise&amp;lt;void&amp;gt;
  onFail: () =&amp;gt; Promise&amp;lt;void&amp;gt;
}

export const generateAssistantMessage = async (
  prompt: string,
  model: string,
  { onToken, onComplete, onFail }: Callbacks,
  signal?: AbortSignal
): Promise&amp;lt;void&amp;gt; =&amp;gt; {
  const abortController = new AbortController()
  if (signal) {
    if (signal.aborted) abortController.abort()
    else signal.addEventListener('abort', () =&amp;gt; abortController.abort(), { once: true })
  }

  try {
    const response = query({
      prompt,
      options: {
        model,
        systemPrompt: CHAT_SYSTEM_PROMPT,
        allowedTools: ['WebSearch', 'WebFetch'],
        settingSources: [],
        includePartialMessages: true,
        maxTurns: 16,
        permissionMode: 'bypassPermissions',
        abortController
      }
    })

    let full = ''
    for await (const message of response) {
      if (signal?.aborted) break

      if (message.type === 'stream_event') {
        const event = message.event
        if (event.type === 'content_block_delta' &amp;amp;&amp;amp; event.delta.type === 'text_delta') {
          full += event.delta.text
          onToken(event.delta.text)
        }
        continue
      }

      if (message.type === 'result' &amp;amp;&amp;amp; message.is_error) {
        throw new Error(`Claude 응답 실패: ${message.subtype}`)
      }
    }

    if (!signal?.aborted &amp;amp;&amp;amp; full.trim() === '') {
      throw new Error('Claude 응답이 비어 있습니다')
    }
    if (!signal?.aborted) await onComplete(full)
  } catch (error) {
    if (!signal?.aborted) await onFail()
    console.error('[generateAssistantMessage] failed', error)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, OpenAI API의 응답 생성부를 Claude Agent SDK의 &lt;code&gt;query()&lt;/code&gt; 한 곳으로 교체했고, 별도 API 키 없이 로컬 구독으로 동작하게 만들었습니다. 챗봇용으로는 도구를 끄고 환경을 격리하는 게 핵심이며, 최신 정보가 필요하면 읽기 전용인 웹 검색 도구만 골라 붙이는 게 가성비가 좋았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 처음의 가설을 솔직하게 점검하면, &lt;b&gt;비용은 확실히 이득&lt;/b&gt;이었고 퀄리티도 보장되었지만, &lt;b&gt;속도는 기대만큼은 아니었습니다.&lt;/b&gt; 로컬 CLI를 프로세스로 띄우는 오버헤드가 있고, 첫 응답까지의 지연과 약 0.45초 간격의 뭉텅이 스트리밍이 있어서, 원격 API의 촘촘한 토큰 스트림보다 오히려 끊겨 느껴질 수 있습니다.&lt;br /&gt;본인이나 팀 내부에서 쓰는 로컬 용도라면 비용 면에서 훌륭한 선택이지만, 불특정 다수를 위한 공개 서비스라면 구독 단일 사용자 전제와 동시성&amp;middot;레이트리밋 제약 때문에 한계가 있을 수는 있어보입니다. -&amp;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;a href=&quot;https://code.claude.com/docs/ko/agent-sdk/typescript&quot;&gt;Claude Agent SDK TypeScript 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발..</category>
      <category>Claude Agent SDK</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/261</guid>
      <comments>https://white-blank.tistory.com/261#entry261comment</comments>
      <pubDate>Thu, 11 Jun 2026 09:30:59 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 16 App Router에 설치형 PWA 붙이기</title>
      <link>https://white-blank.tistory.com/260</link>
      <description>&lt;p&gt;AI 채팅 앱은 이제 모바일에서도 자연스럽게 쓸 수 있어야 합니다.&lt;br&gt;매번 브라우저를 열어 주소를 치고 들어가는 대신 홈 화면 아이콘으로 바로 띄울 수 있도록, 편의성을 더하기 위해 PWA를 붙였습니다.&lt;br&gt;이 글은 App Router에 설치형 PWA를 붙이고, 데스크톱 브라우저까지 설치 안내 기록입니다.&lt;/p&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%84%A4%EC%B9%98%ED%98%95-pwa%EC%9D%98-%EC%B5%9C%EC%86%8C-%EC%A1%B0%EA%B1%B4&quot;&gt;설치형 PWA의 최소 조건&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#web-app-manifest-%EB%A7%8C%EB%93%A4%EA%B8%B0&quot;&gt;Web App Manifest 만들기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9B%8C%EC%BB%A4%EB%8A%94-%EC%99%9C-%EB%94%B0%EB%A1%9C-%EB%93%B1%EB%A1%9D%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80&quot;&gt;서비스 워커는 왜 따로 등록해야 하는가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#ios-%EC%84%A4%EC%B9%98-%EC%95%88%EB%82%B4%EC%99%80-%ED%95%98%EC%9D%B4%EB%93%9C%EB%A0%88%EC%9D%B4%EC%85%98-%ED%95%A8%EC%A0%95&quot;&gt;iOS 설치 안내와 하이드레이션 함정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%8D%B0%EC%8A%A4%ED%81%AC%ED%86%B1%EA%B9%8C%EC%A7%80-%EC%84%A4%EC%B9%98-%EC%A7%80%EC%9B%90-%EB%84%93%ED%9E%88%EA%B8%B0&quot;&gt;데스크톱까지 설치 지원 넓히기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%B3%B4%EC%95%88-%ED%97%A4%EB%8D%94-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0&quot;&gt;보안 헤더 추가하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A1%9C%EC%BB%AC%EC%97%90%EC%84%9C-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0&quot;&gt;로컬에서 확인하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;설치형 PWA의 최소 조건&lt;/h2&gt;
&lt;p&gt;먼저 &amp;quot;설치 가능(installable)&amp;quot;이라는 상태가 무엇인지 정리하고 시작했습니다. 브라우저가 홈 화면 설치 프롬프트를 띄우려면 세 가지가 필요합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;유효한 Web App Manifest (이름, 아이콘, &lt;code&gt;display: standalone&lt;/code&gt; 등)&lt;/li&gt;
&lt;li&gt;HTTPS로 서빙 (localhost는 예외로 보안 컨텍스트 취급)&lt;/li&gt;
&lt;li&gt;등록된 서비스 워커&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 중요한 건, 푸시 알림이나 오프라인 캐싱은 설치의 필수 조건이 아니라는 점입니다. 그래서 저는 푸시와 오프라인을 빼고, 위 세 가지만 충족하는 구성으로 잡았습니다. 결과적으로 손대야 할 곳은 manifest 파일, 서비스 워커 파일, 서비스 워커 등록 코드, 아이콘 정도였습니다.&lt;/p&gt;
&lt;h2&gt;Web App Manifest 만들기&lt;/h2&gt;
&lt;p&gt;App Router는 &lt;code&gt;app/manifest.ts&lt;/code&gt; 파일 컨벤션을 제공합니다. 이 파일을 두면 Next가 알아서 &lt;code&gt;/manifest.webmanifest&lt;/code&gt;로 서빙하고, HTML &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;에 &lt;code&gt;&amp;lt;link rel=&amp;quot;manifest&amp;quot;&amp;gt;&lt;/code&gt;까지 자동으로 넣어줍니다. 직접 link 태그를 달 필요가 없습니다.&lt;/p&gt;
&lt;p&gt;다음은 다크 테마 앱에 맞춘 manifest입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import type { MetadataRoute } from &amp;#39;next&amp;#39;

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: &amp;#39;AI Chat&amp;#39;,
    short_name: &amp;#39;AI Chat&amp;#39;,
    description: &amp;#39;GPT-5 기반 AI 채팅&amp;#39;,
    start_url: &amp;#39;/&amp;#39;,
    display: &amp;#39;standalone&amp;#39;,
    background_color: &amp;#39;#1A1A1A&amp;#39;,
    theme_color: &amp;#39;#1A1A1A&amp;#39;,
    lang: &amp;#39;ko&amp;#39;,
    icons: [
      { src: &amp;#39;/icon-192x192.png&amp;#39;, sizes: &amp;#39;192x192&amp;#39;, type: &amp;#39;image/png&amp;#39; },
      { src: &amp;#39;/icon-512x512.png&amp;#39;, sizes: &amp;#39;512x512&amp;#39;, type: &amp;#39;image/png&amp;#39; }
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;홈 화면에서 앱을 열면 주소창과 탭이 사라지고 네이티브 앱처럼 독립 창으로 뜨도록 standalone 로 설정하였습니다.&lt;br&gt;아이콘은 192/512 두 사이즈가 필수 임으로, 이미지를 맞게 준비하시면 됩니다.&lt;/p&gt;
&lt;h2&gt;서비스 워커는 왜 따로 등록해야 하는가&lt;/h2&gt;
&lt;p&gt;manifest는 Next가 자동으로 연결해주지만, 서비스 워커는 그렇지 않습니다. &lt;code&gt;public/sw.js&lt;/code&gt;에 파일을 둬도 그건 그냥 정적 파일일 뿐이고, 브라우저는 누군가 &lt;code&gt;navigator.serviceWorker.register()&lt;/code&gt;를 호출해주기 전까지 그 존재를 모릅니다.&lt;br&gt;이 호출이 없으면 서비스 워커 미등록 상태가 되고, 설치 자격을 충족하지 못합니다.&lt;/p&gt;
&lt;p&gt;설치형만 목표였기 때문에 서비스 워커 자체는 최소한으로 두었습니다. 오프라인 캐싱 로직 없이, 설치 자격을 위한 뼈대만 남깁니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// 설치형 PWA용 최소 서비스 워커.
// 푸시/오프라인 캐싱 없음 — 설치 가능 자격만 충족한다.
self.addEventListener(&amp;#39;install&amp;#39;, () =&amp;gt; {
  self.skipWaiting()
})

self.addEventListener(&amp;#39;activate&amp;#39;, (event) =&amp;gt; {
  event.waitUntil(self.clients.claim())
})

self.addEventListener(&amp;#39;fetch&amp;#39;, () =&amp;gt; {
  // 의도적으로 비움: 요청은 평소대로 네트워크로 통과한다.
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 이 파일을 등록할 차례입니다. &lt;code&gt;navigator&lt;/code&gt;는 브라우저에만 존재하므로, 서버 컴포넌트에서는 호출할 수 없습니다. 그래서 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt; 컴포넌트를 만들어 마운트 시점에 등록하도록 했습니다. UI는 없고 등록만 담당합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#39;use client&amp;#39;

import { useEffect } from &amp;#39;react&amp;#39;

export const ServiceWorkerRegister = () =&amp;gt; {
  useEffect(() =&amp;gt; {
    if (!(&amp;#39;serviceWorker&amp;#39; in navigator)) return

    navigator.serviceWorker
      .register(&amp;#39;/sw.js&amp;#39;, { scope: &amp;#39;/&amp;#39;, updateViaCache: &amp;#39;none&amp;#39; })
      .catch((error) =&amp;gt; {
        console.error(&amp;#39;Service worker registration failed:&amp;#39;, error)
      })
  }, [])

  return null
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 컴포넌트를 루트 레이아웃 &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;에 넣어두면 모든 페이지에서 서비스 워커가 등록됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const RootLayout = ({ children }: { children: ReactNode }) =&amp;gt; (
  &amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
    &amp;lt;body&amp;gt;
      &amp;lt;ServiceWorkerRegister /&amp;gt;
      &amp;lt;Providers&amp;gt;
        &amp;lt;AppShell&amp;gt;{children}&amp;lt;/AppShell&amp;gt;
      &amp;lt;/Providers&amp;gt;
    &amp;lt;/body&amp;gt;
  &amp;lt;/html&amp;gt;
)&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;iOS 설치 안내와 하이드레이션 함정&lt;/h2&gt;
&lt;p&gt;iOS Safari는 안드로이드 크롬과 달리 자동 설치 프롬프트를 띄우지 않습니다. 사용자가 직접 공유 버튼을 눌러 &amp;quot;홈 화면에 추가&amp;quot;를 해야 합니다. 그래서 iOS에서만, 그리고 아직 설치 전(standalone이 아닐 때)에만 안내 문구를 보여주는 컴포넌트가 필요했습니다.&lt;/p&gt;
&lt;p&gt;처음엔 렌더 시점에 &lt;code&gt;navigator.userAgent&lt;/code&gt;와 &lt;code&gt;matchMedia&lt;/code&gt;를 읽어 iOS 여부를 판단했습니다.&lt;br&gt;그런데 클라이언트 컴포넌트라도 SSR 단계에서 한 번 서버 렌더링이 되다보니, 서버에는 &lt;code&gt;navigator&lt;/code&gt;가 없어 &lt;code&gt;null&lt;/code&gt;을 반환하지만 iOS 클라이언트의 첫 렌더에서는 안내 문구를 그리니, 둘이 어긋나면서 하이드레이션 불일치가 발생했습니다. 결국 클라이언트 전용 값이라도 서버 사이드 렌더링까지 고려해야 한다는 게 핵심이었습니다.&lt;/p&gt;
&lt;p&gt;해결책은 &lt;code&gt;useSyncExternalStore&lt;/code&gt;였습니다. 이 훅은 &lt;strong&gt;React가 직접 관리하지 않는 &amp;quot;외부 저장소&amp;quot;의 값을 읽어야 할 때&lt;/strong&gt; 쓴다고 합니다. &lt;code&gt;useState&lt;/code&gt;로 만든 값은 React가 변경 시점을 알지만, 그 바깥에 사는 값은 React가 언제 바뀌는지 모르기 때문에 따로 연결해줘야 합니다. 여기서 외부 저장소란 이런 것들입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;브라우저 API: &lt;code&gt;matchMedia&lt;/code&gt;, &lt;code&gt;navigator&lt;/code&gt;, &lt;code&gt;window.location&lt;/code&gt;, 온라인 여부(&lt;code&gt;navigator.onLine&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;상태 관리 라이브러리: Redux, Zustand 등의 store&lt;/li&gt;
&lt;li&gt;브라우저 저장소: &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;실시간 연결: WebSocket, EventSource 같은 외부 이벤트 소스&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;우리 경우의 외부 저장소는 &lt;strong&gt;브라우저 환경 그 자체&lt;/strong&gt;(&lt;code&gt;navigator&lt;/code&gt;, &lt;code&gt;matchMedia&lt;/code&gt;)입니다. 이 훅은 세 인자를 받는데, &lt;code&gt;subscribe&lt;/code&gt;(값이 바뀌면 React에 알림), &lt;code&gt;getSnapshot&lt;/code&gt;(클라이언트에서 현재 값 읽기), &lt;code&gt;getServerSnapshot&lt;/code&gt;(SSR일 때 쓸 값)입니다. 제가 이 훅을 고른 핵심은 세 번째 인자에 있습니다. &lt;code&gt;navigator&lt;/code&gt;와 &lt;code&gt;matchMedia&lt;/code&gt;는 서버에 아예 없는 외부 저장소라, 서버용 값을 &lt;code&gt;() =&amp;gt; false&lt;/code&gt;로 고정하면 SSR과 클라이언트 첫 렌더가 모두 &lt;code&gt;null&lt;/code&gt;로 일치하고, 하이드레이션 이후에야 클라이언트의 실제 값으로 다시 평가됩니다. effect 안에서 &lt;code&gt;setState&lt;/code&gt;를 하지 않으므로 앞서 걸렸던 린트 규칙도 자연스럽게 피해 갑니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#39;use client&amp;#39;

import { useTheme } from &amp;#39;@emotion/react&amp;#39;
import { useSyncExternalStore } from &amp;#39;react&amp;#39;

import { styles } from &amp;#39;./InstallPrompt.styles&amp;#39;

// 값이 마운트 후 바뀌지 않으므로 구독은 no-op
const subscribe = () =&amp;gt; () =&amp;gt; {}

const getIsIOS = () =&amp;gt;
  /iPad|iPhone|iPod/.test(navigator.userAgent) &amp;amp;&amp;amp; !(&amp;#39;MSStream&amp;#39; in window)

const getIsStandalone = () =&amp;gt;
  window.matchMedia(&amp;#39;(display-mode: standalone)&amp;#39;).matches

export const InstallPrompt = () =&amp;gt; {
  const theme = useTheme()
  const s = styles(theme)
  // 서버 스냅샷은 false → SSR/하이드레이션 시 null로 일치, 이후 클라이언트에서 재평가
  const isIOS = useSyncExternalStore(subscribe, getIsIOS, () =&amp;gt; false)
  const isStandalone = useSyncExternalStore(
    subscribe,
    getIsStandalone,
    () =&amp;gt; false
  )

  if (isStandalone || !isIOS) {
    return null
  }

  return (
    &amp;lt;div css={s.container} role=&amp;quot;note&amp;quot;&amp;gt;
      &amp;lt;p css={s.text}&amp;gt;
        홈 화면에 추가하려면 공유 버튼을 누른 뒤 &amp;quot;홈 화면에 추가&amp;quot;를 선택하세요.
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;getSnapshot&lt;/code&gt;이 매번 새 객체가 아니라 불리언 원시값을 반환한다는 점이 중요합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const subscribe = (onChange: () =&amp;gt; void) =&amp;gt; {
  const mql = window.matchMedia(&amp;#39;(prefers-color-scheme: dark)&amp;#39;)
  mql.addEventListener(&amp;#39;change&amp;#39;, onChange) // 값이 바뀌면 React에 알림
  return () =&amp;gt; mql.removeEventListener(&amp;#39;change&amp;#39;, onChange)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정리하면 이 훅은 &lt;strong&gt;React 바깥의 값을 읽어야 하고, 특히 그 값이 서버에 없어 하이드레이션을 맞춰줘야 할 때&lt;/strong&gt; 꺼내 쓰기 좋은 도구입니다.&lt;/p&gt;
&lt;h2&gt;설치 지원 넣기&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Chromium 계열(크롬, 엣지, 안드로이드 크롬): &lt;code&gt;beforeinstallprompt&lt;/code&gt; 이벤트를 가로채 직접 &amp;quot;설치&amp;quot; 버튼을 띄울 수 있습니다.&lt;/li&gt;
&lt;li&gt;iOS/iPadOS Safari: 자동 프롬프트가 없어 &amp;quot;홈 화면에 추가&amp;quot;를 손으로 안내합니다. (iPadOS는 데스크톱 UA를 보내기 때문에, 터치 지원 여부까지 함께 봐야 정확히 가려집니다.)&lt;/li&gt;
&lt;li&gt;macOS Safari: 이쪽도 &lt;code&gt;beforeinstallprompt&lt;/code&gt;가 없어, 메뉴 막대의 &amp;quot;파일 → Dock에 추가&amp;quot;를 안내합니다.&lt;/li&gt;
&lt;li&gt;그 외(파이어폭스 등): 마땅한 설치 수단이 없어 아무것도 보여주지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 &lt;code&gt;beforeinstallprompt&lt;/code&gt;가 새 변수를 만들었습니다.&lt;br&gt;앞 절의 &lt;code&gt;isIOS&lt;/code&gt;/&lt;code&gt;isStandalone&lt;/code&gt;은 마운트 시점에 한 번 정해지고 바뀌지 않는 값이라 &lt;code&gt;subscribe&lt;/code&gt;를 no-op으로 둘 수 있었습니다.&lt;br&gt;그런데 &lt;code&gt;beforeinstallprompt&lt;/code&gt;는 페이지 로드 후 브라우저가 비동기로 쏘는 이벤트라, &amp;quot;마운트 후 안 바뀐다&amp;quot;는 전제가 깨집니다. 이건 진짜로 구독해야 하는 외부 이벤트입니다.&lt;/p&gt;
&lt;p&gt;그래서 두 종류의 값을 분리했습니다. 브라우저 환경 판별(iOS, macOS Safari, standalone)은 그대로 &lt;code&gt;useSyncExternalStore&lt;/code&gt;로 읽고, &lt;code&gt;beforeinstallprompt&lt;/code&gt;와 설치 완료(&lt;code&gt;appinstalled&lt;/code&gt;)만 &lt;code&gt;useEffect&lt;/code&gt;에서 이벤트로 구독합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 자동 설치 이벤트만 진짜로 구독한다 (마운트 후 비동기 발생)
useEffect(() =&amp;gt; {
  const handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) =&amp;gt; {
    event.preventDefault() // 브라우저 기본 인포바를 막고
    setDeferredPrompt(event) // 이벤트를 보관해 버튼으로 노출
  }
  const handleAppInstalled = () =&amp;gt; setDeferredPrompt(null)

  window.addEventListener(&amp;#39;beforeinstallprompt&amp;#39;, handleBeforeInstallPrompt)
  window.addEventListener(&amp;#39;appinstalled&amp;#39;, handleAppInstalled)
  return () =&amp;gt; {
    window.removeEventListener(&amp;#39;beforeinstallprompt&amp;#39;, handleBeforeInstallPrompt)
    window.removeEventListener(&amp;#39;appinstalled&amp;#39;, handleAppInstalled)
  }
}, [])&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 환경 판별 값과 보관해둔 이벤트를 합쳐, 무엇을 보여줄지 한 군데에서 결정합니다. 우선순위는 이미 설치됨 → 자동 설치 버튼 → iOS 안내 → macOS Safari 안내 순입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const mode = resolveMode({
  isStandalone,
  hasInstallPrompt: deferredPrompt !== null,
  isIOS,
  isMacSafari
})
// &amp;#39;hidden&amp;#39; | &amp;#39;button&amp;#39; | &amp;#39;iosGuide&amp;#39; | &amp;#39;macSafariGuide&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;beforeinstallprompt&lt;/code&gt;로 받은 이벤트는 한 번만 쓸 수 있습니다. 버튼을 누르면 &lt;code&gt;event.prompt()&lt;/code&gt;로 네이티브 설치 창을 띄우고, 쓰임이 끝난 이벤트는 폐기해 버튼을 감춥니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const promptInstall = async () =&amp;gt; {
  if (!deferredPrompt) return
  await deferredPrompt.prompt()
  setDeferredPrompt(null) // 일회성 이벤트 → 사용 후 폐기 (버튼도 사라짐)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;환경 판별은 &lt;code&gt;useSyncExternalStore&lt;/code&gt;, 비동기 이벤트는 &lt;code&gt;useEffect&lt;/code&gt; 구독으로 역할을 나누고 나니, 같은 컴포넌트가 크롬에선 버튼을, 사파리에선 안내를 보여주게 됐습니다. 설치 로직이 제법 커져서, 이 부분은 &lt;code&gt;useInstallPrompt&lt;/code&gt; 훅으로 분리해 컴포넌트는 &lt;code&gt;mode&lt;/code&gt;에 따라 렌더만 하도록 두었습니다.&lt;/p&gt;
&lt;h2&gt;보안 헤더 추가하기&lt;/h2&gt;
&lt;p&gt;마지막으로 &lt;code&gt;next.config.ts&lt;/code&gt;에 헤더를 추가했습니다. 전역 보안 헤더와 더불어, 서비스 워커가 항상 최신으로 받아지도록 &lt;code&gt;/sw.js&lt;/code&gt;에 캐시 무효화 헤더를 지정했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;async headers() {
  return [
    {
      source: &amp;#39;/(.*)&amp;#39;,
      headers: [
        { key: &amp;#39;X-Content-Type-Options&amp;#39;, value: &amp;#39;nosniff&amp;#39; },
        { key: &amp;#39;X-Frame-Options&amp;#39;, value: &amp;#39;DENY&amp;#39; },
        { key: &amp;#39;Referrer-Policy&amp;#39;, value: &amp;#39;strict-origin-when-cross-origin&amp;#39; }
      ]
    },
    {
      source: &amp;#39;/sw.js&amp;#39;,
      headers: [
        { key: &amp;#39;Content-Type&amp;#39;, value: &amp;#39;application/javascript; charset=utf-8&amp;#39; },
        { key: &amp;#39;Cache-Control&amp;#39;, value: &amp;#39;no-cache, no-store, must-revalidate&amp;#39; }
      ]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;서비스 워커를 캐시해버리면 사용자가 오래된 버전을 계속 들고 있게 되므로, &lt;code&gt;no-cache, no-store, must-revalidate&lt;/code&gt;로 매번 새로 받게 하는 게 안전합니다.&lt;/p&gt;
&lt;h2&gt;로컬에서 확인하기&lt;/h2&gt;
&lt;p&gt;데스크톱에서 확인할 때는 일반 &lt;code&gt;pnpm dev&lt;/code&gt;로 충분합니다. 브라우저는 &lt;code&gt;localhost&lt;/code&gt;(와 &lt;code&gt;127.0.0.1&lt;/code&gt;)를 보안 컨텍스트로 취급해서, HTTP라도 서비스 워커 등록과 설치가 모두 동작하기 때문입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dev&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;확인은 Chrome DevTools의 Application 탭에서 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Manifest: 이름과 아이콘, &lt;code&gt;display: standalone&lt;/code&gt;이 에러 없이 잡히는지&lt;/li&gt;
&lt;li&gt;Service Workers: &lt;code&gt;/sw.js&lt;/code&gt;가 activated 상태로 running인지&lt;/li&gt;
&lt;li&gt;주소창의 설치 아이콘이나 화면의 &amp;quot;앱 설치&amp;quot; 버튼으로 standalone 창이 뜨는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;데스크톱 크롬에서는 설치 버튼이, macOS Safari에서는 &amp;quot;Dock에 추가&amp;quot; 안내가 보입니다.&lt;/p&gt;
&lt;p&gt;문제는 실제 휴대폰에서 확인할 때입니다. 같은 네트워크에서 &lt;code&gt;http://192.168.0.x:3000&lt;/code&gt; 같은 IP로 접속하면 이건 &lt;code&gt;localhost&lt;/code&gt;가 아니라서 보안 컨텍스트로 잡히지 않고, 서비스 워커가 등록되지 않아 설치 자체가 막힙니다. 특히 iOS Safari에서 &amp;quot;홈 화면에 추가&amp;quot; 흐름을 실제로 보려면 HTTPS가 필요합니다. 이때 Next가 자체 서명 인증서를 만들어주는 옵션을 씁니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dev --experimental-https&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 폰 브라우저에서 &lt;code&gt;https://192.168.0.x:3000&lt;/code&gt;으로 접속하면(인증서 경고는 허용) localhost와 똑같이 서비스 워커와 설치가 동작합니다. 정리하면, 데스크톱 localhost 확인은 &lt;code&gt;pnpm dev&lt;/code&gt;, 폰에서 IP로 붙어 확인할 때만 &lt;code&gt;--experimental-https&lt;/code&gt;가 필요합니다.&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;설치형 PWA는 manifest, 최소 서비스 워커, 서비스 워커 등록 컴포넌트, 아이콘만 있으면 됩니다. 가장 기억에 남는 건 클라이언트 전용 값을 다루다 만난 하이드레이션 불일치였고, 마운트 후 고정되는 값은 &lt;code&gt;useSyncExternalStore&lt;/code&gt;로, 비동기로 도착하는 &lt;code&gt;beforeinstallprompt&lt;/code&gt;는 &lt;code&gt;useEffect&lt;/code&gt; 구독으로 나눠 다루면 깔끔하게 풀립니다.&lt;/p&gt;
&lt;p&gt;참고한 자료입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/guides/progressive-web-apps&quot;&gt;Next.js — How to build a Progressive Web Application (PWA)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/reference/react/useSyncExternalStore&quot;&gt;React — useSyncExternalStore&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발../Nextjs</category>
      <category>Nextjs16</category>
      <category>pwa</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/260</guid>
      <comments>https://white-blank.tistory.com/260#entry260comment</comments>
      <pubDate>Tue, 9 Jun 2026 17:40:51 +0900</pubDate>
    </item>
    <item>
      <title>Next.js App Router에 FSD 합치기</title>
      <link>https://white-blank.tistory.com/259</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 프로젝트를 진행할 때 마다 어떤 프로젝트 구조로 진행할지 고민을 많이 하게 됩니다.&lt;br /&gt;필자는 도메인 기반, 컨택스트 기반 으로 프로젝트를 진행하다가 신규 프로젝트에서는 React 에 FSD 를 차용하여 Nextjs 에서 App Router 와 FSD 를 합친 구조를 만들게 되었습니다. &lt;br /&gt;app 아래에서는 특정 도메인 형태로 구분하되, 그 외 기능 기반을 별도 FSD 형태로 관리하는 구조 입니다.&lt;br /&gt;다만 Next.js App Router 환경에 그대로 가져오면 충돌이 생기는 부분이 있어서, App Router의 특성에 맞게 구조를 조정해야 합니다. 이 글은 FSD를 Next.js App Router에 최적화해서 도입하는 과정을 담고 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#fsd%EB%A5%BC-%EA%B7%B8%EB%8C%80%EB%A1%9C-%EC%A0%81%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%83%9D%EA%B8%B0%EB%8A%94-%EB%AC%B8%EC%A0%9C&quot;&gt;FSD를 그대로 적용하면 생기는 문제&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#pages-%EB%A0%88%EC%9D%B4%EC%96%B4%EB%A5%BC-app%EC%9C%BC%EB%A1%9C-%EB%8C%80%EC%B2%B4%ED%95%98%EA%B8%B0&quot;&gt;pages 레이어를 app/으로 대체하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#widgets-%EB%A0%88%EC%9D%B4%EC%96%B4-%ED%8C%90%EB%8B%A8-%EA%B8%B0%EC%A4%80&quot;&gt;widgets 레이어 판단 기준&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%B5%9C%EC%A2%85-%EA%B5%AC%EC%A1%B0&quot;&gt;최종 구조&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%BD%94%EB%93%9C-%EB%B0%B0%EC%B9%98-%EA%B8%B0%EC%A4%80&quot;&gt;코드 배치 기준&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD를 그대로 적용하면 생기는 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD의 표준 레이어 구성입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;app &amp;rarr; pages &amp;rarr; widgets &amp;rarr; features &amp;rarr; entities &amp;rarr; shared&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;이 구조를 Next.js App Router 프로젝트에 그대로 가져오면 두 가지 문제가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, &lt;code&gt;src/pages/&lt;/code&gt;는 Next.js가 Pages Router 디렉토리로 인식합니다. App Router(&lt;code&gt;src/app/&lt;/code&gt;)와 Pages Router가 동시에 활성화되어 빌드가 꼬이거나 의도하지 않은 라우팅이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &lt;code&gt;widgets&lt;/code&gt; 레이어는 역할이 &lt;code&gt;features&lt;/code&gt;와 겹치는 경우가 많습니다. &quot;이게 widget이야, feature야?&quot;라는 판단 비용이 반복해서 생깁니다. (불필요한 레이어가 많습니다. App Router 구조를 활용하면서 필요한 레이어만 이식합시다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 문제를 해결하면 App Router 환경에서 FSD가 훨씬 자연스럽게 동작합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;pages 레이어를 app/으로 대체하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Router의 &lt;code&gt;app/&lt;/code&gt; 폴더는 &lt;code&gt;page.tsx&lt;/code&gt;나 &lt;code&gt;route.ts&lt;/code&gt;가 없으면 어떤 파일도 라우트로 노출되지 않습니다. 즉, 페이지 전용 컴포넌트를 별도 레이어로 분리하지 않아도 라우트 폴더 안에 함께 두면 됩니다. App Router가 FSD의 pages 레이어 역할을 자연스럽게 대신합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지에서만 쓰는 컴포넌트는 해당 라우트 폴더 안에 &lt;code&gt;components/&lt;/code&gt;로 co-locate합니다. co-locate는 관련 파일을 같은 폴더에 함께 두는 것을 의미합니다. 역할별로 분리(&lt;code&gt;components/&lt;/code&gt;, &lt;code&gt;hooks/&lt;/code&gt;, &lt;code&gt;utils/&lt;/code&gt;)하는 대신 사용되는 위치를 기준으로 묶습니다. 해당 페이지가 사라지면 폴더 하나만 지우면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;nsis&quot;&gt;&lt;code&gt;src/app/
└── benefit/
    ├── page.tsx
    ├── components/       # 이 페이지에서만 쓰는 컴포넌트
    └── [id]/
        ├── page.tsx
        └── components/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 App Router의 co-location 철학과도 일치합니다. 파일이 어디 있는지 찾으려면 라우트 구조를 따라가면 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;widgets 레이어 판단 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;widgets는 여러 feature를 조합한 복합 UI 블록을 담는 레이어입니다. 프로젝트에 따라 유용할 수 있지만, 다음 기준으로 판단하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;widgets가 필요한 경우:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 feature를 조합하는 복잡한 UI 블록이 반복해서 등장할 때&lt;/li&gt;
&lt;li&gt;features와 역할이 명확히 구분될 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;widgets 없이 features로 흡수해도 되는 경우:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대부분의 복합 UI가 특정 도메인의 여러 페이지에서 공유되는 수준일 때&lt;/li&gt;
&lt;li&gt;&quot;이게 widget이야, feature야?&quot; 판단이 계속 모호할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단이 계속 애매하다면 widgets를 두지 않고 features에서 관리하는 것이 팀의 결정 비용을 줄입니다.&lt;br /&gt;이 경우 의존 방향은 다음과 같이 단순해집니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;app &amp;rarr; features &amp;rarr; entities &amp;rarr; shared&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 구조&lt;/h2&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;src/
├── app/                      # Next.js 16 라우팅 + 페이지 전용 코드
│   ├── layout.tsx            # Provider 주입만
│   ├── {domain}/             # 도메인명 (예: benefit, coupon, user)
│   │   ├── page.tsx
│   │   ├── components/       # 이 페이지에서만 쓰는 컴포넌트
│   │   └── [id]/
│   │       ├── page.tsx
│   │       └── components/
│   └── api/
│       └── {resource}/
│           └── route.ts
│
├── features/                 # 같은 도메인 여러 페이지에서 공유
│   └── {feature-name}/
│       ├── ui/
│       ├── model/
│       └── index.ts
│
├── entities/                 # 여러 도메인에서 공유
│   └── {entity-name}/
│       ├── ui/
│       ├── model/
│       └── index.ts
│
└── shared/                   # 순수 재사용 코드
    ├── ui/
    ├── api/
    ├── lib/
    └── config/&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;proxy.ts&lt;/code&gt;(구 &lt;code&gt;middleware.ts&lt;/code&gt;)는 Next.js 런타임이 직접 인식하는 파일이므로 프로젝트 루트에 둡니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드 배치 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD의 핵심은 &quot;이 코드가 얼마나 넓은 범위에서 쓰이는가&quot;로 위치를 결정하는 것입니다. 다음 순서로 판단합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;여러 페이지에서 쓰이는가?
├── NO  &amp;rarr; app/{도메인}/ 안에 co-locate
└── YES &amp;rarr; 같은 도메인의 여러 페이지에서 쓰이는가?
          ├── YES &amp;rarr; features/{도메인}/
          └── NO  &amp;rarr; 여러 도메인에 걸쳐 쓰이는가?
                    ├── YES &amp;rarr; entities/{도메인}/
                    └── NO  &amp;rarr; shared/&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;app/&lt;/code&gt; 안에 작성하고, 재사용이 필요해질 때 위 기준에 따라 올려보내는 Bottom-Up 방식을 권장합니다. 처음부터 레이어를 예측해서 나누려 하면 판단 비용이 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬라이스 외부에서는 반드시 &lt;code&gt;index.ts&lt;/code&gt;를 통해서만 import합니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// ✅
import { BenefitCard } from &quot;@/entities/benefit&quot;

// ❌
import { BenefitCard } from &quot;@/entities/benefit/ui/BenefitCard&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙 덕분에 슬라이스 내부를 변경해도 외부에 영향을 주지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;232&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/doLum9/dJMcaffJDFq/DfJV8Mfiy8RADSdkVHTtSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/doLum9/dJMcaffJDFq/DfJV8Mfiy8RADSdkVHTtSk/img.png&quot; data-alt=&quot;최종적 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/doLum9/dJMcaffJDFq/DfJV8Mfiy8RADSdkVHTtSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdoLum9%2FdJMcaffJDFq%2FDfJV8Mfiy8RADSdkVHTtSk%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;232&quot; height=&quot;375&quot; data-origin-width=&quot;232&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;최종적 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD를 Next.js App Router에 도입할 때 핵심은 두 가지입니다. pages 레이어는 App Router의 co-location으로 대체하고, widgets 레이어는 프로젝트 상황에 맞게 유지하거나 features에 흡수합니다. 레이어가 단순해질수록 코드 위치에 대한 팀 내 논쟁도 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 규칙을 따르기 위한 도구가 아니라 &quot;이 코드가 어디까지 영향을 미치는가&quot;를 명확히 하기 위한 도구입니다. App Router의 특성을 활용하면 FSD의 의도를 더 자연스럽게 살릴 수 있습니다.&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://feature-sliced.design/&quot;&gt;Feature-Sliced Design 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/fsd/&quot;&gt;카카오페이 FSD 적용기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app&quot;&gt;Next.js App Router 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발../Nextjs</category>
      <category>app router</category>
      <category>FSD</category>
      <category>NextJS</category>
      <category>구조 합치기</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/259</guid>
      <comments>https://white-blank.tistory.com/259#entry259comment</comments>
      <pubDate>Thu, 28 May 2026 14:16:49 +0900</pubDate>
    </item>
    <item>
      <title>Client Component 안에 Server Component children 넣기</title>
      <link>https://white-blank.tistory.com/258</link>
      <description>&lt;p&gt;Next.js App Router로 프로젝트를 만들면서 &lt;code&gt;createContext only works in Client Components&lt;/code&gt;라는 에러를 마주쳤습니다. &lt;code&gt;ThemeProvider&lt;/code&gt;를 &lt;code&gt;layout.tsx&lt;/code&gt;에서 바로 사용했기 때문이었는데, 이 에러를 고치면서 RSC(React Server Components)에서 &lt;code&gt;children&lt;/code&gt;이 어떻게 동작하는지, 그리고 서버와 클라이언트 사이에 실제로 무슨 데이터가 오가는지 정확히 이해하게 됐습니다. 기존 React 멘탈모델로는 직관적이지 않은 부분이 있어서 정리해 두려 합니다.&lt;/p&gt;
&lt;h2&gt;목차&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%AC%B8%EC%A0%9C-%EC%83%81%ED%99%A9&quot;&gt;문제 상황&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%99%9C-%EC%97%90%EB%9F%AC%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94%EA%B0%80&quot;&gt;왜 에러가 발생하는가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%ED%95%B4%EA%B2%B0-provider%EB%A5%BC-client-component%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0&quot;&gt;해결: Provider를 Client Component로 분리하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EA%B7%B8%EB%9F%AC%EB%A9%B4-pagetsx%EB%8F%84-client-component%EA%B0%80-%EB%90%98%EB%8A%94-%EA%B1%B8%EA%B9%8C&quot;&gt;그러면 page.tsx도 Client Component가 되는 걸까&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#import%EC%99%80-children%EC%9D%80-%EB%8B%A4%EB%A5%B4%EB%8B%A4&quot;&gt;import와 children은 다르다&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#rsc-payload%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80&quot;&gt;RSC Payload란 무엇인가&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#rsc-payload%EB%A1%9C-%EB%B3%B4%EB%8A%94-%EC%8B%A4%EC%A0%9C-%EA%B5%AC%EC%A1%B0&quot;&gt;RSC Payload로 보는 실제 구조&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%83%80%EC%9E%84%EB%9D%BC%EC%9D%B8&quot;&gt;렌더링 타임라인&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;문제 상황&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;layout.tsx&lt;/code&gt;에 Emotion의 &lt;code&gt;ThemeProvider&lt;/code&gt;를 다음과 같이 작성했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/layout.tsx
import { ThemeProvider, Global } from &amp;#39;@emotion/react&amp;#39;
import { theme } from &amp;#39;@/shared/ui&amp;#39;

export default function RootLayout({ children }) {
  return (
    &amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ThemeProvider theme={theme}&amp;gt;
          &amp;lt;Global styles={globalStyles} /&amp;gt;
          {children}
        &amp;lt;/ThemeProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행하면 바로 에러가 납니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: createContext only works in Client Components.
Add the &amp;quot;use client&amp;quot; directive at the top of the file to use it.&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;왜 에러가 발생하는가&lt;/h2&gt;
&lt;p&gt;App Router의 모든 컴포넌트는 기본적으로 Server Component입니다. &lt;code&gt;layout.tsx&lt;/code&gt;도 마찬가지입니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThemeProvider&lt;/code&gt;는 내부적으로 &lt;code&gt;React.createContext()&lt;/code&gt;를 사용합니다. Context API는 브라우저의 컴포넌트 트리를 기반으로 동작하는 클라이언트 개념이라서, 서버에서 실행되는 Server Component에서는 쓸 수 없습니다.&lt;/p&gt;
&lt;p&gt;단순히 &lt;code&gt;layout.tsx&lt;/code&gt; 맨 위에 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt;를 붙이면 해결될 것 같지만, 그렇게 하면 &lt;code&gt;export const metadata&lt;/code&gt;를 사용할 수 없게 됩니다. &lt;code&gt;metadata&lt;/code&gt;는 Server Component에서만 export할 수 있기 때문입니다.&lt;/p&gt;
&lt;h2&gt;해결: Provider를 Client Component로 분리하기&lt;/h2&gt;
&lt;p&gt;Context를 사용하는 부분만 별도 파일로 빼고, 거기에만 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt;를 붙입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/providers.tsx
&amp;#39;use client&amp;#39;

import { Global, ThemeProvider, css } from &amp;#39;@emotion/react&amp;#39;
import type { ReactNode } from &amp;#39;react&amp;#39;
import { theme } from &amp;#39;@/shared/ui&amp;#39;
import { EmotionRegistry } from &amp;#39;./emotion-registry&amp;#39;

export const Providers = ({ children }: { children: ReactNode }) =&amp;gt; (
  &amp;lt;EmotionRegistry&amp;gt;
    &amp;lt;ThemeProvider theme={theme}&amp;gt;
      &amp;lt;Global styles={globalStyles} /&amp;gt;
      {children}
    &amp;lt;/ThemeProvider&amp;gt;
  &amp;lt;/EmotionRegistry&amp;gt;
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;layout.tsx&lt;/code&gt;는 Server Component로 유지하면서 &lt;code&gt;Providers&lt;/code&gt;를 가져다 씁니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/layout.tsx
import type { ReactNode } from &amp;#39;react&amp;#39;
import { AppShell } from &amp;#39;./app-shell&amp;#39;
import { Providers } from &amp;#39;./providers&amp;#39;

export const metadata = { title: &amp;#39;AI Chat&amp;#39;, description: &amp;#39;GPT-5 기반 AI 채팅&amp;#39; }

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    &amp;lt;html lang=&amp;quot;ko&amp;quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;Providers&amp;gt;
          &amp;lt;AppShell&amp;gt;{children}&amp;lt;/AppShell&amp;gt;
        &amp;lt;/Providers&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;그러면 page.tsx도 Client Component가 되는 걸까&lt;/h2&gt;
&lt;p&gt;여기서 의문이 생깁니다. &lt;code&gt;Providers&lt;/code&gt;와 &lt;code&gt;AppShell&lt;/code&gt;은 Client Component인데, 그 안에 &lt;code&gt;{children}&lt;/code&gt;으로 들어오는 &lt;code&gt;page.tsx&lt;/code&gt;도 Client Component가 되는 걸까요?&lt;/p&gt;
&lt;p&gt;아닙니다. &lt;code&gt;page.tsx&lt;/code&gt;는 &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt;를 선언하지 않는 한 Server Component로 유지됩니다.&lt;/p&gt;
&lt;h2&gt;import와 children은 다르다&lt;/h2&gt;
&lt;p&gt;Client 경계를 만드는 건 &lt;strong&gt;import&lt;/strong&gt;이지, &lt;strong&gt;children prop&lt;/strong&gt;이 아닙니다.&lt;/p&gt;
&lt;p&gt;Client Component가 다른 컴포넌트를 &lt;strong&gt;직접 import&lt;/strong&gt;하면, 그 컴포넌트는 클라이언트 번들에 포함됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ClientComponent.tsx
&amp;#39;use client&amp;#39;
import { Page } from &amp;#39;./page&amp;#39;  // Page가 클라이언트 번들에 포함됨

export const ClientComponent = () =&amp;gt; &amp;lt;Page /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;반면 &lt;strong&gt;children으로 전달&lt;/strong&gt;하면 달라집니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// layout.tsx (Server Component)
export default function RootLayout({ children }) {
  return (
    &amp;lt;Providers&amp;gt;        // Client Component
      &amp;lt;AppShell&amp;gt;       // Client Component
        {children}     // page.tsx — Server Component인 채로 전달됨
      &amp;lt;/AppShell&amp;gt;
    &amp;lt;/Providers&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;layout.tsx&lt;/code&gt;(Server Component)가 &lt;code&gt;page.tsx&lt;/code&gt;를 렌더링하고, 그 결과물을 &lt;code&gt;Providers&lt;/code&gt;의 &lt;code&gt;children&lt;/code&gt; prop으로 넘깁니다. &lt;code&gt;Providers&lt;/code&gt;는 page를 import하거나 실행하지 않습니다. 이미 서버에서 만들어진 React 엘리먼트를 받아서 자기 자리에 끼워 넣을 뿐입니다.&lt;/p&gt;
&lt;h2&gt;RSC Payload란 무엇인가&lt;/h2&gt;
&lt;p&gt;이 동작을 이해하려면 서버가 클라이언트로 무엇을 전송하는지 알아야 합니다. Next.js App Router는 기존 SSR처럼 HTML만 보내지 않습니다. HTML과 함께 &lt;strong&gt;RSC Payload&lt;/strong&gt;라는 별도의 데이터를 함께 전송합니다.&lt;/p&gt;
&lt;p&gt;기존 SSR과 비교하면 이렇습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;기존 SSR
  서버 → 완성된 HTML 문자열 → 클라이언트

RSC (App Router)
  서버 → HTML + RSC Payload → 클라이언트&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;RSC Payload는 &lt;strong&gt;직렬화된 React 트리의 설명서&lt;/strong&gt;입니다. 브라우저 개발자 도구의 네트워크 탭에서 실제로 확인해 보면 다음과 같은 형태입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2:I[&amp;quot;(app-pages-browser)/./src/app/providers.tsx&amp;quot;,[&amp;quot;app/layout&amp;quot;,&amp;quot;static/chunks/app/layout.js&amp;quot;],&amp;quot;Providers&amp;quot;]
3:I[&amp;quot;(app-pages-browser)/./src/app/app-shell.tsx&amp;quot;,[&amp;quot;app/layout&amp;quot;,&amp;quot;static/chunks/app/layout.js&amp;quot;],&amp;quot;AppShell&amp;quot;]
4:[&amp;quot;$&amp;quot;,&amp;quot;html&amp;quot;,null,{&amp;quot;lang&amp;quot;:&amp;quot;ko&amp;quot;,&amp;quot;children&amp;quot;:[&amp;quot;$&amp;quot;,&amp;quot;body&amp;quot;,null,{&amp;quot;children&amp;quot;:[&amp;quot;$&amp;quot;,&amp;quot;$L2&amp;quot;,null,...]}]}]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;I&lt;/code&gt;로 시작하는 항목은 &lt;strong&gt;Client Component 참조&lt;/strong&gt;입니다. 어떤 JS 파일의 어떤 export인지만 담겨 있습니다. 실제 컴포넌트 코드가 들어있는 게 아니라 &amp;quot;이 자리에 이 컴포넌트를 hydrate 해라&amp;quot;는 지시입니다.&lt;/p&gt;
&lt;p&gt;두 가지 데이터가 각각 역할을 나눕니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HTML&lt;/strong&gt;: 브라우저가 화면에 즉시 그릴 수 있는 마크업. 사용자가 JS 로드 전에 볼 수 있는 내용입니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RSC Payload&lt;/strong&gt;: React가 트리를 이해하기 위한 설명서. 어디가 Client Component이고 어디가 Server Component 결과물인지 알 수 있습니다. 이후 클라이언트 내비게이션 시에는 HTML 없이 RSC Payload만 요청해서 부분 업데이트합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;HTML만 있으면 화면은 그릴 수 있지만 React가 트리를 파악하지 못해서 hydration이 불가능합니다. RSC Payload 덕분에 React는 어느 노드에 어떤 이벤트 핸들러와 상태를 붙여야 하는지 정확히 알 수 있습니다.&lt;/p&gt;
&lt;h2&gt;RSC Payload로 보는 실제 구조&lt;/h2&gt;
&lt;p&gt;이 구조를 우리 코드에 대입해서 보면 다음과 같습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Client Component 참조]
  Providers  → 클라이언트 번들의 providers.js
  AppShell   → 클라이언트 번들의 app-shell.js

[React 엘리먼트 트리]
  &amp;lt;Providers&amp;gt;             ← &amp;quot;providers.js를 여기서 hydrate&amp;quot;
    &amp;lt;AppShell&amp;gt;            ← &amp;quot;app-shell.js를 여기서 hydrate&amp;quot;
      &amp;lt;div&amp;gt;               ← page.tsx가 서버에서 실행된 결과물 (완성된 트리)
        &amp;lt;h1&amp;gt;AI Chat&amp;lt;/h1&amp;gt;
        ...
      &amp;lt;/div&amp;gt;
    &amp;lt;/AppShell&amp;gt;
  &amp;lt;/Providers&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt;의 &lt;strong&gt;결과물&lt;/strong&gt;은 payload에 이미 완성된 트리로 담겨 있습니다. &lt;code&gt;Providers&lt;/code&gt;와 &lt;code&gt;AppShell&lt;/code&gt;은 어떤 컴포넌트인지 &lt;strong&gt;참조&lt;/strong&gt;만 담겨 있습니다. 클라이언트는 이 payload를 받아서 Client Component들을 hydrate하고, page 자리에는 서버에서 받은 결과물을 그대로 마운트합니다.&lt;/p&gt;
&lt;h2&gt;렌더링 타임라인&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;서버
  layout.tsx (SC) 실행
  page.tsx   (SC) 실행       ← 동시에 처리됨
  Providers, AppShell은 실행하지 않음
  → Client Component는 참조만 Payload에 기록

        HTML + RSC Payload 전송

클라이언트
  HTML로 화면을 즉시 표시
  RSC Payload를 읽고 Providers, AppShell hydrate
  page 결과물은 이미 있으므로 그대로 마운트&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;Providers&lt;/code&gt;가 렌더링된 다음에 &lt;code&gt;page&lt;/code&gt;가 렌더링되는 게 아닙니다. 서버는 Server Component 트리를 모두 실행하고, Client Component는 실행하지 않은 채 참조만 Payload에 남깁니다. &lt;code&gt;page.tsx&lt;/code&gt;는 &lt;code&gt;Providers&lt;/code&gt;의 실행을 기다리지 않습니다. 둘은 서버와 클라이언트라는 완전히 다른 환경에서 별개로 처리됩니다.&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;Client Component 안에 &lt;code&gt;children&lt;/code&gt;으로 Server Component를 넣어도 page는 Server Component로 유지됩니다. Client 경계를 만드는 건 import이지 children prop이 아니기 때문입니다. 이 동작이 가능한 이유는 Next.js가 HTML과 함께 RSC Payload를 전송하기 때문입니다. 서버는 Server Component를 실행한 결과물을 Payload에 담고, Client Component는 참조만 기록해서 클라이언트가 hydrate하도록 위임합니다.&lt;/p&gt;
&lt;p&gt;이 &lt;code&gt;children&lt;/code&gt; 슬롯 패턴 덕분에 Context Provider 같은 Client Component를 루트에 두면서도, page에서는 서버에서만 가능한 작업(DB 접근, 환경변수 사용 등)을 그대로 활용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;Next.js 공식 문서에서도 이 패턴을 &lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#using-context-providers&quot;&gt;Using context in Server Components&lt;/a&gt; 항목에서 권장하고 있습니다.&lt;/p&gt;</description>
      <category>개발../Nextjs</category>
      <category>hydrate</category>
      <category>NextJS</category>
      <category>RSC</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/258</guid>
      <comments>https://white-blank.tistory.com/258#entry258comment</comments>
      <pubDate>Wed, 27 May 2026 19:13:51 +0900</pubDate>
    </item>
    <item>
      <title>Query Factory 패턴을 Claude Code 스킬로 만들기</title>
      <link>https://white-blank.tistory.com/257</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;TanStack Query를 쓰다 보면 어느 시점부터 쿼리 키가 여러 파일에 흩어지고, 옵션의 경우에도 하드코딩으로 박혀있는 것을 보게 됩니다. Query Factory 패턴을 익히면 이 문제가 해결되는데, 이러한 패턴들을 도입시에는 관리 포인트라는게 발생하게 됩니다. 우리는 매번 패턴을 적용하기 위해 코드를 반복으로 사용하게 되는데, 이걸 Claude Code 스킬로 만들어두고 나서 그 고민이 사라졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;#query-factory-%ED%8C%A8%ED%84%B4%EC%9D%B4%EB%9E%80&quot;&gt;Query Factory 패턴이란&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%8A%A4%ED%82%AC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0&quot;&gt;스킬로 만들기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EC%8A%A4%ED%82%AC%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%98%EB%A9%B4-%EB%8B%AC%EB%9D%BC%EC%A7%80%EB%8A%94-%EA%B2%83&quot;&gt;스킬이 적용되면 달라지는 것&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%EB%A7%88%EB%AC%B4%EB%A6%AC&quot;&gt;마무리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Query Factory 패턴이란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 키와 &lt;code&gt;queryFn&lt;/code&gt;, 옵션을 도메인별로 하나의 객체에 모아두는 패턴입니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;export const characterQueries = {
  all:   () =&amp;gt; [&quot;characters&quot;] as const,
  lists: () =&amp;gt; [...characterQueries.all(), &quot;list&quot;] as const,

  list: (filter?: Filter) =&amp;gt;
    queryOptions({
      queryKey: [...characterQueries.lists(), filter] as const,
      queryFn:  () =&amp;gt; fetchCharacters(filter),
      staleTime: 1000 * 60,
    }),

  detail: (id: string) =&amp;gt;
    queryOptions({
      queryKey: [...characterQueries.all(), &quot;detail&quot;, id] as const,
      queryFn:  () =&amp;gt; fetchCharacter(id),
      staleTime: 1000 * 60 * 10,
    }),
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 &lt;code&gt;useQuery(characterQueries.list())&lt;/code&gt;와 서버 컴포넌트의 &lt;code&gt;prefetchQuery(characterQueries.list())&lt;/code&gt;가 항상 같은 키를 바라봅니다. SSR prefetch가 제대로 전달되는 전제 조건이고, &lt;code&gt;invalidateQueries&lt;/code&gt;로 무효화 범위를 계층별로 조절할 수도 있습니다. 좋은 패턴인데, 매번 손으로 짜는 게 문제입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스킬로 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code 스킬은 마크다운 파일 하나입니다. &lt;code&gt;.claude/skills/api-integration/SKILL.md&lt;/code&gt;에 이 패턴의 규칙을 적어두면, 관련 작업이 들어올 때마다 Claude가 스킬을 읽고 그 구조를 따라 코드를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 트리거 조건과 규칙을 명시하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
name: api-integration
description: &amp;gt;
  TanStack Query + Query Factory 패턴 스킬.
  &quot;쿼리 만들어줘&quot;, &quot;API 연동해줘&quot;, &quot;useQuery 작성&quot; 등의 요청에 사용한다.
---

## Query Factory 패턴

쿼리는 반드시 도메인별 파일로 분리하고 아래 구조를 따른다.

- `src/lib/query-options/[domain].ts`에 작성한다
- `queryOptions()` 헬퍼를 사용한다
- 키는 `all &amp;rarr; lists &amp;rarr; list(filter)`, `all &amp;rarr; detail(id)` 계층으로 만든다
- `staleTime`은 팩토리 안에서 정의한다. 호출부에서 직접 쓰지 않는다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 스킬 파일에 한 번만 적어두면, 이후에 &quot;캐릭터 목록 쿼리 만들어줘&quot;라고 했을 때 매번 같은 구조가 나옵니다.&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;쿼리 만들어줘&quot;라고 하면 어떤 날은 &lt;code&gt;useQuery&lt;/code&gt; 코드만 뚝 나오고, 어떤 날은 키를 문자열로 직접 쓰고, 또 어떤 날은 &lt;code&gt;staleTime&lt;/code&gt;을 컴포넌트에 넣기도 합니다. 컨텍스트에 따라 결과가 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬이 있으면 다릅니다. 요청만 해도 &lt;code&gt;src/lib/query-options/&lt;/code&gt; 아래 도메인 파일이 생기고, 계층적 키 구조와 &lt;code&gt;queryOptions&lt;/code&gt;가 포함된 코드가 나옵니다. 리뷰할 때 &quot;이건 왜 이렇게 됐어요?&quot;가 없어집니다. 팀에서 같이 쓰면 사람마다 구조가 달라지는 일도 없습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Query Factory 패턴 자체는 어렵지 않습니다. 한 번 익히면 됩니다. Claude Code 스킬로 만들어두는 건 그 다음 단계입니다. 익힌 패턴을 매번 기억해서 적용하는 대신, 스킬에 위임해두면 됩니다. 새 프로젝트를 시작해도, 새 도메인을 추가해도, &quot;이번엔 어떻게 구조 잡지?&quot; 고민 없이 같은 결과가 나옵니다. 좋은 패턴은 습관이 되어야 하는데, 스킬이 그 습관을 대신해줍니다.&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;a href=&quot;https://tanstack.com/query/latest/docs/framework/react/reference/queryOptions&quot;&gt;TanStack Query 공식 문서 &amp;mdash; queryOptions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.anthropic.com/ko/docs/claude-code/skills&quot;&gt;Claude Code 스킬 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발..</category>
      <category>클로드 코드</category>
      <category>플러그인</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/257</guid>
      <comments>https://white-blank.tistory.com/257#entry257comment</comments>
      <pubDate>Wed, 20 May 2026 19:29:54 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code 플러그인 추천 (Atlassian, Figma)</title>
      <link>https://white-blank.tistory.com/256</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;업무시에 추천드릴 2가지 플러그인 소개시켜드리겠습니다. &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;바로 &lt;/span&gt;&lt;b&gt;Atlassian 플러그인&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;과 &lt;/span&gt;&lt;b&gt;Figma 플러그인&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 플러그인 모두 Anthropic 공식 마켓플레이스(claude-plugins-official)에 등록되어 있고, 내부적으로 MCP(Model Context Protocol) 서버를 통해 동작하며, 한 번의 OAuth 인증만 거치면 터미널에서 자연어로 지라와 피그마를 다룰 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 두 플러그인이 각각 무엇을 할 수 있고, 어떻게 설치하고, 같이 쓸 때 어떤 시너지가 나는지 정리해보려고 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;먼저, 플러그인과 MCP가 무엇인지부터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code 플러그인은 단순한 명령어 모음이 아닙니다. 한 플러그인 안에는 &lt;b&gt;MCP 서버 설정, 슬래시 커맨드, 에이전트, 스킬&lt;/b&gt;이 함께 묶여 있고, 명령어 하나로 이 모든 게 한 번에 셋업됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 MCP입니다. MCP는 AI 에이전트가 외부 서비스와 대화할 수 있게 해주는 표준 프로토콜로, &quot;Claude가 지라의 언어를 알아듣게 해주는 통역기&quot; 정도로 이해하면 충분합니다. 예전에는 MCP 서버를 직접 설치하고, 설정 파일을 손으로 수정하고, 인증 토큰을 관리하는 과정을 거쳐야 했는데, 플러그인 시스템이 등장하면서 이 과정이 명령어 한 줄 또는 메뉴 선택 한 번으로 줄었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;플러그인을 깐다 = MCP 서버 + 미리 만들어진 슬래시 커맨드 + 인증 흐름을 한꺼번에 설치한다&lt;/b&gt;고 생각하시면 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Atlassian 플러그인 &amp;mdash; 지라 티켓에서 커밋까지 한 흐름으로&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무엇을 할 수 있나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Atlassian 플러그인은 Atlassian Cloud 제품들, 즉 &lt;b&gt;Jira, Confluence, Compass&lt;/b&gt;에 Claude Code가 직접 접근할 수 있게 해줍니다. OAuth 2.1 기반으로 인증하며, 사용자가 이미 가지고 있는 Jira/Confluence 권한을 그대로 따릅니다. 즉, 본인이 못 보는 프로젝트는 Claude도 못 봅니다.&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;/capture-tasks-from-meeting-notes &amp;mdash; 회의록에서 액션 아이템을 뽑아내고 담당자가 지정된 지라 티켓으로 등록&lt;/li&gt;
&lt;li&gt;/spec-to-backlog &amp;mdash; Confluence 스펙 문서를 Epic-Story 구조의 백로그로 변환&lt;/li&gt;
&lt;li&gt;/generate-status-report &amp;mdash; 프로젝트 상태 리포트를 생성해서 Confluence에 발행&lt;/li&gt;
&lt;li&gt;/search-company-knowledge &amp;mdash; 사내 문서 검색&lt;/li&gt;
&lt;li&gt;/triage-issue &amp;mdash; 이슈를 분류하고 우선순위 정리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에도 자연어로 가능한 일들이 많습니다. JQL이나 자연어로 이슈를 검색하고(&quot;내가 담당인 In Progress 티켓 보여줘&quot;), 새 티켓 생성, 기존 티켓 업데이트, 상태 전이(To Do &amp;rarr; In Progress &amp;rarr; In Review &amp;rarr; Done), 댓글 달기, Confluence 페이지 검색&amp;middot;생성까지 다 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지만 보면 그냥 &quot;지라를 자연어로 조작하는 도구&quot;처럼 보이지만, 진짜 가치는 &lt;b&gt;이게 코딩 작업과 같은 세션 안에서 일어난다&lt;/b&gt;는 점입니다.&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;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;나: PROJ-1234 티켓 내용 좀 확인해주고, 관련 코드를 봐서 어떻게 구현할지 계획 세워줘

Claude Code:
  &amp;rarr; Atlassian MCP로 PROJ-1234 티켓 본문 + 댓글 조회
  &amp;rarr; 코드베이스에서 관련 파일 검색
  &amp;rarr; 구현 계획을 마크다운으로 출력
  &amp;rarr; &quot;이대로 진행할까요?&quot; 확인

나: 좋아, 진행해줘. 다 되면 티켓 상태를 In Review로 바꾸고,
    git 커밋 스킬 깔려있으면 컨벤션 맞춰서 커밋까지 해줘

Claude Code:
  &amp;rarr; 코드 작성 &amp;rarr; 테스트
  &amp;rarr; [git 커밋 스킬] 컨벤션에 맞춰 커밋 메시지 작성 후 커밋
  &amp;rarr; Atlassian MCP로 티켓 상태 In Review 전이 + 작업 내용 댓글 추가
&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;이런 식으로 여러 플러그인이나 스킬을 한 문장에 섞어서 시킬 수도 있습니다. 지라는 Atlassian 플러그인이, 커밋은 git 커밋 스킬이, 코드 변경은 Claude Code 본체가 처리하는 식으로 각자 역할을 나눠서 동작합니다. 지라를 한 번도 열지 않고 티켓을 닫는 게 가능해집니다.&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;방법 1. /plugin 메뉴에서 골라서 설치 (가장 편함)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 /plugin만 입력하면 마켓플레이스 화면이 열리고, 거기서 atlassian을 찾아 선택할 수 있습니다. 화살표 키로 이동하고 엔터를 누르면 설치가 진행됩니다. 패키지 이름을 외울 필요도 없고, 어떤 플러그인이 있는지 둘러보기에도 좋습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;892&quot; data-origin-height=&quot;154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EAV0F/dJMcahYHrhJ/KAwTatNUMmD6UffXR7etX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EAV0F/dJMcahYHrhJ/KAwTatNUMmD6UffXR7etX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EAV0F/dJMcahYHrhJ/KAwTatNUMmD6UffXR7etX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEAV0F%2FdJMcahYHrhJ%2FKAwTatNUMmD6UffXR7etX1%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;892&quot; height=&quot;154&quot; data-origin-width=&quot;892&quot; data-origin-height=&quot;154&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;&lt;b&gt;방법 2. 명령어로 바로 설치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;/plugin install atlassian@claude-plugins-official
&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;2. Figma 플러그인 &amp;mdash; 디자인 시안을 보고 코드로 옮기기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;무엇을 할 수 있나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Figma 플러그인의 핵심은 &lt;b&gt;Claude Code가 피그마 디자인 파일을 직접 읽어서 코드로 옮길 수 있게 해주는 것&lt;/b&gt;입니다. 디자이너가 만든 시안을 개발자가 눈으로 보고 픽셀 단위로 옮겨 적는 작업을 Claude가 대신해주는 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인에 포함된 대표 커맨드는 /implement-design입니다. 피그마 URL을 주고 이 커맨드를 호출하면, 프로젝트의 디자인 시스템에 맞춰 프레임을 코드로 변환해줍니다.&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;/li&gt;
&lt;li&gt;디자인 변수와 토큰 가져오기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Code Connect&lt;/b&gt;를 통해 피그마 컴포넌트와 실제 코드베이스의 컴포넌트 매핑 &amp;mdash; &quot;기존 Button 컴포넌트를 재사용해서 짜줘&quot;가 가능&lt;/li&gt;
&lt;li&gt;검증용 비주얼 레퍼런스 캡처&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Code Connect 부분이 실무에서 가장 큰 차이를 만듭니다. 단순 변환이면 &quot;또 새로운 Button 컴포넌트 하나 추가&quot;로 끝나서 코드베이스가 지저분해지는데, 디자인 시스템과 매칭되어 있으면 기존 컴포넌트를 정확히 재사용하기 때문에 일관성이 유지됩니다. Figma 공식 문서에서도 &quot;production-ready code with pixel-perfect fidelity&quot;를 목표로 한다고 명시하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인은 Figma의 &lt;b&gt;클라우드(remote) 서버&lt;/b&gt;와 &lt;b&gt;데스크톱 앱&lt;/b&gt; 양쪽 모두에서 동작합니다. 대부분의 경우 remote 버전으로 충분하고, 데스크톱 버전은 특정 엔터프라이즈 요건이 있을 때만 쓰시면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 워크플로우 예시&lt;/h3&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;나: 이 피그마 프레임 좀 봐줘 &amp;rarr; [프레임 링크]
    우리 코드베이스의 design-system을 써서 컴포넌트로 만들어줘

Claude Code:
  &amp;rarr; Figma MCP로 프레임 메타데이터, 토큰, 레이아웃 정보 조회
  &amp;rarr; Code Connect로 우리 코드베이스의 Button, Input 매칭 확인
  &amp;rarr; src/components/에 새 컴포넌트 파일 생성
  &amp;rarr; 기존 Button을 재사용한 코드 작성
  &amp;rarr; Storybook 스토리까지 생성

나: 좋아. 그런데 hover 상태가 시안에 없네. 일반적인 hover 스타일로
    적당히 추가해줘

Claude Code:
  &amp;rarr; 기존 디자인 토큰의 hover 색상 변수 활용해서 스타일 추가
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 &quot;디자이너가 만든 시안의 의도를 코드로 옮기는 작업&quot;이 자연어 몇 줄로 끝난다는 점입니다. 시안을 한 줄 한 줄 보면서 padding 값 측정하고, 색상 코드 복사하고, 컴포넌트 어디에 둘지 고민하는 시간이 사라집니다.&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;방법 1. /plugin 메뉴에서 골라서 설치 (가장 편함)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 /plugin을 입력하면 열리는 마켓플레이스 화면에서 figma를 찾아 선택하면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;133&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGvEWr/dJMcad21wsl/oj9xMn8MzixvKRaTGNXRKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGvEWr/dJMcad21wsl/oj9xMn8MzixvKRaTGNXRKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGvEWr/dJMcad21wsl/oj9xMn8MzixvKRaTGNXRKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGvEWr%2FdJMcad21wsl%2Foj9xMn8MzixvKRaTGNXRKK%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;891&quot; height=&quot;133&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;133&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;&lt;b&gt;방법 2. 명령어로 바로 설치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;/plugin install figma@claude-plugins-official
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;조합해서 사용하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따로도 충분히 강력하지만, 플러그인은 조합해서 사용할 때 강력합니다. 예를 들어, 아틀라시안 + 피그마를 같이 써본다고 가정해봅시다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;나: PROJ-1234 티켓 내용 보고, 첨부된 피그마 링크에서 디자인 가져와서
    구현해줘. 끝나면 PR 만들고 티켓 상태도 In Review로 바꿔줘

Claude Code:
  1. [Atlassian MCP]  PROJ-1234 티켓 + 댓글 + 첨부 링크 조회
  2. [Figma MCP]      해당 피그마 프레임의 컴포넌트, 토큰, 레이아웃 가져오기
  3. [코드베이스]      관련 디렉토리 탐색, 컨벤션 파악
  4. [Code Connect]   재사용 가능한 디자인 시스템 컴포넌트 확인
  5. [구현]            컴포넌트 작성, 스토리북, 테스트
  6. [Git]             브랜치 생성, 커밋, push, PR 작성
  7. [Atlassian MCP]  티켓 상태 In Review로 전이 + PR 링크 댓글 추가
&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;브라우저 탭을 한 번도 안 열고 끝낼 수 있는 흐름입니다. 물론 실제로는 중간중간 사용자가 확인하고 개입해야 하지만, &quot;단순 반복 작업&quot;의 양이 극적으로 줄어듭니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 Claude Code 기반만 정말 잘 써도 많은 부분이 효율적으로 변경되고 있습니다. 거기에 여러 플러그인을 하나씩 얹어보시면, 어느 순간 브라우저 탭이 한 번도 안 열린 채로 작업이 끝나 있는 경험을 하게 됩니다. 그 시점부터가 진짜 효율이 시작되는 지점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인 시스템은 아직 빠르게 진화하고 있어서, 공식 마켓플레이스(anthropics/claude-plugins-official)와 Figma, Atlassian 각각의 공식 문서를 주기적으로 확인하시는 걸 추천드립니다. 새 기능이 매주 단위로 추가되고 있고, 특히 Code Connect처럼 한 번 익혀두면 워크플로우가 크게 바뀌는 기능들이 계속 나오고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 업무에서 가장 아까운 시간은 &quot;맥락을 다시 끌어모으는 시간&quot;입니다. Claude Code와 두 플러그인은 그 시간을 줄이기 위한 가장 빠른 투자라고 생각합니다.&lt;/p&gt;</description>
      <category>업무 메모..</category>
      <category>클로드 코드</category>
      <category>플러그인</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/256</guid>
      <comments>https://white-blank.tistory.com/256#entry256comment</comments>
      <pubDate>Wed, 13 May 2026 19:25:27 +0900</pubDate>
    </item>
    <item>
      <title>Superpowers로 완성하는 AI 네이티브 엔지니어링</title>
      <link>https://white-blank.tistory.com/255</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code, Cursor, Codex 같은 AI 코딩 도구들이 보편화되면서 개발 속도는 비약적으로 빨라졌습니다. 하지만 동시에 우리에게는 새로운 숙제가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;AI가 뱉어낸 이 수많은 코드를 누가, 어떻게 검증할 것인가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;동작하는 것 같다&quot;는 느낌만으로 코드를 머지하는 '바이브 코딩'은 결국 기술 부채로 돌아옵니다. 이 문제를 정면으로 해결하기 위해 등장한 프레임워크가 바로 &lt;b&gt;Superpowers&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Superpowers란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Superpowers는 Jesse Vincent와 Prime Radiant 팀이 만든 오픈소스 &lt;b&gt;에이전틱 스킬 프레임워크&lt;/b&gt;입니다. Claude Code뿐 아니라 Codex, Cursor, Gemini CLI, GitHub Copilot CLI 등 다양한 코딩 에이전트에서 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 단순합니다. AI에게 시니어 개발자의 작업 방식, 즉 &lt;b&gt;&quot;브레인스토밍 &amp;rarr; 설계 &amp;rarr; 계획 &amp;rarr; 구현 &amp;rarr; 리뷰&quot;&lt;/b&gt; 라는 규율을 강제로 주입하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Superpowers는 14개 이상의 구조화된 스킬을 제공하는데, 그중 대표적인 것들이 다음과 같습니다.&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;Brainstorming&lt;/b&gt;: 코드 작성 전, 소크라테스식 질문으로 요구사항을 다듬는 단계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TDD (Red-Green-Refactor)&lt;/b&gt;: 테스트 우선 작성을 협상 불가능한 규칙으로 강제&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Subagent-Driven Development&lt;/b&gt;: 작업을 서브에이전트에 분배하고 2단계 리뷰&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Systematic Debugging&lt;/b&gt;: 근본 원인 분석 기반 디버깅 (3회 수정 실패 시 아키텍처 재검토 트리거)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Git Worktree 관리&lt;/b&gt;: 격리된 브랜치에서 안전하게 작업&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Superpowers는 단순한 &quot;TDD 도구&quot;가 아니라, &lt;b&gt;여러 엔지니어링 규율을 스킬 단위로 모듈화해 AI에게 주입하는 플랫폼&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 왜 'Superpowers'인가? (핵심 강점)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 없이는 코드도 없다 (TDD 강제)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 강력한 기능입니다. AI가 실패하는 테스트(Red)를 작성하기 전에 운영 코드를 쓰면, Superpowers는 그 코드를 &lt;b&gt;삭제하고 다시 시작하게 만듭니다&lt;/b&gt;. 이 강제성 덕분에 AI 생성 코드의 고질병인 &quot;테스트 커버리지 부재&quot; 문제가 해결됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안전장치가 있는 구조화된 디버깅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 실패하면 AI가 무작정 코드를 고치는 게 아니라, 가설을 세우고 검증하는 4단계 디버깅 방법론을 따릅니다. &lt;b&gt;3번 연속 수정에 실패하면 아키텍처 차원의 재검토를 트리거&lt;/b&gt;하므로, &quot;AI가 똑같은 실수를 무한 반복하는&quot; 시나리오를 막아줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;격리된 작업 환경 (Git Worktree)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 작업 브랜치를 더럽히지 않고 백그라운드 워크트리에서 별도로 작업을 수행합니다. 환경 오염 걱정 없이 안전하게 AI에게 일을 맡길 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spec-Driven Development(SDD)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계가 명확하지 않으면 테스트도, 계획도 짤 수 없습니다. 자연스럽게 '일단 짜보고 고치기'가 아닌 '설계 후 구현'이라는 올바른 개발 습관을 AI에게 이식합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 진짜 힘은 '스킬 조합'에서 나온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Superpowers의 가장 매력적인 특징은 &lt;b&gt;확장 가능한 스킬 시스템&lt;/b&gt;입니다. Superpowers가 제공하는 기본 14개 스킬에 더해, 팀에 필요한 커스텀 스킬을 추가로 정의해 조합할 수 있습니다. 두 가지 활용 예시를 소개합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Playwright 기반 E2E 테스트 스킬과의 조합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 TDD 스킬이 함수 단위(유닛 테스트)를 검증한다면, Playwright 스킬을 추가하면 &lt;b&gt;제품 전체의 사용자 흐름&lt;/b&gt;까지 자동 검증할 수 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;사용자가 로그인 후 요약 버튼을 눌렀을 때 결과가 화면에 나타나야 한다&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 시나리오를 던지면, AI는 Superpowers의 TDD 규율 위에서 Playwright 테스트를 먼저 작성하고, 실제 브라우저에서 테스트가 통과할 때까지 자율적으로 코드를 수정합니다. &lt;b&gt;유닛 테스트 &amp;rarr; E2E 테스트가 모두 그린(Green)&lt;/b&gt; 이 되어야 작업이 완료된 것으로 간주됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 컨벤션(Convention) 스킬&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀만의 &lt;b&gt;프로젝트&amp;nbsp;아키텍처&lt;/b&gt;, &lt;b&gt;Named Export 규칙&lt;/b&gt;, &lt;b&gt;네이밍 컨벤션&lt;/b&gt; 같은 규약을 스킬 파일로 정의해두면, AI는 단순히 돌아가는 코드를 짜는 것을 넘어 &lt;b&gt;팀의 코드 스타일과 폴더 구조를 완벽히 준수&lt;/b&gt;하면서 테스트를 통과시킵니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  핵심은 이것입니다. Superpowers 자체가 &quot;정답&quot;을 주는 게 아니라, &lt;b&gt;팀의 정답을 스킬로 코드화하면 AI가 그 규율을 따라준다&lt;/b&gt;는 점입니다. 우리 팀만의 시니어 엔지니어를 만드는 셈이죠.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 외에도 디자인 시스템 준수, API 스펙 검증, 보안 체크리스트 등 팀의 필요에 따라 무한히 확장할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실무 도입 시 고려할 점&lt;/h2&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;: 아주 사소한 UI 수정조차 브레인스토밍과 테스트 절차를 거치므로, 마감이 급한 핫픽스 상황에서는 거추장스럽게 느껴질 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경 세팅의 중요성&lt;/b&gt;: Vitest, Playwright 등 테스트 도구가 프로젝트에 잘 세팅되어 있어야 합니다. AI가 테스트를 돌릴 줄 모르면 Superpowers의 위력은 반감됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;초기 학습 비용&lt;/b&gt;: 팀의 컨벤션을 스킬로 문서화하는 초기 작업이 필요합니다. 다만 이건 일회성 투자에 가깝습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 코드를 많이 생성하는 AI의 시대는 지났습니다. 이제는 &quot;내가 믿고 맡길 수 있는 동료&quot;가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Superpowers는 AI에게 '책임감'과 '검증'이라는 엔지니어의 핵심 덕목을 부여합니다. 그리고 진정한 가치는 &lt;b&gt;기본 스킬 위에 우리 팀만의 스킬을 쌓아 올릴 때&lt;/b&gt; 나옵니다. Playwright를 통한 실질적인 브라우저 검증부터 팀 내 아키텍처 컨벤션까지, 다양한 스킬을 조합해보세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분의 AI는 이제 단순한 코더를 넘어, &lt;b&gt;우리 팀의 아키텍처를 이해하고 스스로 품질을 증명하는 시니어 엔지니어&lt;/b&gt;가 될 것입니다.&lt;/p&gt;</description>
      <category>개발..</category>
      <category>AI에이전트</category>
      <category>Superpowers</category>
      <category>TDD</category>
      <category>엔지니어링</category>
      <category>프론트엔드</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/255</guid>
      <comments>https://white-blank.tistory.com/255#entry255comment</comments>
      <pubDate>Mon, 11 May 2026 19:35:34 +0900</pubDate>
    </item>
    <item>
      <title>모노레포에서 CSS Layer 구조 설계하기</title>
      <link>https://white-blank.tistory.com/254</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js + Turborepo 기반 모노레포를 운영하다 보면 결국 CSS 구조를 다시 고민하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 이 글은 &lt;b&gt;Tailwind v4를 기본으로 사용하는 프로젝트&lt;/b&gt; 기준으로 정리한 내용입니다. Tailwind v4부터는 native CSS Cascade Layers를 사용하기 때문에, @layer 기반 설계가 자연스럽게 어울립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 단순히:&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;packages/ui
&lt;/code&gt;&lt;/pre&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;globals.css import 순서 꼬임&lt;/li&gt;
&lt;li&gt;Tailwind utility override 충돌&lt;/li&gt;
&lt;li&gt;서비스별 스타일 override&lt;/li&gt;
&lt;li&gt;디자인 토큰 관리&lt;/li&gt;
&lt;li&gt;앱별 브랜딩&lt;/li&gt;
&lt;li&gt;B2B SaaS 멀티 테넌트 테마 대응&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히:&lt;/p&gt;
&lt;pre class=&quot;nsis&quot;&gt;&lt;code&gt;apps/web
apps/admin
apps/landing
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처럼 여러 앱이 공통 UI를 공유하기 시작하면 CSS 우선순위 관리가 굉장히 중요해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Next.js App Router + Turborepo + Tailwind v4 기준으로, 제가 직접 고민하면서 정리한 CSS Layer 구조를 공유합니다.&lt;/p&gt;
&lt;h1&gt;왜 기존 CSS 방식이 문제가 되는가&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 CSS는 import 순서 기반입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;.button {
  color: blue;
}

.button {
  color: red;
}
&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;나중에 import 된 red가 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 앱에서는 큰 문제가 아닐 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 모노레포에서는 아래와 같이 여러 패키지와 앱이 CSS를 공유하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;packages/ui
packages/design-system
apps/web
apps/admin
&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 CSS가 마지막에 번들되는지&lt;/li&gt;
&lt;li&gt;어떤 스타일이 override 되는지&lt;/li&gt;
&lt;li&gt;Tailwind utility가 왜 안 먹는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예측하기 어려워집니다. (실제로 운영하다보면 갑작스러운 css 충돌로 인한 버그를 만나게 되죠;)&lt;/p&gt;
&lt;h1&gt;CSS Layer(@layer)란?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS Cascade Layer는 CSS 우선순위를 &quot;import 순서&quot; 대신 &quot;레이어 순서&quot;로 제어하는 기능입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer theme, base, components, utilities;
&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;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;uarr; 강함
utilities

components
&amp;uarr;

base
&amp;uarr;

theme
&amp;darr; 약함&lt;/code&gt;&lt;/pre&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;theme &amp;rarr; 디자인 토큰&lt;/li&gt;
&lt;li&gt;base &amp;rarr; reset 및 기본 스타일&lt;/li&gt;
&lt;li&gt;components &amp;rarr; 공통 UI&lt;/li&gt;
&lt;li&gt;utilities &amp;rarr; override 및 유틸성 스타일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 명확하게 분리할 수 있습니다.&lt;/p&gt;
&lt;h1&gt;중요한 포인트: @layer 밖의 CSS가 더 강합니다&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 많이 헷갈리는 부분이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;unlayered CSS &amp;gt; layered CSS
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@layer 안에 들어간 CSS(layered)보다 @layer 밖에서 선언된 CSS(unlayered)가 우선순위가 높습니다.&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;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer base {
  body {
    background: black;
  }
}

body {
  background: red;
}
&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;최종적으로는 red가 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 이건 &lt;b&gt;specificity가 같을 때&lt;/b&gt;의 이야기입니다. unlayered라고 해서 specificity를 무시하는 건 아니고, 같은 조건이면 unlayered가 layered를 이긴다는 의미입니다.&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;layered CSS = 구조화된 CSS&lt;/li&gt;
&lt;li&gt;unlayered CSS = 명시적 override&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;아래와 같은 원칙으로 가지고 갑니다.&lt;/p&gt;
&lt;pre class=&quot;gml&quot;&gt;&lt;code&gt;가능하면 모든 CSS를 layer 안에서 관리한다
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;unlayered CSS가 섞이기 시작하면 &quot;왜 이게 안 먹지?&quot; 하는 순간이 옵니다.&lt;/p&gt;
&lt;h1&gt;모노레포 구조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 잡은 구조는 아래와 비슷합니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;apps/
  web/
  admin/
  landing/

packages/
  ui/
  design-system/
&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 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;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ui&lt;/td&gt;
&lt;td&gt;React Component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;design-system&lt;/td&gt;
&lt;td&gt;CSS / token / theme&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apps&lt;/td&gt;
&lt;td&gt;실제 서비스 조합 및 utility/page-level override&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;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;packages = framework
apps = composition
&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 data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 중요하게 생각한 원칙이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;디자인 토큰은 design-system에서만 정의한다 (Single Source of Truth)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;apps는 토큰을 &lt;b&gt;소비&lt;/b&gt;할 뿐, &lt;b&gt;재정의하지 않습니다&lt;/b&gt;. 만약 apps마다 --primary를 다시 정의하기 시작하면 그건 이미 디자인 시스템이 아니라 그냥 변수일 뿐입니다.&lt;/p&gt;
&lt;h1&gt;packages/design-system 구조&lt;/h1&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;packages/design-system/
  styles/
    index.css
    theme.css
    base.css
    components.css
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;index.css&lt;/h1&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer theme, base, components, utilities;

@import &quot;./theme.css&quot;;
@import &quot;./base.css&quot;;
@import &quot;./components.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 layer 우선순위를 먼저 고정합니다.&lt;/p&gt;
&lt;h1&gt;Theme Layer&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 토큰은 가장 약해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer theme {
  :root {
    --background: #ffffff;
    --foreground: #111111;

    --primary: oklch(0.62 0.18 250);
    --radius: 12px;
  }
}
&lt;/code&gt;&lt;/pre&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;semantic color&lt;/li&gt;
&lt;li&gt;spacing&lt;/li&gt;
&lt;li&gt;radius&lt;/li&gt;
&lt;li&gt;typography&lt;/li&gt;
&lt;li&gt;motion token&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;멀티 테넌트나 화이트라벨링이 필요하다면, &lt;b&gt;앱이 토큰을 재정의하는 게 아니라&lt;/b&gt; design-system에서 미리 정의된 테마를 스코프로 분리해두고 앱은 선택해서 쓰는 방식으로 가져가려고 합니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer theme {
  :root,
  [data-theme=&quot;default&quot;] {
    --primary: oklch(0.62 0.18 250);
  }

  [data-theme=&quot;admin&quot;] {
    --primary: oklch(0.5 0.2 30);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;// apps/admin/app/layout.tsx
&amp;lt;html data-theme=&quot;admin&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 토큰의 단일 출처는 그대로 유지되면서 앱별 브랜딩이 가능합니다.&lt;/p&gt;
&lt;h1&gt;Base Layer&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reset 및 기본 HTML 스타일입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer base {
  html,
  body {
    max-width: 100vw;
    overflow-x: hidden;
  }

  * {
    border-color: var(--border);
    outline-color: color-mix(
      in oklab,
      var(--ring) 50%,
      transparent
    );
  }

  body {
    background-color: var(--background);
    color: var(--foreground);
    font-family: var(--font-pretendard), sans-serif;
  }

  a {
    color: inherit;
    text-decoration: none;
  }
}
&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;pre class=&quot;d&quot;&gt;&lt;code&gt;html/body 같은 기본 스타일도 @layer 밖이 아닌
@layer base 안에 두어야 합니다
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 @layer 밖의 unlayered CSS는 layered CSS보다 우선순위가 높아서, base가 의도치 않게 utility까지 이겨버릴 수 있기 때문입니다.&lt;/p&gt;
&lt;h1&gt;Components Layer&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 UI 컴포넌트 스타일입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer components {
  .btn {
    display: inline-flex;
    align-items: center;
    border-radius: var(--radius);
  }

  .card {
    border-radius: var(--radius);
    border: 1px solid var(--border);
  }
}
&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;pre class=&quot;maxima&quot;&gt;&lt;code&gt;component style은 utility보다 약해야 합니다
&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;그래야 Tailwind utility override가 자연스럽게 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 한 가지 유용한 패턴은 :where()를 사용해 specificity를 0으로 낮추는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer components {
  :where(.btn) {
    border-radius: var(--radius);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;:where()는 안에 들어간 selector의 specificity를 0으로 만들기 때문에, 같은 layer 안에서도 override가 매우 쉬워집니다. 컴포넌트를 다른 곳에서 사용할 때 별도 selector 없이도 쉽게 덮을 수 있게 해주는 패턴입니다.&lt;/p&gt;
&lt;h1&gt;Utilities는 어디에 둬야 할까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 모노레포에서 중요한 설계 포인트가 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 utilities는:&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;packages 보다 apps 에 두는 것이 맞다
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고 판단했습니다.&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. 성격상 서비스에 종속됩니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;utilities는&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;실험성 UI&lt;/li&gt;
&lt;li&gt;페이지 전용 effect&lt;/li&gt;
&lt;li&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;&lt;b&gt;2. 구조적으로도 마지막 layer라 apps에 두는 게 맞습니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;utilities가 가장 강한 layer이기 때문에, 이걸 design-system 같은 공통 패키지에 두면 정작 앱이 override할 layer가 없어집니다. 결국 apps에서 unlayered CSS로 강제로 덮어야 하는데, 그 순간 layer 전략 자체가 무너집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;apps에 utilities를 두면 자연스럽게 가장 마지막 layer가 되면서 override 흐름이 깔끔해집니다.&lt;/p&gt;
&lt;h1&gt;정리한 구조&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;packages/design-system&lt;/h2&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;theme
base
components
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;apps/web&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;utilities
page effect
service override
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &quot;service override&quot;는 &lt;b&gt;토큰을 바꾸는 게 아니라&lt;/b&gt;, design-system이 제공한 토큰을 사용해 페이지/섹션 단위 스타일을 작성한다는 의미입니다.&lt;/p&gt;
&lt;h1&gt;실제 예시&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;packages/design-system&lt;/h2&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer components {
  .btn {
    border-radius: 12px;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;apps/web&lt;/h2&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@layer utilities {
  .hero-dot-grid {
    background-image: radial-gradient(
      circle,
      rgba(255, 255, 255, 0.055) 1px,
      transparent 1px
    );

    background-size: 28px 28px;
  }

  .hero-brand-glow {
    background: radial-gradient(
      ellipse at center bottom,
      color-mix(
        in oklch,
        var(--brand-600) 28%,
        transparent
      ) 0%,
      transparent 60%
    );
  }
}
&lt;/code&gt;&lt;/pre&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;landing 전용&lt;/li&gt;
&lt;li&gt;marketing 전용&lt;/li&gt;
&lt;li&gt;app branding 전용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성격이라 공통 패키지에 넣지 않았습니다.&lt;/p&gt;
&lt;h1&gt;Tailwind v4와의 관계&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind v4는 내부적으로 native CSS Cascade Layers를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;theme
&amp;darr;
base
&amp;darr;
components
&amp;darr;
utilities
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;&amp;lt;Button className=&quot;bg-red-500&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처럼 utility class가 component style을 자연스럽게 override할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 설계한 layer 구조는 Tailwind v4의 layer 체계와 그대로 정렬됩니다. 별도 레이어를 새로 만들 필요 없이, Tailwind가 깔아둔 레이어 위에 디자인 시스템을 얹는 구조가 됩니다.&lt;/p&gt;
&lt;h1&gt;Next.js App Router에서 Import&lt;/h1&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;import &quot;@repo/design-system/styles/index.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;global css는 반드시:&lt;/p&gt;
&lt;pre class=&quot;glsl&quot;&gt;&lt;code&gt;app/layout.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 root level에서 import 해야 합니다.&lt;/p&gt;
&lt;h1&gt;마무리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js + Turborepo 모노레포에서 CSS 구조는 단순한 스타일 문제가 아니라 아키텍처 문제에 가깝다고 느꼈습니다.&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;/li&gt;
&lt;li&gt;멀티 앱&lt;/li&gt;
&lt;li&gt;B2B SaaS&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;구조로 갈수록 CSS Layer 전략이 중요해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 잡은 방향은&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;packages
  -&amp;gt; theme/base/components (토큰의 단일 출처)

apps
  -&amp;gt; utilities/page-level override (토큰을 소비)
&lt;/code&gt;&lt;/pre&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;그리고 아래와 같이 layer 안으로 관리하는 것이 훨씬 안정적인 구조를 만들 수 있을 것 입니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;모든 CSS를 @layer 안에서 관리
&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;unlayered CSS가 섞이기 시작하는 순간 우선순위 디버깅이 다시 어려워지기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개발..</category>
      <category>css</category>
      <category>Layer</category>
      <category>Monorepo</category>
      <author>링재호</author>
      <guid isPermaLink="true">https://white-blank.tistory.com/254</guid>
      <comments>https://white-blank.tistory.com/254#entry254comment</comments>
      <pubDate>Fri, 8 May 2026 14:58:49 +0900</pubDate>
    </item>
  </channel>
</rss>