본문으로 건너뛰기

아키텍처

intro에서 guardrail 세 개를 짚었다. 여기서는 그 세 개가 코드에서 어떻게 생겼는지를 본다. FSD 6레이어 의존 방향, 메시지 타입 단일 파일, 백엔드 어댑터. 셋 다 "AI가 임의로 가로지르지 못하게" 빌드 또는 타입이 막아주는 자리다.

FSD 6레이어 의존 방향

폴더는 위에서 아래로 의존이 흐른다. 화살표 반대로 import 하면 빌드가 깨진다.

각 레이어의 자리는 이렇게 갈린다.

  • shared: 도메인 무관한 유틸, UI 키트, 메시지 타입, 어댑터 인터페이스. 어디서나 가져다 쓸 수 있고, 자기 자신만 의존한다.
  • entities: "수집한 페이지", "백엔드 설정" 같은 도메인 모델. discriminated union 으로 잡는 자리.
  • features: 사용자 동작 한 단위. "현재 페이지 분석", "결과 백엔드로 전송", "백엔드 모드 변경".
  • widgets: feature 여러 개를 묶은 화면 블록. popup 헤더, sidePanel 본문 같은 것.
  • pages: popup, options, sidePanel 같은 진입 화면 단위.
  • app: 진입점 글루. 라우팅, provider, manifest 와 React 트리를 잇는 자리.

eslint-plugin-boundaries가 위 화살표를 강제한다. 역방향 import는 물론, 같은 레이어 안에서 슬라이스끼리 가로지르는 것도 막는다. features/analyze-page에서 features/send-to-backend를 직접 import 하면 빌드 실패. 슬라이스 바깥에서 들어가는 유일한 통로는 그 슬라이스의 index.ts 다.

AI에게 코드를 맡길 때 어느 레이어에 둘지를 모델이 직접 고르게 두면 십중팔구 features와 widgets 사이를 흐릿하게 섞는다. 슬라이스 이름까지 사람이 지정해서 주는 편이 안전하다. "features/configure-backend에 옵션 폼 컴포넌트 추가" 식으로.

의존 방향을 이 모양으로 잡은 근거는 docs/adr/0001-fsd-import-direction.md에 한 장 들어있다. 코드는 무엇을 했는지를, ADR은 왜 그랬는지를 들고 있는다.

메시지 타입 단일 파일

content script와 background, popup 사이로 오가는 모든 메시지는 shared/lib/messaging/messages.ts 한 장에 정의된 discriminated union을 통과한다.

export type Message =
| { type: 'analyze-page'; tabId: number }
| { type: 'send-result'; payload: PageResult }
| { type: 'config-changed'; mode: BackendMode }

송신은 chrome.runtime.sendMessage(msg) 또는 chrome.tabs.sendMessage(tabId, msg) 에서 같은 union 으로 type 좁힘이 따라온다. 수신은 chrome.runtime.onMessage.addListener 안에서 switch (msg.type) 로 분기한다.

새 메시지를 추가할 때 절차는 messages.ts 에 union 가지를 한 줄 더 적는 것뿐이다. 송신 쪽과 수신 쪽 양쪽이 동시에 컴파일 깨지면서 강제로 같이 갱신된다. 같은 페이로드 모양이 두 군데에 따로 정의되는 사고가 여기서 막힌다.

popup은 background를 향해서만 말을 건다. content script와 popup이 직접 붙는 경로는 의도적으로 비워뒀다. 그 자리는 늘 background를 거치게 만들어야 service worker가 깨어나고, 권한 검사와 어댑터 호출이 한 곳에 모인다.

백엔드 어댑터 전략

shared/api/backend/에 어댑터 인터페이스 한 개와 구현 셋이 들어있다.

shared/api/backend/
├── index.ts 공개 진입점, factory 포함
├── types.ts Backend 인터페이스, BackendMode union
├── console-log.ts 콘솔에 페이로드만 찍는다
├── supabase-direct.ts Supabase로 바로 쏜다
└── server-relay.ts 자체 서버를 거친다

호출하는 쪽 (feature 또는 entity) 은 어떤 모드인지 모르고 backend.send(payload) 만 부른다. 모드 결정은 옵션 페이지에서 하고 chrome.storage.sync 에 저장된다. factory가 그 값을 읽어 인스턴스를 고른다.

이 어댑터의 본 목적은 백엔드가 정해지지 않은 단계에서 UI 작업을 시작할 수 있게 하는 것이다. 첫날은 console-log 모드로 페이로드 모양만 검증하고, Supabase 테이블이 잡힌 다음 supabase-direct 로 바꾼다. 호출 코드는 한 줄도 안 건드린다.

어댑터를 새로 늘릴 때는 types.tsBackendMode union 에 가지를 한 줄 추가하고, 같은 인터페이스를 구현한 파일을 shared/api/backend/ 에 한 장 더 둔다. factory의 switch 가 컴파일 시점에 누락을 잡는다.

다음

다음은 build-and-package. manifest.config.ts가 어떻게 manifest.json을 생성하는지, Vite + @crxjs/vite-plugin 흐름이 dev에서 어떻게 변경 감지를 도는지, pnpm run buildpnpm run package가 각각 무엇을 떨구는지를 본다.