문서 읽는 데 57분 · A6

A-6: 제네릭 컴포넌트와 타입 설정 — 꺾쇠를 직접 만들고, 검사를 켠다

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

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

안녕하세요, 홍순구입니다. 지난 시간 마무리에서 제가 이런 말을 했죠. 여러분이 지금까지 쓴 꺾쇠는 전부 남이 만들어둔 것에 타입을 넣은 것이었다고요.

Pick<Post, 'id' | 'imageUrl' | 'mediaKind'> 도, useState<string | null> 도 그랬습니다. 누군가 꺾쇠 자리를 만들어뒀고 우리는 거기 타입을 넣기만 했어요. 오늘은 그 자리를 직접 만듭니다.

만들고 나면 바로 쓸 데가 있습니다. CommentListFeed 를 열어놓고 나란히 보세요. 목록을 받아서, 하나씩 그리고, key 를 붙입니다. 똑같은 일을 두 번 적어놨어요. 담기는 것이 댓글이냐 게시물이냐만 다릅니다.

그리고 수업 후반에는 조금 서늘한 이야기를 합니다. 지금까지 여러분이 돌린 npm run dev 는 타입을 한 번도 검사한 적이 없습니다. 에디터에 빨간 줄이 그어져 있어도 화면은 멀쩡히 떴어요. 왜 그런지, 그럼 검사는 누가 언제 하는지 오늘 확인합니다.

텍스트
 오늘의 여정

 Step 1   타입 매개변수를 직접 만든다 · 아무거나 받지 않게 제약을 건다
    │
 Step 2   제네릭 컴포넌트 List<T> · 댓글 목록과 피드를 하나로
    │
 Step 3   남이 만든 컴포넌트의 props 를 물려받는다
    │
 Step 4   tsconfig.json 을 한 줄씩 읽는다
    │
 Step 5   번들러는 타입을 검사하지 않는다
    
 중복이 걷히고 검사가 켜진 인스타 피드

💡 오늘 수업의 핵심 — "타입을 재료로 받으면 코드가 하나로 합쳐진다"

값을 매개변수로 받으면 함수 하나로 여러 값을 다루죠. 타입도 똑같이 매개변수로 받을 수 있습니다. 그러면 하는 일이 같고 타입만 다른 코드가 하나로 합쳐집니다.

🎯 학습 목표

  • 타입 매개변수를 직접 선언하고, extends 로 받을 수 있는 타입을 제한할 수 있다
  • 어떤 타입의 목록이든 그리는 제네릭 컴포넌트를 만들고, .tsx 에서 주의할 곳을 안다
  • tsconfig.json 의 핵심 옵션을 읽고, 타입 검사가 언제 누구에 의해 도는지 설명할 수 있다

Step 1: "타입도 매개변수로 받을 수 있습니다"

지난 시간에 만든 리듀서 두 개를 나란히 놓고 보겠습니다. 각각에서 한 줄씩만 뽑았어요.

TypeScript
// 피드 리듀서 — 번호로 게시물 하나 찾기
const target = state.posts.find((post) => post.id === action.id);

// 댓글 리듀서 — 번호로 댓글 하나 빼기
items: state.items.filter((item) => item.id !== action.id),

하는 일이 똑같습니다. 번호를 받아서 그 번호를 가진 항목을 찾거나 뺍니다. 다른 건 담긴 것이 게시물이냐 댓글이냐 뿐이에요.

값 매개변수는 이미 알고 있습니다

함수를 만드는 이유를 떠올려보면 답이 보입니다.

텍스트
 이렇게 두 번 적는 대신                     이렇게 한 번 적고

   1240 * 2                                function double(n: number) {
   8500 * 2                                  return n * 2;
                                            }

    달라지는 값을 매개변수로 뽑는다         double(1240) · double(8500)

달라지는 것이 이면 값 매개변수로 뽑습니다. 그런데 지금 우리 경우엔 달라지는 것이 값이 아니라 타입이에요. 그러면 타입을 매개변수로 뽑으면 됩니다.

타입 매개변수 만들기

이름 옆에 꺾쇠를 열고 이름을 하나 적으면 그게 타입 매개변수입니다.

TypeScript
// apps/web-spa/src/lib/collections.ts
export function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

<T> 부분이 새로 생긴 것이고 나머지는 평범한 함수입니다. 읽는 법은 이래요.

텍스트
 findById <T extends { id: number }> (items: T[], id: number): T | undefined
           └──────┬──────┘  └───┬───┘        └┬┘             └────┬────┘
                  │             │             │                   │
           타입 매개변수      받을 수 있는    T 가 담긴 배열     찾으면 T,
           이름은 T          타입의 조건                        없으면 undefined

T 는 그냥 이름입니다. Item 이라고 해도 되고 Post 라고 해도 돼요. 관습적으로 짧게 T 를 씁니다. 값 매개변수에 n 이나 x 를 쓰는 것과 같습니다.

중요한 건 부르는 쪽이 T 를 안 적어도 된다는 겁니다.

TypeScript
findById(feedPosts, 2)   // T 가 Post 로 정해진다 — 돌려주는 것도 Post | undefined
findById(comments, 1)    // T 가 DraftComment 로 정해진다

넘긴 배열을 보고 TypeScript 가 알아서 정합니다. 지난 시간에 배운 추론이 여기서도 그대로 도는 거예요.

extends { id: number } 를 붙였을까

제약을 빼고 그냥 <T> 로만 써보면 알 수 있습니다.

TypeScript
export function findByIdUnconstrained<T>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}
텍스트
Property 'id' does not exist on type 'T'.

당연한 말입니다. T 는 "나중에 정해질 무언가" 인데, 아직 뭔지 모르니 id 가 있는지도 알 수 없어요. 모르는 것의 속을 들여다보려 하면 막힙니다.

extends { id: number } 는 여기에 대한 약속입니다. "아무거나 다 받지는 않겠다. id 를 가진 것만 받겠다" 는 뜻이에요. 조건을 걸었으니 그 안에서는 id 를 꺼내 써도 됩니다.

약속을 어긴 걸 넘기면 부르는 쪽에서 걸립니다.

TypeScript
const tags = [{ name: '한강' }, { name: '노을' }];

findById(tags, 1);
텍스트
Argument of type '{ name: string; }[]' is not assignable to parameter of type '{ id: number; }[]'.
  Property 'id' is missing in type '{ name: string; }' but required in type '{ id: number; }'

무엇이 모자란지 정확히 말해줍니다.

any 와 무엇이 다른가

"그냥 any[] 로 받으면 되지 않나요?" 하는 생각이 들 수 있습니다. 실제로 에러는 안 나요.

텍스트
 any 로 받으면                          제네릭으로 받으면

 findById(feedPosts, 2)                 findById(feedPosts, 2)
      any                                   Post | undefined
        │                                       │
   .usernme 오타도 통과                   .usernme 는 곧바로 막힘
   .liked 자동완성 없음                   .liked 자동완성 나옴

any검사를 끄는 것이고 제네릭은 검사를 미루는 것입니다. any 를 쓰면 그 뒤로 타입이 사라지지만, 제네릭은 부르는 순간 구체적인 타입으로 되살아나요. 넣은 것이 게시물이면 나오는 것도 게시물입니다.

두 리듀서에 적용하기

이제 지난 시간 코드에서 중복이 걷힙니다.

TypeScript
// apps/web-spa/src/lib/feed-state.ts
      const target = findById(state.posts, action.id);
TypeScript
// apps/web-spa/src/lib/comments.ts
        items: removeById(state.items, action.id),

각자 다른 타입을 다루는데 같은 함수를 씁니다. 그리고 target 은 여전히 Post 라서 target.username 을 그대로 꺼낼 수 있어요.

💡 한 줄 정리

달라지는 것이 값이면 값 매개변수로, 타입이면 타입 매개변수로 뽑습니다. extends 로 조건을 걸면 그 안에서는 조건에 적힌 것을 꺼내 쓸 수 있고, any 와 달리 부르는 쪽의 타입이 그대로 살아 돌아옵니다.

🙋 학생 질문 — "튜터님, T 말고 이름을 길게 지어도 되나요?"

됩니다. findById<Item extends { id: number }> 라고 써도 똑같이 동작해요.

다만 관습이 좀 갈립니다. 타입 매개변수가 하나뿐이고 "무엇이든 담기는 것" 정도의 의미라면 T 가 읽기 편합니다. 짧아서 함수 시그니처가 한눈에 들어오거든요.

반대로 매개변수가 여러 개거나 각각 역할이 뚜렷하면 이름을 지어주는 게 낫습니다. 예를 들어 리듀서를 직접 만들 때 <S, A> 라고 쓰는데, 이게 StateAction 이라는 걸 알기 전엔 암호처럼 보이죠. <State, Action> 이라고 적으면 주석이 필요 없어집니다.

기준은 단순합니다. T 하나면 짧게, 둘 이상이면 이름으로.


Step 2: "어떤 타입의 목록이든 그리는 컴포넌트"

함수에 타입 매개변수를 붙였으니 컴포넌트에도 붙일 수 있습니다. 컴포넌트도 결국 함수니까요.

먼저 우리가 없애려는 중복을 정확히 보겠습니다.

텍스트
 CommentList 가 하는 일              Feed 가 하는 일

 댓글 배열을 받는다                  게시물 배열을 받는다
 하나씩 돈다                         하나씩 돈다
 key 를 붙인다                       key 를 붙인다
 한 줄을 그린다                      카드 하나를 그린다
       │                                   │
       └───────── 다른 건 딱 두 가지 ──────┘
                  담기는 타입 · 한 줄을 그리는 방법

두 가지가 다르니, 그 두 가지를 밖에서 받으면 됩니다. 타입은 타입 매개변수로, 그리는 방법은 함수 props 로.

List 만들기

tsx
// apps/web-spa/src/components/List.tsx

// 담기는 것의 타입은 쓰는 쪽이 정한다 — 여기서는 T 라는 이름으로만 비워둔다.
// 다만 아무것이나 받지는 않는다. key 로 쓸 id 가 있어야 한다.
interface ListProps<T extends { id: number }> {
  items: T[];
  // 한 줄을 어떻게 그릴지는 쓰는 쪽이 알려준다
  renderItem: (item: T) => React.ReactNode;
  className?: string;
  // 화면 읽어주는 도구에 이 목록이 무엇인지 알려준다
  'aria-label'?: string;
}

export function List<T extends { id: number }>({
  items,
  renderItem,
  className,
  'aria-label': ariaLabel,
}: ListProps<T>) {
  return (
    <ul className={className} aria-label={ariaLabel}>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

interface ListProps<T ...> 처럼 인터페이스에도 타입 매개변수를 붙일 수 있습니다. A-3 에서 쓰던 Pick<...> 이 이런 식으로 만들어진 거예요. 이제 그 안쪽이 어떻게 생겼는지 보이시죠.

renderItem: (item: T) => React.ReactNode 이 오늘의 요령입니다. 한 줄을 어떻게 그릴지는 List 가 모릅니다. 몰라도 되고요. 그건 쓰는 쪽이 넘겨줍니다.

extends { id: number } 를 여기도 건 이유는 key={item.id} 때문입니다. B-2 에서 key 에 index 를 썼다가 항목을 지웠을 때 상태가 옆줄로 옮겨간 것 기억하시죠. 그래서 id 를 요구하고, 요구했으니 안심하고 꺼내 씁니다.

댓글 목록에 적용하기

tsx
// apps/web-spa/src/components/CommentList.tsx
export function CommentList({ comments, onRemove }: CommentListProps) {
  return (
    <List
      items={comments}
      className="comment-list"
      renderItem={(comment) => (
        <>
          <strong>me</strong> {comment.content}
          {onRemove && (
            <IconButton
              className="comment-remove"
              aria-label="댓글 삭제"
              onClick={() => onRemove(comment.id)}
            >
              ×
            </IconButton>
          )}
        </>
      )}
    />
  );
}

<ul><li>key 가 사라졌습니다. 그건 List 가 하니까요. CommentList 에는 한 줄이 어떻게 생겼는지만 남았습니다.

여기서 눈여겨볼 곳이 있어요. renderItem={(comment) => ...}comment 에 타입을 안 적었는데 comment.content 가 통합니다. items={comments} 에서 TDraftComment 로 정해졌고, 그게 renderItem 까지 흘러온 거예요.

없는 필드를 꺼내보면 확인됩니다.

텍스트
Property 'username' does not exist on type 'DraftComment'.

comment 가 진짜로 DraftComment 라는 걸 TypeScript 가 알고 있습니다.

피드에도 그대로

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

같은 List 인데 이번엔 게시물이 담깁니다. 지난 시간까지 두 파일에 나뉘어 있던 "줄줄이 그리기" 가 한 곳으로 모였어요.

덤도 하나 생겼습니다. 피드가 이제 진짜 목록(<ul>)이 되어서, 화면을 읽어주는 도구가 "목록, 항목 2개" 라고 알려줍니다. 예전엔 카드가 그냥 나열돼 있어서 그런 안내가 없었어요.

⚠️ .tsx 에서 딱 한 곳 조심할 것

제네릭을 화살표 함수로 쓸 때, 그것도 제약을 안 걸었을 때 함정이 있습니다.

tsx
const List = <T>({ items }: ListProps<T>) => {
텍스트
JSX element 'T' has no corresponding closing tag.

.tsx 파일에서 <T> 는 파서 입장에서 <div> 와 구별이 안 됩니다. 그래서 JSX 태그로 읽고 "닫는 태그가 없다" 고 하는 거예요.

해결은 쉼표 하나입니다. <T,> 라고 쓰면 태그일 수 없는 모양이 되니 타입 매개변수로 읽힙니다.

tsx
const List = <T,>({ items }: ListProps<T>) => {

그런데 여기서 한 걸음 더 들어가면 재밌는 게 있습니다. 제약을 걸면 쉼표가 필요 없어요.

tsx
const List = <T extends { id: number }>({ items }: ListProps<T>) => {

이건 그냥 통과합니다. <T extends 까지 읽는 순간 파서가 "JSX 태그에는 extends 가 올 수 없다" 는 걸 알거든요. 헷갈릴 여지가 사라지는 겁니다.

정리하면 이렇습니다.

텍스트
 .tsx 에서 제네릭을 쓸 때

 function List<T>(...)                    안전 — function 뒤는 태그일 수 없다
 function List<T extends {...}>(...)      안전

 const List = <T>(...) =>                 ⚠️ TS17008 — 태그로 읽힌다
 const List = <T,>(...) =>                안전 — 쉼표가 태그 가능성을 없앤다
 const List = <T extends {...}>(...) =>   안전 — extends 가 태그 가능성을 없앤다

우리 코드는 위 두 줄 다 해당합니다. function 선언이고 제약도 걸었으니 애초에 걸릴 일이 없어요.

다른 사람 코드에서 <T,> 라는 어색한 쉼표를 보면 "아, 화살표로 쓰면서 제약을 안 걸었구나" 하고 알아보시면 됩니다.

💡 한 줄 정리

컴포넌트도 함수라 타입 매개변수를 붙일 수 있습니다. 타입은 <T> 로, 달라지는 동작은 함수 props 로 받으면 하는 일이 같은 목록 컴포넌트가 하나로 합쳐집니다.

🙋 학생 질문 — "List 하나로 합쳤는데, 나중에 목록마다 다르게 그려야 하면 어쩌죠?"

좋은 걱정입니다. 합치는 것에는 늘 이 위험이 따라와요.

지금은 renderItem 하나로 충분합니다. 하지만 "댓글 목록에만 구분선을 넣어달라", "피드에만 무한 스크롤을 붙여달라" 같은 요구가 쌓이면 어떻게 될까요. List 에 props 가 하나씩 늘어나고, 안에는 if 가 생깁니다. 그러다 보면 합쳐놓은 게 오히려 읽기 어려워지는 순간이 옵니다.

기준은 이렇게 잡으시면 됩니다. props 로 넘기는 "다른 점" 이 셋을 넘어가면 합친 것을 의심하세요. 하나로 묶어둔 이득보다 그 안의 분기를 읽는 비용이 커진 겁니다.

그때는 다시 나누면 됩니다. 나누는 건 언제든 할 수 있어요. B-3 에서 컴포넌트를 쪼갤 때 세운 기준과 같습니다. 중복 그 자체가 나쁜 게 아니라, 같이 바뀌어야 하는 것이 떨어져 있는 게 나쁜 거예요.


Step 3: "남이 만든 컴포넌트의 props 를 물려받기"

우리 화면에 글자 없이 기호만 있는 버튼이 두 개 있습니다. 게시물 머리의 와 댓글 옆의 × 요.

이런 버튼은 화면을 읽어주는 도구에 문제가 됩니다. 를 읽어봐야 사용자는 무슨 버튼인지 모르니까요. 그래서 B-3 에서 Buttonaria-label 을 넣어뒀습니다.

tsx
// apps/web-spa/src/components/Button.tsx (B-3 시절)
  // 글자 대신 기호만 보이는 버튼은 읽어줄 이름을 따로 준다
  'aria-label'?: string;

물음표가 붙어 있죠. 안 넘겨도 됩니다. 글자가 있는 버튼은 필요 없으니까요.

문제는 기호만 있는 버튼에서도 안 넘겨도 통과한다는 겁니다. 깜빡하면 그대로 나갑니다. 화면에는 아무 문제가 없어 보여서 눈으로는 못 잡아요.

물려받아서 좁히기

aria-label 이 필수인 버튼을 따로 만들면 됩니다. 그런데 Button 의 props 를 전부 다시 적어야 할까요?

tsx
// 이렇게 베껴 적으면?
interface IconButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  type?: 'button' | 'submit';
  className?: string;
  'aria-label': string;
}

동작은 합니다. 그런데 나중에 Button 에 props 를 하나 더하면 여기도 더해야 해요. 안 더하면 두 개가 조용히 어긋납니다. A-3 에서 배운 그 문제예요. 같은 것을 두 곳에 적으면 반드시 갈라집니다.

그래서 베끼는 대신 물려받습니다.

tsx
// apps/web-spa/src/components/IconButton.tsx
import type { ComponentProps } from 'react';
import { Button } from './Button';

// Button 이 받는 props 를 그대로 물려받는다. 여기에 다시 적지 않는다.
// 다만 글자 대신 기호만 보이는 버튼이라, 읽어줄 이름은 있으면 좋은 게 아니라 필수다.
type IconButtonProps = ComponentProps<typeof Button> & {
  'aria-label': string;
};

// 하는 일이 거의 없다. 타입 한 곳을 좁히는 것이 이 컴포넌트의 존재 이유다.
export function IconButton(props: IconButtonProps) {
  return <Button {...props} />;
}

ComponentProps<typeof Button> 을 읽어봅시다. "Button 이라는 컴포넌트가 받는 props 의 타입" 이에요.

typeof 가 또 나왔습니다. A-3 에서 ReturnType<typeof toCardView> 를 쓸 때 설명했죠. 꺾쇠 안은 타입을 적는 칸인데 Button 은 값이라서, typeof 로 "이 값의 타입" 이라고 바꿔주는 겁니다.

그다음 & { 'aria-label': string } 로 물음표 없는 버전을 덮어씁니다. 원래 있던 옵셔널 자리를 필수로 바꾸는 거예요.

정말 좁혀졌는지 확인

aria-label 을 빼고 써보면 걸립니다.

텍스트
Property ''aria-label'' is missing in type '{ children: string; }' but required in type '{ 'aria-label': string; }'.

같은 자리에서 Button 을 썼다면 그냥 통과했을 겁니다. 깜빡할 수 있던 것이 깜빡할 수 없는 것으로 바뀌었어요.

물려받은 것도 그대로 살아 있는지 볼까요. Button 에 없는 props 를 넣어보면요.

tsx
<IconButton aria-label="닫기" href="/home">×</IconButton>
텍스트
Property 'href' does not exist on type 'IntrinsicAttributes & ButtonProps & { 'aria-label': string; }'.

에러 메시지에 ButtonProps 라는 이름이 그대로 보입니다. 우리가 베껴 적지 않았는데도 TypeScript 는 이게 Button 의 props 라는 걸 알고 있어요. ComponentProps 가 진짜로 물려받은 거죠.

이제 두 버튼을 갈아끼웁니다.

tsx
// apps/web-spa/src/components/PostHeader.tsx
      <IconButton className="post-more" aria-label="게시물 메뉴">
        ⋯
      </IconButton>

💡 한 줄 정리

ComponentProps<typeof X> 로 다른 컴포넌트의 props 타입을 꺼내 쓸 수 있습니다. 베껴 적지 않으니 원본이 바뀌어도 따라오고, 교차 타입으로 일부만 좁혀 "깜빡할 수 없게" 만들 수 있습니다.

🙋 학생 질문 — "하는 일이 없는 컴포넌트를 굳이 만들 필요가 있나요?"

IconButton 은 정말 아무것도 안 합니다. Button 을 그대로 돌려줄 뿐이에요. 렌더된 결과도 Button 을 직접 쓴 것과 글자 하나 다르지 않습니다.

그래도 만든 이유는 타입은 값에만 붙일 수 있기 때문입니다. "이 자리에서는 aria-label 이 필수" 라는 규칙을 코드로 강제하려면, 그 자리에 쓸 무언가가 있어야 해요. 그게 IconButton 입니다.

처음엔 이런 게 낭비처럼 느껴질 수 있습니다. 그런데 팀이 커지면 이야기가 달라져요. 새로 온 사람이 버튼을 만들 때 IconButton 을 쓰면 에디터가 곧바로 이름을 요구합니다. 문서를 읽거나 코드 리뷰에서 지적받을 필요가 없어요.

규칙을 문서에 적는 대신 타입으로 박아두는 것. 이게 TypeScript 를 쓰는 큰 이유 중 하나입니다.


Step 4: "tsconfig.json 을 읽어봅시다"

A-1 에서 프로젝트를 시작할 때 tsconfig.json 을 만들었습니다. 그때는 "일단 이렇게 두세요" 하고 넘어갔죠. 이제 읽을 준비가 됐습니다.

JSON
// apps/web-spa/tsconfig.json
{
  "compilerOptions": {
    "target": "es2025",
    "lib": ["es2025", "dom", "dom.iterable"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "types": ["vitest/globals", "vite/client"]
  },
  "include": ["src", "vite.config.ts", "vitest.config.ts"]
}

한꺼번에 보면 많아 보이는데, 성격별로 묶으면 네 덩어리입니다.

텍스트
 ① 어떤 자바스크립트를 쓸 건가      target · lib

 ② 모듈을 어떻게 찾을 건가          module · moduleResolution · esModuleInterop
                                    resolveJsonModule

 ③ 얼마나 엄격하게 볼 건가          strict · skipLibCheck

 ④ 번들러와 어떻게 손발을 맞출 건가  jsx · noEmit · isolatedModules
                                    verbatimModuleSyntax · types

① 어떤 자바스크립트를 쓸 건가

target: "es2025" 는 "우리는 2025년 문법까지 쓴다" 는 선언입니다. 옛날 브라우저를 신경 쓰던 시절엔 이걸 낮춰 잡았는데, 지금은 그럴 일이 거의 없어요.

lib어떤 기능이 이미 있다고 칠 것인가입니다. dom 을 넣었으니 documentwindow 를 아는 거예요. 지난 시간 useScrollRestore 에서 window.scrollY 를 썼는데 아무 설정 없이 통했죠. libdom 이 있어서였습니다.

② 모듈을 어떻게 찾을 건가

moduleResolution: "bundler" 가 요즘 방식입니다. 예전에는 Node.js 가 파일을 찾는 규칙을 그대로 흉내 냈는데, 그러면 import { Button } from './Button' 처럼 확장자를 뺀 import 를 쓸 수 없었어요. 번들러는 확장자를 알아서 찾아주니 그 사실을 TypeScript 에도 알려주는 설정입니다.

③ 얼마나 엄격하게 볼 건가

strict: true 가 오늘까지 배운 거의 모든 검사의 스위치입니다. 지난 시간의 possibly 'null' 도, A-4 의 implicitly has an 'any' type 도 전부 이게 켜져 있어서 나왔어요.

여기서 알아둘 게 있습니다. 요즘 TypeScript 는 이걸 안 적어도 켜져 있습니다. 버전별로 재보면 이렇게 갈려요.

텍스트
 빈 설정 { } 로 같은 파일을 검사하면

 TypeScript 5.9      에러 0 건       아무것도 안 잡아준다
 TypeScript 6.0      에러 2 건       여기서 기본값이 바뀌었다
 TypeScript 7.0      에러 2 건

6.0 부터 기본이 됐습니다. 그러니 우리 설정의 "strict": true 는 지금 버전에선 없어도 그만이에요.

그래도 적어두는 편이 낫습니다. 읽는 사람이 설정만 보고 "이 프로젝트는 엄격하다" 를 알 수 있고, 누가 실수로 끄면 눈에 띄니까요.

옛날 프로젝트를 열었을 때 tsconfig.jsonstrict: true 가 큼직하게 적혀 있다면, 그건 그 시절엔 기본이 아니었기 때문입니다.

④ 번들러와 손발 맞추기

noEmit: true"자바스크립트 파일을 만들지 마라" 는 뜻입니다. 이상하게 들리죠. TypeScript 컴파일러인데 결과물을 만들지 말라니요.

실제로 파일을 만드는 건 Vite 입니다. TypeScript 는 검사만 맡아요. 역할이 나뉜 겁니다.

isolatedModulesverbatimModuleSyntax 는 그 분업에서 나온 규칙입니다. A-3 에서 import type 을 쓰라고 했던 이유가 verbatimModuleSyntax 였어요. 번들러는 파일을 하나씩 따로 변환하기 때문에, 그 파일만 보고 "이 import 는 타입이라 지워도 된다" 를 알 수 있어야 합니다. 그래서 우리가 type 이라고 직접 표시해주는 거예요.

그러니까 이 네 번째 묶음은 전부 "우리는 번들러와 함께 쓴다" 는 사실에서 나옵니다.

그런데 방금 한 말에 이상한 데가 있습니다. TypeScript 는 검사만 하고 Vite 가 파일을 만든다고 했는데, 그럼 Vite 는 검사를 안 한다는 뜻일까요?

💡 한 줄 정리

tsconfig.json 은 어떤 자바스크립트를 쓸지, 모듈을 어떻게 찾을지, 얼마나 엄격할지, 번들러와 어떻게 나눠 일할지 네 가지를 정합니다. strict 는 6.0 부터 기본으로 켜져 있습니다.

🙋 학생 질문 — "skipLibCheck: true 는 검사를 건너뛰는 건데 켜도 되나요?"

이름만 보면 불안한데, 건너뛰는 건 우리 코드가 아니라 node_modules 안 라이브러리들의 타입 선언 파일입니다.

왜 건너뛰냐면, 라이브러리 수십 개의 타입 선언끼리 서로 안 맞는 경우가 실제로 있기 때문이에요. 우리가 고칠 수도 없는 남의 파일 때문에 우리 프로젝트가 빌드가 안 되면 곤란하죠. 게다가 그 파일들을 전부 검사하면 느려집니다.

우리가 그 라이브러리를 쓰는 부분은 여전히 검사됩니다. useState 에 숫자를 잘못 넣으면 그대로 걸려요. 남의 집 내부 사정을 안 들여다볼 뿐, 우리가 그 집과 주고받는 것은 다 봅니다.

그래서 거의 모든 프로젝트가 켜둡니다. 공식 문서도 권장하는 설정이에요.


Step 5: "번들러는 타입을 검사하지 않습니다"

Step 4 끝의 질문으로 돌아옵니다. 직접 해보는 게 빠릅니다.

Toast 컴포넌트에 말이 안 되는 줄을 하나 넣어보세요. 문자열을 숫자에 담는 겁니다.

tsx
// apps/web-spa/src/components/Toast.tsx (확인용으로 잠깐 넣는 줄 — 곧 지웁니다)
export function Toast({ message }: ToastProps) {
  const wrong: number = message;

에디터에 빨간 줄이 그어집니다. 명백한 타입 오류예요. 이 상태로 빌드해봅시다.

Bash
npx vite build
텍스트
dist/assets/index-Cex0Dy17.js   202.37 kB │ gzip: 64.29 kB

✓ built in 266ms

성공했습니다. 빨간 줄이 그어진 코드가 아무 일 없이 빌드돼서 나왔어요.

같은 코드에 타입 검사를 돌려보면요.

Bash
npx tsc --noEmit
텍스트
src/components/Toast.tsx(10,9): error TS2322: Type 'string' is not assignable to type 'number'.

이쪽은 잡습니다. 같은 코드인데 한쪽은 통과하고 한쪽은 막았어요.

왜 이렇게 되나

번들러가 타입을 다루는 방식이 원인입니다.

텍스트
 번들러가 .tsx 파일을 만났을 때

   const wrong: number = message;
                └──┬──┘
                   │
            타입 표기를 그냥 지운다
                   │
                   
   const wrong = message;           문법적으로 멀쩡한 자바스크립트
                                      브라우저는 잘 돌린다

번들러는 타입 표기를 지우기만 합니다. 맞는지 따지지 않아요. 지우고 나면 평범한 자바스크립트고, 그건 실행에 아무 문제가 없습니다.

왜 안 따질까요. 빠르려고요. 타입 검사는 프로젝트 전체를 훑어야 합니다. 파일 하나를 고칠 때마다 그걸 다 하면 개발 서버가 느려터집니다. 그래서 번들러는 지우기만 하고, 검사는 따로 떼어냈어요.

텍스트
 npm run dev       Vite 만 돈다              타입 검사 없음. 빠르다
 npx tsc --noEmit  TypeScript 만 돈다         검사만. 파일은 안 만든다

noEmit: true 의 정체가 이겁니다. 검사 전담이라는 뜻이었어요.

그럼 언제 검사하나

우리 package.json 을 보면 답이 있습니다.

JSON
// apps/web-spa/package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }

build 를 보세요. tsc --noEmit && vite build 입니다. &&앞이 성공해야 뒤가 돈다는 뜻이에요. 타입 검사를 통과 못 하면 빌드가 아예 시작되지 않습니다.

그러니까 실무에서 타입 검사는 이렇게 세 겹으로 돕니다.

텍스트
 ① 코드 쓰는 동안   에디터가 실시간으로      빨간 줄
 ② 빌드할 때        tsc --noEmit &&         통과 못 하면 배포 못 함
 ③ 팀에 올릴 때     CI 에서 같은 명령        남의 실수도 막힘

①은 눈으로 보는 것이라 끌 수 있습니다. 급하면 무시하고 넘어가지죠. 그래서 ②·③이 필요합니다. 사람이 안 보고 지나쳐도 기계가 막아줘요.

여기서 오늘 수업이 한 바퀴 돌아 만납니다. Step 1~3 에서 우리가 공들여 붙인 타입들, 제네릭 제약이며 필수 aria-label 이며 — 그게 실제로 사람을 막아주려면 검사가 어딘가에서 돌아야 합니다. 에디터의 빨간 줄만으로는 부족해요.

확인이 끝났으면 Toast 에 넣은 줄은 지워주세요.

💡 한 줄 정리

번들러는 타입 표기를 지우기만 하고 맞는지 따지지 않습니다. 빠르기 위한 분업이라, 검사는 tsc --noEmit 으로 따로 돌려야 하고 그래서 build 스크립트가 두 단계로 되어 있습니다.

🙋 학생 질문 — "그럼 개발할 때 타입 오류를 놓치면 어떡하죠?"

에디터가 실시간으로 보여주니 대부분은 그 자리에서 잡힙니다. 문제는 지금 열어둔 파일만 본다는 거예요.

이런 상황을 떠올려보세요. Post 타입에서 필드 이름 하나를 바꿉니다. 그 파일은 멀쩡해 보여요. 그런데 그 필드를 쓰던 다른 파일 다섯 개가 깨졌습니다. 열어보지 않으면 모릅니다.

그래서 습관 하나를 권합니다. 작업을 마무리할 때 npm run typecheck 를 한 번 돌리세요. 우리 package.json 에 이미 넣어뒀습니다. 몇 초면 끝나고, 열지 않은 파일까지 전부 봅니다.

터미널을 하나 더 열어 켜두는 방법도 있습니다. npx tsc --noEmit --watch 라고 하면 파일을 고칠 때마다 프로젝트 전체를 다시 검사해요. 개발 서버 옆에 나란히 띄워두는 사람이 많습니다.


마무리

오늘 배운 것을 한 줄로 줄이면 이렇습니다. 타입은 재료로도 쓰이고, 켜둬야 일한다.

앞의 세 Step 은 타입을 재료로 넘기는 법이었습니다. 값 대신 타입을 매개변수로 받으니 중복이 걷혔죠. 뒤의 두 Step 은 그렇게 붙인 타입이 실제로 일하게 만드는 설정 이야기였고요.

오늘 배운 핵심 세 가지

💡 하나 — 달라지는 것이 타입이면 타입을 매개변수로 뽑습니다. extends 로 조건을 걸면 그 안에서 조건에 적힌 것을 꺼내 쓸 수 있어요. any 는 검사를 끄지만 제네릭은 검사를 미룰 뿐이라, 부르는 순간 구체적인 타입이 되살아납니다.

💡 둘 — 컴포넌트도 함수라 타입 매개변수를 붙일 수 있습니다. 타입은 <T> 로, 달라지는 동작은 함수 props 로 받으면 CommentListFeed 처럼 하는 일이 같은 것들이 하나로 합쳐집니다. 다른 컴포넌트의 props 는 ComponentProps<typeof X> 로 물려받고요.

💡 셋 — 번들러는 타입을 검사하지 않습니다. 타입 표기를 지우기만 해요. 그래서 tsc --noEmit 을 빌드 앞에 세워둡니다. 우리가 붙인 타입은 검사가 돌아야 비로소 사람을 막아줍니다.

다음 시간 예고

오늘로 TypeScript 카테고리가 끝났습니다. A-1 에서 타입을 처음 선언한 것부터 오늘 설정까지, 여섯 번에 걸쳐 가드레일을 붙였어요.

다음 시간에는 다시 화면으로 돌아갑니다. 을 다뤄요.

사실 우리는 이미 폼을 하나 만들었습니다. 댓글 입력창이요. 그런데 그 폼이 하는 검증이 뭐였는지 떠올려보세요. content.trim() === '' 로 빈 값만 막았습니다. 그게 전부예요.

회원가입 폼이라면 어떨까요. 이메일 모양이 맞는지, 비밀번호가 여덟 자를 넘는지, 비밀번호 확인이 일치하는지. 필드마다 규칙이 있고, 틀리면 그 필드 밑에 메시지가 떠야 하고, 사용자가 고치면 메시지가 사라져야 합니다. 이걸 useState 로 하나씩 만들면 어떻게 될지 상상해보세요.

그리고 오늘 이야기와 이어지는 문제가 하나 더 있습니다. 타입은 검사가 개발 중에만 돕니다. 배포된 앱에는 타입이 남아 있지 않아요. 그런데 서버가 이상한 응답을 보내면 그건 실행 중에 벌어지는 일이죠. 실행 중에도 확인하려면 어떻게 해야 할까요.

이 두 문제는 다음 두 번의 수업에 걸쳐 풉니다. 먼저 폼을 제대로 다루는 도구를 만나고, 그다음 시간에 실행 중에도 도는 검사를 붙일 거예요.


과제

[구현] 해시태그 목록을 제네릭 List 로

게시물마다 해시태그가 달려 있는데 지금은 화면에 안 보입니다. 오늘 만든 List 를 써서 붙여봅시다.

  • Post 타입에 이미 hashtagNames: string[] 이 있습니다. A-1 에서 선언해뒀어요. 값은 feedPosts 에 들어 있습니다.
  • apps/web-spa/src/components/HashtagList.tsx 를 만들고, List 를 써서 해시태그를 줄줄이 그려 주세요. 각 태그는 #한강 처럼 앞에 # 을 붙입니다.
  • 여기서 문제가 하나 생깁니다. hashtagNames 는 문자열 배열인데 Listid 가 있는 것만 받죠. 곧바로 넘기면 에러가 납니다. 그 에러 메시지를 먼저 적어 두세요.
  • 해결 방법은 여러 가지입니다. 하나 골라서 구현하고, 왜 그걸 골랐는지 두세 줄로 적어 주세요. 다른 방법을 안 고른 이유도 함께요.
  • PostBody 의 캡션 아래에 해시태그 목록을 넣어 주세요. 태그가 없는 게시물도 있을 수 있으니 그때는 목록 자체를 안 그리는 편이 낫습니다.
  • 스타일은 자유입니다. 가로로 나열되게 하고 싶으면 .hashtag-listdisplay: flex 를 주면 됩니다.

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

[탐구] 검사가 언제 도는지 직접 확인하기

Step 5 에서 본 것을 손으로 확인해 봅시다. 각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.

  • Avatar 컴포넌트의 username 을 쓰는 곳에서 오타를 내보세요. usernme 처럼요. 에디터가 뭐라고 하나요?
  • 그 상태로 npm run dev 를 띄우고 브라우저를 열어 보세요. 화면이 어떻게 되나요? 개발자 도구 콘솔도 함께 확인해 주세요. 빌드는 막지 않았는데 왜 이런 일이 생겼는지 Step 5 로 설명해 보세요.
  • 이번엔 npm run build 를 돌려 보세요. 어디서 멈추나요? package.jsonbuild 스크립트와 견줘서 이유를 적어 주세요.
  • 오타를 고치고, 이번엔 Listitems={[{ name: '한강' }]} 을 넘겨 보세요. 에러가 몇 개 나나요? 하나가 아닐 겁니다. 두 번째 에러가 왜 따라 나오는지 생각해 보세요.
  • IconButton 을 쓰는 곳에서 aria-label 을 지워 보세요. 그다음 같은 자리를 Button 으로 바꿔 보세요. 한쪽만 걸리는 이유를 한 줄로 정리해 주세요.
  • 마지막은 두 단계입니다. 먼저 useCommentInputinputRef.current?.focus() 에서 물음표를 지우고 npm run typecheck 를 돌려 에러를 확인하세요. 그 상태 그대로 tsconfig.json"strict": truefalse 로 바꾸고 다시 돌려 보세요. 에러가 어떻게 되나요? strict 가 무엇을 막아주고 있었는지 한 줄로 정리해 주세요. (확인이 끝나면 둘 다 되돌립니다.)

생각해볼 주제

1. 중복을 없애는 것이 늘 이득일까

오늘 CommentListFeedList 하나로 합쳤습니다. 같은 코드를 두 번 적지 않게 됐죠.

그런데 합치기 전에는 각각을 마음대로 고칠 수 있었습니다. 지금은 List 를 고치면 두 곳이 함께 움직여요. 그게 이득일 때도 있고 발목을 잡을 때도 있습니다.

"우연히 닮은 코드" 와 "정말 같은 코드" 를 어떻게 구분하시겠어요. 합치기 전에 무엇을 확인해야 나중에 후회하지 않을까요.

2. IconButton 같은 얇은 컴포넌트

IconButtonButton 을 그대로 돌려주기만 합니다. 렌더 결과도 똑같아요. 하는 일이라곤 타입 하나를 좁히는 것뿐입니다.

이런 컴포넌트를 두고 팀에서 의견이 갈릴 수 있습니다. "규칙을 타입으로 박아두니 좋다" 는 쪽과 "파일만 늘고 한 번 더 거쳐 가서 헷갈린다" 는 쪽으로요.

여러분은 어느 쪽인가요. 그리고 이런 규칙을 강제하는 다른 방법에는 무엇이 있을까요. 그 방법들과 견줘서 타입으로 막는 것의 장단점은 무엇일까요.

3. 검사를 어디에 세울 것인가

Step 5 에서 타입 검사가 세 겹으로 돈다고 했습니다. 에디터, 빌드, 그리고 CI 요.

그런데 팀에서 이런 제안이 나올 수 있습니다. "빌드가 느리니까 build 에서 tsc --noEmit 을 빼고 CI 에만 두자" 고요. 실제로 그렇게 하는 팀도 있습니다.

어떻게 답하시겠어요. 검사를 앞에 세울수록 좋은 걸까요, 아니면 적당한 자리가 따로 있을까요. 판단이 갈릴 만한 조건은 무엇일까요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 해시태그 목록을 제네릭 List 로

채점 포인트

항목 확인할 것 배점
부딪힌 에러 기록 문자열 배열을 그대로 넘겼을 때의 메시지를 적었는가 ★★★
해결 방법 선택 하나를 고르고 왜 골랐는지다른 걸 왜 안 골랐는지를 적었는가 ★★★
List 재사용 <ul>·<li>·key 를 직접 안 쓰고 List 에 맡겼는가 ★★☆
빈 목록 처리 태그가 없을 때 목록 자체를 안 그리는가 ★★☆
# 붙이는 위치 데이터가 아니라 화면에서 붙이는가 ★☆☆

먼저 부딪히는 에러

hashtagNames 를 그대로 넘기면 이렇게 됩니다.

tsx
<List items={names} renderItem={(name) => <span>#{name}</span>} />
텍스트
Type 'string[]' is not assignable to type '{ id: number; }[]'.
  Type 'string' is not assignable to type '{ id: number; }'.
Type '{ id: number; }' is not assignable to type 'ReactNode'.

에러가 두 개입니다. 첫째는 예상한 것이고, 둘째가 흥미로워요. items 가 제약에 안 맞으니 TypeScript 가 T 를 제약 타입인 { id: number } 로 잡아버립니다. 그래서 renderItem 이 받는 것도 { id: number } 가 되고, 그걸 화면에 그리려니 또 막히는 거예요.

하나가 어긋나면 그 뒤로 줄줄이 어긋납니다. 이럴 때는 첫 번째 에러부터 고치세요. 대개 나머지가 같이 사라집니다.

풀이 예시

tsx
// apps/web-spa/src/components/HashtagList.tsx
import { List } from './List';

interface HashtagListProps {
  names: string[];
}

export function HashtagList({ names }: HashtagListProps) {
  // List 는 id 가 있는 것만 받는다. 문자열에는 id 가 없으니 여기서 붙여준다.
  // 순서가 곧 신원이라 index 를 써도 되는 드문 경우다 — 이 목록은 더하거나 지우지 않는다.
  const tags = names.map((name, index) => ({ id: index, name }));

  return (
    <List
      items={tags}
      className="hashtag-list"
      aria-label="해시태그"
      renderItem={(tag) => <span className="hashtag">#{tag.name}</span>}
    />
  );
}

PostBody 에는 이렇게 답니다.

tsx
      {hashtagNames.length > 0 && <HashtagList names={hashtagNames} />}

PostBody 는 지금 hashtagNames 를 안 받고 있으니 props 에 추가하고, PostCard 에서 내려주면 됩니다. PostCardProps 는 A-3 에서 Omit<Post, 'hashtagNames' | 'createdAt'> 로 만들었죠. 해시태그를 쓰기로 했으니 그 Omit 목록에서 hashtagNames 를 빼는 것도 방법입니다.

방법을 고르는 기준

해결책이 여럿이라 어느 걸 골라도 됩니다. 다만 왜 골랐는지가 이 과제의 핵심이었어요.

방법 좋은 점 걸리는 점
그리기 직전에 id 를 붙인다 List 를 안 건드린다. 딱 이 자리에서만 해결 렌더할 때마다 배열을 새로 만든다
List 의 제약을 풀고 keyOf 를 받는다 어떤 타입이든 받는 진짜 범용 목록이 된다 쓰는 곳마다 keyOf 를 넘겨야 한다. 기존 두 곳도 고쳐야 함
hashtagNames 를 객체 배열로 바꾼다 근본적. 태그에 나중에 링크나 개수가 붙어도 대응됨 서버 응답 모양을 바꾸는 일이라 우리 마음대로 못 한다

저는 첫 번째를 권합니다. 이유는 이렇습니다.

두 번째는 문제 하나 때문에 이미 잘 돌아가는 두 곳(CommentList·Feed)을 건드립니다. 해시태그는 목록 중 특이한 경우인데, 특이한 하나 때문에 공통 도구를 무르게 만드는 건 순서가 거꾸로예요.

세 번째는 가장 깔끔하지만 우리 권한 밖입니다. hashtagNames 는 백엔드가 주는 모양이라 프론트 사정으로 바꿔달라고 하기엔 근거가 약해요. 나중에 태그에 다른 정보가 붙을 일이 실제로 생기면 그때 이야기하면 됩니다.

자주 나오는 실수

id 로 index 를 쓴 것을 걱정하기 — B-2 에서 index key 로 사고가 났으니 불안할 수 있습니다. 그런데 그때 문제가 된 건 목록이 바뀔 때였어요. 항목을 지우면 index 가 밀리면서 상태가 옆줄로 옮겨갔죠. 해시태그 목록은 더하지도 지우지도 않고 각 줄에 상태도 없습니다. 이런 곳에서는 index 가 안전합니다. 규칙을 외우기보다 왜 그 규칙이 생겼는지를 기억하세요.

# 을 데이터에 넣기names.map((name) => ({ id, name: '#' + name })) 처럼요. 동작은 하는데 나중에 태그로 검색하거나 링크를 걸 때 # 을 도로 떼야 합니다. 표시용 기호는 화면에서 붙이는 게 맞습니다.

태그가 없을 때 빈 목록을 그리기<ul></ul> 만 덩그러니 남습니다. 눈에는 안 보이지만 화면을 읽어주는 도구는 "목록, 항목 0개" 라고 읽어요.

💡 튜터의 한마디

이 과제의 진짜 주제는 해시태그가 아니라 제약에 안 맞는 것을 만났을 때 어디를 고칠까입니다.

도구를 무르게 만들 것인가, 데이터를 바꿀 것인가, 쓰는 자리에서 맞출 것인가. 셋 다 답이 될 수 있고 상황에 따라 갈립니다. 다만 공통 도구를 무르게 만드는 선택은 가장 마지막에 두세요. 그 도구를 쓰는 다른 곳들이 대가를 함께 치르니까요.

🎯 [과제 2 예시답안] 검사가 언제 도는지 직접 확인하기

채점 포인트

항목 확인할 것 배점
오타 에러 메시지 Did you mean 'username'? 까지 확인했는가 ★★☆
dev 서버 결과 화면이 안 뜬다는 것과 그 이유를 적었는가 ★★★
build 가 멈추는 곳 tsc --noEmit 단계임을 && 와 연결해 설명했는가 ★★★
에러 두 개 두 번째가 첫 번째의 결과라는 것을 설명했는가 ★★★
IconButton vs Button 한쪽만 걸리는 이유 ★★☆
strict 끄기 무엇이 사라지는지 직접 보고 적었는가 ★★★

풀이 예시

첫째 — 오타 냈을 때 에디터

텍스트
Cannot find name 'usernme'. Did you mean 'username'?

username 을 쓰려던 것 아니냐고 물어봐 줍니다. 이런 제안은 늘 정확하진 않지만 이 경우엔 맞습니다.

둘째 — 그 상태로 npm run dev

화면이 안 뜹니다. 흰 화면이 되고 콘솔에 이런 게 찍혀요.

텍스트
usernme is not defined

여기가 헷갈리기 쉬운 곳입니다. Step 5 에서 "번들러는 타입을 검사하지 않는다" 고 배웠으니 그냥 넘어갈 것 같은데, 실제로는 터졌죠.

둘 다 맞습니다. 번들러는 타입만 안 볼 뿐, 없는 변수를 만들어주지는 않아요. 타입 표기를 지우고 나면 {usernme} 라는 코드가 남는데, 그런 변수는 어디에도 없으니 브라우저가 실행하다 멈춥니다.

정리하면 이렇습니다.

텍스트
 타입이 안 맞는 것        번들러는 통과시킨다      실행은 되고, 값이 이상해진다
 변수가 없는 것           번들러는 통과시킨다      실행하다 멈춘다

번들러 입장에서는 둘 다 "내 일이 아닌 것" 입니다. 차이는 브라우저가 그걸 실행할 수 있느냐예요.

셋째 — npm run build

tsc --noEmit 단계에서 멈춥니다. vite build 는 시작도 안 해요.

JSON
"build": "tsc --noEmit && vite build",

&& 는 앞 명령이 성공해야 뒤가 도는 기호입니다. 타입 검사가 실패했으니 거기서 끝나죠.

이게 Step 5 의 결론입니다. npm run dev 는 검사를 안 해서 브라우저에서야 알았지만, npm run build 는 검사를 앞에 세워뒀기 때문에 배포되기 전에 막힙니다. 같은 실수인데 발견되는 시점이 다릅니다.

넷째 — List{ name: '한강' } 넘기기

에러가 두 개 납니다.

텍스트
Object literal may only specify known properties, and 'name' does not exist in type '{ id: number; }'.
Property 'name' does not exist on type '{ id: number; }'.

첫째는 예상한 것이고, 둘째가 처음엔 이상해 보입니다. 분명 name 을 가진 걸 넘겼는데 name 이 없다니요.

이유는 이렇습니다. items 가 제약에 안 맞으니 TypeScript 가 T 를 정하지 못하고 제약 타입인 { id: number } 로 대신 잡습니다. 그러면 renderItem 이 받는 것도 { id: number } 가 되고, 거기엔 name 이 없죠.

두 번째 에러는 첫 번째 에러의 결과입니다. 첫째를 고치면 같이 사라져요.

한 가지 덧붙이면, 같은 값을 변수에 담아서 넘기면 첫 번째 메시지가 달라집니다.

텍스트
Type '{ name: string; }[]' is not assignable to type '{ id: number; }[]'.
  Property 'id' is missing in type '{ name: string; }' but required in type '{ id: number; }'.

JSX 에 객체를 곧바로 적으면 "이 리터럴에 낯선 필드가 있다" 를 먼저 잡고, 변수로 담으면 "배열끼리 안 맞는다" 로 잡습니다. 같은 잘못인데 어디서 걸리느냐에 따라 말이 달라지는 거예요.

다섯째 — IconButtonButton

IconButton 에서 aria-label 을 지우면 걸립니다.

텍스트
Property ''aria-label'' is missing in type '{ children: string; }' but required in type '{ 'aria-label': string; }'.

같은 자리를 Button 으로 바꾸면 통과합니다.

Button'aria-label'?: string 에는 물음표가 있어서 안 넘겨도 되고, IconButton 은 그 위에 물음표 없는 버전을 덮어써서 필수가 됐습니다. 같은 이름의 props 인데 한쪽만 필수인 것이 두 컴포넌트의 유일한 차이예요.

여섯째 — strict 끄기

먼저 물음표를 지우면 이렇게 나옵니다.

텍스트
src/hooks/useCommentInput.ts(22,5): error TS18047: 'inputRef.current' is possibly 'null'.

그 상태로 "strict": false 로 바꾸고 다시 돌리면 에러가 0 건이 됩니다.

코드는 하나도 안 고쳤는데 경고가 사라졌어요. 위험이 없어진 게 아니라 안 보게 된 것입니다. inputRef.current 는 여전히 첫 렌더에 null 이고, 그때 focus() 를 부르면 실행 중에 터집니다.

strict 가 막아주던 게 이겁니다. "값이 없을 수도 있는데 없는 경우를 안 챙겼다" 를 미리 알려주는 것이죠.

💡 튜터의 한마디

여섯 개를 관통하는 게 하나 있습니다. 검사는 공짜로 도는 게 아니라 우리가 켜둔 만큼만 돕니다.

strict 를 켰으니 null 을 챙기라고 하고, buildtsc 를 세워뒀으니 배포 전에 막히고, dev 에는 안 세워뒀으니 브라우저에서 알게 됩니다. 전부 우리가 정한 것이에요.

새 프로젝트에 들어가면 tsconfig.jsonpackage.jsonscripts 를 먼저 읽어보세요. 그 팀이 어디까지 기계에 맡기고 어디부터 사람을 믿는지가 그 두 파일에 적혀 있습니다.


🤔 [생각해볼 주제 1] 중복을 없애는 것이 늘 이득일까

문제 상황 요약

오늘 CommentListFeedList 하나로 합쳤습니다. 같은 코드를 두 번 적지 않게 됐죠. 그런데 합치는 순간 두 곳이 한 몸이 됐습니다.

튜터의 가이드 및 해설

여기서 구분해야 할 게 있습니다. 닮아 보이는 것과 같은 것은 다릅니다.

텍스트
 정말 같은 것                          우연히 닮은 것

 요구사항이 하나다                     요구사항이 둘인데 지금 답이 같다
        │                                      │
 한쪽이 바뀌면 다른 쪽도 반드시 바뀐다   한쪽만 바뀌는 날이 온다
        │                                      │
   합치면 이득                          합치면 나중에 if 가 생긴다

List 는 어느 쪽일까요. "목록을 받아 하나씩 그리고 key 를 붙인다" 는 React 가 목록을 다루는 방식 그 자체입니다. 댓글이든 게시물이든 해시태그든 달라질 이유가 없어요. 이건 정말 같은 것에 가깝습니다.

반대 예를 들어보죠. 지난 시간 두 리듀서를 보면 switch 로 갈라 새 상태를 돌려주는 구조가 똑같습니다. 그렇다고 리듀서 하나로 합칠 수 있을까요. 못 합니다. 모양만 같지 하는 일이 전혀 다르거든요. 닮은 건 구조지 요구사항이 아닙니다.

그래서 합치기 전에 물어볼 것은 이겁니다.

  1. 두 곳이 같은 이유로 존재하는가 — 우연히 지금 코드가 같은 건 아닌가
  2. 한쪽만 바뀌는 상황을 상상할 수 있는가 — 그려진다면 합치지 않는다
  3. 합친 뒤 "다른 점" 을 props 몇 개로 넘기게 되는가 — 셋을 넘으면 신호다

실무에서 자주 보는 실패가 있습니다. 닮았다는 이유로 합쳤다가, 요구사항이 갈리기 시작하면서 if 가 하나씩 늘어나는 거예요. 그러다 보면 원래 두 파일이었을 때보다 읽기 어려워집니다. 이럴 땐 되돌리는 게 정답인데, 합치는 것보다 나누는 게 심리적으로 어려워서 다들 버팁니다.

기억할 것은 이겁니다. 중복 그 자체는 죄가 아닙니다. 문제는 "같이 바뀌어야 하는 것이 떨어져 있는 것" 이에요. 그게 아니라면 두 번 적혀 있어도 괜찮습니다.

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

"중복을 없앨지 판단할 때 코드가 닮았는지가 아니라 요구사항이 하나인지를 봅니다. 한쪽만 바뀌는 상황이 그려지면 우연히 닮은 것이라 합치지 않아요. 합친 뒤에 분기용 props 가 계속 늘어난다면 그건 판단이 틀렸다는 신호라, 저는 그때 되돌리는 것을 주저하지 않는 편입니다."

🤔 [생각해볼 주제 2] IconButton 같은 얇은 컴포넌트

문제 상황 요약

IconButtonButton 을 그대로 돌려줍니다. 렌더 결과도 똑같아요. 하는 일은 타입 하나를 좁히는 것뿐입니다. 이런 게 값어치가 있는지 팀에서 갈릴 수 있습니다.

튜터의 가이드 및 해설

먼저 같은 목적을 이룰 다른 방법들을 늘어놓고 견줘봅시다.

방법 언제 막히나 우회 가능한가 비용
코딩 컨벤션 문서에 적기 안 막힘 (사람이 기억) 쉽게 문서 관리
코드 리뷰에서 지적 올린 뒤 리뷰어가 놓치면 통과 사람 시간
린트 규칙(jsx-a11y) 코드 쓰는 중 주석으로 끌 수 있음 설정
IconButton 타입 코드 쓰는 중 어렵다 파일 하나

타입으로 막는 것의 특징은 가장 이르고 가장 우회하기 어렵다는 겁니다. 린트는 주석 한 줄로 끄지만, 타입은 끄려면 any 를 쓰거나 단언을 해야 해서 그 자체가 눈에 띕니다.

대신 대가가 있습니다. 컴포넌트가 하나 늘고, 코드를 읽는 사람이 한 번 더 건너뛰어야 해요. IconButton 을 처음 본 사람은 "이건 뭘 하는 거지" 하고 열어보게 됩니다. 열어보면 아무것도 안 하죠.

그래서 저는 이렇게 정리합니다. 얇은 컴포넌트는 "이름이 규칙을 설명할 때" 값어치가 있습니다.

IconButton 이라는 이름은 "아이콘만 있는 버튼" 이라는 상황을 말해주고, 그 상황에서 이름표가 필요하다는 건 자연스럽게 따라옵니다. 열어보지 않아도 왜 있는지 짐작이 가요.

반대로 이름이 규칙을 설명하지 못하면 그냥 짐입니다. MyButton 이나 Button2 같은 이름이었다면 만들지 않는 편이 나았을 겁니다.

한 가지 덧붙이면, 실무에서는 린트와 타입을 같이 씁니다. jsx-a11y 같은 규칙이 aria-label 없는 아이콘 버튼을 넓게 잡아주고, 우리 팀이 특히 중요하게 보는 자리는 타입으로 한 번 더 막는 식이죠. 둘은 경쟁 관계가 아닙니다.

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

"규칙을 강제하는 수단은 문서·리뷰·린트·타입 순으로 점점 이르고 우회하기 어려워집니다. 얇은 래퍼 컴포넌트는 그중 타입을 쓰는 방법인데, 저는 이름이 그 규칙을 설명해줄 때만 만듭니다. IconButton 은 왜 이름표가 필요한지가 이름에 들어 있지만, MyButton 이었다면 읽는 사람에게 짐만 됐을 거예요."

🤔 [생각해볼 주제 3] 검사를 어디에 세울 것인가

문제 상황 요약

타입 검사는 에디터·빌드·CI 세 곳에서 돕니다. "빌드가 느리니 build 에서 빼고 CI 에만 두자" 는 제안이 나올 수 있어요.

튜터의 가이드 및 해설

먼저 이 제안이 왜 나오는지를 인정하고 시작해야 합니다. 그냥 틀린 소리가 아니에요. 검사는 프로젝트 전체를 훑기 때문에 코드가 늘면 눈에 띄게 느려집니다. 빌드를 하루에 수십 번 돌리는 사람에게는 실제 고통이에요.

핵심은 어디서 막느냐가 아니라 얼마나 늦게 알게 되느냐입니다.

텍스트
 발견 시점                      고치는 비용

 에디터에서 (초)                거의 0 — 방금 쓴 코드라 머릿속에 있다
 빌드에서 (분)                  낮음 — 아직 내 컴퓨터 안이다
 CI 에서 (십 분)                중간 — 컨텍스트를 다시 불러와야 한다
 배포된 뒤 (며칠)               크다 — 사용자가 먼저 본다

늦게 알수록 비쌉니다. 그래서 "가능한 한 앞에서" 가 기본 방향이에요.

그럼 build 에서 빼도 되느냐. 조건에 따라 갈립니다.

빼도 괜찮은 경우는 개발자가 로컬에서 build 를 실제로는 잘 안 돌릴 때입니다. dev 로 개발하고 CI 가 빌드한다면, 로컬 build 에 검사를 붙여둔 효과가 크지 않아요. 이때는 CI 에 두는 것으로 충분합니다.

빼면 안 되는 경우는 로컬 build 결과물을 그대로 배포하는 흐름일 때입니다. 검사 없이 나간 번들이 그대로 사용자에게 갑니다. 이건 안전망을 걷어내는 것이에요.

그리고 어느 쪽이든 CI 에는 반드시 있어야 합니다. 로컬은 사람마다 다르고 건너뛸 수 있지만 CI 는 모두가 같은 관문을 지나니까요.

느려서 힘들다면 검사를 빼는 대신 다른 손을 먼저 써볼 수 있습니다. TypeScript 7 이 Go 로 다시 쓰이면서 훨씬 빨라졌으니 버전을 올리는 것이 첫 번째고, 바뀐 파일만 다시 보는 증분 검사나 별도 터미널에서 도는 감시 모드도 방법입니다.

"느리다" 는 문제의 답이 "검사를 끄자" 여야 할 이유는 없습니다. 그 답으로 가기 전에 빠르게 만드는 쪽을 먼저 보는 게 순서예요.

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

"검사를 어디에 둘지는 틀린 코드를 얼마나 늦게 알게 되는가로 판단합니다. 늦을수록 고치는 비용이 커지니 가능한 한 앞에 두되, 로컬 빌드 결과를 그대로 배포하지 않는 팀이라면 CI 한 곳으로 모으는 것도 합리적입니다. 다만 느리다는 이유로 검사를 걷어내기 전에, 버전을 올리거나 증분 검사를 켜는 쪽을 먼저 봅니다."

전체 목록 리액트