git help -a를 치면 명령어가 150개 넘게 쏟아진다. 그런데 실무에서 하루에 쓰는 것은 스무 개가 채 안 된다. 문제는 그 스무 개 안에도 서로 비슷해 보이는 것들이 섞여 있다는 점이다. reset과 revert와 restore는 이름이 셋 다 "되돌린다"인데 하는 일이 전혀 다르다. fetch와 pull도 어느 쪽을 써야 하는지 매번 헷갈린다.
이 글은 명령어 사전이 아니다. 파일 하나가 내 손에서 원격 저장소까지 가는 길을 따라가면서, 그 길 위의 각 지점에서 실제로 무엇을 치는지, 잘못 갔을 때 어떻게 돌아오는지를 정리한다. 외울 것은 명령어가 아니라 지도다.
네 개의 자리
Git을 쓰면서 헷갈리는 것의 절반은 "내 변경이 지금 어디에 있는지" 모르기 때문이다. 자리는 네 개뿐이다.

| 자리 | 뜻 | 여기 있는지 보는 법 |
|---|---|---|
| 작업 트리 | 편집기에서 방금 고친 실제 파일 | git diff |
| 스테이지(인덱스) | 다음 커밋에 담기로 표시한 것 | git diff --staged |
| 로컬 저장소 | 내 컴퓨터에 쌓인 커밋 | git log |
| 원격 저장소 | GitHub 등에 올라간 커밋 | git log origin/main |
git status가 알려주는 것이 정확히 이 자리 정보다. 기본 출력은 말이 너무 많으므로 짧은 꼴을 쓴다.
git status -sb## feature/login...origin/feature/login [ahead 2]
M app/login/page.tsx
M lib/auth.ts
?? app/login/form.tsx앞의 두 칸이 각각 스테이지와 작업 트리다. 왼쪽 칸이 M이면 스테이지에 담긴 수정, 오른쪽 칸이 M이면 아직 담지 않은 수정, ??는 Git이 모르는 새 파일이다. 첫 줄의 [ahead 2]는 원격보다 커밋 두 개가 앞서 있다는 뜻이다. 이 한 줄만 읽어도 지금 상태가 전부 나온다.
담기: add .을 습관으로 두지 않는다
git add .은 편하지만 커밋을 망치는 가장 흔한 원인이다. 디버깅용 console.log, 실험하다 만 코드, 실수로 만든 임시 파일이 함께 딸려 들어간다. 리뷰어는 그것까지 읽어야 하고, 나중에 git log로 원인을 추적할 때 커밋 하나가 서로 관계없는 변경 다섯 개를 안고 있으면 되돌릴 방법이 없어진다.
대신 조각 단위로 담는다.
git add -p바뀐 덩어리(hunk)를 하나씩 보여주면서 담을지 묻는다.
@@ -12,7 +12,9 @@ export async function signIn(form: FormData) {
const email = String(form.get('email') ?? '');
+ console.log('email', email);
const password = String(form.get('password') ?? '');
(1/3) Stage this hunk [y,n,q,a,d,s,e,?]?여기서 자주 쓰는 답은 네 개다.
| 키 | 하는 일 |
|---|---|
y |
이 덩어리를 담는다 |
n |
건너뛴다 |
s |
덩어리를 더 잘게 쪼갠다 |
e |
편집기를 열어 줄 단위로 고른다 |
add -p를 쓰기 시작하면 커밋 단위가 자연히 작아진다. 커밋이 작아지면 revert도, cherry-pick도, bisect도 그때부터 쓸모가 생긴다. 서로 다른 변경 열 개가 든 커밋 하나는 되돌릴 수 없는 덩어리다.
담은 것을 확인하는 습관도 같이 붙인다.
git diff --staged커밋 직전에 이것만 한 번 읽어도 오타와 찌꺼기 코드의 대부분이 걸린다.
커밋: 나중의 나에게 보내는 쪽지
커밋 메시지는 코드 리뷰용이 아니라 여섯 달 뒤 원인을 찾는 사람용이다. 그 사람은 대개 나 자신이다. 무엇을 고쳤는지는 diff가 말해주므로, 메시지에는 diff가 말하지 못하는 것을 적는다.
fix: 로그인 후 리다이렉트가 외부 주소로 나가는 문제 차단
callbackUrl을 그대로 믿고 리다이렉트하면 피싱 페이지로 보낼 수 있다.
같은 오리진인지 확인한 뒤에만 이동하고, 아니면 홈으로 보낸다.제목은 명령형 현재 시제로 한 줄, 50자 안쪽. 본문은 빈 줄 하나를 띄우고 "왜"를 적는다. git log --oneline이 제목만 보여주기 때문에 이 구조가 실제로 도움이 된다.
방금 커밋에서 오타를 발견했다면 새 커밋을 쌓지 말고 고쳐 넣는다.
git commit --amend # 메시지와 스테이지 내용을 방금 커밋에 합친다
git commit --amend --no-edit # 메시지는 그대로 두고 내용만 합친다--amend는 기존 커밋을 고치는 것이 아니라 새 커밋으로 갈아 끼우는 것이다. 해시가 바뀐다. 그래서 이미 push한 커밋에 쓰면 원격과 어긋난다. 이 경우의 대처는 뒤의 push 절에서 다룬다.
되돌리기: 셋을 구분하는 한 가지 기준
되돌리는 명령은 셋인데, 고르는 기준은 하나다. 되돌릴 것이 지금 어느 자리에 있는가.
아직 커밋하지 않은 변경을 버린다
git restore app/login/page.tsx # 파일 하나를 마지막 커밋 상태로
git restore . # 작업 트리 전체를restore는 Git 2.23에서 들어온 명령이다. 그전에는 git checkout -- <파일>을 썼는데, checkout이 브랜치 이동과 파일 복원을 겸하는 바람에 사고가 잦았다. 지금은 restore(파일 복원)와 switch(브랜치 이동)로 나뉘어 있으니 새로 쓰는 코드에서는 이쪽을 쓴다.
이 명령에는 되돌리기가 없다. 버린 작업 트리 변경은 Git 어디에도 남지 않는다. 커밋도 스테이지도 거치지 않은 것은 Git이 본 적이 없기 때문이다.
스테이지에서만 내린다
git restore --staged lib/auth.ts파일 내용은 그대로 두고 "다음 커밋에 담기"만 취소한다. 예전 git reset HEAD <파일>과 같은 일을 한다.
커밋을 되돌린다
git reset --soft HEAD~1 # 커밋만 취소. 변경은 스테이지에 그대로
git reset HEAD~1 # 커밋과 스테이지 취소. 변경은 작업 트리에
git reset --hard HEAD~1 # 전부 버린다. 변경도 사라진다
세 옵션의 차이는 "어디까지 되감는가"다. --soft는 커밋만, 기본값인 --mixed는 커밋과 스테이지, --hard는 작업 트리까지.
실무에서 --soft가 가장 쓸모 있다. 커밋을 만들고 나서 "이건 두 개로 쪼갰어야 했는데" 싶을 때 reset --soft HEAD~1로 커밋만 풀고 add -p로 다시 나눠 담으면 된다.
--hard는 위험하지만 완전히 잃는 것은 아니다. 한 번이라도 커밋까지 갔던 것은 git reflog로 살릴 수 있다. 이 복구법은 다음 글에서 자세히 다룬다.
이미 push한 커밋을 되돌린다
git revert 3f9a2c1revert는 커밋을 지우지 않는다. 그 커밋을 정확히 뒤집는 새 커밋을 쌓는다. 이력이 그대로 남으므로 남들이 이미 받아 간 커밋에도 안전하다. 협업 브랜치에서 reset --hard와 강제 push로 지우면 팀원 전부의 로컬이 깨진다.
머지 커밋을 되돌릴 때는 어느 쪽 줄기를 남길지 알려줘야 한다.
git revert -m 1 <머지커밋> # 1번 부모(대개 main)를 기준으로 되돌린다잠깐 치워두기
리뷰 요청이 급하게 들어와서 지금 브랜치를 떠나야 하는데 작업이 어중간할 때 쓴다.
git stash push -u -m "로그인 폼 작업중"-u가 중요하다. 이것이 없으면 아직 추적되지 않는 새 파일(??)은 치워지지 않고 그대로 남는다. 새로 만든 컴포넌트 파일이 다른 브랜치까지 따라와 어리둥절해지는 이유가 대개 이것이다.
git stash list # stash@{0}: On feature/login: 로그인 폼 작업중
git stash show -p # 무엇이 들었는지 diff로 확인
git stash pop # 꺼내고 목록에서 지운다
git stash apply # 꺼내되 목록에는 남긴다pop은 충돌이 나면 목록에서 지우지 않고 남겨 두므로 잃을 걱정은 없다. 다만 stash가 서너 개 쌓이면 어느 것이 무엇인지 알 수 없어지니 -m으로 이름을 붙이는 습관이 필요하다.
브랜치를 자주 오간다면 stash 대신 워크트리가 낫다.
git worktree add ../blog-hotfix main같은 저장소를 다른 폴더에 하나 더 펼친다. 브랜치를 오갈 때 node_modules나 빌드 산출물이 통째로 다시 만들어지는 낭비가 없어서, 긴급 수정이 잦은 프로젝트에서 특히 유용하다. 다 쓴 뒤에는 git worktree remove ../blog-hotfix로 정리한다.
찾기: log는 검색 엔진이다
git log를 시간순 목록으로만 쓰면 절반도 못 쓰는 것이다.
# 브랜치 흐름을 그래프로
git log --oneline --graph --all -20
# 이 문자열이 등장하거나 사라진 커밋만
git log -S "callbackUrl" --oneline
# 이 파일의 변경 이력만, diff까지
git log -p -- lib/auth.ts
# 특정 사람이 최근 일주일에 한 것
git log --author=pingulee --since="1 week ago" --oneline
# 커밋 메시지 본문까지 검색
git log --grep="리다이렉트"-S(pickaxe)가 특히 강력하다. "이 설정값을 누가 왜 지웠지"를 추적할 때 가장 빠른 길이다. 문자열이 아니라 정규식으로 찾고 싶으면 -G를 쓴다.
한 줄이 왜 이렇게 됐는지 볼 때는 blame을 쓴다.
git blame -L 40,60 lib/auth.ts포매팅 커밋이 blame을 가리는 경우에는 무시 목록을 만든다.
echo "9a3f2b1c 전체 프리티어 적용" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revsGitHub도 이 파일을 읽는다. 대규모 포매팅을 한 번 돌린 저장소라면 반드시 만들어 둘 값어치가 있다.
원격과 주고받기
fetch와 pull
git fetch origin # 원격 내용을 가져오기만 한다. 내 브랜치는 그대로
git pull # fetch + merge (설정에 따라 rebase)pull은 가져오기와 합치기를 한 번에 한다. 편하지만 무엇이 오는지 모른 채 합쳐진다. 습관을 하나 들이면 사고가 크게 준다.
git fetch origin
git log --oneline HEAD..origin/main # 내가 받게 될 커밋 목록
git diff HEAD...origin/main # 그 변경 내용점 두 개와 점 세 개의 차이가 헷갈린다면 이렇게 기억한다. log에서 A..B는 "B에는 있고 A에는 없는 커밋", diff에서 A...B는 "갈라진 지점부터 B까지의 변경"이다.
pull의 기본 동작은 merge라서 이력에 의미 없는 머지 커밋이 쌓인다. 리베이스로 바꾸는 편이 대체로 깔끔하다.
git config --global pull.rebase truemerge와 rebase 중 무엇을 언제 쓸지는 이 시리즈의 세 번째 글에서 따로 다룬다.
push
git push -u origin feature/login # 첫 push. 추적 브랜치까지 설정
git push # 그다음부터는 이걸로 끝--amend나 rebase로 이력을 고친 뒤에는 그냥 push가 거부된다. 이때 --force를 쓰면 안 된다. 내가 fetch한 뒤 동료가 push한 커밋이 있으면 그것까지 지워버린다.
git push --force-with-lease--force-with-lease는 원격이 내가 마지막으로 본 상태 그대로일 때만 덮어쓴다. 그사이 누가 push했으면 거부한다. 강제 push가 필요한 상황에서는 예외 없이 이쪽을 쓴다.
.gitignore가 안 먹을 때
이미 추적 중인 파일은 .gitignore에 적어도 계속 따라온다. Git은 한 번 추적을 시작한 파일에 대해서는 무시 규칙을 보지 않는다.
git rm --cached .env.local # 추적만 끊는다. 파일은 남는다
git rm -r --cached .next # 폴더면 -r주의할 점은, 이미 커밋된 비밀은 이렇게 지워도 이력에 그대로 남아 있다는 것이다. 과거 커밋에서 실제로 지우는 방법은 다음 글에서 다룬다. 결론만 미리 적으면, 이력을 지우든 말든 유출된 키는 즉시 폐기하고 새로 발급하는 것이 먼저다.
처음 한 번만 해두는 설정
# 신원
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
# 기본 브랜치 이름
git config --global init.defaultBranch main
# pull은 리베이스로
git config --global pull.rebase true
# 같은 충돌을 다시 만나면 지난번 해결을 재사용
git config --global rerere.enabled true
# 줄바꿈: 윈도우
git config --global core.autocrlf true
# 맥과 리눅스
git config --global core.autocrlf inputrerere(reuse recorded resolution)는 이름이 낯설어서 잘 안 쓰이는데, 긴 브랜치를 여러 번 리베이스하는 상황에서 같은 충돌을 반복해 푸는 고통을 없애준다. 켜두면 손해 볼 일이 없다.
별칭은 취향껏 만들되, 몇 개는 거의 모두에게 쓸모 있다.
git config --global alias.st "status -sb"
git config --global alias.lg "log --oneline --graph --decorate -20"
git config --global alias.last "log -1 --stat"
git config --global alias.unstage "restore --staged"하루 흐름 요약
git switch -c feature/login # 브랜치 파기
# ... 작업 ...
git status -sb # 어디에 무엇이 있나
git add -p # 조각 단위로 담기
git diff --staged # 담긴 것 확인
git commit # 왜를 적는다
git fetch origin # 무엇이 왔는지 먼저 보고
git log --oneline HEAD..origin/main
git pull # 합치기
git push -u origin feature/login명령어 스무 개가 여기 다 있다. Git이 어렵게 느껴지는 이유는 명령어가 많아서가 아니라, 지금 내 변경이 네 자리 중 어디에 있는지 모르는 채로 명령을 치기 때문이다. git status -sb를 자주 치는 것만으로도 대부분의 사고는 일어나기 전에 막힌다.
다음 글에서는 그럼에도 사고가 났을 때, 그러니까 reset --hard로 날렸거나 브랜치를 지웠거나 엉뚱한 브랜치에 커밋했을 때 되살리는 방법을 다룬다.
댓글 0
첫 댓글을 남겨보세요.