avatar
nahwasa

Git 기본 이해 - 기본 명령어의 이해, 충돌 해결, github

git의 기본에 대한 이해
May 22
·
28 min read

  최근 git 기초에 대한 세미나 한 자료를 기반으로 글을 작성했습니다. 이 글의 목적은 아래와 같습니다.
1. 기본적인 git 명령어를 이해해서 IDE가 달라지더라도 동일하게 사용할 수 있도록 하는 것

2. 현재 git을 사용해 개발중인 상황에서 충돌 등이 발생했을 때, 현재 상황을 머리속으로 그려볼 수 있도록 이해하는 것

  각 ppt 하단 부가 설명은 리눅스에서 CLI 환경에서 git을 사용했습니다. 이하 리눅스 명령어는 아셔야 이해할 수 있을 것 같습니다.

  • ls : 현재 폴더내에 파일 뭐 있는지 확인

  • vi : 그냥 메모장 같은거 실행해서 txt 파일 편집했다고 보시면 됩니다.

  • cat : txt 파일 내용 확인했다고 보시면 됩니다.

  알아두면 좋을만한 기본 명령어 외 세부적인 옵션들은 작성하지 않았습니다. 명령어 자체보다는 git 사용에 대한 기본적인 이해를 목적으로 합니다. 이 글 '예시' 부분의 CLI 환경은 윈도우에 설치된 WSL 우분투 입니다. 혹시 WSL로 비슷하게 세팅을 하고 싶으시다면 '개발자 윈도우 세팅 (WSL 서브 리눅스, IntelliJ, vscode, git 등)' 글에서 확인 가능합니다.

  제가 이해하고 있던 내용으로 적은거다보니 틀린 내용이 있을 수 있습니다. 알려주시면 확인해보겠습니다.

git init

  이제부터 이 디렉토리는 git이 관리하는 공간이 되도록 초기화하는 명령어 입니다. 폴더 내에 '.git' 폴더가 생깁니다.

  여기서 staging area와 repository라는 구분이 내부적으로 생긴다고 보시면 됩니다. repository는 commit된 버전을 저장하는 곳이고, staging area는 다음번에 commit될(다음 버전이 될) 파일들을 선택해두는 공간입니다.

  그리고 git은 개발에서만 사용 가능한게 아닙니다. 문서작업 같은 것들도 git을 통해 관리할 수 있습니다.

git init

예시

CLI

인텔리제이

  이하 일부 명령어들에 예시로 인텔리제이가 포함되어 있는데, 기본 git 명령어를 알면 IDE 쓸 때도 그냥 동일한 기능 찾아 쓰면 된다는걸 보여드리기 위함입니다. 그러니 인텔리제이를 쓰지 않으셔도 상관 없습니다.

git add

  작업 공간에서 수정한 내용들 중 어떤걸 새로운 버전에 포함시킬지(어떤걸 commit할지) 정하는 명령어 입니다. staging area로 변경사항을 올리는 명령어 입니다.

// <file>을 staging area에 포함
git add <file>

// 모든 변경사항을 staging area에 포함
git add .

예시

CLI

인텔리제이

위처럼 선택한걸 직접 넣을 수도 있고

인텔리제이 쓰신다면 위와 같은 팝업도 보신 적 있으실껍니다. 'Don't ask again' 클릭해두면 이후 알아서 add 시킵니다.

git commit

  staging area에 있던걸 새로운 버전으로 만드는 명령어 입니다. 여기서 Repository는 아직은 github 등 원격 저장소와 관련 없으며, 로컬 PC에 저장됩니다.

// <message> 라는 커밋 내역을 가진 버전 생성
git commit -m "<message>"

// 아래처럼 하면 CLI 기준 좀 더 상세히 내역 적을 수 있는 에디터로 이동됩니다.
git commit

예시

CLI

인텔리제이

git status / git log

  git status는 작업 공간과 staging area의 상태를 보는 것, git log는 commit된 버전들을 보는 것.

// status
git status

// log
git log

// 추가 옵션들 붙여서 그래프로 보는거
git log --all --decorate --graph --oneline

예시

CLI

예시에서 'vi ~~' 가 보인다면 해당 파일이 수정됐다고 생각하시면 됩니다.

인텔리제이

  commit 쪽의 인텔리제이 예시 부분이 status라고 보시면 되고, git log는 show history라고 보시면 됩니다. 이렇게 git 명령어대로 적혀있지 않고 IDE마다 좀 더 이쁘게 보여주는 경우가 대부분 입니다.

git branch / git checkout

  branch는 특정 시점에 대한 일종의 복사본을 만듭니다. 아예 별도로 진행되어도 되고, 이후 합쳐져도 되고, 작업하다가 복사본을 지워도 됩니다. 

  checkout은 현재 작업할 branch를 선택하는 명령어 입니다. 해당 브랜치로 이동할 수 있습니다.

※ 아직 commit 되지 않은 작업 공간 및 staging area의 작업물들은 브랜치가 변경되어도 따라갑니다. (공유됩니다.) -> 'git checkout 시 주의점' 참고

// 현재 보고 있는 branch의 현재 시점을 기준으로 <name> 이라는 브랜치 생성
git branch <name>

// 브랜치 목록 및 현재 브랜치 확인
git branch

// <name> 브랜치 삭제
git branch -d <name>

// <name> 브랜치로 이동
git checkout <name>

// 해당 브랜치 생성 후 바로 이동 = git branch <name> + git checkout <name>
git checkout -b <name>

예시

CLI

현재 브랜치 목록 보니 main임. 이후 dev 브랜치를 생성하고 브랜치 목록 보니 둘 다 뜸. 이 때 '*' 달린게 현재 브랜치.

위에서 생성한 dev 브랜치로 checkout. (현재 세팅해둔 환경에선 명령어 입력하는 부분 좌측에 현재 branch가 뜨고 있습니다.)

dev2 브랜치 생성하면서 바로 checkout.

인텔리제이

새로운 브랜치 생성 시 'checkout branch' 체크해두면 'git checkout -b dev' 와 동일하게 바로 checkout 합니다. 위처럼 체크 해제하면 그냥 'git branch dev'

dev로 checkout

git checkout 시 주의점 (git stash)

  현재 main.txt를 수정 후 add해서 staging area로 올린 후, 다시 main.txt를 수정한 상황 입니다. 이 때, checkout 시 주의할 점이 있는데, 작업 공간 및 staging area는 브랜치가 바뀌어도 공유 된다는 점 입니다.

  즉 위처럼 checkout으로 브랜치를 바꾸어도 작업중이던 내용들은 그대로입니다.

  물론 충돌의 가능성이 있다면 막히긴 합니다. 위 상황은 dev 브랜치의 main.txt는 "aaabbccc"인데, main 브랜치의 main.txt를 "aaabbcccddee22"로 변경한 경우 입니다. 이 때 dev 브랜치로 이동하려고 하면 이미 dev 브랜치에 있는 main.txt와 내용이 달라 충돌이 나므로 위처럼 checkout이 안됩니다.

  하지만 서로 다른 파일을 수정한 경우 처럼 충돌이 나지 않을 경우 그대로 checkout이 됩니다. 이 경우 작업 단위별로 브랜치를 나눈 경우라면 이력 관리에 문제가 생길 수 있습니다. 물론 이 내용을 알고 있다면 어떻게든 처리할 순 있지만, A 브랜치에서 a.txt를 작업하던 중에 add해놓고 B 브랜치로 checkout 해서 파일들 작업하고 거기서 commit 까지하고 이제 다시 a.txt 작업하려고 A 브랜치 갔더니 add된거 없고 이러면 당황스러울 수 있습니다.

  따라서 항상 작업하던걸 commit 까지 완료한 후 checkout 하거나, 위처럼 'git stash'를 통해 작업 중이던 내용을 키핑(?)하고 브랜치 넘어가서 작업하고, 이후 되돌아와서 'git stash pop'으로 다시 꺼내와서 작업하시면 됩니다.

// 작업중이던거 키핑!
git stash

// 키핑해둔거 다시 꺼내오기
git stash pop

git merge / git diff

  어떠한 시점에서 복사본 만들어 다른 branch에서 작업하던걸, 특정 branch에 합치는 명령어 입니다. 이 때 충돌나는 부분이 있다면 merge 하는 개발자가 책임지고 깔끔하게 합쳐야 합니다. git은 byte 단위로 그저 비교할 뿐, 코드 정상 동작에 관여하지 않습니다.

광고에 나온 햄버거와 실제 구매한 햄버거

  commit이 완료된 버전을 기준으로 서로 다른점을 비교합니다.

// 현재 branch에 <name> 브랜치를 합칩니다.
git merge <name>

// <A>에 비해 <B>가 어떤게 달라졌는지 확인합니다.
git diff <A> <B>

  예를들어 main 브랜치에서 새로운 브랜치 A로 간 다음 작업한걸 main에 반영하려면 A에서 작업한걸 commit한 후, git checkout main으로 main 브랜치로 간 다음 'git merge A' 해주면 됩니다.

  git diff에서 <A>와 <B>는 commit 해시값 입니다. 'git log' 시에 보이는 이상한 문자열 입니다.

  또는 head, head^, head^^, head~1 처럼 작성해도 됩니다. head는 가장 최근에 commit된 버전이고, head 뒤에 '^'를 하나 붙일 때 마다 직전 버전 입니다. 또는 head~N 에서 N에 숫자를 넣어 몇번 전 버전인지 지정 가능합니다. 예를들어 'git diff head head^' 또는 'git diff head head~1'은 가장 최근 커밋된 버전과 그 직전 커밋된 버전을 비교합니다.

충돌 상황 해결 (3-way merge, git rebase)

  main에서 작업하다가 dev 브랜치 만들어서 작업한걸 main에 그대로 합친 경우, 아무런 충돌 없이 merge가 가능합니다.

  하지만 어느 시점에 dev 브랜치 만들어서 작업중이었는데, main도 별도로 변경된 경우라면 서로의 최신 버전이 각각 "ABCD"와 "ABC12"로 다르기 때문에 충돌이 나게 됩니다. github에 있는 저장소에서 특정 시점부터 개발자 A와 개발자 B가 브랜치를 만들어 작업을 할 때, A가 작업한걸 올리기 전에 B가 자기가 작업한걸 먼저 올려서 main이 변경된 경우를 생각하시면 됩니다. 이 경우 merge가 바로 되지 않습니다.

  이 경우 3-way merge 혹은 git rebase를 통해 해결할 수 있습니다.

충돌 해결 : 3-way merge

  사실 그냥 'git merge' 하는건데 충돌난 부분을 개발자가 잘 병합만 하면 됩니다. 근데 굳이 왜 3-way merge라고 부르냐면 봐야하는 시점이 두 브랜치가 분리된 시점 ("ABC"), main 브랜치의 최신 시점 ("ABCD"), dev 브랜치의 최신 시점 ("ABC12") 이렇게 3개이기 때문입니다.

  3-way merge의 장점은 병합 과정이 명확하게 드러난다는 점이고, 단점은 병합 과정이 명확하게 드러난다는 점 입니다. 이하 예시들을 보시면 아실 수 있을꺼에요.

// 현재 브랜치에 <name> 브랜치를 합친다.
git merge <name>

  충돌나서 merge가 바로 되지 않은 경우 입니다. 이 때 충돌난 파일을 열어보면 '<<<', '>>>' 처럼 서로 충돌난 부분이 명시되어 있습니다. 이 부분을 개발자가 직접 수정해주면 됩니다. CLI라서 이렇고, IDE라면 보통 각 IDE에서 이쁜 방식으로(?) 충돌 해결을 지원해줍니다.

  위 예시에서는 ddaacc와 ddaaff가 달라 충돌났으니 ddaaccff로 합쳐줬습니다. 물론 실제 코드라면 단순히 이렇게 합치면 안되긴 합니다. 3-way merge의 경우 합치는 책임은 main 브랜치 자체에 있게 됩니다.

  git log에 옵션을 달아 그래프로 확인해보면 (화면상 그래프 위로 갈수록 최신 버전이고, 아래로 갈수록 이전 버전 입니다.) 합치는 과정이 명시적으로 그래프로 보입니다.

3-way merge 브랜치 3개 예시

  단점을 좀 더 명확히 보여드리기 위해 브랜치 3개에 대한 충돌 예시를 들어보겠습니다.

1. 초기 상태 입니다. main.txt에는 "ab"가 쓰여 있습니다.

2. "ab"인 상태에서 2개의 브랜치를 생성했습니다.

3. 근데 셋 다 수정되어 버렸습니다. main은 "abc", feature/a 브랜치는 "abd", feature/b 브랜치는 "abe"로 수정되었습니다.

4. 우선 feature/a를 main에 합쳐줍니다.

5. 다음으로 feature/b도 main에 합쳐줍니다. 히스토리 병합에 대한 그래프를 명확하게 확인 가능합니다. 하지만 개발자 10명이 동일 시점에서 feature/a, b, c, d, ... 를 만들어 작업하다가 전부 3-way merge로 합쳤다면 그래프가 어떻게 될지 아마 상상이 가실 것 같습니다.

충돌 해결 : git rebase

  main에서 dev로 분기쳐진 시점을 dev 입장에서 'base'라고 할 때, main의 최신 버전이 바뀌었다면 main의 최신 버전으로 dev의 base를 바꿀 수도 있을겁니다. (base를 옮겼으므로 rebase!)

  그게 git rebase 입니다. 3-way merge는 합치는 책임이 main에 있었지만, rebase는 각 브랜치에 책임이 있는 셈 입니다. main은 그대로 두고, 다른 브랜치에서 충돌난거 해결한 후에 main에 합치라는거죠.

// <name> 브랜치의 최신 버전으로 rebase
git rebase <name>

  예를들어 위 ppt의 상황이라면 dev 브랜치에서 'git rebase main'을 하면 됩니다.

  git rebase로 충돌을 해결할 시, 3-way merge와 달리 그래프가 깔끔한걸 볼 수 있습니다. 즉, 3-way merge와 반대로 장점은 히스토리가 깔끔함. 단점은 히스토리가 깔끔함. 입니다 ㅋㅋ

git rebase 브랜치 3개 예시

1. 3-way merge 브랜치 3개 예시의 '3'과 동일한 상황 입니다. main 브랜치에 "ab"가 들어가 있는 상태에서 브랜치 2개를 생성한 후에 main은 abc, feature/a는 abd, feature/b는 abe가 된 상태입니다.

2. 우선 feature/a를 처리해 줍니다. rebase는 합치는 책임이 각 브랜치에 있으므로, feature/a에서 'git rebase main'을 해줍니다. 3-way merge 때와 마찬가지로 충돌났다고 내용이 떠있고, abcd로 수정해준 후 'git rebase --continue'를 해줍니다.

git merge는 최신 버전을 바로 합칠 때 충돌난걸 해결하는 과정이지만, git rebase는 base를 순차적으로 옮기면서 최신 버전까지 이동하므로 충돌이 여러번 날 수 있습니다. 그래서 git rebase --continue 처럼 계속해서 rebase하라는 명령어가 존재합니다.

여기서 중요한건 rebase 후 main에서 'git merge feature/a'를 해주었다는 점 입니다. 3-way merge는 main에 feature/a를 합치는 것이었고, rebase는 feature/a가 main에 맞춰 이동한 겁니다. 즉 feature/a에서 작업한걸 main에 최종적으로 적용시켜야하는거죠.

3. 다음으로 feature/b도 마찬가지로 rebase 해준 후 main에 merge 해줍니다.

3-way merge때와 비교해 그래프가 깔끔한걸 볼 수 있습니다.

  그리고 이건 좀 논외긴한데, 기능별로 브랜치를 나눠서 작업하기로 했다면 main에 merge 된 후에 해당 브랜치는 바로바로 삭제해주는게 좋습니다. 위 그래프만 봐도 feature/a와 feature/b가 최신버전과 다른걸 볼 수 있습니다. feautre/a와 feature/b에 추가로 더 작업을 하려면 다시 main의 최신버전을 가져오는 과정이 별도로 필요해집니다. main의 최신버전에서 새로 branch 따는게 더 편하고 꼬이지 않겠죠.

3-way merge, rebase : 어떤걸 사용해야 하나요?

  모든 설계나 개발에서의 선택지에는 트레이드오프가 있습니다. 언제나 좋은 무언가가 있다면 애초에 그 외의 기능은 필요가 없었을거에요.

  그러니 중요한건 해당 프로젝트에서 팀이 정한 룰대로 사용하는 것 뿐 입니다. 더 좋다고 생각되는 방법이 있다면 토론을 통해 룰을 정하면 되는거죠.

  일반적으론 3-way merge와 rebase를 섞어서 사용하는 경우가 많습니다. 각 기능에 대해 feature 브랜치 만들어서 작업하는 경우 feature 브랜치는 필요 시 rebase하고, main이나 release를 상시 유지하도록 팀에서 정했다면 상시 유지하는 애들은 명확히 병합 과정을 알 수 있도록 3-way merge 하는 식으로 섞어서 쓸 수 있습니다.

git reset, revert, reflog

  현재 상황이 위와 같을 때, 작업한 내역을 취소하고 싶을 수 있습니다. 이 때 git reset 혹은 revert를 사용합니다. reset은 내역 자체를 지우는거고, revert는 내역은 지우지 않고 추가로 되돌렸다는 내역을 가진 새로운 버전을 추가하는 차이가 있습니다.

  git reset은 옵션별로 위와 같이 동작됩니다. 옵션을 안적을 경우 default는 --mixed 입니다. 특히 --hard 옵션의 경우 현재 작업중이던것도 날아가므로 주의가 필요하겠죠.

  추가로 reset한걸 reset 하고 싶을 수도 있습니다 (?). 즉 되돌린걸 취소하고 싶은거죠. 이 경우 git reset은 내역 자체를 날렸으므로 'git log'로는 되돌아갈 내역을 확인할 수 없습니다. 이럴 때 쓰는게 'git reflog' 입니다. 여기엔 reset 했다는 기록도 남아있습니다.

// <A>로 되돌아가기. 커밋 해시 혹은 head^, head~2 처럼 작성 가능.
git reset <A>
git reset --soft <A>
git reset --mixed <A>
git reset --hard <A>

// <A>로 되돌아가긴 하는데, 내역 자체를 지우진 않고 추가로 되돌아갔단 내역 남김.
git revert <A>

// reset한걸 취소하고 싶을 때. reset한 것 포함 모든 로그를 보고 싶을 때 사용.(reference log)
git reflog

예시

git reset 예시. 그래프에서 HEAD -> main 이었다가, main을 head^^^(== head~3)으로 전전전 버전으로 reset했으므로 main의 HEAD가 "ab" 일때로 돌아간걸 볼 수 있음.

git reflog로 보니 위에서 git reset한 로그가 남아 있음. reset을 reset 해서(?) reset하기 전으로 되돌림.

github : git remote, push, fetch, pull, PR

  이전까지 git의 기본 명령어들을 알아보았는데, 사실 git이 이해됬다면 github도 이해된거나 마찬가지 입니다. 그냥 나머진 동일하고 원격 repository가 추가되었다는 점과, 추가된 repository와 소통하기 위한 몇 가지 명령어가 추가되었다는 것 뿐입니다.

  전체적으로 그려보면 위와 같습니다. '개발자A'는 메인 깃허브 저장소에서(회사 깃허브 같은 곳) 자신의 깃허브로 fork 후 그걸 로컬에 연결한 경우 입니다(물론 fork가 가능한 저장소여야 합니다). '개발자B'는 메인 깃허브 저장소를 바로 원격지 저장소로 등록한 경우 입니다.

  remote는 원격지 저장소의 위치를 지정하는거고, push는 원격지 저장소에 현재까지 commit된 내역들을 올리는 겁니다. fetch는 원격지 저장소에서 변경된걸 확인하는거고, pull은 실제로 원격지 저장소에서 변경된걸 가져오는겁니다.

  또 pull request랑 fork는 github 자체에서 프로젝트 관리를 편하게 해주기 위해 추가해준 기능일 뿐 git 자체랑은 관련 없는거구요. pull request는 PR이라고 불리고(gitlab에서는 merge request), 병합해달라는 요청을 올리는 겁니다. 병합하면서 코드 리뷰도 받을 수 있고, 정책에 따라 approve로 승인을 받아야만 병합이 가능하기도 하고, github action 같은걸 써서 코드 리뷰 전에 코드 검사를 자동으로 돌릴수도 있는 등 다양하게 사용 가능 합니다. fork는 github에서 github으로 clone 해오는거라고 보시면 됩니다.

  주의하실 점은 로컬의 main과 원격저장소의 main은 다르다는 점 입니다. pull을 하지 않았는데 알아서 동기화 되진 않습니다. 즉, 위 이미지처럼 로컬의 main은 "ABC"인데, github의 main은 "ABCDE" 일 수 있는거죠.

  git pull을 해줘야 이렇게 로컬과 원격의 main이 동일한 시점이 되는겁니다.

  사실 경우의 수를 보면 위의 경우 밖에 없습니다.

1. 로컬도 안바꼈고 깃허브도 안바꼈다면 그냥 로컬에서 작업 후에 git push하면 됩니다. (정확힌 git add -> git commit -> git push)

2. 로컬은 그대로인데, 깃허브에 다른 개발자가 뭔가 올렸다면 git pull로 최신 버전을 가져온 후 작업한 후에 git push를 해주면 됩니다.

3. 로컬을 변경하고 깃허브에 올리려고 보니 깃허브도 이미 다른 개발자가 뭔가 올렸다면, git pull로 가져온 후 충돌나는 부분을 해결하고 (3-way merge, rebase 등으로), git push 해주면 됩니다.

  즉, github이 추가되었다고 해서 기본적인 git의 이해에서 크게 벗어나는 부분은 없습니다.








하악..