(260)

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는 애플리케이션을 구성할 때 거..

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

[Kotlin] Collections: 시퀀스(Sequences)는 무엇이고 언제 사용해야할까

안녕하세요. 저는 2년차 백엔드 개발자입니다.현재 JS(TS)로 개발하고 있고, Java는 코테용 언어로 사용하고 있습니다.코틀린을 사용하기 위해 현재 제가 알고 있는 지식 대비 필요 부분들만 학습하여 정리하는 Kotlin 시리즈입니다.피드백은 언제나 감사합니다!!     코틀린 기본 문법을 공부하고 있는 와중에, 컬렉션의 Lazy Evaluation을 지원하는 Sequence라는 것이 있다는 것을 알게 되었다. 기본 문법 포스팅에 내용을 남겼다가, 분리해야할 것 같아서 포스팅을 따로 작성했다.  SequenceKotlin 1.0에 추가된 Sequence 인터페이스는 Kotlin의 의 내장 라이브러리로, Collections의 다른 유형을 제공하기 위해 등장했다. 기본적으로 Sequence는 Iterat..

[Kotlin] 기본 문법 - Basic types, Collections, 조건문, 반복문

안녕하세요. 저는 2년차 백엔드 개발자입니다.현재 JS(TS)로 개발하고 있고, Java는 코테용 언어로 사용하고 있습니다.코틀린을 사용하기 위해 현재 제가 알고 있는 지식 대비 필요 부분들만 학습하여 정리하는 Kotlin 시리즈입니다.피드백은 언제나 감사합니다!! Kotlin 등장 배경내가 사용하고 있는 Nest는 nodejs의 구조적인 한계를 해결하기 위해 등장했으며 모듈 기반 아키텍처와 의존성 주입과 같은 개념을 적극 도입함으로써 nodejs의 자유도가 높은 구조를 개선하고 엔터프라이즈급 애플리케이션에서도 일관된 개발 경험을 제공하기 위해 만들어진 프레임워크이다.   코틀린의 공식 문서에는, 간결하고 안전하며 Java와 100% 호환이 되고 (JVM 위에서 동작)  NullSafety하다는 특징을 ..

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

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

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

Tech/JS,TS,NodeJS 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/Database 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/Database 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년차 주니어 개발자.

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

Tech/JS,TS,NodeJS 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,NodeJS 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년차 주니어 개발자.

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

Tech/JS,TS,NodeJS 2025. 4. 1. 17:27
728x90
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에서도 이를 사전에 인지했기 때문에 주석을 통해 명시적으로 사용자들에게 알렸다. 결국 중요한 건 사용자 개개인이 어느 수준까지 보안을 신경쓰고 코드를 작성할까? 라는 인지를 하고 코드를 써내려가는 것 아닐까?

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Kotlin] Collections: 시퀀스(Sequences)는 무엇이고 언제 사용해야할까

Tech/Java,Kotlin,Spring 2025. 2. 13. 13:40
728x90
728x90

 

안녕하세요. 저는 2년차 백엔드 개발자입니다.

현재 JS(TS)로 개발하고 있고, Java는 코테용 언어로 사용하고 있습니다.

코틀린을 사용하기 위해 현재 제가 알고 있는 지식 대비 필요 부분들만 학습하여 정리하는 Kotlin 시리즈입니다.

피드백은 언제나 감사합니다!!

 


 

 

 

 

코틀린 기본 문법을 공부하고 있는 와중에, 컬렉션의 Lazy Evaluation을 지원하는 Sequence라는 것이 있다는 것을 알게 되었다. 기본 문법 포스팅에 내용을 남겼다가, 분리해야할 것 같아서 포스팅을 따로 작성했다.

 

 

Sequence

Kotlin 1.0에 추가된 Sequence 인터페이스는 Kotlin의 의 내장 라이브러리로, Collections의 다른 유형을 제공하기 위해 등장했다. 기본적으로 Sequence는 Iterator을 통해 값을 반환하는 기능을 수행한다. 쉽게 말해 Collections의 처리 기능 중 하나라고 생각하면 되겠다.

(kotlin-stdlib/kotlin.sequences/Sequence)

 

package kotlin.sequences

/**
 * A sequence that returns values through its iterator. The values are evaluated lazily, and the sequence
 * is potentially infinite.
 *
 * Sequences can be iterated multiple times, however some sequence implementations might constrain themselves
 * to be iterated only once. That is mentioned specifically in their documentation (e.g. [generateSequence] overload).
 * The latter sequences throw an exception on an attempt to iterate them the second time.
 *
 * Sequence operations, like [Sequence.map], [Sequence.filter] etc, generally preserve that property of a sequence, and
 * again it's documented for an operation if it doesn't.
 *
 * @param T the type of elements in the sequence.
 */
public interface Sequence<out T> {
    /**
     * Returns an [Iterator] that returns the values from the sequence.
     *
     * Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
     */
    public operator fun iterator(): Iterator<T>
}

 

 

Sequence는 컬렉션과 달리 요소들을 포함하지 않고 반복하면서 생성한다. 순회하며 일련의 연산을 하는 것이 Iterable과 동일한 기능을 제공하지만 여러 연산에서 컬렉션 처리에 대해 다른 방식을 사용한다.

 

Collections의 인터페이스인 Iterable은 여러 연산을 처리할 때, 각 처리 단계를 순차적으로 완료하고, 하나의 연산이 종료될 때 마다 중간 결과를 임시 컬렉션에 저장한다. 다음 단계는, 초기에 선언했던 컬렉션이 아닌 생성한 임시 컬렉션을 통해 진행된다.

val numbers = listOf(1, 2, 3, 4, 5)

val result = numbers.map { 
    println("Mapping $it")
    it * 2
}.filter { 
    println("Filtering $it")
    it > 5
}.find { true }
print("Result $result")

 

 

위 연산에서 가장 처음 생성됐던 numbers 컬렉션에서, map을 수행하면 map의 결과 컬렉션이 생성되고, filter또한 마찬가지이다.

 

반대로 Sequence는  함수를 수행할 때 지연 실행을 지원하는데 공식문서에서는 이를 evaluated lazily라고 표현하고 있다. 즉 Collections을 통한 다양한 연산을 수행할 때 Lazy Evaluation이 가능하게 해주는 인터페이스가 Sequence이다.

 

 

Sequence 생성하기

Sequence는컬렉션과 동일하게 생성하거나, 컬렉션에서 변환할 수 있다.

val sequenceExam = sequenceOf('1', '2', '3')

val numbers = listOf(1, 2, 3, 4, 5)
val numbersSequence = numbers.asSequence()

 

 

컬렉션이나 sequenceOf 외에도 함수를 사용하여 동적으로 생성할 수도 있다. 이 때 기본적으로 무한대의 시퀀스를 생성하기 때문에 반드시 특정 종료 조건을 명시해야한다.

val oddNumbers = generateSequence(1) { it + 2 } // 1, 3, 5, ........
println(oddNumbers.take(5).toList()) //take(5)를 통해 5개만 가져오게끔 설정
// [1, 3, 5, 7, 9]



// ⚠️ 무한 루프
val oddNumbers = generateSequence(1) { it + 2 } // 1, 3, 5, ........
println(oddNumbers.count())



// 종료 조건을 명시
val oddNumbersLessThan10 = generateSequence(1) { if (it < 8) it + 2 else null }

 

 

마지막으로, sequence 블록({ })을 활용하여 시퀀스를 하나 또는 여러개를 동시에 생성할 수도 있다.

val oddNumbers = sequence {
    yield(1) // 개별 요소 반환
    yieldAll(listOf(3, 5)) // 리스트의 모든 요소 반환
    yieldAll(generateSequence(7) { it + 2 }) // 무한 시퀀스 반환
}
println(oddNumbers.take(5).toList()) 
// 출력: [1, 3, 5, 7, 9]

 

위 코드에서 yield(1)은 단일 값을, yieldAll(listOf(3, 5))는 리스트의 모든 값을 반한다. 또한 yieldAll(generateSequence(7) { it + 2 })를 통해 무한 시퀀스를 마지막에 추가할 수도 있다.

 

 

 

Iterable vs Sequence

위에서, 기본 컬렉션의 연산 처리 방식은, 하나의 함수가 끝나면 임시 컬렉션을 생성한다고 했다. 반대로 Sequence의 경우 지연 연산을 통해 데이터를 임시 컬렉션에 저장하지 않고 처리하는 방식이라고 설명했다.

 

말로 와닿지 않아서, 직접 코드로 작성해보았다. 아래는 간단한 Iterable VS Sequence 예제이다.

val numbers = listOf(1, 2, 3, 4, 5)

// 즉시 실행
val result = numbers.map { 
    println("Mapping $it")
    it * 2
}.filter { 
    println("Filtering $it")
    it > 5
}.find { true }
print("Result $result")


// 지연 실행
val result = numbers.asSequence().map { 
    println("Mapping $it")
    it * 2
}.filter { 
    println("Filtering $it")
    it > 5
}.find { true }
print("Result $result")
// 즉시 실행
Mapping 1
Mapping 2
Mapping 3
Mapping 4
Mapping 5
Filtering 2
Filtering 4
Filtering 6
Filtering 8
Filtering 10
Result 6


// 지연 실행
Mapping 1
Filtering 2
Mapping 2
Filtering 4
Mapping 3
Filtering 6
6

 

 

즉시 실행 방식은 numbers 컬렉션에 대해 하나의 메서드가 끝난 후 다른 메서드를 실행하는 방식이다. 위 예제에서는 map의 연산이 끝난 후 filter을, filter 연산이 끝난 후 find가 수행된다. 이 과정에서 numbers 배열에 연산의 결과가 덮어씌워지는 것이 아니라, 임시 컬렉션이 생성되면서 연산 결과를 가지고있게 된다.

 

 

 

하지만 sequence를 사용하면 임시 컬렉션이 생성되지 않고, 최종 연산이 호출되어 실행될 때 까지 연기된다.

(Java의 Stream 객체의 동작과 같다고 하는데 이 부분은 잘 모르겠다.)

 

중간 결과를 따로 저장할 임시 컬렉션이 생성되지 않고, 원소에 연산을 차례대로 적용하다가, 결과가 얻어진 후의 연산은 수행하지 않는다. 그래서 위 결과에서 filtering 6 이후의 연산은 수행되지 않는 것이다.

 

 

 

 

그렇다면 언제 사용해야할까? 사실 잘 와닿지가 않아서 극단적인 상황을 가정해봤다.

class IteratorVsSequenceTest {

    @Test
    fun `즉시 실행과 지연 실행의 성능 차이`() {
        try {
            val numbers = (1..10_000_000).toList()

            val iteratorMemory = measureMemoryUsage {
                val iteratorTime = measureTimeMillis { iteratorFunction(numbers) }
                println("Iterator 실행 시간: ${iteratorTime}ms")
            }

            val sequenceMemory = measureMemoryUsage {
                val sequenceTime = measureTimeMillis { sequenceFunction(numbers) }
                println("Sequence 실행 시간: ${sequenceTime}ms")
            }

            println("Iterator 메모리 사용량: ${iteratorMemory} KB")
            println("Sequence 메모리 사용량: ${sequenceMemory} KB")

        } catch (e: Exception) {
            println("테스트 실패: ${e.message}")
        }
    }

    private fun iteratorFunction(lists: List<Int>): Int? {
        return lists
            .filter { it % 2 == 0 }
            .filter { it % 3 == 0 }
            .map { it * it }
            .map { it / 2 }
            .filter { it > 1_000_000 }
            .firstOrNull { it % 123456 == 0 }
    }

    private fun sequenceFunction(lists: List<Int>): Int? {
        return lists.asSequence()
            .filter { it % 2 == 0 }
            .filter { it % 3 == 0 }
            .map { it * it }
            .map { it / 2 }
            .filter { it > 1_000_000 }
            .firstOrNull { it % 123456 == 0 }
    }

    private fun measureMemoryUsage(block: () -> Unit): Long {
        val runtime = Runtime.getRuntime()
        System.gc()
        val before = runtime.totalMemory() - runtime.freeMemory()
        block()
        val after = runtime.totalMemory() - runtime.freeMemory()
        return (after - before) / 1024
    }
}

 

천 만개의 numbers List를 사용해서, 시퀀스함수에서는 최대한 빨리 조건을 만족해서 빠져나올 수 있도록 구성해봤다. 레퍼런스들을 찾아보고 메모리 사용량을 볼 수 있는 코드도 추가해보았다. 과연 결과는 어떻게 나왔을까?

 

Iterator 실행 시간: 249ms
Sequence 실행 시간: 6ms
Iterator 메모리 사용량: 184529 KB
Sequence 메모리 사용량: 512 KB

 

 

실행 시간 측면에서는 다소 차이가 있었지만 메모리 사용량에서 차이가 엄청나게 많았다. 위에서 설명했듯이 함수 하나하나에 임시 컬렉션을 사용하기 때문에 메모리 사용량에서 엄청난 차이가 있다. 위처럼 극단적인 상황은 없겠지만, 유추해볼 때 파일을 포함한 대용량 데이터를 핸들링할 때 사용해야할 것으로 보인다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Kotlin] 기본 문법 - Basic types, Collections, 조건문, 반복문

Tech/Java,Kotlin,Spring 2025. 2. 12. 17:29
728x90
728x90

 

안녕하세요. 저는 2년차 백엔드 개발자입니다.

현재 JS(TS)로 개발하고 있고, Java는 코테용 언어로 사용하고 있습니다.

코틀린을 사용하기 위해 현재 제가 알고 있는 지식 대비 필요 부분들만 학습하여 정리하는 Kotlin 시리즈입니다.

피드백은 언제나 감사합니다!!


 

Kotlin 등장 배경

내가 사용하고 있는 Nest는 nodejs의 구조적인 한계를 해결하기 위해 등장했으며 모듈 기반 아키텍처와 의존성 주입과 같은 개념을 적극 도입함으로써 nodejs의 자유도가 높은 구조를 개선하고 엔터프라이즈급 애플리케이션에서도 일관된 개발 경험을 제공하기 위해 만들어진 프레임워크이다.

 

 

 

코틀린의 공식 문서에는, 간결하고 안전하며 Java와 100% 호환이 되고 (JVM 위에서 동작)  NullSafety하다는 특징을 Introduce에 서술해두었다. 이 밖에도 코틀린이 등장한 배경이라던가, 해결하고자 하는 문제가 무엇이었는지 찾아보고 싶어 찾아봤고, 기존의 자바에서 어떤 문제가 있었는지는 직접 자바로 서버 구축을 하고 유지 보수를 해보지 않아서, 정리된 글들을 가져왔다.

 

출처: f-lab

 

 

 

나는 이 수많은 이유들 중에 안드로이드 앱을 여러 개 출시할 계획으로 코틀린 공부를 시작한다.

현재 사용중인 RN / Node(Nest)를 통해 개발을 하면 훨씬 생산성이 향상되겠지만, 익숙함을 잠시 내려놓고 개인적인 개발을 할 때는 새로운 환경에서 다시 시작해보려한다.

 


Take Kotlin Tour

공식 문서의 기본 가이드에 간단히 나와있는 언어의 핵심 개념 페이지들을 하나씩 읽어보며 공부한 내용들을 정리해보았다. 내용이 너무 길어져서, Functions (+ Lamda), Classes, Null Safety는 다음 포스팅에 정리해서 총 2회분의 포스팅을 통해 문법을 정리하려고 한다.

 

 

 

 

 

 

 

Variable

JS의 let, const처럼 mutable하고 immutable한 키워드인 var, val을 통해 변수 선언을 할 수 있다.

// Immutable variable ( == const )
val test1 = 10
test1 = 5 // ❌ Val cannot be reassigned


// Mutable variable ( == let )
var test2 = "test" // 타입 추론 (test2: String)
test2  = 1         // ❌ Type Error (정수 리터럴이 String과 호환되지 않음)
tet2 = "TEST2"     // ✅


// 객체의 멤버 변수에서 `val`은 readonly처럼 동작
data class User(var name: String, val age: Int)
val user = User("mag1c", 30)
user.name = "mag2c" // ✅ 변경 가능
user.age = 20      // ❌ 변경 불가능


val list = mutableListOf(1, 2, 3)
list.add(4)                    // ✅ 가능 (리스트 내부 값 변경 가능)
list = mutableListOf(5, 6, 7)  // ❌ 오류 (val 자체는 변경 불가능)

 


Basic Types

TS처럼 자료형을 콜론(:)을 이용해 명시할 수도, 생략할 수도 있다. 마찬가지로 TS처럼 타입 추론을 자동으로 할 수 있기 때문에 굳이 명시하지 않아도 된다. 신기한 부분은, Unsigned Integers를 지원한다는 점이었다.

  Basic Types 예시
Integers Byte, Short, Int, Long val year: Int = 2020
Unsigned integers UByte, UShort, UInt, ULong val score: UInt = 100u
Floating-point numbers Float, Double val currentTemp: Float = 24.5f, val price: Double = 19.99
Booleans Boolean val isEnabled: Boolean = true
Characters Char val separator: Char = ','
Strings String val message: String = "Hello, world!"
Any Any val anything: Any = "HELLO"

 

Any 타입은 모든 타입의 부모 타입(super type)이다. 기본적으로 모든 값이 될 수 있다. TS의 any와 유사하지만 코틀린에서의 Any 타입은 nullable하지 않다. 옵셔널 연산자인 ?을 통해 nullable을 명시할 수 있다.

//TS
let value: any;  // undefined
value = 42;      // ✅
value = null;    // ✅
//Kotlin
var value: Any = "HELLO"
value = 42      // ✅
value = null    // ❌

var value: Any? = "HELLO"
value = 42      // ✅
value = null    // ✅

 

 

Type Check

is를 통한 타입 체크도 가능한데, TS의 typeof와 유사하게 동작한다. 다만 TS의 typeof는 object 타입이 어떤 객체인지까지는 명시해주지 않지만, is를 사용하게되면 instanceof 처럼 동작한다는 점이다.

// is를 통한 타입 체크가 가능
val string1 = "string1"
val isString = if (string1 is String) true else false
print(isString)

 

또한 TS에 있는 as를 통한 명시적 형변환도 코틀린에서 가능하다.

// casting
val obj: Any = "Hello"
val str: String = obj as String  // ✅ 명시적 형 변환
println(str.length)  // 5

// null-safe한 casting
val obj: Any? = null
val str: String? = obj as? String  // ✅ 안전한 형 변환 (null 반환 가능)

String Templates

JS의 Template literals과 유사하지만, 코틀린의 문법이 조금 더 편리하다고 느꼈다. JS에서는 항상 중괄호로 감싸주어야했지만, 코틀린에서는 달러 기호($) 하나면 끝이다.

// JS
let name = "mag1c"
let message = "HELLO"

function sendMessage(name, message) {
	console.log(`[${name}] ${message}`);
}
val name = "mag1c"
val message = "HELLO"

fun sendMessage(name: String, message: String) {
	println("[$name] $message")
}

 

Collections

기본적으로 List, Set, Map을 지원한다. 아래는 코틀린의 기본적인 Collections 구조이다.

 

 

 

기본적으로 JS의 Iterable과 같은 개념으로 보인다. forEach, iterator, hasNext등을 사용할 수 있고, 이런 내장 메서드를 통해 요소를 순회할 수 있다. 반대로 MutableIterable은 Iterable을 상속하지만, mutable한 요소를 구현하여 쓰기 작업을 가능하게 한다.

// Iterable<T>
val list: Iterable<Int> = listOf(1, 2, 3)
for (item in list) {
    println(item)
}



// MutableIterable<T>
val list = mutableListOf(1, 2, 3)
val iterator = list.iterator()
while (iterator.hasNext()) {
    if (iterator.next() == 2) {
        iterator.remove() // ✅ 요소 제거 가능
    }
}
println(list) // [1, 3]



// Collection<T>
val collection: Collection<Int> = listOf(1, 2, 3)
println(collection.size)        // ✅ 크기 확인 가능
println(collection.contains(2)) // ✅ 특정 요소 포함 여부 확인 가능
collection.add(4)               // ❌ (Collection<T>에는 add() 없음)



// MutableCollection<T>
val mutableCollection: MutableCollection<Int> = mutableListOf(1, 2, 3)
mutableCollection.add(4)    // ✅ 가능
mutableCollection.remove(1) // ✅ 가능

 

 

Map은 Iterator의 특성인 순회를 전제로 하지 않기 떄문에 독립적인 Collections을 구현한 것으로 보인다. 여튼 콜렉션 구현을 위한 인터페이스는 동일하다.

// List
val fruits = listOf("apple", "banana", "orange") // immutable
val myDoggies = mutableListOf("guzzi", "coco")   // mutable

// Set
val fruits = setOf("apple", "banana", "orange")  // immutable
val myDoggies = mutableSetOf("guzzi", "coco")    // mutable

// Map
val fruits = mapOf("apple" to 100, "banana" to 200, "orange" to 300)  // immutable
val myDoggies = mutableMapOf("guzzi" to 1, "coco" to 2)               // mutable

 

기본적으로 in 연산자도 지원하는 것으로 보인다.

println("apple" in fruits) //true

 

 

 

고차 함수(?)

JS의 map(), filter() 등의 Array.prototype에 있는 고차 함수들을 List, Set에서도 사용이 가능했다.

// map
val numbers = listOf(1, 2, 3)
val squared = numbers.map { it * it }
println(squared) // [1, 4, 9]



// filter
val numbers = listOf(1, 2, 3, 4, 5)
val evens = numbers.filter { it % 2 == 0 }
println(evens) // [2, 4]


// forEach
val numbers = listOf(1, 2, 3)
numbers.forEach { println(it) } // 1 2 3



// reduce
val numbers = listOf(1, 2, 3, 4)
val sum = numbers.reduce { acc, num -> acc + num }
println(sum) // 10



// flatMap
val arr = listOf(listOf(1, 2), listOf(3, 4))
val flattened = arr.flatMap { it }
println(flattened) // [1, 2, 3, 4]

 


Control Flow

조건문

IF문이나 WHEN문을 통해 조건문을 작성할 수 있으며, 공식 문서에 따르면 둘 중 하나를 선택하여 사용해야한다면 WHEN이 가독성, 유지보수, 확장성 측면에서 낫다고 설명하고 있다.

 

 

val trafficLightState = "Red"
var action: String

// if
if (trafficLightState === "Green") {
    action = "GO"
}
if (trafficLightState === "Yellow") {
    action = "SLOW DOWN"
}
if (trafficLightState === "Red") {
    action = "STOP"
}

// when
val action2 = when (trafficLightState){
    "Green" -> "GO"
    "Yellow" -> "SLOW DOWN"
    "Red" -> "STOP"
    else -> "?"
}

 

 

 

 

반복문

// Range 표현
1..4            // 1, 2, 3, 4
1..<4           // 1, 2, 3
4 downTo 1      // 4, 3, 2, 1
4..1            // ❌
1..5 step 2     // 1, 3, 5
'a'..'d'        // a, b, c, d
'z' downTo 's'  // z, y, x, ..., s
// for
for (number in 1..5) {}

val array = IntArray(10) { 0 }
for (elem in array) {}  // array 내부 요소를 하나씩 선택해서 순회

// while
while(true) { return }

// do while
var num = 0
do {
    println("TEST")
    num++
} while (num < 3)

 

 

 

 

 

 

 

 

References

https://kotlinlang.org/docs/kotlin-tour-welcome.html

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

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

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

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록