문서 읽는 데 70분 · A5

A-5: 훅 타이핑 — useState 추론·useRef 두 얼굴·useReducer

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

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

안녕하세요, 홍순구입니다. 지난 시간 마무리에서 제가 숙제를 세 개 남겨뒀습니다. 기억하시나요?

하나는 useScrollRestore 가 받는 함수의 타입을 () => void 라고 손으로 적었던 것. 둘은 B-2 에서 useState<DraftComment[]>([]) 처럼 꺾쇠 안에 타입을 적었던 것. 셋은 알림 문구가 같으면 타이머가 다시 걸리지 않는다는 한계였죠.

세 개가 따로 노는 숙제처럼 보이지만 뿌리가 하나입니다. 훅이 주고받는 값에 타입이 어떻게 붙는가. 오늘 이걸 정리합니다.

지금까지 여러분은 훅을 쓰면서 꺾쇠를 적은 적도 있고 안 적은 적도 있어요. 그런데 그 기준을 배운 적은 없습니다. 그냥 제가 적으라고 할 때 적었죠. 오늘 그 기준을 손에 넣습니다.

텍스트
 오늘의 여정

 Step 1   useState 는 첫 값만 보고 정한다 · 그래서 생기는 두 함정
    │
 Step 2   useRef 의 두 얼굴 — DOM 손잡이와 값 상자
    │
 Step 3   여러 값이 함께 움직일 때 · 액션을 판별 유니온으로
    │
 Step 4   좋아요와 알림을 한 리듀서로 · never 로 빠뜨림 막기
    
 상태의 모양이 타입으로 드러나는 인스타 피드

💡 오늘 수업의 핵심 — "첫 값이 말해주지 못하는 것만 내가 적는다"

훅에 타입을 다는 일은 전부 적는 것도, 전부 맡기는 것도 아닙니다. TypeScript 가 첫 값만 보고 알아낼 수 있는 것은 맡기고, 알아낼 수 없는 것만 내가 적습니다. 오늘은 그 경계선을 긋습니다.

🎯 학습 목표

  • useState 의 추론이 되는 자리와 안 되는 자리를 구분하고, 꺾쇠를 적을지 판단할 수 있다
  • useRef 의 두 가지 쓰임을 구분하고, 왜 null 로 시작하며 왜 물음표가 필요한지 설명할 수 있다
  • 함께 움직이는 값들을 useReducer 로 묶고, 액션을 판별 유니온으로 설계할 수 있다

Step 1: "TypeScript 는 첫 값만 보고 정합니다"

useState 를 쓸 때 우리가 넘기는 건 처음 값 하나뿐입니다. 그런데 화면이 도는 내내 그 상태에 온갖 값이 들어가죠. TypeScript 는 어떻게 그걸 다 알고 검사할까요.

답은 단순합니다. 넘긴 첫 값을 보고 정합니다. 그게 전부예요.

첫 값이 충분히 말해주는 자리

우리 코드베이스에 이미 있는 것부터 보겠습니다.

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
const [content, setContent] = useState('');

빈 문자열을 넘겼습니다. TypeScript 는 "아, 여기엔 문자열이 들어가는구나" 하고 string 으로 정합니다. 앞으로 setContent('노을 최고') 처럼 문자열을 넣는 건 통과하고, setContent(42) 는 막힙니다. 우리가 아무것도 안 적었는데 검사가 붙었어요.

useToggle 도 마찬가지입니다.

TypeScript
// apps/web-spa/src/hooks/useToggle.ts
export function useToggle(initialOn = false): [boolean, () => void] {
  const [on, setOn] = useState(initialOn);

initialOnboolean 이니 상태도 boolean 입니다. 여기서도 꺾쇠를 적지 않았죠.

그런데 훅이 주고받는 것은 추론이 안 도와줍니다

같은 useToggle 을 다시 보세요. 상태에는 아무것도 안 적었는데, 함수가 무엇을 돌려주는지는 손으로 적었습니다. : [boolean, () => void] 부분이요.

지워보면 왜 적었는지 압니다.

텍스트
Type 'boolean | (() => void)' is not assignable to type '(() => void) | undefined'.
  Type 'boolean' is not assignable to type '() => void'.

return [on, toggle] 을 보고 TypeScript 는 "boolean 하나랑 함수 하나가 든 배열" 이라고 정확히 보지 않습니다. "둘 중 아무거나 들어 있는 배열" 로 뭉뚱그려요. 배열은 원래 원소가 몇 개든 같은 타입이 담기는 것이니 자연스러운 판단입니다.

그러면 꺼내 쓰는 쪽에서 toggle 이 함수인지 boolean 인지 알 수 없어 부를 수가 없죠. 그래서 "첫째는 boolean, 둘째는 함수" 라고 순서까지 못박아 적은 겁니다.

지난 시간에 만든 useScrollRestore 는 더 분명합니다.

TypeScript
// apps/web-spa/src/hooks/useScrollRestore.ts
export function useScrollRestore(onReachBottom: () => void) {

여기서 : () => void 를 지우면 이렇게 됩니다.

텍스트
Parameter 'onReachBottom' implicitly has an 'any' type.

매개변수는 아예 손쓸 도리가 없어요. 함수를 만드는 시점에는 누가 무엇을 넘길지 모르니까요. 첫 값 같은 단서가 아예 존재하지 않습니다.

여기서 오늘의 그림이 완성됩니다.

텍스트
 추론은 "값  타입" 방향으로만 흐른다

   useState('')            값이 있다         타입이 따라온다
   useState(null)          값이 모자라다     내가 보탠다
   훅의 매개변수           값이 없다         내가 적는다
   훅의 반환               값이 여럿이다     내가 정리해준다

TypeScript 가 게으른 게 아니라, 볼 것이 없으면 못 보는 겁니다. 훅의 입구와 출구는 값이 흐르기 전이라 늘 우리 몫이에요.

추론된 타입이 뭔지 확인하는 법

"그래서 지금 이 상태가 무슨 타입인데요?" 라고 묻고 싶을 때가 있습니다. 에디터에서 변수 위에 마우스를 올리면 알려주지만, 코드로 확인하는 방법도 알아두면 좋아요.

일부러 엉뚱한 타입에 담아보는 겁니다.

TypeScript
const [count] = useState(0);

const reveal: string = count;
텍스트
Type 'number' is not assignable to type 'string'.

에러 메시지가 countnumber 라고 말해줬습니다. 확인이 끝났으니 그 줄은 지우면 됩니다. 타입이 헷갈릴 때마다 쓸 수 있는 방법이에요.

함정 하나 — null 로 시작하기

이제 첫 값이 충분히 말해주지 못하는 자리를 봅시다. 알림 상태를 만든다고 해보죠. 처음엔 알림이 없으니 null 로 시작하는 게 자연스럽습니다.

tsx
const [message, setMessage] = useState(null);

setMessage('업로드했습니다');
텍스트
Argument of type '"업로드했습니다"' is not assignable to parameter of type 'SetStateAction<null>'.

메시지에 답이 들어 있습니다. SetStateAction<null> — TypeScript 는 이 상태의 타입을 null 로 정했어요. 우리가 넘긴 첫 값이 null 이었으니까요.

TypeScript 입장에서는 틀린 판단이 아닙니다. null 을 넘겼으니 null 이라고 본 거죠. 문제는 우리 머릿속 계획이 "지금은 없지만 나중에 문자열이 들어온다" 였는데, 그 계획을 말해주지 않았다는 겁니다.

그래서 적어줍니다.

tsx
// apps/web-spa/src/App.tsx (B-4 시절)
const [toastMessage, setToastMessage] = useState<string | null>(null);

B-4 에서 제가 이렇게 적으라고 했던 이유가 이겁니다. 꺾쇠 안의 string | null 이 "문자열이 들어올 수도 있고 없을 수도 있다" 는 계획을 미리 알려주는 거예요.

함정 둘 — 빈 배열로 시작하기

같은 일이 배열에서도 벌어집니다. 이쪽이 더 당황스러워요.

tsx
const [tags, setTags] = useState([]);

setTags(['한강']);
텍스트
Type 'string' is not assignable to type 'never'.

never 라는 낯선 타입이 나왔습니다. A-2 에서 판별 유니온의 완전성을 검사할 때 만났던 그 타입이에요. "여기 올 수 있는 값은 하나도 없다" 는 뜻입니다.

왜 이렇게 됐을까요. 빈 배열 [] 에는 원소가 하나도 없습니다. TypeScript 가 원소 타입을 알아낼 단서가 없어요. 그래서 "아무것도 들어올 수 없는 배열", 즉 never[] 로 정해버립니다.

B-2 에서 댓글 목록을 만들 때 제가 이렇게 적었죠.

tsx
// apps/web-spa/src/components/PostCard.tsx (B-2 시절)
const [comments, setComments] = useState<DraftComment[]>([]);

빈 배열로 시작하는데 앞으로 DraftComment 가 담긴다는 걸 알려준 겁니다.

그래서 기준은 하나입니다

두 함정이 같은 모양이라는 게 보이시나요.

텍스트
 useState(첫 값)
        │
        ├─ 첫 값이 앞으로 담길 것을 다 대표하는가?
        │
   ┌────┴────┐
   │         │
  YES        NO
   │         │
 추론에      꺾쇠로
 맡긴다      직접 적는다

 ''  string      OK      null  null 로 굳음      막힌다
 0   number      OK      []    never[] 로 굳음   막힌다
 false  boolean  OK

'' 는 앞으로 들어올 모든 문자열을 대표합니다. 0 도 모든 숫자를 대표하고요. 그런데 null 은 앞으로 들어올 문자열을 대표하지 못하고, [] 는 앞으로 담길 원소를 대표하지 못합니다.

첫 값이 대표하지 못하면 내가 적는다. 이것만 기억하면 됩니다.

💡 한 줄 정리

useState 는 첫 값만 보고 타입을 정합니다. 첫 값이 앞으로 담길 것을 대표하면 맡기고, null 이나 빈 배열처럼 대표하지 못하면 꺾쇠로 직접 알려줍니다.

🙋 학생 질문 — "튜터님, 헷갈리면 그냥 다 적으면 되지 않나요?"

그렇게 해도 동작은 합니다. 다만 두 가지가 아쉬워요.

첫째, 손이 많이 갑니다. useState<string>('')useState('') 과 결과가 똑같은데 글자만 늘었어요. 읽는 사람도 "여기 뭔가 특별한 이유가 있나?" 하고 한 번 멈칫하게 됩니다.

둘째, 이게 더 중요한데, 꺾쇠가 붙어 있으면 그 자체로 신호가 됩니다. 지금 우리 코드에서 꺾쇠가 붙은 useState 는 방금 본 두 개뿐이고, 둘 다 "여기는 추론으로 안 되는 곳이다" 라는 표시예요. 전부 적기 시작하면 이 신호가 묻힙니다.

읽는 사람이 꺾쇠를 보고 "아, 첫 값만으로는 부족했구나" 하고 알아채게 두는 편이 좋습니다.


Step 2: "useRef 는 두 가지 일을 합니다"

A-4 에서 useRef 를 처음 썼습니다. 그때 제가 "쓰되 깊이는 안 판다" 고 하고 넘어갔죠. 오늘 그 안을 봅니다.

먼저 그때 쓴 코드를 다시 보겠습니다.

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
const inputRef = useRef<HTMLInputElement>(null);

여기 이상한 게 두 개 있습니다. 꺾쇠 안에 HTMLInputElement 를 적었는데 정작 넘긴 값은 null 이에요. 그리고 나중에 쓸 때는 물음표가 붙습니다.

TypeScript
inputRef.current?.focus();

이 물음표가 오늘의 주제 하나입니다.

null 로 시작할까

순서를 따져보면 답이 나옵니다.

텍스트
 ① 컴포넌트 함수가 돌아간다       inputRef 가 만들어진다
        │                          이 시점에 <input> 은 아직 화면에 없다
        │                          그러니 가리킬 것도 없다  null
        
 ② React 가 화면에 <input> 을 그린다
        │
        
 ③ React 가 inputRef.current 에 그 <input> 을 꽂아준다
        │
        
 ④ 사용자가 버튼을 누른다         이제 inputRef.current 에 <input> 이 있다

손잡이를 만드는 시점에는 잡을 물건이 아직 없습니다. 그래서 null 로 시작할 수밖에 없어요.

물음표를 빼면 무슨 일이 생기나

물음표 없이 써보면 TypeScript 가 막습니다.

tsx
const inputRef = useRef<HTMLInputElement>(null);

function handleClick() {
  inputRef.current.focus();
}
텍스트
'inputRef.current' is possibly 'null'.

useRef<HTMLInputElement>(null) 이 돌려주는 것의 타입은 HTMLInputElement 가 아니라 HTMLInputElement | null 입니다. 위 순서에서 ① 시점이 있으니 당연하죠.

물음표 하나가 "null 이면 여기서 멈추고 아무것도 안 한다" 는 뜻입니다. 아직 못 가리키는 동안을 건너뛰는 거예요.

요소 종류를 틀리게 적으면

꺾쇠 안을 잘못 적었을 때가 재밌습니다. 어디서 걸리는지 보세요.

tsx
const inputRef = useRef<HTMLTextAreaElement>(null);

return <input ref={inputRef} />;
텍스트
Type 'RefObject<HTMLTextAreaElement | null>' is not assignable to type 'Ref<HTMLInputElement> | undefined'.
  Type 'HTMLTextAreaElement' is missing the following properties from type 'HTMLInputElement': accept, align, alt, capture, and 29 more.

useRef 를 부른 줄이 아니라 손잡이를 붙이는 줄에서 걸렸습니다. useRef<HTMLTextAreaElement>(null) 자체는 아무 문제가 없거든요. 그걸 <input> 에 붙이려는 순간에야 짝이 안 맞는다는 게 드러납니다.

A-4 에서 이벤트 타입을 틀렸을 때도 똑같았죠. 정의하는 곳이 아니라 갖다 붙이는 곳에서 걸립니다.

그리고 첫 줄에 RefObject<HTMLTextAreaElement | null> 이 보이시죠. 이게 useRef 가 실제로 돌려주는 것의 이름입니다. 꺾쇠 안에 적은 타입에 | null 이 붙어서 나옵니다.

두 번째 얼굴 — 화면에 안 나오는 값 담기

여기까지가 useRef 의 첫 번째 쓰임인 DOM 손잡이입니다. 그런데 이 훅에는 얼굴이 하나 더 있어요.

tsx
// apps/web-spa/src/components/RefVsStateDemo.tsx
export function RefVsStateDemo() {
  const [stateCount, setStateCount] = useState(0);
  const refCount = useRef(0);

  return (
    <section className="ref-demo" aria-label="ref 와 state 비교">
      <p>state 로 센 수: {stateCount}</p>
      <p>ref 로 센 수: {refCount.current}</p>
      <Button onClick={() => setStateCount(stateCount + 1)}>state +1</Button>
      <Button
        onClick={() => {
          refCount.current += 1;
        }}
      >
        ref +1
      </Button>
    </section>
  );
}

useRef(0)null 이 아니라 숫자로 시작했습니다. 이러면 DOM 손잡이가 아니라 그냥 값을 담아두는 상자가 돼요.

App<Section> 위에 <RefVsStateDemo /> 를 잠깐 넣어주세요. B-2 의 KeyDemo, B-4 의 EffectTimingDemo 를 붙였던 것과 같은 방식입니다. 다 확인하면 지울 거예요.

직접 눌러보면 이상한 일이 벌어집니다. ref +1 을 세 번 눌러보세요.

텍스트
 ref +1 을 세 번 누른 뒤 화면

 state 로 센 수: 0
 ref 로 센 수:   0       세 번 눌렀는데 0 이다

화면이 꿈쩍도 안 합니다. 그런데 이어서 state +1 을 딱 한 번 누르면요.

텍스트
 state +1 을 한 번 더 누른 뒤 화면

 state 로 센 수: 1
 ref 로 센 수:   3       갑자기 3 이 나타났다

3 은 어디서 왔을까요. 아까 누른 세 번이 사라진 게 아니었습니다. refCount.current 는 처음부터 1, 2, 3 으로 올라가고 있었어요. 다만 그 값이 바뀌어도 React 가 화면을 다시 그리지 않았을 뿐입니다. state +1 이 화면을 다시 그리게 만들자, 그동안 쌓여 있던 값이 그제야 드러난 거죠.

B-2 첫 Step 이 떠오르시나요. 그때 일반 변수 clickCount 를 올렸더니 화면이 안 바뀌었습니다. 세 가지를 나란히 놓으면 이렇게 정리됩니다.

텍스트
                      값이 다음 렌더까지 남나?   바뀌면 화면을 다시 그리나?

 일반 변수                    ✗                      ✗
 useRef                       ✓                      ✗
 useState                     ✓                      ✓

useRef 는 딱 가운데입니다. 값은 남기는데 화면은 안 건드려요.

규칙이 직접 말해주는 것

그런데 위 화면을 그대로 쓰면 에디터에 빨간 줄이 그어집니다.

텍스트
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. Accessing a ref value
(the `current` property) during render can cause your component not to update as expected

{refCount.current} 를 화면에 그리려 한 줄이 걸린 겁니다. 메시지를 읽어보면 규칙이 결론을 그대로 말해줘요. "ref 는 화면을 그리는 데 필요하지 않은 값" 이라고요.

방금 우리가 본 게 정확히 그거였습니다. ref 값을 화면에 띄우려 했더니 세 번 눌러도 0 이었죠. 규칙은 그 일이 벌어지기 전에 미리 막아주는 겁니다.

위 화면은 "안 바뀐다" 를 눈으로 보여주려고 일부러 규칙을 어긴 것이니, 실제 코드에서는 이렇게 쓰지 않습니다.

그래서 언제 무엇을 쓰나

텍스트
 이 값이 화면에 보이나?
        │
   ┌────┴────┐
  YES        NO
   │         │
 useState   그럼 렌더를 넘어 기억해야 하나?
                  │
             ┌────┴────┐
            YES        NO
             │         │
          useRef    그냥 변수

DOM 손잡이는 이 표에서 "화면에 안 보이지만 기억해야 하는 값" 에 들어갑니다. <input> 을 가리키는 손잡이가 화면에 글자로 나오지는 않으니까요.

💡 한 줄 정리

useRef 는 DOM 손잡이와 값 상자 두 가지로 쓰입니다. 손잡이는 첫 렌더에 가리킬 게 없어서 null 로 시작하고 그래서 물음표가 필요하며, 어느 쪽이든 값이 바뀌어도 화면은 다시 그려지지 않습니다.

🙋 학생 질문 — "화면이 안 바뀌면 쓸모가 없는 거 아닌가요?"

화면에 안 나오는데 기억은 해야 하는 값이 생각보다 많습니다.

지난 시간에 만든 타이머 번호가 그런 값이에요. setTimeout 이 돌려준 번호는 나중에 clearTimeout 에 넘기려고 들고 있는 것이지, 화면에 "타이머 번호 7" 이라고 띄우려는 게 아니잖아요. B-4 에서는 effect 안에서 만들고 그 안에서 정리했으니 useRef 가 필요 없었지만, effect 밖에서도 그 번호를 써야 한다면 담아둘 곳이 필요합니다.

반대로 생각해보면 더 분명해집니다. 이런 값을 useState 에 담으면 어떻게 될까요. 타이머 번호가 바뀔 때마다 화면이 통째로 다시 그려집니다. 화면에는 아무 변화도 없는데 말이죠. 그건 낭비예요.

화면에 안 보이는 값을 useState 에 담으면 쓸데없이 다시 그리게 되고, 화면에 보이는 값을 useRef 에 담으면 안 바뀝니다. 두 실수가 정확히 반대 방향이에요.


Step 3: "함께 움직이는 값은 함께 둡니다"

지금까지 상태를 만들 때 값 하나에 useState 하나였습니다. 이번엔 그게 잘 안 되는 자리를 만나봅시다.

댓글에 삭제를 넣어보면

지금 우리 카드는 댓글을 달 수만 있고 지울 수는 없습니다. 지우는 기능을 넣어보죠. B-2 에서 쓰던 코드는 이랬습니다.

tsx
// apps/web-spa/src/components/PostCard.tsx (B-2 시절)
const [comments, setComments] = useState<DraftComment[]>([]);

function addComment(text: string) {
  setComments([...comments, { id: comments.length + 1, content: text }]);
}

번호를 comments.length + 1 로 매기고 있습니다. 댓글이 두 개면 다음은 3번이죠. 지우는 기능이 없을 때는 잘 돌아갑니다.

이제 지우기를 넣고 이렇게 해보세요.

텍스트
 ① 댓글 두 개를 단다          1번 "첫째" · 2번 "둘째"
 ② 1번을 지운다               2번 "둘째" 만 남는다
 ③ 댓글을 하나 더 단다        목록 길이가 1 이니 번호는 1+1 = 2

                               2번이 둘이 됐다

번호가 겹쳤습니다. B-2 에서 목록을 그릴 때 key 가 겹치면 무슨 일이 생기는지 봤죠. 한 줄을 지웠는데 엉뚱한 줄의 상태가 따라 움직였습니다. 여기서도 같은 사고가 납니다.

고치려면 다음에 줄 번호를 따로 기억해야 합니다. 목록에서 계산하면 안 되고, 한 번 쓴 번호는 다시 쓰지 않아야 해요.

그럼 상태가 둘이 됩니다. 목록 하나, 다음 번호 하나. 그리고 이 둘은 항상 같이 움직입니다. 댓글을 달면 목록도 늘고 번호도 올라가야 하죠. 하나만 바뀌면 곧바로 어긋납니다.

useState 를 두 개 두면 그 "항상 같이" 를 지켜주는 게 아무것도 없습니다. 한쪽만 바꾸는 코드를 짜도 아무도 안 말려요.

useReducer — 다음 상태를 계산하는 함수 하나

이럴 때 쓰는 것이 useReducer 입니다. 생각하는 방향이 useState 와 다릅니다.

텍스트
 useState   "상태를 이 값으로 바꿔줘"           바꿀 값을 컴포넌트가 계산해서 넘긴다

 useReducer "이런 일이 일어났어"                다음 상태는 리듀서 함수가 계산한다

컴포넌트는 무슨 일이 있었는지만 알리고, 그 일이 상태를 어떻게 바꾸는지는 한 함수가 전부 책임집니다. 그 함수를 리듀서(reducer)라고 불러요.

먼저 상태의 모양과 일어날 수 있는 일을 타입으로 적습니다.

TypeScript
// apps/web-spa/src/lib/comments.ts
import type { DraftComment } from '../components/CommentList';

// 댓글 목록과 다음에 줄 번호는 늘 함께 움직인다 — 그래서 한 덩어리로 둔다
export interface CommentState {
  items: DraftComment[];
  nextId: number;
}

// 이 화면에서 일어날 수 있는 일 전부. type 하나로 갈라지는 판별 유니온이다
export type CommentAction =
  | { type: 'add'; content: string }
  | { type: 'remove'; id: number };

export const initialCommentState: CommentState = {
  items: [],
  nextId: 1,
};

CommentAction 을 보세요. A-2 에서 배운 판별 유니온입니다. type 이라는 공통 필드 하나로 갈라지고, 갈래마다 딸린 정보가 다릅니다. 댓글을 달 때는 내용이 필요하고, 지울 때는 번호가 필요하죠.

이제 리듀서를 씁니다.

TypeScript
// apps/web-spa/src/lib/comments.ts
export function commentReducer(state: CommentState, action: CommentAction): CommentState {
  switch (action.type) {
    case 'add':
      return {
        items: [...state.items, { id: state.nextId, content: action.content }],
        nextId: state.nextId + 1,
      };

    case 'remove':
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.id),
      };

    default: {
      // 위에서 빠뜨린 일이 있으면 이 줄에서 잡힌다
      const missed: never = action;
      return missed;
    }
  }
}

읽어보면 특별한 게 없습니다. 지금 상태와 일어난 일을 받아서 다음 상태를 돌려주는 그냥 함수예요. React 가 안 나오죠. 그래서 따로 떼어놓고 생각하기도, 검사하기도 쉽습니다.

case 'add' 안에서 action.content 를 꺼내 쓰는 게 보이시나요. 판별 유니온이라 type'add' 로 좁혀진 순간 content 가 있다는 걸 TypeScript 가 압니다. A-2 에서 배운 좁히기가 그대로 통해요.

defaultnever 도 A-2 에서 본 그 방식입니다. 나중에 다시 보겠습니다.

컴포넌트에 붙이기

tsx
// apps/web-spa/src/components/PostCard.tsx
const [comments, dispatch] = useReducer(commentReducer, initialCommentState);

useState 와 모양이 닮았습니다. 왼쪽은 지금 상태, 오른쪽은 무슨 일이 있었는지 알리는 함수예요. 이 함수를 관례적으로 dispatch 라고 부릅니다.

쓸 때는 일어난 일을 객체로 만들어 넘깁니다.

tsx
// apps/web-spa/src/components/PostCard.tsx
      footer={<CommentForm onSubmit={(text) => dispatch({ type: 'add', content: text })} />}
tsx
// apps/web-spa/src/components/PostCard.tsx
      <CommentList
        comments={comments.items}
        onRemove={(commentId) => dispatch({ type: 'remove', id: commentId })}
      />

목록 쪽도 지우는 함수를 받도록 손봤습니다.

tsx
// apps/web-spa/src/components/CommentList.tsx
interface CommentListProps {
  comments: DraftComment[];
  // 지울 수 있는 목록에만 넘긴다 — 안 넘기면 × 버튼이 아예 안 그려진다
  onRemove?: (id: number) => void;
}

물음표가 붙었습니다. B-3 에서 Card 의 슬롯에 물음표를 붙였을 때와 같아요. 안 넘기면 그 버튼은 그려지지 않습니다.

여기서 얻는 것 — dispatch 가 타입을 물려받는다

이게 오늘 Step 3 의 핵심입니다. 리듀서 함수에 타입을 달아뒀더니, dispatch 에는 아무것도 안 적었는데 검사가 붙습니다.

없는 일을 알리면 막힙니다.

tsx
dispatch({ type: 'clear' });
텍스트
Type '"clear"' is not assignable to type '"add" | "remove"'.

일어날 수 있는 일이 두 가지뿐이라고 알려줍니다.

딸린 정보를 빠뜨려도 막힙니다.

tsx
dispatch({ type: 'add' });
텍스트
Argument of type '{ type: "add"; }' is not assignable to parameter of type 'CommentAction'.
  Property 'content' is missing in type '{ type: "add"; }' but required in type '{ type: "add"; content: string; }'

'add' 에는 content 가 필요하다고 콕 집어 말해줘요. 판별 유니온이라 갈래마다 필요한 것이 다르다는 걸 알고 있는 겁니다. 'remove' 였다면 id 를 요구했겠죠.

Step 1 의 함정이 여기선 사라집니다

초기값을 다시 보세요.

TypeScript
export const initialCommentState: CommentState = {
  items: [],
  nextId: 1,
};

items: [] 입니다. 빈 배열이에요. Step 1 에서 이게 never[] 가 된다고 했는데 여기선 아무 문제가 없습니다.

왜일까요. 리듀서 함수에 타입을 적어뒀기 때문입니다.

TypeScript
export function commentReducer(state: CommentState, action: CommentAction): CommentState {

useReducer 는 이 함수를 받습니다. 함수가 "나는 CommentState 를 받아서 CommentState 를 돌려준다" 고 이미 밝혔으니, TypeScript 는 초기값을 보고 추측할 필요가 없어요. 초기값은 그저 CommentState 에 맞기만 하면 됩니다.

같은 값을 useState 에 주면 어떻게 될까요.

tsx
const [state, setState] = useState({ items: [], nextId: 1 });

setState({ items: [{ id: 1, content: '노을' }], nextId: 2 });
텍스트
Type '{ id: number; content: string; }' is not assignable to type 'never'.

터집니다. useState 에는 타입을 알려줄 함수가 없으니 첫 값만 보고 정할 수밖에 없고, 빈 배열은 never[] 가 되니까요.

타입을 알려주는 통로가 꺾쇠만 있는 게 아닙니다. 리듀서를 쓰면 함수 시그니처가 그 통로가 됩니다. 상태의 모양을 한 번 적어두면 초기값이든 dispatch 든 전부 거기서 타입을 받아가요.

💡 한 줄 정리

항상 같이 움직여야 하는 값들은 useReducer 로 한 덩어리에 둡니다. 액션을 판별 유니온으로 적어두면 dispatch 가 그 타입을 물려받아, 없는 일이나 정보를 빠뜨린 일을 알릴 수 없게 됩니다.

🙋 학생 질문 — "그럼 상태 두 개는 항상 리듀서로 묶어야 하나요?"

아닙니다. 개수가 기준이 아니에요. 같이 움직여야 하는가가 기준입니다.

PostBody 를 보면 캡션이 펼쳐졌는지를 useToggle 로 들고 있죠. 카드 안에는 댓글 상태도 있고요. 둘 다 같은 카드 안에 있지만 서로 아무 상관이 없습니다. 캡션을 펼쳐도 댓글은 그대로고, 댓글을 달아도 캡션은 그대로예요. 이런 건 따로 두는 게 맞습니다.

반대로 목록과 다음 번호는 한쪽만 바뀌면 곧바로 어긋납니다. 이런 것이 묶을 대상이에요.

판단이 애매하면 이렇게 물어보세요. "한쪽만 바꾸는 코드를 짰을 때, 그게 버그인가?" 버그라면 묶어야 할 값들입니다.


Step 4: "좋아요와 알림을 한 리듀서로"

지난 시간에 남긴 숙제를 풉니다. 알림 문구가 같으면 타이머가 다시 걸리지 않는 문제였어요.

문제를 먼저 다시 봅니다

B-4 시절 App 의 알림 부분입니다.

tsx
// apps/web-spa/src/App.tsx (B-4 시절)
const [toastMessage, setToastMessage] = useState<string | null>(null);

useEffect(() => {
  if (toastMessage === null) {
    return;
  }

  const timerId = setTimeout(() => setToastMessage(null), TOAST_DURATION);

  return () => clearTimeout(timerId);
}, [toastMessage]);

의존성 배열이 [toastMessage] 입니다. 알림 문구가 바뀌면 타이머를 새로 걸죠.

그런데 문구가 안 바뀌면요? 피드를 끝까지 내리면 게시물을 모두 확인했습니다 · 좋아요 1개 라는 알림이 뜹니다. 이 상태에서 2초쯤 뒤에 다시 끝까지 내려보세요. 좋아요 개수가 그대로면 문구도 글자 하나 안 틀리고 똑같습니다.

React 입장에서는 [toastMessage] 가 바뀌지 않았습니다. 같은 문자열이니까요. effect 를 다시 돌릴 이유가 없죠. 그래서 첫 번째 타이머가 그대로 살아 있습니다.

두 판을 나란히 재봤습니다. 끝까지 내리고, 2초 뒤 다시 끝까지 내린 다음 두 번째 알림이 화면에 머문 시간을 잽니다.

텍스트
                          두 번째 알림이 머문 시간

 문자열로 들고 있던 판          1초        첫 타이머가 남은 시간만 쓰고 지워버린다
 오늘 만들 판                   3초        자기 시간을 온전히 채운다

정확히 1초입니다. 첫 알림이 뜬 지 3초가 되는 순간 첫 타이머가 깨어나서, 두 번째 알림을 자기 것인 줄 알고 지워버리거든요. 사용자에겐 알림이 깜빡이다 만 것처럼 보입니다.

왜 이런 일이 생기나

문자열이라서 그렇습니다.

텍스트
 '좋아요 1개' 와 '좋아요 1개' 를 React 가 비교하면
        │
        └─ 같은 값이다  바뀐 게 없다고 판단  effect 를 다시 안 돈다

문자열은 글자가 같으면 같은 값입니다. 그게 정상이에요. 문제는 우리가 "같은 문구지만 새로 뜬 알림" 이라는 뜻을 문자열 하나로 표현할 수 없다는 겁니다.

담아둘 정보가 하나 더 필요한데 useState 하나로는 담을 데가 없어요. Step 3 에서 본 상황과 같습니다.

상태를 묶고, 알림을 객체로

좋아요를 누를 때 무슨 일이 벌어지는지 보면 묶을 이유가 하나 더 있습니다. 게시물 목록이 바뀌고, 동시에 알림도 뜹니다. 둘은 항상 같이 움직여요.

TypeScript
// apps/web-spa/src/lib/feed-state.ts
// 알림을 문자열이 아니라 객체로 들고 있는다.
// 문구가 같아도 새로 만들면 다른 값이 되기 때문이다.
export interface FeedToast {
  message: string;
}

// 좋아요를 누르면 게시물과 알림이 늘 함께 바뀐다 — 그래서 한 덩어리로 둔다
export interface FeedState {
  posts: Post[];
  toast: FeedToast | null;
}

// 피드에서 일어날 수 있는 일 전부. type 하나로 갈라지는 판별 유니온이다
export type FeedAction =
  | { type: 'toggleLike'; id: number }
  | { type: 'reachBottom' }
  | { type: 'dismissToast' };

FeedToast 가 오늘의 열쇠입니다. 안에 든 것은 문자열 하나뿐이라 얼핏 보면 쓸데없이 감싼 것 같아요. 그런데 이 한 겹이 문제를 풉니다.

텍스트
 문자열                          객체

 '좋아요 1개'                    { message: '좋아요 1개' }
 '좋아요 1개'                    { message: '좋아요 1개' }
        │                                  │
   글자가 같으면 같은 값          새로 만들면 다른 값
        │                                  │
   effect 가 안 돈다              effect 가 다시 돈다

B-2 에서 배운 것이 여기서 다시 나옵니다. 그때 toggleLike 가 게시물을 새로 만들어 돌려줬죠. 그대로 고쳤더니 화면이 안 바뀌었고요. 객체는 새로 만들면 다른 값이라는 그 성질을, 이번에는 반대로 이용하는 겁니다.

이제 리듀서입니다.

TypeScript
// apps/web-spa/src/lib/feed-state.ts
export function feedReducer(state: FeedState, action: FeedAction): FeedState {
  switch (action.type) {
    case 'toggleLike': {
      const target = state.posts.find((post) => post.id === action.id);

      if (!target) {
        return state;
      }

      return {
        posts: toggleLike(state.posts, action.id),
        toast: {
          message: target.liked
            ? `${target.username}님의 게시물 좋아요를 취소했습니다`
            : `${target.username}님의 게시물을 좋아합니다`,
        },
      };
    }

    case 'reachBottom':
      return {
        ...state,
        toast: { message: `게시물을 모두 확인했습니다 · 좋아요 ${countLiked(state.posts)}개` },
      };

    case 'dismissToast':
      return { ...state, toast: null };

    default: {
      // 위에서 빠뜨린 일이 있으면 이 줄에서 잡힌다
      const missed: never = action;
      return missed;
    }
  }
}

B-4 의 App 에 흩어져 있던 것이 한군데로 모였습니다. 좋아요를 누르면 무슨 문구가 뜨는지, 끝까지 내리면 무슨 문구가 뜨는지가 전부 이 함수 안에 있어요.

훅으로 감싸기

리듀서를 그대로 쓰면 화면 쪽에 dispatch 와 액션 객체가 흩어집니다. B-3 에서 했던 것처럼 훅으로 한 겹 감싸 이름을 붙여줍니다.

TypeScript
// apps/web-spa/src/hooks/useFeed.ts
export function useFeed(initialPosts: Post[]) {
  const [state, dispatch] = useReducer(feedReducer, createFeedState(initialPosts));
  const likedCount = state.posts.filter((post) => post.liked).length;

  return {
    posts: state.posts,
    toast: state.toast,
    likedCount,
    toggleLike: (id: number) => dispatch({ type: 'toggleLike', id }),
    reachBottom: () => dispatch({ type: 'reachBottom' }),
    dismissToast: () => dispatch({ type: 'dismissToast' }),
  };
}

B-3 의 useLikeToggle 이 좋아요만 알았다면, useFeed 는 알림까지 함께 봅니다. 이름을 바꾼 이유가 그거예요. 이름과 하는 일이 어긋나면 읽는 사람이 헤맵니다.

App 전체가 이렇게 짧아집니다.

tsx
// apps/web-spa/src/App.tsx
import { useEffect } from 'react';
import { Feed } from './components/Feed';
import { Section } from './components/Section';
import { Toast } from './components/Toast';
import { feedPosts } from './data/feed';
import { useFeed } from './hooks/useFeed';
import { useScrollRestore } from './hooks/useScrollRestore';

// 알림이 화면에 머무는 시간
const TOAST_DURATION = 3000;

export function App() {
  const { posts, likedCount, toast, toggleLike, reachBottom, dismissToast } = useFeed(feedPosts);

  // 피드 끝에 닿으면 여기까지 봤다고 알려준다
  useScrollRestore(reachBottom);

  // 브라우저 탭 제목은 React 가 그리는 화면 밖에 있다. 직접 맞춰줘야 한다.
  useEffect(() => {
    document.title = `인스타그램 (좋아요 ${likedCount})`;
  }, [likedCount]);

  // 알림을 띄웠으면 치우는 것까지가 한 일이다.
  useEffect(() => {
    if (toast === null) {
      return;
    }

    const timerId = setTimeout(dismissToast, TOAST_DURATION);

    return () => clearTimeout(timerId);
  }, [toast, dismissToast]);

  return (
    <main className="feed">
      <header className="feed-header">
        <h1 className="feed-title">인스타그램</h1>
        <span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
      </header>
      <Section title="피드">
        <Feed posts={posts} onToggleLike={toggleLike} />
      </Section>
      {toast !== null && <Toast message={toast.message} />}
    </main>
  );
}

B-4 에 있던 handleToggleLike 함수가 통째로 사라졌습니다. 게시물을 찾고 문구를 고르던 그 열 줄이 리듀서 안으로 들어갔거든요. App 은 이제 화면을 그리는 일과 effect 만 남았습니다.

의존성 배열이 [toast, dismissToast] 로 바뀐 것도 보세요. 알림이 새 객체가 되면 effect 가 다시 돌고, 타이머가 새로 걸립니다. 같은 문구가 연달아 떠도 두 번째 알림이 자기 3초를 온전히 챙겨요.

never 가 빠뜨림을 막아줍니다

마지막으로 default 의 그 줄입니다.

TypeScript
    default: {
      const missed: never = action;
      return missed;
    }

앞의 case 들이 모든 갈래를 처리했다면, default 에 도달했을 때 action 에 남은 가능성은 하나도 없습니다. 그래서 never 에 담을 수 있어요.

여기에 나중에 액션을 하나 더한다고 해봅시다. 게시물 저장 기능이 생겨서 { type: 'save'; id: number } 를 유니온에 추가했는데, case 를 안 적었어요. 그러면 이렇게 됩니다.

텍스트
Type '{ type: "save"; id: number; }' is not assignable to type 'never'.

default'save' 가 흘러들어올 수 있게 됐으니 never 에 못 담습니다. 타입 하나만 고치고 구현을 빠뜨렸는데 컴파일러가 잡아준 겁니다.

이게 판별 유니온과 리듀서가 잘 어울리는 이유예요. 액션이 늘어날수록 빠뜨리기 쉬워지는데, 늘어날수록 이 검사가 더 값어치를 합니다.

💡 한 줄 정리

함께 움직이는 값을 한 리듀서에 모으면 흩어져 있던 판단이 한 함수로 모입니다. 알림을 객체로 감싸면 문구가 같아도 새 값이 되어 effect 가 다시 돌고, never 완전성 검사가 액션을 늘릴 때 빠뜨림을 막아줍니다.

🙋 학생 질문 — "타이머가 다시 걸린 게 정말 알림이 새 객체라서인가요? 다른 게 바뀐 건 아니고요?"

좋은 의심입니다. 의존성 배열에 [toast, dismissToast] 두 개가 들어 있으니, dismissToast 가 바뀌어서 effect 가 다시 돈 것일 수도 있잖아요.

확인해봤습니다. dismissToast화면이 다시 그려져도 계속 같은 함수입니다. 바뀌는 건 toast 뿐이에요. 그러니 effect 를 다시 돌린 건 알림 객체가 맞습니다.

왜 같은 함수로 남아 있을까요. useFeed 안에서 화살표 함수로 만들고 있으니 렌더할 때마다 새로 만들어질 것 같은데 말이죠. React Compiler 가 빌드할 때 자동으로 처리해주기 때문입니다. B-1 에서 설정에 넣어둔 그 도구예요. 바뀔 이유가 없는 값은 알아서 같은 것을 다시 씁니다.

이런 걸 손으로 관리하던 시절이 있었는데, 지금은 컴파일러가 합니다. C-4 에서 이 도구가 정확히 무엇을 해주는지, 그리고 드물게 손을 대야 하는 때가 언제인지 제대로 다룹니다.


마무리

오늘 배운 것을 한 줄로 줄이면 이렇습니다. 타입은 값의 모양을 따라갑니다. 상태의 모양을 제대로 잡으면 타입은 대부분 따라오고, 모양이 어긋나 있으면 타입을 아무리 적어도 어색해집니다.

오늘 배운 핵심 세 가지

💡 하나 — 추론은 첫 값까지만 봅니다. '' 이나 0 은 앞으로 들어올 값을 대표하니 맡겨도 되고, null 이나 빈 배열은 대표하지 못하니 내가 적습니다. 판단 기준이 "헷갈리면 적는다" 가 아니라 "첫 값이 대표하는가" 라는 게 중요해요.

💡 둘 — useRef 는 화면에 안 나오는 값을 위한 것입니다. DOM 손잡이든 값 상자든, 바뀌어도 화면은 다시 그려지지 않습니다. 손잡이가 null 로 시작하는 것도, 그래서 물음표가 필요한 것도 전부 "그릴 때는 아직 없다" 는 순서에서 나옵니다.

💡 셋 — 같이 움직여야 하는 값은 같이 둡니다. 개수가 아니라 관계가 기준이에요. 묶고 나면 액션을 판별 유니온으로 적을 수 있고, 그러면 dispatch 가 타입을 물려받고 never 가 빠뜨림을 잡아줍니다.

다음 시간 예고

오늘 우리가 만든 것을 다시 보면 닮은 게 눈에 띕니다.

CommentList 는 댓글 목록을 받아 줄줄이 그립니다. Feed 는 게시물 목록을 받아 줄줄이 그리고요. 하는 일이 똑같습니다. 목록을 받아서, 하나씩 그리고, key 를 붙이죠. 담긴 것이 댓글이냐 게시물이냐만 다릅니다.

그런데 지금은 둘을 따로 만들 수밖에 없었어요. "어떤 타입이든 받는 목록" 을 만들 방법을 아직 모르니까요.

사실 여러분은 그런 걸 써보긴 했습니다. A-3 의 Pick<Post, 'id' | 'imageUrl' | 'mediaKind'> 를 떠올려보세요. Pick 은 어떤 타입이든 받아서 일부를 남겨줍니다. 오늘 쓴 useState<string | null> 도 그렇고요. 꺾쇠 안에 타입을 넣어주면 그에 맞춰 동작하는 것들이었죠.

전부 남이 만들어둔 꺾쇠에 우리가 타입을 넣은 것이었습니다. 다음 시간에는 그 꺾쇠를 우리가 직접 만듭니다. 그러면 CommentListFeed 를 하나로 합칠 수 있어요.

그리고 프로젝트 설정도 한 바퀴 돌아봅니다. tsconfig.json 을 A-1 에서 만들어놓고 제대로 읽어본 적이 없죠. 마지막으로 조금 서늘한 이야기를 하나 할 겁니다. 지금까지 여러분이 돌린 npm run dev 는 타입을 검사한 적이 없습니다. 타입 에러가 있어도 화면은 멀쩡히 떴어요. 왜 그런지, 그럼 검사는 언제 누가 하는지 다음 시간에 확인합니다.


과제

[구현] 좋아요 되돌리기

방금 누른 좋아요를 한 번에 취소하는 기능을 리듀서에 더해보세요. 액션을 새로 만들 때 무엇이 필요한지 손으로 겪어보는 과제입니다.

  • FeedState 에 마지막으로 좋아요를 누른 게시물 번호를 담을 자리를 더해 주세요. 아직 누른 적이 없을 수도 있으니 타입을 잘 골라야 합니다. Step 1 에서 세운 기준을 그대로 쓰면 됩니다.
  • FeedAction{ type: 'undoLike' } 를 더해 주세요. 더하자마자 tsc 를 한 번 돌려보고, 어디서 무슨 에러가 나는지 적어 주세요. 이게 이 과제의 핵심입니다.
  • 그다음 case 'undoLike' 를 구현합니다. 마지막으로 누른 게시물의 좋아요를 되돌리고, 되돌렸습니다 라는 알림을 띄워 주세요. 되돌린 뒤에는 되돌릴 것이 없는 상태가 되어야 합니다.
  • toggleLikereachBottom 이 이 새 값을 어떻게 다뤄야 할지도 생각해 보세요. 좋아요를 누르면 마지막 번호가 갱신되어야겠죠. 피드 끝에 닿은 것은 좋아요와 상관이 없고요.
  • useFeedundoLike 를 더하고, App 머리말 좋아요 개수 옆에 되돌리기 버튼을 놓아 주세요. 되돌릴 것이 없으면 버튼이 눌리지 않아야 합니다. B-3 에서 만든 Buttondisabled 를 이미 받습니다.
  • 브라우저에서 좋아요를 누르고 되돌려 보세요. 개수와 알림이 함께 맞게 움직이는지 확인해 주세요.

다 만들고 나면 npm run typecheck -w web-spanpm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.

[탐구] 추론이 무너지는 자리를 직접 보기

오늘 말로 들은 것을 손으로 확인해 봅시다. 각 단계에서 나온 메시지를 한 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.

  • AppuseFeed 를 잠시 두고, 그 아래에 const [memo, setMemo] = useState(null); 을 적은 뒤 setMemo('안녕') 을 핸들러 안에서 불러 보세요. 에러 메시지에 나오는 SetStateAction<...> 의 꺾쇠 안이 무엇인가요?
  • 같은 자리에서 useState([]) 로 바꾸고 setMemo(['안녕']) 을 해보세요. 이번엔 어떤 타입이 나오나요? 그 타입이 무슨 뜻인지 A-2 를 떠올려 한 줄로 적어 주세요.
  • useCommentInputinputRef.current?.focus() 에서 물음표를 지워 보세요. 무슨 에러가 나나요? 물음표 대신 if 로 막아도 통과하는지 확인해 보세요.
  • RefVsStateDemo 에서 ref +1 을 다섯 번 누른 뒤 state +1 을 한 번 누르면 ref 로 센 수 에 무엇이 뜨나요? 그 숫자가 나온 이유를 적어 주세요.
  • commentReducer 에서 case 'remove' 블록을 통째로 지워 보세요. 어느 줄에 빨간 줄이 그어지나요? 지운 곳이 아니라는 게 중요합니다. 왜 거기서 잡히는지 적어 주세요.
  • initialCommentState: CommentState 애너테이션만 지워 보세요. 아무 일도 일어나지 않습니다. 그런데 같은 초기값을 useState 에 넘기면 Step 3 마지막에서 본 것처럼 터지죠. 둘의 차이가 어디서 오는지 한 줄로 정리해 주세요.

생각해볼 주제

1. 상태 하나가 여러 개보다 항상 나을까

오늘 목록과 다음 번호를 한 덩어리로 묶었습니다. 좋아요와 알림도 묶었고요. 묶으니 어긋날 일이 없어졌죠.

그럼 이 논리를 끝까지 밀면 어떻게 될까요. 화면의 모든 상태를 리듀서 하나에 넣으면 어긋날 일이 아예 없어질 텐데요. 실제로 그렇게 만든 앱도 있습니다.

어디서 선을 그으시겠어요. 그리고 반대로, 지금 PostBody 의 캡션 펼침 상태처럼 따로 두는 게 나은 것들은 무엇이 다른 걸까요.

2. useRef 에 담기로 한 값이 화면에 필요해질 때

useRef 를 고르는 기준은 "이 값이 화면에 안 나온다" 였습니다. 그런데 이 판단은 지금 이 순간의 화면을 보고 내리는 것이죠.

기획이 바뀌어서 그 값을 화면에 띄워야 한다면 어떻게 될까요. useRefuseState 로 바꾸는 건 한 줄이지만, 그 값을 바꾸던 코드들도 함께 손봐야 합니다.

이런 변화를 미리 대비해서 처음부터 useState 로 두는 게 나을까요, 아니면 필요해졌을 때 바꾸는 게 나을까요. 여러분이라면 팀에서 어떻게 이야기하시겠어요.

3. 타입을 어디까지 손으로 적을 것인가

오늘 우리는 "추론이 되면 맡긴다" 를 기준으로 삼았습니다. 그런데 팀에는 반대 의견도 있어요. "함수 반환 타입과 상태 타입은 무조건 적자" 는 규칙을 쓰는 팀이 실제로 많습니다.

그 팀들의 이유도 그럴듯합니다. 적어두면 안쪽 코드를 고쳐도 타입이 멋대로 안 바뀌고, 에러가 고친 곳에서 바로 나거든요.

여러분은 어느 쪽에 서시겠어요. 두 입장이 각각 무엇을 지키려는 것인지, 그리고 프로젝트의 어떤 조건이 판단을 갈라놓을지 생각해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 좋아요 되돌리기

채점 포인트

항목 확인할 것 배점
되돌릴 대상의 타입 number 가 아니라 number | null. 누른 적이 없는 상태를 표현했는가 ★★★
액션을 먼저 더했을 때 never 에서 에러가 났고, 그 위치와 메시지를 적었는가 ★★★
undoLike 구현 좋아요와 개수가 원래대로 돌아오는가 ★★★
되돌린 뒤 상태 다시 null 로 되돌려 두 번 연속 되돌리기가 막히는가 ★★☆
다른 액션의 처리 toggleLike 는 갱신, reachBottom 은 유지 ★★☆
불변성 원본 feedPosts 를 건드리지 않는가 ★★☆
버튼 비활성화 되돌릴 것이 없으면 disabled ★☆☆

풀이 예시

먼저 상태에 담을 곳을 만듭니다. 여기가 이번 과제의 첫 갈림길이에요.

TypeScript
// apps/web-spa/src/lib/feed-state.ts
export interface FeedState {
  posts: Post[];
  toast: FeedToast | null;
  lastLikedId: number | null;
}

number 로만 적으면 "아직 누른 적 없음" 을 표현할 방법이 없습니다. 0 이나 -1 같은 값으로 때우고 싶어지는데, 그러면 읽는 사람이 그 약속을 따로 알아야 해요. Step 1 에서 useState<string | null>(null) 을 쓴 것과 같은 판단입니다.

다음은 액션을 더합니다. 여기서 바로 tsc 를 돌려보는 게 이 과제의 핵심이었어요.

TypeScript
export type FeedAction =
  | { type: 'toggleLike'; id: number }
  | { type: 'reachBottom' }
  | { type: 'dismissToast' }
  | { type: 'undoLike' };

이 상태에서 타입 검사를 돌리면 이렇게 나옵니다.

텍스트
Type '{ type: "undoLike"; }' is not assignable to type 'never'.

리듀서의 default 안, const missed: never = action; 줄입니다. 액션 목록만 늘리고 구현을 안 했다는 걸 컴파일러가 정확히 짚어준 거예요. 타입을 먼저 적고 구현이 따라가는 흐름에서 이 검사가 안전망 노릇을 합니다.

이제 구현합니다.

TypeScript
    case 'undoLike': {
      if (state.lastLikedId === null) {
        return state;
      }

      return {
        posts: toggleLike(state.posts, state.lastLikedId),
        toast: { message: '되돌렸습니다' },
        // 한 번 되돌리면 되돌릴 것이 없어진다
        lastLikedId: null,
      };
    }

toggleLike 를 그대로 다시 부르는 게 요령입니다. 좋아요는 켜고 끄는 값이라 같은 게시물에 한 번 더 부르면 원래대로 돌아와요. 되돌리기 전용 함수를 새로 만들 필요가 없습니다.

lastLikedId === null 일 때 state그대로 돌려주는 것도 중요합니다. { ...state } 로 새 객체를 만들면 바뀐 게 없는데도 화면이 다시 그려져요.

나머지 두 액션도 손봐야 합니다.

TypeScript
    case 'toggleLike': {
      // ...
      return {
        posts: toggleLike(state.posts, action.id),
        toast: { message: likeMessage(target) },
        // 방금 건드린 게시물을 기억해둔다
        lastLikedId: action.id,
      };
    }

reachBottomdismissToast 는 이미 ...state 로 펼치고 있어서 손댈 게 없습니다. 되돌릴 대상이 자동으로 유지돼요. 스크롤을 내렸다고 되돌리기가 사라지면 이상하겠죠.

훅과 화면은 이렇게 이어집니다.

TypeScript
// apps/web-spa/src/hooks/useFeed.ts
  return {
    posts: state.posts,
    toast: state.toast,
    likedCount,
    canUndo: state.lastLikedId !== null,
    toggleLike: (id: number) => dispatch({ type: 'toggleLike', id }),
    undoLike: () => dispatch({ type: 'undoLike' }),
    reachBottom: () => dispatch({ type: 'reachBottom' }),
    dismissToast: () => dispatch({ type: 'dismissToast' }),
  };

canUndo 를 훅에서 계산해 내보내는 게 깔끔합니다. likedCount 와 같은 방식이에요. 화면 쪽이 lastLikedId !== null 이라는 조건을 직접 알 필요가 없습니다.

tsx
// apps/web-spa/src/App.tsx
        <span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
        <Button onClick={undoLike} disabled={!canUndo}>
          되돌리기
        </Button>

자주 나오는 실수

lastLikedIdnumber 로 두고 0 을 "없음" 으로 쓰기 — 동작은 합니다. 그런데 게시물 번호가 0 부터 시작하는 날 조용히 깨져요. 타입으로 표현할 수 있는 것을 관습으로 미루면 그렇게 됩니다.

되돌린 뒤 lastLikedId 를 그대로 두기 — 되돌리기를 두 번 누르면 좋아요가 다시 켜집니다. 되돌리기가 아니라 토글이 되는 거죠.

toggleLike 에서 좋아요를 켤 때만 기억하기 — 취소도 되돌릴 수 있어야 자연스럽습니다. minji 게시물은 처음부터 좋아요가 눌려 있으니, 취소했다가 되돌리는 흐름을 직접 눌러보면 바로 확인됩니다.

액션만 더하고 tsc 를 안 돌려본 경우 — 답은 맞을 수 있지만 이 과제가 보여주려던 것을 놓쳤습니다. never 가 언제 어디서 잡아주는지를 눈으로 보는 게 목적이었어요.

💡 튜터의 한마디

이 과제에서 진짜 배울 것은 undoLike 구현이 아니라 순서입니다. 타입을 먼저 늘리고, 컴파일러가 어디를 짚는지 보고, 그 자리를 채웠죠. 리듀서가 커질수록 이 순서가 더 값어치를 합니다. 액션이 열 개쯤 되면 하나 빠뜨렸는지 눈으로는 못 찾거든요.

🎯 [과제 2 예시답안] 추론이 무너지는 자리를 직접 보기

채점 포인트

항목 확인할 것 배점
SetStateAction<...> 꺾쇠 안이 null 임을 확인했는가 ★★★
빈 배열의 never never 의 뜻을 A-2 와 연결해 설명했는가 ★★★
물음표와 if 둘 다 통과한다는 것을 확인했는가 ★★☆
ref 다섯 번 숫자와 그 이유를 함께 적었는가 ★★★
case 삭제 위치 지운 곳이 아니라 never 줄에서 잡힌다는 것 ★★★
마지막 항목 아무 일도 안 일어나는 이유를 리듀서 시그니처로 설명했는가 ★★★

풀이 예시

첫째 — useState(null)

텍스트
Argument of type '"안녕"' is not assignable to parameter of type 'SetStateAction<null>'.

꺾쇠 안이 null 입니다. 첫 값이 null 이었으니 상태의 타입도 null 로 굳었어요. 이 상태에는 null 말고는 아무것도 넣을 수 없습니다.

둘째 — useState([])

텍스트
Type 'string' is not assignable to type 'never'.

never"여기 올 수 있는 값이 하나도 없다" 는 뜻입니다. A-2 에서 판별 유니온의 갈래를 전부 처리했을 때 남는 것이 never 였죠. 거기서는 "더 남은 경우가 없다" 는 뜻이었고, 여기서는 "이 배열에 담길 수 있는 것이 없다" 는 뜻입니다. 빈 배열에는 원소가 없어서 TypeScript 가 원소 타입을 알아낼 단서가 하나도 없었던 거예요.

두 상황의 공통점은 가능성이 0 인 곳이라는 겁니다.

셋째 — 물음표 지우기

텍스트
'inputRef.current' is possibly 'null'.

if (inputRef.current) { inputRef.current.focus(); } 로 바꿔도 통과합니다. 둘은 같은 일을 해요. null 인 동안은 아무것도 안 하겠다고 밝히는 것이죠. 표현만 다릅니다.

한 줄이면 물음표가 짧고, 안에서 여러 줄을 해야 하면 if 가 읽기 좋습니다.

넷째 — ref 다섯 번

텍스트
 ref +1 을 다섯 번 누른 뒤        state +1 을 한 번 더 누른 뒤

 state 로 센 수: 0                state 로 센 수: 1
 ref 로 센 수:   0                ref 로 센 수:   5

5 입니다. 값은 다섯 번 다 올라가 있었어요. 다만 useRef 의 값이 바뀌는 것은 React 가 화면을 다시 그릴 이유가 되지 않습니다. state +1 이 화면을 다시 그리게 만들자 그동안 쌓인 값이 그제야 화면에 나온 겁니다.

"5가 그때 만들어진 것" 이 아니라 "5가 계속 거기 있었는데 화면이 안 따라갔던 것" 이라고 적었으면 정확합니다.

다섯째 — case 'remove' 지우기

텍스트
src/lib/comments.ts(31,13): error TS2322: Type '{ type: "remove"; id: number; }' is not assignable to type 'never'.

지운 곳이 아니라 default 안의 const missed: never = action;에서 잡힙니다.

이유를 따라가 보면 이렇습니다. case 'remove' 가 사라지자 'remove' 액션이 default 까지 흘러올 수 있게 됐어요. 그러면 default 에 도달한 action 은 더 이상 "가능성 없음" 이 아닙니다. never 에 담을 수 없죠.

빠뜨린 곳이 아니라 빠뜨림을 감시하는 곳에서 알람이 울리는 구조입니다. 화재경보기가 불난 방이 아니라 천장에 붙어 있는 것과 같아요.

여섯째 — 애너테이션 지우기

아무 일도 일어나지 않습니다.

useReducer 는 리듀서 함수를 함께 받는데, 그 함수가 (state: CommentState, action: CommentAction): CommentState 라고 이미 밝혀뒀거든요. 초기값이 무슨 타입인지 추측할 필요가 없습니다. CommentState 에 맞기만 하면 되고, { items: [], nextId: 1 } 은 맞습니다.

반면 useState 에는 그런 함수가 없습니다. 첫 값 하나가 유일한 단서라 빈 배열이 곧바로 never[] 가 돼요.

같은 빈 배열인데 한쪽은 괜찮고 한쪽은 터지는 이유는, 타입을 알려줄 통로가 하나 더 있느냐 없느냐입니다.

💡 튜터의 한마디

여섯 개를 관통하는 게 하나 있습니다. TypeScript 는 우리가 준 단서만 봅니다. 첫 값이 단서일 때도 있고, 함수 시그니처가 단서일 때도 있고, 우리가 직접 적은 꺾쇠가 단서일 때도 있어요.

에러가 났을 때 "왜 이걸 모르지" 하고 답답해하기 전에 "내가 이걸 알려준 적이 있나" 를 먼저 물어보세요. 대부분 안 알려준 겁니다.


🤔 [생각해볼 주제 1] 상태 하나가 여러 개보다 항상 나을까

문제 상황 요약

오늘 두 번 묶었습니다. 목록과 다음 번호를 묶었고, 게시물과 알림을 묶었죠. 묶으니 어긋날 일이 사라졌습니다. 그럼 전부 묶으면 되는 것 아니냐는 질문이 자연스럽게 나옵니다.

튜터의 가이드 및 해설

묶는 것에는 값이 있고 대가가 있습니다. 둘을 같이 봐야 해요.

묶어서 얻는 것은 "어긋날 수 없음" 입니다. 목록과 번호가 한 리듀서 안에 있으면, 한쪽만 바꾸는 코드를 짜는 것 자체가 어려워집니다. 상태를 바꾸는 통로가 액션 하나뿐이니까요.

대가는 두 가지입니다.

첫째, 상관없는 것들이 서로를 다시 그리게 만듭니다. 캡션 펼침과 댓글 목록을 한 상태에 넣으면, 캡션을 펼쳤을 뿐인데 댓글 쪽 코드도 다시 돕니다. 지금 규모에선 티가 안 나지만 목록이 길어지면 달라져요.

둘째, 이게 더 중요한데 읽는 사람이 상태를 이해하는 비용이 올라갑니다. 상태가 스무 개 필드짜리 객체 하나면, "지금 이 컴포넌트가 실제로 쓰는 게 뭐지" 를 알려면 전부 읽어야 해요. 작게 나뉘어 있으면 이름만 봐도 압니다.

그래서 기준은 개수가 아니라 관계입니다. 세 단계로 물어보세요.

텍스트
  한쪽만 바뀌면 버그인가?
      YES  묶는다 (오늘 한 것)
      NO   

  항상 같은 사건 때문에 함께 바뀌는가?
      YES  묶는 것을 고려 (아직은 따로 둬도 됨)
      NO   

  그냥 같은 화면에 있을 뿐인가?
       따로 둔다

PostBody 의 캡션 펼침이 ③ 입니다. 댓글과 같은 카드 안에 살지만 서로 아무 관계가 없어요. 이걸 묶으면 얻는 것 없이 대가만 치릅니다.

실무에서 큰 리듀서가 문제가 되는 지점도 알아두면 좋습니다. 상태 하나에 모든 걸 넣은 코드는 처음엔 깔끔해 보이는데, 기능이 늘면서 액션이 40개쯤 되면 아무도 전체를 못 읽습니다. 그때는 화면 단위로 리듀서를 나누는 것이 답이지, 하나를 더 크게 키우는 게 아니에요.

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

"상태를 묶는 기준은 개수가 아니라 불변식이라고 봅니다. '한쪽만 바뀌면 그건 버그다' 라고 말할 수 있는 값들이 한 덩어리예요. 그런 관계가 없는데 그냥 같은 화면에 있다는 이유로 묶으면, 얻는 것 없이 상관없는 리렌더와 읽기 비용만 늘어납니다."

🤔 [생각해볼 주제 2] useRef 에 담기로 한 값이 화면에 필요해질 때

문제 상황 요약

useRef 를 고른 근거는 "이 값은 화면에 안 나온다" 였습니다. 그런데 이건 지금 이 순간의 화면을 보고 내린 판단이죠. 기획은 바뀝니다.

튜터의 가이드 및 해설

먼저 바꾸는 비용이 실제로 얼마인지 보겠습니다. 생각보다 크지 않아요.

텍스트
 useRef                              useState

 const c = useRef(0);               const [c, setC] = useState(0);
 c.current += 1;                    setC(c + 1);
 ...c.current...                    ...c...

읽는 곳은 .current 만 떼면 되고, 쓰는 곳은 setC 로 바꾸면 됩니다. 기계적인 작업이에요. 그리고 하나라도 빠뜨리면 컴파일러가 잡아줍니다. .current 를 안 뗀 곳은 곧바로 타입 에러가 나거든요.

그래서 저는 필요해졌을 때 바꾸는 쪽을 권합니다. 이유가 셋입니다.

첫째, 바꾸는 비용이 낮고 안전합니다. 위에서 본 대로 컴파일러가 안내해줘요.

둘째, 미리 useState 로 두면 지금 당장 대가를 치릅니다. 화면에 안 나오는 값을 상태로 두면 그 값이 바뀔 때마다 화면이 통째로 다시 그려져요. 일어날지 안 일어날지 모르는 변화에 대비하느라 확실한 낭비를 사는 셈입니다.

셋째, 이게 가장 중요한데 타입 선택은 의도를 적는 일입니다. useRef 라고 적혀 있으면 "이건 화면에 안 나오는 값이다" 라는 판단이 코드에 남아요. 혹시 몰라서 useState 로 두면 그 판단이 사라지고, 다음 사람은 "이게 화면 어디에 나오지?" 하고 찾아다니게 됩니다.

다만 예외가 있습니다. 판단이 진짜로 애매할 때는 useState 로 시작하세요. 예를 들어 로딩 상태 같은 건 지금은 화면에 안 띄우더라도 곧 스피너를 붙일 게 뻔합니다. "안 나온다" 가 아니라 "아직 안 붙였다" 인 거죠. 이 둘을 구분하는 게 요령입니다.

팀에서 이야기할 때는 이렇게 물어보면 정리가 빠릅니다. "이 값이 화면에 안 나오는 게 설계인가요, 아직 안 만든 건가요?"

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

"useRef 로 둘지 useState 로 둘지는 성능 문제가 아니라 의도를 남기는 문제라고 생각합니다. useRef 는 '이 값은 화면과 무관하다' 는 판단을 코드에 새겨두는 것이고, 나중에 그 판단이 바뀌면 컴파일러가 고칠 곳을 전부 짚어줍니다. 혹시 몰라서 useState 로 두면 그 판단만 사라지고 불필요한 리렌더는 그대로 남아요."

🤔 [생각해볼 주제 3] 타입을 어디까지 손으로 적을 것인가

문제 상황 요약

오늘 기준은 "추론이 되면 맡긴다" 였습니다. 그런데 실무에는 "반환 타입과 상태 타입은 무조건 적는다" 를 규칙으로 둔 팀이 꽤 많습니다. 둘 다 나름의 이유가 있어요.

튜터의 가이드 및 해설

두 입장이 지키려는 것이 다릅니다. 거기서부터 봐야 정리가 됩니다.

"맡기자" 가 지키려는 것은 한 곳에서만 고치기입니다. 타입을 여러 군데 적어두면 값을 고칠 때 타입도 같이 고쳐야 해요. 두 곳이 어긋나면 그게 또 버그가 됩니다. 추론에 맡기면 값만 고치면 타입이 따라옵니다.

"적자" 가 지키려는 것은 에러가 나는 위치입니다. 이게 핵심이에요. A-3 의 toCardView 로 직접 확인해볼 수 있습니다. 이 함수는 반환 타입을 안 적었죠.

headlineheadlien 으로 오타냈다고 해봅시다. 함수 자체는 아무 불평 없이 통과합니다. 반환 타입이 오타에 맞춰 따라 바뀌니까요. 에러는 그 값을 읽는 쪽에서 납니다.

텍스트
Property 'headline' does not exist on type '{ headlien: string; caption: string; ... }'.
  Did you mean 'headlien'?

마지막 줄을 보세요. 제안이 거꾸로 나옵니다. 오타 난 이름을 정답으로 알고 "headlien 을 쓰려던 것 아니냐" 고 묻고 있어요. 컴파일러 입장에서는 오타가 이미 사실이 됐으니 당연한 반응입니다.

반환 타입을 적어뒀다면 이렇게 됩니다.

텍스트
Object literal may only specify known properties, but 'headlien' does not exist in type 'CardViewShape'.
  Did you mean to write 'headline'?

오타를 낸 그 줄에서 잡히고, 제안도 올바른 방향입니다. 적어둔 타입이 "여기서 벗어나면 여기서 알려줘" 라는 기준선 노릇을 한 거예요.

그래서 프로젝트 조건에 따라 답이 갈립니다.

조건 기우는 쪽
여러 팀이 함께 쓰는 라이브러리·공용 모듈 적자 — 경계는 명시적이어야 한다
한 화면 안에서만 쓰는 지역 상태 맡기자 — 손만 늘어난다
사람이 자주 오가는 큰 팀 적자 — 읽는 사람이 함수 안을 안 봐도 된다
빠르게 모양이 바뀌는 초기 코드 맡기자 — 고칠 곳이 하나

제 권장은 경계에는 적고 안쪽은 맡기는 것입니다. 모듈 밖으로 나가는 것, 다른 사람이 쓰는 것, props 와 API 응답에는 적습니다. 컴포넌트 안에서만 사는 useState('') 같은 것은 맡깁니다.

그리고 규칙을 정할 때 한 가지 덧붙이면 좋습니다. "무조건" 을 붙이는 순간 신호가 사라진다는 점이요. 오늘 우리가 꺾쇠 두 개를 보고 "여기는 추론이 안 되는구나" 하고 알아챈 것처럼, 예외적으로 적힌 타입은 그 자체로 정보를 줍니다. 전부 적으면 그 정보가 없어져요.

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

"저는 경계에는 타입을 적고 안쪽은 추론에 맡기는 쪽입니다. 명시한 타입의 진짜 값어치는 문서화보다 에러가 터지는 위치를 앞당기는 데 있다고 봐요. 반환 타입을 적어두면 오타를 낸 그 줄에서 바로 잡히지만, 추론에 맡기면 오타가 반환 타입이 되어버려서 그 값을 읽는 쪽에서야 터집니다. 심지어 자동 수정 제안이 오타를 정답으로 알고 거꾸로 나와요. 대신 안쪽 지역 상태까지 전부 적으면 고칠 곳만 늘고, 예외적으로 적힌 타입이 주던 신호도 묻힙니다."

전체 목록 리액트