요즘 시대에 이정도 요구사항은 AI와 함께라면 전혀 두렵지 않지만, 개발을 접하고 지금까지 파일을 다루는 일을 크게 해오지 않았던 터라, 기록 형태로 남겨보려고 한다.
정의
사용자는 여러 명이 올린 영상들을 하나하나 클릭해서 다운로드 받아야 했다. 이에 불편함을 느껴 일괄 다운로드를 받고싶어 한다.
추가적인 정보는 다음과 같다.
숏폼 형태의 영상으로, 영상 하나당 최대 300MB 제한이다.
영상의 개수는 최대 100개, 약 30GB이다.
앞으로 개수는 더 늘어날 수 있다.
기존 단건 다운로드 방식은 public URL로 S3에서 직접 다운로드 받는 방식이다.
생각 정리
얼마 안되는 개발 짬빱으로 1차로 생각을 정리했다.
압축
스트리밍
멱등한 비동기 처리
여튼 효율적으로, 빠르게 다운로드가 가능하게 구현하고 요청과 분리해서 202를 떨어뜨리자. 그럼 effectively-once 하게 다운로드가 가능하지 않을까? (이탈·중복·실패에도 정확히 한 번 완료되도록)
영상도 파일이다
알다시피 파일 다운로드라고 딱히 특별하지는 않다. HTTP GET 요청의 응답 body로 온 바이트를 화면에 렌더링할지 디스크에 저장할지를 결정할 뿐이다. 응답 헤더를 통해서 말이다.
Content-Type을 통해 video/mp4 등의 inline 실행인지, application/octet-stream 등의 저장 방식을 결정하거나
Content-Disposition을 통해 다운로드를 강제할 수 있다. Content-Length 헤더가 있다면, 진행률 바를 그릴 수도 있고 말이다.
이 기본적인 매커니즘은 S3 저장, 다운로드 방식 위에 있는 기본적인 네트워크 개념이다.
촬영되는 영상의 성질
촬영 기기의 카메라 센서는 raw 단위의 프레임을 만들지만 촬영 기기는 raw 단위 프레임을 이어서 저장하기에 버겁다. 그렇기 때문에 하드웨어 인코더 칩 등으로 실시간 압축하여 파일로 쓴다. 글을 작성하는 2026년 기준 HEVC라는 비디오 코딩 기술이 안드로이드와 아이폰 기기에 탑재되어 있다.
HEVC(High Efficiency Video Coding/H.265)는 고효율 비디오 코딩 기술이다. 기존 대비 40~50% 수준의 파일 크기로 고품질 영상을 유지할 수 으며, 4K 및 8K와 같은 초고화질(UHD) 영상의 용량을 획기적으로 줄여 스트리밍과 저장에 널리 사용된다.
아하? 영상이라 파일 크기가 클 수 밖에 없겠지만, 이미 1차로 최대한 품질을 보존한 압축 기법을 통해 저장이 되는구나?
그렇다면 내가 이걸 더 압축한다면 별도 비디오 코딩 기술을 사용해야하고 그것을 유지보수하기 위한 러닝커브가 존재하겠다.
그리고 애초에 고품질 압축을 추가 압축했을 때 광고 집행이나 2차 활용을 보장할 수 있는 품질인가?
그래서 어떻게 개선?
S3도 사실 큰 틀에서 다르지 않다. 어떻게 저장소 따위(?)가 더 상위 개념인 네트워크의 본질을 벗어날 수 있을까.
S3의 코어는 분산 오브젝트 스토어에 있다. 인증, 멀티파트 등등..
다운로드 요청과 처리를 분리해서, 완료된 파일을 사용자가 이탈할 때에도 다운로드 할 수 있도록 지원하고
중복 요청 방지를 위해서 멱등키로 dedup, 같은 키의 zip이 있으면 S3에서 재생성 없이 presigned URL만 새로 서명했다.
백그라운드 워커가 S3 GET을 통해 영상을 읽는다. 다행히 같은 리전이라 트래픽이 무료이며
영상 파일을 묶어서 멀티파트 업로드를 통해 zip 파일로 업로드해서 presigned URL로 다운로드 시킨게 전부..
되게 별게 없다
글을 쓰다보니까 문득 되게 기술적으로는 별 것 안했구나? 라는 생각이 든다.
광고주의 문제 정의를 하고, 기존 지표를 통해 광고주들이 크리에이터의 소재들을 일일이 번거롭게 다운로드 하는 것을 확인했다.
KPI의 가장 큰 축이 광고주의 리텐션인데, 리텐션 지표 상승의 엄청난 임팩트가 있는 개선이라고 보기는 어렵다만...
임팩트가 높은 시도와 병렬적으로 유저의 행동을 데이터로 보고 부정적인 행동들을 빠르게 끊을 수 있는 시도를 계속 하다보면 목표 달성에 더 빠르게 가까워지지 않을까..?
뭔가 기술적인 내용을 쓰려고 시작했는데 제품적인 글로 마무리 되는 것 같아 찜찜하게 마무리되는 글..
두 방식 모두 복잡한 검색 조건을 표현하기에는 훨씬 편하다. 하지만 HTTP 의미론 관점에서는 찜찜한 지점이 남는다.
우리가 원하는 것은 서버 리소스를 생성하거나 변경하는 것이 아니다. 조건이 복잡해서 URL query string만으로 표현하기 어렵기 때문에 request body가 필요할 뿐이다. 그런데 HTTP method는 POST다.
POST가 항상 리소스 생성을 뜻하는 것은 아니지만, RFC 9110 기준으로 POST는 safe하지도, idempotent하지도 않은 메서드로 취급된다. 즉 애플리케이션 레이어에서는 읽기 요청이라고 알고 있어도, HTTP 레이어와 중간 인프라 입장에서는 이 요청이 읽기인지 쓰기인지 method만 보고 판단하기 어렵다.
HTTP Method의 safe와 idempotent에 대해 다룬 글이 있으니 필요하다면 참고하길 바란다.
POST /search와 GraphQL POST는 실무적으로 좋은 해법이다. 복잡한 조건을 JSON으로 표현할 수 있고, typed client나 schema validation을 얹기도 쉽다. GraphQL은 한 걸음 더 나아가 클라이언트가 필요한 field shape까지 operation으로 표현할 수 있다.
하지만 HTTP 관점에서는 어떨까?
요청한 애플리케이션의 의미는 읽기이고, 조건 표현을 위해 request body가 필요하다. HTTP method는 POST라서 safe, idempotent의 의미가 method에 드러나지 않는다. 문제는 조건을 어디에 담을 것인가 만이 아니라, 그 요청이 HTTP 레이어에서 어떤 의미로 보이는가를 같이 생각해보아야 한다.
GraphQL도 마찬가지로 애플리케이션 레이어에서는 읽기라고 말하지만 HTTP method만 보면 그냥 POST로 보인다. 그래서 GraphQL 생태계에서는 persisted query 같은 패턴도 생겼다. 긴 query document를 매번 보내는 대신 hash를 URL에 싣거나 CDN cache key를 직접 조정하거나 POST body를 해석하는 애플리케이션 레벨 캐시를 둔다. 하지만 이런 방법들이 HTTP method의 의미 자체를 바꿔주지는 않는다.
그럼 GET에 body는?
그럼 의미론적으로 읽기 요청인 GET에 body를 실어 보내는 방법은 왜 안됐을까?
GET에서도 당연히 실어 보낼 수는 있다. 하지만 RFC 9110에서 요청 content에 대해 일반적으로 정의된 의미가 없다고 설명한다. 서버가 GET body를 검색 조건으로서 해석하고 사용해야 하는지, 무시하고 거부해야 하는지 표준으로 정해주지 않는다.
클라이언트, 서버, 프록시, CDN, 브라우저, 캐시는 모두 method를 보고 이 요청을 어떻게 다룰지 판단하는데, GET body에 자체 규칙을 얹으면 내가 관리하는 서버에서는 동작할 수 있지만, 중간 경로 전체가 같은 의미를 공유한다고 기대하기 어렵다.
QUERY는 POST처럼 body를 실을 수 있지만, GET처럼 safe하고 idempotent한 query 요청을 표현할 수 있다.
RFC 10008
이런 특성을 가진 QUERY라는 method의 의미는 단순 GET 요청에 body를 실을 수 있는 것보다는 더 많은 가치가 있을 것으로 보인다.
연결이 끊겼을 때 재시도를 고려할 수 있다.
캐시가 safe한 query response로 판단될 수 있다.
gateway나 WAF 읽기 요청과 쓰기 요청을 더 명확하게 구분할 수 있다.
observability 관점에서 method 기준으로 요청의 성격을 해석하기 쉬워진다. (기존 POST의 오용 감소)
QUERY, 사용해 볼 수 있을까?
QUERY가 표준화됐다고 해서 내일부터 모든 API에 바로 적용하기는 어렵다.
우선 중간 경로가 method를 알아야 한다. 오래된 proxy, WAF, CDN, gateway는 알 수 없는 method를 거부하거나 별도 allowlist로 관리할 수 있다. 서버 framework가 QUERY 라우팅을 지원하더라도 실제 인터넷 경로에서는 막힐 수 있다.
또한 브라우저의 CORS에서 QUERY는 safelisted method가 아니다. RFC 10008도 CORS를 구현하는 user agent에서 QUERY 요청은 preflight가 필요하다고 언급한다. 브라우저 클라이언트에서 쓸 때는 OPTIONS 처리와 Access-Control-Allow-Methods 설정까지 같이 봐야 한다.
그리고 캐시가 request content를 cache key에 포함해야 한다. 이걸 모르는 캐시는 QUERY response를 아예 저장하지 않거나, 저장하더라도 잘못 재사용할 위험이 있다. QUERY의 장점을 제대로 얻으려면 cache layer가 RFC 10008의 규칙을 구현해야 한다. RFC 10008은 QUERY request의 cache key가 request content와 관련 metadata를 포함해야 한다고 명시한다.
마지막으로 지원 여부를 발견하는 절차가 필요하다. RFC 10008은 Accept-Query response header를 정의한다. 서버는 어떤 media type의 query content를 받을 수 있는지 알릴 수 있다.
또는 OPTIONS 응답의 Allow header로 QUERY 지원 여부를 드러낼 수 있다.
Allow: GET, QUERY, OPTIONS, HEAD
현실적인 도입 순서는 공용 브라우저 API보다 내부 API나 gateway-controlled API 쪽일 가능성이 높다. 클라이언트, 서버, proxy, cache 설정을 한 팀이 함께 통제할 수 있어야 QUERY의 의미와 이점을 끝까지 보존할 수 있기 때문이다.
마치며
QUERY는 POST이지만 GET으로 사용했던 기존의 레거시 API들을 대체할 수 있는 것처럼 보인다. 그렇다고 당장 기존 API를 바꿀 필요는 없다. POST는 여전히 실용적이며 모든 인프라가 POST를 알고 있고, framework와 client 지원도 안정적이다. 캐시가 꼭 필요하지 않거나, 애플리케이션 레벨에서 캐시/재시도/중복 방지를 이미 잘 처리하고 있다면 POST 검색 API를 유지하는 편이 더 현실적일 수 있다.
다만 RFC 10008이 의미 있는 이유는, 우리가 그동안 관습으로 처리하던 요구사항에 HTTP 차원의 이름을 붙였다는 데 있다. GET은 복잡한 request body를 표준 의미로 다룰 수 없으며, POST는 safe, idempotent하지 않다. 그 문제 정의를 표준으로 정의했다는 것에 의미가 있다고 본다.
그래서, POST지만 GET으로 사용되던 레거시를 완전 대체하기보다는 아직은 생태계 지원을 확인하면서 조심스럽게 봐야 하지만, 검색 API와 GraphQL, 분석 API를 설계할 때 앞으로 꽤 자주 언급되고, 고려할 대상으로 보이지 않을까? 싶다.
스타트업으로 이직 후, 수습 회고 통해 입사 후 혼란스러운 환경을 개선하기 위한 노력들을 소개했었습니다.
요약하자면 빠르게 적응하기 위해 개인적으로 가장 필요했던 것이 무엇인지 생각해봤고, 자주 연달아 이어지는 구두 논의나 회의 때문에 이전 맥락을 잊어버리기 일쑤였습니다. 이를 극복하기 위해 미팅 컨텍스트 허브를 만들어서 개인적으로 사용해왔습니다. 미팅 녹음을 올리면 AI가 자동으로 요약하고, 결정사항을 뽑아주고, 임베딩해서 검색 가능하게 만들어주는 도구였습니다.
개인적으로 만들어 사용했던 미팅 컨텍스트 허브
개인적으로 MCH(Meeting-Context-Hub)라고 부르는 이 도구의 사용성이 꽤나 좋았어서 전사적으로 공유해야겠다고 생각했던 찰나, 사내에서도 티로라고 불리는 비슷한 제품을 구독해서 사용하기 시작했습니다. 미팅록을 한 곳에 모으고 정리하는 역할은 티로가 해결해버렸습니다. 전사적으로 제 도구를 도입하지는 못했지만, 이 과정을 겪으면서 돌아보게 된 것이 있습니다.
구성원들이 더 효율적으로 빠르게 일하기 위해, 현재 워크플로우의 문제들이 무엇일까?
미팅에서 결정된 내용은 티로를 통해 확인할 수 있습니다. 하지만 그 결정이 실행되는 과정은 슬랙 스레드에 흩어집니다.더불어 큰 의사결정 안에서 작은 논의들이 계속 오가고, 자잘하게 변경되는 내용도 많습니다.
또, 저는 이제 막 수습을 지났습니다. 제가 입사하기 이전의 수 많은 컨텍스트는 무시되어도 되는 걸까요? 이전 조직 구성원들이 슬랙에 나눈 대화와 정리된 노션 문서 또한 조직의 자산입니다. 이건 저희 조직 뿐 아니라 어떤 조직이든 공통적인 부분이라고 생각합니다.
MCH를 만들면서 느낀 건, 개인의 기억력 문제가 아니라 조직의 맥락이 검색 가능한 형태로 존재하지 않는다는 것이었습니다. 미팅록은 그 퍼즐의 한 조각일 뿐이고, 여러 컨텍스트들이 전부 한 곳에 모여야 비로소 재활용 가능한 가장 좋은 형태가 될 것이라고 생각했습니다. 원활하게 꺼내볼 수 있어야 이걸 찾아보는 리소스도 줄어들고, 자연스레 다음 스텝이 있을 수 있다고 판단했습니다. 그래서 이 허브를 전사의 컨텍스트 허브로 확장해보기로 했습니다.
사내 컨텍스트 허브인 아라
데이터 수집 파이프라인 설계
사람이 작업을 하든, AI가 하든 맥락이 있어야 합니다. 당연하게도 슬랙과 노션에 흩어진 조직의 지식을 한 곳에 모으는 것이 첫 시작이었습니다. 현재 파이프라인을 시각화하면 아래와 같습니다.
임베딩
임베딩을 통해 텍스트를 고정 길이의 숫자 벡터로 변환합니다. 예를 들어 연차 규정과 휴가 정책의 글자는 다르지만, 임베딩 벡터 간의 거리는 가깝다는 논리입니다. 이를 이용하면 키워드가 정확히 일치하지 않아도 의미적으로 유사한 문서를 찾을 수 있게 됩니다.
임베딩에는 OpenAI의 text-embedding-3-small 모델을 사용했습니다. 우선은 빠르게 PoC를 구축하는 것이 목표였기 때문에 API 호출 한 번으로 바로 쓸 수 있고, 비용이 저렴한 것을 우선에 뒀습니다. 블로그를 작성하는 지금 시점에서는 임베딩 벤치마크 리더보드에서 괜찮은 모델로 마이그레이션 할 계획을 가지고 있습니다.
변환된 벡터를 SQLite에 AI가 추출한 메타데이터와 함께 저장합니다. AI가 뽑아낸 메타데이터(제목, 요약, 태그)는 키워드 검색과 필터링에 활용하고, 임베딩은 시맨틱 검색에 활용됩니다. 이 때 수집된 원본 텍스트는 반드시 그대로 보존합니다.조직의 데이터 규모가 수천 건 수준이기 때문에 파일 하나로 단순하게 관리되고, 백업에도 용이하기 때문입니다. 코사인 유사도(임베딩) 계산은 애플리케이션 레벨에서 수행할 수 있는 수준이기 때문에 SQLite를 사용했습니다.
메타데이터 추출 파이프라인
메타데이터를 수집 시점에 AI가 한 번 추출하지만, 특히 긴 문서의 경우 중요한 결정사항이나 맥락을 놓치는 경우가 있었습니다.
그래서 2-pass 구조로 확장하여, 최초 수집 시점은 빠르게 처리하고, 그 뒤에 정교한 분석을 수행하도록 했습니다.
2nd pass에서는 1st pass의 결과와 원본 텍스트를 함께 LLM에게 전달하여 최종 검토를 하게 됩니다.
데이터 수집
현재 조직의 컨텍스트는 노션과 슬랙으로 관리되고 있습니다. 두 도구의 성격이 당연히 다른데요.
슬랙: 러프한 대화 위주의 내용. 잡담도 포함. 데이터 구조가 단순하며 플랫함(최대 1Depth Thread 구조)
노션: 구조화된 포맷을 기반으로 각 논의된 내용들을 어느정도 정리함. 페이지, DB 등 여러 Depth로 구성되어 복잡함
도구의 성격이 다른 만큼, 수집 방법도 달랐는데요, 슬랙과 노션의 수집 방법은 이렇습니다.
슬랙
평일 1시간 단위의 스케줄러가 슬랙의 채널 대화를 수집합니다. 채널 중에는 정말 잡담을 위한 채널도 존재하고, 레거시 채널도 존재합니다. 수집할 필요가 없는 채널들을 제외하고 화이트리스트 방식으로 수집 대상 채널만 직접 선택하는 구조로 만들었습니다. 이 채널의 대화는 조직의 지식으로 보존할 가치가 있다고 판단되는 채널만 등록했습니다.
굳이 디테일을 언급하자면, 증분 수집을 위해 채널 단위의 독립적인 워터마크를 추가했습니다.
이를 통해 각 채널의 메세지와 쓰레드를 증분 수집했고, 별도 노이즈 필터링을 통해 봇 메세지나 시스템 메세지, 10자 미만 메세지나 특정 노이즈 키워드(ㅋㅋ, 넵 등등)를 위한 딕셔너리를 별도로 두어 관리하고 있습니다.
노션
노션은 특정 결정의 중간/최종 산물의 성격을 띱니다. 그렇기에 평일 12시간 단위로 08:00, 20:00에 하루 일과 시작 전/후를 고려하여 배치 시간을 정했습니다.
위에서 언급했듯이 노션은 복잡한 뎁스의 트리 구조입니다. 페이지 안에 하위 페이지가 있고, 그 안에 또 하위 페이지가 있을 수 있죠. 데이터베이스 안에 row가 있고, 각 row도 하나의 페이지입니다. 그래서 기존 노션 문서들의 포맷을 보면서, 어느 깊이까지 탐색해야 할 지 정했습니다.
더불어 중복과 업데이트 처리도 같이 신경 써야했습니다. 같은 배치 안에서 상위 하위 페이지가 모두 수집 대상이 될 수 있고, 이미 수집한 페이지가 수정되었을 경우도 충분히 발생할 수 있는 경우이기 때문입니다.
// notion-ingester.ts — 중복/업데이트 판별
for (const page of pages) {
// 제외 대상 페이지 또는 그 하위 트리 전체를 건너뜀
if (excludeSet.has(page.id) || (page.parentId && excludeSet.has(page.parentId))) {
result.skippedExcluded++;
continue;
}
// 동일 배치 내 중복 제거
if (seenPageIds.has(page.id)) {
result.skippedDuplicate++;
continue;
}
seenPageIds.add(page.id);
// 마지막 수집 이후 수정되지 않은 페이지는 건너뜀
const existing = existingByPageId.get(page.id);
if (existing && existing.lastEditedTime >= page.lastEditedTime) {
result.skippedUnchanged++;
} else {
toProcess.push(page);
}
}
결론적으로, 제외 목록을 건너뛰고, 배치 내 중복을 건너뛰며, 마지막으로 변경 여부를 확인하여 건너 뛰는 로직을 통과한 페이지만 5개의 동시 워커로 병렬 처리했습니다.
RAG는 실패했다
결론부터 얘기하자면, 저는 RAG 방식으로 대차게 실패했습니다. 첫 시도부터 틀려먹어서 좀 당황했습니다.이 허브를 구축하면서 RAG에 관련된 레퍼런스를 많이 찾아봤는데, 정말 매력적이고 좋은 패턴이라고 생각합니다. 다만 저는 이 패턴에 대한 이해도가 모자랐기 때문에 실패했다고 생각합니다.
제 최초 설계는 5단계 RAG 파이프라인이었습니다.
SEARCH - 임베딩 유사도 + 키워드(SQL LIKE) 하이브리드 검색
FILTER - 유사도 0.3(30%) 미만 결과 제거
ENRICH - 슬랙 메타(채널명, 날짜), 미팅록이 있다면 미팅록의 메타로 검색 결과 보강
ASSEMBLE - Token Budget(21000자, 약 6000토큰) 내에서 컨텍스트 조립
SYNTHESIZE - Claude에게 조립된 컨텍스트와 질문을 전달하여 답변 생성
거창하게 5단계를 나눠서 썻지만, 결론적으로 RAG란 제가 미리 특정 컨텍스트를 단정지어서 LM에게 넘기는 방식입니다.
위 실패 케이스를 자세히 살펴보고 비슷한 실험들을 토대로 결론을 내릴 수 있었습니다. 결국 긴 문서에서 제가 설계한 방법은 틀려먹었다는 겁니다. 저희 노션 페이지에 긴 문서들의 특징은 한 페이지 안에 여러 주제의 내용들이 혼재되어 있는 경우에 해당합니다. 위 연차 규정 같은 경우도 신규 입사자 온보딩 가이드라는 큰 문서의 한 섹션에 있었고, 저는 그 가이드 전체를 통째로 임베딩 했었던 것이었습니다.
문서 전체를 하나의 벡터로 변환했을 때, 그 벡터는 문서의 평균적인 의미를 담습니다. 과연 이 문서가 연차 규정인지 알까요? 당연히 연차 규정이라는 질문과 코사인 유사도가 0.3 미만으로 나왔고, 2단계 필터에서 잘려나갔습니다.
문제점을 파악했다면 당연히 개발자스럽게 해결하면 되겠습니다. 문서를 작은 단위로 쪼개면 해결하기 쉽겠죠? 바로 청킹(Chunking)을 사용하면 됩니다. 청킹이란 방대한 정보나 개별 요소를 의미 있는 작은 덩어리(Chunk)로 묶어 기억하거나 처리하는 인지 심리학 기법이라고 합니다. 여기서의 청킹이란, 긴 문서를 청크 단위로 쪼개서 각각을 별도로 임베딩한다는 의미로 이해하시면 됩니다. 온보딩 가이드를 섹션별로 나누면 연차 섹션의 임베딩이 질문과 높은 유사도를 보일 것입니다.
하지만 청킹은 새로운 튜닝 포인트를 만듭니다. 청크 크기는 얼마로 할 지? 크기에 따라 문맥이 잘릴 수도 있겠죠... 문맥이 잘리면 이전 결정 사항과 분리되어 의미를 잃을 수 있고, 너무 크다면 원래 문제가 재현됩니다. 이를 위해 오버랩을 사용할 수 있습니다. 하지만 오버랩은 노션 페이지가 수정될 때 마다 청크를 다시 나누고 임베딩을 또 해야되는 문제가 있겠죠...
또 다른 방법으로, 청킹 과정에서도 별도 LLM 레이어를 두어 문맥 단위로 청킹 시키도록 할 수 있겠습니다. 하지만 저는 이런 방법들로 또 다른 문제를 야기할 수도 있다는 생각에, 검색 파이프라인을 고도화하는 방법에서 LLM의 자율 주행에 몸을 맡겨보기로 했습니다.
Agentic Search
사실 선택의 이유는 두 가지가 더 있습니다. Claude Code의 모델이 발전함에 따라 알아서 잘 찾겠구나 라는 생각과 거기에 기름을 부어준 클로드 코드 창시자인Boris Cherny의 트윗을 보고 빠르게 전환을 결심했습니다. 트윗의 내용은 Claude Code 자체도 초기에는 RAG + 로컬 벡터 DB 기반이었다가 Agentic Search로 전환했다는 내용입니다.
Agentic Search를 통해 검색 파이프라인의 각 단계를 사람이 하드코딩하지 않고, LLM에게 검색 도구를 쥐어주고 스스로 판단하게 했습니다. 사람이 어떤 순서로 검색할지 정하는 게 아니라 LLM이 질문을 보고 이건 키워드 검색이 낫겠다, 이 문서의 3번째 섹션을 읽어야겠다 를 자율적으로 결정합니다.
Agentic Search도 거창해보이지만 간단합니다. 로컬 CLI로 여태까지 Claude Code를 사용했던 경험을 떠올려 보니 자연스레 MCP를 떠올렸습니다. 저는 8가지 MCP를 통해 LLM의 자율 주행을 보장했습니다. 각 MCP는 Claude Agent SDK의 tool()로 정의하고, query()를 통해 질문과 MCP를 포함한 각종 옵션들을 넘겨 사용합니다.
이렇게 MCP를 분리해서 LLM이 질문의 성격에 따라 전략을 선택할 수 있게 했습니다. 위의 연차 규정처럼 명확한 키워드가 있으면 바로 search_by_keyword를 사용하고, 최근 팀 분위기가 어떠니? 같은 모호한 질문에는 search_by_meaning을 사용합니다. 짧은 스레드는 read_context로 전체를 읽으면 되지만, 긴 맥락은 read_context_section으로 맥락 내 특정 섹션만 읽어야 효율적입니다.
LLM이 완전히 자율적으로 도구를 선택하지만, 효율적인 전략을 가이드했습니다. 예를 들어 검색형 질의에는 search_by_meaning을 우선 사용하고 고유명사가 포함된 질문에는 search_by_keyword을 먼저, 분석형 질의에는 list_channel_contexts로 벌크 조회를 수행하라는 가이드가 있습니다.
연차 규정 알려줘 라는 동일한 질문에 대해 이제 Agent Search는 다음과 같이 동작합니다.
모든 도구 선택을 LLM이 자율적으로 수행합니다. 임베딩 유사도에 의존하지 않기 때문에 긴 문서 안에 묻힌 정보도 정확히 찾아낼 수 있게 되었습니다.
운영하면서 겪은 문제들
2~3주 정도 운영해오면서 아래와 같은 이슈들을 발견했고, 다음과 같이 수정했습니다.
유사도가 낮은 문서까지 여러 개 확인하면서 응답 시간이 수 분까지 늘어났다
비동기 + graceful degradation(실패해도 답변을 돌려줌)로 개선
슬랙 스레드에서 후속 질문을 하면 이전 대화 맥락을 몰라 발생하는 문제
스레드의 이전 메세지를 수집해서 질문에 컨텍스트로 주입
맺으며
개인의 미팅록 관리 도구에서, 필요한 것들을 하나씩 추가하며 점진적으로 구조를 정리하고 전사적인 플랫폼으로 발전시키려고 했습니다. 이 허브가 궁극적으로 지향하는 것은 조직의 기억입니다. 누구든지 직군의 구분 없이 조직의 맥락을 쉽게 파악하고, 과거 논의와 결정을 즉시 찾아주고 담당자가 바뀌어도 히스토리가 유실되지 않게 하는 것. 그것을 사람의 손으로 항상 문서를 정리하고 인수인계를 만들어야 했던 것들을 당장에 해결할 수 있겠죠. 또, 이 허브를 바탕으로 CS 자동화도 쉽게 구축할 수 있습니다. 허브에 과거 CS들을 최초 정리만 하고, 정말 필요 시 CX 담당자가 직접 소통 할 때, CX 담당자의 상담 내용을 재학습하는 하네스를 구축한다면 쉽게 도달 할 수 있는 영역입니다.
(이미 CS 봇은 테스트 중에 있습니다.)
물론 위에서 언급한 UX가 전부라 생각하지는 않습니다. 저는 이 허브를 바탕으로, 다양한 시도들을 해나갈 생각입니다. 이 허브가 정말 두뇌라면, AX화를 하기 위한 멀티 에이전트의 오케스트레이터가 되기에 가장 적합하다고 생각합니다. 이러한 방향으로 발전시켜 볼 생각입니다.
// express/lib/application.js
app.listen = function listen() {
var server = http.createServer(this) // Node.js 내장 http 모듈 사용
return server.listen.apply(server, arguments)
}
그런데 Django는...
python manage.py runserver
# "WARNING: This is a development server. Do not use it in production."
# django 소스코드 일부
# django/core/management/commands/runserver.py
self.stdout.write(
self.style.WARNING(
"WARNING: This is a development server. Do not use it in a "
"production setting. Use a production WSGI or ASGI server "
"instead.\nFor more information on production servers see: "
f"https://docs.djangoproject.com/en/{docs_version}/howto/"
"deployment/"
)
)
소스코드를 확인해보니 무슨 SGI를 사용하라고하네요. 서울보증보험인가.. 별도의 서버가 필요하다는 것은 확실해 보였습니다.
Java기반의 Spring을 짧게 사용했을 때도 당연히 Tomcat을 별도로 사용했기 때문에 그런가보다 했습니다.
그런데 공부하다보니 2025년을 살아가는 저에게는 꽤나 독특한 녀석이라고 생각했습니다.
왜 Python 웹 생태계는 GIL 뿐 아니라 WSGI 같은 녀석도 표준이 되어 지금까지도 사용되고 있을까요?
애플리케이션과 서버의 분리
다시 말하지만 Node는 개발자가 웹 서버를 별도 구성할 필요가 없습니다.
런타임에 HTTP 서버가 내장되어 있어 별도 서버를 구성하지 않고도 바로 웹 서버를 띄울 수 있죠.
왜 이렇게 분리되었을까요?
2000년대 초반에는 Python 웹 생태계에는 Zope, Quixote, Webware 등의 다양한 프레임워크가 있었다고 합니다.
문제는 프레임워크 선택이 서버 선택이 되어, Zope를 쓰려면 Zope 서버를, Quixote를 쓰려면 또 다른 서버를 써야 했다고 해요.
Java에서는 Servlet API가 이 문제를 해결했어요.
어떤 서블릿 컨테이너(Tomcat, Jetty 등)에서든 서블릿 스펙을 따르는 웹 앱을 실행할 수 있습니다.
WSGI(Web Server Gateway Interface)는 웹 서버 게이트웨이의 표준 인터페이스입니다.
웹 서버와 Python Application 사이의 표준 인터페이스 인 셈이죠.
Python이 그러한 것 처럼, WSGI 또한 단순하고 간결한 것이 원칙이었다고 합니다.
Thus, simplicity of implementation on both the server and framework sides of the interface is absolutely critical to the utility of the WSGI interface, and is therefore the principal criterion for any design decisions.
the goal of WSGI is to facilitate easy interconnection of existing servers and applications or frameworks, not to create a new web framework
Phillip J. Eby (PEP 333 - https://peps.python.org/pep-0333)
callable은 말 그대로 호출할 수 있는 객체를 뜻해요. application() 처럼요.
참고로 응답이 리스트(iterable)인 이유가 있어요. 대용량 파일을 한 번에 메모리에 올리지 않고 chunk 단위로 스트리밍할 수 있게 하려는 설계입니다.
environ
이름만 봐도 감이 오죠? .env를 생각하면 될 것 같아요.
environ은 CGI 스타일의 환경 변수 딕셔너리에요. CGI(Common Gateway Interface)는 1990년대 웹 서버가 외부 프로그램을 실행하던 방식이에요. WSGI가 이 변수 컨벤션을 그대로 사용한 이유는, 당시 Python 프레임워크들이 이미 CGI 방식을 구현해뒀기 때문입니다.
요청이 들어오면 워커 하나가 요청을 받고, 처리가 끝날 때까지 해당 워커는 점유되며 응답을 반환하고 나서야 다음 요청 처리가 가능해요. (sync worker 기준)
물론 Gunicorn도 gevent나 eventlet 같은 async worker를 사용하면 Green thread 기반으로 수백 개의 동시 연결을 처리할 수 있어요. 하지만 이건 WSGI 표준 위에서의 우회 방식이고, WebSocket 같은 양방향 통신은 여전히 구조적으로 불가능합니다.
물론 Django를 사용하더라도 멀티프로세싱이나 스케일 아웃으로 많은 워커를 구성하거나 적절한 캐싱과 인프라 구조의 최적화를 통해 개선할 수도 있겠죠...?
실제로 인스타그램은 2012년 Django + Gunicorn 스택으로 1400만 유저까지 스케일했고, 현재도 Django를 핵심 스택으로 사용하며 수십억 사용자를 처리하고 있어요. 대단하죠.. (인스타 기술 블로그)
ASGI
ASGI(Asynchronous Server Gateway Interface)는 비동기 기능을 갖춘 파이썬 웹 서버 인터페이스입니다.
(ASGI 스펙 문서에서는 WSGI의 정신적 후계자(spiritual successor)라고 소개되어 있어요)
async를 통해 비동기 처리를 지원하는 ASGI는 Django 기준 3.0부터 공식 지원한다고해요.
WSGI가 요청을 받아 응답을 반환하는 단방향이었다면, ASGI는 receive/send로 언제든 양방향 통신이 가능한 구조입니다.
# asgi.py
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = get_asgi_application()
Django에서는 ASGI를 지원한다고 해서 모든 코드가 비동기로 동작을 지원하지는 않습니다.
대표적으로 Django ORM은 기본적으로 동기 드라이버(psycopg2 등)를 사용하기 때문에, DB 쿼리 시 해당 스레드가 블로킹됩니다.
def my_view(request):
result = SomeModel.objects.all() # 동기 ORM
return HttpResponse(result)
하지만, Django 4.1 버전 이후부터는 async ORM을 점진적으로 지원하기 시작했고, ORM 뿐 아니라 Django와 Python에서 비동기를 점진적으로 지원하기 위한 노력은 지금도 꾸준히 진행되고 있는 것으로 보여요. (이전 글에서 다룬 GIL Free-threading도 그 일환이죠)
정리
Node에서는 런타임에 내장되어있었기 떄문에, 그리고 기본적으로 비동기를 지원했기 떄문에 다소 많은 차이가 느껴졌습니다.
이전 GIL 포스팅과는 다르게 마냥 부정적으로만 보이지는 않았는데요, 이는 GIL이라는 언어 자체의 레거시와는 느낌이 달랐기 떄문입니다.
GIL은 언어(CPython) 레벨의 문제이고, WSGI는 프레임워크와 웹 서버 생태계의 문제입니다. 흥미롭게도 생태계 전환이 오히려 더 빠르게 진행 중이에요. GIL 제거는 수십 년간 시도 끝에 Python 3.13에서야 실험적으로 도입된 반면, ASGI로의 전환은 FastAPI의 부상, Django 3.0+의 공식 지원 등 이미 활발히 이루어지고 있죠.
특히 Django는 ORM, Admin, Auth 등 많은 기능이 내장되어 있고, 이 모든 것들이 동기 기반으로 설계되어 있잖아요. 이걸 비동기로 전환하려면 프레임워크 전체가 바뀌어야 하는 거니까요. 그래도 Django 4.1부터 async ORM이 점진적으로 지원되고 있고, Python 생태계 전체가 비동기를 향해 나아가고 있으니 긍정적으로 보고 있어요.
그리고..... 개발자의 역량에 따라 동기적인 웹 서버로도 충분히 10M+의 트래픽이 제어 가능하고 인스타라는 선진 사례도 있기 떄문에, 이 모든게 저의 역량에 달린 일이 아닐까..(?????????) 하는 생각도 들었습니다.
다음 Python 관련 스터디는 딱히 정해지진 않았지만, 무언가 정리할 만한 주제를 찾아 돌아오도록 하겠습니다.
GIL(Global Interpreter Lock)은 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 보장하는 뮤텍스이며 CPython의 특성입니다. GIL 덕분에 thread-safe를 보장하지만, 같은 프로세스 안에서 스레드가 여러 개 있어도 한 번에 하나의 인터프리터만 실행시키는 제약이 생깁니다.
1. CPython
JavaScript는 V8, SpiderMonkey, NodeJS, Deno, Bun 등 여러 런타임이 존재합니다.
Python도 실행하는 인터프리터의 종류가 다양하며, 그 중 가장 널리 쓰이는 공식 구현체가 C로 작성된 CPython입니다.
2. Mutex
Mutex(Mutual Exclusion)는 공유 자원에 대한 동시 접근을 막는 동기화 메커니즘입니다.
GIL은 일종의 열쇠입니다. 이 GIL을 통해 하나의 스레드에서 작업을 수행하고 반납하면, 다음 스레드에서 GIL을 얻어 작업을 수행합니다.
동작 방식을 시각화해보면 다음과 같습니다.
Python 3.2 기준으로 CPython은 기본적으로 5ms 간격으로 GIL을 해제하여 다른 스레드에게 실행 기회를 줍니다.
이 간격은 sys.getswitchinterval() 로 확인해볼 수 있습니다.
GIL은 왜 존재할까?
GIL 때문에 멀티스레드가 제한된다는 건 알겠습니다. 근데 왜 굳이 이런 제약을 만들었을까요? 동시성에 제약이 생긴다는 것은 많은 부분에서 성능 이슈들이 발생할 잠재적인 원인이 될 수 있는데 말이에요.
이해를 돕기 위해 CPython의 메모리 관리 방식을 조금 뜯어보았습니다.
CPython의 메모리 관리
CPython은 참조 카운팅(Reference Counting) 기반의 GC를 사용합니다.
import sys
a = [] # 리스트 객체 생성, refcount = 1
b = a # 같은 객체 참조, refcount = 2
print(sys.getrefcount(a)) # 3 (함수 인자로 전달되면서 +1)
del b # refcount = 2
del a # refcount = 1 → 스코프 종료 시 0 → 메모리 해제
모든 Pyhthon 객체는 내부적으로 ob_refcnt 라는 참조 카운터를 가지고 있어요.
typedef struct _object {
Py_ssize_t ob_refcnt; // 참조 카운트
PyTypeObject *ob_type; // 타입 정보
} PyObject;
객체를 참조할 때마다 이 카운터가 증가하고, 참조가 해제되면 감소하는 구조입니다. 카운터가 0이 되면 메모리에서 해제되는거죠.
GIL이 없다면?
만약 GIL이 없어 여러 스레드가 동시에 같은 객체를 참조한다면, 예상하시다시피 Race Condition이 발생하게 되죠.
이 현상은 참조 카운터에도 동일하게 적용됩니다.
현재 참조 카운트가 1인 객체를 스레드 1과 스레드 2가 동시에 참조했습니다.
두 번의 참조가 추가되었기 때문에 당연히 3일 줄 알았지만 결과는 2가 될 수 있어요.
이런 상황이 반복되면 실제로 참조중이지만 GC에 의해 객체가 메모리에서 해제되어 참조에 실패하게되고
반대의 경우에는 참조가 끝났지만 메모리에 남아있어 메모리 누수가 발생하게 됩니다.
왜 하필 GIL인가?
여기까지 이해한 내용을 바탕으로 곱씹어보니, 참조 카운트마다 개별 락을 걸어도 될 것 같다는 생각이 들었습니다.
물론 당연히 구현 복잡도는 올라가겠지만 현대의 프로그래밍에서 이 정도의 복잡성을 해결하지 못할 리가 없으니까요.
하지만, Python이 만들어졌을 때는 1991년으로 싱글 코어 CPU가 일반적이었다고 해요.
GIL은 그 당시 시대성을 반영한 단일 스레드 성능의 최적화 라는 관점에서의 합리적인 선택이었다고 합니다.
Node와의 동시성 모델 비교
저를 포함한 Node 개발자 입장에서 동시성 처리에 혼동이 오는 이유는, Node의 동시성과 병렬 처리 방식과 Python의 방식이 다르기 때문이라고 생각합니다.
Node와 JavaScript의 철학은 다음과 같죠
JavaScript 코드는 싱글 스레드에서 실행
I/O 작업은 libuv의 스레드 풀에서, 또는 OS 비동기 API로 위임
I/O 완료를 기다리지 않고 다음 작업을 진행하는 Non-Blocking 모델
콜백과 Promise로 결과 처리
const fs = require('fs').promises;
async function readFiles() {
// 두 파일 읽기가 "동시에" 진행
const [file1, file2] = await Promise.all([
fs.readFile('a.txt'),
fs.readFile('b.txt')
]);
return [file1, file2];
}
Node가 싱글 스레드 + 이벤트 루프인데 반해 CPython은 멀티스레드 + GIL 조합을 사용합니다.
여러 스레드를 생성할 수 있지만, GIL 때문에 Python 코드를 실행하는 스레드는 하나일 수밖에 없습니다.
데이터베이스의 락처럼, 해제를 기다리게 되죠. (단, I/O 작업에는 GIL이 해제되어 다른 스레드가 실행될 수 있습니다.)
NodeJS에서 Promise.all로 동시에 파일을 읽었다면, Python에서는 스레드를 직접 생성해서 처리합니다.
import threading
def read_file(filename):
with open(filename) as f:
return f.read()
# 스레드 생성
t1 = threading.Thread(target=read_file, args=('a.txt',))
t2 = threading.Thread(target=read_file, args=('b.txt',))
t1.start()
t2.start()
t1.join()
t2.join()
차이점 정리
CPU bound 와 I/O bound
GIL의 영향이 작업 유형에 따라 달라지는데요.
CPU bound 작업과 I/O bound 작업을 비교해보겠습니다.
CPU bound
CPU bound 작업에서는 멀티스레드를 활용하더라도 작업 속도 개선에 도움되지 않는데요. 바로 코드로 확인해보겠습니다.
import threading
import multiprocessing
import time
def count_primenum(n):
"""2부터 n-1까지 소수 개수 세기"""
count = 0
for i in range(2, n):
if all(i % j != 0 for j in range(2, int(i**0.5) + 1)):
count += 1
return count
def main():
N = 1000000
# 순차 실행
start = time.time()
count_primenum(N)
count_primenum(N)
print(f"순차: {time.time() - start:.2f}초")
# 멀티스레드 실행
start = time.time()
t1 = threading.Thread(target=count_primenum, args=(N,))
t2 = threading.Thread(target=count_primenum, args=(N,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"멀티스레드: {time.time() - start:.2f}초")
# 멀티프로세싱 실행
start = time.time()
with multiprocessing.Pool(2) as p:
p.map(count_primenum, [N, N])
print(f"멀티프로세싱: {time.time() - start:.2f}초")
if __name__ == '__main__':
main()
순차 실행과 멀티 스레드의 실행 속도가 거의 동일합니다.
GIL 때문에 두 스레드가 번갈아 실행되지만, 결국 한 번에 하나의 스레드만 Python 코드를 실행하기 때문에 총 소요 시간은 순차 실행과 다를 바가 없습니다. 별개로 위 예제에서는 멀티프로세싱은 프로세스를 여러 대 활용하는 것이기 때문에, 영향을 받지 않습니다.
공부하면서 코드로 실제로 확인해보고나니 오히려 스레드가 많아지면 GIL 획득과 해제 오버헤드가 추가되어 더 느려질 수도 있겠다는 생각이 드네요. GIL의 간격마다 해제되고 새로 GIL을 획득하는 과정을 반복하게 되기 때문이겠죠.
I/O bound
위에서 언급했다시피 I/O 작업에서는 조금 다른데요. 블로킹 작업에서는 GIL이 해제됩니다.
import threading
import time
import requests
URL = "https://example.com"
def io_work():
requests.get(URL)
def run_sequential(num_requests=20):
start = time.time()
for _ in range(num_requests):
io_work()
return time.time() - start
def run_threads(num_threads=20):
threads = []
start = time.time()
for _ in range(num_threads):
t = threading.Thread(target=io_work)
t.start()
threads.append(t)
for t in threads:
t.join()
return time.time() - start
if __name__ == "__main__":
print(f"순차 (20회): {run_sequential(20):.2f}초")
print(f"멀티스레드 (20개): {run_threads(20):.2f}초")
CPU 작업과는 달리 20개 요청이 거의 단일 요청 시간과 비슷하게 완료되는데요.
스레드에서 I/O 대기중에는 GIL이 해제되기 때문에, 다른 스레드에서 GIL을 획득하여 그 시간을 활용할 수 있습니다.
20개의 스레드는 너무 많기에, 3개만 압축해서 플로우 차트를 그려봤어요.
세 개의 스레드로도 복잡한데요. 요약하자면 Python 코드, 즉 바이트 코드를 실행하기 위해서 GIL이 필요합니다.
하지만 I/O bound는 커널 혹은 OS 레벨의 작업이 필요하기 때문에 GIL을 반환하게 돼요. 이 때 다른 스레드에서 GIL을 획득해요.
백그라운드 작업이 끝난 뒤에도 마찬가지입니다. 그 뒤에 실행 로직들이 있다면 다시 GIL을 획득해야만 작업할 수 있어요.
다시 정리하겠습니다.
I/O 대기중에는 GIL이 풀리므로 다른 스레드가 그 시간을 활용할 수 있어요.
반면 CPU 작업에서는 GIL을 번갈아 잡기 때문에 스레드가 많을수록 오버헤드가 생깁니다.
동시성과 최적화 모두 잡기
GIL에 대해 알아봤어요.
그렇다면 극단적으로 보이는 GIL 위에서, 개발자인 저는 상황에 맞게 동시성을 제한하거나, 동시성을 극대화하는 등 다양한 방향으로 구현을 해야할텐데요. 실제로 어떻게 구현을 해야할까요? 무엇을 어떻게 써야할까요?
멀티프로세싱
위에서 보여드린 예제처럼, 멀티프로세싱을 활용하는 방법이 있습니다.
위 내용들에서 눈치채셨겠지만, GIL은 프로세스 단위로 존재해요.
스레드는 같은 프로세스 내에서 메모리를 공유하기 때문에 GIL로 동기화가 필요하지만, 프로세스는 완전히 독립된 메모리 공간을 가지기 때문에 독립적인 Python 인터프리터와 GIL을 갖게 됩니다. 즉 4개의 프로세스를 띄우면 4개의 GIL이 독립적으로 동작하고, 각 프로세스는 서로의 GIL에 영향을 받지 않아 병렬 실행이 가능해지죠.
아래의 상황에서 고려해볼 수 있을 것 같아요.
CPU bound 작업이 명확한 이미지 처리나 연산 처리 등
작업 단위가 독립적이고 데이터/상태 공유가 적음
작업 하나의 실행 시간이 프로세스 생성 오버헤드보다 클 때
하지만 IPC 오버헤드가 우려되거나, 비동기 처리가 더 효율적일 때는 사용을 피하는 게 좋습니다.
비동기처리
NodeJS의 async/await와 유사한 모델인 asyncio를 사용할 수도 있어요.
asyncio는 코루틴 기반의 비동기처리 모델로 싱글 스레드에서 이벤트 루프를 통해 여러 I/O 작업을 동시에 처리합니다.
스레드를 여러 개 만들지 않고도 I/O 대기 시간을 효율적으로 활용할 수 있어요.
Node 개발자라면 익숙한 패턴이죠
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = ['https://example.com'] * 10
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
asyncio.run(main())
threading(멀티스레딩)과 asyncio는 뭐가 다를까요? 저는 위에서 threading 방식도 I/O bound 작업에 효과적이라고 언급했습니다.
핵심 차이는 동시성을 만드는 방식에 있어요.
threading: OS가 스레드를 관리하고, OS가 컨텍스트 스위칭 결정
asyncio: 이벤트 루프가 코루틴을 관리하고, await 지점에서 능동적으로 제어권을 넘김
이런 방식의 차이 때문에, asyncio는 스레드를 만들지 않기 때문에 컨텍스트 스위칭 오버헤드가 적고 메모리 사용량도 낮습니다.
동시 요청이 수백 ~ 수천 개로 늘어나도 threading처럼 리소스가 폭발적으로 사용되진 않아요.
다만 제약도 있습니다.
사용하는 라이브러리가 async를 지원해야함
CPU bound 작업에는 여전히 적합하지 않음 (싱글 스레드니까)
일반적인 서버 애플리케이션은 네트워크, DB, 파일 등 I/O 작업 비중이 높기 때문에 async 지원 라이브러리를 쓰고 있다면 asyncio가 자연스러운 선택이 될거에요.
GIL을 해제하기
NumPy, Pandas 같은 라이브러리는 C로 작성된 부분에서 GIL을 해제한다고 합니다.
import numpy as np
# NumPy 연산은 C 레벨에서 GIL 해제 후 병렬 처리
a = np.random.rand(10000, 10000)
b = np.random.rand(10000, 10000)
c = np.dot(a, b)
또, Cython에서는 명시적으로 GIL을 해제할 수 있어요. 마치 free 처럼요
# example.pyx
from cython.parallel import prange
def parallel_sum(double[:] arr):
cdef double total = 0
cdef int i
with nogil: # GIL 해제
for i in prange(arr.shape[0]):
total += arr[i]
return total
# free-threaded 버전 확인
pyenv install --list | grep 3.13t
# free-threaded 버전 설치 (3.13t가 있으면)
pyenv install 3.13t-dev # 또는 3.13.0t 같은 형식
# 해당 디렉토리에서 사용
pyenv local 3.13t-dev
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와 더불어 이번에 살짝 언급했던 커밋 그래프와 브랜치 쪽을 조금 더 파볼 예정입니다.
핵심은, 사이드 이펙트가 없는 직렬화를 감지했을 때, 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으로 계속 갖고 가기 위해 반드시 하나씩 깊게 독파하는 포스팅으로 찾아뵙겠습니다 하하..