Yeon's 개발블로그

지식을 전파하는 개발자가 되고싶습니다.

FYI/FE

리액트에서 컴포넌트를 작성하는 기준 (Feat. SOLID원칙)

Dev.yeon 2024. 1. 25. 18:53

리액트를 개발할때 컴포넌트를 어떤 기준으로 작성하시나요? SOLID원칙을 통해 어떻게 컴포넌트를 작성할지에 대해서 살펴보도록하겠습니다. 본격적으로 시작하기전에 리액트 가이드문서에서 계층을 분리하는 과정을 먼저 보고가겠습니다.

리액트 계층분리과정 

https://react.dev/learn/thinking-in-react 글을 요약하였습니다. 실제예시가 본문에 잘나와있으므로 참고하시기바랍니다.

1. UI를 컴포넌트 계층으로 쪼개기

컴포넌트를 하위 컴포넌트들로 쪼개야하는데 나누는 관점에는 차이가 있을수있습니다. 이미 디자인팀에서 정해놓은 계층이 있다면 그것을 기준으로 분리해도되고, 개발관점에서는 SRP원칙에따라 분리를 할수도있습니다. JSON이 잘구조화되어있다면, 응답값을 기준으로 컴포넌트 구조를 분리해볼수도있습니다. UI와 데이터는 같은 구조를 가지기때문입니다. 

2. 정적인 버전 구현하기

컴포넌트 계층구조를 만들었다면, 실제로 리액트에서 개발을 해야합니다. 처음에는 상호작용기능은 추가하지않되, UI를 렌더링하는 버전을 먼저 만드는게 좋습니다. 재사용성을 고려해서 개발하게된다면 props를 이용하여 데이터를 넘겨주는 컴포넌트를 구현하게될것입니다. 이때 정적인 버전이기때문에 state는 고려하지않는것이 좋습니다. state는 시간이 지남에따라 데이터가 바뀌는것에 사용하기때문입니다.  계층구조에따라 상층부에 있는 컴포넌트부터 하양식(top-down)으로 만들거나, 하층부에있는 컴포넌트부터 상향식(bottom-up)으로 만들수있지만 프로젝트가 클수록 탑다운방식이 더 좋습니다. 

3. 최소한의 데이터만 이용해서 완벽하게 UI State 표현하기

UI를 상호작용하기위헤서는 이제 state를 활용해야합니다. state를 구조화하는데 가장 중요한원칙은 DRY(Don't Repeat Yourself) 입니다. 가장 최소한의 state만을 가지게하고, 나머지는 필요에따라 실시간으로 계산하는게 좋습니다. 시간이 지나면서 변하지않거나, 부모로부터 props를 통해 전달되거나, 컴포넌트안의 다른 값을 이용해서 계산이 가능하다면 state가 아닙니다. 

4. state가 어디에있어야하는지 정하기

state를 구조화했다면 어떤 컴포넌트가 이 state를 소유하고 변경할 책임을 지게할지 정해야합니다. 리액트애서는 항상 컴포넌트 게층을 따라 부모에서 자식으로 데이터를 전달하는 단방향 데이터 흐름을 사용한다는것을 기억하면 조금더 쉽게 정할수있습니다. state를 기반으로 렌더링하는 모든 컴포넌트를 찾은다음, 가장 가까운 공통되는 부모컴포넌트에 state를 두면됩니다. 

5. 역데이터 흐름 추가하기

계층구조 아래로 흐르는 props와 state를 구성했다면, 반대로 사용자의 입력을 상위로 전달하는 역데이터흐름을 추가해야합니다. 이때에는 이벤트핸들러를 추가하여 state를 변경하도록 구현합니다. 

 

아마도 실제 프로젝트를 진행하실때도 리액트에서 소개하고있는 계층분리과정순으로 개발을 많이 하실것이라고 생각됩니다. 1, 2번에서 소개하고있는 컴포넌트를 쪼개고 실제 구현을 할때 컴포넌트 어떻게 구분할지에 대한 기준이 없다면 개발할때 상당히 난처할수있습니다. 개발을 진행하면서도 컴포넌트를 분리할지 합쳐서 작성할지에대해 고민하게되고, 그러다보면 프로젝트구조가 통일성없이 설계될수있습니다. 또한 개발후에 변경건이 발생하게되거나, 확장하게될경우 공수가 크게들수도있습니다. 따라서 실제 개발전에 팀원들과 컴포넌트를 나누는 기준을 정하고간다면, 리뷰할때나 유지보수할때에 편리할것입니다.

 

SOLID 원칙

SOLID원칙은 아키텍처 설계부터 코딩과 리뷰까지 사용되는 원칙입니다. 이 원칙을 하나하나 살펴보며, 리액트에서 컴포넌트를 작성할때 어떻게 작성하면 좋을까?에 대해서 탐구해보도록하겠습니다. 

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/ 글을 참고하였습니다. 보다 자세하게 알고싶은분들은 원글을 꼭 읽어보시기바랍니다. 
SRP: Single Responsibility Principle
OCP: Open/Closed Principle
LSP: Liskov Substitution Principle
ISP: Interface Segregation Principle
DIP: Dependency Inversion Principle

1. SRP(Single Responsibility Principle)

SRP를 먼저논하기전에 콘웨이법칙을 알고가면좋습니다. 콘웨이법칙소프트웨어구조는 해당 소프트웨어를 개발한 조직의 커뮤니케이션 구조를 닮게된다 라는 법칙입니다. 즉 이말은 소프트웨어구조가 조직의 커뮤니케이션 구조와 다르다면 잘못된 구조이다 라는뜻을 내포하고있습니다. 코드를 변경해야하는 요구사항도 조직에서 비롯되기때문에, 이상적인 컴포넌트 구조는 변경된 요구사항이 전달되었을때 최대한 side-effect없이 원하는 부분만 변경할수있는 구조가 좋습니다. 응집도는 높고, 결합도는 낮은 구조로 구성해야한다는 뜻입니다. 

SRP를 직역하면 단일책임원칙입니다. 하지만 개발관점에서 "책임"을 "동작"으로 해석하게되면 컴포넌트를 과하게 쪼갤수있습니다. 이렇게되면 전체로직을 한눈에 파악하기어렵고 코드 네이게이션에 들어가는 공수를 늘어나게 만듭니다. 관점을 다르게하여 R을 책임이 아닌 책무라고 이해해보면, R은 소프트웨어 내부의 동작이나 논리가 아니라 조직간 커뮤니케이션 영역으로 볼수있습니다. 

즉, 책무기반 컴포넌트 설계라고 볼수있습니다. 프로젝트별로 기획이나 디자인부서가 있을것이고, 요구사항이 있을것입니다. 컴포넌트 설계를 '요구사항을 전달하는 책무단위'로 진행하게되면, 변경에도 사이드이펙트없이 대처가 가능하며 커뮤니케이션 비용이 줄어들게됩니다. 실제업무에 대입해서 생각해본다면 다음과같이 원칙을 세울수있을것입니다. 

  • 기획에서 구조화된 그대로 컴포넌트를 설계하고, 네이밍또한 별도의 변환없이 그대로 사용한다.
  • 디자인시스템이 구축되어있다면 아토믹 디자인을 차용하여 그대로 사용한다.
    • https://fe-developers.kakaoent.com/2022/220505-how-page-part-use-atomic-design-system/
    • 아토믹디자인: 컴포넌트를 atom, molecule, organism, template, page의 5가지 레벨로 나누어 생각하는 디자인시스템
    • 아토믹디자인에서의 SRP를 생각해본다면, molecule단계를 잘 설계해야합니다. molecule은 쪼갤수없는 atom으로 구성되어 SRP에 따라 한가지의 책무를 지는것입니다.
    • molecule과 organism을 구분은, 작성한 컴포넌트에 컨텍스트가 있을경우에는 Organism으로, 컨텍스트없이 UI적인 요소라면 molecule로 한다.
  • 범용적인 컴포넌트를 구성할때는 특정 도메인과의 결합도를 낮추고 재사용성을 높이는것이 중요하며, 너무나 많은 역할을 수행하지 않도록 주의한다.
    • https://fe-developers.kakaoent.com/2022/221020-component-abstraction/
    • 범용적인 컴포넌트에서 특정도메인과 높은 의존성은 변경에 취약한 코드를 낳게되기때문에, 공통의 기능을 수행하게끔 구성해야한다.
    • 도메인과의 의존성과 별개로 컴포넌트자체가 수행하는 책임이 크다면 합성을 잘 이용해야한다. 하나의 컴포넌트가 가진 책임을 잘게 나누어 유연하게 대응할수있도록 한다.

실무에서 다음과같은 원칙을 정해놓고 개발을 하게된다면 굉장히 용이할것입니다. 정리하자면, 컴포넌트에는 SRP 따라 단일책임을 부여하고, 특정도메인과의 결합도는 낮추자라고 볼수있습니다. 

 

2.  OCP(Open/Closed Principle)

OCP 요구사항이 변경될때 기존코드를 변경하는것이아니라 새롭게 코드를 추가하는 원칙입니다. 개방폐쇄원칙은 객체는 확장에 개방되어있고, 변경에는 폐쇄되어있어야한다 뜻을 내포합니다. 기획은 계속해서 변경되고, 새로운 스펙들이 추가되는 상황에서 닫혀있는 구조를 사용한다면 코드구조가 많이 변경되게됩니다. https://www.octobot.io/blog/solid-principles-react/ 에 작성된 코드예시를 보겠습니다.

const RecipeCard = ({id, name, ingredients, type, onClick}) => {
   return (
       <Card>
           <CardHeader>
               <CardTitle>{name}</CardTitle>
           </CardHeader>
           <CardBody>
               <p>Id: {id}</p>
               <IngredientsFields>
                   {ingredients.map((ingredient) => {
                       <Ingredient>{ingredient}</Ingredient>
                   })}
               </IngredientsFields>
               {type === 'byAuthor' &&
                   <button onClick={onClick}>Go to site</button>
               }
               {type === 'byUs' &&
                   <button onClick={onClick}>Go to cook steps</button>
               }
           </CardBody>
       </Card>
   )
}

RecipeCard 컴포넌트는 type에 따라 다른 버튼을 렌더링합니다. 새로운 type을 추가하려면 컴포넌트를 수동으로 수정해야하므로 변경이 일어날수밖에 없습니다. 기존코드를 변경해야하기때문에 앞서 언급한 원칙에 위배됩니다. 따라서 다음과 같이 코드를 작성하는것이 좋습니다. 

// parent
const RecipeCard = ({id, name, ingredients}) => {
   return (
       <Card>
           <CardHeader>
               <CardTitle>{name}</CardTitle>
           </CardHeader>
           <CardBody>
               <p>Id: {id}</p>
               <IngredientsFields>
                   {ingredients.map((ingredient) => {
                       <Ingredient>{ingredient}</Ingredient>
                   })}
               </IngredientsFields>
               {children}
           </CardBody>
       </Card>
   )
}


// children
const AuthorRecipeCard = ({id, name, ingredients, onClick}) => {
   return (
       <RecipeCard id={id} name={name} ingredients={ingredients}>
           <button onClick={onClick}>Go to site</button>
       </RecipeCard>
   )
}

const OwnRecipeCard = ({id, name, ingredients, onClick}) => {
   return (
       <RecipeCard id={id} name={name} ingredients={ingredients}>
           <button onClick={onClick}>Go to cook steps</button>
       </RecipeCard>
   )
}

아이디, 이름, 재료와같이 공통적인 부분은 유지를 하되, type별로 나눠지는 부분은 특정컴포넌트를 만들어서 children으로 렌더링하게됩니다. 새로운 type이 추가되더라도 새 컴포넌트를 추가하고 children으로 전달하면되기때문에 확장성이 높아졌습니다. 즉, 확장에 개방되어있고, 변경에는 폐쇄되어있는 원칙을 잘 지켰다고 볼수있습니다. 또한, 앞서 설명한 SRP에서 특정도메인과의 결합도를 낮추는것과도 부합한다고 볼수있습니다. 

 

3.  LSP(Liskov Substitution Principle)

LSP는 리스코프 치환법칙입니다. 주로 객체지향언어에서 아용되는 원칙이기때문에 보통 "클래스 A가 클래스 B의 하위유형인 경우, 프로그램에서 오류를 일으키지않고 클래스 B의 객체를 클래스 A의 객체로 대체해야한다" 라고 설명합니다. 자식부모관계는 클래스 상속을 통해 설정되지만, 더 넓은 맥락에서 상속이란 비슷한 구현을 유지하면서 한 객체를 다른객체 위에 구축하는것을 의미합니다. https://www.octobot.io/blog/solid-principles-react/ 에 작성된 코드예시를 보겠습니다. 

const RecipeCard = ({children, color, size}) => {
   return (
       <Card style={{color, fontSize: size === 'xl' ? '32px' : '16px'}}>
           {children}
       </Card>
   )
}

위의 코드는 자식컴포넌트, 색상, 크기를 props로 받는 RecipeCard component입니다. 여기에 나중에 다크모드가 기획상 추가되어서 새로운 DarkRecipeCard 컴포넌트를 만들어야한다고 가정해보겠습니다.

const DarkRecipeCard = ({children, isBig}) => {
   return (
       <RecipeCard color='dark' size={isBig ? 'xl' : 'tb'}>
           {children}
       </RecipeCard>
   )
}

이 코드는 언뜻보면 문제가 없어보입니다. 렌더링할때도 제대로 동작하는것을 볼수있습니다. 하지만 리스코프 치환법칙을 준수하지는 않습니다. 왜냐하면 DarkRecipeCard를 다른 레시피 카드로 대체하고싶어도 상속되는 props의 동작을 수정해서 이름을 변경하였기때문에 대체하지 못하기때문입니다. 따라서 이 리스코프 치환법칙을 지키기위해서는 아래와같이 코드를 작성하는편이 좋습니다. 

const DarkRecipeCard = ({children, size}) => {
   return (
       <RecipeCard color='dark' size={size}>
           {children}
       </RecipeCard>
   )
}

렌더링상 문제는 없지만 아무생각없이 개발하다보면 놓칠수 있는 부분입니다. 이 원칙을 코드레벨말고 아키텍쳐레벨 관점에서 본다면 더 넓은 의미로 적용이 가능합니다. 리스코프 치환법칙은 다시말하자면 상속으로 이어진 관계에서 예상못할 행동을 하지말라는 것입니다. 타입스크립트 인터페이스정의를 아무생각없이 상속관계로 만든다거나, `ApiErrorBoudary` 를 작성해놓고 API와 관련없는 에러처리를 한다거나 등의 위반을 저지를수있습니다. 실제로 SOLID원칙중 장애상황과 가장 밀접한 부분은 LSP이기때문에 실제개발단계에서 이원칙을 지켰나 생각하면서 개발을 하는것이 좋습니다. 상위정의된부분과 실제 구현된 부분이 다르가면 장애상황이 일어날 확률이 커지게되고, 나중에 원인을 찾기도 힙들게됩니다. 따라서 개발단계에서부터 조심해서 쓰는것이 좋습니다. 

 

4.  ISP(Interface Segregation Principle)

ISP는 인터페이스 분리원칙입니다. 어떤 코드도 "사용하지 않는 메서드/인터페이스"에 의존하도록 해서는 안된다라는 원칙입니다. 리액트에서 ISP는 컴포넌트를 작성할때 정확하고 최소한의 인터페이스를 만드는것에 대한 중요성을 강조하고있습니다. 즉, 불필요한 부피를 피하면서 특정요구에 맞는 컴포넌트 인터페이스를 설계하도록 권장하는 것입니다. 이러한 접근방식은 컴포넌트가 작아지고 집중화되어 가독성과 확장성이 개선된 모듈로 기능을 하게됩니다. https://www.octobot.io/blog/solid-principles-react/ 에 작성된 코드예시를 보겠습니다.

const RecipeCard = ({ recipeData }) => {
   return (
     <Card>
       <Title>{recipeData.name}</Title>
       <TypeLabel>{recipeData.type}</TypeLabel>
       {recipeData.ingredients.map((ingredient) =>
       (<Ingredient>{ingredient}</Ingredient>))}
       <Photo src={recipeData.img} />
     </Card>
   );
}
RecipeCard.propTypes = {
   recipeData: PropTypes.object.isRequired,

RecipeCard는 recipeData를 받아서 렌더링하는 컴포넌트입니다. 만약 recipeData에서 특정 데이터만 필요하다면 이 부분은 쪼개어 props를 받는것이 좋습니다. 컴포넌트에 불필요한 정보를 제공하는것은 컴포넌트의 클라이언트가 사용하지않는 인터페이스에 의존하도록 강요하는것이기 때문입니다. 따라서 다음과같이 코드를 수정하는것이 좋습니다. 

const RecipeCard = ({ name, type, ingredients, photo }) => {
   return (
     <Card>
       <Title>{name}</Title>
       <TypeLabel>{type}</TypeLabel>
       {ingredients.map((ingredient) =>
       (<Ingredient>{ingredient}</Ingredient>))}
       <Photo src={photo} />
     </Card>
   );
}
RecipeCard.propTypes = {
   name: PropTypes.string.isRequired,
   type: PropTypes.string.isRequired,
   ingredients: PropTypes.array.isRequired,
   photo: PropTypes.string.isRequired,
};

이렇게함으로써 컴포넌트가 사용하지않는 props에 의존하지않게되었습니다. 컴포넌트간의 종속성을 최소하하고, 한 컴포넌트에서 다른 컴포넌트로 전달되는 props를 최소화하여 결합도를 낮췄습니다. 이렇게하면 코드를 유지보수할때나, 장애상황 발생시 빠르게 파악이 가능합니다. 

 

5. DIP(Dependency Inversion Principle)

DIP는 의존성역전법칙으로 그 개념이 다소 어렵습니다. 클린아키텍처저자인 Martin, Robert C의 말씀을 빌리면 DIP는 다음과같습니다.

- 고차원모듈은 저차원 모듈에 의존하면안된다. 
- 이 모듈 모두 다른 추상화된것에 의존해야한다. 
- 추상화된것은 구체적인 것에 의존하면안된다. 구체적인것이 추상화된것에 의존해야한다. 

이 부분을 리액트에 대입해서 생각해보자면, 상위 컴포넌트가 하위 컴포넌트에 직접적으로 의존하게 하는것이 아닌, props나 context와 같은 추상화에 의존하여 유연성과 유지보수를 강화하자는 말로 이해할수있습니다. 이렇게되면 모듈화하기에도 좋고, 테스트하기에도 쉬우며 코드유지관리가 쉬워집니다. https://fe-developers.kakaoent.com/2023/230330-frontend-solid/에 작성된 예시를 보겠습니다. 

function TicketInfoContainer(){
  const { isLoading, data:ticketInfo, error } = useTicketInfoQuery();
  const { data:commentInfo, error:commentApiError } = useCommentInfoQuery();
  const { data:keywordInfo } = useKeywordInfoQuery();
  
  if(isLoading){
    return <Loading />
  }

  if(error){
    return <Error />
  }

  return (
    <TicketInfo
      waitfreePeriod={ticketInfo.waitfreePeriod}
      waitfreeChargedDate={ticketInfo.waitfreeChargedDate}
      rentalTicketCount={ticketInfo.rentalTicketCount}
      ownTicketCount={ticketInfo.ownTicketCount}
      commentCount={commentInfo.commentCount}
      commentError={commentApiError}
      keywordInfo={keywordInfo}
    />
  )
}

이 코드를 DIP원칙으로 개선해본다면 다음과같은 개선점이 있을수있습니다. 

  • Loading과 Error 처리가 TicketInfoContainer 컴포넌트 내부에 강하게 결합되어있기때문에 의존성을 반전시켜주는게 좋습니다. 리액트 ErrorBoundary와 Suspense를 사용하면 상위레벨에서 해당처리를 할수있습니다.  또한 ErrorBoundary와 Suspense는 여러부분에서 공통으로 사용되므로 응집도는 높이고 결합도는 낮추는 결과를 가져오게됩니다.
  • TicketInfo 컴포넌트가 너무많은 props를 가지고있습니다. 의존되는곳이 많기때문에 한번에 파악이 어렵고 테스트를 작성하기도 어렵습니다. 원글에서는 합성컴포넌트를 사용하여 컴포넌트를 분리하였습니다. 선호하는 방향에따라 각각의 컴포넌트를 폴더구조 하위에 만들어서 사용하는 방법도 있을것입니다. 
    • 여기서 IoC라는 개념을 잠깐 살피고 넘어가자면, IoC(Inversion of Control)는 제어역전으로, 컴포넌트를 사용하는 개발자에게 컴포넌트 제어권을 넘겨줌으로써 개발자가 원하는대로 컨트롤하도록하는 개념입니다. 
    • 컴포넌트가 특정조건에서 다른 렌더링결과물을 만들어내야하는데, 전달되는 props도 많고 렌더링조건이 많아지게된다면 컴포넌트 복잡도는 증가할것입니다. 이때 여러 IoC패턴들을 사용해서 쉽게 쪼개볼수있습니다. 
    • https://fe-developers.kakaoent.com/2022/221110-ioc-pattern/ 글을 참고하시기 바랍니다. 

 

마치며

React에 SOLID원칙을 적용하면 프로젝트에서 발생할수있는변화, 장애상황시에 조금 더 유연한 대처가 가능합니다. 응집도는 높고 결합도는 낮은, 확장에는 개방되어있고 변경에는 폐쇄되어있는 컴포넌트를 만들어내는것이 중요한것같습니다. 여태까지는 나중에 어떤 결과를 가져올지는 생각안하고 대충느낌상 쪼개야할때 컴포넌트를 분리하곤했었습니다. SOLID원칙을 정리하고나니, 이젠 근거를 가지고 컴포넌트구조를 설계할수있을것같습니다. 더 나은 의견이 있다면 댓글로 알려주세요! 긴글 읽어주셔서 감사합니다.