NDC Oslo 2025 - 당신의 자바스크립트는 안녕한가요 (성능 최적화 총정리)

NDC Oslo 2025 유튜브 링크

여기 아주 좋은 유튜브 강연이 있는데요, 이 강연의 핵심만 전체적으로 살펴볼까 힙니다.

발표자는 에릭(Eric)이라는 분인데, 비디오 게임을 만드는 개발자답게 자바스크립트를 빠르게 만드는 데 아주 도가 튼 분이더라고요.

사실 자바스크립트는 좋든 싫든 웹 세상을 지배해버린 언어죠.

그런데 이 언어의 성능은 곧 돈과 직결되는 문제거든요.

느린 웹사이트가 수백만 달러의 손실을 유발한다는 기사는 이제 흔하게 찾아볼 수 있습니다.

게다가 CPU를 많이 잡아먹는 자바스크립트는 사용자의 배터리를 광탈시키는 주범이 되기도 하죠.

안타깝게도 무어의 법칙은 끝났고, 싱글 코어 성능은 더 이상 극적으로 좋아지지 않을 거예요.

자바스크립트가 대부분 싱글 스레드로 동작한다는 걸 생각하면 꽤 암울한 소식이죠.

결국 성능 최적화의 비밀은 마법 같은 게 아니라, 그냥 '일을 덜 하는 것'입니다.

이 강연은 바로 '일을 덜 하는 똑똑한 방법들'에 대한 이야기입니다.

성능 최적화의 대원칙

본격적인 기술 이야기에 앞서, 발표자가 제시한 최적화의 기본 원칙부터 짚고 넘어가야 하는데요.

이게 정말 중요하거든요.

첫째, '목표를 설정하세요'.

어디까지 빨라져야 '충분히 좋은' 것인지 명확한 숫자로 정의해야 합니다.

안 그러면 개선의 늪에 빠져서 영원히 헤어 나오지 못할 수도 있죠.

둘째, '측정하고, 또 측정하세요'.

최적화를 시작하기 전에 현재 상태를 측정해서 기준점을 잡아야 하고요.

변경을 가한 후에는 반드시 다시 측정해서 정말 효과가 있었는지 과학적으로 검증해야 합니다.

셋째, '가장 큰 물고기부터 잡으세요'.

측정을 해보면 우리 앱이 시간의 90%를 어디서 보내는지 알 수 있는데요.

바로 그 부분을 먼저 공략해야 합니다.

사소한 for문 하나 개선하는 것보다 훨씬 효과가 크죠.

마지막으로, '마이크로벤치마크를 조심하세요'.

오늘 제가 보여드릴 예제들은 대부분 마이크로벤치마크인데요.

이건 실험실 환경일 뿐, 실제 운영 환경과는 다를 수 있다는 점을 항상 기억해야 합니다.

가장 쉬운 치트키, 체감 성능과 데이터 구조

사실 가장 쉬운 성능 개선 방법은 '속이는 것'인데요.

스피너나 로딩 애니메이션을 보여주면서 사용자가 실제로는 기다리고 있지만, 시스템이 열심히 일하고 있는 것처럼 느끼게 만드는 거죠.

소프트웨어는 종종 사용자를 속여서 잘 작동하는 것처럼 보이게 하는 기술이기도 합니다.

진짜 기술로 넘어가면, '데이터 구조'를 잘 선택하는 것만으로도 엄청난 성능 향상을 이룰 수 있는데요.

Map, Set, WeakMap 등을 상황에 맞게 사용하는 거죠.

특히 '캐싱'은 정말 강력한데요.

'아무것도 하지 않는 것'은 무한히 확장 가능하거든요.

캐싱은 거의 거기에 가깝죠.

한 번 계산한 결과를 저장해두고 다음에는 그냥 가져다 쓰기만 하면 되니까요.

function withCaching(func) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = func(...args);
    cache.set(key, result);
    return result;
  };
}

// 재귀로 구현된 느린 피보나치 함수
function slowFib(n) {
  if (n < 2) {
    return n;
  }
  return slowFib(n - 1) + slowFib(n - 2);
}

const fastFib = withCaching(slowFib);

console.time('fastFib(40)');
fastFib(40); // 거의 즉시 결과가 나옴
console.timeEnd('fastFib(40)');

console.time('slowFib(40)');
slowFib(40); // 2초 이상 소요됨
console.timeEnd('slowFib(40)');

이 간단한 캐싱 데코레이터 하나만으로도 극적인 성능 차이를 만들어낼 수 있습니다.

진짜 시작, 웹 워커로 멀티스레딩 활용하기

자바스크립트가 싱글 스레드라는 건 이제 옛말이 되어가고 있는데요.

바로 '웹 워커(Web Workers)' 덕분에 멀티스레딩이 가능해졌죠.

발표자는 이걸 가장 먼저 시도해볼 '가성비 최고의 기술'이라고 소개하더라고요.

메인 스레드를 막지 않고 무거운 계산을 백그라운드에서 처리할 수 있으니 당연한 이야기입니다.

다만 주의할 점이 있는데요.

메인 스레드와 워커 스레드는 서로 다른 실행 컨텍스트에서 작동하기 때문에 데이터를 주고받을 때 '복제'가 일어납니다.

만약 아주 큰 데이터를 계속 주고받는다면, 이 복제 비용 때문에 오히려 성능이 떨어질 수도 있죠.

이럴 때 'Transferable' 객체를 사용하면 데이터의 소유권을 이전해서 복제 비용을 없앨 수 있습니다.

// main.js
const worker = new Worker('worker.js');

// 8MB 크기의 배열 버퍼 생성
const bigArray = new Uint8Array(8 * 1024 * 1024);
console.log('전송 전:', bigArray.byteLength); // 8388608

// 두 번째 인자로 버퍼를 전달하여 소유권 이전
worker.postMessage(bigArray.buffer, [bigArray.buffer]);

console.log('전송 후:', bigArray.byteLength); // 0

postMessage의 두 번째 인자로 배열 버퍼를 넘겨주면 되는데요.

전송 후 메인 스레드에서 배열의 길이를 찍어보면 0이 나오는 걸 볼 수 있습니다.

소유권이 워커로 완전히 넘어갔다는 뜻이죠.

큰 파일을 처리할 때 아주 유용한 기술입니다.

반복문의 피를 짜내다

컴퓨터는 반복 작업을 아주 잘하는데요.

그래서 루프를 최적화하는 것만으로도 큰 성능 향상을 기대할 수 있습니다.

첫 번째 팁은 '불변값 끌어올리기(Hoisting Invariants)'입니다.

루프 안에서 변하지 않는 값은 루프 밖으로 빼내는 건데요.

// ❌ 느린 방식
for (let i = 0; i < largeArray.length; i++) {
  // 매번 config.value에 접근
  doSomething(config.value); 
}

// ✅ 빠른 방식
const value = config.value;
for (let i = 0; i < largeArray.length; i++) {
  doSomething(value);
}

자바스크립트에서 객체의 프로퍼티에 접근하는 건 생각보다 비쌀 수 있기 때문에, 이 간단한 변경만으로도 엄청난 차이를 만들 수 있죠.

두 번째는 'C 스타일 루프'를 사용하는 겁니다.

forEachfor...of 같은 문법은 사용하기는 편하지만, 내부적으로 이터레이터를 사용하기 때문에 약간의 오버헤드가 있는데요.

정말 극한의 성능이 필요한 곳에서는 고전적인 C 스타일 for문이 가장 빠릅니다.

마지막은 '캐시 일관성(Cache Coherency)'인데요.

CPU는 메모리에서 데이터를 읽어올 때, 해당 데이터 주변의 데이터까지 한꺼번에 '캐시 라인'이라는 곳에 가져옵니다.

만약 메모리를 순차적으로 접근하면 CPU 캐시 안에서 빠르게 처리가 가능한데요.

메모리를 여기저기 건너뛰면서 접근하면 매번 메인 메모리에 다시 다녀와야 해서 엄청나게 느려집니다.

배열을 순서대로 순회하는 것이 건너뛰면서 순회하는 것보다 훨씬 빠른 이유가 바로 이것 때문이죠.

메모리와의 전쟁, 누수와 쓰레기

CPU만큼이나 메모리 문제도 앱을 느리게 만드는 주범인데요.

자바스크립트에는 '가비지 컬렉션(GC)'이 있어서 메모리 관리를 자동으로 해주지만, 그렇다고 메모리 누수가 없는 건 아닙니다.

오히려 더 찾기 힘들어서 골치 아프죠.

가장 흔한 메모리 누수 원인은 바로 '이벤트 핸들러'인데요.

// DOM 요소에 이벤트 핸들러 추가
element.addEventListener('click', this.handleClick);

// 나중에 이 요소가 DOM에서 제거되더라도,
// 이벤트 핸들러를 제거하지 않으면 메모리 누수 발생!
// element.removeEventListener('click', this.handleClick); // 이걸 꼭 해줘야 함

DOM 요소를 만들고 이벤트 핸들러를 붙였다가, 나중에 그 요소를 DOM에서 제거할 때 이벤트 핸들러를 함께 제거해주지 않으면 누수가 발생합니다.

특히 클로저(closure)와 결합되면 문제가 더 심각해지는데요.

setTimeout이나 setInterval 안에서 외부의 큰 객체를 참조하고 있다면, 타이머가 해제되기 전까지 그 객체는 절대로 메모리에서 해제되지 않습니다.

또 다른 문제는 '메모리 스래싱(Memory Thrashing)'인데요.

이건 누수는 아니지만, 너무 많은 쓰레기를 짧은 시간 안에 계속 만들어내서 가비지 컬렉터가 쉴 틈 없이 일하게 만드는 겁니다.

주로 루프 안에서 객체 리터럴({})이나 배열 리터럴([])을 계속 생성할 때 발생하죠.

이걸 해결하는 방법으로는 '스크래치 변수'나 '객체 풀링'이 있는데요.

객체를 미리 만들어두고 필요할 때마다 재사용하는 방식입니다.

성능은 확실히 좋아지지만 코드가 복잡해지는 단점이 있죠.

JIT 컴파일러와 친구 되기

자바스크립트가 빠른 이유는 'JIT(Just-In-Time)' 컴파일러 덕분인데요.

JIT는 코드가 실행되는 것을 지켜보다가, 자주 사용되는 코드를 기계어로 컴파일해서 성능을 높여줍니다.

JIT가 최적화를 잘 하도록 도와주려면, 우리는 JIT가 예측하기 쉬운 코드를 짜야 하죠.

JIT는 객체가 생성될 때 내부적으로 '히든 클래스' 또는 'Shape'라는 것을 만드는데요.

같은 방식으로 생성된 객체는 같은 Shape을 공유하게 됩니다.

const v1 = { x: 1, y: 2 }; // Shape A
const v2 = { x: 3, y: 4 }; // Shape A

const v3 = { y: 5, x: 6 }; // Shape B

놀랍게도 위 코드에서 v1v3는 프로퍼티 순서가 달라서 JIT에게는 완전히 다른 객체로 보입니다.

만약 어떤 함수가 Shape A 타입의 객체만 계속 처리한다면, JIT는 이 함수를 아주 빠르게 최적화할 수 있는데요. (이걸 '단형성(monomorphic)'이라고 합니다)

하지만 갑자기 Shape B 타입의 객체가 들어오면, JIT는 혼란에 빠져서 기존의 최적화를 포기하고 다시 인터프리터 모드로 돌아가 버립니다.

이걸 '최적화 해제(deoptimization)'라고 부르는데, 앱 성능에 큰 악영향을 주죠.

따라서 객체를 생성할 때는 항상 같은 순서의 프로퍼티를 갖도록 생성자나 팩토리 함수를 사용하는 것이 좋습니다.

마치며

이 외에도 발표자는 데이터 중심 프로그래밍, Wasm, GPU 활용 등 더 많은 고급 기법들을 소개했는데요.

결국 핵심은 같습니다.

'측정하고, 가장 큰 병목을 찾아내고, 목표를 향해 과학적으로 개선하라'.

그리고 이렇게 힘들게 최적화한 성능이 다시 나빠지지 않도록, CI 환경에서 성능 벤치마킹 테스트를 자동화해서 꾸준히 관리하는 것도 정말 중요하죠.

성능 최적화는 어렵고 때로는 지루한 작업이지만, 사용자에게 쾌적한 경험을 제공하기 위한 개발자의 중요한 책임이라고 생각합니다.

오늘 살펴본 팁들이 여러분의 코드를 조금이라도 더 빠르게 만드는 데 도움이 되었으면 좋겠네요.