콘텐츠로 이동

0008. 프로세스 안쪽을 도메인 폴더로 나누고, 폴더 사이의 의존 방향을 eslint로 검사한다

코드가 늘면서 세 가지 불편이 쌓였다. 첫째, 의존성 방향을 찾기 어렵다. 모듈이 폴더가 아니라 파일 이름 접두어(plan*, claude*, git*)로만 구분되고, 방향을 강제하는 장치가 없다.

둘째, src/main/에 파일 45개가 평평하게 놓여 있어 어느 파일이 어느 책임에 속하는지 이름으로만 짐작해야 한다. 셋째, renderer의 App.tsx가 1075줄로 커져 있고, common/·hooks/처럼 무엇이든 들어갈 수 있는 폴더가 있다.

main에는 예외가 두 가지 있었다. appMenu.ts가 언어 상태와 메뉴 IPC 등록까지 맡아서 메뉴와 무관한 모듈이 언어를 얻으려고 메뉴에 의존했다. 또 claudeHooks.ts가 plan 훅 스크립트를 가져다 설치해서 claude 모듈이 plan 경로와 스크립트 이름을 알고 있었다.

main, renderer 각 프로세스의 안쪽을 도메인 폴더로 나누고, 폴더 사이에 허용하는 의존 방향을 표로 정해 eslint no-restricted-imports로 검사한다. 프로세스 경계(main / preload / renderer / shared)는 그대로 둔다. 파일 이름은 바꾸지 않고, 폴더마다 index.ts(barrel)를 두지 않는다.

폴더 책임
persistence/ userData 아래 JSON 파일을 읽고 쓴다
backend/ 앱이 붙는 서버 환경(local, qa, prod)의 주소와 client id, 서버 요청
account/ GitHub Device Flow 로그인, Belloga 세션 토큰의 암호화 보관과 상태
i18n/ 메인 프로세스 몫의 언어 상태와 그 저장
window/ 앱 메뉴, 모든 창에 보내기, 창 위치 저장과 화면 안으로 옮기기
external/ 앱 밖(VS Code, 브라우저)으로 넘기기
notifications/ OS 알림 표시와 알림 설정 화면 주소
git/ git 명령 실행과 결과 해석, .git 메타데이터 감시
connections/ 연결한 폴더 목록과, 그 안의 저장소·워크트리 발견
files/ 파일 목록·읽기·쓰기·휴지통, 연결 밖 경로 차단, 폴더 감시
plan/ plan 폴더 위치 계산, 방식 설정, 틀, 세션 시작 훅 스크립트의 내용과 설치
claude/ Claude Code 설정 파일 읽기·쓰기와 병합, 세션 상태 훅과 statusLine 설치, 훅 이벤트로 상태 만들기
terminal/ 셸 세션을 띄우고 끈다
ipc/ 채널 등록만 한다
(최상위) index.ts, env.d.ts

아래 층은 위 층을 import하지 않는다. 같은 층끼리는 표에 적힌 것만 import한다. 모든 모듈은 @shared를 쓸 수 있다.

층 모듈 import해도 되는 모듈
0 persistence, backend (없음)
1 i18n persistence
1 account persistence, backend
2 window i18n, persistence
2 git, notifications, claude (없음)
2 external i18n
3 connections git, persistence
3 terminal claude
4 files connections, git, i18n
5 plan files, git, i18n, persistence, claude
8 ipc 전부
9 index.ts 전부
  • 여러 도메인을 잇는 조율(예: 워크트리를 만들 때 plan을 심고, 지울 때 셸을 먼저 끄는 일)은 ipc/가 맡는다.
  • 언어 상태는 i18n/lang.ts가 갖고, 메뉴 IPC 등록은 ipc/menu.ts가 갖는다. window/appMenu.ts는 메뉴를 만들고 다시 만드는 일만 한다.
  • plan 훅에 관한 것(스크립트 이름, 이벤트, 권한 폴더, 설치·해제·상태)은 모두 plan/planHookInstall.ts에 둔다. claude/는 설정 파일 접근(claude/claudeSettingsFile.ts)과 세션 상태 훅만 안다. 그래서 방향은 plan → claude다.

src/renderer/src/ 아래의 폴더다.

폴더 책임
ui/ 도메인을 모르는 화면 부품
lib/ 도메인을 모르는 도구
features/files/ 파일 트리와 편집
features/worktrees/ 저장소·워크트리 트리, 워크트리 추가·삭제
features/git/ 변경 목록, diff, 스테이징, 커밋
features/plan/ plan 배지, 설정, 첫 설정 모달
features/account/ 계정 로그인 상태 구독과 계정 설정 화면
workspace/ 탭, pane 레이아웃, 문서와 터미널 표시, 탭 묶음 단위
features/claude/ Claude 세션 상태 구독과 알림
features/harness/ 하네스 모드의 트리와 하네스 화면
features/settings/ 설정 창
features/sidebar/ 왼쪽 사이드바
app-shell/ 앱 뼈대와 조립용 훅
(최상위) App.tsx, main.tsx, lang.ts, env.d.ts, tokens.css, styles/
층 모듈 import해도 되는 것
0 ui, lib, lang.ts 같은 층
1 features/files, features/worktrees, features/git, features/plan, features/account 0층 (서로는 import하지 않음)
2 workspace 0층, features/git
3 features/claude, features/harness 0~2층 (서로는 import하지 않음)
4 features/settings 0~3층
5 features/sidebar 0~4층
6 app-shell 0~5층
7 App.tsx, main.tsx 전부
  • Claude 상태를 모으는 claudeStatusSummary와 탭 제목에 쓰는 claudeStatusLabel은 workspace/에 둔다. pane과 탭을 훑는 코드라 features/claude로 옮기면 workspace ↔ features/claude 순환이 생긴다.
  • 탭 묶음 열쇠를 판단하는 worktreeLookup도 workspace/에 둔다. 쓰는 곳이 탭 묶음 코드뿐이다.
  • ProjectLayout 타입은 컴포넌트 파일이 아니라 workspace/paneLayout.ts에 둔다. 순수 함수 파일이 컴포넌트 파일에 기대지 않게 하려는 것이다.
대상 파일 금지 이유
src/main/** 중 ipc/**와 index.ts 밖 electron의 ipcMain IPC 등록은 ipc/에만 둔다
main 각 모듈 폴더 main 의존 방향 표에서 허용하지 않은 모듈 폴더 main 의존 방향
renderer 각 층 폴더 renderer 의존 방향 표에서 허용하지 않은 층의 폴더 renderer 의존 방향
src/renderer/** electron, node:* renderer는 preload가 넘겨준 API로만 main과 이야기한다
src/shared/** electron, node:*, **/main/**, **/renderer/**, **/preload/** shared는 세 프로세스가 모두 쓰므로 어느 쪽에도 기대지 않는다

참고 사례로 stablyai/orca를 조사했다. orca는 TS 코드가 belloga의 약 110배라서, 규모 때문에 생긴 장치는 빼고 작은 앱에서도 효과가 있는 관례만 가져왔다.

orca 관례 적용
main을 도메인 폴더(git/, pty/, notifications/, window/ 등)로 나눔 채택. 폴더 이름도 구체적인 도메인 이름을 쓴다
도메인 폴더는 ipcMain을 모르고, ipc/<domain>.ts가 등록만 함 채택. belloga의 예외는 appMenu.ts의 메뉴 IPC 등록 하나였다
renderer 루트 App.tsx를 얇게 두고 셸 컴포넌트는 app-shell/에 둠 채택
common, utils, helpers, misc 같은 폴더 이름 금지 채택
barrel을 거의 쓰지 않음 채택. 파일을 직접 import해야 의존이 보인다
테스트를 대상 옆에 둠 유지. belloga도 이미 이렇게 한다
경계 검사 테스트(약 100개) 방식만 바꿔 채택. 테스트 대신 eslint no-restricted-imports로 검사한다
파일 길이 lint 가져오지 않음. 긴 파일은 리뷰에서 본다
kebab-case 파일 이름 가져오지 않음. belloga는 camelCase 파일 이름을 유지하고, 여러 단어 폴더만 kebab-case로 쓴다
큰 도메인을 동작 단계별 하위 폴더로 나눔 가져오지 않음. belloga 도메인은 가장 큰 것도 파일 5~6개다
preload를 도메인별 파일로 나눔, 단일 store, 채널 이름 일치 테스트 가져오지 않음. 규모에 비해 비용이 크다

그 밖에 검토한 대안은 다음과 같다.

  • 역할별 구조를 유지하고 큰 파일만 쪼갠다. 변경이 가장 작다. 그러나 접두어로만 모듈을 구분하는 상태가 그대로라 의존 방향을 찾기 어렵다는 불편이 남는다.
  • eslint-plugin-import의 no-cycle로 순환을 잡는다. 파일 단위 순환까지 잡지만 새 의존성을 들여야 한다. 층 표를 지키면 모듈 단위 순환은 구조적으로 생기지 않으므로 기각했다.
  • orca처럼 경계를 테스트로 검사한다. 규칙을 코드로 자유롭게 쓸 수 있지만, 이미 쓰는 eslint로 충분히 표현되므로 기각했다.
  • claude와 plan 사이를 지금 방향(claude → plan)으로 둔다. 변경이 가장 작다. 그러나 claude 모듈이 plan 경로와 plan 훅 스크립트 이름을 계속 알아야 해서 고르지 않았다.
  • 새 모듈 폴더를 만들면 이 기록의 층 표와 eslint.config.mjs의 모듈 표를 함께 고쳐야 한다.
  • flat config에서는 한 파일에 같은 규칙을 거는 블록이 여럿이면 마지막 블록의 옵션만 남는다. 그래서 모듈 방향 금지와 ipcMain 금지, 프로세스 경계 금지를 파일마다 한 블록에 합쳐 만든다.
  • 규칙은 import 문자열의 폴더 이름으로 판단한다. 같은 폴더 안의 ./x import는 검사하지 않으므로, 폴더 안의 파일 단위 순환은 리뷰에서 확인한다.
  • renderer에서 @renderer 별칭을 쓰기 시작하면, 금지 패턴에 @renderer/<폴더>/**도 더해야 한다.
  • 파일 길이를 lint로 제한하지 않는다.
  • plan 권한 폴더(~/.belloga/plans)는 plan/planHookInstall.ts의 PLAN_DIRECTORY와 plan/planContext.ts의 경로 조립에 중복으로 남는다. 두 값을 하나로 합치는 일은 이 결정의 범위 밖이다.