본 글은 제가 프로젝트에 리액트의 고급 디자인 패턴인 compound component 패턴과 render props 패턴을 프로젝트에 적용한 과정을 소개하고자 작성했습니다. 하지만 프로젝트에 적용한 내용을 소개하는 것을 넘어, 해당 패턴들은 무엇인지 그리고 이 패턴들을 객체지향의 관점에서는 어떻게 이해해볼 수 있을지를 고찰해 볼 것입니다. 여러분들이 이 글을 끝까지 읽고나면 얻게 될 내용은 다음과 같습니다.
만약 Compound Component 패턴과 Render Prop 패턴도 처음들어왔고, 객체지향의 여러 개념들을 처음 들으신 분들이라면 잘 오셨습니다. 여러분은 2마리 토끼를 잡게 될 것입니다. 제가 친절하게 설명해드릴테니, 천천히 글을 따라오시면 됩니다.
하지만, compound pattern이나 render prop pattern에 대해 이미 알고 계신 분들이라면 해당 내용은 굳이 안읽어도 되겠죠? 여러분의 시간을 아끼세요.
compound pattern이나 render prop pattern은 다른 아티클에서도 많이 찾아볼 수 있습니다. 그러나 compound pattern과 render prop pattern을 함께 사용한 코드나 이것을 객체지향적으로 조망해 본 글은 많이 없을 것입니다. 이 아티클의 핵심은 바로 여기에 있습니다. 여기에 주목해주세요👀
먼저 Compound Component Pattern에 대해서 알아보겠습니다. Compound의 뜻은 '합성'입니다. 그렇다면 단어적 의미로 생각해 볼 때, 컴포넌트들을 합성한 패턴이라고 이해할 수 있을 것 같습니다. 이 패턴을 사용하면, Prop drilling 문제를 해결함과 동시에, 선언적이고 이해하기 쉬운 컴포넌트를 작성할 수 있으며, 또한 ui 구조를 유연하게 변경할 수 있게 됩니다.
이런 패턴을 적용한 모습은 어떤 모습일까요? 아래와 같은 코드가 있었다고 해보겠습니다.
const Usage = () => {
return <MediumClap />
}
저희는 이 코드만 봐서는 MediumClap 내부에는 무엇이 들어있는지, 어떻게 구성되어있는지 알 수가 없습니다. 하지만 이 코드에 Compound Component 패턴을 적용하면 어떻게 변경될까요?
const Usage = () => {
...
return (
<MediumClap onClap={handleClap}>
<MediumClap.Icon />
<MediumClap.Count />
<MediumClap.Total />
</MediumClap>
)
}
이렇게 변경됩니다. Compound Component 패턴을 적용하고 나면 이런 모습을 볼 수 있을 것입니다. 하나의 부모컴포넌트에 자식 컴포넌트들이 묶여와서 사용되고 있네요. 이것의 구현 방법과 얻을 수 있는 이점에 대해서 알아봅시다.
어떻게 구현하는지에 대해서 간단하게 알아보도록 하겠습니다. 기본적인 구현방법은 매우매우 간단합니다. 기본적인 사용법에서 어떻게 옵션을 추가해나갈 것이냐에 따라서 난이도는 상이할 수 있습니다. 우선 기본적인것부터 알아보겠습니다.
// 구현부 - MediumClap.js
import Count from "./Count.js"
import Total from "./Total.js"
import Icon from "./Icon.js"
const MediumClap = ({ children, onClap }) => {
...
return <div>{children}</div>
}
MediumClap.Count = Count
MediumClap.Total = Total
MediumClap.Icon = Icon
// 사용부
const Usage = () => {
...
return (
<MediumClap onClap={handleClap}>
<MediumClap.Icon />
<MediumClap.Count />
<MediumClap.Total />
</MediumClap>
)
}
끝입니다. 구체적인 세부사항은 걷어냈습니다. 그냥 부모 컴포넌트라는 Object에 자식 Property를 가지도록 컴포넌트를 할당해 줄 뿐입니다. 이 다음에는 그냥 사용하기만 하면 됩니다.
이런 기본적인 구현 외에도 몇가지 옵션이 있다고 했습니다. 그 옵션에 대한 내용은 이 패턴의 장단점을 살펴본 후 다시 다뤄보도록 하겠습니다.
자식 컴포넌트에서 사용해야하는 props가 많으면 많을수록, 부모 컴포넌트를 사용하는 쪽에서 내려줘야하는 Props의 갯수는 많아졌을 것입니다. 예를 들어서 이런 모습이었을 것입니다.
const Usage = () => {
...
return <MediumClap
onClap={handleClap}
handleCount={handleCount}
updateTotal={updateTotal}
count={count}
total={total}
/>
}
하지만 Compound 컴포넌트 패턴으로 수정하고나면 곧바로 자식들에게 Props를 내려줄 수 있습니다.
const Usage = () => {
...
return (
<MediumClap onClap={handleClap}>
<MediumClap.Icon />
<MediumClap.Count count={count} handleCount={handleCount} />
<MediumClap.Total total={total} updateTotal={updateTotal} />
</MediumClap>
)
}
이렇게 수정하고나니 부모에게 넘겨주는 Props가 훨씬 simple해졌습니다. 그런데 이런 모습을 갖추고 있으면 단순히 simple해지는 것을 넘어서는 효과가 있습니다.
prop drilling이란 특정 컴포넌트는 A라는 state를 필요로 하지도 않으면서, 자식에게 전달하려는 목적으로 prop에서 받아오는 문제를 말합니다. 이런 prop drilling으로부터 코드에 불필요한 결합도도 많이 생기게 되고, 불필요한 렌더링을 유발할 수도 있습니다. 그런데, 위에서 보는것과 같이 Compound pattern을 활용하면, 자식이 필요로 하는 props를 부모를 거쳐 전달하는 것이 아닌, 자식에게 직접 전달할 수 있습니다.
사용하는 곳에서 UI의 구조를 유동적으로 바꿀 수 있습니다. 사용법에서도 보셨다시피 children 부분에 저희가 호출한 컴포넌트가 들어가게 됩니다. 때문에 저희가 사용할 때 컴포넌트의 순서를 조정하면 자연스럽게 UI에 렌더링되는 순서도 변경될 것입니다.
핵심이 되는 비지니스 로직은 부모 컴포넌트에게만 가지고 있게 됩니다. 자식에 해당하는 컴포넌트들은 자연스럽게 비즈니스 로직과 분리되게 되고, 그들의 역할에만 집중하는 컴포넌트가 됩니다. 그리고 이렇게 작성될 수록, 다른 컴포넌트에서도 재사용될 확률이 높아집니다.
기존의 코드보다도 훨씬 더 가독성이 높아집니다. 사용부만 보고서도 컴포넌트 내부가 어떻게 될지, 어떤 모습으로 그려질 지를 예상할 수 있다는 장점이 있습니다.
아니 그렇다면, 단점은 없을까요?
장점이 좀 압도적이긴 합니다만, 있습니다.
// before
const Usage = () => {
return <MediumClap />
}
// after
const Usage = () => {
...
return (
<MediumClap onClap={handleClap}>
<MediumClap.Icon />
<MediumClap.Count />
<MediumClap.Total />
</MediumClap>
)
}
딱 봐도 compound component 패턴을 적용한 코드가 훨씬 길어졌습니다. 음.. 길어진 것이 때로는 단점이 될 만한 상황이 있을 것 같습니다. 만약 저런 컴포넌트가 여러개 생긴 상황이라면 줄이 너무 길어질 수도 있고, 오히려 가독성이 떨어질 수도 있겠네요.
그치만 개인적인 생각으론 줄이 너무 길어지지만 않는다면, compound component 패턴을 통해 가독성이 올라가는 측면이 더욱 장점으로 부각되는 것 같습니다.
구현방법에서 봤듯이, 부모에서 사용한 컴포넌트들은 {children}으로 들어가게 됩니다. 컴포넌트를 사용한 순서대로 들어가게 되기 때문에, 기획의 의도와는 다르게 ui가 배치될 가능성도 있습니다. 때문에 사용부에서 주의해서 사용해 주어야 합니다. 하지만 이런 단점도 커버할 수 있는 부분이 있습니다. 추가적인 옵션에서 해당 내용을 다루어보겠습니다.
두가지 정도가 됩니다. 1)UI 레이아웃 고정하기와 2)context api로 상태 전달하기입니다.
현재는 {children}으로 컴포넌트가 들어오면서, 사용부에서 순서대로 나열되는대로 화면에 그려집니다. 그러나 분명 특정 상황에서는 부모컴포넌트가 미리 디자인 프레임을 가지고 있고, 그 프레임에 자식을 넣고 싶을 수도 있습니다. 또는 children으로 받아오는 컴포넌트의 타입을 확인하고, 위치를 고정시키고 싶을 수도 있습니다. 그럴 경우 같은 코드로 구현할 수 있습니다.
const DialogLabelButtonType = (<DialogLabelButton />).type;
function getDialogLabelButtons(children: ReactNode) {
const childrenArray = Children.toArray(children);
return childrenArray
.filter(
child => isValidElement(child) && child.type === DialogLabelButtonType,
)
.slice(0, 2);
}
interface DialogMainProps {
children?: ReactNode;
isOpen: boolean;
}
function DialogMain({children, isOpen}: DialogMainProps){
if(!isOpen) {
return null;
}
const dialogContents = getDialogContents(children);
const dialogLabelButtons = getDialogLabelButtons(children);
const dialogDimmed = getDialogDimmed(children);
return createPortal(
<div>
<div>{getDialogDimmed(children)}</div>
{dialogContents && (
<div>{dialogContents}</div>
)}
{dialogLabelButtons && (
<div>{dialogLabelButtons}</div>
)}
</div>,
document.body)
}
보시면 컴포넌트의 타입을 확인하고, 특정 컴포넌트의 타입이라면 특정 위치에 렌더링하도록 Compound Component 패턴을 구현하고 있습니다. 위 코드는 [합성 컴포넌트로 재사용성 극대화하기] 이 링크에서 가져온 코드인데, 더욱 세부적인 내용이 궁금하시다면 참고해보시면 좋을 것 같습니다.
자식에게 직접 props를 넘겨줄 수도 있지만, 상황에 따라선 context api를 활용하는 것이 유용할 수 있습니다. 자식들끼리 상태를 공유해야하는 상황이 예가 될 수 있을 것 같습니다. 그런 경우 아래와 같이 구현해볼 수 있습니다.
const MediumClapContext = createContext()
const { Provider } = MediumClapContext
const MediumClap = ({ children, onClap }) => {
const [clapState, setClapState] = useState()
...
const memoizedValue = useMemo(
() => ({
...clapState,
}),
[clapState]
)
return (
<Provider value={memoizedValue}>
<button
className={styles.clap}
onClick={handleClapClick}
>
{children}
</button>
</Provider>
)
}
특별한 내용은 없습니다. Context API에서 제공하는 Provider를 사용하고, 해당 Provider의 value에 자식들에게 전달할 value값을 넣어줍니다. 이때 value값을 memoization 해주어서 불필요한 렌더링이 생기지 않도록 신경써줍니다. 그리고 자식에서도 마찬가지로 Context API를 활용해 값을 사용하면 됩니다.
const Icon = () => {
const { isClicked } = useContext(MediumClapContext)
return (
...
)
}
조금 더 자세한 내용을 알고 싶으시다면, 다음의 링크를 참고해보시면 좋을 것 같습니다.
Compound components with react hooks
다음은 render prop pattern에 대해서 알아보겠습니다. render prop은 코드의 재사용성을 높임과 동시에, 관심사의 분리를 이룰 수 있도록 만들어주는 패턴입니다. 해당 패턴은 HOC(High Order Component)과 코드의 재사용과 관심사의 분리라는 동일한 목적과 효과를 가지고 있지만, prop을 활용한다는 점에서 차이가 있습니다.
여기서 말하는 render prop이란, prop에 넘겨주는 값이 JSX요소를 반환하는 함수인 prop을 말합니다. 말이 헷갈리죠? 그러니까 JSX 요소를 반환하는 함수를 prop에 넘겨주면, 그 prop을 우리는 render prop이라고 부를 수 있다는 말입니다. 그래도 추상적일 수 있으니 코드로 보겠습니다. (render prop에 대한 설명과 예시코드는 patterns.dev의 도움을 받았습니다.)
<Title render={() => <h1>I am a render prop!</h1>} />
요기 render라는 prop 부분이 보이시는가요. 저곳에 우리는 지금 어떤 함수를 넣어주고 있습니다. 어떤 함수인가요? JSX요소를 반환하는 함수를 넣어주고 있지요. 저런 prop을 우리는 "render prop"이라고 부릅니다. 저렇게 render prop에 render를 하는 함수를 넣어주면 어떤 일이 일어날까요?
const Title = ({render}) => render();
렌더링을 합니다. 이렇게 prop에 특정 JSX를 반환하는 함수를 넣어주는 패턴을 render prop pattern이라고 합니다. 왜 이렇게 prop로 렌더함수를 넘겨주는걸까요?
조금 더 구체적인 상황을 가져와보겠습니다. 특정 Input에 값을 넣으면 Input에 들어간 값을 다른 컴포넌트에 공유해야하는 상황이라고 생각해봅시다.
import React, { useState } from "react";
import "./styles.css";
function Input() {
const [value, setValue] = useState("");
return (
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
);
}
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input />
<Kelvin />
<Fahrenheit />
</div>
);
}
function Kelvin({ value = 0 }) {
return <div className="temp">{value + 273.15}K</div>;
}
function Fahrenheit({ value = 0 }) {
return <div className="temp">{(value * 9) / 5 + 32}°F</div>;
}
어 그런데, 지금 Input의 값이 Kelvin과 Fahrenheit 컴포넌트에 공유될 방법이 없습니다. 여러분 이 문제를 어떻게 해결하시겠습니까? 가장 기본적으로 시도할 수 있는 방법은 state 끌어올리기 일것입니다.
function Input({ value, handleChange }) {
return <input value={value} onChange={e => handleChange(e.target.value)} />;
}
export default function App() {
const [value, setValue] = useState("");
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input value={value} handleChange={setValue} />
<Kelvin value={value} />
<Fahrenheit value={value} />
</div>
);
}
state를 App컴포넌트까지 끌어올린다음, state를 set하는 함수를 Input에게 넘겨주고 그 값을 다른 컴포넌트들에게 공유합니다. 사실 이 방법은 공식문서에서도 소개하고 있고, 나쁜 방법은 아닙니다. 하지만 아쉬운 것은 자식이 깊어지는 상황이 생기면 구현이 복잡해질 수 있고, 불필요한 렌더링을 유발할 수도 있겠지요. 이 지점에 render prop 패턴이 들어옵니다.
위의 구현을 render prop의 패턴으로 수정해보겠습니다.
function Input(props) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.render(value)}
</>
);
}
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input
render={value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
/>
</div>
);
}
Input 컴포넌트에서 render prop을 받을 수 있도록 준비를 했습니다. 그리고 value를 관리하는 로직을 Input에 넣게 되었네요. 결과적으로 Input은 value를 관리하는 로직을 재사용할 수 있게 되었고, render prop으로 받아오는 어떤 컴포넌트에든지 해당 value를 넣어줄 수 있게 되었습니다. 정말 유용하지 않나요?
우선 여기까지만 이해하셔도 사용하시는데에는 무리가 없을 것이라 생각합니다. 하지만, render props에 대해 더 알아보고 싶다면 다음의 자료들을 추천드립니다.
공식문서 : render props
리액트 베타문서 : passing data with a render prop
patterns.dev : render props
자, 드디어 제 프로젝트에 compound & render prop 패턴을 적용한 내용을 소개해드릴 수 있게 되었습니다. 어쩌면 지금부터가 본론입니다.
다른 프로젝트의 코드를 이해하는 과정은 생각보다 피곤할 수도 있습니다. 하지만, 장담하건데 제가 가져온 코드는 위에서 예시로 소개한 코드보다 쉽습니다. 그리고 무엇보다 제가 프로젝트에서 어떤 사고의 과정을 통해서 해당 패턴을 적용했는지, 패턴들을 적용하면서 얻게 된 유익은 무엇인지를 살펴봄으로써, 여러분들이 얻게 될 인사이트가 있을 것이라고 생각합니다. 이것을 기대하시며, 위에서 소개한 패턴들을 저는 어떻게 적용하게 되었는지 그 상황부터 소개해보겠습니다.
주어진 상황을 설명하기 위해서는 저에게 주어져 있는 디자인 시안을 보여드릴 필요가 있을 것 같습니다. 저는 블로그 프로젝트를 진행중입니다. 그 중, 리스트를 보여주는 페이지가 있는데 각각의 리스트페이지의 모습은 아래와 같습니다.
유사성이 너무 눈에 띄지 않나요? 각각의 페이지에는 Hero 텍스트가 주어져있고, tag 리스트, 그리고 각 페이지에 알맞는 모양의 아티클 컴포넌트가 주어져있습니다. 여기서 페이지별로 달라지는 영역은 딱 아티클 컴포넌트와 그것을 보여주는 레이아웃 뿐입니다. 그런데 기존의 코드에는 몇가지 불편한 점이 있었습니다.
// ProgrammingPage.js
...
import UpperLayout from "./UpperLayout"
import ArticleList from "./ArticleList"
...
const ProgrammingPage = ({articles}) => {
...
return (
<>
<UpperLayout text="Programming" articles={articles} />
<ArticleList articles={articles} />
</>
);
};
기존의 코드에서는 이런 식으로 페이지 컴포넌트가 구성되어있었습니다. 아마 코드를 이해하는게 어렵지는 않을 것 같습니다. 이 코드가 가지고 있는 문제점을 살펴보기 위해서 간단하게 각각의 컴포넌트에 대해서 알아보겠습니다. 먼저 UpperLayout 컴포넌트입니다.
// UpperLayout.js
import Hero from "./Hero"
import TagSearch from "./TagSearch"
const UpperLayout = ({text, articles}) => {
return (
<Container>
<Hero text={Hero} articleLength={articles.length}/>
<TagSearch articles={articles} />
</Container>
)
}
여기서 UpperLayout이라는 컴포넌트는 페이지에서 아래 사진과 같은 부분을 담당하고 있습니다.
이 컴포넌트에서는 부모에서 받아온 articles를 가지고 Hero와 TagSearch에 보내주고 있습니다. 여기서 발견할 수 있는 문제점은 무엇일까요?
바로 prop drilling입니다. 사실 UpperLayout 컴포넌트는 articles라는 prop이 필요하지도 않으면서 해당 prop을 받아오고 있습니다. 이런 상황을 우리는 prop drilling이라고 합니다.
부모에서는 아래와 같이 사용하고 있었습니다.
<UpperLayout text="Programming" articles={articles} />
이 코드만 읽어서는 UpperLayout의 내부가 어떻게 이루어져있는지 예측하기가 힘듭니다. 사실 이 부분은 "문제"라고 하기는 어려울 수 있지만, 가독성을 더욱 높일 수 있는 가능성이 있다면 언제든지 고치면 좋을 것 같습니다.
UpperLayout 컴포넌트를 살펴봤으니, ArticleList 컴포넌트를 살펴보겠습니다.
// ArticleList.js
import Article from "./Article"
const ArticleList = ({articles}) => {
...
return (
<Grid>
{articles.map(article => {
return <Article
key={article._id}
path={encodeURI(article._id)}
title={article.title}
imgUrl={article.imgUrl}
description={article.description}
blurDataURL={article.blurDataURL}
createdAt={article.createdAt}
/>
})}
</Grid>
)
}
Article이라는 컴포넌트를 import 해와서 map으로 화면에 뿌려주고 있습니다. 그리고 받아온 articles라는 prop을 Article에 넣어주고 있습니다. 이 코드가 가지고 있는 문제점은 무엇일까요?
위에서 필요한 페이지들을 보여드렸다시피, Programming 페이지, Essay 페이지, Quotes 페이지들은 유사한 구조를 가지고 화면에 보여주고 있습니다. ArticleList를 다양한 페이지에서 재사용하고 싶은데, 현재로서는 다양한 컴포넌트를 보여주기가 힘듭니다. 만약 기능을 확장하려한다면 3가지 방법이 있을 수 있습니다.
첫번째는 ArticleList 내부에서 if문으로 확인을 하는 방법입니다. category를 prop으로 받아와서 if를 확인한 다음 컴포넌트를 보여주는 방법이죠.
const ArticleList = ({category, articles}) => {
if(category === "programming") return {articles.map(article => <Article .../>)}
if(category === "essay") return {articles.map(article => <Essay .../>)}
if(category === "qoutes") return {articles.map(article => <Quote .../>)}
}
이런 방법으로 분기를 나누어 리스트를 화면에 보여줄 수 있습니다. 그러나, 만약 페이지가 추가된다면, 그때 마다 이렇게 분기를 나누고 컴포넌트를 또 이 안에서 만들어주고 map을 돌려줘야합니다. 기능이 확장될 때마다 코드의 수정이 일어나는 구조이기 때문에 불편합니다.
두번째는 ArticleList가 내부를 수정해주는 방법이 아니라, 페이지가 만들어질 때마다 List 컴포넌트를 만들어주는 겁니다. EssayList, QuoteList 와 같은 컴포넌트를 만들어주는 것이죠. 그런데 이 방법 역시 코드가 중복되는 방식으로 이루어지기에 DRY 원칙을 위반합니다.
세번째는 children을 이용하는 방법입니다. children을 활용하면 아래와 같은 코드가 될 것입니다.
const ArticleList = ({children : React.ReactElement[], articles}) => {
return (
<Grid>
{children}
</Grid>
)
}
그런데 문제는 이렇게 children으로 받아오면 articles 라는 prop를 children에게 전달해 줄 방법이 없습니다.
그렇다면 저는 이 문제들을 어떻게 해결했을까요?
우선 저는 Compound Component를 적용하여 기존에 가지고 있던 Prop Drilling 문제와 적용부만 보고선 내부를 이해하기 어려운 문제를 해결했습니다. 그 과정을 살펴보겠습니다. 우선 Compound Component패턴을 적용할 ListPageContainer를 만들었습니다. 그리고 이 컴포넌트와 함께 묶어줄 컴포넌트들을 가져와서 묶어줍니다.
// ListPageContainer.tsx
import UpperLayout from "./UpperLayout";
import ArticleList from "./ArticleList";
import Hr from "./Hr";
interface Props {
children: React.ReactElement[];
}
const ListPageContainer = ({ children }: Props) => {
return <>{children}</>;
};
export default ListPageContainer;
// 아래에서 가져온 컴포넌트들을 묶어준다.
ListPageContainer.UpperLayout = UpperLayout; // 이 컴포넌트는 또 다른 Compound Component다
ListPageContainer.ArticleList = ArticleList;
ListPageContainer.Hr = Hr;
그리고 UpperLayout이라는 컴포넌트를 또 다시 한번 Compound Component로 만들어줍니다.
// UpperLayout.tsx
...
interface Props {
children: React.ReactElement[];
}
const UpperLayout = ({ children }: Props) => {
return <Layout>{children}</Layout>;
};
export default UpperLayout;
UpperLayout.Hero = Hero;
UpperLayout.TagSearch = TagSearch;
이렇게 만들어져있습니다. 이제 이렇게 만들어진 ListPageContainer를 활용해 ListPage를 구현해보겠습니다.
// pages/programming/index.tsx
const ProgrammingPage = ({articles,tags}) => {
...
return (
<ListPageContainer>
<ListPageContainer.UpperLayout>
<ListPageContainer.UpperLayout.Hero
text="Programming"
listLength={articles.length}
/>
<ListPageContainer.UpperLayout.TagSearch tags={tags} />
</ListPageContainer.UpperLayout>
<ListPageContainer.Hr />
<ListPageContainer.ArticleList articles={articles} />
</ListPageContainer>
);
};
이렇게 ListPage를 구현했습니다. 보시다시피 자식 컴포넌트가 필요로하는 prop을 중간부모를 거치지 않고 직접 전달해줌으로써 prop drilling 문제를 해결하고 있으며, 내부가 감춰져있던 컴포넌트를 사용하는 곳에서도 예측할 수 있도록 코드를 수정했습니다. 불편했던 부분들이 해결되었습니다. 보기좋네요👍
근데 아직도 ArticleList가 가지고 있던 확장성의 문제는 해결하지 못했습니다. 이 문제는 어떻게 해결할 수 있을까요?
Render Prop패턴을 적용해서 확장성 없는 구조에 대한 문제를 해결했습니다. 다음은 Render Prop 패턴을 적용한 ArticleList 컴포넌트입니다.
...
interface Props {
articles: ViewArticleElement[];
renderListItem: (article: ViewArticleElement) => JSX.Element;
}
const ArticleList = ({ articles, renderListItem }: Props) => {
...
return (
<Grid >
<>
{articles.map((article: ViewArticleElement) =>
renderListItem(article),
)}
</>
</Grid>
);
};
render prop의 이름을 renderListItem로 지어줬습니다. renderListItem애는 받아올 함수의 타입을 정의해주었습니다. article이라는 인자를 받아, JSX요소를 반환하는 함수입니다. 코드를 이렇게 수정함으로써 얻을 수 있는 효과는 무엇이었을까요? 바로 확장성입니다. 더 이상 ArticleList 컴포넌트는 특정 Article 컴포넌트에 묶이지 않고, prop로 받아오는 컴포넌트를 자유롭게 받아오면서, 그 컴포넌트에 prop도 넘겨줄 수 있게 되었습니다. 바로 이것을 사용한 부분을 살펴봅시다. 먼저 ProgrammingPage입니다.
const ProgrammingPage = ({articles,tags}) => {
...
return (
<ListPageContainer>
<ListPageContainer.UpperLayout>
<ListPageContainer.UpperLayout.Hero
text="Programming"
listLength={articles.length}
/>
<ListPageContainer.UpperLayout.TagSearch tags={tags} />
</ListPageContainer.UpperLayout>
<ListPageContainer.Hr />
<ListPageContainer.ArticleList // 바로 이 부분!!
articles={articles}
renderListItem={(article) => ( // 여기 render prop에 JSX를 반환하는 함수를 넣어주고 있다.
<Article article={article} key={article._id} />
)}
/>
</ListPageContainer>
);
};
ProgrammingPage 에서는 Article이라는 컴포넌트가 필요했기 때문에, Article 컴포넌트를 반환하는 함수를 render prop에 넣어주고 있습니다. 그렇다면 EssayPage에서 Essay 컴포넌트를 넣어주고 싶으면 어떻게 하면 될까요?
const EssayPage = ({articles,tags}) => {
...
return (
<ListPageContainer>
<ListPageContainer.UpperLayout>
<ListPageContainer.UpperLayout.Hero
text="Essay"
listLength={articles.length}
/>
<ListPageContainer.UpperLayout.TagSearch tags={tags} />
</ListPageContainer.UpperLayout>
<ListPageContainer.Hr />
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Essay article={article} key={article._id} /> // 이 부분만 수정하면 된다.
)}
/>
</ListPageContainer>
);
};
그냥 render prop에 전달하는 함수가 반환하는 JSX만 수정하면 됩니다. Essay 페이지 뿐만이 아니라, QuotePage에서도 혹 새롭게 생길지도 모르는 페이지에서도 render prop에 넣어주는 컴포넌트만 수정하면 기능의 확장은 손 쉽게 일어납니다.
이렇게 저는 compound component 패턴과 render prop 패턴을 활용해 가독성있고, 재사용성 높으며, 확장가능성 있게 설계할 수 있었습니다. 그럼 지금부터는 이 각각의 패턴에 담겨있는 객체지향적 의미에 대해서 고찰해보겠습니다.
가장 먼저 생각해 볼 관점은 개방폐쇄원칙입니다. 이는 객체지향의 5대 원칙인 SOLID에서 O를 맡고 있습니다. 그렇다면 개방 폐쇄 원칙의 정의는 무엇일까요?
개방-폐쇄 원칙(OCP, Open-Closed Principle)은 '소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다'는 프로그래밍 원칙이다. - 위키백과
다행이 위키백과에서도 알기 쉽게 정의되어있습니다. 이를 저희 컴포넌트에 적용시킨다면, 컴포넌트는 기능의 확장에 대해서는 열려있어야하고, 수정에 대해서는 닫혀있어야 한다는 의미가 됩니다.
저희가 작성한 ArticleList컴포넌트를 다시 가져와 OCP의 관점으로 살펴보겠습니다. 먼저 render prop을 적용하기 전의 코드입니다.
const ArticleList = ({articles}) => {
...
return (
<Grid>
{articles.map(article => {
return <Article
...
/>
})}
</Grid>
)
}
이렇게 ArticleList안에 코드 자체로 Article이 들어가 있는 상황이기 때문에 기능의 확장이 필요할 때, 저희는 if문을 만들어서 다양한 컴포넌트를 지원할 수 있게 만들던지, 새로운 List컴포넌트를 만들어야했습니다. 결국 기능이 추가될 때마다 List 컴포넌트와 관련해서 코드의 수정이 일어납니다. 하지만 render prop 패턴을 적용한 이후에는 어떻게 되었나요?
interface Props {
articles: ViewArticleElement[];
renderListItem: (article: ViewArticleElement) => JSX.Element;
}
const ArticleList = ({ articles, renderListItem }: Props) => {
...
return (
<Grid >
<>
{articles.map((article: ViewArticleElement) =>
renderListItem(article),
)}
</>
</Grid>
);
};
여기 보이시는 renderListItem 부분. 이 부분을 수정한 덕분에 이 컴포넌트는 개방 폐쇄원칙을 달성한 원칙이라고 부를 수 있게 되었습니다. 왜냐구요? 기능이 추가되어도, ArticleList에는 더 이상 코드의 수정이 일어나지 않기 때문입니다.
여기서 말하는 기능이란 renderListItem에 들어올 컴포넌트의 종류를 말합니다. 사용부를 보면,
// Article을 사용할 때
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Article article={article} key={article._id} /> // 딱 이 부분만 수정하면 된다.
)}
/>
// Essay를 사용할 때
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Essay article={article} key={article._id} /> // 딱 이 부분만 수정하면 된다.
)}
/>
// Quote를 사용할 때
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Quote article={article} key={article._id} /> // 딱 이 부분만 수정하면 된다.
)}
/>
이렇게 넣어줄 컴포넌트만 새롭게 만들면 됩니다. 그리고 기존의 ArticleList 내부에는 어떤 코드의 변경도 일어나지 않습니다. 이렇게 개방폐쇄원칙을 적용하면 저희가 얻을 수 있는 이점은 무엇일까요?
우선 객체지향의 궁극적인 목적은 유지보수를 용이하게 하는 것입니다. 조금 더 쉽게 말하면, 기능의 추가나 수정이 생길 때 코드의 변경을 최소화하는 것이라고 할 수 있습니다. 이것을 위해 객체지향의 다양한 개념과 원칙이 소개되는 것입니다.
그리고 개뱅폐쇄원칙은 이런 궁극적 목적을 달성할 수 있도록 도와주고 있습니다. 기존의 코드에는 변경이 일어나지 않지만, 기능을 쉽게 확장할 수 있게 함으로써 유연하고 확장성이 높고, 코드의 변경을 최소화한다는 목적을 달성하고 있습니다.
개방폐쇄원칙의 이점까지 알아보았습니다. 하지만, 아직 render prop에는 객체지향적으로 바라볼 부분이 존재합니다.
render prop 패턴에서는 의존성 주입의 모습까지 볼 수 있습니다. 먼저 의존성 주입의 정의부터 알아봅시다.
의존성 주입(Dependency Injection)은 프로그램 디자인이 결합도를 느슨하게 되도록하고 의존성 역전 원칙과 단일 책임 원칙을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다. - 위키백과
흠,, 위키가 의존성 주입에 대해서는 약간 어렵게 설명했다는 생각이 듭니다. 쉽게 말하면, 의존성 주입은 특정 객체가 내부에서 코드시점에서 필요했던 코드를 제거하고, 객체가 실행될 때 필요한 코드를 외부에서 받아올 수 있도록 수정해주는 패턴입니다. 이것을 통해서 객체가 가지고 있던 결합도와 의존성을 낮추어주는 역할을 합니다. 말만 들어서는 헷갈릴 것 같습니다. 코드를 보겠습니다. 먼저 기존의 코드입니다.
import Article from "./Article" // ArticleList는 Article이 필요하다. (의존성이 있다.)
const ArticleList = ({articles}) => {
...
return (
<Grid>
{articles.map(article => {
return <Article // ArticleList는 Article이 필요하다. (의존성이 있다.)
...
/>
})}
</Grid>
)
}
기존의 코드를 보면 ArticleList가 온전하게 구현되기 위해 Article이라는 컴포넌트가 "필요한" 것을 알 수 있습니다. "의존성"이라는 용어가 익숙하지 않은 분들을 위해 설명드리자면, "필요한 대상이 있다"고 할 때, "의존성이 있다"고 합니다. DI패턴은 이런 의존성을 내부에서 제거하고 외부에서 주입하도록 만듭니다. 그럼 render prop 패턴을 적용한 코드를 보겠습니다.
// import Article from "./Article" 은 제거되었다.
interface Props {
articles: ViewArticleElement[];
renderListItem: (article: ViewArticleElement) => JSX.Element; // 외부에서 주입받을 준비를 한다.
}
const ArticleList = ({ articles, renderListItem }: Props) => {
...
return (
<Grid >
<>
{articles.map((article: ViewArticleElement) =>
renderListItem(article), // Article 컴포넌트가 제거되고, 동적으로 받을 준비를 했다
)}
</>
</Grid>
);
};
보시다시피 render prop 패턴을 적용한 이후, 코드시점에 의존성을 가지고 있던 Article 컴포넌트가 제거되었습니다. 그럼 외부에서 의존성을 주입하는 부분을 보겠습니다. 물론 위에서 봤던 코드랑 똑같습니다.
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Article article={article} key={article._id} /> // 외부에서 Article이라는 의존성을 주입해주고 있다.
)}
/>
이렇게 내부에서 의존성을 제거하고, 외부에서 의존성을 주입해줌으로써, 의존성 주입 패턴을 적용할 수 있었습니다. 그러니까, render prop 패턴을 활용하면 의존성 주입 패턴도 활용될 수 있다는 이야기입니다. 그렇다면 이 의존성 주입(DI)패턴을 적용하면 얻을 수 있는 이점은 무엇일까요?
1) 결합도를 낮출 수 있습니다. 결합도란 객체가 서로에게 상호의존적인 수준을 말합니다. 만약 서로에 대한 상호의존의 수준이 높으면 높을수록 한 객체가 변경되었을 때 다른 객체에게 미치는 영향이 크겠죠? 때문에 결합도를 낮게 유지할 수록, 변경의 여파를 더욱 잘 차단할 수 있습니다. 코드에서 보셨던 것 같이 ArticleList는 Article이라는 컴포넌트에 대해서 의존성을 가지고 있었습니다. 이는 생각보다 낮은 결합도였다고 할 수 있으나, 그것마저 의존성 주입패턴을 도입했더니 사라지게 되었습니다. 코드의 변경이 차단된 것입니다.
2) 유연해집니다. 의존성을 외부에서 주입하게 만들어 줌으로써, 새로운 의존성으로 갈아끼울 때 드는 비용이 감소했습니다. 이는 바꿔말하면 새로운 기능을 추가할 때 드는 비용이 줄어들었다는 말입니다.
외에도 여러 이점이 있지만 여기선 이 정도로만 살펴보겠습니다.
그나저나, render prop 패턴 안에서 2가지의 객체지향 패턴과 원칙을 발견할 수 있다니 무슨 일일까요? 도대체 OCP와 DI는 어떤 관계가 있는걸까요?
사실 이 둘은 매우 밀접한 관계를 가지고 있습니다. 하지만 엄밀하게 다른 이름을 가지고 있고, 다른 원칙, 다른 패턴입니다. 이 둘은 목적이 다릅니다.
먼저 개방폐쇄원칙같은 경우에는 기존의 코드에는 수정이 일어나지 않고도, 기능을 확장하는 것이 가능하게 만드는 것이 목적입니다. 반면 의존성 주입은 컴포넌트의 의존성을 관리하기 위한 패턴입니다. 그런데 이 의존성 주입을 사용하면 의존성을 매우 쉽게 갈아끼울 수 있다는 점에서 기능 확장에 매우 용이해집니다. 심지어 의존성을 주입하는 대상 컴포넌트의 내부에선 의존성을 제거한 상태이기 때문에 수정이 발생하지 않습니다. 이는 바꿔말하면, 의존성 주입 패턴을 사용하면 개방폐쇄원칙을 달성하는데 도움이 됩니다. 위에서 언급한 이유 때문에 그렇습니다.
이로써 저희는 개방폐쇄원칙과 의존성 주입의 차이를 이해하고, 각각이 어떤 관계를 맺고 있는지도 알게되었습니다. 다음으로는 제어 역전 원칙으로 render prop 패턴을 이해해보겠습니다.
제어 반전, 제어의 반전, 역제어는 프로그래머가 작성한 프로그램이 재사용 라이브러리의 흐름 제어를 받게 되는 소프트웨어 디자인 패턴을 말한다. 줄여서 IoC(Inversion of Control)이라고 부른다. - 위키
역시 위키의 정의는 한 번에 이해하기가 힘듭니다. 조금 더 쉽게 설명해보면, 특정 컴포넌트의 동작이 스스로 결정되는 것이 아니라, 다른 프레임워크나 컨테이너에 의해서 제어되는 원칙 혹은 패턴을 제어의 역전이라고 부릅니다. 이런 원칙을 적용함으로써 목적하는 바는 시스템에서 컴포넌트 사이의 결합도를 낮추는 것입니다. 당연히 결합도를 낮추면 해당 모듈은 훨씬 더 유연해 질 것입니다.
코드를 보겠습니다.
<ListPageContainer.ArticleList
articles={articles}
renderListItem={(article) => (
<Article article={article} key={article._id} />
)}
/>
보시다시피 render prop을 통해서 특정 컴포넌트를 ArticleList에게 넘겨줍니다. ArticleList를 사용하는 사용부에서는 더 이상 render Prop에 넘겨주는 컴포넌트에 대한 제어권이 없어집니다. ArticleList가 받아온 Article가 그 내부에서 컴포넌트를 어떻게 제어할지에 대한 통제권을 가진 상황이 되었습니다. 때문에 render prop을 통해 IoC가 달성되었다고 할 수 있습니다.
위에서도 설명드렸지만, 이런 제어 역전을 통해서 얻게 되는 이점은 유연성입니다. 컨테이너이나 프레임워크에 해당하는 객체에서는 인자로 받아오는 대상에 대해서 더 이상 의존성을 가지지 않습니다. 때문에 더욱 유연하게 동작합니다. 저희가 ArticleList에 Article 컴포넌트도 넣고, Essay 컴포넌트도 넣을 수 있는 것 처럼요.
제어의 역전의 정의에 대해서는 위에서도 살펴보았습니다. 그렇다면 Compound Component에서 어떻게 제어 역전을 볼 수 있을까요? 말씀드렸다시피, 사용부에서 특정 컴포넌트를 넘겨주고나면 더 이상 컴포넌트가 어떤 방식으로 통제될 지, 어떤 방식으로 렌더링될지 통제권이 없어집니다. 부모컴포넌트가 가지고 있는 로직에 의해서 받아온 자식 컴포넌트들이 통제될 것입니다.
function App() {
return (
<Menu>
<Menu.MenuButton>
Actions <span aria-hidden>▾</span>
</Menu.MenuButton>
<Menu.MenuList>
<Menu.MenuList.MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
<Menu.MenuList.MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
<Menu.MenuList.MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
</Menu.MenuList>
</Menu>
)
}
여기 메뉴버튼과 메뉴리스트를 보여주는 Compound Component가 있습니다. MenuButton을 클릭하면 MenuList가 보여지는 방식입니다. 내부적으로는 이런 로직을 구현하기 위해 Menu라는 부모 컴포넌트가 자식 컴포넌트들을 통제하고 있습니다. 이렇게 코드를 사용하는 클라이언트 입장에서는 컴포넌트들에 대한 통제권을 잃어버리고 컨테이너 혹은 프레임워크게 제어권을 넘겨줌으로써 제어 역전이 달성됩니다. 보시다시피 이렇게 제어역전이 됨으로써, 더욱 유연한 구조를 갖출 수 있게 되었으며, 훨씬 더 깔끔한 API가 되었습니다.
결론입니다. 저는 이 아티클을 통해서 단순히 리액트의 특정 디자인 패턴을 사용하는 것을 넘어서, 이것이 가지고 있는 의미는 무엇인지 객체지향의 원리로 살펴보았습니다.
사실 처음 객체지향을 공부할 때까지만 해도 대부분의 코드들이 클래스로 이루어져있고, 자바코드를 기반으로 되어있었기 때문에 이것을 어떻게 리액트 프로젝트에 적용할 수 있을지에 대한 아이디어가 없었습니다. 하지만 객체지향은 결국 좋은 설계를 하기 위한 패러다임이었고, 좋은 설계란 유지보수를 쉽게 할 수 있도록 만들어주는 것임을 기억했습니다. 그리고 객체지향이 소개하는 각 개념과 원리에 얽매이기 보다는, 결국 그것들이 어떻게 좋은 설계를 가능하게 하는지를 생각하려고 노력했습니다. 이를 통해 좋은 설계가 만들어지기 위한 개념과 시선을 장착했습니다.
그러고나니 저의 리액트 코드가 가지고 있는 설계의 한계들이 눈에 들어오기 시작했습니다. 그 한계들을 극복할 수 있는 디자인 패턴을 찾고 적용하다보니, 또 다시 그 디자인패턴에 숨어있는 객체지향적 원리를 이해하게 되었습니다. 결국 리액트에서도 일부 객체지향 프로그래밍을 적용하는 것은 가능하다는 생각이 들었습니다.
객체지향을 공부하면서 코드를 하나의 작품처럼 만들어나가는 그 과정에 매력을 느꼈는데, 이것을 리액트에 적용할 생각을 하니 약간 풀이 꺽였습니다. 하지만, 이번 계기를 통해 리액트의 다양한 디자인 패턴, 그리고 나도 모르게 사용하고 있던 코드 속에서 객체지향의 원리가 숨어있다는 것을 더욱 확실히 알게 되었습니다. 그러더니 더더욱 디자인 패턴을 알고 싶고 공부하고 싶은 욕심이 생기는 요즘입니다.
부디 저의 글을 통해서 리액트 디자인 패턴 속에서 객체지향의 원리를 발견할 수 있으며, 이를 통해 리액트 프로젝트에서도 좋은 설계를 할 수 있다는 인사이트를 얻었기를 기대하며 글을 마무리하겠습니다 🙏
지금까지 코드짜면서 아쉬웠던 부분이 꽤나 많았었고 라이브러리를 사용하면서 어떻게만들었는지 궁금했던 부분이 많이있었는데 이글 한방에 해결이 되네요? 정말 감사합니다
render props 패턴으로 구현된 컴포넌트는 자체적으로 렌더링 로직을 구현하는 대신, react 엘리먼트 요소를 반환하고 이를 호출하는 함수를 사용합니다 myherbalife
개꿀 리액트 디자인패턴 팁 배워갑니다!!!!정말 멋진글이네요!!!