내 생각엔 리액트 처음 공부할 때 필독해야하는 글이다.
이걸 안보고 지나갔다니.
내 맘대로 의역 및 요약해서 정리한다.
리액트처럼 생각하기 - 리액트에 대한 오해
리액트는 클래스 컴포넌트에서 시작했다.
함수(형이 아님) 컴포넌트가 나왔다 하더라도, 리액트는 객체지향 철학에 깊은 뿌리를 두고 있다.
상태를 캡슐화하고, 외부로 뷰라는 인터페이스만 제공한다.
객체지향 설계에서 가장 중요한 것은 역할, 협력, 책임이다.
적절한 객체에 적절한 책임을 할당하는 것이 객체지향 설계의 첫번째 덕목이며, 이는 "관심사의 분리"라고도 불린다.
리액트가 클래스보다 좀 더 어려운 점은 뷰라는 관심사가 설계에 포함되어야 한다는 점이다.
이는 css기술과 엮여있고, 컴포넌트 best practice에 대한 논란이 있다.
(요즘은 styled-component + tailwindCSS로 정리되는 분위기인듯 함.)
여튼 리액트 컴포넌트의 책임은 html + css + js가 합쳐질 때 완성된다.
잘나가는 테크기업들은 리액트를 이용해 완결성 있는 컴포넌트들을 내부적으로 구축해 사용한다.
Ant Design - The world's second most popular React UI framework
가짜 데이터에서 시작하기
아래와 같은 제품 검색 테이블 UI를 만들어보자.
JSON API의 리턴값은 아래와 같다.
[
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
1. UI를 컴포넌트 계층으로 분리
마크업을 좀 해봤다면 알겠지만 ui 설계 기본은 컨테이너 div로 큼지막하게 나누는 것이다.
이는 대부분 레이아웃을 첫번째 고려대상으로 생각하고 나누게 된다.
따라서 포토샵 레이어를 기준으로 나누어지는게 대부분이다.
리액트 관점에서는?
리액트는 클래스 기준으로 사고한다. 즉 적절한 책임(기능)을 부여하되, 단일 책임 원칙을 지켜야 한다는 것이다.
단일책임원칙에 대한 오해는 단 한가지 기능만을 해야 한다고 생각하는 것이다.
단일책임원칙은 변경의 원인이 하나여야 한다는게 가장 중요한 포인트다. 변경의 원인은 대부분 클래스의 데이터다.
즉 클래스는 유의미한 기능(책임)과, 해당 기능을 제공하기 위한 완결성 있는 데이터를 캡슐화해야 한다는 것이다.
(데이터의 초기화가 라이프 사이클에 의존하거나, null 필드가 있는 클래스는 잘못된 클래스다.)
리액트는 가장 간단하게 가장 좋은 설계를 가져갈 수 있다. 일단 presentation 컴포넌트는 가장 잘게 쪼갠다!
가장 잘게 쪼개면 null 필드도 없고, 조건부 초기화도 없기 때문이다.
이 잘게 쪼갠 presentation 컴포넌트를 선언적으로 조합하면 된다.
그런데 가장 잘게 쪼개기 위해선 마크업을 알아야 한다.
이는 런던 스타일(mockist) TDD와 궤를 같이 하는 감이 있다.
여튼 잡설이 길었지만 정리하자면
리액트 컴포넌트는 객체지향 설계 방법론에 따라 설계해야 하는데,
UI랑 데이터 모델링은 같이 가는 경향이 있다. (동일한 정보 아키텍처를 따른다 한다.)
그래서 ui를 데이터 모델로 쪼갤 수 있다.
공식 문서에서는 아래와 같이 분류한다.
- FilterableProductTable (orange): 컴포넌트 전체
- SearchBar (blue): 사용자 입력(검색어와 필터 여부)을 받는 책임
- ProductTable (green): 데이터 필터 및 row들 디스플레이 책임
- ProductCategoryRow (turquoise): 각 카테고리의 헤더를 보여주는 책임
- ProductRow (red): 각 제품의 열을 보여주는 책임.
쪼개보니까, 리액트 컴포넌트의 책임은 뷰 관점에서 할당되는게 명확하다.
약간 이질적인게 3번인데 데이터를 그리는 책임이 해당 테이블에 있기 때문이다.
식별된 구조는 아래와 같으니, 구현한다!
- FilterableProductTable
- SearchBar
- ProductTable
- ProductCategoryRow
- ProductRow
step 2 : 상호작용 제외 버전
정적 버전 구축은 인터랙션을 생각할 필요가 없다.
인터랙션은 코딩보다 생각을 많이 필요로 한다.
정적 버전 구축은 생각보다 코딩을 많이 필요로 한다.
해당 개발 프로세스는 분리해서 생각하는 것이 좋다 가이드한다.
아래 코드는 html 문서로 열어볼 수 있다.
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="https://unpkg.com/react@17.0.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.0/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.12.4/babel.js"></script>
<script type="text/babel">
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..."/>
<p>
<input type="checkbox"/>
{' '}
Only show products in stock
</p>
</form>
);
}
function FilterableProductTable({products}) {
return (
<div>
<SearchBar/>
<ProductTable products={products}/>
</div>
);
}
const PRODUCTS = [
{category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
{category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
{category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
{category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
{category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
{category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];
ReactDOM.render(
<FilterableProductTable products={PRODUCTS}/>,
document.getElementById('container')
);
</script>
</body>
</html>
To build a static version of your app that renders your data model, you’ll want to build components that reuse other components and pass data using props. props are a way of passing data from parent to child.
위 구현에 상태가 없다는 것에 의아할 수 있다.
함수형 프로그래밍을 공부했다면 순수 함수에 대해 들어봤을 것이다.
상태는 곧 사이드이펙트다.
상태가 없을수록 컴포넌트는 투명하다.
또한 상태 관리 로직에서 자유롭다.
내부 필드가 많은 괴물 클래스를 생각해보자.
상태 개념에 익숙하다면 이 정적 버전을 빌드하는 데 상태를 전혀 사용하지 마십시오.
상태는 상호 작용, 즉 시간이 지남에 따라 변경되는 데이터에만 예약되어 있습니다. 이것은 앱의 정적 버전이므로 필요하지 않습니다.
더 간단한 예에서는 일반적으로 하향식으로 이동하는 것이 더 쉽고 대규모 프로젝트에서는 상향식으로 이동하여 빌드할 때 테스트를 작성하는 것이 더 쉽습니다.
해당 작업의 결과로, 데이터 모델을 렌더링하는 재사용 가능한 컴포넌트를 얻었다.
데이터 모델이 바뀌면, ReactDOM.render() 메소드가 호출된다.
그러면 UI가 업데이트 된다.
리액트의 데이터는 위에서 아래로(단방향 바인딩) 흐른다.
모든것을 모듈화하고 빠르게 동작한다.
Step 3 : 최소 UI 상태 식별
상태는 없을수록 좋다. 계산 가능한 정보면, 계산해서 사용하고, 상태에 유지하지 마라(배열길이)
우리 애플리케이션에 있는 모든 정보들을 살펴보자
- The original list of products
- The search text the user has entered
- The value of the checkbox
- The filtered list of products
- props를 통해 부모로부터 전달됩니까? 그렇다면 아마도 상태가 아닐 것입니다.
- 시간이 지나도 변함없나요? 그렇다면 아마도 상태가 아닐 것입니다.
- 다른 state 또는 props를 통해 계산할 수 있습니까? 그렇다면 상태가 아닙니다.
결국 우리에게 필요한 state는 2개 뿐이었다. 전부 상호작용과 관련있다.
- text 입력
- 체크박스 입력
Step 4 : State의 위치를 결정
Remember: React is all about one-way data flow down the component hierarchy
이제 가장 어려운 결정을 내릴 시간이다.
클래스 설계할 때에도, 어떤 클래스에 어떤 데이터를 관리할 책임을 할당해야 하는가가 가장 어려운 문제다.
(클래스의 경우, 답은 전문가에게 물어보는 것이다.)
식별된 state들에 대해서 다음과 같은 작업을 수행한다.
- 해당 state에 의존하는 컴포넌트들을 찾는다.
- 그 컴포넌트들을 children으로 소유하는 컴포넌트를 찾는다.
- 그 컴포넌트 혹은 상위 컴포넌트에서 state를 소유해야 한다.
- 3에서 적절한 컴포넌트를 못찾았으면 하나 만든다.
해당 방법대로 찾으면 FilterableProductTable이 {filterText: '', inStockOnly: false} 상태를 갖게 된다.
해당 가이드대로라면 단방향 데이터 플로우의 관점에서는 적합한 설계이다.
자식 컴포넌트의 렌더링에 대한 책임을 부모가 갖고 있다 생각하면 이도 어찌되었던 맞는 말이다.
그런데 해당 방법대로 설계하면 필연적으로 복잡한 UI에서는 상위 컴포넌트로 상태가 모이게 된다.
반대로 이는 데이터 캡슐화 관점에서는 좋지 않다.
또한 보통 데이터 뿐만 아니라, 해당 데이터를 이용하는 기능 또한 위에서 주입해주기 마련이다.
그럼 상위 컴포넌트는 괴물이 된다.
또한 리액트 컴포넌트명은 view로써의 역할 또한 반영한다.
4에서 이상한 컴포넌트를 만들거나 하면 jsx만 보고 뷰를 예상하기 어렵게 된다.
이를 막기 위해선 아래와 같은 방법을 사용한다. [WIP!!!]
- contextAPI/redux/recoil을 통해 상태관리 책임을 분리한다.
- VAC 패턴을 통해 컴포넌트 로직과 비즈니스로직 및 상태관리 로직 분리
여튼 위와 같이 도출된 설계 결과는 다음과 같다.
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="https://unpkg.com/react@17.0.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.0/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.12.4/babel.js"></script>
<script type="text/babel">
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.indexOf(filterText) === -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"
placeholder="Search..."
value={filterText} />
<p>
<input
type="checkbox"
checked={inStockOnly} />
{' '}
Only show products in stock
</p>
</form>
);
}
function FilterableProductTable({products}) {
const [state,setState] = React.useState({
filterText: '',
inStockOnly: false
});
return (
<div>
<SearchBar
filterText={state.filterText}
inStockOnly={state.inStockOnly}
/>
<ProductTable
products={products}
filterText={state.filterText}
inStockOnly={state.inStockOnly}
/>
</div>
);
}
const PRODUCTS = [
{category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
{category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
{category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
{category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
{category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
{category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];
ReactDOM.render(
<FilterableProductTable products={PRODUCTS}/>,
document.getElementById('container')
);
</script>
</body>
</html>
Step 5: Add Inverse Data Flow
자식 컴포넌트에서 부모 컴포넌트의 상태를 변경할 수 있도록 하기 위해, 콜백 함수를 추가한다.
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="https://unpkg.com/react@17.0.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.0/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.12.4/babel.js"></script>
<script type="text/babel">
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.indexOf(filterText) === -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,onInStockChange}) {
return (
<form>
<input
type="text"
placeholder="Search..."
value={filterText}
onChange={onFilterTextChange}
/>
<p>
<input
type="checkbox"
checked={inStockOnly}
onChange={onInStockChange}
/>
{' '}
Only show products in stock
</p>
</form>
);
}
function FilterableProductTable({products}) {
const [state,setState] = React.useState({
filterText: '',
inStockOnly: false
});
const handleFilterTextChange= React.useCallback((e)=>setState({...state,filterText:e.target.value}),[]);
const handleInStockChange = React.useCallback((e)=>setState({...state,inStockOnly:!state.inStockOnly}),[state]);
return (
<div>
<SearchBar
filterText={state.filterText}
inStockOnly={state.inStockOnly}
onFilterTextChange={handleFilterTextChange}
onInStockChange={handleInStockChange}
/>
<ProductTable
products={products}
filterText={state.filterText}
inStockOnly={state.inStockOnly}
/>
</div>
);
}
const PRODUCTS = [
{category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
{category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
{category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
{category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
{category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
{category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];
ReactDOM.render(
<FilterableProductTable products={PRODUCTS}/>,
document.getElementById('container')
);
</script>
</body>
</html>
요약
설계 단계를 정적(props only) => 상태 식별 및 상태 관리 책임 할당 => 인터랙티브 기능 추가로 나눈다.
리액트 컴포넌트는 클래스다.
리액트의 컴포넌트는 view라는 책임을 동시에 갖는다.
계층구조를 갖고 있어 자식을 렌더링하는 책임 또한 갖는다.
그런데 자식에 기능과 데이터를 제공하는 책임을 꼭 부모 컴포넌트에서 갖고 있어야 하는지 생각해봐야 한다.
즉, 기본적인 리액트 컴포넌트만으로는 부족하다.
props를 우선해서 사용한다. 상태는 없을 수록 좋다.
컴포넌트는 작을수록 좋다.
'FrontEnd' 카테고리의 다른 글
Simple Data-Fetching with React Suspense (0) | 2021.12.24 |
---|---|
[짤막글][Concurrent Mode] 비동기 모드 사용 방법 (0) | 2021.12.23 |
React Suspense : 비동기 렌더링 내용은 아직 "미정" 상태에요 (0) | 2021.12.23 |
8. 커스텀 훅 테스트 (0) | 2021.12.23 |
Ad hoc polymophism(애드훅 다형성) with typescript (0) | 2021.12.10 |