3장. 버전 관리와 변경 검토

학습 목표

  • 작업 트리, 스테이징 영역, 커밋 기록, 원격 저장소의 관계를 설명한다.
  • 비교 대상을 골라 diff를 읽고, 에이전트의 변경에서 범위 밖 수정과 검사를 약하게 만든 변경을 찾아낸다.
  • 하나의 목적만 담은 커밋을 만들고, 한 파일 안의 변경도 골라서 커밋한다.
  • 변경이 공유됐는지에 따라 restore, reset, revert, stash 가운데 알맞은 되돌리기를 고른다.
  • 사라진 커밋을 reflog로 되찾고, 충돌을 두 변경의 의도를 살려 푼다.

들어가며

코딩 에이전트는 몇 분 만에 수십 개 파일을 고칩니다. 사람이 하루 걸릴 일을 대신해 주는 셈이지만, 그만큼 무엇이 바뀌었는지 놓치기도 쉽습니다. 에이전트의 보고는 "날짜 필터를 추가했습니다" 한 줄인데, 실제로는 날짜 계산 함수가 통째로 새로 짜였고 패키지 하나가 새로 들어왔으며 테스트 하나가 조용히 꺼져 있을 수 있습니다.

이 차이를 보여 주는 것이 Git입니다. Git은 무엇이 바뀌었는지 줄 단위로 보여 주고, 마음에 드는 상태를 기록해 두었다가 언제든 돌아가게 해 줍니다. 에이전트와 일할 때 Git은 선택이 아니라 안전망입니다. 에이전트에게 과감한 시도를 맡길 수 있는 것도 언제든 되돌릴 지점이 있기 때문입니다.

이 장은 Git의 기본 구조에서 시작해 diff를 읽고 검토하는 법, 커밋과 브랜치를 쓰는 법, 충돌을 푸는 법, 상황에 맞게 되돌리는 법을 차례로 다룹니다. 깊이 읽기에서는 사라진 커밋 되찾기, 공유된 기록 다시 쓰기, 여러 에이전트를 동시에 돌리는 법을 봅니다.

핵심 개념

Git은 세이브 포인트가 달린 타임머신

Git은 파일의 변경 기록을 남기는 버전 관리 도구입니다. 게임의 세이브 포인트처럼 마음에 드는 상태마다 기록해 두면 언제든 그 상태로 돌아갈 수 있습니다. 이 세이브 포인트를 커밋(commit)이라 하고, 커밋마다 왜 바꿨는지 적는 메시지가 붙습니다. GitHub 문서는 이 기록을 시점별 스냅샷이라고 부릅니다.

Git에서 변경은 세 구역을 거칩니다.

구역 비유 설명
작업 트리 작업대 지금 파일을 고치고 있는 곳
스테이징 영역(인덱스) 포장 상자 다음 커밋에 넣기로 고른 변경을 모아 두는 곳
커밋 기록 창고 선반 확정된 세이브 포인트들이 쌓인 곳

HEAD는 지금 꺼내 둔 브랜치의 가장 최근 커밋을 가리키는 표시로, '지금 서 있는 세이브 포인트'입니다. 지금 상태는 git status로 봅니다. git status --short에서 M은 추적 중인 파일이 수정됐다는 뜻이고, ??는 Git이 아직 추적하지 않는 새 파일이라는 뜻입니다. 에이전트에게 일을 맡기기 전에 이 출력이 깨끗한지 보는 습관을 들이세요. Codex 문서도 작업을 맡기기 전에 기능 브랜치에서 일하고 git status를 깨끗하게 유지하며, 자주 커밋해 되돌리기 쉽게 하라고 권합니다.

내 컴퓨터의 기록과 원격 저장소

GitHub 문서에 따르면 Git은 분산형이라, 개발자마다 프로젝트와 그 기록 전체의 사본을 가집니다. 내 컴퓨터의 저장소를 로컬, GitHub처럼 함께 쓰는 서버의 저장소를 원격(remote)이라 합니다.

명령 하는 일
git clone 원격 저장소 전체를 처음 내려받는다
git fetch 원격의 새 기록을 가져오되 내 작업에는 섞지 않는다
git pull 가져온 기록을 내 브랜치에 합친다
git push 내 로컬 커밋을 원격에 올린다

이 구분이 중요한 이유는 되돌리는 방법이 공유 여부에 따라 달라지기 때문입니다. 아직 푸시하지 않은 커밋은 나만 가진 기록이라 지우거나 다시 써도 됩니다. 이미 푸시해 다른 사람이 받아 간 커밋을 지우면 동료의 기록과 어긋납니다.

diff: 무엇과 무엇을 비교하는가

diff는 두 상태의 차이를 줄 단위로 보여 줍니다. 에이전트가 한 일을 가장 정확하게 알려 주는 것은 보고가 아니라 diff입니다. 다만 같은 git diff라도 무엇과 무엇을 비교하느냐에 따라 보이는 것이 다릅니다.

git diff                 # 작업 트리와 스테이징 영역 비교: 아직 스테이징하지 않은 변경
git diff --staged        # 스테이징 영역과 마지막 커밋 비교: 다음 커밋에 들어갈 변경
git diff HEAD            # 작업 트리와 마지막 커밋 비교: 커밋 뒤의 모든 변경
git diff main...feature  # 두 브랜치가 갈라진 지점부터 feature에서 바뀐 것

--staged는 --cached와 같은 뜻입니다. 큰 변경은 요약부터 봅니다. --stat은 파일별로 몇 줄이 바뀌었는지, --name-only는 바뀐 파일 이름만 보여 줍니다. 에이전트가 들여쓰기나 줄 끝 공백을 대량으로 바꿔 진짜 변경이 묻혔다면 -w로 공백 차이를 무시하고, 한 줄 안의 작은 변화는 --word-diff로 단어 단위로 봅니다.

새로 만든 파일은 Git이 아직 추적하지 않으므로 git diff에 나오지 않습니다. 에이전트가 새 파일을 만들었는지는 git status의 ?? 표시로 확인하고, 스테이징한 뒤 git diff --staged로 내용을 봅니다.

diff 검토 순서와 체크리스트

모든 줄을 같은 깊이로 읽으려 하면 지칩니다. 범위를 먼저 보고, 위험한 곳부터 깊게 읽습니다.

  1. 범위를 본다. git diff --stat으로 파일 수와 위치를 훑습니다. 요청에 비해 파일이 많으면 그것부터 의심합니다.
  2. 위험한 곳을 먼저 연다. 인증, 결제, 권한, 설정 파일, 데이터베이스 구조, 의존성 목록(package.json과 lock 파일)이 바뀌었다면 이유가 분명한지 봅니다.
  3. 사라진 것을 찾는다. 삭제된 테스트, 지워진 오류 처리, 빠진 검사 단계를 찾습니다.
  4. 값 하나를 본다. 정렬 방향이 a.date - b.date에서 b.date - a.date로 뒤집히는 것처럼, 한 줄 안의 작은 변화가 동작을 크게 바꿉니다.
  5. 들어가면 안 되는 것을 찾는다. 비밀값, 빌드 결과물, 임시 파일, 로그가 섞이지 않았는지 봅니다. 이런 파일은 .gitignore(4장)로 아예 추적하지 않게 합니다.
  6. 이해하지 못한 변경은 묻는다. "왜 이 파일을 바꿨는가?"에 답할 수 없는 변경은 받아들이지 않습니다.

검사를 약하게 만든 변경

에이전트는 실패를 없애라는 요청을 받으면 가끔 앱을 고치는 대신 검사를 약하게 만듭니다. 화재경보기가 울린다고 경보기 배터리를 빼는 것과 같습니다. diff에서 다음 신호를 찾습니다.

신호 모습
테스트 삭제 테스트 파일이나 테스트 블록이 통째로 사라짐
건너뛰기 it.skip, describe.skip, xit 같은 표시가 생김
기대값 완화 구체적인 값 비교가 "존재한다" 수준으로 바뀜, 기대값이 실제값으로 바뀜
검사 끄기 주석 타입 검사나 린트를 끄는 주석이 새로 생김
설정 완화 엄격한 타입 검사 옵션을 끄거나 검사 대상에서 폴더를 뺌
자동화 단계 삭제 CI 설정에서 테스트나 린트 단계가 빠짐

요구가 바뀌어 테스트도 바뀌어야 할 때가 있으니, 기준은 "그 변경을 정당화하는 요구가 있는가"입니다. 없다면 되돌립니다.

커밋은 작게, 하나의 목적으로

GitHub 흐름 안내는 커밋마다 무엇이 바뀌었는지 알 수 있는 설명을 붙이고, 이상적으로는 커밋 하나가 독립적이고 완결된 변경 하나를 담아야 한다고 말합니다. 그래야 나중에 그 변경만 골라 되돌리기 쉽습니다. '문구 수정'과 '인증 로직 변경'이 한 커밋에 섞이면, 인증만 되돌리려 해도 필요한 부분을 손으로 골라내고 다시 검증해야 합니다. 서식 정리(들여쓰기, 따옴표)는 특히 기능 변경과 섞지 않습니다. 서식 변경이 수백 줄이면 진짜 변경이 그 속에 묻힙니다.

에이전트가 한 파일 안에 필요한 수정과 불필요한 수정을 함께 남겼다면 git add -p로 골라 담습니다. Git은 변경을 헌크(hunk, 붙어 있는 변경 덩어리) 단위로 보여 주며 하나씩 묻습니다.

키 뜻
y 이 헌크를 스테이징한다
n 이 헌크는 스테이징하지 않는다
s 헌크를 더 작게 나눈다
e 헌크를 편집기에서 직접 고친다
q 그만둔다. 이 헌크와 남은 헌크는 스테이징하지 않는다

커밋 메시지에는 무엇과 함께 왜를 씁니다. "버그 수정"보다 "오전 예약이 형식 검사에 걸리던 문제 수정"이 석 달 뒤에 훨씬 쓸모 있습니다.

브랜치와 풀 리퀘스트 흐름

브랜치(branch)는 본래 흐름(보통 main)에서 갈라져 나온 별도의 작업 줄기입니다. 본편과 따로 하는 시험 촬영과 같습니다. GitHub 흐름 안내가 권하는 순서는 다음과 같습니다.

  1. 짧고 설명적인 이름으로 브랜치를 만든다. 관계없는 변경은 브랜치를 따로 만든다.
  2. 브랜치에서 설명이 붙은 커밋을 쌓는다.
  3. 풀 리퀘스트(pull request, PR)를 열어 변경 요약을 적고 검토를 받는다. 검토 의견에는 같은 브랜치에 커밋을 더 올려 답한다.
  4. 승인되면 병합하고 브랜치는 지운다. PR과 커밋 기록은 남는다.

에이전트에게 큰 일을 맡길 때 브랜치가 특히 쓸모 있습니다. 결과가 마음에 들지 않으면 main은 그대로 두고 브랜치째 버리면 되고, PR마다 미리보기 배포가 생긴다면(5장) 병합 전에 실제 화면도 봅니다.

충돌은 의미로 푼다

두 브랜치가 같은 파일의 같은 줄을 서로 다르게 바꾸면 Git은 어느 쪽을 택할지 모르므로 멈춥니다. GitHub 문서는 같은 줄을 다르게 바꿨을 때, 또는 한쪽은 파일을 고치고 다른 쪽은 지웠을 때 충돌이 생기며, 충돌을 모두 풀어야 병합할 수 있다고 설명합니다. 충돌한 파일에는 표시가 남습니다.

<<<<<<< HEAD
const MAX_GUESTS = 8;
=======
const MAX_GUESTS = 6;
const WAITLIST_ENABLED = true;
>>>>>>> feature/waitlist

======= 위는 지금 브랜치(HEAD) 쪽, 아래는 합치려는 쪽입니다. 충돌을 푼다는 것은 표시를 지우는 일이 아니라 두 변경의 의도를 모두 살리는 결과를 정하는 일입니다. 위 예라면 "정원은 8명으로 늘리기로 한 결정"과 "대기 명단 기능을 켠다는 결정"이 각각 왜 나왔는지 확인한 뒤, 둘 다 살아 있는 코드를 씁니다. 한쪽을 통째로 받으면 다른 쪽 결정이 조용히 사라집니다.

에이전트에게 충돌 해결을 맡길 때는 양쪽 변경이 각각 어떤 목적이었는지 알려 주고, 해결 뒤에 테스트를 다시 돌리게 합니다. 풀다가 판단이 서지 않으면 git merge --abort로 병합 전 상태로 돌아갈 수 있습니다. Git 문서는 이 명령이 커밋하지 않은 변경을 완전히 복원하지 못할 수 있으니 병합 전에 커밋하거나 stash해 두라고 권합니다.

되돌리기 판단표

되돌리기 명령은 무엇을 되돌리는지, 기록을 어떻게 다루는지가 서로 다릅니다.

명령 다루는 대상 쓰는 상황 주의
git restore <파일> 커밋 전 작업 트리 변경 방금 고친 내용을 버릴 때 기본은 스테이징 영역의 내용으로 되돌린다. 스테이징하지 않은 수정을 버리면 Git으로 되찾을 수 없다
git restore --staged <파일> 스테이징 영역 스테이징만 취소할 때 작업 트리의 수정은 그대로 남는다
git stash push -u 커밋 전 변경과 추적하지 않는 새 파일(무시된 파일은 --all일 때만) 버리기 아까운 변경을 잠시 치울 때 apply는 목록에 남기고 적용, pop은 적용에 성공하면 목록에서 지운다
git reset --soft <지점> 커밋 기록만 아직 공유하지 않은 커밋 여러 개를 하나로 합칠 때 되돌린 커밋들의 변경이 스테이징 영역에 그대로 남는다
git reset --hard <지점> 기록, 스테이징, 작업 트리 전체 공유하지 않은 로컬 커밋을 통째로 버릴 때 커밋하지 않은 변경도 함께 덮어쓴다
git revert <커밋> 이미 기록된 커밋 공유된 커밋의 효과를 되돌릴 때 기록을 지우지 않고 반대 변경을 새 커밋으로 남긴다. 작업 트리가 깨끗해야 한다

reset에는 --keep도 있습니다. 커밋을 버리면서도 커밋하지 않은 수정은 남기고, 지금 HEAD와 목표 지점 사이에서 내용이 다른 파일에 커밋하지 않은 수정이 있으면 멈춥니다. Git 문서는 --hard를 이미 다른 사람에게 준 커밋에 쓰지 말라고 분명히 적습니다.

핵심

판단 순서는 간단합니다. 아직 커밋하지 않았다면 restore 또는 stash, 커밋했지만 아무와도 공유하지 않았다면 reset, 이미 푸시해 다른 사람이 받았다면 revert입니다.

머지 커밋(브랜치를 합친 커밋)은 부모가 둘 이상이라 어느 쪽을 기준으로 되돌릴지 -m으로 알려 줘야 합니다(보통 -m 1). Git 문서는 머지 커밋을 되돌리는 것이 그 병합으로 들어온 변경을 앞으로도 원하지 않는다는 선언이라, 나중에 같은 브랜치를 다시 병합해도 그 변경이 자동으로 다시 들어오지 않는다고 경고합니다.

에이전트의 되감기 기능과 Git

Claude Code는 새 턴을 시작하는 요청을 보낼 때마다 체크포인트를 만들어 파일 상태를 저장해 둡니다. 입력창이 비어 있을 때 Esc를 두 번 누르거나 /rewind로 메뉴를 열면 코드만, 대화만, 또는 둘 다 이전 지점으로 되돌릴 수 있습니다. 편리하지만 공식 문서가 밝힌 한계가 분명합니다.

  • 에이전트의 파일 편집 도구로 바뀐 내용만 추적한다. rm, mv, cp 같은 셸 명령으로 바뀐 파일은 되돌리지 못한다.
  • 사람이 직접 고친 파일이나 동시에 돌던 다른 세션의 편집은 보통 담지 못한다.
  • 하위 에이전트가 고친 파일은 대개 복원되지 않는다.
  • 스냅샷은 기본적으로 약 30일 뒤 정리된다.
  • 버전 관리를 대신하지 않는다. 영구적인 기록과 협업에는 Git을 계속 쓴다.

되감기는 세션 안에서 빠르게 실수를 무르는 보조 수단입니다. 진짜 안전망은 검증된 지점마다 남긴 커밋입니다. Codex는 작업 폴더 쓰기를 허용하는 모드에서도 .git 폴더를 읽기 전용으로 보호해, 에이전트가 기록 자체를 망가뜨리지 못하게 합니다.

깊이 읽기

사라진 커밋 되찾기: reflog

git reset --hard를 잘못 실행해 커밋이 git log에서 사라져도 대부분은 되찾을 수 있습니다. Git은 브랜치 끝이 움직일 때마다 그 기록을 reflog(참조 기록)에 남기기 때문입니다. 지운 줄 알았던 커밋도 한동안은 저장소 안에 남아 있습니다.

git reflog                      # HEAD가 거쳐 간 위치 목록
# a1c9e02 HEAD@{0}: reset: moving to HEAD~2
# 7f3b8d1 HEAD@{1}: commit: 정기 결제 안내 문구 추가
git branch rescue 7f3b8d1        # 사라진 커밋에 브랜치를 붙여 살린다

HEAD@{2}는 "두 번 전에 HEAD가 있던 곳"이라는 뜻입니다. 방금 한 reset을 무르는 것이라면 git reset --hard ORIG_HEAD도 쓸 수 있습니다(reset 직전 위치가 ORIG_HEAD에 남습니다. merge나 rebase 같은 다른 명령이 이 값을 덮어쓰므로 바로 씁니다). 다만 reflog는 영원하지 않습니다. Git 문서에 따르면 기본 보관 기간은 현재 브랜치에서 닿는 항목이 90일, 닿지 않는 항목이 30일입니다. 또 reflog는 로컬 기록이라, 한 번도 커밋하지 않은 변경은 담지 못합니다. 한 번도 스테이징하지 않은 수정을 --hard로 덮어썼다면 Git으로는 되찾을 수 없습니다. 스테이징했던 내용은 Git 객체로 남아 git fsck --lost-found로 찾을 여지가 있지만, 기대지 않는 편이 낫습니다.

공유된 기록을 다시 쓸 때

푸시하기 전이라면 지저분한 커밋을 정리해도 됩니다. Git 문서의 예처럼 git reset --soft HEAD~5 뒤에 git commit을 하면 마지막 다섯 커밋이 하나로 합쳐집니다. 문제는 이미 푸시한 기록을 다시 쓸 때입니다.

다시 쓴 기록을 올리려면 강제 푸시가 필요합니다. git push --force는 원격 상태를 따지지 않고 덮어씁니다. 내가 기록을 고치는 동안 동료가 같은 브랜치에 새 커밋을 올렸다면 그 커밋이 사라집니다. git push --force-with-lease는 원격 브랜치가 내가 마지막으로 가져온 그 지점에 그대로 있을 때만 덮어쓰고, 그사이 움직였다면 거절됩니다. 다만 Git 문서는 백그라운드에서 자동으로 git fetch가 돌면 '마지막으로 본 상태'가 몰래 갱신되어 이 보호가 무력해질 수 있다고 경고하며, --force-if-includes를 함께 쓰는 방법을 소개합니다.

GitHub는 보호 브랜치에서 강제 푸시를 기본으로 막습니다. 공유 브랜치의 기록을 다시 써야 할 일 자체를 줄이는 것이 가장 좋습니다. 기능 브랜치를 짧게 유지하고, 정리는 푸시 전에 합니다.

기능 브랜치 없이 로컬 main에서 바로 커밋해 버리는 실수도 흔합니다. 원격에 올리기 전이라면 지금 위치에 브랜치 이름부터 붙여 커밋을 붙잡아 두고(git branch <새 브랜치>), main은 원격과 같은 지점으로 돌린 뒤(git reset --hard origin/main), 새 브랜치로 넘어가 작업을 이어 가면 됩니다. 순서가 중요합니다. 브랜치를 먼저 만들지 않고 reset부터 하면 커밋이 reflog에만 남습니다.

여러 에이전트를 동시에 돌릴 때

에이전트 세션 여럿이 한 폴더에서 동시에 일하면 같은 파일을 번갈아 고치게 되고, 한 커밋에 서로 다른 작업이 뒤엉키기 쉽습니다. 한 부엌에서 두 요리사가 도마 하나를 나눠 쓰는 셈입니다. 해결책은 워크트리(worktree)입니다. Git 문서에 따르면 한 저장소에 작업 트리를 여러 개 연결해 서로 다른 브랜치를 동시에 꺼내 둘 수 있습니다.

git worktree add -b feature/coupon ../shop-coupon   # 새 브랜치를 새 폴더에
git worktree list
git worktree remove ../shop-coupon                  # 끝나면 정리

Claude Code는 claude --worktree <이름>으로 저장소의 .claude/worktrees/<이름>/ 아래에 새 브랜치의 워크트리를 만들어 그 안에서 세션을 시작합니다. 세션마다 파일 사본이 따로 있으니 편집이 부딪히지 않습니다. 워크트리는 새로 꺼낸 사본이라 .env 같은 무시된 파일이 없으므로, Claude Code 문서는 이 도구 전용 설정 파일인 .worktreeinclude로 필요한 파일을 복사하게 하고 .claude/worktrees/를 .gitignore에 넣으라고 안내합니다.

동시 실행은 검토에도 쓸모가 있습니다. Claude Code 모범 사례는 새 맥락이 코드 검토를 더 잘한다고 말합니다. 방금 쓴 코드에 치우치지 않기 때문입니다. 한 세션이 구현하고 다른 세션이 검토하는 작성자-검토자 방식, 또는 하위 에이전트에게 계획 파일과 diff만 주고 요구 누락과 범위 밖 변경을 찾게 하는 방식이 있습니다. 다만 문서는 빈틈을 찾으라고 시킨 검토자는 작업이 멀쩡해도 무언가를 보고하기 마련이라, 정확성과 요구에 영향을 주는 지적만 받으라고 덧붙입니다.

실무 장면

바람길 자전거: 정렬 하나에 바뀐 파일 아홉 개

공유 자전거 대여소 '바람길 자전거'의 운영자 서연은 에이전트에게 "대여 목록을 반납 예정 시각 순으로 정렬해 줘"라고 맡겼다. 에이전트는 완료를 보고했고 테스트도 통과했다. 서연은 습관대로 git diff --stat부터 봤다. 두 파일이면 충분해 보이는 작업에 바뀐 파일이 아홉 개였다.

훑어보니 세 갈래였다. 목록 화면과 정렬 함수의 수정은 요청 그대로였다. 다섯 개 파일은 따옴표와 들여쓰기만 바뀐 서식 정리였다. 그리고 package.json과 lock 파일에 날짜 라이브러리가 새로 들어왔고, 반납 시각 계산 테스트 하나에 it.skip이 붙어 있었다. skip이 붙은 테스트는 실행되지 않고 건너뛴 것으로만 표시되니, 테스트가 통과한 이유 중 하나가 그 건너뛰기였다.

서연은 세 가지 선택지를 놓고 따졌다.

  • 그대로 받기: 테스트가 통과했으니 편하다. 하지만 꺼진 테스트와 새 의존성을 이유도 모른 채 기록에 남기게 된다. 기각했다.
  • 전부 버리고 다시 맡기기: 깨끗하지만, 요청대로 된 정렬 수정까지 버린다. 기각했다.
  • 골라 담기: 필요한 변경만 커밋하고 나머지를 되돌린다. 이것을 골랐다.

이유를 묻자 에이전트는 "표준 날짜 비교로는 시간대 처리가 번거로워 라이브러리를 넣었고, 그 바람에 기존 테스트와 결과가 달라져 건너뛰었다"고 답했다. 서연은 -w 옵션으로 서식 변경을 걷어 내고 정렬 함수의 diff만 다시 봤다. 기존 날짜 비교로도 정렬이 가능했다. 서연은 라이브러리 없이 고치게 하고, 건너뛴 테스트를 되살려 전체 테스트를 돌렸다. 정렬 함수가 들어 있는 파일에도 서식 변경이 섞여 있어 git add -p로 정렬 헌크만 y, 서식 헌크는 n으로 골랐다. 나머지 서식 파일들은 git restore로 되돌렸다.

커밋 메시지는 "대여 목록을 반납 예정 시각 순으로 정렬"로 남겼다. 서식 정리가 필요하다면 다음에 그것만 담은 커밋으로 따로 하기로 했다. 테스트 통과만 보고 받았다면, 꺼진 검사 하나와 아무도 모르는 의존성이 기록에 섞였을 것이다.

한빛 합창단: 금요일의 커밋, 월요일의 발견

단원 출석 관리 앱을 운영하는 '한빛 합창단'의 총무 태민은 월요일 아침 "출석률이 이상하게 낮게 나온다"는 연락을 받았다. 금요일 오후에는 정상이었다. 그사이 main에는 태민과 다른 봉사자 두 명이 올린 커밋이 열한 개 쌓였고, 모두 이미 원격에 푸시되어 있었다.

어느 커밋이 원인인지 하나씩 열어 보기에는 많았다. 출석률을 확인하는 테스트가 없어서, 태민은 먼저 금요일 데이터로 출석률을 계산해 기대값과 비교하는 작은 스크립트를 에이전트에게 쓰게 했다. 기대값과 같으면 종료 코드 0, 다르면 1을 돌려주고, 빌드가 안 돼 확인할 수 없는 커밋에서는 125를 돌려 건너뛰게 하는 스크립트였다. 그리고 Git의 이분 탐색을 돌렸다.

git bisect start HEAD <금요일 정상 커밋>
git bisect run node scripts/check-attendance.mjs
git bisect reset

git bisect는 정상과 비정상 사이의 중간 커밋을 꺼내 스크립트를 돌리고, 결과에 따라 범위를 절반씩 줄인다. 네 번 만에 원인 커밋이 나왔다. 결석 사유를 추가하면서 '공결'을 결석으로 세도록 조건이 바뀐 커밋이었다.

봉사자 한 명은 기록을 금요일 지점으로 통째로 돌려 원격에 덮어쓰자고 했다. 태민은 거절했다. 이미 두 사람이 받은 기록을 지우면 그들의 로컬과 어긋나고, 원인과 관계없는 커밋 열 개도 함께 사라진다. 태민은 그 커밋 하나만 git revert했다.

되돌리는 중에 충돌이 났다. 원인 커밋 뒤에 다른 봉사자가 같은 함수에 '지각' 처리를 추가했기 때문이다. 태민은 충돌 표시를 지우기 전에 양쪽의 의도를 정리했다. 되돌릴 것은 '공결을 결석으로 센 조건'이고, 지킬 것은 '지각 처리'였다. 둘 다 살린 코드를 쓰고, 출석률 스크립트와 전체 테스트를 다시 돌린 뒤 푸시했다. 출석률 스크립트는 그대로 테스트로 옮겨, 같은 실수가 다시 들어오면 CI에서 잡히게 했다.

자주 틀리는 지점

  1. "테스트가 통과했으니 diff는 안 봐도 된다." 통과는 안심을 줍니다. 하지만 통과는 범위가 맞다는 뜻이 아닙니다. 테스트가 지워지거나 꺼져서 통과했을 수도 있습니다.
  2. "문제 커밋은 reset으로 지우고 강제 푸시하면 깔끔하다." 기록이 깨끗해 보입니다. 하지만 공유한 기록을 다시 쓰면 동료의 기록과 어긋나고 남의 작업까지 날릴 수 있습니다. 공유된 커밋은 revert로 되돌립니다.
  3. "커밋은 하루 끝에 한 번이면 충분하다." 커밋 횟수가 적으면 기록이 단순해 보입니다. 하지만 큰 커밋은 일부만 골라 되돌리기 어렵고, 골라낸 결과를 다시 검증해야 합니다. 검증된 작은 단위마다 커밋합니다.
  4. "git diff에 안 나오면 바뀐 것이 없다." 명령이 조용하면 깨끗하다고 느낍니다. 하지만 새 파일은 추적 전이라 나오지 않고, 스테이징한 변경은 --staged로 봐야 나옵니다. git status를 함께 봅니다.
  5. "충돌은 한쪽을 통째로 받으면 빨리 끝난다." 표시는 빨리 사라집니다. 하지만 다른 쪽의 결정이 조용히 지워집니다. 양쪽 의도를 확인하고 둘 다 살린 뒤 테스트를 다시 돌립니다.
  6. "에이전트의 되감기가 있으니 커밋은 덜 해도 된다." 되감기는 편리합니다. 하지만 셸 명령으로 바뀐 파일, 다른 세션의 편집은 되돌리지 못하고 Git을 대신하지 않습니다.

한눈에 정리

주제 기억할 것
세 구역 작업 트리 → 스테이징 → 커밋. git status로 확인, ??는 새 파일
공유 여부 푸시 전 기록은 다시 써도 되지만 푸시 뒤에는 revert
diff 그냥 git diff는 미스테이징, --staged는 다음 커밋, HEAD는 커밋 뒤 전부
검토 순서 --stat → 위험한 파일 → 사라진 것 → 값 하나 → 섞이면 안 되는 것
약해진 검사 삭제, skip, 기대값 완화, 끄기 주석, 설정 완화, CI 단계 삭제
커밋 목적 하나, 서식과 기능 분리, git add -p로 골라 담기, 메시지에 이유
충돌 표시 지우기가 아니라 두 의도 살리기. 판단이 안 서면 git merge --abort
되돌리기 미커밋은 restore·stash, 미공유는 reset, 공유는 revert. 머지는 -m
되찾기 reflog로 위치를 찾아 브랜치를 붙인다. 미커밋 변경은 담지 못한다
동시 작업 세션마다 워크트리. 검토는 새 맥락에서

공식 문서로 확인하기

셀프 체크

3-1. git diff, git diff --staged, git diff HEAD는 각각 무엇과 무엇을 비교하는가? 스테이징한 변경과 하지 않은 변경을 한 번에 모두 보려면 어느 것을 쓰는가?

3-2. 에이전트가 src/lib/receipt.ts라는 새 파일을 만들었다고 보고했는데 git diff에는 아무것도 나오지 않는다. 왜 그런가? 이 파일의 내용을 diff로 보려면 어떻게 하는가?

3-3. 에이전트가 기능 하나를 고치면서 파일 열 개의 들여쓰기를 탭에서 공백으로 바꿨다. diff가 수천 줄이라 진짜 변경을 찾기 어렵다. 진짜 변경만 보려면 어떤 옵션을 쓰는가? 커밋은 어떻게 나누는 것이 좋은가?

3-4. 이미 공유된 main에서 기능 브랜치를 합친 머지 커밋 하나를 되돌리려 한다. git revert에 무엇을 추가로 지정해야 하는가? 되돌린 뒤 같은 브랜치를 고쳐서 다시 병합할 때 주의할 점은 무엇인가?

3-5. 실험 중인 변경을 stash에 넣어 두었다가 다른 브랜치 두 곳에 똑같이 적용해 보려 한다. git stash apply와 git stash pop 중 무엇이 알맞은가? 이유는 무엇인가?

3-6. 아직 푸시하지 않은 로컬 커밋 두 개를 버리고 싶다. 그런데 작업 트리에는 커밋하지 않은 다른 파일 수정이 있고, 그것은 남기고 싶다. --hard 대신 쓸 수 있는 reset 모드는 무엇이고, 어떤 경우에 그 명령이 멈추는가?

3-7. git bisect run에 넘길 확인 스크립트를 쓰고 있다. 어떤 커밋은 빌드조차 되지 않아 정상인지 판단할 수 없다. 그런 커밋에서 스크립트는 어떤 종료 코드를 돌려줘야 하는가?

실습 과제

실습

되돌리기 네 가지와 되찾기 (45분)

  1. 새 폴더에서 git init으로 저장소를 만들고, 파일 하나를 세 번 고치며 매번 커밋한다.
  2. 네 번째 수정은 커밋하지 않은 채 git stash push로 치운 뒤 git stash apply로 되살리고, 이번에는 git restore로 버린다.
  3. 두 번째 커밋을 git revert로 되돌리고 git log --oneline에서 새 커밋이 생긴 것을 확인한다.
  4. 새 브랜치에서 커밋 하나를 한 뒤 git reset --hard HEAD~1로 버린다.
  5. git reflog에서 방금 버린 커밋을 찾아 git branch rescue <해시>로 되살린다.

완료 기준: 단계마다 git log --oneline 출력이 기록되어 있고, revert는 기록이 늘고 reset은 줄어든다는 차이와, 되살린 커밋이 rescue 브랜치에 있다는 것을 출력으로 보여 줄 수 있다.

실습

에이전트 변경을 골라 담기 (45분)

  1. 연습용 앱에서 에이전트에게 작은 기능을 맡기되, 일부러 "하는 김에 코드 서식도 정리해 줘"를 덧붙인다.
  2. 커밋하기 전에 git diff --stat, git diff -w, git status로 기능 변경, 서식 변경, 새 파일을 구분해 표로 적는다.
  3. 검사를 약하게 만든 변경(삭제, skip, 기대값 변경)이 있는지 찾고, 있으면 이유를 묻는다.
  4. git add -p로 기능 변경만 골라 첫 커밋을 만들고, 서식 변경은 두 번째 커밋으로 따로 만든다.
  5. 두 커밋 각각에서 테스트를 돌려 통과하는지 확인한다.

완료 기준: 커밋 두 개가 각각 한 가지 목적만 담고 있고(git show --stat으로 확인), 변경 분류 표와 약해진 검사 여부 확인 결과가 남아 있으며, 두 커밋 모두에서 테스트가 통과한 출력이 있다.