프론트엔드 컴포넌트 리팩터링 노트
사내 프로젝트에서 복잡한 컴포넌트를 리팩터링한적이 있다. 그 때 어떤 기준으로 기존의 컴포넌트를 나누고, 합쳤는지 그 과정을 기록해보고자 한다.
1. 문제상황 파악하기
일단 내가 식별했던 문제는 아래와 같다.
- 컴포넌트A가
재사용
을 목적으로 여러 페이지에서 사용되고 있다. - 하지만 컴포넌트A는 사용처마다 스타일이나 보여줘야하는 데이터가 미세하게 다르고 사용방법도 다르다.
- 따라서 컴포넌트A의 props 가 엄청나게 뚱뚱해지고, 여러 컴포넌트가 한 컴포넌트A 에 묶인채로 조건부 렌더링되다보니 코드 파악이 어렵고 변경에 취약하다.
내가 리팩터링했던 컴포넌트는 주문 데이터(주문 한 상품명, 상품 이미지, 상품 가격)등을 렌더링하는 주문 아이템 컴포넌트(OrderProductItem)
였고 사용처별로 사용방법들과 요건들을 정리해보면 아래와 같았다.
0. 컴포넌트 사용처 및 사용방법 정리해보기
- 주문완료 페이지
- 맨 위에 border 없음
- 맨 위에 padding 값 다름
- 주문서 페이지
- 맨 위에 border 없음
- Info Margin 값 다름
- 주문 취소 페이지
- 맨 위에 border 없음
- 해당 페이지에서는 OrderCancelledItem 컴포넌트로 한번 랩핑되어서 사용됨
- 주문 목록 페이지
- 맨 위에 border 있음
- 주문 상태 메시지와 주문 버튼 그룹 렌더링 필요
- 주문 상세 페이지
- 맨 위에 border 있음
- 주문 상태 메시지와 주문 버튼 그룹 렌더링 필요
- 해당 페이지에서는 OrderItem 컴포넌트로 한번 랩핑되어서 사용됨
여기서 가장 큰 문제는 두가지였다.
1. 문제 식별하기
문제점 1. 다양한 케이스의 UI를 하나의 컴포넌트로 대응하려다 보니 props와 컴포넌트 내부 조건문이 많아져서 파악하기 어렵다.
- 예를들어 주문 목록 페이지와 주문 상세 페이지에서
주문 상태 메시지와 주문 버튼 그룹 렌더링 필요
라는 요건을 지키기 위해서 해당 컴포넌트의 인터페이스는 아래와 같이 정의되었다.
interface OrderProductItemProps = {
/**생략**/
lineItemStatus?: LineItemStatus;
orderStatus?: OrderStatus;
shippingUrl?:string;
paymentMethod?:PaymentMethod;
cancelRejectionReason?: string;
onCancelOrderButtonClick?: () => void;
onWithdrawCancelOrderButtonClick?: () => void;
};
왜 주문 상태 메시지와 주문 버튼 그룹 렌더링 필요
라는 조건을 지키기위해서 저만큼 많은 props가 필요하게 된걸까? 아마 컴포넌트를 처음 만들었을 당시는 아래와 같은 의사결정을 거치게 되었을거라고 생각한다.
- 주문 상태 / 주문 버튼 그룹을 렌더링하는 요건은 주문데이터의 lineItemStatus, orderStatus, cancelRejectionReason에 의존한다.
- 이 렌더링 요건을 사용처에서 OrderProductItem 컴포넌트의 props에 boolean 값으로 전달해줄 수도 있겠지만 (ex) shouldShowingOrderStatus) 그러기에는 중복되는 코드들이 생겨서 OrderProductItem 컴포넌트에 렌더링 요건을 직접 계산한다.
- 주문 버튼 그룹에는 버튼 handler 함수가 필요하다.
- 따라서 onCancelOrderButtonClick 과 onWithdrawCancelOrderButtonClick 라는 handler 함수를 props로 또 주입해주어야 한다.
그리고 컴포넌트에서는 아래와 같이 사용하고 있었다.
const OrderProductItem = (
{
/** 생략 **/
},
) => {
const isOrdered = lineItemStatus && paymentMethod && orderStatus;
const isActionButtonShowing = !(
isCancelInfoButtonHidden &&
(orderStatus === 'cancelled' || lineItemStatus === LINE_ITEM_STATUS.refundAccepted.id)
);
return (
<S.OrderProductItem>
{isOrdered && (
<>
<OrderStatusText
orderStatus={orderStatus}
lineItemStatus={lineItemStatus}
paymentMethod={paymentMethod}
cancelRejectionReason={cancelRejectionReason}
/>
{isActionButtonShowing && (
<OrderActionButton
orderStatus={orderStatus}
lineItemStatus={lineItemStatus}
shippingUrl={shippingUrl}
onCancelOrderButtonClick={onCancelOrderButtonClick}
onWithdrawCancelOrderButtonClick={onWithdrawCancelOrderButtonClick}
/>
)}
</>
)}
</S.OrderProductItem>
);
};
일단 중복 제거라는 목적을 위해 이 컴포넌트가 불필요한 데이터를 주입받고있었고 따라서 너무 큰 책임을 지고 있었다.
문제점 2. 컴포넌트의 사용방법이 다른 곳이 있다. (추상화 레벨이 통일되지 않음)
- 이 문제점은
OrderProductItem
컴포넌트의 추상화 레벨을 통일시키지 않았다. - 따라서 사용처마다 사용방법이 다르다. 이는 결국 1번의 문제와도 직결되기도 하고, 개발자 생산성을 현저하게 떨어뜨린다.
그러면 이제 차근차근 하나씩 문제를 풀어나간 과정을 보자
2. 첫번째 문제해결하기 - 컴포넌트의 책임 덜어내기
1. 불필요한 데이터 분리하기
-
주문 상태 / 주문 버튼 그룹을 렌더링
하는 요건 때문에 위에서 해당 컴포넌트의 interface 에 optional 한 인자들이 많이 추가되어있었다. 즉, 불필요한 데이터를 너무 많이 들고 있었다. -
해결방법 - 주문 상태 메시지와 주문 버튼 그룹을 렌더링하는 코드를 OrderProductItem 컴포넌트로부터 분리하자.
- 해당 요건이 필요한 곳은 주문 목록 / 주문 상세이다.
- OrderProductItem 컴포넌트 외부로 해당 렌더링 로직을 분리한다.
const OrderDetail = () => {
/**생략**/
return (
<div>
{orders.map((order) => (
<OrderProductItem {...orders} />
))}
<>
<OrderStatusText
orderStatus={order.orderStatus}
lineItemStatus={lineItemStatus}
paymentMethod={order.paymentMethod}
cancelRejectionReason={ordercancelRejectionReason}
/>
<OrderActionButton
orderStatus={orderStatus}
lineItemStatus={lineItemStatus}
shippingUrl={shippingUrl}
onCancelOrderButtonClick={onCancelOrderButtonClick}
onWithdrawCancelOrderButtonClick={onWithdrawCancelOrderButtonClick}
/>
</>
</div>
);
};
- 이렇게 리팩터링함으로써 OrderProductItem에
OrderStatusText
와OrderActionButton
을 위해서 넘겨주어야 했던 props들을 제거할 수 있다.
2. Style 관련 props 제거하여 컴포넌트의 역할 명확히 하기
- border 유무, padding-top 유무가 각 사용처마다 다르기 때문에 아래와 같은 props들도 필요했다.
interface OrderProductItemProps = {
/**생략**/
hasBorderTop?:boolean;
hasFirstChildPaddingTop?:boolean;
}
하지만 영 찜찜하다. 위의 두 Props는 OrderProductItem
컴포넌트의 다른 데이터들 (주문 관련 데이터)과는 동떨어져보인다.
- 해결방법 - style 관련 컴포넌트 분리하기
- OrderProductItem 을 위한 랩퍼 컴포넌트를 만들어서 분리한다. 해당 컴포넌트는 Wrapping 하여 스타일링하는 역할만 한다. props 로는 아래와 같은 값을 받는다.
- children (OrderProductItem 이 들어간다)
- hasBorderTop (위에 선 유무)
- hasFirstChildPaddingTop (맨 위 아이템에 padding-top 유무)
- OrderProductItem 을 위한 랩퍼 컴포넌트를 만들어서 분리한다. 해당 컴포넌트는 Wrapping 하여 스타일링하는 역할만 한다. props 로는 아래와 같은 값을 받는다.
사용방법은 아래와 같다.
<OrderProductItemWrapper hasBorderTop>
<OrderProductItem
imageUrl={item.product.images[0]?.url}
name={item.product.name}
quantity={item.quantity}
price={item.total.price}
options={item.variant.optionNames}
/>
</OrderProductItemWrapper>
이제 OrderProductItem에는 스타일과 관련한 props가 제거되고 오로지 주문데이터만 props로 받아서 하나의 역할만한다.
이렇게 리팩터링을 통해 OrderProductItem 컴포넌트는 사용처마다 다른 역할이 아니라 동일한 하나의 역할만 해줄 수 있게 되었다.
3. 두번째 문제 해결하기 - 컴포넌트 추상화 레벨 통일하기
정확히 문제를 식별하면 아래와 같았다.
- 주문 상세 / 주문 목록
- OrderItem ( OrderItem 컴포넌트 내부에서 OrderProductItem 사용하는 구조) 사용 하여 해당 페이지에서 직접 OrderProductItem를 사용하지 않음
- 주문 취소
- OrderCancelItem (OrderCancelItemderItem 컴포넌트 내부에서 OrderProductItem 사용하는 구조) 사용하여 해당 페이지에서 직접 OrderProductItem를 사용하지 않음
- 주문완료/ 주문서
- 해당 페이지에서 직접 OrderProductItem 사용
그러면 어떤식으로 추상화 레벨을 통일 할 수 있을까?
주문 상세 / 주문 목록페이지에서 직접 OrderProductItem을 사용하지 않은 이유는 스타일 추가 혹은 데이터 추가같은 문제 때문이었고, 직접 OrderProductItem 컴포넌트를 사용한 페이지들은 아직 그런 문제점이 없었기 때문이다.
따라서 단순하게 하나의 규칙을 통해서 추상화 레벨을 통일했다.
OrderProductItem 컴포넌트를 특정 페이지에서 사용할 때는 직접 사용하지 않고 하나의 껍데기 컴포넌트를 가지고 그 안에서 사용하도록 한다.
따라서 아래와 같이 만들어 사용하여 추상화 레벨을 통일 했다.
- 주문 상세 / 주문 목록 - OrderItem
- 내부에서 OrderProductItem 사용
- 주문 취소 - OrderCancelItem
- 내부에서 OrderProductItem 사용
- 주문서 - OrderCheckoutItem
- 내부에서 OrderProductItem 사용
- 주문 완료 - OrderCompletedItem
- 내부에서 OrderProductItem 사용
따라서 앞으로 해당 OrderProductItem 컴포넌트를 사용하는 곳이 추가될 때마다 Order**Item
컴포넌트를 만들어사 사용하면 된다.라는 규칙이 생겨서 컴포넌트의 역할/위치가 명확해졌다.
또한 컴포넌트 수정 시 OrderProductItem까지 볼 필요 없고, Order**Item
컴포넌트만 보면 되어서 디버깅이 수월해질 수 있을 것 같고 다양하게 추가되는 요구사항 (스타일변경,레이아웃변경)에 Order**Item
을 수정하면 되니, 유연하게 대처 할 수 있을 것 같다.
나가며
복잡한 컴포넌트를 리팩터링하면서 느꼈던 점은
- 컴포넌트의 미학은 재사용이다. 하지만 섣부른 재사용은 하나의 컴포넌트의 여러 역할만 가중시킨다.
- 컴포넌트도 함수처럼 생각하자. 1일 1역할을 하는 것처럼 1컴포넌트 1역할을 지키자. 어쩌면 스타일도 하나의 역할로 분리 할 수 있다.
- 결국 사용자가 사용방법을 알기 쉬워야한다. 사용자는 개발자다. 이를 위해서는 사용방법을 통일시키자. 개발자가 코드를 파악하는 시간을 최소화 시켜주자.