문서 읽는 데 80분 · C4

C-4: 전역 상태 ② — Zustand와 세 갈래 결정

목차 59
전체 59강 중 16강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

질문 하나로 시작하겠습니다.

C-2에서 만든 게시물 모달은 주소에 담았습니다. ?post=1이 붙어 있으면 상자가 열린 채로 뜨고, 그 주소를 친구에게 보내면 친구 화면에도 같은 상자가 열립니다.

그럼 오늘 만들 삭제 확인 상자도 주소에 담으면 될까요? ?confirm=3처럼요.

안녕하세요, 홍순구입니다. 답부터 말씀드리면 담으면 안 됩니다. 둘 다 화면 가운데 뜨는 상자이고, 둘 다 열렸다 닫히고, 코드도 거의 똑같이 생겼는데 한쪽만 안 됩니다.

왜 안 되는지는 오늘 Step 7에서 직접 담아보고 확인할 텐데, 그 답이 오늘 수업의 알맹이라 거기까지 가려면 먼저 새 도구를 하나 배워야 해요.

💡 오늘 수업의 핵심 — "값을 어디에 둘지는 취향이 아니라 그 값의 성질이 정한다"

지난 시간에 Context를 배우면서 값을 나르는 길을 뚫었는데, Context는 어떻게 나를지만 답해줄 뿐 무엇을 어디에 둘지는 아직 아무도 안 정해줬어요.

오늘 그 기준을 세우는데, 기준이 서면 지난 시간에 못 닫고 넘긴 문제 하나가 저절로 풀립니다.

🎯 학습 목표

  • Provider 없이 쓰는 store를 만들고, 커스텀 훅과 무엇이 다른지 직접 재봅니다.
  • 컴포넌트가 아닌 곳에서 상태를 읽고 쓰는 방법과, Context로는 왜 그게 안 되는지 확인합니다.
  • selector로 필요한 조각만 구독하고, 다시 그려지는 횟수를 세어봅니다.
  • 조각을 묶어서 고를 때 화면이 멈추는 현상을 재현하고 해결합니다.
  • 오프닝에서 본 네 단계 배달을 새 도구가 아니라 이미 배운 것으로 없앱니다.
  • 값이 놓일 세 곳을 표로 정리하고, 무엇을 어디에 둘지 정하는 규칙을 세웁니다.
텍스트
 오늘의 여정

 1. 먼저 물어보고 지운다        확인 상자를 만든다
        
 2. store 가 곧 훅이다          감쌀 것이 없다
        
 3. 컴포넌트 밖에서             getState 와 subscribe
        
 4. 조각만 골라 구독            다시 그려지는 횟수를 센다
        
 5. 묶으면 멈춘다               useShallow
        
 6. 네 단계 배달을 없앤다        읽는 쪽이 직접 부른다
        
 7. 두 상자가 갈린다            무엇이 기준인가
        
 8. 표를 완성한다               세 갈래와 결정 규칙

Step 1: "먼저 물어보고 지운다"

새 도구 이야기는 잠시 미루고, 기능부터 하나 만들겠습니다.

지금 우리 앱에서 댓글 옆의 X를 누르면 어떻게 되죠? 그냥 지워집니다. 확인도 없고 되돌리기도 없어서, 손이 미끄러지면 그걸로 끝나요.

실제 서비스라면 이럴 때 한 번 물어봅니다. "정말 지울까요?" 하고요. 그 상자를 만들어 봅시다.

어디에 만들까요

지우는 버튼이 CommentList 안에 있으니 상자도 거기에 두는 게 자연스럽겠죠. 어느 댓글을 물어보는 중인지만 기억하면 됩니다.

tsx
// apps/web-spa/src/components/CommentList.tsx
export function CommentList({ comments, onRemove }: CommentListProps) {
  // 지우기 직전에 한 번 물어본다. 어느 댓글을 물어보는 중인지만 기억하면 된다.
  const [pendingId, setPendingId] = useState<number | null>(null);

  function confirmRemove() {
    if (pendingId !== null) {
      onRemove?.(pendingId);
    }

    setPendingId(null);
  }

X 버튼은 이제 지우지 않고, 번호만 기억해 둡니다.

tsx
<IconButton
  className="cursor-pointer p-1 text-sm leading-none text-faint hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
  aria-label="댓글 삭제"
  onClick={() => setPendingId(comment.id)}
>
  <X className="size-4" />
</IconButton>

목록 아래에는 상자를 다는데, E-6에서 들여온 그 Dialog 그대로예요.

tsx
<Dialog open={pendingId !== null} onOpenChange={(open) => !open && setPendingId(null)}>
  <DialogContent showCloseButton={false}>
    <DialogTitle>댓글을 지울까요?</DialogTitle>
    <DialogDescription>지운 댓글은 되돌릴 수 없습니다.</DialogDescription>
    <DialogFooter>
      <Button variant="outline" onClick={() => setPendingId(null)}>
        취소
      </Button>
      <Button onClick={confirmRemove}>지우기</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

showCloseButton={false}를 준 데는 이유가 있는데, 오른쪽 위 X로도 닫을 수 있게 두면 "지울까요?"를 묻는 상자에 닫는 방법이 셋이 되기 때문이에요. 취소·X·바깥 누르기. 물어보는 상자는 답이 둘이어야 명확해요.

그런데 이게 잘 돌아갑니다

여기서 여러분이 예상하실 만한 게 하나 있어요. 카드가 열 장이면 CommentList도 열 개고, 그럼 확인 상자도 열 개 아닌가요?

렌더 트리 기준으로는 맞습니다. useState가 목록마다 한 벌씩 생기는데, 화면에서는 그게 문제로 안 나타나는 이유가 두 가지 있어요.

첫째, 닫혀 있는 상자는 화면에 아예 안 그려집니다. 목록 두 개를 나란히 두고 아무것도 안 눌렀을 때 상자 관련 요소를 세어보면 0개인데, 이 상자는 열릴 때 비로소 만들어지기 때문이에요.

둘째, 한 상자가 열려 있을 때 다른 카드의 X를 눌러도 상자가 둘이 되지 않고, 첫 상자가 닫히면서 두 번째가 열려요. 눌린 것이 무시된 게 아니라, 정말로 두 번째 요청이 처리되고 첫 번째만 정리된 겁니다.

텍스트
 카드 A 의 X 누름     A 의 상자 열림
 카드 B 의 X 누름     A 의 상자 닫힘 + B 의 상자 열림
                       화면에 있는 상자 개수는 내내 1

기능은 멀쩡한데, 그럼 오늘 배울 게 없는 걸까요?

진짜 문제는 다른 곳에 있습니다

지울 수 있는 게 댓글뿐인가요? 게시물도 지울 수 있어야 하고, 나중에는 계정 삭제도 있을 텐데, 팔로우 취소에도 물어보고 싶을 수 있어요.

그때마다 이 상자를 또 만들어야 하는데, 제목만 다르고 나머지는 똑같은 코드를 네 곳 다섯 곳에 복사하게 되고, 어느 날 "취소를 왼쪽에 두자"는 결정이 나면 네 곳을 다 고쳐야 합니다.

확인 상자는 앱에 하나만 있으면 되는 종류의 화면입니다. 알림 배너나 로딩 표시처럼요.

앱 꼭대기에 상자 하나만 두면 모두가 함께 쓸 수 있는데, 그러려면 어떻게 해야 하죠?

B-2에서 배운 방법이 있는데, 바로 상태를 위로 올리는 것이에요. pendingId를 앱 꼭대기로 올리고, 아래로 내려보냅니다.

텍스트
 App           pendingId 를 들고 있다
     │
      넘기기
 HomePage
     │
      넘기기
 Feed
     │
      넘기기
 PostCard
     │
      넘기기
 CommentList   여기서 setPendingId 를 부른다

이 그림, 어디서 보셨죠. 오프닝에서 본 그 배달인데, 지난 시간 시작할 때 봤던 것과 똑같아요.

지난 시간에 배운 Context로 풀 수도 있고 실제로 되긴 하지만, 감싸는 Provider가 하나 더 늘고 값을 들고 있을 컴포넌트도 하나 더 필요해요.

오늘은 감싸지도 않고 올리지도 않는 세 번째 길로 갑니다.

💡 한 줄 정리

지역 상태로 만든 확인 상자는 잘 동작합니다. 문제는 그것이 앱에 하나만 있어야 하는 화면이라는 것이고, 하나로 만들려면 배달이 생긴다는 것입니다.

🙋 학생 질문 — "튜터님, 브라우저에 있는 confirm()을 쓰면 안 되나요?"

한 줄이면 끝나긴 합니다.

JavaScript
if (confirm('댓글을 지울까요?')) {
  onRemove(comment.id);
}

실무에서 안 쓰는 이유가 몇 가지 있습니다.

먼저 생김새를 우리가 못 정하는데, 브라우저가 그리는 상자라 우리 색도 글꼴도 안 먹어요. 크롬과 사파리가 서로 다르게 생겼고, 주소가 함께 표시돼서 갑자기 우리 앱이 아닌 것처럼 보입니다.

자바스크립트를 통째로 멈추기도 합니다. 사용자가 답할 때까지 다른 코드가 한 줄도 안 돌고, 애니메이션도 멈추고 서버에서 오던 응답도 못 받아요.

마지막으로 요즘 브라우저는 이걸 점점 막고 있는데, 화면 안에 끼워 넣은 다른 사이트에서는 아예 안 뜨고, 여러 번 부르면 "이 사이트가 더 이상 대화 상자를 못 만들게" 하는 확인이 뜹니다.

배우는 입장에서 더 중요한 이유도 있어요. confirm()을 쓰면 오늘 배울 것을 하나도 못 배우는데, 상태를 어디에 둘지 고민할 일 자체가 없어지거든요.


Step 2: "store를 만들면 그게 곧 훅이 된다"

새 도구를 설치하겠습니다.

Bash
npm i zustand -w web-spa

이름은 독일어로 "상태"라는 뜻인데, 읽을 때는 보통 "주스탠드"라고 해요.

설치하고 나서 한 가지 확인해 볼 게 있는데, 이 꾸러미가 무엇을 함께 끌고 왔는지요.

텍스트
 zustand 5.0.15
   dependencies : 없음 (0 개)

함께 딸려오는 것이 하나도 없습니다. E-5에서 Shadcn을 설치했을 때 여덟 개가 함께 깔렸던 것과 대조되죠. 최소화하고 압축했을 때 핵심 기능이 627바이트라, 사진 한 장보다 훨씬 작습니다.

상자를 하나 만듭니다

stores라는 폴더를 새로 만들고 그 안에 둡니다.

TypeScript
// apps/web-spa/src/stores/useConfirmStore.ts
import { create } from 'zustand';

// 지금 무엇을 물어보는 중인지. 물어볼 말과, 확인했을 때 할 일이 늘 함께 다닌다.
export interface ConfirmRequest {
  message: string;
  onConfirm: () => void;
}

interface ConfirmState {
  request: ConfirmRequest | null;
  ask: (message: string, onConfirm: () => void) => void;
  confirm: () => void;
  close: () => void;
}

여기까지는 그냥 타입 선언인데, A-1에서 배운 유니온으로 null이 될 수 있다는 것을 적어뒀어요. 물어보는 중이 아니면 requestnull이고, confirm 안의 get().request?.onConfirm()이 그 null을 안전하게 지나갑니다.

이제 상자를 만듭니다.

TypeScript
// apps/web-spa/src/stores/useConfirmStore.ts

// create 를 두 번 부르는 것처럼 보이는 이 모양(create<T>()(...))은 실수가 아니다.
// 타입은 우리가 적고, 함수 안의 set·get 타입은 TypeScript 가 알아내게 나눠 부른다.
export const useConfirmStore = create<ConfirmState>()((set, get) => ({
  request: null,

  ask: (message, onConfirm) => set({ request: { message, onConfirm } }),

  // 확인한 다음에 닫는다. 순서가 바뀌면 이미 지워진 요청을 부르게 된다.
  confirm: () => {
    get().request?.onConfirm();
    set({ request: null });
  },

  close: () => set({ request: null }),
}));

읽어보면 어렵지 않은데, 값 하나와 그 값을 바꾸는 함수 셋이 한 객체에 들어 있어요. set은 값을 바꾸고 get은 지금 값을 읽습니다.

주목할 것은 함수도 이 안에 함께 들어간다는 점입니다. Context를 쓸 때는 값과 그 값을 바꾸는 함수를 따로 신경 써야 했는데, 여기서는 둘이 한 덩어리로 삽니다.

이제 상자를 화면에 그립니다

앱 전체에 하나만 있으면 되니까 지난 시간에 만든 AppProviders 옆에 둡니다.

tsx
// apps/web-spa/src/components/ConfirmDialog.tsx
import { useConfirmStore } from '../stores/useConfirmStore';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from './ui/dialog';

// 앱에 하나만 있는 확인 상자.
// 누가 물어보라고 했는지는 몰라도 된다 — store 에 적힌 것만 그린다.
export function ConfirmDialog() {
  const { request, confirm, close } = useConfirmStore();

  return (
    <Dialog open={request !== null} onOpenChange={(open) => !open && close()}>
      <DialogContent showCloseButton={false}>
        <DialogTitle>{request?.message}</DialogTitle>
        <DialogDescription>이 작업은 되돌릴 수 없습니다.</DialogDescription>
        <DialogFooter>
          <Button variant="outline" onClick={close}>
            취소
          </Button>
          <Button onClick={confirm}>지우기</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

useConfirmStore()를 그냥 부르니까 값이 나오고, 인자도 없고 문맥도 없어요.

지우는 쪽은 이렇게 바뀝니다.

tsx
// apps/web-spa/src/components/CommentList.tsx
export function CommentList({ comments, onRemove }: CommentListProps) {
  // 물어보라고 부탁만 한다. 상자를 어디에 어떻게 그리는지는 이 컴포넌트가 모른다.
  const { ask } = useConfirmStore();
tsx
onClick={() => ask('댓글을 지울까요?', () => onRemove(comment.id))}

CommentList에서 useState도 사라지고 Dialog도 사라졌습니다. 남은 것은 "이 말을 물어보고, 확인하면 이걸 해 주세요" 한 줄뿐이에요.

감싼 것이 하나도 없습니다

AppProviders를 보겠습니다.

tsx
// apps/web-spa/src/AppProviders.tsx
export function AppProviders({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider>
      <ThemeColorMeta />
      {children}
      {/* 확인 상자는 감쌀 것이 없다. 앱에 한 번 그려두기만 하면 된다. */}
      <ConfirmDialog />
    </ThemeProvider>
  );
}

ThemeProvider는 지난 시간 그대로 하나뿐인데, 새로 감싼 Provider가 0 개예요.

여기서 정확히 짚고 갈 게 있습니다. 아무것도 안 늘어난 건 아니고, <ConfirmDialog /> 한 줄이 늘었는데, 상자를 화면에 그리려면 어딘가에는 그려야 하니까요.

늘지 않은 것은 감싸는 것입니다. Context 였다면 <ConfirmProvider>로 앱을 한 겹 더 싸야 했고, 그 안쪽에 있는 컴포넌트만 값을 읽을 수 있었을 거예요.

위아래 관계가 사라졌습니다

이게 얼마나 다른지 확인해 보려고, 확인 상자를 <section> 안에 두고, 댓글 목록을 그것과 나란한 <article> 안에 두고, 상자를 먼저 그려봤어요.

텍스트
 <section>   상자가 여기 있고
   ConfirmDialog
 </section>
 <article>   물어보는 쪽은 여기 있다. 위도 아래도 아니다
   CommentList
 </article>

동작합니다. 그런데 Context 라면 이 배치는 불가능합니다. Provider가 위에 있어야만 아래에서 읽을 수 있으니까요.

그리고 지난 시간의 그 실험을 다시 해봤습니다

C-3 Step 1 기억하시죠. 같은 커스텀 훅을 두 곳에서 부르면 useState가 두 벌 생겨서, 한쪽에서 바꿔도 다른 쪽은 모른다고 했습니다.

똑같은 판을 만들어서 나란히 재봤어요. 왼쪽은 useState를 쓰는 커스텀 훅, 오른쪽은 오늘 만든 store. 양쪽 다 "쓰는 컴포넌트"와 "읽는 컴포넌트"를 따로 뒀습니다.

쓰는 쪽에서 값을 바꾼 뒤 읽는 쪽에 보이는 것
커스텀 훅 (useState) 바뀜 ❌ 그대로 (없음)
store 훅 바뀜 ✅ 바뀐 값

같은 훅을 두 번 불렀는데 결과가 정반대입니다. 커스텀 훅은 부를 때마다 안에서 useState를 새로 만들지만, store 훅은 이미 만들어져 있는 하나를 가리키기만 하기 때문이에요.

이 차이가 create를 파일 맨 위에서 한 번 부른 것에서 나옵니다. 컴포넌트 안이 아니라 모듈이 처음 읽힐 때 딱 한 번 만들어져요.

💡 한 줄 정리

create로 만든 store는 그 자체가 훅입니다. 감쌀 Provider도 없고 위아래 관계도 없어서, 어디서 불러도 같은 값 하나를 가리킵니다.

🙋 학생 질문 — "튜터님, create&lt;ConfirmState&gt;()(...)에서 괄호가 왜 두 번인가요?"

검색해 보면 "타입을 쓸 때는 반드시 이렇게 나눠 불러야 한다"는 설명이 많이 나오길래, 저도 직접 확인해 봤어요. 네 가지 모양을 만들어서 타입 검사를 돌려봤습니다.

쓴 모양 결과
create<T>()((set) => ...) ✅ 통과
create<T>((set) => ...) ✅ 통과
create<T>()(persist((set) => ..., { ... })) ✅ 통과
create<T>(persist((set) => ..., { ... })) ❌ 오류

우리 코드처럼 단순한 store는 둘 다 됩니다. 그런데 갈리는 것은 마지막 줄, persist 같은 부가 기능을 얹었을 때예요.

persist는 store 값을 브라우저에 저장해 주는 도구입니다. 이런 것을 끼우면 store의 타입에 표시가 하나 더 붙는데, 한 번에 부르면 TypeScript가 그 표시를 우리가 적은 타입과 맞춰보다가 어긋난다고 판단합니다. 나눠 부르면 순서가 달라져서 그 문제를 피하는데, 정확한 설명은 이렇습니다. 괄호를 두 번 쓰는 것은 부가 기능을 얹을 때 필요하고, 지금은 습관으로 미리 맞춰두는 것입니다. 나중에 저장 기능을 붙일 때 이 줄을 안 고쳐도 되도록요.


Step 3: "컴포넌트가 아닌 곳에서 값을 건드린다"

방금 만든 것에 문제가 하나 숨어 있는데, 직접 확인해 보겠습니다.

댓글에 확인 상자를 띄운 상태에서, 브라우저 뒤로 가기를 눌러 다른 화면으로 갑니다. 무슨 일이 일어날까요?

상자가 따라옵니다. 탐색 화면으로 넘어갔는데 "댓글을 지울까요?"가 그대로 떠 있어요. 확인을 누르면 이제 화면에 보이지도 않는 댓글이 지워집니다.

왜 이렇게 됐는지는 지난 시간에 우리가 한 선택 때문입니다

지난 시간 Step 5를 떠올려 보세요. 밝기 상태를 Layout 안에 뒀더니 오류 화면에서 통째로 사라져서, AppProviders로 올려 라우터 바깥에 뒀죠.

확인 상자도 거기에 두었더니 화면이 바뀌어도 안 사라집니다. 그런데 오류 화면에서도 살아남게 하려던 성질이, 여기서는 원하지 않는 방향으로 나타난 거예요.

텍스트
 AppProviders           확인 상자가 여기 있다
   RouterProvider       화면이 바뀌는 것은 이 안쪽 일
     Layout
       HomePage / ExplorePage / ...

바깥에 있으니 안쪽이 통째로 바뀌어도 모릅니다.

고치려면 화면이 바뀔 때 닫아줘야 합니다

그럼 그 일을 누가 하죠? 화면이 바뀌는 것을 아는 건 라우터인데, 그 라우터도 우리가 React 바깥에서 만들었어요.

tsx
// apps/web-spa/src/main.tsx
const router = createBrowserRouter(routes);

라우터에는 "상태가 바뀌면 알려줘"라고 부탁하는 방법이 있는데, 바로 subscribe예요.

TypeScript
// apps/web-spa/src/lib/closeConfirmOnNavigate.ts
import type { DataRouter } from 'react-router';
import { useConfirmStore } from '../stores/useConfirmStore';

export function closeConfirmOnNavigate(router: DataRouter) {
  // 라우터는 주소 말고도 여러 이유로 알림을 준다. 주소가 실제로 바뀐 때만 닫는다.
  let lastKey = router.state.location.key;

  return router.subscribe((state) => {
    if (state.location.key === lastKey) {
      return;
    }

    lastKey = state.location.key;
    useConfirmStore.getState().close();
  });
}

이 파일을 다시 보세요. 컴포넌트가 하나도 없습니다. JSX도 없고 훅도 안 불렀고, 그냥 함수인데도 useConfirmStore.getState().close()로 상태를 바꾸고 있는데, 이름이 use로 시작하는 것을 컴포넌트 밖에서 부른 거예요.

TypeScript
// apps/web-spa/src/main.tsx
const router = createBrowserRouter(routes);

// 라우터도 store 도 React 바깥에 있다. 둘을 잇는 이 줄도 컴포넌트가 아니다.
closeConfirmOnNavigate(router);

useConfirmStore는 훅이면서 동시에 객체입니다

어떻게 이게 되는 걸까요. 꾸러미 안을 열어보면 답이 세 줄로 나옵니다.

JavaScript
const createImpl = (createState) => {
  const api = createStore(createState);
  const useBoundStore = (selector) => useStore(api, selector);
  Object.assign(useBoundStore, api);
  return useBoundStore;
};

useBoundStore는 함수라서 컴포넌트 안에서 부르면 훅으로 동작하는데, 그 함수 위에 getState·setState·subscribe를 속성으로 붙여놨습니다.

자바스크립트에서 함수는 객체이기도 하니까 이게 가능합니다.

텍스트
 useConfirmStore             부르면 훅. 컴포넌트 안에서만.
 useConfirmStore.getState    속성. 어디서든.
 useConfirmStore.setState    속성. 어디서든.
 useConfirmStore.subscribe   속성. 어디서든.

subscribe도 같은 방식으로 씁니다. 값이 바뀔 때마다 우리가 준 함수를 불러주고, 돌려받은 함수를 부르면 구독이 끊깁니다.

Context로는 이게 안 됩니다

같은 것을 지난 시간에 만든 Context로 해보면 어떻게 될까요. 컴포넌트가 아닌 곳에서 useThemeContext()를 불러봤습니다.

텍스트
 Cannot read properties of null (reading 'useContext')

우리가 만들어 둔 "ThemeProvider 안에서 불러야 합니다"라는 안내가 안 나오고, 그 검사에 닿기도 전에 막혀요.

이 메시지가 왜 이렇게 생겼냐면, 훅은 React가 지금 어떤 컴포넌트를 그리는 중인지 알고 있어야 동작하기 때문입니다. 컴포넌트 밖에서는 그 정보가 null이라, null.useContext를 읽으려다 터진 겁니다.

⚠️ 이 메시지는 원인을 거의 안 알려줍니다. 실무에서 이 문장을 만나면 "훅을 컴포넌트 밖에서 불렀구나"로 바로 연결하실 수 있게 기억해 두세요.

💡 한 줄 정리

store는 훅이면서 동시에 getState·setState·subscribe를 가진 객체라서, 라우터 알림처럼 React 바깥에서 벌어지는 일과도 이어붙일 수 있습니다.

🙋 학생 질문 — "튜터님, location.key를 왜 비교하나요? 그냥 알림이 오면 닫으면 안 되나요?"

안 됩니다. 라우터가 알림을 주는 이유가 주소 변경 하나가 아니거든요.

라우터는 데이터를 다시 불러올 때도, 폼 제출이 진행 중일 때도, 화면 전환이 시작되고 끝날 때도 알려줍니다. 그때마다 상자를 닫으면 사용자가 답하기도 전에 상자가 사라질 수 있어요.

location.key는 라우터가 방문마다 붙여주는 고유한 표시라서, 같은 주소로 두 번 들어와도 방문이 다르면 값이 달라지고, 이 값이 바뀐 때가 "정말로 화면을 옮긴 때"입니다.

확인해 보려고 이 비교를 일부러 빼봤더니, 주소는 그대로 두고 데이터만 다시 불러오는 경우에도 상자가 닫혔어요. 세 줄짜리 조건 하나가 그걸 막고 있는 겁니다.


Step 4: "필요한 조각만 골라 구독한다"

지금 ConfirmDialogCommentList는 이렇게 store를 받고 있습니다.

tsx
const { request, confirm, close } = useConfirmStore();
tsx
const { ask } = useConfirmStore();

둘 다 store 전체를 받아옵니다. CommentListask 하나만 쓰는데도요.

이게 왜 문제인지 재보려고, 두 컴포넌트를 만들어서 하나는 통째로 받고 하나는 조각만 받게 했어요. 둘 다 ask만 쓰게 하고, 다시 그려지는 횟수를 셌습니다.

시점 통째로 받는 쪽 조각만 받는 쪽
처음 그려질 때 1 1
확인 상자가 열림 2 1
확인 상자가 닫힘 3 1
열고 닫기 두 번 더 7 1

통째로 받는 쪽은 누군가 무엇을 물어볼 때마다 다시 그려집니다. 자기와 상관없는 일인데도요. 카드가 열 장이면 확인 상자를 한 번 열 때마다 목록 열 개가 전부 다시 그려집니다.

조각만 고르는 방법

useConfirmStore에 함수를 하나 넘기면 됩니다. "store 전체 중에 이것만 주세요" 라고요.

tsx
// apps/web-spa/src/components/CommentList.tsx
  // 통째로 받지 않고 ask 한 조각만 고른다. 이 조각은 만들어진 뒤로 안 바뀌니까,
  // 다른 사람이 무엇을 물어보든 이 목록은 다시 그려지지 않는다.
  const ask = useConfirmStore((state) => state.ask);

이 함수를 selector라고 부르는데, 우리말로 하면 고르는 함수예요.

동작 원리는 단순한데, store 값이 바뀔 때마다 이 함수를 다시 돌려보고 나온 값이 이전과 같으면 다시 안 그립니다. ask는 store를 만들 때 한 번 만들어지고 그 뒤로 안 바뀌니까, 몇 번을 확인해도 늘 같은 값이에요.

ConfirmDialog도 같이 바꿉니다.

tsx
// apps/web-spa/src/components/ConfirmDialog.tsx
  // 여기는 request 가 바뀌면 반드시 다시 그려져야 한다. 그래서 그것만 고른다.
  // 나머지 둘은 안 바뀌는 함수라 골라 담아도 다시 그리는 이유가 되지 않는다.
  const request = useConfirmStore((state) => state.request);
  const confirm = useConfirmStore((state) => state.confirm);
  const close = useConfirmStore((state) => state.close);

여기는 세 줄이 됐는데, 줄 수만 보면 늘어난 것 같아도 이 컴포넌트가 무엇 때문에 다시 그려지는지가 코드에 그대로 보입니다. request 하나예요.

이번엔 컴파일러가 안 구해줍니다

C-1부터 지난 시간까지, 널리 퍼진 이야기가 실제와 다른 경우를 여러 번 만났습니다. 지난 시간에는 "Context 값을 객체로 주면 리렌더가 폭발한다"는 이야기가 React Compiler가 켜진 우리 판에서 재현되지 않았는데, 이번에도 컴파일러를 잠시 끄고 다시 재보면 위 표의 숫자가 달라질까요?

하나도 안 달라집니다. 컴파일러를 켜든 끄든 통째로 받는 쪽은 1·2·3·5로 늘고, 조각만 받는 쪽은 계속 1입니다.

이유가 있습니다. 컴파일러가 하는 일은 컴포넌트 안에서 계산한 결과를 기억해 두는 것인데, 지금 다시 그리는 이유는 계산이 아니라 구독이라서 컴파일러가 끼어들 곳이 아닙니다.

🌟 그래서 selector는 컴파일러 시대에도 여전히 우리가 직접 챙겨야 하는 것입니다. C-8에서 배울 useMemo 같은 도구는 컴파일러에게 넘겼지만, 이건 다릅니다.

💡 한 줄 정리

selector는 "store에서 무엇을 볼지"를 정하고, 그것이 곧 언제 다시 그려질지를 정합니다. 안 보는 값이 바뀌는 것은 나와 상관없는 일이 됩니다.

🙋 학생 질문 — "튜터님, 그럼 항상 selector를 써야 하나요? 통째로 받는 건 언제 쓰죠?"

거의 항상 selector를 쓰는 게 맞고, 통째로 받는 게 나은 경우는 별로 없어요.

다만 언제 실제로 손해가 되는지는 구분하시면 좋겠는데, 통째로 받아서 손해가 나려면 두 조건이 함께 맞아야 해요.

첫째, 내가 안 쓰는 값이 store에 있어야 합니다. store에 값이 하나뿐이고 내가 그걸 쓴다면 통째로 받든 골라 받든 결과가 같아요.

둘째, 그 값이 실제로 자주 바뀌어야 하는데, 안 바뀌면 다시 그릴 일도 없습니다.

우리 ConfirmDialog는 어차피 request가 바뀌면 다시 그려져야 하는 컴포넌트라, 사실 selector로 바꿔도 그려지는 횟수가 안 줄어듭니다. 그래도 바꾼 이유는 코드를 읽는 사람에게 이 컴포넌트가 무엇을 보는지 알려주기 위해서예요.

CommentList 쪽은 이야기가 다른데, 거기는 실제로 횟수가 줄어듭니다.


Step 5: "묶어서 고르면 화면이 멈춘다"

ConfirmDialog에서 selector 세 줄이 나란히 있는 걸 보면 이런 생각이 듭니다. 한 번에 고르면 안 되나?

tsx
const { message, isOpen } = useConfirmStore((state) => ({
  message: state.request?.message ?? '(없음)',
  isOpen: state.request !== null,
}));

읽기 좋아 보이지만, 이걸 그리면 화면이 안 뜨고 대신 이런 메시지가 나옵니다.

텍스트
 Maximum update depth exceeded.

그 앞에는 경고가 하나 더 찍혀 있어요.

텍스트
 The result of getSnapshot should be cached to avoid an infinite loop

무슨 일이 일어난 건지

방금 selector가 어떻게 동작하는지 봤죠. store 값이 바뀔 때마다 selector를 다시 돌려보고, 나온 값이 이전과 같으면 다시 안 그립니다.

여기서 "같다"를 어떻게 판단할까요. 자바스크립트 기본 비교를 쓰는데, 객체는 안에 든 내용이 같아도 새로 만들면 다른 것으로 칩니다.

JavaScript
{ a: 1 } === { a: 1 }   // false

우리 selector는 부를 때마다 { message, isOpen }새로 만드는데, 내용은 같아도 매번 다른 것으로 판정돼요.

텍스트
 그린다  selector 를 부른다  새 객체
         "이전과 다르네"  다시 그린다
         selector 를 부른다  또 새 객체
         "이전과 다르네"  다시 그린다
         ... (React 가 한도를 넘었다고 판단하고 멈춘다)

React가 이걸 막아주는 게 다행인데, 안 막았으면 브라우저가 멈췄을 거예요.

갈리는 기준은 개수가 아닙니다

여기서 오해하기 쉬운 게 있는데, "값을 여러 개 고르면 안 된다"로 외우면 틀립니다. 네 가지를 만들어서 재봤습니다.

selector가 돌려주는 것 결과
{ message, isOpen } 객체로 묶기 ❌ 멈춤
[message, isOpen] 배열로 묶기 ❌ 멈춤
request === null ? '닫힘' : '열림: ...' 계산한 글자 하나 ✅ 정상
state.request 조각 하나 ✅ 정상

배열도 멈추는데, 이는 개수가 문제가 아니라는 뜻이에요.

세 번째 줄을 보면 저건 매번 새로 계산하는데, 그런데도 괜찮은 이유는 나오는 것이 글자이기 때문입니다. 글자는 내용이 같으면 같은 값으로 칩니다.

JavaScript
'닫힘' === '닫힘'       // true
{ a: 1 } === { a: 1 }  // false

진짜 기준은 이겁니다. selector가 돌려주는 것이 매번 새로 만들어지는 물건이면 멈춥니다.

그래도 묶어야 한다면

묶는 게 정말 나은 경우가 있는데, 그럴 때 쓰라고 도구가 하나 딸려 와요.

tsx
// apps/web-spa/src/components/ConfirmBundleDemo.tsx
import { useShallow } from 'zustand/react/shallow';

// (b) 같은 것을 useShallow 로 감싼다. 안쪽 값이 그대로면 앞서 만든 객체를 다시 준다.
export function ShallowReader() {
  const { message, isOpen } = useConfirmStore(
    useShallow((state) => ({
      message: state.request?.message ?? '(없음)',
      isOpen: state.request !== null,
    })),
  );

  return <p>{`(b) ${isOpen ? '열림' : '닫힘'} / ${message}`}</p>;
}

useShallow로 감싸면 정상 동작하고, 값이 바뀌면 화면도 제대로 따라와요.

이름의 "shallow"는 얕다는 뜻인데, 새로 만든 객체와 이전 객체를 한 겹만 열어서 비교해요. message 끼리 같고 isOpen 끼리 같으면 "같다" 고 보고 이전 객체를 그대로 돌려주기 때문에, 다시 그릴 이유가 없어집니다.

우리 코드는 이걸 안 씁니다

ConfirmDialog를 다시 보시면 useShallow가 없는데, 세 줄로 따로 골랐기 때문이에요.

묶을 필요가 없는데 묶고, 묶었으니 감싸는 것보다는 처음부터 안 묶는 게 낫습니다. 세 줄이 한 줄보다 길지만 도구도 하나 덜 쓰고 읽기도 더 쉬워요.

🌟 useShallow는 기본값이 아니라 묶어야만 할 때의 도구입니다. 이 순서로 기억해 두세요. 조각으로 따로 고른다 → 정말 묶어야 하면 그때 감싼다.

💡 한 줄 정리

selector가 매번 새 객체나 새 배열을 돌려주면 화면이 멈춥니다. 조각을 따로 고르면 애초에 생기지 않는 문제이고, 꼭 묶어야 할 때만 useShallow로 감쌉니다.

🙋 학생 질문 — "튜터님, 예전 자료에서는 이렇게 묶어 쓰던데요?"

맞습니다. 이 도구의 이전 버전(v4)에서는 묶어도 괜찮았는데, 비교하는 방법을 우리가 정할 수 있었거든요.

버전 5로 오면서 그 기능이 빠지고, 대신 React가 기본으로 제공하는 구독 방식을 그대로 쓰게 됐어요. 꾸러미 크기가 줄어드는 이득이 있었지만, 비교는 이제 React 규칙을 따르고, 그 규칙이 "새로 만든 것은 다른 것"이에요.

그래서 검색해서 나온 예제를 그대로 베끼면 화면이 안 뜨는 일이 생기는데, 이 오류는 컴퓨터가 미리 안 잡아줍니다. 타입 검사도 통과하고 빌드도 성공하니, 화면을 열어봐야 압니다.

우리 코드베이스에는 이 네 가지 경우를 나란히 둔 파일을 남겨뒀습니다. 직접 하나씩 그려보시면 무엇이 갈리는지 눈으로 확인하실 수 있어요.


Step 6: "네 단계 배달을 없앤다"

이제 지난 시간에 못 닫고 넘긴 문제로 갑니다.

지난 시간 오프닝에서 본 그림을 다시 보겠습니다.

텍스트
 HomePage      주소에서 읽는다        openPostId, onOpenPost
     │
      넘기기만
 Feed          안 쓴다                modalOpen, onModalOpenChange
     │
      넘기기만
 PostCard      안 쓴다                modalOpen, onModalOpenChange
     │
      넘기기만
 PostBody      안 쓴다                open, onOpenChange
     │
     
 PostModal     드디어 쓴다

값 하나가 넷을 거치고 그중 셋은 안 쓰는데, 이걸 오늘 없앱니다.

방금 배운 store로 옮기면 될까요? 그러면 안 되는데, 그 이유는 다음 Step에서 정면으로 다루고요.

이 값이 원래 어디서 왔는지 보세요

HomePage의 맨 윗줄을 보겠습니다.

tsx
// apps/web-spa/src/routes/HomePage.tsx (걷어내기 전)
  const [searchParams, setSearchParams] = useSearchParams();
  const openPost = searchParams.get('post');

주소에서 읽었습니다. 주소는 앱 어디에서나 물어볼 수 있는데, useSearchParamsHomePage 전용 훅이 아니니까요.

그러니 배달할 이유가 없고, 받는 쪽이 직접 물어보면 됩니다.

tsx
// apps/web-spa/src/components/PostBody.tsx
  // 위에서 받아 넘기지 않고 여기서 직접 읽는다.
  // 이 값은 원래 주소에 적혀 있었고, 주소는 누구나 물어볼 수 있다.
  const [searchParams, setSearchParams] = useSearchParams();
  const openPostId = searchParams.get('post');

모달에 넘길 때는 자기 번호와 비교합니다.

tsx
<PostModal
  username={username}
  profileImageUrl={profileImageUrl}
  imageUrl={imageUrl}
  content={content}
  likeCount={likeCount}
  commentCount={commentCount}
  open={openPostId === String(id)}
  onOpenChange={(open) => setSearchParams(open ? { post: String(id) } : {})}
/>

Feed는 이렇게 줄었습니다.

tsx
// apps/web-spa/src/components/Feed.tsx
export function Feed({ posts, onToggleLike }: FeedProps) {
  return (
    <List
      items={posts}
      className="@2col:grid @2col:grid-cols-2 @2col:gap-6"
      aria-label="피드 목록"
      renderItem={(post) => (
        <PostCard {...post} onToggleLike={onToggleLike} />
      )}
    />
  );
}

모달 이야기가 한 글자도 없고, HomePage에서는 useSearchParams를 가져오는 줄 자체가 사라졌어요.

텍스트
 HomePage      모달을 모른다
     │
 Feed          모달을 모른다
     │
 PostCard      id 를 내려준다 (그 카드가 누구인지, 원래 있던 데이터)
     │
 PostBody      주소를 직접 읽는다  ─ 여기 한 곳
     │
 PostModal     받아서 연다 (예전 그대로)

없어진 props가 여덟 개인데, 새로 내려가는 것은 id 하나이고 그건 배달이 아니라 그 카드의 데이터예요. 원래도 PostCard가 들고 있던 값입니다.

왜 모달까지 안 내려갔을까요

궁금하실 수 있습니다. 이왕이면 PostModal이 직접 읽으면 PostBody도 깨끗해질 텐데요.

지난 시간에 우리가 한 약속 때문인데, C-2에서 모달을 만들 때 이렇게 정했어요. 모달 자체는 주소를 모른다. 여닫는 것을 밖에서 시켜줄 수도 있고, 아무것도 안 주면 혼자 열고 닫습니다.

그 덕에 PostModal은 라우터 없이도 혼자 동작하고, 다른 화면에서 다른 방식으로 쓸 수도 있어요.

모달이 주소를 직접 읽게 만들면 그 성질이 사라져서 라우터가 있는 곳에서만 살 수 있게 되기 때문에, 한 칸 위에서 멈췄습니다. 읽는 쪽을 아래로 내리는 건 좋지만, 마지막 하나까지 내리는 게 늘 옳은 건 아닙니다.

공짜가 아닙니다

이 리팩터링에는 대가가 있습니다. PostBody가 이제 라우터 없이는 못 삽니다.

PostBody는 카드 안에 있고 카드는 앱 곳곳에서 쓰이기 때문에, 카드를 그리는 모든 곳이 이제 라우터 안에 있어야 하고, 우리 코드베이스에서 그 영향을 받은 곳이 스무 곳 넘습니다.

⚠️ props를 없애는 대신 의존성이 생겼습니다. 배달을 줄이는 리팩터링은 늘 이 교환을 합니다. "이 컴포넌트를 라우터 밖에서 쓸 일이 있는가"를 먼저 물어보고 결정하세요.

우리는 카드가 늘 앱 안에서만 쓰이므로 괜찮다고 판단했습니다. 모달은 그렇지 않다고 판단해서 남겨뒀고요.

💡 한 줄 정리

주소에 적힌 값은 배달이 필요 없습니다. 읽는 쪽이 직접 물어보면 되고, 그 대신 그 컴포넌트는 라우터에 매이게 됩니다.

🙋 학생 질문 — "튜터님, 카드마다 useSearchParams를 부르면 열 장이면 열 번인데 괜찮나요?"

괜찮은데, 그 이유가 이 리팩터링을 이해하는 데 도움이 됩니다.

바꾸기 전에도 주소가 바뀌면 HomePage가 다시 그려지고, 그러면 Feed와 카드 전부가 다시 그려졌습니다. 지금은 카드마다 직접 듣고 있지만 결과는 같아요. 다시 그려지는 컴포넌트의 개수가 늘어난 게 아닙니다.

오히려 범위가 좁아진 면도 있습니다. 예전에는 HomePage부터 다시 그렸는데, 지금은 주소를 실제로 읽는 곳부터 다시 그립니다.

한 가지 덧붙이면, "훅을 여러 번 부르면 비싸다"는 감각은 대체로 맞지 않습니다. 훅은 값을 읽어오는 통로지 무거운 계산이 아니에요. 정말 비싼 것은 화면을 다시 그리는 일이고, 그건 Step 4에서 본 것처럼 무엇을 구독하느냐로 정해집니다.


Step 7: "같은 상자인데 하나는 주소에 담고 하나는 안 담는다"

오프닝의 질문으로 돌아왔습니다.

게시물 모달은 ?post=1로 주소에 담았고 삭제 확인은 store에 담았는데, 왜 다를까요?

말로 설명하기 전에 바꿔서 담아보겠습니다. 양쪽 다요.

삭제 확인을 주소에 담아보면

?confirm=3으로 담는 판을 만들어서 돌려봤습니다.

먼저 놀라운 것부터. 잘 됩니다. X를 누르면 상자가 뜨고, 지우기를 누르면 지워지고, 취소하면 안 지워지는데, 이 단계까지만 보면 아무 문제가 없습니다.

문제는 그다음입니다.

하나. 그 주소로 새로 들어오면 아무도 안 눌렀는데 상자가 떠 있습니다. 며칠 전 저장해 둔 링크를 열었더니 "3번 댓글을 지울까요?"가 화면 가운데 떠 있어요.

둘. 그 주소를 남에게 보내면 그 사람 화면에도 뜹니다. 그 사람이 무심코 지우기를 누르면 실제로 지워지는데, 링크 하나가 남의 댓글을 지우는 버튼이 된 거예요.

셋. 이게 가장 결정적인데, 무엇을 할지가 사라집니다.

우리 request 안에는 확인했을 때 할 일이 함수로 들어 있는데, 주소에는 글자만 담기기 때문에 이 값을 글자로 바꾸면 이렇게 됩니다.

JavaScript
// 살아 있는 값
{ message: '댓글을 지울까요?', onConfirm: () => onRemove(3) }

// 글자로 바꾸면
'{"message":"댓글을 지울까요?"}'

onConfirm이 조용히 사라지는데, 오류도 경고도 없이 그냥 없어집니다.

그래서 주소에 담으려면 "3번 댓글을 지운다"는 행동을 어떻게든 글자로 다시 표현하고, 주소를 받은 쪽에서 그 글자를 보고 행동을 다시 만들어내야 합니다. 할 수는 있지만, 그건 지금 없는 문제를 만들어서 푸는 거예요.

게시물 모달을 store에 담아보면

반대쪽도 해봤는데, 결과는 이렇습니다.

  • 열어도 주소가 안 바뀝니다.
  • 새로고침하면 닫혀 있습니다. 주소에 아무것도 안 적혔으니 되살릴 것이 없어요.
  • 링크를 보내면 상대는 그냥 피드를 봅니다.
  • 뒤로 가기를 누르면 모달이 닫히는 게 아니라 앱을 떠납니다.

C-2에서 애써 살려낸 네 가지가 전부 죽습니다.

그래서 기준이 무엇이냐면

두 상자를 나란히 놓고 보겠습니다.

게시물 모달 삭제 확인
담긴 값 게시물 번호 (1) 물어볼 말 + 할 일(함수)
글자로 왕복되나 String(1)'1'Number('1') ❌ 함수가 사라진다
남이 봐도 되나 ✅ 공개된 게시물이다 ❌ 남이 지우면 안 된다
새로고침 뒤 살아 있어야 하나 ✅ 보던 것을 계속 본다 ❌ 물어보던 것은 무효다
링크로 보낼 만한가 ✅ "이거 봐" ❌ "이거 지울까?"

세 개의 질문으로 줄일 수 있습니다.

텍스트
 1. 글자로 적었다가 그대로 되살릴 수 있는가
 2. 남이 그 주소를 열어도 괜찮은가
 3. 새로고침한 뒤에도 살아 있어야 하는가

 셋 다 그렇다    주소에 담는다
 하나라도 아니다   주소에 담지 않는다

게시물 모달은 셋 다 그런데, 삭제 확인은 셋 다 아니에요.

한 문장으로 줄이면

두 상자를 가르는 것은 이겁니다.

게시물 모달은 "무엇을 보고 있는가"이고, 삭제 확인은 "지금 무엇을 하는 중인가"입니다.

보고 있는 것은 남에게 보여줄 수 있고, 나중에 다시 찾아갈 수 있고, 주소로 표현됩니다. 그런데 하는 중인 것은 그 순간에만 의미가 있어서, 브라우저를 닫으면 없어지는 게 맞습니다.

이 구분은 모달에만 해당하는 게 아닙니다. 탭 선택·검색어·정렬 순서는 보고 있는 것이고, 드롭다운 열림·드래그 중·저장 중 표시는 하는 중인 것입니다.

💡 한 줄 정리

같은 모양의 화면이라도 담긴 값의 성질이 다르면 사는 곳이 달라야 합니다. 글자로 왕복되는가, 남이 봐도 되는가, 새로고침 뒤에 살아 있어야 하는가로 가릅니다.

🙋 학생 질문 — "튜터님, 그럼 삭제 확인 상자를 주소에 담는 서비스는 다 잘못 만든 건가요?"

아니요. 담아도 되는 경우가 있습니다.

무엇을 지울지가 이미 주소에 있는 경우예요. 게시물 상세 화면 /p/3에서 그 게시물을 지우는 확인 상자라면, /p/3?confirm=1처럼 담아도 셋 중 두 가지는 통과합니다. 지울 대상이 주소로 정해져 있으니 함수를 저장할 필요가 없거든요.

그래도 저는 안 담는 쪽을 권합니다. "남이 그 주소를 열어도 괜찮은가"가 여전히 걸리기 때문이에요. 링크를 받은 사람 화면에 삭제 확인이 떠 있는 것은 어느 경우에도 좋은 경험이 아닙니다.

기준을 통과하는 것과 그렇게 하는 게 좋은 것은 다릅니다. 세 질문은 담으면 안 되는 것을 걸러내는 그물이지, 통과했다고 반드시 담으라는 뜻은 아니에요.


Step 8: "표를 완성한다"

C-2에서 이런 그림을 그려두고 한 칸을 비워뒀습니다.

텍스트
 화면 안 상태     useState            이 컴포넌트만 아는 값 (캡션 펼침 여부)
 주소 안 상태     useSearchParams     링크로 보낼 수 있어야 하는 값 (고른 태그)
 서버가 가진 것    아직 안 배움          게시물·댓글처럼 남이 들고 있는 값

오늘 한 칸이 더 생겼으니, 네 갈래로 채워보겠습니다.

갈래 도구 어떤 값인가 우리 앱에서
화면 안 useState · useReducer 이 컴포넌트만 아는 값 캡션 펼침, 댓글 목록, 좋아요
주소 useSearchParams · nuqs 링크로 보낼 수 있어야 하는 값 ?post=1 모달, ?tag=한강 필터
클라이언트 전역 Zustand · Context 여러 곳이 함께 봐야 하는데 주소엔 못 담는 값 삭제 확인, 밝기
서버 다음 시간 남이 들고 있어서 낡을 수 있는 값 게시물, 댓글 (아직 코드 안에)

우리 앱의 지금 상태를 실제로 확인해 보면 이렇습니다.

  • 캡션을 펼쳐도 주소는 그대로입니다.
  • 태그를 고르면 주소에 ?tag=한강이 적힙니다.
  • 게시물 모달을 열면 ?post=1이 적힙니다.
  • 삭제 확인을 띄우면 주소는 그대로고 store에만 남습니다.
  • 새로고침하면 주소에 적힌 것만 되살아납니다.

정하는 순서

값 하나를 새로 만들 때 이 순서로 물어보세요.

텍스트
 1. 이 값을 쓰는 곳이 한 컴포넌트뿐인가?
        예  useState. 여기서 끝.
        아니오 
 2. 이 값이 서버에 있는 것인가? (게시물·댓글·프로필)
        예  서버 상태 도구 (다음 시간)
        아니오 
 3. 글자로 왕복되고, 남이 봐도 되고, 새로고침 뒤에 살아 있어야 하는가?
        예  주소
        아니오 
 4. 그제야 전역 store

🌟 순서가 중요합니다. 위에서 되는 것은 위에 둡니다. 전역 store는 마지막에 남는 것들을 담는 곳이지, 편해서 먼저 쓰는 도구가 아니에요.

이 순서를 뒤집으면 앱의 거의 모든 값이 전역 store로 몰리는데, 그러면 어느 값이 어디서 바뀌는지 아무도 추적할 수 없게 되고, 전역 상태 도구가 욕을 먹는 이유의 대부분이 이겁니다. 도구가 나쁜 게 아니라 순서를 뒤집어서 그렇습니다.

표에 있는데 아직 안 써본 이름 하나

세 번째 줄에 nuqs라는 이름을 적어뒀는데, 오늘 설치는 안 했어요.

이건 주소에 값을 담는 일을 useState처럼 만들어주는 꾸러미인데, 우리가 useSearchParams로 하고 있는 일을 좀 더 편하게 해줘요.

JavaScript
// 우리가 지금 하는 방식
const [searchParams, setSearchParams] = useSearchParams();
const tag = searchParams.get('tag');   // 늘 글자 아니면 null

// nuqs 를 쓰면
const [tag, setTag] = useQueryState('tag');

가장 큰 차이는 글자 말고 다른 타입을 다룰 수 있다는 것입니다. 주소에 담기는 것은 늘 글자라서 숫자를 담으면 꺼낼 때 Number()로 바꿔야 하죠. Step 6에서 우리도 openPostId === String(id)로 비교했었는데, nuqs는 그 변환을 대신 해줍니다.

이 과목에서는 안 쓰는데, useSearchParams 만으로도 충분하고 도구를 하나 더 얹으면 원리가 가려지거든요.

다만 실무에서 주소에 담을 값이 여러 개가 되면 이 이름을 떠올리시면 좋겠습니다.

💡 한 줄 정리

값이 사는 곳은 네 군데이고, 정하는 순서가 있습니다. 한 컴포넌트 → 서버 → 주소 → 전역 store. 전역은 마지막입니다.

🙋 학생 질문 — "튜터님, 그럼 지난 시간에 배운 Context는 언제 쓰나요? Zustand가 더 좋아 보이는데요."

셋째 줄에 둘이 함께 있는 게 이상하게 보이실 수 있는데, 갈리는 지점이 있습니다.

Context 가 나은 경우는 값이 여럿일 때입니다. 화면 한 덩어리 안에서만 쓰이는 값이라면 Context로 그 범위를 코드에 드러낼 수 있어요. 지난 시간에 AppProviders를 어디에 두느냐가 곧 밝기가 닿는 범위였던 것 기억하시는데, store에는 그런 범위가 없어서 만들면 앱 전체가 봅니다.

Context는 같은 종류의 값을 여러 벌 둘 수도 있는데, 같은 Context를 Provider 두 개로 감싸면 값이 둘이 돼요. 예를 들어 화면 안에 편집 영역이 두 개 있고 각각 자기 설정을 갖는다면 Context가 맞습니다.

store 가 나은 경우는 오늘 본 것들입니다. 앱에 하나뿐인 값이고, 감싸는 것 없이 어디서든 읽고 싶고, React 밖에서도 건드려야 할 때요.

실무에서는 둘을 함께 쓰는데, 우리 앱이 지금 그래요. 밝기는 Context에, 확인 상자는 store에 있는데, 하나를 고르고 나머지를 버리는 선택이 아닙니다.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 값마다 사는 곳을 정했습니다.

시작할 때 우리에게는 useStateuseSearchParams 두 도구가 있었어요. 지난 시간에 Context를 배웠지만 그것을 언제 써야 하는지는 몰랐는데, 지금은 네 갈래가 있고 무엇을 어디에 둘지 정하는 순서가 있습니다.

오늘 배운 핵심 세 가지

💡 하나 — store는 만들어 두는 것이지 감싸는 것이 아닙니다. create로 만들면 그것이 곧 훅이고, Provider도 위아래 관계도 없는데, 훅 위에 getState·subscribe가 얹혀 있어서 React 바깥의 라우터 알림과도 이어붙일 수 있어요.

💡 둘 — 무엇을 구독하느냐가 언제 다시 그려질지를 정합니다. selector로 조각만 고르면 안 보는 값이 바뀌는 것은 나와 상관없는 일이 됩니다. 대신 매번 새로 만든 객체를 돌려주면 화면이 멈추고, 이 오류는 실행해 봐야만 보입니다.

💡 셋 — 같은 모양의 화면도 담긴 값의 성질이 다르면 사는 곳이 다릅니다. 게시물 모달과 삭제 확인은 코드가 거의 같은데 한쪽만 주소에 담깁니다. 글자로 왕복되는가, 남이 봐도 되는가, 새로고침 뒤에 살아 있어야 하는가로 갈려요.

오늘 닫은 것과 안 닫은 것

약속했던 것 닫힌 곳
Provider 없는 도구 ✅ Step 2
두 상자가 갈리는 기준 ✅ Step 7
값 하나가 네 단계를 내려가는 문제 ✅ Step 6 — 새 도구가 아니라 C-2로
세 갈래 표 ✅ Step 8 — 네 갈래가 됐고 한 칸이 비었다
서버가 가진 값 ❌ 안 닫음 — 다음 시간에

표의 마지막 줄을 보면, 서버 칸만 비어 있습니다.

우리 앱에는 이미 게시물이 열 장 있고 댓글도 달리는데, 그것들이 지금 어디에 있냐면 코드 안에 적혀 있어요. feedPosts라는 배열로요.

다음 시간 예고

다음 시간에는 그 배열을 걷어내고 진짜 서버에서 가져옵니다.

오전에 만드신 백엔드 API를 오후에 화면에 띄우는 시간이에요. 주소는 localhost:8090이고, 응답은 여러분이 만든 그 껍데기에 싸여 오는데, 서버 값은 지금까지 다룬 것들과 성질이 완전히 다릅니다. 낡습니다. 우리가 화면에 그려둔 좋아요 개수는 그리는 순간의 값이고, 그사이 다른 사람이 눌렀으면 이미 틀린 값이에요. useState에는 이런 문제가 없었는데, 우리가 바꾸지 않으면 절대 안 변했으니까요.

기다리는 동안 무엇을 보여줄지, 실패하면 어떻게 할지도 정해야 하고요. B-4에서 "이건 안티패턴의 출발점"이라고 예고만 하고 넘어간 그 방식으로 직접 짜보고, 무엇이 힘든지 확인한 다음에 도구를 들일 거예요.

마지막으로 오늘 만든 확인 상자가 그때 한 번 더 나오는데, 서버에서 지우는 일은 실패할 수 있거든요. "지울까요?"에 확인했는데 서버가 거절하면 화면을 어떻게 되돌릴지, 그 이야기를 함께 합니다.


과제

[구현] 게시물 삭제에도 같은 상자를 씁니다

PostHeader의 점 세 개 버튼에 게시물 삭제를 붙여보세요.

  • 누르면 "이 게시물을 지울까요?"를 물어봅니다.
  • 확인하면 그 게시물이 피드에서 사라집니다.
  • 확인 상자는 새로 만들지 않습니다. 오늘 만든 것을 그대로 씁니다.

붙이고 나서 ConfirmDialoguseConfirmStore를 몇 줄이나 고쳤는지 세어보세요. 그 숫자가 오늘 이 구조를 만든 이유입니다.

[구현] 저장 중에는 물어보지 않게

지금 ask는 언제든 새 요청으로 덮어씁니다. 확인 상자가 이미 떠 있는데 다른 X를 누르면 조용히 바뀌어요.

store에 isBusy를 추가해서, 처리 중일 때는 새로 물어보지 못하게 막아보세요.

  • confirm이 불리면 isBusy를 켭니다.
  • 켜져 있는 동안 ask는 아무 일도 하지 않습니다.
  • 지우기 버튼은 그동안 비활성화되고 글자가 "지우는 중…"으로 바뀝니다.

ConfirmDialog에서 isBusy를 어떻게 구독할지 정할 때 Step 4·5를 떠올려 보세요.

[탐구] 다시 그려지는 횟수를 직접 세어보세요

CommentList의 selector를 잠시 통째 구독으로 되돌려 보세요.

tsx
const { ask } = useConfirmStore();

React 개발자 도구의 Profiler로 확인 상자를 한 번 열었다 닫아보세요. 다시 그려진 컴포넌트가 몇 개인가요? selector로 되돌린 뒤에는 몇 개인가요?

두 숫자를 적고, 카드가 백 장이면 각각 몇 개가 될지 계산해 보세요.

[탐구] 멈추는 selector를 직접 만들어 보세요

코드베이스의 ConfirmBundleDemo.tsx에 다섯 가지 selector가 나란히 있습니다. 이것들을 화면에 하나씩 그려보고 무엇이 멈추는지 확인하세요.

그다음 여섯 번째를 직접 만들어 보세요. 멈출 것 같은데 안 멈추는 것을요. 왜 안 멈추는지 한 문장으로 적어주세요.

[설계]이 값들은 어디에 살아야 할까요

아래 여섯 가지를 Step 8의 네 갈래 중 하나에 배정하고, 각각 한 문장으로 이유를 적으세요.

  1. 게시물 상세 화면에서 사진을 확대한 배율
  2. 로그인한 사용자의 프로필 사진 주소
  3. 피드를 격자로 볼지 목록으로 볼지 (사용자가 고른 뒤 다음에도 유지)
  4. 댓글 입력창에 지금까지 친 글자
  5. 다른 사람이 나를 팔로우했다는 알림의 읽지 않음 개수
  6. 검색 화면에서 친 검색어

⚠️ 여섯 중에 조건에 따라 답이 갈리는 것이 있습니다. 그것을 찾아서, 어떤 조건에서 어디로 가는지 함께 적어주세요.


생각해볼 주제

1. 전역 store를 마지막에 두는 순서가 정말 옳을까요

Step 8에서 "위에서 되는 것은 위에 둔다"는 순서를 세웠는데, 실무에서 이 순서를 지키면 처음에 useState로 만든 것을 나중에 store로 옮기는 일이 자주 생깁니다. 옮기는 데 드는 시간도 만만치 않고요.

"어차피 나중에 공유하게 될 테니 처음부터 store에 두자"는 주장은 어떤 점에서 설득력이 있고, 어떤 점에서 위험할까요. 두 방식이 프로젝트가 커졌을 때 각각 어떤 모습이 되는지로 답해 보세요.

2. 라이브러리가 실행해 봐야만 알 수 있는 오류를 내는 것

Step 5에서 본 무한 루프는 타입 검사도 통과하고 빌드도 성공했는데, 화면을 열어야만 알 수 있었어요.

이 과목에서 비슷한 것을 여러 번 만났습니다. C-3의 옛 Provider 문법도 도구가 아무 말을 안 했고, Zod의 옛 API도 그대로 동작했죠.

라이브러리를 만드는 쪽에서 이런 실수를 컴퓨터가 미리 잡게 만들 수 있었을까요. 안 그랬다면 어떤 이유가 있었을지 생각해 보고, 우리가 쓰는 쪽에서 이런 종류의 오류를 줄이려면 무엇을 해야 할지도 함께 생각해 보세요.

3. 리팩터링이 만든 새 의존성

Step 6에서 props 여덟 개를 없앤 대신 PostBody가 라우터에 매였습니다. 스무 곳 넘는 곳이 영향을 받았고요.

"props를 줄이는 것"과 "의존성을 안 늘리는 것"이 부딪힐 때 무엇을 기준으로 정해야 할지, 그리고 이 판단이 라이브러리를 만들 때와 서비스를 만들 때 달라져야 한다면 어떻게 달라져야 할지 생각해 보세요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 먼저 직접 해보시고 열어주세요.


과제 예시답안

🎯 [과제 1 예시답안] 게시물 삭제에도 같은 상자를 씁니다

채점 포인트

항목 확인할 것 배점
상자를 새로 안 만들었다 Dialog·useState를 새로 쓰지 않고 ask만 불렀다 25
store를 안 고쳤다 useConfirmStore.ts에 새 값이나 새 함수를 추가하지 않았다 25
selector로 골랐다 useConfirmStore((state) => state.ask)로 조각만 받았다 15
지우는 일을 넘겼다 ask의 두 번째 인자로 실제 삭제를 넘겼다 20
리듀서에 일을 추가했다 게시물을 지우는 액션을 판별 유니온에 더했다 15

풀이 예시

물어보는 쪽은 이 두 줄이 전부입니다.

tsx
const ask = useConfirmStore((state) => state.ask);
tsx
<button type="button" onClick={() => ask('이 게시물을 지울까요?', onDelete)}>
  게시물 삭제
</button>

지우는 일은 피드가 맡아서 A-5에서 만든 리듀서에 할 일을 하나 더합니다.

TypeScript
type AnswerFeedAction = Parameters<typeof feedReducer>[1] | { type: 'removePost'; id: number };

export function answerFeedReducer(state: FeedState, action: AnswerFeedAction): FeedState {
  if (action.type === 'removePost') {
    return { ...state, posts: removeById(state.posts, action.id) };
  }

  return feedReducer(state, action);
}

removeById는 A-6에서 만든 것을 그대로 쓰고, 카드가 자기 번호를 넘겨줍니다.

tsx
<PostHeaderWithDelete
  username={post.username}
  profileImageUrl={post.profileImageUrl}
  onDelete={() => dispatch({ type: 'removePost', id: post.id })}
/>

세어보라고 한 숫자

ConfirmDialog.tsxuseConfirmStore.ts를 몇 줄 고치셨나요.

0 줄입니다.

지울 대상이 댓글에서 게시물로 바뀌었는데 상자는 아무것도 몰라도 됩니다. 상자가 아는 것은 "물어볼 말"과 "확인하면 할 일" 둘뿐이고, 그 둘은 부르는 쪽이 채워 넣으니까요.

Step 1에서 상자를 CommentList 안에 뒀다면 이 과제는 상자를 통째로 복사하는 일이 됐을 텐데, 지금은 버튼 하나 붙이는 일이에요.

자주 나오는 실수

하나. ask 안에서 바로 지우기

tsx
// ❌ 이러면 물어보지도 않고 지워진다
onClick={() => ask('이 게시물을 지울까요?', onDelete())}

괄호 하나 차이인데, onDelete()는 지금 부르는 것이고 onDelete는 나중에 부르라고 넘기는 것이에요. 실행해 보면 확인 상자가 뜨기도 전에 게시물이 사라집니다.

둘. 게시물마다 확인 상자를 하나씩

PostCard 안에 <ConfirmDialog />를 넣으신 분이 있을 텐데, 그러면 화면에 상자가 게시물 수만큼 생겨서 하나를 열면 여러 개가 겹쳐 뜹니다. 상자는 앱에 하나뿐이고 이미 AppProviders에 있습니다.

셋. 리듀서에 액션을 안 더하고 배열을 직접 자르기

B-2에서 배운 그대로입니다. posts.splice()로 원본을 자르면 화면이 안 바뀌니까, 새 배열을 만들어 돌려줘야 해요.

💡 튜터의 한마디

이 과제의 핵심은 "0 줄"인데, 좋은 구조인지 아닌지는 새 기능을 붙일 때 드러나요. 상자를 앱에 하나 두고 부르는 쪽이 내용을 채우는 방식은, 지울 것이 네 종류 다섯 종류로 늘어나도 계속 0 줄입니다.

🎯 [과제 2 예시답안] 저장 중에는 물어보지 않게

채점 포인트

항목 확인할 것 배점
잠금이 실제로 막는다 처리 중에 ask를 불러도 요청이 안 바뀐다 25
실패해도 풀린다 try/finally로 예외가 나도 isBusy를 되돌린다 30
두 번 눌려도 한 번만 버튼이 비활성화되고 confirm이 중복 실행되지 않는다 20
selector로 골랐다 isBusy를 따로 골랐거나 묶었다면 useShallow를 썼다 15
글자가 바뀐다 처리 중에 "지우는 중…" 이 보인다 10

풀이 예시

store에 값 하나와 잠금 검사를 더합니다.

TypeScript
export const useBusyConfirmStore = create<BusyConfirmState>()((set, get) => ({
  request: null,
  isBusy: false,

  // 처리 중이면 새 요청을 받지 않는다. 조용히 무시하는 대신 여기서 한 번만 막는다.
  ask: (message, onConfirm) => {
    if (get().isBusy) {
      return;
    }

    set({ request: { message, onConfirm } });
  },

confirm은 이제 기다립니다.

TypeScript
  confirm: async () => {
    const { request, isBusy } = get();

    if (request === null || isBusy) {
      return;
    }

    set({ isBusy: true });

    try {
      await request.onConfirm();
    } finally {
      // 실패해도 잠금은 풀어야 한다. 안 그러면 영영 아무것도 못 물어본다.
      set({ request: null, isBusy: false });
    }
  },

상자 쪽은 조각 둘을 따로 고릅니다.

tsx
export function BusyConfirmDialog() {
  const request = useBusyConfirmStore((state) => state.request);
  const isBusy = useBusyConfirmStore((state) => state.isBusy);
  const confirm = useBusyConfirmStore((state) => state.confirm);
tsx
<button type="button" disabled={isBusy} onClick={() => void confirm()}>
  {isBusy ? '지우는 중…' : '지우기'}
</button>

finally인가

이게 이 과제의 진짜 문제입니다. 서버에 지워달라고 했는데 거절당하면 어떻게 되죠?

try 없이 이렇게 쓰면요.

TypeScript
// ❌ 실패하면 여기까지 안 온다
await request.onConfirm();
set({ request: null, isBusy: false });

예외가 던져지는 순간 아래 줄이 안 돌아가고, isBusytrue로 남아서 그다음부터 아무것도 물어볼 수 없습니다. 화면은 멀쩡한데 삭제 버튼이 전부 먹통이 됩니다.

이 오류는 서버가 실패했을 때만 나타나서 개발 중에는 거의 안 보이니, finally를 먼저 써두는 습관이 중요해요.

자주 나오는 실수

하나. 버튼만 비활성화하고 store는 안 막기

disabled 만으로는 부족한데, 키보드나 다른 경로로 함수가 다시 불릴 수 있고 getState().confirm()은 버튼과 상관없이 불러집니다. 막는 곳은 store여야 하고, 버튼의 disabled는 사용자에게 알려주는 표시일 뿐이에요.

둘. isBusyrequest와 묶어서 고르기

tsx
// ❌ 화면이 멈춘다
const { request, isBusy } = useBusyConfirmStore((s) => ({ request: s.request, isBusy: s.isBusy }));

Step 5에서 본 그것입니다. 두 줄로 나누거나 useShallow로 감싸세요.

셋. 처리 중에 상자를 닫을 수 있게 두기

close는 잠금을 안 봐서, 처리 중에 취소를 누르면 상자만 사라지고 삭제는 계속 진행돼요. 취소 버튼도 disabled={isBusy}로 막는 것이 맞습니다.

💡 튜터의 한마디

isBusy는 다음 시간에 다시 만납니다. 서버에 요청을 보내놓고 답을 기다리는 상태를 직접 만들어 보는 셈이거든요.

그런데 다음 시간에 배울 도구는 이 값을 우리가 안 만들어도 알아서 줍니다. 그때 "아, 이걸 손으로 만들면 이렇게 귀찮았지" 하고 떠올리실 수 있게 오늘 한 번 해보는 겁니다.

🎯 [과제 3 예시답안] 다시 그려지는 횟수를 직접 세어보세요

채점 포인트

항목 확인할 것 배점
두 숫자를 실제로 쟀다 짐작이 아니라 Profiler 화면의 숫자를 적었다 40
무엇이 늘었는지 안다 늘어난 것이 CommentList라는 것을 짚었다 25
백 장으로 계산했다 카드 수에 비례한다는 것을 숫자로 보였다 20
안 늘어난 것도 봤다 ConfirmDialog는 양쪽 다 다시 그려진다는 것을 짚었다 15

풀이 예시

카드 열 장이 있는 피드에서 확인 상자를 한 번 열었다 닫았을 때입니다.

통째 구독 selector
열 때 다시 그려진 컴포넌트 ConfirmDialog 1 + CommentList 10 ConfirmDialog 1
닫을 때 같은 11 같은 1
열고 닫기 한 번의 합계 22 2

카드가 백 장이면 통째 구독은 202 개, selector는 그대로 2 개입니다. 카드가 늘어도 안 늘어나요.

늘어난 것은 하나뿐입니다

여기서 짚고 갈 것이 있는데, ConfirmDialog는 양쪽 다 다시 그려집니다. 그건 줄일 수도 없고 줄여서도 안 되는데, 물어볼 내용이 바뀌었으니 상자는 반드시 다시 그려야 합니다.

줄어든 것은 CommentList 쪽인데, CommentListrequest를 안 보고 ask만 씁니다. 안 보는 값이 바뀌었는데 다시 그려지던 것이 없어진 겁니다.

자주 나오는 실수

하나. Profiler에서 아무것도 안 잡힘

React 개발자 도구의 Profiler는 녹화 버튼을 누른 뒤의 것만 기록합니다. 녹화를 켜고 상자를 열었다 닫은 다음 정지하세요.

그리고 Profiler 설정에서 "Record why each component rendered"를 켜두시면 왜 다시 그려졌는지도 함께 보입니다. selector를 되돌린 판에서는 CommentList마다 "Hook 1 changed"가 보일 거예요.

둘. 개발 모드 숫자를 그대로 믿기

B-4에서 본 것처럼 개발 모드에서는 컴포넌트가 두 번씩 그려지는데, 두 판을 같은 조건에서 비교하는 것은 괜찮지만 "22 번 그려지니 느리겠다"는 결론은 성급해요. 배포된 앱에서는 절반입니다.

💡 튜터의 한마디

숫자를 재보라고 한 이유는 selector를 쓰라고 설득하려는 게 아닙니다. 무엇이 다시 그려지는지 눈으로 확인하는 습관을 들이시라는 거예요.

앞으로 "느린 것 같은데"라는 느낌이 들 때가 오는데, 그때 고칠 곳을 짐작으로 찾으면 대개 틀립니다. C-8에서 이 이야기를 본격적으로 합니다.

🎯 [과제 4 예시답안] 멈추는 selector를 직접 만들어 보세요

채점 포인트

항목 확인할 것 배점
다섯 가지를 다 확인했다 어느 것이 멈추고 어느 것이 안 멈추는지 실제로 그려봤다 35
여섯 번째를 만들었다 멈출 것 같은데 안 멈추는 것을 새로 만들었다 35
이유가 정확하다 "개수" 가 아니라 "새로 만드느냐" 로 설명했다 30

다섯 가지 결과

selector 결과 이유
{ message, isOpen } 객체 ❌ 멈춤 부를 때마다 새 객체
[message, isOpen] 배열 ❌ 멈춤 배열도 새로 만들어진다
useShallow(...)로 감싼 객체 ✅ 정상 안쪽 값이 같으면 이전 객체를 다시 준다
계산한 글자 하나 ✅ 정상 글자는 내용이 같으면 같은 값
state.request 조각 ✅ 정상 store 안에 있던 그것을 그대로

여섯 번째 — 객체를 돌려주는데 안 멈춥니다

tsx
export function SixthSelector() {
  const request = useConfirmStore((state) => state.request);

  return <p>{`(f) ${request === null ? '닫힘' : request.message}`}</p>;
}

request는 객체입니다. { message, onConfirm }이 들어 있는데, 안 멈춥니다.

이유 한 문장: 이 객체는 selector가 만든 것이 아니라 store 안에 이미 있던 것이라, 부를 때마다 같은 것이 나옵니다.

앞의 실패한 것들과 비교해 보세요. { message: state.request?.message }state를 재료로 새 객체를 조립합니다. state.request는 조립하지 않고 가리키기만 해요.

또 하나 — 늘 같은 하나를 돌려주기

tsx
const NOTHING = { message: '(없음)' };

export function ConstantSelector() {
  const value = useConfirmStore((state) => state.request ?? NOTHING);

  return <p>{`(g) ${value.message}`}</p>;
}

NOTHING을 컴포넌트 바깥에 뒀으므로, 이 객체는 앱이 켜질 때 한 번 만들어지고 그 뒤로 늘 같아요.

안쪽에 뒀다면 어떻게 될까요? 매번 새로 만들어져서 멈추는데, 줄 하나의 위치가 결과를 가릅니다.

자주 나오는 실수

하나. 멈추는 것을 확인 못 하고 넘어감

BundledReader를 그렸는데 아무 일도 안 일어났다면, 화면에 실제로 붙이지 않았을 가능성이 큽니다. 컴포넌트를 만들어만 두면 그려지지 않아서 selector가 한 번도 안 불려요.

둘. "값을 두 개 이상 고르면 안 된다"로 정리하기

배열 사례와 계산한 글자 사례를 나란히 놓으면 이 정리가 틀렸다는 게 보입니다. 배열은 하나짜리여도 멈추고, 글자는 여러 값을 조합해 만들어도 안 멈춰요.

💡 튜터의 한마디

이 규칙은 Zustand만의 것이 아닙니다. React가 바깥 값을 구독할 때 쓰는 방식이 그렇게 생겼고, 다른 라이브러리도 같은 방식을 쓰면 같은 문제가 생기니, "이 라이브러리는 이런 함정이 있다"로 외우는 것보다 "돌려주는 것이 매번 새로 만들어지는가"를 습관적으로 확인하시는 게 오래갑니다.

🎯 [과제 5 예시답안]이 값들은 어디에 살아야 할까요

채점 포인트

항목 확인할 것 배점
서버 값 둘을 찾았다 프로필 사진과 알림 개수를 서버로 보냈다 30
갈리는 것을 찾았다 조건에 따라 답이 달라지는 항목을 짚었다 30
순서대로 물었다 한 컴포넌트 → 서버 → 주소 → 전역 순으로 판단했다 20
이유가 기준과 이어진다 세 질문(왕복·공개·새로고침)으로 설명했다 20

풀이 예시

어디에 이유
1. 사진 확대 배율 화면 안 (조건부 주소) 아래 참조 — 갈리는 항목
2. 로그인 사용자 프로필 사진 주소 서버 남이 들고 있고 바뀔 수 있다. 사용자가 사진을 바꾸면 낡는다
3. 격자 / 목록 보기 전역 store + 저장 여러 화면이 함께 보고, 다음에 와도 유지돼야 하는데 주소로는 그게 안 된다
4. 댓글 입력창 글자 화면 안 그 입력창만 안다. 카드마다 따로 있어야 한다
5. 알림 읽지 않음 개수 서버 남이 나를 팔로우하면 내가 아무것도 안 해도 바뀐다
6. 검색어 주소 글자로 왕복되고, 남에게 보내도 되고, 새로고침 뒤에도 남아야 한다

갈리는 항목은 1번입니다

사진 확대 배율은 기본적으로 화면 안입니다. 그 화면을 떠나면 의미가 없는 값이고, 아무도 함께 볼 필요가 없는데, 조건이 하나 붙으면 답이 바뀝니다.

"이 사진의 이 부분을 봐" 하고 링크로 보낼 수 있어야 한다면 주소인데, 지도 서비스가 정확히 그렇게 하죠. 확대 배율과 위치가 주소에 들어 있어서 링크만 보내면 상대도 같은 화면을 봅니다.

세 질문으로 확인해 보세요.

텍스트
 글자로 왕복되나          1.5 같은 숫자. 된다.
 남이 봐도 되나           공개된 사진이면 된다.
 새로고침 뒤 살아야 하나   "링크로 공유" 요구가 있을 때만 그렇다.

세 번째만 요구사항에 달려 있어서, 기술이 아니라 제품 결정이 답을 정합니다.

3번이 주소가 아닌 이유

여기서 헷갈리기 쉽습니다. "다음에도 유지" 라니까 주소 같아 보이거든요.

두 가지가 걸리는데, 하나는 주소가 다음 방문까지 안 따라온다는 것입니다. 사용자가 북마크를 안 하고 그냥 사이트에 다시 들어오면 주소는 /인데, 다음에도 유지하려면 브라우저에 저장해야 합니다.

다른 하나는 취향은 남에게 강요할 것이 아니라는 것인데, 격자 보기가 주소에 담기면 내가 보낸 링크가 상대의 보기 방식까지 바꿉니다.

지난 시간에 만든 밝기와 정확히 같은 종류인데, 그것도 전역에 두고 브라우저에 저장했습니다.

자주 나오는 실수

하나. 2번을 전역 store로 보내기

로그인한 사용자 정보를 전역에 두는 코드는 실무에서 정말 많습니다. 그런데 그 값의 출처는 서버예요. 전역에 복사해 두면 서버에서 바뀌었을 때 우리 복사본이 낡습니다.

이걸 어떻게 다루는지가 다음 시간 주제입니다. "로그인 정보는 전역 상태"라는 익숙한 답이 왜 문제를 만드는지 그때 보실 거예요.

둘. 4번을 전역으로

"댓글을 쓰다 말고 다른 화면에 갔다 오면 남아 있어야 하지 않나"라고 생각하실 수 있는데, 좋은 관찰이지만 그건 별개 기능(임시 저장)이고 요구사항에 없습니다. 없는 요구를 상상해서 값을 위로 올리면 앱이 빠르게 복잡해져요.

💡 튜터의 한마디

이 과제에 정답 하나만 있는 항목은 여섯 중 다섯이고, 나머지 하나는 요구사항을 물어봐야 정해져요.

실무에서 상태를 어디에 둘지 고민될 때, 대개는 기술 문제가 아니라 아직 안 정해진 요구사항이 있는 겁니다. 그럴 때는 코드를 쓰기 전에 "이거 링크로 공유해야 하나요?"를 먼저 물어보세요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 전역 store를 마지막에 두는 순서가 정말 옳을까요

문제 상황 요약

Step 8의 순서는 "한 컴포넌트 → 서버 → 주소 → 전역 store" 였습니다. 이 순서를 지키면 처음에 useState로 만든 값을 나중에 store로 옮기는 일이 생기고 그 옮기는 작업이 공짜가 아니라서, "어차피 공유하게 될 텐데 처음부터 store에 두자"는 주장이 나옵니다. 어느 쪽이 맞을까요.

튜터의 가이드 및 해설

먼저 그 주장이 설득력 있는 지점부터 인정하고 갑시다.

옮기는 비용이 실제로 있는데, 오늘 Step 6에서 우리가 겪었죠. props를 걷어내는 리팩터링 하나에 스무 곳 넘는 곳이 영향을 받았습니다.

그리고 어떤 값은 나중에 공유될 것이 거의 확실합니다. 로그인 정보, 권한, 지금 선택된 작업 공간 같은 것들인데, 이런 것을 굳이 useState로 시작할 이유는 없습니다.

그런데 "처음부터 전역"이 위험한 이유는 옮기는 비용보다 큰 것이 있기 때문입니다.

useState로 만든 값은 그 값을 바꿀 수 있는 곳이 컴포넌트 하나 안이라 눈에 보이는데, 버그가 나면 읽을 코드가 그만큼입니다.

store에 있는 값은 앱 어디에서나 바꿀 수 있고, 오늘 배웠듯이 컴포넌트가 아닌 곳에서도 바꿀 수 있어요. 값이 이상하다는 신고가 들어왔을 때 읽어야 할 코드가 앱 전체가 됩니다.

여기서 결정적인 비대칭이 나옵니다.

텍스트
 useState  store   :  옮기는 곳이 정해져 있다. 컴파일러가 안 고친 곳을 알려준다.
 store  useState   :  누가 그 값을 쓰는지 전부 찾아야 한다. 다 찾았는지 확신할 수 없다.

좁은 곳에서 넓은 곳으로 가는 것은 기계적이고, 반대는 아니라서, 좁게 시작하는 쪽이 되돌리기 쉬운 선택이에요.

프로젝트가 커졌을 때의 모습으로 답해 보면 이렇습니다.

순서를 지킨 프로젝트는 store가 작아서, 다섯에서 열 개 정도의 값만 들어 있고 그 목록만 봐도 이 앱이 무엇을 전역으로 관리하는지 알 수 있어요. 대신 리팩터링 기록이 여기저기 남아 있습니다.

순서를 뒤집은 프로젝트는 store가 거대해서, 필드가 수십 개고 그중 절반은 한 화면에서만 쓰이는데 아무도 지울 엄두를 못 내요. 지워도 되는지 확인하려면 앱 전체를 검색해야 하거든요.

제가 권하는 절충은 이겁니다. 순서는 지키되, "이 값을 두 번째 화면에서 쓰게 되는 순간 옮긴다"를 기준으로 두세요. 미리 옮기지도 말고, 세 번째 네 번째 화면까지 props로 나르지도 말고요.

🎯 면접관을 홀리는 핵심 멘트

"전역 상태를 마지막에 두는 이유는 옮기는 비용 때문이 아니라 되돌리는 비용 때문입니다. 지역에서 전역으로 가는 리팩터링은 컴파일러가 도와주지만, 전역에서 지역으로 돌아오는 리팩터링은 '이 값을 누가 쓰는지'를 사람이 전부 찾아야 합니다. 그래서 저는 좁게 시작하고, 두 번째 소비자가 생기는 시점을 옮기는 신호로 씁니다."

🤔 [생각해볼 주제 2] 라이브러리가 실행해 봐야만 알 수 있는 오류를 내는 것

문제 상황 요약

Step 5의 무한 루프는 타입 검사도 통과하고 빌드도 성공했는데, 화면을 열어야 알 수 있었어요.

이 과목에서 비슷한 것을 여러 번 만났습니다. 지난 시간의 옛 Provider 문법도 도구가 침묵했고, Zod의 옛 API도 그대로 동작했죠.

왜 이런 것들을 컴퓨터가 미리 못 잡을까요.

튜터의 가이드 및 해설

먼저 왜 못 잡는지부터 보면, 세 가지 이유가 섞여 있습니다.

첫째, 타입은 값의 모양을 보지 정체성을 안 봅니다.

TypeScript
(state) => ({ message: state.request?.message ?? '', isOpen: state.request !== null })

이 함수의 타입은 완벽해서, ConfirmState를 받아 { message: string; isOpen: boolean }을 돌려줘요. 문제는 부를 때마다 다른 객체를 돌려준다는 것인데, 그건 타입이 아니라 실행 중에만 알 수 있는 성질입니다.

TypeScript에게 "이 함수는 같은 입력에 같은 객체를 돌려줘야 한다"를 표현할 방법이 없어요.

둘째, 막으면 정당한 코드까지 막힙니다.

무한 루프를 원천 차단하려면 "selector는 원시값만 돌려줄 수 있다"로 제한하면 되지만, 그러면 오늘 우리가 쓴 (state) => state.request도 막히는데, 이건 객체를 돌려주지만 완전히 안전합니다.

안전한 것과 위험한 것이 타입 위에서 구분이 안 돼서, 막으면 정상적인 사용이 함께 죽어요.

셋째, 린트로 잡기에는 경우의 수가 너무 많습니다.

useConfirmStore((s) => ({ ... }))라는 모양은 잡을 수 있을 텐데, selector를 변수에 담아 넘기면요? 다른 함수가 만들어 준 selector를 넘기면요? 잡는 규칙을 아무리 정교하게 만들어도 우회로가 남고, 대신 정상 코드를 잘못 잡는 경우가 늘어납니다.

그럼에도 라이브러리 쪽에서 할 수 있었던 것이 있고, 실제로 했습니다.

텍스트
 The result of getSnapshot should be cached to avoid an infinite loop

이 메시지는 React가 실행 중에 감지해서 알려주는 것입니다. 미리는 못 잡지만, 터졌을 때 원인을 정확히 알려주는 쪽으로 투자한 거예요.

이게 중요한 선택인데, 미리 못 잡는 오류가 있을 때 라이브러리가 할 수 있는 최선은 터졌을 때 원인을 정확히 말해주는 것입니다. Step 3에서 본 Cannot read properties of null (reading 'useContext')는 그 반대 사례인데, 같은 회사가 만든 것인데도 한쪽은 친절하고 한쪽은 아닙니다.

쓰는 쪽에서 줄이는 방법은 세 가지입니다.

하나, 화면을 한 번은 열어봅니다. 당연해 보이지만 타입 검사와 테스트만 돌리고 넘어가는 일이 실제로 많은데, 오늘 우리도 겪었어요. 테스트가 전부 통과하는데 타입 검사가 여덟 건을 잡은 일이 있었던 것처럼, 도구마다 보는 것이 다릅니다.

둘, 버전을 올릴 때 바뀐 것을 읽습니다. Step 5의 함정은 버전 5에서 새로 생긴 것이라, 버전 4 시절 코드를 그대로 가져오면 터져요. 검색해서 나온 예제에는 버전이 안 적혀 있습니다.

셋, "이거 왜 되지"를 그냥 넘기지 않습니다. 지난 시간에 옛 Provider 문법이 아무 경고 없이 동작하는 것을 봤는데, 도구가 침묵한다고 옳은 것은 아닙니다.

🎯 면접관을 홀리는 핵심 멘트

"타입 시스템은 값의 모양은 검사하지만 정체성은 검사하지 못합니다. 그래서 '매번 새 객체를 돌려주는 selector' 같은 문제는 원리적으로 컴파일 타임에 못 잡습니다. 막으려고 하면 안전한 사용까지 막히고요. 그래서 저는 라이브러리를 볼 때 '무엇을 미리 막아주나'보다 '못 막는 것이 터졌을 때 원인을 알려주나'를 봅니다."

🤔 [생각해볼 주제 3] 리팩터링이 만든 새 의존성

문제 상황 요약

Step 6에서 props 여덟 개를 없앤 대신 PostBody가 라우터에 매였습니다. 스무 곳 넘는 곳이 영향을 받았고요.

"props를 줄이는 것"과 "의존성을 안 늘리는 것"이 부딪힐 때 무엇을 기준으로 정해야 할까요.

튜터의 가이드 및 해설

먼저 우리가 실제로 무엇을 맞바꿨는지 정확히 봅시다.

바꾸기 전 PostBody는 아무것에도 안 매인 컴포넌트였습니다. props만 주면 어디서든 그려지는 대신, 그 props를 위에서 네 단계에 걸쳐 날라야 했어요.

바꾼 뒤에는 props가 사라졌지만 라우터가 있는 곳에서만 삽니다.

텍스트
 props 가 많다     쓰기 번거롭다. 대신 어디서든 쓸 수 있다.
 의존성이 있다     쓰기 편하다.  대신 그 환경에서만 쓸 수 있다.

한쪽이 더 좋은 게 아니라 번거로움을 어디에 둘지의 선택이라서, 기준은 이 질문 하나로 줄어듭니다. 이 컴포넌트를 그 환경 밖에서 쓸 일이 있는가?

PostBody는 없는데, 카드는 우리 앱 안에서만 살고 우리 앱에는 늘 라우터가 있기 때문이에요. 그래서 매여도 잃는 것이 없었습니다.

PostModal은 다르므로 안 매었어요. 오늘 그 판단이 실제로 값을 했는데, 모달을 라우터 없이 혼자 그려보는 것이 여전히 가능하거든요.

두 번째 기준은 그 의존성이 얼마나 넓게 퍼지는가입니다.

PostBody는 카드 안에 있고 카드는 앱 곳곳에서 쓰여서, 스무 곳이 영향을 받았어요. 만약 PostBody가 한 화면에서만 쓰이는 컴포넌트였다면 영향이 두세 곳이었을 겁니다.

여기서 유용한 감각 하나를 드리면, 잎사귀에 가까울수록 매지 않는 쪽이 낫습니다. 잎사귀는 재사용되고, 재사용되는 것이 환경에 매이면 그 환경을 통째로 끌고 다녀야 하거든요.

라이브러리를 만들 때와 서비스를 만들 때의 차이는 이 감각이 뒤집힐 만큼 큽니다.

라이브러리는 누가 어떤 환경에서 쓸지 모르므로, 의존성을 늘리는 것이 거의 항상 손해예요. props가 많아 번거롭더라도 아무것에도 안 매인 쪽을 고릅니다. 오늘 우리가 쓴 Zustand를 보세요. 런타임 의존성이 하나도 없었는데, 그게 우연이 아닙니다.

서비스는 환경이 정해져 있는데, 우리 앱에는 라우터가 있고 앞으로도 있을 것이므로 그 사실을 활용하지 않을 이유가 없어요. props를 여덟 개 나르면서 "혹시 라우터 없이 쓸지도 모르니까"를 대비하는 것은 오지 않을 미래를 위해 오늘 비용을 내는 겁니다.

한 문장으로 줄이면, 라이브러리는 모르는 미래를 대비하고 서비스는 아는 현재를 활용합니다.

그리고 우리 코드베이스처럼 둘이 섞여 있을 수 있습니다. 오늘 PostModal은 라이브러리처럼 다뤘고 PostBody는 서비스처럼 다뤘는데, 파일 하나 단위로 정하는 게 아니라 그 컴포넌트가 어떤 성격인지로 정합니다.

🎯 면접관을 홀리는 핵심 멘트

"props를 줄이는 리팩터링은 사실 번거로움을 옮기는 일입니다. 나르는 번거로움을 없애는 대신 환경 의존성을 얻습니다. 그래서 저는 '이 컴포넌트를 그 환경 밖에서 쓸 일이 있는가'와 '이게 얼마나 넓게 재사용되는가'로 판단합니다. 라이브러리는 모르는 미래를 대비해야 하니 의존성을 피하고, 서비스는 환경이 확정돼 있으니 그걸 활용하는 쪽이 낫습니다."

전체 목록 리액트