---
layout: post
title: "자잘한 도움말 #18 Git에 강제 푸시 기능이 존재하는 이유"
description: "Git, 형상 관리, git push, 깃 푸시, 강제 푸시, Forced Push, 리베이스, Rebase, 브랜치, Branch, 푸시, Push, 풀, Pull, Amend, Reset, Revert, force with lease, --force-with-lease, force if includes, --force-if-includes, Git Refs, 깃 참조, git push --force-with-lease=<refname>:<expect>, 안전한 강제 푸시"
date: 2024-07-25 14:00:00 +0900
categories: [Micro]

id: 18
tags: []
---

## TL;DR

- `git push --force`에 대해
  - 강제 푸시 옵션이 위험하다고들 하는데 그러면 왜 존재하는가?
- 강제 푸시는 다음과 같은 상황에 유용하다.
  - 이미 푸시된 커밋을 수정할 때
  - 로컬에서 진행한 리베이스를 원격에도 반영할 때
- 어찌 됐든 위험한 명령어인 만큼 안전하게 사용하자.
  - 기본적으로 혼자 사용하는 브랜치에서만 사용하자.
  - `--force` 대신 더 안전한 `--force-with-lease` 옵션을 사용하자.
- GUI 클라이언트를 사용한다면 내부적으로 무슨 옵션을 사용 중인지 한번 확인해 보자
  - 내가 사용 중인 [Fork](https://git-fork.com/){:target="_blank"}는 내부적으로 `--force-with-lease` 옵션을 쓰고 있다

## 이하 그리 중요하진 않은 내용들

**형상 관리(Configuration Management)**란 소프트웨어의 변경 사항을 체계적으로 추적하고 통제하는 소프트웨어 공학의 절차를 뜻한다. ([위키백과](https://ko.wikipedia.org/wiki/%EA%B5%AC%EC%84%B1_%EA%B4%80%EB%A6%AC){:target="_blank"}) 그 자체로 컴퓨터공학 논문 소재가 될 수도 있는 방대한 분야로 알고 있지만, 굳이 그렇게까지 잘 알진 못하더라도 이미 다들 그 개념을 실생활에서 접해 왔을 것이다. 형상 관리 도구의 발전사가 다음과 같기 때문: CVS와 SVN 등을 거쳐, 그 유명한 **Git**의 등장.

![Git](https://i.postimg.cc/BQKqV6fj/image.png)  
{: .rounded-edge-16 .center .w-half}

어릴 땐 Git과 Github를 놓고 구분 짓지 못하던 시절도 있었지만, 수년 간의 개발을 겪으며 이젠 Git이 없는 삶을 상상할 수 없는 수준 까진 왔다. 그만큼 개발자의 절친 같은 도구가 Git이다. 다만 이렇게 일상적으로 쓰고 있기는 해도 원체 사용법이 다양하고 복잡한 도구이기 때문에 "Git을 잘 안다"라는 표현까진 쉽사리 입 밖으로 나오진 않는다. 친한데 가끔 어색하기도 한 그런 친구. 심지어 나는 CLI도 아니고 거진 GUI로만 그 기능을 쓰는 편이라구.  

아무튼 이걸 가지고 파헤쳐보는 시간을 언젠간 가져야겠지만, 오늘은 방대한 형상 관리와 Git의 세계 그중 극히 일부분에 대해서만 다뤄보자. 전부터 해왔던 엉뚱한 생각이 하나 있거든.

### Git으로 협업하는 법

![svnvsgit](https://i.postimg.cc/Lshkp0KX/image.png)  
{: .rounded-edge-16 .center}

형상 관리 도구는 크게 두 방식으로 나눠진다. 중앙 집중형 방식과 분산 저장소 방식. 중앙 집중형 방식에선 개발자가 코드 저장소에 직접 찾아가 개발을 진행한다. 분산 저장소 방식에선 개발자가 자기 PC에 코드 저장소를 통째로 복제해 개발을 진행한다. **Git**이 바로 **분산 저장소 방식**의 형상 관리 도구이다. 따라서 우리가 Git을 통해 프로젝트 개발에 참여하려면, 먼저 `git clone` 명령어를 사용해 PC에 저장소를 통째로 복제해야 한다.

형상 관리에서 변경을 나타나는 단위를 **커밋(Commit)**이라고 부른다. 중앙 집중형 방식에서는 개발자의 커밋이 즉시 중앙 코드 저장소, 다시 말해 프로젝트 전체의 변경으로 반영된다. 반대로 분산 저장소 방식에서는 개발자의 커밋이 그 개발자의 **로컬 저장소**에만 머무른다.

![push and pull](https://i.postimg.cc/MpKXpXps/image.png)  
{: .rounded-edge-16 .center}

따라서 분산형 방식에서는 커밋 말고도 저장소를 동기화하는 작업이 추가로 필요하다. 변경 사항이 생겨난 저장소는 그 커밋들을 묶어 다른 저장소에 밀어 넣어야 하고(**푸시, Push**), 다른 저장소들은 그 새로운 커밋들을 당겨와야 한다(**풀, Pull**). 물론 일반적으로 아무 저장소끼리 푸시와 풀을 주고받진 않고, 중심이 될 **원격 저장소**를 기준으로 푸시와 풀이 오가게 된다. 내가 커밋을 만들어냈다면 원격 저장소를 향해 푸시, 프로젝트에 커밋이 발생했다면 원격 저장소로부터 풀. 약간 사담이지만 이 원격 저장소 호스팅 서비스로 대박 난 게 바로 [Github](https://github.com/){:target="_blank"}.

![conflict](https://i.postimg.cc/vHbgjbCv/image.png)  
{: .center .w-1-quarter .rounded-edge-16}

아무튼, 간단하게 설명했지만 사실 푸시와 풀은 필연적으로 여러 갈등(**충돌, Conflict**)을 야기하는 작업이다. 커밋을 원격 저장소에 푸시했더니 이미 다른 로컬 저장소의 커밋이 적용된 상황이고 심지어 그 커밋이 내가 추가한 코드와 충돌을 발생시킨다면?  

![rebase-push](https://i.postimg.cc/RZWJkZmz/image.png)  
원격에 새로운 커밋이 있어서 푸시가 되지 않는 상황  
{: .center}

따라서 푸시는 무작정 덮어쓰기 방식으로 진행되지 않는다. 일단 푸시를 하기 전 로컬 저장소는 먼저 원격 저장소로부터 풀을 받아야 한다. 그리고 Git은 충돌이 발생한 부분이 없거나 전부 해결(**Resolve**)되어야지만 성공적으로 푸시가 완료되는 구조다. 따라서 푸시 이전에 원격 저장소 측에 "제가 지금 푸시하려고 하니 풀 좀 받아주세요" 하는 요청, 풀 리퀘스트(**Pull Request**)를 작성하는 것이 Git을 통한 협업의 일반적인 흐름이다.

### 강제로 밀어 넣기

```sh
git push --force
## or
git push -f
```

하지만 원격 저장소의 상태를 신경 쓰지 않고 무작정 덮어쓰기 방식으로 진행하는 푸시도 있다. `--force` 옵션이 적용된 푸시는 원격 저장소의 상태를 로컬 저장소의 상태와 똑같이 만들어 버린다.

![forced push](https://i.postimg.cc/85j9Z9kx/image.png)  
{: .rounded-edge-16 .center}

덮어쓰기 방식의 강제 푸시는 이처럼 기존에 적용된 커밋들을 흔적도 남기지 않고 지워버린다. 따라서 강제 푸시는 위험한 행동으로 여겨진다. 커밋 기록이 사라지면 곤란하지, 변화를 추적하기 위해 형상 관리를 하는 건데 말이야.

![big red button](https://i.postimg.cc/pTLXTbcz/image.png)  
{: .center .w-1-quarter .rounded-edge-16}

하지만 만화 영화에서 과학자들이 엄청 비싼 기계에 자폭 버튼이나 과부하 장치를 만들어 놓듯, 이 옵션은 존재한다. 성격 깐깐한 거로 유명한 리누스 토르발스가 Git을 만들면서 이 옵션을 구현했다는 것은 분명 쓸모가 있다는 의미일 것이다. 혹은 토르발스 입장에선 "엔지니어가 이 정도 리스크도 못 다뤄?" 싶어서 놔뒀을지도 모르지. 아무튼 강제 푸시를 유용하게 사용할 수 있는 상황들을 살펴보자.

![실수했다.](https://i.postimg.cc/prkbBG5F/image.png)  
{: .rounded-edge-16 .center}

첫 번째 시나리오는, 사람이라면 충분히 저지를 수 있는 가벼운 실수들을 수습할 때 강제 푸시를 사용하는 것이다. 한 개발자가 "**Nabbar** 컴포넌트 구현"이라고 메시지를 적은 커밋을 푸시했다고 생각해 보자. 그는 푸시를 마친 후 커피 한 모금을 마신 다음 실수를 알아차린다(띠용👀). 그리고 급히 로컬에서 `git commit --amend` 명령어를 사용, 메시지를 "**Navbar** 컴포넌트 구현"으로 바꾼다. 이 순간, 원격과 로컬에 위치한 동일한 두 커밋이 서로 다른 내용을 가지게 된다. 여기서 명백한 실수인 원격에 푸시된 커밋을 수정된 로컬의 커밋으로 덮어쓰기 위해 사용하는 방식이 강제 푸시다.

이렇듯 강제 푸시를 사용해 실수를 되돌리는 방법은 실수를 저질렀다는 흔적을 남기지 않는다는 점에서 매력적이다. 하지만 관점을 달리하면 오히려 실수의 흔적을 모두 남기는 것이 더 좋은 형상 관리라고 볼 수도 있다. 앞서 형상 관리는 소프트웨어의 변경 사항을 체계적으로 추적하고 통제할 수 있어야 한다고 했다. 커밋 자체를 덮어쓰거나 지워버리는 것보다는 **커밋을 되돌린다는 커밋**을 남겨 프로젝트의 변화를 추적할 수 있도록 하자. 물론 어느 정도 실수까지 강제 푸시를 사용할지에 대한 가이드라인 정도는 생각해 둬도 좋지 않을까. 솔직히 알파벳 하나 틀린 흔적까지 다 남아있으면 너무 민망하잖아.

![리베이스](https://i.postimg.cc/PxqQGVJH/image.png)  
{: .rounded-edge-16 .center}

두 번째 시나리오는 **브랜치(Branch)**를 **리베이스(Rebase)**한 다음 강제 푸시를 사용하는 것이다. 형상 관리에서 특정 커밋을 기준으로 분리한 개발 흐름을 브랜치라고 부른다. 즉, 다시 말해 브랜치가 분리되기 시작한 지점의 커밋은 기준점(Base)이 된다. 여기서 브랜치의 기준점을 변경하는 동작을 리베이스(Re**base**)라고 부른다. 리베이스는 다음과 같은 상황에 일어날 수 있다. "신규 기능 A"를 개발하는 브랜치에서, 브랜치 분리 당시엔 존재하지 않았던 또 다른 "신규 기능 B"를 필요로 하는 경우, 브랜치의 기준점을 "신규 기능 B"가 적용된 커밋으로 옮기는 리베이스를 진행하면 된다.

이렇게 분리된 브랜치들은 로컬에만 저장되는 경우도 있고, 푸시를 통해 원격 저장소에도 저장될 수 있다. 그런데 원격에도 브랜치가 저장되는 경우 리베이스는 조금 난감해진다. 위 그림을 살펴보자. 로컬에서 리베이스를 통해 "feat/A" 브랜치의 기준점을 (1)에서(2)로 옮기는 순간, 해당 브랜치에 앞서 등록되어 있던 커밋 (3)은 (2)의 내용이 섞여 변형된 (3') 커밋이라고 봐야 한다. 원격과 로컬의 같은 커밋이 다른 내용을 가지게 되어 충돌이 발생할 것이다. 이 경우 원격에서 아직 리베이스가 적용되지 않은 "feat/A" 브랜치와 거기에 포함될 (3) 커밋은 어차피 리베이스된 (3') 커밋으로 대체되어야 하므로, 강제 푸시로 로컬의 브랜치 형태를 그대로 적용해 버리면 된다.

### 조금 더 안전한 강제 푸시

![안전제일](https://i.postimg.cc/7L1ZbRxc/image.png)  
{: .rounded-edge .center .w-1-quarter}


그럼에도 불구하고, 강제 푸시가 위험하다는 사실엔 변함이 없다. 예를 들어 위 리베이스 상황에서, "개발자 B"가 "feat/A" 브랜치 리베이스를 위해 강제 푸시를 했다가 원격에서 로컬로 미처 가져오지 못한 "개발자 C"의 "feat/A" 브랜치 커밋이 날아갈 수도 있다. 따라서 기본적으로 강제 푸시는 혼자 작업하는 저장소나 브랜치에서만 사용하는 것이 좋다. 하지만 형상 관리라는 게 워낙 사용 사례가 다양하기 때문에 앞날은 모르는 것. 그래서 Git에선 다음과 같은 옵션을 제공한다.

```sh
git push --force-with-lease
git push --force-with-lease=<refname>
git push --force-with-lease=<refname>:<expect>
```

![force with lease](https://i.postimg.cc/YSML9dq0/image.png)  
{: .rounded-edge-16 .center}

Git의 각 커밋엔 저마다 SHA-1 해시값으로 된 ID가 있다. 각 저장소는 브랜치가 최신 커밋으로 어떤 커밋을 바라보고 있는지, 이 해시값 ID를 기록함으로써 브랜치의 상태를 나타낼 수 있다. 해당 정보는 Git 저장소로 설정된 폴더의 `.git/refs/` 경로에서 확인할 수 있다. `--force-with-lease` 옵션이 사용되면 이 상태를 통해 로컬과 원격의 상태를 먼저 비교한 다음 강제 푸시를 진행한다. 로컬 저장소가 가지고 있는 원격 저장소의 최신 상태 기댓값과 원격 저장소에 저장된 실제 최신 상태를 비교해 두 해시값이 다르다면 강제 푸시가 진행되지 않는 것이다. 따라서 이 옵션을 통해 혹시라도 강제 푸시 이전 아직 로컬 저장소에 업데이트되지 않은 원격 저장소의 최신 커밋들이 사라지지 않게 보호할 수 있다.

위를 보면 알겠지만 `<refname>:<expect>` 같은 추가 옵션들도 있다. 여기서 `<refname>`은 상태를 보고자 하는 브랜치, `<expect>`는 기댓값이 된다. 하지만 일반적으로는 굳이 이 두 값을 건드릴 일이 없을 것이다. 관련 예제를 찾아보기도 힘들고... 나처럼 GUI를 쓰고 있는 사람들도 있을 텐데, Git GUI 클라이언트로는 저렇게까지 디테일하게 명령어를 수행하기엔 어렵지 않을까.

![git fork는 합니다](https://i.postimg.cc/XvmcJNdw/image.png)  
{: .rounded-edge-16 .center .w-3-quarter}

GUI를 쓰는 사람이라면 본인이 사용하는 클라이언트가 내부적으로 어떤 옵션을 사용하는지 한번 확인해 보자. GUI(Graphical User Interface), 즉 **인터페이스**는 그 역할에 따라 최선을 다해 Git 사용에 대한 캡슐화를 수행할 것이다. 겉으로 봐서는 우리는 이 프로그램이 강제 푸시에 `git push --force`를 쓰는지 `git push --force-with-lease`를 쓰는지 확인하기 어렵다. 다행히 내가 애용하는 도구 [Fork](https://git-fork.com/){:target="_blank"}에서는 오래전에 업데이트를 통해 `--force-with-lease` 옵션을 사용하도록 구현해 놓았더라.

## 내가 배운 것

이 주제에 왜 꽂힌 건지... 그래도 이렇게 당연해 보이는 것에도 계속 질문하고 궁금해 해야지 이렇게 몰랐던 옵션 하나라도 더 알아가는 거 아니겠어.

- 관성적으로 사용해 오던 형상 관리 도구에 대해 다시 한번 생각해 보는 계기
  - 게임 개발 쪽에서는 SVN도 여전히 사용하나봐
- `.git/refs/` 폴더와 Git에서 참조의 역할
- Fork는 한참 전에 이미 `--force-with-lease`를 쓰고 있었다.
  - 나는 이 옵션 존재도 몰랐었지만 이미 얼떨결에 써오고 있었군.
- `--force-if-includes`라는 옵션도 있다고 한다.
  - `--force-with-lease`와 함께 써야 한다.
  - 자세한 동작은 잘 이해가 되지 않는다.
  - "이 옵션은 로컬 커밋에 원격 저장소에 없는 커밋이 포함되어 있는 경우에만 강제 푸시를 허용합니다. 로컬 커밋에 새로운 커밋이 포함되어 있다면 강제 푸시가 가능하지만, 로컬 커밋이 원격 저장소의 커밋을 단순히 덮어쓰는 경우에는 강제 푸시를 허용하지 않습니다."
    - 라고 GPT가 요약해 줬으나 여전히 잘 이해가 되지 않는다.

참고한 자료들은 다음과 같다. 역시나 워낙 파편적으로 정보를 읽은 탓에 기억나는 대로만.

- [Git 로컬 브랜치를 원격 저장소로 푸시(Push)하는 방법](https://www.freecodecamp.org/korean/news/git-push-to-remote-branch/){:target="_blank"}  
- [--force 는 위험하니, git의 --force-with-lease 를 사용해보자](https://velog.io/@rawoon/-force-%EB%8A%94-%EC%9C%84%ED%97%98%ED%95%98%EB%8B%88-git%EC%9D%98-force-with-lease-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90){:target="_blank"}  
- [Understanding the Differences Between Git Push Force and Git Push Force-With-Lease](https://safjan.com/understanding-the-differences-between-git-push-force-and-git-push-force-with-lease/#git-push-force-with-lease){:target="_blank"}  

아래 두 글은 주제와 직접적으로 관련 있는 것은 아니지만 읽다 보니 재밌어서 기록해 두려고 한다.  

- [게임 개발 에서의 git vs svn](https://elky84.github.io/2020/07/19/git_vs_svn/){:target="_blank"}  
- [오픈소스의 승리](https://sangminpark.blog/2013/04/22/%ec%98%a4%ed%94%88%ec%86%8c%ec%8a%a4%ec%9d%98-%ec%8a%b9%eb%a6%ac/){:target="_blank"}