들어가며
[react.dev] LEARN REACT > QUICK START > Thinking in React을 번역하며 정리한 내용입니다.
React는 디자인을 바라보는 방식과 앱을 만드는 방식에 변화를 줄 수 있습니다. React로 사용자 인터페이스를 만들 때는 먼저 인터페이스를 컴포넌트라는 조각으로 나눕니다. 그런 다음, 각 컴포넌트의 다양한 시각적 상태를 설명합니다.마지막으로, 데이터를 컴포넌트를 통해 흐르게 하여 컴포넌트를 서로 연결합니다. 이 튜토리얼은 React로 검색 가능한 제품 데이터 테이블을 만드는 과정입니다.
JSON data and mockup
JSON API에서 받은 데이터와 mockup은 준비되어 있습니다. 이제 이것들을 가지고 React를 통해 UI를 구현할 것입니다.
[
{ 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" }
]
Summary
UI를 컴포넌트 계층 구조로 나누기
- 무엇을 하나의 컴포넌트로 만들지 결정합니다.
- 앱 내에서 각 기능 단위 혹은 시각적 요소들을 어떻게 분리해서 컴포넌트로 쪼갤지 고민합니다.
- 예를 들어, 큰 레이아웃(헤더, 메인, 푸터) 안에 작은 컴포넌트(검색창, 버튼, 카드 등)가 들어가는 식으로 구성합니다.
정적 버전(static version)을 먼저 만들기
- 상태(state) 없이 화면만 구성해봅니다.
- 데이터를 상수(`props`) 형태로 넘기고, 컴포넌트가 이를 렌더링만 하도록 만듭니다.
- 즉, 화면에 보이는 부분만 먼저 구현하여 구조를 확실히 잡고, 이후에 동적으로 변화하는 부분(상태)을 추가합니다.
UI 상태의 최소이면서 완전한 표현(representation)을 찾기
- 앱에 필요한 데이터(상태)가 무엇인지, 그리고 정말 필요한지 꼭 필요한 상태만 식별합니다.
- 모든 데이터가 상태(state)가 되어야 하는 것은 아닙니다.
- 사용자 입력값, 서버에서 받은 데이터 등 UI가 변화하는 데 꼭 필요한 데이터만 상태로 관리합니다.
- 단순히 렌더링 용으로만 쓰이는 값이라면, 부모로부터 `props`를 받을 수 있습니다.
상태(state)를 어디에 둘지 결정
- 어떤 컴포넌트가 해당 상태를 소유해야 할지 결정합니다.
- 일반적으로, 상태를 변경하는 컴포넌트나 그 상태가 필요한 여러 자식 컴포넌트들의 공통 부모 컴포넌트에 상태를 둡니다.
- 이는 데이터가 한 방향(Top-Down)으로 내려가는 React의 특징에 잘 부합합니다.
역방향 데이터 흐름(상향식 데이터 흐름) 추가
- 자식 컴포넌트에서 발생한 이벤트(예: 클릭, 폼 입력 등)로 인해 상태가 변경되어야 하는 경우, 이 이벤트를 부모로 전달하여 부모가 상태를 업데이트하는 구조로 만듭니다.
- 부모 컴포넌트가 상태를 변경하면, 변경된 값을 다시 자식 컴포넌트로 `props`로 내려주어 UI를 갱신합니다.
1. UI를 컴포넌트 계층 구조로 나누기
UI mockup에서 각 컴포넌트와 서브컴포넌트를 박스로 구분하고 이름을 붙이는 것부터 시작합니다. 이는 배경에 따라 방식이 다를 수 있습니다:
- 프로그래밍 관점 : 새로운 함수나 객체를 생성할지 결정하는 것과 같은 기법을 사용합니다. 대표적인 기법으로는 단일 책임 원칙(Single Responsibility Principle)이 있습니다. 즉, 컴포넌트는 이상적으로 하나의 역할만 수행해야 합니다. 만약 컴포넌트가 커진다면, 더 작은 서브컴포넌트로 분해하는 것이 좋습니다.
- CSS 관점 : 클래스 선택자를 생성할 때처럼 생각합니다(다만 컴포넌트는 클래스보다 조금 더 포괄적입니다).
- 디자인 관점 : 디자인의 레이어를 구성하는 방식으로 접근합니다.
JSON 데이터가 잘 구조화되어 있다면, UI의 컴포넌트 구조와 자연스럽게 매핑되는 경우가 많습니다. 이는 UI와 데이터 모델이 동일한 정보 아키텍처(구조)를 가지기 때문입니다. UI를 컴포넌트로 분리하고, 각 컴포넌트를 데이터 모델의 한 부분에 대응시킵니다.
위 사진에는 다섯 개의 컴포넌트가 있습니다:
- `FilterableProductTable` (회색) : 전체 앱을 감싸는 컨테이너입니다.
- `SearchBar` (파란색) : 사용자 입력을 받습니다.
- `ProductTable` (연보라색) : 사용자 입력에 따라 목록을 필터링하고 표시합니다.
- `ProductCategoryRow` (초록색) : 각 카테고리의 헤더를 표시합니다.
- `ProductRow` (노란색) : 각 제품의 정보를 한 줄씩 표시합니다.
`ProductTable`을 살펴보면, 테이블 헤더("Name"과 "Price" 레이블 포함)는 별도의 컴포넌트로 분리되어 있지 않습니다. 이는 개인의 선호에 따라 다를 수 있으며, 어떤 방식이든 가능합니다. 이 예제에서는 테이블 헤더가 `ProductTable` 내부의 리스트에 포함되기 때문에 `ProductTable`의 일부로 처리됩니다. 하지만 테이블 헤더가 복잡해지고 정렬 기능과 같은 요소가 추가된다면, 이를 별도의 `ProductTableHeader` 컴포넌로 분리하는 것이 좋습니다.
목업에서 컴포넌트를 식별했다면, 이제 이를 계층 구조로 정리합니다. 목업에서 다른 컴포넌트 안에 나타나는 컴포넌트는 계층 구조에서 자식으로 배치해야 합니다.
FilterableProductTable
SearchBar
ProductTable
ProductCategoryRow
ProductRow
2. 정적 버전(static version)을 먼저 만들기
이제 컴포넌트 계층 구조를 정리했으니, 앱을 구현할 차례입니다. 가장 직관적인 접근 방식은 데이터 모델에서 UI를 렌더링하는 버전을 구축하는 것이며, 아직은 상호작용을 추가하지 않습니다. 정적인 버전을 먼저 만들고, 이후에 상호작용을 추가하는 것이 더 쉽습니다. 정적인 버전을 구축하는 것은 많은 타이핑이 필요하지만, 깊이 생각할 필요는 없습니다. 반면, 상호작용을 추가하는 것은 많은 고민이 필요하지만, 타이핑은 적게 필요합니다.
데이터 모델을 렌더링하는 앱의 정적인 버전을 만들려면, 다른 컴포넌트를 재사용하고 props를 사용해 데이터를 전달하는 컴포넌트를 구축해야 합니다. Props는 부모에서 자식으로 데이터를 전달하는 방법입니다. (state 개념에 익숙하더라도 이 정적인 버전에서는 state를 사용하지 마세요. state는 시간이 지나면서 변하는 데이터, 즉 상호작용을 위해 예약된 것입니다. 이 앱은 정적인 버전이므로 state는 필요하지 않습니다.)
컴포넌트를 만들 때는 계층 구조에서 상위 컴포넌트(`FilterableProductTable`)부터 시작해 "위에서 아래로(top down)" 작업하거나, 하위 컴포넌트(`ProductRow`)부터 시작해 "아래에서 위로(bottom up)" 작업할 수 있습니다. 간단한 예제에서는 보통 top-down 방식이 더 쉽고, 규모가 큰 프로젝트에서는 bottom-up 방식이 더 쉽습니다.
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} />;
}
컴포넌트를 구축하고 나면, 데이터 모델을 렌더링하는 재사용 가능한 컴포넌트 라이브러리가 생깁니다. 이 앱은 정적이므로, 컴포넌트는 JSX만 반환합니다. 계층 구조의 최상단 컴포넌트(`FilterableProductTable`)는 데이터 모델을 prop으로 받습니다. 이를 단방향 데이터 흐름(one-way data flow)이라고 하며, 데이터는 상위 컴포넌트에서 하위 컴포넌트로 흐릅니다.
3. UI 상태의 최소이면서 완전한 표현을 찾기
UI를 상호작용하게 만들려면 사용자가 기본 데이터 모델을 변경할 수 있도록 해야 합니다. 이를 위해 state (상태)를 사용합니다.
state는 앱이 기억해야 하는 최소한의 변화하는 데이터입니다. 상태를 구성하는 가장 중요한 원칙은 DRY (Don't Repeat Yourself), 즉 중복을 피하는 것입니다. 앱에 필요한 상태의 최소 표현을 찾고, 나머지는 필요할 때마다 계산합니다. 예를 들어, 쇼핑 목록 앱을 만든다고 가정할 때, 목록의 항목들은 배열로 상태에 저장할 수 있습니다. 이 목록의 항목 개수를 표시하고 싶다면, 개수를 별도로 상태에 저장하지 말고, 배열의 길이를 읽어 사용합니다.
이제 이 예제 애플리케이션에서 사용되는 데이터 조각들을 살펴보겠습니다:
- 원래의 제품 목록
- 사용자가 입력한 검색어
- 체크박스의 값
- 필터링된 제품 목록
이 중에서 상태(state)에 해당하는 것은 무엇일까요? 상태가 아닌 항목들을 식별해보겠습니다:
- 시간이 지나도 변하지 않는다면 상태가 아닙니다.
- 부모로부터 props로 전달된다면 상태가 아닙니다.
- 컴포넌트의 기존 상태나 props를 기반으로 계산할 수 있다면 상태가 아닙니다.
이제 각 항목을 다시 살펴보겠습니다:
- 원래의 제품 목록은 props로 전달되므로 상태가 아닙니다.
- 검색어는 시간이 지나면서 변하고, 다른 것으로부터 계산할 수 없기 때문에 상태입니다.
- 체크박스 값도 시간이 지나면서 변하고, 다른 것으로부터 계산할 수 없으므로 상태입니다.
- 필터링된 제품 목록은 원래의 제품 목록을 검색어와 체크박스 값으로 필터링하여 계산할 수 있으므로 상태가 아닙니다.
결과적으로, 상태는 검색어와 체크박스 값 두 가지뿐입니다.
4. 상태(state)를 어디에 둘지 결정
앱의 최소 상태 데이터를 식별한 후에는, 어떤 컴포넌트가 이 상태를 변경할 책임이 있는지를 결정해야 합니다. React는 단방향 데이터 흐름(one-way data flow)을 사용하여 데이터를 부모에서 자식 컴포넌트로 전달합니다. 어떤 컴포넌트가 상태를 소유해야 하는지 명확하지 않을 수 있지만, 다음 단계에 따라 해결할 수 있습니다
애플리케이션의 각 상태에 대해:
- 해당 상태를 기반으로 무언가를 렌더링하는 모든 컴포넌트를 식별합니다.
- 이 컴포넌트들의 가장 가까운 공통 부모 컴포넌트를 찾습니다(계층 구조에서 모든 컴포넌트 위에 있는 컴포넌트).
- 상태를 어디에 위치시킬지 결정합니다:
- 상태를 공통 부모 컴포넌트에 직접 추가하는 경우가 많습니다.
- 공통 부모보다 더 위의 컴포넌트에 상태를 배치할 수도 있습니다.
- 만약 상태를 소유할 적절한 컴포넌트를 찾지 못한다면, 상태만을 위한 새로운 컴포넌트를 생성하고, 공토 부모의 상위 계층에 배치합니다.
이전 단계에서 이 애플리케이션에서 두 가지 상태를 찾았습니다: 검색 입력 텍스트와 체크박스 값입니다. 이 두가지는 항상 함께 나타나므로, 동일한 위치에 두는 것이 합리적입니다.
이제 이를 위한 전략을 살펴보겠습니다:
- 상태를 사용하는 컴포넌트 식별:
- ProductTable : 제품 목록을 필터링할 때 상태(검색어, 체크박스 값)를 사용합니다.
- SearchBar : 상태(검색어, 체크박스 값)를 화면에 표시합니다.
- 공통 부모 찾기:
- 두 컴포넌트가 공유하는 첫 번째 부모 컴포넌트는 FilterableProductTable입니다.
- 상태 위치 결정:
- FilterableProductTable에서 상태(검색어와 체크박스 값)를 관리합니다.
따라서 상태 값은 `FilterableProductTable`에 위치하게 됩니다.
컴포넌트에 상태를 추가하려면 `useState()` Hook을 사용합니다. Hook은 React에서 "연결(hook)"하여 특정 기능을 사용할 수 있도록 하는 특별한 함수입니다. `FilterableProductTable` 상단에 두 개의 상태 변수를 추가하고 초기 상태를 지정합니다.
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>
이제 애플리케이션이 어떻게 동작할지 확인할 수 있습니다. 아래 샌드박스 코드에서 `useState('')`를 `useState('fruit')`로 수정하여 `filterText`의 초기 값을 변경합니다. 그러면 검색 입력 텍스트와 테이블이 모두 업데이트되는 것을 확인할 수 있습니다.
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} />;
}
하지만 위의 코드는 아직 동작하지 않습니다. 그 이유를 설명하는 콘솔 오류는 아래와 같습니다.
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
코드에서 `ProductTable`과 `SearchBar`는 `filterText` 및 `inStockOnly` props를 읽어 테이블, 입력 필드 그리고 체크박스를 렌더링합니다. 예를 들어, 아래는 `SearchBar`가 입력 필드 값을 채우는 방식입니다:
function SearchBar({ filterText, inStockOnly }) { // add
return (
<form>
<input
type="text"
value={filterText} // add
placeholder="Search..."/>
그러나 아직 사용자의 입력과 같은 동작에 응답하는 코드를 추가하지 않았습니다. 이 부분이 마지막 단계가 될 것입니다.
5. 역방향 데이터 흐름(상향식 데이터 흐름) 추가
현재 앱은 props와 state가 계층 구조를 따라 아래로 흐르며 올바르게 렌더링됩니다. 그러나 사용자 입력에 따라 상태를 변경하려면 데이터가 반대로 흐르도록 지원해야 합니다. 계층 구조 깊숙한 폼 컴포넌트들이 `FilterableProductTable`의 상태를 업데이트할 수 있어야 합니다.
React는 이러한 데이터 흐름을 명시적으로 처리하지만, 양방향 데이터 바인딩보다 조금 더 많은 타이핑이 필요합니다. 예제에서 입력을 시도하거나 체크박스를 클릭해도 React는 사용자의 입력을 무시합니다. 이것은 의도된 동작입니다. `input value={filterText} />`와 같이 작성했기 때문에, `FilterableProductTable`에서 전달된 `filterText` 상태와 항상 일치하도록 input의 value prop이 설정됩니다. 그러나 `filterText` 상태가 업데이트되지 않으므로, 입력 값이 변경되지 않습니다.
사용자가 폼 입력을 변경할 때마다 상태가 업데이트되어 이러한 변경 사항을 반영하도록 만들고자 합니다. 상태는 `FilterableProductTable`에서 관리하므로, `setFilterText` 및 `setInStockOnly`를 호출할 수 있는 것은 `FilterableProductTable`뿐입니다. `SearchBar`가 `FilterableProductTable`의 상태를 업데이트 할 수 있도록 하려면, 이 함수들을 SearchBar로 전달해야 합니다.
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState(''); // add
const [inStockOnly, setInStockOnly] = useState(false); // add
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText} // add
onInStockOnlyChange={setInStockOnly} /> // add
`SearchBar` 안에서 `onChange` 이벤트 핸들러를 추가하고, 이를 통해 부모 컴포넌트 상태를 설정합니다.
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange, // add
onInStockOnlyChange // add
}) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} // add
/>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)} // add
이제 어플리케이션이 의도한 대로 동작할 것입니다.
전체 코드
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} />;
}
'JAVASCRIPT' 카테고리의 다른 글
[React] React 컴포넌트란? 기본 개념편 (0) | 2025.01.10 |
---|---|
[React] Virtual DOM: 리액트의 핵심 기술 (0) | 2025.01.09 |
자바스크립트 문자열(String)의 모든 것 (1) | 2024.12.29 |
자바스크립트와 브라우저의 비동기 처리 구조: 이벤트 루프와 큐 (1) | 2024.12.28 |
배열 생성 심화: Array / Array.of / Array.from (0) | 2024.12.24 |