-
[Software Engineering] 버전 관리 시스템(VCS): Git 핵심CS/Software Engineering 2026. 4. 11. 16:09
소프트웨어 개발에서 코드 변경 이력을 관리하는 것은 단순한 습관이 아니라 필수 역량이다. 이 글에서는 버전 관리 시스템(Version Control System, VCS)의 개념부터 Git의 핵심 명령어까지 정리한다.
왜 버전 관리가 필요한가?
"완벽한 소프트웨어"라도 시간이 지나면 유지보수가 필요하다. VCS(Source Code Management, SCM이라고도 부른다)는 소스 코드, 문서, 웹사이트 등 소프트웨어 산출물에 가해지는 모든 변경을 추적·관리한다.
VCS가 필요한 이유는 크게 네 가지다.
- 안전한 저장소 — 프로젝트를 안정적으로 보관하기 위해
- 롤백(Roll-back) — 실수를 이전 버전으로 되돌리기 위해
- 게이트키핑(Gate-keeping) — 들어오는 변경 사항을 검토·통제하기 위해
- 다중 버전 유지 — 동일 시스템의 여러 버전을 병행 관리하기 위해
VCS의 핵심 요구사항: Atomic updates and Merging
원자적 업데이트 (Atomic Updates)
"원자적"이란 작업이 전부 성공하거나 전혀 적용되지 않는 것을 의미한다. 중간 상태(Work-In-Progress)를 절대 남기지 않아야 한다.
한 개발자가 파일을 수정하는 도중 다른 사람이 동시에 수정하면 원자성이 깨진다. 이를 막는 전통적인 방법이 파일 잠금인데, 한 번 체크아웃되면 다른 사람은 수정할 수 없다. 하지만 잠금이 유실되면 심각한 문제가 발생한다.
머지 (Merging)
파일 잠금 없이 여러 사람이 동시에 작업하면 충돌이 발생할 수 있다. 이때 머지는 기존의 방식처럼 덮어쓰는 것이 아닌 변경된 부분만 골라 새로운 베이스라인에 반영한다. 단, 같은 부분을 두 사람이 동시에 수정했다면 충돌(Conflict)이 발생하고, 이는 개발자가 직접 해결해야 한다.
Centralized VCS vs Distributed VCS
CVCS (Centralized VCS) — 예: SVN

모든 개발자가 하나의 중앙 서버 저장소에 Update & Commit으로 연결되는 클라이언트-서버 구조다. 저장소는 서버에만 존재하고, 개발자는 워킹 카피(Working Copy)만 로컬에 보유한다.
DVCS (Distributed VCS) — 예: Git

중앙 서버의 역할을 줄이고, 모든 개발자가 전체 저장소(히스토리 포함)를 로컬에 복제해 보유한다. 부분적인 P2P 구조로 동작하며, 각자의 로컬 저장소와 원격 저장소 사이에서 push/pull로 동기화한다.
DVCS의 장점:
- 오프라인에서도 작업 가능하다
- 서버 통신 없이 대부분의 작업이 빠르게 처리된다
- 공개하지 않은 변경 사항을 로컬에 보관할 수 있다
- 모든 워킹 카피가 자동으로 백업 역할을 한다
- 더 유연한 워크플로우를 지원한다
DVCS의 단점:
- 최초 클론 시 전체 히스토리를 받아야 해 느리고 용량이 크다
- 추가 저장 공간이 필요하다
- 복사본이 많아질수록 보안 취약점이 증가한다
Git 소개
Git은 리눅스 커널을 만든 Linus Torvalds가 개발한 가장 널리 쓰이는 DVCS다.
git init 명령으로 디렉토리를 Git 저장소로 초기화하면 .git 숨김 폴더가 생성된다. 이 안에 히스토리, 설정 등 Git의 내부 데이터베이스가 저장되며, 디렉토리 자체는 로컬 워킹 디렉토리가 된다.
Git의 세 가지 영역 (Three Areas)

Git은 로컬에서 세 영역으로 파일 상태를 관리한다.
- Working Directory: 현재 작업 중인 파일들이 위치하는 영역이다
- Staging Area (Index): 다음 커밋에 포함될 파일들이 대기하는 공간이다
- Local Repository: 영구적으로 저장된 커밋 히스토리로, .git 폴더 내부에 존재한다
$ git init # .git 생성 $ echo "Local change 1" >> file.txt # 파일 수정 $ git add file.txt # 스테이징 영역으로 이동 $ git commit -m "Add a local change" # 로컬 저장소에 저장
실수를 되돌리는 방법
스테이징 전 변경 취소 git restore <file> 스테이징된 파일 되돌리기 git restore --staged <file> 

git restore file.txt는 워킹 디렉토리의 변경을 버리고 마지막 커밋 상태로 되돌린다. --staged 옵션을 붙이면 스테이징 영역에서 워킹 디렉토리로 파일을 되돌린다. 변경 내용 자체는 사라지지 않는다.
이미 로컬 저장소에 커밋된 경우에는 git reset으로 되돌려야 한다. 이 부분은 뒤에서 자세히 다룬다.
git status
git status는 현재 저장소의 상태를 한눈에 보여주는 명령어다. 다음 세 가지 상태를 출력한다.
- Untracked files: Git이 추적하지 않는 새 파일
- Changes not staged for commit: 수정됐지만 스테이징되지 않은 파일
- Changes to be committed: 스테이징 영역에 올라가 커밋 대기 중인 파일
추가로 현재 브랜치가 원격 브랜치보다 몇 커밋 앞서 있는지도 함께 표시된다.

커밋 히스토리

각 커밋은 스냅샷이다. 파일의 특정 시점 상태를 통째로 저장한다고 이해하면 된다. SHA-1 해시를 커밋 ID로 사용하며, 실제 변경 내용(diff), 날짜, 작성자 이메일 등의 메타데이터가 포함된다.
HEAD는 현재 체크아웃된 마지막 커밋을 가리키는 포인터다. 브랜치를 이동하거나 커밋을 추가하면 HEAD도 함께 움직인다.
git log --oneline --graph로 브랜치 분기 구조를 ASCII 그래프로 확인할 수 있다.
원격 저장소와 협업
git clone

원격 저장소를 로컬로 복제한다. 단순히 파일만 가져오는 것이 아니라 전체 커밋 히스토리까지 그대로 복사된다는 점이 중요하다.
git clone <remote_repo_url>git remote
원격 저장소를 관리하는 명령어다. origin은 관례적으로 업스트림(원본) 저장소를 가리키는 이름으로, GitHub에서 클론하면 자동으로 설정된다.
git remote -v # 목록 확인 git remote add origin <URL> # 원격 추가 git remote rename oldname name # 이름 변경 git remote remove name # 제거로컬에서 git init으로 시작한 경우에는 원격 저장소가 자동으로 연결되지 않는다. 이때 git remote add origin <URL>로 직접 연결해줘야 한다.
git push
로컬 브랜치가 원격 브랜치보다 앞서 있을 때, 추가된 커밋을 원격으로 올린다.

git pull
원격 브랜치가 로컬보다 앞서 있을 때, 원격의 변경 사항을 로컬로 가져온다.

Push/Pull 시 Conflict
충돌은 왜 발생하는가?
충돌의 근본 원인은 베이스라인(Baseline)이 달라졌기 때문이다. 아래 시나리오를 보자.

- 내가 원격 저장소를 pull했을 때 원격의 HEAD는 커밋 1이었다
- 나는 로컬에서 작업을 진행해 커밋 2를 만들었다
- 그 사이 팀원이 원격에 커밋 3을 push했다
- 내가 git push origin main을 시도하면 실패한다
내가 만든 커밋 2는 1을 베이스로 만든 변경이다. 그런데 원격은 이미 3으로 앞서 나가 있다. Git은 1 → 2라는 변경을 3 위에 그냥 덮어쓸 수 없기 때문에 push를 거부한다.
이 문제를 해결하는 방법이 바로 Merge와 Rebase다.
git pull의 실체: fetch + merge/rebase
git pull은 사실 두 단계의 조합이다.

git pull origin main # 위 명령은 아래 두 단계와 동일하다 git fetch origin # 1단계: 원격 정보만 가져오기 git merge origin/main # 2단계: 로컬에 통합하기git fetch는 원격의 최신 커밋을 로컬 저장소로 가져오되, 워킹 디렉토리와 스테이징 영역에는 아무것도 하지 않는다. 즉 충돌 해결을 나중으로 미룰 수 있다. 원격에 어떤 변경이 있었는지 먼저 확인하고 싶을 때 유용하다.
Merge와 Rebase는 이 두 번째 단계에서 선택하는 전략이다.
해결 방법 1: Merge
git merge는 두 브랜치의 변경 사항을 하나로 합친다. 내부 동작은 다음과 같다.

핵심 특징은 다음과 같다.
- 두 변경이 겹치지 않으면 자동으로 머지된다
- 두 변경이 같은 부분을 수정했다면 충돌이 발생하고 개발자가 직접 해결해야 한다
- 머지가 완료되면 머지 커밋(Merge Commit)이 하나 생성된다. 히스토리에 병합의 흔적이 남는다
충돌이 발생하면 Git은 해당 파일에 아래와 같은 마커를 삽입한다.
- <<<<<<< HEAD ~ =======: 현재 브랜치(내 코드)의 내용
- ======= ~ >>>>>>> develop: 머지하려는 브랜치(상대 코드)의 내용
해결 방법은 간단하다. 둘 중 하나를 선택하거나, 둘을 적절히 조합한 뒤 마커를 모두 제거하고 저장한다. 그 다음 git add와 git commit으로 마무리한다.
# 충돌 해결 후 git add <충돌났던 파일> git commit -m "conflict 해결"해결 방법 2: Rebase
git rebase는 머지와 목적은 같지만 접근 방식이 다르다. 내 커밋들의 베이스(시작점)를 옮겨 마치 처음부터 최신 코드 위에서 작업한 것처럼 히스토리를 재구성한다.

핵심 특징은 다음과 같다.
- 머지 커밋이 생기지 않아 히스토리가 일직선으로 깔끔하게 유지된다
- 커밋 ID가 바뀌므로 이미 원격에 push된 브랜치에는 사용을 피해야 한다 (팀원의 히스토리와 충돌 발생)
- 충돌이 발생하면 커밋 하나하나를 적용할 때마다 멈추고 해결을 요구한다
Merge vs Rebase 비교
Merge Rebase 히스토리 형태 분기 후 합류 (비선형) 일직선 (선형) 머지 커밋 생성됨 생성 안 됨 커밋 ID 변경 없음 변경됨 충돌 해결 시점 한 번에 커밋 단위로 순차 처리 공유 브랜치 사용 안전 위험 (force push 필요) 적합한 상황 기능 브랜치 → main 통합 로컬 정리, PR 전 히스토리 정돈 pull 시 전략 선택
git pull을 실행할 때 머지와 리베이스 중 어떤 전략을 쓸지 명시할 수 있다.
git pull --no-rebase origin main # merge 전략 (기본값) git pull --rebase origin main # rebase 전략
브랜치와 탐색
git branch / git switch
브랜치는 기능별로 독립적인 타임라인을 만들어 준다. 예를 들어 feature/A, feature/B, feature/B-bug-fix처럼 작업 단위로 브랜치를 나눠 서로 영향을 주지 않고 개발할 수 있다.

git branch feature/A # 브랜치 생성 git switch feature/A # 브랜치 이동 (HEAD가 feature/A로 이동) git branch --list # 브랜치 목록 확인 git switch main # main으로 복귀브랜치를 만들고 이동한 뒤 커밋하면, 그 커밋은 해당 브랜치에만 쌓인다. git switch main으로 돌아오면 워킹 디렉토리도 main 브랜치의 상태로 바뀐다.
git checkout
git checkout은 Git 2.23 이전에 브랜치 이동, 파일 복원, 특정 커밋 탐색을 모두 처리하던 명령어다. 기능이 너무 많다 보니 혼란스럽다는 피드백이 많았고, 결국 Git 2.23부터 git switch(브랜치 이동)와 git restore(파일 복원)로 역할이 분리됐다. 이 글에서도 git >= 2.23 버전을 기준으로 한다.
그렇다고 git checkout이 사라진 건 아니다. 여전히 동작하며, 실무에서도 자주 보이기 때문에 제대로 이해해둘 필요가 있다.
git checkout main # 브랜치 이동git switch main과 동일하게 동작한다. HEAD를 해당 브랜치의 최신 커밋으로 이동시키고, 워킹 디렉토리와 스테이징 영역을 그 상태에 맞게 업데이트한다.
uncommitted 변경 사항이 있을 때는 이동이 가능한 경우도 있고 불가능한 경우도 있다. Git이 판단해서 충돌 없이 가져갈 수 있으면 변경 사항을 유지한 채 이동하고, 충돌 가능성이 있으면 이동을 abort시킨다.
특정 커밋으로 이동 (Detached HEAD)
git checkout 44c4f8브랜치가 아닌 특정 커밋 ID를 지정하면 detached HEAD 상태가 된다. HEAD가 브랜치를 가리키는 것이 아니라 커밋을 직접 가리키는 상태다.
일반 상태: HEAD → main → ●(커밋) Detached: HEAD → ●(커밋) (브랜치를 거치지 않음)이 상태에서 커밋을 하면 어떤 브랜치에도 속하지 않는 커밋이 만들어진다. 나중에 다른 브랜치로 이동하면 이 커밋은 브랜치에서 참조되지 않아 결국 Git의 가비지 컬렉션에 의해 사라질 수 있다. 과거 코드를 잠깐 둘러보는 용도라면 괜찮지만, 작업을 이어가려면 반드시 새 브랜치를 만들어야 한다.
특정 파일만 되돌리기 (-- 옵션)
git checkout 44c4f8 -- file.txt-- 뒤에 파일 경로를 지정하면 HEAD는 움직이지 않고 해당 파일만 지정한 커밋 시점의 상태로 덮어쓴다. 강의에서 강조하듯 이때 Git은 아무런 경고 없이 워킹 디렉토리의 파일을 덮어쓴다. git restore처럼 되묻지 않는다.

위 예시에서 change 2는 이미 스테이징까지 됐고, change 3은 아직 스테이징 전이다.
이 상태에서 git checkout 44c4f8 -- file.txt를 실행하면 두 변경이 모두 사라지고 44c4f8 시점의 파일 내용으로 교체된다. 되돌릴 방법이 없으므로 신중하게 사용해야 한다.
위 그림처럼 git checkout은 워킹 디렉토리와 로컬 저장소 양쪽에 동시에 영향을 줄 수 있다.
git restore, git switch와의 관계
앞서 설명한 git restore와 git switch는 모두 git checkout에서 파생된 명령어다. 역할을 명확하게 분리했을 뿐 내부 동작은 거의 동일하다.
브랜치 이동 git checkout <branch> git switch <branch> 워킹 디렉토리 변경 취소 git checkout -- <file> git restore <file> 스테이징 취소 git checkout HEAD <file> git restore --staged <file> 특정 커밋의 파일 복원 git checkout <id> -- <file> git restore --source=<id> <file>
고급 커밋 조작
git reset
git reset은 HEAD 포인터를 이동시켜 커밋 히스토리를 "되감는" 명령어다. 얼마나 되돌릴지에 따라 세 가지 모드가 있다.
모드 HEAD 이동 스테이징 영역 워킹 디렉토리 --soft 0 유지 유지 --mixed (기본값) 0 초기화 유지 --hard 0 초기화 초기화 git reset --soft HEAD~ # 커밋만 취소, 변경 내용은 스테이징에 유지 git reset --mixed HEAD~ # 커밋 취소, 변경 내용은 워킹 디렉토리에 유지 git reset --hard HEAD~ # 커밋 취소, 변경 내용 완전 삭제 (주의)--soft는 커밋 메시지만 수정하고 싶을 때, --mixed는 스테이징을 다시 구성하고 싶을 때, --hard는 작업 자체를 완전히 없애고 싶을 때 사용한다.
HEAD 표기법
특정 커밋을 지칭할 때 커밋 ID 대신 HEAD를 기준으로 상대적으로 표현할 수 있다.
- HEAD~n: HEAD로부터 n번째 조상 (수직 이동, 첫 번째 부모만 따라감)
- HEAD^n: 여러 부모를 가진 커밋에서 n번째 부모 (수평 이동, 머지 커밋에서 사용)

히스토리 검사와 디버깅
git blame
특정 파일의 각 줄을 마지막으로 수정한 커밋과 작성자를 추적한다. 코드에서 버그가 발견됐을 때 언제, 누가 그 줄을 변경했는지 파악할 수 있다.
git blame <filename> # 파일 전체 확인 git blame <filename> -L 10,20 # 10~20번 줄만 확인git bisect
버그가 처음 발생한 커밋을 이진 탐색(Binary Search)으로 빠르게 찾아낸다. 커밋이 수백 개 쌓인 프로젝트에서도 로그 횟수만큼만 검사하면 되기 때문에 매우 효율적이다.

git bisect start # 탐색 시작 git bisect bad # 현재 커밋은 버그 있음 git bisect good <commit-id> # 마지막으로 정상이었던 커밋 지정 # 이후 Git이 중간 커밋을 체크아웃 → good 또는 bad 판정을 반복 git bisect reset # 탐색 종료, HEAD 원위치good/bad의 기준은 코드 직접 확인, 테스트 실행 등 무엇이든 될 수 있다. 자동화된 테스트 스크립트가 있다면 git bisect run <script> 형태로 완전 자동화도 가능하다.
git cherry-pick
특정 브랜치의 커밋 하나를 현재 브랜치에 복사해 적용한다. 전체 브랜치를 머지하지 않고 개별 버그 수정만 선택적으로 가져올 때 유용하다.

git cherry-pick <commit-hash>예를 들어 feature/A 브랜치에서 수정한 버그 픽스 커밋만 main으로 가져오고 싶을 때 사용한다. cherry-pick된 커밋은 새로운 커밋 ID를 부여받아 현재 브랜치에 추가된다.
정리
VCS, 특히 DVCS는 현대 소프트웨어 개발의 근간이다. Git은 기능이 방대한 만큼 같은 결과를 얻는 방법도 다양하지만, 핵심 흐름은 다음과 같다.
변경 → git add → git commit → git push ↑ git restore (실수 취소)브랜치 생성, 머지, 충돌 해결은 협업에서 매일 일어나는 작업이다. 명령어를 외우려고 하기보다는 각 명령어가 세 영역(워킹 디렉토리, 스테이징 영역, 로컬 저장소) 중 어디에 영향을 주는지를 이해하는 것이 중요하다.
출처: 경북대학교 손정주 교수님, "소프트웨어공학" 강의 자료
'CS > Software Engineering' 카테고리의 다른 글
[Software Engineering] Use Case (1) 2026.04.12 [Software Engineering] Requirement Engineering (2) 2026.04.12 [Software Engineering] Design Pattern: Behavioral Patterns (1) 2026.04.10 [Software Engineering] Design Pattern: Structural Patterns (0) 2026.04.09 [Software Engineering] Design Pattern: Creational Patterns (1) 2026.04.08