0008. 프로세스 안쪽을 도메인 폴더로 나누고, 폴더 사이의 의존 방향을 eslint로 검사한다
- 상태: 채택
- 날짜: 2026-09-27
- 관련: eslint.config.mjs, src/main/ipc/index.ts, stablyai/orca
코드가 늘면서 세 가지 불편이 쌓였다. 첫째, 의존성 방향을 찾기 어렵다. 모듈이 폴더가 아니라 파일 이름 접두어(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)를 두지 않는다.
main 폴더
섹션 제목: “main 폴더”| 폴더 | 책임 |
|---|---|
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 |
main 의존 방향
섹션 제목: “main 의존 방향”아래 층은 위 층을 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다.
renderer 폴더
섹션 제목: “renderer 폴더”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/ |
renderer 의존 방향
섹션 제목: “renderer 의존 방향”| 층 | 모듈 | 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 문자열의 폴더 이름으로 판단한다. 같은 폴더 안의
./ximport는 검사하지 않으므로, 폴더 안의 파일 단위 순환은 리뷰에서 확인한다. - renderer에서
@renderer별칭을 쓰기 시작하면, 금지 패턴에@renderer/<폴더>/**도 더해야 한다. - 파일 길이를 lint로 제한하지 않는다.
- plan 권한 폴더(
~/.belloga/plans)는plan/planHookInstall.ts의PLAN_DIRECTORY와plan/planContext.ts의 경로 조립에 중복으로 남는다. 두 값을 하나로 합치는 일은 이 결정의 범위 밖이다.