(41)

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 ..

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

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

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

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

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

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

NestJS의 MIME Type 보안 취약점(에 기여할 뻔한 이야기)

요약1. NestJS의 내장 FileValidator은 파일 내용을 확인하지 않고 MIME Type만 정규 표현식으로 확인한다. (주석에도 언급되어 있다.)2. 프로젝트를 구성하는 많은 파이프라인들 중 일부 요소들에서 (Snyk, GitHub Dependabot 등) 보안 취약점이라고 알림이 발생한다. 심할 경우 파이프라인이 제대로 동작하지 않는다.3. 만약 NestJS에서 수정해야할 경우를 가정하고 작성한 포스팅이다. (파이프라인의 보안 취약점 수정 요청 등을 가정하지 않는다.) 서론 Affected versions of this package are vulnerable to Arbitrary Code Injection via the FileTypeValidator function due to i..

NestJS 11에서 의존성 초기화 성능 이슈를 어떻게 해결했을까?

피드백은 큰 힘이 됩니다.   서론이전 포스팅에서 NestJS 11의 릴리즈 노트 중 부트스트랩 최적화에 대해 다뤘다. 모듈을 식별하는 Opaque Key 생성 알고리즘의 개선으로 모듈을 읽어들이는 속도가 대폭 향상되었다고 나와 있었다. 하지만 최근 NestJS 11로 업데이트한 이후, AppModule 초기화 속도가 급격히 느려지는 이슈가 발생했다. 10버전에서 55ms였던 초기화 시간이 11버전에서는 50초 ~ 80초까지 증가하며 성능 저하 문제가 제기되었다. 저번 포스팅에서는 Opaque Key 최적화로 부트스트래핑 속도를 개선하는 방법을 살펴봤다면, 이번 포스팅에서는 AppModule의 의존성 초기화 과정에서 발생한 성능 이슈와 Nest에서 이를 어떻게 해결하였는지에 대해 분석해봤다    11.0..

NestJS v11 살펴보기: 부트스트랩 최적화로 앱 실행 성능 개선

(메인 버전 릴리즈노트에 내 PR이 있다니 보람차다)Nest v11의 릴리즈노트를 보며, Express v5의 도입과 더불어 Node 20버전 미만은 지원을 중단하는 등의 패치 내용을 죽 읽어보다가, imporove bootstrap perfomance 라고 적힌 Features가 눈에 띄었다. 앱을 실행하는, 가장 핵심적인 코어의 기능이 개선되었다고 하는데, 어떤 변화가 있었길래 전반적인 앱 실행 속도가 향상되었는가에 대한 궁금증에 적당히 파헤쳐보고자 한다. 불투명 키 알고리즘의 추가로 앱 실행속도 향상Nest v11에서는 모듈 간의 고유성을 보장하기 위한 기존의 불투명 키(Opaque key) 생성 방식이 개선되어 동적 모듈과 대규모 애플리케이션에서 직렬화 비용이 대폭 줄어 부트스트래핑 성능이 향상..

[Nest] Localstack으로 AWS S3 파일 업로드, 삭제, 다운로드 및 테스트 코드 작성하기

서론최근에 사이드 프로젝트에서 S3 버킷에 파일을 업로드해야 하는 일이 생겼고, 자연스럽게 통합 테스트를 작성해야 할 상황이 됐다. 하지만 실제 AWS S3 환경에서 테스트를 작성하는 데는 몇 가지 현실적인 문제들이 예상됐다. 1. 비용 문제S3는 사용량 기반으로 요금이 부과되기 때문에, 테스트가 자주 실행되는 환경에서는 비용이 계속 쌓일 가능성이 있다. 특히, 개발하면서 테스트를 반복적으로 실행하다 보면 생각 이상으로 비용이 발생할 수밖에 없다. 현재 사이드프로젝트의 테스트코드 실행 주기가 pre-commit에만 달려있어도, 하루에 십 수번은 넘게 실행되고 있다.2. 보안 문제 테스트 환경에서 IAM의 Access Key와 Secret Key를 사용하는 건 보안상 굉장히 위험할 수 있다. 키가 노출되면..

[TypeORM] Join의 속성(RelationOptions)들을 파헤쳐보자

서론최근 신입 개발자분이 입사하셨다. TypeORM을 사용해서 특정 기능을 구현하던 도중, 계속해서 하위 테이블에서 상위 테이블의 FK가 NULL로 들어가는 문제가 있었다. 구현하신 로직을 따라가면서 문제점을 발견할 수 있었는데, 기존에 하위 모델에서 가지고 있는 상위 모델 객체의 정보를 저장 직전에 ORM의 create 인터페이스로 새로 생성하여 저장했기 때문이다. 현재 내가 개발중인 도메인의 테이블들은 대부분 비정규화가 심한 테이블들이여서 ORM에서 관계를 매핑해주지 않고 ORM의 인터페이스 혹은 raw query로 JOIN을 수행하고 있다. 이렇다보니 한 번에 무엇이 문제인지 찾을 수 없었다. 사용하고 있는 특정 기술들 중 핵심적인 ORM이기 때문에, 이번 일을 계기로 하나하나 직접 사용하며 정리해..

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

Tech/JavaScript & TypeScript 2025. 10. 28. 16:55
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
 
 

300x250
mag1c

mag1c

2년차 주니어 개발자.

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

Tech/JavaScript & TypeScript 2025. 6. 26. 17:56
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

300x250
mag1c

mag1c

2년차 주니어 개발자.

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

Tech/JavaScript & TypeScript 2025. 4. 23. 00:02
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)

300x250
mag1c

mag1c

2년차 주니어 개발자.

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

Tech/JavaScript & TypeScript 2025. 4. 5. 17:48
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/

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

NestJS의 MIME Type 보안 취약점(에 기여할 뻔한 이야기)

Tech/JavaScript & TypeScript 2025. 4. 1. 17:27
728x90

요약

1. NestJS의 내장 FileValidator은 파일 내용을 확인하지 않고 MIME Type만 정규 표현식으로 확인한다. (주석에도 언급되어 있다.)

2. 프로젝트를 구성하는 많은 파이프라인들 중 일부 요소들에서 (Snyk, GitHub Dependabot 등) 보안 취약점이라고 알림이 발생한다. 심할 경우 파이프라인이 제대로 동작하지 않는다.

3. 만약 NestJS에서 수정해야할 경우를 가정하고 작성한 포스팅이다. (파이프라인의 보안 취약점 수정 요청 등을 가정하지 않는다.)

 

 

 

서론

 

 

Affected versions of this package are vulnerable to Arbitrary Code Injection via the FileTypeValidator function due to improper MIME Type Validation. An attacker can execute arbitrary code by sending a crafted payload in the Content-Type header of a request.

 

 

최근에, NestJS에 제기됐던 FileTypeValidation의 보안 취약점 이슈가 대두되었다. MIME Type을 임의로 주입했을 때 검증할 수 없다는 내용이다.

 

/**
 * Defines the built-in FileType File Validator. It validates incoming files mime-type
 * matching a string or a regular expression. Note that this validator uses a naive strategy
 * to check the mime-type and could be fooled if the client provided a file with renamed extension.
 * (for instance, renaming a 'malicious.bat' to 'malicious.jpeg'). To handle such security issues
 * with more reliability, consider checking against the file's [magic-numbers](https://en.wikipedia.org/wiki/Magic_number_%28programming%29)
 *
 * @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators)
 */
 export class FileTypeValidator extends FileValidator<
  FileTypeValidatorOptions,
  IFile
> { 
    buildErrorMessage(file?: IFile): string {
      if (file?.mimetype) {
        return `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`;
      }
      return `Validation failed (expected type is ${this.validationOptions.fileType})`;
  }

  isValid(file?: IFile): boolean {
    if (!this.validationOptions) {
      return true;
    }

    return (
      !!file &&
      'mimetype' in file &&
      !!file.mimetype.match(this.validationOptions.fileType)
    );
}

 

(예를 들어, 'malicious.bat'을 'malicious.jpeg'로 이름을 바꾸는 것입니다). 이러한 보안 문제를 해결하기 위해
더 신뢰성 있게 파일의 [magic-numbers] 을 확인해 보세요

 

 

 

 

 

 

MIME Type

MIME Type(Multipurpose Internet Mail Extensions)은 파일의 형식을 설명하는 문자열로 흔히 Content-Type이나 file.mimetype속성에서 볼 수 있다. 이 MIME Type은 브라우저나 서버가 파일을 어떻게 처리하는지 결정하는 데 사용된다.

 

하지만 이 MIME Type은 클라이언트에서 직접 조작하여 보낼 수 있기 때문에 MIME Type만으로 파일을 검증하는 것은 보안 취약점으로 드러날 수 있다. 서론의 Git Dependencies Bot이나 Snyx등에서 알림이 발생하는 것처럼 말이다.

 

@ApiBody({ type: UploadFileRequest })
@ApiConsumes('multipart/form-data')
@Put('signup/request/profile')
@UseInterceptors(FileInterceptor('file'))
async uploadTempProfileImage(@CustomImageValidator() file: Express.Multer.File)
: Promise<UploadFileResponse> {
    console.log('TEST', file);
    return await this.authService.uploadTempProfileImage(file);
}

 

import { FileTypeValidator, MaxFileSizeValidator, ParseFilePipe, UploadedFile } from '@nestjs/common';

export function CustomImageValidator(
    maxSize: number = 1024 * 1024 * 15 + 1,
    // application\/x-msdownload 추가
    fileType: RegExp = /^(image\/jpg|image\/jpeg|image\/png|image\/gif|image\/bmp
    		|image\/svg\+xml|application\/x-msdownload)$/i,
) {
    return UploadedFile(
        new ParseFilePipe({
            validators: [
                new MaxFileSizeValidator({ maxSize }),
                new FileTypeValidator({ fileType }),
            ],
        }),
    );
}

 

 

// 파일 업로드 시 Content-Type 조작
const fakeFile = new File([file], file.name, {
    type: "application/x-msdownload", // .exe MIME
});

 

테스트를 위해 간단하게 프로필 이미지 업로드 코드를 만들고, 실제로 클라이언트에서 Content-Type을 조작한 뒤 파일을 업로드하게되면, 서버에 파일이 올바르게(?) 전달되게 되고 뒤 프로세스들이 그대로 실행되는 모습을 볼 수 있었다.

 

 

 

 

 

 

 

그래서 뭐가 문제임?

그렇다면 이게 왜, 어떤 문제가 되어 보안 취약점이라고 계속해서 말하는걸까?

 

실제로 업로드된 파일의 내용을 보면, 파일은 PNG 이미지이지만, MIME Type은 application/x-msdownload로 설정되어 있었다.

서버는 이 MIME Type을 믿고 x-msdownload 확장자로 저장했고, 앞의 벨리데이션을 통과했기 때문에 이후 로직에서도 별다른 제약 없이 이 파일을 처리하게 된다.

 

서버는 신뢰할 수 없는 Content-Type값을 기준으로 벨리데이션을 처리했다. NestJS의 FileTypeValidator은 정의한 정규표현식을 통해서 파일의 타입을 단순 문자열 비교만 수행한다. 이 문자열 타입은 클라이언트에서 조작이 가능하므로 기본적으로 취약한 구조가 된다.

 

 

 

가상의 시나리오를 하나 구성해보았다. 공격자가 악성 실행 파일을 png인 것으로 인식하게끔 Content-Type만 위변조하여 업로드하였다. 나는 올바르게 ImageValidation Type을 설정했지만 서버 내부의 FileTypeValidator은 PNG로 인식하기 때문에 올바르게 벨리데이션을 통과하게 되며 이는 곧 서버와의 상호작용을 통해 어딘가 저장됨을 의미한다.

 

저장된 이 파일이 사용자에 의해 다시 실행되게 되면?? XSS, RCE(Remote Code Execution)등의 취약점이 발생하게 된다.

 

 

 

이 문제의 본질은, 계속 강조했던 것 처럼 서버가 신뢰할 수 없는 Content-Type값을 기준으로 벨리데이션을 수행하기 때문에 발생하는 문제이다. 이 MIME Type은 클라이언트가 조작이 가능하기 때문에 신뢰할 수 없다. NestJS에서는 이러한 MIME Type에 대한 정규표현식 검증만 수행하기 때문에 취약한 구조일 수 밖에 없다.

 

 

 

 

어떻게 개선할까?

이 문제를 해결하기 위해서는, Content-Type에 의존하는 것이 아닌, 파일의 실제 내용을 기반으로 판단해야한다.

 

파일 바이너리의 시작 부분에는 파일 형식을 식별하는 시그니처(Magic Numbers)가 들어있다. 예를들어 JPEG 이미지는 0xFFD8로 시작하고, PNG는 항상 0x89504E47로 시작한다. 매직 넘버는 파일의 형식을 정확하게 식별하는데 이미 널리 사용되고 있다. 

 

이 문제를 처음 접했을 때 NestJS에서 작성 해 놓은 주석을 기반으로 나도 Magic Number을 사용할 수 있도록 개선하고자 했다. FileValidator라는 공통 인터페이스가 있으니, FileMagicTypeValidator같은 것을 확장해서 구현하려고 방향성을 정한 뒤 PR을 작성하기 전 NestJS의 개발 방향과 일치하는지 이슈 코멘트에 방향성을 재확인받고자 코멘트를 작성했다.

 

 

 

하지만 다른 누군가가 바로 PR을 올려버렸기 때문에 아쉽지만 기여에는 실패한 것 같다.

 

 

 

NestJS의 기존 의존성들에는, 이러한 파일 벨리데이션을 해결해줄 수 있는 라이브러리가 존재하지 않기 때문에, Node 진영에서 가장 많이 쓰이는 라이브러리 중 하나인 file-type을 사용하여 해결하고자 했다.

 

기존의 Validator을 확장하여 regExp에서 아래 코드로 변환해주면 되니 말이다.

const fileType = await fileTypeFromBuffer(file?.buffer);
if (!fileType) {
	return false;
}

 

 

 

여기에 그치지 않고, 추가로 보안 취약점을 개선하기 위해

  • 업로드 된 파일은 확장자를 클라이언트의 입력(MIME Type)에 의존하지 않고 재정의
  • 서버의 File Validation에 WhiteList를 재정의하고 깐깐하게(?) 관리하기
  • 이미지 파일의 경우 - 이미지 변환, 리사이징 등을 통해 정말 이미지 파일이 맞는지 검증해보기

등의 추가 개선을 자체 서버에서 구현할 수도 있지 않을까? 라는 생각을 해본다.

 

 

 

 

 

마무리하며

우선, 오픈소스 PR은 올린 사람이 임자(?) 라는 것을 다시금 깨닫는다. 간만에 기여할 거리가 생겼는데 엄청 아쉽다.

 

이번 이슈는 단순히 NestJS에 보안 취약점이 있다. 라는 수준을 넘어서, 서버 사이드에서 클라이언트 입력값을 얼마나 신중하게 다뤄야하는지를 조금이나마 일깨워줬다. MIME Type은 단순히 HTTP 요청에 포함된 단순한 문자열일 뿐이고, 이를 신뢰할 경우 우리는 의도치 않게 악성 파일을 통과시킬 수도 있다.

 

NestJS에서도 이를 사전에 인지했기 때문에 주석을 통해 명시적으로 사용자들에게 알렸다. 결국 중요한 건 사용자 개개인이 어느 수준까지 보안을 신경쓰고 코드를 작성할까? 라는 인지를 하고 코드를 써내려가는 것 아닐까?

 

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

NestJS 11에서 의존성 초기화 성능 이슈를 어떻게 해결했을까?

Tech/JavaScript & TypeScript 2025. 2. 12. 12:34
728x90

 

 

피드백은 큰 힘이 됩니다.

 


 

 

서론

이전 포스팅에서 NestJS 11의 릴리즈 노트 중 부트스트랩 최적화에 대해 다뤘다. 모듈을 식별하는 Opaque Key 생성 알고리즘의 개선으로 모듈을 읽어들이는 속도가 대폭 향상되었다고 나와 있었다.

 

하지만 최근 NestJS 11로 업데이트한 이후, AppModule 초기화 속도가 급격히 느려지는 이슈가 발생했다. 10버전에서 55ms였던 초기화 시간이 11버전에서는 50초 ~ 80초까지 증가하며 성능 저하 문제가 제기되었다.

 

저번 포스팅에서는 Opaque Key 최적화로 부트스트래핑 속도를 개선하는 방법을 살펴봤다면, 이번 포스팅에서는 AppModule의 의존성 초기화 과정에서 발생한 성능 이슈와 Nest에서 이를 어떻게 해결하였는지에 대해 분석해봤다

 

 


 

 

11.0.9

위에서 언급한 의존성 초기화 속도 개선을 위해 Nest에서는 topology tree를 사용하는 방식을 선택했다. 소스 코드 분석과 더불어 간단한 결제 모듈 구조를 통해 어떻게 개선되었는지 알아보고자 했다. 결제 모듈의 구조는 순환 의존과 더불어 글로벌 모듈을 사용했다. (개인적으로는 최대한 단방향 의존을 사용하려고하고 서비스가 확장됨에 따라 양방향 의존이 불가피할 경우 중간 레이어를 하나 더 두는 형태로 개발하고 있다.)

 

 

기존 - DFS

기존에는 DFS(깊이 우선 탐색) 기반 재귀 호출 방식을 사용하여 모듈 간의 거리를 계산했다. DFS의 특성상, 이미 방문한 모듈도 다시 탐색하는 경우가 많아지고, 불필요한 연산이 많아지는 문제가 발생했다. 또한, 모듈이 많아질수록 DFS의 호출 스택이 깊어져 스택 오버플로우 위험도 존재했다. 특히 순환 참조가 발생하는 경우, 무한 루프에 빠질 가능성이 높아, 안정적인 부트스트래핑이 어려워지는 문제가 있었다.

 

Nest의 DependenciesScanner는 의존성 관리를 위한 클래스이다. 모듈 초기화 순서를 결정하기 위해 모듈 간의 거리를 계산한다. 이 과정이 없다면 초기화 순서가 꼬이면서 앱이 실행되지 않을 것이다. 의존성이 있는 모듈을 먼저 초기화해야 이후 다른 모듈에서 정상적으로 주입이 가능한 것이다.

 

public calculateModulesDistance() {
    const modulesGenerator = this.container.getModules().values();

    // Skip "InternalCoreModule" from calculating distance
    modulesGenerator.next();

    const calculateDistance = (
      moduleRef: Module,
      distance = 1,
      modulesStack: Module[] = [],
    ) => {
      const localModulesStack = [...modulesStack];     // 1. 방문한 모듈 추적
      if (!moduleRef || localModulesStack.includes(moduleRef)) {
        return;
      }
      localModulesStack.push(moduleRef);      // 2. 현재 모듈 추가

      const moduleImports = moduleRef.imports;  // 3. 의존성 가져옴
      moduleImports.forEach(importedModuleRef => {
        if (importedModuleRef) {
          if (
            distance > importedModuleRef.distance &&
            !importedModuleRef.isGlobal
          ) {
            importedModuleRef.distance = distance;  // 4. 거리 갱신
          }
          calculateDistance(importedModuleRef, distance + 1, localModulesStack); // 5. DFS
        }
      });
    };

    const rootModule = modulesGenerator.next().value;
    calculateDistance(rootModule!);
}

 

 

 

코드를 보면 알겠지만, 기존의 모듈의 거리를 계산할 때 calculateDistance라는 내부 함수의 재귀 호출을 통해 거리를 계산한다. 위 코드의 주석처럼, 1 ~ 5의 과정을 모든 모듈을 하나씩 방문하면서 DFS를 돌리는 것이다. 글로벌 모듈에 대한 동작을 수행하지 않는 등의 성능에 신경 쓴 부분도 보이지만, 근본적으로 서비스가 커짐에 따라 DFS 자체가 부담이 된다. 이슈에서처럼 AppModule의 초기화 시간이 80초나 걸린 것만 봐도 알 수 있다.

 

 

SharedModule의 위치가 이상하지만 AppModule의 하위 모듈로 이해해주세요.

 

 

정리하자면, 기존의 모듈 거리 계산 방식은 DFS를 통한 그래프 형태로 모듈 간의 거리가 계산된다. 중복 방문이 필연적이며 모듈에서 의존하는 것이 많아지고, 서비스가 확장됨에 따라 앱 초기화에 필요한 시간은 훨씬 길어지게 된다. 이런 문제들로 55ms였던 AppModule의 의존성 초기화 시간이 80000ms까지 대폭 상승했었던 것으로 보인다.

 

 

 

트리 구조로의 개선

새로운 방식에서는 TopologyTree를 도입하여 DFS 방식의 재귀 호출을 제거하고, 모듈 간 관계를 트리 구조로 변환하여 반복 탐색하는 방식으로 변경했다.

export class TopologyTree {
  private root: TreeNode<Module>;
  private links: Map<Module, TreeNode<Module>> = new Map(); // 1.빠른 탐색을 위한 해시 테이블

  constructor(moduleRef: Module) {
    this.root = new TreeNode<Module>({ value: moduleRef, parent: null });
    this.links.set(moduleRef, this.root); // 2.해시 구조로 저장하여 중복 방지
    this.traverseAndMapToTree(this.root);
  }

  public walk(callback: (value: Module, depth: number) => void) {
    function walkNode(node: TreeNode<Module>, depth = 1) {
      callback(node.value, depth); // 3. 거리(distance) 계산
      node.children.forEach(child => walkNode(child, depth + 1));
    }
    walkNode(this.root);
  }

  private traverseAndMapToTree(node: TreeNode<Module>, depth = 1) {
    if (!node.value.imports) return;

    node.value.imports.forEach(child => {
      if (!child) return;

      if (this.links.has(child)) { // 4.이미 존재하는 모듈인지 확인
        const existingSubtree = this.links.get(child)!;

        if (node.hasCycleWith(child)) return; // 5.순환 참조 감지 (사이클 방지)

        const existingDepth = existingSubtree.getDepth();
        if (existingDepth < depth) {
          existingSubtree.relink(node); // 6.기존 트리 노드 재연결
        }
        return;
      }

      const childNode = new TreeNode<Module>({ value: child, parent: node });
      node.addChild(childNode);
      this.links.set(child, childNode);
      this.traverseAndMapToTree(childNode, depth + 1);
    });
  }
}
export class TreeNode<T> {
  public readonly value: T;
  public readonly children = new Set<TreeNode<T>>();
  private parent: TreeNode<T> | null;

  constructor({ value, parent }: { value: T; parent: TreeNode<T> | null }) {
    this.value = value;
    this.parent = parent;
  }

  addChild(child: TreeNode<T>) {
    this.children.add(child);
  }

  removeChild(child: TreeNode<T>) {
    this.children.delete(child);
  }

  relink(parent: TreeNode<T>) {
    this.parent?.removeChild(this);
    this.parent = parent;
    this.parent.addChild(this);
  }

  getDepth() {
    let depth = 0;
    let current: TreeNode<T> | null = this;
    const visited = new Set<TreeNode<T>>();

    while (current) {
      depth++;
      current = current.parent;
      if (visited.has(current!)) return -1; // 1.순환 참조 감지
      visited.add(current!);
    }
    return depth;
  }

  hasCycleWith(target: T) {
    let current: TreeNode<T> | null = this;
    const visited = new Set<TreeNode<T>>();

    while (current) {
      if (current.value === target) return true;
      current = current.parent;
      if (visited.has(current!)) return false;
      visited.add(current!);
    }
    return false;
  }
}
public calculateModulesDistance() {
    const modulesGenerator = this.container.getModules().values();
    // Skip "InternalCoreModule"
    // The second element is the actual root module
    modulesGenerator.next();

    const rootModule = modulesGenerator.next().value!;
    if (!rootModule) {
      return;
    }

    // Convert modules to an acyclic connected graph
    const tree = new TopologyTree(rootModule);
    tree.walk((moduleRef, depth) => {
      if (moduleRef.isGlobal) {
        return;
      }
      moduleRef.distance = depth;
    });
}

 

 

  • TreeNode 클래스를 사용하여 각 모듈을 트리 노드로 변환
  • walk()를 활용하여 BFS처럼 반복 탐색
  • hasCycleWith() 메서드를 사용하여 순환 참조를 사전에 방지

결과적으로 O(n²)에서 O(n)으로 최적화되었으며, 중복 방문 없이 일정한 성능을 유지할 수 있게 되었다.

 

 

 

 

 

 

정리

이번 업데이트를 통해 대규모 애플리케이션에서도 모듈 간 의존성을 더욱 효율적으로 관리할 수 있게 되었다. 기존 DFS 기반 탐색 방식에서 발생하던 불필요한 중복 방문으로 인한 성능 이슈를 TopologyTree 기반 반복 탐색 방식으로 성능이 대폭 향상된 것으로 보인다.

 

(실제로 이슈의 코멘트 중 업데이트를 적용하고 35초에서 145ms로 단축되었다.)

  기존 방식(DFS) 새로운 방식 (Topology Tree)
탐색 방식 DFS (깊이 우선 탐색, 재귀 호출) 트리 기반 반복 탐색 (walk())
중복 방문 문제 여러 번 방문 가능 한 번만 방문
순환 참조 감지 제한적 (modulesStack 사용) hasCycleWith() 사용
스택 오버플로우 위험 있음 (재귀 호출) 없음 (반복 탐색)
성능 O(n²) (중복 탐색 발생) O(n) (최적화된 탐색)
거리 계산 방식 재귀 호출로 계산 BFS처럼 walk()로 계산

 

 

언제나 issue가 있으면 기여해보고자 하는 생각에 레포를 꾸준히 방문하다보니 재밌는 이슈거리가 있어 자연스레 들여다봤던 시간이었다. 오픈 소스의 코드 컨벤션부터 시작해서 개발 방향, 의도 등이 코드에 드러나기 때문에 기여를 할 때도 보다 더 수월하게 할 수 있지 않을까? 다양한 사람들의 여러 관점에서의 이슈 분석을 보고 같이 토론하면서 하드 스킬 뿐 아니라 소프트 스킬도 키우고 겸사겸사 고수들의 코드도 공짜로 볼 수 있고 말이다 ㅋㅋ..

300x250
mag1c

mag1c

2년차 주니어 개발자.

NestJS v11 살펴보기: 부트스트랩 최적화로 앱 실행 성능 개선

Tech/JavaScript & TypeScript 2025. 1. 31. 15:13
728x90

 

(메인 버전 릴리즈노트에 내 PR이 있다니 보람차다)


Nest v11의 릴리즈노트를 보며, Express v5의 도입과 더불어 Node 20버전 미만은 지원을 중단하는 등의 패치 내용을 죽 읽어보다가, imporove bootstrap perfomance 라고 적힌 Features가 눈에 띄었다. 앱을 실행하는, 가장 핵심적인 코어의 기능이 개선되었다고 하는데, 어떤 변화가 있었길래 전반적인 앱 실행 속도가 향상되었는가에 대한 궁금증에 적당히 파헤쳐보고자 한다.
 

 

불투명 키 알고리즘의 추가로 앱 실행속도 향상

Nest v11에서는 모듈 간의 고유성을 보장하기 위한 기존의 불투명 키(Opaque key) 생성 방식이 개선되어 동적 모듈과 대규모 애플리케이션에서 직렬화 비용이 대폭 줄어 부트스트래핑 성능이 향상되었다고 한다.
 

NestJS는 IOC 컨테이너에서 모듈을 고유하게 식별하기 위해 불투명 키를 사용한다.
이 키는 모듈의 메타데이터를 기반으로 생성되며 각 모듈을 정확히 구분하는 역할을 한다.

 
 

Nest v10: 직렬화 기반 키 생성

Nest v10에서는 모듈의 불투명 키(Opaque Key - 고유 식별자)를 생성하기 위해 전체 모듈 메타데이터를 해싱하는 방식이 사용되었다. 이 방식의 문제점은 해싱 알고리즘이 모듈 메타데이터의 전체를 읽어 이를 바탕으로 고유 해시를 생성했는데, 이로 인해 동적 모듈의 규모가 클 수록 해시 생성 속도가 느려지는 오버헤드가 발생했다. 또한 모든 메타데이터를 직렬화한 뒤 해싱하는 방식이 불필요하게 복잡해 규모가 클수록 오버헤드는 더욱 심해졌다.
 
TypeORM 모듈에 여러 엔터티를 포함시킨 동적 모듈의 경우, 이를 여러 모듈에서 동시에 사용할 수 있다. 예를 들어, 유저 모듈에서 UserEntity를 사용하지만, 다른 모듈에서 유저 모듈을 임포트할 경우, Entity만 사용할 경우, 최악으로 UserModule 자체를 @Global으로 두어 사용하는 경우 모두 해당된다. 이 때 Nest는 각 모듈의 동적 메타데이터를 해싱하여 불투명 키를 생성한 뒤, 이를 기준으로 중복을 제거하고 단일 노드로 처리했다. 하지만 위의 예시처럼 UserModule 내의 엔터티들이 많아질 수록, 직렬화와 해싱 작업이 가중되어 오버헤드가 점점 심해지게 된다.
 

// Class ModuleTokenFactory
create(type: Type, dynamicMetadata?: Partial<DynamicModule>): string {
  const serializedMetadata = dynamicMetadata
    ? JSON.stringify(dynamicMetadata)
    : '';
  return this.hashString(type.name + serializedMetadata);
}

 
위 코드를 보면, 모든 메타데이터에 대해 직렬화하는 것을 볼 수 있다.
 
 

Nest v11: 참조 기반 키 생성

v11에서는 ByReferenceModuleOpaqueKeyFactory를 도입하여 더 간단한 방식으로 모듈의 불투명 키를 생성한다. 객체 참조를 통해 불투명 키를 생성하는데, 이는 메타데이터를 직렬화하거나 해시를 계산하지 않고 모듈 객체의 참조값을 직접 식별자로 사용한다. 객체 참조를 통해 이미 고유성을 가지기 때문에 복잡한 해싱 로직이 없어도 정확히 모듈을 구별할 수 있고, 이로 인해 모든 메타데이터를 직렬화하고 해싱하지 않아 실행 성능이 많이 개선되었다고 한다.
 
v10에서 언급한 TypeORM의 예시에서, v11에서는 모듈의 객체 참조 변수에 할당하고, 이를 여러 모듈에서 재사용하면서 자연스럽게 중복이 제거되게 된다.
 
추가된 코드들을 간단히 살펴보자. 우선 새롭게 추가된 모듈 식별자를 생성하는 방식을 정의하는 알고리즘 옵션이 추가되었다.

export class NestApplicationContextOptions {
  /**
   * Determines what algorithm use to generate module ids.
   * When set to `deep-hash`, the module id is generated based on the serialized module definition.
   * When set to `reference`, each module obtains a unique id based on its reference.
   *
   * @default 'reference'
   */
  moduleIdGeneratorAlgorithm?: 'deep-hash' | 'reference';
}

 
 
default가 reference로 설정되어있기 때문에, 위에서 설명한 객체 참조를 기반으로 식별자를 생성하게 되었다. 변경된 버전에서는, 객체 참조만을 사용해 기존의 키를 가져오거나, 생성하기만 하면된다.

// Class ByReferenceModuleOpaqueKeyFactory
public createForStatic(
  moduleCls: Type,
  originalRef: Type | ForwardReference = moduleCls,
): string {
  return this.getOrCreateModuleId(moduleCls, undefined, originalRef);
}

// 동적 모듈의 경우 실제 모듈은 제외하고
// providers, imports등의 메타데이터만 포함하여 키를 생성한다.
public createForDynamic(
  moduleCls: Type<unknown>,
  dynamicMetadata: Omit<DynamicModule, 'module'>,
  originalRef: DynamicModule | ForwardReference,
): string {
  return this.getOrCreateModuleId(moduleCls, dynamicMetadata, originalRef);
}

 
 
참조 키를 생성할 때, 랜덤 문자열을 기본적으로 사용하여 직렬화를 가능한 회피하려고 했고, 동적 메타데이터가 없는 경우에는 직렬화를 완전히 배제하려고 한 것 같다.

// Class ByReferenceModuleOpaqueKeyFactory
private getOrCreateModuleId(
  moduleCls: Type<unknown>,
  dynamicMetadata: Partial<DynamicModule> | undefined,
  originalRef: Type | DynamicModule | ForwardReference,
): string {
  if (originalRef[K_MODULE_ID]) {
    return originalRef[K_MODULE_ID];
  }

  let moduleId: string;
  if (this.keyGenerationStrategy === 'random') {
    moduleId = this.generateRandomString();
  } else {
    moduleId = dynamicMetadata
      ? `${this.generateRandomString()}:${this.hashString(
          moduleCls.name + JSON.stringify(dynamicMetadata), // 동적 메타데이터에만 직렬화 호출
        )}`
      : `${this.generateRandomString()}:${this.hashString(moduleCls.toString())}`;
  }

  originalRef[K_MODULE_ID] = moduleId;
  return moduleId;
}

 
 
Nest에서 모듈 관리 및 IoC 컨테이너로 사용되는 NestContainer에서 불투명 키를 생성할 때, 기본적으로 reference 알고리즘을 사용하도록 했고, 그 내부에서도 random을 기본적으로 사용하도록 구성했다.

// NestContainer
const moduleOpaqueKeyFactory =
  this._contextOptions?.moduleIdGeneratorAlgorithm === 'deep-hash'
    ? new DeepHashedModuleOpaqueKeyFactory()
    : new ByReferenceModuleOpaqueKeyFactory({
        keyGenerationStrategy: this._contextOptions?.snapshot
          ? 'shallow'
          : 'random',
      });

 
이로 인해 애플리케이션 내의 대부분의 모듈에 대한 불투명 키를 생성하는 과정에서 직렬화가 제거됐을 것이다. 이로 인해 모듈들을 읽는 속도가 크게 향상되었고 이는 곧 실행 속도가 크게 개선됐다고 얘기하는 것이 아닐까 생각해본다.
 

300x250
mag1c

mag1c

2년차 주니어 개발자.

[Nest] Localstack으로 AWS S3 파일 업로드, 삭제, 다운로드 및 테스트 코드 작성하기

Tech/JavaScript & TypeScript 2025. 1. 24. 00:16
728x90

서론

최근에 사이드 프로젝트에서 S3 버킷에 파일을 업로드해야 하는 일이 생겼고, 자연스럽게 통합 테스트를 작성해야 할 상황이 됐다. 하지만 실제 AWS S3 환경에서 테스트를 작성하는 데는 몇 가지 현실적인 문제들이 예상됐다.

 

1. 비용 문제
S3는 사용량 기반으로 요금이 부과되기 때문에, 테스트가 자주 실행되는 환경에서는 비용이 계속 쌓일 가능성이 있다. 특히, 개발하면서 테스트를 반복적으로 실행하다 보면 생각 이상으로 비용이 발생할 수밖에 없다. 현재 사이드프로젝트의 테스트코드 실행 주기가 pre-commit에만 달려있어도, 하루에 십 수번은 넘게 실행되고 있다.

2. 보안 문제
테스트 환경에서 IAM의 Access Key와 Secret Key를 사용하는 건 보안상 굉장히 위험할 수 있다. 키가 노출되면 프로젝트뿐만 아니라 AWS 계정 전체에도 영향을 줄 수 있다.

3. 정합성 문제
프로덕션과 테스트 환경이 같은 S3 버킷을 공유한다면, 테스트 중 파일 업로드나 삭제가 프로덕션 데이터에 영향을 미쳐 정합성을 깨트릴 가능성이 있다. 이건 최악의 상황을 초래할 수도 있다.

 

그래서 이러한 문제를 해결하기 위해 LocalStack을 사용했다. LocalStack은 로컬 환경에서 AWS 서비스를 에뮬레이션할 수 있는 도구로, S3뿐만 아니라 DynamoDB, Lambda 같은 다양한 AWS 리소스를 로컬에서 테스트할 수 있게 해준다. LocalStack을 활용해서 S3 업로드 기능에 대한 통합 테스트를 작성했는데, 공식 문서가 너무 잘 되어있어 쉽게 적용해볼 수 있었다.

 

 

Localstack 실행

docker-compose를 통해 테스트 실행 전 활성화를 시켜놓았다. 테스트 시작 구문에서 LocalStack 컨테이너를 세팅해보았으나 한 번에 5초 이상 걸렸다. 초기화 시 매번 세팅해주기에는 테스트 파일이 많아질수록 실행 속도가 느려질 것 같다. 

 

#!/bin/sh

export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=000000000000
export AWS_SECRET_ACCESS_KEY=000000000000

awslocal s3 mb s3://my-bucket
version: '3.9'
services:
    localstack:
        image: localstack/localstack
        ports:
            - '4566:4566'
        environment:
            - SERVICES=s3
        container_name: localstack
        volumes:
            - './localstack/init.sh:/etc/localstack/init/ready.d/init-aws.sh'

 

초기화 쉘스크립트를 작성해주고 compose에서 쉘 스크립트를 참조할 수 있게 마운트를 해주면 끝이다.

 

 

 

테스트 작성

LocalStack을 세팅하고 나니 자연스레 구현해 둔 S3Service에 대한 통합 테스트를 작성할 수 있었다.

S3Service는 모듈 셋업 과정에서 S3Config에서 환경변수를 받아 S3Client를 생성하여 S3Service에 주입하는 구조로 되어있어, 테스트용 환경변수만 세팅해주면 실제 비즈니스 코드의 통합 테스트가 가능했다.

 

import { S3Module } from '../../src/infra/s3/s3.module';
import { S3Service } from '../../src/infra/s3/s3.service';
import { setupModule } from '../util/setup';

describe('[Integration] EquipmentService', () => {
    let s3Service: S3Service;

    beforeAll(async () => {
    	// 테스트를 위한 기본적인 module setup (ConfigModule)
        const module = await setupModule([S3Module]);

        s3Service = module.get<S3Service>(S3Service);
    });

    it('파일 업로드에 성공하고 path를 반환한다', async () => {
        // given
        const file = {
            originalname: 'test.jpg',
            buffer: Buffer.from('test'),
        } as Express.Multer.File;
        const directory = 'test-dir/';

        // when
        const result = await s3Service.uploadFile(file.originalname, file.buffer, directory);

        // then
        return expect(result).toBe(
            `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${directory + file.originalname}`,
        );
    });

    it('파일 삭제에 성공한다', async () => {
        // given
        const key = 'test.jpg';

        // when then
        await expect(s3Service.deleteFile(key)).resolves.not.toThrow();
    });
    
    it('파일 다운로드에 성공한다', async () => {
        // given
        const file = {
            originalname: 'test.jpg',
            buffer: Buffer.from('test'),
        } as Express.Multer.File;
        const directory = 'test-dir/';

        await s3Service.uploadFile(file.originalname, file.buffer, directory);

        // when
        const result = await s3Service.downloadFile(file.originalname, directory);

        // then
        expect(result.mimeType).toBe('application/octet-stream');
        expect(result.data).toBeInstanceOf(Uint8Array);
    });
});

 

 

 

 

 

실제 구현

업로드는 테스트를 위해 실제 경로를 반환했고 삭제는 응답 코드가 204이다.

실제 다운로드를 사용하고자하는 구간이 클라이언트에서 이미지를 미리보기하는 상황이 아니라 파일을 무조건적으로 다운로드 해야하는 상황이다보니 다운로드 시 ContentType을 명시하지 않았고, 디폴트인 octet-stream으로 받아지게 된다.

async uploadFile(key: string, body: Buffer, dir: string = 'equipments-export/'): Promise<string> {
    const command = new PutObjectCommand({
        Bucket: this.bucketName,
        Key: dir + key,
        Body: body,
    });

    await this.s3Client.send(command);
    const region = await this.s3Client.config.region();

    return `https://${this.bucketName}.s3.${region}.amazonaws.com/${dir + key}`;
}

async downloadFile(
    key: string,
    dir: string = 'equipments-export/',
): Promise<{ data: Uint8Array; mimeType: string }> {
    const command = new GetObjectCommand({
        Bucket: this.bucketName,
        Key: dir + key,
    });

    const response = await this.s3Client.send(command);

    if (!response.Body) {
        throw new Error('File not found');
    }

    const mimeType = response.ContentType || 'application/octet-stream';
    const data = await response.Body.transformToByteArray();

    return { data, mimeType };
}

async deleteFile(key: string, dir: string = 'equipments-export/'): Promise<void> {
    const command = new DeleteObjectCommand({
        Bucket: this.bucketName,
        Key: dir + key,
    });

    await this.s3Client.send(command);
}

 

 

 

정리

LocalStack 덕분에 실제 AWS를 사용하지 않고도 비슷한 인프라로 S3 버킷의 테스트를 작성할 수 있었다. 무엇보다도 테스트를 반복 실행하더라도 비용이 발생하지 않는다는게 현재 사이드프로젝트에서는 큰 장점인 것 같다.

 

사실 오늘 처음 LocalStack을 알았는데, 바로 적용이 가능한 데에는 공식문서가 잘 되어있다는 점이 가장 컸다. 다른 AWS의 리소스들도 무료로 지원해주는 게 생각보다 많기 때문에 AWS를 사용할 때 테스트코드 작성에 대한 부담을 느껴 모킹해버리는 경우가 많이 줄어들 것 같다.

300x250
mag1c

mag1c

2년차 주니어 개발자.

[TypeORM] Join의 속성(RelationOptions)들을 파헤쳐보자

Tech/JavaScript & TypeScript 2025. 1. 12. 21:52
728x90

서론

최근 신입 개발자분이 입사하셨다. TypeORM을 사용해서 특정 기능을 구현하던 도중, 계속해서 하위 테이블에서 상위 테이블의 FK가 NULL로 들어가는 문제가 있었다. 구현하신 로직을 따라가면서 문제점을 발견할 수 있었는데, 기존에 하위 모델에서 가지고 있는 상위 모델 객체의 정보를 저장 직전에 ORM의 create 인터페이스로 새로 생성하여 저장했기 때문이다.

 

현재 내가 개발중인 도메인의 테이블들은 대부분 비정규화가 심한 테이블들이여서 ORM에서 관계를 매핑해주지 않고 ORM의 인터페이스 혹은 raw query로 JOIN을 수행하고 있다. 이렇다보니 한 번에 무엇이 문제인지 찾을 수 없었다. 사용하고 있는 특정 기술들 중 핵심적인 ORM이기 때문에, 이번 일을 계기로 하나하나 직접 사용하며 정리해보면서 나에게, 또 누군가에게 레퍼런스가 되었으면 한다.

 

 

 

(@DeleteDateColumn사용을 위한 deleted_at도 추가했습니다.)

 

 

국룰과도 같은 user, post로 Join의 속성들을 하나하나 알아보자.

 

 

 

 

테이블 생성

@Entity('post')
export class Post {
    @PrimaryGeneratedColumn('increment', { unsigned: true })
    id!: number;

    @Column({ type: 'varchar' })
    title!: string;

    @Column({ type: 'text' })
    content!: string;
    
    //soft delete를 위한 컬럼이 아닌, update cascade를 위한 필드
    @Column({ type: 'boolean', default: false })
    deleted!: boolean | null;
    
    @DeleteDateColumn({ type: 'datetime', precision: 0, nullable: true, default: null })
    deletedAt!: Date | null;
    
    @ManyToOne(() => User, (user) => user.posts)
    user!: User;
}
@Entity('user')
export class User {
    @PrimaryGeneratedColumn('increment', { unsigned: true })
    id!: number;

    @Column({ type: 'varchar' })
    name!: string;

    @Column({ type: 'varchar' })
    email!: string;
    
    //soft delete를 위한 컬럼이 아닌, update cascade를 위한 필드
    @Column({ type: 'boolean', default: false })
    deleted!: boolean | null;
    
    @DeleteDateColumn({ type: 'datetime', precision: 0, nullable: true, default: null })
    deletedAt!: Date | null;
    
    @OneToMany(() => Post, (post) => post.user)
    posts?: Post[];
}

 

 

 

보통 위와 같이, 관계를 설정해주면 런타임 시에 ORM에서 데코레이터를 기반으로 각 엔터티들을 읽고 데이터베이스에 직접 테이블을 생성/수정하게 된다. Post에 굳이 userId 컬럼을 직접 명시해주지 않아도 user의 PK값이 자동으로 FK로 설정되며, 디폴트로 모델명_PK명이 생성된다.

 

 

 

 

user에서, user아님으로 필드명을 변경해주자 아래처럼 FK 변경을 위해 작업을 추가로 수행한다.

 

 

 

 

관계를 정의했으니, 이제 어떤 관계인지를 실제로 설정해보자.

 

 

 

RelationOptions

TypeORM에서는, 엔터티 간의 관계에서 다음과 같은 설정들을 제공한다.

 

export interface RelationOptions {
    cascade?: boolean | ("insert" | "update" | "remove" | "soft-remove" | "recover")[];
    nullable?: boolean;
    onDelete?: OnDeleteType;
    onUpdate?: OnUpdateType;
    deferrable?: DeferrableType;
    createForeignKeyConstraints?: boolean;
    lazy?: boolean;
    eager?: boolean;
    persistence?: boolean;
    orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable";
}

 

 

 

cascade

cascade는 연관된 엔터티가 삽입, 수정, 삭제될 때 자동으로 전파되도록 설정하는 옵션이다. TypeORM에서 엔터티를 처리하는 방식을 제어하기 위해 사용된다. 쉽게 말해 코드레벨에서의 자식 엔터티로의 추가 작업을 전파하는 방법이며, 실제 데이터베이스의 제약 조건에는 영향을 끼치지 않는다. 또한 반드시 명시적으로 상태를 정의하거나, 변경해주어야 동작한다.

 

 

양방향에 cascade 제약 조건을 사용할 경우 순환 에러가 발생한다.
재귀적으로 상위가 하위에 전파된 동작을 하위에서 다시 상위로 전파하기 때문이다.

 

 

 

insert

@Entity('user')
export class User {
    @OneToMany(() => Post, (post) => post.user, { cascade: 'insert' })
    posts?: Post[];
}
it('CASCADE INSERT:: 유저 엔터티 내부에서 포스트 엔터티를 같이 생성할 수 있다.', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
        posts: [post],
    });

    // when
    await userRepo.save(user);

    const userResult = await userRepo.find({ relations: ['posts'] });
    expect(userResult).toHaveLength(1);
    expect(userResult[0].posts).toHaveLength(1);
});

 

 

 

 

 

user 객체에 정의된 posts를 정의하고 user를 save할 경우, post도 같이 저장된다.

 

 

 

 

update

@Entity('user')
export class User {
    @OneToMany(() => Post, (post) => post.user, { cascade: 'update' })
    posts?: Post[];
}
@Entity('user')
export class User {
    @OneToMany(() => Post, (post) => post.user, { cascade: 'update' })
    posts?: Post[];
}
it('CASCADE UPDATE:: 유저의 isActive 상태 변경이 포스트에 전파된다.', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
        posts: [post],
    });

    // when
    await userRepo.save(user);

    // when
    const userResult = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
    userResult.deleted = true; // 유저 상태 변경
    if (userResult.posts) {
        userResult.posts[0].deleted = true; // 포스트 상태 변경
    }

    await userRepo.save(userResult);

    // then
    const postResult = await postRepo.findOneOrFail({ where: { id: post.id } });
    expect(postResult).toBeDefined();
    expect(postResult?.deleted).toBe(true);
});

 

 

 

 

 

강제로 업데이트 상황을 만들어보기위해 어거지로 deleted를 업데이트 시켜주었다.

user가 update될 때, posts를 감지하여, 업데이트 된 변경사항이 있으면 자동으로 반영해준다.

 

 

 

 

remove

다들 짐작하겠지만 데이터베이스 레벨에서의 제약 조건을 설정하지 않았기 때문에, TypeORM이 정의하는 기본 옵션으로 정의된다. 하위 테이블에서 FK를 참조할 때, ON DELETE, ON UPDATE가 NO ACTION으로 정의된다.

 

 

 

위에서도 얘기했듯이, cascade는 단순 ORM에서 코드 동작을 정의하는 것이다. 그렇기 때문에 DB에서 DELETE에 대해 부모 레코드를 삭제할 수 없다는 에러를 반환하게 된다.

 

@Entity('user')
export class User {
    @OneToMany(() => Post, (post) => post.user, { cascade: 'delete' })
    posts?: Post[];
}
it('CASCADE REMOVE:: 유저 엔터티를 삭제하면 포스트 엔터티도 같이 삭제된다.', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
        posts: [post],
    });
    await userRepo.save(user);

    // when
    await userRepo.remove(user);

    const userResult = await userRepo.find();
    const postResult = await postRepo.find();
    expect(userResult).toHaveLength(0);
    expect(postResult).toHaveLength(0);
});

 

 

 

그렇기 때문에, 반드시 하위 테이블에 onDelete를 설정하여 DB에 제약조건을 걸어주도록 하자.

@Entity('post')
export class Post {
    @ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
    user!: User;
}

 

 

 

 

soft-remove

/**
 * Records the delete date of a given entity.
 */
softRemove<T extends DeepPartial<Entity>>(entity: T, options?: SaveOptions): Promise<T & Entity>;

 

 

TypeORM에는 soft delete를 위해 softRemove 인터페이스를 제공한다.

 

 

이 softRemove는 @DeleteDateColumn 데코레이터가 달린 날짜 형태의 필드를 제어하여 soft delete를 구현하도록 되어있다. 조회 관련 인터페이스에서는, 이를 기본적으로 조회하지 않도록 되어있으며, soft delete된 레코드 까지 조회하기 위해서는 withDeleted를 true로 지정해서 조회해야한다.

 

/**
 * Indicates if soft-deleted rows should be included in entity result.
 */
withDeleted?: boolean;

 

 

이제 cascade를 사용해서, soft-remove를 하위 테이블까지 전파해보려고 한다.

@Entity('user')
export class User {
    @DeleteDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
    deletedAt!: Date;

    @OneToMany(() => Post, (post) => post.user, { cascade: ['soft-remove'] })
    posts?: Post[];
}
it('CASCADE SOFT REMOVE:: 유저를 소프트 삭제하면 포스트도 소프트 삭제된다.', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    post.user = user;

    await userRepo.save(user);
    await postRepo.save(post);

    // when
    user.posts = [post];
    await userRepo.softRemove(user); // 소프트 삭제

    // then
    const allUsers = await userRepo.find();
    const allPosts = await postRepo.find();
    const userResult = await userRepo.find({ withDeleted: true });
    const postResult = await postRepo.find({ withDeleted: true });

    expect(allUsers).toHaveLength(0);
    expect(allPosts).toHaveLength(0);
    expect(userResult).toHaveLength(1);
    expect(postResult).toHaveLength(1);
});

 

 

단순 컬럼값을 제어하는 것이지 실제 삭제하는 것이 아니기 때문에, 하위 테이블에 ON DELETE 제약 조건을 걸 필요는 없다. 하지만 반드시 cascade를 soft-remove만 사용해야 하는 상황이라면 반드시 soft-remove 전에 엔터티 객체에 관계 명시를 해준 후에 삭제를 해야한다.

 

다소 불편한 것 같아서, 이슈를 찾아봤는데 이미 4~5년 전에 발행된 이슈가 있었다. 아직 미해결인 것 같지만 해결책이 없는 것 같아 각자의 방법대로 사용중인 것 같았다. 보통은 cascade를 true로 사용하면서, soft-remove를 사용하며, 실제 TypeORM의 soft-remove 테스트 코드도 그렇게 적용되어 있다. 나도 이슈에 코멘트를 달아놓고, 실제로 삭제할법한 두 가지의 상황을 가정하여 위 테스트 코드의 when 절을 변경해보았다. 두 가지 모두 삭제하기 위해 PK를 받아와서 삭제하는 상황이다.

 

// when

// 가능한 방법, 실제로 관계 명시를 통해 엔터티 객체를 그대로 삭제하여 하위 엔터티까지 영향
const fetchedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
await userRepo.softRemove(fetchedUser); // 소프트 삭제

// userId에 해당하는 테이블만 삭제하기 때문에, 하위 테이블에 영향 X
await userRepo.softDelete(user.id);

 

 

두 번째 방법이 동작하지 않는 이유는, 위에서 언급했듯 명시적으로 관계를 정의하지 않았기 때문인 것 같다.

 

 

 

 

recover

recover은 soft-remove로 인해 삭제되었던 레코드를 삭제되지 않은 상태로 복구하는 기능이다.

TypeORM의 recover 인터페이스를 사용하면되고, 거의 모든 상황에서 soft-remove와 같이 사용한다.

 

 

 

 

nullable

/**
 * Indicates if relation column value can be nullable or not.
 */
nullable?: boolean;

 

 

부모 자식 관계에서 자식 엔터티가 부모 엔터티가 nullable할 수 있기 때문에 이를 설정해주는 값이다.

 

 

@Entity('post')
export class Post {
    @ManyToOne(() => User, (user) => user.posts, { nullable: true })
    user?: User;
}

 

 

이를 통해 Post 엔터티에서 user_id 필드가 NULLABLE할 수 있다.

 

 

 

 

onDelete, onUpdate

cascade는 TypeORM이 코드 레벨에서 제약조건에 따른 이후 행동들을 정의한 것이라면, 이 두 옵션은 실제 데이터베이스에서 부모 엔터티의 행동에 따라 자식 엔터티의 외래 키를 어떻게 처리할지 결정한다. 실제 데이터베이스의 외래 키 제약 조건을 지정하는 옵션으로  앞, 뒤의 설정들은 모두 코드 레벨에서 동작을 정의하지만 유일하게 데이터베이스에 적용되는 옵션이다. 

 

 

onDelete

  • CASCADE: 부모 엔터티 삭제 시 자식 엔터티도 삭제
  • SET NULL: 부모 엔터티 삭제 시 자식 엔터티의 참조 키를 NULL로 설정
  • RESTRICT: 부모 엔터티가 자식 엔터티와의 관계를 유지하고 있을 경우 삭제 불가능

 

onUpdate

  • CASCADE: 부모 엔터티의 키가 변경될 때 자식 엔터티의 참조 키도 같이 변경됨
  • RESTRICT: 부모 엔터티의 키가 변경되면 에러 발생

 

 

 

 

 

deferrable

/**
 * Indicate if foreign key constraints can be deferred.
 * IMMEDIATE: 변경 사항 발생 시 즉시 확인한다.
 * DEFFRRED: 트랜잭션 커밋 시점에 확인한다.
 */
export type DeferrableType = "INITIALLY IMMEDIATE" | "INITIALLY DEFERRED";

 

 

외래키 제약 조건의 지연 여부를 설정한다.

 

 

it('INITIALLY DEFERRED:: 외래 키 제약 조건이 트랜잭션 커밋 시점에서 확인된다.', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    post.user = user;

    await userRepo.save(user);
    await postRepo.save(post);

    // when
    const queryRunner = userRepo.manager.connection.createQueryRunner();
    await queryRunner.startTransaction();

    // 부모 엔터티 삭제
    const fetchedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
    await queryRunner.manager.remove(fetchedUser);

    // 트랜잭션 중간 상태에서 외래 키 무결성 위반 발생 여부 확인
    const remainingPosts = await queryRunner.manager.find(Post);
    expect(remainingPosts).toHaveLength(1); // 삭제되지 않은 상태 확인
    try {
        // 커밋 시도
        await queryRunner.commitTransaction(); // 여기서 외래 키 제약 조건 위반 발생
    } catch (error: unknown) {
        console.error(error);
        if (error instanceof Error) {
            expect(error.message).toContain('foreign key constraint');
        } else {
            throw error;
        }
    } finally {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
    }
});

 

 

위와 같은 테스트를 작성하여, 예상 동작을 기대했지만 실패했다. InnoDB에서는 모든 외래 키 제약 조건을 항상 IMMEDIATE로 설정하기 때문에 코드 레벨에서 동작하는 deffered 옵션은 동작하지 않았다. 공식 문서를 살펴보니 MySQL의 스토리지 엔진 중 NDB만 deferrable을 지원하며 NO ACTION레벨에서만 지원한다고 한다.

 

 

 

 

 

 

psql의 경우 아래처럼 DEFFERABLE을 설정하여 사용이 가능하다.

 

CREATE TABLE post (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    user_id INT,
    CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user(id)
    ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED
);

 

 

 

 

createForeignKeyConstraints

/**
 * Indicates whether foreign key constraints will be created for join columns.
 * Can be used only for many-to-one and owner one-to-one relations.
 * Defaults to true.
 */
 createForeignKeyConstraints?: boolean;

 

데이터베이스에서 FK는 데이터 일관성을 유지한다는 장점을 가지고 있다. 하지만 각종 제약 조건으로 인해, 데이터 자체를 수동으로 변경해야하거나 서비스 규모가 큰 경우의 샤딩, 파티셔닝 등을 할 때 제약 조건에 따른 제약들이 발생한다. 우리 회사의 데이터베이스도 이러한 제약 조건에서 자유롭기 위해 FK를 제거하고, 논리적인 관계만 유지한 채로 사용하고 있다.

 

이러한 기능을 지원해주는 속성이 createForeignKeyConstraints이다. 이 속성을 활성화하면 FK 제약 조건을 걸지 않는다. 즉 데이터베이스에서 FK 상태가 아닌 것을 의미한다. 외래 키가 적용되지 않았더라도, 논리적인 관계는 유지할 수 있기 때문에 정상적으로 조회는 가능하며, 1:N 관계에서 하위 관계(N)에 적용하거나, 1:1관계에서만 적용이 가능하다.

 

 

@Entity('post')
export class Post {
    @ManyToOne(() => User, (user) => user.posts, {
        nullable: false,
        createForeignKeyConstraints: false,
        lazy: true,
    })
    user!: User;
}

 

기존의 Post 엔터티에서, createForeignKeyContstraints를 false로 설정하면, 아래처럼 Post 테이블에, User의 제약조건이 걸리지 않는다. 

 

 

실제 FK가 아니더라도 TypeORM이 연관된 데이터를 올바르게 로드할 수 있는지 확인하기 위해 위해 lazy loding(지연 로딩)을 적용해주었다. 지연 로딩을 통해 SQL JOIN 없이도 내부적으로 관계를 매핑하고 데이터를 로드할 수 있음을 검증해보자.

 

it('createForeignKeyConstraints:: 외래 키 제약 조건을 생성하지 않는다.
	논리적 관계는 유지되어 조회가 가능하다.', async () => {
    
    // given
    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    await userRepo.save(user);

    const post = postRepo.create({
        title: 'test',
        content: 'test',
        user,
    });

    await postRepo.save(post);

    // when
    const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });
    const fetchedUser = await fetchedPost.user;

    // then
    expect(fetchedPost.title).toBe(post.title);
    expect(fetchedUser.name).toBe(user.name);
});

 

 

 

 

FK를 걸지 않는다는 의미는 곧 INDEX 생성도 하지 않는다는 의미이다.

 

 

 

연관 관계가 있는 조회에서 성능 이슈가 발생하지 않으려면, 반드시 createForeignKeyConstraints를 false로 설정해 둔 관계 엔터티에 인덱스를 설정해주어야한다. 엔터티 정의 시 아래의 a, b 위치 중에 작성해주면 된다. 필자는 복합 인덱스는 클래스 레벨에, 단일 인덱스는 필드 레벨에 작성하는 편이다.

 

@Entity('post')
@Index('idx_user_id', ['user']) // ⓐ
export class Post {
    @Index('idx_user_id')       // ⓑ
    @ManyToOne(() => User, (user) => user.posts, {
        nullable: false,
        createForeignKeyConstraints: false,
        lazy: true,
    })
    user!: User;
}

 

 

 

 

lazy / eager (관계 테이블 로딩)

/**
 * Set this relation to be lazy. Note: lazy relations are promises. When you call them they return promise
 * which resolve relation result then. If your property's type is Promise then this relation is set to lazy automatically.
 */
lazy?: boolean;
/**
 * Set this relation to be eager.
 * Eager relations are always loaded automatically when relation's owner entity is loaded using find* methods.
 * Only using QueryBuilder prevents loading eager relations.
 * Eager flag cannot be set from both sides of relation - you can eager load only one side of the relationship.
 */
eager?: boolean;

 

lazy와 eager은 엔터티 간 관계를 로드하는 방식과 관련된 속성이다. 엔터티 간의 관계를 사용할 때 연관된 데이터를 언제, 어떻게 로드할지 제어한다. 편리하다고 생각되지만 개인적으로 잘 사용하지 않는 속성이다. 관련해서 포스팅도 작성했지만, 실제 Join을 명시하는 것을 좋아하는 내 습관 때문이다. 포스팅에서도 언급됐듯 TypeORM의 기본 로딩은 Lazy도 Eager도 아니다. 

 

 

 

lazy

지연 로딩으로도 불리는 lazy loding은 실제로 접근하려고 할 때 추가적인 조회를 통해 로드된다. 관계가 정의된 필드를 호출할 때 추가적인 쿼리 실행으로 데이터가 생성된다.

 

it('Lazy Loding', async () => {
    // given
    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    await userRepo.save(user);

    const post = postRepo.create({
        title: 'test',
        content: 'test',
        user,
    });

    await postRepo.save(post);

    // when
    const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });
    
    console.log('POST', fetchedPost); // 쿼리 구분을 위한 콘솔
    
    const fetchedUser = await fetchedPost.user;

    // then
    expect(fetchedPost.title).toBe(post.title);
    expect(fetchedUser.name).toBe(user.name);
});

 

 

 

 

위 콘솔을 보면 User객체는 없는데 어떻게 테스트가 통과했는지 의문이 들 수 있다.

lazy loding을 사용한 조회 시 지정한 엔터티는 Promise shell 상태로 조회된다. 이 Promise 내부에는 DB 접근 트리거 역할을 하는 LazyLoding Handler을 포함하고 있어 후에 await를 사용하여 해당 객체에 접근할 때 추가적인 DB I/O 를 통해 데이터를 가져올 수 있게 된다.

 

이러한 Lazy Loding의 특성은 초기 데이터 로딩 시 불필요한 데이터를 가져오지 않고, 필요할 때만 데이터를 가져오기 때문에 메모리 절약이 가능하다. 하지만 Promise 객체를 생성하는 과정에서 일반 조회 방식 대비 메모리 사용량은 많다. 특히 위 테스트 상황처럼 N + 1 문제가 발생하게 된다. N + 1 문제를 방지하기 위해, 적절하게 eager혹은 default를 사용하고, default 사용 시 쿼리 빌더 등으로 join을 명시해서 사용해주자.

 

 

 

eager

eager loding은 연관된 데이터를 엔터티를 로드하는 시점에 즉시 가져온다. find 인터페이스로 엔터티를 조회할 때 자동으로 연관된 데이터도 가져오게 된다.

it('Eager Loding', async () => {
    // given
    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    await userRepo.save(user);

    const post = postRepo.create({
        title: 'test',
        content: 'test',
        user,
    });

    await postRepo.save(post);

    // when
    const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title } });

    // then
    expect(fetchedPost.title).toBe(post.title);
    expect(fetchedPost.user.name).toBe(user.name);
});

 

 

항상 연관된 데이터를 포함해 로드하기 때문에 쿼리가 한 번만 실행되며, 자동으로 조인하여 로드되기 때문에 추가적인 쿼리 호출이 필요없다. 하지만 항상 데이터를 로드하기 때문에 성능 최적화를 위해서 반드시 관계 데이터가 항상 필요할 경우에만 사용하도록 하는것이 좋다.

 

 

 

 

persistence

/**
 * Indicates if persistence is enabled for the relation.
 * By default its enabled, but if you want to avoid any changes in the relation to be reflected in the database you can disable it.
 * If its disabled you can only change a relation from inverse side of a relation or using relation query builder functionality.
 * This is useful for performance optimization since its disabling avoid multiple extra queries during entity save.
 */
persistence?: boolean;

 

https://github.com/typeorm/typeorm/issues/2859

 

주석을 해석해보고, 위 이슈에서의 열띤 토론을 이해해보려고 했는데 테스트 작성에 실패했다. 도저히 어떤 테스트를 짜야할 지 모르겠다. 속성을 false로 지정하면 역방향에서만 관계를 변경할 수 있다고 하고, 혹은 쿼리 빌더를 이용해 관계 변경을 수행할 수 있다고 한다. 일단 차근차근 풀어보자.

 

우리의 User와 Post관계에서 관계의 소유자는 Post이다. Post에서 FK를 가지고 있기 때문이다.

반대로 역방향(Inverse Side)은 User쪽에서 Posts의 상태를 변경하는 상황이다.

 

// when: 역방향에서 관계 변경 시도
user.posts = [];
await userRepo.save(user);

// then: 역방향에서 관계 변경
const savedUser = await userRepo.findOneOrFail({ where: { id: user.id }, relations: ['posts'] });
expect(savedUser.posts).toHaveLength(0);
// when: 정방향에서 관계 변경 시도
const newUser = userRepo.create({
    name: 'new user',
    email: 'new@new.com',
});

await userRepo.save(newUser);

post.user = newUser;
expect(post.user).toBe(newUser);
await postRepo.save(post);

// then: 정방향에서 관계 변경
const savedPost = await postRepo.findOneOrFail({ where: { id: post.id }, relations: ['user'] });
expect(savedPost.user).toBe(user);

 

 

하지만, 지금 정방향에서도 역방향에서도 관계 변경 시 반영이 잘 되는 모습이라 제대로 구현이 되지 않았다. 만약 구현이 되더라도 어떠한 상황에서 적절히 사용해야 하는건지 잘 모르겠다. 이건 찾는 대로 해당 포스팅에 수정해두도록 하겠다.

 

 

 

 

 

orphanedRowAction

/**
 * When a parent is saved (with cascading but) without a child row 
 *   that still exists in database, this will control what shall happen to them.
 * delete will remove these rows from database.
 * nullify will remove the relation key.
 * disable will keep the relation intact. Removal of related item is only possible through its own repo.
 */
orphanedRowAction?: "nullify" | "delete" | "soft-delete" | "disable";

 

부모-자식 관계에서 부모 엔터티 저장 시, 기존에 자식 엔터티가 데이터베이스에 존재하지만 부모와의 관계가 끊어진 경우 해당 자식 엔터티의 처리를 정의하는 옵션이다. 코드 레벨에서 동작하는 옵션이기 때문에 상위 레벨인 FK 제약 조건이 걸려있다면 같은 설정이 아니라면 올바르게 동작하지 않는다.  같은 설정을 두더라도, 이는 DB 레벨에서 제약조건에 따른 결과이지, 코드 레벨에서 무언가 수행하기 위해 추가로 입력하는 것이 아니다.

 

예를 들어, ON DELETE CASCADE를 사용하고, orphanedRowAction을 nullify로 사용한다면, 제약 조건에 따라 삭제될 것이다. 따라서 기본적으로 createForeignKeyConstraints설정을 false로 두고 필요 시 코드레벨에서 정의해서 사용하는 설정이라고 생각된다.

 

  • nullify: 자식 엔터티의 외래 키 값을 NULL로 설정한다.
  • delete: 자식 엔터티도 같이 삭제한다.
  • soft-delete: 자식 엔터티를 소프트 딜리트한다. (@DeleteDateColumn)
  • disable: 아무 것도 하지 않는다.

 

it('orphanedRowAction: nullify', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    user.posts = await postRepo.find();
    await userRepo.save(user);

    post.user = user;
    await postRepo.save(post);

    const fetchedPost = await postRepo.findOneOrFail({ where: { title: post.title }, relations: ['user'] });

    // when: 부모-자식 관계 끊기
    user.posts = [];
    await userRepo.save(user);

    // then
    expect(fetchedPost.user).not.toBeNull();
    expect(fetchedPost.user?.name).toBe(user.name);

    const removedPost = await postRepo.findOneOrFail({ where: { title: post.title }, relations: ['user'] });
    expect(removedPost.user).toBeNull();
});

it('orphanedRowAction: delete', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    user.posts = await postRepo.find();
    await userRepo.save(user);

    post.user = user;
    await postRepo.save(post);

    const fetchedPost = await postRepo.find();

    // when: 부모-자식 관계 끊기
    user.posts = [];
    await userRepo.save(user);

    // then: 자식 엔터티가 삭제되었는지 확인
    const removedPost = await postRepo.find();
    expect(fetchedPost.length).toBe(1);
    expect(removedPost.length).toBe(0);
});

it('orphanedRowAction: soft-delete', async () => {
    // given
    const post = postRepo.create({
        title: 'test',
        content: 'test',
    });

    const user = userRepo.create({
        name: 'test',
        email: 'test@test.com',
    });

    user.posts = await postRepo.find();
    await userRepo.save(user);

    post.user = user;
    await postRepo.save(post);

    const fetchedPost = await postRepo.find();

    // when: 부모-자식 관계 끊기
    user.posts = [];
    await userRepo.save(user);

    // then: 자식 엔터티가 조회되지 않는지 확인.
    const removedPost = await postRepo.find();
    const whithDeletedPost = await postRepo.find({ withDeleted: true });

    expect(fetchedPost.length).toBe(1);
    expect(removedPost.length).toBe(0);

    expect(whithDeletedPost.length).toBe(1);
});

 

 

각각의 테스트에서 createForeignKeyConstraints를 false로 두고, orphanedRowAction을 제어하면서 테스트를 진행했다. 이 설정은 FK를 실제 DB에 사용하지 않으면서 무언가 코드 레벨에서의 제약 컨벤션(?)을 걸어 사용할 때 유용할 것 같다.

 

 

 

 

 

 

정리

이렇게 정리해놓고 보니, 현재 사용중인 엔터티들도 단순 leftJoinAndMap 으로 조인해서 사용할 것이 아니라, FK 제약조건을 없애고, 논리적으로만 매핑해서 사용하는 방법이 훨씬 나을 것 같다는 생각이 들었다. 생각보다 현재 로직들에서도 활용하면 좋을 것들이 눈에 보여 잘 정리했다는 생각이 든다.

 

테스트 코드는 깃허브에서 확인하실 수 있습니다.

 

 

 

 

 

references.

https://typeorm.io/

https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html

https://dev.mysql.com/doc/refman/8.4/en/ansi-diff-foreign-keys.html

https://stackoverflow.com/questions/55098023/typeorm-cascade-option-cascade-ondelete-onupdate

https://github.com/typeorm/typeorm/issues/2859

https://github.com/typeorm/typeorm/issues/5838

https://github.com/typeorm/typeorm/issues/5877

 

 

300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록