작업하던 파일을 main_final.py, main_final2.py로 복사해 두다 보면 어느 것이 최신인지 금방 헷갈린다. 며칠 전 동작하던 코드로 돌아가고 싶은데 그 시점의 사본이 없기도 하다. Git은 이 문제를 파일 사본이 아니라 “변경 이력”으로 해결한다.
이 글에서는 Git 저장소를 처음 만드는 것부터 커밋, 되돌리기, 브랜치 병합, 원격 저장소 동기화까지 기본 명령을 실제 실행 결과와 함께 정리한다. 아래 출력은 모두 Git 2.43.0에서 직접 실행한 것이다.
최초 설정
커밋에는 작성자 이름과 이메일이 기록되므로 설치 후 한 번은 지정해야 한다. --global은 사용자 홈의 ~/.gitconfig에 저장되어 모든 저장소에 적용된다.
$ git config --global user.name "Hong Gildong"
$ git config --global user.email "hong@example.com"
$ git config --global init.defaultBranch main
$ git config --global core.editor vim
$ git config --global --list
user.name=Hong Gildong
user.email=hong@example.com
init.defaultbranch=main
core.editor=vim
init.defaultBranch를 지정하지 않으면 새 저장소의 기본 브랜치가 master가 되어 GitHub 기본값인 main과 어긋난다.
저장소 만들고 첫 커밋하기
Git은 파일을 세 영역으로 나눠 다룬다. 이 구분만 잡히면 대부분의 명령이 이해된다.
| 영역 | 설명 | 이동하는 명령 |
|---|---|---|
| 작업 트리 (working tree) | 지금 편집 중인 실제 파일 | — |
| 인덱스 (index, staging area) | 다음 커밋에 담을 변경을 모아두는 곳 | git add |
| 저장소 (HEAD) | 확정된 커밋 이력 | git commit |
$ git init
Initialized empty Git repository in /home/hong/myapp/.git/
$ git status
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
hello.py
nothing added to commit but untracked files present (use "git add" to track)
hello.py는 아직 Git이 추적하지 않는 Untracked 상태다. add로 인덱스에 올린 뒤 커밋한다.
$ git add hello.py
$ git status --short
A hello.py
$ git commit -m "feat: greet 함수 추가"
[main (root-commit) 3ff8434] feat: greet 함수 추가
1 file changed, 6 insertions(+)
create mode 100644 hello.py
$ git log --oneline
3ff8434 feat: greet 함수 추가
git status --short의 두 글자는 각각 인덱스와 작업 트리의 상태다. A 는 인덱스에 추가됨, M은 작업 트리만 수정됨을 뜻한다.
무엇이 바뀌었는지 확인하기
파일 하나는 수정만 하고, 다른 하나는 add까지 한 상태에서 두 diff를 비교하면 차이가 분명해진다.
$ git status --short
A README.md
M hello.py
$ git diff # 작업 트리 vs 인덱스 (아직 스테이징 안 한 변경)
diff --git a/hello.py b/hello.py
index 110c807..57c66a6 100644
--- a/hello.py
+++ b/hello.py
@@ -1,5 +1,5 @@
def greet(name):
- return f"Hello, {name}!"
+ return f"안녕하세요, {name}님!"
if __name__ == "__main__":
$ git diff --staged # 인덱스 vs 마지막 커밋 (커밋될 내용)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9bc27aa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# myapp
+
+인사말을 출력하는 예제 프로그램.
git diff는 스테이징하지 않은 변경만, git diff --staged는 커밋될 내용만 보여준다. 커밋 직전에 --staged로 한 번 확인하는 습관이 실수를 크게 줄인다.
되돌리기: 커밋 전
아직 커밋하지 않은 변경은 restore로 되돌린다. 인덱스만 내릴지, 파일 내용까지 폐기할지가 다르다.
$ git status --short
M hello.py
?? scratch.txt
$ git restore --staged hello.py # 인덱스에서만 내림 (수정 내용은 남음)
M hello.py
?? scratch.txt
$ git restore hello.py # 작업 트리 변경까지 폐기
?? scratch.txt
$ git clean -n # 추적되지 않는 파일 확인 (실제 삭제는 -f)
Would remove scratch.txt
git restore <파일>로 폐기한 변경은 Git이 기록한 적이 없으므로 복구할 수 없다.
되돌리기: 커밋 후
직전 커밋의 메시지나 내용을 고칠 때는 --amend를 쓴다. 새 커밋으로 대체되므로 해시가 바뀐다.
$ git log --oneline -1
a39988a chore: 재시도 상수 추ㄱ
$ git commit --amend -m "chore: 재시도 상수 추가" # 메시지 수정
[main 6ddad4e] chore: 재시도 상수 추가
1 file changed, 1 insertion(+)
$ git log --oneline -1
6ddad4e chore: 재시도 상수 추가
커밋 자체를 물릴 때는 reset을 쓰는데, 세 가지 모드가 “어디까지 되돌리는가”에서 갈린다. 같은 시작 상태에서 각각 실행한 결과다.
$ git reset --soft HEAD~1
$ git status --short
M hello.py ← 변경이 인덱스에 남는다 (바로 다시 커밋 가능)
$ git reset --mixed HEAD~1 # 기본값
Unstaged changes after reset:
M hello.py
$ git status --short
M hello.py ← 변경이 작업 트리에만 남는다
$ git reset --hard HEAD~1
HEAD is now at 22d3ba7 feat: 한국어 인사말과 README 추가
$ git status --short
← 변경이 통째로 사라진다
| 모드 | HEAD | 인덱스 | 작업 트리 | 주 용도 |
|---|---|---|---|---|
--soft | 이동 | 유지 | 유지 | 커밋만 합치거나 다시 나누기 |
--mixed | 이동 | 초기화 | 유지 | 스테이징을 다시 하고 싶을 때 |
--hard | 이동 | 초기화 | 초기화 | 변경을 완전히 버릴 때 |
이미 push한 커밋에는 reset 대신 revert를 쓴다. 이력을 고치지 않고 “되돌리는 커밋”을 새로 쌓기 때문에 협업 중인 브랜치에서 안전하다.
$ git revert --no-edit HEAD
[main 614d608] Revert "chore: 재시도 상수 추가"
1 file changed, 1 deletion(-)
$ git log --oneline -3
614d608 Revert "chore: 재시도 상수 추가"
6ddad4e chore: 재시도 상수 추가
99aa222 Merge branch 'feature/uppercase'
브랜치와 병합
브랜치는 커밋을 가리키는 이름표에 가깝다. 생성 비용이 거의 없으므로 작업 단위마다 만들어 쓴다.
$ git switch -c feature/uppercase # 브랜치 생성과 이동을 한 번에
Switched to a new branch 'feature/uppercase'
... 작업 후 커밋 ...
$ git switch main
Switched to branch 'main'
... main에서도 같은 줄을 수정하고 커밋 ...
$ git log --oneline --graph --all
* 1a758c2 feat: 이름을 대문자로 표시
| * dfa1de9 feat: 인사말 문구 변경
|/
* 99ce62c chore: 디버그 플래그 추가
* 22d3ba7 feat: 한국어 인사말과 README 추가
* 3ff8434 feat: greet 함수 추가
두 브랜치가 같은 줄을 고쳤으므로 병합하면 충돌이 난다. 충돌은 오류가 아니라 “판단해 달라”는 요청이다.
$ git merge feature/uppercase
Auto-merging hello.py
CONFLICT (content): Merge conflict in hello.py
Automatic merge failed; fix conflicts and then commit the result.
$ git status --short
UU hello.py
$ cat hello.py
def greet(name):
<<<<<<< HEAD
return f"{name}님, 좋은 하루입니다!"
=======
return f"{name.upper()}님 환영합니다!"
>>>>>>> feature/uppercase
<<<<<<<와 >>>>>>> 사이에서 위쪽이 현재 브랜치(HEAD), 아래쪽이 병합해 오는 브랜치다. 표시줄까지 모두 지우고 원하는 최종 코드만 남긴 뒤 add하면 해결된다.
$ git add hello.py && git commit --no-edit # 충돌 해결 후 병합 커밋
[main 99aa222] Merge branch 'feature/uppercase'
$ git log --oneline --graph -5
* 99aa222 Merge branch 'feature/uppercase'
|\
| * 1a758c2 feat: 이름을 대문자로 표시
* | dfa1de9 feat: 인사말 문구 변경
|/
* 99ce62c chore: 디버그 플래그 추가
* 22d3ba7 feat: 한국어 인사말과 README 추가
$ git branch -d feature/uppercase
Deleted branch feature/uppercase (was 1a758c2).
작업을 잠시 치워두기
커밋하기 애매한 변경을 들고 다른 브랜치로 가야 할 때 stash를 쓴다.
$ git stash
Saved working directory and index state WIP on main: 2249ab1 feat: farewell 함수 추가
$ git status --short
← 작업 트리가 깨끗해졌다
$ git stash list
stash@{0}: WIP on main: 2249ab1 feat: farewell 함수 추가
$ git stash pop # 다시 꺼내오기
Changes not staged for commit:
modified: hello.py
Dropped refs/stash@{0} (e88fdb9c76cfee69147608f84da25a62cd4a7ce7)
.gitignore로 커밋에서 제외하기
빌드 산출물, 로그, 자격증명 파일은 저장소에 넣지 않는다. 저장소 루트에 .gitignore를 두면 된다.
__pycache__/
*.log
.env$ git status --short
?? .env
?? __pycache__/
?? app.log
... .gitignore 작성 후 ...
$ git status --short
?? .gitignore
$ git check-ignore -v app.log # 어떤 규칙에 걸렸는지 확인
.gitignore:2:*.log app.log
주의할 점은 .gitignore가 아직 추적되지 않는 파일에만 적용된다는 것이다. 이미 커밋된 파일은 규칙을 추가해도 계속 따라온다.
$ git status --short
M tracked.log ← .gitignore에 *.log가 있어도 잡힌다
$ git rm --cached tracked.log # 추적만 해제 (파일은 남김)
rm 'tracked.log'
$ git status --short
D tracked.log
원격 저장소와 동기화
GitHub 같은 원격 저장소는 URL에 이름을 붙여 등록해 두고 쓴다. 관례적인 이름이 origin이다.
$ git remote add origin https://github.com/hong/myapp.git
$ git push -u origin main
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
$ git remote -v
origin https://github.com/hong/myapp.git (fetch)
origin https://github.com/hong/myapp.git (push)
-u는 로컬 main과 원격 origin/main을 연결해 둔다. 이후로는 인자 없이 git push, git pull만 쓰면 된다.
$ git clone https://github.com/hong/myapp.git teammate
Cloning into 'teammate'...
done.
... 다른 작업 공간에서 커밋 후 ...
$ git push
831f922..2249ab1 main -> main
... 원래 작업 공간으로 돌아와서 ...
$ git pull
831f922..2249ab1 main -> origin/main
Updating 831f922..2249ab1
Fast-forward
hello.py | 2 ++
1 file changed, 2 insertions(+)
Fast-forward는 내 쪽에 새 커밋이 없어 포인터만 앞으로 옮겼다는 뜻이다. 양쪽에 커밋이 쌓여 있으면 이때도 병합이 일어나고 충돌이 날 수 있다.
주의사항
- 이미 push한 커밋에는
reset·--amend를 쓰지 않는다. 해시가 바뀌어 남의 이력과 어긋나고, 되돌리려면 강제 push가 필요하다. 공유된 브랜치에서는revert를 쓴다. git reset --hard와git clean -f는 확인 후 실행한다. 커밋되지 않은 변경은 어디에도 기록이 없어 복구가 불가능하다.clean은-n으로 먼저 목록을 확인한다.- 커밋한 비밀번호·API 키는 파일을 지워도 이력에 남는다. 이력 재작성(
git filter-repo등)이 필요하고, 그 전에 해당 자격증명을 폐기하는 것이 우선이다. - 커밋을 날린 것 같아도
git reflog부터 확인한다. HEAD가 거쳐 간 위치가 기본 90일간 남아 있어 대부분 복구된다. - 커밋 메시지는 무엇을 왜 바꿨는지 한 줄로 쓴다. “수정”, “update” 같은 메시지는 나중에
git log를 훑을 때 아무 정보도 주지 않는다.
마지막 항목의 복구는 다음과 같이 동작한다.
$ git reset --hard HEAD~2 # 커밋 2개를 실수로 날림
HEAD is now at 21dfff6 chore: 로그 파일 실수로 커밋
$ git reflog -3
21dfff6 HEAD@{0}: reset: moving to HEAD~2
2249ab1 HEAD@{1}: pull: Fast-forward
831f922 HEAD@{2}: commit: chore: 로그 파일 추적 해제
$ git reset --hard 2249ab1 # reflog에서 찾은 해시로 복구
HEAD is now at 2249ab1 feat: farewell 함수 추가
마무리
실무에서 쓰는 명령은 결국 아래 정도로 좁혀진다. 나머지는 필요할 때 찾아 쓰면 된다.
| 상황 | 명령 |
|---|---|
| 상태 확인 | git status --short, git log --oneline --graph |
| 변경 확인 | git diff, git diff --staged |
| 커밋 | git add <파일> → git commit -m "메시지" |
| 되돌리기 (커밋 전) | git restore [--staged] <파일> |
| 되돌리기 (커밋 후) | git reset --soft|--mixed|--hard, git revert |
| 브랜치 | git switch -c <브랜치>, git merge, git branch -d |
| 원격 | git clone, git push, git pull |
| 비상시 | git reflog |
명령을 외우기보다 작업 트리·인덱스·저장소 세 영역 사이에서 지금 무엇이 어디에 있는지 git status로 확인하는 습관이 먼저다. 상태를 읽을 수 있으면 어떤 명령이 필요한지는 대체로 따라온다.