여러분들은 개발을 하면서 얼마나 설계에 대해 고민하시나요? 자판으로 코드를 입력하기 전까지 어느정도의 시간을 고민하시는지 궁금합니다.
개발자로서 일을 시작하기 이전에 여러 대외활동을 했었는데, 장기 해커톤을 하던 사이드 프로젝트를 하던 항상 이에 대한 갈증이 있었습니다. 그래서 어떻게 해야 돌아가기만 하는 코드에서 유지보수가 쉽고 새로운 기능을 붙이기도 좋은 코드를 짤 수 있을까 하는 고민을 하려고 노력했는데, 그 어떻게에 대한 답을 내리지 못하고 결국 시간에 쫓겨 추후 리팩토링을 기약하며 돌아게가만 하는 코드를 완성시켰던 것 같아요. 협업 팀에서도 항상 일정이 있었고 확장성이 있는 기획이라도 추후에 이어 지는 것은 미지수였기 때문에 일정에 맞춰 어떻게든 만드는게 팀에게는 더 중요했습니다.
고민하기엔 시간이 충분하지 않았고, 더 좋은 코드와 설계에 대한 갈증은 커져갔기 때문에 실무를 경험하고 싶었습니다. 그리고 저는 이제 4개월차에 들어선 프론트엔드 개발자가 되었습니다. 프로젝트를 시작할 때 컨벤션을 어떻게 정해야 할지, 폴더 구조는 어떤식으로 정하는게 좋을지 실무자들의 코드는 어떨지 막연히 궁금했고 그런 점들은 규칙같은 부분이라 생각하여 금방 배울 수 있을거라고 생각했습니다.
하지만 그 이상으로 설계적인 부분들이 정말 중요하다는 걸 많이 느끼게 되었고, 처음 입사했을 때 온보딩 프로세스를 겪으면서 "객체지향의 사실과 오해" 라는 책을 추천 받는 것을 시작으로 함수형 프로그래밍을 중심으로 개발하는 프론트엔드 생태계에서도 객체지향의 개념은 컴포넌트를 설계하는데에 있어서는 중요하다는 걸 알게 되었습니다.
먼저 공식문서에 나와있는 React로 코드를 작성하는 방법, 가이드라인을 잡고 자세한 예시와 함께 설명드리도록 하겠습니다.
선언형
으로 프로그래밍 된다고 나와있다.명령형 프로그래밍은 무엇을 어떻게 할 것인가에 가깝고,
선언형 프로그래밍은 무엇을 할 것인가와 가깝다.
function double (arr) {
let result = [];
for (let i=0; i<arr.length; i++) {
result.push(arr[i] * 2) }
return (result);
}
function double (arr){
return arr.map(x => x*2);
}
useState
상태 사용을 선언한다.useEffect
부수효과 발생을 선언한다.children
or ReactNode
등을 통해서 사용하는 개발자의 입장에서 넣어줄 수 있도록 위임어떤 시스템을 구성하는 여러 요소 중 하나를 컴포넌트라고 할 수 있습니다.
그렇다면 React 의 경우에는 시스템을 UI로 볼 수 있기 때문에, UI를 구성하고 있는 요소를 컴포넌트라고 할 수 있겠네요!
어느 제품이든 UI에는 일정한 패턴을 두고 반복도는 요소들이 존재합니다. 그렇기 때문에 반복되는 요소들을 매번 개발하기보다는, 미리 만들어둔 요소를 재사용하여 개발 리소스를 줄일 수 있습니다. 실제로 잘 추상화하여 만들어둔 컴포넌트를 재사용 함으로써 개발할 수 있는 경우가 많았습니다.
여기서 포인트는 잘 추상화 했을 때
입니다. 추상화가 잘 이루어져야 필요한 곳에 적절하게 재사용하여 UI의 일관성을 유지하고 리소스를 절감할 수 있습니다. UI를 결정짓는 요소들이 정말 많기 때문에 수 많은 요소들 중 하나라도 달라지면 재사용이 어렵기 때문이죠.
이제부터 들어가기에서 말했던 "어떻게 잘 나눌 수 있을까?" 에 대한 고민을 조금이나마 해결하고자 몇가지 방법들을 같이 이야기하고자 합니다. 먼저 역할과 책임
에 따라서 분리한다는 소프트웨어 기본원칙을 UI 컴포넌트에도 적용해보면 어떨까요?
프론트엔드 어플리케이션이 복잡해지면서 수많은 데이터들을 관리하게 되었습니다. 데이터를 가져오고 이를 보여주는 부분이 복잡해지자 이 부분을 추상화
하려는 노력들이 생겨났습니다. React와 같은 라이브러리를 사용함으로써 이를 컴포넌트 기반으로 추상화 하는 것이죠. 그러면서 크게 다음의 세 가지 정도의 역할을 하게 되었습니다.
컴포넌트의 역할을 나열해 보았을 때 데이터
가 빠지지 않습니다. 컴포넌트를 데이터 기준으로 나눠야 역할을 기반으로 컴포넌트를 분리할 수 있겠다 라고 추측됩니다. 그럼 컴포넌트를 데이터 중심으로 나눠서 역할과 책임을 분리해 볼까요?
💡 컴포넌트를 역할과 책임을 기준으로 분리해보자.
외부로부터 주소 목록을 가져와 렌더링하는 페이지를 컴포넌트로 분리해 보겠습니다.
// AddressPage.tsx
function AddressPage() {
const [addresses, setAddresses] = useState<주소[] | null>(null)
const handleSaveClick = (id: string) => {
// save
}
const handleUnsaveClick = (id: string) => {
// unsave
}
useEffect(() => {
(async () => {
const addresses = await fetchAddressList()
setAddresses(addresses)
})()
}, [])
return (
<section>
<h1>주소목록</h1>
<ul>
{addresses != null
? addresses.map(({ id, address, saved }) => {
return (
<li key={id}>
{address}
<button onClick={saved ? handleUnsaveClick : handleSaveClick}>
{saved ? '저장' : '삭제'}
</button>
</li>
)
})
: null}
</ul>
</section>
)
}
주소 목록을 렌더링하는 AddressPage
컴포넌트는 외부에서 데이터를 가져오고, 그 데이터를 UI로 표현하며 사용자의 인터랙션을 받고 있습니다.
function AddressPage() {
return (
<section>
<h1>주소목록</h1>
<AddressList />
</section>
)
}
이번엔 주소목록
, 주소
라는 두개의 데이터를 기준으로 컴포넌트를 분리 해 보았습니다.
function AddressList() {
const [addresses, setAddresses] = useState<주소[] | null>(null);
// call fetchAddressList
const handleSaveClick(id: string) => {
// save
}
const handleUnSaveClick(id: string) => {
// unsave
}
return (
<ul>
{addresses.map(address => {
return (
<AddressItem
key={address.id}
name={address.name}
location={address.location}
price={address.price}
saved={address.save}
onSaveClick={handleSaveClick}
onUnsaveClick={handleUnsaveClick}
{...address}
/>
);
})}
</ul>
);
}
여기에서 주목할 만한 부분은, AddressPage
에서 데이터를 불러와 props로 AddressList
에 넘겨준게 아니라 스스로 AddressList
가 가져올 수 있도록 한 것입니다.
이 컴포넌트 에서도 또한 AddressItem
컴포넌트를 만들어서 하나의 주소를 보여주는 컴포넌트로 또 분리해낼 수 있겠습니다.
// 1번 AddressItem
interface Props {
name: string;
location: string;
price: number;
// ...
address: 주소;
}
function AddressItem({ name, location, price, saved, onSaveClick, onUnsaveClick }: Props) {
return (
<li>
<span>{name}<span>
<span>{location}<span>
<span>{price}<span>
<button onClick={saved ? onUnsaveClick : onSaveClick}>
{saved ? '저장' : '삭제'}
</button>
</li>)
}
여기에 아직 두 가지 정도 고려해볼 만한 점이 남아있습니다.
AddressList
보단 AddressItem
의 역할에 더 어울리는 것 같습니다. AddressItem
에서 보여줘야하는 주소 데이터가 추가되거나 삭제되는 등의 변경사항이 있으면, AddressItem
의 interface를 수정해 주고, AddressList
에서 props로 넘겨주는 작업이 필요합니다. 단순히 보여줄 데이터가 하나 추가된 작업이지만, 두 컴포넌트를 변경시켜주는 작업인 것이죠.interface Props {
주소: AddressItem_주소fragment;
}
function AddressItem({ address, saved, onSaveClick, onUnsaveClick }: Props) {
return (
<li>
{address}
<button onClick={saved ? onUnsaveClick : onSaveClick}>
{saved ? '저장' : '삭제'}
</button>
</li>)
}
gql`
fragment AddressItem_주소 { # 어떤 컴포넌트에서 어떤 props 이름으로 쓰이는지 확인 가능
name
location
price # 얘만 추가해주면 된다.
}
`
// 2번 AddressItem
function AddressItem({ id, address, saved }: 주소) {
const handleSaveClick = () => {
// save
}
const handleUnsaveClick = () => {
// unsave
}
return (
<li key={id}>
{address}
<button onClick={saved ? handleSaveClick : handleUnsaveClick}>
{saved ? '저장' : '삭제'}
</button>
</li>
)
}
각 개별 주소 데이터를 기준으로 저장하고 삭제하고 있었기 때문에 id: string
의 인자를 이벤트 핸들러가 알 필요가 없어졌습니다. 불필요한 인자가 삭제된 것이죠.
interface
도 주소 전부를 받아오도록 수정되었습니다. 공통의 주소 모델을 AddressList
에서 받아오고, 그대로 데이터를 Item에 넘겨주었기 때문에 더 보여주고 싶은 데이터만 UI에 새로 추가하면 그만입니다.
여기서 볼 수 있던 사실은, 데이터 중심의 모델은 진리의 원천(Single Source of Truth)로 관리되어야 한다는 사실입니다.
function AddressList() {
const [addresses, setAddresses] = useState<주소[] | null>(null);
// call fetchAddressList
return (
<ul>
{addresses.map(address => {
return <AddressItem key={address.id} {...address} />;
})}
</ul>
);
}
AddressList는 다음과 같이 개별 아이템 저장 / 삭제 역할을 했던 이벤트 핸들러를 없애고 다음과 같은 모습을 띄게 되었습니다.
// 3번 AddressItem
function AddressItem({ id, address, saved }: 주소) {
return (
<li key={id}>
{address}
{saved ? <SaveAddressButton id={id} /> : <UnsaveAddressButton id={id} />}
</li>
)
}
function SaveAddressButton({ id }: { id: string }) {
const handleSaveClick = () => {
// save
}
return <button onClick={handleSaveClick}>저장</button>
}
function UnsaveAddressButton({ id }: { id: string }) {
const handleUnsaveClick = () => {
// unsave
}
return <button onClick={handleUnsaveClick}>삭제</button>
}
여기서 조금만 더 역할을 생각해 볼게요. AddressItem의 원래 의도한 역할은 개별 주소 데이터를 보여주는 것 이었습니다. 그런데 저장과 삭제 하는 역할까지 수행하고 있기 때문에 이를 버튼 컴포넌트로 분리하여 역할을 위임해줄 수 있습니다.
해당 예시에서는 주석으로 역할만 적혀있었지만, 수정하는 역할의 api 요청을 보낸다면 더욱 더 분리해주는 게 좋을 것 같습니다.
여기까지 고생 많으셨습니다. 우리는 여태까지 다음과 같은 기준으로 컴포넌트를 분리해 보았습니다.
- 컴포넌트가 의존하고 있는 데이터를 기준으로 분리한다.
- 컴포넌트의 이름에 맞는 역할을 기준으로 분리한다. (+ 단일 책임 원칙)
역할에 맞게 잘 분리한 것에는 이견이 없지만, 컴포넌트의 중요한 의미 중 하나는 재사용
에 있습니다. 우리가 나눈 컴포넌트는 과연 재사용이 가능할까요?
앞에서 데이터 중심으로 컴포넌트를 나누어 설계하였습니다. 재사용
의 측면에서 보면 이 컴포넌트가 재사용이 가능한지, 변경에 대응할 수 있는지 살펴봐야 합니다. 그러기 위해선 좋은 인터페이스를 가지고 있어야 하는데, 여기서는 props
와 컴포넌트의 네이밍
이 인터페이스 역할을 수행하고 있습니다. 그렇다면, 좋은 인터페이스란 무엇일까요?
위의 예시에서 분리한 컴포넌트들은 '주소' 라는 도메인을 가진 페이지에서만 사용할 수 있습니다. 같은 도메인 아래에서 데이터 그리고 역할에 따라 의존성은 잘 분리해 냈지만, 특정한 도메인을 알고 있는 컴포넌트 이기 때문에 다른 도메인에서는 재사용할 수 없겠죠. 예를 들어 주소 목록을 보여주는 UI는 같은데 데이터의 종류만 다른 페이지가 있다면 사용자에게 보이는 인터페이스는 동일하더라도 컴포넌트를 새로 만들어야 합니다.
방금 언급했던 것 처럼 재사용이 불가능한 이유는 컴포넌트가 '주소' 라는 특정 도메인과 강하게 결합되어 있기 때문입니다. 이 문제를 해결하기 위해 컴포넌트에서 도메인 맥락을 제거해보겠습니다.
// before: AddressItem
function FlexListItem({ text, button }: { text: string, button: ReactNode }) {
return (
<FlexLi>
{text}
{button}
</FlexLi>
)
}
const FlexLi = styled('li')``
FlexListItem
이라는 이름으로 변경하였습니다.address
라는 props
의 이름도 text
로 도메인 맥락을 제거해 주었습니다.SaveAddressButton
이라는 컴포넌트를 해당 컴포넌트에 직접 불러와서 사용중이었는데, 이 역시 주소 도메인과 강하게 연결되어 있으므로, 사용하는 개발자 쪽에서 Button
이라는 것만 알려주고 직접 넣어서 사용할 수 있도록 변경하였습니다. (이렇게 사용하는 입장에서 조합하여 사용하는 것을 컴포넌트의 합성이라고 합니다.)자, 이제 어떤가요? 회원 목록과 같이 다른 도메인을 나타내는 페이지에서도 재사용할 수 있게 되었습니다.
function AddressList() {
const [addresses, setAddresses] = useState<주소[] | null>(null);
// call fetchAddressList
return (
<ul>
{addresses.map(({ id, address, saved }) => {
return (
<FlexListItem key={id} text={address} button={saved ? <SaveAddressButton id={id} /> : <UnsaveAddressButton id={id} />}/>);
})}
</ul>);
}
정리하자면 우린 재사용성을 높이기 위해 다음과 같은 두가지 방법을 사용했습니다.
- 도메인 맥락을 제거한다.
- 의존성을 컴포넌트가 직접 참조하지 말고 외부로부터 주입받자.
도메인 맥락을 제거함에 따라 재사용성이 높아졌기 때문에 (많은 곳에서 사용할 수 있기 때문에) 변경될 여지가 많습니다. 사용할 수 있는 곳이 많아진 만큼, 변경에 대한 요구가 점점 더 많아지겠죠.
예를 들어 FlexListItem
컴포넌트에서 스타일이 조금만 수정된다면 이 부분을 외부에서 수정할 수 있어야 컴포넌트를 사용할 수 있을 것입니다. 또한 text
부분에 단순한 text
ui 만 오는게 아니라 textArea
로 여러줄을 오게 하고 싶을 수도 있고, 단순 스타일링이 조금씩 바꿔달라는 요구사항이 있을 수도 있겠죠. (소프트웨어는 끊임없이 변화한다..) 그리고 button
없이도 사용할 수 있지 않을까요?!
이런 상황이 발생할 때 마다 props를 추가하여 문제를 해결할 수 있을 것 입니다. props로 요구사항을 조건으로 받아와서 조건부 렌더링으로 해결할 수 있겠죠. 하지만 이는 재사용이 불가능해진 컴포넌트를 수정하는 방법에 해당합니다.
왜냐하면, props가 추가될 때 마다 해당 props에 의존해서 컴포넌트 내부 구현은 복잡해질 것이고 그렇게 되면 오히려 재사용하기 어려운 컴포넌트가 되어버립니다.
따라서 이렇게 여러가지 요소가 인터페이스를 결정하기 때문에 컴포넌트를 사용하는 입장에서 결정할 수 있도록 주도권을 외부에 넘겨주어야 합니다. 즉, 주도권을 외부에 넘김으로써 외부에서 많은 것을 결정하여 확장할 수 있도록 해야 하는 것입니다.
interface Props extends LiHTMLAttributes<HTMLLIElement> {
button?: ReactNode;
}
function FlexListItem({ children, button, ...props }: Props) {
return (
<FlexLi {...props}>
{children}
{button}
</FlexLi>
)
}
FlexListItem
을 확장 가능하도록 몇 가지 수정작업을 진행해 보겠습니다.
extends LiHTMLAttributes
를 통해 스타일 커스텀(= style)이나 li
태그의 어트리뷰트를 사용할 수 있게 되었습니다.text: string
→ children: ReactNode
로 바꿔주어 들어올 수 있는 내용을 한정짓지 않고 외부에 주도권을 넘겨주었습니다.button
을 typescript의 optional props
로 변경하였습니다. 필요한 경우에만 button props를 주입하여 사용할 수 있도록 변경한 것이죠.거의 대부분의 주도권을 외부에 넘기도록 설계를 바꾸었습니다. 이제 FlexListItem은 사실 list의 스타일링과 더불어 children과 button 간의 레이아웃을 결정하는 역할만 지니게 되었습니다.
✍🏻 컴포넌트가 확장 가능하도록 합성 가능한 구조를 만들자.
변경에 대응할 수 있으려면, 컴포넌트 내부보다 외부로 노출되는 것에 신경을 써야합니다. 컴포넌트가 외부로 노출하고 있는 정보는 props
(interface)와 컴포넌트의 네이밍
입니다. 이것들은 컴포넌트의 역할을 이해하는데도 큰 도움을 주게 됩니다. 따라서 위에서 정의한 인터페이스의 정의에 한 가지를 더 추가할 수 있겠습니다.
FlexListItem
의 경우를 확인해 보겠습니다. 방금 언급한 컴포넌트의 네이밍
때문에 해당 컴포넌트는 flex 스타일로 레이아웃을 구성하고 있는 것이 예측가능합니다. 네이밍에 의해 내부의 구현이 바깥으로 노출된 것이죠.
이때 만약 flex 가 아닌 grid 스타일을 적용해야 하는 요구사항이 들어온다면 어떻게 해야 할까요? 그러면 네이밍을 변경하는 것이 불가피 할 것이고, 사용중인 모든 곳에 영향을 미치게 됩니다.
ListItem
으로 바꾼다면 어떨까요? Style에 관련된 맥락을 제거하여 내부의 구현을 숨기고 나니까 외부와의 의존성이 사라졌고 내부가 flex → grid
로 변경되더라도 큰 문제가 생기지 않을 것 입니다. (물론 한단계 더 추상화를 해서 내부 구현을 감추고 외부에서 스타일을 주입받는 방식도 있겠습니다.)
✍🏻 내부의 구현을 캡슐화 하여 내부의 변경이 외부에 영향을 미치지 않도록 해야 한다.
// Header.tsx
interface HeaderProps {
isMine: boolean;
isMyPage: boolean; // 인터페이스는 외부와의 의존성을 만드는데, 이는 MyPage와 강하게 의존하게 된다. 내부 구현(mypage와 의존하고 있는 코드)이 외부로 노출된 것
// ... 앞으로 또 요구사항이 추가된다면..?
}
export default function Header(({ isMine, isMypage }){
return <></>;
}
// 내부의 구현이 변경되면, 외부에 의존하고 있기 때문에 변경에 대응하기 쉽지 않다.
Header
컴포넌트가 있었습니다. 재사용되는 컴포넌트는 아니었지만, 공통의 컴포넌트였고 페이지가 새로 생겨날 때 마다 조금씩 다른 형태의 모습을 보여주고 있었습니다.
공유하기 페이지에서는 검색 아이콘과 사용자 아이콘이 빠졌고, 마이페이지에서는 검색 아이콘이 빠졌습니다. 처음에 헤더를 만든 친구가 isMine
이라는 boolean
값을 props로 받아와서 해결했습니다.
하지만 요구사항은 마이페이지까지 추가되었고, isMyPage
라는 props가 추가됨으로써 점점 읽기 어렵고 props에 의존한 복잡한 컴포넌트가 되어갔습니다.
요구사항에 맞춰 내부의 구현을 변경하려고 하면, 외부에 의존하고 있는 isMine
때문에 isMypage를 통해 요구사항을 맞춰주려고 하면 외부의 변화까지 고려해야 해서 간단한 요구사항도 수정하기 어려웠습니다.
ListItem
의 인터페이스(props, 컴포넌트 네이밍)만 보고서는 사용자가 button
props가 어디서 어떻게 사용될지 예측하기 어렵습니다. 따라서 인터페이스의 특징인 "사용자는 인터페이스를 보고 컴포넌트의 역할을 예측할 수 있습니다" 에 맞게 컴포넌트에서의 역할이 드러나도록 네이밍을 수정해보도록 하겠습니다.
input
태그에 들어갈 핸들러를 props로 받아올 때 onChangeValue
보단, 네이밍 그대로 onChange
로 지정해주어 역할을 예측할 수 있도록 하는 것이죠. 내부의 구현을 들여다 보지 않고서도 말이죠!li
태그의 attributes로 button이 일반적으로 위치하고 있진 않습니다. 기본 attributes에 없는 경우에 해당합니다.right
라는 props 네이밍을 예시로 들어주셨습니다. li 태그와 button의 조합으론 button이 어떤 역할을 하는지 예측하기 어렵기 떄문에, right
라는 이름을 주어 리스트의 오른쪽에 위치한 컴포넌트라는 역할을 알릴 수 있을 것입니다.✍🏻 역할은 드러내고 구현은 감추어 일반적인 인터페이스를 설계하자.
// /components/ListItem.tsx
interface Props extends LiHTMLAttributes<HTMLLIElement> {
right: ReactNode
}
function ListItem({ children, right, ...props }: Props) {
return (
<StyledLi {...props}>
{children}
{right}
</StyledLi>
)
}
// /pages/address/AddressList.tsx
function AddressList() {
const [addresses, setAddresses] = useState<주소[] | null>(null)
// call fetchAddressList
return (
<ul>
{addresses.map(({ id, address, saved }) => {
return (
<ListItem
key={id}
right={
saved ? (
<SaveAddressButton id={id} />
) : (
<UnsaveAddressButton id={id} />
)
}
>
{address}
</ListItem>
)
})}
</ul>
)
}
해당 글에서는
에 초점을 맞춘 글이에요. 은수가 마지막에 질문해줬던, 꼭 도메인 맥락을 제거해야 하는가? 에 대해서 답변을 글에 답변을 생략해버려서 첨언하자면, "이 컴포넌트는 다른곳에서 재사용 될 여지가 없는 컴포넌트인데?" 라는 생각이 든다면 굳이 이걸 재사용하기 위해 추상화 작업을 하면서 개발자의 리소스를 쏟을 필요는 없다고 생각합니다.
컴포넌트는 어쩔 수 없이 데이터와 도메인에 의존하고 있는데, 이를 재사용이 되지 않을 컴포넌트에서 까지 추상화해낼 필요는 없다고 생각합니다! 우리의 리소스 또한 중요하기 때문입니다.
글 감명깊게 잘 봤습니다!
한가지 든 생각은 컴포넌트의 주도권을 외부에 위임하면 그 위임하는 쪽 코드가 매우 비대해질 것이라는 예상이 되어요.
Juno님은 이에 대해서 어떻게 생각하시나요? 저는 아직 그 답을 찾지 못해서 실제 프로젝트 예제를 보고 감을 익혀보고 싶은데 위 컴포넌트 설계 원칙대로 구현한 코드를 볼 수 있을까요?