C-3: 전역 상태 ① — 값을 나르는 문제와 Context
목차 51
값 하나가 컴포넌트 넷을 거칩니다. 그중 셋은 그 값을 쓰지 않습니다.
지난 시간 마지막에 만든 코드예요. 어느 게시물의 상자가 열려 있는지를 HomePage가 주소에서 읽어서, 아래로 아래로 내려보냈습니다.
HomePage 주소에서 읽는다 openPostId, onOpenPost
│
↓ 넘기기만
Feed 안 쓴다 modalOpen, onModalOpenChange
│
↓ 넘기기만
PostCard 안 쓴다 modalOpen, onModalOpenChange
│
↓ 넘기기만
PostBody 안 쓴다 open, onOpenChange
│
↓
PostModal 드디어 쓴다
가운데 셋은 이 값에 아무 관심이 없습니다. 그냥 받아서 넘길 뿐이에요. PostBody.tsx에는 그때 제가 이런 주석을 적어뒀습니다.
// 지나가기만 한다 — 이 컴포넌트는 주소를 모른다
안녕하세요, 홍순구입니다. 오늘은 저 화살표들을 없애는 이야기입니다.
그런데 시작하기 전에 미리 말씀드릴 게 하나 있어요. 오늘 배울 도구가 저 문제를 푸는 유일한 답은 아니라서, 오히려 위 그림은 다른 답이 더 어울리는 경우예요. 왜 그런지는 다음 시간에 정면으로 다루고, 오늘은 더 단순하고 더 자주 만나는 문제부터 잡아요.
💡 오늘 수업의 핵심 — "Context는 값을 담는 상자가 아니라 값이 지나가는 통로다"
전역 상태라는 말을 들으면 보통 이런 그림을 떠올립니다. 앱 어딘가에 큰 상자가 하나 있고, 거기에 값을 넣어두면 아무 데서나 꺼내 쓴다.
Context는 그 상자가 아닙니다. 값은 여전히 useState가 들고 있고, Context는 그 값이 아래로 내려가는 길만 뚫어줍니다. 오늘 이 차이를 눈으로 확인할 거예요. 이걸 구분하지 못하면 다음 시간에 배울 도구와 Context 중 무엇을 골라야 할지 영영 판단이 안 섭니다.
🎯 학습 목표
- 커스텀 훅이 로직은 공유하지만 상태는 공유하지 않는다는 것을 직접 재보고 확인합니다.
createContext와 React 19의<Context value>문법으로 값을 위에 두고 아래에서 꺼냅니다.- 기본값 대신
null을 두고 감싸는 걸 잊으면 그 자리에서 멈추는 훅을 만듭니다. use(Context)가 훅 규칙을 안 받는다는 사실과 그 이유를 확인합니다.- Provider를 어디에 두느냐가 무엇을 정하는지 오류 화면으로 확인합니다.
- 값이 바뀔 때 실제로 누가 다시 그려지는지 세어보고, 널리 퍼진 두 가지 설명을 검증합니다.
오늘의 여정
1. 훅을 두 번 부르면 상태가 둘이 된다
↓
2. 값을 위에 두고 아래에서 createContext 와 <Context value>
↓
3. 기본값을 주지 말고 null 과 던지는 훅
↓
4. use 는 조건 안에서도 훅 규칙이 갈리는 곳
↓
5. Context 는 상태를 안 든다 Provider 위치가 범위를 정한다
↓
6. 누가 다시 그려지나 직접 세어본다
Step 1: "훅을 두 번 부르면 상태가 둘이 된다"
먼저 오늘 우리를 곤란하게 만들 기능을 하나 추가하겠습니다. 위에서 본 모달 이야기가 아니라, 훨씬 작고 단순한 것부터요.
휴대폰 주소창에도 색이 있습니다
휴대폰에서 웹사이트를 열면 주소창이 사이트 색을 따라 물드는 걸 보신 적 있을 거예요. 그건 HTML이 브라우저에게 색을 알려주기 때문입니다.
<meta name="theme-color" content="#fafafa" />
우리 앱은 지난 몇 시간 동안 밝게·어둡게·시스템 셋 중에 고를 수 있게 됐는데, 주소창은 그걸 모릅니다. 화면은 새까만데 주소창만 하얗게 남아 있으면 그 경계가 그대로 보여요.
여기서 중요한 게 하나 있습니다. 주소창 색은 CSS로 못 바꿉니다. <meta>는 <head> 안에 있고 우리 스타일 규칙이 닿는 곳이 아니에요. 화면 밖의 것은 화면 밖의 방법으로 맞춰줘야 합니다.
이런 일을 전에도 한 적 있죠. 브라우저 탭 제목을 맞출 때요. document.title은 React가 그리는 화면 밖이라 effect로 직접 건드렸습니다. 지금도 똑같습니다.
먼저 index.html에 시작값을 하나 둡니다.
<!-- apps/web-spa/index.html -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 휴대폰 주소창 색. 밝은 값으로 시작하고 화면 쪽에서 지금 밝기에 맞춰 고쳐 쓴다. -->
<meta name="theme-color" content="#fafafa" />
<title>인스타그램 클론</title>
이어서 어떤 색을 쓸지 정하는데, 우리 배경색 토큰과 같은 값이어야 해요.
// apps/web-spa/src/lib/theme.ts
/**
* 휴대폰 브라우저가 주소창을 칠할 색. `--color-canvas` 와 같은 값이어야 한다.
* 주소창은 CSS 가 닿지 않는 곳이라 값을 여기 한 번 더 적는다 —
* 토큰을 바꾸면 이쪽도 함께 바꿔야 한다(index.html 의 첫 표시 붙이기와 같은 사정).
*/
export const THEME_COLOR: Record<ResolvedTheme, string> = {
light: '#fafafa',
dark: '#000000',
};
같은 색을 두 군데 적는 게 마음에 걸리시죠. 저도 그렇습니다. 그런데 --color-canvas는 CSS 안에 사는 값이고 <meta>의 content는 CSS를 못 읽어요. 이미 index.html의 첫 표시 붙이기도 같은 이유로 규칙을 베껴 쓰고 있으니, 이런 곳은 주석으로 "짝이 있다"고 남겨두는 게 최선이에요.
🙋 학생 질문 — "튜터님, #fafafa는 어디서 나온 숫자예요?"
우리 토큰이 oklch(98.5% 0 none)이니까 그걸 그냥 옮겨 적고 싶은데, 그러면 안 되는 이유가 있습니다.
theme-color는 CSS 속성이 아니라 HTML 속성이에요. 브라우저마다 어떤 색 표기까지 읽어주는지가 달라서, 가장 오래되고 가장 확실한 표기인 16진수로 바꿔 적었어요.
oklch(98.5% 0 none)을 sRGB로 변환하면 #fafafa가 나오고, oklch(0% 0 none)은 #000000입니다. 눈대중으로 고른 값이 아니라 계산해서 나온 값이에요.
이제 그걸 실제로 맞추는 컴포넌트
// apps/web-spa/src/components/ThemeColorMeta.tsx
import { useEffect } from 'react';
import { useTheme } from '../hooks/useTheme';
import { THEME_COLOR } from '../lib/theme';
// 휴대폰 브라우저는 주소창을 이 값으로 칠한다.
// 화면은 어두운데 주소창만 하얗게 남으면 그 경계가 그대로 보인다.
// 주소창은 React 가 그리는 화면 밖이라 CSS 로는 못 닿는다 — 직접 맞춰줘야 한다.
export function ThemeColorMeta() {
const { resolved } = useTheme();
useEffect(() => {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta !== null) {
meta.setAttribute('content', THEME_COLOR[resolved]);
}
}, [resolved]);
// 그리는 것이 없다. 화면 밖을 맞추는 일만 한다.
return null;
}
아무것도 안 그리고 null을 돌려주는 컴포넌트입니다. 화면에 나올 게 없으니까요. 하는 일은 화면 밖을 맞추는 것 하나예요.
Layout에 넣어줍니다.
// apps/web-spa/src/routes/Layout.tsx
<main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
<ThemeColorMeta />
<header className="mb-4 flex items-baseline justify-between">
자, 다 됐습니다. 머리말에는 밝기 버튼이 있고, 바로 위에 주소창 색을 맞추는 컴포넌트가 있어요. 둘 다 useTheme()을 부릅니다.
그런데 안 됩니다
'어둡게'를 눌러보세요. 화면은 어두워집니다. 잘 되는 것 같죠. 그런데 개발자 도구에서 <head>를 열어보면 이렇습니다.
| 눌러본 뒤 확인한 것 | 값 |
|---|---|
| 밝기 버튼의 눌림 표시 | 어둡게가 눌림 |
<html>의 어둡게 표시 |
붙어 있음 |
| 저장소에 적힌 선택 | dark |
<meta name="theme-color"> |
#fafafa |
화면은 새까만데 주소창만 하얗습니다.
⚠️ 여기가 오늘 가장 중요한 순간이에요. 화면만 보면 아무 문제가 없습니다. 어둡게 눌렀더니 어두워졌으니까요. 버그는 눈에 안 보이는 곳에만 남았습니다.
왜 이렇게 됐을까요
useTheme 안을 다시 열어봅시다.
// apps/web-spa/src/hooks/useTheme.ts
export function useTheme() {
const [choice, setChoice] = useState<ThemeChoice>(() => readStoredChoice(window.localStorage));
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window.matchMedia(DARK_QUERY).matches,
);
useState가 있는데, 훅을 부른다는 것은 그 컴포넌트 안에 그 useState를 새로 만든다는 뜻이에요.
커스텀 훅은 함수입니다. 함수를 두 번 부르면 안에 있는 지역 변수도 두 벌 생기죠. 훅도 마찬가지예요.
ThemeToggle 이 부른 useTheme() → useState 한 벌 choice = 'dark'
ThemeColorMeta 가 부른 useTheme() → useState 또 한 벌 choice = 'system'
그런데 이 둘이 건드리는 곳은 한 군데뿐
document.documentElement ← 한 벌
localStorage ← 한 벌
버튼을 누르면 ThemeToggle 쪽 상태만 바뀝니다. 그 결과로 <html>에 표시가 붙고 저장소에 값이 들어가요. 이 둘은 한 벌뿐이라 화면 전체에 반영됩니다.
ThemeColorMeta 쪽 상태는 아무도 안 건드렸습니다. 여전히 'system'이고 결과는 'light'예요. 그래서 주소창이 하얗게 남았습니다.
⚠️ 한 가지 짚고 갈게요. 이걸 "두 상태가 서로 싸운다"고 이해하시면 안 됩니다. 싸우지 않아요. ThemeColorMeta는 자기 값이 안 바뀌었으니 effect의 의존성도 그대로고, 그래서 effect 가 다시 돌지도 않습니다. 표시를 지우지 않는 거예요.
안정적인 게 아니라 부딪히지 않을 뿐입니다. 만약 운영체제 설정이 바뀌어서 ThemeColorMeta 쪽 값이 움직이면, 그때는 사용자가 고른 것과 무관한 색을 덮어씁니다.
💡 한 줄 정리
커스텀 훅은 로직을 공유하지 상태를 공유하지 않습니다. 같은 훅을 두 곳에서 부르면 useState가 두 벌 생기고, 화면이 멀쩡해 보여도 한쪽은 틀린 값을 들고 있습니다.
Step 2: "값을 위에 두고 아래에서 꺼낸다"
문제는 분명합니다. 상태를 한 곳에서만 만들고, 그걸 여러 곳에서 봐야 해요.
props로 내려보내면 되지 않냐고요? 해보면 압니다. ThemeToggle은 Layout 안에 있고 ThemeColorMeta도 Layout 안에 있으니 지금은 한 단계면 되지만, 오프닝에서 본 그림 기억하시죠. 네 단계짜리도 있고, 밝기는 앞으로 더 깊은 곳에서도 필요해질 값입니다.
React에는 이런 상황을 위한 도구가 원래 들어 있습니다.
통로를 하나 만듭니다
// apps/web-spa/src/contexts/ThemeContext.tsx
import { createContext, useContext, type ReactNode } from 'react';
import { useTheme } from '../hooks/useTheme';
// 훅이 무엇을 돌려주는지는 훅이 이미 알고 있다. 같은 모양을 손으로 또 적지 않는다.
type ThemeContextValue = ReturnType<typeof useTheme>;
// 통로를 하나 만든다. 값은 여기 없다 — 아래에서 Provider 가 넣어준다.
const ThemeContext = createContext<ThemeContextValue>({
choice: 'system',
resolved: 'light',
select: () => {},
});
// 상태는 이 컴포넌트 한 곳에서만 생긴다.
// 아래 어디에서 몇 번을 꺼내 쓰든 useState 는 늘어나지 않는다.
export function ThemeProvider({ children }: { children: ReactNode }) {
const theme = useTheme();
// React 19 부터는 Context 를 그대로 쓴다. `.Provider` 를 안 붙인다.
return <ThemeContext value={theme}>{children}</ThemeContext>;
}
// 꺼내 쓰는 쪽은 Context 가 무엇인지 몰라도 된다. 이 훅만 부르면 된다.
export function useThemeContext() {
return useContext(ThemeContext);
}
한 줄씩 뜯어봅시다.
type ThemeContextValue = ReturnType<typeof useTheme> — 통로에 어떤 모양의 값이 흐르는지 타입으로 적어야 하는데, 그 모양은 useTheme이 이미 알고 있습니다. 손으로 또 적으면 나중에 훅을 고칠 때 한쪽만 고쳐서 어긋나요. 원본에서 파생시키는 방식은 A-3에서 PostCardProps를 만들 때 이미 썼던 겁니다.
createContext(...) — 통로를 만듭니다. 여기서 준 값은 아무도 값을 안 넣어줬을 때 쓸 것이에요. 이게 나중에 문제가 되는데, 그건 Step 3에서 봅니다.
ThemeProvider — 여기서 useTheme()을 부릅니다. 앱 전체에서 이 한 번만요. 그러니 useState도 한 벌만 생깁니다.
<ThemeContext value={theme}> — 통로에 값을 흘려보냅니다.
.Provider를 안 붙인 이유
검색해서 나오는 코드는 십중팔구 이렇게 되어 있을 거예요.
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
React 19부터는 .Provider 없이 Context를 그대로 쓸 수 있습니다. 공식 문서는 옛 문법을 이렇게 부릅니다.
SomeContext.Provideris a legacy way to provide the context value before React 19.
⚠️ 그런데 정확히 알아두셔야 할 게 있습니다. .Provider는 아직 없어지지 않았습니다. 지워진 것도 아니고 경고가 뜨는 것도 아니에요. React 팀이 공지한 것은 "앞으로 걷어낼 예정"까지입니다.
두 문법으로 같은 화면을 그려서 결과물을 비교해봤는데 한 글자도 다르지 않았습니다. 타입 검사도, 린트도 양쪽 다 아무 말을 안 합니다.
| 확인한 것 | <Context value> |
<Context.Provider value> |
|---|---|---|
| 그려진 HTML | <p>넣은 값</p> |
<p>넣은 값</p> (동일) |
| 타입 검사 | 0건 | 0건 |
| 린트 | 0건 | 0건 |
옛 문법을 베껴 와도 도구가 알려주지 않으니, 알고 있어야만 아는 겁니다. 새로 쓰는 코드는 <Context value>로 가세요.
꺼내 쓰는 쪽
두 컴포넌트가 이제 훅 대신 통로에서 값을 꺼냅니다.
// apps/web-spa/src/components/ThemeToggle.tsx
import { useThemeContext } from '../contexts/ThemeContext';
export function ThemeToggle() {
const { choice, select } = useThemeContext();
// apps/web-spa/src/components/ThemeColorMeta.tsx
import { useThemeContext } from '../contexts/ThemeContext';
export function ThemeColorMeta() {
const { resolved } = useThemeContext();
바뀐 것은 두 줄씩입니다. 부르는 함수 이름만 갈렸어요. 컴포넌트 본문은 손도 안 댔습니다.
이어서 Layout이 그 아래를 감쌉니다.
// apps/web-spa/src/routes/Layout.tsx
return (
// 이 안쪽은 전부 같은 밝기 값을 본다. 여기가 그 범위의 경계다.
<ThemeProvider>
<main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
<ThemeColorMeta />
'어둡게'를 누르면 이제 주소창도 같이 검어지고, 되돌리면 같이 돌아와요.
🙋 학생 질문 — "튜터님, 왜 useContext를 그냥 쓰지 않고 useThemeContext로 한 번 더 감쌌나요?"
지금은 별 차이가 없어 보이지만, 세 가지가 달라집니다.
첫째, 쓰는 쪽이 Context를 몰라도 됩니다. ThemeToggle은 ThemeContext라는 게 있는지도 모르고 훅 하나만 부릅니다. 나중에 안쪽 구현을 통째로 바꿔도 ThemeToggle은 안 건드려요.
둘째, ThemeContext를 밖으로 안 내보내도 됩니다. 위 코드에서 export가 붙은 것은 ThemeProvider와 useThemeContext 둘뿐이에요. Context 자체는 파일 안에만 있습니다. 아무나 가져다 다른 Provider를 씌우는 일이 막힙니다.
셋째, 곧 여기에 안전장치를 하나 넣을 겁니다. 바로 다음 단계에서요.
💡 한 줄 정리
createContext로 통로를 만들고, ThemeProvider 안에서 useTheme()을 딱 한 번 불러 그 값을 흘려보냅니다. 아래에서는 몇 곳이 꺼내 쓰든 useState가 늘어나지 않습니다.
Step 3: "기본값을 주지 말고, 없으면 그 자리에서 멈추게"
Step 2 코드에는 시한폭탄이 하나 있습니다. 이 줄이에요.
// apps/web-spa/src/contexts/ThemeContext.tsx — 방금 Step 2 에서 쓴 그대로
const ThemeContext = createContext<ThemeContextValue>({
choice: 'system',
resolved: 'light',
select: () => {},
});
select: () => {}를 보세요. 아무것도 안 하는 함수입니다.
감싸는 걸 잊으면 무슨 일이 생기나
누군가 ThemeToggle을 새 화면에 가져다 씁니다. 그런데 그 화면은 ThemeProvider로 안 감싸여 있어요. 어떻게 될까요.
화면은 멀쩡하게 그려집니다. 버튼 세 개가 나오고 '시스템'이 눌린 것으로 보이는데, 눌러도 아무 일이 안 일어납니다. 에러도 없고 경고도 없어요.
이걸 실제로 확인해봤습니다. Provider 없이 Context를 읽는 컴포넌트를 그렸더니, 기본값이 그대로 나오면서 아무 소리 없이 통과했습니다.
버그를 만난 사람은 이렇게 생각할 겁니다. "버튼이 안 먹네? 이벤트 핸들러가 잘못 붙었나? 상태 갱신이 안 되나?" 진짜 원인인 "Provider로 안 감쌌다"는 후보에도 안 올라옵니다.
그래서 기본값을 없앱니다
// apps/web-spa/src/contexts/ThemeContext.tsx
// 통로를 하나 만든다. 값은 여기 없다 — 아래에서 Provider 가 넣어준다.
// 그럴듯한 기본값을 주면 감싸는 걸 잊어도 화면이 그려져서, 버튼이 안 먹는
// 이유를 한참 못 찾는다. 그래서 "아직 아무도 안 넣었다" 는 뜻으로 null 을 둔다.
const ThemeContext = createContext<ThemeContextValue | null>(null);
null은 "아직 아무도 값을 안 넣었다"는 뜻입니다. 그럴듯한 가짜 값 대신 없다는 사실 자체를 담는 거예요.
이어서 꺼내는 훅에서 그걸 확인합니다.
// 꺼내 쓰는 쪽은 Context 가 무엇인지 몰라도 된다. 이 훅만 부르면 된다.
// null 검사를 여기서 한 번만 하면, 쓰는 쪽 열 곳이 그 검사를 안 해도 된다.
// 그리고 이 if 를 통과한 아래에서 TypeScript 는 value 가 null 이 아님을 안다.
export function useThemeContext() {
const value = useContext(ThemeContext);
if (value === null) {
throw new Error('useThemeContext 는 ThemeProvider 안에서 불러야 합니다');
}
return value;
}
이제 감싸는 걸 잊으면 조용히 넘어가지 않고 그 지점에서 멈춥니다. 그리고 메시지가 무엇을 해야 하는지까지 알려줘요.
이 if가 두 가지 일을 합니다
첫 번째는 방금 본 실행 중 안전장치입니다. 두 번째는 타입이에요.
useContext(ThemeContext)가 돌려주는 값의 타입은 ThemeContextValue | null입니다. 그러니 이렇게 쓰면 타입 검사가 막습니다.
const { resolved } = useContext(ThemeContext);
// error TS2339: Property 'resolved' does not exist on type 'Value | null'.
null 일 수도 있는 값에서 바로 꺼내려 하니까요. 이걸 막으려면 쓰는 쪽마다 if를 또 써야 합니다. 열 곳에서 쓰면 열 번이지만, 훅 안에서 한 번 걸러주면 이야기가 달라집니다.
const { resolved } = useThemeContext();
// 타입 검사 0건
throw 아래로는 value가 null 일 수 없다는 걸 TypeScript가 압니다. A-2에서 배운 좁히기가 여기서 일합니다. 실행 중 안전장치와 타입 좁히기를 한 줄이 동시에 해내는 겁니다.
바꾸자마자 하나 걸렸습니다
솔직하게 말씀드리면, 이 안전장치를 넣은 직후에 우리 코드베이스에서 실제로 하나가 걸렸습니다.
C-1에서 <a>와 <Link>를 비교하려고 만들어둔 머리말 판이 있었어요. 거기에 ThemeToggle은 있는데 ThemeProvider가 없었습니다. 기본값이 있던 동안에는 죽은 버튼을 그리면서 아무 문제 없는 척하고 있었던 거예요.
기본값을 없애자마자 그 화면이 멈추면서 우리가 적어둔 메시지가 떴습니다. 연출이 아니라 진짜로 그랬어요. 이게 이 패턴을 쓰는 이유 그 자체입니다.
🙋 학생 질문 — "튜터님, 에러를 던지는 게 너무 과격하지 않나요? 그냥 경고만 찍으면 안 되나요?"
좋은 질문이에요. 판단 기준은 그 상태로 계속 굴러가도 되는가입니다.
경고만 찍고 기본값을 돌려주면 화면은 계속 그려집니다. 그런데 그 화면은 이미 거짓말을 하고 있어요. '시스템'이 눌린 것처럼 보이는데 실제로는 아무 선택도 반영되지 않은 상태니까요. 사용자는 버튼을 계속 누르면서 왜 안 되는지 모릅니다.
콘솔 경고는 잘 안 읽히는데, 개발 중에 콘솔에 이미 여러 줄이 흐르고 있으면 묻혀요.
여기서 던지는 에러는 사용자가 만날 에러가 아닙니다. Provider를 안 씌운 코드는 개발 중에 화면을 한 번만 열어봐도 바로 걸립니다. 배포까지 갈 수가 없어요. 그러니 개발자에게 가장 크게 소리치는 쪽이 맞습니다.
반대로 사용자의 입력이나 서버 응답 때문에 생길 수 있는 문제는 던지면 안 됩니다. 그건 화면에서 안내로 처리해야 해요. 이 둘을 구분하는 게 핵심입니다.
💡 한 줄 정리
Context의 기본값은 편의가 아니라 함정입니다. null로 두고 꺼내는 훅에서 한 번 막으면, 잘못 쓴 코드가 조용히 굴러가는 대신 그 지점에서 멈추고 타입 검사까지 따라옵니다.
Step 4: "use는 조건 안에서도 부를 수 있다"
B-3에서 훅 규칙을 못 박았죠. 훅은 조건문이나 반복문 안에서 부르면 안 된다. 컴포넌트 최상위에서만 부른다.
그런데 React 19에 이 규칙을 안 받는 게 하나 있습니다. 이름이 use로 시작하는데 훅이 아니에요.
같은 파일, 한 줄 차이
두 컴포넌트를 나란히 놓아봤습니다. 하는 일이 똑같고, 값을 꺼내는 함수만 다릅니다.
// apps/web-spa/src/components/ConditionalContextDemo.tsx
// (a) use — 조건 안에서 불러도 된다. 공식이 그렇게 정했다.
export function ReadWithUse({ show }: { show: boolean }) {
if (!show) {
return <p>(a) 안 읽음</p>;
}
const label = use(LabelContext);
return <p>(a) {label ?? '없음'}</p>;
}
// (b) useContext — 같은 자리인데 훅이라서 규칙을 받는다.
export function ReadWithUseContext({ show }: { show: boolean }) {
if (!show) {
return <p>(b) 안 읽음</p>;
}
const label = useContext(LabelContext);
return <p>(b) {label ?? '없음'}</p>;
}
린트를 돌리면 (b)만 걸립니다.
error React Hook "useContext" is called conditionally.
React Hooks must be called in the exact same order in every component render.
react-hooks/rules-of-hooks
(a)는 아무 말이 없어요. 타입 검사도 양쪽 다 통과하고, 빌드도 양쪽 다 됩니다. 갈리는 것은 린트 하나뿐입니다.
공식 문서가 이렇게 적어뒀습니다.
While
useContextmust be called at the top level of your component,usecan be called inside conditionals likeifand loops likefor.
그런데 (b)를 실제로 돌려보면
여기서부터가 재밌습니다. 규칙을 어긴 (b)를 실제로 그려봤어요. show를 껐다 켜면서요.
안 터집니다. 화면이 멀쩡하게 나옵니다.
"앞에 살아남는 훅이 없어서 그런가?" 싶어서 앞에 useState를 하나 두고 다시 해봤습니다.
// (c) 앞에 진짜 훅을 하나 두고, 조건부 useContext 를 그 뒤에 놓는다.
// useState 였다면 개수가 달라져 터질 자리인데 useContext 는 안 터진다.
export function ReadWithUseContextAfterState({ show }: { show: boolean }) {
const [mounted] = useState('붙어있음');
if (!show) {
return <p>{`(c) 안 읽음 ${mounted}`}</p>;
}
const label = useContext(LabelContext);
여전히 안 터집니다.
이번엔 같은 자리에 진짜 훅을 넣으면 어떨까요.
// (d) 대조군 — 같은 자리에 진짜 훅을 넣으면 어떻게 되는지.
export function ReadWithConditionalState({ show }: { show: boolean }) {
const [mounted] = useState('붙어있음');
if (!show) {
return <p>{`(d) 안 읽음 ${mounted}`}</p>;
}
const [extra] = useState('하나 더');
이건 터집니다.
Error: Rendered more hooks than during the previous render.
그럼 useContext는 뭐가 다를까요
가설을 하나 세워봅시다. useContext는 훅 순서 목록에 자리를 안 잡는 게 아닐까.
확인할 방법이 있습니다. 조건부 useContext를 진짜 훅 두 개 사이에 끼우는 거예요. 자리를 잡는다면 뒤에 있는 useState의 순서가 한 칸씩 밀려서, 조건이 켜졌을 때 엉뚱한 값이 나오거나 터져야 합니다.
// (e) 조건부 useContext 를 진짜 훅 두 개 사이에 끼운다.
// 훅 목록에 자리를 잡는다면 뒤 useState 의 순서가 밀려야 한다.
export function SandwichedUseContext({ show }: { show: boolean }) {
const [first] = useState('앞');
if (show) {
const label = useContext(LabelContext);
void label;
}
const [last] = useState('뒤');
return <p>{`(e) ${first}/${last}`}</p>;
}
조건을 켜도 앞/뒤가 그대로 나옵니다. 순서가 안 밀렸어요.
정리하면 이렇습니다.
| 조건 안에 넣은 것 | 앞에 진짜 훅 | 결과 |
|---|---|---|
useContext |
없음 | 안 터짐 |
useContext |
있음 | 안 터짐 |
useContext |
앞뒤로 하나씩 | 순서 안 밀림 |
useState |
있음 | Rendered more hooks than... |
useContext는 순서에 기대지 않습니다. 훅 목록을 한 칸씩 세어 나가는 방식이 아니라, 자기 위에 있는 Provider를 찾아 올라가는 방식이에요. 그래서 몇 번째로 불렸는지가 상관없습니다.
그러면 use는 무엇을 준 걸까요
여기까지 오면 use를 보는 눈이 달라집니다.
use는 없던 능력을 새로 만들어준 게 아닙니다. Context 읽기는 원래부터 순서에 안 묶여 있었어요. React 19가 한 일은 그 사실에 이름과 공식 허가를 준 것에 가깝습니다.
그렇다면 린트는 왜 useContext까지 막을까요. 규칙을 하나로 유지하려는 선택입니다. "훅은 최상위에서만"이라는 규칙 하나가 "단 useContext는 예외인데 그건 순서 목록에…"로 늘어나면 아무도 못 외웁니다. 대신 조건 안에서 읽어야 하면 use를 쓰라고 길을 따로 냈어요.
⚠️ 다만 use에는 제약이 하나 붙어 있습니다. 공식 문서가 이렇게 적어뒀어요.
Reading context with
useis not supported in Server Components.
서버 컴포넌트라는 게 무엇인지는 Next.js를 다루는 D 카테고리에서 제대로 봅니다. 지금은 "이 방식이 안 통하는 곳이 나중에 나온다" 정도만 기억해두세요.
그래서 우리는 뭘 쓰나
우리 앱에는 아직 Context를 조건부로 읽어야 하는 곳이 없어서, useThemeContext는 useContext를 그대로 씁니다.
필요 없는데 새 문법을 먼저 쓰지 않는 게 좋습니다. use는 조건이 진짜로 필요해질 때 여는 문이에요. 오늘은 그런 문이 있다는 것과, 왜 그 문이 열려 있는지를 알아두는 것으로 충분합니다.
🙋 학생 질문 — "튜터님, 규칙을 어겨도 안 터진다면 (b)처럼 써도 되는 거 아닌가요?"
안 됩니다. 두 가지 이유가 있어요.
첫째, 린트가 막습니다. 팀 프로젝트라면 검사를 통과 못 해서 코드가 합쳐지지 않습니다. "돌아가긴 하는데요"는 통하지 않아요.
둘째, 그게 보장된 동작이 아닙니다. 우리가 확인한 것은 "지금 이 버전에서 이렇게 동작한다"까지입니다. React 팀이 문서로 약속한 것은 use 쪽이에요. 문서에 없는 동작에 기대는 코드는, 그 동작이 바뀌는 날 조용히 깨집니다.
규칙을 어겨도 안 터지는 것을 확인한 이유는 그렇게 써도 된다는 뜻이 아니라, 린트가 왜 필요한지를 보여주기 위해서예요. 실행해서 확인되지 않는 문제를 잡아주는 게 린트의 일입니다.
💡 한 줄 정리
use는 이름이 use로 시작하지만 훅이 아니라서 조건과 반복 안에서 부를 수 있습니다. useContext도 실은 순서에 안 묶여 있지만 린트가 막고, 조건이 필요하면 use를 쓰는 것이 공식 통로입니다.
Step 5: "Context는 상태를 안 들고 있다"
오늘 핵심으로 걸어둔 문장을 이제 증명할 차례입니다. Context는 상자가 아니라 통로다.
Provider를 둘 놓아보면
같은 Context 하나에 Provider를 두 개 놓고 각각 다른 값을 넣어본 뒤, 그 아래에서 값을 읽었어요.
<Ctx value="왼쪽"> → 읽는 쪽이 본 값 : 왼쪽
<Ctx value="오른쪽"> → 읽는 쪽이 본 값 : 오른쪽
같은 Context 인데 두 값이 나옵니다.
Context가 값을 들고 있다면 이럴 수 없어요. 하나여야 하니까요. Context가 하는 일은 "이 이름으로 물어보면 가장 가까운 위쪽 Provider에게 물어본다"는 약속을 만드는 것뿐입니다.
그럼 값은 누가 들고 있을까요.
ThemeProvider 안의 useTheme() 안의 useState ← 값은 여기
│
│ <ThemeContext value={theme}>
↓
아래 어디서든 useThemeContext() 로 꺼낸다
여전히 useState입니다. Step 1에서 문제였던 그 useState요. 달라진 것은 그게 앱 전체에 하나뿐이라는 것뿐이라서, Context를 "상태 관리 도구"라고 부르면 곤란합니다. 상태를 관리하는 것은 useState 고, Context는 그 값을 필요한 곳에 주입하는 통로입니다.
그러면 통로의 입구를 어디에 둘까요
지금 ThemeProvider는 Layout 안에 있습니다. 여기서 문제가 하나 생깁니다.
지난 시간에 오류 화면을 만들었죠. 없는 게시물 주소로 들어가면 404를 던지고, 그걸 ErrorBoundary가 받아서 화면을 그립니다. 그때 제가 이렇게 말씀드렸어요. "오류가 나면 머리말까지 사라집니다."
머리말이 사라진다는 건 Layout이 통째로 대체된다는 뜻입니다. 그 안에 있는 ThemeProvider도 같이요.
어두운 화면을 쓰는 사람이 없는 게시물 링크를 받아서 곧바로 들어왔다고 해봅시다. 그 순간 화면을 재봤습니다.
| 오류 화면에서 확인한 것 | 값 |
|---|---|
| 본문 | 없는 페이지예요 / 404 게시물을 찾을 수 없습니다 |
| 머리말 | 없음 |
| 밝기 버튼 | 없음 |
| 저장된 선택 | dark |
<meta name="theme-color"> |
#fafafa |
또 하얗습니다.
화면 자체는 어두워요. index.html의 첫 표시 붙이기가 그리기 전에 이미 처리하니까요. 그런데 주소창 색을 맞추는 컴포넌트가 Layout과 함께 사라져서 손을 못 댔습니다.
⚠️ Step 1과 증상이 똑같은데 원인은 전혀 다릅니다. Step 1은 상태가 두 벌이라서였고, 지금은 상태가 한 벌인데 그게 닿는 범위 밖에서 화면이 그려졌기 때문이에요.
범위를 올립니다
밝기는 어느 화면에서든 있어야 하는 값입니다. 라우터가 무엇을 그리든 상관없어야 하니, 라우터보다 바깥에 둬야 합니다.
라우터 바깥에 있어야 할 것들을 한자리에 모읍니다.
// apps/web-spa/src/AppProviders.tsx
import type { ReactNode } from 'react';
import { ThemeColorMeta } from './components/ThemeColorMeta';
import { ThemeProvider } from './contexts/ThemeContext';
// 라우터보다 바깥에 있어야 하는 것들을 한자리에 모은다.
//
// Layout 안에 뒀을 때는 오류 화면에서 통째로 사라졌다.
// 그 화면은 Layout 을 대체하기 때문이다 — 머리말도 토글도 없이 그려진다.
// 밝기는 어느 화면에서든 있어야 하니까 라우터 바깥으로 올린다.
export function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<ThemeColorMeta />
{children}
</ThemeProvider>
);
}
이어서 앱의 가장 바깥에서 라우터를 감쌉니다.
// apps/web-spa/src/main.tsx
// 밝기는 라우터보다 바깥에 있다. 어느 주소로 들어오든, 오류 화면이 떠도 그대로다.
createRoot(rootElement).render(
<StrictMode>
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
</StrictMode>,
);
Layout은 지난 시간 모습으로 되돌아갑니다. ThemeProvider도 ThemeColorMeta도 빠졌어요. 머리말에 밝기 버튼만 남습니다.
이제 오류 화면으로 곧장 들어가도 주소창 색이 맞습니다. 밝기 버튼은 여전히 없어요. 그건 머리말에 붙어 있는 것이고 머리말은 대체됐으니까요. 색은 맞고 버튼은 없는 것, 이게 지금 우리가 의도한 결과입니다.
감싸도 화면은 안 바뀝니다
Provider를 하나 더 씌웠으니 화면이 미묘하게 달라졌을까 봐 걱정되시죠. 확인해봤습니다.
앱의 첫 화면 HTML을 감싸기 전과 후로 비교했는데 한 글자도 다르지 않았습니다.
당연합니다. ThemeProvider는 children을 그대로 돌려주고, ThemeColorMeta는 null을 돌려줘요. 둘 다 화면에 아무것도 안 더합니다. Context는 결과물에 흔적을 안 남깁니다.
🙋 학생 질문 — "튜터님, 그럼 Provider는 무조건 가장 바깥에 두면 되나요?"
그게 기본값이긴 한데, 무조건은 아닙니다.
바깥에 둘수록 좋은 것은 앱 전체가 하나로 공유해야 하는 값입니다. 밝기가 그렇죠. 로그인한 사용자 정보도 그렇고요.
안쪽에 두는 게 맞는 것도 있습니다. 예를 들어 게시물 카드 하나가 자기 안쪽 컴포넌트들에게 "지금 이 카드의 게시물은 이거야"를 알려주고 싶다면, Provider는 그 카드 안에 있어야 해요. 카드마다 다른 값이어야 하니까요. Step 5 앞부분에서 본 Provider 두 개가 정확히 그 그림입니다.
그러니 질문은 "어디에 두지?"가 아니라 "이 값을 누가 볼 수 있어야 하지?"입니다. 그 답이 곧 Provider의 위치예요.
💡 한 줄 정리
Context는 값을 안 들고 있습니다. 값은 useState가 들고 있고 Context는 그것을 아래로 주입하는 통로라서, Provider 를 어디에 두느냐가 곧 그 값을 누가 볼 수 있는지를 정합니다.
Step 6: "값이 바뀌면 누가 다시 그려지나"
Context를 검색하면 거의 반드시 만나는 경고가 두 개 있습니다.
- "Context 값이 바뀌면 Provider 아래가 전부 다시 그려진다"
- "
value로 객체를 넘기면 매번 새 객체라 리렌더 폭풍이 난다"
무섭게 들리죠. 그런데 이 두 문장이 우리 앱에서도 사실인지는 확인해봐야 압니다. 세어봅시다.
세는 방법
컴포넌트가 몇 번 그려지는지는 간단하게 셀 수 있습니다. 함수 맨 위에 한 줄을 넣으면 돼요.
export function Bystander() {
console.log('Bystander 그려짐');
return <p>안 읽음</p>;
}
⚠️ 이 한 줄은 확인이 끝나면 반드시 지워야 합니다. 렌더 중에 바깥 세상을 건드리는 코드라 린트도 곱게 보지 않아요. 잠깐 재보려고 넣는 것까지가 용도입니다.
이런 판을 만들었습니다.
Harness (밝기와 무관한 버튼 하나를 들고 있다)
├─ Outside Provider 바깥
├─ [바깥에서 다시 그리기] 누르면 Harness 만 상태가 바뀐다
└─ ThemeProvider
├─ ThemeToggle 밝기를 고르는 버튼
├─ Reader Context 를 읽는다
└─ Bystander Provider 아래인데 안 읽는다
첫 번째 경고를 재봅니다
'어둡게'를 눌러서 밝기를 실제로 바꿨습니다.
| 컴포넌트 | 그려진 횟수 |
|---|---|
| Reader (읽음) | 1 → 2 |
| Bystander (Provider 아래, 안 읽음) | 1 → 1 |
| Outside (Provider 바깥) | 1 → 1 |
Provider 아래인데 안 그려졌습니다. 첫 번째 경고는 틀렸어요.
왜 그럴까요. ThemeProvider는 children을 prop으로 받습니다. 그 children 엘리먼트를 만든 것은 Harness예요. 밝기가 바뀌어서 ThemeProvider가 다시 그려질 때, Harness는 다시 그려지지 않았으니 children은 아까 그 엘리먼트 그대로입니다.
React는 엘리먼트가 그대로면 그 아래를 건너뜁니다. 그래서 Bystander는 안 그려져요. 반면 Reader는 Context를 구독하고 있으니 값이 바뀌면 따로 알림을 받습니다.
밝기가 바뀌면
ThemeProvider 다시 그려짐 (자기 상태가 바뀌었다)
│
├─ children 은 같은 엘리먼트 → React 가 건너뜀
│ └─ Bystander → 안 그려짐
│
└─ Context 를 구독한 컴포넌트 → 따로 알림
└─ Reader → 그려짐
두 번째 경고를 재봅니다
이번엔 밝기를 안 바꾸고, 바깥의 무관한 버튼을 눌러서 Harness만 다시 그리게 했습니다. 교과서대로라면 useTheme이 매번 새 객체를 돌려주니까 아래가 전부 다시 그려져야 합니다.
| 컴포넌트 | 그려진 횟수 |
|---|---|
| Reader | 1 → 1 |
| Bystander | 1 → 1 |
| Outside | 1 → 1 |
하나도 안 그려졌습니다. Context 값의 정체도 그대로였어요. 같은 객체입니다.
이건 좀 이상하죠. useTheme은 분명 return { choice, resolved, select }로 매번 새 객체를 만드는데요.
범인은 React Compiler입니다
우리 프로젝트에는 처음부터 이게 켜져 있었습니다.
// apps/web-spa/vite.config.ts
// React Compiler — 빌드할 때 자동으로 메모이제이션을 넣어준다
await babel({
presets: [reactCompilerPreset()],
}),
확인해보려고 이 설정을 잠깐 빼고 똑같이 재봤습니다.
| 바깥이 다시 그려질 때 | 컴파일러 켬 | 컴파일러 끔 |
|---|---|---|
| Reader | 1 → 1 | 1 → 2 |
| Bystander | 1 → 1 | 1 → 2 |
| Outside | 1 → 1 | 1 → 2 |
| Context 값의 정체 | 같은 객체 | 새 객체 |
컴파일러를 빼니까 교과서 그대로 됩니다. 두 번째 경고는 틀린 게 아니라 낡은 겁니다. 컴파일러가 없던 시절에는 사실이었고, 지금은 그 비용을 컴파일러가 대신 걷어가요.
컴파일러가 한 일은 두 가지입니다. useTheme이 돌려주는 객체를 값이 안 바뀌면 새로 안 만들게 붙들었고, props가 그대로인 자식 엘리먼트도 붙들었습니다. 우리가 손으로 아무것도 안 했는데요.
B 카테고리 내내 제가 "깔끔하게 쓰고 컴파일러에 맡기세요"라고 했던 게 이겁니다. 오늘 그 말이 숫자로 확인됐어요.
⚠️ 그렇다고 성능을 아예 잊어도 된다는 뜻은 아닙니다. 컴파일러가 못 잡는 경우도 있고 진짜로 느려지는 화면도 생기지만, 먼저 재보고 나서 손대는 것이 순서예요. 재는 방법과 손대는 방법은 C-8에서 제대로 다룹니다.
🙋 학생 질문 — "튜터님, 그럼 인터넷에 있는 Context 최적화 글들은 다 쓸모없나요?"
쓸모없진 않은데, 읽을 때 언제 쓰인 글인지를 먼저 봐야 합니다.
value를 useMemo로 감싸라, Context를 값과 함수로 쪼개라, 이런 조언들은 전부 컴파일러가 없던 시절에 나온 것들입니다. 그 시절에는 정말 필요했어요.
지금 그 조언을 그대로 따르면 코드만 복잡해지고 얻는 게 없습니다. 우리가 방금 잰 것처럼 컴파일러가 이미 하고 있거든요.
프론트엔드에서 정보의 유통기한은 짧은데, 특히 성능 조언이 그래서 이 과목에서는 계속 재보고 나서 말하는 습관을 들이려고 합니다. 오늘 제가 두 문장을 재봤듯이요. 둘 다 널리 퍼진 이야기였고, 하나는 틀렸고 하나는 낡았습니다.
💡 한 줄 정리
"Provider 아래는 전부 다시 그려진다"는 틀렸습니다. 안 읽으면 안 그려져요. "객체를 넘기면 리렌더 폭풍"은 컴파일러가 없던 시절 이야기이고, 켜져 있는 지금은 재현되지 않습니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 값을 나르는 길을 만들었습니다.
시작할 때 우리에게는 밝기를 아는 곳이 하나뿐이었어요. 두 번째 소비자를 붙이자마자 상태가 갈라졌고, 화면은 멀쩡한데 주소창만 틀린 값을 들고 있었습니다. 지금은 앱 전체에 상태가 하나뿐이고, 라우터 바깥에 있어서 오류 화면까지 닿습니다.
오늘 배운 핵심 세 가지
💡 하나 — 커스텀 훅은 로직을 공유하지 상태를 공유하지 않습니다. 훅은 함수예요. 두 번 부르면 안에 있는 useState도 두 벌 생깁니다. 이 사실을 모르면 "훅으로 뺐으니까 공유되겠지"라고 생각하고 넘어가게 되고, 그 버그는 화면에 안 나타나서 오래 살아남습니다.
💡 둘 — Context는 상태 관리 도구가 아니라 주입 통로입니다. 값은 여전히 useState가 들고 있어요. Context가 하는 일은 "이 이름으로 물으면 가장 가까운 위쪽 Provider에게 물어본다"는 약속뿐입니다. 그래서 같은 Context라도 Provider가 둘이면 값이 둘이고, Provider 위치가 곧 그 값이 닿는 범위예요.
💡 셋 — 널리 퍼진 이야기도 재보고 써야 합니다. 오늘 세 개를 확인했어요. .Provider는 없어지지 않았고, "Provider 아래 전부 리렌더"는 틀렸고, "객체 value는 리렌더 폭풍"은 컴파일러가 켜진 판에서 재현되지 않습니다. 셋 다 검색하면 나오는 이야기입니다.
오늘 닫은 것과 안 닫은 것
| 약속했던 것 | 닫힌 곳 |
|---|---|
| 상태를 어디에 둘지 누가 정하나 | 아직 절반 — 오늘은 "어떻게 나르나" 까지 |
| 값 하나가 네 단계를 내려가는 문제 | ❌ 안 닫음 — 다음 시간에 |
| 서버가 가진 값 | ❌ 안 닫음 — C-6에서 |
오프닝에서 본 네 단계 배달, 기억나시죠. 오늘 Context를 배웠으니 그걸로 풀면 될 것 같은데 아직 손도 안 댔습니다.
일부러 안 했어요. 그 문제는 Context가 답이 아닙니다.
다음 시간 예고
다음 시간에는 세 가지를 합니다.
먼저 Provider가 아예 없는 도구를 하나 봅니다. store를 만들면 그게 곧 훅이 되고, 감쌀 것도 없고, React 바깥에서도 값을 읽고 쓸 수 있어요. Context와 나란히 놓고 무엇이 없어졌는지 확인합니다.
그다음 상자 두 개를 비교합니다. 지난 시간에 만든 게시물 모달은 주소에 담았죠. 그런데 댓글을 지울 때 뜨는 확인 상자는 주소에 담으면 안 됩니다. 같은 '모달' 인데 왜 하나는 되고 하나는 안 되는지, 그 기준이 다음 시간의 알맹이예요.
마지막으로 오프닝의 네 단계를 없앱니다. Context로도 아니고 새 도구로도 아니라, 이미 배운 것으로요. 그 값이 원래 어디서 왔는지 다시 보면 답이 보이고, 그 셋을 합쳐서 표를 하나 완성합니다. 지난 시간에 두 칸까지 채워둔 그 표요.
과제
[구현] 로그인한 사용자를 Context로
밝기 말고 하나 더 만들어보세요. 지금 로그인한 사용자가 누구인지를 앱 전체가 알아야 한다고 해봅시다.
src/contexts/에SessionContext.tsx를 만드세요. 값은{ username: string; displayName: string }정도면 충분합니다. 아직 서버가 없으니useState로 고정값 하나를 들고 시작하세요.- 오늘 배운 세 가지를 그대로 적용하세요. 기본값 대신
null, 꺼내는 훅에서 던지기,<Context value>문법. AppProviders안에 넣으세요.ThemeProvider와 나란히 두면 됩니다.- 이어서
PostBody에서 그 값을 꺼내 쓰세요. 캡션 앞에 붙는 사용자 이름이 지금은 게시물 작성자인데, 그 게시물이 내 것일 때만 이름 옆에 표시를 하나 붙여보세요. - 여기가 핵심입니다:
PostBody는HomePage에서 세 단계 아래에 있습니다. props를 한 개도 안 늘리고 값을 가져올 수 있었나요? 오프닝의 네 단계 그림과 비교해서 무엇이 달라졌는지 두세 줄로 적어주세요.
[구현] 오류 화면에도 밝기 버튼 달기
Step 5에서 오류 화면은 색만 맞고 버튼은 없는 상태로 뒀는데, 이제 버튼도 달아보세요.
RootErrorBoundary에ThemeToggle을 넣습니다. 던져진 화면에서도 밝기를 바꿀 수 있어야 해요.- 넣기 전에 먼저 예상해보세요.
ThemeProvider는 지금 어디 있죠? 오류 화면이 그 범위 안일까요 밖일까요? 예상을 적고 나서 실제로 넣어보세요. - 만약 Step 5를 하기 전 상태였다면 어떻게 됐을까요?
ThemeProvider를Layout안으로 잠깐 되돌려서 확인해보세요. 무슨 화면이 뜨나요. 확인이 끝나면 되돌립니다.
[탐구] 재보고 숫자를 적기
각 항목의 결과를 한두 줄씩 적어주세요. 확인이 끝나면 전부 되돌립니다.
ThemeContext.tsx에서<ThemeContext value={theme}>를<ThemeContext.Provider value={theme}>로 바꿔보세요. 화면이 달라지나요? 타입 검사와 린트는 뭐라고 하나요?- 던지는 훅의
if (value === null)블록을 지우고 타입 검사를 돌려보세요. 어디에 몇 개의 오류가 뜨나요? 오류 메시지를 그대로 옮겨 적어주세요. AppProviders에서<ThemeColorMeta />한 줄만 빼고, 어두운 화면인 채로 새로고침해보세요. 개발자 도구에서<head>를 열어theme-color값을 확인하고 적어주세요.- Step 6의 세는 방법으로
Layout이 몇 번 그려지는지 재보세요. 밝기를 바꿀 때와 주소를 옮길 때가 각각 다른가요? vite.config.ts에서 React Compiler를 잠깐 빼고 같은 것을 다시 재보세요. 숫자가 달라지나요? 확인 뒤 반드시 되돌리세요.
[설계] 밝기 Context를 둘로 쪼개보기
지금 우리 Context는 choice·resolved·select를 한 덩어리로 흘려보냅니다. 이걸 둘로 쪼개는 설계를 검토해보세요.
- 값을 흘리는 Context 하나(
choice·resolved)와 함수를 흘리는 Context 하나(select)로 나눈다면 코드가 어떻게 생길까요. 실제로 짜보지 않아도 좋으니 파일 구조와 Provider 배치를 글로 적어주세요. - 이렇게 쪼개면 무엇이 좋아질까요. 컴파일러가 없던 시절에는 이게 왜 권장됐을지 생각해보세요.
- 이어서 Step 6에서 잰 숫자를 근거로, 지금 우리 프로젝트에서 이 쪼개기가 얻는 것이 있을지 판단해주세요. 있다면 왜, 없다면 왜인지 함께요.
- 마지막으로 한 문장으로 답해주세요. 언제 쪼개야 할까요?
생각해볼 주제
1. 안전장치는 어디까지 크게 소리쳐야 하나
오늘 우리는 Provider를 안 씌운 코드를 만나면 아예 멈추게 만들었습니다. 경고를 찍고 넘어갈 수도 있었는데 그러지 않았죠.
이 선택에는 대가가 있습니다. 잘못 쓴 컴포넌트 하나가 그 화면 전체를 무너뜨립니다. 만약 그 컴포넌트가 앱의 중요하지 않은 구석에 있었다면요? 광고 배너 하나 때문에 피드 전체가 안 보이는 게 맞을까요?
멈추는 것과 넘어가는 것 사이에서 어떻게 판단해야 할지 기준을 한 문장으로 만들어보고, 그 기준으로 봤을 때 애매한 경우를 하나 떠올려 그때는 무엇을 더 따져봐야 할지 생각해보세요.
2. 도구가 대신해주면 원리는 안 배워도 되나
오늘 리렌더를 재보니 React Compiler가 우리 대신 최적화를 하고 있었습니다. 손으로 할 일이 없어졌어요.
그런데 우리는 그 원리를 굳이 파봤습니다. 왜 안 그려지는지, 엘리먼트 정체성이 어떻게 작동하는지까지요. 도구가 알아서 해주는데 그걸 알아야 할 이유가 있을까요.
비슷한 일이 계속 일어납니다. 타입은 컴파일러가 잡아주고, 포맷은 도구가 맞춰주고, 앞으로 더 많은 것이 자동화될 거예요.
무엇을 계속 이해하고 있어야 하고 무엇은 도구에 맡겨도 되는지, 그 경계를 어떻게 그을지 생각해보세요. 여러분이 실제로 겪은 예가 있다면 그걸로 설명해도 좋습니다.
3. 낡은 정보를 어떻게 걸러낼 것인가
오늘 확인한 세 가지 이야기는 전부 검색하면 나오는 것들이었습니다. 하나는 틀렸고, 하나는 낡았고, 하나는 절반만 맞았는데, 셋 다 자신 있게 단정적으로 쓰여 있습니다.
이건 이 분야의 구조적인 문제입니다. 프론트엔드는 6개월마다 큰 게 바뀌는데, 글은 지워지지 않고 계속 검색에 걸립니다. 오히려 오래된 글일수록 링크가 많이 쌓여서 위에 올라오죠.
여러분이 새 기술을 배울 때 이걸 어떻게 다룰지 구체적인 방법을 하나 만들어보고, 팀에서 일한다면 이미 낡은 방식으로 짜여 있는 코드를 발견했을 때 어떻게 접근하는 게 좋을지도 함께 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 로그인한 사용자를 Context로
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| null 기본값 | createContext<T | null>(null)로 두었는가 |
15 |
| 던지는 훅 | 꺼내는 훅에서 null을 막고 무엇을 해야 하는지 알려주는가 |
20 |
| React 19 문법 | <Context value>로 썼는가 (.Provider 아님) |
10 |
| Provider 위치 | AppProviders 안에 넣었는가 |
15 |
| props를 안 늘림 | PostBody까지 props를 한 개도 추가하지 않았는가 |
25 |
| 비교 서술 | 오프닝 그림과 무엇이 달라졌는지 적었는가 | 15 |
풀이 예시
먼저 Context를 만듭니다. 밝기 쪽과 똑같은 규약을 씁니다.
// apps/web-spa/src/contexts/SessionContext.tsx
export interface Session {
username: string;
displayName: string;
}
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
// 서버가 붙기 전까지는 고정값 하나로 시작한다.
// useState 로 둔 이유는 나중에 로그인·로그아웃이 생길 자리를 비워두기 위해서다.
const [session] = useState<Session>({ username: 'jaehoon', displayName: '이재훈' });
return <SessionContext value={session}>{children}</SessionContext>;
}
export function useSession() {
const value = useContext(SessionContext);
if (value === null) {
throw new Error('useSession 은 SessionProvider 안에서 불러야 합니다');
}
return value;
}
AppProviders에 나란히 넣습니다.
// apps/web-spa/src/AppProviders.tsx
<ThemeProvider>
<SessionProvider>
<ThemeColorMeta />
{children}
</SessionProvider>
</ThemeProvider>
이어서 이름을 그리는 부분을 작은 컴포넌트로 뺍니다.
// apps/web-spa/src/components/AuthorName.tsx
// 캡션 앞의 사용자 이름. 내 게시물이면 표시를 하나 더 붙인다.
// props 로 받는 것은 게시물 작성자뿐이다 — 내가 누구인지는 위에서 꺼내 온다.
export function AuthorName({ username }: { username: string }) {
const session = useSession();
const isMine = session.username === username;
return (
<strong>
{username}
{isMine && <span className="ml-1 font-normal text-note text-faint">내 게시물</span>}
</strong>
);
}
PostBody에서는 <strong>{username}</strong>을 <AuthorName username={username} />으로 바꾸기만 하면 됩니다.
무엇이 달라졌나
이게 이 과제의 알맹이입니다. 오프닝 그림과 나란히 놓아보세요.
모달 여부는 이렇게 갔다 세션은 이렇게 간다
HomePage openPostId AppProviders session
↓ props ╎
Feed modalOpen ╎ (통로)
↓ props ╎
PostCard modalOpen ╎
↓ props ╎
PostBody modalOpen ╎
↓ props ↓
PostModal 드디어 씀 AuthorName 꺼내 씀
왼쪽은 중간 셋이 전부 props를 하나씩 더 받았습니다. 오른쪽은 중간에 아무도 아무것도 안 받았어요. Feed·PostCard·PostBody 파일은 한 글자도 안 바뀌는데, 하나 더 있습니다. 나중에 세션에 필드를 추가한다고 해봅시다. 왼쪽 방식이었다면 네 파일을 고쳐야 하는데, 오른쪽은 SessionContext와 쓰는 곳 두 군데만 고치면 됩니다. 중간 컴포넌트들이 이 값을 아예 모르는 것이 이득의 정체예요.
자주 나오는 실수
AuthorName에 session을 props로 넘기기. PostBody에서 useSession()을 불러서 그걸 AuthorName에 넘기면, 배달이 한 단계로 줄었을 뿐 없어지진 않았습니다. 읽는 컴포넌트가 직접 부르는 것이 요점이에요.
SessionProvider를 Layout 안에 넣기. 지금은 동작하지만, Step 5에서 본 것처럼 오류 화면에서 사라지고, 로그인 화면이 라우터 밖에 생기는 날 곤란해집니다.
💡 튜터의 한마디
이 과제를 하고 나면 "그럼 모든 값을 Context로 하면 되겠네"라는 생각이 들 수 있어요. 그 유혹을 다음 시간까지 붙들어 두세요. 세션은 앱 전체가 하나로 공유하고, 자주 안 바뀌고, 깊은 곳에서 필요한 값이라 Context와 아주 잘 맞습니다. 이 세 조건이 다 맞는 값은 생각보다 적어요.
🎯 [과제 2 예시답안] 오류 화면에도 밝기 버튼 달기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 예상 먼저 | 넣기 전에 범위를 예상하고 적었는가 | 20 |
| 구현 | 오류 화면에서 밝기가 실제로 바뀌는가 | 25 |
| 되돌려 확인 | Provider를 Layout 안으로 옮겨 무슨 일이 생기는지 봤는가 |
30 |
| 원인 설명 | 왜 그렇게 되는지 범위로 설명했는가 | 25 |
예상은 이렇게 나와야 합니다
ThemeProvider는 Step 5에서 main.tsx로 올라갔습니다. RouterProvider를 감싸고 있으니 라우터가 무엇을 그리든 그 바깥이에요. 오류 화면도 라우터가 그리는 것이므로 범위 안입니다.
그러니 그냥 넣으면 됩니다.
풀이 예시
// apps/web-spa/src/routes/RootErrorBoundary.tsx
// 머리말이 통째로 사라지는 화면이라 밝기 버튼을 여기 직접 놓는다.
// ThemeProvider 는 라우터 바깥에 있으므로 이 화면도 그 범위 안이다.
return (
<main className="mx-auto max-w-[996px] p-4">
<div className="mb-4 flex items-start justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<ThemeToggle />
</div>
<p className="mb-4 text-sm text-faint">{detail}</p>
<Link className="text-sm underline underline-offset-4" to="/">
홈으로
</Link>
</main>
);
/p/999로 들어가서 '어둡게'를 눌러보면 화면이 어두워지고 주소창 색도 따라옵니다.
되돌려서 확인하면
ThemeProvider를 Layout 안으로 옮기고 같은 주소로 들어가면 이렇게 됩니다.
Error: useThemeContext 는 ThemeProvider 안에서 불러야 합니다
오류 화면이 또 다른 오류를 내면서 멈춥니다.
이게 Step 3의 안전장치가 일하는 모습이에요. 만약 기본값을 남겨뒀다면 화면은 그려지고 버튼만 안 먹었을 겁니다. 오류 화면 안에서 그 버그를 찾는 상황을 상상해 보세요. 차라리 멈추는 편이 낫습니다.
자주 나오는 실수
밝기 버튼 대신 머리말을 통째로 넣기. 오류 화면에 <Layout>을 다시 그리면 될 것 같지만, 그러면 <Outlet>이 또 오류를 받아서 무한히 겹칩니다. 오류 화면은 껍데기를 대신하는 화면이지 껍데기 안의 화면이 아니에요.
💡 튜터의 한마디
실무에서 오류 화면은 대충 만들어지기 쉽습니다. 자주 안 보이지만, 사용자가 오류 화면을 보는 순간은 이미 뭔가 잘못된 순간입니다. 그때 화면까지 어색하면 인상이 두 배로 나빠져요. 밝기 하나라도 맞춰두는 게 그래서 의미가 있습니다.
🎯 [과제 3 예시답안] 재보고 숫자를 적기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 다섯 항목 | 전부 실제로 돌려보고 결과를 적었는가 | 50 |
| 오류 메시지 | 그대로 옮겨 적었는가 (요약 아님) | 20 |
| 되돌리기 | 확인 뒤 전부 원래대로 돌려놨는가 | 15 |
| 해석 | 숫자가 무엇을 뜻하는지 한 줄이라도 붙였는가 | 15 |
풀이 예시
1. <Context value>를 <Context.Provider value>로
화면이 안 달라집니다. 타입 검사 0건, 린트 0건입니다.
아무도 안 알려준다는 것이 요점이에요. 옛 문법을 베껴 와도 도구는 조용합니다. 새로 쓰는 코드는 알고서 <Context value>를 골라야 합니다.
2. 던지는 if를 지우고 타입 검사
우리 앱 파일 두 곳에서 네 개가 뜹니다.
src/components/ThemeColorMeta.tsx(10,11): error TS2339: Property 'resolved' does not exist on type '{ ... } | null'.
src/components/ThemeColorMeta.tsx(16,36): error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<ResolvedTheme, string>'.
src/components/ThemeToggle.tsx(13,11): error TS2339: Property 'choice' does not exist on type '{ ... } | null'.
src/components/ThemeToggle.tsx(13,19): error TS2339: Property 'select' does not exist on type '{ ... } | null'.
ThemeColorMeta를 보세요. 오류가 둘입니다. resolved를 못 꺼낸 게 첫 번째고, 못 꺼낸 값이 any가 되어 THEME_COLOR[resolved]의 색인까지 오염된 게 두 번째예요. 한 곳을 안 막으면 그 아래로 번집니다.
3. <ThemeColorMeta /> 한 줄 빼고 어두운 화면으로 새로고침
<meta name="theme-color">가 #fafafa로 남습니다. 화면은 검은데 주소창만 하얀 그 상태예요. Step 1과 Step 5에서 본 것과 같은 증상입니다.
4. Layout이 몇 번 그려지나
| 무엇을 했을 때 | Layout 렌더 |
|---|---|
| 밝기를 바꿈 | 1 → 1 |
| 주소를 옮김 | 1 → 1 |
둘 다 안 그려집니다. 이유가 서로 달라요.
밝기 쪽은 Layout이 Context를 안 읽기 때문입니다. 읽는 것은 그 안에 있는 ThemeToggle이에요. Step 6에서 본 그대로입니다.
주소 쪽은 C-1에서 배운 것입니다. 중첩 라우트에서 갈리는 것은 <Outlet> 자리뿐이고 껍데기는 살아남죠. 그때 말로 배운 것이 여기서 숫자로 확인됩니다.
5. React Compiler를 빼고 다시
| 바깥이 다시 그려질 때 | 켬 | 끔 |
|---|---|---|
| Context를 읽는 컴포넌트 | 1 → 1 | 1 → 2 |
| 안 읽는 컴포넌트 | 1 → 1 | 1 → 2 |
| Context 값의 정체 | 같은 객체 | 새 객체 |
자주 나오는 실수
2번에서 오류 개수만 적기. 메시지를 그대로 옮겨야 TS7053이 왜 딸려왔는지 보입니다. 개수만 세면 그 연쇄를 놓쳐요.
5번을 확인하고 안 되돌리기. 컴파일러가 빠진 채로 다음 시간을 시작하면 그때 재는 숫자가 전부 달라집니다.
💡 튜터의 한마디
이 과제의 진짜 목적은 다섯 개의 답을 맞히는 게 아닙니다. 고쳐보고 → 재보고 → 되돌리는 사이클을 몸에 익히는 거예요. 남의 글을 읽고 "그렇구나" 하는 것과, 직접 깨뜨려서 무슨 일이 생기는지 보는 것은 남는 게 완전히 다릅니다.
🎯 [과제 4 예시답안] 밝기 Context를 둘로 쪼개보기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 설계 서술 | 파일 구조와 Provider 배치를 구체적으로 적었는가 | 25 |
| 원리 이해 | 왜 이 방식이 권장됐는지 설명했는가 | 25 |
| 측정 근거 | Step 6의 숫자를 근거로 판단했는가 | 30 |
| 결론 | "언제 쪼개야 하나" 에 한 문장으로 답했는가 | 20 |
풀이 예시
설계는 이렇게 됩니다.
통로를 둘로 나눕니다. 하나는 바뀌는 값, 하나는 안 바뀌는 함수예요.
ThemeContext.tsx
├─ ThemeValueContext { choice, resolved } ← 밝기를 고를 때마다 바뀐다
├─ ThemeSelectContext select ← 처음 만들어진 뒤 안 바뀐다
│
└─ ThemeProvider
useTheme() 을 한 번 부르고
<ThemeSelectContext value={select}>
<ThemeValueContext value={{ choice, resolved }}>
{children}
꺼내는 훅도 둘
useThemeValue() 지금 밝기를 알아야 하는 쪽
useThemeSelect() 바꾸기만 하는 쪽
Provider를 겹칠 때 함수 쪽이 바깥입니다. 안 바뀌는 것을 바깥에 두어야 안쪽이 바뀔 때 바깥이 안 흔들려요.
왜 이게 권장됐나.
버튼만 놓고 생각해봅시다. 밝기를 바꾸는 버튼은 select만 필요하고 지금 밝기는 알 필요가 없을 수 있는데, 한 덩어리로 받으면 choice가 바뀔 때마다 그 버튼도 다시 그려집니다. 관심 없는 값 때문에요.
쪼개두면 useThemeSelect()만 부르는 컴포넌트는 밝기가 아무리 바뀌어도 안 그려집니다.
그런데 지금 우리 프로젝트에서는 얻는 게 없습니다.
Step 6에서 잰 숫자를 보세요. 밝기를 바꿨을 때 다시 그려진 것은 Context를 읽는 컴포넌트 하나뿐이었습니다. Bystander는 Provider 바로 아래인데도 안 그려졌는데, 지금 값을 읽는 곳은 둘뿐입니다. ThemeToggle은 choice와 select를 둘 다 쓰고, ThemeColorMeta는 resolved를 씁니다. 쪼개도 ThemeToggle은 양쪽을 다 구독해야 해서 아무것도 안 줄어요.
정리하면 이렇습니다.
| 쪼개서 얻는 것 | 지금 우리 경우 |
|---|---|
| 함수만 쓰는 컴포넌트가 값 변화에 안 흔들림 | 그런 컴포넌트가 0개 |
| 리렌더 감소 | 이미 읽는 쪽만 그려지고 있음 |
| 늘어나는 것 | 파일 개념 2배, 훅 2개, Provider 중첩 |
언제 쪼개야 할까요.
한 문장으로 답하면 이렇습니다. "값을 안 쓰고 함수만 쓰는 컴포넌트가 여럿 생겼고, 그것들이 실제로 느리다고 측정됐을 때."
두 조건이 모두 필요합니다. 앞의 것만 있으면 아직 문제가 아니고, 뒤의 것 없이 앞의 것만 보고 쪼개면 미리 최적화하는 거예요.
자주 나오는 실수
"쪼개는 게 더 좋은 설계니까 지금 하자". 좋은 설계의 기준은 그 코드가 실제로 겪는 문제입니다. 안 겪는 문제를 대비한 구조는 읽는 사람에게 "왜 이렇게 되어 있지?"만 남겨요.
Provider 순서를 반대로 두기. 값 쪽을 바깥에 두면 값이 바뀔 때 함수 Provider까지 다시 그려집니다. 쪼갠 이유가 절반 날아가요.
💡 튜터의 한마디
이 과제는 정답이 "안 쪼갠다"인 설계 과제입니다. 좀 이상하죠. 그런데 실무에서 훨씬 자주 마주치는 판단이 이쪽이에요. 좋아 보이는 패턴을 안 쓰기로 결정하고 그 이유를 대는 것 말입니다. 남이 좋다고 한 것을 가져오기는 쉽고, 우리 경우엔 아니라고 말하려면 근거가 필요합니다. 그 근거가 측정이에요.
🤔 [생각해볼 주제 1] 안전장치는 어디까지 크게 소리쳐야 하나
문제 상황 요약
Provider를 안 씌운 코드를 만나면 화면을 통째로 멈추게 만들었습니다. 그런데 그 컴포넌트가 화면의 사소한 구석에 있었다면요? 배너 하나 때문에 피드 전체가 안 보이는 게 맞을까요.
튜터의 가이드 및 해설
기준을 세우려면 먼저 누구의 실수인가를 갈라야 합니다.
개발자의 실수 바깥에서 온 문제
──────────────────────── ────────────────────────
Provider 를 안 씌움 서버가 안 뜸
필수 prop 을 빼먹음 응답 모양이 예상과 다름
오타 난 키로 값을 꺼냄 사용자가 이상한 값을 넣음
↓ ↓
개발 중에 반드시 걸린다 배포된 뒤에도 계속 생긴다
배포까지 갈 수가 없다 생기는 게 정상이다
↓ ↓
크게 터뜨린다 화면에서 안내로 처리한다
왼쪽은 화면을 한 번만 열어봐도 걸립니다. 그러니 가장 크게 소리쳐도 손해가 없어요. 오히려 조용히 넘어가면 잘못된 화면이 배포됩니다.
오른쪽은 다릅니다. 서버가 잠깐 안 뜨는 것은 버그가 아니라 일어나기로 되어 있는 일이에요. 그걸로 화면을 멈추면 그냥 못 만든 앱입니다.
그럼 배너 문제는 어느 쪽일까요. "멈춘다"의 범위를 좁히면 됩니다. 지난 시간에 배운 ErrorBoundary를 배너 주변에만 따로 두면, 배너는 자기 자리에서만 무너지고 피드는 살아남아요. 개발자에게는 여전히 크게 알리면서 사용자 피해는 그 구역으로 가두니, 질문이 "멈출까 넘어갈까"의 둘이 아니라 "어디까지 멈출까"의 셋이 되는 겁니다.
애매한 경우를 하나 들어보죠. 사용자 프로필 이미지 주소가 깨진 값으로 왔습니다. 개발자 실수 같기도 하고 서버 문제 같기도 해요. 이럴 때는 그 값이 어디서 왔는지를 따집니다. 우리 코드가 만든 값이면 왼쪽, 서버나 사용자가 준 값이면 오른쪽입니다. 판별이 안 되면 오른쪽으로 가세요. 사용자를 막는 쪽이 언제나 더 비쌉니다.
🎯 면접관을 홀리는 핵심 멘트
"에러를 던질지 말지는 심각도가 아니라 누구의 실수인가로 가릅니다. 개발자 실수는 개발 중에 반드시 걸리니까 크게 터뜨려도 사용자에게 도달하지 않고, 오히려 조용하면 잘못된 화면이 배포됩니다. 반대로 서버 장애처럼 바깥에서 오는 문제는 일어나는 게 정상이라 안내로 처리해야 하고요. 그리고 이건 양자택일이 아닙니다. Error Boundary로 터지는 범위를 좁히면 개발자에게는 크게 알리면서 피해는 그 구역에 가둘 수 있습니다."
🤔 [생각해볼 주제 2] 도구가 대신해주면 원리는 안 배워도 되나
문제 상황 요약
React Compiler가 리렌더 최적화를 대신하고 있었습니다. 손댈 게 없었어요. 그런데 우리는 왜 그려지고 왜 안 그려지는지를 굳이 파봤습니다. 도구가 해주는데 알아야 할 이유가 있을까요.
튜터의 가이드 및 해설
경계를 이렇게 그어보면 어떨까요.
안 배워도 되는 것 계속 알아야 하는 것
────────────────── ────────────────────────
도구가 쓰는 방법 도구가 무엇을 대신하는지
(어떤 규칙으로 메모하는지) (원래 무슨 문제였는지)
→ 도구가 바뀌면 같이 버린다 → 도구가 못 잡을 때 필요하다
→ 도구를 고를 때 필요하다
→ 도구가 틀렸을 때 필요하다
도구가 못 잡는 때가 반드시 옵니다. 컴파일러는 우리가 쓴 코드 모양을 보고 판단하는데, 그 판단이 안 되는 코드가 있어요. 그때 "왜 얘만 계속 그려지지?"를 물으려면 원래 문제가 무엇이었는지 알아야 합니다.
도구를 고를 때도 필요합니다. 다음 프로젝트에서 컴파일러가 없을 수 있어요. 회사 코드가 옛 버전에 묶여 있을 수도 있고요. 그때 "이건 원래 손으로 해야 하는 일이었구나"를 알아야 판단이 섭니다.
도구가 틀렸을 때가 제일 무섭습니다. 오늘 우리가 잰 것을 생각해보세요. 만약 원리를 몰랐다면 "안 그려지네, 좋네"로 끝났을 텐데, 우리는 컴파일러를 빼고 다시 재서 원인을 확정했어요. 원인을 모르면 그게 사라졌을 때도 모릅니다.
다만 반대쪽도 인정해야 합니다. 모든 것을 다 파고들 시간은 없어요. 그래서 저는 한 겹만 파는 걸 권합니다. 도구가 하는 일이 무엇인지 한 겹, 그게 없으면 어떻게 되는지 한 겹. 그 아래 구현 세부까지는 필요할 때 가면 됩니다.
오늘 우리가 딱 그만큼 했어요. 컴파일러가 객체와 엘리먼트를 붙든다는 것까지 봤고, 어떤 알고리즘으로 그러는지는 안 봤습니다.
🎯 면접관을 홀리는 핵심 멘트
"도구가 대신해주는 일이라도 원래 무슨 문제였는지는 알고 있어야 한다고 봅니다. 이유가 셋인데요. 도구가 못 잡는 경우가 반드시 오고, 도구가 없는 환경으로 옮길 수 있고, 무엇보다 도구가 일을 안 하고 있을 때 그걸 알아채려면 원리를 알아야 합니다. 다만 무한정 파지는 않고 한 겹만 봅니다. 도구가 무엇을 대신하는지와 그게 없으면 어떻게 되는지까지요. 실제로 React Compiler 효과를 확인할 때도 컴파일러를 빼고 같은 것을 다시 재서 원인을 확정하는 데까지만 갔습니다."
🤔 [생각해볼 주제 3] 낡은 정보를 어떻게 걸러낼 것인가
문제 상황 요약
오늘 확인한 세 가지는 전부 검색하면 나오는 이야기였습니다. 하나는 틀렸고, 하나는 낡았고, 하나는 절반만 맞았는데, 셋 다 아주 단정적으로 쓰여 있습니다.
튜터의 가이드 및 해설
먼저 구조를 이해합시다. 왜 낡은 글이 위에 뜰까요.
오래된 글일수록 링크와 인용이 쌓여 있습니다. 검색은 그걸 신뢰의 신호로 읽어요. 그래서 가장 많이 읽힌 글이 가장 낡은 글인 상황이 자주 생깁니다. 게다가 그 글을 인용한 새 글들이 낡은 내용을 실어 나릅니다.
방법을 하나 만들어보면 이렇습니다.
첫째, 날짜보다 본문에 나오는 이름을 봅니다. 글에 적힌 날짜는 수정일일 수 있어요. 대신 코드에 쓰인 API 이름을 표지로 씁니다.
오늘 하나 얻었죠. Context.Provider가 보이면 React 19 이전에 쓰인 글입니다. 지난 시간 라우터를 시작할 때도 하나 얻었고요. 설치 명령에 적힌 패키지 이름이 지금 것과 다르면 v7 이전 글이에요.
이렇게 내가 직접 확인한 표지를 하나씩 모으는 것이 남의 체크리스트를 외우는 것보다 오래갑니다. 모듈을 하나 끝낼 때마다 "이번에 알게 된 옛날 표지"를 한 줄씩 적어두세요.
둘째, 공식 문서를 먼저 열고 검색 결과를 나중에 봅니다. 순서를 바꾸는 것만으로 많이 걸러집니다. 공식 문서에서 개념을 잡고 나면, 검색 결과에서 어긋나는 부분이 눈에 띄어요.
셋째, 그리고 재봅니다. 이게 제일 확실합니다. 오늘 우리가 한 것처럼요. 성능 이야기는 특히 그래요. 내 프로젝트에서 재본 숫자만이 내 프로젝트의 사실입니다.
팀 코드에서 낡은 방식을 발견했다면 조금 다릅니다. 여기서 조심할 게 있어요. 낡았다는 것과 고쳐야 한다는 것은 다릅니다.
돌아가고 있는 코드를 "요즘은 이렇게 안 해요" 만으로 고치자고 하면 설득이 안 되고, 실제로 안 고치는 게 맞을 때도 많아요. 이렇게 정리해보세요.
| 어떤 낡음인가 | 어떻게 할까 |
|---|---|
| 안전 문제가 있다 | 바로 제안한다 |
| 곧 없어질 API 다 | 일정을 확인하고 계획을 세운다 |
| 그냥 요즘 방식이 아니다 | 그 파일을 어차피 고칠 때 함께 고친다 |
그리고 새로 쓰는 코드부터 새 방식으로 가는 게 실전에서 가장 잘 통합니다. 기존 코드를 한 번에 뒤집는 제안은 위험도 크고 통과도 잘 안 돼요.
🎯 면접관을 홀리는 핵심 멘트
"프론트엔드는 오래된 글일수록 링크가 쌓여서 검색 위에 올라오는 구조라, 가장 많이 읽히는 글이 가장 낡은 글인 경우가 많습니다. 그래서 저는 날짜 대신 본문의 API 이름을 표지로 봅니다.
Context.Provider하나만 보여도 React 19 이전 글이라는 걸 알 수 있으니까요. 그리고 성능 이야기는 반드시 제 프로젝트에서 다시 잽니다. 팀 코드에서 낡은 방식을 만났을 때는 낡은 것과 고쳐야 하는 것을 구분합니다. 보안 문제면 바로 제안하고, 그냥 옛 방식이면 그 파일을 어차피 고칠 때 함께 고치는 쪽이 통과도 잘 되고 위험도 적었습니다."