파일 5,868개짜리 저장소를 열어봤다 — Git GUI 개발기: 0단계와 디자인
계획 편에서 멈추지 않았어요. 열기→변경→diff→저장까지 수직으로 뚫고, 디자인 토큰과 픽토그램을 입혔습니다. 바뀐 파일 5,868개짜리 극단 케이스를 열어본 순간까지 — Git GUI 2편이에요.

일부러 제일 험한 브랜치를 골라서 열어봤어요. 대규모 마이그레이션이 걸려 있어서 바뀐 파일 5,868개, 저장 예정 3,126개 — 평소엔 절대 안 나오는 숫자죠. 화면이 떴습니다.
1편에서 "대형 저장소에서도 점진적으로 반응해야 한다"를 제품 원칙으로 적어뒀으니, 언젠가 마주칠 최악의 케이스를 미리 들이밀어 본 거예요. 이번 편은 계획이 아니라 진짜 만든 이야기입니다.
5,868개를 열었을 때 실제로 뭘 봤나
"화면이 떴습니다"로 넘어가기엔 아까운 순간이라 조금 더 적어 볼게요. 이 규모에서 깨지는 건 대개 세 군데예요.
| 깨지는 지점 | 증상 | 우리가 한 것 |
|---|---|---|
| git 출력 파싱 | 5,868줄을 한 번에 문자열로 받아 멈춤 | 스트리밍으로 읽으면서 점진 파싱 |
| 목록 렌더 | DOM 노드 수천 개 | 보이는 만큼만 렌더 |
| 상태 갱신 | 파일 하나 바뀔 때 전체 재계산 | 변경분만 갱신 |
| 스크롤 | 행 높이 계산이 매 프레임 | 고정 높이로 고정 |
제일 먼저 무너진 건 세 번째였어요. 목록을 가상화해도 상태 갱신이 전체를 훑으면 소용이 없거든요. 화면은 60프레임인데 그 뒤에서 5,868개를 매번 도는 거죠.
그래서 목록을 그리는 문제가 아니라 상태를 갱신하는 문제였습니다. 이 순서를 착각하면 가상화만 붙이고 여전히 버벅이는 앱이 나와요.
어디까지 왔나
| 구간 | 상태 |
|---|---|
| 0단계 기반 — 열기, 변경 파일, diff, stage/unstage, commit | 완료 |
| E0-1 — 디자인 토큰, 픽토그램, 정식 3열 레이아웃 | 완료 |
| E0-2 — 저장된 역사(log), 백업(push), 메시지 자동 제안 | 진행 중 |
처음 화면은 이랬어요.

일부러 이 순서로 갔어요. HTML 민짜라도 저장소 열기 → 상태 감지 → diff → stage → commit이 계층을 관통해서 동작하는 것부터 확인하고, 디자인은 그 위에 입히는 거죠. 예쁜데 안 돌아가는 화면보다, 못생겨도 돌아가는 수직 한 줄이 먼저니까요.
계층부터 세웠어요
1편에서 그린 모노레포 구조 그대로예요. domain(순수 TS 상태 모델) ← git-adapter(porcelain v2 파서) ← git-process(spawn·취소·환경 격리)가 main 프로세스에 있고, renderer는 타입이 정의된 IPC 계약으로만 통신해요.
Electron 쪽은 보안 기본값을 처음부터 박았어요 — sandbox 명시, contextIsolation, 네비게이션 차단. IPC는 allowlist + 인자 검증이라 모르는 채널이나 이상한 인자는 거부합니다.
계층을 이렇게 나눈 게 이번 편의 뼈대예요.
packages/domain # 순수 TS — Electron도 git도 모릅니다
packages/git-adapter # git 출력 → 도메인 모델 변환
packages/git-process # 실행 · 취소 · 스트리밍 · 환경 격리
packages/ipc-contract # renderer ↔ main 타입 계약
apps/desktop # 화면
# 화면은 git 을 직접 부르지 않습니다.
# 계약에 없는 채널은 열리지 않습니다.| 계층 | 아는 것 | 모르는 것 |
|---|---|---|
| domain | 상태 모델 · 정책 | git도 Electron도 모름 |
| git-adapter | git 출력 형식 | 화면도 프로세스 관리도 모름 |
| git-process | 실행과 취소 | 출력의 의미를 모름 |
| renderer | 계약과 화면 | Node API에 손 못 댐 |
이렇게 갈라 두면 테스트가 편해져요. 파서는 문자열만 주면 되고, 도메인은 값만 주면 됩니다. git이 깔려 있어야 도는 테스트는 맨 바깥 한 겹뿐이에요.
보안 기본값도 처음부터 박았습니다. 샌드박스, 컨텍스트 격리, 네비게이션 차단, 채널 허용 목록. 나중에 붙이면 이미 어긴 코드를 다 고쳐야 하거든요.
재미없어 보이는 스토어 쪽에 오히려 고민이 많았는데요.
- busy 재진입 거부 — 커밋 도중에 또 커밋 버튼이 눌리면 안 되니까요
- stage 후 낡은 diff 정리 — 파일을 올렸는데 diff 패널이 이전 내용을 보여주면 상태를 오해해요
- 커밋 실패 시 메시지 보존 — 실패했다고 정성껏 쓴 메시지가 날아가면 화나잖아요
(전부 E2E로 박아뒀어요. 전역 gitconfig에 의존하지 않는 hermetic 픽스처로요.)
디자인 규칙도 감각이 아니라 검사로 뒀어요.
// 토큰 대비 회귀 테스트
test("본문 텍스트는 배경 대비 4.5:1 이상", () => {
for (const theme of ["light", "dark"]) {
const ratio = contrast(token(theme, "text"), token(theme, "surface"));
expect(ratio).toBeGreaterThanOrEqual(4.5);
}
});
// 색을 예쁘게 바꾸다 대비가 떨어지면 여기서 막힙니다| 금지한 것 | 이유 |
|---|---|
| 임의 색값 | 토큰 밖의 색은 다크 모드에서 깨짐 |
| 보라색 그라데이션 남용 | "AI가 만든 화면"의 대표적 인상 |
| 이모지 아이콘 | 플랫폼마다 모양이 달라 정체성이 안 생김 |
| 과한 유리 효과 | 읽기가 어려워지고 대비 기준을 못 맞춤 |
두 번째 줄이 나중에 이 앱의 방향을 통째로 바꾸게 되는데, 그건 다른 편 이야기예요.
디자인을 입히다 — E0-1
디자인 규칙은 스펙에 이미 못 박아뒀었어요. 모든 색·간격·타이포는 토큰(CSS 변수)으로만, 임의 색 금지. 보라색 그라데이션 남용, 이모지 아이콘, 과도한 glassmorphism — "AI 생성물처럼 보이는" 관습도 금지 목록에 있고요. 스택은 react-aria-components + Lucide 아이콘 + Pretendard입니다.


눈에 띄실지 모르겠는데, 개발자용 화면인데도 문구가 "지금 바뀐 것 unstaged", "저장 예정 staged", "올리기/내리기"예요. 쉬운 모드 설계에서 정한 일상어 주 표기 + Git 용어 병기 원칙을 개발자 모드에서 먼저 실험하고 있는 거예요. 듀얼 모드가 같은 화면 구조를 공유해야 전환이 안 어색하거든요.
토큰에는 WCAG 대비 회귀 테스트도 붙였어요. 색을 바꾸다 대비가 기준 밑으로 떨어지면 테스트가 실패해요. 디자인 감각이 아니라 검사기가 접근성을 지키는 구조 — "컨벤션은 문서가 아니라 실패하는 빌드다"를 여기서도 그대로 씁니다.
고민했던 것 하나 — diff 줄 분류
diff 뷰어에 색을 입히면서 순진하게 줄 첫 글자로 분류했다가 바로 데였어요. +new file은 추가된 내용인데, +++ b/notes.txt도 +로 시작하거든요. 헤더가 초록색 추가 줄로 칠해지는 거죠.
그래서 위치 기반 분류로 바꿨어요. diff 출력은 구조가 정해져 있으니(헤더 블록 → hunk 헤더 → 내용) 분류기가 지금 어디를 읽고 있는지 상태를 들고 가면서 판단합니다. 물론 단위 테스트와 함께요.
(파일명 말줄임도 비슷한 고민이었어요. 경로가 길면 중간을 접되, 파일명은 끝까지 보여줘야 하거든요 — src/appl…ForbiddenPage.tsx처럼요.)
그리고 이 단계에서 제일 많이 배운 게 파서였어요.
# 순진한 분류 — 첫 글자만 봅니다
+++ b/notes.txt ← 헤더인데 "+" 로 시작
@@ -1,4 +1,6 @@ ← hunk 헤더
+const next = 1; ← 진짜 추가된 줄
-const prev = 0; ← 진짜 삭제된 줄
--- a/notes.txt ← 헤더인데 "-" 로 시작// 그래서 위치 기반으로 바꿨습니다
let section = "header";
for (const line of lines) {
if (line.startsWith("@@")) { section = "body"; continue; }
if (section === "header") { emit(line, "meta"); continue; }
// 여기부터는 첫 글자가 진짜 의미를 갖습니다
emit(line, line[0] === "+" ? "add" : line[0] === "-" ? "del" : "ctx");
}교훈이 하나 있어요. 형식이 정해진 출력은 문자열이 아니라 상태 기계로 읽어야 한다는 것. 첫 글자로 판단하는 코드는 대부분의 입력에서 잘 돌아가다가, 헤더에서 조용히 틀립니다.
그리고 이런 건 눈으로 못 잡아요. 초록색으로 칠해진 헤더 한 줄이 화면에 있어도, 대충 보면 그냥 그런가 보다 하거든요. 단위 테스트가 잡았습니다.
민짜 HTML로 먼저 간 게 맞았을까
돌아보면 이 순서가 이번 단계에서 제일 잘한 결정이었어요. 그런데 처음엔 답답했습니다. 몇 주 동안 화면이 못생겼거든요.
| 순서 | 장점 | 단점 |
|---|---|---|
| 화면부터 | 일찍 보여줄 수 있음 | 계층이 화면 모양에 끌려감 |
| 수직 한 줄부터 | 계층이 먼저 굳음 | 한동안 보여줄 게 없음 |
두 번째를 고른 이유는 하나예요. 화면은 나중에 통째로 갈아끼울 수 있는데, 계층은 그렇지 않거든요.
실제로 그렇게 됐어요. 민짜 HTML에서 픽토그램까지 왔지만 바뀐 건 화면 계층뿐입니다. 스토어도 파서도 그대로인 채 화면만 갈아끼웠어요.
계층을 나눈 보람은 나눌 때가 아니라 갈아끼울 때 돌아옵니다. 그 전까진 그냥 파일이 많은 것처럼 보이고요.
지금은 E0-2를 진행 중이에요. "저장된 역사" 패널을 실제 log로 채우고, 백업(push)을 붙이고, 저장 메시지를 규칙 기반으로 자동 제안하는 것까지. AI 제안은 나중 옵션이고, 파일 수·경로·변경 유형으로 만드는 로컬 규칙이 먼저예요.
민짜 HTML에서 픽토그램까지 왔지만, 바뀐 건 프레젠테이션 계층뿐이에요. 스토어도 파서도 그대로인 채 화면만 갈아끼웠다는 것 — 1편에서 계층을 나눈 보람을 이번 편에서 처음 회수했습니다. 다음 편은 역사·백업 이야기로 이어갈게요.
여기까지가 기반 이야기예요. 이 앱이 진짜 도구가 되어가는 과정은 Git GUI, 도구가 되기까지 시리즈에서 이어집니다.


