충돌을 카드 한 장씩 고르게 만들기
꺾쇠 기호가 잔뜩 박힌 파일을 처음 본 사람은 대개 겁을 먹어요. 그걸 "두 버전이 같은 곳을 다르게 고쳤어요, 어느 쪽을 쓸까요?"로 바꾼 이야기입니다.

병합 충돌이 나면 파일에 꺾쇠 기호가 잔뜩 박힙니다. 처음 본 사람은 대개 파일이 망가진 줄 알아요.
사실 저도 처음엔 그랬거든요. 저 기호를 지워야 하는지, 지우면 뭐가 사라지는지 아무도 안 알려주잖아요.
이 앱에서 제일 하고 싶었던 게 이거였습니다. 충돌을 사고가 아니라 질문으로 바꾸는 것.
꺾쇠 기호가 실제로 뭔지
먼저 이걸 짚고 가야 뒤 이야기가 됩니다. 충돌 마커는 사실 아주 정직한 표기예요.
<<<<<<< HEAD
const color = "blue"; // 내가 고친 것
=======
const color = "green"; // 상대가 고친 것
>>>>>>> feature/themegit은 여기서 판단을 포기한 거예요. 같은 줄을 양쪽이 다르게 고쳤으니 둘 중 뭐가 맞는지 모르겠다고, 둘 다 파일에 적어 둔 겁니다.
그러니까 파일이 망가진 게 아니에요. 질문이 파일 안에 적혀 있는 거죠. 문제는 그 질문이 사람이 읽으라고 쓰인 형식이 아니라는 거고요.
| 사용자가 보는 것 | 실제 의미 | 앱이 해야 할 일 |
|---|---|---|
| 꺾쇠와 등호가 잔뜩 | 두 버전이 같은 자리를 다르게 고침 | 질문 문장으로 바꾸기 |
| 파일이 저장이 안 됨 | 아직 해결 표시를 안 함 | 진행 상태를 보여주기 |
| 커밋이 안 됨 | 병합이 진행 중인 상태 | 지금 무슨 상태인지 말해주기 |
| 되돌리는 법을 모름 | 병합 취소가 가능함 | 빠져나갈 문을 항상 보여주기 |
기본은 카드 한 장씩
충돌을 한꺼번에 보여주지 않아요. 한 건씩 카드로 냅니다.
카드에는 이렇게 적혀 있어요. "두 버전이 같은 곳을 다르게 고쳤어요. 어느 쪽을 쓸까요?" 그리고 양쪽 코드를 나란히 보여줍니다.
한쪽을 고르면 다음 충돌로 넘어가요. 위에는 진행 표시가 있습니다. 세 곳 중 두 번째, 하는 식으로요.
별것 아닌 것 같은데, 이 진행 표시가 체감을 많이 바꿨어요. 끝이 보이는 일과 안 보이는 일은 완전히 다른 일이거든요.
(충돌 마커만 보고 있으면 이게 세 군데인지 서른 군데인지도 모르잖아요.)
한 건씩 보여주려면 먼저 쪼개야 해요
카드 한 장씩 보여주겠다고 정하고 나니, 그 앞에 할 일이 있더라고요. 충돌을 "건"으로 셀 수 있어야 합니다.
// 파일 하나에서 충돌 덩어리들을 뽑아냅니다
type ConflictHunk = {
id: string;
file: string;
startLine: number;
ours: string[]; // 내 쪽 줄들
theirs: string[]; // 상대 쪽 줄들
base?: string[]; // 공통 조상 (3-way 일 때)
choice: "ours" | "theirs" | "custom" | null;
};
// 카드 한 장 = 헝크 하나
// 진행 표시는 choice 가 채워진 개수 / 전체 개수base를 같이 들고 있는 게 은근히 중요했어요. "원래는 이랬는데 양쪽이 이렇게 고쳤어요"까지 보여줄 수 있거든요. 양쪽만 보면 왜 다른지가 안 보입니다.
그리고 choice를 헝크마다 따로 들고 있는 게, 뒤에 나올 "선택을 유지한 채 상세 뷰로 넘어가기"를 가능하게 만든 구조예요. 선택이 파일이 아니라 헝크에 붙어 있으니까요.
그런데 고르기만으로는 부족해요
실제로는 "양쪽을 섞어야 하는" 경우가 많습니다. 내 쪽 변수명에 상대 쪽 로직인 경우 같은 거요.
그래서 카드에서 "자세히 보기"로 넘어가면 결과를 직접 고칠 수 있게 했어요. 그리고 중요한 게 하나 있는데 — 앞에서 고른 선택이 유지된 채로 넘어갑니다.
세 곳 중 두 곳을 카드로 처리하고 나머지 한 곳만 손으로 고치는 게 되는 거죠. 이게 안 되면 사람들은 결국 처음부터 손으로 하게 되고, 그러면 카드는 장식이 됩니다.
그래서 두 화면이 같은 데이터를 봅니다.
// 카드 뷰와 상세 뷰가 같은 헝크 배열을 씁니다
// 카드에서 고른 것 → choice 채움
// 상세에서 직접 고친 것 → choice: "custom" + 결과 텍스트
const resolvedText = hunks.map(h => {
switch (h.choice) {
case "ours": return h.ours;
case "theirs": return h.theirs;
case "custom": return h.customLines;
case null: return null; // 아직 안 정함
}
});
// 하나라도 null 이면 해결 표시를 못 합니다여기서 흔히 하는 실수가 상세 뷰를 "텍스트 편집기"로 만드는 거예요. 그러면 카드에서 고른 선택을 텍스트로 한 번 굽고 나서 편집하게 되고, 되돌아올 수가 없습니다.
그래서 상세 뷰도 헝크 단위로 편집해요. 카드로 돌아가면 선택이 그대로 남아 있고요. 이게 안 되면 사람들은 결국 처음부터 손으로 하게 되고, 그러면 카드는 장식이 됩니다.
안 하기로 한 것
자동 해결은 안 넣었어요. 이건 명시적으로 범위에서 뺐습니다.
충돌을 앱이 알아서 정리해주면 편하죠. 그런데 그 순간 사용자는 자기 코드에 무슨 일이 일어났는지 모르게 됩니다.
이 앱의 원칙이 "현재 상태를 숨기지 않는다"라서요. 어려운 걸 쉬운 말로 설명하는 것과, 어려운 걸 안 보이게 치우는 건 다른 일이에요.
자동 해결을 안 넣은 게 제일 오래 고민한 결정이에요
정직하게 말하면 여러 번 흔들렸어요. 자동 해결은 만들기도 어렵지 않고, 데모에서 제일 인상적인 기능이거든요.
| 자동 해결을 넣으면 | 좋아지는 것 | 대신 잃는 것 |
|---|---|---|
| 공백·줄바꿈만 다른 충돌 | 클릭 한 번이 사라짐 | 어떤 판단이 있었는지 사용자가 모름 |
| 한쪽만 수정한 충돌 | 대부분 자동 처리됨 | "대부분"이 언제 아닌지를 사용자가 모름 |
| import 문 정렬 충돌 | 거의 항상 맞음 | 거의가 아닌 경우가 조용히 섞임 |
오른쪽 열이 전부 같은 말이에요. 사용자가 모른다는 것. 그리고 이 앱을 쓰는 사람은 지금 git을 배우는 중인 사람이거든요.
어려운 걸 쉬운 말로 설명하는 것과, 어려운 걸 안 보이게 치우는 건 다른 일이에요. 앞쪽은 사용자를 남기고, 뒤쪽은 앱에 대한 의존을 남깁니다.
그래서 범위 문서에 "안 하는 것" 항목으로 적어 뒀어요. 나중에 제가 흔들릴 걸 알았거든요.
선택 화면에는 세 층의 설명이 있습니다
한쪽을 고르면 다른 쪽이 결과에서 빠지잖아요. 그건 무서운 일이에요. 그래서 설명을 세 겹으로 깔았습니다.
| 층 | 말해주는 것 |
|---|---|
| 상단 안내문 | 무슨 일이 벌어졌고 무엇을 하면 되는지 |
| 각 선택지 한 줄 | 이 선택이 코드에서 뜻하는 것 |
| 결과 안내 | 고르면 어떻게 되는지, 그리고 안 고른 쪽도 역사에 남는다는 것 |
세 번째 줄이 제일 중요했어요. "안 고른 쪽도 사라지지 않아요"라는 한 문장이요.
사실 git에서는 당연한 얘기예요. 커밋된 건 안 없어지니까요. 그런데 그걸 모르는 사람에게는 지금 이 클릭이 남의 코드를 지우는 일처럼 느껴집니다.
(아이콘 중심 UI를 만들면서도 이건 문장으로 적었어요. 그림으로 안심시킬 수는 없더라고요.)
막히면 알아서 치워두고 진행
여기에 하나 더 붙였어요. 저장 안 한 변경이 있는 채로 병합이나 브랜치 전환을 시도하면 git이 막습니다.
그럴 때 "먼저 정리하고 오세요"라고 하면 사용자는 방금 하려던 일을 잊어버려요.
그래서 자동으로 치워두고 → 하려던 작업을 하고 → 다시 꺼내옵니다. 꺼내오다 충돌이 나면 그때 다시 같은 카드 흐름으로 안내하고요.
돌아보면 이 앱에서 제일 자주 쓰는 기능이 됐습니다. 저부터 그렇거든요.
치워두고 돌아오는 흐름
이 기능은 사실 흐름이 전부예요. 순서를 틀리면 사용자 변경이 사라질 수 있어서, 단계마다 실패했을 때 어디로 가는지를 먼저 정했습니다.
| 단계 | 실패하면 | 사용자에게 보이는 것 |
|---|---|---|
| 1. 변경 자동 보관 | 중단하고 원래대로 | "보관에 실패해서 아무것도 안 했어요" |
| 2. 하려던 작업 수행 | 보관한 것을 즉시 복원 | "작업이 안 돼서 되돌렸어요" |
| 3. 보관한 것 복원 | 충돌 해결 흐름으로 안내 | 같은 카드 화면 |
| 4. 보관 항목 정리 | 남겨 둠 | "보관함에 아직 남아 있어요" |
4번을 남겨 두는 게 포인트예요. 복원이 성공한 것 같아도 바로 지우지 않습니다. 지웠는데 뭔가 잘못됐으면 돌아갈 데가 없으니까요.
돌아보면 이 앱에서 제일 자주 쓰는 기능이 됐어요. 저부터 그렇거든요. 브랜치 옮길 때마다 뭔가 고치다 만 게 항상 있잖아요.
만들고 나서 안 것
충돌 해결을 쉽게 만드는 일의 팔 할은 UI가 아니라 상태를 정확히 아는 것이었어요.
지금 병합 중인지, 어디까지 해결됐는지, 해결 표시가 된 파일은 뭔지, 취소하면 어디로 돌아가는지. 이걸 정확히 알아야 카드 한 장을 그릴 수 있습니다.
쉬운 화면은 대충 만든 화면이 아니라, 정확히 아는 화면이었어요.
그래서 상태를 얼마나 정확히 알아야 하냐면
"쉬운 화면은 정확히 아는 화면"이라고 썼는데, 카드 한 장을 그리기 위해 실제로 알아야 하는 것들을 적어 볼게요.
| 알아야 하는 것 | 모르면 생기는 일 |
|---|---|
| 지금 병합 중인가 · 리베이스 중인가 | 취소 버튼이 엉뚱한 걸 취소함 |
| 충돌 파일이 몇 개이고 헝크가 몇 개인가 | 진행 표시가 거짓말을 함 |
| 어떤 파일이 해결 표시가 됐는가 | 다 됐는데 커밋 버튼이 안 열림 |
| 취소하면 어디로 돌아가는가 | "되돌리기"가 무섭게 느껴짐 |
| 보관함에 방금 넣은 게 있는가 | 복원을 안 하고 화면을 떠남 |
이걸 전부 git 명령 출력에서 파싱해서 도메인 모델로 만들어 둬요. 화면은 그 모델만 봅니다. 화면이 git을 직접 물어보기 시작하면 상태가 갈라지거든요.
결국 이 편의 결론은 UI 이야기가 아니었어요. 쉬운 화면은 대충 만든 화면이 아니라, 정확히 아는 화면이었습니다.
다음 편은 앱 안에 터미널이 들어온 이야기예요. 워크트리를 진짜로 다룰 줄 아는 git 도구를 못 찾아서 직접 만든 얘기도 같이요.


