B-1: React 입문 — 컴포넌트·JSX·props
목차 50
안녕하세요, 홍순구입니다. 오늘 드디어 React를 시작합니다.
지난 세 시간 동안 우리는 타입만 다뤘는데, 화면은 한 번도 안 그렸죠. User와 Post를 선언했고, 정체 모를 값을 갈라내는 가드를 만들었고, 원본에서 필요한 모양만 뽑아냈는데, 그중에 이런 게 있었어요.
// apps/web-spa/src/types/derived.ts
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;
이름 끝에 Props를 붙여놓고 "다음 시간에 쓸 겁니다"라고만 하고 넘어갔는데, 오늘 그걸 씁니다.
그전에 지난 과목 얘기를 잠깐 할게요. 순수 HTML/CSS/JS로 인스타그램 카드를 그릴 때 이런 코드를 썼습니다.
// 지난 과목에서 카드 한 장을 만들던 방식
function renderPost(post) {
const article = document.createElement('article');
article.className = 'post-card';
const img = document.createElement('img');
img.src = post.imageUrl;
article.appendChild(img);
const likes = document.createElement('p');
likes.textContent = `좋아요 ${post.likeCount}개`;
article.appendChild(likes);
return article;
}
카드 한 장에 요소를 만들고, 속성을 넣고, 부모에 붙이는 세 동작이 요소마다 반복되다 보니, 화면이 조금만 복잡해져도 이 함수는 금방 백 줄이 넘어가요. 그리고 좋아요 숫자가 바뀌면 어디를 어떻게 고쳐야 하는지 직접 찾아서 직접 갱신해야 합니다.
오늘 배울 React는 이 방식을 통째로 뒤집는데, "어떻게 만들지"를 적는 대신 "무엇이 보여야 하는지"를 적어요. 그 차이가 오늘의 전부입니다.
오늘의 여정
Step 1~2 왜 React 인가 · Vite 로 앱 만들기
│
Step 3~4 컴파일러와 린트 켜기 · 첫 컴포넌트
│
Step 5~6 JSX 문법 · props 로 값 건네주기
│
Step 7~8 컴포넌트 쪼개기 · 단방향 데이터 흐름
▼
인스타 카드 두 장이 React 로 그려진 화면으로 마무리
💡 오늘 수업의 핵심 — "어떻게 그릴지 대신 무엇이 보일지를 적는다"
순수 JS는 요소를 만들고 붙이는 절차를 적었고, React는 결과 화면을 적습니다. 오늘은 그 결과를 적는 문법(JSX)과, 결과를 만들어내는 단위(컴포넌트), 그리고 그 단위에 값을 넣는 방법(props)까지 갑니다.
🎯 학습 목표
- 컴포넌트가 왜 함수인지 설명하고, JSX로 화면 한 조각을 그릴 수 있다
- props로 부모에서 자식으로 값을 건네고, 그 props에 타입을 붙일 수 있다
- 데이터가 위에서 아래로만 흐른다는 원칙을 인스타 카드 화면에서 확인할 수 있다
Step 1: "왜 React인가 — 카드 하나에 스무 줄이 필요했던 이유"
코드를 치기 전에 오프닝의 그 함수를 다시 봅시다. 저 코드가 하는 일을 말로 풀면 이렇습니다.
"article 요소를 만들어라. 거기에 클래스 이름을 넣어라. img 요소를 만들어라. src를 넣어라. article에 붙여라. p 요소를 만들어라. 텍스트를 넣어라. article에 붙여라."
전부 명령이니까 컴퓨터에게 순서대로 시키는 거예요. 이런 방식을 명령형(imperative, 하나씩 시키는)이라고 부릅니다.
문제는 화면이 바뀔 때 생기는데, 좋아요를 눌러서 숫자가 1240에서 1241이 됐다고 해봅시다. 순수 JS에서는 이렇게 했죠.
likeCountEl.textContent = post.likeCount;
이 한 줄을 쓰려면 세 가지를 알아야 하는데, 첫째는 좋아요 숫자를 담은 요소를 어떻게 찾을지, 둘째는 그 요소의 어떤 속성을 바꿔야 하는지, 셋째는 이 화면에서 좋아요 숫자가 나오는 데가 여기 하나뿐인지입니다. 화면 곳곳에 같은 값이 뿌려져 있으면 전부 찾아 고쳐야 하고, 하나라도 빠뜨리면 화면 안에서 숫자가 서로 안 맞아요.
선언적으로 적는다는 것
React는 다르게 접근하는데, "좋아요 숫자가 바뀌면 화면의 이 부분을 고쳐라"가 아니라 "이 화면은 이렇게 생겼다"를 적어두는 겁니다.
명령형 — 어떻게 바꿀지 적는다
숫자가 바뀜 → 요소를 찾는다 → 텍스트를 고친다 → 다른 데도 찾는다 → ...
(개발자가 전부 직접)
선언적 — 무엇이 보일지 적는다
숫자가 바뀜 → "화면은 이렇게 생겼다" 를 다시 읽는다 → React 가 바뀐 데만 고친다
(React 가 알아서)
적는 사람은 결과만 적고, 그 결과를 만들기 위해 무엇을 고칠지는 React가 계산하는데, 이런 방식을 선언적(declarative, 결과를 선언하는)이라고 해요.
여기가 오늘 가장 중요한 멘탈모델 재배선 지점인데, 지난 과목에서 여러분의 머릿속은 "이벤트가 오면 DOM을 어떻게 고칠까"로 배선돼 있어요. React를 쓰는 동안은 그 배선을 잠시 끊어야 하니, 대신 "지금 데이터가 이러면 화면은 어떻게 생겼나"만 생각하세요.
⚠️ 이 재배선이 안 되면 React를 쓰면서도 계속 document.querySelector를 찾게 됩니다. React 안에서 그 습관은 거의 항상 잘못된 방향이에요.
그런데 React만 그런 건 아닙니다
솔직하게 짚고 갈 게 있는데, 선언적 렌더링은 React만의 아이디어가 아닙니다. Vue도 Svelte도 같은 뿌리를 공유해요. React를 배우는 이유는 그게 유일해서가 아니라, 국내외 채용 시장과 생태계에서 가장 넓게 쓰이기 때문입니다.
그리고 순수 JS가 나쁜 것도 아니라서, 화면이 정말 단순하면 document.createElement가 더 가볍습니다. React는 화면이 복잡해지고 상태가 많아질 때 값을 하는데, 지금 우리가 만드는 인스타그램이 후자예요.
💡 한 줄 정리
순수 JS는 화면을 어떻게 고칠지 적고, React는 화면이 어떻게 생겼는지 적습니다. 무엇을 고칠지는 React가 계산합니다.
🙋 학생 질문 — "튜터님, React가 알아서 고쳐준다면 순수 JS보다 느리지 않나요?"
날카로운 질문이에요. 정확히 말하면 React가 하는 일이 더 많은 건 맞는데, 바뀐 데를 찾아내는 계산을 React가 대신 하니까요.
그런데 실제로 느려지는 경우는 생각보다 드문데, 이유는 두 가지예요. 첫째, React가 화면 전체를 다시 그리는 게 아니라 실제로 달라진 부분만 골라서 DOM을 건드립니다. DOM을 건드리는 게 비싼 작업이라서, 계산을 좀 더 하더라도 DOM 조작을 줄이는 쪽이 대체로 이득이에요.
둘째, 사람이 손으로 최적화한 DOM 조작이 항상 더 빠른 것도 아닙니다. 화면이 복잡해지면 사람은 실수로 필요 없는 갱신을 넣거나, 반대로 필요한 갱신을 빠뜨리거든요.
정말 성능이 문제가 되는 순간은 분명히 있는데, 그건 C-8에서 측정하는 방법과 함께 다룹니다. 지금 단계에서 성능을 미리 걱정하면서 코드를 비틀 필요는 없습니다.
Step 2: "Vite로 React 앱 만들기"
이론은 여기까지 하고 실제로 앱을 만들어 봅시다. 눈으로만 보면 안 느니까 직접 쳐보면서 따라오세요.
지금 우리 프로젝트에는 apps/web-spa/가 있는데, 지난 세 시간 동안 여기에 타입 파일만 쌓았어요. React도 없고 화면도 없는데, 여기에 React를 얹을 겁니다.
무엇으로 만들 것인가
React 앱을 만드는 도구는 여러 개가 있었는데, 그중 오랫동안 표준이던 게 CRA(Create React App)예요. 검색하면 아직도 CRA로 시작하는 글이 잔뜩 나오지만, 그거 쓰지 마세요.
CRA는 공식적으로 수명이 끝났고, React 팀이 직접 "새 프로젝트에 쓰지 말라"고 공지했어요. 지금 새로 만든다면 Vite입니다.
Vite는 개발 서버를 띄우고 코드를 묶어주는 도구인데, 최신 버전인 8은 Rolldown이라는 엔진을 씁니다. Rust로 만들어져서 빠르고, 예전에 개발용과 배포용 도구가 따로 놀던 구조를 하나로 합쳤어요.
설치
터미널을 열고 프로젝트 최상위 폴더에서 아래를 실행합니다.
npm install -w web-spa --save-exact react@19.2.8 react-dom@19.2.8
npm install -w web-spa --save-exact -D vite@8.1.5 @vitejs/plugin-react@6.0.4 @types/react@19.2.17 @types/react-dom@19.2.3
react는 컴포넌트를 만드는 핵심이고, react-dom은 그걸 브라우저 화면에 그려주는 부분인데, 둘이 나뉘어 있는 이유는 React가 브라우저 말고 다른 곳에도 그릴 수 있기 때문이에요.
@types/로 시작하는 둘은 React의 타입 정보인데, React 자체는 JavaScript로 만들어져 있어서 TypeScript가 알아볼 수 있게 타입을 따로 받아옵니다.
설정 파일 세 개
먼저 tsconfig.json에 한 줄을 더합니다. 지금까지는 타입만 다뤄서 필요 없었는데, 이제 JSX를 쓸 거라 알려줘야 해요.
{
"compilerOptions": {
"jsx": "react-jsx"
}
}
"react-jsx"는 JSX를 만나면 알아서 React를 불러다 쓰라는 뜻이에요. 예전에는 JSX를 쓰는 파일마다 import React from 'react'를 맨 위에 적어야 했는데, 이 설정 덕분에 안 적어도 됩니다.
그다음 index.html을 만드는데, 브라우저가 가장 먼저 읽는 파일이에요.
<!-- apps/web-spa/index.html -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>인스타그램 클론</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
눈여겨볼 데가 두 군데예요. <div id="root"></div>는 비어 있는데, 우리가 만들 화면 전체가 여기 안에 들어갈 거예요. 그리고 <script>가 .tsx 파일을 직접 가리키는데, 브라우저는 .tsx를 모르지만 Vite가 중간에서 브라우저가 아는 형태로 바꿔줍니다.
마지막으로 vite.config.ts입니다.
// apps/web-spa/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
Vite에게 "이 프로젝트는 React를 쓴다"고 알려주는 파일인데, 이 파일은 다음 단계에서 한 번 더 손봅니다.
실행
package.json에 실행 명령을 넣습니다.
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
}
}
build를 보세요. tsc --noEmit를 먼저 돌리고 그다음에 vite build를 하는데, 이렇게 붙여둔 이유가 있어요. Vite는 코드를 묶을 때 타입을 검사하지 않아서, 타입이 틀려도 빌드가 성공해요. 그래서 타입 검사를 따로 걸어둬야 하는데, 이 얘기는 A-5에서 제대로 다룹니다.
이제 띄워봅시다.
npm run dev -w web-spa
터미널에 이런 게 뜹니다.
VITE v8.1.5 ready in 78 ms
➜ Local: http://localhost:5173/
78 밀리초 만에 준비됐어요. 주소를 브라우저에 넣으면 아직 아무것도 없는 흰 화면이 뜨는데, 당연해요. main.tsx를 아직 안 만들었으니까요. 다음 단계에서 만듭니다.
💡 한 줄 정리
CRA는 끝났고 지금은 Vite인데, index.html의 빈 <div id="root"> 안에 React가 그릴 화면 전체가 들어갑니다.
🙋 학생 질문 — "튜터님, 검색하면 npm create vite로 시작하라던데 우리는 왜 install부터 했나요?"
둘 다 맞는 방법이에요. 상황이 달라서 그렇습니다.
npm create vite@latest는 빈 폴더에서 새 프로젝트를 통째로 만들어 주는 명령인데, 폴더 구조, 설정 파일, 예제 컴포넌트까지 한 번에 생겨요. 아무것도 없는 상태에서 시작한다면 이게 편합니다.
그런데 우리는 이미 apps/web-spa/를 갖고 있고, 지난 세 시간 동안 만든 타입 파일 열일곱 개가 거기 들어 있습니다. 여기에 npm create vite를 돌리면 기존 설정을 덮어쓰거나 폴더를 새로 만들어서, 지금까지 만든 걸 다시 옮겨야 해요.
그래서 필요한 것만 골라 설치하고 설정 파일을 직접 만들었습니다. 실무에서도 이미 돌아가는 프로젝트에 무언가를 얹을 때는 이 방식을 쓰는데, 새로 시작할 때 npm create vite를 쓰는 것과 충돌하지 않아요.
Step 3: "React Compiler와 린트를 처음부터 켜기"
컴포넌트를 만들기 전에 도구 두 개를 더 붙입니다. 나중에 붙여도 되는 것들인데 처음부터 켜두는 편이 나은데, 왜 그런지는 붙이면서 설명할게요.
React Compiler
React에는 오래된 고민이 하나 있었는데, 화면 일부만 바뀌었는데 관련 없는 부분까지 다시 계산되는 경우가 생겨요. 이걸 막으려고 개발자가 손으로 "이건 다시 계산하지 마"라고 표시하는 방법이 있었습니다.
그 표시를 손으로 다는 게 꽤 번거롭고 실수도 잦았는데, 어디에 달아야 하는지 판단하기 어렵고 잘못 달면 오히려 느려집니다.
React Compiler는 그 판단을 대신 해주는데, 코드를 빌드할 때 어디를 다시 계산하지 않아도 되는지 자동으로 찾아내서 표시를 넣어줘요. 1.0이 정식으로 나왔고 실무에서 쓰입니다.
그래서 이 과목에서는 그 표시를 손으로 다는 방법을 당분간 안 가르치고, 깔끔하게 쓰고 컴파일러에 맡기는 게 기본이에요. 손으로 다는 방법은 C-8에서, 측정해서 정말 문제가 확인된 곳에만 쓰는 탈출구로 다룹니다.
설치는 이렇습니다.
npm install -w web-spa --save-exact -D babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel@0.2.3
그리고 vite.config.ts를 이렇게 고칩니다.
// apps/web-spa/vite.config.ts
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [
react(),
// React Compiler — 빌드할 때 자동으로 메모이제이션을 넣어준다
await babel({
presets: [reactCompilerPreset()],
}),
],
});
⚠️ 검색하면 react({ babel: { plugins: [...] } })처럼 react() 안에 직접 넣는 예제가 많이 나오는데, 그건 예전 버전 방식이에요. 지금 버전에는 그 옵션이 아예 없어서, 그렇게 적으면 에러도 안 나고 조용히 무시됩니다. 컴파일러가 안 돌아가는데 돌아간다고 착각하기 딱 좋아요.
정말 켜졌는지 확인하는 방법이 있는데, 개발 서버를 띄운 뒤 브라우저 개발자 도구의 Network 탭에서 변환된 파일을 열어보면 원래 없던 _c(2) 같은 코드가 함수 맨 앞에 들어가 있어요. 그게 컴파일러가 넣어준 표시입니다.
린트
두 번째는 린트(lint, 코드에서 문제 될 만한 곳을 미리 잡아주는 도구)입니다.
npm install -w web-spa --save-exact -D eslint@9.39.1 @eslint/js@9.39.1 typescript-eslint@8.65.0 eslint-plugin-react-hooks@7.1.1 globals@16.5.0
eslint-plugin-react-hooks가 오늘 중요한 부분인데, React에는 지켜야 하는 규칙이 몇 개 있고 어기면 조용히 이상하게 동작해요. 이 도구가 그런 곳을 미리 잡아주는데, 최신 7 버전은 컴파일러와도 연동돼서 컴파일러가 최적화하지 못하는 코드까지 알려줍니다.
설정 파일은 eslint.config.js입니다.
// apps/web-spa/eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2025,
globals: globals.browser,
},
},
);
파일 이름이 eslint.config.js인 게 중요한데, 예전에는 .eslintrc라는 이름을 썼지만 그 방식은 이제 안 씁니다. 검색해서 .eslintrc 예제가 나오면 오래된 글이에요.
이제 package.json에 "lint": "eslint ."를 넣고 돌려보면 검사가 됩니다.
왜 처음부터 켜는가
이 두 개는 나중에 붙여도 동작하는데, 나중에 붙이면 그때까지 쌓인 코드에서 경고가 무더기로 쏟아져요. 백 개쯤 뜨면 사람은 그냥 안 봅니다.
처음부터 켜두면 경고가 생기는 순간 하나씩 보이는데, 하나씩이면 고칠 수 있어요.
💡 한 줄 정리
React Compiler가 최적화를 대신해 주니 손으로 표시를 달지 않습니다. 린트는 코드가 쌓이기 전에 켜야 볼 수 있습니다.
🙋 학생 질문 — "튜터님, 컴파일러가 다 해준다면 최적화는 아예 몰라도 되나요?"
몰라도 되는 건 아니고, 배우는 순서가 바뀌었다고 보는 게 정확합니다.
예전에는 React를 배우면서 거의 동시에 최적화 표시 다는 법을 배웠는데, 초보자가 원리도 모르는 채로 여기저기 달아놓는 일이 흔했고, 그게 오히려 코드를 읽기 어렵게 만들었습니다.
지금은 컴파일러가 대부분을 처리하니, 먼저 깔끔하게 쓰는 법을 익히고 나중에 측정하는 법을 배우는데, 이 순서가 더 자연스러워요. 측정해서 느린 곳을 찾는 능력이 표시를 다는 문법보다 훨씬 중요하거든요.
그리고 컴파일러가 못 하는 최적화도 있는데, 필요 없는 데이터를 받아오는 것이나 이미지가 너무 큰 것 같은 건 컴파일러 소관이 아니에요. 그런 건 H 카테고리에서 다룹니다.
Step 4: "첫 컴포넌트 — 함수 하나가 화면 한 조각"
이제 화면을 그립니다.
React에서 화면 한 조각은 함수인데, 그 함수는 값을 받아서 화면을 돌려줘요. 그게 전부예요. 클래스도 아니고 특별한 문법도 아니고 그냥 함수입니다.
가장 단순한 컴포넌트를 봅시다.
// apps/web-spa/src/components/Greeting.tsx
export function Greeting() {
return <h1>인스타그램</h1>;
}
함수가 HTML처럼 생긴 걸 돌려주는데, 이게 컴포넌트예요.
여기서 규칙이 하나 있는데, 컴포넌트 이름은 반드시 대문자로 시작해야 해요. Greeting은 되고 greeting은 안 됩니다.
왜냐하면 React는 이름의 첫 글자로 둘을 구분하기 때문인데, 소문자로 시작하면 HTML 태그로 취급해요. <div> 나 <p>처럼요. 대문자로 시작해야 "이건 내가 만든 컴포넌트구나" 하고 알아봅니다.
소문자로 쓰면 이런 에러가 납니다.
error TS2339: Property 'postCard' does not exist on type 'JSX.IntrinsicElements'.
JSX.IntrinsicElements는 브라우저가 아는 기본 태그 목록인데, 거기에 postCard라는 태그가 없다는 뜻입니다. 당연하죠, 그건 우리가 만든 컴포넌트니까요.
화면에 띄우기
컴포넌트를 만들었으면 브라우저에 그려야 하는데, 그 연결을 하는 파일이 main.tsx예요.
// apps/web-spa/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles/globals.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('#root 를 찾지 못했습니다');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
한 줄씩 볼게요.
document.getElementById('root')는 지난 과목에서 쓰던 그 함수 맞는데, index.html의 빈 <div id="root">를 찾아오는 거예요. React 앱에서 이렇게 DOM을 직접 찾는 곳은 여기 딱 한 군데인데, 시작점만 알려주고 그다음부터는 React가 알아서 합니다.
if (!rootElement)는 못 찾았을 때를 막아둔 겁니다. A-2에서 배운 좁히기예요. getElementById는 못 찾으면 null을 돌려주니, 그 경우를 걸러야 아래에서 안심하고 쓸 수 있습니다.
createRoot(rootElement).render(...)가 실제로 그리는 부분인데, "이 요소 안에 이 컴포넌트를 그려라"라는 뜻이에요.
<StrictMode>는 개발 중에만 동작하는 검사 도구인데, 문제가 될 만한 코드를 찾아서 경고해 줘요. 배포할 때는 자동으로 빠지니 그냥 두면 됩니다.
App 컴포넌트
main.tsx가 부르는 App을 만듭니다.
// apps/web-spa/src/App.tsx
export function App() {
return (
<main className="feed">
<h1 className="feed-title">인스타그램</h1>
</main>
);
}
이제 npm run dev -w web-spa로 서버를 띄우고 브라우저를 보면, 흰 화면 대신 "인스타그램"이라는 제목이 떠 있는데, 첫 React 화면이에요.
파일을 저장할 때마다 브라우저가 알아서 갱신되는 것도 확인해 보세요. 제목을 다른 글자로 바꾸고 저장하면 새로고침 없이 바뀝니다.
💡 한 줄 정리
컴포넌트는 화면을 돌려주는 함수이고, 이름은 대문자로 시작해야 합니다. DOM을 직접 찾는 곳은 main.tsx 한 군데뿐입니다.
🙋 학생 질문 — "튜터님, <App />처럼 태그로 쓰는데 왜 함수인가요?"
<App />은 React가 알아보는 표기법이고, 실제로는 함수 호출로 바뀝니다.
React는 <App />을 보면 "App이라는 함수를 불러서 그 결과를 화면에 넣어라"로 해석하는데, 그러니까 태그처럼 생겼지만 하는 일은 함수 호출입니다.
왜 굳이 태그처럼 쓰냐면, 화면 구조를 눈으로 보기 좋기 때문이에요. App(Header(), Feed())처럼 함수 호출로 중첩을 적으면 괄호가 겹겹이 쌓여서 어디가 어디 안에 있는지 읽기 힘든데, 태그로 적으면 HTML을 읽듯이 구조가 보여요.
이 표기법의 정확한 정체는 다음 단계에서 다룹니다.
Step 5: "JSX — HTML처럼 생겼지만 JavaScript"
함수가 돌려주던 그 HTML처럼 생긴 것의 정체를 볼 차례인데, 이걸 JSX라고 불러요.
JSX는 JavaScript 안에 화면 구조를 적을 수 있게 해주는 문법입니다. 브라우저는 JSX를 모르지만, Vite가 브라우저가 아는 형태로 바꿔줍니다.
중괄호로 값 넣기
JSX 안에서 중괄호를 열면 그 안은 JavaScript입니다.
// apps/web-spa/src/components/StaticPostCard.tsx
export function StaticPostCard() {
const username = 'jaehoon';
const likeCount = 1240;
return (
<article className="post-card">
<img className="post-image" src="https://picsum.photos/seed/post1/640/640" alt="게시물" />
<p className="post-likes">좋아요 {likeCount}개</p>
<p className="post-content">
<strong>{username}</strong> 오늘 한강 노을이 미쳤다
</p>
</article>
);
}
{likeCount}가 화면에서는 1240으로 바뀌는데, 지난 과목에서 백틱으로 `좋아요 ${post.likeCount}개` 하던 것과 같은 일이에요. 기호만 다릅니다.
중괄호 안에는 값을 돌려주는 JavaScript 라면 뭐든 들어갑니다. {likeCount + 1}도 되고 {username.toUpperCase()}도 되는데, 다만 if 문 같은 건 값이 아니라서 못 들어갑니다.
className을 쓰는 이유
class 대신 className이라고 적은 게 눈에 걸리셨을 겁니다.
class는 JavaScript에서 이미 예약된 단어인데, 클래스를 만들 때 쓰는 그 class입니다. JSX는 JavaScript 안에 있는 문법이라 그 단어를 쓸 수 없어서 className을 씁니다.
실수로 class라고 적으면 이렇게 알려줍니다.
error TS2322: Property 'class' does not exist on type
'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.
Did you mean 'className'?
친절하게 className을 제안해 주네요. 비슷한 경우가 하나 더 있는데, <label>의 for 속성도 htmlFor로 씁니다. for 역시 예약어라서요.
태그를 반드시 닫는다
HTML에서는 <img src="...">처럼 닫지 않아도 브라우저가 알아서 처리해 줬는데, JSX에서는 안 돼요. 반드시 <img src="..." />로 닫아야 합니다.
<br>, <input>, <hr>도 마찬가지로 <br />, <input />, <hr />입니다.
돌려주는 것은 하나여야 한다
함수는 값을 하나만 돌려줄 수 있죠. JSX도 같아서, 형제 요소 둘을 나란히 돌려주면 에러가 납니다.
error TS2657: JSX expressions must have one parent element.
해결책은 감싸는 것인데, 의미 없는 <div>를 하나 더 만들면 화면 구조가 지저분해져요. 그래서 React는 이름 없는 껍데기를 줍니다.
export function TwoLines() {
return (
<>
<h1>인스타그램</h1>
<p>피드</p>
</>
);
}
<>와 </>로 감싸면 되는데, 이걸 프래그먼트(fragment, 조각)라고 불러요. 실제 화면에는 아무 요소도 안 생기고 자식들만 들어갑니다.
💡 한 줄 정리
JSX는 JavaScript 안의 화면 문법입니다. 중괄호 안은 JavaScript이고, class는 className이며, 돌려주는 것은 항상 하나여야 합니다.
🙋 학생 질문 — "튜터님, JSX는 결국 뭘로 바뀌나요?"
함수 호출로 바뀝니다.
<h1>인스타그램</h1>은 대략 "h1이라는 태그를, 이런 속성으로, 이런 자식과 함께 만들어라"라는 함수 호출이 되는데, 우리가 지난 과목에서 document.createElement('h1') 하던 것과 비슷한 모습입니다.
다만 결정적인 차이가 있는데, document.createElement는 실제 DOM 요소를 바로 만들지만 JSX가 만드는 건 "이런 화면이면 좋겠다"는 설명서일 뿐입니다. 진짜 DOM은 React가 그 설명서를 보고 나중에 만들어요.
이 차이가 Step 1에서 얘기한 선언적 렌더링의 실체인데, 설명서를 다시 쓰면 React가 이전 설명서와 비교해서 달라진 곳만 실제 DOM에 반영해요. 그래서 우리는 결과만 적으면 됩니다.
궁금하면 개발자 도구 Network 탭에서 변환된 .tsx 파일을 열어보세요. JSX가 사라지고 함수 호출만 남아 있는 걸 볼 수 있습니다.
Step 6: "props — 컴포넌트에 값을 건네주기"
Step 5의 카드에는 문제가 있는데, 사용자 이름과 좋아요 수를 함수 안에 적어놨어요. 이러면 카드를 두 장 그릴 때 함수를 두 개 만들어야 하는데, 말이 안 되죠.
컴포넌트에 밖에서 값을 넣어줄 방법이 필요한데, 그게 props예요.
값을 받는 함수로 바꾸기
함수가 매개변수를 받는 것과 똑같습니다.
// apps/web-spa/src/components/MiniPostCard.tsx
interface MiniCardProps {
username: string;
likeCount: number;
}
export function MiniPostCard({ username, likeCount }: MiniCardProps) {
return (
<article className="post-card">
<p className="post-content">
<strong>{username}</strong>
</p>
<p className="post-likes">좋아요 {likeCount}개</p>
</article>
);
}
쓰는 쪽은 이렇습니다.
<MiniPostCard username="jaehoon" likeCount={1240} />
HTML 속성을 쓰듯이 값을 넘기는데, 문자열은 따옴표로, 숫자는 중괄호로 넣어요. 숫자에 중괄호를 쓰는 이유는 따옴표로 쓰면 문자열 "1240"이 되기 때문입니다.
구조분해로 꺼내기
{ username, likeCount } 부분을 보세요. React는 props를 객체 하나로 묶어서 넘겨주는데, 구조분해를 안 쓰면 이렇게 됩니다.
export function MiniPostCardWithoutDestructuring(props: MiniCardProps) {
return (
<article className="post-card">
<p className="post-content">
<strong>{props.username}</strong>
</p>
<p className="post-likes">좋아요 {props.likeCount}개</p>
</article>
);
}
둘 다 동작하는데, 다만 구조분해 쪽이 널리 쓰여요. 이 컴포넌트가 무슨 값을 쓰는지 함수 첫 줄만 보면 알 수 있거든요.
지난 시간에 만든 타입을 꺼낸다
이제 오프닝의 그 타입으로 돌아갑니다.
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;
Post에서 카드가 안 쓰는 둘을 뺀 타입이었는데, 이름에 Props를 붙여둔 이유가 이제 드러납니다. 이게 그대로 컴포넌트의 입구예요.
// apps/web-spa/src/components/PostCard.tsx
import type { PostCardProps } from '../types/derived';
export function PostCard({
username,
profileImageUrl,
imageUrl,
content,
likeCount,
commentCount,
}: PostCardProps) {
return (
<article className="post-card">
<img className="post-image" src={imageUrl} alt={`${username} 의 게시물`} />
<p className="post-likes">좋아요 {likeCount}개</p>
<p className="post-content">
<strong>{username}</strong> {content}
</p>
<p className="post-comments">댓글 {commentCount}개 모두 보기</p>
</article>
);
}
A-3에서 만든 타입을 새로 쓰지 않고 그대로 가져왔습니다. Post의 필드 이름이 나중에 바뀌면 이 컴포넌트도 같이 에러가 나는데, 손으로 따로 선언했다면 조용히 어긋났을 겁니다.
import type을 쓴 것도 눈여겨보세요. A-3에서 verbatimModuleSyntax를 켰으니 타입을 가져올 때는 이 표시가 필요합니다.
타입이 잡아주는 것
props에 타입을 달아두면 잘못 쓸 때 바로 걸리는데, 필수 값을 빠뜨리면 이렇게 나와요.
error TS2741: Property 'profileImageUrl' is missing in type '{ username: string; }'
but required in type 'AvatarProps'.
이름에 오타를 내면 제안까지 해주고요.
error TS2322: Property 'usernam' does not exist on type 'IntrinsicAttributes & AvatarProps'.
Did you mean 'username'?
숫자가 와야 할 데 문자열을 넣으면 이렇게 납니다.
error TS2322: Type 'number' is not assignable to type 'string'.
지난 과목이었다면 이런 실수는 전부 화면에 undefined로 나타났을 텐데, 지금은 코드를 실행하기 전에 걸립니다.
💡 한 줄 정리
props는 컴포넌트에 넘기는 값이고 함수 매개변수와 같습니다. A-3에서 파생해 둔 PostCardProps가 그대로 입구가 됩니다.
🙋 학생 질문 — "튜터님, props를 컴포넌트 안에서 바꾸면 안 되나요?"
안 됩니다. 그리고 이게 React의 중요한 약속 중 하나예요.
props는 부모가 자식에게 건네준 값인데, 자식이 그걸 마음대로 바꾸면 부모는 자기가 준 값이 바뀐 줄 모릅니다. 화면 곳곳에서 같은 데이터가 서로 다른 값을 갖게 되는데, Step 1에서 얘기한 그 문제가 그대로 돌아옵니다.
그래서 React에서 props는 읽기 전용으로 다루는데, 자식은 받은 값을 그리기만 해요.
그럼 값이 바뀌어야 할 때는 어떻게 하냐고요? 좋아요를 누르면 숫자가 올라가야 하잖아요. 그건 props가 아니라 상태(state)라는 다른 도구로 다루는데, 다음 시간에 바로 그걸 배웁니다.
Step 7: "컴포넌트 쪼개기 — Avatar를 분리한다"
컴포넌트의 진짜 힘은 쪼갤 때 나옵니다.
인스타그램 화면을 떠올려 보세요. 프로필 사진과 사용자 이름이 나란히 있는 조각이 카드 위에도 있고, 댓글 옆에도 있고, 스토리 목록에도 있는데, 같은 모양이 여러 군데 나와요.
이런 걸 따로 떼어냅니다.
// apps/web-spa/src/components/Avatar.tsx
interface AvatarProps {
username: string;
profileImageUrl: string;
}
export function Avatar({ username, profileImageUrl }: AvatarProps) {
return (
<div className="avatar">
<img className="avatar-image" src={profileImageUrl} alt={`${username} 프로필 사진`} />
<span className="avatar-name">{username}</span>
</div>
);
}
그리고 PostCard 안에서 씁니다.
// apps/web-spa/src/components/PostCard.tsx
import type { PostCardProps } from '../types/derived';
import { Avatar } from './Avatar';
export function PostCard({
username,
profileImageUrl,
imageUrl,
content,
likeCount,
commentCount,
}: PostCardProps) {
return (
<article className="post-card">
<Avatar username={username} profileImageUrl={profileImageUrl} />
<img className="post-image" src={imageUrl} alt={`${username} 의 게시물`} />
<p className="post-likes">좋아요 {likeCount}개</p>
<p className="post-content">
<strong>{username}</strong> {content}
</p>
<p className="post-comments">댓글 {commentCount}개 모두 보기</p>
</article>
);
}
PostCard 안에 <Avatar />가 들어갔는데, 컴포넌트 안에 컴포넌트를 넣는 거예요. 이렇게 계속 중첩할 수 있습니다.
어디까지 쪼개야 하는가
처음 배우면 "그럼 다 쪼개야 하나" 싶어지는데, 그러면 파일만 늘고 읽기 어려워져요.
기준을 하나 드릴게요. 다음 중 하나라도 해당하면 쪼갤 만합니다.
- 같은 모양이 두 군데 이상 나온다
- 한 컴포넌트가 너무 길어져서 한눈에 안 들어온다
- 그 조각에 이름을 붙일 수 있다
Avatar는 세 조건을 다 만족하는데, 여러 데 나오고, 이름이 자연스럽고, 떼어내니 PostCard가 짧아졌어요.
반대로 <p className="post-likes">좋아요 {likeCount}개</p>를 LikeCount 컴포넌트로 떼어내는 건 지금은 과해요. 한 군데서만 쓰이고 한 줄이니까요.
쪼개기의 더 깊은 방법은 B-3에서 다룹니다.
💡 한 줄 정리
컴포넌트 안에 컴포넌트를 넣을 수 있습니다. 같은 모양이 반복되거나 이름 붙일 수 있는 조각이면 떼어냅니다.
🙋 학생 질문 — "튜터님, Avatar에도 username이 있고 PostCard에도 있는데 중복 아닌가요?"
좋은 관찰이에요. 중복처럼 보이지만 성격이 다릅니다.
PostCard의 username은 "이 카드가 누구 게시물인가"라는 정보이고, Avatar의 username은 "이 동그란 조각에 누구 이름을 쓸 것인가"라는 요구입니다. PostCard가 자기가 아는 값을 Avatar에게 건네주는 거죠.
이렇게 해두면 Avatar는 게시물을 전혀 몰라도 되는데, 이름과 이미지 주소만 주면 어디서든 쓸 수 있어요. 댓글 옆에서도 쓰고 스토리 목록에서도 씁니다.
만약 Avatar가 Post 전체를 받게 만들었다면, 게시물이 아닌 곳에서는 못 쓰게 됩니다. 필요한 것만 받는 게 재사용의 핵심인데, A-3에서 Post 전체 대신 PostThumbnail을 만든 것과 같은 생각입니다.
Step 8: "단방향 데이터 흐름 — 값은 위에서 아래로만"
마지막으로 카드를 실제 데이터로 그려봅시다.
지난 과목에서 쓰던 그 배열을 가져오는데, 이번엔 타입이 붙었어요.
// apps/web-spa/src/data/feed.ts
import type { Post } from '../types/instagram';
// 지난 과목에서 순수 JS 로 다루던 그 배열 — 이번엔 타입이 붙었다
export const feedPosts: Post[] = [
{
id: 1,
username: 'jaehoon',
profileImageUrl: 'https://picsum.photos/seed/jaehoon/64/64',
imageUrl: 'https://picsum.photos/seed/post1/640/640',
mediaKind: 'image',
content: '오늘 한강 노을이 미쳤다',
hashtagNames: ['한강', '노을'],
likeCount: 1240,
commentCount: 32,
liked: false,
createdAt: '2026-07-20T18:30:00',
},
// ... 두 번째 게시물
];
그리고 App에서 카드를 그립니다.
// apps/web-spa/src/App.tsx
import { PostCard } from './components/PostCard';
import { feedPosts } from './data/feed';
const [firstPost, secondPost] = feedPosts;
export function App() {
return (
<main className="feed">
<h1 className="feed-title">인스타그램</h1>
<PostCard {...firstPost} />
<PostCard {...secondPost} />
</main>
);
}
{...firstPost}가 처음 보는 표기일 거예요. 지난 과목에서 객체를 펼치던 그 점 세 개 맞는데, 게시물 객체의 필드를 전부 props로 펼쳐 넣는 거예요.
이게 되는 이유가 A-3에 있습니다. PostCardProps는 Post에서 두 개를 뺀 타입이니, Post 객체를 펼치면 필요한 게 다 들어 있어요. 타입을 파생으로 만들어 둔 보람이 여기서 나옵니다.
브라우저를 보면 카드 두 장이 서로 다른 내용으로 떠 있는데, 컴포넌트는 하나인데 화면은 둘이에요.
데이터는 한 방향으로만 흐른다
지금 값이 어떻게 움직였는지 따라가 봅시다.
feedPosts (데이터)
│
▼
App ─── 게시물 객체를 props 로 넘긴다
│
▼
PostCard ─── username 과 profileImageUrl 을 다시 넘긴다
│
▼
Avatar ─── 받은 값을 그린다
값은 위에서 아래로만 간다 (아래에서 위로 올라가는 화살표는 없다)
App이 PostCard에게 주고, PostCard가 Avatar에게 줍니다. 반대 방향은 없어서, Avatar가 PostCard의 값을 바꿀 방법이 없습니다.
이걸 단방향 데이터 흐름(one-way data flow)이라고 부르는데, 답답해 보일 수 있지만 이 제약이 주는 이득이 큽니다.
화면에 이상한 값이 떴다고 해봅시다. 어디를 봐야 할까요? 그 값을 넘겨준 부모를 따라 올라가면 되는데, 반드시 위에서 왔으니까요. 양방향이면 누가 언제 바꿨는지 찾느라 훨씬 오래 걸립니다.
지금 화면의 한계
카드 두 장을 손으로 적었는데, 게시물이 백 개면 백 줄을 적을 순 없죠. 배열을 화면으로 바꾸는 방법이 따로 있는데 그건 다음 시간에 배웁니다.
그리고 더 큰 한계가 있어요. 좋아요를 눌러도 아무 일이 안 일어나는데, 지금 우리 카드는 받은 값을 그리기만 하거든요. liked와 likeCount를 받아놓고 쓰지도 않았습니다.
바뀌는 값을 다루려면 props 말고 다른 도구가 필요한데, 그게 다음 시간의 주제입니다.
💡 한 줄 정리
데이터는 부모에서 자식으로 한 방향으로만 흐릅니다. 그래서 이상한 값이 보이면 위로 거슬러 올라가면 됩니다.
🙋 학생 질문 — "튜터님, 자식이 부모에게 뭔가 알려야 하면 어떡하나요?"
좋아요 버튼을 누르는 상황이죠. 자식에서 일어난 일을 부모가 알아야 합니다.
방법이 있는데, 부모가 자식에게 함수를 props로 넘겨주는 거예요. 자식은 그 함수를 부를 뿐이고, 실제로 값을 바꾸는 건 부모입니다.
이러면 단방향이 깨지지 않는데, 값은 여전히 위에서 아래로 흐르고 자식은 "이런 일이 있었어요"라고 알리기만 합니다. 값을 바꿀 권한은 그 값을 가진 쪽에 그대로 있습니다.
props로 함수를 넘기는 것과 상태를 부모로 올리는 방법은 다음 시간에 함께 다루는데, 오늘은 "값은 아래로, 알림은 함수로"라는 그림만 기억해 두세요.
마무리
오늘 순수 JS로 스무 줄 넘게 쓰던 카드가 React 컴포넌트 하나가 됐는데, 그 컴포넌트에 A-3에서 만들어 둔 타입이 그대로 붙었어요.
오늘 배운 핵심 세 가지
💡 하나 — 컴포넌트는 화면을 돌려주는 함수입니다. 이름은 대문자로 시작하고, JSX를 돌려주며, 돌려주는 것은 항상 하나여야 합니다. DOM을 직접 찾는 곳은 main.tsx 한 군데뿐이에요.
💡 둘 — props로 밖에서 값을 넣습니다. 함수 매개변수와 같고, 구조분해로 꺼내 쓰며, 자식이 마음대로 바꾸지 않습니다. A-3의 PostCardProps가 그대로 입구가 됐습니다.
💡 셋 — 데이터는 위에서 아래로만 흐릅니다. 이 제약 덕분에 이상한 값이 보이면 부모를 따라 올라가는 것만으로 원인을 찾을 수 있습니다.
다음 시간 예고
오늘 만든 카드에는 좋아요 버튼이 없었는데, 정확히는 liked와 likeCount를 props로 받아놓고 쓰지 않았어요.
버튼을 누르면 숫자가 올라가야 하는데, props는 자식이 바꿀 수 없다고 했죠. 그럼 바뀌는 값은 어디에 둬야 할까요. 다음 시간에 상태(state)를 배우는데, 화면이 기억해야 하는 값을 담는 도구예요.
그리고 오늘 카드 두 장을 손으로 적었던 것도 해결하는데, 게시물이 백 개여도 코드는 그대로인 방법이 있어요. 배열을 화면으로 바꾸는 방법과, 그때 반드시 필요한 key라는 것을 함께 다룹니다.
과제
[구현] 스토리 링을 React 컴포넌트로 만들기
A-3 과제에서 StoryRingProps를 파생 타입으로 만드셨는데, 그 타입을 입구로 쓰는 컴포넌트를 만들어 주세요.
요구사항은 다음과 같습니다.
apps/web-spa/src/components/StoryRing.tsx에StoryRing컴포넌트를 만들어 주세요. props 타입은 A-3에서 만든 것을 그대로 가져다 씁니다. 새로 선언하지 마세요.- 동그란 프로필 이미지와 그 아래 작성자 이름이 보이게 그려 주세요. 이미지에는 대체 텍스트를 꼭 넣어 주시고요.
apps/web-spa/src/components/StoryTray.tsx에StoryTray를 만들어, 그 안에서StoryRing을 세 개 그려 주세요. 세 개 모두 다른 사람이어야 합니다.App에StoryTray를 넣어 피드 위에 뜨게 해주세요.- 스타일이 필요하면
globals.css에 클래스를 더하시면 됩니다.
다 만들고 나면 npm run typecheck -w web-spa와 npm run lint -w web-spa를 돌려 두 개 다 통과하는지 확인해 주세요.
useState는 쓰지 마세요. 아직 안 배웠지만, 오늘 배운 것만으로 충분히 됩니다.
[탐구] 일부러 어겨보기
아래 여섯 가지를 하나씩 시도해 보고, 화면이나 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.
- 컴포넌트 이름을 소문자로 바꿔보기 (
function storyRing()) className을class로 바꿔보기- 형제 요소 둘을 프래그먼트 없이 돌려주기
- props 하나를 빼고 컴포넌트를 써보기
- props 이름에 오타를 내보기
- 숫자를 받는 props에 따옴표를 붙여 넘겨보기 (
likeCount="1240")
특히 첫 번째는 에러 메시지에 JSX.IntrinsicElements라는 말이 나오는데, 그게 무슨 목록인지, 왜 거기서 못 찾는다고 하는지 생각해 보세요.
생각해볼 주제
1. 컴포넌트를 쪼개는 기준은 어디에 둘 것인가
오늘 Avatar를 떼어냈는데, 기준으로 "두 군데 이상 나오면"을 제시했죠.
그런데 실무에서는 판단이 애매한 경우가 훨씬 많습니다. 지금은 한 군데서만 쓰지만 곧 여러 데서 쓸 것 같은 조각이라면요? 미리 떼어두는 것과 실제로 두 번째 쓰임이 생겼을 때 떼는 것 중 어느 쪽이 나을까요? 그 판단에 영향을 주는 요소는 무엇일까요?
2. 타입을 파생시키는 것과 컴포넌트마다 선언하는 것
오늘 PostCard는 A-3에서 파생한 PostCardProps를 썼고, Avatar는 AvatarProps를 새로 선언했는데, 두 방식이 한 파일 안에 같이 있는 셈이에요.
어떤 팀은 컴포넌트 props는 그 컴포넌트 옆에 선언하는 걸 규칙으로 두고, 어떤 팀은 도메인 타입에서 파생시키는 걸 선호해요. 각각 무엇을 얻고 무엇을 잃을까요? 여러분이라면 어느 쪽을 기본으로 두시겠습니까?
3. 자동으로 최적화해 주는 도구를 어디까지 믿을 것인가
React Compiler를 켜면서 "손으로 최적화 표시를 다는 법은 당분간 안 배운다"고 했는데, 도구가 대신 해주니까요.
그런데 도구가 대신 해주는 걸 배우지 않으면, 도구가 실패했을 때 알아차릴 수 있을까요? 반대로 도구가 잘 하는 일을 굳이 손으로 익히는 게 시간 낭비는 아닐까요? 자동화 도구와 그 아래 원리 사이에서 어디까지 알아야 한다고 생각하시나요?
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 스토리 링을 React 컴포넌트로 만들기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 타입 재사용 | A-3의 StoryRingProps를 그대로 썼는가 (새로 선언하지 않았는가) |
25 |
| 컴포넌트 이름 | 대문자로 시작하는가 | 10 |
| props 구조분해 | 매개변수에서 필요한 값을 꺼내 썼는가 | 15 |
| 대체 텍스트 | img에 의미 있는 alt를 넣었는가 |
15 |
| 중첩 | StoryTray 안에서 StoryRing을 세 번 그렸는가 |
20 |
| 검사 통과 | typecheck와 lint가 모두 통과하는가 |
15 |
새 props 인터페이스를 손으로 쓰지 않았다면, 이 과제의 핵심을 잡으신 겁니다.
풀이 예시
// apps/web-spa/src/components/StoryRing.tsx
import type { StoryRingProps } from '../types/story-derived';
// A-3 에서 Pick 으로 파생해 둔 타입을 그대로 입구로 쓴다 — 새로 선언하지 않는다
export function StoryRing({ username, profileImageUrl }: StoryRingProps) {
return (
<div className="story-ring">
<img
className="story-ring-image"
src={profileImageUrl}
alt={`${username} 스토리`}
/>
<span className="story-ring-name">{username}</span>
</div>
);
}
여기서 가장 중요한 줄은 맨 위의 임포트인데, StoryRingProps는 A-3에서 Pick<Story, 'username' | 'profileImageUrl'>로 만들어 둔 타입이에요. 컴포넌트를 만들면서 props 타입을 새로 선언하고 싶은 충동이 들었다면, 그게 바로 A-3에서 없애려던 중복입니다.
alt에 ${username} 스토리를 넣은 것도 채점 대상인데, 화면을 못 보는 사용자에게 이미지가 무엇인지 알려주는 유일한 통로예요. alt="이미지"처럼 아무 정보 없는 값은 없느니만 못합니다.
트레이는 이렇게 됩니다.
// apps/web-spa/src/components/StoryTray.tsx
import { StoryRing } from './StoryRing';
export function StoryTray() {
return (
<section className="story-tray">
<StoryRing username="jaehoon" profileImageUrl="https://picsum.photos/seed/jaehoon/64/64" />
<StoryRing username="minji" profileImageUrl="https://picsum.photos/seed/minji/64/64" />
<StoryRing username="seungwoo" profileImageUrl="https://picsum.photos/seed/seungwoo/64/64" />
</section>
);
}
같은 컴포넌트를 세 번 쓰면서 매번 다른 값을 넘겼는데, 컴포넌트 하나로 화면 세 개를 만든 거예요. 이게 props의 값어치입니다.
스타일은 이 정도면 충분합니다.
/* apps/web-spa/src/styles/globals.css */
.story-tray {
display: flex;
gap: 16px;
padding: 12px 0;
overflow-x: auto;
}
.story-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.story-ring-image {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #dc2743;
padding: 2px;
}
.story-ring-name {
font-size: 12px;
}
자주 나오는 실수
가장 흔한 건 props 타입을 새로 선언하는 건데, 아래처럼요.
// 이렇게 하면 과제의 목적이 사라진다
interface StoryRingProps {
username: string;
profileImageUrl: string;
}
동작은 하지만, A-3에서 Story의 필드 이름이 바뀌면 파생 타입은 따라오는데 이건 안 따라와요. 조용히 어긋나는 그 문제가 그대로 돌아옵니다.
두 번째로 흔한 건 세 링에 같은 사람을 넣는 건데, 요구사항에 "세 개 모두 다른 사람"이라고 적어둔 이유가 있어요. 같은 값을 세 번 넘기면 props가 실제로 화면을 바꾸는지 확인이 안 됩니다.
세 번째는 StoryTray 안에서 배열과 map을 쓰려다 막히는 경우인데, 아직 안 배웠으니 세 번 적는 게 맞습니다. 다음 시간에 배열을 화면으로 바꾸는 방법을 배우면 이 코드가 세 줄에서 한 줄로 줄어듭니다.
💡 튜터의 한마디
이 과제의 진짜 목적은 스토리 링을 그리는 게 아니라, A-3에서 만든 타입이 오늘 컴포넌트의 입구로 그대로 이어지는 걸 손으로 확인하는 겁니다. 타입 선언과 화면이 따로 노는 게 아니라 한 줄로 이어져 있다는 감각이요. 이 감각이 잡히면 앞으로 컴포넌트를 만들 때 "이 props 타입 어디서 오지"를 먼저 묻게 되는데, 그 질문을 하는 사람이 타입을 제대로 쓰는 사람이에요.
🎯 [과제 2 예시답안] 일부러 어겨보기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 여섯 가지 시도 | 여섯 경우를 모두 재현했는가 | 30 |
| 메시지 기록 | 각각의 에러 메시지를 옮겨 적었는가 | 20 |
| 의미 해석 | 메시지가 무슨 뜻인지 자기 말로 설명했는가 | 30 |
| 소문자 탐구 | JSX.IntrinsicElements가 무엇인지 짚었는가 |
20 |
풀이 예시
컴포넌트 이름을 소문자로 바꾼다
error TS2339: Property 'postCard' does not exist on type 'JSX.IntrinsicElements'.
JSX.IntrinsicElements는 브라우저가 원래 아는 태그 목록인데, div, p, img 같은 것들이 여기 들어 있어요. React는 소문자로 시작하는 이름을 보면 이 목록에서 찾는데, postCard는 우리가 만든 컴포넌트지 HTML 태그가 아니니 목록에 없죠.
여기서 중요한 건 React가 대소문자만으로 둘을 구분한다는 건데, 다른 표시가 없어요. 그래서 컴포넌트 이름은 반드시 대문자로 시작해야 합니다.
className 을 class로 바꾼다
error TS2322: Property 'class' does not exist on type
'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.
Did you mean 'className'?
class는 JavaScript 예약어라 JSX에서 쓸 수 없는데, 제안까지 해주는 게 친절하죠. 같은 이유로 <label>의 for는 htmlFor입니다.
형제 요소를 프래그먼트 없이 돌려준다
error TS2657: JSX expressions must have one parent element.
함수는 값을 하나만 돌려줄 수 있으니까요. <>...</>로 감싸면 해결되는데, 의미 없는 <div>를 넣지 않고도 감쌀 수 있어서 프래그먼트를 씁니다.
props 하나를 뺀다
error TS2741: Property 'profileImageUrl' is missing in type '{ username: string; }'
but required in type 'AvatarProps'.
무엇이 빠졌는지 이름까지 알려주는데, 여러 개를 한꺼번에 빼면 메시지가 조금 달라져요.
error TS2740: Type '{ username: string; }' is missing the following properties from
type 'PostCardProps': profileImageUrl, id, imageUrl, mediaKind, and 4 more.
빠진 게 많으면 앞의 네 개만 보여주고, 나머지는 개수로 알려줍니다.
props 이름에 오타를 낸다
error TS2322: Property 'usernam' does not exist on type
'IntrinsicAttributes & AvatarProps'. Did you mean 'username'?
없는 props를 넘기면 "그런 거 없다"고 하면서 비슷한 이름을 제안하는데, 지난 과목이었다면 이 오타는 화면에 undefined로 나타났을 거예요.
숫자를 받는 props에 따옴표를 붙인다
error TS2322: Type 'string' is not assignable to type 'number'.
likeCount="1240"은 문자열 "1240"을 넘긴 겁니다. 숫자를 넘기려면 likeCount={1240}처럼 중괄호를 써야 하는데, JSX에서 따옴표는 문자열이고 중괄호는 JavaScript 값입니다.
자주 나오는 실수
에러 메시지를 옮겨 적기만 하고 뜻을 안 적는 경우가 많은데, 이 과제의 배점은 해석에 더 많이 걸려 있어요. 메시지를 읽고 무슨 말인지 아는 게 실무에서 훨씬 오래 쓰이는 능력입니다.
그리고 형제 요소 실험은 파일 전체의 문법이 깨져서 다른 에러가 안 보이게 되니, 확인했으면 바로 되돌리고 다음 실험으로 넘어가세요.
💡 튜터의 한마디
에러 메시지를 무서워하지 않는 게 이 과제의 목적입니다. 빨간 줄이 뜨면 "뭔가 잘못됐다"에서 멈추지 말고 끝까지 읽어보세요. TypeScript는 무엇이 빠졌는지, 어떤 이름을 쓰려던 건지까지 알려줍니다. Did you mean이 나오면 대체로 그게 정답이에요. 실무에서 개발 속도를 가르는 건 에러를 안 내는 능력이 아니라 에러를 빨리 읽는 능력입니다.
🤔 [생각해볼 주제 1] 컴포넌트를 쪼개는 기준은 어디에 둘 것인가
문제 상황 요약
오늘 Avatar를 떼어내면서 "두 군데 이상 나오면"이라는 기준을 제시했는데, 실무에서는 지금 한 군데지만 곧 여러 데서 쓸 것 같은 조각이 훨씬 많습니다. 미리 떼어둘 것인가, 두 번째 쓰임이 생겼을 때 뗄 것인가.
튜터의 가이드 및 해설
결론부터 말하면 두 번째 쓰임이 생겼을 때 떼는 쪽을 기본으로 두시길 권합니다.
이유는 미리 떼는 판단이 생각보다 자주 틀리기 때문이에요. "곧 여러 데서 쓸 것 같다"는 예상은 절반쯤 빗나가는데, 빗나가면 한 군데서만 쓰는 컴포넌트가 파일 하나를 차지하고 그걸 읽으려면 파일을 오가야 해요.
더 나쁜 건 재사용을 예상하고 만든 컴포넌트가 대체로 과하게 유연하다는 겁니다. 아직 오지 않은 두 번째 쓰임에 대비해서 props를 여러 개 열어두게 되거든요. 그 props는 아무도 안 쓰는데 읽는 사람은 전부 이해해야 합니다.
반대로 두 번째 쓰임이 실제로 나타났을 때 떼면, 두 곳의 요구를 다 보고 나서 설계할 수 있는데, 어디가 같고 어디가 달라야 하는지가 추측이 아니라 사실로 주어져요.
다만 예외가 둘 있습니다. 하나는 그 조각이 이미 너무 길어서 부모를 읽기 어렵게 만들 때인데, 이건 재사용과 무관하게 쪼갤 이유가 됩니다. 다른 하나는 조각에 도메인 이름이 확실히 붙을 때인데, Avatar 나 PriceTag처럼 팀에서 쓰는 말이 그대로 이름이 되면 한 군데서만 쓰더라도 그 이름이 코드를 설명해 줍니다.
정리하면 쪼개는 이유는 세 가지입니다. 반복되니까, 너무 기니까, 이름이 있으니까. 이 중 하나도 해당 안 되는데 "나중에 쓸 것 같아서" 쪼개는 건 대체로 이릅니다.
🎯 면접관을 홀리는 핵심 멘트
"저는 재사용을 예상해서 미리 쪼개지 않고, 두 번째 쓰임이 실제로 나타났을 때 쪼갭니다. 예상으로 만든 컴포넌트는 아직 오지 않은 요구에 대비하느라 props가 과하게 열려 있는 경우가 많은데, 두 곳의 요구를 다 보고 나서 설계하면 무엇이 공통이고 무엇이 달라야 하는지가 추측이 아니라 사실로 주어지기 때문입니다. 다만 길이가 부모의 가독성을 해치거나 조각에 도메인 이름이 확실히 붙는 경우는 한 군데서만 쓰더라도 분리합니다."
🤔 [생각해볼 주제 2] 타입을 파생시키는 것과 컴포넌트마다 선언하는 것
문제 상황 요약
오늘 PostCard는 A-3에서 파생한 PostCardProps를 썼고, Avatar는 AvatarProps를 새로 선언했는데, 한 프로젝트 안에 두 방식이 섞여 있는 셈입니다. 무엇을 기준으로 갈라야 할까요.
튜터의 가이드 및 해설
둘이 섞여 있는 게 실수가 아니라는 걸 먼저 짚고 싶은데, 두 컴포넌트의 성격이 다르기 때문에 다른 선택을 한 거예요.
PostCard는 게시물이라는 도메인에 묶여 있습니다. 서버가 내려주는 Post가 바뀌면 이 카드도 반드시 따라 바뀌어야 하니, Post에서 파생시키는 게 맞습니다. 서버 응답이 바뀌면 컴파일러가 여기도 고치라고 알려주거든요. 이게 파생의 값어치입니다.
Avatar는 다릅니다. 이름과 이미지 주소만 있으면 어디서든 동작해서, 게시물 옆에서도 쓰고 댓글 옆에서도 쓰고 스토리 목록에서도 씁니다. 이걸 Post에서 파생시키면 게시물이 아닌 곳에서 쓸 수 없게 되는데, 게시물과 아무 상관 없는 컴포넌트에 게시물 타입을 묶는 셈입니다.
그래서 기준은 이렇게 잡습니다. 그 컴포넌트가 특정 도메인 데이터를 그리는 게 목적이면 파생시키고, 모양만 담당하고 어디서든 쓰이면 자기 타입을 따로 선언합니다.
잃는 것도 짚어야 공평하겠죠. 파생시키면 원본이 바뀔 때 예상 못 한 데서 에러가 나는데, 좋은 일이지만 한 번에 여러 파일을 고쳐야 할 때도 있어요. 그리고 파생이 겹겹이 쌓이면 그 타입에 뭐가 들었는지 읽기 어려워지는데, A-3에서 얘기한 그 문제죠.
반대로 컴포넌트마다 선언하면 각 파일이 독립적이라 읽기 쉽습니다. 대신 서버 응답이 바뀌었을 때 아무도 안 알려줘서, 화면에 undefined가 뜨고 나서야 압니다.
한 가지 덧붙이면, 실무에서는 공용 컴포넌트를 도메인에서 떼어놓는 게 대체로 이득이 큰데, 디자인 시스템 컴포넌트가 특정 API 응답 타입을 알고 있으면 그 컴포넌트는 그 프로젝트 밖으로 못 나가거든요.
🎯 면접관을 홀리는 핵심 멘트
"컴포넌트가 특정 도메인 데이터를 그리는 게 목적이면 도메인 타입에서 파생시키고, 모양만 담당하는 공용 컴포넌트면 자기 props 타입을 따로 선언합니다. 전자는 서버 응답이 바뀌었을 때 컴파일러가 고칠 곳을 알려주는 게 이득이고, 후자는 도메인에 묶이는 순간 다른 화면에서 재사용할 수 없게 되는 게 더 큰 손해이기 때문입니다. 디자인 시스템 컴포넌트가 특정 API 응답 타입을 알고 있으면 그 프로젝트 밖으로 못 나갑니다."
🤔 [생각해볼 주제 3] 자동으로 최적화해 주는 도구를 어디까지 믿을 것인가
문제 상황 요약
React Compiler를 켜면서 손으로 최적화 표시를 다는 방법은 당분간 안 배우기로 했는데, 도구가 대신 해주는 걸 배우지 않으면 도구가 실패했을 때 알아차릴 수 있을까요.
튜터의 가이드 및 해설
이 질문은 React만의 것이 아닙니다. 컴파일러가 기계어를 대신 만들어 주고, 가비지 컬렉터가 메모리를 대신 정리해 주죠. 그때마다 같은 질문이 나왔고, 답은 대체로 비슷하게 정리됐습니다.
문법은 몰라도 되지만 원리와 증상은 알아야 합니다.
무슨 뜻이냐면, 최적화 표시를 어떤 문법으로 다는지는 필요할 때 찾아보면 됩니다. 하지만 왜 그런 표시가 필요한지, 없으면 무슨 일이 생기는지는 알아야 하는데, 그걸 알아야 화면이 버벅일 때 "이건 그 문제구나" 하고 의심할 수 있습니다.
React Compiler로 좁혀서 보면, 컴파일러가 손대지 못하는 경우가 실제로 있습니다. 코드가 React의 규칙을 벗어나면 컴파일러는 안전을 위해 그 부분을 건너뛰는데, 잘못 최적화하느니 안 하는 쪽을 고릅니다. 그래서 오늘 린트를 함께 켠 거예요. 규칙을 벗어난 곳을 린트가 알려주니까요.
여기서 순서가 중요합니다. 예전에는 React를 배우면서 거의 동시에 최적화 문법을 배웠고, 원리를 모르는 채 여기저기 표시를 다는 일이 흔했습니다. 그건 코드를 어렵게 만들면서 성능은 별로 나아지지 않는 최악의 조합이에요.
지금은 순서를 바꿀 수 있습니다. 먼저 깔끔하게 쓰는 법을 익히고, 다음에 측정하는 법을 배우고, 마지막에 측정된 문제에만 손을 대는데, 이 순서가 더 자연스럽고 실무와도 맞아요. 측정 없이 최적화하는 건 어느 시대에나 잘못된 방법이었으니까요.
그리고 컴파일러가 아예 관여하지 않는 영역이 훨씬 넓다는 것도 기억해 두세요. 필요 없는 데이터를 받아오는 것, 이미지가 너무 큰 것, 번들에 안 쓰는 코드가 들어가는 것은 컴파일러 소관이 아닌데, 실무에서 체감 성능을 가르는 건 대체로 이쪽이에요.
🎯 면접관을 홀리는 핵심 멘트
"자동화 도구는 문법을 대신해 주지만 판단을 대신해 주지는 않습니다. 그래서 저는 최적화 표시를 다는 문법보다 측정하는 방법과 증상을 아는 걸 우선합니다. React Compiler도 코드가 규칙을 벗어나면 안전을 위해 그 부분을 건너뛰기 때문에, 컴파일러와 린트를 함께 켜서 최적화되지 않은 곳을 드러나게 만드는 게 실제 방어선입니다. 그리고 체감 성능을 가르는 건 대체로 컴파일러가 관여하지 않는 영역, 즉 불필요한 요청과 이미지와 번들 크기입니다."