이번주부터 프론트엔드 첫번째 주가 시작되었다.
지금까지 JS 공부는 기초를 닦는 과정이었다면, 지금부터는 닦은 기초를 가지고 활용하는 시간이다. 이전에 회사에서 일할 때 Vue.js 써서 개발을 해보긴 했지만, React는 거의 처음이라 다른 동기들과 비슷한 상황이다.
어쨌든 오늘도 한번 달려보자!
이번주 과제
✅ 과제 주제
이번주 과제는 React 를 활용하여 페이지를 구현하는 것이다. 앞으로 4주 동안은 계속해서 react 를 활용하여 페이지를 구성할 것 같다.
다만, 소스 자체는 공개하면 안되기 때문에 어떤 개념이 들어갔는지, 어떤 성능 개선이나 트러블슈팅은 어떻게 했는지를 같이 살펴보면 좋을 것 같다.
✅ 사전에 알아야 하는 개념
들어가기에 앞서 Thinking in React에 대해서 빠르게 짚고 넘어가면 좋을 것 같다.
Thinking in React?
‘Thinking in React’라는 건, 아직 react에 익숙하지 않은 우리가 React에 익숙해지기 위해 공식 커뮤니티에서 제공하는 일종의 가이드다.
앞으로 과제를 할때나 실제로 현업에서 프로젝트를 시작할 때도, 이런식으로 문제를 인식하고, 프로젝트를 진행한다면 좀 더 매끄럽게 진행을 할 수 있을 것 같다.
그럼 한번 단계별로 어떤 과정이 진행되는지 살펴 보자!
Step 1: UI를 컴포넌트 계층으로 쪼개기
시작에 앞서 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" }
]
- 모의 시안
위의 내용을 토대로 UI를 컴포넌트 계층으로 분리해야 한다. 보통 리엑트에서는 컴포넌트는 새로운 함수나 객체를 말하며, 한번에 한가지 일만 한다.
위에 말한 ‘한번에 한가지 일만 한다.’는 단일책임원칙(SRP)을 이야기하는 것이며, 이를 지키는 것이 좋은 설계이다.
이 외에도 좋은 객체지향설계를 위한 원칙들이 4개나 더 있다. 관심이 있다면 다른 원칙들도 어떤것들이 있는지 살펴보면 도움이 될 것이다.
JSON 데이터가 구조화가 잘 되어있다면, 작업이 더 수월할 것이다.
여기 작업 결과를 같이 살펴보자.
- FilterableProductTable: 예시 전체를 포괄한다.
- SearchBar: 사용자의 입력을 받는다.
- ProductTable: 데이터 리스트를 보여주고, 사용자의 입력을 기반으로 필터링한다.
- ProductCategoryRow: 각 카테고리의 헤더를 보여준다.
- ProductRow: 각각의 제품에 해당하는 행을 보여준다.
사람마다 결과가 다를 수 있기 때문에, 꼭 이렇게 안 쪼개지더라도 정답이 아니라고 생각하지는 말자.
위의 결과를 좀 더 계층적으로 보이면 다음과 같이 보일 수 있다.
- FilterableProductTable
- SearchBar
- ProductTable
- ProductCategoryRow
- ProductRow
Step 2: React로 정적인 버전 구현하기
이제 컴포넌트 계층이 만들어졌으니, 이를 실제로 구현을 해 볼 시간이다.
지금 단계에서 해볼 것은 상호작용 기능(State
)은 아직 추가하지 않고, 데이터 모델로부터 UI를 렌더링하는 버전을 만드는 것이다.
이때 데이터는 props
를 통해서 부모에서 자식으로 넘겨주는 것이 중요하다.
그럼 코드를 한번 작성해보자.
- 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} />;
}
소스로 보면 구조가 복잡해 보일 수 있는데, 렌더트리를 통해 구조를 파악하면 금방 파악할 수 있다!
🍯 렌더 트리란?
React 앱을 렌더링할 때, 이 관계를 렌더 트리라고 알려진 트리로 모델링할 수 있다.
트리는 노드로 구성되어 있으며, 각 노드는 컴포넌트를 나타냅니다.
FilterableProductTable , SearchBar , ProductTable , ProductCategoryRow , ProductRow 등은 모두 트리의 노드입니다.
React 렌더 트리에서 루트 노드는 앱의 Root 컴포넌트입니다.
이 경우 루트 컴포넌트는 App이며 React가 렌더링하는 첫 번째 컴포넌트입니다.
트리의 각 화살표는 부모 컴포넌트에서 자식 컴포넌트를 가리킵니다.
Step 3: 최소한의 데이터만 이용해서 완벽하게 UI State 표현하기
UI를 상호작용(interactive)하게 만들려면 사용자가 기반 데이터 모델을 변경할 수 있게 해야 한다.
React는 state를 통해 기반 데이터 모델을 변경할 수 있게 할 수 있다.
state는 앱이 기억해야 하는, 변경할 수 있는 데이터의 최소 집합이라고 생각해보자. 즉 최소한으로 state를 구성해야 한다!!
위의 예시는 다음과 같은 데이터를 가지고 있다.
- 제품의 원본 목록
- 사용자가 입력한 검색어
- 체크박스의 값
- 필터링된 제품 목록
이 중에 어떤 것들이 state가 될 수 있을까?
선정 과정은 아래와 같이 질문을 통해 필터링을 해 볼 수 있다.
- 시간이 지나도 변하지 않나요? 그러면 확실히 state가 아니다.
- 부모로부터 props를 통해 전달됩니까? 그러면 확실히 state가 아니다.
- 컴포넌트 안의 다른 state나 props를 가지고 계산 가능한가요? 그렇다면 절대로 state가 아니다.
이 조건들을 토대로, 위의 4가지 데이터를 검토해보자.
- 제품의 원본 목록(
PRODUCTS
)은 props로 전달됨 - ⇒ state X
- 사용자가 입력한 검색어는 시간이 지남에 따라 변하고, 다른 요소로부터 계산될 수 없음
- ⇒ state O
- 체크박스의 값은 시간에 따라 바뀌고 다른 요소로부터 계산될 수 없음
- ⇒ state O
- 필터링된 제품 목록은 원본 제품 목록을 받아서 검색어와 체크박스의 값에 따라 계산할 수 있음
- ⇒ state X
따라서, 2.검색어와 3.체크박스의 값만이 state로 선정된다!!
Step 4: State가 어디에 있어야 할 지 정하기
React는 항상 컴포넌트 계층구조를 따라 부모에서 자식으로 데이터를 전달하는 단방향 데이터 흐름을 사용한다.
위의 말을 참고하여 state 위치를 선정을 다음과 같은 과정으로 해볼 수 있다.
- 해당 state를 기반으로 렌더링하는 모든 컴포넌트를 찾아보자.
- 그들의 가장 가까운 공통되는 부모 컴포넌트를 찾아보자. - 계층에서 모두를 포괄하는 상위 컴포넌트
- state가 어디에 위치 돼야 하는지 결정해보자.
- 보통은 공통 부모에 state를 그냥 두면 된다.
- 혹은, 공통 부모 상위의 컴포넌트에 둬도 된다.
- state를 소유할 적절한 컴포넌트를 찾지 못하였다면, state를 소유하는 컴포넌트를 하나 만들어서 상위 계층에 추가하자.
위의 전략을 현재 상황에 적용해보자.
- state를 쓰는 컴포넌트를 찾아보자:
ProductTable
은 state에 기반한 상품 리스트를 필터링해야 한다. (검색어와 체크 박스의 값)SearchBar
는 state를 표시해 주어야 한다. (검색어와 체크 박스의 값)
- 공통 부모를 찾아보자: 둘 모두가 공유하는 첫 번째 부모는
FilterableProductTable
이다. - 어디에 state가 존재해야 할지 정해보자: 우리는
FilterableProductTable
에 검색어와 체크 박스 값을 state로 두면 적절할 것 같다.
아까 위에서 보였던 렌더트리를 통해서도 명확히 파악할 수 있다.
useState()
Hook을 이용해서 state를 컴포넌트에 추가하자.
Hooks는 React 기능에 “연결할 수(hook into)” 있게 해주는 특별한 함수이다.
FilterableProductTable
의 상단에 두 개의 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>
이를 통해 부모 노드에서 전달된 props로 자식 노드의 상태를 제어할 수 있게 되었다!!
그러나, 이를 적용해도 아직 다음과 같은 에러가 발생할 것이다.
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
이를 해결하기 위해 마지막 단계가 필요하다.
Step 5: 역 데이터 흐름 추가하기
지금까지 우리는 계층 구조 아래로 흐르는 props와 state의 함수로써 앱을 만들었다.
이제 사용자 입력에 따라 state를 변경하려면 반대 방향의 데이터 흐름을 만들어야 한다.
이를 위해서는 계층 구조의 하단에 있는 컴포넌트(SearchBar
, ProductTable
)에서 FilterableProductTable
의 state를 업데이트할 수 있어야 합니다.
우리는 사용자가 input을 변경할 때마다, 사용자의 입력을 반영할 수 있도록 state를 업데이트하기를 원한다.
state는 FilterableProductTable
이 가지고 있고, state 변경을 위해서는 setFilterText
와 setInStockOnly
를 호출을 하면 된다.
SearchBar
가 FilterableProductTable
의 state를 업데이트할 수 있도록 하려면, 이 함수들을 SearchBar
로 전달해야 한다.
다음과 같이 구현하면 하위 컴포넌트가, 부모 컴포넌트에 state를 업데이트 할 수 있게 된다!
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)}
✅ 도전이 되었던 부분
위에 했던 작업을 다른 말로 상태 끌어올리기라고도 하는데, 처음에 과제를 시작하기 전에 ‘Thinking in React?’ 이걸 자세히 살펴보지 않아서, state를 자식 노드에 두게 되었다.
예를 들어 이런식으로 처음에는 작업을 하였다.
export function Child1() {
const [content, setContent] = useState([]);
// useEffect를 facth에 쓸 수 있지만, 권장하지는 않는다!
useEffect(()=>{
const result = await fatchData1();
setContent(result);
}, []);
...
return ( <h1 className="center-text">Content1 !!!
<div> {content.name} </div>
<div> {content.author} </div>
...
</h1>;
)
}
export function Child2() {
const [content, setContent] = useState([]);
// useEffect를 facth에 쓸 수 있지만, 권장하지는 않는다!
useEffect(()=>{
const result = await fatchData2();
setContent(result);
}, []);
...
return ( <h1 className="center-text">Content2 !!!
<div> {content.name} </div>
<div> {content.author} </div>
...
</h1>;
)
}
export default function Parent() {
const [isShow, setIsShow] = useState(false);
...
return (
<section>
<h1>Amazing scientists</h1>
<div>
{<isShow && Child1 />}
</div>
<div>
{<!isShow && Child2 />}
</div>
</section>
);
}
이렇게 했을 때 문제점은 isShow
의 값이 바뀔때마다, 렌더링이 되는 컴포넌트가 달라지는데, 그럼 이전에 가지고 있던 content 데이터를 기억을 하지 못하고 계속 다시 fecthData
를 통해서 가져온다는 것이다!
요구사항에 이전 데이터를 계속 기억하게 만들어 달라는 것이 있었다.
그래서 위와 같은 상황에서 각 Child
컴포넌트에 있는 state 를 Parent
로 끌어올린다면 위와 같은 문제를 해결할 수 있다!!
export function Child1({content}) {
...
return ( <h1 className="center-text">Content1 !!!
<div> {content.name} </div>
<div> {content.author} </div>
...
</h1>;
)
}
export function Child2({content}) {
...
return ( <h1 className="center-text">Content2 !!!
<div> {content.name} </div>
<div> {content.author} </div>
...
</h1>;
)
}
export default function Parent() {
const [isShow, setIsShow] = useState(false);
const [content1, setContent1] = useState([]);
const [content2, setContent2] = useState([]);
useEffect(()=>{
const result1 = await fatchData1();
const result2 = await fatchData2();
setContent1(result1);
setContent2(result2);
}, []);
...
return (
<section>
<h1>Amazing scientists</h1>
<div>
{isShow && <Child1 content={content1} />}
</div>
<div>
{!isShow && <Child2 content={content2} />}
</div>
</section>
);
}
이런식으로 고치면 리렌더링이 되더라도 이전 데이터를 가지고 있을 수 있다!
또 다른 것은 성능 개선을 할 수 있는 부분을 찾아서 성능 개선을 하는 것이다.
밑에 코드를 한번 보자.
async function getUserData(player) {
const detail = await getDetail(player);
const image = await getImage(player);
return {
detail,
image
};
}
함수 getUserData
가 호출이 되면, getDetail
과 getImage
를 통해 데이터를 가져오게 된다. 여기서 문제는 await
키워드를 만나면 코드가 blocking이 된다는 것이다.
이 부분을 만약 이렇게 고치면 await
한번에 모든 데이터를 가져와 리턴할 수 있다.
async function getUserData(player) {
const [detail, image] = await Promise.all([getDetail(player),
getImage(player)]);
return {
detail,
image
};
}
실제로 개발자모드를 통해 속도를 비교해보면, 거의 3배 가까이 차이가 나는걸 볼 수 있다.
- 적용 전
- 적용 후
fecth API 로 가져오는 종류가 많아지면 많아질수록, 성능 차이는 더욱 극대화 될 것이다.
멘토링 시간
✅ 멘토와 나눈 대화
딱 이곳에 온지 한달이 지났다… 시간이 참 빠른것 같다.(벌써 4분에 1이 지났다니?!?!)
이번주 체크인 시간에는 한달 동안 힘들었는게 없었는지, 스케줄 관리를 제대로 했는지 위주로 이야기를 나누었다.
아무래도 나는 현업에서 일을 하다가 왔다 보니 과제를 다른 사람들 보다는 일찍 끝내는 편이다.
그래서 과제를 끝내고 남은 시간을 어떻게 보내면 좋을지를 ken님과도 이야기를 해보는게 좋겠다는 결론이 났다!
앞으로도 지금과 같은 페이스로 끝까지 가보자!!!!!
✅ 멘토링이 학습에 준 영향
작업을 하던 중 조금 이상한 부분을 발견하였다.
export function Card({content}) {
function handleButtonClick(){
console.log("Button click!!");
return (event) => {
...
}
}
...
return ( <h1 className="center-text">Card
<button onClick={handleButtonClick()}>
{content.name}
</button>
</h1>;
)
}
만약 이런 코드가 있다면, 랜더링이 될때 마다 콘솔에 Button click!!
이 찍힐 것이다.
// 랜더링이 될때마다 로그 찍힘
"Button click!!"
"Button click!!"
"Button click!!"
...
나는 버튼이 클릭이 됐을 때만 handleButtonClick
호출이 되면 좋겠는데 왜 랜더링이 될때 마다 handleButtonClick
이 실행될까??
이렇게 코드를 고친다면, 내가 생각한대로 버튼을 클릭할 때만 콘솔에 내용이 찍힌다.
export function Card({content}) {
function handleButtonClick(event){
console.log("Button click!!");
...
}
...
return ( <h1 className="center-text">Card
<button onClick={()=> handleButtonClick()}>
{content.name}
</button>
</h1>;
)
}
// 버튼을 클릭할 때만 로그 찍힘
"Button click!!"
"Button click!!"
"Button click!!"
...
왜 이런 차이가 발생할까??
멘토님께 물어본 결과 랜더링이 될때 마다 코드는 위에서부터 전부 다시 다 실행이 된다고 한다.
그럼 onClick
라인에 실행라인이 도착했을 때 위의 경우에는 함수를 호출하고, 콜백 함수를 리턴한다.
그러나 고쳤던 코드는 함수를 호출하는 대신, 바로 콜백 함수를 리턴한다.
따라서 이벤트가 발생할 때 handleButtonClick
있는 콜백 함수의 내용이 실행되는 것이다!
이러한 이유로 함수형 컴포넌트를 쓸 때, 콜백함수를 JSX 표현식에 바로 값으로 넣는다고 한다.
만약 콜백함수의 인자가 하나 이하이면서, 같다면 이렇게 생략도 가능하다!
<button onClick={()=> handleButtonClick()}>
// 위와 같은 표현이다!
<button onClick={handleButtonClick}>
이런 조금의 디테일이 쌓이면서 더 좋은 개발자로 성장하는게 아닐까 생각이 든다!
한줄 후기
React, 친하게 지내자!
참조
'바닐라 부트캠프' 카테고리의 다른 글
[WIL] 바닐라코딩 부트캠프 8주차 후기 - redux, 전역으로 끌어올리기! (0) | 2025.04.06 |
---|---|
[WIL] 바닐라코딩 부트캠프 6주차 후기 - Hook, 내뜻대로 안되네.. (0) | 2025.03.23 |
[WIL] 바닐라코딩 부트캠프 3주차 후기 - 유틸 함수 직접 구현해 보자! (2) | 2025.02.28 |