
성능 프로파일링
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

엄청난 데이터를 아주 적은 메모리로 검사하는 방법. 100% 정확도를 포기하고 99.9%의 효율을 얻는 확률적 자료구조의 세계. 비트코인 지갑과 스팸 필터는 왜 이것을 쓸까요?

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

사용자 대시보드가 느리다는 피드백을 받았다. 체감상으로도 확실히 버벅거렸다. 그래서 내가 한 일은? 검색해서 나온 "React 성능 최적화 Best Practices" 글을 보고 모든 컴포넌트에 React.memo()를 붙이고, 모든 함수에 useCallback()을 감싸고, 모든 계산에 useMemo()를 적용했다.
결과는? 더 느려졌다.
뭔가 잘못됐다는 걸 직감했지만 어디서부터 손을 대야 할지 몰랐다. 컴포넌트가 30개가 넘는데, 어느 것이 문제인지 감으로는 알 수 없었다. 의사가 증상만 듣고 수술하지 않듯이, 개발자도 체감만 믿고 코드를 고쳐서는 안 된다는 걸 그제야 깨달았다.
그때 선배가 던진 한마디: "Performance 탭 켜봤어?"
Chrome DevTools의 Performance 탭을 열었다. 빨간 녹화 버튼을 누르고, 느린 대시보드 화면을 조작한 뒤 멈췄다. 그리고 나타난 건... 마치 심전도처럼 생긴 무언가였다.
처음엔 암호 같았다. 하지만 천천히 읽다 보니 이게 내 앱의 '해부도'라는 걸 알았다. Flame chart라는 이 그래프는 시간 순서대로(왼쪽→오른쪽) 브라우저가 무슨 일을 했는지 보여줬다. 노란색 블록이 JavaScript 실행, 보라색이 Layout, 초록색이 Paint.
그런데 한 구간에서 노란색 블록이 1초 가까이 이어져 있었다. 확대해서 보니 calculateMetrics라는 함수가 범인이었다. 300개 항목을 루프 돌며 복잡한 계산을 하고 있었는데, 이게 메인 스레드를 완전히 막고 있었다.
// 문제의 코드
function Dashboard() {
const metrics = calculateMetrics(data); // 매 렌더마다 300개 항목 계산
return (
<div>
{metrics.map(m => <MetricCard key={m.id} {...m} />)}
</div>
);
}
function calculateMetrics(data) {
// 1초 가까이 걸리는 무거운 계산
return data.map(item => ({
...item,
score: complexAlgorithm(item),
trend: calculateTrend(item.history),
projection: runSimulation(item)
}));
}
나는 컴포넌트 리렌더링이 문제라고 생각했는데, 실제 병목은 무거운 계산이었다. 마치 차가 느린 이유가 타이어가 아니라 엔진에 있었던 것처럼.
Flame chart 아래 Summary 패널을 봤더니 더 명확했다. 전체 시간 중:
렌더링 최적화에 집중했던 내 노력은 12%의 문제를 해결하려 했던 것이고, 진짜 78%의 범인은 놔둔 셈이었다.
이 경험으로 배운 것: 프로파일러는 거짓말하지 않는다. 내 직감은 틀릴 수 있지만, 측정 데이터는 정확하다.
Performance 탭에서 가장 중요한 개념을 하나 꼽으면 Main Thread 분석이다. 브라우저의 메인 스레드는 단 하나뿐이다. JavaScript 실행, Layout 계산, Paint, 사용자 입력 처리까지 전부 여기서 일어난다.
그래서 50ms 이상 걸리는 작업(Long Task)이 하나라도 있으면 사용자 인터랙션이 멈춘다. 버튼을 눌러도 반응이 없는 그 찰나의 순간 말이다.
Performance 탭은 Long Task를 빨간색으로 표시해준다. 내 대시보드에는 무려 3개의 Long Task가 있었다. 모두 calculateMetrics 함수와 연관돼 있었다.
해결책은 간단했다:
// 해결 방법 1: useMemo로 캐싱
function Dashboard() {
const metrics = useMemo(
() => calculateMetrics(data),
[data] // data가 바뀔 때만 재계산
);
return (
<div>
{metrics.map(m => <MetricCard key={m.id} {...m} />)}
</div>
);
}
// 해결 방법 2: Web Worker로 오프로드
const worker = new Worker('metrics-worker.js');
function Dashboard() {
const [metrics, setMetrics] = useState([]);
useEffect(() => {
worker.postMessage(data);
worker.onmessage = (e) => setMetrics(e.data);
}, [data]);
return (
<div>
{metrics.map(m => <MetricCard key={m.id} {...m} />)}
</div>
);
}
이번엔 다시 Performance 탭으로 측정했다. Long Task가 사라졌다. Scripting 시간이 78%에서 23%로 떨어졌다. 측정 → 식별 → 수정 → 검증 사이클을 돌리니 확실히 개선됐다.
또 다른 발견은 보라색 Layout 블록이 톱니처럼 반복되는 구간이었다. 이게 바로 Layout Thrashing(강제 리플로우)이다.
코드를 보니 이런 패턴이었다:
// 안티패턴: 읽기-쓰기 반복
function adjustHeights(elements) {
elements.forEach(el => {
const height = el.offsetHeight; // 읽기 → Layout 발생
el.style.height = height + 10 + 'px'; // 쓰기 → 다음 읽기 시 다시 Layout
});
}
// 개선: 읽기 먼저, 쓰기 나중에
function adjustHeights(elements) {
const heights = elements.map(el => el.offsetHeight); // 읽기 일괄 처리
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // 쓰기 일괄 처리
});
}
DOM 읽기(offsetHeight, getBoundingClientRect 등)는 브라우저에게 "지금 당장 Layout 계산해!"라고 요구하는 것과 같다. 읽기와 쓰기를 섞으면 Layout이 여러 번 발생해서 성능이 폭망한다.
마치 요리할 때 재료를 하나씩 꺼내서 쓰고 다시 냉장고 가고를 반복하는 것과 같다. 미리 필요한 재료를 다 꺼내놓고 요리하는 게 훨씬 빠르다.
Lighthouse를 돌려보니 Core Web Vitals 점수가 형편없었다:
LCP는 가장 큰 콘텐츠 요소가 렌더되는 시간이다. 내 경우 큰 차트 이미지가 문제였다. 이미지를 WebP로 바꾸고 lazy loading을 적용하니 2.1초로 개선됐다.
FID(현재는 INP로 대체 중)는 사용자 인터랙션 지연 시간이다. 위에서 고친 Long Task 제거로 이미 100ms 이하로 내려갔다.
CLS는 예상치 못한 레이아웃 이동이다. 이미지에 width와 height 속성을 명시하고, 스켈레톤 UI를 추가하니 0.05로 떨어졌다.
이 숫자들은 단순한 점수가 아니다. 실제 사용자가 느끼는 답답함을 정량화한 것이다. 점수를 개선하자 사용자 피드백도 확연히 좋아졌다.
Chrome DevTools도 좋지만, React 앱에서는 React DevTools의 Profiler가 더 직관적이다. 어느 컴포넌트가 얼마나 자주, 얼마나 오래 렌더되는지 보여준다.
더 나아가 React Profiler API를 쓰면 코드에서 직접 측정할 수 있다:
import { Profiler } from 'react';
function onRenderCallback(
id, // "Dashboard" 같은 Profiler id
phase, // "mount" 또는 "update"
actualDuration, // 렌더에 걸린 시간
baseDuration, // 메모이제이션 없이 걸릴 시간
startTime,
commitTime
) {
console.log(`${id} ${phase}: ${actualDuration}ms`);
// 프로덕션에서는 analytics로 전송
if (actualDuration > 100) {
analytics.track('slow_render', { component: id, duration: actualDuration });
}
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}
이렇게 하면 실제 사용자 환경에서 성능 데이터를 수집할 수 있다. 개발 환경에선 빠른데 프로덕션에선 느린 경우를 잡아낼 수 있다.
결국 내가 배운 것은 워크플로우였다:
처음에 내가 한 일(무작정 memo, callback 도배)은 3번 수정부터 시작한 거였다. 1, 2번 없이 3번을 하면 그건 도박이다. 운이 좋으면 맞을 수도 있지만, 대부분은 빗나간다.
프로파일러를 쓰기 전과 후의 나는 완전히 다른 개발자가 됐다. 이제는 "이게 느린 것 같은데?"가 아니라 "Performance 탭에서 보니 이 함수가 200ms 걸리네"라고 말한다. 감이 아니라 사실에 기반해서 결정한다.
성능 최적화는 예술이 아니라 과학이다. 그리고 그 과학의 현미경이 바로 프로파일러다.