A-4: React 컴포넌트 타이핑 — props·이벤트·children·ref
목차 45
안녕하세요, 홍순구입니다. 지난 시간에 좋아요가 눌리고 댓글이 달리는 피드를 만들었죠. 그러면서 제가 마지막에 이런 말을 남겼습니다. 오늘 이벤트 객체의 타입을 한 번도 안 적었다고요.
정말 그랬는지 지난 시간 코드를 다시 봅시다.
// apps/web-spa/src/components/CommentForm.tsx — 지난 시간에 만든 입력창
<input
className="comment-input"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="댓글 달기..."
aria-label="댓글 입력"
/>
event에 타입이 없습니다. 그런데도 event.target.value를 칠 때 에디터가 자동완성을 띄워줬어요. TypeScript가 이미 알고 있었다는 뜻입니다.
함수 안이 길어지면 누구나 하는 일이니까, 이 핸들러를 밖으로 한번 빼 봅시다.
function handleChange(event) {
setContent(event.target.value);
}
빨간 줄이 그어집니다. 방금까지 알아서 잘 되던 게 안 돼요.
오늘은 이 빨간 줄에서 출발합니다. 컴포넌트에는 값이 드나드는 입구가 여럿 있어요. props로 값이 들어오고, 이벤트로 사용자의 동작이 들어오고, children으로 다른 화면 조각이 들어오고, ref로는 실제 DOM 요소가 나갑니다. 오늘 이 네 입구에 전부 타입을 답니다.
오늘의 여정
Step 1~3 이벤트 객체에 타입 달기 · 함수를 props 로 받을 때의 타입
│
Step 4~5 props 타입을 짓는 세 가지 길 · 컴포넌트를 함수로 선언하는 이유
│
Step 6~7 children 을 명시로 받기 · ref 를 props 로 받기
▼
네 입구가 모두 타입으로 지켜지는 인스타 컴포넌트
💡 오늘 수업의 핵심 — "컴포넌트의 입구를 타입으로 적는다"
지난 세 번의 TypeScript 시간에 데이터의 모양을 타입으로 적었습니다. 오늘은 그 타입을 컴포넌트에 연결해요. 무엇이 들어오고 무엇이 나가는지를 함수 시그니처 하나만 봐도 알 수 있게 만드는 것이 오늘의 전부입니다.
🎯 학습 목표
- 이벤트 핸들러를 밖으로 뺐을 때 필요한 이벤트 타입을 스스로 찾아 적을 수 있다
- props 타입을 새로 선언할지, 파생시킬지, 확장할지 판단할 수 있다
children과ref를 props로 명시해서 받을 수 있다
Step 1: "핸들러를 밖으로 빼는 순간"
오프닝에서 본 빨간 줄부터 확인하고 갑시다. 에디터에 마우스를 올리면 이렇게 나옵니다.
error TS7006: Parameter 'event' implicitly has an 'any' type.
매개변수 event가 슬그머니 any가 됐다는 뜻입니다. 지난 A-1 시간에 strict를 켜 두었죠. any를 조용히 넘어가지 않고 잡아주는 것이 그 설정의 일입니다.
인라인일 때는 왜 됐을까
같은 코드인데 JSX 안에 있을 때는 아무 말이 없었습니다. 그 이유는 화살표 함수가 놓인 위치에 있어요.
onChange={(event) => setContent(event.target.value)}
<input>의 onChange는 아무 함수나 받는 게 아닙니다. React 타입 정의에 "이 속성에는 이런 모양의 함수가 온다" 고 이미 적혀 있어서, 그 속성에 직접 써 넣은 화살표 함수는 자기 매개변수의 타입을 그 정보에서 물려받습니다. 주변 문맥을 보고 타입을 채워주는 것이라 문맥 기반 타이핑이라고 불러요.
밖으로 빼는 순간 이 연결이 끊어집니다. 함수 혼자 떨어져 있으면 자기가 나중에 어디에 쓰일지 알 방법이 없거든요.
인라인으로 쓸 때 밖으로 뺐을 때
<input onChange={(e) => ...} /> function handleChange(e) { ... }
↑ ↑
onChange 가 원하는 함수 모양을 물려받을 문맥이 없다
그 자리에서 물려받는다 → 직접 적어야 한다
그래서 직접 적는다
// apps/web-spa/src/components/CommentForm.tsx
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
이름을 두 조각으로 나눠 읽으면 외울 게 없습니다. ChangeEvent는 어떤 종류의 이벤트인지를 말하고, 꺾쇠 안의 HTMLInputElement는 그 이벤트가 어느 요소에서 났는지를 말해요. 값이 바뀌는 사건이 입력창에서 일어났다, 이렇게 읽으면 됩니다.
이걸 적어주면 event.target.value가 다시 string으로 잡힙니다. 자동완성도 돌아와요.
지금까지의 CommentForm은 이렇게 생겼습니다.
// apps/web-spa/src/components/CommentForm.tsx
import { useState } from 'react';
interface CommentFormProps {
onSubmit: (content: string) => void;
}
export function CommentForm({ onSubmit }: CommentFormProps) {
const [content, setContent] = useState('');
const isEmpty = content.trim() === '';
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
return (
<form
className="comment-form"
onSubmit={(event) => {
event.preventDefault();
onSubmit(content.trim());
setContent('');
}}
>
<input
className="comment-input"
value={content}
onChange={handleChange}
placeholder="댓글 달기..."
aria-label="댓글 입력"
/>
<button className="comment-submit" type="submit" disabled={isEmpty}>
게시
</button>
</form>
);
}
제출 핸들러는 아직 JSX 안에 남아 있습니다. 다음 Step에서 저것도 꺼냅니다.
💡 한 줄 정리
JSX 속성에 직접 쓴 화살표 함수는 이벤트 타입을 문맥에서 물려받지만, 이름 붙여 밖으로 뺀 함수는 물려받을 문맥이 없어 직접 적어야 합니다.
🙋 학생 질문 — "튜터님, React를 임포트한 적이 없는데 React.ChangeEvent를 어떻게 쓰나요?"
좋은 관찰입니다. 파일 맨 위를 보면 import { useState } from 'react' 뿐이고 React라는 이름을 가져온 적이 없죠.
타입을 적는 위치에서는 임포트 없이도 React.로 시작하는 이름을 쓸 수 있습니다. React 타입 패키지가 이 이름을 전역으로 열어두기 때문이에요. 이건 타입에만 해당하는 이야기라서, 실제 값으로 쓰려고 하면 그때는 임포트하라는 에러가 납니다.
지난 A-3 시간에 배운 방식으로 가져와도 똑같이 동작합니다.
import type { ChangeEvent } from 'react';
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
둘 다 맞는 코드예요. 팀에서 한쪽으로 정해두면 됩니다. 우리 강의는 React.를 붙이는 쪽으로 통일할게요. 이벤트 타입은 종류가 많은데, 앞에 React.가 붙어 있으면 임포트 목록을 늘리지 않고도 어디서 온 이름인지 바로 보이거든요.
Step 2: "클릭과 제출 — MouseEvent와 FormEvent"
이번엔 제출 핸들러도 밖으로 꺼내 보는데, 방식은 똑같아요. 어떤 이벤트가 어느 요소에서 나는지만 적으면 됩니다.
// apps/web-spa/src/components/CommentForm.tsx
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isEmpty) {
return;
}
onSubmit(content.trim());
setContent('');
}
제출 사건이 폼에서 일어났으니 FormEvent와 HTMLFormElement입니다.
event.preventDefault()는 지난 시간에도 있었던 줄이에요. 브라우저는 폼이 제출되면 페이지를 통째로 새로 불러오려고 합니다. 그 기본 동작을 막는 것이고, 이걸 빼먹으면 댓글을 달 때마다 화면이 깜빡이며 처음으로 돌아갑니다.
if (isEmpty) return도 새로 넣었습니다. 버튼은 비활성이라 못 누르지만, 입력창에서 엔터를 치면 제출은 그대로 일어나거든요. 빈 댓글이 달리는 걸 막아줍니다.
// apps/web-spa/src/components/CommentForm.tsx
import { useState } from 'react';
interface CommentFormProps {
onSubmit: (content: string) => void;
}
export function CommentForm({ onSubmit }: CommentFormProps) {
const [content, setContent] = useState('');
const isEmpty = content.trim() === '';
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isEmpty) {
return;
}
onSubmit(content.trim());
setContent('');
}
return (
<form className="comment-form" onSubmit={handleSubmit}>
<input
className="comment-input"
value={content}
onChange={handleChange}
placeholder="댓글 달기..."
aria-label="댓글 입력"
/>
<button className="comment-submit" type="submit" disabled={isEmpty}>
게시
</button>
</form>
);
}
JSX가 훨씬 읽기 좋아졌습니다. 폼이 무엇을 그리는지와 제출될 때 무슨 일이 일어나는지가 나뉘었어요.
마우스 이벤트로 더블클릭 좋아요 만들기
인스타그램에는 사진을 두 번 두드리면 좋아요가 눌리는 동작이 있는데, 이제 우리도 만들 수 있습니다.
// apps/web-spa/src/components/PostCard.tsx
function handleImageDoubleClick(event: React.MouseEvent<HTMLImageElement>) {
// 더블클릭이 이미지를 선택 상태로 만드는 브라우저 기본 동작을 막는다
event.preventDefault();
onToggleLike(id);
}
<img
className="post-image"
src={imageUrl}
alt={`${username} 의 게시물`}
onDoubleClick={handleImageDoubleClick}
/>
클릭 계열 사건이 이미지에서 났으니 MouseEvent와 HTMLImageElement입니다. 지난 시간에 만들어 둔 onToggleLike를 그대로 부르기 때문에, 버튼으로 누르든 사진을 두드리든 같은 상태를 건드립니다.
이름 짓는 규칙은 하나뿐
이벤트 타입은 종류가 많지만 형태는 전부 같습니다.
| 사용자가 한 일 | 이벤트 타입 | 자주 쓰는 요소 |
|---|---|---|
| 입력창에 글자를 친다 | React.ChangeEvent |
HTMLInputElement·HTMLTextAreaElement |
| 버튼이나 사진을 누른다 | React.MouseEvent |
HTMLButtonElement·HTMLImageElement |
| 폼을 제출한다 | React.FormEvent |
HTMLFormElement |
| 키를 누른다 | React.KeyboardEvent |
HTMLInputElement |
| 입력창에 커서가 들어가고 나간다 | React.FocusEvent |
HTMLInputElement |
외울 필요는 없습니다. 요소 이름을 모르겠으면 JSX 안에 인라인으로 한 번 써 보세요. 에디터가 추론해서 보여주는 타입을 그대로 복사해 밖으로 옮기면 됩니다.
요소를 틀리게 적으면 어디서 걸릴까
HTMLInputElement 자리에 엉뚱한 요소를 적었다고 해봅시다. 재미있는 것은 에러가 뜨는 위치예요.
error TS2322: Type '(event: ChangeEvent<HTMLFormElement, Element>) => void' is not
assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.
핸들러를 정의한 곳이 아니라 그 핸들러를 onChange에 붙이는 곳에서 걸립니다. 함수 자체는 그것대로 말이 되는 함수니까요. 문제는 그 함수를 입력창에 붙일 수 없다는 것이고, TypeScript는 붙이려는 순간에 그 사실을 말해줍니다.
💡 한 줄 정리
이벤트 타입은 React.{무슨 사건}Event<{어느 요소}> 한 가지 형태입니다. 요소를 잘못 적으면 핸들러를 정의한 곳이 아니라 JSX에 붙이는 곳에서 걸립니다.
🙋 학생 질문 — "튜터님, event.target과 event.currentTarget은 뭐가 다른가요?"
currentTarget은 핸들러를 붙여 둔 요소이고, target은 실제로 사건이 시작된 요소입니다. 대개는 같지만 다를 때가 있어요. 버튼 안에 아이콘이 들어 있으면 아이콘을 눌렀을 때 target은 아이콘이고 currentTarget은 버튼입니다.
타입에서도 이 차이가 드러납니다. ChangeEvent는 target을 우리가 적어준 요소로 좁혀줘요. 그래서 event.target.value가 바로 됩니다. 값이 바뀌는 사건은 값을 가진 요소에서만 나기 때문에 React 타입이 이렇게 정해뒀어요.
반면 MouseEvent는 안 좁혀줍니다. 클릭은 어느 요소에서든 날 수 있으니까요. 확인해 보면 이렇게 말합니다.
error TS2339: Property 'value' does not exist on type 'EventTarget'.
그래서 클릭 핸들러 안에서 요소의 정보를 읽고 싶으면 event.currentTarget을 쓰세요. 이쪽은 우리가 적어준 요소 타입으로 좁혀져 있습니다. 습관을 하나만 들이자면 currentTarget 쪽이 안전합니다.
Step 3: "함수를 props로 받을 때"
지금까지는 컴포넌트가 자기 안에서 쓰는 핸들러에 타입을 달았는데, 함수는 props를 타고 컴포넌트 사이를 오가기도 하죠. 지난 시간에 이미 그렇게 썼어요.
// apps/web-spa/src/components/CommentForm.tsx
interface CommentFormProps {
onSubmit: (content: string) => void;
}
(content: string) => void는 함수의 모양을 적은 타입입니다. 문자열 하나를 받고 아무것도 안 돌려준다는 뜻이에요. 화살표 왼쪽이 받는 것, 오른쪽이 돌려주는 것입니다.
돌려줄 게 없을 때 쓰는 void가 눈에 띌 겁니다. 부모가 이 함수로 무엇을 하든 자식은 관심이 없다는 선언이에요. 자식은 그저 "댓글이 달렸다" 고 알리기만 하고, 그 뒤에 무슨 일이 일어날지는 부모가 정합니다.
지난 시간의 의문 하나
LikeButton을 다시 봅시다.
// apps/web-spa/src/components/LikeButton.tsx
interface LikeButtonProps {
liked: boolean;
likeCount: number;
onToggle: () => void;
}
onToggle은 아무것도 안 받는 함수지만, 이걸 버튼에 그대로 넘겼어요.
<button onClick={onToggle}>
onClick은 함수를 부를 때 이벤트 객체를 하나 넘겨줍니다. 그런데 아무것도 안 받는 함수를 넣었는데도 통과했어요. 왜일까요.
넘겨주는 쪽이 인자를 하나 준다고 해서 받는 쪽이 반드시 받아야 하는 건 아니기 때문입니다. 안 쓸 거면 안 받아도 돼요. 반대로 받는 쪽이 요구하는 것보다 적게 주는 것은 안 됩니다.
onClick 이 부를 때: handler(event)
() => void 받기로 한 게 없다 → event 를 그냥 흘려보낸다 → 통과
(e: MouseEvent) => void 받기로 한 걸 받는다 → 통과
(a, b) => void 두 번째 인자를 아무도 안 준다 → 거절
JavaScript에서 인자를 덜 받는 함수를 부르는 건 원래 자연스러운 일이고, 타입도 그 사실을 그대로 따릅니다. 덕분에 이벤트 객체가 필요 없는 핸들러는 매개변수를 아예 안 적어도 됩니다. 실제로 클릭 핸들러 대부분이 이벤트 객체를 안 씁니다.
준비된 이름을 쓰는 방법
이벤트 핸들러의 모양은 워낙 자주 쓰여서 React가 이름을 미리 만들어 뒀습니다.
// apps/web-spa/src/components/CommentInput.tsx — Step 7 에서 만들 입력창 컴포넌트
interface CommentInputProps {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
}
React.ChangeEventHandler<HTMLInputElement>는 (event: React.ChangeEvent<HTMLInputElement>) => void와 똑같은 뜻입니다. 짧아지죠. 이 컴포넌트는 Step 7에서 실제로 만듭니다.
소괄호 하나가 만드는 사고
지난 시간에 함수를 넘기는 것과 부르는 것을 구분했습니다. props로 넘길 때도 똑같은 실수가 나옵니다.
<LikeButton onToggle={handleToggle()} />
error TS2322: Type 'void' is not assignable to type '() => void'.
handleToggle()은 함수를 실행한 결과이고 그 결과는 void입니다. props는 나중에 부를 함수를 원했는데 이미 실행해 버린 뒤의 빈손을 받은 거예요. 함수 타입을 적어두면 이런 실수가 화면을 열어보기 전에 잡힙니다.
💡 한 줄 정리
props로 받는 함수는 (받는 것) => 돌려주는 것으로 적습니다. 받기로 한 것보다 적게 받는 함수는 그 자리에 들어갈 수 있어서, 이벤트가 필요 없는 핸들러는 매개변수를 안 적어도 됩니다.
🙋 학생 질문 — "튜터님, 핸들러 별칭과 직접 적기 중에 뭘 써야 하나요?"
둘은 완전히 같은 타입이라 어느 쪽을 써도 동작은 같습니다. 판단 기준은 그 props가 무엇을 약속하느냐예요.
React.ChangeEventHandler<HTMLInputElement>라고 적으면 "나는 입력창의 onChange에 그대로 꽂을 함수를 받는다"는 뜻이 됩니다. 그 컴포넌트가 입력창을 감싸고 있다는 사실이 타입에 드러나요.
반대로 (content: string) => void처럼 적으면 이벤트 객체가 아니라 이미 다듬어진 값을 넘기겠다는 뜻입니다. CommentForm이 이쪽이죠. 부모는 이벤트가 무엇인지 몰라도 되고 댓글 내용만 받으면 됩니다.
정리하면 이렇습니다. DOM 이벤트를 그대로 전달하는 얇은 컴포넌트라면 별칭이 잘 맞고, 자기가 한 번 가공해서 의미 있는 값을 올려보내는 컴포넌트라면 직접 적는 쪽이 낫습니다. 뒤쪽이 대체로 더 좋은 설계예요. 부모가 자식의 DOM 구조를 몰라도 되니까요.
Step 4: "props 타입을 짓는 세 가지 길"
지금 우리 코드베이스에는 props 타입이 세 가지 방식으로 만들어져 있습니다. 지금까지 그때그때 편한 대로 썼는데, 오늘 기준을 세우고 갑시다.
첫째, 새로 선언한다
// apps/web-spa/src/components/Avatar.tsx
interface AvatarProps {
username: string;
profileImageUrl: string;
}
Avatar가 필요한 것만 손으로 적었습니다. 짧고, 이 컴포넌트가 무엇을 요구하는지 한눈에 보여요.
둘째, 원본에서 파생시킨다
// apps/web-spa/src/types/derived.ts
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;
A-3에서 만든 타입입니다. Post에서 카드가 안 쓰는 두 개를 빼고 나머지를 그대로 씁니다. 백엔드가 Post에 필드를 하나 추가하면 이쪽도 자동으로 따라와요.
셋째, 있는 것을 확장한다
// apps/web-spa/src/components/PostCard.tsx
interface PostCardViewProps extends PostCardProps {
onToggleLike: (id: number) => void;
}
지난 시간에 만든 것입니다. 서버가 준 데이터는 PostCardProps 그대로 두고, 화면에서만 필요한 콜백을 얹었어요.
무엇을 언제 쓰나
| 방식 | 어울리는 곳 | 대가 |
|---|---|---|
| 새로 선언 | 서버 데이터와 상관없는 작은 표시용 컴포넌트 | 원본이 바뀌어도 안 따라온다 |
| 파생 | 서버 응답을 거의 그대로 그리는 컴포넌트 | 원본을 모르면 무엇이 오는지 안 보인다 |
| 확장 | 데이터에 화면 전용 값을 더할 때 | 상속 사슬이 길어지면 추적이 힘들다 |
기준을 한 문장으로 줄이면 이렇습니다. 그 props가 서버 데이터의 일부인가, 화면이 만들어낸 값인가. 서버에서 온 것은 원본에서 파생시키고, 화면이 만들어낸 것은 손으로 적습니다. PostCardViewProps가 정확히 이 둘을 나눠 담고 있어요.
interface와 type 중 무엇을 쓸지도 같은 결론입니다. A-1에서 정한 대로 공개되는 객체 모양은 interface, 유틸리티 타입으로 만든 것은 type입니다. extends로 얹을 일이 있으면 interface가 편해요.
💡 한 줄 정리
서버 데이터에서 온 props는 원본 타입에서 파생시키고, 화면이 만들어낸 props는 새로 적습니다. 둘이 섞이면 extends로 나눠 담습니다.
🙋 학생 질문 — "튜터님, 그럼 AvatarProps도 Post에서 파생시키는 게 낫지 않나요?"
지난 시간 마지막에 이 이야기를 던져뒀었죠. 답부터 말하면 지금은 안 하는 쪽이 낫습니다.
Avatar가 받는 username과 profileImageUrl은 확실히 Post에도 있는 이름입니다. Pick<Post, 'username' | 'profileImageUrl'>로 뽑아낼 수 있는데, 그렇게 적는 순간 Avatar는 게시물 전용 컴포넌트가 됩니다.
아바타는 게시물에만 뜨는 게 아니에요. 프로필 화면에도 뜨고, 댓글 목록에도 뜨고, 팔로워 목록에도 뜹니다. 그때 넘어오는 데이터는 Post가 아니라 User 나 Comment 죠. Post에서 파생시켜 두면 User를 그리려는 순간 타입이 안 맞습니다.
그래서 판단 기준이 "이름이 같은가"가 아니라 "운명을 같이하는가"입니다. Post에 필드가 하나 늘었을 때 Avatar도 따라 바뀌어야 하나요? 아니죠. 서로 상관없이 바뀔 것들을 타입으로 묶어두면 나중에 한쪽을 고칠 때 다른 쪽이 딸려옵니다.
PostCardProps는 반대입니다. 카드는 게시물을 그리려고 존재하니까 Post와 운명을 같이해요. 그래서 파생이 맞습니다.
Step 5: "컴포넌트를 함수로 선언하는 이유"
여기까지 우리가 만든 컴포넌트는 전부 같은 모양이었습니다.
function 이름({ 구조분해 }: Props) {
return (JSX);
}
너무 당연해 보이지만, 사실 이건 선택의 결과입니다. 예전에는 다른 방식이 표준에 가까웠고, 지금도 오래된 코드나 조금 지난 블로그 글에서는 그쪽을 자주 만나게 될 거예요.
예전 방식
React.FC라는 타입이 있습니다. Function Component의 줄임말이고, 컴포넌트를 변수에 담으면서 그 변수에 이 타입을 붙이는 방식이었어요. 이름을 알아둬야 하는 이유는 하나입니다. 남의 코드에서 봤을 때 "아, 옛날 방식이구나" 하고 알아보기 위해서요. 새로 쓸 일은 없습니다.
밀려난 이유는 세 가지입니다.
첫째, children이 자동으로 딸려왔습니다. 자식을 받을 생각이 없는 컴포넌트에도 자식을 넘길 수 있었어요. props에 안 적은 것이 들어온다는 뜻이라 타입이 거짓말을 하는 셈이었습니다. 이 문제는 지금은 고쳐졌지만, 고치는 과정에서 버전마다 동작이 달라져 혼란이 컸습니다.
둘째, 제네릭 컴포넌트를 만들기가 까다로웠습니다. 어떤 타입의 목록이든 받아 그리는 컴포넌트 같은 것을 말해요. 변수에 타입을 붙이는 형태라 타입 매개변수를 얹을 자리가 마땅치 않았습니다. 이건 다음 시간에 직접 만들어 봅니다.
셋째, 함수 선언의 장점을 버려야 했습니다. function으로 선언하면 파일 안 어디에 두든 위에서 쓸 수 있고, 스택 트레이스에 이름이 제대로 찍히고, 에러 메시지도 짧습니다.
지금은 함수로 선언하고 매개변수에 타입을 붙이는 방식이 표준인데, 우리가 처음부터 써온 그대로예요.
반환 타입은 적지 않습니다
함수를 배울 때 반환 타입을 적으라고 배웠으니 이렇게 쓰고 싶어질 수 있습니다.
export function Avatar({ username }: AvatarProps): JSX.Element {
그런데 이건 아예 컴파일이 안 됩니다.
error TS2503: Cannot find namespace 'JSX'.
예전에는 JSX라는 이름이 전역에 열려 있었지만 React 19의 타입 정의에서는 React 안쪽으로 들어갔습니다. 그래서 굳이 적으려면 React.JSX.Element라고 써야 해요.
하지만 적지 않는 쪽을 권합니다. 컴포넌트가 무엇을 돌려주는지는 return 문만 봐도 뻔하고, TypeScript가 알아서 정확하게 추론합니다. A-3에서 이야기했듯 애너테이션은 추론이 못 하거나 틀리게 하는 곳에만 붙이는 것이 좋아요. 여기는 둘 다 아닙니다.
💡 한 줄 정리
컴포넌트는 function으로 선언하고 매개변수에 props 타입을 붙입니다. 반환 타입은 추론에 맡깁니다.
🙋 학생 질문 — "튜터님, 그럼 화살표 함수로 컴포넌트를 만들면 틀린 건가요?"
틀리지 않습니다. 아래 두 개는 똑같이 잘 동작해요.
function Avatar({ username }: AvatarProps) {
return <span>{username}</span>;
}
const Avatar = ({ username }: AvatarProps) => {
return <span>{username}</span>;
};
제가 앞에서 밀어낸 것은 화살표 함수 자체가 아니라 변수에 컴포넌트 전용 타입을 붙이는 방식입니다. 화살표 함수를 쓰면서 매개변수에 타입을 다는 건 아무 문제가 없어요.
실제로 팀에 따라 화살표 함수로 통일하는 곳도 많습니다. 파일 안에서 컴포넌트와 일반 함수를 한눈에 구분하기 좋다는 이유로요.
우리 강의는 function 선언으로 통일합니다. 컴포넌트가 여러 개인 파일에서 순서 신경 안 쓰고 위아래로 배치할 수 있어서요. 여러분이 나중에 팀에 들어가면 그 팀 규칙을 따르면 됩니다. 중요한 건 한 프로젝트 안에서 섞어 쓰지 않는 것입니다.
Step 6: "children을 명시로 받기"
지금까지 컴포넌트에 값을 넘길 때는 항상 속성을 썼는데, HTML을 떠올려 보면 태그 사이에 내용을 넣는 방식도 있죠.
<section>
<p>여기가 내용입니다</p>
</section>
React 컴포넌트도 똑같이 쓸 수 있습니다. 피드 위에 제목을 붙이는 껍데기를 하나 만들어 봅시다.
// apps/web-spa/src/components/Section.tsx
interface SectionProps {
title: string;
children: React.ReactNode;
}
export function Section({ title, children }: SectionProps) {
return (
<section className="section" aria-label={title}>
<h2 className="section-title">{title}</h2>
{children}
</section>
);
}
children은 React가 정해둔 특별한 이름입니다. 태그 사이에 넣은 것이 이 이름으로 들어와요. 나머지는 우리가 아는 props와 똑같습니다. props 목록에 적고, 구조분해로 꺼내고, JSX 안에서 중괄호로 그립니다.
App에서 이렇게 씁니다.
// apps/web-spa/src/App.tsx
<Section title="피드">
<Feed posts={posts} onToggleLike={handleToggleLike} />
</Section>
<Section>과 </Section> 사이에 넣은 <Feed />가 children으로 넘어가서 제목 아래에 그려집니다.
ReactNode는 넓은 타입입니다
React.ReactNode는 화면에 그릴 수 있는 거의 모든 것을 받습니다.
React.ReactNode 가 받는 것
<Feed /> 컴포넌트
<p>글자</p> HTML 요소
"그냥 문자열" 문자열
42 숫자
[<A />, <B />] 위 것들의 배열
null · undefined 아무것도 안 그린다
못 받는 것: { username: 'jaehoon' } 같은 순수 객체
넓은 이유는 분명합니다. 태그 사이에 뭐가 올지는 쓰는 사람 마음이니까요. 숫자나 문자열을 그대로 넣어도 되고, 조건에 따라 null을 넣어 아무것도 안 그릴 수도 있습니다.
순수 객체만 막혀 있는데, 이건 React가 화면에 어떻게 그려야 할지 모르기 때문입니다. 실수로 객체를 넣으면 이렇게 알려줘요.
error TS2353: Object literal may only specify known properties, and 'username'
does not exist in type 'ReactElement<...> | Iterable<ReactNode> | ReactPortal | ...'
안 적으면 못 받습니다
여기가 중요합니다. props에 children을 안 적어두면 자식을 넘길 수 없어요.
interface BadgeProps {
label: string;
}
export function Badge({ label }: BadgeProps) {
return <span className="badge">{label}</span>;
}
이 컴포넌트에 자식을 넣으면 이렇게 막힙니다.
error TS2322: Type '{ children: string; label: string; }' is not assignable to type
'IntrinsicAttributes & BadgeProps'.
Property 'children' does not exist on type 'IntrinsicAttributes & BadgeProps'.
불편해 보이지만 이게 맞습니다. Badge는 자식을 그릴 방법이 없거든요. 넘겨봐야 화면에 안 나옵니다. 조용히 사라지는 대신 미리 막아주는 편이 훨씬 낫죠. 앞 Step에서 이야기한 예전 방식의 문제가 정확히 이것이었습니다.
자식을 받고 싶으면 받겠다고 적으면 됩니다. 그것뿐이에요.
💡 한 줄 정리
태그 사이의 내용은 children이라는 이름으로 들어옵니다. children: React.ReactNode를 props에 적어둔 컴포넌트만 자식을 받을 수 있습니다.
🙋 학생 질문 — "튜터님, 그냥 props로 넘기면 되는데 왜 굳이 children을 쓰나요?"
<Section title="피드" content={<Feed />} />처럼 써도 화면은 똑같이 나옵니다. 맞는 말이에요.
차이는 읽는 사람에게 있습니다. children으로 쓰면 감싸는 관계가 코드 모양에 그대로 드러나요. 여는 태그와 닫는 태그 사이에 들어 있으니 무엇이 무엇을 감싸는지 눈으로 보입니다. HTML을 읽던 감각을 그대로 쓸 수 있고요.
그리고 자식이 여러 개이거나 개수가 정해지지 않았을 때 훨씬 편합니다. props로 넘기려면 배열로 묶어야 하는데, 태그 사이에는 그냥 나열하면 되거든요.
다만 자식을 받을 곳이 여러 군데면 이야기가 달라집니다. 예를 들어 카드에 머리말과 본문과 꼬리말을 각각 받아야 하면 children 하나로는 부족해요. 그때는 header·footer 같은 이름으로 나눠 받습니다. 이렇게 컴포넌트를 조립해서 화면을 만드는 방법은 다음 시간(B-3)에 본격적으로 다룹니다.
Step 7: "ref를 props로 받기"
마지막 입구입니다. 지금까지는 값이 들어오기만 했는데, 이번에는 컴포넌트 안의 실제 DOM 요소를 밖으로 꺼내 봅니다.
이유가 있어요. 지금 댓글을 달고 나면 입력창에서 커서가 사라집니다. 연달아 댓글을 쓰려면 매번 입력창을 다시 눌러야 해요. 인스타그램은 안 그렇죠.
커서를 옮기는 일은 값을 바꿔서 할 수 없습니다. 실제 <input> 요소를 붙잡고 focus()를 불러야 해요. 그 붙잡는 손잡이가 ref입니다.
입력창을 별도 컴포넌트로 떼어냅니다
// apps/web-spa/src/components/CommentInput.tsx
interface CommentInputProps {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
ref: React.Ref<HTMLInputElement>;
}
export function CommentInput({ value, onChange, ref }: CommentInputProps) {
return (
<input
className="comment-input"
ref={ref}
value={value}
onChange={onChange}
placeholder="댓글 달기..."
aria-label="댓글 입력"
/>
);
}
ref를 다른 props와 똑같이 받아서 <input>에 그대로 넘겼습니다. 특별할 게 없어 보이죠. 이게 React 19의 변화입니다.
타입은 React.Ref<HTMLInputElement>예요. 앞에서 배운 규칙이 그대로 통합니다. 무엇을 가리키는 손잡이인지를 꺾쇠 안에 적습니다.
예전에는 특별 취급이었습니다
ref는 오랫동안 일반 props가 아니었습니다. 컴포넌트에 ref를 넘기면 React가 중간에서 가로챘고, 자식에게 전달하려면 forwardRef라는 함수로 컴포넌트를 한 겹 감싸야 했는데, 그 바람에 컴포넌트 모양이 달라지고 타입도 복잡해졌습니다.
React 19부터는 감쌀 필요가 없습니다. ref가 그냥 props가 됐어요. forwardRef는 아직 동작하지만 쓸 이유가 없고, React 팀도 앞으로 걷어낼 예정이라고 밝혔습니다. 오래된 코드에서 만나면 "이건 옛날에 필요했던 껍데기구나" 하고 알아보면 됩니다.
부모가 손잡이를 만들어 넘깁니다
// apps/web-spa/src/components/CommentForm.tsx
import { useRef, useState } from 'react';
import { CommentInput } from './CommentInput';
interface CommentFormProps {
onSubmit: (content: string) => void;
}
export function CommentForm({ onSubmit }: CommentFormProps) {
const [content, setContent] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isEmpty = content.trim() === '';
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isEmpty) {
return;
}
onSubmit(content.trim());
setContent('');
inputRef.current?.focus();
}
return (
<form className="comment-form" onSubmit={handleSubmit}>
<CommentInput ref={inputRef} value={content} onChange={handleChange} />
<button className="comment-submit" type="submit" disabled={isEmpty}>
게시
</button>
</form>
);
}
useRef로 빈 손잡이를 만들어 CommentInput에 넘깁니다. React가 실제 <input>을 그리고 나면 그 요소를 inputRef.current에 넣어줘요.
inputRef.current?.focus()의 물음표를 보세요. 아직 화면에 안 그려졌을 수도 있어서 current는 비어 있을 수 있습니다. A-2에서 배운 좁히기가 여기서 쓰입니다. 있을 때만 focus()를 부르는 거예요.
댓글을 달면 이제 커서가 입력창에 남아서 연달아 쓸 수 있어요.
상태로 하는 일 ref 로 하는 일
값이 바뀌면 화면을 다시 그린다 화면을 다시 그리지 않는다
무엇을 보여줄지 정한다 이미 그려진 요소를 직접 만진다
예) 입력값·좋아요·댓글 목록 예) 커서 옮기기·스크롤 위치·재생 정지
경계는 이렇게 잡으면 됩니다. 화면에 보이는 내용이 달라지면 상태, 화면은 그대로인데 브라우저에 뭔가 시켜야 하면 ref입니다. ref를 상태 대신 쓰면 값이 바뀌어도 화면이 안 따라오니 조심하세요.
💡 한 줄 정리
React 19에서 ref는 일반 props입니다. ref: React.Ref<HTMLInputElement>로 받아 안쪽 요소에 넘기면, 부모가 그 요소를 직접 만질 수 있습니다.
🙋 학생 질문 — "튜터님, ref를 아무 컴포넌트에나 넘길 수 있나요?"
아니요. 받겠다고 적어둔 컴포넌트에만 넘길 수 있습니다. children과 똑같아요. props에 ref가 없으면 넘길 수 없습니다.
그리고 어떤 요소를 가리키는지도 맞아야 합니다. 입력창용 손잡이를 다른 요소에 달면 이렇게 막혀요.
error TS2322: Type 'Ref<HTMLInputElement>' is not assignable to type
'Ref<HTMLTextAreaElement> | undefined'.
당연한 안전장치입니다. HTMLInputElement라고 적어둔 손잡이를 잡고 입력창에만 있는 기능을 부를 텐데, 실제로는 다른 요소가 들어 있으면 실행 중에 터지겠죠. 그걸 미리 막아줍니다.
하나 더 기억해 두세요. ref는 꼭 필요할 때만 씁니다. 화면에 보이는 것을 바꾸려고 ref로 DOM을 직접 고치기 시작하면 지난 과목의 순수 JS로 돌아가는 것과 같습니다. 상태로 안 되는 일에만 쓰세요.
마무리
오늘 컴포넌트의 입구를 하나씩 열어봤습니다. props로 값이 들어오고, 이벤트로 사용자의 동작이 들어오고, children으로 다른 화면 조각이 들어오고, ref로 요소가 나갑니다. 네 개 다 타입으로 적었어요.
지난 세 번의 TypeScript 시간이 데이터의 모양을 그리는 일이었다면 오늘은 그 타입이 화면과 만나는 지점을 다뤘습니다. Post라는 타입 하나가 PostCardProps로 파생되고, 그게 다시 컴포넌트의 입구가 되어 화면까지 이어졌어요.
오늘 배운 핵심 세 가지
💡 하나 — 문맥이 없으면 타입을 직접 적습니다. JSX 속성에 바로 쓴 화살표 함수는 이벤트 타입을 물려받지만, 이름 붙여 밖으로 뺀 함수는 그 연결이 끊깁니다. 그래서 React.{무슨 사건}Event<{어느 요소}>를 적어줍니다.
💡 둘 — props 타입은 출처를 따라 짓습니다. 서버 데이터에서 온 것은 원본에서 파생시키고, 화면이 만들어낸 것은 새로 적습니다. 둘이 섞이면 extends로 나눠 담아요. 기준은 이름이 같은지가 아니라 함께 바뀔 운명인지입니다.
💡 셋 — 안 적은 것은 못 받습니다. children도 ref도 props에 적어둔 컴포넌트만 받을 수 있습니다. 불편해 보여도 이게 맞아요. 받을 방법이 없는 것이 조용히 넘어와 사라지는 것보다 낫습니다.
다음 시간 예고
오늘 Section을 만들면서 자식을 받는 껍데기를 처음 써봤는데, 자식을 받을 곳이 하나뿐이라 제목과 본문 정도만 나눌 수 있었어요. 실제 화면에는 머리말과 꼬리말이 따로 있는 카드, 눌러야 열리는 패널처럼 자식을 여러 갈래로 받아야 하는 껍데기가 많습니다.
다음 시간에는 컴포넌트를 조립해서 화면을 만드는 방법을 배웁니다. 큰 컴포넌트를 언제 쪼개야 하는지, 쪼갠 것들을 어떻게 다시 합칠지가 주제예요.
그리고 오늘 CommentForm 안에 useState와 useRef와 핸들러 두 개가 함께 들어갔습니다. 지난 시간 LikeButton에서도 비슷한 묶음을 봤죠. 이렇게 반복되는 묶음을 통째로 뽑아 이름을 붙이는 방법도 함께 다룹니다.
과제
[구현] 댓글 입력창에 글자 수 세기
CommentInput 아래에 지금 몇 글자를 썼는지 보여주고, 너무 길면 못 쓰게 막아 주세요.
요구사항은 다음과 같습니다.
apps/web-spa/src/components/CommentCounter.tsx에CommentCounter컴포넌트를 만들어 주세요. 지금 글자 수와 최대 글자 수를 props로 받습니다.- 최대 글자 수는 100 자로 하고,
CommentForm이 넘겨줍니다.CommentCounter안에 숫자를 적어두지 마세요. - 남은 글자가 10 자 이하로 떨어지면 눈에 띄게 보여 주세요.
- 100 자를 넘기면 게시 버튼이 안 눌리게 해주세요. 지금 빈 값을 막는 것과 같은 방식으로 하면 됩니다.
CommentForm의 핸들러는 전부 이름 붙인 함수로 두고, 이벤트 타입을 명시해 주세요.Section을 써서 피드 제목 아래에 카드가 들어가는 구조는 그대로 유지해 주세요.
다 만들고 나면 npm run typecheck -w web-spa와 npm run lint -w web-spa를 돌려 둘 다 통과하는지 확인해 주세요.
useMemo 나 useCallback은 쓰지 마세요. 아직 안 배웠고, 오늘 배운 것만으로 충분히 됩니다.
[탐구] 타입을 일부러 어겨보기
아래 여섯 가지를 하나씩 시도해 보고, 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.
handleChange의React.ChangeEvent<HTMLInputElement>에서 꺾쇠 안을HTMLFormElement로 바꿔보기- 더블클릭 핸들러 안에
console.log(event.currentTarget.src)를 넣어보고, 그 다음event.target.src로 바꿔보기 Avatar의 반환 타입에: JSX.Element를 적어보기Badge처럼children을 안 받는 컴포넌트에 자식 넘겨보기CommentInput에 넘기는inputRef를<textarea>에 달아보기<LikeButton onToggle={handleToggle} />을onToggle={handleToggle()}로 바꿔보기
특히 첫 번째와 두 번째를 비교해 보세요. 하나는 핸들러를 붙이는 곳에서 걸리고 다른 하나는 핸들러 안에서 걸립니다. 왜 걸리는 위치가 다른지 생각해 보세요.
생각해볼 주제
1. 타입을 파생시킬 것인가 새로 적을 것인가
오늘 PostCardProps는 Post에서 파생시켰고 AvatarProps는 새로 적었습니다. 기준으로 "함께 바뀔 운명인가"를 제시했죠.
그런데 실무에서는 이 판단이 처음부터 명확한 경우가 드뭅니다. 지금은 게시물에만 쓰이지만 나중에 다른 화면에도 쓰일지 모르는 컴포넌트라면 어느 쪽으로 시작하시겠어요. 나중에 방향을 바꿔야 할 때 어느 쪽에서 어느 쪽으로 옮기는 게 더 쉬울지도 함께 생각해 보세요.
2. 이벤트 타입을 매번 적는 비용
오늘 핸들러를 밖으로 뺄 때마다 타입을 적었습니다. 인라인으로 두면 안 적어도 되는데 말이죠.
그렇다면 짧은 핸들러는 계속 인라인으로 두는 게 나을까요. 아니면 길이와 상관없이 밖으로 빼는 규칙을 정하는 게 나을까요. 타입을 적는 수고 말고도 두 방식은 읽기 쉬움이나 컴포넌트가 다시 그려질 때의 동작에서 차이가 납니다. 어떤 기준으로 정하시겠어요.
3. ref가 일반 props가 된 것
React 19는 ref를 특별 취급하던 규칙을 없애고 보통의 props로 만들었습니다. 감싸는 껍데기가 사라져서 코드는 확실히 짧아졌어요.
특별 취급에는 특별 취급 나름의 쓸모가 있었을 수도 있는데, 문법적으로 눈에 띄면 "여기서 DOM을 직접 만지는구나" 하고 알아채기 쉬웠을 테니까요. 쉬워진 것과 눈에 덜 띄게 된 것 사이에서 무엇을 얻고 무엇을 잃었을까요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 댓글 입력창에 글자 수 세기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| props 설계 | 지금 글자 수와 최대 글자 수를 둘 다 props로 받았는가 | 20 |
| 한도의 위치 | 100 이라는 숫자가 CommentCounter 안에 없고 CommentForm에 있는가 |
15 |
| 이벤트 타이핑 | 핸들러가 전부 이름 붙은 함수이고 이벤트 타입이 명시됐는가 | 20 |
| 경고 표시 | 남은 글자가 10 자 이하일 때 구별되게 보이는가 | 10 |
| 제출 차단 | 한도를 넘기면 버튼도 잠기고 엔터로도 안 올라가는가 | 20 |
| 검사 통과 | typecheck와 lint가 모두 통과하는가 |
15 |
한도를 넘긴 상태에서 엔터를 쳐봤다면 이 과제의 숨은 부분까지 잡으신 겁니다.
풀이 예시
먼저 세어 보여주는 컴포넌트입니다.
// apps/web-spa/src/components/CommentCounter.tsx
interface CommentCounterProps {
count: number;
max: number;
}
export function CommentCounter({ count, max }: CommentCounterProps) {
const remaining = max - count;
const isNearLimit = remaining <= 10;
return (
<p
className={isNearLimit ? 'comment-counter near-limit' : 'comment-counter'}
aria-label="글자 수"
>
{count} / {max}
</p>
);
}
props 두 개를 모두 받은 것이 핵심입니다. max를 안 받고 컴포넌트 안에 const MAX = 100이라고 적으면 당장은 동작하지만, 나중에 소개글 입력창에 이 컴포넌트를 다시 쓰려는 순간 막힙니다. 소개글은 한도가 다르거든요.
remaining을 상태로 두지 않은 것도 봐주세요. 글자 수에서 계산해 나오는 값이라 따로 기억할 이유가 없습니다. 지난 시간에 배운 기준 그대로예요. 다른 값에서 계산되는 것은 상태로 두지 않습니다.
다음은 폼입니다.
// apps/web-spa/src/components/CommentForm.tsx
import { useRef, useState } from 'react';
import { CommentInput } from './CommentInput';
import { CommentCounter } from './CommentCounter';
const MAX_COMMENT_LENGTH = 100;
interface CommentFormProps {
onSubmit: (content: string) => void;
}
export function CommentForm({ onSubmit }: CommentFormProps) {
const [content, setContent] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isEmpty = content.trim() === '';
const isTooLong = content.length > MAX_COMMENT_LENGTH;
const cannotSubmit = isEmpty || isTooLong;
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (cannotSubmit) {
return;
}
onSubmit(content.trim());
setContent('');
inputRef.current?.focus();
}
return (
<form className="comment-form" onSubmit={handleSubmit}>
<CommentInput ref={inputRef} value={content} onChange={handleChange} />
<CommentCounter count={content.length} max={MAX_COMMENT_LENGTH} />
<button className="comment-submit" type="submit" disabled={cannotSubmit}>
게시
</button>
</form>
);
}
MAX_COMMENT_LENGTH를 컴포넌트 바깥에 둔 이유가 있습니다. 렌더링과 상관없이 항상 같은 값이라 안에 둘 이유가 없어요. 밖에 두면 다시 그릴 때마다 새로 만들지 않습니다.
막는 조건을 cannotSubmit 하나로 합쳐둔 것도 봐주세요. 버튼의 disabled와 handleSubmit의 방어가 같은 조건을 보게 됩니다. 두 곳에 따로 적으면 한쪽만 고치는 사고가 나거든요.
스타일은 이 정도면 됩니다. 직접 globals.css에 더해 보세요.
.comment-counter {
margin: 0;
padding: 0 12px;
font-size: 12px;
color: #8e8e8e;
text-align: right;
}
.comment-counter.near-limit {
color: #ed4956;
font-weight: 600;
}
자주 나오는 실수
한도를 컴포넌트 안에 적는 것. 요구사항에서 굳이 막아둔 부분입니다. CommentCounter는 세어서 보여주는 일만 하고, 얼마까지 허용할지는 쓰는 쪽이 정합니다. 이렇게 나눠두면 소개글이든 댓글이든 같은 컴포넌트를 씁니다.
남은 글자를 상태로 두는 것.
const [remaining, setRemaining] = useState(100);
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setContent(event.target.value);
setRemaining(100 - event.target.value.length);
}
동작은 합니다. 하지만 상태가 두 개가 되면서 둘이 어긋날 길이 열립니다. 다른 곳에서 setContent만 부르면 글자 수가 안 맞게 되죠. 계산되는 값은 계산으로 두세요.
버튼만 잠그고 끝내는 것. 버튼의 disabled는 마우스로 누르는 것만 막습니다. 입력창에 커서를 두고 엔터를 치면 제출은 그대로 일어나요. handleSubmit 안에도 같은 방어를 넣어야 합니다.
maxLength 속성으로 처리하는 것. <input maxLength={100} />을 쓰면 아예 101 번째 글자가 안 쳐집니다. 간단해 보이지만 사용자는 왜 안 써지는지 모릅니다. 넘긴 상태를 보여주고 왜 못 올리는지 알려주는 편이 친절해요.
💡 튜터의 한마디
이 과제의 진짜 주제는 글자 세기가 아니라 무엇을 props로 열어둘 것인가입니다.
컴포넌트를 만들 때마다 이 질문을 하게 될 거예요. 안에 고정할까, 밖에서 받을까. 전부 받게 하면 쓸 때마다 넘겨야 해서 번거롭고, 전부 고정하면 한 곳에서만 쓸 수 있습니다.
기준은 "이 값이 쓰는 곳마다 다를까"입니다. 한도는 다르고, 세는 방식은 같죠. 그래서 한도는 props가 되고 세는 방식은 안에 남습니다.
🎯 [과제 2 예시답안] 타입을 일부러 어겨보기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 여섯 가지 시도 | 각각 실제로 해보고 메시지를 옮겨 적었는가 | 30 |
| 메시지 해석 | 무엇이 무엇과 안 맞는지 자기 말로 설명했는가 | 30 |
| 위치 비교 | 첫 번째와 두 번째가 왜 다른 곳에서 걸리는지 답했는가 | 25 |
| 정리 | 여섯 개를 관통하는 규칙을 한 줄로 뽑았는가 | 15 |
풀이 예시
1. 꺾쇠 안을 HTMLFormElement로 바꾸기
error TS2322: Type '(event: ChangeEvent<HTMLFormElement, Element>) => void' is not
assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.
핸들러를 정의한 곳이 아니라 onChange={handleChange}라고 붙이는 곳에서 걸립니다. 폼에서 나는 변경 사건을 받는 함수를 만든 것 자체는 말이 되니까요. 문제는 그 함수를 입력창에 붙일 수 없다는 것입니다.
2. event.currentTarget.src를 event.target.src로 바꾸기
error TS2339: Property 'src' does not exist on type 'EventTarget'.
이번에는 핸들러 안에서 걸립니다. currentTarget은 HTMLImageElement로 좁혀져 있어서 src를 읽을 수 있지만, target은 좁혀지지 않은 EventTarget이라 src가 없어요.
3. 반환 타입에 : JSX.Element 적기
error TS2503: Cannot find namespace 'JSX'.
타입이 안 맞는다는 말이 아니라 그런 이름을 못 찾겠다는 말입니다. React 19의 타입 정의에서 이 이름은 React 안쪽으로 들어갔어요. 적으려면 React.JSX.Element라고 써야 하지만, 안 적는 쪽이 낫습니다.
4. children을 안 받는 컴포넌트에 자식 넘기기
error TS2322: Type '{ children: string; label: string; }' is not assignable to type
'IntrinsicAttributes & BadgeProps'.
Property 'children' does not exist on type 'IntrinsicAttributes & BadgeProps'.
태그 사이에 넣은 내용이 children이라는 props로 바뀐다는 사실이 메시지에 그대로 보입니다. BadgeProps에는 그런 props가 없다고 하고요.
5. inputRef를 <textarea>에 달기
error TS2322: Type 'Ref<HTMLInputElement>' is not assignable to type
'Ref<HTMLTextAreaElement> | undefined'.
useRef<HTMLInputElement>로 만든 손잡이는 입력창만 잡을 수 있습니다. 여기에 텍스트 영역을 넣으면 나중에 입력창에만 있는 기능을 부를 때 터지겠죠.
6. onToggle={handleToggle}을 onToggle={handleToggle()}로 바꾸기
error TS2322: Type 'void' is not assignable to type '() => void'.
소괄호를 붙이는 순간 함수가 아니라 함수를 실행한 결과가 넘어갑니다. handleToggle은 아무것도 안 돌려주니 그 결과는 void예요. props는 나중에 부를 함수를 원했는데 빈손을 받았습니다.
위치가 다른 이유
1 번과 2 번을 비교해 보라고 한 이유입니다.
1번 — 함수 자체는 문제없다 2번 — 함수 안에서 이미 틀렸다
function handleChange( function handleDoubleClick(
e: ChangeEvent<HTMLFormElement> e: MouseEvent<HTMLImageElement>
) { ... } ← 여긴 통과 ) {
e.target.src ← 여기서 걸림
<input onChange={handleChange} /> }
↑ 여기서 걸림
1 번은 함수 혼자 놓고 보면 아무 문제가 없습니다. 폼의 변경 사건을 받는 함수일 뿐이에요. 입력창에 붙이려는 순간에야 안 맞는다는 게 드러납니다.
2 번은 함수 안에서 이미 틀렸습니다. 마우스 이벤트의 target에 src가 없다는 건 붙이기 전에도 알 수 있으니까요.
여섯 개를 관통하는 규칙
타입은 값이 만들어지는 곳이 아니라 값이 쓰이는 곳에서 검사됩니다. 함수를 만들 때는 통과하고 그 함수를 넘길 때 걸리는 것, 손잡이를 만들 때는 통과하고 엉뚱한 요소에 달 때 걸리는 것이 전부 같은 이야기예요.
자주 나오는 실수
메시지를 복사만 하고 넘어가는 것. 이 과제의 절반은 읽는 연습입니다. TypeScript 메시지는 대개 "무엇이 무엇에 할당될 수 없다" 형태예요. 앞의 것이 내가 준 것, 뒤의 것이 요구되는 것입니다. 이 순서만 알아도 대부분 읽힙니다.
빨간 줄이 그어진 곳만 보는 것. 1 번처럼 원인과 증상이 다른 곳에 있을 때가 있어요. 붙이는 곳에 빨간 줄이 그어졌다고 그 줄을 고치려 들면 안 됩니다. 고칠 곳은 핸들러의 타입 선언입니다.
🤔 [생각해볼 주제 1] 타입을 파생시킬 것인가 새로 적을 것인가
문제 상황 요약
PostCardProps는 Post에서 파생시켰고 AvatarProps는 새로 적었습니다. 기준으로 "함께 바뀔 운명인가"를 제시했죠.
새 컴포넌트를 만드는 시점에는 이걸 알기 어려운 경우가 많은데, 지금은 게시물 화면에만 있지만 나중에 다른 곳에도 쓰일지 모르는 컴포넌트라면 어느 쪽으로 시작해야 할까요.
튜터의 가이드 및 해설
결론부터 말하면 새로 적는 쪽으로 시작합니다. 확신이 없을 때는요.
이유는 나중에 방향을 바꿀 때의 비용이 한쪽으로 크게 기울어 있기 때문입니다.
새로 적어둔 것을 파생으로 바꾸는 일은 쉽습니다. 필드 이름이 이미 같다면 선언 한 줄을 Pick<Post, ...>로 갈아끼우면 끝이에요. 컴포넌트 본문도, 그 컴포넌트를 쓰는 곳도 안 바뀝니다.
반대는 훨씬 아픕니다. Post에서 파생시켜 둔 컴포넌트를 User에도 쓰려고 하면, 그 컴포넌트를 쓰는 모든 곳이 이미 Post를 넘기고 있어요. 타입만 바꾸는 게 아니라 호출하는 쪽까지 훑어야 합니다.
예외가 하나 있는데, 서버 응답을 거의 그대로 그리는 컴포넌트라면 처음부터 파생이 맞습니다. 백엔드 필드가 늘거나 이름이 바뀔 때 자동으로 따라오는 것이 이런 컴포넌트에서는 진짜 이득이거든요. PostCard가 그렇습니다. 게시물을 그리려고 존재하는데 게시물 타입과 따로 놀 이유가 없습니다.
정리하면 판단 순서는 이렇습니다.
이 컴포넌트는 특정 서버 응답을 그리려고 존재하는가?
예 → 파생시킨다 (PostCard · CommentItem · ProfileHeader)
아니오 → 새로 적는다 (Avatar · Button · Badge · Section)
모르겠다 → 새로 적는다 (나중에 파생으로 바꾸기 쉽다)
한 가지 덧붙이면, 필드 이름이 같다는 사실 자체는 근거가 못 됩니다. username이라는 이름은 Post에도 User에도 Comment에도 있어요. 이름이 겹치는 것과 함께 바뀌는 것은 다릅니다.
🎯 면접관을 홀리는 핵심 멘트
"타입 파생은 중복을 줄이는 도구가 아니라 의존 관계를 선언하는 도구라고 봅니다.
PostCardProps를Post에서 파생시킨 건 코드를 줄이려는 게 아니라 이 컴포넌트가 게시물 응답과 운명을 같이한다고 못 박은 겁니다. 그래서 이름이 겹친다는 이유로 파생시키지는 않습니다. 확신이 없으면 새로 적어두는데, 나중에 파생으로 바꾸는 건 선언 한 줄이지만 반대로 되돌리려면 호출하는 곳까지 전부 훑어야 하거든요. 되돌리기 싼 쪽에서 시작하는 게 원칙입니다."
🤔 [생각해볼 주제 2] 이벤트 타입을 매번 적는 비용
문제 상황 요약
핸들러를 밖으로 뺄 때마다 이벤트 타입을 적었습니다. 인라인으로 두면 안 적어도 되는데 말이죠.
짧은 핸들러는 계속 인라인으로 둘까요, 아니면 길이와 상관없이 밖으로 빼는 규칙을 세울까요.
튜터의 가이드 및 해설
먼저 이 선택이 성능 문제라고 생각하기 쉬운데, 지금은 그렇지 않다는 걸 짚고 갑시다.
인라인 화살표 함수는 컴포넌트를 다시 그릴 때마다 새로 만들어집니다. 예전에는 이것이 실제로 문제가 됐어요. 자식에게 넘기는 함수가 매번 달라지면 자식도 매번 다시 그려졌거든요. 지금은 빌드할 때 컴파일러가 이런 것을 알아서 처리합니다. 성능을 이유로 인라인을 피할 필요는 없어요. 정말 느린 곳이 생기면 그때 재보고 손대면 되고, 그 방법은 C-8에서 다룹니다.
그래서 판단 기준은 읽기 쉬움입니다.
인라인이 나은 경우는 그 함수가 무엇을 하는지 한 눈에 들어올 때입니다.
<button onClick={() => onToggleLike(post.id)}>
여기서는 클릭하면 무슨 일이 나는지 버튼을 보는 순간 알 수 있습니다. 이걸 굳이 밖으로 빼면 이름을 확인하러 위로 올라갔다 내려와야 해요. 오히려 손해입니다.
밖으로 빼는 게 나은 경우는 세 가지입니다.
첫째, 두 줄을 넘어갈 때. JSX는 화면 구조를 보여주는 곳인데 그 사이에 로직이 길게 끼면 구조가 안 보입니다. 오늘 handleSubmit이 그랬죠. preventDefault, 빈 값 검사, 부모 호출, 입력창 비우기, 커서 되돌리기까지 다섯 가지를 하는데 이게 <form> 태그 안에 들어 있으면 폼이 무엇을 그리는지 안 보입니다.
둘째, 이름이 설명이 될 때. handleImageDoubleClick이라는 이름 하나가 주석 한 줄 역할을 합니다.
셋째, 이벤트 객체를 실제로 쓸 때. 이 경우는 타입을 적는 것이 오히려 이득이에요. event.target.value를 쓰는 순간 그 함수가 입력창 전용이라는 사실이 타입에 드러납니다.
그래서 제가 권하는 규칙은 이렇습니다.
| 상황 | 어떻게 |
|---|---|
| 한 줄이고 이벤트 객체를 안 쓴다 | 인라인 |
| 두 줄 이상이다 | 밖으로 빼고 타입 명시 |
| 이벤트 객체를 쓴다 | 밖으로 빼고 타입 명시 |
타입을 적는 수고 자체는 생각보다 작습니다. 요소 이름이 기억 안 나면 인라인으로 한 번 써서 에디터가 보여주는 타입을 복사해 오면 되니까요. 그 수고보다는 팀 안에서 기준이 흔들리는 쪽이 더 비쌉니다.
🎯 면접관을 홀리는 핵심 멘트
"인라인이냐 분리냐를 성능으로 판단하지 않습니다. React Compiler가 들어온 뒤로 매 렌더마다 함수가 새로 만들어지는 것은 대부분 문제가 아니고, 정말 느리면 그때 측정해서 손대면 되니까요. 제 기준은 JSX 가 구조를 보여주는 데 집중하느냐입니다. 한 줄로 끝나고 이벤트 객체도 안 쓰면 인라인이 더 읽히고, 두 줄이 넘어가거나 이벤트 객체를 만지기 시작하면 밖으로 뺍니다. 밖으로 빼면 타입을 적어야 하는데, 그게 비용이 아니라 그 핸들러가 어떤 요소에 붙는 물건인지 문서가 돼서 오히려 남는 장사라고 봅니다."
🤔 [생각해볼 주제 3] ref가 일반 props가 된 것
문제 상황 요약
React 19는 ref를 특별 취급하던 규칙을 없애고 보통의 props로 만들었습니다. 감싸는 껍데기가 사라져 코드가 짧아졌어요.
특별 취급에도 나름의 쓸모가 있었을 수 있는데, 눈에 띄면 "여기서 DOM을 직접 만지는구나" 하고 알아채기 쉬웠을 테니까요.
튜터의 가이드 및 해설
얻은 것부터 정리해 봅시다.
배우는 순서가 자연스러워졌습니다. 예전에는 입력창에 커서를 옮기는 정도의 일을 하려 해도 감싸는 함수 하나를 먼저 배워야 했어요. props를 이제 막 이해한 학생에게 "ref는 props가 아니라서 다른 방식으로 받아야 한다"는 예외를 설명해야 했습니다. 지금은 예외가 사라졌습니다.
타입이 단순해졌습니다. 오늘 우리가 적은 것은 ref: React.Ref<HTMLInputElement> 한 줄이었죠. 다른 props와 형태가 같습니다.
그리고 명시적이 됐습니다. 이게 사실 제일 큽니다. 예전에는 컴포넌트가 ref를 받을지 말지가 감싸는 함수를 썼느냐로 정해졌어요. 지금은 props 목록에 ref를 적었느냐로 정해집니다. 받을 생각이 없으면 안 적으면 되고, 안 적힌 컴포넌트에 ref를 넘기면 타입이 막습니다. children과 똑같은 규칙이 됐어요.
잃은 것도 분명합니다.
가장 큰 것은 눈에 덜 띄게 됐다는 점입니다. 감싸는 함수가 있으면 그 컴포넌트가 DOM을 밖으로 내준다는 사실이 코드 모양에서 튀었어요. 지금은 props 목록 한 줄이라 훑어보다 지나치기 쉽습니다.
이게 왜 문제가 되냐면, ref는 남용하기 쉬운 도구이기 때문입니다. 상태로 풀어야 할 것을 ref로 DOM을 직접 고쳐서 해결하면 당장은 돌아가지만, 그 순간 React가 화면을 관리한다는 전제가 깨집니다. 화면과 상태가 어긋나기 시작하고, 지난 과목의 순수 JS로 돌아가는 셈이 됩니다.
그래서 실무에서는 이렇게 메웁니다.
ref를 받는 컴포넌트를 만들 때 왜 필요한지 짧게 남기면서, 코드 리뷰에서 ref props가 새로 생기면 "이거 상태로는 안 되나요"를 한 번 묻습니다. 문법이 안 해주는 신호를 팀의 습관으로 대신하는 거예요.
정리하면 이 변화는 입문 비용을 줄이는 대신 규율의 책임을 팀으로 옮긴 것입니다. 프레임워크가 문법으로 막아주던 것을 사람이 봐야 하게 됐어요. React 전반이 이런 방향으로 가고 있습니다. 예외를 줄이고 일관된 규칙으로 통일하는 대신, 무엇을 조심할지는 쓰는 사람이 알아야 하는 쪽으로요.
🎯 면접관을 홀리는 핵심 멘트
"ref-as-prop은 단순히
forwardRef를 없앤 편의 기능이 아니라 일관성을 위해 예외를 걷어낸 결정이라고 봅니다. ref가 props가 되면서children과 같은 규칙을 따르게 됐어요. 받겠다고 적은 컴포넌트만 받고, 안 적었으면 타입이 막습니다. 대신 잃은 것도 있는데, 감싸는 함수가 사라지면서 이 컴포넌트가 DOM을 밖으로 내준다는 신호가 코드에서 덜 튀게 됐습니다. ref는 남용하면 React가 화면을 관리한다는 전제를 깨뜨리는 도구라서, 저희 팀은 리뷰에서refprops가 새로 생기면 상태로 안 되는 이유를 한 번 확인하는 규칙을 둡니다. 프레임워크가 문법으로 막아주던 걸 팀 습관으로 옮겨온 셈이죠."