지난 글에서 최적화를 위해 사용되는 훅과 React.memo의 적절한 사용법에 대해 깊이 살펴보았다. 이어지는 내용은 리액트 컴포넌트의 일반적인 설계 패턴이다. 필요한 만큼이나 알고자 하는 갈증이 깊었고, 또 중요한 내용이기에 책에서 이해한 내용과 함께 직접 고민한 생각을 정리해본다. 컴포넌트 설계에 관한 내용들은 여러차례 나누어 작성할 예정이다.
들어가기에 앞서, 많은 컴포넌트 설계 패턴들이 리액트 훅 이전, Class 컴포넌트를 전제로 고안된 것이 많다. 그러므로, 이 글에선 간단한 패턴의 소개와 react 18에 더 적합한 방법을 함께 고민해보기로 한다.
1. Presentational/Container Components
Presentational/Container 패턴은 일반적인 리액트 컴포넌트 구성 패턴이다. Presentational 컴포넌트는 UI를 렌더링하고, Container 컴포넌트는 UI의 상태state를 관리한다. 즉 UI와 State를 분리하여 컴포넌트를 구성한다.
const UserProfile = ({ user, onLogout }) => (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<button onClick={onLogout}>Logout</button>
</div>
);
const UserProfileContainer = () => {
const [user, setUser] = useState({
name: 'John Doe', email:'john.doe@example.com',});
const handleLogout = () => {
console.log('User logged out'); // Handle logout logic here
};
return ( <UserProfile user={user} onLogout={handleLogout} />);
};
여기서 상태와 핸들러를 props로 넘기는 UserProfileContainer가 Container이며, UserProfile는 모든 것을 props로 받는 Presentation이다. 이를 통해 UI 컴포넌트와 상태의 로직을 완전히 분리하였다.
이 패턴을 사용하면 다음과 같은 이점이 있다(고 한다).
- 단순성, 가독성 : 코드 몇줄 뿐만 아니라, 컴포넌트 단위의 코드 파악이 빨라진다. 그러므로 유지/보수에 용이하다
- 관심사의 분리 : 컴포넌트 분리의 원칙이 분명하다
- 테스트 용이성 : 상태 로직과 ui를 분리하여 테스트하기 쉽다
- 재사용성 : Presentational 컴포넌트를 재사용할 수 있도록 설계하여 특정 로직에 구속되지 않게 할 수 있다. 예를 들어 UI는 Presentational 컴포넌트의 조합으로 구성하고, 각 페이지의 이벤트 처리와 비동기 요청은 Container에서 처리할 수 있다.
하지만 거의 모든 기술이 그러하듯, 트레이드 오프가 있다.
- 대규모 앱에서 복잡성
- 크고 복잡한 앱에서 Container/Presentational 컴포넌트 사이의 구분이 불필요한 코드 스플릿으로 복잡성이 더해질 수 있다.
- 특히 UI 상태 업데이트가 잦은 이벤트 핸들러의 경우, 일부 컴포넌트에서 로직과 UI의 구분이 모호해지는 경우가 있다.이 패턴을 고수하면서 Props drilling이 발생하기 쉽다.
- 넘겨줘야할 Props가 끝없이 증가할 수 있다. 코드 복잡성 증가로 이어진다
- 상태 관리의 오버헤드
- 복잡한 상태 로직을 다루는 시나리오에서 컨테이너 컴포넌트 내의 훅만을 사용하는 것만으로는 충분하지 않을 수 있다.
- 프롭 드릴링의 오버헤드
- 컨테이너 컴포넌트가 여러 계층의 컴포넌트에 걸쳐 프롭을 전달해야 할 수 있다
- 간단한 컴포넌트 임에도 ui를 분리하면서 deps가 계속 깊어질 수 있다.
- 상태 관리 라이브러리(Redux, Context API)를 사용해야 하는 경우에 이 패턴을 고수한다면 컴포넌트의 deps가 매우 깊어질 것이다.
- 성능 문제
- UI 컴포넌트가 모든 상태를 Props로 받기 때문에 리렌더링 이슈가 생길 수 있다.
- 특히 useState와 useEffect 훅의 잘못된 사용이 성능 문제로 이어질 수 있다.
- 부모 컴포넌트에서 props로 상태를 넘겨받기 때문에 하나의 Container가 수많은 Presentational 컴포넌트를 갖고있다면, 모든 리렌더링의 비용이 높아진다.
- 제대로 최적화되지 않은 경우(예: memo, useCallback, useMemo 사용 안 함, useEffect 잘못된 의존성 배열) 필요 이상으로 리렌더링 될 수 있다.
- 테스팅의 복잡성
- 단위 테스팅은 쉬워질 수 있으나, 테스팅의 오버헤드가 증가할 수도 있다.
- Props와 훅을 Mocking 해야할 양이 더 늘어 테스트의 복잡성이 증가할 수 있다.
면도날처럼 단순 명료하다. 하지만 실제 개발에선 오히려 복잡성을 높이기 쉽다. 경험 상으로도 프로젝트 후반에 이르러 헤아리기 어려울 지경으로 자라난 코드 베이스를 마주한 적이 있었다. 그러므로 적용에 앞서 1) UI 상태 변화 플로우 2) 전체 컴포넌트 구성 3) 컴포넌트 적용 범위에 대한 고려가 필요하다.
Snippet
hook과 함께 사용 시 적절한 방식을 고민해보며 code snippet을 작성했다.
const ReusableButton:React.FC<PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>>
= memo(({ children, ...props}) => {
console.log('rerender ReusableText : ', children);
return <button {...props}>{children}</button>;
})
const ReusableInput:React.FC<HTMLProps<HTMLInputElement>>
= memo((props) => {
console.log('rerender ReusableInput');
return <input {...props}/>;
})
const ReusableText:React.FC<{text: string}>
= memo(({ text}) => {
console.log('rerender ReusableText : ', text);
return <p>{text}</p>;
})
const ContainerComponent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const handlePlusButtonClick = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
const handleMinusButtonClick = useCallback(() => {
setCount((prev) => prev - 1);
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
}, []);
return (
<div>
<ReusableText text={`count : ${count}`}/>
<ReusableText text={`text : ${text}`}/>
<div>
<ReusableButton onClick={handlePlusButtonClick}>+</ReusableButton>
<ReusableButton onClick={handleMinusButtonClick}>-</ReusableButton>
<ReusableInput type="text" placeholder="Type Here" value={text} onInput={handleInputChange}/>
</div>
</div>
);
}
코드의 의도 :
1 - Container에서 발생하는 state 업데이트가 모든 UI 컴포넌트를 리렌더링한다.
- 이를 방지하기 위해 useCallback을 사용한다.
2 - Presentation 컴포넌트의 재사용성을 높인다.
- 리렌더링 이슈를 막기 위해 React.memo를 활용한다
- 각 엘리먼트 ui 디자인에 variation이 많지 않고, 일관성이 있다면 style에 관한 props로 쉽게 관리할 수 있다
3 - useCallback의 deps가 비어있다.
- +, - 버튼 핸들러 콜백훅에 count가 deps로 추가된다면 양쪽 버튼이 모두 리렌더링된다
- 그러나 같은 동작으로 setter에 콜백함수를 사용하면 이를 막을 수 있다.
- input 핸들로 또한 event.target의 값이 들어가기 때문에 deps가 비어도 작동된다.
재사용성, 렌더링 최적화에만 초점을 둔 snippet이기 때문에, 실제 개발이라면 한계가 있다. 이상적인 케이스 대로 구현사항과 컴포넌트 구조가 패턴대로 명확히 구분되지 않을 가능성이 크기 때문이다. 또한 이러한 필연적인 단점도 떠오른다. memo, callback을 써서 Presentation의 리렌더링은 막아도, 여전히 Container의 리렌더링은 막을 수 없다.. 당연한 얘기다. 위의 gif에서 버튼 이벤트, 텍스트 이벤트 각각의 상태 변화가 생길 때마다 UI 자식 컴포넌트와 함께 매번 Container도 리렌더링된다. 매번 모든 자식 컴포넌트에 콜백, 메모 훅 등을 사용하는 것 또한 적절하지 않다. 이 훅들을 "비용이 높은 컴포넌트"에 적용하지 않는 이상, 결국 오버헤드이기 때문이다.
조금 더 복잡한 상태 관리가 필요한 경우는 어떨까?
Presntational/Container 패턴
// Container
const FormContainer = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!formData.email.includes('@')) newErrors.email = 'Email is invalid';
if (formData.password.length < 8) newErrors.password = 'Password must be at least 8 characters';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validateForm()) return;
console.log('Form data:', formData);
};
return <FormPresentation formData={formData} setFormData={setFormData} errors={errors} handleSubmit={handleSubmit} />;
};
// Presentational
const FormPresentation = ({ formData, setFormData, errors, handleSubmit }) => {
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
{errors.email && <p>{errors.email}</p>}
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Password"
/>
{errors.password && <p>{errors.password}</p>}
<button type="submit">Submit</button>
</form>
);
};
유저의 입력과 validation에 따라 ui의 업데이트가 발생하는 일반적인 form 컴포넌트이다. 여기서 이 패턴의 비효율성이 드러난다. 일반적인 form 컴포넌트라면, 아마 하나의 컴포넌트로 작성되었을 코드이기 때문이다. 첫째로 컴포넌트에 불필요한 nesting이 생긴다. 이 컴포넌트는 분리되지 않아도 애초에 render(return) 상단에는 상태 관리와 연관된 코드만 포함한다. 둘째로 이로 인해 늘어나는 코드의 양이다. 파일이 추가되고, props로 넘겨야할 양이 증가한다. 타입스크립트를 사용한다면, props의 타입을 지정하면서 더 많은 코드가 늘어났을 것이다. 이와 같은 경우엔 패턴의 순기능은 딱히 찾기 어려우며, 오히려 비용이 늘어나는 걸로 보인다.
커스텀 훅 useForm
const useForm = (initialValues) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setValues({ ...values, [name]: value });
};
const validate = () => {
const newErrors = {};
if (!values.email.includes('@')) newErrors.email = 'Email is invalid';
if (values.password.length < 8) newErrors.password = 'Password must be at least 8 characters';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
return { values, handleChange, errors, validate };
};
const Form = () => {
const { values, handleChange, errors, validate } = useForm({ email: '', password: '' });
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
console.log('Form data:', values);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" value={values.email} onChange={handleChange} placeholder="Email" />
{errors.email && <p>{errors.email}</p>}
<input name="password" type="password" value={values.password} onChange={handleChange} placeholder="Password" />
{errors.password && <p>{errors.password}</p>}
<button type="submit">Submit</button>
</form>
);
};
상태 변경 로직의 추상화가 필요하다면, 차라리 이같은 커스텀 훅으로 분리하는 것이 더 효율적으로 보인다. 불필요한 컴포넌트 네스팅 없이, 커스텀 훅 안에서 상태와 상태 로직만을 분리하기 때문에, "로직 분리"의 이점과 불필요한 코드 추가를 막는 이점을 동시에 챙길 수 있다.
Conclusion & Use Cases
"UI와 상태를 분리한다"는 패턴이 주는 명쾌함이 있지만, 이 패턴을 코드베이스 전체에 적용하는 것은 무리가 있어보인다. 모든 경우에 이 패턴을 강제한다면, 오히려 단점만 더 커지기 쉽상이다. 따라서 이 패턴을 적용하기 위해선 이러한 전제조건이 필요하겠다.
- 레이아웃, 템플릿처럼 큰 단위가 아닌 작은 단위의 컴포넌트
- 상태 업데이트를 트리거하는 이벤트 핸들링이 많지 않은 경우
이 두가지를 염두하면 구체적으로 다음과 같은 유즈 케이스가 떠오른다.
- 리스트의 아이템 컴포넌트처럼 UI와 상태 분리가 명확하고, 아이템 컴포넌트에서 복잡한 상태 로직이 없는 경우
- 여러 컴포넌트에서 재사용되는 UI 컴포넌트 (아토믹 패턴의 atom처럼 작은 단위로 여러 군데에서 재사용되는 컴포넌트)
(사실 두번째의 경우에는 일반적인 atomic 패턴의 ui 컴포넌트와 다를 바 없어보이긴 하다.)
결론을 요약하면 다음과 같다.
- 모든 컴포넌트에 패턴을 강제하지 않고, 적절한 케이스에 사용한다
- 패턴의 간결함이 코드의 간결함으로 이어지지는 않는다. (props가 방대해지고, 불필요한 코드량이 늘 수 있다)
- 함수 컴포넌트에서 복잡한 상태관리는 커스텀 훅으로 관리하는 것이 효율적인 대안이 될 수 있다.