C-8: React Compiler 시대의 성능 — 측정 먼저
목차 67
여러분 앱은 빠른가요?
빠르다고 답하셨다면 하나 더 여쭐게요, 그걸 어떻게 아셨어요?
느리다고 답하셨다면 같은 질문인데, 어디가 느린지는 아세요?
안녕하세요, 홍순구입니다. 이 두 질문에 숫자로 답할 수 있는 사람은 생각보다 적은데, 대부분은 "그냥 좀 버벅이는 것 같아서"라고 답하면서 그 느낌만 가지고 코드를 고치기 시작해요.
오늘은 그 순서를 뒤집습니다. 재고 나서 고칩니다.
💡 오늘 수업의 핵심 — "빨라 보이게 만드는 것과 빠르게 만드는 것은 다릅니다"
지난 시간에 우리는 하트를 즉시 반응하게 만들었는데, 서버는 하나도 안 빨라졌는데도 화면은 빨라 보였어요. 그건 눈속임에 가까웠는데, 나쁜 눈속임은 아니지만 실제로 일을 줄인 건 아니었죠.
오늘은 실제로 일을 줄이는데, 그러려면 먼저 얼마나 하고 있는지부터 알아야 해요.
오늘 여러분은 조금 이상한 경험도 하게 되는데, 재보면 이미 누군가 우리 대신 일을 줄여두고 있고 심지어 우리가 시킨 적도 없거든요.
🎯 학습 목표
- 화면이 몇 번 다시 그려지는지를 느낌이 아니라 도구로 확인합니다.
- 빌드가 만들어낸 코드를 직접 열어보고 자동 메모이제이션의 정체를 확인합니다.
- 자동 최적화를 껐다 켜서 그것이 실제로 일하고 있는지 가릅니다.
- 자동 최적화가 통째로 포기하는 파일이 있다는 것과 그 이유를 확인합니다.
- 그래도 손으로 기억해둬야 하는 한 자리를 찾고, 거기서만 씁니다.
- 다시 그리는 비용보다 큰 비용을 재고, 화면을 나눠 내려받습니다.
오늘의 여정
1. 몇 번 그려지나 하트 한 번에 카드가 여섯 번
↓
2. 우리가 안 쓴 코드 빌드 결과물을 열어본다
↓ (도구가 있는데 왜 여섯 번이지?)
3. 껐다 켜서 가른다 6 · 6 · 6 · 0 — 갈리는 한 줄
↓
4. 포기하는 파일 규칙을 어기면 건너뛴다
↓
5. 손으로 쓰는 한 자리 effect 가 뜻 없이 도는 곳
↓ (다시 그리기보다 큰 비용이 있다)
6. 처음 내려받는 양 600 KB 한 덩어리
↓
7. 쪼개고 다시 잰다 lazy · Suspense
↓
8. 어디를 경계로 결정 순서
Step 1: "몇 번 그려지는지부터 센다"
느낌으로는 아무것도 못 고칩니다
우리 피드는 지금 꽤 잘 돌아가는데, 하트를 누르면 즉시 빨개지고 알림이 떴다가 사라져서 눈으로 봐서는 문제가 없어 보입니다.
"문제가 없다"와 "일을 안 하고 있다"는 다른 이야기라서, 화면이 부드러워 보여도 뒤에서 필요 없는 일을 잔뜩 하고 있을 수 있고 게시물이 세 장일 때는 안 보이다가 삼백 장이 되면 그게 드러나요.
그러니 먼저 셉니다.
다시 그려지는 것을 눈으로 보는 법
React 개발자 도구에는 이걸 그대로 보여주는 기능이 있는데, 브라우저 확장으로 설치하셨다면 개발자 도구에 Components와 Profiler라는 탭이 새로 생겨 있을 거예요.
Components 탭의 톱니바퀴를 누르면 설정이 나오는데, 거기서 이것을 켜세요.
☑ Highlight updates when components render
이걸 켜면 다시 그려지는 컴포넌트마다 테두리가 번쩍이는데, 코드는 한 줄도 안 고치고 그냥 보기만 하는 겁니다.
이게 중요한데, 재려고 코드를 고치기 시작하면 그 코드가 결과에 영향을 주기 때문에 재는 동안에는 아무것도 안 건드리는 게 좋습니다.
하트를 한 번 눌러봅시다
이제 홈 화면에서 첫 게시물의 하트를 눌러보세요.
빨개지는 건 첫 번째 카드뿐인데, 테두리는 어디에서 번쩍이나요?
하트를 한 번 눌렀을 때
┌─────────────┐
│ 카드 1 │ ← 번쩍 (내가 누른 것)
└─────────────┘
┌─────────────┐
│ 카드 2 │ ← 번쩍 (?)
└─────────────┘
┌─────────────┐
│ 카드 3 │ ← 번쩍 (?)
└─────────────┘
카드 세 장이 전부 번쩍이는데, 정작 누른 건 첫 번째 하나뿐입니다.
두 번째, 세 번째 카드는 좋아요 개수도 사진도 그대로인데, 아무것도 안 바뀐 채로 다시 그려졌습니다.
번쩍임은 눈으로 보는 신호라 몇 번인지까지 세기는 어렵기 때문에 따로 세어봤는데, 하트 한 번에 카드 세 장이 각각 두 번씩, 모두 여섯 번 다시 그려집니다.
🙋 학생 질문 — "다시 그려지면 화면이 깜빡이나요?"
아니요, 그래서 눈으로는 안 보였던 겁니다.
React가 컴포넌트를 다시 그린다는 것은 그 함수를 다시 부른다는 뜻인데, 함수를 불러서 나온 결과가 이전과 같으면 실제 화면(DOM)은 안 건드리기 때문에 깜빡임이 없어요.
그러니 낭비되는 것은 화면이 아니라 계산인데, 함수를 부르고 결과를 만들고 이전 것과 비교하고 같으니까 버리는 과정이 그대로 낭비예요. 카드 세 장이면 별것 아니지만 삼백 장이면 이야기가 달라져요.
왜 두 번인가요
번쩍임이 두 번인 이유부터 짚고 갈게요.
하트를 누르면 우리 코드에서 두 가지 일이 일어나는데, 알림이 떠서 toast 상태가 바뀌고 좋아요 요청이 나가면서 상태가 갈립니다. 이 둘이 각각 화면을 다시 그리게 만들어요.
여기까지는 정상인데, 알림을 띄우기로 한 건 우리고 요청 상태가 바뀌는 것도 당연해요.
문제는 그게 왜 카드까지 번지느냐인데, 알림은 피드 아래쪽에 뜨는 작은 상자일 뿐이거든요.
💡 한 줄 정리
느낌으로는 아무것도 못 고치는데, 개발자 도구의 Highlight updates를 켜면 다시 그려지는 것에 테두리가 번쩍여요. 하트 한 번에 카드 세 장이 두 번씩, 모두 여섯 번 다시 그려집니다.
Step 2: "우리가 안 쓴 코드가 이미 들어 있습니다"
먼저 고백할 게 하나 있습니다
여러분은 아마 React를 공부하면서 이런 조언을 들어보셨을 거예요.
"다시 그려지는 걸 막으려면 값을 기억해둬라."
이름도 들어보셨을 겁니다 — useMemo, useCallback, React.memo. React를 검색하면 거의 반드시 나오는 세 가지인데, 저는 지금까지 이 셋을 한 번도 안 꺼냈습니다. 스물다섯 번의 시간 동안 한 번도 안 썼는데, 일부러 그랬습니다.
이유가 있는데, 지금 그 일을 사람이 안 하고 있거든요.
우리 설정에 이미 들어 있던 것
프로젝트를 처음 만들 때 넣어뒀던 빌드 설정을 다시 열어봅시다.
// apps/web-spa/vite.config.ts
plugins: [
react(),
tailwindcss(),
// React Compiler — 빌드할 때 자동으로 메모이제이션을 넣어준다
await babel({
presets: [reactCompilerPreset()],
}),
],
이 세 줄이 React Compiler인데, 2025년 10월에 1.0이 정식으로 나왔고 지금은 안정된 도구예요.
하는 일은 이름 그대로인데, 빌드할 때 우리 컴포넌트를 읽고 다시 계산할 필요 없는 곳에 기억해두는 코드를 대신 써 넣습니다.
말로만 들으면 안 믿기니, 결과물을 직접 열어봅시다.
컴파일러가 실제로 쓴 코드
우리가 쓴 코드가 이렇다고 해볼게요.
// 컴파일러에게 넣어준 코드 (설명을 위해 줄인 것)
export function LikeButton({ liked, count, onToggle }) {
const label = liked ? '좋아요 취소' : '좋아요';
return <button onClick={onToggle} aria-label={label}>{count}</button>;
}
빌드를 거치면 이렇게 바뀝니다.
// 컴파일러가 만들어낸 코드
import { c as _c } from "react/compiler-runtime";
export function LikeButton(t0) {
const $ = _c(4);
const { liked, count, onToggle } = t0;
const label = liked ? "좋아요 취소" : "좋아요";
let t1;
if ($[0] !== count || $[1] !== label || $[2] !== onToggle) {
t1 = <button onClick={onToggle} aria-label={label}>{count}</button>;
$[0] = count;
$[1] = label;
$[2] = onToggle;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
한 줄씩 읽어봅시다.
_c(4)는 칸이 네 개인 서랍을 하나 꺼내는 것인데, 정식 이름은 캐시예요 — 이 교안에서는 계속 "서랍"이라고 편하게 부를게요. 이 서랍(캐시)은 이 컴포넌트가 살아 있는 동안 계속 남아 있어요.
그다음 if가 핵심인데, 서랍에 지난번에 넣어둔 값들과 지금 값을 하나씩 비교해요. 하나라도 다르면 화면을 새로 만들고, 만든 것과 비교한 값들을 서랍에 넣습니다.
전부 같으면? else로 가서 지난번에 만들어둔 것을 그대로 꺼내 쓸 뿐, 새로 만들지 않아요.
서랍 칸 네 개의 정체
$[0] count ┐
$[1] label ├─ 비교할 값 세 개
$[2] onToggle ┘
$[3] 만들어둔 화면 ← 값이 그대로면 이걸 그냥 꺼낸다
우리가 손으로 썼다면 useMemo로 감쌌을 그 일을, 컴파일러가 대신 써준 겁니다.
앱의 진짜 코드는 어떤가요
방금 건 설명하려고 줄인 예시예요. 실제 우리 LikeButton은 하트 아이콘과 개수 표시가 더 있어서 서랍이 더 큰데, 재보면 열한 칸이에요.
다른 화면도 마찬가지입니다.
| 화면 | 서랍(캐시) 칸 |
|---|---|
LikeButton |
11 |
FeedSection |
24 |
PostCard |
34 |
PostBody |
36 |
HomePage |
3 |
우리가 쓴 코드에는 useMemo도 useCallback도 한 글자도 없고, 전부 컴파일러가 넣은 겁니다.
🙋 학생 질문 — "그럼 개발자 도구에서도 확인할 수 있나요?"
네. Components 탭에서 컴포넌트를 고르면 이름 옆에 Memo ✨라는 배지가 붙어 있는데, 컴파일러가 최적화한 컴포넌트라는 표시예요.
배지가 안 보이면 둘 중 하나인데, 컴파일러가 그 컴포넌트를 건너뛰었거나 최적화할 게 없었던 거예요. 건너뛰는 경우는 잠시 뒤에 자세히 봅니다.
그런데 이상하죠
여기서 앞뒤가 안 맞는데, Step 1에서 우리는 카드가 여섯 번 다시 그려지는 걸 봤거든요. 방금 확인한 대로면 컴파일러가 이미 기억해두는 코드를 넣어뒀습니다.
기억해두는 코드가 있는데 왜 여섯 번이나 그려지죠?
둘 중 하나인데, 컴파일러가 일을 안 하고 있거나 일을 하는데도 여섯 번인 거예요. 이건 재보면 압니다.
💡 한 줄 정리
React Compiler가 빌드할 때 우리 코드에 기억해두는 코드를 대신 써 넣는데, _c(N) 서랍을 만들고 값이 그대로면 지난번 화면을 다시 씁니다. 우리 코드에는 useMemo가 한 글자도 없는데도요.
Step 3: "껐다 켜서 가릅니다"
도구가 일하는지 확인하는 방법
"컴파일러가 알아서 해줍니다"라는 말을 그냥 믿으면 안 되는데, 믿는지 아닌지는 껐다 켜보면 알아요.
빌드 설정에서 그 세 줄을 잠깐 빼봅시다.
// apps/web-spa/vite.config.ts — 잠깐만 이렇게
plugins: [
react(),
tailwindcss(),
// 컴파일러를 뺀 상태 — 재보고 나서 반드시 되돌립니다
],
⚠️ 이건 재보려고 잠깐 빼는 겁니다. 확인이 끝나면 반드시 되돌리세요. 되돌린 뒤에는 파일이 원래대로인지 눈으로 한 번 확인하시고요.
이제 다시 하트를 눌러보세요.
결과가 뜻밖입니다
여섯 번입니다. 컴파일러를 켰을 때와 똑같아요.
하트 한 번에 카드가 다시 그려진 횟수
컴파일러 끔 → 6
컴파일러 켬 → 6
컴파일러가 놀고 있었던 걸까요? 아닙니다. Step 2에서 우리는 컴파일러가 만든 코드를 두 눈으로 봤는데, FeedSection은 서랍이 스물네 칸이었습니다.
일을 안 하는 게 아니라, 못 하고 있는 겁니다. 그러면 왜 못 하는지를 찾아야죠.
서랍이 매번 비워지고 있었습니다
컴파일러의 판단 기준을 다시 떠올려보면, 서랍에 넣어둔 값과 지금 값을 비교해서 하나라도 다르면 새로 만듭니다.
그러니 뒤집어 생각해보면 답이 나오는데, 매번 새로 만든다는 건 비교하는 값 중에 매번 달라지는 게 있다는 뜻입니다.
FeedSection을 보면, 알림을 띄우고 좋아요를 보내는 함수가 있어요.
// apps/web-spa/src/components/FeedSection.tsx — 고치기 전
export function FeedSection({ posts }: FeedSectionProps) {
const likeMutation = useLikeMutation();
// ...
function handleToggleLike(id: number) {
const target = findById(posts, id);
if (!target) {
return;
}
setToast({ message: likeToastMessage(target) });
likeMutation.mutate(id);
}
handleToggleLike는 posts와 likeMutation을 붙잡고 있는데, posts는 안 바뀌니까 likeMutation이겠네요.
재봤습니다. 화면을 두 번 그리는 동안 useLikeMutation()이 돌려준 것을 모아보면 이렇습니다.
| 무엇을 봤나 | 서로 다른 것의 수 |
|---|---|
useLikeMutation() 결과 통째로 |
2 (매번 새것) |
그 안의 .mutate |
1 (안 바뀜) |
결과 객체는 매번 새로 만들어지는데, 요청이 어떤 상태인지 답이 왔는지 같은 게 계속 갈리니까 당연해요.
우리가 실제로 쓰는 건 .mutate 하나뿐인데, 그건 안 바뀝니다.
handleToggleLike 가 붙잡고 있던 것
likeMutation ← 통째로 붙잡음. 매 렌더 새것.
├── mutate 안 바뀜 ← 우리가 쓰는 건 이거 하나
├── isPending 바뀜
├── data 바뀜
└── ... 바뀜
하나라도 다르면 새로 만든다 → 매번 새로 만든다
컴파일러 잘못이 아니었는데, 우리가 매번 바뀌는 것을 붙잡고 있으니 컴파일러는 정직하게 다시 만든 거예요.
한 줄을 고칩니다
안 바뀌는 것만 꺼내 씁니다.
// apps/web-spa/src/components/FeedSection.tsx
export function FeedSection({ posts }: FeedSectionProps) {
const { mutate: toggleLike } = useLikeMutation();
// 알림은 이 화면에만 있다가 사라지는 것이라 여기 그대로 둔다.
const [toast, setToast] = useState<FeedToast | null>(null);
// 세는 것도 넘겨받은 것에서 센다. 따로 들고 있는 숫자가 없다.
const likedCount = posts.filter((post) => post.liked).length;
function handleToggleLike(id: number) {
const target = findById(posts, id);
if (!target) {
return;
}
setToast({ message: likeToastMessage(target) });
toggleLike(id);
}
바뀐 건 받는 방식과 부르는 방식, 두 줄뿐이라 동작은 하나도 안 바뀝니다.
다시 재봅시다
컴파일러를 켠 상태로 하트를 누르면 — 카드에 테두리가 하나도 안 번쩍입니다.
여섯 번이 영 번이 됐는데, 여기서 멈추면 안 됩니다. 방금 우리는 두 가지를 동시에 한 셈인데, 컴파일러도 켜져 있고 코드도 고쳤습니다. 둘 중 무엇 덕분인지 아직 모릅니다.
그러니 고친 코드 그대로 컴파일러만 다시 꺼봅니다.
컴파일러 끔 컴파일러 켬
likeMutation 통째로 6 6
{ mutate } 만 꺼내서 6 0
이제 분명합니다. 컴파일러만으로는 안 됩니다. 코드를 고치는 것만으로도 안 됩니다. 둘이 만나야 영이 돼요.
🙋 학생 질문 — "그럼 컴파일러가 있어도 우리가 신경 쓸 게 있다는 건가요?"
네. 다만 신경 쓰는 방향이 예전과 다릅니다.
예전에는 "여기에 useCallback을 씌울까?"를 고민했지만, 지금은 그럴 필요가 없고 대신 "내가 지금 안 바뀌는 것을 붙잡고 있나, 매번 바뀌는 것을 붙잡고 있나"를 봅니다.
앞의 것은 도구를 하나 더 쓰는 일이고, 뒤의 것은 코드를 더 정확하게 쓰는 일이에요. 뒤쪽이 더 나은데, 도구를 안 써도 남는 개선이니까요.
⚠️ 확인이 끝났으면 빌드 설정을 원래대로 되돌리세요. 컴파일러가 빠진 채로 계속 가면 다음 Step의 이야기가 전부 어긋납니다.
💡 한 줄 정리
컴파일러를 껐다 켜서 갈랐습니다. 매번 바뀌는 것을 통째로 붙잡으면 컴파일러가 있어도 여섯 번이에요. 안 바뀌는 .mutate만 꺼내 쓰면 영 번이 되는데, 컴파일러를 끄면 다시 여섯 번입니다. 둘이 만나야 합니다.
Step 4: "컴파일러가 통째로 포기하는 파일이 있습니다"
서랍이 아예 없는 파일
컴파일러가 만든 코드를 파일별로 훑어보면 이상한 게 눈에 띄는데, 어떤 파일에는 _c(N) 서랍이 하나도 없어요.
| 파일 | 결과 |
|---|---|
LikeButton |
서랍 11칸 |
PostBody |
서랍 36칸 |
FeedSection |
서랍 24칸 |
ClickCounter |
없음 |
RefVsStateDemo |
없음 |
뒤의 두 파일은 컴파일러가 통째로 건너뛰어 최적화를 아예 안 했는데, 이 두 파일은 어디서 본 적 있으시죠?
우리가 일부러 규칙을 어겨둔 파일들
ClickCounter는 상태를 배우기 전에 "일반 변수로 세면 왜 안 되는가"를 보려고 만든 화면입니다.
// apps/web-spa/src/components/ClickCounter.tsx
export function ClickCounter() {
// 상태를 배우기 전 단계 — 일반 변수로 세어 본다
let clickCount = 0;
function handleClick() {
clickCount += 1;
console.log('지금 clickCount 는', clickCount);
}
return (
<button className="..." onClick={handleClick}>
눌린 횟수: {clickCount}
</button>
);
}
RefVsStateDemo는 useRef에 담은 값이 화면에 안 나온다는 걸 보려고, 그리기 도중에 refCount.current를 읽습니다.
두 파일 다 코드 편집기에서 빨간 줄이 그어져 있었는데, 규칙을 어겼다고 알려주는 그 줄이라서 우리는 이 두 파일에 한해 그 검사를 꺼뒀습니다. 학생이 따라 칠 때 그 경고가 그대로 떠야 하니까요.
// apps/web-spa/eslint.config.js
{
// 일반 변수로는 화면이 안 바뀐다는 것을 보여주려고 일부러 규칙을 어긴 파일이다.
// 학생이 따라 칠 때는 이 경고가 그대로 떠야 하므로 파일 안에 disable 주석을 넣지 않는다.
files: ['src/components/ClickCounter.tsx'],
rules: {
'react-hooks/immutability': 'off',
},
},
{
// ref 에 담은 값이 화면에 안 나온다는 것을 눈으로 보여주려고 일부러 렌더 중에 읽는다.
// 학생이 따라 칠 때는 이 에러가 그대로 떠야 하므로 파일 안에 disable 주석을 넣지 않는다.
files: ['src/components/RefVsStateDemo.tsx'],
rules: {
'react-hooks/refs': 'off',
},
},
여기서 놀라운 사실
컴파일러에게 "왜 건너뛰었냐" 고 물어보면 이렇게 답합니다.
| 파일 | 컴파일러가 붙인 이름 | 사유 |
|---|---|---|
ClickCounter |
Immutability |
렌더가 끝난 뒤에 변수를 다시 고칠 수 없습니다 |
RefVsStateDemo |
Refs |
그리는 도중에는 ref를 읽을 수 없습니다 |
이름을 잘 보세요. Immutability, Refs인데, 우리가 꺼둔 검사 규칙 이름은 react-hooks/immutability, react-hooks/refs였습니다.
같은 이름입니다. 우연이 아니에요.
편집기의 빨간 줄 ─────┐
├── 같은 것이었다
컴파일러의 판정 ─────┘
react-hooks/immutability ←→ category "Immutability"
react-hooks/refs ←→ category "Refs"
여러분이 그동안 편집기에서 봤던 그 빨간 줄은, 사실 컴파일러가 미리 말을 걸어온 거였습니다. "이렇게 쓰시면 제가 최적화를 못 해드립니다" 라고요.
🙋 학생 질문 — "그럼 규칙을 어긴 파일은 무조건 느려지나요?"
느려진다기보다 최적화를 못 받는다가 정확합니다. 원래 하던 만큼은 그대로 하는데, 다만 컴파일러가 줄여줄 수 있었던 몫을 못 받아요.
진짜 문제는 속도가 아니라, 규칙을 어긴 코드가 예상과 다르게 동작할 수 있다는 거예요. RefVsStateDemo를 만들 때 봤듯이, ref에 값을 바꿔도 화면이 안 갈리는 그런 일이요.
그러니 순서는 이렇습니다. 빨간 줄이 보이면 규칙을 지키도록 고치는데, 최적화는 그 결과로 따라옵니다. 최적화를 위해 규칙을 지키는 게 아니라요.
이 검사는 언제부터 있었나요
처음부터 있었는데, 프로젝트를 만들 때 넣은 eslint-plugin-react-hooks 안에 들어 있어요.
이 도구가 7 버전으로 올라오면서, 컴파일러가 쓰는 판단 기준이 검사 규칙으로 그대로 들어왔습니다. 예전에는 훅을 조건문 안에서 부르는지 정도만 봤는데, 지금은 스물아홉 가지를 봅니다. 그 덕분에 우리는 지금까지 컴파일러 이야기를 한 번도 안 하고도 컴파일러가 좋아하는 코드를 써온 것인데, 편집기가 그때그때 알려줬으니까요.
💡 한 줄 정리
규칙을 어긴 파일은 컴파일러가 통째로 건너뛰는데, 건너뛴 이유의 이름과 편집기 빨간 줄의 규칙 이름이 같아요. 그 빨간 줄은 처음부터 컴파일러의 판정이었습니다.
Step 5: "그래도 손으로 써야 하는 한 자리"
컴파일러가 못 하는 것
여기까지 오면 "그럼 useMemo는 이제 아무 데도 안 쓰나" 하는 생각이 듭니다.
거의 그렇지만, 한 자리는 남아요. React 공식 안내가 딱 하나를 지목하는데, 바로 effect 의 의존성으로 들어가는 값입니다.
왜 하필 거기일까요. 컴파일러와 effect가 서로 다른 것을 보기 때문입니다.
컴파일러가 묻는 것 : "이 값이 지난번 것과 같은 물건인가?" (신원)
effect 가 묻는 것 : "뜻이 바뀌었나?" (내용)
둘이 어긋나는 곳에서 문제가 생긴다
어긋나는 장면을 만들어봅시다
좋아요 누른 게시물 목록을 뽑아서, 그게 바뀔 때마다 어딘가에 알려주는 화면을 생각해볼게요.
// apps/web-spa/src/components/LikedSyncDemo.tsx
// (1) 컴파일러에게 맡긴다 — 파생값은 posts 의 신원을 따라간다
export function LikedSyncByCompiler({ posts, onSync }: DemoProps) {
const likedIds = posts.filter((post) => post.liked).map((post) => post.id);
useEffect(() => {
onSync(likedIds);
}, [likedIds, onSync]);
return <p>좋아요 {likedIds.length}개</p>;
}
깔끔합니다. 컴파일러가 likedIds를 기억해줄 테니 useMemo도 필요 없어 보이는데, 여기에 지난 시간에 배운 게 끼어듭니다. 서버에서 피드를 다시 물어보면 어떻게 될까요.
내용이 하나도 안 바뀌었어도 서버는 새 배열을 돌려줍니다. 같은 게시물, 같은 좋아요인데 다른 물건이라서, posts가 새것이 되고 likedIds도 새로 만들어지고 effect가 다시 돕니다.
재봤습니다
같은 내용을 세 번 다시 받아오는 동안 effect가 몇 번 돌았는지 세어봤어요.
| 방식 | effect가 돈 횟수 | 좋아요 목록이 실제로 바뀐 횟수 |
|---|---|---|
| 컴파일러에게 맡김 | 4 | 0 |
| 우리가 기준을 정함 | 1 | 0 |
좋아요 목록은 처음부터 끝까지 한 번도 안 바뀌었는데도, 첫 번째 방식은 네 번 돌았어요.
우리가 기준을 정합니다
// apps/web-spa/src/components/LikedSyncDemo.tsx
// (2) 우리가 기준을 정한다 — 내용이 같으면 같은 것으로 친다
export function LikedSyncByUseMemo({ posts, onSync }: DemoProps) {
const likedKey = posts
.filter((post) => post.liked)
.map((post) => post.id)
.join(',');
// 이 자리가 공식 문서가 말하는 탈출구다.
// "무엇이 바뀐 것으로 칠지" 를 우리가 정해야 할 때만 손으로 쓴다.
const likedIds = useMemo(
() => (likedKey === '' ? [] : likedKey.split(',').map(Number)),
[likedKey],
);
useEffect(() => {
onSync(likedIds);
}, [likedIds, onSync]);
return <p>좋아요 {likedIds.length}개</p>;
}
likedKey는 글자입니다. "1,3,7" 같은 것이요. 내용이 같으면 글자도 같으니까, useMemo에게 "이 글자가 바뀔 때만 새로 만들어라"라고 시킵니다. 배열이 새것이 되든 말든 상관하지 않게요.
⚠️ 이게 effect를 멈춰 세운 건 아닌데, 좋아요를 실제로 하나 더 누르면 글자가 바뀌고 그때는 effect가 제대로 다시 돕니다. 재봤어요. 한 번에서 두 번이 됐습니다.
그래서 언제 손으로 쓰나요
정리하면 이렇습니다.
| 상황 | 어떻게 |
|---|---|
| 다시 그려지는 게 많은 것 같다 | 먼저 잽니다. 대개 컴파일러가 이미 해뒀습니다 |
| 재보니 실제로 많다 | 매번 바뀌는 것을 붙잡고 있는지 봅니다 (Step 3) |
| 편집기에 빨간 줄이 있다 | 규칙을 지키게 고칩니다 (Step 4) |
| effect가 뜻 없이 자꾸 돈다 | 여기서 useMemo를 씁니다 |
순서가 중요한데, useMemo는 맨 아래에 있어요. 위의 셋을 다 해보고 나서 쓰는 겁니다.
🙋 학생 질문 — "기존 프로젝트에 이미 useMemo가 잔뜩 있으면 다 지워야 하나요?"
아니요. 공식 안내도 그러지 말라고 합니다.
이유는 지우는 게 나빠서가 아니라, 지우면 컴파일 결과가 달라지기 때문입니다. 달라진다는 건 동작이 미묘하게 갈릴 수 있다는 뜻이라서, 잘 돌아가는 코드를 위험하게 만들 이유가 없습니다.
그러니 이렇게 하세요. 새로 쓰는 코드에는 안 씁니다. 이미 있는 것은 그냥 둡니다. 정 지우고 싶으면 하나씩, 지운 뒤에 확인하면서요.
💡 한 줄 정리
컴파일러는 신원을 보고 effect는 내용을 보는데, 서버가 같은 내용을 새 배열로 돌려주면 그 둘이 어긋나요. 그럴 때만 useMemo로 "무엇을 바뀐 것으로 칠지"를 우리가 정합니다.
Step 6: "다시 그리는 것보다 큰 비용이 있습니다"
지금까지 잰 것은 두 번째 문제였습니다
여기까지 우리는 "이미 화면에 들어온 사람"의 이야기를 했습니다. 카드가 여섯 번 그려지든 영 번 그려지든, 그건 앱이 이미 켜진 다음의 일이에요.
그런데 앱이 켜지기 전에는요?
주소를 치고 엔터를 누른 순간부터 첫 화면이 뜰 때까지, 브라우저는 우리가 만든 코드를 통째로 내려받습니다. 그게 끝나기 전에는 아무것도 안 보여요.
이건 다시 그리는 비용과 비교가 안 되는데, 다시 그리기는 밀리초 단위지만 내려받기는 네트워크가 느리면 몇 초예요.
얼마나 되는지 재봅시다
빌드를 하면 결과가 그대로 나옵니다.
npm run build
dist/assets/index-CcLV_-HA.css 42.48 kB │ gzip: 8.58 kB
dist/assets/index-DoaYSZ9-.js 599.99 kB │ gzip: 192.12 kB
자바스크립트가 600 KB 한 덩어리입니다. 압축해서 192 KB 고요.
그리고 빌드 도구가 밑에 한마디 덧붙입니다.
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
500 KB를 넘었으니 나눠서 내려받는 걸 생각해보라고요. 도구가 먼저 말을 걸어온 겁니다.
이 안에 뭐가 들었나요
지금까지 우리가 깔아온 것들이 전부 들었습니다. React, 라우터, 서버 상태 도구, 스타일, 아이콘, 그리고 폼 도구와 검사 도구요.
여기서 하나 짚어봅시다. 회원가입 화면에는 꽤 무거운 폼 도구와 검사 도구가 들어가는데, 회원가입 화면을 보는 사람이 몇이나 될까요?
인스타그램에 들어온 사람 100명
████████████████████████████████████ 피드를 보러 온 사람 (대부분)
███ 처음 와서 가입하는 사람 (소수)
그런데 지금은 100명 모두가
회원가입 화면의 코드를 내려받고 있다
대부분은 이미 가입한 사람이라 피드를 보러 왔는데, 그런 사람들도 지금은 회원가입 화면의 코드를 전부 내려받고 나서야 피드를 봅니다.
한 번도 안 열 화면 때문에요.
💡 한 줄 정리
다시 그리는 비용보다 처음 내려받는 비용이 큰데, 우리 앱은 600 KB 한 덩어리고 거기엔 대부분의 사람이 한 번도 안 열 회원가입 화면의 코드까지 들어 있습니다.
Step 7: "쪼개고 다시 잽니다"
나중에 가져오라고 말하는 법
React에는 "이 화면은 필요해질 때 가져와라"라고 말하는 방법이 있습니다. lazy입니다.
라우트 표를 고쳐봅시다.
// apps/web-spa/src/routes/routes.ts
import { lazy } from 'react';
import type { RouteObject } from 'react-router';
import { Layout } from './Layout';
import { HomePage } from './HomePage';
import { PostDetailPage } from './PostDetailPage';
import { ExplorePage } from './ExplorePage';
import { postLoader } from './postLoader';
import { RootErrorBoundary } from './RootErrorBoundary';
import { NotFoundPage } from './NotFoundPage';
// 회원가입은 처음 오는 사람만 본다. 피드를 보러 온 사람에게까지
// 이 화면의 코드를 내려보낼 이유가 없다.
// 이름 붙은 내보내기라 default 로 갈아 끼워 건넨다.
const SignUpPage = lazy(() =>
import('./SignUpPage').then((module) => ({ default: module.SignUpPage })),
);
맨 위의 import 목록에서 SignUpPage가 빠지고 아래로 내려왔는데, 이게 핵심이에요.
위쪽 import는 "지금 가져와라"이고, 아래의 import('./SignUpPage')는 괄호가 붙은 함수예요. 부를 때 가져오는데, 우리는 그 함수를 지금 부르지 않고 lazy에게 건네주기만 했어요.
라우트 표의 나머지는 한 글자도 안 바꿉니다.
{ path: 'signup', Component: SignUpPage },
표에 적힌 이름은 그대로인데, 그 이름이 가리키는 것이 "화면"에서 "화면을 가져오는 방법"으로 바뀐 겁니다.
🙋 학생 질문 — "왜 .then으로 default를 만들어주나요?"
lazy는 건네받은 함수가 { default: 컴포넌트 } 모양을 돌려주기를 기대합니다. 기본 내보내기를 쓰는 관습에 맞춰져 있는데, 우리 프로젝트는 처음부터 이름 붙은 내보내기를 씁니다. export function SignUpPage처럼요. 이름이 있으면 편집기가 자동 완성을 해주고, 파일 이름과 컴포넌트 이름이 어긋나는 사고도 안 납니다.
그래서 관습을 바꾸는 대신 한 줄로 갈아 끼웁니다. .then((module) => ({ default: module.SignUpPage }))가 그 일이에요.
기다리는 동안 보여줄 것
여기서 새 문제가 생깁니다. 회원가입 화면이 아직 도착 안 했을 때 그 자리에 뭘 그리죠?
React는 이걸 Suspense로 처리하는데, "이 안쪽이 아직 준비가 안 됐으면 대신 이걸 보여줘라"라고 감싸두는 겁니다.
Outlet을 감싸면 됩니다.
// apps/web-spa/src/routes/Layout.tsx
{/* 아직 안 내려받은 화면이 있으면 여기서 기다린다 */}
<Suspense fallback={<p className="text-sm text-faint">화면을 불러오는 중이에요…</p>}>
<Outlet />
</Suspense>
감싼 위치를 잘 보면, Outlet만 감쌌습니다. 머리말과 메뉴는 바깥에 있어서, 기다리는 동안에도 머리말은 그대로 있습니다. 갈리는 것은 안쪽뿐인데, 라우터를 배울 때 봤던 그 구조가 여기서도 그대로 작동합니다.
┌─────────────────────────────────┐
│ 인스타그램 홈 탐색 회원가입 │ ← 그대로 있다
├─────────────────────────────────┤
│ │
│ 화면을 불러오는 중이에요… │ ← 여기만 갈린다
│ │
└─────────────────────────────────┘
다시 재봅시다
npm run build
dist/assets/index-CcLV_-HA.css 42.48 kB │ gzip: 8.58 kB
dist/assets/SignUpPage-DDjaqBIR.js 100.00 kB │ gzip: 30.19 kB
dist/assets/index-Dl8QvvZs.js 499.92 kB │ gzip: 161.70 kB
파일이 하나 늘었는데, SignUpPage라는 이름이 붙어 있어요.
| 첫 화면에 받는 JS | 압축하면 | |
|---|---|---|
| 전 | 599.99 kB | 192.12 kB |
| 후 | 499.92 kB | 161.70 kB |
100 KB가 빠졌습니다. 압축 기준으로도 30 KB가 줄었어요.
그리고 아까 그 경고가 사라졌습니다. 500 KB 밑으로 내려갔거든요.
빠진 100 KB의 정체는 폼 도구와 검사 도구인데, 회원가입 화면만 쓰던 것들이라 이제 그건 회원가입에 들어가는 사람만 받습니다.
스타일은 왜 그대로인가요
CSS를 보면 42.48 kB로 한 글자도 안 바뀌었는데, 파일 이름의 해시까지 같아요.
당연한데, 우리는 코드를 어떻게 나눠 보낼지만 바꿨지 화면 생김새는 하나도 안 건드렸으니까요.
이게 좋은 신호인데, 성능 작업을 했는데 스타일이 바뀌었다면 뭔가 의도하지 않은 걸 건드린 거예요.
두 번째부터는 안 기다립니다
회원가입에 들어갔다 나왔다가 다시 들어가 보면, 이번엔 "화면을 불러오는 중이에요"가 안 보입니다.
한 번 받아온 것은 기억하기 때문에, 기다림은 처음 한 번뿐입니다.
💡 한 줄 정리
lazy로 "필요할 때 가져와라"라고 말하고 Suspense로 기다리는 동안 보여줄 것을 정했는데, 첫 화면에 받는 코드가 600 KB에서 500 KB로 줄었고 스타일은 한 글자도 안 바뀌었습니다.
Step 8: "어디를 경계로 쪼갤까요"
다 쪼개면 되나요
회원가입에서 100 KB를 아꼈으니, 탐색 화면도 상세 화면도 전부 쪼개면 더 좋을까요?
아닙니다. 쪼개는 데도 값이 있어요.
쪼개지 않으면 쪼개면
한 번에 다 받는다 필요할 때 받는다
첫 화면이 늦다 첫 화면이 빠르다
들어가면 즉시 들어가면 잠깐 기다린다
요청 1번 요청이 화면 수만큼 는다
화면에 들어갈 때마다 잠깐 멈칫하는 앱은 오히려 답답합니다. 그러니 아무 데나 자르면 안 돼요.
세 가지를 물어봅니다
쪼갤지 말지는 이 셋으로 정합니다.
하나. 그 화면을 여는 사람이 얼마나 되나요?
대부분이 여는 화면은 어차피 받아야 해서, 쪼개면 요청만 한 번 더 늘어요. 우리 앱에서 홈은 절대 안 쪼갭니다.
둘. 그 화면만 쓰는 무거운 것이 있나요?
회원가입이 좋은 예였는데, 폼 도구와 검사 도구가 거기서만 쓰였어요. 반대로 무거운 게 없으면 쪼개도 몇 KB밖에 안 줄어들어서, 수고에 비해 남는 게 없어요.
셋. 잠깐 기다려도 되는 화면인가요?
회원가입은 어차피 한참 입력할 화면이라서, 0.2초 기다리는 게 안 거슬려요. 반대로 하트를 누르면 열리는 모달 같은 건 그렇지 않습니다.
우리 앱에 적용해보면
| 화면 | 쪼갤까 | 왜 |
|---|---|---|
| 홈 (피드) | ✗ | 거의 모두가 첫 화면으로 봅니다 |
| 회원가입 | ○ | 소수만 보고, 무거운 것이 거기만 있습니다 |
| 탐색 | △ | 많이 보지만 무거운 게 없어 이득이 작습니다 |
| 게시물 상세 | ✗ | 피드에서 바로 넘어가는데 멈칫하면 거슬립니다 |
지금 우리가 쪼갠 건 회원가입 하나뿐인데, 그게 맞아요.
오늘의 순서를 정리하면
오늘 우리가 밟은 순서가 성능 작업의 순서 그 자체입니다.
1. 잰다 도구를 켜고 숫자를 본다
↓
2. 원인을 찾는다 매번 바뀌는 것을 붙잡고 있나
↓
3. 규칙을 지킨다 빨간 줄부터 없앤다
↓
4. 그래도 남으면 그때 손으로 기억해둔다
↓
5. 더 큰 것을 본다 다시 그리기보다 내려받기
이 순서를 거꾸로 밟으면 어떻게 되는지 아세요? useMemo부터 씌우기 시작합니다. 코드는 지저분해지고, 재보지 않았으니 나아졌는지도 모르고, 정작 600 KB 짜리 덩어리는 그대로 남아 있어요.
💡 한 줄 정리
쪼개는 데도 값이 있어서 아무 데나 자르면 안 됩니다. 얼마나 많은 사람이 보나, 그 화면만 쓰는 무거운 게 있나, 잠깐 기다려도 되나 — 셋으로 정합니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 재고 나서 고쳤습니다. 그리고 재보니 이미 절반은 되어 있었어요.
오늘 배운 핵심 세 가지
💡 하나 — 도구가 있다고 저절로 되는 건 아닙니다. 컴파일러는 켜져 있었고 코드도 만들고 있었는데 카드는 여섯 번 그려졌습니다. 우리가 매번 바뀌는 것을 붙잡고 있었거든요. 도구는 우리가 보여준 것만 볼 수 있습니다.
💡 둘 — 편집기의 빨간 줄은 성능 이야기였습니다. 규칙을 어긴 파일은 컴파일러가 통째로 건너뜁니다. 그러니 규칙을 지키는 것과 빠른 것은 따로가 아니에요. 하나입니다.
💡 셋 — 눈앞의 문제가 가장 큰 문제는 아닙니다. 다시 그려지는 횟수를 여섯에서 영으로 줄인 것보다, 100 KB를 안 보내기로 한 쪽이 사용자에게는 더 큽니다. 재보기 전에는 어느 쪽이 큰지 모릅니다.
오늘 닫은 것
| 지난 시간에 넘긴 것 | 닫힌 곳 |
|---|---|
| 빨라 보이게와 빠른 것은 다르다 | ✅ Step 1·6 |
| 왜 이제껏 그 셋을 안 가르쳤나 | ✅ Step 2 |
| 재보고 나서 쓰는 것이지 미리 쓰는 게 아니다 | ✅ Step 5 |
| 나중에 내려받기와 기다리는 화면 | ✅ Step 7 |
다음 시간 예고
오늘 우리는 앱을 빠르게 만들었는데, 지난 시간부터 계속 걸리는 게 하나 남아 있어요.
우리는 우리가 누를 때만 화면이 바뀝니다.
다른 사람이 내 게시물에 좋아요를 누르면? 우리는 모릅니다. 다시 물어보기 전까지는요. 새 댓글이 달려도, 누가 나에게 메시지를 보내도 마찬가지예요.
지금까지 우리가 서버와 이야기한 방식은 전부 "우리가 묻고 서버가 답하는" 것이었습니다. 편지를 보내면 답장이 오는 식이죠. 그 방식으로는 아무리 빨라도 못 푸는 문제가 있는데, 서버가 먼저 말을 걸 수가 없거든요.
다음 시간에는 그 연결을 여는데, 한 번 열어두면 양쪽이 아무 때나 말할 수 있는 통로예요. 통로를 열고 남이 누른 좋아요가 내 화면에 뜨는 데까지가 다음 시간이고, 그 위에 메시지와 알림을 얹는 것은 그다음 시간입니다.
그 전에 통로 자체를 먼저 이해하고 갈 거예요. 왜 편지 방식으로는 안 되는지, 그 통로가 무엇 위에 서 있는지부터요.
과제
[구현] 탐색 화면도 재보기
오늘은 홈 피드만 쟀는데, 탐색 화면(/explore)에서는 태그를 바꿔가며 눌러보세요.
- Highlight updates를 켜고 태그를 세 번 바꿔봅니다.
- 어디가 번쩍이는지, 안 번쩍여야 할 것이 번쩍이는지 적어주세요.
- 번쩍이는 게 있다면 Step 3처럼 원인을 찾아보세요. 매번 바뀌는 것을 붙잡고 있나요?
- ⚠️ 고치기 전에 반드시 먼저 재고 숫자를 적어두세요. 고친 뒤와 비교해야 하니까요.
[구현] 상세 화면을 쪼갤지 정하기
게시물 상세 화면(/p/:postId)을 lazy로 쪼갠 다음, 빌드해서 얼마나 줄었는지 재보세요.
- 줄어든 양을 적습니다.
- 그다음 홈에서 게시물을 눌러 상세로 들어가 보세요. 멈칫하나요?
- Step 8의 세 질문에 답해보고, 되돌릴지 남길지 스스로 정하세요.
- 정답은 없습니다. 다만 왜 그렇게 정했는지를 한 문장으로 적어주세요.
[탐구] 컴파일러가 건너뛰게 만들어보기
멀쩡히 돌아가는 컴포넌트 하나를 골라 일부러 규칙을 어겨보세요.
| 어겨볼 것 | 어떻게 |
|---|---|
| 그리는 도중에 ref 읽기 | useRef를 하나 만들고 화면에 .current를 표시 |
| 렌더 끝난 뒤 변수 고치기 | 이벤트 처리 함수에서 바깥 변수에 대입 |
어긴 뒤에 편집기에 무엇이 뜨는지, 그리고 그 컴포넌트에 Memo ✨ 배지가 그대로 있는지 확인하고 적어주세요. 확인이 끝나면 되돌려놓으세요.
[탐구] 압축 전과 후 중 무엇을 볼까
빌드 결과에는 두 숫자가 나옵니다. 그냥 크기와 gzip 크기요.
회원가입을 쪼갤 때 그냥 크기는 100.00 kB 줄었고 gzip 크기는 30.42 kB 줄었는데, 차이가 세 배가 넘어요.
- 사용자가 실제로 기다리는 시간과 더 관계있는 쪽은 어느 것일까요.
- 그런데 다른 쪽 숫자도 아주 무의미하지는 않습니다. 그건 어디에 영향을 줄까요.
- 힌트: 내려받은 다음에 브라우저가 할 일이 하나 더 있습니다.
[설계]이 앱이 열 배 커진다면
지금 우리 앱은 화면이 다섯 개입니다. 이게 쉰 개가 됐다고 해봅시다.
- 화면마다 쪼갤지 말지를 사람이 하나씩 정하는 게 현실적일까요?
- 아니라면 팀에서 어떤 기준을 미리 정해두면 좋을까요.
- 그 기준을 지키는지 자동으로 확인하려면 무엇을 재야 할까요.
생각해볼 주제
1. 컴파일러가 다 해준다면 규칙은 왜 배우나요
오늘 우리는 규칙을 어긴 파일을 컴파일러가 건너뛴다는 걸 봤으니, 규칙을 지키면 최적화를 받는다고 말할 수 있어요.
이 설명에는 함정이 있는데, 규칙을 최적화를 위한 수단으로만 보게 되거든요. 그러다 보면 "최적화가 필요 없는 화면이면 규칙을 어겨도 되나"라는 질문이 따라옵니다.
여러분은 이 질문에 어떻게 답하시겠어요. 그리고 규칙을 지켜야 하는 이유를 성능 말고 어떻게 설명하시겠습니까.
2. 재지 않고 최적화하는 것은 왜 위험한가요
오늘 우리는 재고 나서 고쳤습니다. 하지만 실무에서는 재는 것 자체가 귀찮아서 그냥 useMemo를 씌우고 넘어가는 일이 흔합니다. 어차피 손해는 없다고 생각하면서요.
정말 손해가 없을까요. 안 재고 씌웠을 때 잃는 것을 세 가지쯤 떠올려보세요.
그리고 팀에서 "재기 전에는 최적화 코드를 안 넣는다"를 규칙으로 만든다면, 그게 지켜지게 하려면 무엇이 필요할까요.
3. 100 KB를 아낀 것은 누구에게 얼마나 큰 일인가요
우리는 첫 화면에서 100 KB를 덜 보내기로 했는데, 여러분의 컴퓨터에서는 아마 차이를 못 느끼셨을 거예요.
그런데 이 앱을 쓰는 사람이 전부 여러분과 같은 환경일까요. 지하철 안에서, 오래된 휴대폰으로, 데이터가 얼마 안 남은 채로 여는 사람도 있습니다.
성능 작업의 값어치를 "내 컴퓨터에서 얼마나 빨라졌나"로 재면 무엇을 놓치게 될까요. 그리고 그걸 놓치지 않으려면 무엇을 기준으로 삼아야 할까요.
✅ 예시 답안정답 보기
과제 1. 탐색 화면도 재보기 (25점)
문제 상황 요약
홈 피드는 수업에서 함께 쟀는데, 탐색 화면(/explore)에서는 태그를 바꿔가며 눌러보고 번쩍이는 것 중에 안 번쩍여야 할 게 있는지 찾는 과제였어요.
재보면 이렇게 나옵니다
Highlight updates를 켜고 태그를 하나 누르면 태그 단추가 전부 번쩍이는데, 하나도 안 빠지고 목록의 사진들도 함께 갈려요.
수업에서 본 것과 똑같은 장면이죠. "내가 누른 건 하나인데 왜 전부?"
여기서 곧장 useMemo부터 찾으면 안 되는데, 먼저 물어야 할 것은 "이게 낭비인가?"입니다.
답: 낭비가 아닙니다
태그 단추를 하나 열어봅시다.
// apps/web-spa/src/routes/ExplorePage.tsx
{tags.map((name) => (
<Button
key={name}
variant={tag === name ? 'default' : 'outline'}
size="sm"
aria-pressed={tag === name}
onClick={() => setSearchParams({ tag: name })}
>
{name}
</Button>
))}
variant와 aria-pressed를 보면, 둘 다 tag === name으로 갈립니다.
그러니까 태그가 바뀌면 모든 단추의 겉모습이 실제로 바뀔 수 있는데, 방금 눌린 단추는 켜져야 하고 아까 켜져 있던 단추는 꺼져야 해요.
확인해보면 이렇습니다.
| 시점 | 전체 | 눌린 태그 |
|---|---|---|
| 처음 | aria-pressed="true" |
aria-pressed="false" |
| 태그를 누른 뒤 | aria-pressed="false" |
aria-pressed="true" |
둘 다 뒤집혔으니까, 다시 그려질 이유가 있었던 거예요.
목록도 다르지 않은데, 태그가 바뀌면 서버에 새로 물어보고 다른 게시물이 오니까 사진이 갈리는 게 맞아요.
그래서 고칠 게 없습니다
이 과제의 답은 "고칠 게 없다"인데, 그걸 재보고 나서야 말할 수 있게 된 것이 이 과제의 값어치예요.
⚠️ 만약 여기서 억지로 최적화를 넣었다면 어떻게 됐을까요. 코드는 복잡해지고, 성능은 그대로고, 나중에 읽는 사람은 "여긴 왜 이렇게 돼 있지?" 하고 헤맵니다. 없는 문제를 고치면 문제가 생깁니다.
채점 포인트
| 항목 | 배점 |
|---|---|
| 고치기 전에 먼저 재고 숫자를 적었다 | 8 |
| 단추가 전부 다시 그려지는 것을 확인했다 | 5 |
variant / aria-pressed가 tag에 걸려 있음을 찾아냈다 |
7 |
| "낭비가 아니다 · 고칠 게 없다" 로 결론 내렸다 | 5 |
🌟 가산점: 억지로 고치려다 멈춘 과정을 적었다면 만점입니다. 재보고 안 고치기로 하는 것도 하나의 결정이에요.
과제 2. 상세 화면을 쪼갤지 정하기 (25점)
문제 상황 요약
게시물 상세 화면(/p/:postId)을 lazy로 쪼개보고, 얼마나 줄었는지 재고, 남길지 되돌릴지 스스로 정하는 과제였습니다.
먼저 쪼개봅니다
회원가입 때와 똑같이 합니다.
// apps/web-spa/src/routes/routes.ts — 재보기 위해 잠깐
const PostDetailPage = lazy(() =>
import('./PostDetailPage').then((module) => ({ default: module.PostDetailPage })),
);
맨 위 import 목록에서 PostDetailPage 줄을 안 빼면 여전히 처음부터 가져오게 되니, 빼는 것을 잊지 마세요.
재보면
dist/assets/PostDetailPage-BrvRBMAU.js 3.87 kB │ gzip: 1.90 kB
dist/assets/SignUpPage-CpiUf2aP.js 100.01 kB │ gzip: 30.19 kB
dist/assets/index-CaIco-K2.js 496.49 kB │ gzip: 160.88 kB
| 첫 화면에 받는 JS | 줄어든 양 | |
|---|---|---|
| 회원가입만 쪼갠 상태 | 499.92 kB | — |
| 상세까지 쪼갠 상태 | 496.49 kB | 3.43 kB |
회원가입은 100 KB 였는데 상세는 3.43 KB라서, 서른 배 가까이 차이가 나요.
왜 이렇게 적을까요
상세 화면이 쓰는 것들이 대부분 다른 화면도 쓰는 것이기 때문입니다. 사진, 좋아요 단추, 댓글 목록 — 전부 피드에도 있어요.
그러니 상세 화면을 따로 떼어내도, 떼어낼 것이 그 화면만의 코드 몇 줄뿐입니다. 무거운 것들은 어차피 주 파일에 남아요.
회원가입을 뗐을 때 상세를 뗐을 때
폼 도구 ─┐ 상세 화면 코드 ─┐
검사 도구 ─┼─ 100 KB (나머지는 전부 ├─ 3.87 KB
회원가입 코드 ─┘ 피드와 공유) ─┘
답: 되돌립니다
Step 8의 세 질문에 답해봅시다.
| 질문 | 상세 화면은 |
|---|---|
| 얼마나 많은 사람이 보나 | 피드에서 사진을 누르면 바로 옵니다. 아주 많이 봅니다 |
| 그 화면만 쓰는 무거운 게 있나 | 없습니다. 3.87 KB 뿐이에요 |
| 잠깐 기다려도 되나 | 안 됩니다. 사진을 눌렀는데 멈칫하면 바로 거슬립니다 |
세 질문 모두 쪼개지 말라고 답합니다. 얻는 것은 3.43 KB 인데, 잃는 것은 가장 자주 오가는 길의 매끄러움이에요.
되돌립니다.
⚠️ 되돌린 뒤에는 routes.ts가 원래대로인지 확인하세요. import 줄을 지웠다가 lazy만 빼면 화면이 통째로 안 그려집니다.
채점 포인트
| 항목 | 배점 |
|---|---|
| 실제로 쪼개고 빌드해서 숫자를 적었다 | 8 |
| 회원가입(100 KB)과 비교해 차이가 크다는 것을 짚었다 | 5 |
| 왜 적게 줄었는지(공유하는 코드) 설명했다 | 6 |
| 세 질문에 답하고 되돌리기로 정했다 | 6 |
🌟 가산점: "되돌린다"가 아니라 "남긴다"로 정했더라도, 세 질문에 근거를 대고 그 대가를 알고 있다면 감점하지 않습니다. 다만 "일단 쪼개면 좋으니까"는 감점입니다.
과제 3. 컴파일러가 건너뛰게 만들어보기 (20점)
문제 상황 요약
멀쩡한 컴포넌트에 일부러 규칙을 어겨 넣고, 편집기와 개발자 도구가 뭐라고 하는지 확인하는 과제였습니다.
그리는 도중에 ref 읽기
// 일부러 어겨보는 코드 — 확인 뒤 반드시 되돌리세요
export function Something() {
const seen = useRef(0);
// 그리는 도중에 .current 를 읽는다
return <p>본 횟수: {seen.current}</p>;
}
편집기에 이렇게 뜹니다.
Cannot access refs during render
React refs are values that are not needed for rendering.
Refs should only be accessed outside of render, such as in event handlers or effects.
개발자 도구 Components 탭에서 이 컴포넌트를 보면 Memo ✨ 배지가 없는데, 컴파일러가 이 파일을 건너뛴 거예요.
렌더 끝난 뒤 변수 고치기
// 일부러 어겨보는 코드 — 확인 뒤 반드시 되돌리세요
export function Something() {
let count = 0;
function handleClick() {
count += 1;
}
return <button onClick={handleClick}>{count}</button>;
}
Cannot reassign variable after render completes
Reassigning `count` after render has completed can cause
inconsistent behavior on subsequent renders. Consider using state instead
역시 배지가 사라집니다.
여기서 알아야 할 것
두 경우 모두 컴파일러가 붙인 이름과 편집기 규칙 이름이 같습니다.
| 컴파일러가 붙인 이름 | 편집기 규칙 |
|---|---|
Refs |
react-hooks/refs |
Immutability |
react-hooks/immutability |
우연이 아니라 같은 것이기 때문인데, 편집기의 빨간 줄은 컴파일러가 미리 하는 말입니다.
실무에서 순서는 분명한데, 배지가 없는 컴포넌트를 발견하면 최적화를 고민하지 말고 빨간 줄부터 찾으세요. 원인이 거기 있습니다.
채점 포인트
| 항목 | 배점 |
|---|---|
| 규칙을 어기고 편집기 경고 문구를 적었다 | 7 |
| 배지가 사라진 것을 확인했다 | 6 |
| 경고 이름과 컴파일러 판정 이름이 같다는 것을 짚었다 | 5 |
| 확인 뒤 되돌렸다 | 2 |
과제 4. 압축 전과 후 중 무엇을 볼까 (15점)
문제 상황 요약
회원가입을 쪼갤 때 그냥 크기는 100.00 kB, gzip 크기는 30.42 kB 줄었습니다. 세 배 넘게 차이 나는 두 숫자 중 무엇을 봐야 하냐는 질문이었어요.
기다리는 시간과 관계있는 쪽은 gzip
브라우저가 실제로 내려받는 것은 압축된 것인데, 서버가 gzip으로 눌러서 보내고 브라우저가 풀어요.
그러니 네트워크에서 기다리는 시간은 gzip 크기에 비례합니다. 30 KB를 덜 받는 것이지 100 KB를 덜 받는 게 아니에요.
그런데 그냥 크기도 무의미하지 않습니다
내려받은 다음에 브라우저가 할 일이 하나 더 있는데, 압축을 풀고 자바스크립트를 읽어서 실행 준비를 하는 일입니다.
이 일은 압축된 크기가 아니라 풀린 크기에 비례합니다. 브라우저가 실제로 읽어야 하는 글자 수가 그만큼이니까요.
서버 ──── 30 KB 보냄 ────→ 브라우저
│
├─ 압축 풀기 → 100 KB
├─ 읽고 해석하기 → 100 KB 만큼의 일
└─ 실행 준비
네트워크는 30 KB 를 봤지만
브라우저의 CPU 는 100 KB 를 본다
이렇게 두 숫자는 서로 다른 것을 말해줍니다.
| 숫자 | 무엇에 영향 | 누구에게 특히 |
|---|---|---|
| gzip 크기 | 내려받는 시간 | 네트워크가 느린 사람 |
| 그냥 크기 | 읽고 준비하는 시간 | 기기가 느린 사람 |
둘 다 봐야 합니다. 하나만 보면 한쪽 사람들을 놓쳐요.
채점 포인트
| 항목 | 배점 |
|---|---|
| 내려받는 시간은 gzip 크기라고 답했다 | 5 |
| 그냥 크기가 해석·실행 준비에 영향을 준다고 답했다 | 6 |
| 둘이 서로 다른 사람에게 중요하다는 것까지 짚었다 | 4 |
과제 5. 이 앱이 열 배 커진다면 (15점)
문제 상황 요약
화면이 다섯 개에서 쉰 개가 됐을 때, 쪼갤지 말지를 사람이 하나씩 정하는 게 맞느냐는 질문이었습니다.
사람이 하나씩 정하는 것은 안 됩니다
이유는 두 가지예요.
하나. 판단이 금방 낡습니다. 오늘 "이 화면은 가벼우니 안 쪼갠다"고 정해도, 다음 달에 누가 무거운 라이브러리를 하나 넣으면 그 판단이 틀린 게 됩니다. 그런데 아무도 다시 안 봐요.
둘. 쉰 개를 사람이 다 기억할 수 없습니다. 결국 새로 들어온 사람은 앞의 결정을 모른 채 자기 방식대로 씁니다.
그래서 기준을 미리 정합니다
기준은 이런 모양이 좋습니다.
| 기준 | 왜 |
|---|---|
| 라우트 단위로만 쪼갠다 | 경계가 분명하고 사람마다 안 갈립니다 |
| 첫 화면에서 갈 수 있는 곳은 안 쪼갠다 | 가장 자주 오가는 길을 지킵니다 |
| 그 화면만 쓰는 무거운 것이 있으면 쪼갠다 | 회원가입 같은 경우요 |
| 무거운 것의 기준을 숫자로 정한다 | "무겁다" 는 사람마다 다릅니다 |
마지막이 중요한데, "무겁다"를 말로 두면 논쟁이 되고 숫자로 두면 확인이 됩니다.
자동으로 확인하려면
재야 할 것은 하나입니다. 첫 화면에서 받는 JS 크기.
이 숫자에 한도를 정해두고, 그걸 넘으면 빌드를 실패시킵니다.
빌드할 때마다
첫 화면 JS 크기를 잰다
↓
정해둔 한도를 넘었나?
↓ 넘음
빌드 실패 — 누군가 무거운 것을 넣었다
이렇게 해두면 사람이 기억할 필요가 없어지는데, 무거운 것을 넣은 사람이 그 자리에서 알게 되고 쪼갤지 말지를 그때 정하게 돼요.
⚠️ 한도는 처음부터 이상적인 숫자로 잡지 마세요. 지금 크기보다 조금 위로 잡고 시작한 뒤 줄어들면 한도도 같이 내리는데, 처음부터 못 지킬 숫자를 잡으면 사람들이 그냥 꺼버립니다.
채점 포인트
| 항목 | 배점 |
|---|---|
| 사람이 하나씩 정하는 것의 한계를 짚었다 | 5 |
| 팀 기준을 구체적으로 제시했다 (라우트 단위 등) | 5 |
| 첫 화면 JS 크기를 자동으로 재는 것을 답했다 | 5 |
생각해볼 주제
1. 컴파일러가 다 해준다면 규칙은 왜 배우나요
🤔 [생각해볼 주제 1] 컴파일러가 다 해준다면 규칙은 왜 배우나요
문제 상황 요약
규칙을 어기면 컴파일러가 최적화를 안 해준다고 배웠는데, 그러면 "최적화가 필요 없는 화면은 규칙을 어겨도 되나?"라는 질문이 따라옵니다.
튜터의 가이드 및 해설
이 질문은 순서를 거꾸로 놓고 있는데, 컴파일러가 규칙을 어긴 파일을 건너뛰는 것은 벌을 주는 게 아니라 자신이 없기 때문입니다.
컴파일러가 하는 일을 다시 생각해봅시다. "이 값이 안 바뀌었으니 지난번 결과를 그대로 써도 된다" 고 판단하는 거예요. 이 판단이 성립하려면 전제가 필요한데, 같은 값을 넣으면 같은 결과가 나온다는 전제요.
규칙은 바로 그 전제를 지키기 위한 것입니다. 그리는 도중에 ref를 읽으면 값은 그대로인데 결과가 달라질 수 있어서 컴파일러의 판단이 틀린 게 됩니다. 그래서 손을 뗍니다.
그러니 규칙은 성능을 위한 게 아니라 예측 가능성을 위한 것입니다. 최적화는 예측 가능한 코드에 따라오는 보너스예요.
그리고 실무에서는 이게 훨씬 무섭습니다. 느린 건 눈에 보이지만, 예측 불가능한 건 안 보이거든요. 열 번 중 아홉 번은 잘 되다가 한 번 이상하게 동작하는 화면, 그게 규칙을 어긴 코드가 만드는 것입니다.
거꾸로 이렇게 답할 수도 있습니다. 컴파일러가 없던 시절에도 이 규칙들은 전부 있었는데, 컴파일러는 그 규칙을 검사해주는 도구가 생긴 것이지 규칙을 만든 게 아닙니다.
🎯 면접관을 홀리는 핵심 멘트
"컴파일러가 규칙 어긴 파일을 건너뛰는 건 벌이 아니라 자신이 없어서입니다. '같은 값이면 같은 결과'라는 전제가 깨지면 캐시 판단 자체가 틀리니까요. 그래서 저는 규칙을 성능 규칙이 아니라 예측 가능성 규칙으로 봅니다. 느린 건 눈에 보이지만 열 번에 한 번 이상한 건 안 보이거든요."
2. 재지 않고 최적화하는 것은 왜 위험한가요
🤔 [생각해볼 주제 2] 재지 않고 최적화하는 것은 왜 위험한가요
문제 상황 요약
재는 게 귀찮아서 그냥 useMemo를 씌우고 넘어가는 일이 흔합니다. "어차피 손해는 없다"는 생각이죠.
튜터의 가이드 및 해설
손해가 있는데, 세 가지를 짚어볼게요.
하나. 최적화 자체가 공짜가 아닙니다. useMemo는 값을 저장하고 의존성을 매번 비교하는데, 비교 비용이 계산 비용보다 큰 경우도 흔해요. 숫자 두 개 더하는 걸 useMemo로 감싸면 오히려 느려집니다.
둘. 읽는 사람이 오해합니다. 코드에 useMemo가 있으면 다음 사람은 "여기가 무거운 자리구나"라고 읽고, 그 주변을 고칠 때 조심하게 됩니다. 근거 없이 붙은 최적화는 거짓 정보를 남기는 것입니다.
셋. 진짜 문제를 못 찾습니다. 이게 제일 큰데, 오늘 우리가 겪었잖아요. useMemo를 아무리 씌워도 likeMutation을 통째로 붙잡고 있는 한 여섯 번은 여섯 번이었고, 600 KB 짜리 덩어리는 그대로 남아 있었습니다. 증상에 손대면 원인은 계속 살아 있습니다.
팀에서 이걸 규칙으로 만들려면요. "재기 전에 최적화 금지"라고 써 붙이는 것만으로는 안 되는데, 재는 게 쉬워야 지켜져요.
그러니 규칙보다 먼저 재는 방법을 팀 문서에 한 페이지로 만들어두세요. 어떤 도구를 어떻게 켜고, 무엇을 보고, 숫자를 어디에 적는지요. 재는 데 5분이 걸리면 사람들은 잽니다. 30분이 걸리면 안 재고 씌웁니다.
🎯 면접관을 홀리는 핵심 멘트
"재지 않은 최적화의 진짜 비용은 성능이 아니라 정보입니다. 근거 없이 붙은
useMemo는 다음 사람에게 '여기가 무거운 곳'이라는 거짓 정보를 남기고, 그 사이 진짜 병목은 그대로 살아 있어요. 그래서 저는 팀에 규칙을 붙이기 전에 재는 방법부터 문서로 만듭니다. 재는 데 5분이면 사람들은 재고, 30분이면 안 재고 씌웁니다."
3. 100 KB를 아낀 것은 누구에게 얼마나 큰 일인가요
🤔 [생각해볼 주제 3] 100 KB를 아낀 것은 누구에게 얼마나 큰 일인가요
문제 상황 요약
우리는 첫 화면에서 100 KB를 덜 보내기로 했는데, 내 컴퓨터에서는 차이가 안 느껴집니다.
튜터의 가이드 및 해설
개발자의 환경은 사용자 환경 중에서 가장 좋은 축입니다. 빠른 컴퓨터, 빠른 인터넷, 그리고 대부분 캐시가 이미 채워져 있죠.
내 컴퓨터에서 재면 거의 모든 성능 문제가 안 보이는데, 이게 성능 작업이 자꾸 뒤로 밀리는 이유예요. 문제를 겪는 사람과 고칠 수 있는 사람이 다른 환경에 있으니까요.
그럼 무엇을 기준으로 삼아야 할까요.
첫째, 크기 같은 절대 숫자는 환경과 무관합니다. 100 KB는 어디서나 100 KB라서, "몇 초 빨라졌나"보다 "몇 KB 줄었나"가 오히려 정직한 기준이 될 때가 있습니다.
둘째, 느린 환경을 흉내 내서 재야 합니다. 브라우저 개발자 도구에는 네트워크와 CPU를 일부러 느리게 만드는 기능이 있는데, 거기서 재면 내 컴퓨터에서 안 보이던 게 보입니다.
셋째, 그리고 이게 가장 중요한데 — 진짜 사용자에게서 재야 합니다. 내 컴퓨터에서 백 점이 나와도 실제 사용자의 4분의 1이 느리게 겪고 있으면 그건 느린 앱이에요. 실제 사용자 환경에서 숫자를 모으는 방법은 나중에 따로 다룹니다.
정리하면 이렇습니다. 내 느낌도 내 환경도 기준이 될 수 없고, 기준은 사용자에게 있어야 합니다.
🎯 면접관을 홀리는 핵심 멘트
"성능 문제의 구조적 어려움은 문제를 겪는 사람과 고칠 수 있는 사람이 다른 환경에 있다는 겁니다. 개발자 장비는 사용자 환경 중 최상위라 대부분의 문제가 안 보여요. 그래서 저는 '내 컴퓨터에서 몇 초 빨라졌나' 대신 환경과 무관한 절대 숫자를 기준으로 잡고, 네트워크와 CPU를 일부러 조여서 잽니다."