(326)

NodeJS v25의 변경사항을 알아보자

NodeJS v25 Release Node.js — Node.js v25.0.0 (Current)Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.nodejs.org Node 25버전이 며칠 전, 25년 10월 15일에 릴리즈되었습니다. 자세한 변경 사항들은 위 공식 블로그에서, 커밋들을 확인해보시면 됩니다. V8 14.1 적용기존 24버전의 Node에서는 13.6버전의 V8 엔진을 사용했습니다. 메이저 버전의 업데이트에 따라 V8을 14.1로 업데이트하였습니다.아래는 V8 ..

MyISAM에서 트랜잭션 사용하기

서론MyISAM에서는 트랜잭션을 사용할 수 있을까요? "아뇨, 사용할 수 없습니다." 엥??? 그럼 제목은 어그로임??? 아쉽게도 MyISAM은 트랜잭션을 지원하지 않습니다. MySQL 공식 문서에서도 분명 명시되어있습니다. 더 아쉽게도, 저는 현업에서 아직도 MyISAM 엔진을 사용중이며, InnoDB로의 마이그레이션이 불가능한 상황입니다. 협업 과정에서 데이터베이스 엔진의 마이그레이션을 하지 않으면 안되겠냐는 얘기를 들었고, 마이그레이션이 불가능한 원인을 소통 과정에서 짐작해보자면 기술적인 문제보다는 현재 잘 동작하기에, 그리고 변경에 대한 불안감 등의 심리적인 요인일 듯 합니다. 이런 상황을 베이스로 실제 발생했던 아래 문제들과데이터 정합성이 맞지 않음(CS로 이어짐): 주문은 했는데 캐시..

Claude Code, OOM과 할루시네이션 없이 똑똑하게 사용하는 메모리 최적화 전략

최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코..

JavaScript 객체는 해시 테이블이 아닌가? – V8의 Hidden Class와 Inline Caching

최근 면접 이야기최근 기술면접에서 다음과 같은 질문을 받았다. "JS에서 Array에 모든 데이터를 Array에 넣고, find()로 찾으면 되지, 왜 굳이 객체를 사용할까요?" 이 질문에 대해 "find()는 O(N)이지만, 객체는 프로퍼티를 통해 값을 조회할 수 있어서 O(1)이기 때문에 사용한다고 생각합니다." 라고 대답했고, 다음과 같은 꼬리질문들이 이어지기 시작했다.객체는 어떻게 값을 저장하길래 O(1)인가요?객체는 값을 가져올 때 항상 O(1)인가요? 정말인가요? 최근 학습했던 JS와 V8의 메모리 구조와 관리 내용을 기반으로 알고 있던 지식을 버무려 답하고자 했다. 생각하는 시간을 가질수록 머리가 하얘져서, 객체의 프로퍼티 - 값 형태에 집착했고, 해시 구조로 저장된다는 답을 드렸다. 그 ..

Docker로 Redis Sentinel 구성하기.

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서mag1c.tistory.com 이전 포스팅에 이어서, Sentinel 구성해보자. Redis Master + Replica 구성먼저, Master노드와 Replica 노드를 구성해보자.# docker-compose.ymlredis-master: image: redis:latest command: redis-server container_name: "redis-mas..

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의mag1c.tistory.com 이전 글에서 메시지 큐의 장애 발생 상황을 여러가지로 가정하고, 간단한 해결책들을 생각해서 서술했었다.이번 글에서는 그 중에서도 특히 많은 메시지 큐에서 Redis를 저장소로 사용하거나 지원하는 만큼, Redis의 failover전략 중 하나인 Redis Sentinel에 대해 공식 문서와 실제 사례를 기반으로 공부한 내용을 작성한다. Redis에 장애가 발생한다면?생각해보면 Redis는 애플리케이션을 구성할 때 거..

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의 애플리케이션에 대한 이해를 넘어서, 어느 정도의 주인의식과 우선적으로 해결해야하는 과제는 무엇인지 선정하는 연습(?)을 같이 하고 있다. 이 포스팅은, 속했던 조직에서 가장 먼저 개선해야한다고 판단했던 실시간 채팅 기능의 개선기이며, 2년차인 현재 시점에서 더 개선할 부분은 없었는지가 첨가된 포스팅이다. 모자란 내용에 혹여 더 좋은 의견 남겨주시면 성장에 큰 도움이 됩니다. 감사합니다! 문제 파악하기속했던 조직은, 커머스 비스무리한(?) 서비스를 운영하고 있었지만, 도메인 특성상 결제는 곧 예약이었다.결제 후 오프라인으..

TypeScript로 힙(Heap), 우선순위 큐(Priority Queue) 구현하기.

최근 LeetCode 등의 알고리즘, 구현 문제들을 풀면서 자료 구조를 직접 구현해보기 시작했다. Heap에 대한 개념은 어느정도 있었다고 생각했는데, 막상 구현하려고 보니 입력, 삭제 시 어떻게 정렬을 보장할 수 있지? 에서 멈칫했다. 생각을 정리하고 코드를 짜보려 했지만, 선뜻 키보드에 손이 가지 않아 정리하는 마음으로 이 포스팅을 작성하게 되었다. 힙(Heap)힙은 트리 기반의 자료구조이며, 반드시 부모노드와 자식 노드 사이에는 대소 관계가 성립해야한다는 규칙이 있다. 힙에는 규칙에 따라 최소 힙(Min Heap), 최대 힙(Max Heap)이 있으며, 위 대소관계에 따라 부모 노드가 자식 노드보다 작다면 최소 힙, 반대의 경우는 최대 힙이라 부른다. 이 대소 관계는 반드시 부모 노드와 자식..

JavaScript의 메모리 구조와 관리, V8의 가비지 컬렉션 (스택이 무한정 커지면 힙은 불필요할까?)

서론 출근길 최고의 선택 중 하나인 널개님의 CS 영상을 보면서 출근을 했다. 오늘 영상의 주제는 다음과 같았다.스택이 무한정 커졌다고 가정할 때, 힙은 불필요할까?힙의 파편화에 대해 알고있나? 유튜브를 보는 내내 30분 동안 지하철에서 머리속에 흩어져 있는 지식을 조합해서 대답을 만들어보았다. 일반적으로 메모리 공간은 스택, 힙, 코드, 데이터가 어쩌고.....JavaScript의 실행 컨텍스트가 스택으로 관리되고 내부적으로는 동적으로 생성되고 가비지 컬렉션이 어쩌고......Primitives는 일반적으로 스택에 저장되고.......힙 파편화는 메모리 할당, 해제가 반복되면서 어쩌고.... 디스크 조각모음 어쩌고.... 힙 파편화가 아니라, 내 머리 속의 파편부터 GC하고 싶은 출근길이었다.. ..

NodeJS v25의 변경사항을 알아보자

Tech/NodeJS 2025. 10. 28. 16:55
728x90
728x90

 

NodeJS v25 Release

 

Node.js — Node.js v25.0.0 (Current)

Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.

nodejs.org

 
Node 25버전이 며칠 전, 25년 10월 15일에 릴리즈되었습니다. 자세한 변경 사항들은 위 공식 블로그에서, 커밋들을 확인해보시면 됩니다.
 
 

V8 14.1 적용

기존 24버전의 Node에서는 13.6버전의 V8 엔진을 사용했습니다. 메이저 버전의 업데이트에 따라 V8을 14.1로 업데이트하였습니다.
아래는 V8 버전의 업데이트에 따라 자연스레 Node에도 적용된 변경사항입니다.
 

1. JSON.Stringify 최적화

두 달 전인 8월에 V8 공식 블로그에 JSON.stringify를 두 배 이상 빠르게 만드는 방법이라는 제목으로 포스팅이 게시되었습니다.
 
핵심은, 사이드 이펙트가 없는 직렬화를 감지했을 때, 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을 확인해보시면 좋을 것 같습니다. 

 

Uint8Array - JavaScript | MDN

Uint8Array is currently the only TypedArray subclass that has additional methods compared to other typed arrays. Because of its nature as a generic byte array, it is the most suitable for working with arbitrary binary data. It supports two sets of methods

developer.mozilla.org

 

// 1) 기본: base64 ↔ bytes
const bytes = Uint8Array.fromBase64("aGVsbG8="); // "hello"
const b64   = bytes.toBase64();                  // "aGVsbG8="

// 2) 옵션: base64url + 패딩 생략(프로토콜 규약에 맞춤)
const b64url = bytes.toBase64({ alphabet: "base64url", omitPadding: true });

// 3) 부분 디코딩: 프리할당 버퍼에 직접 채우기(대용량/스트리밍 친화)
const buf = new Uint8Array(1024 * 1024);
const { read, written } = buf.setFromBase64(b64, { lastChunkHandling: "strict" });

// 4) Hex
const hex  = bytes.toHex();                      // "68656c6c6f"
const data = Uint8Array.fromHex("deadbeef");     // Uint8Array [222,173,190,239]

 
기존의 Node에서는 보통 Buffer에 의존했는데요.

Buffer.from(bytes).toString('base64')
Buffer.from(str, 'base64')

 
 
이번 업데이트로 Node와 브라우저가 동일한 코드를 사용할 수 있게 되었고, 특히 setFromBase64/Hex가 직접 버퍼를 채우는 방식이기 때문에 중간 문자열, 메모리 복사를 줄이고 큰 페이로드에서 GC Pressure을 낮추고, 메모리 사용을 절감할 수 있습니다. 또한 옵션으로 유니온 리터럴 타입을 사용하여 옵션들을 표준화했습니다. 코드 일관성과 퍼포먼스 둘 다 개선했다고 볼 수 있겠습니다.
 
 

3. JIT 파이프라인 변경

V8의 JavaScript 실행 파이프라인은 여러 단계로 구성되어있습니다.

 

  • Ignition: 인터프리터
  • SparkPlug: 베이스라인 컴파일러
  • Maglev: 중간 계층 최적화 컴파일러
  • TurboFan: 최적화 컴파일러

Maglev는 Chrome M117에 도입된 새로운 최적화 컴파일러로, 기존 SparkPlug와 TurboFan 사이에 위치합니다. 컴파일 속도 측면에서 Maglev는 SparkPlug보다 약 10배 느리고, TurboFan보다 약 10배 빠르다고 합니다. Maglev는 기존 두 컴파일러 사이의 간격을 좁혀 빠른 최적화와 균형 잡힌 성능, 그리고 점진적 워밍업을 제공합니다. 보다 더 자세한 내용은 공식 블로그 내용을 참조하시면 좋습니다.

 

Maglev - V8’s Fastest Optimizing JIT · V8

In Chrome M117 we introduced a new optimizing compiler: Maglev. Maglev sits between our existing Sparkplug and TurboFan compilers, and fills the role of a fast optimizing compiler that generates good enough code, fast enough. Background # Until 2021 V8 had

v8.dev

 
 
 

4. JSPI(JavaScript Promise Integration) 지원

Node 25부터는 JSPI를 지원합니다.
 
WASM은 기본적으로 동기적인 실행 모델을 가정합니다. 하지만 웹 환경의 많은 API들은 비동기적입니다.
기존에는 이 문제를 해결하기 위해 Binaryen의 ASYNCIFY 같은 복잡한 변환 도구를 사용해야 했습니다. 이로 인해 코드 크기가 증가하고, 런타임 오버 헤드가 자연스레 증가하며 빌드 프로세스 또한 복잡해지는 문제가 있습니다.
 
Node 25부터는 JSPI를 통해 WASM 애플리케이션이 동기적으로 작성되어 있더라도, JavaScript의 비동기 API를 자연스럽게 사용할 수 있게 해줍니다.
 

// 1) WebAssembly.Suspending: 비동기 함수를 래핑
const importObject = {
  env: {
    asyncFetch: new WebAssembly.Suspending(async (url) => {
      const response = await fetch(url);
      return await response.text();
    })
  }
};

// 2) WebAssembly.promising: WASM 함수를 Promise 반환 함수로 변환
const wasmModule = await WebAssembly.instantiate(wasmBytes, importObject);
const promising = new
WebAssembly.promising(wasmModule.instance.exports.main);
await promising();

 
 

 

Introducing the WebAssembly JavaScript Promise Integration API · V8

Posted by Francis McCabe, Thibaud Michaud, Ilya Rezvov, Brendan Dahl.

v8.dev

 
 
 
 
여기까지가, 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가 글로벌 객체로 사용 가능합니다. 자세한 구현사항은 아래 커밋을 확인해보시면 좋습니다.

 

lib: expose global ErrorEvent · nodejs/node@663554a

PR-URL: https://github.com/nodejs/node/pull/58920 Reviewed-By: Zeyu "Alex" Yang <himself65@outlook.com> Reviewed-By: Matthew Aitken <maitken033380023@gmail.com> Reviewed-By: J...

github.com

 
 

WebStorage 기본 활성화

Node v22 이전까지는 localStorage, sessionStorage 같은 WebStorage API를 사용하려면 --experimental-webstorage 플래그가 필요했는데, 이 부분을 Node v25부터는 기본적으로 활성화 상태로 애플리케이션이 실행됩니다. 자세한 변경 내용은 아래 커밋을 확인해보시면 좋습니다.

 

src: unflag --experimental-webstorage by default · nodejs/node@3312e4e

PR-URL: https://github.com/nodejs/node/pull/57666 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Edy Silva <edigleyssonsilva@gmail.com> Reviewed-By: Colin Ihrig <...

github.com

 
 

Portable한 Compile Cache 추가

Node v22.8.0부터 내장 컴파일 캐시가 도입되었었습니다. 컴파일은 JS 실행 전 항상 수행되어야 하기 때문에, 내장 컴파일 캐시가 도입되기 이전에는 반복적인 컴파일 비용 문제와 그에 따른 느려지는 애플리케이션 부트스트래핑 등의 문제가 있었을 것이라 생각됩니다.
 
이 내장 컴파일 캐시에 상대 경로를 지정하여 재사용 할 수 있게, 즉 Portable한 패치가 이루어졌습니다.

node --compile-cache --compile-cache-portable app.js

 
이를 통해 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으로 계속 갖고 가기 위해 반드시 하나씩 깊게 독파하는 포스팅으로 찾아뵙겠습니다 하하..
 
 


References

1. NodeJS 공식 문서

NodeJS 25 릴리즈노트: https://nodejs.org/en/blog/release/v25.0.0
Github NodeJS 25 Realease Tags: https://github.com/nodejs/node/releases/tag/v25.0.0
NodeJS Docs API: https://nodejs.org/api
NodeJS Docs API - Permissions: https://nodejs.org/api/permissions.html
 
 

2. V8

JSON.Stringify 최적화: https://v8.dev/blog/json-stringify
Maglev(JIT Compiler): https://v8.dev/blog/maglev
JSPI(Javscript Promise Integration): https://v8.dev/blog/jspi
 
 

3. ECMAScript 관련

TC39 ArrayBuffer Base64 Spec: https://tc39.es/proposal-arraybuffer-base64/spec
MDN Uint8Array 관련 API들: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
 
 

4. 기타

NodeJS Permission Model References - 1: https://www.nodejs-security.com/learn/nodejs-runtime-security/nodejs-permissions-model
NodeJS Permission Model References - 2: https://dev.to/andreasbergstrom/introducing-the-nodejs-permission-model-enhanced-security-and-granular-control-3md0
NodeJS Permssion Model References - 3: https://www.nearform.com/blog/adding-a-permission-system-to-node-js/
Node v24 Referecnes: https://blog.logrocket.com/node-js-24-new
Node v22 References: https://medium.com/@branimir.ilic93/exploring-node-js-22-maglev-top-level-await-v8-engine-update-and-more-d8e9a8d847f2
V8 JSON.stringify Optimization References: https://dev.to/figsify/the-invisible-optimization-that-sped-up-the-web-how-v8-supercharged-jsonstringify-ke9
MDN Web Storage API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
MDN ErrorEvent: https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
 
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

MyISAM에서 트랜잭션 사용하기

Tech/기타 2025. 10. 10. 10:21
728x90
728x90

서론

MyISAM에서는 트랜잭션을 사용할 수 있을까요?

 

"아뇨, 사용할 수 없습니다."

 

 

엥??? 그럼 제목은 어그로임???

 

 

 

 

 

아쉽게도 MyISAM은 트랜잭션을 지원하지 않습니다. MySQL 공식 문서에서도 분명 명시되어있습니다.

 

더 아쉽게도, 저는 현업에서 아직도 MyISAM 엔진을 사용중이며, InnoDB로의 마이그레이션이 불가능한 상황입니다.

 

협업 과정에서 데이터베이스 엔진의 마이그레이션을 하지 않으면 안되겠냐는 얘기를 들었고, 마이그레이션이 불가능한 원인을 소통 과정에서 짐작해보자면 기술적인 문제보다는 현재 잘 동작하기에, 그리고 변경에 대한 불안감 등의 심리적인 요인일 듯 합니다.

 

이런 상황을 베이스로 실제 발생했던 아래 문제들과

  • 데이터 정합성이 맞지 않음(CS로 이어짐): 주문은 했는데 캐시 사용이 제대로 안되거나 발주서가 기업체에게 도달하지 않음.
  • DX문제: 보상 로직을 예외 처리 구간에서 직접 하나하나 넣어줘야함.

트랜잭션과 유사한 동시성과 데이터 정합성을 지키기 위해 애플리케이션 레벨에서 시도했던 경험,

어쩌면 InnoDB를 사용하지 않는 환경이었기에 얻었던 경험에 대해 기록하고 공유하고자 합니다.

 

 

아래부터 나올 모든 코드는 Typescript로 작성되어있습니다.

 

 

 

MyISAM의 데이터 정합성 문제

다들 아시다시피 MyISAM은 트랜잭션을 지원하지 않습니다.

간단하게, 결제 시 캐시를 차감하고 히스토리를 남기는 로직을 예시로 들어보겠습니다.

async function createOrder(userId: number, amount: number) {
  const rollbackActions: Array<() => Promise<void>> = [];

  try {
    // 1. 포인트 차감
    const user = await prisma.user.update({
      where: { id: userId },
      data: { icash: { decrement: amount } }
    });
    rollbackActions.push(async () => {
      await prisma.user.update({
        where: { id: userId },
        data: { icash: { increment: amount } }
      });
    });

    if (user.icash < 0) {
      throw new Error('잔액 부족');
    }

    // 2. 주문 생성
    const order = await prisma.order.create({
      data: { userId, amount, status: 'PAID' }
    });
    rollbackActions.push(async () => {
      await prisma.order.delete({ where: { id: order.id } });
    });

    // 3. 결제 이력 생성
    await prisma.paymentHistory.create({
      data: { userId, amount, action: 'DEDUCT' }
    });
  } catch (error) {
    // 역순으로 롤백 실행
    while (rollbackActions.length > 0) {
      try {
        const rollback = rollbackActions.pop();
        if (!rollback) break;
        await rollback();      
      } catch(rollbackError) {
        // 보상 로직 실패
        console.error(rollbackError);
      }
    }
    throw error;
  }
}

 

MyISAM은 트랜잭션이 없기 때문에 원자성을 보장하지 않습니다.

따라서 성공 작업들을 모두 개발자가 직접 롤백해줘야겠군요.

 

이정도면, 개발자가 작업하면서 실수로 코드를 뺴먹는 일만 없다면, 문제 없을 것 처럼 보입니다.

실수 방지를 위해 타입스크립트를 활용해서 보완할 수 있습니다.

아래처럼 compensate를 강제한다면 컴파일 단계에서 휴먼 에러도 방지할 수 있을 것 같아요. 훌륭합니다!

interface TransactionStep<TResult = any> {
  name: string;
  execute: () => Promise<TResult>;
  compensate: (result: TResult) => Promise<void> | void;
}

async function createOrder(userId: number, amount: number) {
  // Step 정의: execute와 compensate를 함께 정의하도록 강제
  const steps: TransactionStep[] = [
    {
      name: '포인트_차감',
      execute: async () => {
        const user = await prisma.user.update({
          where: { id: userId },
          data: { icash: { decrement: amount } }
        });

        if (user.icash < 0) {
          throw new Error('잔액 부족');
        }

        return { userId, amount };
      },
      compensate: async (result) => {
        await prisma.user.update({
          where: { id: result.userId },
          data: { icash: { increment: result.amount } }
        });
      }
    },
    // ... 나머지 Step들
  ];
}

 

 

하지만, 롤백 로직만 커스터마이징한다고 해결되는 문제가 아닙니다.

  • 보상에 실패한다면?
  • 동시 요청이 발생한다면?

보상에 실패한다면 어떻게 해야 할까요? 무한 반복? 코드를 아래처럼 바꾸면 될까요?

while (rollbackActions.length > 0) {
  try {
    const rollback = rollbackActions[rollbackActions.length - 1]; // peek
    await rollback();
    rollbackActions.pop(); // 성공 시에만 제거
  } catch(rollbackError) {
    // 계속 재시도... 무한 반복
    await sleep(1000);
  }
}

 

예상되지 않는 상황에 무한정 반복을 하는 것은 올바른 패턴이 아닙니다.

어떤 원인으로 인해 실패했는지 모르는데 무한정 반복하게 되면 어떤 다른 문제들이 더 발생할지 짐작할 수 없기 때문입니다.

 

 

동시 요청을 막기 위해 MyISAM의 Lock을 사용하면 될까요?

MyISAM의 Lock은 InnoDB처럼 레코드 단위의 락이 아닙니다. 테이블 단위의 락은 락을 해제할 때까지 그 누구도 해당 테이블에 접근하지 못하는 것을 의미합니다. 1번 유저의 캐시 차감을 위해 모든 사용자의 정보 변경, 조회 등 모든 요청이 락이 해제될 때 까지 대기하게 되는 문제가 발생합니다.

 

 

 

 

트랜잭션이 없다면 직접 만들자

지금까지 확인한 문제들을 정리하면

  1. 보상 로직 누락 방지(O): TransactionStep 인터페이스로 컴파일 타입에 누락 방지 가능
  2. 보상 실패 처리(X): 무한 재시도는 답이 아님 - 데이터 복구를 위한 시스템(DLQ 등)이 필요
  3. 동시성 제어(X): MyISAM의 테이블 락은 안됨 - 세밀한 동시성 제어를 위한 수단이 필요함

 

 

MyISAM에서 트랜잭션을 제공하지 않기 때문에, 애플리케이션 레벨에서 하나씩 구현하기로 했습니다.

  1. 트랜잭션 컨텍스트: 트랜잭션 관리와 트랜잭션과 유사한 다양한 기능을 지원하는 무언가가 필요함
  2. 원자성: 메시지 큐를 이용한 Step 단위의 실행과 자동 보상
  3. 격리성: Redis 분산락을 이용한 리소스별 동시성 제어
  4. 장애 복구: DLQ로 보상 실패에 대한 격리 및 재시도 인터페이스

 

 

트랜잭션 컨텍스트

트랜잭션을 위해서는 우선 나는 트랜잭션이다!!! 라는 명확한 컨텍스트가 필요했습니다.

 

또한 이 트랜잭션 컨텍스트 내부에는 다음과 같은 정보들이 포함되어야 한다고 생각했습니다.

{
  transactionId: "payment-123",           // 트랜잭션 식별자
  currentStep: 2,                          // 현재 진행 중인 Step 인덱스
  executedSteps: [                         // 완료된 Step들의 결과
    { name: "포인트_차감", result: {...} },
    { name: "주문_생성", result: {...} }
  ],
  status: "in_progress",                   // 트랜잭션 상태
  lockKey: "user:123",                     // 획득한 락 정보
  businessContext: { userId: 123, ... }    // 비즈니스 메타데이터
}

 

 

저는 이 트랜잭션 컨텍스트를 BullMQ의 Job으로 구현했는데요, 이유는 다음과 같습니다.

 

 

1. 중단 지점 재개

실행된 Step은 건너뛰고 중단 지점부터 재실행이 가능합니다.

@Process('execute-transaction')
async handleTransaction(job: Job) {
  const executedSteps = job.data.executedSteps || [];
  const startIndex = executedSteps.length; // 이미 실행된 Step 건너뛰기

  for (let i = startIndex; i < steps.length; i++) {
    const result = await steps[i].execute();
    executedSteps.push({ step: steps[i].name, result });
    await job.updateData({ ...job.data, executedSteps });
  }
}

 

 

2. 트랜잭션 추적 및 상태 조회

Job ID가 트랜잭션 식별자(txid) 역할을 하며, 언제든 상태 조회가 가능합니다.

const status = await transactionManager.getStatus(jobId);
// { status: 'active', progress: 66, currentStep: 2 }

 

 

3. 동기식 API 응답 지원

try {
  // 대부분의 트랜잭션은 3초 내 완료가 됨
  const result = await job.waitUntilFinished(queueEvents, 5000);
  return { status: 'completed', result };
} catch (timeout) {
  // 긴 트랜잭션의 경우 jobId 반환으로 추가 조회(폴링 등)
  return { status: 'processing', jobId };
}

 

waitUntilFinished는 언제 완료될 지 모르기 때문에, timeout과 반드시 결합해서 사용해야 합니다.
저는 정량적으로 측정이 가능한 작업 시간이기 때문에, 타임아웃을 사용했지만 보통 권장하는 패턴은 아니라고합니다.
(문서 참조)

 

 

 

4. 재시도 정책

BullMQ는 일시적 장애일 경우를 대비해 자동 재시도를 구성할 수 있습니다.

await queue.add('transaction', data, {
  attempts: 3,
  backoff: {
    type: 'exponential',
    delay: 2000
  }
});

 

 

 

 

 

원자성

트랜잭션의 원자성을 만족하려면, 모두 커밋되거나 모두 롤백되어야합니다.

@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;
  }
}

 

기본적인 흐름은 다음과 같습니다.

  1. 트랜잭션 컨텍스트 내 각 작업(Step)을 순차 실행
  2. 완료된 스텝들은 혹시 모를 롤백을 위해 기록

 

 

위처럼 원자성을 보장하기 위한 시도도 보상 로직의 실패에 대한 잠재적인 위험은 그대로 남아있기 때문에, 이리저리 고민을 해봤습니다.

애플리케이션 레벨에서 보상을 계속 진행하기 위해서는 데이터베이스와의 통신이 불가피한데, 예기치 못한 상황으로 계속 실패할 수 있습니다. 결국 직접 데이터베이스 엔진에서 수행하는 일련의 동작이 아니기 때문에, 어느정도 수동 개입은 필요하다고 판단했습니다.

 

이는 아래 장애 복구 세션에서 다루도록 하겠습니다.

 

 

 

격리성

앞서 언급했듯이, 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의 레코드 락 처럼 구현해보려고 했습니다.

export class DistributedLockService {
  async acquire(key: string, ttl: number): Promise<RedisLock> {
    const lockKey = `lock:transaction:${key}`;
    const lockValue = this.generateLockId();
    const ttlSeconds = Math.ceil(ttl / 1000);
    
    const result = await this.redis.set(
      lockKey,
      lockValue,
      'EX', ttlSeconds,
      'NX'
    );

    if (result !== 'OK') {
      throw new Error(`락 획득 실패: ${key} (다른 프로세스가 사용 중)`);
    }

    return { key: lockKey, value: lockValue, acquiredAt: Date.now() };
  }
}
@Process('execute-transaction')
async handleTransaction(job: Job) {
  const { steps, lockKey } = job.data;
  const executionResults: ExecutionResult[] = [];
  let lock: RedisLock | null = null;

  try {
    // 1. 분산락 획득
    if (lockKey) {
      lock = await this.lockService.acquire(lockKey, 30000);
      this.logger.log(`분산락 획득: ${lockKey}`);
    }

    // 2. Step 순차 실행
    for (const stepData of steps) {
      const step = this.stepRegistry.get(stepData.executeFnName);
      const result = await step.execute(stepData.params);
      executionResults.push({
        stepName: stepData.name,
        compensateFnName: stepData.compensateFnName,
        result
      });
    }

    return { status: 'success' };

  } catch (stepError) {
    // 3. 보상 트랜잭션
    for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
      try {
        const compensateFn = this.stepRegistry.get(compensateFnName);
        await compensateFn(result);
      } catch (compensationError) {
        this.logger.error(`[${stepName}] 보상 실패`);
        // 보상 실패는 장애 복구 섹션에서 처리
        break;
      }
    }
    throw stepError;

  } finally {
    // 4. 락 해제
    if (lock) {
      await this.lockService.release(lock);
      this.logger.log(`분산락 해제: ${lockKey}`);
    }
  }
}

 

 

1. TTL

TTL을 추가한 이유는, 뭔가 재배포 상황이나 기타 프로세스의 크래시로 인해 락이 영원히 남아있는 것을 고려하여 자동 만료 처리를 위해 구성해두었습니다.

const result = await this.redis.set(
  lockKey,
  lockValue,
  'EX', ttlSeconds,  // TTL
  'NX'
);

 

 

 

2. Lua로 Lock 소유권 검증

다른 프로세스가 혹여 락을 삭제할 수도 있기 때문에, 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 }

 

 

멱등키를 구현함으로써, 위와 같은 엣지 케이스에 대해 중복 처리를 방지할 수 있다고 생각했습니다.

(멱등키는 클라이언트에서 생성을 하도록 구성해뒀기 때문에 서버 코드에는 생략되어있습니다.)

 

@Post('payment')
async createPayment(
  @Body() dto: PaymentDto,
  @Headers('idempotency-key') idempotencyKey: string
) {
  // 1. 멱등키 확인 (중복 트랜잭션 방지)
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) return JSON.parse(existing);

  // 2. 분산락 획득 (동시 실행 방지)
  const lock = await lockService.acquire(`user:${dto.userId}`, 30000);

  try {
    // 3. 트랜잭션 실행
    const result = await transactionManager.executeTransaction({
      transactionId: idempotencyKey,
      lockKey: `user:${dto.userId}`,
      steps: [
        { name: '포인트_차감', executeFn: 'deductIcash', ... },
        { name: '주문_생성', executeFn: 'createOrder', ... },
        { name: 'SMS_발송', executeFn: 'sendSMS', ... }
      ]
    });
    
    // 4. 멱등키에 결과 저장
    await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));

    return result;
  } finally {
    // 5. 락 해제
    await lockService.release(lock);
  }
}

 

이렇게, Redis 분산락과 멱등키를 활용해서 MyISAM에서도 활용가능한 격리성을 구현할 수 있었습니다.

InnoDB의 레코드 단위의 락 처럼 사용자별 독립적인 처리가 가능하고, 중복 요청을 완전 차단하며 크래시 안정성을 TTL + Lua로 보완할 수 있었습니다.

 

 

 

 

장애 복구

원자성 섹션에서 언급했듯이, 보상 트랜잭션도 실패할 수 있습니다.

일부 수동 처리는 불가피하다고 판단했기 때문에 현재 Job의 구성과 동일한 DLQ를 구성해 사용했습니다.

@Process('execute-transaction')
async handleTransaction(job: Job) {
  const { steps, lockKey } = job.data;
  const executionResults: ExecutionResult[] = [];
  let lock: RedisLock | null = null;

  try {
    // 1. 분산락 획득
    if (lockKey) {
      lock = await this.lockService.acquire(lockKey, 30000);
    }

    // 2. Step 순차 실행
    for (const stepData of steps) {
      const step = this.stepRegistry.get(stepData.executeFnName);
      const result = await step.execute(stepData.params);

      executionResults.push({
        stepName: stepData.name,
        compensateFnName: stepData.compensateFnName,
        result
      });
    }

    return { status: 'success' };

  } catch (stepError) {
    // 3. 보상 트랜잭션
    const compensationResults = [];

    for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
      try {
        const compensateFn = this.stepRegistry.get(compensateFnName);
        await compensateFn(result);

        compensationResults.push({ stepName, status: 'compensated' });

      } catch (compensationError) {
        // 보상 실패 기록
        compensationResults.push({
          stepName,
          status: 'compensation_failed',
          error: compensationError
        });

        this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
        break;
      }
    }

    // 4. DLQ에 격리
    const hasFailure = compensationResults.some(
      r => r.status === 'compensation_failed'
    );

    if (hasFailure) {
      await this.dlqService.add({
        jobId: job.id!,
        transactionId: job.data.transactionId,
        executedSteps: executionResults,
        compensationResults,
        failedAt: compensationResults.find(r => r.status === 'compensation_failed')?.stepName,
        error: stepError,
        businessContext: job.data.businessContext
      });

      this.logger.error(`DLQ 추가: ${job.id}`);
    }

    throw stepError;

  } finally {
    // 5. 락 해제
    if (lock) {
      await this.lockService.release(lock);
    }
  }
}

 

전체 보상 트랜잭션의 상태를 저장해서, 수동 처리 시 전체 컨텍스트를 파악할 수 있게 DLQ 관리 인터페이스들을 만들 수 있었습니다.

// DLQ 조회
GET /api/dlq

// 재시도 (실패한 보상부터 다시 실행)
POST /api/dlq/retry/:jobId

// 수동 처리 후 제거
POST /api/dlq/resolve/:jobId
// 재시도 로직
async retry(jobId: string): Promise<void> {
  const dlqEntry = await this.get(jobId);

  // 실패한 보상부터 다시 시도
  const failedIndex = dlqEntry.compensationResults.findIndex(
    r => r.status === 'compensation_failed'
  );

  const remainingSteps = dlqEntry.executedSteps.slice(failedIndex);

  for (const step of remainingSteps) {
    const compensateFn = this.stepRegistry.get(step.compensateFnName);
    await compensateFn(step.result);
  }

  // 성공 시 DLQ에서 제거
  await this.remove(jobId);
}

 

 

 

 

 

 

마무리

긍정적인 효과를 정리하자면, MyISAM의 한계를 BullMQ + Redis로 극복하여 유사 트랜잭션을 얻을 수 있게 되었다는 점입니다.

  • DX 개선: 보상 로직 코드를 더이상 일일이 만들지 않고 트랜잭션 컨텍스트화 하면 됨
  • UX 개선: 데이터 부정합 CS가 월 3+a건에서 0~1건으로 줄어듬
  • 운영 효율: DLQ 기반 자동 복구로 수동 작업 불필요

 

하지만, 이 접근법은 결국 트랜잭션은 아니기 때문에 한계가 존재하는데요.

  1. 보상 트랜잭션의 한계: 보상 실패 시 수동 개입 필요
  2. 성능 오버헤드와 운영 복잡도: 분산락(네트워크 I/O)과 메시지 큐 관리 오버헤드 및 DB I/O에 필요한 시스템이 많아짐

 

무에서 유를 창조해야하는데 이정도의 트레이드오프는 어느정도 감수해야하지 않을까 생각은 했지만 정말 이 방법 밖에 없었을까, 조금 더 간소화 할 수 있었을까 하는 생각이 계속 드는 프로젝트였습니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Claude Code, OOM과 할루시네이션 없이 똑똑하게 사용하는 메모리 최적화 전략

Tech/AI 2025. 9. 8. 20:43
728x90
728x90

 

최근 개발 환경에서 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가 어떻게 컨텍스트를 기억하는지 알아야 합니다. 핵심은 간단합니다.

  1. 세션은 하나의 프로세스: claude 명령어로 대화형 모드에 진입하면 하나의 세션(프로세스)이 시작됩니다.
  2. 모든 대화는 메모리에: 이 세션 내에서 오고 간 모든 질문과 답변(토큰)은 컨텍스트 유지를 위해 메모리에 계속 쌓입니다.
  3. CLAUDE.md는 항상 로드: 세션을 시작할 때 현재 디렉토리의 CLAUDE.md 파일은 무조건 읽어와 기본 컨텍스트로 사용합니다.

 

결국 대화가 길어질수록 메모리에 쌓이는 토큰이 많아져 위에서 언급한 문제들이 발생하는 구조입니다. 이제 이 구조를 역이용하여 메모리를 통제하는 방법을 알아봅시다.

 

 

 

 

CLAUDE.md는 실행 시 반드시 읽는다. 그러므로

 

Manage Claude's memory - Anthropic

Claude Code can remember your preferences across sessions, like style guidelines and common commands in your workflow. Determine memory type Claude Code offers four memory locations in a hierarchical structure, each serving a different purpose: Memory Type

docs.anthropic.com

 

문서에 따르면, 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는 강력한 도구지만, 그 성능을 제대로 이끌어내기 위해서는 내부 동작 방식을 이해하고 사용자가 직접 제어하려는 노력이 필요합니다. 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

JavaScript 객체는 해시 테이블이 아닌가? – V8의 Hidden Class와 Inline Caching

Tech/JS & TS 2025. 6. 26. 17:56
728x90
728x90

최근 면접 이야기

최근 기술면접에서 다음과 같은 질문을 받았다.

 

"JS에서 Array에 모든 데이터를 Array에 넣고, find()로 찾으면 되지, 왜 굳이 객체를 사용할까요?"

 

이 질문에 대해

 

"find()는 O(N)이지만, 객체는 프로퍼티를 통해 값을 조회할 수 있어서 O(1)이기 때문에 사용한다고 생각합니다."

 

 

라고 대답했고, 다음과 같은 꼬리질문들이 이어지기 시작했다.

  1. 객체는 어떻게 값을 저장하길래 O(1)인가요?
  2. 객체는 값을 가져올 때 항상 O(1)인가요? 정말인가요?

 

최근 학습했던 JS와 V8의 메모리 구조와 관리 내용을 기반으로 알고 있던 지식을 버무려 답하고자 했다. 생각하는 시간을 가질수록 머리가 하얘져서, 객체의 프로퍼티 - 값 형태에 집착했고, 해시 구조로 저장된다는 답을 드렸다.

 

그 이후에도 계속해서 질문들이 이어졌고, 이미 한 번 엉켜져버린 탓에 그 무엇에도 자신있게 상세한 대답을 드리지 못했다.

  1. 해시 형태로 저장된다고 하셨는데, 그럼 객체는 해시 테이블인가요?
  2. 메모리에 해시 테이블이 어떤 구조로 올라가나요?

 

면접이 끝난 후에 부정확했던 개념들에 대해, 다시 한 번 인지하기 위해 정리하는 글이다.

이 포스팅에서는 V8의 객체가 어떻게 저장되고 사용되는지에 초점을 맞춰 정리해보려고 한다.

 

 

 

JS 객체는 해시 테이블이 아니다

객체는 프로퍼티 - 값의 형태고, 직관적으로 user.name과 같은 접근은 O(1)인 것처럼 보인다. 이러한 특징들 때문에 해시 테이블인가? 라고 생각의 혼선이 생겼었다. 하지만 정확하게는 틀렸다. 

 

적어도 V8엔진 위의 자바스크립트는, Hidden Class의 Map 구조로 생성되며, Fast Property Access 방식을 통해 성능을 최적화한다. 이 구조는 클래스 기반 언어의 필드를 고정된 순서로 저장하는 방식과 유사하며, 객체의 프로퍼티들이 특정 오프셋에 정적으로 매핑되도록 최적화되어있다. 즉, 초기에는 고정된 내부 메모리 레이아웃을 따른다는 말이다.

 

 

히든 클래스

더 자세하게 이해하기 위해, 자바스크립트가 동적 타입 언어라는 것을 상기할 필요가 있다. 컴파일 단계에서 객체가 어떤 구조가 될 지 알 수없기 때문에, V8은 히든 클래스를 이용해 구조적인 패턴을 감지하고 최적화한다.

 

https://v8.dev/docs/hidden-classes

 

 

V8의 히든 클래스 설명에 따르면, Map은 히든 클래스 자체, DescriptorArray는 Map이 가진 프로퍼티 순차 리스트와 위치 정보, 마지막으로 TransitionArray는 Map에서 Map으로 전이될 때의 간선으로, 이를 통해 트랜지션 트리를 형성하고 객체 구조의 모양(shape)가 같을 경우 동일한 히든 클래스를 재사용한다.

 

이런 매커니즘을 기반으로 히든 클래스는, 동일한 구조를 가진 객체는 동일한 히든 클래스를 사용하며, 이를 위해 객체에 프로퍼티를 추가할 때 마다 다른 히든 클래스를 사용한다.

 

예제 코드를 통해 어떻게 히든 클래스가 생성되는지 알아보자.

function User(name, age) {
  this.name = name;
  this.age = age;
}
const user1 = new User("Alice", 30);

 

 

객체 생성 직후, name, age 프로퍼티 추가에 각각의 히든 클래스를 생성한다. 최초 텅 빈 히든 클래스(Map0)에서 시작해 프로퍼티가 추가되는 순서에 따라 Transition Chain을 생성한다. 이 때 DescriptorArray에는 name, age 프로퍼티가 순서대로 구성되고, 여러 Map에서 공유될 수 있다. 아래의 그림에서, Map2까지 같은 DescriptorArray를 공유하여 히든 클래스를 구성한다.

 

 

그럼 왜 이런 구조를 설계했을까? V8에서는 동적 타입 언어의 특성 때문에, 런타임에 객체가 어떤 구조일지 알 수 없기 때문에, 히든 클래스를 추적하여 어떤 구조인지 파악해나가기 위함이라고 한다. 이 히든 클래스를 통해 V8이 구조 패턴을 감지하고 최적화를 수행할 수 있다고 언급한다. 

 

https://v8.dev/docs/hidden-classes

 

 

 

만약, 같은 User을 통해 생성된 새로운 user가 다른 프로퍼티들을 갖게 된다면 어떻게 될까?

const user2 = new User("Bob", 28);
user2.gender = "M";
user2.phoneNumber = "010-0000-0000";

 

 

user를 선언하고 최초 할당하는 순간까지는 같은 구조이기 때문에 동일한 Map2 히든 클래스를 사용하게 된다.

 

하지만 프로퍼티 추가 순서가 바뀌거나 조건에 따라 다른 구조가되면, 이 둘은 다른 클래스를 가지게 된다. user2는 Map4라는 다른 히든 클래스를 가지게 되었다. 이렇게 다른 히든 클래스를 가지는 객체는 인라인 캐싱에서 효율적으로 동작하지 않는다.

 

 

실제로 객체에 많은 프로퍼티가 추가되거나 삭제되면 DescriptorArray와 히든 클래스를 유지하는데 많은 오버헤드가 발생할 수 있다.
이를 방지하기 위해 V8은 Slow Property도 지원한다. 이 Slow Property를 가진 객체는 소위 Dictionary Mode가 되어 해당 객체는 딕셔너리에 직접 저장되어 더 이상 히든 클래스를 통해 공유되지 않는다.

 

https://v8.dev/blog/fast-properties

 

 


인라인 캐시

인라인 캐시(IC)는 특정 프로퍼티 접근이 반복될 때, 해당 오프셋 정보를 캐싱하여 다시 계산하지 않도록 한다. 이를 통해 반복적인 객체 접근 시 빠른 속도를 유지할 수 있다. 객체가 호출될 때 마다 객체 참조를 통해 힙에서 직접 객체를 조회하는 것이 아닌, 미리 캐싱된 데이터를 참조하는 것이다.

 

인라인 캐시는 다음과 같은 상태를 거친다.

  1. Uninitialized: 처음 호출되는 시점. 전체 탐색을 수행해 프로퍼티를 찾고, 히든 클래스와 offset을 기록한다.
  2. Monomorphic: 항상 같은 객체가 들어올 경우, 캐싱하게 된다.
  3. Polymorphic: 2~4가지 정도의 다양한 구조가 반복될 경우
  4. Megamorphic: 5개 이상의 다양한 구조가 등장한 경우 - 최적화 불가능

위의 user1, user2를 예시로 간단하게 코드를 작성해보았다.

function logName(user) {
  console.log(user.name);
}

const u1 = { name: 'Alice' };  // Map1
const u2 = { name: 'Bob' };    // Map1 → Monomorphic 유지
const u3 = { name: 'Charlie', age: 25 };  // Map2 → Polymorphic 전환

// 첫 번째 호출: 모노모픽 상태로 최적화
logName(u1); // IC가 {name: string} 구조에 특화

// 두 번째 호출: 같은 구조이므로 최적화 유지
logName(u2); // 캐시 히트

// 세 번째 호출: 다른 구조로 인해 폴리모픽으로 전환
logName(u3); // IC가 여러 구조를 처리하도록 변경, 성능 저하

 

우리가 아는 해시 함수를 통한 해시 구조도 항상 O(1)을 보장하는 것은 아니다. 해시 충돌이나 리사이징 등 다양한 비용이 숨어있다. 객체의 프로퍼티를 통한 값의 조회 역시 항상 O(1)일 수는 없는데, 다음과 같은 대표적인 경우에 인라인 캐시의 대상에서 제외되고, Fast Property 구조는 깨진다.

 

// 1. 메가모픽 상태
function logName(user) {
  console.log(user.name);
}

// 너무 많은 다른 구조의 객체들
const users = [
  { name: 'Alice' },
  { name: 'Bob', age: 25 },
  { name: 'Charlie', age: 30, city: 'Seoul' },
  { name: 'David', age: 35, city: 'Busan', job: 'Engineer' },
  { name: 'Eve', age: 40, city: 'Daegu', job: 'Designer', hobby: 'Reading' }
  // ... 더 많은 다른 구조들
];

users.forEach(logName); // 메가모픽 상태로 전환
// IC가 포기하고 일반적인 프로퍼티 룩업 사용
// 2. 동적인 프로퍼티 변경
function processUser(user) {
  console.log(user.name);
}

const user = { name: 'Alice' };
processUser(user); // 최적화됨

// 런타임에 프로퍼티 추가
user.age = 25;        // 히든 클래스 변경
user.city = 'Seoul';  // 또 다른 히든 클래스

// delete 연산은 기존의 히든 클래스 구조와 DescriptorArray를 무효화시키고
// V8이 해당 객체를 Dictonary Mode로 전환시킨다.
delete user.age;

processUser(user); // 딕셔너리 모드로 전환

 

// 3. 너무 많은 프로퍼티
const bigObject = {};
for (let i = 0; i < 2000; i++) {
  bigObject[`prop${i}`] = i;
}

 

 

 

 

 

 

객체 접근이 느려지는 코드 패턴을 피하자

동적으로 프로퍼티를 추가하거나 객체 생성에서 프로퍼티 순서나 구조가 달라지는 코드를 지양해야한다. 가능한 동일한 객체 구조를 유지하며 동적으로 프로퍼티를 추가하지 말고, 기본 구조 안에서 undefined를 활용하는 것이 좋다.

 

이런 패턴은 자연스레 작성하게 되는데, 개발 시에 보통 도메인 객체 혹은 모델 객체로 출발하여 비즈니스 로직을 작성하고, DTO로 변환하는 로직들을 자주 작성하게 된다. 이 때 기본적인 인터페이스나 클래스를 선언하고, 정적으로 확장해서 사용하지 동적으로 프로퍼티들을 추가하지는 않는다. 아마 고대(?) 오래 전부터 사용되고 발전되어온 언어 사용의 패턴을 현재 시점을 살아가는 우리가 사용하고 있기 때문에, 세부 내용은 모르지만 자연스레 좋은 습관으로 자리잡고 있는 것으로 보인다.

 

 

 

정리

다시 돌아와서, 객체 접근이 O(1)인 이유는 해시 형태이기 때문에 아니라, 프로퍼티가 오프셋으로 정적 매핑 되어있기 때문에, 히든 클래스를 기반으로 고정된 위치(offset)를 알 수 있기 때문이다. 반면, Array또한 INDEX를 프로퍼티로 가진 객체지만, find()는 순회하기 때문에 O(N)의 시간 복잡도를 가진다. 결과적으로 V8이 해시 기반이 아닌 정적 구조로 최적화했기 때문에 객체 접근에 O(1)이 성립하는 것이다.

 

 

이 외에도, 아래 V8 레퍼런스들을 참고하면서 알게된 사실들을 정리하면서 마치려고 한다.

 

 

 

Array의 INDEX는 객체의 프로퍼티와 다른 저장소에 저장된다.

https://v8.dev/blog/fast-properties

 

배열의 프로퍼티는 Indexed Properties라는 별도 공간에 저장된다고 한다.

(일반적인 객체의 프로퍼티는 Named Properties에 저장된다.)

 

 

배열을 최적화하기 위해 V8에서 판단하는 내부 기준(?)이 있다.

 

V8은 배열의 요소(element)를 내부적으로 SMI(정수), DOUBLE(부동소수점), OBJECT(혼합형)로 나누고,

각 타입마다 PACKED(꽉 찬 배열), HOLEY(중간에 빈 구간이 존재) 여부에 따라 최적화 전략을 다르게 적용한다.

const a = [1, 2, 3]    // PACKED_SMI_ELEMENTS
const b = [1, 2.2, 3]  // PACKED_DOUBLE_ELEMENTS
const c = [1, 'a', 3]  // PACKED_ELEMENTS
const d = [1, , 3]     // HOLEY_SMI_ELEMENTS
const e = [1, , 3.3]   // HOLEY_DOUBLE_ELEMENTS
const f = [1, , 'a']   // HOLEY_ELEMENTS

 

-0, NaN, undefined등을 배열에 넣지 말고 등등의 최적화 방법에 대해 자세히 알고 싶다면 https://v8.dev/blog/elements-kinds를 참고하면 되겠다.

 

 

 

 

 

References

https://v8.dev/blog/fast-properties

https://v8.dev/docs/hidden-classes

https://v8.dev/blog/elements-kinds

https://braineanear.medium.com/the-v8-engine-series-iii-inline-caching-unlocking-javascript-performance-51cf09a64cc3

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Docker로 Redis Sentinel 구성하기.

Tech/데이터베이스 2025. 6. 16. 12:54
728x90
728x90

 

 

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서

mag1c.tistory.com

 

 

이전 포스팅에 이어서, Sentinel 구성해보자.

 

 

 

 

Redis Master + Replica 구성

먼저, Master노드와 Replica 노드를 구성해보자.

# docker-compose.yml
redis-master:
    image: redis:latest
    command: redis-server
    container_name: "redis-master"
    networks:
        - redis-net

redis-replica-1:
    image: redis:latest
    command: redis-server --replicaof redis-master 6379
    links:
        - redis-master
    container_name: "redis-replica-1"
    networks:
        - redis-net

redis-replica-2:
    image: redis:latest
    command: redis-server --replicaof redis-master 6379
    links:
        - redis-master
    container_name: "redis-replica-2"
    networks:
        - redis-net

 

 

 

 

 

Sentinel 구성

Sentinel은 Redis와는 별도의 구성으로, Redis Sentinel 문서에서 권장하는 최소 Sentinel인 3대의 Sentinel을 띄워 quorum을 만족시키도록 구성했다. 위 포스팅에서도 언급한 바 있지만, Sentinel은 failover 시 quorum을 만족해야 마스터를 전환할 수 있는데, 2개 이상의 Sentinel이 동의해야 객관적 장애(odown)로 판단할 수 있기 때문이다.

 

# Dockerfile
FROM redis:latest

EXPOSE 26379

ADD sentinel.conf /etc/redis/sentinel.conf

RUN mkdir -p /var/lib/redis && \
    chmod 777 /var/lib/redis && \
    chown redis:redis /etc/redis/sentinel.conf

COPY sentinel-entrypoint.sh /usr/local/bin/

RUN chmod +x /usr/local/bin/sentinel-entrypoint.sh

ENTRYPOINT ["entrypoint.sh"]
#entrypoint.sh
#!/bin/sh

sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER_MS/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER_TIMEOUT/g" /etc/redis/sentinel.conf

exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel
# sentinel.conf
# Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf
port 26379
dir /tmp

# 도커 서비스명을 hostname으로 인식,
# 해당 설정이 없으면 `Can't resolve master instance hostname` 오류 발생.
sentinel resolve-hostnames yes

# master redis를 감시.
sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM

# 장애 간주 시간 설정 (MS)
sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER_MS

# 새로운 master가 된 redis에 동기화할 수 있는 slave(repl) 제한
sentinel parallel-syncs mymaster 1

# failover과정 전체의 timeout
sentinel failover-timeout mymaster $SENTINEL_FAILOVER_TIMEOUT

bind 0.0.0.0

 

SENTINEL_DOWN_AFTER_MS=5000
SENTINEL_FAILOVER_TIMEOUT=500
SENTINEL_QUORUM=2

 

 

sentinel.conf를 컨테이너로 복사하고, entrypoint.sh에서 환경변수를 가지고 실시간 구성 파일을 만들어, Sentinel을 실행시킨다.

이 때, reslove-hostnames yes 는 도커 환경에서 서비스명을 호스트로 인식하게 하는 명령어이니, 서비스명을 사용하기 위해 반드시 우선적으로 입력해야한다.

 

 

 

위 코들을 바탕으로 전체 docker-compose 파일은 다음과 같이 구성된다.

version: "3.8"

services:
    redis-master:
        image: redis:latest
        command: redis-server
        container_name: "redis-master"
        networks:
            - redis-net

    redis-replica-1:
        image: redis:latest
        command: redis-server --replicaof redis-master 6379
        links:
            - redis-master
        container_name: "redis-replica-1"
        networks:
            - redis-net

    redis-replica-2:
        image: redis:latest
        command: redis-server --replicaof redis-master 6379
        links:
            - redis-master
        container_name: "redis-replica-2"
        networks:
            - redis-net

    sentinel-1:
        build: sentinel
        ports:
            - "26379:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel1"
        networks:
            - redis-net

    sentinel-2:
        build: sentinel
        ports:
            - "26380:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel2"
        networks:
            - redis-net

    sentinel-3:
        build: sentinel
        ports:
            - "26381:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel3"
        networks:
            - redis-net
networks:
    redis-net:
        driver: bridge

 

 

 

 

 

장애 유도 및 Failover 확인하기

위의 도커 구성대로 컨테이너를 구성한 후, 실제 장애를 발생시켜보았다.

 

$ docker stop redis-master

 

 

1. SDOWN

Sentinel들은 주기적으로 Master 노드에 PING을 보낸다. 일정 시간 응답이 없으면 Master을 SDOWN으로 판단한다. 여기서 일정 시간은, 위에서 설정한 DOWN_AFTER_MS 값이다.

 

 

2. ODOWN

여러 Sentinel이 SDOWN 상태를 보고하면, Quorum 수 이상의 Sentinel이 동의했을 때, ODOWN으로 승격된다.

 

 

3. 리더 Sentinel 선출

Failover을 수행할 리더 Sentinel을 선출한다. 이 과정은 투표 기반으로 진행되며, Sentinel은 자신이 리더가 되겠다는 요청을 다른 Sentinel에게 보내고 과반 투표를 받는다.

 

 

 

4. 새로운 Master 선택

리더 Sentinel은 Replica 중 하나를 Master로 승격시킨다.

선택된 Replica는 MASTER MODE로 전환된다.

 

 

새로운 Master가 선출됨에 따라 나머지 Replica를 재구성하고, 새로운 Master의 정보를 다른 Sentinel과 클라이언트에 전파한다.

 

 

 

 

기타

장애 복구 중간중간에 아래와 같은 에러 로그가 반복적으로 출력되는 것을 볼 수 있다.

Failed to resolve hostname 'redis-master'

 

이는 Sentinel 설정에서 호스트명을 컨테이너명을 사용했기 때문인데, redis-master 컨테이너가 완전히 종료되는 상황을 가정했기 때문에, 도커 내부 DNS - Hostname의 해석이 불가능해서이다. 실제 내부 IP로 바인딩하는 등의 처리로 해결할 수 있다.

 

 

 

 

마무리

Sentinel의 Failover의 과정을 따라 HA를 보장하는 것을 확인했다. 실제 장애를 유도하고 Failover로그를 추적하며 이전 포스팅에서 개념적으로 정리했던 Sentinel의 동작 원리를 간단하게 살펴보고 이해할 수 있었다.

 

다음 포스팅에서는 Sentinel Notificiations을 적용해보고, 장애 발생 시 정상적으로 알림을 발생시킬 수 있는지 확인해보려고 한다. 이 포스팅들을 기반으로, 실제 업무에 Sentinel을 좀 더 견고하게 적용시킬 수 있을 것으로 기대한다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

Tech/데이터베이스 2025. 6. 6. 18:12
728x90
728x90

 

 

 

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의

mag1c.tistory.com

 

이전 글에서 메시지 큐의 장애 발생 상황을 여러가지로 가정하고, 간단한 해결책들을 생각해서 서술했었다.

이번 글에서는 그 중에서도 특히 많은 메시지 큐에서 Redis를 저장소로 사용하거나 지원하는 만큼, Redis의 failover전략 중 하나인 Redis Sentinel에 대해 공식 문서와 실제 사례를 기반으로 공부한 내용을 작성한다.

 

 

Redis에 장애가 발생한다면?

생각해보면 Redis는 애플리케이션을 구성할 때 거의 대부분 사용했던 것 같다. 질의를 위한 쿼리에 대한 최적화를 수행해도 UX를 저해하는 경우에 캐싱하여 사용하고 있다. 추가로 랭킹 등의 집계 후 자주 변하지 않는 데이터에도 Redis에 올려 사용하고, 주기적으로 갱신하곤 했다. 기타 여러 상황들이 있겠지만, 나의 경우는 이 대부분의 모든 카테고리가 캐싱 이다.

 

 

 

내가 사용하는 Redis사례나 기타 사례 등은 Redis의 빠른 응답 특성을 이용해 최대한 DB 조회를 피하고자 하는 전략이 대부분이다.

 

보통의 이런 캐싱 전략에서, 정해놓은 주기가 만료된 후의 최초 요청에서는 캐싱된 데이터가 Redis에 존재하지 않기 때문에 DB Fetching 후 Redis에 적재하는 일련의 과정을 거친다. 만약에 이 Redis에 문제가 생겨서 Redis 서버가 다운됐다고 가정해보자.

 

 

Redis에 데이터가 없는 것을 포함한 모든 예외 상황 시 DB에서 데이터를 가져오게 만들었다고 가정해보자. 이제 Redis 장애로 인해 모든 캐시 미스 요청이 DB로 직행하게 되고, 이는 곧 DB의 TPS가 급증하게 되어 DB CPU의 과부하로 이어진다. 단일 DB 인프라에서는 특히 감당하지 못하고 서버 전체가 죽는 시나리오로 연결될 수 있다.

 

직전 포스팅에서도 메시지 큐가 레디스의 문제로 동작하지 않는다면, 서비스 직원분들의 업무 알림이 전혀 발생하지 않아 모든 업무가 마비될 것이다. 이는 곧 매출에 심각한 영향이 발생할 수 있다.

 

 

간단히 Redis의 장애 발생 시 여파들에 대해 알아봤다. Redis의 확장을 고려해야할 때가 온다면, Cluster에 대해서도 깊게 다뤄볼 예정이다. 하지만 고가용성만을 목적으로 했기 때문에 아래에서부터는, 현재의 환경에 맞춘 failover 을 구성하기 위한 Sentinel만을 다룬다.

 

 

Redis Sentinel

Redis Sentinel은 Redis의 고가용성(HA: High Availability)을 보장하기 위한 구성방식이다.

 

 

Sentinel의 특징

 

 

Sentinel은 Active-Passive 구조로 동작한다. 즉, 하나의 Master 노드가 활성화되어 있고, 나머지 Replica 노드들은 대기 상태에 있다. Sentinel은 이 Redis 인스턴스들을 모니터링 하며, 장애가 발생했을 때 해당 상태를 감지 하고, 알림을 전송 하며, 필요 시 Replica중 하나를 Master로 승격시켜 자동으로 failover를 수행 한다.

 

 

Sentinel 사용 권장사항

 

 

Sentinel은 단순한 Redis 프로세스가 아니다. 서로 통신하고 감시하며 장애 발생 시 투표를 통해 Failover을 트리거하는 분산시스템의 일부 이다. 이런 Sentinel이 하나만 있다면, 그것이 죽는 순간 전체 시스템의 복구 능력도 함께 사라진다. Sentinel을 1개만 두는 경우, 해당 인스턴스에서 장애 발생 시 아무도 Redis를 감지할 수 없고, 자동 failover도 동작하지 않는다. 가용성을 위한 감시 시스템 자체가 SPOF 이 되는 셈이다.

 

그래서 Redis 공식 문서에서도 항상 3개 이상의 Sentinel 프로세스를 운영할 것을 가장 우선해서 권장한다. 이런 이유로 Sentinel은 독립적으로 장애가 발생할 것으로 예상되는 서버에 배치하는게 좋다. 아래 예시 구성에서는 도커 컨테이너로 세 개의 Sentinel을 띄워 테스트해 볼 예정이다.

 

다시 정리하고 넘어가자면, Sentinel을 분산 배치하는 이유는 장애 감지의 신뢰성 확보와 더불어 자체 장애에 대한 복원력 확보에 있다. 특정 Sentinel의 오탐지로 잘못 바뀌는걸 방지하며, Sentinel이 죽더라도 다른 Sentinel들이 감시를 이어갈 수 있기 때문이다.

 

 

 

Sentinel들은 다음과 같은 방식으로 협업한다.

  • 모든 Sentinel들은 Redis Master의 상태를 독립적으로 모니터링한다.
  • Master에 문제가 생긴 것으로 의심되면, 이를 다른 Sentinel에게 전파한다.
  • 이 상태에서 정해진 정족수(Quorum) 이상이 Master가 죽었다고 합의(Voting) 하면, Failover가 시작된다.

Sentinel들은 Quorum 이라는 설정된 최소 합의 Sentinel 수가 서로 합의되어야 failover를 수행한다. 이 때 Quorum은 기본적으로 과반수를 따르지만, 설정을 통해 지정할 수도 있다.

 

 

Sentinel은 "하나의 Sentinel은 누구도 믿지 않는다" 는 전제를 기반으로
신뢰 기반의 감시 구조를 만들기 위해 반드시 다중 구성과 투표 기반 구조를 요구한다.

 

 

 

그렇다면, Sentinel은 언제 어떤 기준으로 Master가 죽었다고 판단할까? 장애 인식 과정을 간단하게 알아보자.

 

 

장애 인식과 Failover

Sentinel은 SDOWN(주관적 다운)과 ODOWN(객관적 다운)을 통해 Master 노드의 장애를 인식한다.

우선 각 Sentinel은 개별적으로 ping을 보내 상태를 감시하는데, 이 때 응답이 없다면 SDOWN으로 간주한다.

 

잠시 네트워크 이상 등의 일시적인 현상일 수도 있기 때문에, 주관적 다운 상태로 변경된다. 하지만 여러 Sentinel들이 동일하게 이 SDOWN을 감지한다면 Master 노드에 문제가 있는 상태로 간주한다. Quorum 이상의 SDOWN이 감지되는 이 때, ODOWN으로 승격되고 failover 작업이 시작된다.

 

 

가장 먼저 투표 요청을 보내고, 과반수 이상의 동의를 얻은 Sentinel이 리더로 선출된다. 이 때 리더는 다음과 같은 역할을 수행한다.

  • Replica 노드들 중 적절한 노드를 선택하여 새로운 Master로 전환한다 
  • 나머지 Replica를 새 Master를 바라보도록 설정
  • 다른 Sentinel들과 클라이언트에게 새로운 Master의 정보를 전파

 

새로운 Master 노드가 선정되더라도 시스템 전체가 기존처럼 하나의 구성으로 수렴되어야한다. 리더 Sentinel은 새로운 레디스의 구성을 Configuration Epoch 값과 함께 전파한다. 이 값은 일종의 버전 관리를 위한 값으로 가장 최신의 구성이 무엇인지를 구분할 수 있게 해준다.

 

또한, 모든 Sentinel들은 __sentinel__: hello 채널을 통해 주기적으로 구성 정보를 공유 하는데, 이 때 더 높은 epoch를 가진 구성을 선택하여 자연스럽게 일관적인 시스템 구성으로 수된다.

 

$ redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
1) "192.168.0.10"
2) "6379"

 

클라이언트는 항상 현재 Master 주소를 요청하기 때문에 구성 변화에도 자동으로 대응할 수 있게 된다.

 

 

 

마무리.

다음 포스팅에서는, 실제 Redis Sentinel을 도커 컨테이너로 구성해서, Failover을 실습해보고 정리한 내용들을 검증해보고자 한다.

 

 

 

 

 

 

 

 

 

Referecnes.

https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel

https://redisgate.kr/redis/sentinel/sentinel_election.php

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

Tech/트러블슈팅 2025. 5. 14. 16:38
728x90
728x90

 

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의 애플리케이션에 대한 이해를 넘어서, 어느 정도의 주인의식과 우선적으로 해결해야하는 과제는 무엇인지 선정하는 연습(?)을 같이 하고 있다.

 

이 포스팅은, 속했던 조직에서 가장 먼저 개선해야한다고 판단했던 실시간 채팅 기능의 개선기이며, 2년차인 현재 시점에서 더 개선할 부분은 없었는지가 첨가된 포스팅이다.

 

모자란 내용에 혹여 더 좋은 의견 남겨주시면 성장에 큰 도움이 됩니다. 감사합니다!

 

 

 

문제 파악하기

속했던 조직은, 커머스 비스무리한(?) 서비스를 운영하고 있었지만, 도메인 특성상 결제는 곧 예약이었다.

결제 후 오프라인으로 상품을 직접 소비(?)하는 특징과 더불어 상품들이 우리가 자주 소비하는 필수 소비재들의 성격이 아닌, 특정 니즈에 따라 대부분 일회성으로 구매하는 상품들이기 때문에 결제 전/후로 채팅을 통한 상담이 서비스의 코어였다.

 

이런 핵심 기능인 채팅에서 응답 속도가 평균 3초 정도로 매우 느리게 동작했고, 이는 시간을 갈아넣어서라도 반드시 해결해야하는 최우선 과제라고 판단했다. 

 

최초에 파악했던, 채팅 전반의 플로우를 그림으로 나타내보았다. 메시지를 전송하면, 기본적인 메시지 관련 데이터베이스 I/O 작업과 더불어 메시지 전송에 처리되어야 할 모든 기능들이 함께 동기적으로 처리 되고 있었다. 근본적으로 응답 속도가 느릴 수 밖에 없는 구조였다.

 

 

 

더불어 아이러니하게도 이 실시간 채팅을 포함해 애플리케이션 내부에서 MyISAM 엔진을 사용하고 있었다. 동시성 제어를 위해 테이블 락 매커니즘을 사용하는 MyISAM의 특성상 쓰기 작업이 느릴 수 밖에 없었다. 여기저기 쓰기 작업을 하게 되는데, 메시지가 많아지면 많아질 수록 여러 테이블에서 서로 쓰기 작업을 위해 기다리는 현상이 기하급수적으로 늘어날 수 밖에 없다.

 

 

효과적인 테스트와 구현을 위해, 당시 최대 TPS를 산정해서 예상 최대 지점까지 고려했다면 어땟을까?

여기까지 생각이 미치지 않았다보니, 워커의 처리량보다 큐에 작업 유입량이 많을 때 어떻게 대처할지 등의 비동기 작업의 안전성을 보장하지 못했다고 생각한다.

 

 

 

 

 

 

비동기 처리를 위해

스토리지 엔진의 한계 외에도 단순 하나의 로직에 이리저리 얽혀있는 여러 비즈니스 로직들을 살펴보고, 메시지 전송 과정에서 반드시 수행되어야 할 로직과 아닌 로직들을 분리 했다. 메시지 전송이 성공했다. 라는 의미는 메시지를 저장하는 chat_message 테이블에만 입력을 보장하면 된다고 판단했고, 나머지 로직들을 전부 분리했다.

 

 

 

이 분리한 로직들을 다시 네 개의 구간들로 나눴고, 원자성을 보장해야하는 구간을 추가 DB 입력 구간인 추가 I/O와 업무 알림으로, 실패해도 괜찮다고 판단되는 부분들을 푸시알림과 SMS전송으로 구분했다.

 

메시지 전송 로직과 분리하여 비동기 처리를 수행하기 위해 BullMQ라는 메시지 큐를 사용했다. Kafka나 RabbitMQ 등도 Nest의 공식 문서에 UseCase등을 문서화해뒀는데 사용하지 않았다. 이들을 사용하기에는 발톱의 때 만큼의(?) TPS였다. 또 Nest에서 기본적인 Queue 사용문서 에 친절하게 언급되어있는 BullMQ의 실패 시 재시도와 스케줄링과의 연동, 이벤트 기반 처리와 이벤트 리스너를 통한 통합 로깅 등을 구현하기에 용이했기 때문에 BullMQ를 사용했다.

 

@Processor('chat-message-side-effect')
export class ChatMessageSideEffectWorker extends WorkerHost {
  constructor(
    private readonly chatService: ChatService,
    private readonly notificationService: NotificationService,
    private readonly crmService: CrmService,
    private readonly fcmService: FcmService,
  ) {
    super();
  }
  async process(
    job: Job<{ jobId: string; payload: { userId: number; message: string } }>,
  ): Promise<void> {
  
    // 메시지 전송 추가작업
    await this.chatService.processAfterMessage(job.data.payload);
    
    // 업무 알림
    await this.notificationService.sendNotification(job.data.payload.userId);

    // 고객 알림 (SMS, FCM)
    await Promise.all([
      this.crmService.sendCRM(job.data.payload.userId),
      this.fcmService.sendFCM(job.data.payload.userId),
    ]);
  }
}

 

 

처리량보다 작업량이 많아지는 상황을 고려했다면..?

TPS를 산정했을 때, 최대 TPS는 1.55였다. 단일 워커에서 DB I/O와 외부 API의 연동 작업들의 평균 latency가 1초대라고 가정하더라도 큐에는 작업이 계속 쌓이게된다. 위에서 언급한 것 처럼, 이러한 상황들을 먼저 가정하고 접근했더라면 워커의 concurrency를 늘리는 등의 동시 처리 방법까지 자연스레 고려할 수 있었을 것 같다.

 

 

 

 

원자성 보장을 위해

메시지의 추가 I/O는 단일 테이블의 insert라고 하더라도, 업무 알림은 여러개의 테이블을 insert/update하는 연속적인 과정이다.

MyISAM은 항상 auto-commit한 쓰기 작업을 보장한다. 롤백을 무시하며 트랜잭션을 지원하는 엔진이 아니기 때문에, 이런 연속적인 과정에서 트랜잭션을 보장하기 위해서는 소위 transaction-like한 무언가를 직접 구현해야했다.

 

type InsertRecord = {
  table: string;
  id: number;
  deleteFn: (id: number) => Promise<void>;
};

class JobTransactionContext {
  private inserts: InsertRecord[] = [];

  recordInsert(record: InsertRecord) {
    this.inserts.push(record);
  }

  async rollback() {
    for (const record of this.inserts.reverse()) {
      await record.deleteFn(record.id);
    }
  }
}

 

이를 해결하기 위해 위처럼 트랜잭션 컨텍스트 객체를 사용해서, 트랜잭션을 보장해야하는 로직에 활용하게 되었다.

 

 

 

 

실패 후속 처리

워커에서 작업을 실패할 경우 원자성을 보장해야하는 경우는 실패로 간주되지만, 위에서 말했던 것 처럼 실패했을 경우에도 운영 상에 지장이 없다고 판단했던 SMS 발송과 푸시 알림은 실패로 간주되지 않는다.

 

위 정책에 따라 분리하여 트랜잭션이 롤백되는 상황에서만 실패로 간주하여 작업을 종료시키고, 실패 시 재시도 전략을 수립했다.

재시도는 BullMQ에서 기본으로 구현되어있는 지수 백오프(Exponential Backoff)를 사용했다. 

모든 재시도에 지수 백오프만 사용할 경우 모든 실패한 작업들이 동시에 백오프 될 경우도 고려해야한다.
실패한 작업에 대해 재시도를 분산하기 위해 사용하는 전략임에도 재시도 과정에서 다시 요청이 몰리는 것은 똑같다.
이를 해결하기 위해 AWS에서는 지연 변이(Jitter)라는 개념의 추가 전략을 통해 일정의 랜덤 시간을 추가로 부여하여 재시도의 동시성을 분산했다고 한다.
(자세한 내용은 AWS의 공식 포스팅1 / 포스팅2 를 참조)

 

 

 

 

 

추가 개선

최근에 이 내용들을 복기하면서 추가로 고려하지 못했던 사항들이 무엇이었는지, 내가 1년 반 전과 비교해서 어디까지 고려하는 개발자가 되어있었는지 확인해보고 싶었다. 위에 잠깐 언급했던 트러블 슈팅을 위한 TPS 산정을 포함해서 정리한 피드백 내용은 다음과 같다.

  1. 위에서 언급한 TPS를 조기에 산정했더라면
  2. 큐에 메시지가 계속 쌓인다면? (처리량보다 유입량이 많은 경우)
  3. BullMQ의 심장(?)인 Redis에 장애가 발생한다면?

 

위 세 가지 상황이 모두 연관이 있는 것 같다. 1번을 조기에 고려하지 못해서 자연스레 2번 문제를 캐치하지 못했고, 2번 문제를 계속 방치하다보면 결국 최종에는 Redis에도 문제가 생기지 않을까? 추가 개선을 위해, BullMQ는 어떻게 Redis를 활용해서 Job을 입력하는지 알아보는 게 좋겠다.

 

 

 

BullMQ는 어떻게 메시지를 넣는가

import { Queue } from 'bullmq';
const queue = new Queue('queueName');

async addJob() {
    await queue.add('jobName', { payload: ... });
}

await addJob();

 

BullMQ에서 구현한 Queue의 인스턴스를 활용하여 큐의 이름을 설정하고, (기본적으로) HSET을 활용해서 job의 이름과 데이터들을 넣는다. 아래 코드는 Nest의 공식문서를 따라 큐에 적재하기 위한 코드의 예시이다.

@Injectable()
export class ChatProducer {
  constructor(
    @InjectQueue('chat-message-side-effect') private readonly queue: Queue,
  ) {}

  async sendMessage(userId: string, message: string) {
    const jobId = `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
    const payload = {
      userId,
      message,
    };

    await this.queue.add(
      'chat-message-side-effect',
      { payload },
      {
        jobId,
      },
    );
  }
}

 

 

여기서 생각해보아야할 부분은, Redis의 SET은 중복된 키값이 있다면 내부 데이터를 덮어 쓰는 방식으로 동작 한다는 점이다. 이해를 돕기 위해, 실제 Job 등록에 사용되는 여러 자료구조 중 Hashes를 직접 CLI를 통해 입력해본 결과를 아래에 서술해두었다. 결과를 보면 중복 방지를 디폴트로 수행하지 않는다는 것을 알 수 있다.

127.0.0.1:6379> HSET user-1 name test
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test"
127.0.0.1:6379> HSET user-1 name test2
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test2"

 

 

 

그렇다면 BullMQ를 사용하는 우리 개발자들은 중복 처리를 사전에 확인하는 모듈을 따로 구성해야할까? 그렇지 않다. BullMQ에서는 편의를 위해 중복된 Job은 등록이 되지 않도록 처리해두었다. 우선 BullMQ의 소스 코드를 실제로 분석해 본 후 동작 과정에 대한 간략한 플로우를 그려봤다.

 

 

Redis에 데이터를 등록하기 위해 실행되는 add....Job-*.lua 스크립트에서 입력 전 중복 확인에 대한 로직이 같이 수행된다.

else
    jobId = args[2]
    jobIdKey = args[1] .. jobId
    if rcall("EXISTS", jobIdKey) == 1 then
        return handleDuplicatedJob(jobIdKey, jobId, parentKey, parent,
            parentData, parentDependenciesKey, KEYS[5], eventsKey,
            maxEvents, timestamp)
    end
end

 

추가적으로 priority나 delayed 작업을 위한 ZSET 활용, 작업 로그를 위한 Streams등에 추가로 입력하지만, 이 부분은 현재 주안점에 벗어나니 생략하겠다. 관심 있으신 분들은 BullMQ 소스코드를 참고하면 될 것 같다.

 

이제 이러한 이해들을 바탕으로, 추가 개선을 어떻게 해야하는지 한 번 생각해보았다.

 

 

 

처리량 < 유입량

우선 처리량보다 유입량이 많은 경우부터 따져보자.

 

워커의 처리 속도가 생산 속도를 따라가지 못할 경우 큐에는 자연스레 Job이 쌓이게 된다.

이 상황이 지속되면 메모리 부담은 물론이고(Redis) 뒤에 들어온 Job의 처리 시간은 기하급수적으로 증가하게 된다.

위의 비즈니스 흐름을 예시로 절망적인 상황을 들어보자면 고객님이 어제 채팅을 보냈는데, 담당자는 오늘 업무 알림을 받아볼 수도 있다.

@Processor('chat-message-side-effect', { concurrency: 3 })

 

큐에 작업이 원활하게 처리되지 못하는 상황을 해결하기 위해 워커에서 동시 처리량을 제어할 수 있다. TPS가 최대 1.55였기 때문에 645ms당 1개의 요청이 발생한다고 가정하고, 워커의 처리 속도는 외부 API에 의해 최대 2초가 걸린다고 가정한다면 concurrency는 3~4정도가 적당할 것이다. 이처럼 적절한 동시 처리나 워커 자체를 늘리는 방향도 고려해볼 수 있다.

 

하지만 동시성을 제어할 경우에는 현재 사용중인 리소스, 여기서는 데이터베이스의 총 Connection과 평균 활성 Connection, 현재 서버의 Connection Pool과 할당된 메모리 자원 등을 고려하는 것이 필수이다. 이 모든 리소스간의 밸런스를 고려하는 엔지니어링이 개발자의 필수 덕목인 것 같다.

 

 

유입량이 많은 경우 중 또 고려해야할 부분은, 처리해야할 메시지가 중복해서 들어오는 경우이다.

하지만 위의 BullMQ의 기본 Job 적재 방식에 대한 이해를 바탕으로 중복 방지에 선 조회 후 early-return하는 코드는 오히려 추가 I/O가 발생할 것이라는 것을 짐작할 수 있다.

 

 

 

 

Redis 장애 대응을 위해

근본적으로 Redis 장애 발생 시 당연히 BullMQ는 더이상 메시지를 받을 수 없다. 또한 이미 enqueued된 작업조차 Redis의 휘발성이라는 특성 때문에 손실될 수 있다.

 

개발자로서 이런 현상을 미리 대응할 수 있도록 설계하여 이미 enqueued된 작업을 복구할 수 있도록 구성할 수 있어야한다.

 

아직까지 서비스에서 사용중인 Redis에 장애가 발생한 적은 없지만, 혹시 모를 Fail Over에 대비한 전략이 하나도 구성되어있지 않다는 것을 인지했다. 서비스 도메인 특성상 트래픽이 엄청나게 성장할 일은 없다고 판단해서 Sentinel로 FailOver 시 노드 승격 전략과 장애 발생 알림 처리를 구성했다. 서비스 레벨에서 Redis 연결 재시도를 허용하여 마스터 노드 전환 시에도 워커가 자동으로 재연결되도록 처리했다.

 

이와 더불어 꾸준히 큐들의 작업 개수를 주기적으로 수집하여 모니터링하고 대기열이  일정 수치를 초과하면 알림을 받아볼 수 있도록 구성하여 장애 징후를 빠르게 감지할 수 있도록 했다.

 

 

 

 

마무리

당시의 개선 방향과 현재 시점에서 생각나는 추가 개선 사항들을 정리하여 쭉 정리해보았다.

 

점진적으로 이런저런 시도를 해보면서 현재 트래픽을 감당하기 여유로운 상황이다보니 엣지 케이스들을 또 고려하지 못했나 싶기도 하다.

조금씩 알면 알수록 더 어려운 빌어먹을 엔지니어링의 세계 ㅡㅡ.. 외부 레퍼런스들을 많이 찾아보면서 실제 개선 사례들을 대입해보면서 무엇을 놓쳤는지, 지금 방식이 최적이었는지 계속 생각해보고있다. 언젠가 이 글을 다시 꺼내먹는 날 예전의 내가 한심해질지도..?

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

TypeScript로 힙(Heap), 우선순위 큐(Priority Queue) 구현하기.

Tech/자료구조 2025. 4. 23. 00:02
728x90
728x90

 

최근 LeetCode 등의 알고리즘, 구현 문제들을 풀면서 자료 구조를 직접 구현해보기 시작했다.

 

Heap에 대한 개념은 어느정도 있었다고 생각했는데, 막상 구현하려고 보니 입력, 삭제 시 어떻게 정렬을 보장할 수 있지? 에서 멈칫했다. 생각을 정리하고 코드를 짜보려 했지만, 선뜻 키보드에 손이 가지 않아 정리하는 마음으로 이 포스팅을 작성하게 되었다.

 

 

 

힙(Heap)

은 트리 기반의 자료구조이며, 반드시 부모노드와 자식 노드 사이에는 대소 관계가 성립해야한다는 규칙이 있다.

 

출처: 나무위키 - 힙

 

 

힙에는 규칙에 따라 최소 힙(Min Heap), 최대 힙(Max Heap)이 있으며, 위 대소관계에 따라 부모 노드가 자식 노드보다 작다면 최소 힙, 반대의 경우는 최대 힙이라 부른다. 이 대소 관계는 반드시 부모 노드와 자식 노드 간에만 성립하며, 형제 사이에서는 대소 관계가 정해지지 않는다.

 

 

일반적으로 힙이라고 하면, 완전 이진 트리(Complete Binary Tree) 기반의 Binary Heap을 의미한다. 완전 이진 트리란 이진 트리를 만족하면서, 모든 레벨이 왼쪽에서부터 차례대로 채워진 트리 구조를 말한다. 자식이 세 개 이상인 D-ary Heap이나, 비정형 트리를 사용하는 Fibonacci Heap 등 다양한 힙이 있지만, 이 포스팅에서는 일반적인 힙을 다룬다.

 

출처: geeksforgeeks - fibonacci heap


(힙의 종류에 대한 더 많은 설명은 링크를 참조)

 

 

 

다시 돌아와서, 힙은 데이터를 꺼낼 때 항상 루트 노드에서 데이터를 꺼내는 방식으로 동작한다. 위의 특징들과 더해 유추해보면 최소값 최대값을 조회하는 데 O(1)의 시간이 걸린다. 그래서 주로 최소값이나 최대값을 빠르게 얻기 위해 사용된다. 선형 구조에서는 최소값 최대값을 얻기 위해 기본적으로 O(N)의 시간이 필요하다. 최적화된 탐색 알고리즘을 사용하더라도 O(logN)이다.

 

이러한 특징을 바탕으로 힙은 실제로 다양하게 활용된다.

  • OS 스케줄러의 우선순위 태스크 처리를 위한 최대 힙
  • 다익스트라 알고리즘을 활용한 최단거리(최소값)을 구하기 위한 최소 힙
  • NodeJS의 이벤트 루프의 Timer Phase - libuv의 타이머 큐(uv_timer)

 

구현하기 전 마지막으로 알아야할 것이 있다. 위에서 언급한 일반적인 힙인 Binary Heap은 이진 트리 구조임에도 불구하고 배열로 구현할 수가 있다. 자식 노드들의 인덱스나 부모 노드의 인덱스를 구하는 명확한 규칙이 존재하기 때문이다. 이해가 쉽게 그림으로 표현해보았다. 아래 그림은, 배열을 트리 형태의 그림으로 표현한 것이기 때문에 size가 아니라 index임을 알아두자.

 

 

우리는 위 그림에서, 다음과 같은 규칙을 유추할 수 있다. 아래의 규칙을 인지하고 힙을 구현해보자.

왼쪽 자식 노드 인덱스 = 부모 노드 인덱스 * 2 + 1
오른쪽 자식 노드 인덱스 = 부모 노드 인덱스 * 2 + 2
부모 노드 인덱스 = (자식 노드 인덱스 - 1) / 2

 

 

 

 

구현하기

1. 기본 구조

위에서 언급한 것 처럼, 배열을 사용한 트리 형태의 기본적인 구조를 만들었다. 기본적인 메서드들과 함께 힙 정렬 과정에서 자주 사용되는 요소 교환 함수를 swap이라는 이름으로 구현했다.

type Comparator<T> = (a: T, b: T) => number;

export class Heap<T> {
  protected heap: T[] = [];
  private comparator: Comparator<T>;

  constructor(comparator: Comparator<T>) {
    this.comparator = comparator;
  }
  
  isEmpty(): boolean {
    return this.heap.length === 0;
  }
  clear(): void {
    this.heap = [];
  }
  peek(): T | null {
    return this.isEmpty() ? null : this.heap[0];
  }
  
  private swap(i: number, j: number): void {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }
}

 

여기서 Comparator 콜백을 필드로 사용한 이유는, Java의 Comparator 인터페이스에서 영감을 받아, 나중에 PriorityQueue를 구현할 때에 선택적으로 우선순위 정렬을 변경하기 위함이다. 선언 시에 선택적으로 결정할 수 있기 때문에 조금 더 활용하기 용이하다고 생각했다.

// Java
PriorityQueue<Integer> pq = new PriorityQueue<Integer>();
PriorityQueue<Integer> pq = new PriorityQueue<Integer>(Collections.reverseOrder());
// TS
const minHeap = new Heap<number>((a, b) => a - b);
const maxHeap = new Heap<number>((a, b) => b - a);

class PriorityQueue<T> extends Heap<T> {}
const pq = new PriorityQueue<number>((a, b) => a - b);

 

 

 

 

2. 삽입과 삭제

기본적인 insert와 remove를 구현했다. remove에서 T | null을 반환하는 것은 Stack이나 Queue의 pop, poll에서 착안했다.

insert(value: T): void {
  this.heap.push(value);
  this.heapifyUp();
}

remove(): T | null {
  if (this.isEmpty()) {
    return null;
  }
  this.swap(0, this.heap.length - 1);
  const removedValue = this.heap.pop()!;
  this.heapifyDown();
  return removedValue;
}

 

insert와 remove를 구현할 때, heapify라는 함수를 사용한다. heapify을 직역하면 heap 형태로 바꾸겠다 라는 의미이다. 즉 삽입과 삭제에서 heap처럼 정렬하겠다는 의미라고 이해하면 된다. 재정렬을 위해 삽입 시에는 위로(Up), 삭제 시에는 아래(Down)으로 바라보며 정렬을 수행하게 된다.

 

remove에서 swap을 먼저 호출하는 이유는, 배열의 pop이 항상 마지막 요소를 삭제하기 때문이다. 위에서 언급한 것 처럼 힙에서 데이터를 꺼낼 때는 항상 루트 노드를 꺼내야하기 때문에, 먼저 swap을 수행한 후 pop으로 루트를 제거하는 방식을 사용한다. 이후 변경된 루트를 기준으로 heapify를 수행한다.

 

 

3. Heapify

private heapifyUp(): void {
  let index = this.heap.length - 1;
  while (
    Math.floor((index - 1) / 2) >= 0 && // 부모 노드가 존재할 때
    this.comparator(
      this.heap[Math.floor((index - 1) / 2)],
      this.heap[index]
    ) > 0 // 부모 노드가 현재 노드보다 우선 순위가 낮을 경우
  ) {
    this.swap(Math.floor((index - 1) / 2), index); // 부모와 교환하며
    index = Math.floor((index - 1) / 2); // 인덱스를 부모로 갱신함
  }
}

private heapifyDown(): void {
  let index = 0;
  // 왼쪽 자식 노드가 존재하는 동안 (완전 이진 트리의 특성 상 왼쪽이 먼저)
  while (index * 2 + 1 < this.heap.length) {
    let smallerChildIndex = index * 2 + 1;
    
    // 오른쪽 자식도 존재하고 오른쪽이 더 우선순위가 높다면
    if (
      index * 2 + 2 < this.heap.length &&
      this.comparator(this.heap[index * 2 + 2], this.heap[index * 2 + 1]) < 0
    ) {
      smallerChildIndex = index * 2 + 2; // 오른쪽 자식을 선택하고
    }
    
    // 현재 노드가 자식 노드보다 우선순위가 높다면 중단함
    if (this.comparator(this.heap[index], this.heap[smallerChildIndex]) < 0) {
      break;
    }
    
    this.swap(index, smallerChildIndex); // 자식과 교환하며
    index = smallerChildIndex; // 다음 탐색 위치로 갱신함
  }
}

 

insert에서 사용하는 heapify는 마지막 index에 삽입되기 때문에, 상향식을 통해 부모 노드와 계속 우선순위를 비교하여 정렬을 수행한다. 반대로 remove는 하향식을 통해 제거된 루트 노드의 위치에서부터 자식 노드와 계속 비교하며 정렬을 수행하게 된다. 즉, 삽입은 자식이 부모를 향해 올라가며 정렬하고, 삭제는 부모가 자식을 향해 내려가며 정렬하는 형태이다.

 

 

 

(완성된 heap의 전체 코드는 깃허브에서 볼 수 있다)

 

 

 

4. 테스트해보기

테스트에 사용된 input은 위 그림의 heap 숫자를 그대로 사용했다.

describe("heap", () => {
  describe("minHeap", () => {
    it("heapify up when inserting a value", () => {
      const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

      const minHeap = new Heap<number>((a, b) => a - b);
      input.forEach((v) => minHeap.insert(v));

      const expected = [...input].sort((a, b) => a - b);

      expect(minHeap.peek()).toBe(Math.min(...input));

      const result: number[] = [];
      while (!minHeap.isEmpty()) {
        result.push(minHeap.remove()!);
      }

      expect(result).toEqual(expected);
    });
    it("heapify down when removing a value", () => {
      const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

      const minHeap = new Heap<number>((a, b) => a - b);
      input.forEach((v) => minHeap.insert(v));

      const removeLength = input.length / 2;
      for (let i = 0; i < removeLength; i++) {
        minHeap.remove();
      }

      const expected = [...input]
        .sort((a, b) => a - b)
        .slice(removeLength, input.length);

      const result: number[] = [];
      while (!minHeap.isEmpty()) {
        result.push(minHeap.remove()!);
      }
      expect(result).toEqual(expected);
    });
  });

  describe("maxHeap", () => {
    it("heapify up when inserting a value", () => {
      const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

      const maxHeap = new Heap<number>((a, b) => b - a);
      input.forEach((v) => maxHeap.insert(v));

      const expected = [...input].sort((a, b) => b - a);

      expect(maxHeap.peek()).toBe(Math.max(...input));

      const result: number[] = [];
      while (!maxHeap.isEmpty()) {
        result.push(maxHeap.remove()!);
      }

      expect(result).toEqual(expected);
    });
    it("heapify down when removing a value", () => {
      const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];
      
      const maxHeap = new Heap<number>((a, b) => b - a);
      input.forEach((v) => maxHeap.insert(v));

      const removeLength = input.length / 2;
      for (let i = 0; i < removeLength; i++) {
        maxHeap.remove();
      }

      const expected = [...input]
        .sort((a, b) => b - a)
        .slice(removeLength, input.length);

      const result: number[] = [];
      while (!maxHeap.isEmpty()) {
        result.push(maxHeap.remove()!);
      }
      expect(result).toEqual(expected);
    });
  });
});

 

 

 

 

우선순위 큐(Priority Queue)

이미 Heap을 구현해뒀기 때문에 우선순위 큐는 기존의 Heap을 확장만 해주면 된다.

export class PriorityQueue<T> extends Heap<T> {
  constructor(comparator: (a: T, b: T) => number) {
    super(comparator);
  }

  enqueue(item: T): void {
    this.insert(item);
  }

  dequeue(): T | null {
    return this.remove();
  }
}

 

describe("priorityQueue", () => {
  it("enque elements in priority order", () => {
    const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

    const priorityQueue = new Heap<number>((a, b) => a - b);
    input.forEach((v) => priorityQueue.insert(v));

    const expected = [...input].sort((a, b) => a - b);

    expect(priorityQueue.peek()).toBe(Math.min(...input));

    const result: number[] = [];
    while (!priorityQueue.isEmpty()) {
      result.push(priorityQueue.remove()!);
    }

    expect(result).toEqual(expected);
  });
  it("dequeue elements in priority order", () => {
    const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

    const priorityQueue = new Heap<number>((a, b) => a - b);
    input.forEach((v) => priorityQueue.insert(v));

    const removeLength = input.length / 2;
    for (let i = 0; i < removeLength; i++) {
      priorityQueue.remove();
    }

    const expected = [...input]
      .sort((a, b) => a - b)
      .slice(removeLength, input.length);

    const result: number[] = [];
    while (!priorityQueue.isEmpty()) {
      result.push(priorityQueue.remove()!);
    }
    expect(result).toEqual(expected);
  });

  it("enque elements in rerverse priority order", () => {
    const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

    const priorityQueue = new Heap<number>((a, b) => b - a);
    input.forEach((v) => priorityQueue.insert(v));

    const expected = [...input].sort((a, b) => b - a);

    expect(priorityQueue.peek()).toBe(Math.max(...input));

    const result: number[] = [];
    while (!priorityQueue.isEmpty()) {
      result.push(priorityQueue.remove()!);
    }

    expect(result).toEqual(expected);
  });

  it("dequeue elements in reverse priority order", () => {
    const input = [33, 17, 27, 14, 5, 9, 19, 21, 18, 11];

    const priorityQueue = new Heap<number>((a, b) => b - a);
    input.forEach((v) => priorityQueue.insert(v));

    const removeLength = input.length / 2;
    for (let i = 0; i < removeLength; i++) {
      priorityQueue.remove();
    }

    const expected = [...input]
      .sort((a, b) => b - a)
      .slice(removeLength, input.length);

    const result: number[] = [];
    while (!priorityQueue.isEmpty()) {
      result.push(priorityQueue.remove()!);
    }
    expect(result).toEqual(expected);
  });
});

 

 

 

 

 

 

 

 

 

 

참조.

https://www.youtube.com/watch?v=AjFlp951nz0

https://github.com/libuv/libuv/

https://www.geeksforgeeks.org/types-of-heap-data-structure/#6-dary-heap

https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A7%84_%ED%8A%B8%EB%A6%AC#%EC%99%84%EC%A0%84%20%EC%9D%B4%EC%A7%84%20%ED%8A%B8%EB%A6%AC

https://ko.wikipedia.org/wiki/%ED%9E%99_(%EC%9E%90%EB%A3%8C_%EA%B5%AC%EC%A1%B0)

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

JavaScript의 메모리 구조와 관리, V8의 가비지 컬렉션 (스택이 무한정 커지면 힙은 불필요할까?)

Tech/JS & TS 2025. 4. 5. 17:48
728x90
728x90

서론

 

 

출근길 최고의 선택 중 하나인 널개님의 CS 영상을 보면서 출근을 했다. 오늘 영상의 주제는 다음과 같았다.

  • 스택이 무한정 커졌다고 가정할 때, 힙은 불필요할까?
  • 힙의 파편화에 대해 알고있나?

 

유튜브를 보는 내내 30분 동안 지하철에서 머리속에 흩어져 있는 지식을 조합해서 대답을 만들어보았다.

 

일반적으로 메모리 공간은 스택, 힙, 코드, 데이터가 어쩌고.....

JavaScript의 실행 컨텍스트가 스택으로 관리되고 내부적으로는 동적으로 생성되고 가비지 컬렉션이 어쩌고......

Primitives는 일반적으로 스택에 저장되고.......

힙 파편화는 메모리 할당, 해제가 반복되면서 어쩌고.... 디스크 조각모음 어쩌고....

 

 

 

힙 파편화가 아니라, 내 머리 속의 파편부터 GC하고 싶은 출근길이었다..

 

 

정리하자 정리

 

 

 

 

JavaScript에서의 메모리

 

 

 

 

 

기본적으로 JavaScript는 고수준의 언어이다. 변수, 함수, 객체 등을 만들때 JavaScript 엔진(V8 등)은 자동으로 메모리를 할당(Allocate)하고, 더 이상 필요로 하지 않을 때 가비지 컬렉션에 의해 않을때 자동으로 해제된다.  그래서 메모리의 생명주기를 얘기할 때 할당(allocate)한다 > 사용(use, references)한다 > 해제(release)의 흐름으로 표현한다.

 

메모리는 JavaScript 엔진의 힙(Memory Heap)과 스택(Call Stack)에 저장된다. 힙과 스택은 JavaScript이 서로 다른 목적으로 사용하는 데이터의 구조이다.

 

 

 

스택(Stack)

스택은 함수가 호출될 때마다 생성되는 실행 컨텍스트(Execution Context)와 원시 타입 값이 저장되는 영역이다. 여기에는 객체나 함수를 가리키는 참조 주소를 포함한다.

 

(이 포스팅에서는 렉시컬 환경에 대한 개념 설명을 포함하지 않는다.)

 

 

 

 

JavaScript는 동적 타입 언어이지만, 엔진은 원시 타입 값들의 크기와 수명에 대한 예측이 가능하다.

원시 타입은 구조가 단순하고, 내부적으로 고정된 포맷으로 저장된다. 예를 들어, number은 대부분 IEEE 754표준의 64비트 부동소수점 형식을 따르며, boolean은 보통 1바이트로 표현된다.

또한 원시 타입은 대부분 함수 내부에서 선언되며, 함수가 종료되면 해당 스택 프레임과 함께 자동으로 해제된다. 객체처럼 여러 참조가 동일한 메모리를 공유하지 않기 때문에, 값의 수명 역시 명확하게 예측 가능하다.

 

이러한 여러 값들은 LIFO인 콜 스택에 저장하여 빠르고 효율적인 접근이 가능하도록 설계되어 있다.

스택은 연속된 메모리 공간을 사용하므로 CPU 캐시 적중률이 높고, 값의 스코프가 명확하여 GC의 개입 없이도 메모리 해제가 자동으로 이루어진다. 단, 브라우저나 JS 엔진마다 스택의 최대 크기와 내부 구현은 다를 수 있다.

 

 

 

힙(Heap)

힙은 JavaScript의 객체, 함수를 저장하는 또다른 공간이다. 스택과 달리 예측이 불가능하기 때문에 동적 메모리를 할당한다. 런타임시에 동적 데이터들이 메모리를 할당받아서 저장되게된다.

 

 

원시 값이 아닌 경우 스택에서는 힙의 객체에 대한 참조(References)를 저장한다. 참조는 일종의 메모리 주소라고 생각하는 것이 편하게 접근이 가능하다. 다시 정리하면, 힙에서 새 객체가 생성되고 스택에는 참조가 생성된다.

 

 

V8 메모리구조: https://deepu.tech/memory-management-in-v8

 

 

JavaScript의 메모리 구조에서 가장 큰 부분을 차지하는 힙 메모리는, 여러 공간들로 나뉘어 관리되는데 V8에서는 New space, Old space로 나뉘어 관리된다.

  • New space: 새로 생성된 객체가 저장되는 영역이다. 객체가 자주 생성되고 삭제되는 애플리케이션의 특성상 빠르고 효율적인 가비지 컬렉션이 가능하도록 설계되어있다.
  • Old space: New space에서 가비지 컬렉션의 대상이 되지 않은 오래된 객체가 저장되는 영역이다. 이 영역의 객체들은 비교적 수명이 길고 New space보다 가비지 컬렉션이 덜 수행된다.

 

 

가비지 컬렉션과 메모리 해제

가비지 컬렉션은 New space와 Old space에서 다른 방식으로 동작한다. 위에서 잠깐 언급했듯이 각 영역 별로 최적화된 minor, major GC로 관리한다. New space는 객체 생명 주기가 짧아 빠르게 가비지 컬렉션을 수행하고, 수명이 길고 메모리 사이즈가 큰 Old space는 major GC가 가비지 컬렉션을 수행한다.

 

 

minor GC (Scavenger)

minor GC는 New space의 가비지 컬렉션을 담당하는 GC이다. New space는 두 개의 semi space로 관리되는데, 객체들이 머무르는 영역은 From space라 부르며 GC의 대상이 되지 않은 객체들이 잠시 머무르는 영역을 to space라 부른다.

 

 

to space는 GC가 동작하지 않을 때는 비어있는 상태로 시작한다. from space에 있는 객체들 중 살아남은 객체들만 to space로 복사하고, 그 후 from space는 초기화된다. 복사가 완료되면 to space는 새로운 from space로 전환되고, 초기화된 이전 from space는 다음 GC를 위한 to space가 된다. 이러한 단순한 복사 + 전체 초기화 매커니즘 덕에 minor GC는 최적화된 빠른 가비지 컬렉션을 수행할 수 있다.

JavaScript는 특성상 짧은 생명 주기를 가진 객체가 매우 많다. 콜백, 클로저, 이벤트 핸들러 등의 실행 흐름은 순간적으로 많은 객체를 만들고 금방 사라지게 만든다. 이런 특성 때문에 V8은 Scavenge 기반의 minor GC 전략을 통해 효율적으로 메모리를 관리할 수 있다.

단, 객체가 여러 번 minor GC를 거쳐 살아남게 되면, 이제 더 이상 short-lived하지 않다고 판단되어 Old Space로 승격된다.  이때부터는 Mark-and-Sweep 방식의 major GC가 동작하게 된다.

 

 

major GC

major GC는 오래 살아남은 객체들(Old space)에 대해 더이상 참조되지 않는 더이상 쓸모없는 객체로 간주한다.

Mark-Sweep-Compact, Tri-color 알고리즘을 사용해 도달할 수 없는 객체를 제거하거나 메모리를 압축한다.

 

 

마킹(Mark)

GC Roots라는 전역 객체, 실행 중인 함수, 클로저 등을 담고있는 곳에서 출발하여 도달 가능한 객체(Reachable)을 전부 dfs로 순회하면서 마킹을 한다. 루트에서 닿을 수 없는 객체는 마킹되지 않고 GC의 대상이 된다.

 

 

1. 루트 객체(Roots) 수집 및 마킹 시작

GC가 시작되면 deque 구조의 marking worklist tri-color 알고리즘을 활용하여 마킹을 수행한다.

초기에는 marking worklist가 비어있고, 모든 객체는 white 상태이다. 이후 Roots는 바로 grey 상태가 되어 marking worklist에 삽입(push front) 다.

  • white(00): 아직 탐색되지 않은 객체
  • grey(10): 발견됐지만 아직 내부 탐색되지 않음 (작업 대기중)
  • black(11): 완전히 마킹 완료된 객체 (내부 필드까지 탐색 완료)

 

Roots를 gray로 marking

 

 

 

2. worklist에서 꺼낸(pop front) 객체를 black으로 마킹한다.

 

꺼낸 객체가 참조하는 모든 필드를 따라가면서 새롭게 참조되는 white 객체들을 gray로 마킹하며 worklist에 추가한다. 이 때, white인 객체만 push front하며 이미 방문된 객체들은 worklist에 추가하지 않는다.

 

 

 

gray를 꺼내어 marking하는 과정

 

 

3. 모두 black이 되거나 white가 될 때 까지 위 과정을 반복한다.

 

마킹되지 않은 대상인 white 객체들은 GC의 대상으로 정리(sweep)된다.

 

dfs를 통한 순회

 

 

마킹 과정은, 객체 간의 참조 그래프를 DFS를 통해 순회하여 이루어진다. 이 때, 중요한 점은 탐색 중인 객체 그래프가 외부에 의해 변경되지 않아야 한다는 점이다. 만약 마킹 도중 애플리케이션이 동작하면서 객체 간 참조가 추가되거나 삭제된다면, GC는 이미 탐색을 마친 객체를 놓치거나 이미 삭제된 참조를 따라가며 잘못된 객체를 가지고 있는 등의 문제가 발생할 수 있다. 이는 곧 살아있는 객체를 실수로 마킹하지 않거나 필요한 객체를 마킹하는 등 심각한 오류로 이어질 수 있다.

 

GC가 객체 그래프 전체를 안전하게 순회할 수 있도록 보장하기 위해서 마킹 단계에서 애플리케이션을 일시 중단(stop-the-world)시킨다. 이를 통해 객체 간 관계를 고정시켜 DFS를 안정적으로 수행할 수 있다.

 

GC가 동작할 때 애플리케이션은 잠시 멈춘다.

 

 

다만, stop-the-world는 앱의 응답성에 직접적인 영향을 주기 때문에, V8은 Parallel, Incremental Marking, Concurrent Marking과 같은 기술을 도입해 정지 시간을 최소화하면서도 객체 그래프의 정합성을 유지할 수 있는 방식을 사용하고 있다.

  • Parallel: 메인 스레드와 헬퍼 스레드가 거의 같은 양의 작업을 동시에 수행하는 방식이다. 총 일시 정지 시간은 스레드 수에 반비례하여 애플리케이션 중지 시간이 대폭 단축된다.
  • Incremental: 메인 스레드가 적은 양의 작업을 간헐적으로 수행한다. 메인 스레드에서 GC에 소요되는 시간을 줄이지 않고 분산시키는 방식으로 메인 스레드의 stop-the-world 시간을 줄일 수 있다.
  • Concurrent: 메인 스레드는 GC 작업을 수행하지 않고, 헬퍼 스레드가 백그라운드에서 GC를 100% 수행한다. JavaScript의 힙이 언제든지 변경될 수 있고, 동시성 문제가 있으므로 읽기/쓰기 경쟁에서 자유롭지 못하다. 메인 스레드의 stop-the-world는 0에 수렴하지만, 헬퍼 스레드와의 동시성 동기화 문제 때문에 약간의 오버헤드가 있다.

Parallel

 

Incremental Marking

 

Concurrent Marking

 

 

 

스위핑(Sweeping)

white으로 마킹된 객체는 도달 가능하지 못한 대상(Unreachable)으로 판단하여 정리 대상이 된다. 이 객체들의 메모리 주소를 free list에 추가한다. 여기서 free list란 linked list 구조로 동적 메모리 할당을 위해 사용되는 자료구조이다.

 

V8은 힙에서 무조건 새로운 메모리를 요청하지 않고, free list에 있는 빈 슬롯을 먼저 조회해서 해당 메모리 주소를 재사용하게 된다.

 

 

압축(Compact)

GC가 끝난 후 힙 메모리 내부에 여기저기 흩어지게 되는데, 이를 메모리 단편화라고 한다.

메모리 단편화가 심할 경우에만 조건적으로 compact를 수행한다.

 

 

 

 

마무리

포스팅을 정리하면서 파편화된 지식을 압축(Compact)하여 내가 면접 질문에서 서론과 같은 질문을 받았을 때에 대처할 수 있도록 압축한 내용을 다시 메모리(?)에 올려놓고 마무리한다.

 

  • 스택은 구조상 크기와 수명이 명확한 값들을 저장하기에 적합하며, 특히 선형적인 구조로 인해 관리가 빠르다. 하지만 무한정 커질 수 있다고 해도 스택의 선형적인 구조가 객체 참조와 같은 동적 패턴을 효율적으로 처리할 수 있을지는 의문이다.
  • 힙은 런타임 중에 크기가 가변적이며, 현재로서는 참조 기반 객체 데이터가 저장되는 유일한 구조이다. 만약 참조 기반 데이터를 스택에 담는다면, 스택에 담을 수는 있겠지만 객체 간 참조를 효율적으로 관리할 수 없고 결국 연속적인 메모리 탐색이 필요하며 최악에는 모든 메모리에 대한 탐색이 필요할 수도 있다. 사과 하나를 꺼내기 위해 냉장고에 있는 모든 음식들을 꺼내는 것 처럼 말이다.

즉, 아무리 스택의 스펙이 좋아진다고 하더라도 힙의 역할을 구조적으로 대체할 수 없다고 생각한다.

 

현재 내 백엔드 스택과 연관되어 JavaScript와 연계된 꼬리질문이 이어진다면

  • V8은 GC 최적화(Minor/Major GC와 구현된 알고리즘)를 통해 힙 메모리의 파편화 문제를 실질적으로 해결하고 있다.
  • 물론 스택 메모리 자체도 V8 내부 최적화(Inlining, Espace Analysis)를 통해 더 빠르게 동작하도록 설계되어 있다. 다만 동적 객체의 수명 주기를 다루는 영역에서 힙은 여전히 핵심적인 역할이다.

 

 

 

 

 

참조

https://v8.dev/blog/concurrent-marking

https://v8.dev/blog/trash-talk

https://felixgerschau.com/javascript-memory-management/

https://fe-developers.kakaoent.com/2022/220519-garbage-collection/

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록