중복 코드의 Git 히스토리를 병합하기 위한 시행착오

이슈

  • 신규 API 코드와 기존 API 코드를 동시에 서비스하던 중, 기존 API를 deprecate시키고, 기존 API 코드를 삭제하려고 하는 상황이었다.
  • 그런데 문제는 신규 코드 최초 작성 당시, 기존 코드를 복붙하여 작성되었고, 수년 간의 git 히스토리는 기존 코드에만 남아있고, 신규 코드의 git 히스토리는 복붙 이후의 시점만 기록되어 있었다.
  • 아래는 기존 코드에는 히스토리가 다 남아있는 반면, 신규 코드에는 복붙 히스토리만 남아있는 모습이다.
    • (그리고 여기서의 중요한 포인트는 신규 코드는 기존 코드와 90% 이상 일치하는 중복 코드였기 때문에, 신규 코드는 사실상 동일한 코드에서 git 히스토리만 사라진 코드라고 볼 수 있었다.)
기존 신규
Image Image
  • 여기서 이슈가 발생하는데, 기존 API 코드를 삭제하더라도, git 히스토리는 유지하고자 했다.
    • 개인적으로 각종 히스토리를 파악하는데 git 히스토리를 자주 활용하기도 하고,
    • 단순 히스토리 파악을 포함해서, 미처 몰랐던 선대(?) 개발자들의 의도를 알 수 있어서 같은 실수를 반복하지 않게 하기도 하기 때문이다.
    • 기존 코드를 그냥 삭제해도 히스토리를 보려면 볼수야 있지만, 기존 코드 삭제 이후에는 삭제된 기존 코드가 어떤 것이었는지 알아내서 찾아가야 한다.
  • 그리고 결정적으로 해당 클래스가 비지니스의 대부분의 주요한 로직과 히스토리를 담고 있는 클래스였기 때문에 git 히스토리를 그대로 가져가고자 했다.
  • 그래서 아래와 같은 목표를 달성하기 위한 시행착오를 이 글에서 다루었다.

목표

기존 코드의 git 히스토리를 신규 코드의 git 히스토리와 병합하고, 기존 코드를 삭제한다.

1. Git blame 병합

사실 이것을 시작한 최초의 목적은 글의 서두에서 보여준 기존 코드의 git blame을 신규 코드의 git blame에 반영하고 싶었던 것이었다.
그리고 동일한 니즈에 대한 해결책을 다룬 이 있었다!

요약하면 rename-rename merge conflict를 이용하는 방법으로, 서로 다른 파일 A,B의 git blame을 합치고자 할때, 아래와 같이 수행한다.

  1. branch1 : A->C rename
  2. branch2 : B->C rename
  3. branch1과 branch2를 merge
    • 이때 파일 C에 대해 발생하는 merge conflict를 resolve 해주어야한다.

구체적인 방법은 글에서 자세하게 다루고 있으니, 그대로 따라하면 된다.

참고로, 이때 기존 코드를 신규 코드로(A->B) 바로 merge하지 않는 이유는, 이렇게 하게 되면, git blame이 merge 시점의 commit으로 모두 덮어써져 버리기 때문에, 달성하고자 하는 목적에 벗어난다.

결과물

Image

기존 코드와 신규 코드의 git blame이 잘 병합되었다.

추가적인 주의사항

위의 글에 따라 반영하면서 추가적으로 직접 겪었던 시행착오와 그에 따른 주의사항을 기록하였다.

  • merge conflict 해소시에는 기존 파일들의 라인을 선택/제거만 하고, 수정은 하지 않는 것이 좋다. 수정이 필요하다면 이후의 별도 커밋에서 수행하는 것이 git blame 유지에 바람직하다.
    • 그 이유는, merge conflict 해소 시 기존에 있던 라인을 수정하면, git에서는 merge 시점에 새로운 라인을 추가한 것으로 인식하고, 해당 라인에 대한 git blame도 merge 커밋 시점으로 기록된다.
      • (git 입장에서는 기존 라인을 수정한 것인지 새로운 라인을 추가한 것인지 자체를 구분하는 것이 불가능하기 때문에)
    • 그래서 merge conflict 해소 직후 시점에는 컴파일이 되지 않는 상태의 커밋이 불가피하게 존재할 수 있다.(수정이 불가능하므로)
  • 완전히 동일한 라인이 두 개의 파일에 모두 포함되어 있을때, 해당 라인에 대한 git blame을 어떤 파일의 것으로 따라갈지는 git의 자체적인 휴리스틱(해당 라인의 앞뒤 라인의 유사도 등)에 따라 결정되기 때문에 일관된 방법을 찾는 것은 한계가 있다.
    • 따라서 위 글의 예시에서처럼 기계적인 merge conflict resolve는 현실적으로 어렵다.
    • 위 글에서 merge conflict가 기계적으로 resolve 가능했던 것은 합치고자 하는 두 개의 파일의 내용이 서로 다른 상황이었기 때문이다.
  • rename 시에는 Intellij 등의 IDE 환경에서 rename하는 경우 파일의 “내용”(import나 클래스명 등)을 자동으로 바꿔주기 때문에 cli 환경에서 단순 mv만 실행하여 파일명만 변경하는 것을 권장한다.

2. Github의 파일 히스토리 통합

위에서 git blame을 합쳤으니 목적을 달성하고 마무리하려고 했다.. 그러나 예상치 못한 이슈가 발생했다.
위에서 작업한 내용을 푸시하여 Github에서 파일 히스토리를 확인해보니, rename 이전의 히스토리가 신규 코드 쪽 히스토리만 남아있고, 기존 코드의 히스토리는 누락된 것이다.

Image
(Github UI 상에서 기존 파일에 대한 히스토리가 누락됐다.)

git blame만큼이나 파일 히스토리 또한 중요하기도 하고, 왜 신규 파일의 히스토리만 보존된 것인지 확인하기 위해 살펴보기 시작했다.

Git의 파일 히스토리와 git log 명령어

들어가기에 앞서, git에서의 “파일” 히스토리에 대해 이해할 필요가 있다.
git의 기본 단위는 커밋이고, git에서는 단순히 커밋의 그래프만을 관리하고 추적하기 때문에 내부적으로는 “파일” 단위의 히스토리라는 개념 자체가 없다.

다만, 파일 단위의 히스토리 조회 목적을 위해 git은 유틸성으로 git log ${filename} 형태의 명령어를 제공한다.
그리고 이 git log ${filename} 명령어는 결국엔 커밋 그래프를 순차적으로 탐색하면서 해당 ${filename}이 포함된 커밋만을 필터링해서 출력해주는 방식으로 동작한다. (해당 파일에 대한 커밋 목록을 따로 관리하지 않기 때문에)

그리고 해당 명령어는 기본적으로는 rename 이전 히스토리에 대한 추적을 지원하지 않는다. rename 커밋을 만나면, 필터링의 기준이 되는 파일명이 더 이상 존재하지 않으니 그대로 종료한다.
하지만 rename도 커밋 중의 하나일 뿐이기 때문에, rename 이전의 파일 히스토리도 이어서 보고 싶은 것은 자연스러운 니즈이며, 이러한 니즈를 위해서 git log 에는 --follow 옵션을 제공한다.

git log --follow ${filename} 형태의 명령어를 통해 rename 이전의 파일 히스토리도 함께 조회할 수 있다.

Github의 파일 히스토리

Github에서도 파일 히스토리(특정 파일에 대한 commit 리스트)를 아래와 같이 제공한다.
171795153-4f327a04-eb27-4d46-acb1-73d2e82ce4c5.gif

그리고 Github에서는 git log 디폴트 옵션처럼, 원래 rename 이전의 파일 히스토리를 UI 상에서 지원하지 않았으나, 2022년 업데이트로 rename 이전의 파일 히스토리도 조회할 수 있도록 업데이트되었다.
Image

예상하기로는 해당 기능은 동일한 목적의 git log --follow 옵션을 내부적으로 사용할 것 같다.
그런데 위 changelog에서 주목할 점은 git log --follow와 “similar”한 방식으로 동작한다는 문구이다.
결국 git log --follow 수행결과와 동일하지 않다는 것인데, 실제로 수행결과가 유사하기는 하나, 완전히 일치하지 않았다.

왜 이미 동일한 목적으로 존재하는 git log --follow 명령어를 그대로 사용하지 않을까?
그 이유는 git log --follow의 버그에 있다.

git log --follow의 버그

git log --follow의 내부 동작 방식을 살펴보자.
git log --follow 의 내부 동작 방식을 자세히 설명한 이 있어 일부를 발췌하였다.

Image

위의 예시를 단순화하여 표현하면 아래와 같다. (커밋의 이름은 커밋시간 순이며, 그래프를 시간 순으로 정렬하였다.)

eyJjb2RlIjoiJSV7aW5pdDoge1wiZ2l0R3JhcGhcIjoge1widXNlTWF4V2lkdGhcIjogZmFsc2UsICdtYWluQnJhbmNoTmFtZSc6ICdtYXN0ZXInfX19JSVcbmdpdEdyYXBoIEJUOlxuY29tbWl0IGlkOlwiW2EudHh0XSBDcmVhdGVcIiB0YWc6XCIxXCJcbmJyYW5jaCBmZWF0dXJlXG5jaGVja291dCBmZWF0dXJlXG5jb21taXQgaWQ6XCJbYS50eHRdIENoYW5nZVwiIHRhZzpcIjJcIlxuY2hlY2tvdXQgbWFzdGVyXG5jb21taXQgaWQ6XCJbYS50eHRdIHRvIFtiLnR4dF1cIiB0YWc6XCIzXCJcbm1lcmdlIGZlYXR1cmUgaWQ6XCJNZXJnZVwiIHRhZzpcIjRcIiIsIm1lcm1haWQiOnsidGhlbWUiOiJkZWZhdWx0In19

예시를 바탕으로 정리하면, (참고로 발췌한 부분의 예시가 반대로 적혀있어 이를 정정하고, 추가 내용을 보강하여 정리하였다.)

  • commit 4에서 git log --follow b.txt 명령어를 수행한 상황에서 살펴보자.
  • --follow 옵션은 rename 커밋(commit 3)을 만나면 탐색대상 파일을 바꿔치기(b.txt -> a.txt) 하는 방식으로 동작한다. (as-is, to-be 파일명을 둘다 추적하는 방식으로 동작하지 않는다.)
  • 그렇기 때문에 “바꿔치기가 언제 됐는지”(rename 커밋(commit 3)을 언제 방문했는지) 시점에 따라 결과가 달라진다. 예를 들어,
    1. 바꿔치기가 먼저 됐다면(commit 3를 먼저 방문했다면, 4->3->2->1), commit 3 방문 시점에 탐색 대상 파일명이 b.txt -> a.txt로 바꿔치기가 되고, commit 2 방문 시점에는 대상 파일명이 a.txt이므로 commit 2가 포함된다. 결과는 3->2->1 로 출력된다.
    2. 하지만, 바꿔치기가 나중에 됐다면(commit 3를 나중에 방문했다면, 4->2->3->1), as-is 파일명 a.txt로 기록된 commit 2를 방문하는 시점에 탐색 대상 파일명은 b.txt이므로 commit 2는 누락된다. 결과는 3->1 로 출력된다.
      • 위 stackoverflow 예시에서 최초 작성자가 의문을 제기한 케이스
  • 확인 결과
    • Image
  • 그리고 git log의 탐색 순서는 priority queue 기반의 BFS(breadth first search) 방식으로 탐색한다.

어쨌든 위의 내용은 내부적인 동작 방식에 대한 디테일한 설명인 것이고,
중요한 것은 git log --follow 명령어는 git log의 탐색 순서가 따라 결과 셋이 달라질 수 있다는 것이다.

git log --follow의 한계

위에서 git log --follow가 동시에 최대 한개의 파일만 추적이 가능하다는 것을 알았다.

그럼 본론으로 돌아와 해결하고자 하는 문제에 위 내용을 적용해보자.
1. Git blame 병합 파트에서 적용하고자 하는 방법을 위의 예시와 동일한 도식으로 나타내면 아래와 같다.

eyJjb2RlIjoiJSV7aW5pdDoge1wiZ2l0R3JhcGhcIjoge1widXNlTWF4V2lkdGhcIjogZmFsc2UsICdtYWluQnJhbmNoTmFtZSc6ICdmZWF0dXJlQSd9fX0lJVxuZ2l0R3JhcGggQlQ6XG5jb21taXQgaWQ6XCJbQl0gRG8gc29tZXRoaW5nIGluIEJcIiB0YWc6XCIwXCJcbmNvbW1pdCBpZDpcIltBXSBEbyBzb21ldGhpbmcgaW4gQVwiIHRhZzpcIjFcIlxuYnJhbmNoIGZlYXR1cmVCXG5jaGVja291dCBmZWF0dXJlQlxuY29tbWl0IGlkOlwiW0JdIHRvIFtDXVwiIHRhZzpcIjJcIlxuY2hlY2tvdXQgZmVhdHVyZUFcbmNvbW1pdCBpZDpcIltBXSB0byBbQ11cIiB0YWc6XCIzXCJcbm1lcmdlIGZlYXR1cmVCIGlkOlwiW0NdIE1lcmdlIEEgYW5kIEIgYXMgQ1wiIHRhZzpcIjRcIiIsIm1lcm1haWQiOnsidGhlbWUiOiJkZWZhdWx0In19

그리고 이 상황에서 탐색의 경우의 수에 따른 git log --follow C 수행 시나리오를 떠올려보자.

  1. commit 3을 먼저 방문(4->3->2->1->0)
    • commit 3 방문 시점에 탐색 대상 파일을 A로 변경
    • commit 2는 A가 포함되지 않았으므로 제외
    • commit 1은 A가 포함됐으므로 출력, commit 0은 A가 포함되지 않았으므로 제외
    • 결과 : 4->3->1 (2와 0은 누락)
  2. commit 2를 먼저 방문(4->2->3->1->0)
    • commit 2 방문 시점에 탐색 대상 파일을 B로 변경
    • commit 3는 B가 포함되지 않았으므로 제외
    • commit 1은 B가 포함되지 않았으므로 제외, commit 0은 B가 포함됐으므로 포함.
    • 결과 : 4->2->0 (3과 1은 누락)

두 가지 시나리오를 살펴본 결과, 결국 양쪽에서 rename이 발생한 경우, 어느 쪽을 먼저 탐색하든, 다른 한쪽이 누락될 수 밖에 없다.
이는 git log --follow가 동시에 최대 한개의 파일만 추적하기 때문에 발생하는 한계이며, 히스토리를 보존하고자 하는 본 과제의 목적상 아주 치명적이다.

그리고 해당 한계에 대해 다룬 이 있어 일부를 발췌하였다.
Image

결국 parent branch가 여러 개 있고, 한쪽 parent에서(혹은 양쪽 모두에서) rename이 있었던 경우 나머지 parent의 커밋 히스토리는 누락된다는 내용이다.

Github의 방식

앞서 Github에서는 파일 히스토리를 git log --follow와 “유사한” 방식으로 보여준다고 했다.
git log --follow는 위와 같은 버그와 한계가 있으니, Github에서는 이를 해결하였을까?

결론부터 얘기하면 아니다.
Github의 UI 상에서 지원하는 파일 히스토리는 git log --follow와 약간 다른 결과를 보여주지만, 본질적으로 본 과제에서 문제가 되는, 한 쪽 파일의 히스토리가 누락되는 문제는 해결되지 않았다.

Github의 파일 히스토리는 아래와 같이 동작하는 것으로 추측하고 있다. (관련 공식 레퍼런스를 찾진 못했다.)
Github url을 통해 아래와 같이 추측하였고, 추측한 내용을 로컬에서 수행한 결과와 화면에서 보여준 결과가 동일하였다.

커밋 c1에 의해 A->B로의 rename이 있었고, 커밋 c2 상태에서 B의 파일 히스토리를 조회하는 시나리오를 기준으로,

  1. 최초에 파일 히스토리 조회시 --follow 옵션 없이 git log c2 B를 수행
    • 파일 히스토리 버튼 클릭시 github.com/{repo}/commits/c2/B url로 연결된다.
    • Image
  2. 마지막 커밋(rename 커밋, 즉 c1)에 도달한 경우 c1으로 부터 다시 git log c1 A를 수행
    • Github에서는 rename이 있었던 경우 Rename from A (Browse History) 형태의 링크를 제공하며,
    • 해당 링크는 github.com/{repo}/commits/c1/A?browsing_rename_history=true&new_path=B&original_branch=c2로 연결된다.
    • Image
  3. 마지막 커밋이 rename이 아닐때까지 1,2를 반복

결국 요약하면, --follow 옵션 없이 git log HEAD filename 형태의 명령어를 HEAD와 filename을 바꿔가며 반복적으로 호출하는 형태이다.
하지만 여전히 동시에 최대 한개의 파일만 추적한다는 동일한 한계를 갖고 있기 때문에, 한 쪽 파일의 히스토리가 누락되는 한계는 여전히 남아있다.

특정 파일의 히스토리를 의도적으로 채택하는 방법(git log의 탐색 우선순위)

위에서 살펴보았듯이, git log --follow든, Github이든 한 쪽의 히스토리가 누락되는 것은 불가피해보인다.
그럼 결국 한 쪽 파일의 히스토리를 선택해야하는 상황에 봉착한다. 본 과제의 목적은 기존 코드의 히스토리를 보존하고자 하는 것이었으므로, 기존 코드의 히스토리를 채택하자.

Github의 방식 파트에서 살펴보았듯이, 기존 파일의 히스토리를 보존하기 위해서는 git log 탐색 순서상 마지막 커밋이 기존 파일의 rename 커밋이 되어야한다.
그렇다면, git log의 탐색 순서는 어떻게 결정되는 것일까?

앞서 “git log의 탐색은 priority queue 기반의 BFS(breadth first search) 방식으로 탐색한다.” 라는 내용을 언급하였다.
이 말은 곧 git log는 우선순위 기반으로 탐색을 한다는 것이고, 우선순위의 디폴트로써 “커밋 시간”의 역순을 채택하고 있다.
결국 특정 커밋(보존하고자 하는 파일의 rename 커밋)을 git log가 마지막에 탐색하도록 하게 하기 위해서는 해당 커밋을 시간순으로 먼저 수행하면 된다.

따라서, 기존 파일의 rename 커밋이 마지막에 탐색되도록 하기 위해, 아래와 같이 기존 파일의 rename 커밋을 먼저 수행 후, 신규 파일의 rename 커밋을 이후에 수행하였다.

Image

git log 우선순위 관련 참고

  • 파트 도입부 에서 문제가 되었던, 히스토리가 신규 파일로 연결되었던 것도, 신규 파일의 rename 커밋이 기존 파일의 rename 커밋보다 시간순으로 먼저 수행되었기 때문이었다.
  • git log의 탐색 우선순위는 디폴트인 커밋 시간 외에도 git log의 옵션을 통해 지정할 수 있으며, --date-order, --author-date-order, --topo-order 등의 옵션을 제공한다. (Git - git-log Documentation 참고)

결과물

이로써 파트 도입부 에서 Github의 파일 히스토리가 신규 파일로 연결되었던 것을 기존 파일로 연결되도록 하였다.
Image

3. Fast forward merge

여기까지 왔으면, 복잡한 과정을 대부분 지나왔다.
여기서 한 가지 더 추가적인 주의사항이 존재하는데, 이 주의사항을 놓치면 지금까지의 수고가 무용지물이 될 수 있다. 그것은 바로 merge 방식이다.

대부분의 피쳐 개발과 마찬가지로, 위의 모든 과정을 feature 브랜치에서 진행했기 때문에, develop 및 master로의 머지가 필요하다.
이때, 명시적인 merge commit이 발생하는 경우 master 및 develop 입장에서는 커밋 원자성이 훼손되기 때문에 커밋 원자성을 보존하기 위한 위의 노력들이 모두 물거품이 된다.
따라서, 명시적인 merge commit이 발생하지 않으면서 커밋 원자성을 보존할 수 있는 Fast forward merge 방식으로 머지해야한다.

Non fast forward merge Fast forward merge
Image Image

(Non-Fast-forward merge와 Fast forward merge의 차이점. 출처 : 🌳🚀 CS Visualized: Useful Git Commands - DEV Community)

그러나 문제는 Github에서는 아직까지 UI 상에서 fast forward merge를 지원하지 않고, 명시적인 merge commit을 강제한다.
따라서 Github 상의 Pull request를 fast forward merge 하기 위해서는 아래와 같이 로컬에서 fast-forward merge 후 push하는 방식으로 수행할 수 있다.
push 시점에 Github에서는 이를 인지하여 Pull request를 머지 처리한다.
Image

혹은 Github에서 Fast Forward PR · Actions · GitHub Marketplace · GitHub 과 같은 github actions를 활용할 수도 있다.

Fast-forward merge 관련 참고

  • Github에서 지원하는 다른 머지 방식인 squash and merge , rebase and merge는 fast-forward 와 유사한 방식으로 동작하지만 아래와 같은 이유로 적합하지 않다.
    • Squash and merge : 커밋 원자성이 훼손되므로, 이 경우에 적합하지 않다.
    • Rebase and merge : rebase는 결국 commit을 다시 수행하는 과정이라고 볼 수 있는데, 위에서 살펴본 것처럼 수동으로 충돌 해결이 필요하므로 버튼 자체가 disable된다.
  • 그리고 사실 이 문제는 Github에서 fast forward merge를 지원하지 않기 때문에 발생하는 문제이고, Gitlab 등에서는 Fast forward merge를 UI 상에서 지원한다.
    • Image

결론

결국 1~3의 과정을 거쳐서 git blame를 병합하고, 기존 코드의 Github 파일 히스토리를 보존하면서 중복 코드를 병합할 수 있었다.

다만, 여기까지 오면서 느낀 점은 두가지인데,

  1. 복잡하다.
  2. 그리고 결정적으로 복잡함을 이겨내더라도, 온전하게 기존 히스토리를 유지할 수 없다는 한계점 또한 있다.

그렇기 때문에 본 과제를 진행하면서 배운 교훈은 아래와 같다.

  1. 위 방법을 사용하는 일이 없도록 하는 것이 가장 좋다.
  2. 다시 말해, 코드 복붙은 git 히스토리 관리 입장에서도 바람직하지 않다.
    • 위에서 살펴본 방법으로 사후적인 수습은 가능하나, 복잡하고, 상황에 따라서는 온전한 수습이 되지 않을 수 있다.
  3. 코드 복붙이 불가피하다면, 복사 시점에 단순 내용 복사가 아닌 git 히스토리를 같이 복사할 수 있는 아래 방법을 활용하는 것이 좋다.

References

Tags:

Categories:

Updated:

Leave a comment