React에서 Container/Presentational을 이용해 관심사의 분리(SoC)를 강제할 수 있다. 이 패턴을 통해 비즈니스 로직에서 뷰를 분리할 수 있다.
관심사의 분리(Separation of Concerns, SoC)란, 하나의 함수, 변수, 클래스, 컴포넌트에게 한 번에 너무 많은 일(concerns)을 부여하면 코드의 복잡도가 높아질 수 있으므로 한 번에 작은 단위의 일만 하도록 나누는 것을 말한다.
- Divede & Conquer (분할 정복)
- Low Coupling (낮은 결합도) + High Cohesive (높은 응집도)
의 장점(재사용성, 유지보수 용이)을 가진다.
비즈니스 로직과 뷰의 분리
6개의 강아지 사진을 다운로드 받아 화면에 렌더하는 앱을 만드는 상황을 가정했을 때,
프로세스를 다음 두 가지로 분리하여 관심사의 분리를 강제할 수 있다.
- Presentational Components: 데이터가 어떻게 보일 지에 대해서만 다루는 컴포넌트로, 강아지 사진 목록을 렌더링하는 부분 (뷰)
- Container Components: 어떤 데이터가 보일 지에 대해 다루는 컴포넌트로, 강아지 사진을 다운로드한다. (비즈니스 로직)
Presentational 컴포넌트
이 컴포넌트의 주요 기능은 받은 데이터를 화면에 표현하는 것이다. 해당 목적을 위해 스타일 시트를 포함한다는 것이 특징이다. props를 통해 데이터를 받지만, 받은 데이터를 건드리지는 않는다.
강아지 사진을 렌더링할 때 단순히 각 이미지들을 (1)API로부터 다운로드 받고 (2)화면에 렌더링하는 전체 과정을 하나의 컴포넌트에서 구현하는 것이 아니라, 이미지들을 props를 통해 받아 화면에 그리는 함수형 컴포넌트만 만든다.
// DogImages.jsx import React from "react"; export default function DogImages({ dogs }) { return dogs.map((dog, i) => <img src={dog} key={i} alt="Dog" />); }
위의 예제 Presentational 컴포넌트는 UI 변경을 위한 상태 외에는 상태를 갖지 않는다. 즉, prop을 통해 받은 데이터
dogs
는 Presentational 컴포넌트에 의해 수정되지 않는다.Container 컴포넌트
Presentational 컴포넌트로 데이터를 전달하는 것이 Container의 주요 기능이다. Container 컴포넌트 자체는 화면에 아무 것도 렌더링하지 않는다. 따라서 스타일 시트도 필요없다.
// DogImagesContainer.jsx import React from "react"; import DogImages from "./DogImages"; export default class DogImagesContainer extends React.Component { constructor() { super(); this.state = { dogs: [] }; } componentDidMount() { fetch("https://dog.ceo/api/breed/labrador/images/random/6") .then(res => res.json()) .then(({ message }) => this.setState({ dogs: message })); } // API로부터 강아지 이미지를 다운로드 render() { return <DogImages dogs={this.state.dogs} />; } // 강아지 이미지 데이터를 전달 }
예제에서 강아지 사진 목록을
DogImages
컴포넌트(Presentational)에 전달하는 Container 컴포넌트는 외부 API로부터 강아지 이미지를 다운로드 하고 Presentaional 컴포넌트에 전달한다.Hooks
Container/Presentational 패턴은 이제 Container 컴포넌트 없이도 React의 커스텀 훅으로 구현할 수 있다.
예제의 Container 컴포넌트에 있는 데이터 다운로드 코드를 커스텀 훅으로 구현하면 다음과 같다.
import { useState, useEffect } from "react"; export default function useDogImages() { const [dogs, setDogs] = useState([]); useEffect(() => { async function fetchDogs() { const res = await fetch( "https://dog.ceo/api/breed/labrador/images/random/6" ); const { message } = await res.json(); setDogs(message); } fetchDogs(); }, []); return dogs; }
훅은 Container/Presentational 패턴처럼 (1)비즈니스 로직과 뷰를 쉽게 분리하게 해주고, (2)불필요한 Container 래핑을 줄일 수 있게 한다.
Container/Presentational 장단점
장점
- 관심사의 분리(SoC)를 구현할 수 있다.
- Presentational 컴포넌트는 데이터를 건드리지 않고 화면에 출력하는 역할만 하므로 앱의 여러 곳에서 다양한 목적으로 재사용할 수 있다.
- Presentational 컴포넌트는 테스트하기 간편하다. 순수 함수로만 구현되므로 전체 데이터 저장소를 만들 필요 없이 요구하는 데이터만 인자로 넘겨주면 된다.
단점
- 훅을 활용하면 이 패턴을 따르지 않아도 같은 효과를 볼 수 있다.
패턴 활용 실습: 간단한 날씨 앱
실습 주제: 사용자가 선택한 도시의 날씨 정보를 표시하는 앱
Containter/Presentational 패턴의 특징인 비즈니스 로직과 UI 렌더링의 분리를 활용해 아주 간단한 날씨 앱을 구현했습니다.
처음에는 해당 패턴 그대로 Container를 만들어 구현하고자 했으나, 실제로는 주로 커스텀 훅을 자주 사용한다고 판단하여 커스텀 훅으로 로직과 뷰의 분리를 수행했습니다!
전세계의 날씨 데이터를 제공하는 오픈 API인 OpenWeatherMap API,
‣를 활용했습니다.
다음은 스타일 적용 전 코드입니다.
useCurrentWeather.jsx
import { useEffect, useState } from "react"; const API_KEY = import.meta.env.VITE_API_KEY; function useCurrentWeather(city) { const [curWeather, setCurWeather] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); useEffect(() => { const fetchCurrentWeather = async () => { setIsLoading(true); try { const response = await fetch( `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}&units=metric` ); const data = await response.json(); setCurWeather(data); } catch (error) { setIsError(true); console.log(error); } finally { setIsLoading(false); } }; fetchCurrentWeather(); }, [city]); return { curWeather, isLoading, isError }; } export default useCurrentWeather;
App.jsx
import { useState } from "react"; import useCurrentWeather from "./hooks/useCurrentWeather"; function App() { const [city, setCity] = useState("Seoul"); const { curWeather, isLoading, isError } = useCurrentWeather(city); return ( <> <h1>🌀🌀🌀</h1> <select name="city" value={city} onChange={(e) => setCity(e.target.value)} > <option value="Seoul">서울특별시</option> <option value="Busan">부산광역시</option> </select> <p>{curWeather.main?.temp}°C</p> <p> {curWeather.weather && curWeather.weather.length > 0 ? curWeather.weather[0]?.description : ""} </p> <p> 최고: {curWeather.main?.temp_max}°C 최저: {curWeather.main?.temp_min}°C </p> <p>습도: {curWeather.main?.humidity}%</p> <p>기압: {curWeather.main?.pressure} hPa</p> <p>체감 온도: {curWeather.main?.feels_like}°C</p> </> ); } export default App;
완성된 모습은 다음과 같습니다.