버전 관리(Git)
Git이 파일을 내용의 해시로 저장하는 방법부터 브랜치가 사실은 이름표라는 것, 병합 기준을 찾아 3-way로 합치는 과정, 리베이스와 충돌, 원격 저장소와 브랜치 전략, 큰 저장소를 다루는 법, 좋은 커밋 습관까지를 실제 해시를 계산해 가며 정리한다.
목차 (11)
버전 관리 시스템은 파일의 변경 이력을 남기고, 여러 사람이 동시에 고친 내용을 합치게 해 준다. 지금은 사실상 Git이 표준이다. 명령어를 외우는 것보다 Git이 안에서 무엇을 저장하는지를 알면 낯선 상황(엉킨 병합, 사라진 것 같은 커밋)에서도 길을 찾을 수 있다. 이 노트는 그 안쪽 구조에서 출발한다. 커밋이 배포로 이어지는 과정은 CI/CD 노트에서 이어진다.
객체 모델: 내용이 곧 주소다
Git 저장소의 .git/objects에는 세 종류의 객체가 쌓인다. 파일 내용 하나는 blob, 폴더 하나(이름과 그 아래 blob·tree의 목록)는 tree, 프로젝트 전체의 한 순간은 commit이다. 커밋은 최상위 트리 하나, 부모 커밋들, 작성자와 시각, 메시지를 담는다. 즉 커밋은 ‘바뀐 부분’이 아니라 그 순간의 스냅숏 전체를 가리킨다. 바뀌지 않은 파일은 같은 blob을 다시 가리키므로 공간을 거의 쓰지 않는다.
객체의 이름(ID)은 내용의 해시다. Git은 "blob 6\0"처럼 종류와 바이트 수를 머리말로 붙인 뒤 SHA-1을 계산한다. hello와 줄바꿈 한 줄짜리 파일의 blob ID는 어느 컴퓨터에서든 ce01362…이고, 느낌표 하나를 더하면 4effa19…로 완전히 달라진다. 이것을 콘텐츠 주소(content addressing)라 한다. 같은 내용은 저장소가 달라도 같은 ID를 가지니 중복이 저절로 사라지고, 커밋 ID 하나가 트리와 부모를 거쳐 이력 전체의 해시를 품으므로 과거의 어느 바이트를 몰래 바꿔도 ID가 어긋나 드러난다.
- SHA-1의 약점: 2017년 SHAttered가 내용이 다른 두 PDF의 SHA-1을 같게 만들어 보였다. Git은 그 뒤로 충돌 공격의 흔적을 알아채는 SHA-1 변형(sha1collisiondetection)을 쓴다. 공격을 막는 응급 처치일 뿐 해시를 바꾼 것은 아니다.
- SHA-256 전환:
git init --object-format=sha256로 SHA-256 저장소를 만들 수 있다. 같은 파일의 ID는 64자리2cf8d83…가 된다. 다만 SHA-1 저장소와 서로 주고받는 변환 계층은 설계 문서 단계에 있고 호스팅 서비스의 지원도 고르지 않다. Git은 BreakingChanges 문서에서 3.0부터 새 저장소의 기본값을 SHA-256으로 바꾸겠다고 예고했다. 2026년 10월 현재 최신 판은 2.56(2026년 9월)이고 3.0은 아직 나오지 않았다. - 팩 파일: 객체를 하나씩 파일로 두면 낭비라서, Git은 때때로 비슷한 객체끼리 델타 압축해 팩 파일(packfile)로 묶는다. 스냅숏 모델이 저장 공간을 많이 먹지 않는 이유다.
참조: 브랜치는 이름표다
커밋 ID는 외우기 어려우니 사람이 붙이는 이름이 참조(ref)다. 브랜치는 커밋 ID 하나를 담은 작은 파일(.git/refs/heads/main)일 뿐이고, 새 커밋을 만들면 지금 브랜치의 파일이 새 ID로 바뀐다. 브랜치를 만드는 비용이 거의 0인 이유다. 태그는 움직이지 않는 이름표다. 그리고 HEAD는 ‘지금 어느 브랜치에 있는지’를 가리키는 참조의 참조다.
- detached HEAD: 브랜치가 아니라 커밋을 직접 체크아웃하면 HEAD가 커밋 ID를 바로 담는다. 이 상태에서 만든 커밋은 어떤 브랜치도 가리키지 않아, 다른 곳으로 옮기면 찾기 어려워진다. 남기려면
git switch -c 새-브랜치로 이름을 붙인다. - reflog: HEAD와 브랜치가 어디를 거쳐 왔는지는
git reflog에 남는다(기본 90일). 잘못된 reset이나 리베이스로 ‘사라진’ 커밋도 대개 여기서 ID를 찾아 되살릴 수 있다. 아무도 가리키지 않는 객체는 나중에git gc가 지운다.
병합: 병합 기준을 찾아 3-way로 합친다
git merge feature는 지금 브랜치와 feature의 변경을 합친다. 먼저 두 끝에서 부모를 따라 올라가 공통 조상 중 가장 가까운 커밋, 병합 기준(merge base)을 찾는다. 그다음 기준에 견줘 ‘우리 쪽이 바꾼 것’과 ‘그쪽이 바꾼 것’을 모두 적용한다. 세 판(기준·우리·그쪽)을 보므로 3-way 병합이라 한다.
main은 C, feature는 E를 가리킨다. HEAD는 main이다. git merge feature를 실행한다.
- fast-forward: main이 아직 B에 있었다면 main은 E의 조상이다. 합칠 변경이 한쪽에만 있으니 새 커밋 없이 main 이름표만 E로 옮긴다. 같은 그래프로 계산하면 결과는
fast-forward다. 이력을 일부러 남기고 싶으면--no-ff로 병합 커밋을 강제한다. - 왜 두 판이 아니라 세 판인가: 두 판만 보면 어느 줄이 ‘한쪽이 추가한 것’인지 ‘다른 쪽이 지운 것’인지 알 수 없다. 기준이 있어야 누가 무엇을 바꿨는지 판정된다.
- 병합 기준이 여럿일 때: 서로 엇갈려 병합한(criss-cross) 이력에서는 가장 가까운 공통 조상이 둘 이상일 수 있다. Git 2.34부터 기본인
ort전략은 그 기준들을 먼저 병합해 가상의 기준 하나를 만든다.
리베이스와 병합
리베이스(git rebase main)는 feature의 커밋들을 하나씩 떼어 main 끝 위에 다시 적용한다. 결과는 갈래 없는 한 줄 이력이다. 그런데 커밋 ID는 부모 ID까지 넣어 해시하므로, 변경 내용이 같아도 부모가 바뀐 커밋은 새 커밋이다. 옛 D·E는 그대로 남아 있다가 아무도 가리키지 않으면 지워진다.
- 병합은 있었던 일을 그대로 남긴다. 언제 갈라져 언제 합쳤는지 보이지만, 큰 팀에서는 그래프가 얽힌다.
- 리베이스는 읽기 좋은 한 줄을 만든다. 대가로 이력을 고쳐 쓴다. 각 커밋이 새 기준 위에서 처음 시험되는 셈이라 중간 커밋이 빌드되지 않을 수도 있다.
- 규칙 하나: 남과 공유한 커밋은 리베이스하지 않는다. 다른 사람의 저장소에는 옛 D·E가 남아 있어, 다시 합칠 때 같은 변경이 두 번 들어온다. 내 브랜치를 고쳐 쓴 뒤 올려야 한다면
git push --force-with-lease를 쓴다. 원격이 내가 마지막으로 본 위치일 때만 덮어써서 남의 커밋을 날리는 사고를 막는다. - squash 병합: PR의 커밋을 하나로 뭉쳐 main에 올리는 방식이다. main은 PR 하나에 커밋 하나로 깔끔하지만 PR 안의 작은 단계는 사라진다.
충돌: 양쪽이 같은 곳을 다르게 고쳤을 때
3-way 병합의 규칙은 단순하다. 한쪽만 바꾼 곳은 바꾼 쪽을 고르고, 양쪽이 똑같이 바꿨으면 그것을 고르고, 양쪽이 다르게 바꾼 곳만 충돌이다. 아래 설정 파일에서 네 줄 중 2줄은 자동으로 정해지고 1줄만 사람이 고른다. 실제 Git은 줄 단위 diff로 바뀐 덩어리(hunk)를 맞춘 뒤 같은 규칙을 덩어리마다 적용한다.
- 기준도 함께 보기:
git config merge.conflictStyle zdiff3로 두면 충돌 표시에 기준 판(|||||||아래)도 나온다. ‘원래 info였는데 한쪽은 warn, 한쪽은 debug로 바꿨다’가 보여 판단이 훨씬 쉽다. - 문법상 충돌만 잡는다: 한쪽이 함수 이름을 바꾸고 다른 쪽이 옛 이름으로 새 호출을 추가하면, 줄이 겹치지 않아 깔끔하게 병합되지만 빌드가 깨진다. 병합한 결과도 CI에서 다시 시험해야 하는 이유다.
- 충돌을 줄이는 법: 브랜치를 짧게 유지하고 main을 자주 받아 들인다. 오래 떨어져 있을수록 같은 곳을 고칠 확률이 커진다. 포매터를 팀 전체가 똑같이 쓰는 것도 의미 없는 공백 충돌을 없앤다.
원격 저장소: fetch, pull, push
Git은 분산 시스템이다. 내 저장소와 원격(origin)은 서로 다른 커밋 그래프를 갖고, 명령으로 객체와 참조를 주고받는다. 내 저장소에는 원격 브랜치의 위치를 기억해 두는 원격 추적 브랜치(origin/main)가 있다. 이것은 마지막으로 통신했을 때의 모습이라, 그 사이 동료가 올린 커밋은 fetch하기 전까지 보이지 않는다.
- fetch를 먼저:
git fetch후git log main..origin/main으로 무엇이 들어올지 보고 합치면 놀랄 일이 줄어든다. - push가 거부될 때: 원격 main이 내 main의 조상이 아니면(그 사이 누가 올렸으면) push는 non-fast-forward로 거부된다. 받아서 합친 뒤 다시 올린다. 강제 push로 덮어쓰면 남의 커밋이 원격에서 사라진다.
- pull의 기본 동작: 받은 쪽과 내 쪽이 둘 다 앞서 있으면 병합 커밋이 생긴다.
pull.rebase설정으로 리베이스하게 하거나,pull.ff only로 fast-forward만 허용하고 나머지는 직접 고르게 할 수 있다.
브랜치 전략
팀이 브랜치를 언제 만들고 언제 합칠지 정한 약속이 브랜치 전략이다. 핵심 변수는 브랜치가 얼마나 오래 사는가다. 오래 살수록 병합이 커지고 충돌이 늘며, 통합된 상태로 시험받는 시간이 줄어든다.
- 트렁크 기반 개발: 모두가 main(트렁크)에 직접 또는 하루 안에 사라지는 짧은 브랜치로 합친다. 덜 만든 기능은 기능 플래그로 숨긴다. 지속적 통합(CI)의 원래 뜻에 가장 가깝고, 강한 자동 테스트가 전제다.
- GitHub Flow: main은 늘 배포할 수 있게 두고, 기능마다 브랜치를 만들어 PR로 리뷰받아 합친 뒤 배포한다. 웹 서비스에서 가장 흔한 방식이고, 브랜치를 짧게 유지하면 트렁크 기반과 거의 같아진다.
- Git Flow: main(출시본)과 develop(다음 판)을 따로 두고, feature·release·hotfix 브랜치를 규칙대로 오간다. 여러 판을 동시에 지원하거나 출시 주기가 정해진 제품(설치형 소프트웨어, 앱 스토어 심사)에 맞는다. 원작자도 지속적으로 배포하는 웹 앱이라면 더 단순한 흐름을 권한다.
큰 저장소: LFS, partial clone, 모노레포
Git은 clone할 때 모든 판의 모든 객체를 받는 것이 기본이다. 소스 코드에는 문제가 없지만, 판마다 통째로 바뀌는 큰 바이너리(텍스처, 영상, 모델 가중치)가 섞이면 저장소가 금세 무거워진다. 40MB 파일을 25번 고쳤다면 그 파일만으로 1000MB를 받아야 한다.
- Git LFS: 지정한 패턴(
*.psd등)의 파일을 포인터 파일(133바이트)로 바꿔 커밋하고, 실제 내용은 체크아웃할 때 필요한 판만 받는다. 위 예에서 받는 양은 약 25분의 1이 된다. 서버 저장 용량과 전송량이 따로 과금되는 경우가 많고, 바이너리는 병합할 수 없으니 잠금(git lfs lock)을 함께 쓴다. - partial clone:
git clone --filter=blob:none은 커밋과 트리는 다 받고 blob은 필요할 때 받는다. LFS 없이도 큰 이력을 가볍게 받을 수 있지만 서버가 지원해야 한다.--depth 1(얕은 clone)은 이력 자체를 자르므로 CI처럼 지금 판만 필요할 때 쓴다. - sparse-checkout: 저장소는 그대로 두고 작업 폴더에 일부 디렉터리만 꺼낸다. partial clone과 함께 쓰면 거대한 모노레포에서도 내 팀 폴더만 받아 일할 수 있다. 이런 설정을 한 번에 해 주는 도구가 Git에 들어 있는 Scalar다.
- 모노레포와 폴리레포: 여러 프로젝트를 한 저장소에 두면 서로 걸친 변경을 커밋 하나로 원자적으로 하고 의존성 판을 하나로 맞추기 쉽다. 대신 빌드·테스트를 바뀐 부분만 돌리는 도구(Bazel, Nx, Turborepo 등)와 위의 큰 저장소 기법이 필요해진다. 저장소를 나누면 각자 가볍지만, 여러 저장소에 걸친 변경은 판 맞추기가 일이 된다.
커밋 위생과 bisect
좋은 커밋은 한 가지 일만 하고, 그 자체로 빌드되며, 왜 바꿨는지 적혀 있다. 이 습관의 값어치는 버그를 추적할 때 드러난다. git bisect는 좋은 커밋과 나쁜 커밋 사이를 이분 탐색해 처음 나쁜 커밋을 찾는다. 사이에 커밋이 1,000개면 최악 10번, 100만 개라도 20번만 시험하면 된다. git bisect run ./test.sh처럼 스크립트를 주면 자동으로 끝까지 간다.
- 메시지: 첫 줄은 50자 안팎의 요약을 명령형으로, 한 줄 띄우고 본문에는 ‘무엇을’보다 ‘왜’를 적는다(Chris Beams의 일곱 규칙). Conventional Commits의
feat:·fix:머리말을 쓰면 변경 기록과 판 번호를 자동으로 만들 수 있다. - 태그와 판 번호: 출시한 커밋에는
git tag -a v1.4.0처럼 주석 태그를 단다. 번호는 흔히 유의적 버전(SemVer)의 MAJOR.MINOR.PATCH를 따른다. 호환되지 않는 API 변경이면 MAJOR, 호환되는 기능 추가면 MINOR, 호환되는 버그 수정이면 PATCH를 올린다. Conventional Commits의fix:는 PATCH,feat:는 MINOR,BREAKING CHANGE는 MAJOR에 대응한다. 0.y.z는 초기 개발 단계라 무엇이든 바뀔 수 있다는 뜻이고, 한 번 내보낸 판의 내용은 고치지 않고 새 판으로 낸다. - 올리기 전에 정리: 작업 중 커밋(‘wip’, ‘오타’)은 공유하기 전에
git rebase -i나git commit --fixup+--autosquash로 뜻 있는 단위로 묶는다.git add -p로 한 파일 안에서도 관련된 조각만 골라 담을 수 있다. - 넣지 말 것: 비밀값(API 키, 비밀번호)은 한 번 커밋되면 이력에 남아, 다음 커밋에서 지워도 clone한 누구나 꺼낼 수 있다. 커밋 전 검사(pre-commit 훅, 비밀값 스캐너)로 막고, 새어 나갔다면 이력 정리보다 키 교체가 먼저다. 빌드 산출물과 의존성 폴더는
.gitignore로 뺀다.
되돌리기: restore, reset, revert, cherry-pick
‘되돌린다’는 말이 가리키는 대상이 셋이다. 작업 폴더의 파일, 다음 커밋에 담을 스테이지(index), 그리고 브랜치가 가리키는 커밋이다. 명령마다 건드리는 층이 달라서, 무엇을 지우는지 모르고 쓰면 작업을 잃는다. 흔히 헷갈리는 다섯 가지를 표로 정리했다.
| 명령 | 하는 일 | 이력을 고쳐 쓰나 |
|---|---|---|
git restore 파일 | 작업 폴더의 파일을 스테이지(또는 --source로 준 커밋) 내용으로 되돌린다. --staged는 스테이지에서만 내린다. | 아니다. 다만 저장 안 한 수정은 사라진다. |
git reset --soft 커밋 | 브랜치 이름표만 그 커밋으로 옮긴다. 스테이지와 파일은 그대로라 커밋 몇 개를 하나로 다시 묶을 때 쓴다. | 그렇다 |
git reset 커밋(--mixed, 기본) | 이름표와 스테이지를 옮기고 파일은 둔다. 변경이 ‘커밋 안 한 수정’으로 돌아온다. | 그렇다 |
git reset --hard 커밋 | 이름표·스테이지·작업 폴더를 모두 그 커밋으로 맞춘다. 커밋하지 않은 수정은 reflog에도 남지 않아 되살릴 수 없다. | 그렇다 |
git revert 커밋 | 그 커밋의 변경을 거꾸로 적용한 새 커밋을 만든다. 옛 커밋은 그대로 남는다. | 아니다 |
- 공유한 뒤에는 revert: 리베이스와 같은 이유로, 이미 push한 커밋을 reset으로 지우고 강제 push하면 동료의 저장소와 어긋난다. 남이 받았을 수 있는 커밋은 revert로 되돌리고, reset은 아직 내 저장소에만 있는 커밋에 쓴다. 병합 커밋을 revert할 때는 어느 부모를 본줄기로 볼지
-m 1처럼 정해 줘야 한다. - cherry-pick: 다른 브랜치의 커밋 하나가 들인 변경만 지금 브랜치에 새 커밋으로 옮긴다. main의 버그 수정을 출시 브랜치에 가져올 때 흔히 쓴다. 부모가 다르니 ID도 새로 생기고, 나중에 두 브랜치를 병합하면 같은 변경이 두 커밋으로 남는다.
-x를 주면 메시지에 원래 커밋 ID를 적어 출처를 따라갈 수 있다. - stash: 커밋하기 이른 수정을 잠시 치워 두고 깨끗한 상태로 브랜치를 옮길 때 쓴다. 기본으로는 Git이 추적하는 파일만 치우므로 새로 만든 파일까지 넣으려면
-u를 준다. 오래 쌓아 두면 무엇이었는지 잊으니, 길어질 일이면 임시 브랜치에 커밋하는 편이 안전하다.
핵심 자료
| 자료 | 한 줄 요지 |
|---|---|
| Pro Git (Scott Chacon, Ben Straub) | Git 공식 사이트에 무료로 공개된 책. 기초부터 브랜치, 서버, 내부 구조(10장)까지 한 권에 담았다. |
| Pro Git 10.2 Git Objects | blob·tree·commit을 hash-object, cat-file로 직접 만들어 보며 내용 주소 저장소를 설명한다. |
| Git 레퍼런스 문서 | 명령마다의 공식 설명서. 옵션의 정확한 뜻은 결국 여기서 확인한다. |
| Hash Function Transition | SHA-1에서 SHA-256으로 옮겨 가는 설계 문서. 두 형식을 함께 쓰는 변환 계획까지 적혀 있다. |
| Git BreakingChanges | Git 3.0에서 바뀔 기본값 목록. 새 저장소의 기본 해시를 SHA-256으로 바꾸는 계획이 있다. |
| Trunk Based Development | 짧은 갈래와 잦은 통합을 중심으로 한 브랜치 전략을 사례와 함께 정리한 사이트. |
| A successful Git branching model (Vincent Driessen) | Git Flow의 원문(2010). 2020년에 덧붙인 글에서 지속적으로 배포하는 웹 앱에는 더 단순한 흐름을 권했다. |
| Conventional Commits 1.0.0 | feat·fix 같은 머리말로 커밋 메시지를 쓰는 약속. 변경 기록과 판 번호를 자동으로 만들 수 있다. |
- Pro Git (Scott Chacon, Ben Straub)
- Pro Git 10.2 Git Objects
- Git 레퍼런스 문서
- Hash Function Transition
- Git BreakingChanges
- Trunk Based Development
- A successful Git branching model (Vincent Driessen)
- Conventional Commits 1.0.0
- git merge-base 설명서
- git rebase 설명서
- git bisect 설명서
- Semantic Versioning 2.0.0
- git restore 설명서
- git reset 설명서
- git revert 설명서
- git cherry-pick 설명서
- git stash 설명서
- Partial Clone 설계 문서
- git sparse-checkout 설명서
- Scalar — 큰 저장소용 Git 설정 도구
- Git LFS
- Git LFS 포인터 파일 명세
- SHAttered — 첫 SHA-1 충돌 (2017)
- GitHub flow (GitHub Docs)
- How to Write a Git Commit Message (Chris Beams)