본문으로 건너뛰기

"javascript" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

모든 태그 보기

Map vs Object, 200ms 차이가 나는 진짜 이유 (ft. Hidden Class)

· 약 6분
김현재
개발자

들어가며

자바스크립트를 활용해 개발 또는 알고리즘 문제를 풀다 보면 Map 객체를 사용해야 하는 경우가 많습니다. 그런데, Map이 Object와 크게 다르지 않아 보이는데 독자 분들은 이 둘의 차이점에 대해 알고 계신가요? 이 글에서는 Map에 대해 알아보며, Object와의 차이점은 무엇이며 자바스크립트 엔진은 객체를 어떻게 최적화하는지 알아보려고 합니다.

Map이란?

Map 객체는 키-값의 쌍을 저장하는 데이터 구조입니다. new Map() 생성자를 통해 생성할 수 있으며, 여러 내장 기능을 제공합니다.

const myMap = new Map();

Map 객체에 요소를 추가하고 싶다면 set() 메서드를 사용하면 됩니다.

myMap.set('name', '일순신');

Map의 키는 유일합니다. 따라서 같은 키로 여러 번 값을 설정하면 마지막 값으로 덮어 씌워집니다.

myMap.set('name', '일순신');
myMap.set('name', '이순신'); // <<- 이 값으로 덮어씌워져요

Map 객체가 제공하는 주요 메서드는 아래와 같습니다.

  • map.set(key, value) : 값 저장
  • map.get(key) : key에 해당하는 값 반환
  • map.has(key) : key 존재 여부에 따라 true or false 반환
  • map.delete(key) : key에 해당하는 값 삭제
  • map.clear() : 객체 안의 모든 요소 삭제
  • map.size : 요소의 개수 반환

더 궁금한 Map의 기능이 있다면, 아래 MDN 문서를 참고하는 것을 추천드립니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Map

Object와 Map의 차이

자바스크립트이 Object와 Map은 비슷해 보이지만, 몇 가지 차이점이 존재합니다. 둘이 어떻게 다른지 아래 내용을 통해 알아봅시다. 편의를 위해 Object와 Map은 다음 코드와 같이 정의되어 있다고 가정하겠습니다.

const myMap = new Map();
const myObj = {};

키 유형

Object의 키는 String 또는 Symbol 만 가능합니다. 반면, Map은 어떤 값이든 키로 사용할 수 있습니다.(객체, 함수, 원시값 등)

myMap.set({ name: '홍길동' }, { id: 0 }); // 가능
myObj[{ name: '홍길동' }] = { id: 0 }; // 불가능

키 순서

Object의 키는 순서가 보장되지 않습니다. 하지만 Map은 삽입 순서대로 키를 유지하는 이터러블 객체입니다.

이터러블(Iterable) 객체란? 데이터를 순차적으로 반복할 수 있는 객체를 의미합니다.

순회

Map은 이터러블 객체이므로 for..offorEach같은 반복문을 사용할 수 있습니다. 반면 Object는 Object.keys()Object.entries()같은 메서드를 통해 간접적으로 순회해야 하며, 키 순서가 보장되지 않는다는 특징을 가지고 있습니다.

myMap.forEach((item) => {
console.log(item);
});

직렬화

Object는 JSON.stringify()JSON.parse()를 사용해 JSON으로 직렬화하거나 역직렬화할 수 있습니다. 하지만 Map은 기본적으로 지원되는 메서드가 없습니다.

만약 Map 객체를 직렬화 하고 싶다면 배열 또는 객체로 변환한 후에 직렬화를 해야합니다.

const myMap = new Map([
['id', 1],
['name', 'Gemini'],
]);

// 1. 직렬화 (Array로 변환)
const json = JSON.stringify(Array.from(myMap.entries()));
console.log(json); // [["id",1],["name","Gemini"]]

// 2. 역직렬화 (다시 Map으로 복구)
const restoredMap = new Map(JSON.parse(json));

Object와 Map 성능 비교

Object와 Map은 요소 추가/삭제 성능에서 차이를 보입니다. 특히, 객체 프로퍼티의 빈번한 추가/삭제가 필요한 경우 Map이 Object보다 더 효율적입니다. 이 이유는 아래 히든 클래스에서 다룰 예정이니 끝까지 읽어주세요!

먼저 아래 코드의 실행 결과를 확인해서 Object와 Map의 성능 차이를 확인할 수 있습니다.

const iterations = 1000000;

// Object 테스트
console.time('Object 추가/삭제');
for (let i = 0; i < iterations; i++) {
myObj[`key${i}`] = i;
}
for (let i = 0; i < iterations; i++) {
delete myObj[`key${i}`];
}
console.timeEnd('Object 추가/삭제');

// Map 테스트
console.time('Map 추가/삭제');
for (let i = 0; i < iterations; i++) {
myMap.set(`key${i}`, i);
}
for (let i = 0; i < iterations; i++) {
myMap.delete(`key${i}`);
}
console.timeEnd('Map 추가/삭제');

위 코드를 실행하면 약 200ms 정도 차이가 나는 결과를 볼 수 있습니다.

Object와 Map 성능 테스트

그렇다면 어떠한 이유로 성능 차이가 발생하는 걸까요? 이 질문에 대한 답을 알아보기 위해 다음 챕터로 넘어가봅시다.

자바스크립트의 성능 최적화

자바스크립트는 동적 타이핑 언어로, 런타임에 객체의 프로퍼티를 자유롭게 추가하거나 수정할 수 있는 유연성을 제공합니다. 이로 인해 자바스크립트는 속성 접근 시 동적으로 프로퍼티를 조회해야 하는 특성을 지니고 있습니다. 초기 자바스크립트 엔진은 객체를 해시 테이블 구조로 관리했으나, 현대 엔진(v8, SpiderMonkey 등)은 히든 클래스와 인라인 캐싱 같은 기법을 적용해 최적화를 하고 있습니다.

전통적인 해시 테이블 기반 객체 속성 접근 방식은 다음과 같습니다. 객체에서 x 라는 문자열 키에 해당하는 값을 찾는 과정을 예시로 들겠습니다.

  1. 'x' 문자열을 해시 함수로 해싱하여 해시값을 생성합니다.
  2. 생성한 해시값을 사용해 해시 테이블의 버킷 인덱스를 계산합니다.
  3. 해당 버킷에 접근하여, 충돌이 발생한 경우 버킷 내에서 선형 탐색 또는 충돌 해결 방식을 수행합니다.
  4. 키 문자열을 비교하여 일치하는 항목을 찾습니다.
  5. 연결된 값을 반환합니다.

히든 클래스

현대 자바스크립트 엔진(V8 등)은 히든 클래스를 활용해 객체의 속성 접근 성능을 최적화해야 합니다. 히든 클래스는 객체의 프로퍼티 구조, 프로퍼티 오프셋(메모리 내 위치), 그리고 프로퍼티 추가/삭제에 따른 전환 정보를 저장하고 있는 내부 데이터 구조입니다. 이를 통해 V8은 객체 구조 변경 시 히든 클래스를 생성하거나 재사용해 성능을 높이고 있습니다.

코드와 함께 히든 클래스가 어떻게 생성되고 사용되는지 알아봅시다. 편의상 현대 자바스크립트 엔진은 V8로 표기하겠습니다.

빈 객체를 생성하면 V8 엔진은 해당 객체에 대한 초기 히든 클래스(C0)를 생성합니다. 이 시점의 히든 클래스는 아직 프로퍼티가 없습니다.

const point = {};

객체에 x프로퍼티를 추가하면, V8 엔진은 기존 히든 클래스(C0)를 기반으로 새로운 히든 클래스(C1)를 생성합니다. 히든 클래스(C1)은 프로퍼티 x와 메모리 오프셋 정보를 포함하며, C0 -> C1 전환 경로를 기록합니다.

point.x = 10;

객체에 새로운 프로퍼티 y를 추가합니다. V8은 기존 히든 클래스(C1)를 상속하여 또 다른 히든 클래스(C2)를 생성합니다. 내부 구조는 히든 클래스(C1)과 동일합니다.

V8 엔진은 동일한 구조(같은 프로퍼티를 같은 순서로 추가한 객체)를 가진 객체들이 동일한 히든 클래스를 공유하도록 합니다. 이를 통해 속성 접근 시 성능을 최적화하고 있습니다.

주의할 점은 객체 프로퍼티티를 동일한 순서가 아닌 다른 순서로 추가하거나 삭제하면 히든 클래스를 재사용하지 못합니다. 따라서 객체 구조를 일관되게 유지하는 것이 중요합니다.

다만 객체의 프로퍼티가 빈번하게 추가/수정 될 경우 히든 클래스 생성 오버헤드를 유발합니다. 따라서 객체의 프로퍼티가 빈번하게 변경되는 경우 Map이 더 유리합니다.

인라인 캐싱

인라인 캐싱은 자주 사용되는 객체의 속성이나 메서드 접근 결과를 캐싱하여 동일한 접근이 발생했을 때 빠르게 결과를 제공해주는 최적화 기법입니다.

function getGeo(geo) {
const { x, y } = geo;
return [x, y];
}

const seoul = { x: 10, y: 10 };
const gyeonggido = { x: 5, y: 5 };

console.log(getGeo(seoul)); // 1번 호출
console.log(getGeo(gyeonggido)); // 2번 호출

1번 호출이 일어날 때 V8 엔진은 seoul 객체의 x, y 프로퍼티에 대한 접근을 캐싱합니다. 그리고 2번 호출이 일어날 때 엔진은 gyeonggido 객체가 seoul 객체와 동일한 히든 클래스를 가진다는 것을 감지하고, 1번 호출에서 캐싱된 결과를 사용해 계산을 빠르게 수행합니다.

마무리

객체 구조가 고정적이고 단순하며, JSON 직렬화가 필요하거나 프로토타입 상속을 활용해야 한다면 Object를 사용하는 것을 추천합니다.

반면, 객체의 프로퍼티 추가/삭제가 빈번하게 발생하거나, 다양한 키 유형을 사용해야 하고, 이터러블한 특성이 필요하다면 Map이 유리합니다.

콜백이 싫어서 만들어진 Promise

· 약 4분
김현재
개발자

들어가며

자바스크립트에서 비동기 작업은 어떻게 처리할 수 있을까요?

콜백, 프로미스, async/await.. 굉장히 많은 방법이 존재합니다. 그렇다면 이 키워드들은 어떤 특징을 가지고 있을까요? 이번 글에서는 각각의 특징에 대해 알아보려고 합니다.

콜백 함수란?

자바스크립트에는 콜백 함수라는 개념이 존재합니다.

콜백 함수는 "다른 함수에 인자로 전달되어, 특정 시점에 실행되는 함수" 를 말합니다. 쉽게 말해 "나중에 너가 할 일 다 끝나면 이 함수 좀 대신 실행해줘"라고 부탁하는 것과 같습니다. 콜백 함수라는 개념이 있는 이유는 자바스크립트의 함수가 일급 객체이기 때문입니다. 먼저 일급 객체에 대해 알아봅시다.

일급 객체

자바스크립트에서는 함수를 일급 객체로 취급합니다. 이는 함수를 으로 다룰 수 있다는 뜻입니다. 일급 객체가 되기 위한 조건은 크게 세 가지입니다.

  1. 함수를 변수에 담을 수 있어야합니다.
  2. 다른 함수를 호출할 때 파라미터로 넘길 수 있어야합니다.
  3. 함수가 또 다른 함수를 반환할 수 있어야합니다.

콜백 함수를 통한 비동기 작업

아래 예시 코드를 살펴봅시다. setTimeout을 이용해 1초마다 숫자를 1씩 증가시키는 코드입니다.

function increase(count, callback) {
setTimeout(() => {
const increased = count + 1;
console.log(increased);
if (callback) {
callback(increased);
}
}, 1000);
}

increase(0, (count) => {
increase(count, (count) => {
increase(count, (count) => {
increase(count, (count) => {
console.log('종료');
});
});
});
});

increase 함수는 1초를 기다린 후, 계산이 끝나면 넘겨준 callback을 호출하며 결과값을 전달합니다.

Promise란?

프로미스는 자바스크립트의 객체로 비동기 작업의 완료 또는 실패를 나타내는 객체입니다. 프로미스는 다음 중 하나의 상태를 가질 수 있습니다.

  • 대기(pending) : 초기 상태
  • 이행(fulfilled) : 연산이 성공적으로 완료됨
  • 거부(rejected) : 연산이 실패함

Promise로 비동기 처리하기

프로미스 객체는 new 키워드와 함께 Promise 생성자 함수를 이용하여 생성할 수 있습니다. 또한 프로미스 생성자의 매개변수로 두 개의 매개변수를 가진 콜백 함수를 넣을 수 있습니다. 매개변수는 각각 작업이 성공했을 때의 resolve 객체, 작업이 실패했을 때의 reject 객체입니다.

const promise = new Promise((resolve, reject) => {
// 비동기 작업

if (성공) {
resolve();
} else {
reject;
}
});

우리가 자주 사용하는 fetch 메서드 또한 프로미스를 반환한다는 사실을 알고 계셨나요? 아래 예제는 https://jsonplaceholder.typicode.com/ 를 이용해서 작성했습니다. API 요청 테스트가 필요할 때 사용하시면 좋은 서비스입니다.

function getUser() {
return fetch('https://jsonplaceholder.typicode.com/todos/1').then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

function getPost(userId) {
return fetch(`https://jsonplaceholder.typicode.com/posts/${userId}`).then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

function getComment(postId) {
return fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`).then((response) => {
if (!response.ok) {
throw new Error('API 요청 실패');
}
return response.json();
});
}

getUser().then(({ userId }) =>
getPost(userId)
.then(({ id }) => getComment(id))
.then((comment) => console.log(comment)),
);

이렇게 작성된 함수들을 프로미스 체인(then)을 이용하여 비동기 작업을 처리할 수 있습니다.

then() 함수는 새로운 프로미스를 반환합니다.

async/await

async/await을 사용하면 프로미스를 더욱 편하게 사용할 수 있습니다. async/await은 프로미스의 문법적 설탕(Syntax Sugar)입니다.

문법적 설탕(Syntax Sugar)은 기능은 그대로 유지하지만, 가독성을 높이고 더 간결하게 사용할 수 있도록 변경하는 것을 의미합니다.

async 함수

async 키워드를 function 앞에 위치시키면 해당 함수는 항상 프로미스를 반환합니다. 만약에 함수 내부에서 강제로 프로미스가 아닌 값을 반환하게 하더라도 이행된 프로미스를 반환합니다.

async function foo() {
return 1;
}

foo().then((data) => console.log(data)); // 1

await

awaitasync 함수 내부에서만 동작합니다. await 키워드를 만나면 프로미스가 처리될 때까지 기다리고 결과는 그 이후에 반환됩니다.

그렇다면 위에 프로미스로 작성한 코드를 async 함수들로 변경하면 아래와 같습니다.

async function getUser() {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/1`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

async function getPost(userId) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${userId}`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

async function getComment(postId) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`);

if (!response.ok) {
throw new Error('API 요청 실패');
}

return response.json();
}

이후 이 함수들을 순차적으로 실행시키기 위한 메인 함수도 하나 만들어 주겠습니다.

async function main() {
try {
const userData = await getUser();
const postData = await getPost(userData.id);
const commentData = await getComment(postData.id);

console.log(commentData);
} catch (error) {
console.error('Error: ', error);
}
}

main();

위처럼 작성된 코드는 우리가 원하던 대로 비동기 코드이지만 순차적으로 실행되게 됩니다.

용어 정리

지금까지 학습한 내용을 간단하게 정리해보겠습니다.

Promise

프로미스란 비동기 작업의 완료 또는 실패를 나타내는 객체입니다. 과거 비동기 작업을 콜백으로 처리했을 때 발생했던 콜백 지옥 문제와 중첩된 에러 핸들링 문제를 then 메서드를 활용해 프로미스 체이닝으로 해결할 수 있습니다. 또한 매개변수로 주어진 콜백은 주어진 순서대로 실행되는 것을 보장합니다.

async/await

function 키워드 앞에 async 키워드를 붙여 async 함수를 생성할 수 있습니다. await 키워드는 반드시 async 함수 내부에서만 사용이 가능하며 try/catch 문을 통해 에러를 핸들링합니다.

마무리

이번 글에서는 자바스크립트에서 비동기를 처리하기 위해 사용하는 콜백 함수, 프로미스, async/await에 대해 알아봤습니다. 자바스크립트 언어가 발전함에 따라 과거의 불편했던 점을 개선해나가는 모습을 볼 수 있습니다. 과연 앞으로는 어떤 문법들이 등장할까요?

똑똑하게 라이브러리 관리하기: npm, yarn, pnpm

· 약 7분
김현재
개발자

들어가며

JavaScript 프로젝트를 시작하면 가장 먼저 하는 일이 npm install입니다. 너무 자연스럽게 사용하고 있지만, 패키지 매니저가 정확히 어떤 일을 하는지, 그리고 npm 외에 yarn과 pnpm은 어떤 차이가 있는지 궁금해진 적이 있을 것입니다.

이번 포스팅에서는 패키지 매니저가 무엇인지부터 시작하여, npm, yarn, pnpm 각각의 특징과 장단점을 비교하고, 마지막으로 모노레포 환경에서 pnpm이 주목받는 이유까지 정리해보려고 합니다. 저도 모노레포 환경에서 pnpm을 선호하고 있습니다!

패키지 매니저란?

패키지 매니저는 프로젝트에서 사용하는 외부 라이브러리(의존성)를 설치하고 관리하는 도구입니다.

우리가 코드를 작성할 때 import React from 'react'처럼 간단하게 외부 라이브러리를 가져다 쓸 수 있는 이유는 패키지 매니저가 그 뒤에서 많은 일을 해주기 때문입니다. ECMAScript 표준에서는 원래 절대 경로나 상대 경로로만 모듈을 import할 수 있지만, 패키지 매니저가 'react'라는 이름만으로도 올바른 파일을 찾을 수 있도록 환경을 만들어줍니다.

package.json에 버전 범위를 명시하면 패키지 매니저가 정확한 버전을 결정합니다.

{
"dependencies": {
"react": "^18.2.0"
}
}

여기서 ^캐럿(Caret) 기호로, 버전 범위를 지정하는 키워드 중 하나입니다.

버전 범위 키워드

npm은 Semantic Versioning(SemVer) 규칙을 따릅니다. 버전은 MAJOR.MINOR.PATCH 형태로 구성되며, 각각의 의미는 다음과 같습니다.

  • MAJOR: 기존 API와 호환되지 않는 변경
  • MINOR: 하위 호환되는 새로운 기능 추가
  • PATCH: 하위 호환되는 버그 수정

이 SemVer를 기반으로, package.json에서는 다양한 키워드를 사용하여 설치할 버전의 범위를 지정할 수 있습니다.

키워드예시범위설명
^ (캐럿)^18.2.0>= 18.2.0, < 19.0.0MAJOR 버전 고정, MINOR와 PATCH 업데이트 허용
~ (틸드)~18.2.0>= 18.2.0, < 18.3.0MAJOR와 MINOR 고정, PATCH 업데이트만 허용
없음 (정확)18.2.018.2.0정확히 해당 버전만 설치
**모든 버전최신 버전 설치
>=, <=>=18.0.0>= 18.0.0비교 연산자로 범위 지정

가장 많이 사용되는 것은 ^(캐럿)입니다. npm install로 패키지를 설치하면 기본적으로 캐럿이 붙습니다. 캐럿은 MAJOR 버전이 바뀌지 않는 선에서 최신 버전을 허용하기 때문에, 하위 호환성을 유지하면서도 버그 수정이나 새로운 기능을 자동으로 반영할 수 있습니다.

이처럼 버전 범위 안에서 어떤 버전을 실제로 설치할지 결정하는 것이 패키지 매니저의 역할입니다.

패키지 매니저의 동작 과정

패키지 매니저는 크게 세 단계를 거쳐 동작합니다.

Resolution

모든 의존성의 정확한 버전을 결정하는 단계입니다. ^18.2.0과 같은 범위를 18.3.1처럼 구체적인 버전으로 고정하고, 의존성이 가진 의존성(transitive dependencies)까지 재귀적으로 탐색합니다.

이 결과는 package-lock.json이나 yarn.lock 같은 lock 파일에 저장되어, 다른 환경에서도 동일한 버전을 설치할 수 있도록 보장합니다.

Fetch

결정된 버전의 패키지 파일을 npm 레지스트리에서 다운로드하는 단계입니다.

다운로드한 패키지를 소스 코드에서 실제로 사용할 수 있도록 연결하는 단계입니다. 이 단계에서 npm, yarn, pnpm의 접근 방식이 크게 달라집니다.

npm

npm(Node Package Manager)은 Node.js와 함께 기본으로 제공되는 가장 오래된 패키지 매니저입니다.

동작 방식

npm은 node_modules 디렉토리에 패키지를 직접 설치합니다. 각 패키지가 자신만의 node_modules를 가질 수 있어, 중첩된 구조가 만들어집니다.

my-project/
└─ node_modules/
├─ react/
└─ some-library/
└─ node_modules/
└─ another-library/

의존성을 찾을 때는 현재 디렉토리의 node_modules부터 시작하여 상위 디렉토리로 올라가며 탐색합니다.

장점

  • 별도 설치 불필요: Node.js에 기본 포함되어 있어 추가 설치가 필요 없습니다.
  • 높은 호환성: 가장 널리 사용되므로 대부분의 패키지와 도구가 npm을 기본으로 지원합니다.
  • 낮은 학습 곡선: 별다른 설정 없이 바로 사용할 수 있습니다.

단점

  • 느린 설치 속도: 파일 I/O가 많아 설치 속도가 느립니다.
  • 디스크 용량: 같은 패키지가 여러 곳에 중복 설치될 수 있어 node_modules 용량이 매우 커질 수 있습니다.
  • 유령 의존성(Phantom Dependency): hoisting 최적화로 인해 package.json에 명시하지 않은 패키지도 사용할 수 있는 문제가 발생합니다.

레거시 버전의 npm은 hoisting이 일어나지 않습니다. npm 최적화를 위해 버전업과 함께 hoisting 기능이 추가되었습니다.

yarn

yarn은 Facebook(현 Meta)에서 개발한 패키지 매니저로, npm의 단점을 보완하기 위해 등장했습니다. 특히 yarn v2 이후 도입된 PnP(Plug'n'Play) 방식이 핵심적인 차별점입니다.

PnP(Plug'n'Play)

PnP는 node_modules 디렉토리 자체를 없애고, JavaScript 객체(Map)로 의존성 관계를 관리하는 방식입니다.

yarn install을 실행하면 .pnp.cjs 파일이 생성됩니다.

// .pnp.cjs (간략화)
[
'my-project',
[
{
packageLocation: './',
packageDependencies: [['react', 'npm:18.2.0']],
},
],
][
('react',
[
[
'npm:18.2.0',
{
packageLocation: './.yarn/cache/react-npm-18.2.0-...',
packageDependencies: [['loose-envify', 'npm:1.4.0']],
},
],
])
];

Node.js가 이 Map을 메모리에 올려두고, import나 require가 호출될 때 파일 시스템을 순회하는 대신 Map에서 바로 경로를 찾아줍니다.

장점

  • 빠른 설치 속도: 파일 하나(.pnp.cjs)만 생성하면 되므로 설치가 매우 빠릅니다.
  • 빠른 실행 속도: 파일 시스템 순회 없이 Map 연산으로 모듈을 찾아 실행 속도도 빠릅니다.
  • 엄격한 의존성 관리: package.json에 명시하지 않은 패키지는 사용할 수 없어 유령 의존성 문제가 발생하지 않습니다.
  • 플러그인 아키텍처: 코어 외 대부분의 기능이 플러그인으로 구성되어 있어 확장성이 뛰어납니다.

단점

  • 호환성 이슈: node_modules가 없기 때문에 node_modules의 존재를 가정하는 도구들과 호환되지 않을 수 있습니다.
  • 높은 학습 곡선: PnP 개념 자체가 기존 방식과 다르기 때문에 초기 학습이 필요합니다.
  • Node.js 프로세스 시작 속도: .pnp.cjs 파일을 메모리에 로드하는 과정에서 프로세스 시작이 다소 느려질 수 있습니다.

pnpm

pnpm(Performant npm)은 이름 그대로 npm의 성능을 개선하는 것에 집중한 패키지 매니저입니다.

동작 방식

pnpm은 node_modules 디렉토리를 유지하면서도 Hard LinkContent-Addressable Storage 방식을 사용합니다.

모든 패키지 파일은 전역 저장소(~/.pnpm-store)에 한 번만 저장되고, 각 프로젝트의 node_modules에는 이 파일들의 하드 링크가 생성됩니다. 파일을 실제로 복사하는 것이 아니기 때문에 디스크 용량을 크게 절약할 수 있습니다.

my-project/
└─ node_modules/
├─ .pnpm/ # 실제 패키지들 (하드 링크)
│ ├─ react@18.2.0/
│ └─ lodash@4.17.21/
├─ react -> .pnpm/react@18.2.0/ # 심볼릭 링크
└─ lodash -> .pnpm/lodash@4.17.21/ # 심볼릭 링크

장점

  • 빠른 설치 속도: npm 대비 매우 빠릅니다. 이미 전역 저장소에 있는 패키지는 다시 다운로드하지 않습니다.
  • 디스크 용량 절약: 하드 링크 방식 덕분에 같은 패키지를 여러 프로젝트에서 사용해도 실제 파일은 하나만 저장됩니다.
  • 엄격한 의존성 관리: package.json에 명시하지 않은 패키지에 접근할 수 없어 유령 의존성 문제를 방지합니다.
  • 높은 호환성: node_modules 구조를 유지하기 때문에 기존 도구들과의 호환성이 우수합니다.

단점

  • yarn PnP 대비 느림: 여전히 파일 시스템을 순회하는 방식이기 때문에 yarn PnP보다는 느립니다.
  • 하드 링크 관련 이슈: 일부 환경에서 하드 링크가 지원되지 않거나, 파일 수정 시 다른 프로젝트에도 영향을 줄 수 있는 잠재적 위험이 있습니다.

비교 요약

항목npmyarn (PnP)pnpm
설치 속도느림매우 빠름빠름
디스크 용량작음매우 작음
호환성우수낮음우수
유령 의존성 방지XOO
학습 곡선낮음높음낮음

모노레포에서 pnpm을 사용하는 이유

모노레포(Monorepo)는 하나의 저장소에서 여러 프로젝트(패키지)를 관리하는 방식입니다. 모노레포 환경에서 pnpm이 자주 선택되는 데에는 몇 가지 이유가 있습니다.

워크스페이스 지원이 강력하다

pnpm은 pnpm-workspace.yaml 파일 하나로 워크스페이스를 설정할 수 있습니다.

packages:
- 'packages/*'
- 'apps/*'

각 패키지 간의 의존성 관리, 스크립트 실행, 버전 관리 등을 체계적으로 지원합니다. --filter 옵션을 통해 특정 패키지만 대상으로 명령어를 실행하는 것도 간편합니다.

# 특정 패키지에서만 빌드 실행
pnpm --filter @my-org/web build

# 특정 패키지와 그 의존성까지 포함하여 빌드
pnpm --filter @my-org/web... build

디스크 용량 절약 효과가 극대화된다

모노레포에서는 여러 프로젝트가 같은 패키지를 공유하는 경우가 많습니다. npm은 각 프로젝트마다 동일한 패키지를 중복 설치하지만, pnpm은 하드 링크를 통해 하나의 파일만 유지합니다. 프로젝트 수가 많아질수록 이 차이는 더 커집니다.

엄격한 의존성 관리가 필수적이다

모노레포에서는 여러 패키지가 서로 다른 의존성을 가지고 있어, 유령 의존성 문제가 발생하면 디버깅이 매우 어려워집니다. pnpm의 엄격한 의존성 격리는 각 패키지가 자신이 명시한 의존성만 사용하도록 보장하여, 이런 문제를 사전에 방지합니다.

높은 호환성을 유지한다

yarn PnP도 성능과 엄격한 의존성 관리 측면에서 우수하지만, node_modules를 제거하는 접근 방식 때문에 일부 도구와의 호환성 문제가 발생할 수 있습니다. pnpm은 node_modules 구조를 유지하면서도 위의 장점들을 제공하기 때문에, 기존 도구나 라이브러리와의 호환성을 유지하면서 모노레포의 이점을 누릴 수 있습니다.

이러한 이유로 Turborepo, Nx 같은 모노레포 도구들도 pnpm을 기본 패키지 매니저로 권장하거나 강력하게 지원하고 있습니다.

마무리

지금까지 패키지 매니저의 개념부터 npm, yarn, pnpm의 특징과 차이점까지 살펴보았습니다.

정리하자면, npm은 안정적이고 호환성이 높지만 성능 면에서 아쉽고, yarn PnP는 혁신적인 접근 방식으로 성능과 정확성을 극대화했지만 호환성이라는 과제가 있으며, pnpm은 성능과 호환성 사이에서 좋은 균형을 이루고 있습니다. 프로젝트의 규모와 요구사항에 따라 적절한 패키지 매니저를 선택하는 것이 중요합니다.

참고

호이스팅이 코드를 위로 올리는게 아니라고? (ft. V8 Engine)

· 약 6분
김현재
개발자

들어가며

자바스크립트로 코드를 작성하다 보면 가끔 의아한 현상을 목격하게 됩니다. 변수를 선언하기 전에 호출했는데 에러가 나지 않는다거나, 의도하지 않은 값(undefined)이 나오는 경우가 있습니다. 마치 선언부가 코드 최상단으로 끌어올려진(hoisted) 것처럼요.

console.log(myVar); // undefined
var myVar = 'Hello, World!';

이런 현상을 바로 호이스팅(Hoisting) 이라고 부릅니다. 면접 질문으로도 자주 출제되어 "선언문을 맨 위로 올리는 현상"이라고 외우곤 하지만, 이 글에서는 좀 더 깊게 호이스팅에 대해 파헤쳐보려고 합니다.

모든 선언이 똑같이 끌어올려질까? - var vs let, const

호이스팅을 이해하기 전, 자바스크립트가 변수를 처리하는 두 단계인 선언초기화를 구분해야 합니다.

  • 선언(Declaration): "나 myVar라는 변수 쓸 거야"라고 자바스크립트 엔진에 알리는 단계
  • 초기화(Initialization): 변수에 메모리를 할당하고, 처음에는 undefined 값을 넣어두는 단계

자바스크립트에서 변수를 선언하기 위해 사용하는 var, let, const는 이 두 단계의 진행 방식에서 차이를 가지고 있습니다.

var의 호이스팅

var로 선언된 변수는 스코프 최상단에서 선언과 초기화가 동시에 이루어집니다. 그래서 변수를 선언하기 전에 호출해도 에러가 나지 않고 undefined를 반환합니다. 아래 코드는 var로 선언된 변수가 V8 엔진을 통해 해석된 예시 코드입니다.

// V8 엔진이 코드를 이렇게 해석합니다
var myVar; // 선언 + 초기화 (undefined)
console.log(myVar); // -> undefined
myVar = 'Hello, World!'; // 할당

let, const의 호이스팅

letconst는 선언 단계만 이루어집니다. 초기화는 실제 코드 라인에 도달해야만 이루어집니다. 그래서 실제 코드에서 초기화 전에 호출하게 되면 자바스크립트 엔진은 ReferenceError를 발생시킵니다.

console.log(myValue); // ReferenceError: Cannot access 'myValue' before initialization

let myValue = 'Hello, World!';

위 코드에서 발생한 ReferenceError는 참조 에러라고도 말하며, 초기화 전에는 해당 변수에 접근할 수 없다는 것을 의미합니다.

TDZ

letconst의 호이스팅에서는 중요한 개념이 하나 존재합니다. 앞서 정리한 내용을 보면 letconst로 선언된 변수는 실제 코드 라인에 도달해서 초기화가 되어야 접근할 수 있었습니다. 이 때 선언은 되었지만 초기화가 되지 않은 영역TDZ(Temporal Dead Zone, 일시적 사각지대)라고 합니다.

함수 호이스팅 - 선언문 vs 표현식

자바스크립트에서는 함수도 호이스팅이 일어납니다. 단, 함수를 선언한 방식에 따라 호이스팅의 동작이 달라집니다.

함수 선언문

함수 선언문은 선언과 동시에 사용 가능한 상태가 됩니다. 따라서 선언문 이전에 함수를 호출해도 정상적으로 작동합니다.

sayHello(); // "Hello!"
function sayHello() {
console.log('Hello!');
}

함수 표현식

함수 표현식은 변수 호이스팅 규칙을 따릅니다. var로 선언하면 undefined가 반환되며, let또는 const로 선언하면 TDZ영역이 생성되며 ReferenceError가 발생합니다.

// var의 경우
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
console.log('Hi!');
};

호이스팅은 왜 발생하는걸까?

지금까지 호이스팅의 정의와 변수, 함수별 동작 방식을 살펴보았습니다. 그렇다면 호이스팅은 왜 발생하는 걸까요?

이 질문의 해답을 찾기 위해서는 실행 컨텍스트라는 개념을 이해해야합니다. 이번 글에서는 호이스팅을 이해하는 데 꼭 필요한 핵심 원리 위주로 실행 컨텍스트를 가볍게 짚어보려 합니다.

실행 컨텍스트

실행 컨텍스트는 자바스크립트 코드를 실행하기 위해 필요한 정보를 모아놓은 객체입니다. 실행 컨텍스트에는 변수 및 함수 선언 정보, 스코프, this 바인딩 정보 같은 것들이 저장됩니다.

실행 컨텍스트는 크게 전역 실행 컨텍스트와 함수 실행 컨텍스트로 나눌 수 있습니다. 함수가 호출되면 새로운 실행 컨텍스트가 만들어지고 콜 스택에 쌓이게 되며, 함수 실행이 종료되면 해당 실행 컨텍스트는 콜 스택에서 제거됩니다.

함수 호출

함수 호출문을 만나면 자바스크립트 엔진은 하던 일을 멈추고 해당 함수의 실행 컨텍스트를 생성합니다. 실행 컨텍스트가 생성될 때 함수 내부의 변수와 함수 정보를 환경 레코드라고 하는 실행 컨텍스트 내부 공간에 미리 기록합니다. 이 기록하는 행위 때문에 호이스팅이 발생합니다.

V8 엔진과 호이스팅

자바스크립트 코드는 자바스크립트 엔진이라고 불리는 해석기를 통해 실행됩니다. 이 글에서는 가장 대표적인 구글의 V8 엔진을 기준으로 호이스팅이 내부적으로 어떻게 처리되는지 설명할 예정입니다.

자바스크립트 코드는 자바스크립트 해석기를 통해 코드가 실행됩니다. 대표적으로 V8이 있으며, 이 글에서는 V8 기준으로 설명할 예정입니다.

변수 선언

변수 선언의 시작은 Parser::DeclareVariable 함수가 담당합니다.

// /src/parsing/parser.cc

Variable* Parser::DeclareVariable(const AstRawString* name, VariableKind kind,
VariableMode mode, InitializationFlag init,
Scope* scope, bool* was_added, int begin,
int end) {
Declaration* declaration;
if (mode == VariableMode::kVar && !scope->is_declaration_scope()) {
DCHECK(scope->is_block_scope() || scope->is_with_scope());
declaration = factory()->NewNestedVariableDeclaration(scope, begin);
} else {
declaration = factory()->NewVariableDeclaration(begin);
}
Declare(declaration, name, kind, mode, init, scope, was_added, begin, end);
return declaration->var();
}

DeclareVariable 함수는 VariableMode 타입의 mode라는 매개변수를 받고 있습니다. mode의 값으로는 kVar, kLet, kConst를 사용할 수 있습니다. 즉, mode에 따라 변수를 선언하는 키워드를 분기하는 로직이 있을 것이라고 예상할 수 있습니다.

var, let, const의 접두사로 k를 붙인 이유는 c++ 코드에서 enum 또는 상수값의 식별자로 k를 붙이는 것이 관례라고 합니다. 이 내용은 Gemini가 알려준 내용이니 궁금한 분들은 교차 검증을 추천드립니다.

다음 흐름으로 넘어가보겠습니다. 조건문에서는 변수를 선언할 때 kVar 모드이며 선언 스코프가 아닌지 확인합니다. 선언 스코프함수 스코프를 의미합니다. 이후 mode값에 따라 NewNestedVariableDeclaration 함수를 사용할 것인지, NewVariableDeclaration 함수를 사용할 것인지 결정합니다.

// /src/ast/ast.h

NestedVariableDeclaration* NewNestedVariableDeclaration(Scope* scope,
int pos) {
return zone_->New<NestedVariableDeclaration>(scope, pos);
}

VariableDeclaration* NewVariableDeclaration(int pos) {
return zone_->New<VariableDeclaration>(pos);
}

위 헤더 파일에 정의되어 있는 두 함수의 차이점으로는 매개변수 scope의 유무가 있습니다. kVar 모드의 변수는 상위 스코프와 연결되어야 하기 때문에 scope 값을 사용합니다. 반면 kLetkConst 모드의 변수는 AST스코프 체인을 통해 관리되므로 따로 명시하지 않아도 선언된 블록의 Scope 객체에 자동으로 추가됩니다.

특징varlet / const
스코프함수 스코프 또는 전역 스코프블록 스코프
스코프 정보 필요 여부필요 (상위 스코프 연결)불필요 (선언된 블록에서 관리)
호출 함수NewNestedVariableDeclarationNewVariableDeclaration

메모리 할당 및 초기화

varlet, const는 선언과 동시에 초기화 여부가 서로 달랐습니다.

// /src/ast/variables.h

static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
DCHECK(IsDeclaredVariableMode(mode));
return mode == VariableMode::kVar ? kCreatedInitialized
: kNeedsInitialization;
}

InitializationFlag 라는 명칭을 보면 초기화 여부를 결정하는 함수임을 알 수 있습니다. 이 함수는 kVar 모드라면 kCreatedInitialized 를 반환하고, 그 외의 값이라면 kNeedsInitialization을 반환합니다.

letconst를 사용해서 변수를 선언하면 초기화 이전, 즉 TDZ 영역이 존재했고 이 영역에서 변수에 접근하면 ReferenceError가 발생합니다. kNeedsInitialization 값을 받으면 초기화 전에 접근을 하지 못하도록 하는 키워드임을 알 수 있습니다.

다음으로 kVar 모드라면 선언과 동시에 undefined로 초기화를 해주어야 하므로 메모리를 할당해주어야 합니다. 그래서 kVar의 경우 메모리를 할당하는 코드를 찾아보았지만, 클론 받은 패키지에서는 찾지 못했습니다.

그래서 구글링을 통해 코드 소스를 확인했고, 궁금하신 분은 아래 링크의 532번째 줄을 참고해주세요.

kVar 모드 메모리 할당

auto var = scope->DeclareParameter(name, VariableMode::kVar, is_optional,
is_rest, &is_duplicate,
ast_value_factory(), beg_pos);
DCHECK(!is_duplicate);
var->AllocateTo(VariableLocation::PARAMETER, 0);

kVar 모드라면 변수 객체를 생성한 후 AllocateTo 함수를 통해 메모리를 할당합니다. 하지만 kLetkConst 모드는 다르게 작동합니다.

// /src/parsing/parser.cc

VariableProxy* tdz_proxy = DeclareBoundVariable(
bound_name, VariableMode::kLet, kNoSourcePosition);
tdz_proxy->var()->set_initializer_position(position());

VariableProxy* proxy =
DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());

kLetkConst 모드로 생성한 변수 객체는 메모리를 할당하는 AllocateTo 함수 대신 set_initializer_position 함수의 매개변수로 position 값을 넘겨주고 있습니다.

kVar 모드와 다르게 kLetkConst 모드는 메모리를 할당 받지 못했고 이 시점에 변수에 접근하려 한다면 ReferenceError가 발생합니다.

마무리

이번 글에서는 단순히 면접용 답변으로 외웠던 호이스팅을 넘어, 실행 컨텍스트와 V8 엔진의 내부 동작까지 살펴보았습니다.