-
[React] React 컴포넌트 설계 패턴React 2023. 4. 15. 17:56
들어가며
코드를 작성하는 것은 개발자로서 당연한 업무이다.
이때 코드를 작성할 때의 목표는 원하는 기능의 구현 일 것이다.
그렇게 기능 위주로 작성하다보면, 문득 “코드를 잘 만들었는가” “다른 사람이 보았을 때, 이해할 수 있는 코드를 만들었는가”에 대한 고민이 생기기 마련이다.
나만 알아볼 수 있는 코드는 다른 동료들과 미래의 나에게 곤란하게 하는 상황을 초래하고, 사이드 이펙트 및 레거시의 우려가 남아있기 때문이다.
리액트는 컴포넌트로 구성된 라이브러리이다. 간단한 프로젝트면 컴포넌트 설계에 대해서 깊게 고민하지 않아도 되겠지만, 규모가 어느정도 있는 프로젝트를 수행할 경우 비즈니스 로직이 더해진 다양하고 복잡한 컴포넌트는 수정하기 힘들어질 수 있다.
그래서 리액트의 코드를 잘 작성하기 위해서는 컴포넌트를 어떻게 설계하고, 구성하면 좋을지 생각해야한다.
오늘은 리액트 컴포넌트 패턴을 배우면서, 어떤 식으로 작성을 하면 소개해보겠다.
리액트 컴포넌트 설계 원칙
1. 컴포넌트는 재사용성과 확장성을 고려하여 만들어야 한다.리액트 내에서 여러가지 컴포넌트를 조합하여 한 페이지를 구성한다.
이때, 재사용성과 범용성을 고려하지 않고 해당 도메인에서만 쓰일 수 있는 (특정 경우에서만 쓸 수 있는) 의존성이 짙은 코드를 작성한다면 결국 각각의 페이지만을 위한 컴포넌트들이 생겨날 것이다.
이는 무분별한 코드의 양을 늘리게 될 것이며 예를 들어 버튼 색깔을 공통적으로 black에서 blue로 변경할 경우 하나하나 코드를 바꿔야 하는 불상사가 발생할 것이다.
2. 컴포넌트는 역할에 따라 단일 책임을 가져야 한다.
재사용성을 높이기 위해 하나의 컴포넌트에 하나의 역할만 수행해야한다.
어떤 역할에 따라 나누면 좋을지 고민할 수 있는데, 리액트에서는 보통 다음과 같이 나눈다. 프론트엔드에서 주로 처리하는 업무들을 생각해보면 아래와 같이 나누는게 합당해보인다.
- 내부 Data
react의 state롤 통해 data를 관리한다. - 외부 Data
react의 props로 전달받으며, 이때 외부 data는 서버 data, 로컬스토리지 data, 쿠키 data, 전역 상태 data 등일 수 있다. - Event action
사용자의 액션에 의해 싱기는 event 동작들로 내부 data, 외부 data에 영향을 준다. - UI
렌더링하여 화면을 구성하는 요소들로, button, input box 등이 올 수 있다.
리액트 설계 패턴
5 Advanced React Patterns에서 소개된 패턴들을 중심으로 리액트에서 자주 사용하는 패턴들에 대해 소개하겠다.
1. 합성 컴포넌트 패턴 Compound Components Pattern
합성 컴포넌트라는 이름에서 알 수 있듯이, 여러 개의 하위 컴포넌트를 조합하여 하나의 컴포넌트로 동작하도록 구현하는 패턴이다.
하나의 상위 컴포넌트가 여러 개의 하위 컴포넌트를 가지며, 하위 컴포넌트들은 상위 컴포넌트를 공유하고 조작 할 수 있다. 또한, 상위 컴포넌트는 하위 컴포넌트들의 구성과 동작을 조정할 수 있다.
이를 통해, prop driling 문제를 해결 할 수 있고, 더 커스터마이즈한 컴포넌트를 만들고 싶을 때, 명확하고 선언적으로 컴포넌트들을 구성할 수 있다.
[ 코드 예제 ]
1) 사용하는 곳import React from "react"; import { Counter } from "./Counter"; function Usage() { const handleChangeCounter = (count) => { console.log("count", count); }; return ( <Counter onChange={handleChangeCounter}> <Counter.Decrement icon="minus" /> <Counter.Label>Counter</Counter.Label> <Counter.Count max={10} /> <Counter.Increment icon="plus" /> </Counter> ); } export { Usage };
2) 상위 컴포넌트
import React, { useEffect, useRef, useState } from "react"; import styled from "styled-components"; import { CounterProvider } from "./useCounterContext"; import { Count, Label, Decrement, Increment } from "./components"; function Counter({ children, onChange, initialValue = 0 }) { const [count, setCount] = useState(initialValue); const firstMounded = useRef(true); useEffect(() => { if (!firstMounded.current) { onChange && onChange(count); } firstMounded.current = false; }, [count, onChange]); const handleIncrement = () => { setCount(count + 1); }; const handleDecrement = () => { setCount(Math.max(0, count - 1)); }; return ( <CounterProvider value={{ count, handleIncrement, handleDecrement }}> <StyledCounter>{children}</StyledCounter> </CounterProvider> ); } const StyledCounter = styled.div` display: inline-flex; border: 1px solid #17a2b8; line-height: 1.5; border-radius: 0.25rem; overflow: hidden; `; Counter.Count = Count; Counter.Label = Label; Counter.Increment = Increment; Counter.Decrement = Decrement; export { Counter };
3) 하위 컴포넌트import React from "react"; import { StyledButton } from "./styles.js"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useCounterContext } from "../useCounterContext"; function Decrement({ icon = "minus" }) { const { handleDecrement } = useCounterContext(); return ( <StyledButton onClick={handleDecrement}> <FontAwesomeIcon icon={icon} color="#17a2b8" /> </StyledButton> ); } export { Decrement };
[장점]
1) API 복잡도 감소prop driling을 피하기 위해, 상위 컴포넌트에 합성 시킨다.
2) 유연한 UI 구성
상위 컴포넌트에서 하위 컴포넌트의 순서를 변경하거나 하위 컴포넌트의 삭제가 용이한 구조로 되어 있다.3) 관심사의 분리
대부분의 로직이 상위 컴포넌트(Counter 컴포넌트)에 집중되어 있으며, useContext를 통해 상태관리를 하여 자식 컴포넌트는 공유받는 형태로 진행되어 있다.- 상위컴포넌트 Counter: CounterProvider
- 하위 컴포넌트 Decrement, Increment, Count: useCounterContext
[단점]
1) UI 유연성이 과도한 경우
유연성이 과도한 경우, 예상치 못한 css 사이드 이펙트가 발생할 수 있다.2) JSX 코드 양 증대
2. 제어 Props 패턴 (Control Props Pattern)
제어 props 패턴은 상위 컴포넌트로부터 하위 컴포넌트에게 제어 function을 props로 전달하여, 상위컴포넌트가 하위 컴포넌트를 제어할 수 있는 패턴이다.
이 패턴을 통해, 하위 컴포넌트의 동작을 custom하게 수정하고 싶을 때 간단하게 props에 주어지는 function을 변경하여 동작을 조작할 수 있다.
[ 코드 예제 ]
1) 사용하는 곳
import React, { useState } from "react"; import { Counter } from "./Counter"; function Usage() { const [count, setCount] = useState(0); const handleChangeCounter = (newCount) => { setCount(newCount); }; return ( <Counter value={count} onChange={handleChangeCounter}> <Counter.Decrement icon={"minus"} /> <Counter.Label>Counter</Counter.Label> <Counter.Count max={10} /> <Counter.Increment icon={"plus"} /> </Counter> ); } export { Usage };
2) 상위 컴포넌트
import React, { useState, useRef, useEffect } from "react"; import styled from "styled-components"; import { CounterProvider } from "./useCounterContext"; import { Count, Label, Decrement, Increment } from "./components"; function Counter({ children, value = null, initialValue = 0, onChange }) { const [count, setCount] = useState(initialValue); const isControlled = value !== null && !!onChange; const getCount = () => (isControlled ? value : count); const firstMounded = useRef(true); useEffect(() => { if (!firstMounded.current && !isControlled) { onChange && onChange(count); } firstMounded.current = false; }, [count, onChange, isControlled]); const handleIncrement = () => { handleCountChange(getCount() + 1); }; const handleDecrement = () => { handleCountChange(Math.max(0, getCount() - 1)); }; const handleCountChange = (newValue) => { isControlled ? onChange(newValue) : setCount(newValue); }; return ( <CounterProvider value={{ count: getCount(), handleIncrement, handleDecrement }} > <StyledCounter>{children}</StyledCounter> </CounterProvider> ); } const StyledCounter = styled.div` display: inline-flex; border: 1px solid #17a2b8; line-height: 1.5; border-radius: 0.25rem; overflow: hidden; `; Counter.Count = Count; Counter.Label = Label; Counter.Increment = Increment; Counter.Decrement = Decrement; export { Counter };
3) 하위 컴포넌트
import React from "react"; import { StyledButton } from "./styles.js"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useCounterContext } from "../useCounterContext"; function Decrement({ icon = "minus" }) { const { handleDecrement } = useCounterContext(); return ( <StyledButton onClick={handleDecrement}> <FontAwesomeIcon icon={icon} color="#17a2b8" /> </StyledButton> ); } export { Decrement };
[장점]
1) 더 많은 control 가능
위의 상위 컴포넌트에서 handleCountChange function을 통해 custom 로직 변경 가능하다.[단점]
1) 구현 복잡성
콜백 함수가 계속해서 전달되어야 하므로 코드가 복잡해질 수 있음.
3. 커스텀 훅 패턴 (Custom Hook Pattern)
커스텀 훅 패턴은 로직을 커스텀 훅으로 이동시켜, 여러 내부 로직을 적절한 function명만으로 노출시키는 전략이다.
리액트 개발자들이 custom hook을 만들면서 접하면서 배우는 패턴이다.
[ 코드 예제 ]1) 사용하는 곳
import React from "react"; import styled from "styled-components"; import { Counter } from "./Counter"; import { useCounter } from "./useCounter"; function Usage() { const { count, handleIncrement, handleDecrement } = useCounter(0); const MAX_COUNT = 10; const handleClickIncrement = () => { //Put your custom logic if (count < MAX_COUNT) { handleIncrement(); } }; return ( <> <Counter value={count}> <Counter.Decrement icon={"minus"} onClick={handleDecrement} disabled={count === 0} /> <Counter.Label>Counter</Counter.Label> <Counter.Count /> <Counter.Increment icon={"plus"} onClick={handleClickIncrement} disabled={count === MAX_COUNT} /> </Counter> <StyledContainer> <button onClick={handleClickIncrement} disabled={count === MAX_COUNT}> Custom increment btn 1 </button> </StyledContainer> </> ); } export { Usage }; const StyledContainer = styled.div` margin-top: 20px; `;
2) 상위 컴포넌트
import React, { useRef, useEffect } from "react"; import styled from "styled-components"; import { CounterProvider } from "./useCounterContext"; import { Count, Label, Decrement, Increment } from "./components"; function Counter({ children, value: count, onChange }) { const firstMounded = useRef(true); useEffect(() => { if (!firstMounded.current) { onChange && onChange(count); } firstMounded.current = false; }, [count, onChange]); return ( <CounterProvider value={{ count }}> <StyledCounter>{children}</StyledCounter> </CounterProvider> ); } const StyledCounter = styled.div` display: inline-flex; border: 1px solid #17a2b8; line-height: 1.5; border-radius: 0.25rem; overflow: hidden; `; Counter.Count = Count; Counter.Label = Label; Counter.Increment = Increment; Counter.Decrement = Decrement; export { Counter };
3) 하위 컴포넌트
import React from "react"; import { StyledButton } from "./styles.js"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; function Decrement({ icon = "minus", onClick }) { return ( <StyledButton onClick={onClick}> <FontAwesomeIcon icon={icon} color="#17a2b8" /> </StyledButton> ); } export { Decrement };
4) custom hook
import { useState } from "react"; function useCounter(intialeCount) { const [count, setCount] = useState(intialeCount); const handleIncrement = () => { setCount((prevCount) => prevCount + 1); }; const handleDecrement = () => { setCount((prevCount) => Math.max(0, prevCount - 1)); }; return { count, handleIncrement, handleDecrement }; } export { useCounter };
[장점]
1) 많은 제어 제공
상위 컴포넌트에서 로직을 정의하는 것이 아니라, custom hook에서 로직을 정의 가능하다.2) 코드 흐름 파악 유용
상위 컴포넌트에 적용된 하위컴포넌트에 customHook에 정의된 로직을 props에 넘김으로써, 하위 컴포넌트에서 어떤 동작들이 이루어지는지 흐름상 파악하기 유용하다.
[단점]
1) 구현 복잡성
로직과 렌더링 되는 부분이 분리되기 때문에, 적절히 구분하여 구현해야한다.
4. Props Getters 패턴 (Props Getters Pattern)
커스텀 훅 패턴은 개발자들에게 더 쉽게 로직을 다룰 수 있도록 로직을 커스텀 훅으로 이동시켰는데, 이로 인해 하위 컴포넌트에 props에 커스텀 훅에서 사용된 로직들을 넘겨주어야 한다.
그래서 props에 길게 노출하는 것이 아닌 prop getter를 제공하여 복잡성을 숨기는 역할을 제공하는 것이 Props Getter 패턴이다.
[ 코드 예제 ]
1) 사용하는 곳
import React from "react"; import styled from "styled-components"; import { Counter } from "./Counter"; import { useCounter } from "./useCounter"; const MAX_COUNT = 10; function Usage() { const { count, getCounterProps, getIncrementProps, getDecrementProps } = useCounter({ initial: 0, max: MAX_COUNT }); const handleBtn1Clicked = () => { console.log("btn 1 clicked"); }; return ( <> <Counter {...getCounterProps()}> <Counter.Decrement icon={"minus"} {...getDecrementProps()} /> <Counter.Label>Counter</Counter.Label> <Counter.Count /> <Counter.Increment icon={"plus"} {...getIncrementProps()} /> </Counter> <StyledContainer> <button {...getIncrementProps({ onClick: handleBtn1Clicked })}> Custom increment btn 1 </button> </StyledContainer> <StyledContainer> <button {...getIncrementProps({ disabled: count > MAX_COUNT - 2 })}> Custom increment btn 2 </button> </StyledContainer> </> ); } export { Usage }; const StyledContainer = styled.div` margin-top: 20px; `;
2) 상위 컴포넌트
import React, { useRef, useEffect } from "react"; import styled from "styled-components"; import { CounterProvider } from "./useCounterContext"; import { Count, Label, Decrement, Increment } from "./components"; function Counter({ children, value: count, onChange }) { const firstMounded = useRef(true); useEffect(() => { if (!firstMounded.current) { onChange && onChange(count); } firstMounded.current = false; }, [count, onChange]); return ( <CounterProvider value={{ count }}> <StyledCounter>{children}</StyledCounter> </CounterProvider> ); } const StyledCounter = styled.div` display: inline-flex; border: 1px solid #17a2b8; line-height: 1.5; border-radius: 0.25rem; overflow: hidden; `; Counter.Count = Count; Counter.Label = Label; Counter.Increment = Increment; Counter.Decrement = Decrement; export { Counter };
3) 하위 컴포넌트
import React from "react"; import { StyledButton } from "./styles.js"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; function Decrement({ icon = "minus", onClick, ...props }) { return ( <StyledButton onClick={onClick} {...props}> <FontAwesomeIcon icon={icon} color="#17a2b8" /> </StyledButton> ); } export { Decrement };
4) custom hook
import { useState } from "react"; //Function which concat all functions together const callFnsInSequence = (...fns) => (...args) => fns.forEach((fn) => fn && fn(...args)); function useCounter({ initial, max }) { const [count, setCount] = useState(initial); const handleIncrement = () => { setCount((prevCount) => Math.min(prevCount + 1, max)); }; const handleDecrement = () => { setCount((prevCount) => Math.max(0, prevCount - 1)); }; //props getter for 'Counter' const getCounterProps = ({ ...otherProps } = {}) => ({ value: count, "aria-valuemax": max, "aria-valuemin": 0, "aria-valuenow": count, ...otherProps }); //props getter for 'Decrement' const getDecrementProps = ({ onClick, ...otherProps } = {}) => ({ onClick: callFnsInSequence(handleDecrement, onClick), disabled: count === 0, ...otherProps }); //props getter for 'Increment' const getIncrementProps = ({ onClick, ...otherProps } = {}) => ({ onClick: callFnsInSequence(handleIncrement, onClick), disabled: count === max, ...otherProps }); return { count, handleIncrement, handleDecrement, getCounterProps, getDecrementProps, getIncrementProps }; } export { useCounter };
[장점]1) 사용 쉬움
props getter를 통해 복잡성이 숨겨졌기 때문이다.
2) 유연함
props getter를 통해 오버로딩이 가능하기 때문에 특정 custom 하고 싶은 경우 용이해졌다.
[단점]1) 코드의 설명 부족
props getter를 통해 주요 로직이 안에 들어가졌기 때문에 custom hook 패턴에 비해 정확히 어떤 동작이 구현되고 있는지 상위 컴포넌트에서 확인이 불가능하다.
5. State Reducer 패턴 (State reducer Pattern)
간단하게 custom hook 패턴에서 reducer가 추가된 형태라고 볼 수 있다. custom hook 패턴에 reducer가 추가되었으니 코드 구현 상 복잡하지만, 제어권을 많이 위임될 수 있다.
[ 코드 예제 ]
1) 사용하는 곳
import React from "react"; import styled from "styled-components"; import { Counter } from "./Counter"; import { useCounter } from "./useCounter"; const MAX_COUNT = 10; function Usage() { const reducer = (state, action) => { switch (action.type) { case "decrement": return { count: Math.max(0, state.count - 2) //The decrement delta was changed for 2 (Default is 1) }; default: return useCounter.reducer(state, action); } }; const { count, handleDecrement, handleIncrement } = useCounter( { initial: 0, max: 10 }, reducer ); return ( <> <Counter value={count}> <Counter.Decrement icon={"minus"} onClick={handleDecrement} /> <Counter.Label>Counter</Counter.Label> <Counter.Count /> <Counter.Increment icon={"plus"} onClick={handleIncrement} /> </Counter> <StyledContainer> <button onClick={handleIncrement} disabled={count === MAX_COUNT}> Custom increment btn 1 </button> </StyledContainer> </> ); } export { Usage }; const StyledContainer = styled.div` margin-top: 20px; `;
2) 상위 컴포넌트
import React, { useRef, useEffect } from "react"; import styled from "styled-components"; import { CounterProvider } from "./useCounterContext"; import { Count, Label, Decrement, Increment } from "./components"; function Counter({ children, value: count, onChange }) { const firstMounded = useRef(true); useEffect(() => { if (!firstMounded.current) { onChange && onChange(count); } firstMounded.current = false; }, [count, onChange]); return ( <CounterProvider value={{ count }}> <StyledCounter>{children}</StyledCounter> </CounterProvider> ); } const StyledCounter = styled.div` display: inline-flex; border: 1px solid #17a2b8; line-height: 1.5; border-radius: 0.25rem; overflow: hidden; `; Counter.Count = Count; Counter.Label = Label; Counter.Increment = Increment; Counter.Decrement = Decrement; export { Counter };
3) 하위 컴포넌트
import React from "react"; import { StyledButton } from "./styles.js"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; function Decrement({ icon = "minus", onClick, ...props }) { return ( <StyledButton onClick={onClick} {...props}> <FontAwesomeIcon icon={icon} color="#17a2b8" /> </StyledButton> ); } export { Decrement };
4) custom hook
import { useReducer } from "react"; const internalReducer = ({ count }, { type, payload }) => { switch (type) { case "increment": return { count: Math.min(count + 1, payload.max) }; case "decrement": return { count: Math.max(0, count - 1) }; default: throw new Error(`Unhandled action type: ${type}`); } }; function useCounter({ initial, max }, reducer = internalReducer) { const [{ count }, dispatch] = useReducer(reducer, { count: initial }); const handleIncrement = () => { dispatch({ type: "increment", payload: { max } }); }; const handleDecrement = () => { dispatch({ type: "decrement" }); }; return { count, handleIncrement, handleDecrement }; } useCounter.reducer = internalReducer; useCounter.types = { increment: "increment", decrement: "decrement" }; export { useCounter };
[장점]
1) 더 많은 위임 가능
reducer를 추가하여 더 많은 control 할 수 있다.
[단점]
1) 구현 복잡성
reducer를 구현해야하므로 복잡도가 올라간다.
마치며
평소 리액트 컴포넌트 설계할 때, 어떻게 설계하면 좋을지, 내가 작성한 코드가 올바른가에 대해 고민이 많았다.
이번에 5가지 패턴을 배우면서 상황과 목적에 따라 적절한 패턴을 사용하도록 노력해야겠다.출처
https://javascript.plainenglish.io/5-advanced-react-patterns-a6b7624267a6
https://www.stevy.dev/react-design-guide/
'React' 카테고리의 다른 글
[React] React.memo vs useMemo vs useCallback (0) 2022.10.22 [React] JSX.Element vs ReactNode vs ReactElement (1) 2022.10.08 [React] HOC(high order component)의 개념과 사용법 (0) 2022.08.30 - 내부 Data