디자인 패턴을 공부하다 보면 항상 보는 말이 관심사 분리(Seperation of Concerns; SoC)이다. 단일 책임 원칙(Single Responsibility Principle; SRPP), 즉 하나의 모듈은 하나의 기능만 담당해야 한다는 원칙을 준수하기 위해 관심사 분리를 하게 되는데, 이를 어떻게 분리해야 하는지를 다루는 것이 디자인 패턴이기 때문이다.
그런데 리액트를 다루게 되면서 디자인 패턴을 직접 사용해서 뭔가를 구현할 일이 크게 줄어들었다. 특히 화면에 뭐가 뿌려져야 할지를 결정하는 부분은 리액트 프레임워크가 거의 모두 알아서 해주기에 더욱 그렇다. 그래서 처음에는 리액트를 쓰면 패턴을 적용할 필요가 전혀 없을 거라고 생각했다. 리액트가 알아서 해주는데 거창한 패턴이 필요한가? 그냥 비슷한 기능을 하는 컴포넌트끼리 한 디렉터리 안에 모아주고, css 파일도 옆에 놔주면 되는 거 아닌가?
하지만 이건 기껏해야 빌드시간이 10초도 안 걸리는 간단한 대학 프로젝트 수준의 앱을 만들 때나 그렇고, 프로젝트가 커지고 진지하게 클린 코드가 중요해지는 시점부터는 리액트 컴포넌트를 어떻게 분리하는지가 중요해진다. 그 중에서 가장 기초적이라고 생각하는 패턴이 Presentational and Container Pattern이다.
영화 "사랑과 영혼"은 인간 역시 Presentational Component와 Container Component로 분리될 수 있음을 증명하는 좋은 영화이다. 서양사람들은 이런 이분법을 좋아하는 것 같다.
클라이언트에서 동작하는 어떤 어플리케이션이 있다면, 이 어플리케이션은 UI를 제공하는 부분과 데이터를 처리하는 부분으로 나뉠 것이다. Presentational and Container Pattern은 이 간단한 사실에서 출발한다. 즉, 사용자에게 보여주는 부분을 담당하는 Presentational Component와 데이터를 처리하는 Container Component로 리액트 어플리케이션을 분리하는 것이다.
아래의 코드는 뉴스를 가져와서 보여주는 예시 컴포넌트이다.
const NewsList = () => {
const [articles, setArticles] = useState<Article[]>(null)
const fetchArticles = async () => {
const { data } = await axios.get(GET_ARTICLES_URL)
setArticles(data.articles)
}
useEffect(() => fetchArticles(), [])
return (
<div>
<ul>
{articles?.map((article) => (
<li>
<div>
{`title: ${article.title}`}
</div>
<div>
{`author: ${article.author}`}
</div>
<div>
{`description: ${article.description}`}
</div>
</li>
)}
</ul>
<div onClick={fetchArticles}>
Refresh!
</div>
</div>
)
}
위 컴포넌트는 실무적 관점에서 크게 손댈 필요가 없을 것 같긴 하다. 충분히 짧고 간결하며 코드의 각 부분이 뭘 위해 존재하는지 파악하기 쉽다. 하지만 여기서는 그게 목적이 아니니까 Presentational Component와 Container Component로 분리해보도록 하자.
Presentational Component는 쉽게 말해 how things look을 담당한다. 즉 렌더링 되었을 때 어떻게 생겨먹었는지를 담당하는 컴포넌트이다. 따라서 스타일을 가지고 있으며 Container Component 없이도 브라우저에 예쁘게 렌더링 될 수 있다.
하지만 Presentational Component는 데이터를 가져오거나 수정하지 않는다. 그저 데이터를 props로 받아와서 화면에 뿌리기만 하고, 따로 state를 가지거나 외부로부터 데이터를 가져오는 동작을 하지는 않는다. 따라서 Presentational Component는 props에 대한 pure function이다. 이를 한 마디로 stateless functional component라고 하면 되겠다.
뉴스 컴포넌트에서 각 기사를 표시해주는 부분을 컴포넌트로 분리한다면, Presentational Component의 정의에 맞는 컴포넌트가 될 것이다. 기사 정보만을 가져와서 화면에 뿌려주고, 기사를 직접 API 호출을 통해 가져오거나 수정을 하지 않기 때문이다.
// Article.tsx const Article = ({ article }: { article: Article }) => { return ( <li> <div> {`title: ${article.title}`} </div> <div> {`author: ${article.author}`} </div> <div> {`description: ${article.description}`} </div> </li> ) }
단순히 화면에 뿌려주는 역할만 한다고 해서 간단한 계산같은 것도 하지 말라는 법은 없다.
// Article.tsx const DEFAULT_TITLE = 'NO_TITLE' const DEFAULT_AUTHOR = 'ANONYMOUS' const DEFAULT_DESCRIPTION = 'NO_DESCRIPTION' const Article = ({ article }: { article: Article }) => { // 이 정도 계산은 한다. const title = article.title || DEFAULT_TITLE const author = article.author || DEFAULT_AUTHOR const description = article.description || DEFAULT_DESCRIPTION return ( <li> <div> {`title: ${title}`} </div> <div> {`author: ${author}`} </div> <div> {`description: ${description}`} </div> </li> ) }
생각해 보니 NewsList
에서 Refresh 버튼까지 합쳐서 전체를 Presentational Component로 만들 수 있을 것 같다. onClick
동작도 같이 props로 넘기면 되기 때문이다.
// NewsList.tsx type Props { articles: Article[] onRefreshClick: () => void } const NewsList: FC<Props> = ({ articles, onRefreshClick }) => { return ( <div> <ul> {articles?.map((article) => ( // Presentational Component로 만들어뒀던 Article <Article article={article} /> )} </ul> <div onClick={onRefreshClick}> Refresh! </div> </div> )
Presentational Component의 관심사를 철저히 화면에 보여주는 것으로 국한시켰기 때문에 재사용성이 높을 수 밖에 없다. 어플리케이션의 다른 곳에서 Article
컴포넌트를 다른 출처에서 가져온 기사를 넣어 사용하고 싶다면, props로 꽂아주는 데이터만 바꿔주면 될 것이다.
Presentational Component가 how things look를 담당한다면, Container Component는 how things work을 담당한다. 바로 이 컴포넌트에서 state를 들고 있으면서 데이터를 가져오거나 수정하는 것이다. 그렇게 핸들링한 데이터를 Presentational Component에 props로 주입해주거나, 또 다른 Container Component에 넘기고 해당 컴포넌트에서 또 다른 stateful한 동작을 수행하도록 할 수 있다.
위와 마찬가지로 예시 어플리케이션에서 Container Component를 분리해보자.
// NewsListContainer.tsx const NewsListContainer = () => { const [articles, setArticles] = useState<Article[]>(null) const fetchArticles = async () => { const { data } = await axios.get(GET_ARTICLES_URL) setArticles(data.articles) } useEffect(() => fetchArticles(), []) return <NewsList articles={articles} onRefreshClick={fetchArticles} /> }
stateful한 동작은 모조리 NewsListContainer
에서 수행하면서, props에 대해 pure하게 화면을 그리는 몫은 오로지 Presentational Component인 NewsList
(와 그 하위 컴포넌트인 Article
)가 담당하게 되었다. 만약 개발자가 뉴스를 다른 곳에서 가져오고 싶거나, 새로고침 시의 동작을 수정하고 싶다면 NewsListContainer
만 손보면 될 것이고, 스타일이나 컴포넌트의 디자인을 수정하고 싶다면 NewsList
(와 Article
)을 손보면 될 것이다.
Container Component는 /containers
에, Presentational Component는 /components
에 배치하는 게 컨벤션인 것 같다. 디렉터리 배치에서 "같은 기능을 담당하는 파일"끼리 인접해서 배치하는 게 (e.g. Article.tsx
, Article.scss
를 같은 /Article
아래에 배치) "같은 형식의 파일"끼리 인접해서 배치하는 것(e.g. .tsx
는 .tsx
끼리, .scss
는 .scss
끼리)보다 더 나은 방법이라고 생각하지만, Container Component와 Presentational Component는 분리해도 좋을 것 같다. 어차피 Presentational Component는 재사용성이 높은 컴포넌트이기 때문에 여러 Container에서 사용하고 있을 것이고, 따라서 기능별로 묶는 것이 의미가 없을 것이기 때문이다.
"Presentational and Container Pattern은 너무 개쩌는 것 같아요! 청와대 국민청원에 올려서 이 패턴을 대한민국 헌법 1조 1항에 넣고 모두가 이를 지키도록 해야 하겠어요!"
디자인 패턴은 코드를 깔끔하게 유지함으로써 쓸데없는 자원 소모를 줄이고 혹시 있을 버그를 예방하거나 쉽게 찾아낼 수 있는 데 목적이 있다. 거꾸로 코드가 그렇게 냄새나지 않는다면 굳이 패턴에 목을 멜 필요는 없다. 가장 처음의 NewsList
코드도 별로 냄새나지 않는, 간단하면서 명료한 코드이다. 해당 컴포넌트에 더 많은 기능이 추가될 예정이 없다면 굳이 분리할 필요는 없을 것 같다.
Presentational Component
에 hook을 걸어서 stateful한 동작을 조금 시키더라도 그게 효율적이라면 그리 하면 될 것이고, Container Component
에 간단한 스타일을 주는 게 하위 컴포넌트 수를 줄일 수 있다면 그렇게 하면 된다. 클린 코드의 제1원칙은 유지보수의 편의성이지 패턴에 대한 광신도적 집착이 아니니까!