신입으로 입사해서 만 2년 2개월을 근무했던, 아이패밀리SC를 떠나게 되었습니다. 첫 퇴사이기에 조금 싱숭생숭합니다. 이 글은 그간의 경험을 정리하고, 다음 선택에 조금 더 의미 있는 결정을 내리기 위해 남기는 회고입니다.
왜 퇴사했는가
퇴사를 결심한 이유는 크게 두 가지였습니다.
개발 위주의 회사가 아니라 회사 성장에 직접적이고 폭발적인 기여를 하기 어렵다고 생각함
홀로 기술적 의사결정을 감당해야 했던 환경
현재 회사는 꾸준히 성장하고 있으며, 특히 색조 화장품 브랜드 롬앤이 매출의 대부분을 차지합니다.
현재의 구조 속에서 IT가 주도적인 역할을 하긴 어려웠다고 생각했습니다. 개발자의 기여 범위가 한정되어있다고 느꼈습니다.
저는 그중에서도 웨딩 도메인 영역의 백엔드와 서버, 인프라 전반을 담당했습니다. 팀 내에서 저와 유사한 포지션이 없었기에 대부분의 기술적 의사결정을 스스로 고민하고 진행해야 했습니다. 다행히 조직에서는 제가 내린 결정들을 존중해주셨습니다.
이런 환경 덕분에 의사 결정을 위한 공부와 시행착오들을 겪고 더 가파르게 성장할 수 있었지만, 어느 순간 그릇된 선택을 하고 있는 게 아닌가 하는 매몰되는 감정도 동반되었습니다.
개선을 위한 시도들
혼자 기술적 결정을 내려야 한다는 것은 쉽지 않았습니다. 특히 신입 개발자에게는요. 메시지 큐 도입이나 MyISAM 트랜잭션 모듈 SDK 구현처럼 새로운 시도들은 많은 시행착오를 동반했습니다. 하지만 그 과정에서 시스템을 설계하고 트레이드오프를 판단하는 힘을 기를 수 있었습니다. 값진 경험이었습니다.
내부에서 한계가 분명해 보였기에, 기술적인 고립감을 해소하기 위해 외부로도 눈을 돌렸습니다. 제가 주로 사용하는 기술 스택을 기반으로 다양한 오픈소스 프로젝트에 기여했고, 약 1년간 40여 개의 PR을 제출하면서 꾸준히 성장의 방향을 잃지 않으려 했습니다.
또한, 외부에서 배운 것을 내부에도 녹여내기 위해, AI 도입과 같은 변화를 시도했습니다. AI 도입을 제안해 클로드 코드 맥스, 커서 프로, GPT 프로 등 여러 AI 도구를 팀 차원에서 사용할 수 있도록 만들었습니다. 이후에는 AI 활용법과 효율적인 프롬프트 설계에 대해 발표를 진행하며, 기술적 교류의 장을 만들어가고자 했습니다.
개선하지 못한 것
퇴사를 앞두고 되돌아보니, 기술적인 시도나 성장을 위해 노력은 했지만 개발 문화나 조직적인 아이디어 제시 측면에서는 다소 소극적이었던 것 같습니다. 앞으로의 조직에서는 기술적 깊이뿐 아니라 팀의 방향성과 문화에도 적극적으로 참여하는 개발자가 되고자 합니다.
내가 찾고자 했던 환경
이번 퇴사를 계기로 내가 어떤 개발자가 되길 원하고, 어떤 조직을 원하는지 되돌아보게 되었습니다.
제가 바라던 환경은 단순히 좋은 복지나 기술 스택이 맞는 곳이 아니었습니다. 개발자로서의 성장 욕구가 살아 있는 사람들, 그리고 그 성장의 방향이 조직의 목표와 자연스럽게 맞닿아 있는 팀, 그리고 그 속에서 서로의 성장을 자극하며 함께 나아가는 긍정적인 사이클이 존재하는 환경을 원했습니다. 그런 환경이라면 저는 더 깊이 몰입하고, 더 많이 배우며, 더 오래 즐겁게 성장하고 기여할 수 있을 것이라 믿습니다.
몰입과 몰두
개인의 역량이 존중받고, 각자의 일이 단순한 업무가 아니라 함께 이루어내는 성취의 과정으로 느껴지는 곳. 주어진 일을 처리하기보다 ‘왜 이걸 하는가’에 집중할 수 있는 환경.
목표 공유와 주인의식
비즈니스의 목표와 팀의 방향이 투명하게 공유되고, 구성원 모두가 그 목표를 자기 일처럼 고민하는 조직. 단순히 시키는 일을 하는 사람이 아닌, 같이 목표를 이뤄가는 사람으로 대우받는 팀.
성장을 원하는’팀이 아니라, 성장을 행동으로 증명하는 팀
배움과 도전을 말로만 하는 게 아니라, 실제로 학습하고 실험하며 발전을 추구하는 사람들이 모여 있는 곳. 나아가 서로의 성장을 자극하고 도와줄 수 있는 팀 문화.
마치며
딱히 스택이나 직무 범위에 제한을 두지 않고, 정말 함께 일하고 싶은 사람들과 성장할 수 있는 조직을 찾기 위해 여러 채용 전형을 진행했습니다. 감사하게도 여러 곳에서 제게 기회를 주셨고, 그중에서 제가 가장 몰입할 수 있는 환경과 방향성을 가진 곳으로 최종 선택하게 되었습니다.
2023년 9월 4일, 신입으로 입사해 2025년 11월 7일 퇴사합니다. 2년 2개월이라는 시간 동안 기술적으로나 개인적으로 미성숙했던 부분이 많았지만, 그만큼 배우고 성장할 수 있었던 시기이기도 했습니다.
한국에서 처음으로 조직생활을 시작하며 부족한 점이 많았을 텐데, 늘 믿어주시고, 성장할 수 있는 기회를 주셨던 기술연구소 임직원분들께 진심으로 감사드립니다. 이번 경험을 통해 ‘좋은 개발자’는 혼자 잘하는 사람이 아니라 함께 성장하며 조직을 더 좋은 방향으로 이끌어가는 사람이라는 걸 배웠습니다. 다음 조직에서는 더 깊이 몰입하고, 더 넓은 시야로, 팀과 함께 성장하는 개발자가 되고자 합니다.
핵심은, 사이드 이펙트가 없는 직렬화를 감지했을 때, Fast Path를 사용할 수 있도록 개선했다는 내용입니다. 여기에 문자열 이스케이프 경로 개선(플랫폼에 따라 SIMD 활용)과 number 처리 최적화가 얹어져 2+a배의 성능 개선이 이루어 졌다고 합니다.
반대로 getter, proxy, 순환참조, toJSON 커스터마이징, pretty print 등 직렬화 과정에서 사이드 이펙트는 Fast Path가 아닌 일반 경로로 폴백합니다. V8의 직렬화 퍼포먼스 개선의 이점을 얻기 위해서는, 개발자가 직렬화 과정에서 사이드 이펙트가 언제 발생하는지 인지하는 게 중요할 것 같습니다.
const N = 200_000;
const safe = Array.from({ length: N }, (_, i) => ({ id: i, ok: true, n: i|0, s: "x" }));
// 1) Fast path 기대 (무부작용)
console.time("safe");
JSON.stringify(safe);
console.timeEnd("safe");
// 2) replacer 사용 → 일반 경로
console.time("replacer");
JSON.stringify(safe, (k, v) => v);
console.timeEnd("replacer");
// 3) space 사용(pretty print) → 일반 경로
console.time("space");
JSON.stringify(safe, null, 2);
console.timeEnd("space");
// 4) toJSON 개입 → 일반 경로
const withToJSON = { ...safe[0], toJSON(){ return "x"; } };
console.time("toJSON");
JSON.stringify(withToJSON);
console.timeEnd("toJSON");
2. Uint8Array 내장 인코딩 지원
ECMAScript에서 최근 Uint8Array에서 직접 Base64, Hex 인코딩/디코딩을 다루는 표준 API가 구현되었습니다. Unit8Array는 바이너리를 다루는 바이트 배열(Typed Array)로 이미지, 파일, 압축, 암호화, 스트리밍 등에 사용되는 바이너리를 다룰 때 기본 자료 구조로 활용되는 것들 중 하나입니다.
25년 9월 기준의 최신 브라우저나 JS 엔진에서 사용 가능하며 자세한 내용은 MDN을 확인해보시면 좋을 것 같습니다.
이번 업데이트로 Node와 브라우저가 동일한 코드를 사용할 수 있게 되었고, 특히 setFromBase64/Hex가 직접 버퍼를 채우는 방식이기 때문에 중간 문자열, 메모리 복사를 줄이고 큰 페이로드에서 GC Pressure을 낮추고, 메모리 사용을 절감할 수 있습니다. 또한 옵션으로 유니온 리터럴 타입을 사용하여 옵션들을 표준화했습니다. 코드 일관성과 퍼포먼스 둘 다 개선했다고 볼 수 있겠습니다.
3. JIT 파이프라인 변경
V8의 JavaScript 실행 파이프라인은 여러 단계로 구성되어있습니다.
Ignition: 인터프리터
SparkPlug: 베이스라인 컴파일러
Maglev: 중간 계층 최적화 컴파일러
TurboFan: 최적화 컴파일러
Maglev는 Chrome M117에 도입된 새로운 최적화 컴파일러로, 기존 SparkPlug와 TurboFan 사이에 위치합니다. 컴파일 속도 측면에서 Maglev는 SparkPlug보다 약 10배 느리고, TurboFan보다 약 10배 빠르다고 합니다. Maglev는 기존 두 컴파일러 사이의 간격을 좁혀 빠른 최적화와 균형 잡힌 성능, 그리고 점진적 워밍업을 제공합니다. 보다 더 자세한 내용은 공식 블로그 내용을 참조하시면 좋습니다.
WASM은 기본적으로 동기적인 실행 모델을 가정합니다. 하지만 웹 환경의 많은 API들은 비동기적입니다. 기존에는 이 문제를 해결하기 위해 Binaryen의 ASYNCIFY 같은 복잡한 변환 도구를 사용해야 했습니다. 이로 인해 코드 크기가 증가하고, 런타임 오버 헤드가 자연스레 증가하며 빌드 프로세스 또한 복잡해지는 문제가 있습니다.
Node 25부터는 JSPI를 통해 WASM 애플리케이션이 동기적으로 작성되어 있더라도, JavaScript의 비동기 API를 자연스럽게 사용할 수 있게 해줍니다.
여기까지가, V8 업데이트로 인한 Node v25의 변경사항입니다. 아래부터는 Node의 별개 커밋들로 변경된 사항들에 대해 알아보겠습니다.
Permission Model: --allow-net 추가
Node는 기본적으로 모든 시스템 리소스에 대한 접근 권한을 갖고 있었습니다. 이는 편리하지만 보안상의 문제가 생길 수 있습니다. 이를 개선하기 위해 Node v20에 Permission Model이 도입되었고, v25에서는 네트워크 권한 제어가 추가되었습니다.
Permission Model을 활성화하면, 명시적으로 허용하지 않은 모든 작업이 차단됩니다.
# Permission Model 없이 (기존 방식)
node index.js # 모든 권한 허용
# Permission Model 활성화 (네트워크 차단됨)
node --permission index.js
# Error: connect ERR_ACCESS_DENIED Access to this API has been restricted.
# 네트워크 권한 허용
node --permission --allow-net index.js # 정상 작동
런타임에서도 권한을 확인할 수 있습니다.
if (process.permission) {
console.log(process.permission.has('net')); // true or false
}
async function fetchData(url) {
if (!process.permission || !process.permission.has('net')) {
throw new Error('Network access not permitted');
}
return fetch(url);
}
ErrorEvent의 글로벌 객체화
브라우저에서는 ErrorEvent 인터페이스가 스크립트나 파일의 에러와 관련된 정보를 제공하는 표준 WEB API입니다. 하지만 Node에서 이를 사용하려면 별도의 polyfill을 설치하고, 브라우저와 Node환경을 분기 처리하며, 플랫폼(OS)별 에러 핸들링 코드를 별도로 작성해야했습니다.
// 기존 방식: 플랫폼 분기
if (typeof ErrorEvent !== 'undefined') {
// 브라우저 환경
window.addEventListener('error', (event) => {
console.log(event.message, event.filename, event.lineno);
});
} else {
// Node.js 환경: 다른 방식 사용
process.on('uncaughtException', (error) => {
console.log(error.message, error.stack);
});
}
Node v25부터 ErrorEvent가 글로벌 객체로 사용 가능합니다. 자세한 구현사항은 아래 커밋을 확인해보시면 좋습니다.
Node v22 이전까지는 localStorage, sessionStorage 같은 WebStorage API를 사용하려면 --experimental-webstorage 플래그가 필요했는데, 이 부분을 Node v25부터는 기본적으로 활성화 상태로 애플리케이션이 실행됩니다. 자세한 변경 내용은 아래 커밋을 확인해보시면 좋습니다.
이를 통해 CI/CD 환경이나 컨테이너, 혹은 협업 과정 등 실제 컴파일이 필요한 테스트, 배포 단계에서 불필요하게 중복 컴파일을 하는 일이 사라지게 될 것으로 기대합니다.
# 1. 로컬 개발
node --compile-cache --compile-cache-portable dev-server.js
# 2. CI/CD 파이프라인 (e.g. Git Actions)
- name: Build and Test
run: |
node --compile-cache --compile-cache-portable build.js
- name: Deploy # 캐시를 아티팩트로 저장
run: |
node --compile-cache app.js # 캐시 재사용으로 빠른 배포
# 3. Docker
FROM node:25
WORKDIR /app
COPY . .
# 빌드 시 캐시 생성
RUN node --compile-cache --compile-cache-portable build.js
# 런타임에서 캐시 활용
CMD ["node", "--compile-cache", "app.js"]
마치며
Node v25의 주요 변경 사항들을, 신규 피쳐 위주로 알아봤습니다. 더 많은 변경사항이 있고, 특히 이 글에서 다루지 않은 Deprecated들을 포함하여 더 자세하게 알고 싶으신 분들은 릴리즈 노트를 활용해보시면 좋을 것 같습니다.
개인적으로 당장 하나씩 씹어먹어보고 싶지만, 11월까지 바쁜 개인 일정을 마무리하고, 나중에 깊게 공부할 수 있도록 주제별로 정리만 간단하게 했습니다. 특히 V8 관련된 공부를 가장 먼저 깊게 해 볼 생각입니다. 메인 스택을 JS, Node으로 계속 갖고 가기 위해 반드시 하나씩 깊게 독파하는 포스팅으로 찾아뵙겠습니다 하하..
어떤 원인으로 인해 실패했는지 모르는데 무한정 반복하게 되면 어떤 다른 문제들이 더 발생할지 짐작할 수 없기 때문입니다.
동시 요청을 막기 위해 MyISAM의 Lock을 사용하면 될까요?
MyISAM의 Lock은 InnoDB처럼 레코드 단위의 락이 아닙니다. 테이블 단위의 락은 락을 해제할 때까지 그 누구도 해당 테이블에 접근하지 못하는 것을 의미합니다. 1번 유저의 캐시 차감을 위해 모든 사용자의 정보 변경, 조회 등 모든 요청이 락이 해제될 때 까지 대기하게 되는 문제가 발생합니다.
트랜잭션이 없다면 직접 만들자
지금까지 확인한 문제들을 정리하면
보상 로직 누락 방지(O): TransactionStep 인터페이스로 컴파일 타입에 누락 방지 가능
보상 실패 처리(X): 무한 재시도는 답이 아님 - 데이터 복구를 위한 시스템(DLQ 등)이 필요
@Process('execute-transaction')
async handleTransaction(job: Job) {
const { steps } = job.data;
const executionResults: ExecutionResult[] = [];
try {
// 1. Step 순차 실행
for (const stepData of steps) {
const step = this.stepRegistry.get(stepData.executeFnName);
this.logger.log(`[${stepData.name}] 실행 중...`);
const result = await step.execute(stepData.params);
executionResults.push({
stepName: stepData.name,
compensateFnName: stepData.compensateFnName,
result
});
this.logger.log(`[${stepData.name}] 완료`);
}
// 2. 모든 Step 성공
return { status: 'success', results: executionResults };
} catch (stepError) {
// 3. Step 실패 → 자동 보상 (역순)
this.logger.error(`Step 실패, 보상 시작: ${stepError.message}`);
for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
try {
this.logger.log(`[${stepName}] 보상 실행 중...`);
const compensateFn = this.stepRegistry.get(compensateFnName);
await compensateFn(result);
this.logger.log(`[${stepName}] 보상 완료`);
} catch (compensationError) {
// 보상 실패 → 에러 기록
this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
// 보상 실패 건은 "장애 복구" 섹션에서 다룰 DLQ로 격리됩니다.
break;
}
}
throw stepError;
}
}
기본적인 흐름은 다음과 같습니다.
트랜잭션 컨텍스트 내 각 작업(Step)을 순차 실행
완료된 스텝들은 혹시 모를 롤백을 위해 기록
위처럼 원자성을 보장하기 위한 시도도 보상 로직의 실패에 대한 잠재적인 위험은 그대로 남아있기 때문에, 이리저리 고민을 해봤습니다.
애플리케이션 레벨에서 보상을 계속 진행하기 위해서는 데이터베이스와의 통신이 불가피한데, 예기치 못한 상황으로 계속 실패할 수 있습니다. 결국 직접 데이터베이스 엔진에서 수행하는 일련의 동작이 아니기 때문에, 어느정도 수동 개입은 필요하다고 판단했습니다.
이는 아래 장애 복구 세션에서 다루도록 하겠습니다.
격리성
앞서 언급했듯이, MyISAM은 레코드 단위 락을 지원하지 않습니다.
// 사용자 1의 결제 처리 중
LOCK TABLES User WRITE, `Order` WRITE;
await processPayment(userId: 1, amount: 1000);
UNLOCK TABLES;
// 사용자 2의 결제 시도
await processPayment(userId: 2, amount: 500);
// 사용자 1의 락이 해제될 때까지 대기
위의 코드에서, 사용자 1이 결제를 위해 Lock을 획득했습니다.
이 때 MyISAM에서는 사용자 1의 결제 중 모든 사용자가 대기하는 상황이 발생하게 됩니다.
같은 결제 로직 뿐 아니라 단순 조회까지 차단됩니다.
InnoDB라면 당연히 얻을 수 있는 레코드 단위 락과, MVCC같은 잠금 없는 일관된 읽기가 지원되지 않습니다.
이를 해결하기 위해 Redis로 분산락을 사용해 InnoDB의 레코드 락 처럼 구현해보려고 했습니다.
다른 프로세스가 혹여 락을 삭제할 수도 있기 때문에, Lua 스크립트를 통해 내 락인지 확인 후 삭제하도록 구성해뒀습니다.
Lua 스크립트는 원자적인 실행을 보장하기 때문에 Race Condition을 방지하고, 내가 획득한 락만 해제가 가능합니다.
async release(lock: RedisLock): Promise<void> {
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = await this.redis.eval(
luaScript,
1,
lock.key, // KEYS[1]: "lock:transaction:user:123"
lock.value // ARGV[1]: "processA-12345"
);
if (result === 0) {
this.logger.warn(`락 소유권 불일치: ${lock.key}`);
}
}
3. 멱등키로 중복 요청 방지
분산락은 동시 요청을 제어하지만, 처리가 완료된 후의 중복 요청 처리를 방지하지는 못합니다.
// 최초 결제 요청
POST /api/payment { userId: 123, amount: 10000 }
// 가정: 결제는 성공했지만 네트워크 문제로 클라이언트 응답 못 받음
// 클라이언트는 실패한 줄 알고 재시도 -> 락 해제되어 재결제
POST /api/payment { userId: 123, amount: 10000 }
멱등키를 구현함으로써, 위와 같은 엣지 케이스에 대해 중복 처리를 방지할 수 있다고 생각했습니다.
(멱등키는 클라이언트에서 생성을 하도록 구성해뒀기 때문에 서버 코드에는 생략되어있습니다.)
저는 현업에서 혼자 개발하는 환경에 있다 보니, '내가 잘하고 있는 게 맞을까?' 하는 기술적인 갈증이 늘 있었습니다. 그런 저에게 오픈소스는 코드를 통해 전 세계 누구와도 소통할 수 있는 가장 완벽한 소통의 장이자 탈출구가 되어주었습니다. 최근, 오픈소스 기여 모임 9기에서 제가 오픈소스에 기여하는 과정에 대해 '오픈소스 기여로 레벨업'이라는 주제로 발표하는 기회를 가졌습니다.
발표에서 전하려 했던 것
저는 이 발표를 통해, 단순히 코드를 기여한 경험을 넘어, 다음과 같은 저의 고민과 과정을 전달하고 싶었습니다.
1. 기술적 깊이를 더하기
하나의 이슈를 해결하는 것을 넘어, '왜 이 코드가 이렇게 작성되었을까?'라는 질문을 통해 코드의 숨은 의도, 설계 철학등의 새로운 기술적 관점까지 얻어가는 과정을 공유하고 싶었습니다.
Prisma의 Breaking Change를 해결하는 과정에서 '타입 퍼포먼스'라는 새로운 관점을 얻고 거대한 오픈소스가 감수하는 기술적 트레이드오프에 대해 깊이 고민해볼 수 있었습니다.
2. 주도적으로 가치를 만드는 경험
주어진 이슈를 넘어, 직접 이슈를 찾고 개선하며 주도적으로 가치를 만드는 경험을 나누고자 했습니다.
Gemini-CLI의 '첫 실행 시점'처럼 모든 사용자에게 영향을 줄 수 있는 지점을 공략해 실행 과정에서의 문제를 어떻게 발견하고 해결했는지에 대해 공유했습니다.
3. AI 활용
이 모든 과정에서 DeepWiki와 같은 AI 도구를 활용해 방대한 오픈소스 코드를 효율적으로 분석하고 학습한 저만의 노하우를 담아보려 했습니다.
마치며
소심한 성격 탓에 망설임도 많았지만, 발표라는 새로운 시도를 할 수 있도록 기회를 주신 오픈소스 기여 모임의 모든 참여자분들께 진심으로 감사드립니다.
처음 준비하는 발표였기에, 제가 의도했던 이런 고민과 과정이 잘 전달되었을지는 모르겠습니다.
많이 떨리고 부족했지만, 끝까지 귀 기울여 들어주신 모든 분들 덕분에 무사히 마칠 수 있었습니다. 정말 감사합니다.
최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코드 사용법을 정리하려고 합니다.
(현 시점에서, 공식 문서에 정확히 기술되어있는 내용들을 바탕으로 유추한 내용도 있습니다.)
이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션이라는 세 마리 토끼를 한 번에 잡을 수 있는 메모리 최적화 사용법에 대해 이야기해보려 합니다.
왜 메모리 최적화가 필요한가요?
본격적인 방법에 앞서, 우리가 왜 Claude의 메모리를 신경 써야 하는지 정리해볼 필요가 있습니다.
OOM(Out of Memory)으로 인한 프로세스 종료: Claude Code의 대화 세션은 단일 프로세스로 동작합니다. 즉, 대화가 길어질수록 주고받은 모든 토큰이 메모리에 누적되어 시스템의 한계를 초과하면 IDE나 터미널이 강제 종료될 수 있습니다.
의도치 않은 AutoCompact와 할루시네이션: Claude에는 메모리가 부족해지면 자동으로 대화를 요약하는 AutoCompact 기능이 있습니다. 편리해 보이지만, 이 기능이 내가 원치 않는 시점에 작동하면 중요한 컨텍스트가 소실되어 AI가 엉뚱한 답변을 하는 원인이 되기도 합니다.
비용 절약: 결국 API 사용량은 입출력(I/O) 토큰의 양에 따라 결정됩니다. 불필요한 컨텍스트를 계속 유지하는 것은 곧 비용 낭비로 이어지기 때문에, 효율적인 토큰 관리는 비용 절감의 핵심입니다.
Claude Code의 메모리 관리 매커니즘 이해하기
아래는, 머메이드를 이용해서 CLAUDE CODE의 워크플로우를 만들어봤습니다.
최적화를 위해서는 Claude가 어떻게 컨텍스트를 기억하는지 알아야 합니다. 핵심은 간단합니다.
세션은 하나의 프로세스: claude 명령어로 대화형 모드에 진입하면 하나의 세션(프로세스)이 시작됩니다.
모든 대화는 메모리에: 이 세션 내에서 오고 간 모든 질문과 답변(토큰)은 컨텍스트 유지를 위해 메모리에 계속 쌓입니다.
CLAUDE.md는 항상 로드: 세션을 시작할 때 현재 디렉토리의 CLAUDE.md 파일은 무조건 읽어와 기본 컨텍스트로 사용합니다.
결국 대화가 길어질수록 메모리에 쌓이는 토큰이 많아져 위에서 언급한 문제들이 발생하는 구조입니다. 이제 이 구조를 역이용하여 메모리를 통제하는 방법을 알아봅시다.
문서에 따르면, CLI를 실행한 디렉토리를 기준으로 상향/하향으로 CLAUDE.md를 찾아 메모리에 올려 사용합니다.
다시 말해, CLAUDE.md는 claude CLI를 통해 호출할 때 마다 읽는다는 겁니다.
그러므로, CLAUDE.md는 프로젝트 단위의, 사용자 단위의 공통 룰만 정의하고, 나머지는 각 프로젝트 별 마크다운으로 빼서 관리하는 것이 세션 내 메모리와 토큰 비용을 절약하는 효과적인 방법일 것이라고 생각합니다.
메모리 최적화를 위한 핵심 명령어 3가지
Claude Code는 메모리를 수동으로 관리할 수 있는 강력한 명령어들을 제공합니다. 이 세 가지만 기억하면 충분합니다.
/context: 현재 세션의 '메모리 대시보드'입니다. 이 명령어를 입력하면 현재 컨텍스트가 사용 중인 토큰의 양과 비율을 확인할 수 있습니다. 내비게이션의 지도처럼, 현재 상태를 파악하는 데 필수적입니다.
/clear (또는 /reset): 세션을 초기화하는 '하드 리셋' 버튼입니다. 대화 기록과 컨텍스트가 모두 사라지고, CLAUDE.md를 처음부터 다시 로드한 상태가 됩니다. 완전히 새로운 작업을 시작할 때 유용합니다.
/compact {지시문}: 세션을 압축하는 '스마트 요약' 기능입니다. 단순히 기록을 지우는 것이 아니라, "지금까지의 논의를 바탕으로 핵심 내용을 요약해줘" 와 같은 지시를 통해 대화의 맥락은 유지하면서 토큰 사용량을 획기적으로 줄여줍니다.
현재 제가 사용중인 방식
이제 위 명령어들을 조합하여 제가 지금 시점에 사용하는 클로드 코드 방식을 소개하려고 합니다.
정답은 없지만, 이 흐름을 따른 후 OOM으로 인한 중단이 사라졌고 토큰 사용량도 눈에 띄게 줄었습니다.
핵심: AutoCompact는 끄고, 수동으로 관리하여 워크플로우의 제어권을 가져온다.
자연스러운 중단점 활용: 코딩 작업의 흐름을 Git 워크플로우에 비유해봅시다.
Commit 단위로는 /compact: 특정 기능 개발이나 버그 수정 등 작은 작업 단위를 끝냈을 때, /compact를 사용해 "현재까지 작업한 내용을 요약하고 다음 작업을 준비해줘"라고 지시합니다. 이렇게 하면 컨텍스트는 유지하면서 메모리를 확보할 수 있습니다.
Branch 단위로는 /clear: 하나의 브랜치에서 다른 브랜치로 넘어가는 것처럼, 완전히 다른 주제의 작업을 시작할 때는 /clear를 사용해 컨텍스트를 완전히 비워줍니다. 이전 작업의 컨텍스트가 새 작업에 영향을 주는 것을 막고 메모리를 최적으로 관리할 수 있습니다.
# (작업 중...) 기능 A 개발 완료 후
# 1. 현재 토큰 사용량 확인
/context
# 2. 컨텍스트 요약으로 메모리 확보
/compact 지금까지 논의한 feature-A의 핵심 로직과 구현 내용을 정리해줘.
# (다른 작업 시작 전...)
# 3. 새로운 feature-B 작업을 위해 세션 초기화
/clear
이처럼 작업의 흐름에 맞춰 compact와 clear를 전략적으로 사용하면, AI의 AutoCompact에 의해 작업 흐름이 끊기는 안티 패턴을 방지하고 메모리와 토큰 사용량을 모두 최적화할 수 있습니다.
어떻게 프롬프팅을 해야 할까?
커서맛피아님의 레퍼런스를 정리한 하조은님의 유튜브 영상 일부 발췌
포스팅을 작성하고있는 오늘, 당근에서 개발자로 계신 하조은님의 유튜브 영상을 보다가, 좋은 내용이 있어서 가져왔습니다.
항상, 하지말아야 할 것들을 CLAUDE.md에 정의하는 것에 그쳤었는데, 가끔씩 할루시네이션이 발생했던 것을 몸소 체험한 바 있습니다.
영상에서 정리해준 Constraint부분을 특히 프롬프팅 단위로도 잘 정의해야할 것 같습니다.
마무리
여기까지, 2달 남짓 클로드를 사용하면서 AutoCompact와 OOM 문제 때문에 불필요하게 토큰을 많이 사용하던 어느 주니어 개발자의 이야기었습니다.
Claude Code는 강력한 도구지만, 그 성능을 제대로 이끌어내기 위해서는 내부 동작 방식을 이해하고 사용자가 직접 제어하려는 노력이 필요합니다.
이 중, gemini-cli는 현재 AI를 다루는 능력이 거의 필수 스택으로 자리잡았고, 저에게 가장 친숙한 TypeScript기반이라는 점, 마지막으로 내가 구글에 기여할 수 있다니!!! 와 같은 이유로 기여하기로 했는데요 ㅋㅋ..
그래서, 이번 포스팅은 gemini-cli의 기여에 대한 포스팅입니다.
Gemini-CLI
현 시점 CLI 기반 AI의 양대산맥이라고 한다면, claude code와 gemini-cli가 대표적인데요.
여러 차이가 있겠지만, 오픈소스 성격을 띠는지? 의 차이도 있는 것 같아요.
특히, gemini-cli의 경우 공개적인 로드맵까지 작성되어있어, 관심 있는 이슈를 직접 기여해볼 수 있도록 기여자들의 참여를 적극 장려하고 있는 상황입니다.
저는 오늘, 9개월 전 첫 오픈소스 기여를 시작하면서 막연하게 꿈꿔왔던 목표인
직접 이슈를 발견해서 등록하고, 해결해보기
를 달성하고, 더불어 모든 사용자에게 영향을 줄 수 있었던 이슈를 발견하고,
과정에서 AI를 활용하며 기여했던 과정 전반의 경험을 공유드리려고 합니다.
어떻게 이슈를 발견할까?
오픈소스의 코드는 방대합니다. 주당 몇 만자씩 추가되는 이 방대한 오픈소스에서, 어떻게 이슈를 발견해야할까요? 마냥 하나하나 파일을 분석하기에는, 너무 비효율적입니다.
저는 효과적으로 분석하기 위해 우선 UX의 흐름을 생각해보기로 했습니다.
gemini-cli설치하기
터미널에 "gemini" 명령어 실행하기
질문하고 응답받고의 반복
방대한 코드를, UX의 흐름으로 재정의하고보니 엄청 단순해졌습니다.
저는 이 흐름에서 명령어를 실행한 후 interactive mode(대화형 모드)가 생성되기 전까지의 동작을 확인해보기로 결정했습니다.
즉, 초기화 단계를 중점으로 파헤쳐보기로 한 것이죠.
AI와 함께 분석하기
잘 짜여진 코드 덕분인지 초기화 단계는 금방 찾을 수 있었습니다. 하지만 가시성이 아무리 좋고 좋은 구조로 짜여져 있다고 하더라도, 처음 보는 프로젝트의 모든 상호작용을 파악하면서 정확히 어떤 과정들을 수행하는지 한 눈에 알기는 어려웠습니다.
이 때, 시간 낭비를 줄이기 위해 저는 AI를 활용했습니다.
처음에는 할루시네이션 때문에 오픈소스에서 AI를 활용하는 것은 바람직한가? 라는 의문을 품었습니다.
하지만 부정할 수 없는 사실은 AI는 이미, 어쩌면 처음 등장했을 때 부터 제가 알고있는 프로그래밍 지식보다 훨씬 많은 것을 알고 있다는 것을요. 그리고 AI 활용법에 관한 많은 레퍼런스들에서 공통으로 얘기하듯, AI에게 판단을 맡기지 않고 사실 기반으로만 동작하게 한다면 AI는 최고의 동료가 될 것이라는 것을요.
저는 그래서, 짜여진 코드라는 사실에 기반하여 초기화 과정 자체를 분석하는 일을 AI에게 맡겼습니다.
DeepWiki: AI 기반 깃허브 저장소 위키
DeepWiki는 Devin AI를 만든 Cognition에서 만든 위키 형태의 AI 입니다.
깃허브 저장소를 위키 형태로 변환하여 볼 수 있게 해주는 도구로, 오픈소스의 핵심 아키텍처나 오픈소스 내의 피처들이 어떤 흐름으로 동작하는지 등 오픈소스의 전반적인 것들을 분석해주는 도구입니다.
위 사진은, 애플리케이션의 아키텍처를 DeepWiki가 분석해준 워크플로우이고, 빨간 네모 박스는 실행 시 초기화 과정 전반의 동작들입니다. 이를 기반으로 각 함수들을 하나씩 분석해 본 결과, 단순히 사용자의 세팅을 불러오는 구간들을 배제할 수 있었습니다.
작업할 구간을 좁히고 좁히다보니, memoryDiscovery라는 파일에서 퍼포먼스 개선이 가능해보이는 코드를 발견할 수 있었습니다.
memoryDiscovery는 초기화 시 GEMINI-CLI가 호출된 디렉토리를 기준으로, 상/하향 디렉토리들을 순차적으로 확인하여 GEMINI.md가 있는 경로를 수집합니다. 수집이 완료되면, 수집된 경로에서 순차적으로 마크다운 파일을 처리하여 메모리에 올려 사용합니다.
이 과정을 간단하게 도식화해본다면, 아래와 같은 형태입니다.
모든 디렉토리/파일에 대해 순차적인 처리를 수행하기 때문에, 프로젝트가 커지면 문제가 발생하겠다고 생각했고, 이 구간에 대해 작업을 시작했습니다.
Claude Code: 교차 검증
현재 AI는 아시다시피 할루시네이션이 엄청 심합니다.
DeepWiki를 통해 사실 기반의 워크플로우를 추론할 수 있었지만, 사실 확인이 한 번 더 필요하다고 판단했습니다.
이를 위해, GEMINI-CLI를 로컬에 클론시킨 뒤, CLAUDE CODE를 통해서 해당 작업 구간을 다시 한 번 재확인했습니다.
PR 생성
작업 구간이 명확해졌으니, 코드 작업을 해야겠죠?
제가 작업한 최종 코드는 다음과 같습니다.
기존 순차처리를 병렬로 변경
EMFILE 에러 방지를 위한 동시성 제한 추가
Promise.allSettled 사용으로 문제 발생 시에도 안정적인 파일 처리
PR을 생성하는 과정에서, Promise.allSettled는 gemini가 직접 제안해준 리뷰 내용에 포함되어 있어 추가하였습니다.
(뭔가 제가 만든 요리를 레시피 원작자가 직접 평가하는 기분이네요)
결과
생각보다 금방 머지가되어, GEMINI-CLI의 컨트리뷰터가 될 수 있었습니다.
PR 과정에서 얼마나 개선될까 싶어 벤치마크 테스트를 PR의 커밋에 추가했었는데요,
아래 코드에서 알 수 있듯이, 큰 규모의 테스트는 아니지만 약 60% 가량의 퍼포먼스 향상을 이루어낼 수 있었습니다.
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { tmpdir } from 'os';
import { loadServerHierarchicalMemory } from './memoryDiscovery.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
// Helper to create test content
function createTestContent(index: number): string {
return `# GEMINI Configuration ${index}
## Project Instructions
This is test content for performance benchmarking.
The content should be substantial enough to simulate real-world usage.
### Code Style Guidelines
- Use TypeScript for type safety
- Follow functional programming patterns
- Maintain high test coverage
- Keep functions pure when possible
### Architecture Principles
- Modular design with clear boundaries
- Clean separation of concerns
- Efficient resource usage
- Scalable and maintainable codebase
### Development Guidelines
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
`.repeat(3); // Make content substantial
}
// Sequential implementation for comparison
async function readFilesSequential(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const results = [];
for (const filePath of filePaths) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
results.push({ path: filePath, content: processedResult.content });
} catch {
results.push({ path: filePath, content: null });
}
}
return results;
}
// Parallel implementation
async function readFilesParallel(
filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
const promises = filePaths.map(async (filePath) => {
try {
const content = await fs.readFile(filePath, 'utf-8');
const processedResult = await processImports(
content,
path.dirname(filePath),
false,
undefined,
undefined,
'flat',
);
return { path: filePath, content: processedResult.content };
} catch {
return { path: filePath, content: null };
}
});
return Promise.all(promises);
}
describe('memoryDiscovery performance', () => {
let testDir: string;
let fileService: FileDiscoveryService;
beforeEach(async () => {
testDir = path.join(tmpdir(), `memoryDiscovery-perf-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
fileService = new FileDiscoveryService(testDir);
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should demonstrate significant performance improvement with parallel processing', async () => {
// Create test structure
const numFiles = 20;
const filePaths: string[] = [];
for (let i = 0; i < numFiles; i++) {
const dirPath = path.join(testDir, `project-${i}`);
await fs.mkdir(dirPath, { recursive: true });
const filePath = path.join(dirPath, 'GEMINI.md');
await fs.writeFile(filePath, createTestContent(i));
filePaths.push(filePath);
}
// Measure sequential processing
const seqStart = performance.now();
const seqResults = await readFilesSequential(filePaths);
const seqTime = performance.now() - seqStart;
// Measure parallel processing
const parStart = performance.now();
const parResults = await readFilesParallel(filePaths);
const parTime = performance.now() - parStart;
// Verify results are equivalent
expect(seqResults.length).toBe(parResults.length);
expect(seqResults.length).toBe(numFiles);
// Verify parallel is faster
expect(parTime).toBeLessThan(seqTime);
// Calculate improvement
const improvement = ((seqTime - parTime) / seqTime) * 100;
const speedup = seqTime / parTime;
// Log results for visibility
console.log(`\n Performance Results (${numFiles} files):`);
console.log(` Sequential: ${seqTime.toFixed(2)}ms`);
console.log(` Parallel: ${parTime.toFixed(2)}ms`);
console.log(` Improvement: ${improvement.toFixed(1)}%`);
console.log(` Speedup: ${speedup.toFixed(2)}x\n`);
// Expect significant improvement
expect(improvement).toBeGreaterThan(50); // At least 50% improvement
});
it('should handle the actual loadServerHierarchicalMemory function efficiently', async () => {
// Create multiple directories with GEMINI.md files
const dirs: string[] = [];
const numDirs = 10;
for (let i = 0; i < numDirs; i++) {
const dirPath = path.join(testDir, `workspace-${i}`);
await fs.mkdir(dirPath, { recursive: true });
dirs.push(dirPath);
// Create GEMINI.md file
const content = createTestContent(i);
await fs.writeFile(path.join(dirPath, 'GEMINI.md'), content);
// Create nested structure
const nestedPath = path.join(dirPath, 'src', 'components');
await fs.mkdir(nestedPath, { recursive: true });
await fs.writeFile(path.join(nestedPath, 'GEMINI.md'), content);
}
// Measure performance
const startTime = performance.now();
const result = await loadServerHierarchicalMemory(
dirs[0],
dirs.slice(1),
false, // debugMode
fileService,
[], // extensionContextFilePaths
'flat', // importFormat
undefined, // fileFilteringOptions
200, // maxDirs
);
const duration = performance.now() - startTime;
// Verify results
expect(result.fileCount).toBeGreaterThan(0);
expect(result.memoryContent).toBeTruthy();
// Log performance
console.log(`\n Real-world Performance:`);
console.log(
` Processed ${result.fileCount} files in ${duration.toFixed(2)}ms`,
);
console.log(
` Rate: ${(result.fileCount / (duration / 1000)).toFixed(2)} files/second\n`,
);
// Performance should be reasonable
expect(duration).toBeLessThan(1000); // Should complete within 1 second
});
});
마무리
최근 오픈소스 기여를 위해 DeepWiki를 적극 활용하고 있는데요,
위에서도 잠깐 말씀드렸듯이, 사실 기반(=코드베이스 자체만 분석)으로 AI를 활용한다면 엄청난 퍼포먼스를 보이는 것 같습니다.
오픈소스 기여에는 항상 이슈를 해결하기 위한 분석에서 가장 많은 시간을 잡아먹었었는데요, 작업을 위한 분석 구간을 명확하게 좁혀주는 용도로만 사용했지만 거의 PR 생성 속도가 10배 가까이 단축된 것 같아요.
이번 기여에서는, 특히 전 세계 수많은 사용자들의 시간을 매일 조금씩 아껴주었다는 생각에 매우 뿌듯한 경험이었습니다.
모든 gemini-cli의 사용자들이 기존 대비 60%이상 향상된 퍼포먼스를 제 코드를 통해 제공받을 수 있다는 점이 정말 황홀한 것 같아요.
cd ~/Desktop/company-docker
docker compose up -d
docker compose run --rm claude-work claude login
## 성공!!
# 1) Claude account with subscription
# 2) Anthropic Console account
도커 컨테이너를 띄워서, 로그인을 시키고 세팅을 완료했습니다.
확인해보면, 개인 계정(위)과 회사 계정의 기본 세팅이 다른 것을 볼 수 있죠.
ccusage도 잘 동작합니다. 위 사진이 개인 계정(내용이 많아서 짤랐습니다.), 아래가 회사 계정이에요.
도커로 띄워야하나? 로컬로 변경
하필 다 만들고 보니 도커는 기본 메모리 사용량이 엄청 높아서, 굳이 도커로 사용해야하나 라는 생각이 들었습니다. (진작 좀 생각하지 ㅡㅡ)
오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니다.
제가 처음으로 Prisma 생성한 이번 PR은 6.14.0에 반영된 Prisma의 퍼포먼스 개선에서 발생한 이슈를 해결한 내용으로, 퍼포먼스 개선 PR을 분석해보고, 제 PR이 이전 PR의 어떤 문제를 해결했는지의 순서로 작성되었습니다.
문제 원인: 타입 퍼포먼스 개선
6.14.0 릴리즈 노트 안에는, IDE에서 타입 추론에 대한 성능 개선의 내용도 있었습니다.
벤치마크 테스트 코드를 보면, 타입 체킹이 약 1300만회에서 1000회 수준으로 엄청난 퍼포먼스 개선이 있었습니다.
하지만 이 변경사항으로 6.12.0에 Preview로 등장한 ESM 호환 Generator를 사용할 때, 기존 처럼 PrismaClient를 사용할 수 없게 되었습니다.
PR에서, 타입 퍼포먼스 개선을 위해 변경된 주요 코드 PrismaClient 생성자 인터페이스고, 변경 사항은 다음과 같습니다.
// <=6.13.0: ClientOptions와 로그 옵션 제네릭에 기본값이 있었다.
export interface PrismaClientConstructor {
new <
ClientOptions extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
U = LogOptions<ClientOptions>,
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Prisma.Subset<ClientOptions, Prisma.PrismaClientOptions>):
PrismaClient<ClientOptions, U, ExtArgs>
}
// >=6.14.0: 타입 체크 성능을 높이기 위해 개선
// 1. 기존의 ClientOptions을 분리 (기존의 Options, LogOpts, OmitOpts)
// 2. ⭐️ PrismaClientOptions, LogOptions에 타입 기본값이 제거됨 ⭐️
export interface PrismaClientConstructor {
new <
Options extends Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Options): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}
아래는, 6.14.0에 반영된 타입 퍼포먼스 개선에 대한 PR과, 개선의 이유를 정리해봤습니다.
타입 퍼포먼스 개선의 이유
1. 문제 원인
제가 Prisma의 의도를 정확히 파악할 수는 없겠으나, TypeScript이 타입을 추론하기 위해 동작하는 방식과 관계가 있습니다.
복잡한 제네릭과 교차 타입의 집합의 최상위 집합 타입이 타입 추론 성능 저하의 원인이 된 것이죠.
type PrismaClientOptions = {
log?: LogLevel[] | LogDefinition[]
datasourceUrl?: string
omit?: {
[ModelName: string]: {
[FieldName: string]: boolean
}
}
}
// <=6.13.0: 하나의 제네릭
type OldPrismaClient<ClientOptions extends PrismaClientOptions> = {
user: UserDelegate<ClientOptions>
post: PostDelegate<ClientOptions>
// schema.prisma에 정의한 여러 모델들.
}
type UserDelegate<ClientOptions> = {
findMany: (args?: UserFindManyArgs<ClientOptions>) => Promise<User[]>
findFirst: (args?: UserFindFirstArgs<ClientOptions>) => Promise<User | null>
create: (args: UserCreateArgs<ClientOptions>) => Promise<User>
// Prisma에서 지원하는 수많은 인터페이스들
}
type UserFindManyArgs<ClientOptions> = {
where?: UserWhereInput
select?: UserSelect<ClientOptions>
include?: UserInclude<ClientOptions>
omit?: ExtractOmit<ClientOptions, 'user'> // 재귀적으로 Omit하여 Payload 생성
}
type ExtractOmit<ClientOptions, Model> =
ClientOptions extends { omit: infer O }
? O extends { [K in Model]: infer Fields }
? Fields
: never
: never
우리는 PrismaClient 인스턴스를 생성하는 데서 끝이지만, 내부적으로 TypeScript는 거대한 하나의 제네릭 타입을 통해 아래와 같이 타입을 추론합니다. 현재 Nest를 사용하고 있기 때문에, Nest로 예를 들어보겠습니다.
일반적으로 Nest에서 PrimsaClient는 다음과 같은 방식으로 사용됩니다.
// 1. PrismaClient를 상속한 DB Connector Service (NestJS 공식문서 예제)
class PrismaService extends PrismaClient { // 상속 시 타입 체크
constructor() {
super({ log: ['query'] })
}
}
// 2.의존성 주입으로 사용.
class UserService {
constructor(private prisma: PrismaClient) {} // 파라미터 타입 체크
}
// 3. 트랜잭션을 위한 유틸리티 함수
async function withTransaction(
prisma: PrismaClient, // 파라미터 타입 체크
callback: (tx: PrismaClient) => Promise<void>
) {
}
// 4. 혹은 일반적인 타입 명시
let myClient: PrismaClient;
myClient = new PrismaClient({ log: ['query'] });
TypeScript는 덕 타이핑을 통해 타입 호환성을 체크합니다.
이 때, 하나의 거대한 제네릭인 ClientOptions가 모든 곳에 전파되어 모든 모델의 메서드가 ClientOptions에 의존하는 상황이 만들어집니다. 덕 타이핑 때문에, 모든 ClientOptions가 있는 생성된 프리즈마 코드들을 돌면서, 구조 전체를 체크해야만 했습니다.
간단하게 다시 설명해보겠습니다.
model User {
id Int @id @default(autoincrement())
name String
}
위처럼 모델이 user 하나만 있다고 가정해도, 타입 호환성의 체크는 다음과 같이 이루어질 것으로 예상됩니다.
class PrismaService extends PrismaClient {
constructor() {
super({ log: ['query'] })
}
}
// TS는 PrismaService의 인스턴스가 PrismaClient와 호환되는지 체크합니다.
// 프로퍼티, 함수 시그니처, 파라미터 등.
// 프로퍼티 체크
// PrismaService는 PrismaClient의 모든 프로퍼티를 가지는가?
// user: UserDelegate
// $connect(): Promise<void>
// $disconnect(): Promise<void>
// ... 수십 개의 프로퍼티와 메서드
// 메서드 시그니처 체크
// (args?: UserFindManyArgs<{ log: ['query'] }>) => Promise<User[]>
// ↑ ClientOptions 전체가 전파
// 메서드 파라미터의 깊은 체크
// UserFindManyArgs의 모든 프로퍼티 체크
// - where?: UserWhereInput
// - select?: UserSelect<{ log: ['query'] }>
// - include?: UserInclude<{ log: ['query'] }>
// - omit?: ExtractOmit<{ log: ['query'] }, 'user'>
// 중첩된 타입들의 재귀적 체크
// select의 각 필드, include의 관계들 등등....
// 이 과정이 모든 모델, 모든 메서드에 대해 반복됩니다.
모델이 커지면 커질수록, 모델의 필드가 많아질수록 비례해서 증가하게 되겠죠. 이는 Prisma의 벤치마크 코드에도 잘 드러나있습니다.
위에서 정리한 내용을 바탕으로, 이 문제의 원인은 ClientOptions라는 거대한 하나의 타입만 사용했기 때문입니다.
그래서 Prisma에서는 아래와 같이 타입들을 목적별로 분리한 것 같아요.
LogOpts: $on() 메서드에만 영향을 주는 로그 옵션들
OmitOpts: 모델 메서드에만 영향을 주는 옵션들
이를 통해 불필요한 타입 전파를 차단하려고 하는 시도였다고 볼 수 있을 것 같습니다. 기대 효과는, 변경된 벤치마크 코드에서도 볼 수 있다시피 엄청난 개선이 되었습니다. 위의 로그 옵션에 대한 벤치마크 코드를 다시 볼까요?
bench('log config applied', () => {
const client = new PrismaClientConstructor({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
})
const passClientAround = (prisma: PrismaClient) => {
// @ts-expect-error - using a non-existent event type is a type error
prisma.$on('foobarbaz', (event) => {
console.log(event)
})
return prisma
}
const passToAnyClientAround = (prisma: PrismaClient<any>) => {
prisma.$on('info', (event) => {
console.log(event)
})
return prisma
}
client.$on('query', (event) => {
console.log(event)
})
// @ts-expect-error - info is not a valid event type because we do not pass it in the client options
client.$on('info', (event) => {
console.log(event)
})
passClientAround(client)
passToAnyClientAround(client)
}).types([697, 'instantiations']) // 1300만 > 697
타입 세분화를 통해 로그 옵션에 대한 타입 호환성을 체크하다보니, 같은 모델을 정의했다고 하더라도 약 1300만 회에서 700회 정도로 말도 안되는 수치로 감소했던 것을 볼 수 있습니다.
breaking changes 해결하기
다시 제 기여 얘기로 돌아와서, 위 변경사항 때문에 6.14.0으로 업데이트를 했을 때 PrismaClient를 사용하는 거의 모든 코드에서 빨간 줄이 등장(?)하게 되었습니다.
(6.12.0에 도입된 ESM-Generator을 사용할 때 발생하며, 기존의 js Generator은 이상 없는 것으로 확인했습니다.)
NestJS 기준의 코드 예시인데요, 어떤 프레임워크든 간에 Prisma를 사용하는 많은 개발자들이 PrismaClient에 제네릭을 명시하지 않고 사용했기 때문에 위 변경 사항은 breaking changes가 될 수 밖에 없습니다. 모든 PrismaClient관련 코드를 수정해야했으니까요. 아래처럼요.
PR 내용만 보면 별 거 없는 딱 두 줄의 추가인데요. 저는 기존 퍼포먼스 향상을 유지하면서도 많은 개발자들이 코드를 수정하지 않고도 기존 코드 그대로 사용이 가능하도록, PrismaClient에 디폴트 타입을 추가해줬습니다.
이렇게 해서, 바로 PR이 merge가 되었고, 제 PR만 단독으로 머지된 탓에, 아마 Latest Commit에 제 프로필 사진이 올라가지 않았나 싶네요.
정리
Prisma를 거의 처음 사용해보면서, 오픈소스 기여를 위해 관련 코드를 깊이 파헤쳐보고 궁금증이 생겨 Prisma가 왜 Type-Safe한 ORM인지까지 돌아봤습니다. 물론 프리즈마 엔진 코드가 생소한 Rust이고, Prisma에 익숙하지 않아 분석이 다소 완벽하지 않았네요.
단 한 줄, 두 줄의 코드 변경으로 대다수의 개발자들에게 breaking changes 없이 확장된 기능, 더 좋은 퍼포먼스를 제공할 수 있는 기여를 했다는 생각에 현재까지 기여 중에 코드 길이 대비 가장 뿌듯했던 기여 순간이었던 것 같습니다. 더불어 TypeScript와도 조금 더 친해지는 계기가 되었던 것 같아요.
오픈소스 기여는 이렇게 단 한줄의 변경으로 수 억명의 사람들에게 임팩트를 줄 수 있고, 더불어 사용하고 있는 기술에 대한 깊은 이해, 기술의 기반이 되는 더 깊이 있는 지식까지도 습득할 수 있는 좋은 기회인 것 같습니다. 앞으로도 여기저기 사용하는 기술들에 대해 관심 있게 둘러 볼 예정입니다.
오픈 소스 기여에 어려움을 겪고 계신 분들이 있다면, 인제님이 운영하시는 오픈소스 기여 모임에 참여해보시는 것은 어떨까요?
다양한 분야에서 여러 기여를 하신 운영진분들과 참여자분들과 소통하면서, 이슈 선정부터 PR 기여까지 많은 도움을 얻을 수 있습니다!
제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠)
이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.
TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다.
Prisma
Prisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다.(prisma generate)
https://www.prisma.io/typescript
그래서일까요? 많은 레퍼런스들에서도 Type-Safe하다고 언급하고, 공식문서에서도 fully type-safe, zero-cost type-safe하다고 명시되어있습니다. 이쪽 진영(?)의 ORM이 익숙하지 않으신 분들을 위해 예시를 준비했습니다.
비교 대상은이름부터 엄청나게 type-safe할 것 같은 TypeORM입니다.
TypeORM vs Prisma
class UserEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@Column()
name!: string;
}
async getName(id: number) {
const user = await this.userRepostiory.findOne({
select: ['name'],
where: { id }
});
return user;
}
코드를 통해 getName()의 리턴 타입이 Pick<UserEntity, 'name'>일 것으로 예상됩니다.
하지만 TypeORM은 Entity 자체를 타입으로 추론합니다. 엉뚱하죠? 실제 동작과 타입 추론이 어긋납니다.
getName()에서 user를 할당한 후, user.id에 접근도 잘 됩니다. IDE도 올바르게 추적해주고, 경고나 에러도 발생시키지 않죠.
물론, getName을 호출하는 함수의 리턴타입을 강제하는 방법도 있습니다만, 우리는 그걸 원치 않습니다.
물론, TypeORM을 사용하면서 전역으로 타입을 래핑하는 인터페이스를 만들 수도 있습니다만, 우리는 그걸 원치 않습니다.
오픈소스를 사용하는 이유는 다양하지만, 편리하고 다양한 기능을 제공받아 사용하는 이유도 있으니까요. 결국 개발자가 직접 래핑해야 하는 번거로움이 생기죠 (귀찮아요 ㅡㅡ (?))
Prisma는 어떨까요?
model User {
id Int @id @default(autoincrement())
name String
}
class PrismaService extends PrismaClient {
async getUserName(id: number) {
return await this.user.findFirst({
where: { id },
select: {
name: true,
},
});
}
}
Prisma에서 같은 쿼리를 실행시켜보면, 결과는 정확하게 select된 프로퍼티들만 타입으로 추론합니다.
TypeORM의 타입 추론
간략한 이유는 다음과 같습니다.
TypeORM은, 테이블 모델 설계 문서가 아닌 실제 클래스 형태로 엔터티를 작성합니다. 그리고 Repository Interface 사용을 위해 TypeORM의 Repository 클래스의 제네릭으로 엔터티 타입을 명시하여 사용하죠. Repository의 각 인터페이스들은 이 Entity 제네릭 타입을 받아서 타입 추론을 하도록 구성되어있습니다. 아래처럼요.
const userRepository = new Repository<UserEntity>();
class Repository<Entity extends ObjectLiteral> {
findOne(options?: FindOneOptions<Entity>): Promise<Entity | undefined>;
}
ORM에서 FindOptions의 select의 키인 keyof Entity로 타입 추론을 해주지 않는 이상, 위에서 말한 것 처럼 개발자가 직접 타입 좁히기를 통해 type-safe하게 만들어서 사용해야합니다.
Prisma의 타입 추론
반면에 Prisma는 다르죠. Prisma는 스키마 기반 코드 생성 ORM입니다.
런타임 전에 schema.prisma를 해석해서(prisma generate) @prisma/client를 만들어 둡니다. 그래서 쿼리 작성 시점부터 타입이 이미 결정되어 있고, select/include에 따라 반환 타입이 정교하게 내로잉됩니다.
코드를 보면서 간략하게 타입을 만들어내는 파이프라인을 요약해봤습니다.
schema.prisma > Rust/WASM 파서 > DMMF JSON > DMMF Helper 클래스 > TSClient 코드 생성 > 파일 출력(@prisma/client로)
각 과정들을 조금 더 자세히 보도록 하죠.
1. prisma generate
npx prisma generate와 같은 명령어를 사용해보셨을거에요. Prisma의 타입 생성은 이 명령어로 시작됩니다.
이 명령은 CLI 내부의 Generate.ts에서 실행 흐름이 잡히고, 이후 각 generator을 초기화하고 실행시킵니다.
// packages/cli/src/Generate.ts
export class Generate implements Command {
async execute() {
// generator 등록 및 실행 흐름 담당
}
}
이 때, getGenerators 함수가 팩토리 역할을 수행하고, schema.prisma의 generator client { ... } 설정을 읽고 @prisma/client 생성기를 찾아 초기화합니다.
2. Schema 파싱 및 가공
schema.prisma를 중간 포맷인 DMMF(Data Model Meta Format)으로 파싱합니다.
이 과정은 Rust로 구현된 프리즈마 엔진의 파서를 WASM(WebAssembly)로 컴파일해서 NodeJS에서 실행합니다.
직접 wasm이 구현되어 있는 Rust코드를 당장에는 이해할 수 없어, 축약하자면 getDMMF를 통해 스키마가 해석되고, 결과는 JSON 형태의 DMMF로 변환됩니다. 이 JSON형태 위에 모델, 필드, 연관 관계등을 쉽게 탐색할 수 있도록 DMMF 클래스로 래핑되고, 모델/타입/연산을 빠르게 조회할 수 있는 Map이 만들어집니다.
3. TypeScript 코드 생성(PrismaClient)
이 과정 후에, PrismaClientTsGenerator에 의해 Prisma Client 코드를 생성하는데요.
Model: 스키마의 row에 대한 기본 스칼라가 typescript 인터페이스로 정의된 타입.
Output: 집계/그룹핑 처럼 형태가 확정적인 연산 결과는 AggregateUser, UserGroupByOutputType같은 명시적 Output 타입으로 노출돼요. row 관점의 원형 출력은 별도 파일/타입명으로 고정되어있지 않고 아래 Payload가 그 역할을 수행합니다.
Payload: 기본 정의된 Payload를 기반으로 제네릭 타입이 들어오면, 조건부/매핑 유틸리티 타입이 이를 해석해서 Payload를 즉석해서 추론합니다.
const user = await this.user.findFirst({
where: { id },
select: {
name: true,
},
});
>>
findFirst<T extends UserFindFirstArgs>(
args?: SelectSubset<T, UserFindFirstArgs<ExtArgs>>,
): Prisma__UserClient<
$Result.GetResult<
Prisma.$UserPayload<ExtArgs>, // 모델 전체의 스칼라 정의
T, // args의 타입 ( { select: { name: true }}
'findFirst',
GlobalOmitOptions
> | null,
null,
ExtArgs,
GlobalOmitOptions
>;
여기서 일어나는 타입 계산을 단계별로 풀어보면
T 캡처: args 타입인 ({ select: { name: ture } })가 그대로 T로 캡처됩니다.
유효성 검사: SelectSubset에 의해 허용되는 옵션만 통과시키도록 컴파일 타임에 필터링돼요.
반환 타입 계산: 내부의 $Result.GetResult가 $UserPayload에서 T.select를 읽어 name만 뽑아 { name: string } 객체를 만듭니다. findFirst 특성상 결과가 없을 수 있기 때문에 | null이 붙게 돼요.
결과적으로, user.id에 접근하면 IDE에서 바로 타입 에러가 발생하는 메시지가 보이게 되는거죠.
정리
사실 한 번에 뜯어보고 이해를 온전히 할 수 없었어요.
제가 많은 오픈소스를 보지는 않았지만, TS기반의 오픈소스 중 가장 해석하기 힘들었습니다. 하지만 이런 파이프라인을 통해 왜 type-safe하다는 건지 어느정도 실마리를 잡은 것 같아요. 다음엔, 이렇게 Prisma를 뜯어보게 된 원인이 되었던 Prisma 6.14.0 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.