React는 여러분이 디자인을 바라보는 방식과 앱을 만드는 방식을 바꿔줄 수 있어요. React로 사용자 인터페이스를 만들 때는, 먼저 UI를 컴포넌트라고 불리는 조각들로 쪼개게 됩니다. 그런 다음, 각 컴포넌트의 다양한 시각적 상태들을 설명하게 돼요. 마지막으로, 컴포넌트들을 서로 연결해서 데이터가 그 사이를 흐르도록 만들어요. 이 튜토리얼에서는 React로 검색 가능한 상품 데이터 테이블을 만드는 사고 과정을 안내해 드릴게요.
이미 JSON API와 디자이너로부터 받은 목업이 있다고 상상해 보세요.
JSON API가 반환하는 데이터는 이렇게 생겼어요:
[
{ category: "Fruits", price: "$1", stocked: true, name: "Apple" },
{ category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
{ category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
{ category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
{ category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
{ category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]
목업은 이렇게 생겼어요:
<img src="https://react.dev/images/docs/s_thinking-in-react_ui.png" width="300" style={{margin: '0 auto'}} />
React에서 UI를 구현하려면 보통 같은 다섯 단계를 따르게 됩니다.
목업에 있는 모든 컴포넌트와 하위 컴포넌트 주위에 박스를 그리고 이름을 붙여보는 것부터 시작하세요. 디자이너와 함께 작업한다면, 그들이 이미 디자인 도구에서 이 컴포넌트들에 이름을 붙여놨을 수도 있어요. 한번 물어보세요!
여러분의 배경에 따라 디자인을 컴포넌트로 나누는 것을 여러 가지 방식으로 생각해볼 수 있어요:
JSON이 잘 구조화되어 있다면, UI의 컴포넌트 구조에 자연스럽게 매핑되는 것을 종종 발견하게 될 거예요. UI와 데이터 모델은 종종 같은 정보 아키텍처를 가지고 있기 때문이에요 — 즉, 같은 모양을 가지고 있다는 거죠. UI를 컴포넌트로 분리하되, 각 컴포넌트가 데이터 모델의 한 조각과 일치하도록 하세요.
이 화면에는 다섯 개의 컴포넌트가 있어요:
<img src="https://react.dev/images/docs/s_thinking-in-react_ui_outline.png" width="500" style={{margin: '0 auto'}} />
FilterableProductTable (회색)은 전체 앱을 담고 있어요.SearchBar (파란색)은 사용자 입력을 받아요.ProductTable (라벤더색)은 사용자 입력에 따라 목록을 표시하고 필터링해요.ProductCategoryRow (초록색)은 각 카테고리의 제목을 표시해요.ProductRow (노란색)은 각 상품에 대한 행을 표시해요.ProductTable(라벤더색)을 보면, 테이블 헤더("Name"과 "Price" 라벨을 포함하는)가 자체 컴포넌트가 아닌 것을 알 수 있어요. 이건 선호도의 문제이고, 어느 쪽으로 해도 괜찮아요. 이 예제에서는 ProductTable의 목록 안에 나타나기 때문에 ProductTable의 일부로 두었어요. 하지만 이 헤더가 복잡해진다면 (예를 들어, 정렬 기능을 추가한다면), 별도의 ProductTableHeader 컴포넌트로 분리하는 것도 좋겠죠.
이제 목업에서 컴포넌트들을 식별했으니, 이들을 계층 구조로 정리해 보세요. 목업에서 다른 컴포넌트 안에 나타나는 컴포넌트는 계층 구조에서 자식으로 나타나야 해요:
FilterableProductTableSearchBarProductTableProductCategoryRowProductRow이제 컴포넌트 계층 구조가 있으니, 앱을 구현할 차례예요. 가장 간단한 접근 방식은 아직 어떤 상호작용도 추가하지 않고 데이터 모델로부터 UI를 렌더링하는 버전을 만드는 거예요! 정적인 버전을 먼저 만들고 나중에 상호작용을 추가하는 것이 종종 더 쉬워요. 정적인 버전을 만드는 것은 많은 타이핑이 필요하고 생각은 별로 필요 없지만, 상호작용을 추가하는 것은 많은 생각이 필요하고 타이핑은 별로 필요 없거든요.
데이터 모델을 렌더링하는 앱의 정적인 버전을 만들려면, 다른 컴포넌트를 재사용하고 props를 사용해서 데이터를 전달하는 컴포넌트를 만들어야 해요. Props는 부모에서 자식으로 데이터를 전달하는 방법이에요. (state의 개념에 익숙하다면, 이 정적인 버전을 만들 때는 state를 전혀 사용하지 마세요. State는 오직 상호작용을 위해서만 예약되어 있어요, 즉 시간이 지남에 따라 변하는 데이터를 위한 거예요. 이건 정적인 버전의 앱이니까, state가 필요 없어요.)
계층 구조에서 더 위에 있는 컴포넌트부터 만드는 "하향식(top down)" 방식으로 만들 수도 있고 (예: FilterableProductTable), 더 아래에 있는 컴포넌트부터 작업하는 "상향식(bottom up)" 방식으로 만들 수도 있어요 (예: ProductRow). 더 간단한 예제에서는 보통 하향식이 더 쉽고, 더 큰 프로젝트에서는 상향식이 더 쉬워요.
// App.js
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar() {
return (
<form>
<input type="text" placeholder="Search..." />
<label>
<input type="checkbox" />
{' '}
Only show products in stock
</label>
</form>
);
}
function FilterableProductTable({ products }) {
return (
<div>
<SearchBar />
<ProductTable products={products} />
</div>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
body {
padding: 5px
}
label {
display: block;
margin-top: 5px;
margin-bottom: 5px;
}
th {
padding-top: 10px;
}
td {
padding: 2px;
padding-right: 40px;
}
(이 코드가 어렵게 보인다면, 빠른 시작을 먼저 살펴보세요!)
컴포넌트들을 만들고 나면, 데이터 모델을 렌더링하는 재사용 가능한 컴포넌트 라이브러리를 갖게 될 거예요. 이건 정적인 앱이기 때문에, 컴포넌트들은 오직 JSX만 반환할 거예요. 계층 구조의 최상단에 있는 컴포넌트(FilterableProductTable)가 데이터 모델을 prop으로 받게 됩니다. 이것을 단방향 데이터 흐름(one-way data flow)이라고 불러요. 데이터가 최상위 컴포넌트에서 트리의 하단에 있는 컴포넌트들로 흘러내려가기 때문이에요.
이 시점에서는 어떤 state 값도 사용하면 안 돼요. 그건 다음 단계에서 할 거예요!
UI를 인터랙티브하게 만들려면, 사용자가 기반이 되는 데이터 모델을 변경할 수 있게 해야 해요. 이를 위해 state를 사용하게 됩니다.
State를 앱이 기억해야 하는 최소한의 변경 가능한 데이터 집합이라고 생각하세요. State를 구조화하는 가장 중요한 원칙은 DRY (Don't Repeat Yourself, 반복하지 마세요)를 유지하는 거예요. 애플리케이션이 필요로 하는 state의 절대적으로 최소한의 표현을 파악하고, 나머지는 모두 필요할 때 계산하세요. 예를 들어, 쇼핑 목록을 만든다면, 아이템들을 배열로 state에 저장할 수 있어요. 목록에 있는 아이템의 개수도 표시하고 싶다면, 아이템 개수를 또 다른 state 값으로 저장하지 마세요 — 대신, 배열의 길이를 읽으면 돼요.
이제 이 예제 애플리케이션의 모든 데이터 조각들을 생각해 보세요:
이 중에서 어떤 것이 state일까요? state가 아닌 것들을 식별해 보세요:
남은 것이 아마도 state일 거예요.
하나씩 다시 살펴볼까요:
이건 검색 텍스트와 체크박스의 값만이 state라는 의미예요! 잘 했어요!
React에는 두 가지 유형의 "모델" 데이터가 있어요: props와 state. 이 둘은 매우 달라요:
Form이 Button에 color prop을 전달할 수 있어요.Button이 isHovered state를 추적할 수 있어요.Props와 state는 다르지만, 함께 작동해요. 부모 컴포넌트는 종종 일부 정보를 state로 유지하고 (변경할 수 있도록), 자식 컴포넌트에 그들의 props로 전달해요. 처음 읽을 때 차이점이 아직 모호하게 느껴져도 괜찮아요. 정말 확실히 이해하려면 약간의 연습이 필요하거든요!
앱의 최소한의 state 데이터를 식별한 후에는, 이 state를 변경하는 책임이 있는 컴포넌트, 또는 state를 소유하는 컴포넌트가 어떤 것인지 파악해야 해요. 기억하세요: React는 단방향 데이터 흐름을 사용해서, 부모에서 자식 컴포넌트로 컴포넌트 계층 구조를 따라 데이터를 내려보내요. 어떤 컴포넌트가 어떤 state를 소유해야 하는지 바로 명확하지 않을 수도 있어요. 이 개념이 처음이라면 어려울 수 있지만, 다음 단계들을 따라가면 알아낼 수 있어요!
애플리케이션의 각 state에 대해:
이전 단계에서, 이 애플리케이션에서 두 가지 state를 찾았어요: 검색 입력 텍스트와 체크박스의 값. 이 예제에서 그들은 항상 함께 나타나니까, 같은 곳에 두는 것이 합리적이에요.
그럼 우리 전략대로 해볼까요:
ProductTable은 그 state(검색 텍스트와 체크박스 값)를 기반으로 상품 목록을 필터링해야 해요.SearchBar는 그 state(검색 텍스트와 체크박스 값)를 표시해야 해요.FilterableProductTable이에요.FilterableProductTable에 유지할 거예요.그래서 state 값들은 FilterableProductTable에 있게 될 거예요.
useState() Hook으로 컴포넌트에 state를 추가하세요. Hook은 React에 "훅"할 수 있게 해주는 특별한 함수예요. FilterableProductTable 상단에 두 개의 state 변수를 추가하고 초기 state를 지정하세요:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
그런 다음, filterText와 inStockOnly를 ProductTable과 SearchBar에 props로 전달하세요:
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
애플리케이션이 어떻게 동작할지 보이기 시작할 거예요. 아래 샌드박스 코드에서 filterText 초기값을 useState('')에서 useState('fruit')으로 수정해 보세요. 검색 입력 텍스트와 테이블이 모두 업데이트되는 것을 볼 수 있을 거예요:
// App.js
import { useState } from 'react';
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
);
}
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar({ filterText, inStockOnly }) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."/>
<label>
<input
type="checkbox"
checked={inStockOnly} />
{' '}
Only show products in stock
</label>
</form>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
body {
padding: 5px
}
label {
display: block;
margin-top: 5px;
margin-bottom: 5px;
}
th {
padding-top: 5px;
}
td {
padding: 2px;
}
아직 폼 편집이 작동하지 않는다는 걸 눈치챘을 거예요. 위의 샌드박스에 왜 그런지 설명하는 콘솔 에러가 있어요:
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
(폼 필드에 onChange 핸들러 없이 value prop을 제공했습니다. 이것은 읽기 전용 필드를 렌더링합니다.)
위의 샌드박스에서, ProductTable과 SearchBar는 filterText와 inStockOnly props를 읽어서 테이블, 입력, 그리고 체크박스를 렌더링해요. 예를 들어, SearchBar가 입력 값을 채우는 방법은 이래요:
function SearchBar({ filterText, inStockOnly }) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."/>
하지만 아직 타이핑 같은 사용자 액션에 응답하는 코드를 추가하지 않았어요. 이게 마지막 단계가 될 거예요.
현재 앱은 props와 state가 계층 구조를 따라 아래로 흐르면서 올바르게 렌더링돼요. 하지만 사용자 입력에 따라 state를 변경하려면, 반대 방향으로 흐르는 데이터를 지원해야 해요: 계층 구조 깊숙이 있는 폼 컴포넌트들이 FilterableProductTable의 state를 업데이트해야 해요.
React는 이 데이터 흐름을 명시적으로 만들지만, 양방향 데이터 바인딩보다 조금 더 많은 타이핑이 필요해요. 위 예제에서 타이핑하거나 체크박스를 선택해 보면, React가 입력을 무시하는 것을 볼 수 있을 거예요. 이건 의도된 거예요. <input value={filterText} />라고 작성함으로써, input의 value prop이 항상 FilterableProductTable에서 전달된 filterText state와 같도록 설정한 거예요. filterText state가 설정되지 않으니까, 입력도 절대 변하지 않아요.
여러분은 사용자가 폼 입력을 변경할 때마다, state가 그 변경사항을 반영하도록 업데이트되게 만들고 싶어요. State는 FilterableProductTable이 소유하고 있으니까, 오직 그것만이 setFilterText와 setInStockOnly를 호출할 수 있어요. SearchBar가 FilterableProductTable의 state를 업데이트하게 하려면, 이 함수들을 SearchBar에 내려줘야 해요:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
SearchBar 내부에서, onChange 이벤트 핸들러를 추가하고 그것들로부터 부모 state를 설정하세요:
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)}
이제 애플리케이션이 완전히 작동해요!
// App.js
import { useState } from 'react';
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
);
}
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText} placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} />
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)} />
{' '}
Only show products in stock
</label>
</form>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
body {
padding: 5px
}
label {
display: block;
margin-top: 5px;
margin-bottom: 5px;
}
th {
padding: 4px;
}
td {
padding: 2px;
}
상호작용 추가하기 섹션에서 이벤트 처리와 state 업데이트에 대해 모두 배울 수 있어요.
이것은 React로 컴포넌트와 애플리케이션을 만드는 것에 대해 어떻게 생각해야 하는지에 대한 아주 간략한 소개였어요. 지금 바로 React 프로젝트를 시작하거나 이 튜토리얼에서 사용된 모든 문법을 더 깊이 살펴볼 수 있어요.