문서 읽는 데 69분 · B1

B-1: React 입문 — 컴포넌트·JSX·props

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

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

안녕하세요, 홍순구입니다. 오늘 드디어 React 를 시작합니다.

지난 세 시간 동안 우리는 타입만 다뤘어요. 화면은 한 번도 안 그렸죠. UserPost 를 선언했고, 정체 모를 값을 갈라내는 가드를 만들었고, 원본에서 필요한 모양만 뽑아냈습니다. 그중에 이런 게 있었어요.

TypeScript
// apps/web-spa/src/types/derived.ts
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;

이름 끝에 Props 를 붙여놓고 "다음 시간에 쓸 겁니다"라고만 하고 넘어갔죠. 오늘 그걸 씁니다.

그전에 지난 과목 얘기를 잠깐 할게요. 순수 HTML/CSS/JS 로 인스타그램 카드를 그릴 때 이런 코드를 썼습니다.

JavaScript
// 지난 과목에서 카드 한 장을 만들던 방식
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 에서는 이렇게 했죠.

JavaScript
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-4 에서 측정하는 방법과 함께 다룹니다. 지금 단계에서 성능을 미리 걱정하면서 코드를 비틀 필요는 없습니다.


Step 2: "Vite 로 React 앱 만들기"

이론은 여기까지 하고 실제로 앱을 만들어 봅시다. 눈으로만 보면 안 늘어요. 직접 쳐보면서 따라오세요.

지금 우리 프로젝트에는 apps/web-spa/ 가 있습니다. 지난 세 시간 동안 여기에 타입 파일만 쌓았어요. React 도 없고 화면도 없습니다. 여기에 React 를 얹을 겁니다.

무엇으로 만들 것인가

React 앱을 만드는 도구는 여러 개가 있었습니다. 그중 오랫동안 표준이던 게 CRA(Create React App)예요. 검색하면 아직도 CRA 로 시작하는 글이 잔뜩 나옵니다.

그거 쓰지 마세요. CRA 는 공식적으로 수명이 끝났습니다. React 팀이 직접 "새 프로젝트에 쓰지 말라"고 공지했어요. 지금 새로 만든다면 Vite 입니다.

Vite 는 개발 서버를 띄우고 코드를 묶어주는 도구예요. 최신 버전인 8 은 Rolldown 이라는 엔진을 씁니다. Rust 로 만들어져서 빠르고, 예전에 개발용과 배포용 도구가 따로 놀던 구조를 하나로 합쳤어요.

설치

터미널을 열고 프로젝트 최상위 폴더에서 아래를 실행합니다.

Bash
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 를 쓸 거라 알려줘야 해요.

JSON
{
  "compilerOptions": {
    "jsx": "react-jsx"
  }
}

"react-jsx" 는 JSX 를 만나면 알아서 React 를 불러다 쓰라는 뜻입니다. 예전에는 JSX 를 쓰는 파일마다 import React from 'react' 를 맨 위에 적어야 했는데, 이 설정 덕분에 안 적어도 됩니다.

그다음 index.html 을 만듭니다. 브라우저가 가장 먼저 읽는 파일이에요.

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 입니다.

TypeScript
// 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 에 실행 명령을 넣습니다.

JSON
{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "typecheck": "tsc --noEmit"
  }
}

build 를 보세요. tsc --noEmit 를 먼저 돌리고 그다음에 vite build 를 합니다. 이렇게 붙여둔 이유가 있어요. Vite 는 코드를 묶을 때 타입을 검사하지 않습니다. 타입이 틀려도 빌드가 성공해요. 그래서 타입 검사를 따로 걸어둬야 합니다. 이 얘기는 A-5 에서 제대로 다룹니다.

이제 띄워봅시다.

Bash
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-4 에서, 측정해서 정말 문제가 확인된 곳에만 쓰는 탈출구로 다룹니다.

설치는 이렇습니다.

Bash
npm install -w web-spa --save-exact -D babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel@0.2.3

그리고 vite.config.ts 를 이렇게 고칩니다.

TypeScript
// 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, 코드에서 문제 될 만한 곳을 미리 잡아주는 도구)입니다.

Bash
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 입니다.

JavaScript
// 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 에서 화면 한 조각은 함수입니다. 그 함수는 값을 받아서 화면을 돌려줘요. 그게 전부예요. 클래스도 아니고 특별한 문법도 아니고 그냥 함수입니다.

가장 단순한 컴포넌트를 봅시다.

tsx
// 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 예요.

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 을 만듭니다.

tsx
// 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 입니다.

tsx
// 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 는 이름 없는 껍데기를 줍니다.

tsx
export function TwoLines() {
  return (
    <>
      <h1>인스타그램</h1>
      <p>피드</p>
    </>
  );
}

<></> 로 감싸면 됩니다. 이걸 프래그먼트(fragment, 조각)라고 불러요. 실제 화면에는 아무 요소도 안 생기고 자식들만 들어갑니다.

💡 한 줄 정리

JSX 는 JavaScript 안의 화면 문법입니다. 중괄호 안은 JavaScript 이고, classclassName 이며, 돌려주는 것은 항상 하나여야 합니다.

🙋 학생 질문 — "튜터님, 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 예요.

값을 받는 함수로 바꾸기

함수가 매개변수를 받는 것과 똑같습니다.

tsx
// 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>
  );
}

쓰는 쪽은 이렇습니다.

tsx
<MiniPostCard username="jaehoon" likeCount={1240} />

HTML 속성을 쓰듯이 값을 넘깁니다. 문자열은 따옴표로, 숫자는 중괄호로 넣어요. 숫자에 중괄호를 쓰는 이유는 따옴표로 쓰면 문자열 "1240" 이 되기 때문입니다.

구조분해로 꺼내기

{ username, likeCount } 부분을 보세요. React 는 props 를 객체 하나로 묶어서 넘겨줍니다. 구조분해를 안 쓰면 이렇게 됩니다.

tsx
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>
  );
}

둘 다 동작합니다. 다만 구조분해 쪽이 널리 쓰여요. 이 컴포넌트가 무슨 값을 쓰는지 함수 첫 줄만 보면 알 수 있거든요.

지난 시간에 만든 타입을 꺼낸다

이제 오프닝의 그 타입으로 돌아갑니다.

TypeScript
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;

Post 에서 카드가 안 쓰는 둘을 뺀 타입이었죠. 이름에 Props 를 붙여둔 이유가 이제 드러납니다. 이게 그대로 컴포넌트의 입구예요.

tsx
// 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 를 분리한다"

컴포넌트의 진짜 힘은 쪼갤 때 나옵니다.

인스타그램 화면을 떠올려 보세요. 프로필 사진과 사용자 이름이 나란히 있는 조각이 카드 위에도 있고, 댓글 옆에도 있고, 스토리 목록에도 있습니다. 같은 모양이 여러 군데 나와요.

이런 걸 따로 떼어냅니다.

tsx
// 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 안에서 씁니다.

tsx
// 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 에도 있는데 중복 아닌가요?"

좋은 관찰이에요. 중복처럼 보이지만 성격이 다릅니다.

PostCardusername 은 "이 카드가 누구 게시물인가"라는 정보예요. Avatarusername 은 "이 동그란 조각에 누구 이름을 쓸 것인가"라는 요구입니다. PostCard 가 자기가 아는 값을 Avatar 에게 건네주는 거죠.

이렇게 해두면 Avatar 는 게시물을 전혀 몰라도 됩니다. 이름과 이미지 주소만 주면 어디서든 쓸 수 있어요. 댓글 옆에서도 쓰고 스토리 목록에서도 씁니다.

만약 AvatarPost 전체를 받게 만들었다면, 게시물이 아닌 곳에서는 못 쓰게 됩니다. 필요한 것만 받는 게 재사용의 핵심이에요. A-3 에서 Post 전체 대신 PostThumbnail 을 만든 것과 같은 생각입니다.


Step 8: "단방향 데이터 흐름 — 값은 위에서 아래로만"

마지막으로 카드를 실제 데이터로 그려봅시다.

지난 과목에서 쓰던 그 배열을 가져옵니다. 이번엔 타입이 붙었어요.

TypeScript
// 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 에서 카드를 그립니다.

tsx
// 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 에 있습니다. PostCardPropsPost 에서 두 개를 뺀 타입이니, Post 객체를 펼치면 필요한 게 다 들어 있어요. 타입을 파생으로 만들어 둔 보람이 여기서 나옵니다.

브라우저를 보면 카드 두 장이 서로 다른 내용으로 떠 있습니다. 컴포넌트는 하나인데 화면은 둘이에요.

데이터는 한 방향으로만 흐른다

지금 값이 어떻게 움직였는지 따라가 봅시다.

텍스트
 feedPosts (데이터)
    │
    
 App        ─── 게시물 객체를 props 로 넘긴다
    │
    
 PostCard   ─── username 과 profileImageUrl 을 다시 넘긴다
    │
    
 Avatar     ─── 받은 값을 그린다

 값은 위에서 아래로만 간다 (아래에서 위로 올라가는 화살표는 없다)

AppPostCard 에게 주고, PostCardAvatar 에게 줍니다. 반대 방향은 없어요. AvatarPostCard 의 값을 바꿀 방법이 없습니다.

이걸 단방향 데이터 흐름(one-way data flow)이라고 부릅니다. 답답해 보일 수 있는데, 이 제약이 주는 이득이 큽니다.

화면에 이상한 값이 떴다고 해봅시다. 어디를 봐야 할까요? 그 값을 넘겨준 부모를 따라 올라가면 됩니다. 반드시 위에서 왔으니까요. 양방향이면 누가 언제 바꿨는지 찾느라 훨씬 오래 걸립니다.

지금 화면의 한계

카드 두 장을 손으로 적었습니다. 게시물이 백 개면 백 줄을 적을 순 없죠. 배열을 화면으로 바꾸는 방법이 따로 있는데 그건 다음 시간에 배웁니다.

그리고 더 큰 한계가 있어요. 좋아요를 눌러도 아무 일이 안 일어납니다. 지금 우리 카드는 받은 값을 그리기만 하거든요. likedlikeCount 를 받아놓고 쓰지도 않았습니다.

바뀌는 값을 다루려면 props 말고 다른 도구가 필요해요. 그게 다음 시간의 주제입니다.

💡 한 줄 정리

데이터는 부모에서 자식으로 한 방향으로만 흐릅니다. 그래서 이상한 값이 보이면 위로 거슬러 올라가면 됩니다.

🙋 학생 질문 — "튜터님, 자식이 부모에게 뭔가 알려야 하면 어떡하나요?"

좋아요 버튼을 누르는 상황이죠. 자식에서 일어난 일을 부모가 알아야 합니다.

방법이 있습니다. 부모가 자식에게 함수를 props 로 넘겨주는 거예요. 자식은 그 함수를 부를 뿐이고, 실제로 값을 바꾸는 건 부모입니다.

이러면 단방향이 깨지지 않아요. 값은 여전히 위에서 아래로 흐르고, 자식은 "이런 일이 있었어요"라고 알리기만 합니다. 값을 바꿀 권한은 그 값을 가진 쪽에 그대로 있습니다.

props 로 함수를 넘기는 것과 상태를 부모로 올리는 방법은 다음 시간에 함께 다룹니다. 오늘은 "값은 아래로, 알림은 함수로"라는 그림만 기억해 두세요.


마무리

오늘 순수 JS 로 스무 줄 넘게 쓰던 카드가 React 컴포넌트 하나가 됐습니다. 그리고 그 컴포넌트에 A-3 에서 만들어 둔 타입이 그대로 붙었어요.

오늘 배운 핵심 세 가지

💡 하나 — 컴포넌트는 화면을 돌려주는 함수입니다. 이름은 대문자로 시작하고, JSX 를 돌려주며, 돌려주는 것은 항상 하나여야 합니다. DOM 을 직접 찾는 곳은 main.tsx 한 군데뿐이에요.

💡 둘 — props 로 밖에서 값을 넣습니다. 함수 매개변수와 같고, 구조분해로 꺼내 쓰며, 자식이 마음대로 바꾸지 않습니다. A-3 의 PostCardProps 가 그대로 입구가 됐습니다.

💡 셋 — 데이터는 위에서 아래로만 흐릅니다. 이 제약 덕분에 이상한 값이 보이면 부모를 따라 올라가는 것만으로 원인을 찾을 수 있습니다.

다음 시간 예고

오늘 만든 카드에는 좋아요 버튼이 없었습니다. 정확히는 likedlikeCount 를 props 로 받아놓고 쓰지 않았어요.

버튼을 누르면 숫자가 올라가야 하는데, props 는 자식이 바꿀 수 없다고 했죠. 그럼 바뀌는 값은 어디에 둬야 할까요. 다음 시간에 상태(state)를 배웁니다. 화면이 기억해야 하는 값을 담는 도구예요.

그리고 오늘 카드 두 장을 손으로 적었던 것도 해결합니다. 게시물이 백 개여도 코드는 그대로인 방법이 있어요. 배열을 화면으로 바꾸는 방법과, 그때 반드시 필요한 key 라는 것을 함께 다룹니다.


과제

[구현] 스토리 링을 React 컴포넌트로 만들기

A-3 과제에서 StoryRingProps 를 파생 타입으로 만드셨죠. 그 타입을 입구로 쓰는 컴포넌트를 만들어 주세요.

요구사항은 다음과 같습니다.

  • apps/web-spa/src/components/StoryRing.tsxStoryRing 컴포넌트를 만들어 주세요. props 타입은 A-3 에서 만든 것을 그대로 가져다 씁니다. 새로 선언하지 마세요.
  • 동그란 프로필 이미지와 그 아래 작성자 이름이 보이게 그려 주세요. 이미지에는 대체 텍스트를 꼭 넣어 주시고요.
  • apps/web-spa/src/components/StoryTray.tsxStoryTray 를 만들어, 그 안에서 StoryRing 을 세 개 그려 주세요. 세 개 모두 다른 사람이어야 합니다.
  • AppStoryTray 를 넣어 피드 위에 뜨게 해주세요.
  • 스타일이 필요하면 globals.css 에 클래스를 더하시면 됩니다.

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

useState 는 쓰지 마세요. 아직 안 배웠고, 오늘 배운 것만으로 충분히 됩니다.

[탐구] 일부러 어겨보기

아래 여섯 가지를 하나씩 시도해 보고, 화면이나 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.

  • 컴포넌트 이름을 소문자로 바꿔보기 (function storyRing())
  • classNameclass 로 바꿔보기
  • 형제 요소 둘을 프래그먼트 없이 돌려주기
  • props 하나를 빼고 컴포넌트를 써보기
  • props 이름에 오타를 내보기
  • 숫자를 받는 props 에 따옴표를 붙여 넘겨보기 (likeCount="1240")

특히 첫 번째는 에러 메시지에 JSX.IntrinsicElements 라는 말이 나올 겁니다. 그게 무슨 목록인지, 왜 거기서 못 찾는다고 하는지 생각해 보세요.


생각해볼 주제

1. 컴포넌트를 쪼개는 기준은 어디에 둘 것인가

오늘 Avatar 를 떼어냈습니다. 기준으로 "두 군데 이상 나오면"을 제시했죠.

그런데 실무에서는 판단이 애매한 경우가 훨씬 많습니다. 지금은 한 군데서만 쓰지만 곧 여러 데서 쓸 것 같은 조각이라면요? 미리 떼어두는 것과 실제로 두 번째 쓰임이 생겼을 때 떼는 것 중 어느 쪽이 나을까요? 그 판단에 영향을 주는 요소는 무엇일까요?

2. 타입을 파생시키는 것과 컴포넌트마다 선언하는 것

오늘 PostCard 는 A-3 에서 파생한 PostCardProps 를 썼고, AvatarAvatarProps 를 새로 선언했습니다. 두 방식이 한 파일 안에 같이 있는 셈이에요.

어떤 팀은 컴포넌트 props 는 그 컴포넌트 옆에 선언하는 걸 규칙으로 둡니다. 어떤 팀은 도메인 타입에서 파생시키는 걸 선호해요. 각각 무엇을 얻고 무엇을 잃을까요? 여러분이라면 어느 쪽을 기본으로 두시겠습니까?

3. 자동으로 최적화해 주는 도구를 어디까지 믿을 것인가

React Compiler 를 켜면서 "손으로 최적화 표시를 다는 법은 당분간 안 배운다"고 했습니다. 도구가 대신 해주니까요.

그런데 도구가 대신 해주는 걸 배우지 않으면, 도구가 실패했을 때 알아차릴 수 있을까요? 반대로 도구가 잘 하는 일을 굳이 손으로 익히는 게 시간 낭비는 아닐까요? 자동화 도구와 그 아래 원리 사이에서 어디까지 알아야 한다고 생각하시나요?

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 스토리 링을 React 컴포넌트로 만들기

채점 포인트

항목 확인 내용 배점
타입 재사용 A-3 의 StoryRingProps 를 그대로 썼는가 (새로 선언하지 않았는가) 25
컴포넌트 이름 대문자로 시작하는가 10
props 구조분해 매개변수에서 필요한 값을 꺼내 썼는가 15
대체 텍스트 img 에 의미 있는 alt 를 넣었는가 15
중첩 StoryTray 안에서 StoryRing 을 세 번 그렸는가 20
검사 통과 typechecklint 가 모두 통과하는가 15

새 props 인터페이스를 손으로 쓰지 않았다면 이 과제의 핵심을 잡으신 겁니다.

풀이 예시

tsx
// 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="이미지" 처럼 아무 정보 없는 값은 없느니만 못합니다.

트레이는 이렇게 됩니다.

tsx
// 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 의 값어치입니다.

스타일은 이 정도면 충분합니다.

CSS
/* 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 타입을 새로 선언하는 겁니다.

tsx
// 이렇게 하면 과제의 목적이 사라진다
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>forhtmlFor 입니다.

형제 요소를 프래그먼트 없이 돌려준다

텍스트
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 는 아무도 안 쓰는데 읽는 사람은 전부 이해해야 합니다.

반대로 두 번째 쓰임이 실제로 나타났을 때 떼면, 두 곳의 요구를 다 보고 나서 설계할 수 있습니다. 어디가 같고 어디가 달라야 하는지가 추측이 아니라 사실로 주어져요.

다만 예외가 둘 있습니다. 하나는 그 조각이 이미 너무 길어서 부모를 읽기 어렵게 만들 때예요. 이건 재사용과 무관하게 쪼갤 이유가 됩니다. 다른 하나는 조각에 도메인 이름이 확실히 붙을 때입니다. AvatarPriceTag 처럼 팀에서 쓰는 말이 그대로 이름이 되면, 한 군데서만 쓰더라도 그 이름이 코드를 설명해 줍니다.

정리하면 쪼개는 이유는 세 가지입니다. 반복되니까, 너무 기니까, 이름이 있으니까. 이 중 하나도 해당 안 되는데 "나중에 쓸 것 같아서" 쪼개는 건 대체로 이릅니다.

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

"저는 재사용을 예상해서 미리 쪼개지 않고, 두 번째 쓰임이 실제로 나타났을 때 쪼갭니다. 예상으로 만든 컴포넌트는 아직 오지 않은 요구에 대비하느라 props 가 과하게 열려 있는 경우가 많은데, 두 곳의 요구를 다 보고 나서 설계하면 무엇이 공통이고 무엇이 달라야 하는지가 추측이 아니라 사실로 주어지기 때문입니다. 다만 길이가 부모의 가독성을 해치거나 조각에 도메인 이름이 확실히 붙는 경우는 한 군데서만 쓰더라도 분리합니다."


🤔 [생각해볼 주제 2] 타입을 파생시키는 것과 컴포넌트마다 선언하는 것

문제 상황 요약

오늘 PostCard 는 A-3 에서 파생한 PostCardProps 를 썼고, AvatarAvatarProps 를 새로 선언했습니다. 한 프로젝트 안에 두 방식이 섞여 있는 셈입니다. 무엇을 기준으로 갈라야 할까요.

튜터의 가이드 및 해설

둘이 섞여 있는 게 실수가 아니라는 걸 먼저 짚고 싶습니다. 두 컴포넌트의 성격이 다르기 때문에 다른 선택을 한 거예요.

PostCard 는 게시물이라는 도메인에 묶여 있습니다. 서버가 내려주는 Post 가 바뀌면 이 카드도 반드시 따라 바뀌어야 해요. 그러니 Post 에서 파생시키는 게 맞습니다. 서버 응답이 바뀌면 컴파일러가 여기도 고치라고 알려주거든요. 이게 파생의 값어치입니다.

Avatar 는 다릅니다. 이름과 이미지 주소만 있으면 어디서든 동작해요. 게시물 옆에서도 쓰고 댓글 옆에서도 쓰고 스토리 목록에서도 씁니다. 이걸 Post 에서 파생시키면 게시물이 아닌 곳에서 쓸 수 없게 돼요. 게시물과 아무 상관 없는 컴포넌트에 게시물 타입을 묶는 셈입니다.

그래서 기준은 이렇게 잡습니다. 그 컴포넌트가 특정 도메인 데이터를 그리는 게 목적이면 파생시키고, 모양만 담당하고 어디서든 쓰이면 자기 타입을 따로 선언합니다.

잃는 것도 짚어야 공평하겠죠. 파생시키면 원본이 바뀔 때 예상 못 한 데서 에러가 납니다. 좋은 일이지만 한 번에 여러 파일을 고쳐야 할 때도 있어요. 그리고 파생이 겹겹이 쌓이면 그 타입에 뭐가 들었는지 읽기 어려워집니다. A-3 에서 얘기한 그 문제죠.

반대로 컴포넌트마다 선언하면 각 파일이 독립적이라 읽기 쉽습니다. 대신 서버 응답이 바뀌었을 때 아무도 안 알려줘요. 화면에 undefined 가 뜨고 나서야 압니다.

한 가지 덧붙이면, 실무에서는 공용 컴포넌트를 도메인에서 떼어놓는 게 대체로 이득이 큽니다. 디자인 시스템 컴포넌트가 특정 API 응답 타입을 알고 있으면 그 컴포넌트는 그 프로젝트 밖으로 못 나가거든요.

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

"컴포넌트가 특정 도메인 데이터를 그리는 게 목적이면 도메인 타입에서 파생시키고, 모양만 담당하는 공용 컴포넌트면 자기 props 타입을 따로 선언합니다. 전자는 서버 응답이 바뀌었을 때 컴파일러가 고칠 곳을 알려주는 게 이득이고, 후자는 도메인에 묶이는 순간 다른 화면에서 재사용할 수 없게 되는 게 더 큰 손해이기 때문입니다. 디자인 시스템 컴포넌트가 특정 API 응답 타입을 알고 있으면 그 프로젝트 밖으로 못 나갑니다."


🤔 [생각해볼 주제 3] 자동으로 최적화해 주는 도구를 어디까지 믿을 것인가

문제 상황 요약

React Compiler 를 켜면서 손으로 최적화 표시를 다는 방법은 당분간 안 배우기로 했습니다. 그런데 도구가 대신 해주는 걸 배우지 않으면, 도구가 실패했을 때 알아차릴 수 있을까요.

튜터의 가이드 및 해설

이 질문은 React 만의 것이 아닙니다. 컴파일러가 기계어를 대신 만들어 주고, 가비지 컬렉터가 메모리를 대신 정리해 주죠. 그때마다 같은 질문이 나왔고, 답은 대체로 비슷하게 정리됐습니다.

문법은 몰라도 되지만 원리와 증상은 알아야 합니다.

무슨 뜻이냐면, 최적화 표시를 어떤 문법으로 다는지는 필요할 때 찾아보면 됩니다. 하지만 왜 그런 표시가 필요한지, 없으면 무슨 일이 생기는지는 알아야 해요. 그걸 알아야 화면이 버벅일 때 "이건 그 문제구나" 하고 의심할 수 있습니다.

React Compiler 로 좁혀서 보면, 컴파일러가 손대지 못하는 경우가 실제로 있습니다. 코드가 React 의 규칙을 벗어나면 컴파일러는 안전을 위해 그 부분을 건너뛰어요. 잘못 최적화하느니 안 하는 쪽을 고릅니다. 그래서 오늘 린트를 함께 켠 거예요. 규칙을 벗어난 곳을 린트가 알려주니까요.

여기서 순서가 중요합니다. 예전에는 React 를 배우면서 거의 동시에 최적화 문법을 배웠고, 원리를 모르는 채 여기저기 표시를 다는 일이 흔했습니다. 그건 코드를 어렵게 만들면서 성능은 별로 나아지지 않는 최악의 조합이에요.

지금은 순서를 바꿀 수 있습니다. 먼저 깔끔하게 쓰는 법을 익히고, 다음에 측정하는 법을 배우고, 마지막에 측정된 문제에만 손을 댑니다. 이 순서가 더 자연스럽고 실무와도 맞아요. 측정 없이 최적화하는 건 어느 시대에나 잘못된 방법이었으니까요.

그리고 컴파일러가 아예 관여하지 않는 영역이 훨씬 넓다는 것도 기억해 두세요. 필요 없는 데이터를 받아오는 것, 이미지가 너무 큰 것, 번들에 안 쓰는 코드가 들어가는 것은 컴파일러 소관이 아닙니다. 실무에서 체감 성능을 가르는 건 대체로 이쪽이에요.

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

"자동화 도구는 문법을 대신해 주지만 판단을 대신해 주지는 않습니다. 그래서 저는 최적화 표시를 다는 문법보다 측정하는 방법과 증상을 아는 걸 우선합니다. React Compiler 도 코드가 규칙을 벗어나면 안전을 위해 그 부분을 건너뛰기 때문에, 컴파일러와 린트를 함께 켜서 최적화되지 않은 곳을 드러나게 만드는 게 실제 방어선입니다. 그리고 체감 성능을 가르는 건 대체로 컴파일러가 관여하지 않는 영역, 즉 불필요한 요청과 이미지와 번들 크기입니다."

전체 목록 리액트