PHpullh

JAVASCRIPT · 심층 가이드

JavaScript 성능 완전 정리

메모이제이션, 조회 맵, 청크 스트리밍을 포함한 15편의 코드로 JIT과 GC 위에서 실제로 효과가 있는 최적화와 헛수고를 하나씩 구분해 봅니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

JavaScript 성능 이야기가 미묘한 이유는 최적화 주체가 개발자가 아니라 JIT 컴파일러이기 때문입니다. 엔진은 반복 실행되는 함수를 관찰해 기계어로 바꾸는데, 같은 자리에 서로 다른 형태의 객체가 계속 들어오면 그 최적화가 취소됩니다. 그래서 루프를 줄이는 것보다 객체 형태를 일정하게 유지하는 것이 더 크게 작용하는 경우가 많습니다. 손댈 곳을 고르는 감각이 먼저입니다.

시간 측정을 맨 앞에 두고 읽으시기 바랍니다. 측정 없이 고친 것은 최적화가 아니라 추측입니다. 알고리즘 차원의 개선은 조회 맵 사용과 Set 조회 최적화에 모여 있고, 배열을 반복 탐색하던 코드를 상수 시간 조회로 바꾸는 것이 대개 가장 큰 이득입니다. 그 다음이 메모이제이션 캐시로 중복 계산을 없애는 단계이며, 배치 I/O와 청크 스트리밍은 계산이 아니라 대기와 메모리를 줄이는 쪽입니다.

문자열 버퍼링은 조심해서 다뤄야 합니다. 요즘 엔진은 += 연결을 내부적으로 지연 평가하는 구조로 처리해서, 배열에 모았다가 join하는 방식이 항상 빠르지는 않습니다. 실제로 문제가 되는 쪽은 오히려 메모리입니다. 큰 파일을 한 번에 문자열로 읽으면 런타임의 문자열 길이 한계에 부딪힐 수 있어, 그럴 때는 청크 스트리밍이 정답입니다. 사전 할당도 값을 채우지 않은 희소 배열을 만들면 오히려 느려집니다.

01문자열 버퍼링

문자열 조각을 담은 배열을 구분자와 함께 join으로 한 번에 이어 붙입니다. 조각이 많이 쌓일 때는 반복문에서 +=로 붙이는 대신 배열에 모았다가 마지막에 합치는 이 방식이 다루기 편합니다. 다만 요즘 엔진은 문자열 연결을 내부적으로 최적화해서, 조각이 몇 개뿐이면 체감할 차이가 없습니다.

JavaScript code

// 문자열 버퍼링
const parts = ["a", "b", "1"];
console.log(parts.join("-"));
알아두면 좋은 점

join은 구분자를 조각 사이에만 넣기 때문에, 마지막에 남는 구분자를 잘라 내는 처리를 따로 하지 않아도 됩니다.

자주 하는 실수

조각을 배열에 모으는 방식은 조각 수만큼 배열 메모리를 함께 쓰므로, 아주 큰 데이터에서는 중간 배열이 그대로 최대 메모리 사용량이 됩니다.

02사전 할당

Map에 계산 결과를 저장해 두고 같은 인자가 다시 오면 재귀를 통째로 건너뜁니다. 겹치는 부분 문제가 많은 계산에서 호출 횟수를 크게 줄이는 방법입니다. 캐시는 스스로 비워지지 않으므로 입력 범위가 넓어지면 메모리 사용량이 계속 늘어난다는 대가가 따릅니다.

JavaScript code

// 사전 할당
const cache = new Map();
const fib = (x) => { if (x <= 1) return x; if (cache.has(x)) return cache.get(x); const value = fib(x - 1) + fib(x - 2); cache.set(x, value); return value; };
console.log(fib(8));
알아두면 좋은 점

메모이제이션은 같은 인자가 반복될 때만 이득입니다. 인자가 매번 다르면 캐시는 한 번도 적중하지 않고 저장 비용만 남습니다.

자주 하는 실수

이 캐시는 스스로 비워지지 않으므로 입력 범위가 넓어지면 메모리가 계속 늘어납니다. 항목 수에 상한을 두거나 오래된 값을 버리는 규칙이 필요합니다.

03지연 처리

Set에 태그 세 개를 넣고 특정 항목이 있는지만 물어봅니다. 전체를 순회해 새 결과물을 만들지 않고 필요한 질문에만 답한다는 점에서 계산을 미루는 방향과 맞닿아 있습니다. Set의 조회는 배열 includes와 달리 항목 수가 늘어도 비용이 거의 그대로 유지됩니다.

JavaScript code

// 지연 처리
const tags = new Set(["js", "ts", "rust"]);
console.log(tags.has("ts"));
알아두면 좋은 점

Set의 has는 항목 수가 늘어도 비용이 거의 일정해, 큰 목록에서 포함 여부를 반복 확인해야 할 때 배열보다 유리합니다.

자주 하는 실수

루프 안에서 매번 new Set(arr)을 만들면 변환 비용이 반복되어 배열을 그냥 훑는 것보다 느려집니다. Set은 루프 밖에서 한 번만 만들어야 합니다.

04메모이제이션 캐시

performance.now()로 시작 시각을 찍고, reduce 합산이 끝난 뒤 경과 시간을 결과와 함께 출력합니다. 캐시를 넣기 전후를 비교하려면 이렇게 같은 구간을 재는 코드가 먼저 있어야 합니다. 이 정도로 짧은 작업은 타이머 해상도와 JIT 예열에 묻히므로 한 번 측정한 숫자로는 아무것도 판단할 수 없습니다.

JavaScript code

// 메모이제이션 캐시
const start = performance.now();
const total = [1, 2, 3, 4, 4].reduce((sum, value) => sum + value, 0);
console.log(total, performance.now() - start);
알아두면 좋은 점

구간을 잴 때는 예열 실행을 몇 번 돌린 뒤 여러 번 측정해 중앙값을 보는 편이, 한 번 측정한 값보다 훨씬 안정적인 판단 근거가 됩니다.

자주 하는 실수

요소 다섯 개를 더하는 정도의 작업은 타이머 해상도보다 짧아 측정값이 사실상 잡음입니다. 이 숫자로 최적화 여부를 판단하면 잘못된 결론에 이릅니다.

05조회 맵 사용

배열 요소를 구분자로 이어 하나의 문자열로 만드는 한 줄짜리 코드이며, 맵 조회는 쓰이지 않았습니다. 키로 값을 찾는 작업이라면 배열을 매번 훑는 대신 Map을 미리 만들어 두는 쪽이 유리합니다. 참고로 join은 null과 undefined를 빈 문자열로 바꿔 넣어 구분자만 덩그러니 남깁니다.

JavaScript code

// 조회 맵 사용
const parts = ["a", "b", "5"];
console.log(parts.join("-"));
알아두면 좋은 점

배열에서 키로 찾는 일이 반복된다면 항목을 Map으로 한 번 색인해 두는 편이, 매번 find로 전체를 훑는 것보다 낫습니다.

자주 하는 실수

join은 null과 undefined를 빈 문자열로 바꾸므로, 빠진 값이 있어도 구분자만 남을 뿐 오류로 드러나지 않습니다.

06배치 I/O

재귀 함수가 매번 캐시에 값이 있는지 먼저 확인하고, 없을 때만 계산해 저장합니다. 같은 요청을 반복해서 보내는 대신 결과를 모아 두는 발상은 I/O를 묶을 때도 그대로 통합니다. 다만 이 함수는 동기 재귀라 인자가 커지면 캐시가 채워지기 전에 호출 스택 한계에 먼저 부딪힙니다.

JavaScript code

// 배치 I/O
const cache = new Map();
const fib = (x) => { if (x <= 1) return x; if (cache.has(x)) return cache.get(x); const value = fib(x - 1) + fib(x - 2); cache.set(x, value); return value; };
console.log(fib(8));
알아두면 좋은 점

요청을 묶으면 왕복 횟수가 줄어 지연이 크게 개선되지만, 묶음이 실패했을 때 어느 항목까지 처리됐는지 알 수 있도록 응답을 설계해야 합니다.

자주 하는 실수

이 재귀는 캐시가 채워지기 전에 한쪽으로 먼저 깊이 내려가므로, 인자가 크면 캐시 이득을 보기 전에 호출 스택 한계에 부딪힙니다.

07불필요한 복사 줄이기

이미 만들어 둔 Set에 조회만 걸 뿐 새로운 컬렉션을 하나도 만들지 않습니다. filter나 전개 연산자를 이어 붙이면 단계마다 배열이 새로 생기므로, 판단만 필요한 자리에서는 이렇게 조회로 끝내는 편이 낫습니다. 참고로 Set은 NaN끼리를 같은 값으로 취급해 중복으로 저장하지 않습니다.

JavaScript code

// 불필요한 복사 줄이기
const tags = new Set(["js", "ts", "rust"]);
console.log(tags.has("ts"));
알아두면 좋은 점

filter와 map을 여러 번 이어 붙이면 단계마다 배열이 새로 생깁니다. 항목이 많다면 한 번의 순회로 합치는 편이 할당을 크게 줄입니다.

자주 하는 실수

판단만 필요한 자리에서 Set을 배열로 펼쳐 includes를 부르면, 전체 복사 비용을 매번 치르면서 빠른 조회의 이점도 함께 잃습니다.

08시간 측정

구간 앞뒤로 시각을 재서 배열 합산에 걸린 밀리초를 계산해 찍습니다. 어디가 느린지 감이 없을 때 후보 구간을 이렇게 감싸 숫자부터 확보합니다. Date.now()와 달리 performance.now()는 소수점 아래까지 주고 시스템 시계가 바뀌어도 값이 뒤로 튀지 않습니다.

JavaScript code

// 시간 측정
const start = performance.now();
const total = [1, 2, 3, 4, 8].reduce((sum, value) => sum + value, 0);
console.log(total, performance.now() - start);
알아두면 좋은 점

console.time과 console.timeEnd를 쓰면 시작 시각을 담을 변수 없이 이름표만으로 같은 구간을 잴 수 있습니다.

자주 하는 실수

측정 구간 안에 로그 출력이 들어 있으면 그 비용이 결과에 그대로 섞입니다. 로그는 측정이 끝난 뒤에 한 번만 찍어야 합니다.

09병렬 처리

배열 세 요소를 이어 붙여 출력하는 동기 코드이며 병렬 실행은 등장하지 않습니다. 여러 비동기 작업을 겹쳐 돌리려면 각 Promise를 모아 Promise.all에 넘겨야 합니다. 자바스크립트는 한 스레드에서 돌기 때문에 이런 CPU 계산 자체는 await를 붙인다고 나눠지지 않습니다.

JavaScript code

// 병렬 처리
const parts = ["a", "b", "9"];
console.log(parts.join("-"));
알아두면 좋은 점

CPU를 오래 쓰는 계산은 worker_threads나 웹 워커로 옮겨야 실제로 나뉩니다. 같은 스레드에서는 순서만 바뀔 뿐 총 시간이 줄지 않습니다.

자주 하는 실수

긴 동기 계산은 이벤트 루프를 붙잡아 타이머와 응답 처리까지 함께 멈춥니다. 브라우저에서는 화면이 굳고 서버에서는 요청이 그대로 쌓입니다.

10객체 재사용

한 번 만든 cache를 함수 바깥에 두고 호출마다 같은 Map 인스턴스를 계속 씁니다. 호출할 때마다 새 그릇을 만들면 이전 결과가 매번 버려지므로, 재사용할 컨테이너는 이렇게 밖으로 빼 둡니다. 대신 이 캐시는 모듈이 살아 있는 내내 유지되어 테스트 사이에 값이 남고 결과가 섞일 수 있습니다.

JavaScript code

// 객체 재사용
const cache = new Map();
const fib = (x) => { if (x <= 1) return x; if (cache.has(x)) return cache.get(x); const value = fib(x - 1) + fib(x - 2); cache.set(x, value); return value; };
console.log(fib(8));
알아두면 좋은 점

모듈 최상위에 둔 캐시는 프로세스가 살아 있는 동안 유지되므로, 여러 요청이 같은 계산 결과를 공유해야 하는 경우에 잘 맞습니다.

자주 하는 실수

그 캐시는 테스트 사이에도 남아 앞선 테스트의 값이 다음 테스트로 흘러듭니다. 실행 순서를 바꾸면 결과가 달라지는 테스트가 이렇게 만들어집니다.

11Set 조회 최적화

문자열 세 개를 담은 Set에서 포함 여부를 확인합니다. 같은 목록을 두고 포함 검사를 여러 번 해야 한다면 배열을 Set으로 한 번 바꿔 두는 것이 이득입니다. 단 변환 자체에도 비용이 들어서, 검사 횟수가 몇 번뿐이면 그냥 includes로 훑는 쪽이 더 빠를 수 있습니다.

JavaScript code

// Set 조회 최적화
const tags = new Set(["js", "ts", "rust"]);
console.log(tags.has("ts"));
알아두면 좋은 점

포함 검사가 몇 번뿐이라면 배열 includes가 변환 비용이 없어 더 빠를 수 있습니다. 검사 횟수와 목록 크기를 함께 보고 정해야 합니다.

자주 하는 실수

Set은 값을 거의 엄격 비교로 다루므로 객체를 넣으면 내용이 같아도 다른 항목이 됩니다. 조회가 항상 실패하는 원인이 됩니다.

12청크 스트리밍

배열 전체를 reduce로 한 번에 접어 합계를 내고 걸린 시간을 함께 출력합니다. 데이터가 메모리에 다 들어가지 않는 상황이라면 이 통짜 처리를 조각 단위로 나눠 흘려보내야 합니다. 청크로 쪼개면 최대 메모리 사용량은 내려가지만 조각마다 호출 비용이 붙어 총 처리 시간은 오히려 늘 수 있습니다.

JavaScript code

// 청크 스트리밍
const start = performance.now();
const total = [1, 2, 3, 4, 12].reduce((sum, value) => sum + value, 0);
console.log(total, performance.now() - start);
알아두면 좋은 점

조각 크기를 키우면 호출 횟수가 줄어 처리량이 오르고, 줄이면 최대 메모리가 내려갑니다. 두 값 사이에서 목표에 맞춰 고르는 문제입니다.

자주 하는 실수

조각마다 순서대로 기다리면 앞 조각이 끝나야 다음이 시작되어, 병목이 CPU가 아니라 네트워크일 때는 전체 시간이 오히려 늘어납니다.

13정렬 비용 비교

정렬은 나오지 않고, 이미 순서가 정해진 배열을 이어 붙이기만 합니다. 출력 순서를 맞추려고 정렬을 넣기 전에 데이터가 이미 정렬된 채로 들어오는지부터 확인할 필요가 있습니다. sort는 비교 함수를 주지 않으면 요소를 문자열로 바꿔 비교하기 때문에 10이 9보다 앞에 놓입니다.

JavaScript code

// 정렬 비용 비교
const parts = ["a", "b", "13"];
console.log(parts.join("-"));
알아두면 좋은 점

이미 정렬된 채로 들어오는 데이터라면 정렬 자체를 건너뛸 수 있고, 정렬이 꼭 필요하다면 비교 함수를 가볍게 유지하는 것이 가장 큰 차이를 만듭니다.

자주 하는 실수

비교 함수 없이 sort()를 부르면 요소를 문자열로 바꿔 비교해 [9, 10]이 [10, 9]가 됩니다. 숫자 배열에서 결과가 조용히 틀어집니다.

14소유권 또는 참조 재사용

캐시에서 꺼낸 값을 복사하지 않고 그대로 반환하므로, 호출부는 저장소 안의 값을 직접 받아 보게 됩니다. 여기서는 값이 숫자라 문제가 없지만 객체를 캐싱하면 같은 참조가 여러 곳에 퍼집니다. 자바스크립트에는 소유권 개념이 없어 누가 수정 권한을 갖는지는 오직 팀 규약으로만 정해집니다.

JavaScript code

// 소유권 또는 참조 재사용
const cache = new Map();
const fib = (x) => { if (x <= 1) return x; if (cache.has(x)) return cache.get(x); const value = fib(x - 1) + fib(x - 2); cache.set(x, value); return value; };
console.log(fib(8));
알아두면 좋은 점

캐시가 원시 값만 담으면 참조 공유 문제가 없습니다. 객체를 담아야 한다면 내보낼 때 동결하거나 사본을 주는 규칙을 미리 정해 두는 편이 안전합니다.

자주 하는 실수

캐시에서 꺼낸 객체를 호출부가 고치면 다음 조회부터 다른 값이 나옵니다. 원인이 캐시와 멀리 떨어진 코드에 있어 추적이 매우 어렵습니다.

15페이지네이션 슬라이스

Set을 만들고 특정 항목이 들어 있는지만 묻는 코드로, 잘라 내는 연산은 없습니다. 목록을 페이지로 나눌 때는 slice가 흔하지만 Set은 인덱스 접근을 지원하지 않아 배열로 먼저 바꿔야 합니다. Set은 삽입 순서를 유지하므로 전개해서 배열로 만들면 넣은 순서가 그대로 나옵니다.

JavaScript code

// 페이지네이션 슬라이스
const tags = new Set(["js", "ts", "rust"]);
console.log(tags.has("ts"));
알아두면 좋은 점

Set은 인덱스 접근을 지원하지 않아 잘라 내려면 배열로 바꿔야 하고, 이때 넣은 순서가 그대로 유지되어 페이지 경계가 흔들리지 않습니다.

자주 하는 실수

페이지를 만들 때마다 Set 전체를 배열로 펼치면 페이지 수만큼 전체 복사가 반복됩니다. 배열은 한 번만 만들어 두고 잘라 써야 합니다.

정리하며

  • 코드를 손대기 전에 먼저 측정하고, 같은 입력으로 전후를 비교합니다
  • 배열 반복 탐색을 Map이나 Set 조회로 바꾸는 것이 보통 가장 큽니다
  • 객체 형태를 일정하게 유지해야 JIT 최적화가 취소되지 않습니다
  • 대용량 파일은 통째로 읽지 말고 청크 단위로 흘려보내며 처리합니다

더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.