SOLID Principles in React

Reference

정말 흥미로운 글을 보게 되었습니다.

객체지향의 SOLID 원칙을 React 에서는 어떻게 적용해볼 수 있을까? 라는 질문에 대한 글이였습니다.

그 중 OCP 에 대한 리액트 컴포넌트 예제 구성이 제일 먼저 눈에 들어왔습니다.

이 글을 보기 전, 지금까지 짰던 공통 컴포넌트 코드는 아래 예시와 같습니다.

import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
 
 interface AlertProps {
   title: string;
   content: string;
   submitButtonText: string;
   isOpen: boolean;
   onSubmit: () => void;
   onClose: () => void;
 }
 
 const Alert = (props: AlertProps) => {
   const { title, content, submitButtonText, isOpen, onSubmit, onClose } = props;
 
   return (
     <Dialog open={isOpen}>
       <DialogContent className="sm:max-w-[425px]">
         <DialogHeader>
           <DialogTitle>{title}</DialogTitle>
         </DialogHeader>
         <div className="grid gap-4 py-4">{content}</div>
         <DialogFooter>
           <Button variant="ghost" onClick={onClose}>
             취소
           </Button>
           <Button onClick={onSubmit}>{submitButtonText}</Button>
         </DialogFooter>
       </DialogContent>
     </Dialog>
   );
 };
 
 export default Alert;

위 코드를 보면 닫기 버튼은 고정이고 제출 버튼도 고정입니다. 심지어 submit 하는 버튼은 텍스트를 props로 받습니다.

그런데 만약, 위 컴포넌트에서

  1. 문의하기 라는 버튼이 추가되어야 한다면?
  2. Dom element 요소 속성에 직접 접근해야 한다면?
  3. content 부분에 특정 이미지나 여러 부수적인 element 들이 렌더링 되어야한다면?

코드는 요구사항을 충족하기 위해 다음과 같이 바뀔 수 밖에 없습니다.

import React, { forwardRef, ForwardedRef } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
 
interface AlertProps {
  title: string;
  content: string;
  submitButtonText: string;
  isOpen: boolean;
  onSubmit: () => void;
  onClose: () => void;
  onInquiry: () => void; // 문의하기 핸들러 추가
  imageUrl?: string; // imageUrl 타입 추가 (handleGenerateContent에서 사용)
}
 
const Alert = forwardRef<HTMLDivElement, AlertProps>((props: AlertProps, ref: ForwardedRef<HTMLDivElement>) => {
  const { title, content, submitButtonText, isOpen, onSubmit, onClose, onInquiry, imageUrl } = props;
  
  const handleGenerateContent = () => {
    if (!imageUrl) {
      return content;
    }
    return (
      <div>
        <img src={imageUrl} alt="Alert content" />
        {content}
      </div>
    );
  };
 
  return (
    <Dialog open={isOpen}>
      <DialogContent className="sm:max-w-[425px]" ref={ref}>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
        </DialogHeader>
        <div className="grid gap-4 py-4">{handleGenerateContent()}</div>
        <DialogFooter>
          <Button variant="ghost" onClick={onClose}>
            취소
          </Button>
          <Button variant="ghost" onClick={onInquiry}>
            문의하기
          </Button>
          <Button onClick={onSubmit}>{submitButtonText}</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
});
 
Alert.displayName = 'Alert'; 
 
export default Alert; 

과연 위와 같은 컴포넌트를 공통컴포넌트라고 정의할 수 있을까요? 다시 한 번 생각해보게 되었습니다.

공통컴포넌트라고 하면 한 번 만들어놓은 상태면 원본 코드의 수정에는 최대한 닫혀있어야 합니다.

확장 가능성을 생각하지않고 폐쇄적인 컴포넌트를 만들어놓았기 때문에 특정 요구사항을 충족하기 위해 원본 코드에 if-else문이 점점 늘어날 것이고, 정의되는 props type도 점점 많아질 것입니다.

명백히 개방폐쇄원칙에 위배되는 행동입니다.

어떻게 바뀌면 좋을까요? 생각해보았습니다


TO-BE

먼저 책임 이라는 단어에 집중해보았습니다. 위의 컴포넌트는 UI 조각을 공통컴포넌트화 한다 가 주 목적인 컴포넌트입니다. 따라서 Alert 라는 컴포넌트는 단순히 UI 렌더링만 하면 좋겠다고 생각했습니다.

import React, { HTMLAttributes, forwardRef, ForwardedRef, ReactNode } from 'react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
 
interface AlertProps extends HTMLAttributes<HTMLDivElement> { 
	title: string; 
	contentSlot: ReactNode; 
	buttonSlot: ReactNode; 
	isOpen: boolean; 
}
 
const Alert = forwardRef<HTMLDivElement, AlertProps>(
  ({ title, contentSlot, buttonSlot, isOpen, ...props }: AlertProps, ref: ForwardedRef<HTMLDivElement>) => (
      <Dialog open={isOpen}>
        <DialogContent className="sm:max-w-[425px]" ref={ref} {...props}>
          <DialogHeader>
            <DialogTitle>{title}</DialogTitle>
          </DialogHeader>
          <div className="grid gap-4 py-4">
            {contentSlot}
          </div>
          <DialogFooter>
            {buttonSlot}
          </DialogFooter>
        </DialogContent>
      </Dialog>
    );
);
 
Alert.displayName = 'Alert';
 
export default Alert;
  1. ref 값과 기본 Div element의 속성을 정의하는 빌트인 타입을 확장하여 props를 받습니다.
  2. 컨텐츠와 버튼 영역은 엘리먼트로 받아 Alert 컴포넌트를 호출하는 부모컴포넌트에게 세부 레이아웃, 비지니스 로직의 권한을 위임합ㅣ다.

다시 한 번 전에 언급했던 요구사항을 살펴보겠습니다.

  1. 문의하기 라는 버튼이 추가되어야 한다면?
  2. Dom element 요소 속성에 직접 접근해야 한다면?
  3. content 부분에 특정 이미지나 여러 부수적인 element 들이 렌더링 되어야한다면?

이젠 만들 수 있습니다.

//..
 
return (
	<Alert 
	  ref={alertRef}
	  title="중요 알림". 
	  isOpen={isAlertOpen}. 
	  contentSlot={ 
		<div className="flex flex-col items-center space-y-4">. 
			<img src="/warning-icon.svg" alt="경고 아이콘" className="w-16 h-16" /> 
			<p className="text-center"> 이 작업은 되돌릴 수 없습니다. 계속 진행하시겠습니까?</p> 
		</div> 
	  } 
	  buttonSlot={
		<> 
			<Button variant="outline" onClick={handleClose}>취소</Button> 
			<Button variant="destructive" onClick={handleRemove}>삭제</Button>
		<Button variant="destructive" onClick={handleInquiery}>문의하기</Button> 
		</> } />
)

부모 컴포넌트에서 Alert 컴포넌트가 어떻게 렌더링 될 지 감이 잡혀 코드가 보다 더 선언적으로 구성이 되었다고 생각합니다.

지금까지 확장성 있는 컴포넌트를 작성하지 못했던 자신을 반성하며 이번 글을 마무리하도록 하겠습니다.