> ## Documentation Index
> Fetch the complete documentation index at: https://blog.nvim.me/llms.txt
> Use this file to discover all available pages before exploring further.

# 두 갈래 작업은 어떻게 합칠까요?

> fast-forward와 merge commit이 생기는 상황을 직접 만들고, 브랜치 위치와 부모 커밋으로 병합 결과를 읽어요.

작업 브랜치에서 `index.js`의 인사말을 고쳤어요. 이제 `main`에도 넣고 싶어요. `main`으로 전환하면 파일은 예전 내용으로 돌아오는데, 내 변경은 어떻게 가져올까요?

[앞편](https://blog.nvim.me/git/learn/branches-head-and-switch.md)에서는 브랜치를 만들고 작업 위치를 바꿨어요. 이번에는 현재 브랜치에 다른 브랜치의 이력을 합치는 **병합**(merge)을 해봐요. 같은 `merge`라도 브랜치 위치만 움직일 때와 새 커밋이 생길 때가 있어요.

## 어느 브랜치로 합칠지 먼저 골라요

`main`에서 `git merge feature/greeting`을 실행하면 `feature/greeting`의 이력을 **현재 브랜치인 `main`으로** 합쳐요. 명령에 적은 브랜치로 이동하는 동작은 아니에요. 병합 뒤에도 현재 브랜치는 `main`이에요.

이번 글은 공통 조상이 있는 두 로컬 브랜치를 병합해요. 원격 저장소나 GitHub PR은 사용하지 않아요. 두 가지 결과를 비교할 거예요.

| 병합 전 이력 | 이번에 관찰할 결과 |
| - | - |
| `main`의 끝 커밋이 작업 브랜치 이력 안에 있어요 | `main`을 작업 브랜치 끝으로 옮기는 fast-forward가 가능해요 |
| 공통 조상 이후 양쪽에 서로 다른 커밋이 있어요 | 두 끝 커밋을 부모로 연결하는 merge commit을 만들어요 |

fast-forward는 새 병합 커밋 없이 브랜치 참조를 앞으로 옮기는 방식이에요. merge commit은 합친 파일 상태와 여러 부모를 기록하는 커밋이에요. 아래 실습에서는 부모가 둘이에요. 이 구분은 [Pro Git의 기본 병합 설명](https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging)을 기준으로 해요.

## 새 연습 저장소를 준비해요

명령은 **Bash** 기준이에요. Git 설치와 작성자 설정이 낯설면 [첫 커밋 실습](https://blog.nvim.me/git/learn/setup-and-first-commit.md)을 먼저 읽어봐요. 준비 명령의 `git init -b main`에는 Git 2.28 이상이 필요해요. Node.js는 필요하지 않아요.

앞편의 마지막 상태는 두 브랜치가 `index.js`의 같은 줄을 서로 다르게 고친 상태예요. 그대로 합치면 충돌하므로, 이번에는 **별도 저장소에서 충돌 없이 합쳐지는 두 경우**를 만들어요. 앞편의 저장소는 그대로 두세요.

| 항목 | 준비가 끝난 상태 |
| - | - |
| 브랜치와 이력 | `main` 하나, 첫 커밋 하나 |
| `index.js` | `console.log("hello");` 한 줄 |
| `README.md` | `# Git practice` 한 줄 |
| 작업 트리와 인덱스 | 미커밋 변경 없음 |
| 원격 | 없음 |

기존 Git 저장소 밖에서 실행해요. 같은 이름의 폴더가 있어 `mkdir`가 실패하면 멈추고 새 이름을 골라요.

```bash theme={null}
mkdir git-practice-merge && cd git-practice-merge && git init -b main
```

아래 `>`는 파일을 덮어써요. 방금 만든 빈 연습 폴더에서만 실행해요. 작성자 설정은 이 저장소에만 적용해요. 실습은 별도 hooks, 서명, merge driver나 브랜치별 병합 옵션이 없는 환경을 기준으로 해요.

```bash theme={null}
git config --local user.name "Git Learner"
git config --local user.email "learner@example.com"
printf 'console.log("hello");\n' > index.js
printf '# Git practice\n' > README.md
git add index.js README.md
git commit -m "Add initial script and README"
git status
git branch --show-current
git log --oneline
git remote -v
```

현재 브랜치는 `main`이고 `status`는 `working tree clean`이어야 해요. 첫 커밋 하나가 보이고 원격 출력은 없어요. 커밋 ID는 실행할 때마다 달라질 수 있어요. 이력이 별도 화면으로 열리면 `q`로 나와요.

## main이 그대로라면 위치만 옮길 수 있어요

**1. 작업 브랜치에서만 커밋하기**

새 브랜치에서 인사말을 `hello git`으로 바꿔요. `main`에는 새 커밋을 만들지 않아요.

```bash theme={null}
git switch -c feature/greeting
printf 'console.log("hello git");\n' > index.js
git diff -- index.js
git add index.js
git commit -m "Change greeting to hello git"
git switch main
git status
cat index.js
git log --graph --oneline --decorate --all
```

`main`의 파일은 아직 `hello`예요. 그래프에서는 `feature/greeting`이 첫 커밋보다 하나 앞서 있어요. `main`의 끝 커밋은 그 이력의 부모로 이미 들어 있어요.

**2. fast-forward로 합치기**

실행 전에 예상해봐요. 두 브랜치가 같은 커밋을 가리키게 하려면 새 커밋이 필요할까요?

```bash theme={null}
git branch --show-current
git merge --ff-only feature/greeting
git status
cat index.js
git rev-parse main feature/greeting
git log --graph --oneline --decorate --all
git rev-list --count --all
```

병합 출력에 `Fast-forward`가 나오고 파일은 `hello git`으로 바뀌어요. `rev-parse`가 출력하는 두 ID는 같고, 전체 커밋 수는 여전히 `2`예요. **새 커밋을 만들지 않고 `main`을 기존 커밋으로 옮겼어요.** 깨끗했던 작업 트리와 인덱스도 그 커밋의 파일 상태로 갱신됐어요.

`--ff-only`는 fast-forward로 합칠 수 있을 때만 진행해요. 이력이 갈라져 있으면 병합을 거절해요. 옵션의 범위는 [git merge 공식 문서](https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff-only)를 참고해요.

아래는 **실제 출력에서 커밋 ID를 생략한 설명용 도식**이에요. 커밋 사이 화살표는 새 커밋에서 부모 커밋을 향해요. 브랜치 이름에서 나오는 화살표는 그 이름이 가리키는 커밋을 뜻해요.

```mermaid theme={null}
flowchart LR
  H["HEAD"] --> M["main"]
  M --> B["B: hello git"]
  F["feature/greeting"] --> B
  B --> A["A: 첫 커밋"]
```

`feature/greeting`은 이동하지 않았어요. `main`이 같은 위치로 왔을 뿐이에요. 브랜치 이름도 자동으로 삭제되지 않아요.

## 양쪽에 새 커밋이 있으면 부모 둘을 연결해요

이어서 같은 저장소를 사용해요. 이번에는 작업 브랜치에서 `index.js`를, `main`에서 `README.md`를 바꿔요. 공통 조상 이후 서로 다른 파일을 수정해서 이력이 갈라져도 충돌 없이 합쳐지는 경우를 만들어요.

**1. 작업 브랜치의 인사말 바꾸기**

현재 `main`에서 새 브랜치를 만들어요. 기존 `feature/greeting`은 그대로 남겨둬요.

```bash theme={null}
git switch -c feature/world
printf 'console.log("hello world");\n' > index.js
git add index.js
git commit -m "Change greeting to hello world"
git switch main
```

**2. main에도 별도 변경 기록하기**

`main`에서는 사용 안내를 한 줄 추가해요. 아래 `>>`는 기존 파일 끝에 덧붙여요.

```bash theme={null}
printf 'Run index.js to print a greeting.\n' >> README.md
git add README.md
git commit -m "Document how to run the script"
git status
git log --graph --oneline --decorate --all
git merge-base main feature/world
git rev-parse feature/greeting
git diff main feature/world -- index.js README.md
```

현재 브랜치는 `main`이고 작업 상태는 clean이에요. `merge-base`는 두 이력의 공통 조상을 찾아요. 이 실습에서는 출력이 `feature/greeting`의 ID와 같아요. 그 커밋에서 두 갈래로 나뉘었기 때문이에요. [git merge-base 공식 문서](https://git-scm.com/docs/git-merge-base)의 공통 조상 개념에 해당해요.

두 끝 커밋의 diff에는 인사말 변경과 안내 줄 삭제가 보여요. 비교 대상인 `feature/world`에는 `main`에서 추가한 안내 줄이 없기 때문이에요. **이 diff가 병합 결과를 뜻하지는 않아요.** 병합은 공통 조상을 기준으로 양쪽의 변경을 살펴봐요.

**3. fast-forward가 거절되는지 확인하기**

이번에는 `main`의 새 커밋이 `feature/world`의 이력에 없어요. 브랜치 위치만 옮겨서는 두 이력을 함께 보존할 수 없어요.

```bash theme={null}
git merge --ff-only feature/world
```

**이 명령은 의도적으로 실패해요.** 검증 환경에서는 아래 오류가 나왔어요. 부가 안내나 번역은 환경에 따라 다를 수 있어요.

```text theme={null}
fatal: Not possible to fast-forward, aborting.
```

실패를 확인한 뒤 상태를 읽어요.

```bash theme={null}
git status
git log --oneline -1
cat index.js README.md
```

여전히 `main`의 마지막 커밋은 `Document how to run the script`예요. 파일은 `hello git` 인사말과 추가한 안내 줄을 유지해요. 충돌 해결 단계에 들어간 것이 아니므로 `merge --abort`를 실행할 필요도 없어요.

**4. merge commit으로 합치기**

현재 브랜치가 `main`이고 작업 상태가 clean인 것을 확인했어요. 이번에는 fast-forward가 불가능하면 merge commit을 만드는 `--ff`를 사용해요. 옵션을 명시해 `merge.ff` 설정에 따른 차이를 줄여요. `-m`은 병합 메시지, `--no-edit`는 메시지 편집기를 열지 않는 옵션이에요.

```bash theme={null}
git merge --ff --no-edit -m "Merge world greeting into main" feature/world
git status
cat index.js README.md
git log --graph --oneline --decorate --all
git rev-list --count --all
```

`index.js`에는 `hello world`가, `README.md`에는 `main`에서 추가한 안내가 함께 있어요. 커밋 수는 `5`이고 `main`은 새 병합 커밋을 가리켜요. `feature/world`는 인사말을 고친 기존 커밋에 남아 있어요.

완료한 상태를 그리면 아래와 같아요. `A`부터 `M`은 설명용 이름이며 명령에 입력할 커밋 ID가 아니에요.

```mermaid theme={null}
flowchart LR
  H["HEAD"] --> MAIN["main"]
  MAIN --> M["M: 병합 커밋"]
  M -->|첫 번째 부모| D["D: README 안내 추가"]
  M -->|두 번째 부모| C["C: hello world"]
  D --> B["B: hello git"]
  C --> B
  B --> A["A: 첫 커밋"]
  WORLD["feature/world"] --> C
  GREETING["feature/greeting"] --> B
```

병합은 이전 커밋을 고쳐 쓰지 않았어요. `M`이 두 부모를 연결하면서 양쪽 이력이 `main`에서 이어져 보여요. 이력이 갈라졌다는 이유만으로 충돌하는 것도 아니에요. 이번에는 서로 다른 파일의 변경을 자동으로 합쳤어요.

## 부모 커밋과 파일을 함께 확인해요

`git log`의 줄 그림만 보고 판단하기 어렵다면 부모 ID를 직접 읽어봐요.

```bash theme={null}
git show --no-patch --format='%h %s%nparents: %p' HEAD
git rev-parse HEAD^1 HEAD^2 feature/world
git diff HEAD^1 HEAD -- index.js README.md
git diff HEAD^2 HEAD -- index.js README.md
```

`parents:` 뒤에 ID 두 개가 나와요. `HEAD^1`은 첫 번째 부모, `HEAD^2`는 두 번째 부모예요. 이번 병합의 첫 번째 부모는 병합 직전 `main`, 두 번째 부모는 `feature/world`예요. 따라서 `rev-parse`의 두 번째와 세 번째 출력이 같아요. 표기법은 [gitrevisions 공식 문서](https://git-scm.com/docs/gitrevisions)를 기준으로 해요.

| 비교 | 이 실습에서 보이는 차이 |
| - | - |
| `HEAD^1` → `HEAD` | `main`에 들어온 `hello git` → `hello world` 인사말 변경 |
| `HEAD^2` → `HEAD` | 작업 브랜치에는 없었던 README 안내 줄 추가 |

`HEAD~2`는 첫 번째 부모를 두 번 따라가요. 두 번째 부모를 뜻하는 `HEAD^2`와 달라요. 파일 차이를 볼 때도 어느 부모와 비교하는지 골라야 해요.

## 병합 옵션은 이력과 함께 읽어요

아래는 참고 표예요. 실습에서 병합한 브랜치를 다시 만들 필요는 없어요.

| 옵션 | 의미 |
| - | - |
| `--ff-only` | fast-forward가 가능하면 진행하고, 갈라진 이력은 거절해요 |
| `--ff` | 가능하면 fast-forward, 불가능하면 merge commit으로 합쳐요 |
| `--no-ff` | fast-forward가 가능한 상황에서도 merge commit을 만들어요 |

이미 상대 이력이 현재 이력에 포함됐다면 별도 작업 없이 끝날 수 있어요. `--no-ff`도 포함된 이력을 합칠 때마다 새 커밋을 만든다는 뜻은 아니에요. 기본 동작은 설정과 병합 대상에 영향을 받을 수 있으므로 여기서는 옵션을 명시했어요. 세 옵션은 [git merge의 옵션 설명](https://git-scm.com/docs/git-merge#_options)을 참고해요.

fast-forward는 직선 이력을 유지하고, merge commit은 합친 지점을 부모 관계로 남겨요. 어느 방식이든 파일과 이력을 확인해야 해요. 자동 병합에 성공했다는 사실만으로 프로그램의 동작까지 검증한 것은 아니에요. 실제 프로젝트에서는 병합한 코드에 맞는 테스트도 실행해요.

> [!WARNING]
>
> 실제 작업에서는 병합 전에 미커밋 변경을 정리해요. 충돌이 나면 병합이 중간에 멈출 수 있어요. `git merge --abort`는 진행 중인 병합을 중단하는 명령이며, 완료된 병합을 되돌리는 명령은 아니에요. 시작 전에 있던 미커밋 변경을 항상 복원해준다고 가정하지 않아요. 적용 범위는 [git merge의 중단 설명](https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---abort)을 참고해요.

## 끝난 상태를 확인해요

```bash theme={null}
git branch --show-current
git status
git diff
git diff --cached
git branch -v
git log --graph --oneline --decorate --all
git show HEAD:index.js
git show HEAD:README.md
git remote -v
git merge --ff-only feature/world
git rev-list --count --all
```

마지막 병합은 `Already up to date.`로 끝나요. `feature/world`의 이력이 이미 `main`에 포함돼 있어요. 새 커밋은 생기지 않아서 전체 커밋 수는 `5` 그대로예요.

> [!TIP]
>
> 현재 브랜치는 `main`이고 상태는 clean이에요. 두 미커밋 diff와 원격 출력은 비어 있어요. `index.js`의 인사말은 `hello world`이고 README에는 안내 줄이 있어요. 두 작업 브랜치는 남아 있으며, `main`의 마지막 커밋에는 부모가 둘 있어요.

## 예상과 다른 결과가 나왔다면

| 상황 | 확인과 대응 |
| - | - |
| `--ff-only`가 실패해요 | 양쪽 이력이 갈라졌는지 그래프로 확인해요. 연습의 두 번째 병합에서는 의도한 결과예요. 실제 프로젝트에서는 팀의 병합 방식을 먼저 확인해요 |
| 첫 병합부터 `Already up to date.`가 나와요 | 현재 브랜치와 커밋 위치를 읽어요. 이미 병합했거나 작업 브랜치에 새 커밋을 만들지 않았을 수 있어요 |
| 미커밋 변경 때문에 병합이 거절돼요 | `status`와 두 diff로 변경을 확인하고 기록할 브랜치에서 커밋해요. 강제 옵션으로 덮어쓰지 않아요 |
| `CONFLICT`가 나와요 | 이 글의 시작 상태와 수정 파일을 확인해요. clean 상태에서 시작한 이 연습에서 병합이 충돌로 멈췄다면, 아래 중단 절차로 돌아가요 |

충돌로 **병합이 진행 중일 때만** 아래 명령을 실행해요. 중단하면 병합 도중의 해결 작업도 버리므로, 그 편집을 보존해야 한다면 먼저 별도로 복사해요.

```bash theme={null}
git merge --abort
git status
git log --oneline -1
cat index.js README.md
```

이 글의 두 번째 병합 직전 상태로 돌아왔다면 `main`의 마지막 커밋은 README 안내 추가예요. 인사말은 `hello git`이고 상태는 clean이에요. 파일과 시작 상태가 다른 경우에는 다음 명령을 이어 실행하지 말고 차이부터 확인해요.

브랜치 이름이 둘이라고 늘 새 병합 커밋이 필요한 것은 아니에요. **현재 커밋이 상대 이력에 포함됐는지**를 먼저 읽으면 위치만 옮길 수 있는지 판단할 수 있어요. 현재 위치는 [브랜치와 변경 내용 조회](https://blog.nvim.me/git/how-to/inspect-working-changes.md)로, 합친 결과는 [이력과 diff 읽기](https://blog.nvim.me/git/learn/read-history-and-diff.md)로 다시 확인해보세요.

같은 줄을 서로 다르게 고쳐 자동으로 합치지 못하면 어떻게 될까요? [병합 충돌 해결](https://blog.nvim.me/git/learn/resolve-merge-conflicts.md)에서 파일을 직접 정하고 병합을 완료하거나 중단해봐요.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.