Context로 사용자 정보 공유하기

튜토리얼 — Context로 사용자 정보 공유하기
책의 ThemeContext는 마지막 화면에서 "현재 테마: ???" 로 멈췄습니다. ???를 채우는 방법(useContext)이 다음 절에 있다 보니, "Context가 결국 뭘 해 주는지"가 손에 잡히지 않으셨을 거예요.
이 튜토리얼은 Context의 세 부분을 한 번에 끝까지 봅니다 — createContext로 통로를 만들고, Provider로 값을 공급하고, useContext로 그 값을 꺼내 씁니다. 거기에 값을 바꾸는 것(로그인/로그아웃)까지 포함해서, Context가 진짜로 prop drilling 없이 어떻게 트리 전체에 영향을 주는지 눈으로 확인합니다.
예제는 책의 테마와 다른 "현재 로그인 사용자" 입니다. 새 상황에 같은 패턴을 적용해 보면 이해가 일반화됩니다.
개념
1. props 계단의 고통 — prop drilling
먼저 Context가 왜 필요한지부터 손으로 잡아 봅시다. 로그인한 사용자 이름을 화면 곳곳에서 쓰고 싶다고 해 봅시다.
Page (user 상태를 가지고 있음) │ ├─ Header │ └─ Greeting ← 여기서 user.name 표시 │ └─ Card └─ Avatar ← 여기서도 user.name 표시
Greeting과 Avatar만 user를 씁니다. 그런데 props로 전달하려면 중간의 Header와 Card도 받아서 넘겨야 합니다 — 자기는 안 쓰면서.
// 이렇게 안 합니다 (prop drilling) function Page() { const [user, setUser] = useState<User | null>(null); return ( <> <Header user={user} /> <Card user={user} /> </> ); } function Header({ user }: { user: User | null }) { return <Greeting user={user} />; // Header 는 안 쓰는데 받아서 넘김 } function Card({ user }: { user: User | null }) { return <Avatar user={user} />; // Card 도 안 쓰는데 받아서 넘김 }
자식이 두 단계 깊으면 두 번, 다섯 단계 깊으면 다섯 번 — user가 통과만 하는 컴포넌트가 계속 늘어납니다. 새로운 곳에서 user를 쓰고 싶을 때마다 그 길목의 모든 컴포넌트의 props 타입을 고쳐야 합니다. 이게 prop drilling입니다.
2. Context — 트리 전체에 흐르는 "방송"
Context의 비유는 책에서 본 "방송"이 정확합니다. Page에서 한 번 켠 방송을, 그 아래 어느 컴포넌트든 — 중간을 거치지 않고 — 바로 들을 수 있습니다.
Page └─ <UserProvider value={user}> ← 여기서 방송 시작 ├─ Header │ └─ Greeting ← 방송 듣기 (useContext) └─ Card └─ Avatar ← 방송 듣기 (useContext)
Header와 Card는 더 이상 user를 받지도 넘기지도 않습니다. 두 컴포넌트의 props에서 user가 완전히 사라집니다.
3. 세 부분으로 만든다
createContext— "방송 채널"을 하나 만듭니다. 이 채널에 어떤 종류의 값이 흐를지 타입도 같이 정합니다.Provider— "이 안에서는 이 채널에 이 값을 송출한다" 고 정합니다. JSX로 자식 트리를 감싸요.useContext— 자식 컴포넌트에서 그 채널을 듣습니다. 값을 꺼내 옵니다.
책은 ①②까지 다뤘고, 이 튜토리얼에서 ③까지 갑니다. 그리고 값을 바꾸는 함수(로그인/로그아웃)까지 통로에 함께 실어, 누가 어디서 바꾸든 모든 청취자가 함께 갱신되는 것을 확인합니다.
함께 해보기
0단계 — 만들 것
만들 화면:
- 상단
Header— 로그인되어 있으면 "안녕하세요, 민준님 👋", 아니면 "로그인하지 않았습니다" - 가운데
LoginPanel— 이름 입력칸 + 로그인 버튼 / 로그인 상태에서는 로그아웃 버튼 - 아래
Card(중간 단계) →Avatar— 동그란 이니셜 + 이름
Header와 Avatar는 LoginPanel에서 한참 떨어져 있지만, LoginPanel에서 로그인하는 순간 둘 다 동시에 갱신돼야 합니다. Context로 한 줄도 props를 안 넘기고 이걸 해 봅니다.
1단계 — Context 만들기
contexts/UserContext.tsx 파일을 만듭니다. (contexts 폴더는 프로젝트 최상단에 만듭니다.)
"use client"; import { createContext, useContext, useState } from "react"; // 통로에 흐를 데이터의 모양 export type User = { name: string }; type UserContextValue = { user: User | null; // null = 로그아웃 상태 login: (name: string) => void; logout: () => void; }; // 통로 만들기 — 기본값은 일부러 null 로 둔다 (이유는 아래에서) const UserContext = createContext<UserContextValue | null>(null);
여기서 두 가지가 새롭습니다.
(가) 통로에 실리는 게 객체입니다. 책의 ThemeContext는 <string> 한 글자만 흘려보냈지만, 우리는 { user, login, logout } 세 가지를 묶어서 흘립니다. 읽기(user)와 쓰기(login/logout)를 함께 묶어 두는 것이 흔한 패턴입니다. 그래야 통로 하나로 화면 갱신까지 다 됩니다.
(나) 기본값을 null로 둡니다. 책은 "light"처럼 그럴듯한 기본값을 줬지만, 실전에서는 null이 더 안전합니다. 이유: Provider 없이 useContext를 부르면 그럴듯한 기본값이 잡혀 버려서, "Provider 빠뜨림" 버그가 조용히 숨어 버립니다. null이면 "어, Provider를 안 감쌌네"가 즉시 드러납니다. 이 안전망을 다음 단계의 useUser 훅이 챙겨 줍니다.
2단계 — Provider 만들기
같은 파일 아래에 UserProvider를 만듭니다.
// (같은 파일 아래에 이어서) export function UserProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); const login = (name: string) => setUser({ name }); const logout = () => setUser(null); return ( <UserContext.Provider value={{ user, login, logout }}> {children} </UserContext.Provider> ); }
UserProvider는 상태를 보관하는 곳입니다. user라는 상태가 여기 하나만 살아 있고, 자식 트리 어디서 보더라도 같은 값입니다.
value={{ user, login, logout }}이 핵심입니다 — "이 안의 모든 자손은 이 통로로 이 값을 받을 수 있다"는 뜻입니다. setUser 자체는 통로에 안 흘리고, 의미가 분명한 login(name)/logout() 함수로 감싸서 흘립니다. 자식들이 "어떤 이름으로 로그인" / "로그아웃" 이라는 행동만 알면 되도록.
3단계 — useUser 커스텀 훅
같은 파일에 한 함수를 더 만듭니다.
// (계속 같은 파일) export function useUser() { const ctx = useContext(UserContext); if (ctx === null) { throw new Error( "useUser는 <UserProvider> 안에서만 사용할 수 있습니다.", ); } return ctx; }
왜 useContext를 그대로 안 쓰고 한 번 더 감싸는가?
useContext(UserContext)는 타입상 UserContextValue | null을 돌려줍니다(기본값이 null이라서). 그러면 쓰는 쪽에서 매번 if (ctx === null) 검사를 해야 합니다.
// useUser 없이 직접 쓰면 — 이렇게 복잡해집니다 function Header() { const ctx = useContext(UserContext); if (ctx === null) return null; // 매번 검사 const { user } = ctx; // ... }
useUser로 한 번 감싸면, 호출하는 모든 컴포넌트에서 타입이 보장된 값을 바로 받습니다.
// useUser 로 쓰면 — 깔끔합니다 function Header() { const { user } = useUser(); // 절대 null 아님 // ... }
그리고 Provider 없이 쓴 실수가 있으면 즉시 에러로 잡힙니다. Context는 거의 항상 이렇게 커스텀 훅으로 감싸 씁니다. 외워 두면 좋은 패턴입니다.
4단계 — 소비 컴포넌트 만들기
이제 통로에서 값을 꺼내 쓰는 컴포넌트들입니다. components/user-ctx/ 폴더를 만들고 안에 파일들을 차례로 둡니다.
components/user-ctx/Header.tsx — 인사말 표시
"use client"; import { useUser } from "@/contexts/UserContext"; export default function Header() { const { user } = useUser(); // 통로에서 user 꺼내기 return ( <header className="border-b p-3"> {user ? ( <p data-testid="header-greeting"> 안녕하세요, <strong>{user.name}</strong>님 👋 </p> ) : ( <p data-testid="header-greeting">로그인하지 않았습니다</p> )} </header> ); }
Header는 부모로부터 어떤 props도 받지 않습니다. 그래도 user를 알 수 있는 건 통로 덕분입니다.
components/user-ctx/Avatar.tsx — 동그란 이니셜 + 이름
"use client"; import { useUser } from "@/contexts/UserContext"; export default function Avatar() { const { user } = useUser(); if (!user) { return ( <p data-testid="avatar" className="text-gray-500"> (아바타 없음) </p> ); } const initial = user.name.charAt(0).toUpperCase(); return ( <div data-testid="avatar" className="flex items-center gap-2"> <span className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white"> {initial} </span> <span>{user.name}</span> </div> ); }
components/user-ctx/Card.tsx — 중간 단계, user를 모릅니다
import Avatar from "./Avatar"; export default function Card() { return ( <div className="rounded border p-3"> <h3 className="font-bold">프로필 카드</h3> <Avatar /> </div> ); }
여길 잘 보세요. Card는 user라는 단어 자체가 나오지 않습니다. Avatar에게 user를 전달할 일이 없으니까요. 이게 prop drilling 해결의 핵심 증거입니다.
components/user-ctx/LoginPanel.tsx — 통로에 값을 써넣는 컴포넌트
"use client"; import { useState } from "react"; import { useUser } from "@/contexts/UserContext"; export default function LoginPanel() { const { user, login, logout } = useUser(); const [name, setName] = useState(""); if (user) { return ( <div className="space-x-2" data-testid="login-status"> <span> 로그인됨: <strong>{user.name}</strong> </span> <button className="rounded bg-gray-600 px-3 py-1 text-white" onClick={logout} > 로그아웃 </button> </div> ); } return ( <div className="space-x-2"> <input className="border px-2 py-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="이름 입력" /> <button className="rounded bg-blue-500 px-3 py-1 text-white" onClick={() => { const trimmed = name.trim(); if (trimmed !== "") { login(trimmed); setName(""); } }} > 로그인 </button> </div> ); }
LoginPanel은 통로에서 user를 읽으면서 login/logout을 호출합니다. 호출하면 UserProvider 안의 setUser가 실행돼 통로 값이 바뀌고 — 모든 청취자(Header, Avatar)가 함께 다시 그려집니다. 이게 Context의 살아 있는 동작입니다.
5단계 — 페이지에 조립하기
app/account/page.tsx를 만듭니다. 트리 전체를 UserProvider로 감싸는 게 핵심입니다.
import { UserProvider } from "@/contexts/UserContext"; import Header from "@/components/user-ctx/Header"; import LoginPanel from "@/components/user-ctx/LoginPanel"; import Card from "@/components/user-ctx/Card"; export default function AccountPage() { return ( <UserProvider> <Header /> <main className="space-y-4 p-6"> <h2 className="text-xl font-bold">사용자 컨텍스트 데모</h2> <LoginPanel /> <Card /> </main> </UserProvider> ); }
페이지의 어디서도 user라는 단어가 한 번도 나오지 않습니다. props로 내려보낼 게 없으니까요. Provider가 위에서 한 번 켜 두면, 그 아래 어디서든 useUser()로 꺼내 씁니다.
6단계 — 확인
/account를 엽니다.
- 초기 상태 — Header: "로그인하지 않았습니다", Avatar: "(아바타 없음)", 입력칸 + 로그인 버튼이 보입니다.
- 입력칸에 "민준" 입력 → 로그인 클릭
- Header가 "안녕하세요, 민준님 👋"으로 바뀝니다 ✅
- Avatar가 동그란 "민" + "민준"으로 바뀝니다 ✅
- LoginPanel이 "로그인됨: 민준" + 로그아웃 버튼으로 바뀝니다 ✅ - 로그아웃 클릭 → 셋 다 처음 상태로 돌아갑니다 ✅
확인 ✅ — LoginPanel에서 한 번 login("민준")을 부르면, 멀리 떨어진 Header와 Avatar가 동시에 갱신됩니다. 그 사이의 Card는 user를 모르는데도 통과만 합니다. props로 한 줄도 안 넘겼는데 데이터가 흘렀습니다 — 그게 Context입니다.
더 깊이 들어가기
Q1. 한 번 setUser를 부르면 어떻게 모든 곳이 갱신되나요?
setUser를 부르면 UserProvider가 다시 렌더링됩니다. 그러면 <UserContext.Provider value={{ user, login, logout }}>에 새 value가 들어가요. React는 이 통로를 듣고 있는 모든 자손(useContext/useUser를 부른 컴포넌트)을 찾아 다시 렌더링합니다. 중간에 있는 Card처럼 통로를 안 듣는 컴포넌트는 영향받지 않습니다(정확히 말하면 자식이 다시 그려지긴 하지만, Card 자체의 로직은 그대로). 이게 "방송" 비유의 정확한 기술적 의미입니다.
Q2. value={{ ... }} 매번 새 객체가 만들어지지 않나요?
날카로운 관찰입니다. 맞아요 — 부모가 다시 렌더링될 때마다 { user, login, logout } 객체가 새로 만들어집니다. 객체 참조가 매번 달라서, 청취자가 많고 자주 렌더링되는 경우 불필요한 갱신이 생길 수 있습니다. 작은 화면에서는 차이가 안 보이지만, 큰 화면에서는 useMemo + useCallback으로 묶어 둘 수 있어요.
const value = useMemo( () => ({ user, login, logout }), [user], // user 가 안 바뀌면 객체도 그대로 );
이번 튜토리얼 규모에선 안 해도 됩니다. "성급한 최적화는 독" 이라고 useMemo 절에서 배웠죠.
Q3. 어떤 데이터를 Context에 넣어야 하나요?
여러 컴포넌트가 두 단계 이상 떨어져 함께 쓰는 데이터가 좋은 후보입니다. 예: 로그인 사용자, 테마, 언어 설정, 알림 시스템, 장바구니. 한 군데에서만 쓰거나 부모-자식 한 단계만 떨어진 데이터는 Context보다 그냥 props가 깔끔합니다. Context는 prop drilling이 진짜로 아플 때 꺼내는 도구지, 모든 상태의 기본 보관함은 아닙니다.
Q4. 왜 Provider 컴포넌트(UserProvider)를 따로 만들었나요?
createContext가 주는 UserContext.Provider를 직접 페이지에서 쓸 수도 있습니다.
// 이렇게도 가능 — 하지만 권장하지 않습니다 const [user, setUser] = useState<User | null>(null); const login = (name: string) => setUser({ name }); const logout = () => setUser(null); return ( <UserContext.Provider value={{ user, login, logout }}> ... </UserContext.Provider> );
이러면 상태 관리 로직이 페이지에 들러붙어 다른 곳에서 재사용이 어렵습니다. UserProvider라는 한 컴포넌트로 묶어 두면, 어떤 페이지든 그 컴포넌트로 감싸기만 하면 똑같이 동작해요. "공급자도 부품으로 묶는다" — 이게 표준 패턴입니다.
정리
- Context는 세 부분으로 씁니다 —
createContext로 통로 만들고,Provider로 값 공급하고,useContext로 값 꺼내기. - 통로엔 읽기 값(user)과 쓰기 함수(login/logout)를 함께 묶어 흘립니다. 그러면 어디서 바꿔도 모든 청취자가 함께 갱신됩니다.
createContext의 기본값은null로 두고, 커스텀 훅(useUser) 으로 감싸 안전하게 꺼냅니다.- 트리 전체를
Provider로 한 번 감싸면, 중간 컴포넌트들의 props에서 그 값이 완전히 사라집니다 — prop drilling 해결. - 모든 상태를 Context에 넣지 마세요. 여러 곳이 멀리 떨어져 함께 쓰는 데이터에만 씁니다.
연습 거리
User타입에email을 추가하고,LoginPanel에서 이메일도 받아login(name, email)로 전달하기.Avatar/Header에는 손대지 말고도 어디서나 이메일이 나타나는지 확인.Card옆에Footer컴포넌트를 새로 만들어,useUser로 "© 2026 — {user.name}" 형식의 카피라이트를 표시하기. 페이지 코드는 한 줄만 늘어납니다.- 두 번째 Context(예:
ThemeContext)를 만들어,<ThemeProvider><UserProvider>...처럼 두 Provider를 중첩해서 한 화면에서 둘 다 쓰기. Context는 얼마든 겹쳐 쓸 수 있습니다.

댓글
댓글을 작성하려면 이 필요합니다.