git merge 는 현재 branch에 다른 branch의 변경 사항을 통합하는 명령입니다. 독립적으로 진행된 branch들의 히스토리를 다시 한 줄로 합치는 역할을 합니다. 조금 더 풀어보면 기준이 되는 branch에서 다른 branch에 대한 merge 명령을 실행하면, Git은 두 branch의 공통 조상(merge-base)를 찾고 그 시점 이후의 변경사항을 합쳐 새로운 커밋(merge commit)을 만듭니다.
단 fast-forward가 가능한 경우는 제외되는데, 이는 바로 아래에서 다루겠습니다.
fast-forward
먼저, 가장 단순한 케이스부터 살펴보겠습니다.
아직 main에서 아무 작업을 하지 않은 상태에서 아래처럼 feature만 앞으로 나간 경우입니다.
이 상태에서 feature branch를 merge하면 Git은 main도 F2를 가리키게 만들면 되겠다 라고 단순하게 판단합니다.
이 때 새 커밋을 만들지 않고, branch 포인터만 앞으로 이동시키는데 이를 fast-forward라고 합니다. 뒤에서 볼 rebase와 cherry-pick은 공통적으로 다른 곳의 변경을 현재 브랜치 위에 가져온다는 점에서는 비슷하지만, fast-forward와 달리 새 커밋을 만들어서 적용한다는 차이가 있습니다.
3-way-merge
앞에서 만든 예제처럼 main과 feature/login이 서로 다르게 진행된 상태를 다시 보겠습니다.
이 상태에서 다시 feature/login을 merge를 실행해보면 다음과 같은 일들이 일어납니다.
main과 feature/login의 공통 조상(merge-base)를 찾음 (M0)
M0 → M1 사이의 변경과 M0 → F2 사이의 diff를 비교
둘을 합쳐 새로운 커밋(merge commit)을 생성
main branch에서 새로운 커밋을 가리키도록 함
main branch에서 git merge feature/login 명령을 실행한 결과는 다음과 같은 형태가 됩니다.
여기서 새로운 커밋(MG)은 병합 대상이었던 두 커밋을 동시에 부모로 가지게 됩니다. DAG 관점에서는 두 갈래가 한 점으로 합쳐지는 노드가 생성되었습니다.
정리하면 merge 명령은 기준 branch(현재 HEAD)에서 합치고 싶은 다른 branch의 commit들을 가져와서 공통 조상 이후의 변경 내용을 통합해 새로운 commit을 만드는 명령입니다.
rebase
같은 예제로 rebase를 보겠습니다. 현재 히스토리는 아래처럼 갈라져 있습니다. M0을 기준으로 main도 새로운 커밋이 존재하고, feature/login 또한 M0을 기준으로 새로운 커밋들이 존재합니다.
git rebase 는 한 branch에서 만들어진 commit들을 다른 시작점으로 옮겨(transplant) 다시 적용하는 명령어입니다.
즉 branch의 base를 다른 commit으로 바꾸는 것처럼 보이게 만들며, 내부적으로는 새 commit들을 만들고 그 위에 다시 쌓는다는 것입니다. 더 쉽게말해 브랜치를 다른 시작점 위로 끌어올려서, 마치 거기서부터 시작한 것처럼 히스토리를 다시 쓰는 것입니다.
지금 상황에서 feature/login branch에서 main의 내용을 가져오고 싶어서 git rebase main 명령을 실행했다고 해봅시다. 이 때 rebase는 다음과 같이 동작합니다.
feature/login에서 main에 없는 commit 목록을 찾음 (F1, F2)
main의 최신 commit인 M1을 기준으로 F1, F2의 변경 내용을 순서대로 다시 적용하면서 새 commit을 생성
feature/login branch ref를 예전 F2가 아니라 새로운 commit으로 이동
R1, R2는 F1, F2에서 했던 변경 내용을 main 최신 커밋(M1) 위에 다시 적용한 결과이기 때문에, 코드 관점에서는 같은 변경에 가깝지만 Git 입장에서는 해시, 부모 정보 등이 모두 다른 완전히 새로운 커밋입니다. feature/login branch는 이제 R2를 가리키게 되어 F2에 대한 참조가 끊어지게 됩니다. 하지만 1편에서 언급했던 것 처럼 Git의 저장소는 읽기, 쓰기만 가능하기 때문에 참조되지 않는 F1, F2 커밋도 .git/objects와 reflog에 그대로 남아있게 됩니다.
merge vs rebase
같은 상황에서 merge, rebase를 비교해봤습니다.
merge는 새 merge commit을 추가해서 히스토리를 합치고 분기/병합 구조가 그래프에 그대로 남기 때문에 타임라인을 보존하고 싶을 때 유리합니다.
하지만 rebase는 특정 branch의 새로운 커밋들을 다시 만들어서 다른 branch의 HEAD commit 뒤에 이어 붙입니다. 이전 commit은 더이상 참조되지 않기 때문에 외형상 한 줄짜리 깔끔한 히스토리가 됩니다. 대신 기존 commit의 ID(hash)가 모두 바뀐다는 점을 주의해야합니다.
cherry-pick
cherry-pick은 특정 commit만 가져올 때 사용하는 명령입니다. 한 branch 내에 단일 commit 혹은 여러 commit들을 다른 branch의 최신 commit 위에 추가합니다. 앞에서 봤던 merge/rebase가 branch 단위로 여러 commit을 통째로 옮기는 느낌이라면, cherry-pick는 원하는 commit만 골라 복사하는 명령에 가깝습니다.
조금 단순한 예제를 하나 더 가정해보겠습니다. 이번에는 main에서 hotfix/log branch를 하나 생성하여 두 개의 hotfix commit을 만들었습니다. H1은 중요한 버그 픽스라 main에도 바로 반영되어야하고, H2는 단순 디버깅 로그라 main에는 바로 반영하지 않아도 된다고 가정하겠습니다.
git cherry-pick 을 실행하면 Git은 H1의 변경 내용을 기준으로 현재 main이 가리키는 M1 위에 새 commit H1'을 하나 더 만듭니다.
main 입장에서는 새 commit이 하나 생성된 것입니다. H1과 내용은 같지만 다른 commit hash를 가진 별도의 커밋이 됩니다.
cherry-pick을 과하게 사용하면 내용이 같지만 해시가 다른 커밋들이 여러 군데 생겨서 히스토리 추적이 힘들어질 수 있을 것 같습니다. 그래서 보통은 지금 예시처럼 hotfix 일부만 main에 반영해야 할 때나, 잘못된 브랜치에 커밋했을 때 특정 커밋만 옮기고 싶을 때 정도에 사용하는 편이 좋다고 느꼈습니다.
정리
Git의 내부 동작 원리를 세 편으로 나눠서 정리해봤습니다.
1편에서는 Git의 내부 저장소를 해시 기반 K-V 저장소로 보고, blob/tree/commit/tag 객체 구조와 commit 생성 과정을 살펴봤고
2편에서는 commit이 parent 링크로 이어지는 DAG 구조, 그 위에 올라가는 branch/ref/HEAD/tag/reflog를 정리했습니다.
이번 편에서는 merge, rebase, cherry-pick을 실제 예제와 다이어그램으로 정리해봤습니다.
merge는 갈라진 히스토리를 새 merge commit으로 합치는 방식, rebase는 브랜치의 base를 바꾸면서 커밋들을 새로 만드는 방식이며 마지막으로 cherry-pick은 특정 커밋만 골라 복사해오는 방식이었습니다.
세 편에서 정리한 내용을 하나로 합치면, Git은 해시 기반 K-V 저장소 위에 commit DAG를 쌓고, 그 위에서 branch/HEAD/tag 같은 ref를 옮기면서 작업하는 도구 라고 최종 요약할 수 있을 것 같습니다.
Git을 많이 사용하기 때문에, 단순히 명령어만 익히는 것이 아니라 내부 동작을 어느 정도 이해해 보고 싶어서 이번 시리즈를 학습하고 정리해봤습니다. 조금 더 적재적소에 적절한 명령어를 사용하고 특히 오픈소스를 기여하면서 무수히 많은 개발자들의 commit, branch와 유기적으로 잘 분리하고 병합하여 기여해나갈 수 있겠다는 생각이 듭니다.
# first commit
commit 7fc68d4...
tree 3354a0b...
author ...
committer ...
first
# second commit
commit fc912aa...
tree be51317...
parent 7fc68d4...
author ...
committer ...
second
여기서 parent 필드를 주목해야합니다. first 커밋은 최초 커밋으로 부모 커밋이 없으며, second 커밋은 first를 부모로 가리키고 있습니다.
7fc68d4 (first) -> fc912aa (second)
이렇게만 보면 커밋들이 단방향 LinkedList처럼 보일 수 있습니다.
하지만, Git에서는 merge 커밋이 부모를 2개 이상 가질 수 있고, 여러 branch가 갈라졌다가 다시 합쳐질 수 있습니다. 그래서 커밋들은 LinkedList가 아닌 DAG(Directed Acyclic Graph, 방향 비순환 그래프) 구조를 형성합니다.
방향(Directed): commit들은 부모–자식 관계로 이어지고, 보통 부모(과거) → 자식(현재) 방향으로 그래프를 그림
비순환(Acyclic): 과거 커밋이 다시 미래 커밋을 가리키는 식으로의 순환이 생기지 않음
단순하게 정리하자면, 커밋들이 parent 링크로 이어진 그래프 위에서 branch와 HEAD가 움직인다고 할 수 있습니다.
(이 DAG에 대해서는 merge를 다음 포스팅에서 다룬 뒤 더 자세하게 정리해보도록 하겠습니다)
ref & branch & HEAD
ref
ref는 특정 커밋을 가리키는 이름(참조) 입니다. branch, tag, remote branch 등은 모두 ref의 한 종류입니다.
새 커밋이 생길 때마다 “HEAD가 가리키는 **브랜치 ref**”가 한 칸씩 앞으로 이동하는 모습입니다. HEAD가 직접 해시를 들고 움직이는 게 아니라 HEAD → 브랜치 → 커밋 구조에서 브랜치 → 커밋 관계만 새 커밋으로 바뀌는 셈입니다.
tag
branch가 커밋 그래프 위에서 앞으로 움직이는 포인터라면, tag는 한 커밋에 고정된 이름표처럼 쓰입니다. 보통의 오픈소스 릴리즈 등의 버전 관리 등에 자주 쓰이는 그 tag입니다.
위에서 언급했다시피 tag는 .git/refs/tags 경로에 생성됩니다. tag 또한 ref의 한 종류이기 때문에, 브랜치와 마찬가지로 태그 파일 안에 커밋 해시가 한 줄 저장됩니다.
branch와의 차이점은, 직접 삭제하지 않으면 특정 시점에 고정해서 쓰는 이름표처럼 쓰입니다. 반면 branch는 새 커밋이 생길 때마다 앞으로 이동하는 포인터입니다. 결국 둘의 차이는 계속 이동시킬 것이냐, 특정 시점에 고정해서 사용할 것이냐의 차이입니다.
reflog
1편에서 Git의 객체가 불변이고, branch/tag 같은 ref만 옮겨 다닌다고 얘기했습니다. 그렇기 때문에 우리는 만약 하드 리셋으로 잘못된 시점으로 리셋하여 작업을 전부 날려먹더라도 복구할 수 있습니다. 커밋 오브젝트는 .git/objects 안에 그대로 있고, 단지 하드 리셋으로 branch ref가 더이상 그 커밋을 가리키지 않을 뿐입니다.
git reset --hard HEAD~1
reflog는 Git이 HEAD와 각 branch ref가 이전에 어떤 커밋들을 가리켰었는지를 기록해 두는 로그입니다.
$ git reflog
fc912aa HEAD@{0}: reset: moving to HEAD~1
4741022 HEAD@{1}: commit: add feature log
fc912aa HEAD@{2}: checkout: moving from master to feature/signup
...
HEAD가 움직인 기록을 추적할 수 있기 때문에 이 해시들로 새 branch를 만들거나 다시 reset하여 복구할 수 있습니다.
정리하자면, 이전 포스팅에서 다뤘던 Git의 객체(Blob / Tree / Commit)는 Insert/Select만 되는 불변 객체들이라 남아있고, reflog는 HEAD/branch가 어디를 가리켰는지에 대한 로그를 확인할 수 있습니다. 그래서 잘못된 reset 이후에도, 로컬 저장소 기준에서는 꽤 많은 경우 커밋을 되살릴 수 있는 수단이 됩니다. (더 자세한 예시는 Pro Git의 Maintenance & Data Recovery에서 확인할 수 있습니다.)
정리
이전 포스팅에서는 Git을 내용 기반 해시를 사용하는 K-V 저장소 위에 객체들을 쌓는다고 정리했습니다.
이번 포스팅에서는 추가로, ref와 branch, HEAD를 같이 정리하여 우리가 전반적으로 사용하는 커밋, 브랜치 생성 및 이동 등의 상황에서 내부적으로 Git이 어떻게 동작하는지 살펴보았습니다. 이 모든 것이 결국 효율적인 Git 저장 객체들을 활용하면서, 커밋 DAG 위에서 움직이는 이름표들의 조합이라고 볼 수 있을 것 같습니다.
Git은 commit graph 위에 branch/HEAD 같은 ref를 띄워 둔 구조이고, 우리는 평소에 이 ref들을 옮기면서 작업하고 있다는 관점으로 이해하니까, Git 명령어들이 머리 속에서 조금 더 일관되게 정리되는 느낌입니다.
다음 편에서는, merge, rebase를 통해 조금 더 커밋들을 다루고 히스토리에 어떤 차이를 만드는지에 대해 정리해보겠습니다.
만 2년 넘게 개발을 해오면서 Git을 무수히 많이 사용했지만 정작 내부 원리에 대해서는 생각해 본 적이 없는 것 같아 이번 기회에 학습하면서 관련 내용들을 정리 해보려고 합니다.
공식 문서에서는 Git을 내용 기반 주소를 사용하는 Key-Value 저장소이자 파일 시스템 정도로 설명합니다.
이번 포스팅에서는, 이 저장 방식에 대한 이해를 토대로 Git의 데이터 저장 방식과 commit까지의 과정에서 어떤 일들이 발생하는지 등에 대해 알아보려고 합니다.
포스팅에 사용된 디렉토리 구조는 다음과 같습니다.
Git
git 레포지토리 내에는 항상 .git/objects 디렉토리가 있습니다. 이 디렉토리 안에 모든 버전의 파일/디렉토리/커밋 정보가 객체 형태로 저장됩니다. Git은 이 오브젝트들을 해시 → 압축된 오브젝트 형태의 Key-Value로 관리합니다.
key: 오브젝트 해시 (기본은 40자 SHA-1, 최근 SHA-256 지원)
value: 타입(blob/tree/commit/tag) + 내용(zlib 압축)
Git의 객체
Git이 저장하는 오브젝트 타입은 네 가지입니다.
Blob: 파일 내용을 저장하는 객체
blob은 파일의 내용만 저장하는 객체입니다. 우리가 디렉토리에 생성하는 코드 파일, 문서, 기타 텍스트/바이너리 파일들이 여기에 해당합니다.
파일 이름, 경로, 권한 등은 기록되지 않고 오직 내용만 Blob에 저장됩니다.
# src/app/main.ts를 추적
C:\Users\root\Desktop\dev\git-study> git cat-file -p 25b690689b298649c027af668c051282a96eed6c
test
Tree: 디렉토리 1개를 나타내는 객체
디렉토리를 나타내는 객체로, mode/type/name/object-hash가 저장됩니다.
# src/app 디렉토리를 추적
C:\Users\root\Desktop\dev\git-study> git cat-file -p 4401420390c38334914cdb88c0b1231d058605d2
# mode type hash name
100644 blob 25b690689b298649c027af668c051282a96eed6c main.ts
mode: POSIX 파일 모드를 나타내는 6자리 숫자로 파일/디렉토리/실행파일/심볼릭링크 등의 하위 해시값의 판별 정보
type: 하위 해시값의 타입 (blob / tree / commit (submodule일 때)
hash: 해당 객체의 해시
name: 실제 원본 이름
위에 예시에서는 blob 타입의 일반 파일이며, 파일의 해시값과 이름의 k-v를 가지고 있다고 해석할 수 있겠습니다.
Commit: 실제 커밋 시점의 프로젝트 스냅샷을 가리키는 객체
우리가 git commit을 할 때 생성되는 오브젝트입니다. 커밋 자체가 코드 내용을 직접 들고 있는 건 아니고, 루트 트리(tree)의 해시와 메타데이터, 부모 커밋 해시를 함께 가지면서 이 시점의 스냅샷은 이 tree를 보면 된다 라고 가리키는 역할을 합니다.
Commit 객체에는 커밋 시 작성된 메시지를 포함한 각종 메타데이터들을 가지고 있습니다. git log 명령어를 통해 나온 해시값으로 추적해보면 다음과 같은 정보를 얻을 수 있습니다.
# first commit
git cat-file -p 7fc68d4fc2bca212fb60a2aa8dd55a5c3093c46c
tree 3354a0b3ad3cbd78d1ab5c596208b8fccd9e2cc9
author mag123c <diehreo@gmail.com> 1763531007 +0900
committer mag123c <diehreo@gmail.com> 1763531007 +0900
first
# second commit
git cat-file -p fc912aa419552b61e97fb086dae0cefdc20cd58a
tree be513172b3e4eec559c85d7215444197292d7e92
parent 7fc68d4fc2bca212fb60a2aa8dd55a5c3093c46c
author mag123c <diehreo@gmail.com> 1763531141 +0900
committer mag123c <diehreo@gmail.com> 1763531141 +0900
second
tree: 이 커밋이 가리키는 루트 tree의 해시 (루트 디렉토리)
parent: (첫 번째 커밋이 아닐 경우) 부모의 commit 해시
author / committer / 날짜 / 메시지등의 메타데이터
commit을 만들 때 필요한 재료는 위에서 본 것처럼메타데이터, 프로젝트 루트 해시, 부모 커밋 해시로 이루어집니다. 이 세가지를 텍스트 형태로 이어 붙인 뒤, 그 전체에 헤더를 붙여 해시를 내면 커밋 오브젝트의 해시가 됩니다.
tag: 커밋의 이름을 붙이는 객체
보통 버전관리에 많이 쓰이는 tag 또한 객체로 관리되는데, 이번 포스팅 주제에서는 크게 다루지 않겠습니다.
Commit을 하면 어떤 일이 일어날까
Git에서 저장을 위해 사용되는 객체들을 살펴봤습니다. 이제 이 객체들을 조합해서 commit을 할 때 내부적으로 어떤 순서로 동작하는지 알아보겠습니다.
1. 파일 내용을 blob으로 저장
워킹 디렉토리의 스테이징 영역에서 추적된 파일을 읽습니다. 파일 내용을 읽고, 해싱해서 저장합니다. 이 때 같은 내용의 파일이면 해시가 같으므로 저장하지 않습니다. 이는 아래 예제에서 다루겠습니다.
2. 디렉토리를 tree로 저장
이제 디렉토리별 스냅샷을 만듭니다.
디렉토리의 내부 파일 / 디렉토리를 이름 순으로 정렬
각 엔트리에 대해 mode / type / hash / name을 나열
디렉토리 내의 엔트리들을 mode type hash name 형식으로 쭉 나열해서 하나의 바이트 시퀀스로 만들고, 이 전체에 대해 해시를 계산해 tree 오브젝트를 생성합니다.
이 과정을 하위 디렉토리부터 루트까지 재귀적으로 진행하여 루트 디렉토리를 나타내는 하나의 tree 해시를 구합니다.
3. commit 객체 생성
커밋 메시지 등의 메타데이터와 트리 해시, 부모 커밋 해시를 이어 붙인 commit 객체를 만들고, 이 내용 전체를 해싱한 값을 생성합니다.
당연하겠지만, 스테이징이 있으면 새로운 커밋을 생성하고 변경된 blob이 속한 tree들의 해시가 바뀌고 결론적으로 commit이 새로 생성됩니다. 하위 해시가 바뀌면 관련된 상위 해시도 전파되어서 바뀌게 된다는 뜻입니다.
예제로 살펴보기
위의 예제 디렉토리 구조를 처음 생성하고 두 개의 커밋을 생성해서 비교해보겠습니다.
first: main.ts에 "test"라고 입력 후 커밋
second: test.ts에 "TEST"라고 입력 후 커밋
git log
commit fc912aa419552b61e97fb086dae0cefdc20cd58a (HEAD -> master)
Author: mag123c <diehreo@gmail.com>
Date: Wed Nov 19 14:45:41 2025 +0900
second
commit 7fc68d4fc2bca212fb60a2aa8dd55a5c3093c46c
Author: mag123c <diehreo@gmail.com>
Date: Wed Nov 19 14:43:27 2025 +0900
first
git cat-file 명령어의 pretty print(-p)를 통해 첫 번째 커밋을 추적해보겠습니다.
# first commit
git cat-file -p 7fc68d4fc2bca212fb60a2aa8dd55a5c3093c46c
tree 3354a0b3ad3cbd78d1ab5c596208b8fccd9e2cc9
author mag123c <diehreo@gmail.com> 1763531007 +0900
committer mag123c <diehreo@gmail.com> 1763531007 +0900
first
git cat-file -p 3354a0b3ad3cbd78d1ab5c596208b8fccd9e2cc9
040000 tree bb43df4aafae55c85532fa9f8abc1012c5cbfd03 src
git cat-file -p bb43df4aafae55c85532fa9f8abc1012c5cbfd03
040000 tree 4401420390c38334914cdb88c0b1231d058605d2 app
040000 tree dd830e88013a96181c12f9a822313760968701e1 test
PS C:\Users\root\Desktop\dev\git-study> git cat-file -p 4401420390c38334914cdb88c0b1231d058605d2
100644 blob 25b690689b298649c027af668c051282a96eed6c main.ts
PS C:\Users\root\Desktop\dev\git-study> git cat-file -p 25b690689b298649c027af668c051282a96eed6c
test
PS C:\Users\root\Desktop\dev\git-study> git cat-file -p dd830e88013a96181c12f9a822313760968701e1
100644 blob 49cc8ef0e116cef009fe0bd72473a964bbd07f9b test.ts
C:\Users\root\Desktop\dev\git-study> git cat-file -p 49cc8ef0e116cef009fe0bd72473a964bbd07f9b
# 공백
똑같이 두 번째 커밋을 추적해보고, 결과를 플로우 차트로 정리해봤습니다.
파란색이 첫 번째 커밋, 빨간색이 두 번째 커밋입니다.
여기서 눈여겨볼 점은 src/app과 main.ts, 즉 변하지 않은 tree, blob은 그대로 재사용된다는 점입니다.
test.ts 내용이 바뀌었기 때문에 test.ts blob이 새로 생성되고 이와 관련된 test tree, src tree, root tree만 새로 생성되어 새로운 commit객체로 새로 생성되게 됩니다.
안전성/불변성의 보장
이 구조를 보면, 특정 커밋을 읽어오는 과정에서 특정 해시가 사라진다면 전체 커밋에 손상이 생겨 해당 커밋이 날아갈 수도 있습니다. 특정 해시가 없어서 모든 데이터를 온전하게 읽어올 수 없으니까요.
Git은 이러한 문제를 사전에 방지하기 위해, 한 번 생성된 객체의 내용을 바꾸는 대신 항상 새 객체를 만들어 쌓는 방식으로 동작하도록 설계되어 있습니다. 위의 예제에서 test.ts의 내용이 바뀌었을 때 연관된 모든 객체들의 해시가 새로 생성되어 저장되었던 것 처럼 말입니다. 또한, 기존 객체를 수정하는 API도 없으며 일반 Git 사용 흐름에서 객체를 직접 지우지 않고 브랜치/태그에서 해당 해시에 참조가 끊기면 나중에 GC를 통해 쓸모없는 객체를 정리하도록 되어있습니다.
그래서 히스토리를 force-push로 지운 것처럼 보여도 어느 시점까지는 reflog나 GC 설정에 따라 객체는 꽤 오래 남아있게 됩니다.
이 불변성 덕분에 중간에 해시가 바뀌어서 깨지는 문제는 거의 없으며, 오픈소스에서 누가 뭘 하든 기존 커밋 자체는 남아있게 됩니다.
참고로 git commit --amend 명령도 기존 커밋 객체를 수정하는 게 아니라, 수정된 내용/메시지를 반영한 새로운 커밋 객체를 하나 더 만든 다음 branch ref를 그 새 커밋으로 옮기는 동작에 가깝습니다. 개발자 입장에서는 덮어쓰기처럼 보이지만, 내부적으로는 새 커밋이 하나 더 생기고 예전 커밋은 브랜치에서만 끊길 뿐 .git/objects 안에는 남아 있다가, 나중에 reflog나 GC 정책에 따라 정리됩니다.
git diff는 상황에 따라 내부적으로 git diff-tree, git diff-index, git diff-files 같은 로우 레벨 명령을 사용해서 실제 변경 내용을 계산합니다. 두 blob 쌍이 결정되면, 그 위에 Myers 같은 텍스트 diff 알고리즘을 적용해서 우리가 보는 +, - 기반의 diff 출력을 만듭니다.
commit끼리 비교하는 git diff 기준으로 단순화해보면, 동작 방식은 다음과 같습니다.
두 commit에서 각 루트 tree 해시를 가져옴
두 tree를 동시에 비교하면서 같은 path를 가진 엔트리끼리 매칭. 엔트리들의 해시가 다르다면 하위로 내려가며 blob 쌍을 수집
수집된 blob 쌍에 대해 텍스트 diff 알고리즘을 적용해 최종 diff 출력을 만듬
중요한 건, Git이 diff 결과를 저장하지 않는다는 점입니다. Git은 각 커밋에서 전체 스냅샷을 tree/blob으로 보관하고, git diff 실행 시마다 두 스냅샷을 비교해서 그때그때 계산합니다. 그 대신 tree/hash 구조를 활용해 해시가 같은 subtree를 통째로 건너뛰는 식의 최적화를 수행하기 때문에, 큰 저장소에서도 diff가 빠르게 동작할 수 있습니다.
Git은 왜 이런 설계를 택했을까?
Git은 파일 내용을 Blob으로 저장하고, Blob들을 엮어서 Tree(디렉토리)를 만들고, 최상위 Tree와 메타데이터를 Commit으로 묶어서 시점을 고정한 뒤 계속 쌓아 올리는 방식으로 동작하는 것으로 보입니다. 지금까지 정리한 내용을 기준으로 왜 이런 설계를 택했을까? 에 대한 생각을 서술해보려합니다.
중복 제거와 무결성
blob/tree/commit을 모두 해시로 식별하는 구조 자체가 많은 것을 부수적으로 가져오고 있다고 생각합니다.
같은 내용의 파일은 디렉토리와 파일명이 달라도 같은 Blob 해시를 가집니다. 그렇기 때문에 하나의 blob만, tree만 저장하면 됩니다. 내용을 기준으로 주소를 정하는 구조 덕분에 dedup이 기본값이 됩니다.
또한, 내용 전체를 해싱한 값이 곧 ID, Key값 입니다. 내용이 1바이트라도 바뀌면 해시가 달라지기 때문에 해시만 맞으면 내용이 깨지지 않았다는 것을 어느정도 신뢰할 수 있습니다. 중간에 내용이 달라진다면 해시가 변경되기 때문에 바로 확인이 가능합니다.
스냅샷 + 구조적 공유 = 저장 효율
git checkout을 통해 특정 버전의 코드 전체가 구성되기 때문에, 겉으로 보면 커밋 = 프로젝트 전체 스냅샷 인 것처럼 동작합니다.
하지만, 이번 학습을 통해 내부 구조를 확인했습니다.
매 커밋마다 전체 파일을 통으로 새로 저장하지 않고, blob/tree 해시를 기준으로 구조적 공유를 하고 있습니다.
이런 구조 덕분에 사용자 입장에서는 스냅샷처럼 활용이 가능하고, 실제 저장소 입장에서는 변경된 부분만 새로 생성하고 해시로 공유하여 재사용이 가능한 구조입니다. 즉 외부 API는 스냅샷 모델이라 쓰기 편하고, 내부 구현은 구조적 공유를 통해 용량/성능을 최적화한 구조가 됩니다.
불변성과 히스토리 관리
또 하나 인상 깊었던 점은, Git이 한 번 만들어진 객체는 건드리지 않는다는 점입니다. blob / tree / commit은 만들어질 때 내용 전체를 해싱해서 Key(해시)를 만들고, 그 이후에는 그 내용을 수정하지 않습니다. 내용이 바뀌면 항상 새로운 해시, 새로운 객체가 생깁니다.
이렇게 해두면 얻는 장점이 몇 가지 있는 것 같습니다.
우선, 중간에 히스토리가 모르게 바뀌는 일을 막을 수 있습니다.
기존 커밋의 내용을 바꾸는 API가 없기 때문에, 누군가 과거 커밋을 슬쩍 수정해버리는 식의 상황은 구조적으로 만들기 어려워집니다. git commit --amend 나 rebase 같은 것도 사실은 기존 커밋을 수정하는 게 아니라, 새로운 커밋을 만든 다음 브랜치(ref)를 거기로 옮기는 동작에 가깝습니다.
두 번째로, 히스토리를 안전하게 쌓아 올리는 쪽에 초점이 맞춰져 있다고 생각합니다. 브랜치/태그에서 참조가 끊긴 객체는 나중에 git gc 같은 과정에서 정리되지만, 그 전까지는 그대로 남아 있게 됩니다. 그래서 force-push로 히스토리를 지운 것처럼 보여도, 실제 객체들은 reflog나 GC 설정에 따라 꽤 오래 살아남습니다. 오픈소스에서 커밋 한 번 잘못 남기면 오래 박제되는(?) 이유도 결국 이런 구조 때문이라고 보면 될 것 같습니다.
요약하자면 Git은 빠르게 지우고 덮어쓰는 쪽보다, 계속 쌓아 올리면서 필요에 따라 가리키는 포인터(ref)만 바꾸는 방식으로 히스토리를 관리하는 느낌을 받았습니다.
정리하며
이번 글에서는 Git이 데이터를 어떻게 저장하는지에 집중해서 아래의 내용들을 정리해봤습니다.
Blob / Tree / Commit 객체 구조
git cat-file로 내부 객체 추적하기
두 커밋 사이에서 어떤 객체들이 재사용/새로 생성되는지
git diff가 Tree/Blob을 기준으로 어떻게 변경 파일을 찾아내는지
논외로, 부모 커밋을 계속 체이닝하는 구조이기 때문에 자연스럽게 단방향 LinkedList인가? 라고 생각했는데, 조금 더 찾아보니, 보통 Git에서는 이 커밋 구조를 DAG(Directed Acyclic Graph) 라고 부르는 것 같습니다. 아마 한 방향으로만 이어지는 것이 아니라 merge 커밋이 부모를 두 개 이상 가질 수 있기 때문에 전체 구조로 보면 여러 갈래가 합쳐지는 그래프에 더 가까울 것 같다는 생각도 듭니다.
다음 포스팅에서는 merge, rebase와 더불어 이번에 살짝 언급했던 커밋 그래프와 브랜치 쪽을 조금 더 파볼 예정입니다.
신입으로 입사해서 만 2년 2개월을 근무했던, 아이패밀리SC를 떠나게 되었습니다. 첫 퇴사이기에 조금 싱숭생숭합니다. 이 글은 그간의 경험을 정리하고, 다음 선택에 조금 더 의미 있는 결정을 내리기 위해 남기는 회고입니다.
왜 퇴사했는가
퇴사를 결심한 이유는 크게 두 가지였습니다.
개발 위주의 회사가 아니라 회사 성장에 직접적이고 폭발적인 기여를 하기 어렵다고 생각함
홀로 기술적 의사결정을 감당해야 했던 환경
현재 회사는 꾸준히 성장하고 있으며, 특히 색조 화장품 브랜드 롬앤이 매출의 대부분을 차지합니다.
현재의 구조 속에서 IT가 주도적인 역할을 하긴 어려웠다고 생각했습니다. 개발자의 기여 범위가 한정되어있다고 느꼈습니다.
저는 그중에서도 웨딩 도메인 영역의 백엔드와 서버, 인프라 전반을 담당했습니다. 팀 내에서 저와 유사한 포지션이 없었기에 대부분의 기술적 의사결정을 스스로 고민하고 진행해야 했습니다. 다행히 조직에서는 제가 내린 결정들을 존중해주셨습니다.
이런 환경 덕분에 의사 결정을 위한 공부와 시행착오들을 겪고 더 가파르게 성장할 수 있었지만, 어느 순간 그릇된 선택을 하고 있는 게 아닌가 하는 매몰되는 감정도 동반되었습니다.
개선을 위한 시도들
혼자 기술적 결정을 내려야 한다는 것은 쉽지 않았습니다. 특히 신입 개발자에게는요. 메시지 큐 도입이나 MyISAM 트랜잭션 모듈 SDK 구현처럼 새로운 시도들은 많은 시행착오를 동반했습니다. 하지만 그 과정에서 시스템을 설계하고 트레이드오프를 판단하는 힘을 기를 수 있었습니다. 값진 경험이었습니다.
내부에서 한계가 분명해 보였기에, 기술적인 고립감을 해소하기 위해 외부로도 눈을 돌렸습니다. 제가 주로 사용하는 기술 스택을 기반으로 다양한 오픈소스 프로젝트에 기여했고, 약 1년간 40여 개의 PR을 제출하면서 꾸준히 성장의 방향을 잃지 않으려 했습니다.
또한, 외부에서 배운 것을 내부에도 녹여내기 위해, AI 도입과 같은 변화를 시도했습니다. AI 도입을 제안해 클로드 코드 맥스, 커서 프로, GPT 프로 등 여러 AI 도구를 팀 차원에서 사용할 수 있도록 만들었습니다. 이후에는 AI 활용법과 효율적인 프롬프트 설계에 대해 발표를 진행하며, 기술적 교류의 장을 만들어가고자 했습니다.
개선하지 못한 것
퇴사를 앞두고 되돌아보니, 기술적인 시도나 성장을 위해 노력은 했지만 개발 문화나 조직적인 아이디어 제시 측면에서는 다소 소극적이었던 것 같습니다. 앞으로의 조직에서는 기술적 깊이뿐 아니라 팀의 방향성과 문화에도 적극적으로 참여하는 개발자가 되고자 합니다.
내가 찾고자 했던 환경
이번 퇴사를 계기로 내가 어떤 개발자가 되길 원하고, 어떤 조직을 원하는지 되돌아보게 되었습니다.
제가 바라던 환경은 단순히 좋은 복지나 기술 스택이 맞는 곳이 아니었습니다. 개발자로서의 성장 욕구가 살아 있는 사람들, 그리고 그 성장의 방향이 조직의 목표와 자연스럽게 맞닿아 있는 팀, 그리고 그 속에서 서로의 성장을 자극하며 함께 나아가는 긍정적인 사이클이 존재하는 환경을 원했습니다. 그런 환경이라면 저는 더 깊이 몰입하고, 더 많이 배우며, 더 오래 즐겁게 성장하고 기여할 수 있을 것이라 믿습니다.
몰입과 몰두
개인의 역량이 존중받고, 각자의 일이 단순한 업무가 아니라 함께 이루어내는 성취의 과정으로 느껴지는 곳. 주어진 일을 처리하기보다 ‘왜 이걸 하는가’에 집중할 수 있는 환경.
목표 공유와 주인의식
비즈니스의 목표와 팀의 방향이 투명하게 공유되고, 구성원 모두가 그 목표를 자기 일처럼 고민하는 조직. 단순히 시키는 일을 하는 사람이 아닌, 같이 목표를 이뤄가는 사람으로 대우받는 팀.
성장을 원하는’팀이 아니라, 성장을 행동으로 증명하는 팀
배움과 도전을 말로만 하는 게 아니라, 실제로 학습하고 실험하며 발전을 추구하는 사람들이 모여 있는 곳. 나아가 서로의 성장을 자극하고 도와줄 수 있는 팀 문화.
마치며
딱히 스택이나 직무 범위에 제한을 두지 않고, 정말 함께 일하고 싶은 사람들과 성장할 수 있는 조직을 찾기 위해 여러 채용 전형을 진행했습니다. 감사하게도 여러 곳에서 제게 기회를 주셨고, 그중에서 제가 가장 몰입할 수 있는 환경과 방향성을 가진 곳으로 최종 선택하게 되었습니다.
2023년 9월 4일, 신입으로 입사해 2025년 11월 7일 퇴사합니다. 2년 2개월이라는 시간 동안 기술적으로나 개인적으로 미성숙했던 부분이 많았지만, 그만큼 배우고 성장할 수 있었던 시기이기도 했습니다.
한국에서 처음으로 조직생활을 시작하며 부족한 점이 많았을 텐데, 늘 믿어주시고, 성장할 수 있는 기회를 주셨던 기술연구소 임직원분들께 진심으로 감사드립니다. 이번 경험을 통해 ‘좋은 개발자’는 혼자 잘하는 사람이 아니라 함께 성장하며 조직을 더 좋은 방향으로 이끌어가는 사람이라는 걸 배웠습니다. 다음 조직에서는 더 깊이 몰입하고, 더 넓은 시야로, 팀과 함께 성장하는 개발자가 되고자 합니다.
핵심은, 사이드 이펙트가 없는 직렬화를 감지했을 때, Fast Path를 사용할 수 있도록 개선했다는 내용입니다. 여기에 문자열 이스케이프 경로 개선(플랫폼에 따라 SIMD 활용)과 number 처리 최적화가 얹어져 2+a배의 성능 개선이 이루어 졌다고 합니다.
반대로 getter, proxy, 순환참조, toJSON 커스터마이징, pretty print 등 직렬화 과정에서 사이드 이펙트는 Fast Path가 아닌 일반 경로로 폴백합니다. V8의 직렬화 퍼포먼스 개선의 이점을 얻기 위해서는, 개발자가 직렬화 과정에서 사이드 이펙트가 언제 발생하는지 인지하는 게 중요할 것 같습니다.
const N = 200_000;
const safe = Array.from({ length: N }, (_, i) => ({ id: i, ok: true, n: i|0, s: "x" }));
// 1) Fast path 기대 (무부작용)
console.time("safe");
JSON.stringify(safe);
console.timeEnd("safe");
// 2) replacer 사용 → 일반 경로
console.time("replacer");
JSON.stringify(safe, (k, v) => v);
console.timeEnd("replacer");
// 3) space 사용(pretty print) → 일반 경로
console.time("space");
JSON.stringify(safe, null, 2);
console.timeEnd("space");
// 4) toJSON 개입 → 일반 경로
const withToJSON = { ...safe[0], toJSON(){ return "x"; } };
console.time("toJSON");
JSON.stringify(withToJSON);
console.timeEnd("toJSON");
2. Uint8Array 내장 인코딩 지원
ECMAScript에서 최근 Uint8Array에서 직접 Base64, Hex 인코딩/디코딩을 다루는 표준 API가 구현되었습니다. Unit8Array는 바이너리를 다루는 바이트 배열(Typed Array)로 이미지, 파일, 압축, 암호화, 스트리밍 등에 사용되는 바이너리를 다룰 때 기본 자료 구조로 활용되는 것들 중 하나입니다.
25년 9월 기준의 최신 브라우저나 JS 엔진에서 사용 가능하며 자세한 내용은 MDN을 확인해보시면 좋을 것 같습니다.
이번 업데이트로 Node와 브라우저가 동일한 코드를 사용할 수 있게 되었고, 특히 setFromBase64/Hex가 직접 버퍼를 채우는 방식이기 때문에 중간 문자열, 메모리 복사를 줄이고 큰 페이로드에서 GC Pressure을 낮추고, 메모리 사용을 절감할 수 있습니다. 또한 옵션으로 유니온 리터럴 타입을 사용하여 옵션들을 표준화했습니다. 코드 일관성과 퍼포먼스 둘 다 개선했다고 볼 수 있겠습니다.
3. JIT 파이프라인 변경
V8의 JavaScript 실행 파이프라인은 여러 단계로 구성되어있습니다.
Ignition: 인터프리터
SparkPlug: 베이스라인 컴파일러
Maglev: 중간 계층 최적화 컴파일러
TurboFan: 최적화 컴파일러
Maglev는 Chrome M117에 도입된 새로운 최적화 컴파일러로, 기존 SparkPlug와 TurboFan 사이에 위치합니다. 컴파일 속도 측면에서 Maglev는 SparkPlug보다 약 10배 느리고, TurboFan보다 약 10배 빠르다고 합니다. Maglev는 기존 두 컴파일러 사이의 간격을 좁혀 빠른 최적화와 균형 잡힌 성능, 그리고 점진적 워밍업을 제공합니다. 보다 더 자세한 내용은 공식 블로그 내용을 참조하시면 좋습니다.
WASM은 기본적으로 동기적인 실행 모델을 가정합니다. 하지만 웹 환경의 많은 API들은 비동기적입니다. 기존에는 이 문제를 해결하기 위해 Binaryen의 ASYNCIFY 같은 복잡한 변환 도구를 사용해야 했습니다. 이로 인해 코드 크기가 증가하고, 런타임 오버 헤드가 자연스레 증가하며 빌드 프로세스 또한 복잡해지는 문제가 있습니다.
Node 25부터는 JSPI를 통해 WASM 애플리케이션이 동기적으로 작성되어 있더라도, JavaScript의 비동기 API를 자연스럽게 사용할 수 있게 해줍니다.
여기까지가, V8 업데이트로 인한 Node v25의 변경사항입니다. 아래부터는 Node의 별개 커밋들로 변경된 사항들에 대해 알아보겠습니다.
Permission Model: --allow-net 추가
Node는 기본적으로 모든 시스템 리소스에 대한 접근 권한을 갖고 있었습니다. 이는 편리하지만 보안상의 문제가 생길 수 있습니다. 이를 개선하기 위해 Node v20에 Permission Model이 도입되었고, v25에서는 네트워크 권한 제어가 추가되었습니다.
Permission Model을 활성화하면, 명시적으로 허용하지 않은 모든 작업이 차단됩니다.
# Permission Model 없이 (기존 방식)
node index.js # 모든 권한 허용
# Permission Model 활성화 (네트워크 차단됨)
node --permission index.js
# Error: connect ERR_ACCESS_DENIED Access to this API has been restricted.
# 네트워크 권한 허용
node --permission --allow-net index.js # 정상 작동
런타임에서도 권한을 확인할 수 있습니다.
if (process.permission) {
console.log(process.permission.has('net')); // true or false
}
async function fetchData(url) {
if (!process.permission || !process.permission.has('net')) {
throw new Error('Network access not permitted');
}
return fetch(url);
}
ErrorEvent의 글로벌 객체화
브라우저에서는 ErrorEvent 인터페이스가 스크립트나 파일의 에러와 관련된 정보를 제공하는 표준 WEB API입니다. 하지만 Node에서 이를 사용하려면 별도의 polyfill을 설치하고, 브라우저와 Node환경을 분기 처리하며, 플랫폼(OS)별 에러 핸들링 코드를 별도로 작성해야했습니다.
// 기존 방식: 플랫폼 분기
if (typeof ErrorEvent !== 'undefined') {
// 브라우저 환경
window.addEventListener('error', (event) => {
console.log(event.message, event.filename, event.lineno);
});
} else {
// Node.js 환경: 다른 방식 사용
process.on('uncaughtException', (error) => {
console.log(error.message, error.stack);
});
}
Node v25부터 ErrorEvent가 글로벌 객체로 사용 가능합니다. 자세한 구현사항은 아래 커밋을 확인해보시면 좋습니다.
Node v22 이전까지는 localStorage, sessionStorage 같은 WebStorage API를 사용하려면 --experimental-webstorage 플래그가 필요했는데, 이 부분을 Node v25부터는 기본적으로 활성화 상태로 애플리케이션이 실행됩니다. 자세한 변경 내용은 아래 커밋을 확인해보시면 좋습니다.
이를 통해 CI/CD 환경이나 컨테이너, 혹은 협업 과정 등 실제 컴파일이 필요한 테스트, 배포 단계에서 불필요하게 중복 컴파일을 하는 일이 사라지게 될 것으로 기대합니다.
# 1. 로컬 개발
node --compile-cache --compile-cache-portable dev-server.js
# 2. CI/CD 파이프라인 (e.g. Git Actions)
- name: Build and Test
run: |
node --compile-cache --compile-cache-portable build.js
- name: Deploy # 캐시를 아티팩트로 저장
run: |
node --compile-cache app.js # 캐시 재사용으로 빠른 배포
# 3. Docker
FROM node:25
WORKDIR /app
COPY . .
# 빌드 시 캐시 생성
RUN node --compile-cache --compile-cache-portable build.js
# 런타임에서 캐시 활용
CMD ["node", "--compile-cache", "app.js"]
마치며
Node v25의 주요 변경 사항들을, 신규 피쳐 위주로 알아봤습니다. 더 많은 변경사항이 있고, 특히 이 글에서 다루지 않은 Deprecated들을 포함하여 더 자세하게 알고 싶으신 분들은 릴리즈 노트를 활용해보시면 좋을 것 같습니다.
개인적으로 당장 하나씩 씹어먹어보고 싶지만, 11월까지 바쁜 개인 일정을 마무리하고, 나중에 깊게 공부할 수 있도록 주제별로 정리만 간단하게 했습니다. 특히 V8 관련된 공부를 가장 먼저 깊게 해 볼 생각입니다. 메인 스택을 JS, Node으로 계속 갖고 가기 위해 반드시 하나씩 깊게 독파하는 포스팅으로 찾아뵙겠습니다 하하..
어떤 원인으로 인해 실패했는지 모르는데 무한정 반복하게 되면 어떤 다른 문제들이 더 발생할지 짐작할 수 없기 때문입니다.
동시 요청을 막기 위해 MyISAM의 Lock을 사용하면 될까요?
MyISAM의 Lock은 InnoDB처럼 레코드 단위의 락이 아닙니다. 테이블 단위의 락은 락을 해제할 때까지 그 누구도 해당 테이블에 접근하지 못하는 것을 의미합니다. 1번 유저의 캐시 차감을 위해 모든 사용자의 정보 변경, 조회 등 모든 요청이 락이 해제될 때 까지 대기하게 되는 문제가 발생합니다.
트랜잭션이 없다면 직접 만들자
지금까지 확인한 문제들을 정리하면
보상 로직 누락 방지(O): TransactionStep 인터페이스로 컴파일 타입에 누락 방지 가능
보상 실패 처리(X): 무한 재시도는 답이 아님 - 데이터 복구를 위한 시스템(DLQ 등)이 필요
@Process('execute-transaction')
async handleTransaction(job: Job) {
const { steps } = job.data;
const executionResults: ExecutionResult[] = [];
try {
// 1. Step 순차 실행
for (const stepData of steps) {
const step = this.stepRegistry.get(stepData.executeFnName);
this.logger.log(`[${stepData.name}] 실행 중...`);
const result = await step.execute(stepData.params);
executionResults.push({
stepName: stepData.name,
compensateFnName: stepData.compensateFnName,
result
});
this.logger.log(`[${stepData.name}] 완료`);
}
// 2. 모든 Step 성공
return { status: 'success', results: executionResults };
} catch (stepError) {
// 3. Step 실패 → 자동 보상 (역순)
this.logger.error(`Step 실패, 보상 시작: ${stepError.message}`);
for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
try {
this.logger.log(`[${stepName}] 보상 실행 중...`);
const compensateFn = this.stepRegistry.get(compensateFnName);
await compensateFn(result);
this.logger.log(`[${stepName}] 보상 완료`);
} catch (compensationError) {
// 보상 실패 → 에러 기록
this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
// 보상 실패 건은 "장애 복구" 섹션에서 다룰 DLQ로 격리됩니다.
break;
}
}
throw stepError;
}
}
기본적인 흐름은 다음과 같습니다.
트랜잭션 컨텍스트 내 각 작업(Step)을 순차 실행
완료된 스텝들은 혹시 모를 롤백을 위해 기록
위처럼 원자성을 보장하기 위한 시도도 보상 로직의 실패에 대한 잠재적인 위험은 그대로 남아있기 때문에, 이리저리 고민을 해봤습니다.
애플리케이션 레벨에서 보상을 계속 진행하기 위해서는 데이터베이스와의 통신이 불가피한데, 예기치 못한 상황으로 계속 실패할 수 있습니다. 결국 직접 데이터베이스 엔진에서 수행하는 일련의 동작이 아니기 때문에, 어느정도 수동 개입은 필요하다고 판단했습니다.
이는 아래 장애 복구 세션에서 다루도록 하겠습니다.
격리성
앞서 언급했듯이, MyISAM은 레코드 단위 락을 지원하지 않습니다.
// 사용자 1의 결제 처리 중
LOCK TABLES User WRITE, `Order` WRITE;
await processPayment(userId: 1, amount: 1000);
UNLOCK TABLES;
// 사용자 2의 결제 시도
await processPayment(userId: 2, amount: 500);
// 사용자 1의 락이 해제될 때까지 대기
위의 코드에서, 사용자 1이 결제를 위해 Lock을 획득했습니다.
이 때 MyISAM에서는 사용자 1의 결제 중 모든 사용자가 대기하는 상황이 발생하게 됩니다.
같은 결제 로직 뿐 아니라 단순 조회까지 차단됩니다.
InnoDB라면 당연히 얻을 수 있는 레코드 단위 락과, MVCC같은 잠금 없는 일관된 읽기가 지원되지 않습니다.
이를 해결하기 위해 Redis로 분산락을 사용해 InnoDB의 레코드 락 처럼 구현해보려고 했습니다.
다른 프로세스가 혹여 락을 삭제할 수도 있기 때문에, Lua 스크립트를 통해 내 락인지 확인 후 삭제하도록 구성해뒀습니다.
Lua 스크립트는 원자적인 실행을 보장하기 때문에 Race Condition을 방지하고, 내가 획득한 락만 해제가 가능합니다.
async release(lock: RedisLock): Promise<void> {
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = await this.redis.eval(
luaScript,
1,
lock.key, // KEYS[1]: "lock:transaction:user:123"
lock.value // ARGV[1]: "processA-12345"
);
if (result === 0) {
this.logger.warn(`락 소유권 불일치: ${lock.key}`);
}
}
3. 멱등키로 중복 요청 방지
분산락은 동시 요청을 제어하지만, 처리가 완료된 후의 중복 요청 처리를 방지하지는 못합니다.
// 최초 결제 요청
POST /api/payment { userId: 123, amount: 10000 }
// 가정: 결제는 성공했지만 네트워크 문제로 클라이언트 응답 못 받음
// 클라이언트는 실패한 줄 알고 재시도 -> 락 해제되어 재결제
POST /api/payment { userId: 123, amount: 10000 }
멱등키를 구현함으로써, 위와 같은 엣지 케이스에 대해 중복 처리를 방지할 수 있다고 생각했습니다.
(멱등키는 클라이언트에서 생성을 하도록 구성해뒀기 때문에 서버 코드에는 생략되어있습니다.)
저는 현업에서 혼자 개발하는 환경에 있다 보니, '내가 잘하고 있는 게 맞을까?' 하는 기술적인 갈증이 늘 있었습니다. 그런 저에게 오픈소스는 코드를 통해 전 세계 누구와도 소통할 수 있는 가장 완벽한 소통의 장이자 탈출구가 되어주었습니다. 최근, 오픈소스 기여 모임 9기에서 제가 오픈소스에 기여하는 과정에 대해 '오픈소스 기여로 레벨업'이라는 주제로 발표하는 기회를 가졌습니다.
발표에서 전하려 했던 것
저는 이 발표를 통해, 단순히 코드를 기여한 경험을 넘어, 다음과 같은 저의 고민과 과정을 전달하고 싶었습니다.
1. 기술적 깊이를 더하기
하나의 이슈를 해결하는 것을 넘어, '왜 이 코드가 이렇게 작성되었을까?'라는 질문을 통해 코드의 숨은 의도, 설계 철학등의 새로운 기술적 관점까지 얻어가는 과정을 공유하고 싶었습니다.
Prisma의 Breaking Change를 해결하는 과정에서 '타입 퍼포먼스'라는 새로운 관점을 얻고 거대한 오픈소스가 감수하는 기술적 트레이드오프에 대해 깊이 고민해볼 수 있었습니다.
2. 주도적으로 가치를 만드는 경험
주어진 이슈를 넘어, 직접 이슈를 찾고 개선하며 주도적으로 가치를 만드는 경험을 나누고자 했습니다.
Gemini-CLI의 '첫 실행 시점'처럼 모든 사용자에게 영향을 줄 수 있는 지점을 공략해 실행 과정에서의 문제를 어떻게 발견하고 해결했는지에 대해 공유했습니다.
3. AI 활용
이 모든 과정에서 DeepWiki와 같은 AI 도구를 활용해 방대한 오픈소스 코드를 효율적으로 분석하고 학습한 저만의 노하우를 담아보려 했습니다.
마치며
소심한 성격 탓에 망설임도 많았지만, 발표라는 새로운 시도를 할 수 있도록 기회를 주신 오픈소스 기여 모임의 모든 참여자분들께 진심으로 감사드립니다.
처음 준비하는 발표였기에, 제가 의도했던 이런 고민과 과정이 잘 전달되었을지는 모르겠습니다.
많이 떨리고 부족했지만, 끝까지 귀 기울여 들어주신 모든 분들 덕분에 무사히 마칠 수 있었습니다. 정말 감사합니다.
최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코드 사용법을 정리하려고 합니다.
(현 시점에서, 공식 문서에 정확히 기술되어있는 내용들을 바탕으로 유추한 내용도 있습니다.)
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션이라는 세 마리 토끼를 한 번에 잡을 수 있는 메모리 최적화 사용법에 대해 이야기해보려 합니다.
왜 메모리 최적화가 필요한가요?
본격적인 방법에 앞서, 우리가 왜 Claude의 메모리를 신경 써야 하는지 정리해볼 필요가 있습니다.
OOM(Out of Memory)으로 인한 프로세스 종료: Claude Code의 대화 세션은 단일 프로세스로 동작합니다. 즉, 대화가 길어질수록 주고받은 모든 토큰이 메모리에 누적되어 시스템의 한계를 초과하면 IDE나 터미널이 강제 종료될 수 있습니다.
의도치 않은 AutoCompact와 할루시네이션: Claude에는 메모리가 부족해지면 자동으로 대화를 요약하는 AutoCompact 기능이 있습니다. 편리해 보이지만, 이 기능이 내가 원치 않는 시점에 작동하면 중요한 컨텍스트가 소실되어 AI가 엉뚱한 답변을 하는 원인이 되기도 합니다.
비용 절약: 결국 API 사용량은 입출력(I/O) 토큰의 양에 따라 결정됩니다. 불필요한 컨텍스트를 계속 유지하는 것은 곧 비용 낭비로 이어지기 때문에, 효율적인 토큰 관리는 비용 절감의 핵심입니다.
Claude Code의 메모리 관리 매커니즘 이해하기
아래는, 머메이드를 이용해서 CLAUDE CODE의 워크플로우를 만들어봤습니다.
최적화를 위해서는 Claude가 어떻게 컨텍스트를 기억하는지 알아야 합니다. 핵심은 간단합니다.
세션은 하나의 프로세스: claude 명령어로 대화형 모드에 진입하면 하나의 세션(프로세스)이 시작됩니다.
모든 대화는 메모리에: 이 세션 내에서 오고 간 모든 질문과 답변(토큰)은 컨텍스트 유지를 위해 메모리에 계속 쌓입니다.
CLAUDE.md는 항상 로드: 세션을 시작할 때 현재 디렉토리의 CLAUDE.md 파일은 무조건 읽어와 기본 컨텍스트로 사용합니다.
결국 대화가 길어질수록 메모리에 쌓이는 토큰이 많아져 위에서 언급한 문제들이 발생하는 구조입니다. 이제 이 구조를 역이용하여 메모리를 통제하는 방법을 알아봅시다.
문서에 따르면, CLI를 실행한 디렉토리를 기준으로 상향/하향으로 CLAUDE.md를 찾아 메모리에 올려 사용합니다.
다시 말해, CLAUDE.md는 claude CLI를 통해 호출할 때 마다 읽는다는 겁니다.
그러므로, CLAUDE.md는 프로젝트 단위의, 사용자 단위의 공통 룰만 정의하고, 나머지는 각 프로젝트 별 마크다운으로 빼서 관리하는 것이 세션 내 메모리와 토큰 비용을 절약하는 효과적인 방법일 것이라고 생각합니다.
메모리 최적화를 위한 핵심 명령어 3가지
Claude Code는 메모리를 수동으로 관리할 수 있는 강력한 명령어들을 제공합니다. 이 세 가지만 기억하면 충분합니다.
/context: 현재 세션의 '메모리 대시보드'입니다. 이 명령어를 입력하면 현재 컨텍스트가 사용 중인 토큰의 양과 비율을 확인할 수 있습니다. 내비게이션의 지도처럼, 현재 상태를 파악하는 데 필수적입니다.
/clear (또는 /reset): 세션을 초기화하는 '하드 리셋' 버튼입니다. 대화 기록과 컨텍스트가 모두 사라지고, CLAUDE.md를 처음부터 다시 로드한 상태가 됩니다. 완전히 새로운 작업을 시작할 때 유용합니다.
/compact {지시문}: 세션을 압축하는 '스마트 요약' 기능입니다. 단순히 기록을 지우는 것이 아니라, "지금까지의 논의를 바탕으로 핵심 내용을 요약해줘" 와 같은 지시를 통해 대화의 맥락은 유지하면서 토큰 사용량을 획기적으로 줄여줍니다.
현재 제가 사용중인 방식
이제 위 명령어들을 조합하여 제가 지금 시점에 사용하는 클로드 코드 방식을 소개하려고 합니다.
정답은 없지만, 이 흐름을 따른 후 OOM으로 인한 중단이 사라졌고 토큰 사용량도 눈에 띄게 줄었습니다.
핵심: AutoCompact는 끄고, 수동으로 관리하여 워크플로우의 제어권을 가져온다.
자연스러운 중단점 활용: 코딩 작업의 흐름을 Git 워크플로우에 비유해봅시다.
Commit 단위로는 /compact: 특정 기능 개발이나 버그 수정 등 작은 작업 단위를 끝냈을 때, /compact를 사용해 "현재까지 작업한 내용을 요약하고 다음 작업을 준비해줘"라고 지시합니다. 이렇게 하면 컨텍스트는 유지하면서 메모리를 확보할 수 있습니다.
Branch 단위로는 /clear: 하나의 브랜치에서 다른 브랜치로 넘어가는 것처럼, 완전히 다른 주제의 작업을 시작할 때는 /clear를 사용해 컨텍스트를 완전히 비워줍니다. 이전 작업의 컨텍스트가 새 작업에 영향을 주는 것을 막고 메모리를 최적으로 관리할 수 있습니다.
# (작업 중...) 기능 A 개발 완료 후
# 1. 현재 토큰 사용량 확인
/context
# 2. 컨텍스트 요약으로 메모리 확보
/compact 지금까지 논의한 feature-A의 핵심 로직과 구현 내용을 정리해줘.
# (다른 작업 시작 전...)
# 3. 새로운 feature-B 작업을 위해 세션 초기화
/clear
이처럼 작업의 흐름에 맞춰 compact와 clear를 전략적으로 사용하면, AI의 AutoCompact에 의해 작업 흐름이 끊기는 안티 패턴을 방지하고 메모리와 토큰 사용량을 모두 최적화할 수 있습니다.
어떻게 프롬프팅을 해야 할까?
커서맛피아님의 레퍼런스를 정리한 하조은님의 유튜브 영상 일부 발췌
포스팅을 작성하고있는 오늘, 당근에서 개발자로 계신 하조은님의 유튜브 영상을 보다가, 좋은 내용이 있어서 가져왔습니다.
항상, 하지말아야 할 것들을 CLAUDE.md에 정의하는 것에 그쳤었는데, 가끔씩 할루시네이션이 발생했던 것을 몸소 체험한 바 있습니다.
영상에서 정리해준 Constraint부분을 특히 프롬프팅 단위로도 잘 정의해야할 것 같습니다.
마무리
여기까지, 2달 남짓 클로드를 사용하면서 AutoCompact와 OOM 문제 때문에 불필요하게 토큰을 많이 사용하던 어느 주니어 개발자의 이야기었습니다.
Claude Code는 강력한 도구지만, 그 성능을 제대로 이끌어내기 위해서는 내부 동작 방식을 이해하고 사용자가 직접 제어하려는 노력이 필요합니다.
이 중, gemini-cli는 현재 AI를 다루는 능력이 거의 필수 스택으로 자리잡았고, 저에게 가장 친숙한 TypeScript기반이라는 점, 마지막으로 내가 구글에 기여할 수 있다니!!! 와 같은 이유로 기여하기로 했는데요 ㅋㅋ..
그래서, 이번 포스팅은 gemini-cli의 기여에 대한 포스팅입니다.
Gemini-CLI
현 시점 CLI 기반 AI의 양대산맥이라고 한다면, claude code와 gemini-cli가 대표적인데요.
여러 차이가 있겠지만, 오픈소스 성격을 띠는지? 의 차이도 있는 것 같아요.
특히, gemini-cli의 경우 공개적인 로드맵까지 작성되어있어, 관심 있는 이슈를 직접 기여해볼 수 있도록 기여자들의 참여를 적극 장려하고 있는 상황입니다.
저는 오늘, 9개월 전 첫 오픈소스 기여를 시작하면서 막연하게 꿈꿔왔던 목표인
직접 이슈를 발견해서 등록하고, 해결해보기
를 달성하고, 더불어 모든 사용자에게 영향을 줄 수 있었던 이슈를 발견하고,
과정에서 AI를 활용하며 기여했던 과정 전반의 경험을 공유드리려고 합니다.
어떻게 이슈를 발견할까?
오픈소스의 코드는 방대합니다. 주당 몇 만자씩 추가되는 이 방대한 오픈소스에서, 어떻게 이슈를 발견해야할까요? 마냥 하나하나 파일을 분석하기에는, 너무 비효율적입니다.
저는 효과적으로 분석하기 위해 우선 UX의 흐름을 생각해보기로 했습니다.
gemini-cli설치하기
터미널에 "gemini" 명령어 실행하기
질문하고 응답받고의 반복
방대한 코드를, UX의 흐름으로 재정의하고보니 엄청 단순해졌습니다.
저는 이 흐름에서 명령어를 실행한 후 interactive mode(대화형 모드)가 생성되기 전까지의 동작을 확인해보기로 결정했습니다.
즉, 초기화 단계를 중점으로 파헤쳐보기로 한 것이죠.
AI와 함께 분석하기
잘 짜여진 코드 덕분인지 초기화 단계는 금방 찾을 수 있었습니다. 하지만 가시성이 아무리 좋고 좋은 구조로 짜여져 있다고 하더라도, 처음 보는 프로젝트의 모든 상호작용을 파악하면서 정확히 어떤 과정들을 수행하는지 한 눈에 알기는 어려웠습니다.
이 때, 시간 낭비를 줄이기 위해 저는 AI를 활용했습니다.
처음에는 할루시네이션 때문에 오픈소스에서 AI를 활용하는 것은 바람직한가? 라는 의문을 품었습니다.
하지만 부정할 수 없는 사실은 AI는 이미, 어쩌면 처음 등장했을 때 부터 제가 알고있는 프로그래밍 지식보다 훨씬 많은 것을 알고 있다는 것을요. 그리고 AI 활용법에 관한 많은 레퍼런스들에서 공통으로 얘기하듯, AI에게 판단을 맡기지 않고 사실 기반으로만 동작하게 한다면 AI는 최고의 동료가 될 것이라는 것을요.
저는 그래서, 짜여진 코드라는 사실에 기반하여 초기화 과정 자체를 분석하는 일을 AI에게 맡겼습니다.
DeepWiki: AI 기반 깃허브 저장소 위키
DeepWiki는 Devin AI를 만든 Cognition에서 만든 위키 형태의 AI 입니다.
깃허브 저장소를 위키 형태로 변환하여 볼 수 있게 해주는 도구로, 오픈소스의 핵심 아키텍처나 오픈소스 내의 피처들이 어떤 흐름으로 동작하는지 등 오픈소스의 전반적인 것들을 분석해주는 도구입니다.
위 사진은, 애플리케이션의 아키텍처를 DeepWiki가 분석해준 워크플로우이고, 빨간 네모 박스는 실행 시 초기화 과정 전반의 동작들입니다. 이를 기반으로 각 함수들을 하나씩 분석해 본 결과, 단순히 사용자의 세팅을 불러오는 구간들을 배제할 수 있었습니다.
작업할 구간을 좁히고 좁히다보니, memoryDiscovery라는 파일에서 퍼포먼스 개선이 가능해보이는 코드를 발견할 수 있었습니다.
memoryDiscovery는 초기화 시 GEMINI-CLI가 호출된 디렉토리를 기준으로, 상/하향 디렉토리들을 순차적으로 확인하여 GEMINI.md가 있는 경로를 수집합니다. 수집이 완료되면, 수집된 경로에서 순차적으로 마크다운 파일을 처리하여 메모리에 올려 사용합니다.
이 과정을 간단하게 도식화해본다면, 아래와 같은 형태입니다.
모든 디렉토리/파일에 대해 순차적인 처리를 수행하기 때문에, 프로젝트가 커지면 문제가 발생하겠다고 생각했고, 이 구간에 대해 작업을 시작했습니다.
Claude Code: 교차 검증
현재 AI는 아시다시피 할루시네이션이 엄청 심합니다.
DeepWiki를 통해 사실 기반의 워크플로우를 추론할 수 있었지만, 사실 확인이 한 번 더 필요하다고 판단했습니다.
이를 위해, GEMINI-CLI를 로컬에 클론시킨 뒤, CLAUDE CODE를 통해서 해당 작업 구간을 다시 한 번 재확인했습니다.
PR 생성
작업 구간이 명확해졌으니, 코드 작업을 해야겠죠?
제가 작업한 최종 코드는 다음과 같습니다.
기존 순차처리를 병렬로 변경
EMFILE 에러 방지를 위한 동시성 제한 추가
Promise.allSettled 사용으로 문제 발생 시에도 안정적인 파일 처리
PR을 생성하는 과정에서, Promise.allSettled는 gemini가 직접 제안해준 리뷰 내용에 포함되어 있어 추가하였습니다.
(뭔가 제가 만든 요리를 레시피 원작자가 직접 평가하는 기분이네요)
결과
생각보다 금방 머지가되어, GEMINI-CLI의 컨트리뷰터가 될 수 있었습니다.
PR 과정에서 얼마나 개선될까 싶어 벤치마크 테스트를 PR의 커밋에 추가했었는데요,
아래 코드에서 알 수 있듯이, 큰 규모의 테스트는 아니지만 약 60% 가량의 퍼포먼스 향상을 이루어낼 수 있었습니다.
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { tmpdir } from 'os';
import { loadServerHierarchicalMemory } from './memoryDiscovery.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
// Helper to create test content
function createTestContent(index: number): string {
return `# GEMINI Configuration ${index}
## Project Instructions
This is test content for performance benchmarking.
The content should be substantial enough to simulate real-world usage.
### Code Style Guidelines
- Use TypeScript for type safety
- Follow functional programming patterns
- Maintain high test coverage
- Keep functions pure when possible
### Architecture Principles
- Modular design with clear boundaries
- Clean separation of concerns
- Efficient resource usage
- Scalable and maintainable codebase
### Development Guidelines
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
`.repeat(3); // Make content substantial
}
// Sequential implementation for comparison
async function readFilesSequential(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const results = [];
for (const filePath of filePaths) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
results.push({ path: filePath, content: processedResult.content });
} catch {
results.push({ path: filePath, content: null });
}
}
return results;
}
// Parallel implementation
async function readFilesParallel(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const promises = filePaths.map(async (filePath) => {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
return { path: filePath, content: processedResult.content };
} catch {
return { path: filePath, content: null };
}
});
return Promise.all(promises);
}
describe('memoryDiscovery performance', () => {
let testDir: string;
let fileService: FileDiscoveryService;
beforeEach(async () => {
testDir = path.join(tmpdir(), `memoryDiscovery-perf-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
fileService = new FileDiscoveryService(testDir);
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should demonstrate significant performance improvement with parallel processing', async () => {
// Create test structure
const numFiles = 20;
const filePaths: string[] = [];
for (let i = 0; i < numFiles; i++) {
const dirPath = path.join(testDir, `project-${i}`);
await fs.mkdir(dirPath, { recursive: true });
const filePath = path.join(dirPath, 'GEMINI.md');
await fs.writeFile(filePath, createTestContent(i));
filePaths.push(filePath);
}
// Measure sequential processing
const seqStart = performance.now();
const seqResults = await readFilesSequential(filePaths);
const seqTime = performance.now() - seqStart;
// Measure parallel processing
const parStart = performance.now();
const parResults = await readFilesParallel(filePaths);
const parTime = performance.now() - parStart;
// Verify results are equivalent
expect(seqResults.length).toBe(parResults.length);
expect(seqResults.length).toBe(numFiles);
// Verify parallel is faster
expect(parTime).toBeLessThan(seqTime);
// Calculate improvement
const improvement = ((seqTime - parTime) / seqTime) * 100;
const speedup = seqTime / parTime;
// Log results for visibility
console.log(`\n Performance Results (${numFiles} files):`);
console.log(` Sequential: ${seqTime.toFixed(2)}ms`);
console.log(` Parallel: ${parTime.toFixed(2)}ms`);
console.log(` Improvement: ${improvement.toFixed(1)}%`);
console.log(` Speedup: ${speedup.toFixed(2)}x\n`);
// Expect significant improvement
expect(improvement).toBeGreaterThan(50); // At least 50% improvement
});
it('should handle the actual loadServerHierarchicalMemory function efficiently', async () => {
// Create multiple directories with GEMINI.md files
const dirs: string[] = [];
const numDirs = 10;
for (let i = 0; i < numDirs; i++) {
const dirPath = path.join(testDir, `workspace-${i}`);
await fs.mkdir(dirPath, { recursive: true });
dirs.push(dirPath);
// Create GEMINI.md file
const content = createTestContent(i);
await fs.writeFile(path.join(dirPath, 'GEMINI.md'), content);
// Create nested structure
const nestedPath = path.join(dirPath, 'src', 'components');
await fs.mkdir(nestedPath, { recursive: true });
await fs.writeFile(path.join(nestedPath, 'GEMINI.md'), content);
}
// Measure performance
const startTime = performance.now();
const result = await loadServerHierarchicalMemory(
dirs[0],
dirs.slice(1),
false, // debugMode
fileService,
[], // extensionContextFilePaths
'flat', // importFormat
undefined, // fileFilteringOptions
200, // maxDirs
);
const duration = performance.now() - startTime;
// Verify results
expect(result.fileCount).toBeGreaterThan(0);
expect(result.memoryContent).toBeTruthy();
// Log performance
console.log(`\n Real-world Performance:`);
console.log(
` Processed ${result.fileCount} files in ${duration.toFixed(2)}ms`,
);
console.log(
` Rate: ${(result.fileCount / (duration / 1000)).toFixed(2)} files/second\n`,
);
// Performance should be reasonable
expect(duration).toBeLessThan(1000); // Should complete within 1 second
});
});
마무리
최근 오픈소스 기여를 위해 DeepWiki를 적극 활용하고 있는데요,
위에서도 잠깐 말씀드렸듯이, 사실 기반(=코드베이스 자체만 분석)으로 AI를 활용한다면 엄청난 퍼포먼스를 보이는 것 같습니다.
오픈소스 기여에는 항상 이슈를 해결하기 위한 분석에서 가장 많은 시간을 잡아먹었었는데요, 작업을 위한 분석 구간을 명확하게 좁혀주는 용도로만 사용했지만 거의 PR 생성 속도가 10배 가까이 단축된 것 같아요.
이번 기여에서는, 특히 전 세계 수많은 사용자들의 시간을 매일 조금씩 아껴주었다는 생각에 매우 뿌듯한 경험이었습니다.
모든 gemini-cli의 사용자들이 기존 대비 60%이상 향상된 퍼포먼스를 제 코드를 통해 제공받을 수 있다는 점이 정말 황홀한 것 같아요.