본문 바로가기

성능 최적화와 프로파일링

같은 조건의 측정으로 병목을 찾고 고친 뒤 다시 재는 순서를 세우고, CPU와 렌더링, 메모리, 입출력 대기라는 증상에 따라 브라우저의 Performance 패널, React Profiler, Node.js의 CPU 프로파일과 힙 스냅샷을 고르며, 타입스크립트 컴파일 시간의 병목도 측정합니다.

기능이 맞게 동작해도 화면이 늦게 뜨거나, 버튼을 눌렀을 때 반응이 늦거나, 서버의 응답이 느리면 사용자는 불편을 느낍니다. 성능 최적화는 이런 지연과 메모리 사용을 줄이는 일인데, 어디가 느린지 짐작으로 고르면 효과가 없는 곳을 고치거나 코드만 복잡하게 만들기 쉽습니다. 그래서 최적화는 프로파일링(profiling), 즉 프로그램이 실제로 어디에 시간과 메모리를 쓰는지 측정하는 일에서 시작합니다.

이 글은 측정하고 고치고 다시 재는 순서를 세운 뒤, 증상에 따라 브라우저와 React, Node.js의 프로파일러를 고르는 방법을 봅니다. 마지막으로 타입스크립트 자체가 성능에 미치는 영향과, 컴파일 시간이 길 때 원인을 찾는 방법을 정리합니다.

측정하고 고치고 다시 잰다

성능의 개선은 같은 조건에서 다시 쟀을 때 숫자가 나아졌는지로만 확인할 수 있습니다.

MEASURE · CHANGE · VERIFY

성능 개선은 같은 조건의 재측정으로만 닫힌다

같은 조건의 반복 측정으로 병목 가설을 평가하는 절차입니다. 입력·build·동시성·워밍업·캐시를 고정하고 한 변경 뒤 p95·메모리와 기능 회귀를 비교합니다. 본문 명령을 실행한 수치나 개선 결과를 주장하지 않습니다.

성능 개선은 같은 조건의 재측정으로만 닫힌다 기준 측정, 프로파일, 단일 변경, 동일 조건 재측정, 성능 예산, 회귀 보호로 이어지는 증거 기반 성능 개선 루프. PROCESS · 6 STAGES Baseline fixed workload Profile find bottleneck One change one hypothesis Remeasure same conditions Budget p95 · memory Guard regression test
process 핵심 계약
  1. Baseline

    입력·build·환경·동시성·캐시·워밍업을 기록합니다.

  2. Profile

    증상을 설명할 병목 가설을 계측으로 구분합니다.

  3. One change

    한 번에 하나의 변경을 적용합니다.

  4. Remeasure

    같은 조건에서 반복 측정해 변동과 trade-off를 확인합니다.

  5. Budget

    미리 정한 지연·메모리 예산과 기능 회귀를 함께 확인합니다.

  6. Guard

    개선이 확인되면 회귀 감시를 남기고, 확인되지 않으면 가설·조건을 다시 검토합니다.

  • 핵심 경계
  • 진행·선택 경로

그림의 순서대로, 먼저 입력 데이터와 빌드, 동시 사용자 수, 워밍업과 캐시의 상태 같은 조건을 정하고 그 조건에서의 기준값을 잽니다. 프로파일러로 증상을 설명할 병목의 가설을 세운 뒤, 한 번에 하나만 바꿉니다. 그다음 같은 조건에서 여러 번 다시 재어, 평균만이 아니라 느린 쪽의 응답 시간인 95번째 백분위수(p95)와 메모리가 미리 정한 목표에 들어오는지, 기능이 그대로인지를 함께 확인합니다.

나아졌다면 그 측정을 회귀 검사로 남겨 다시 느려지는 것을 막고, 나아지지 않았다면 가설이나 측정 조건을 다시 봅니다. 한 번에 여러 곳을 바꾸면 어느 변경이 효과를 냈는지, 또는 어느 변경이 다른 곳을 느리게 했는지 알 수 없습니다.

증상에 맞는 프로파일러 고르기

느리다는 증상은 원인에 따라 보이는 신호가 다르므로, 그 신호를 구별해 주는 도구를 고릅니다.

BOTTLENECK TRIAGE

측정한 증상에서 구분 가능한 profiler로 간다

선택 기준이며 측정 결과를 제시한 그림이 아닙니다. CPU·렌더, 메모리·GC, I/O 대기를 구분하는 계측을 고르고 원인을 검증합니다. CPU profile만으로 외부 요청 대기 시간을 설명하지 않습니다.

측정한 증상에서 구분 가능한 profiler로 간다 재현한 성능 증상을 CPU와 I/O, 메모리와 GC, 쿼리와 네트워크를 구분하는 도구로 분기하는 흐름. CPU·렌더 메모리 대기 증상 재현 traceable workload 주된 신호는? latency · memory CPU / 렌더 profile · timeline Heap · GC retention Trace · query I/O · network
flowchart 핵심 계약
  1. 증상 재현

    traceable workload

  2. 주된 신호는?

    latency · memory

  3. CPU·렌더 분기

    Node CPU profile, 브라우저 Performance, React Profiler를 해당 실행 환경에서 선택합니다.

  4. 메모리 분기 · Heap·GC

    객체 보유와 GC·시간 경과 추세를 확인합니다. snapshot 한 번만으로 누수를 확정하지 않습니다.

  5. 대기 분기 · Trace·query

    DB 쿼리·외부 요청·pool 대기를 연결해 확인합니다. 세 도구는 순서가 아니라 증상별 분기입니다.

  • 핵심 경계
  • 진행·선택 경로

CPU를 계속 쓰거나 화면이 버벅거린다면 실행 시간을 함수별로 보여 주는 CPU 프로파일과 브라우저의 성능 기록, React Profiler를 씁니다. 메모리가 시간이 지날수록 늘어난다면 힙 스냅샷으로 어떤 객체가 남아 있는지를 봅니다. CPU는 한가한데 응답이 느리다면 데이터베이스의 쿼리나 바깥 서비스의 응답, 연결 풀을 기다리는 것이므로, 쿼리의 실행 시간과 요청의 경로를 잇는 추적을 봅니다. CPU 프로파일은 기다리는 동안의 시간을 함수의 실행으로 보여 주지 않으므로, 대기가 원인인 문제를 CPU 프로파일만으로 설명할 수는 없습니다.

브라우저와 React의 프로파일러

브라우저 개발자 도구의 Performance 패널에서 기록을 시작하고 느린 동작을 한 뒤 멈추면, 그동안 메인 스레드에서 일어난 스크립트 실행, 스타일 계산, 레이아웃, 그리기가 시간 순서대로 보입니다. 50ms가 넘는 긴 작업(long task)은 따로 표시되는데, 그동안 브라우저는 입력에 반응하지 못하므로 버튼의 반응이 늦은 원인일 가능성이 큽니다. 아래쪽의 Bottom-Up 보기에서는 그 시간을 가장 많이 쓴 함수를 순서대로 볼 수 있습니다.

사용자가 느끼는 성능은 Core Web Vitals로 잴 수 있습니다. 가장 큰 콘텐츠가 보이기까지의 시간인 LCP, 입력에 화면이 반응하기까지의 시간인 INP, 화면 요소가 예기치 않게 밀리는 정도인 CLS가 그것입니다. Performance 패널은 열자마자 그 페이지의 LCP와 CLS를 보여 주고, 페이지와 상호작용하면 INP도 함께 보여 줍니다. 페이지를 불러오는 과정을 재는 Lighthouse의 성능 점수에는 INP가 없고, 그 대신 메인 스레드가 막혀 입력을 처리하지 못한 시간인 TBT(Total Blocking Time)가 들어갑니다. 개발자의 컴퓨터는 사용자의 기기보다 빠른 경우가 많으므로, 측정할 때는 CPU와 네트워크를 느리게 하는 설정으로 사용자의 조건에 가깝게 맞춥니다.

React 개발자 도구의 Profiler 탭은 렌더링을 커밋 단위로 기록해, 어떤 컴포넌트가 얼마나 걸렸는지 보여 줍니다. 설정에서 렌더링한 이유를 기록하도록 켜 두면 props, 상태, Context, 부모의 렌더링 가운데 무엇 때문에 다시 렌더링했는지도 볼 수 있습니다. 이렇게 찾은 원인에 맞추어 테스팅과 성능 최적화에서 본 memo, useCallback, useMemo, 가상화를 고르며, 처음 내려받는 코드가 크다면 React.lazy로 화면 단위로 나누어 내려받게 합니다.

Node.js의 프로파일러

Node.js는 V8의 프로파일러를 실행 옵션으로 켤 수 있습니다. --cpu-prof는 프로그램이 끝날 때 CPU 프로파일을 .cpuprofile 파일로 남깁니다.

node --cpu-prof dist/index.js
node --heap-prof dist/index.js
node --inspect dist/index.js

.cpuprofile 파일은 Chrome 개발자 도구의 Performance 패널이나 VS Code에서 열어, 함수별 실행 시간을 위아래로 쌓은 불꽃 그래프(flame graph)로 볼 수 있습니다. --heap-prof는 메모리를 어디서 할당했는지를 표본으로 기록하고, --inspect는 실행 중인 프로그램에 Chrome의 chrome://inspect나 VS Code를 연결해 원하는 때에 CPU 프로파일을 기록하거나 Memory 탭에서 힙 스냅샷을 찍게 합니다. VS Code에서 디버깅 중이라면 명령 팔레트의 Debug: Take Performance Profile로도 기록할 수 있습니다.

메모리 누수를 의심할 때는 힙 스냅샷 하나로 판단하지 않습니다. 같은 동작을 여러 번 반복하면서 스냅샷을 몇 번 찍어, 반복할수록 늘어나기만 하고 가비지 컬렉션(GC)을 거쳐도 줄지 않는 객체가 있는지를 비교합니다. 흔한 원인은 지우지 않은 이벤트 리스너와 타이머, 크기 제한 없이 커지는 캐시입니다.

Node.js는 하나의 스레드에서 이벤트 루프로 요청을 처리하므로, 한 요청이 CPU를 오래 쓰면 그동안 다른 모든 요청이 기다립니다. node:perf_hooks의 monitorEventLoopDelay()로 이벤트 루프가 얼마나 늦어지는지를 잴 수 있고, 무거운 계산은 worker_threads로 다른 스레드에 넘겨 이벤트 루프를 막지 않게 합니다. 데이터베이스 쪽에서는 ORM 사용에서 본 것처럼 실행되는 SQL을 로그로 보아, 목록의 행마다 쿼리를 하나씩 더 보내는 N+1 문제나 인덱스가 없는 조회를 찾습니다.

타입스크립트와 실행 성능

타입스크립트의 타입은 컴파일하면 사라지므로, 타입을 붙였다고 실행이 느려지거나 빨라지지는 않습니다. 실행 성능에 영향을 주는 것은 target 설정에 따라 바뀌는 출력 코드입니다. 예를 들어 target을 낮게 잡으면 async 함수나 클래스 필드가 더 긴 코드로 바뀌므로, 실행할 환경이 지원하는 가장 높은 target을 고릅니다.

대신 타입은 최적화 작업을 안전하게 해 줍니다. 구조를 바꾸는 리팩터링에서 호출하는 쪽의 불일치를 컴파일러가 찾아 주므로, 측정으로 찾은 병목을 고치다가 다른 곳을 깨뜨릴 위험이 줄어듭니다.

컴파일 시간의 병목

프로젝트가 커지면 tsc의 검사 시간 자체가 병목이 됩니다. 이때도 먼저 잽니다.

npx tsc --noEmit --extendedDiagnostics
npx tsc --noEmit --generateTrace trace

--extendedDiagnostics는 검사한 파일 수와 타입 수, 파일을 읽고 해석하고 검사하는 데 걸린 시간을 단계별로 보여 주므로, 검사가 오래 걸리는지 파일을 너무 많이 읽는지를 가를 수 있습니다. --generateTrace는 trace 폴더에 검사 과정의 기록을 남기며, 이 기록을 Chrome의 성능 도구나 @typescript/analyze-trace 같은 도구로 열면 어느 파일과 어느 타입의 검사가 오래 걸렸는지 보입니다. 지나치게 깊은 조건부 타입이나 아주 큰 유니온이 흔한 원인입니다.

검사할 범위를 줄이는 방법은 증분 컴파일과 빌드 최적화에서 본 incremental과 프로젝트 레퍼런스입니다. 또 2026년 7월에 나온 TypeScript 7.0은 Go로 다시 구현한 컴파일러로 전체 빌드가 대략 8배에서 12배 빨라졌다고 발표되었지만, 아직 컴파일러 API를 제공하지 않아 ts-jest 같은 도구와 일부 편집기 플러그인은 6.0을 함께 써야 합니다. 어느 방법이든 바꾸기 전과 후의 시간을 같은 조건에서 재어 비교합니다.

더 깊이: 성능 기록을 제대로 남기고 읽는 법

공식 저장소의 Performance Tracing 안내는 기록을 남기기 전에 몇 가지를 맞추라고 합니다. 증분 빌드는 --incremental false로 끄고, 빌드 모드라면 -f를 붙여 모두 다시 빌드합니다. 컴파일 오류도 먼저 고치되, 당장 고칠 수 없다면 any보다 @ts-ignore로 덮는 편이 낫다고 하는데, any는 그 자리의 검사를 짧게 끊어 버리기 때문입니다. 또 타입스크립트를 API로 부르는 번들러를 거치면 기록이 만들어지지 않으므로 tsc를 직접 실행합니다.

보통의 컴파일은 trace.json과 types.json을 남기고, 빌드 모드에서는 trace.1.json처럼 번호가 붙은 기록 파일이 여럿 생기고, 각 기록 파일이 어느 tsconfig.json에 해당하는지는 legend.json이 알려 줍니다. 기록은 about://tracing에서 열 수 있는데, 개발자 도구의 Performance 탭으로 올리면 주로 컴파일 오류를 나타내는 순간 이벤트가 빠진다고 안내합니다. 기록에서는 먼저 검사 단계의 checkSourceFile, checkExpression, checkVariableDeclaration 상자를 따라 느린 코드를 좁히고, 프로그램을 만드는 단계에 큰 findSourceFile 상자가 있다면 파일을 읽어 들이는 쪽을 의심합니다. 빠르게 요약만 보고 싶다면 npm install --save-dev @typescript/analyze-trace로 먼저 설치한 뒤 npx analyze-trace <기록 폴더>를 실행합니다. 설치하지 않고 npx analyze-trace를 실행하면, npx가 레지스트리에서 이름이 같은 다른 패키지 analyze-trace를 내려받아 실행하려 하기 때문입니다.

정리

  • 최적화는 조건을 정해 기준값을 재고, 프로파일러로 병목의 가설을 세운 뒤, 하나만 바꾸고 같은 조건에서 다시 재는 순서로 합니다.
  • 개선은 평균만이 아니라 p95와 메모리, 기능의 회귀까지 함께 확인하고, 나아진 측정은 회귀 검사로 남깁니다.
  • CPU와 렌더링의 문제는 CPU 프로파일과 Performance 패널, React Profiler로, 메모리의 문제는 여러 번의 힙 스냅샷으로, 대기의 문제는 쿼리와 추적으로 봅니다.
  • 브라우저에서는 긴 작업과 Core Web Vitals를 보고, React에서는 Profiler로 렌더링의 비용과 이유를 확인한 뒤 맞는 최적화를 고릅니다.
  • Node.js는 --cpu-prof, --heap-prof, --inspect로 프로파일을 남기며, 이벤트 루프를 막는 계산은 워커 스레드로 넘깁니다.
  • 타입은 실행 성능에 영향을 주지 않으며, 컴파일 시간의 병목은 --extendedDiagnostics와 --generateTrace로 재고 줄입니다.

연습 문제

1. 평균과 p95

어떤 API의 평균 응답 시간이 120ms에서 100ms로 줄었는데, p95는 400ms에서 600ms로 늘었습니다. 이 변경을 개선이라고 볼 수 있나요?

해설 보기

볼 수 없습니다. 평균은 줄었지만 느린 쪽의 요청 5퍼센트가 더 느려졌으므로, 그 요청을 받는 사용자는 전보다 나쁜 경험을 합니다. 미리 정한 목표가 p95 기준이라면 이 변경은 목표를 벗어난 것이며, 어떤 요청이 느려졌는지 추적해 원인을 찾은 뒤 다시 재어야 합니다.

2. CPU는 한가한데 느린 서버

서버의 CPU 사용률은 10퍼센트 정도인데 응답은 2초씩 걸립니다. CPU 프로파일을 먼저 볼까요, 아니면 다른 것을 볼까요?

해설 보기

CPU가 한가하므로 함수의 실행보다 무언가를 기다리는 시간이 길 가능성이 큽니다. 먼저 데이터베이스의 쿼리 시간과 바깥 서비스의 응답 시간, 연결 풀이 모자라 기다리는지를 추적으로 확인합니다. CPU 프로파일은 기다리는 시간을 함수의 실행으로 보여 주지 않으므로, 이 증상의 원인을 찾는 데는 맞지 않습니다.

3. 힙 스냅샷 하나

서버를 띄우고 힙 스냅샷을 한 번 찍었더니 문자열이 메모리의 절반을 차지했습니다. 이것으로 메모리 누수라고 판단할 수 있나요?

해설 보기

판단할 수 없습니다. 스냅샷 하나는 그 순간에 무엇이 있는지만 보여 줄 뿐, 시간이 지나며 늘어나는지는 보여 주지 않습니다. 같은 동작을 여러 번 반복하면서 스냅샷을 몇 번 찍어, 반복할수록 늘어나고 GC를 거쳐도 줄지 않는 객체가 있는지를 비교해야 누수인지 알 수 있습니다.

이것으로 15장의 테스팅과 디버깅을 마칩니다. 다음 장의 프로젝트 구조 설계에서는 지금까지 배운 것을 모아, 클라이언트와 서버, 공유 패키지로 이루어진 실전 프로젝트의 구조를 설계합니다.