[리팩토링] SOLID한 컴포넌트 만들기

강풍윤·2023년 12월 15일
1

저는 이번 프리온보딩 FE 챌린지(23년 12월)에 참여하여 SOLID 설계 원칙에 대해서 알아보는 시간을 가졌습니다. 물론 SOLID 강의를 단 한 번에 이해하진 못해서 다른 개발자의 블로그와 영상들도 함께 참고해서 제가 이해한 내용을 바탕으로 정리해 보았습니다.

정리하기에 앞서 지금까지 저의 코드는 일단 내가 원하는 대로(요구사항대로) 동작할 수 있도록 작성한 날 것(raw)의 코드였고, 뭔가 나의 코드가 지저분하다는 느낌은 들긴 하는데 어디서부터 어떻게 개선해야 할지 모르는 상태였습니다. 그래서 이 부분에 대해서 항상 고민이 많았는데, 이번 기회에 SOLID 설계 원칙을 알게 되면서 저의 고민을 구체적으로 표현할 수 있게 되고, 그 고민을 어떻게 해결할지 방향성을 알려준 원칙에 대해서 나름대로 정리할 수 있었습니다.

할 줄 아는 욕이 없어
"할 줄 아는 욕이 없어...ㅠㅠ" "마치... 불쾌하긴 한데... 이 상황을 찰떡같이 표현할 줄 몰라 답답한 상태"

1. SOLID 설계 원칙이란?

SOLID 설계 원칙은 로버트 마틴 개발자 선배님이 명명한 객체 지향 프로그래밍 및 설계 중 5가지의 기본 원칙에서 가장 앞의 단어만 따서 표현한 설계원칙입니다.

이 설계 원칙의 핵심은 dependency(의존성 혹은 종속성) 관리입니다. 코드가 거대하고 복잡해질수록 여러 의존성들이 뒤엉켜 있을 확률이 큽니다. 만약 의존성 관리가 부실하다면, 코드는 변경하기 어렵고, 재사용하기 어렵게 만듭니다. 하지만 SOLID의 5가지 원칙을 지킨다면, 코드 변경이 용이하고 재사용이 가능하여 확장 가능한 코드를 구현할 수 있습니다.

저는 의존성에 대한 개념을 이해한 후, 왜 저의 코드가 복잡하다고 느꼈는지에 대한 의문이 풀렸습니다. 저의 코드가 지저분하다는 느낌을 받았다는 부분을 정확히 표현하자면 의존성이 복잡하게 뒤엉켜 있다는 의미였습니다. 그렇다면 의존성이 무엇인지 먼저 살펴보겠습니다.

1-1) 의존성(dependency)이란?

프로그래밍 분야에서 A라는 객체가 변경되면 A를 사용하는 B 객체에 영향을 미친다면, "B 객체가 A 객체를 의존한다"라고 합니다.

class A {
	foo(){
        ...
    }
}

class B {
  ...
  getA(){
  	const a = new A();
    const bar = a.foo();
  }
}

위의 코드처럼 A라는 객체가 변경되면 B 객체 안의 a의 값에 영향을 주기 때문에 B 객체는 A 객체에 의존한다라고 할 수 있습니다.

위의 예시에서는 A와 B 두 객체 사이의 의존관계를 살펴보았지만, 만약 A와 B같이 서로 의존하고 있는 경우가 더 많아지고 아주 복잡해진다면, 하나를 수정하는데 의존하는 모든 것을 함께 수정해야 하는 끔찍한 상황이 발생할 수 있습니다. SOLID 원칙에 따라 의존성을 관리한다면, 이런 끔찍한 상황에서부터 대부분은 자유로워질 수 있을 것이라 생각합니다.

1-2) SOLID의 5가지 원칙

[1] SRP 단일 책임 원칙

SRP(Single responsibility principle)는 하나의 모듈 단위가 가지는 책임을 다른 모듈이 가지지 않도록 독립적으로 분리하는 원칙입니다.

단순히 단 하나의 동작만 가지도록 분리하는 것이 아니라, 비즈니스 로직에 의하여 책임 단위를 나누어 분리하는 원칙입니다. 즉 한 가지 책임에 관한 변경사항이 생겼을 때 단 하나의 모듈 코드만 수정하게 되는 구조가 가장 좋은 구조를 의미합니다.

예를 들어, 변경이 없는 데이터와 자주 변경하는 데이터가 있는 컴포넌트가 있다면, 따로 컴포넌트를 분리해서 합성하는 방식으로 나누어 사용합니다. 변경이 없는 데이터의 컴포넌트는 수정하지 않고, 자주 변경되는 데이터만 다루는 컴포넌트만 살펴서 빠르게 대응할 수 있도록 구조화하는 방법입니다. 여기서 책임은 비즈니스 로직에 의해 나누기 때문에, 단순히 컴포넌트를 변경 가능성을 기준으로 나누는 것이 아니라 회사에서 요구하는 비즈니스 로직에 의하여 컴포넌트를 분리하는 것이 좋습니다.

[2] OCP 개방/폐쇄 원칙

OCP(Open/closed principle)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 원칙입니다. 즉 기존 컴포넌트를 수정하지 않고, 서브컴포넌트를 잘게 나누어서 조합하여 여러 가지의 구체적인 컴포넌트를 만들 수 있는 구조가 가장 좋은 구조를 의미합니다.

이 원칙을 간단하게 보면 필요한 하위 컴포넌트들을 모두 준비하고, 필요에 따라 기존의 하위 컴포넌트를 원하는 곳에 장착하여 새로운 컴포넌트 완성물을 만드는 방법과 같습니다. 만약 컴포넌트 완성물에서 일치하는 하위 컴포넌트가 없다면, 필요한 컴포넌트만 새로 만들어서 추가하는 방식입니다. 하지만, 어떤 부품들이 어떻게 들어갈지 미리 예측하고 기존 컴포넌트를 변경하지 않도록 기존 컴포넌트를 완벽하게 만들어야 하기 때문에 어쩌면 가장 어려운 원칙일 수 있습니다.

[3] LSP 리스코프 치환 원칙

LSP(Liskov substitution principle)는 하위 클래스는 상위 클래스의 규약을 벗어나지 않고 확장해야 한다는 원칙입니다. 같은 의미로 객체 지향 프로그래밍에서 말하는 상속을 통해 다형성을 통한 확장성 획득을 목표로 합니다.

(하지만, React의 경우, Facebook에서는 수천 개의 React 컴포넌트를 사용하지만, 컴포넌트를 상속 계층 구조로 작성을 권장할만한 사례를 찾지 못했다고 합니다. 따라서 React에서는 상속 계층 구조보다는 여러 컴포넌트를 합쳐 새로운 컴포넌트를 만드는 "컴포넌트 합성"을 통해 상속을 구현해야 합니다.)

예를 들어, 두 객체가 동일한 규칙을 가진 같은 상위 개념이라면, 하나의 인터페이스를 만들어서 동일한 규칙을 상속 혹은 합성을 통해 두 객체가 상위 클래스의 규칙으로 사용할 수 있도록 구현합니다.(같은 규칙을 다른 객체에서 따로 사용하기보단 상위 개념을 만들어 같은 규칙을 사용할 수 있도록 만들어야 중복을 피할 수 있다.)

하지만 한 객체에서 원래 규칙에 위배된다면, 이는 더 이상 동일한 상위 클래스를 가지고 있는 객체가 아니게 됩니다.(물론 하위 클래스에서 확장하여 새로운 규칙을 만들 수 있으나, 하위 클래스가 상위 클래스의 규칙을 위배할 수는 없습니다.) 따라서 올바른 상위 컴포넌트로 합성하거나, 상위 클래스의 모든 규칙을 지키는 하위 컴포넌트로 변경하는 것이 바람직합니다.

[4] ISP 인터페이스 분리 원칙

ISP(Interface segregation principle)는 사용하지 않은 것에 의존하지 않아야 한다는 원칙입니다. 이 원칙에 따르면, 불필요한 매개변수가 들어간 인터페이스의 매개변수나 타입을 제거 혹은 분리해야 합니다. 제공하지 않는 매개변수나 타입을 강제하지 않도록 분리해주어야 하기 때문입니다.

예를 들면, 버튼에 아이콘이 들어가는 경우를 대비하여 Button 컴포넌트의 props으로 icon이라는 값을 받는다고 가정하면, 아이콘이 없는 버튼에도 icon이란 props의 값을 전달해줘야 합니다. 즉 Button 컴포넌트에 있어서 icon버튼의 일반적인 매개변수(prop)가 아니기 때문에, Button 컴포넌트 props에서 icon를 제거하고 iconIconButton 컴포넌트의 propsButton 컴포넌트의 props에서 확장하여 사용하는 것이 올바른 방식이라고 볼 수 있습니다.

// ISP 적용하기 전의 Button.tsx
import { Icon } from './Icon';

type ButtonProps = {
  // button의 기본 prop들 타입 정의
  ...
  icon: Icon
}

export const Button = ({icon, ...}:ButtonProps){
  ...
  return(<button icon={icon} ... > ... </button>)
}
// ISP 적용한 후의 Button.tsx
type ButtonProps = {
  // button의 기본 prop들 타입 정의
  ...
} // icon 매개변수 정의 제거

export const Button = ({...}:ButtonProps){
  ...
  return(<button ... > ... </button>)
}

[5] DIP 의존성 역전 원칙

DIP(Dependency inversion principle)을 직역하면 의존성 역전 원칙입니다. 여기서 의존성 역전이란 의존성 제어 흐름과 반대 방향으로 역전되는 것을 의미합니다.

이 개념을 처음 듣고 IoC(Inversion of Control)와 DI(Dependency Injection)의 개념과 혼동하게 되어 헷갈렸습니다. 이렇게 혼동할 수 있는 개념들을 정말 잘 정리해 주신 블로그를 보았는데, 그 블로그 글을 다시 간단하게 정리하면, IoC는 외부와 내부의 의존성 주체가 바뀌는 것에 집중하고, DIP은 구체적인 내부를 추상화하여 의존성을 낮추는 것을 목표로 하며, DI는 내부에서 객체를 생성하지 않고 외부에서 객체를 생성하여 의존성을 주입시키는 방식입니다.

DIP의 예시로 만약 상위 컴포넌트가 하위 컴포넌트를 직접 참조하여 긴밀한 결합을 가지게 되는 경우에 발생하게 됩니다. 이런 경우, 하나의 요소 변경이 또 다른 컴포넌트에 영향을 주기 때문에 코드 변경이 어렵습니다. 이런 종속성을 끊어내기 위해 상위 컴포넌트와 하위 컴포넌트 모두 추상화한 새로운 컴포넌트를 연결시켜서 서로 독립적이게 분리합니다.

2. SOLID 원칙을 적용하여 리팩토링하기

위의 사진은 제가 직접 만든 개인 프로젝트에서 사용하는 모달 예시입니다.

2-1) 기존 코드

// Modal.tsx
import {IconProps, ExclamationIcon, ...} from './Icon';
import LabelProps from './Label';
...

type ModalProps = {
  title: string;
  description?: string;
  icon?: IconProps;
  label?: LabelProps;
  ...
}

export default function Modal({ title, description, icon, label } : ModalProps){
	...
    return (
    	<div className="modal-content">
          // icon
          {icon = "exclamation" ? <ExclamationIcon /> : (...)}
          // title
          <h1 className="modal-title">{title}</h1>
          // description
          {description &&
            <span className="modal-description">
              {description}
            </span>
          }
          // label
          {label &&
          	<label className="madal-label">
              <div>{label.text}</div>
              ...
            </label>
          }
          // buttons
          <div style={{display: "flex", justifyContent: "space-evenly"}} >
            <button
              className="modal-button cancel"
              onClick={...}
            >
              취소
            </button>
            <button
              className="modal-button confirm"
              onClick={...}
            >
              확인
            </button>
          </div>
        </div>
    );
}
// Index.tsx
import Modal from './Modal'
import { useSearchParams, ... } from "next/navigation";

export default function Index(){
  
  const searchParams = useSearchParams();
  const showModal = searchParams?.get("modal");
  ...
  
  return (
    ...
    <Link
      href={`${thisUrl}/?modal=true`}
      className="modal-open-button"
    >
      탈퇴하기
    </Link>
    {showModal && <Modal icon="exclamation" title="정말 탈퇴하시겠습니까?" description="일기와 좋아요 기록이 모두 삭제됩니다." />}
    ...
  );
}

저는 위의 두 코드 파일에서 각 SOLID 설계 원칙 관점에서 만족하지 못한 부분을 확인할 수 있었습니다.

SOLID 설계 원칙 관점에서 발생하는 문제점

1. SRP : Modal 컴포넌트는 icon, title, description, label 등 여러 책임을 가지고 있다. 책임을 분리한다면 기존 Modal 컴포넌트를 건드리지 않고 분리된 책임의 컴포넌트만 수정하면 된다.
=> 비즈니스 로직에 따라 변경하고자 하는 책임 단위로 분리해주어야 한다.

2. OCP : 만약 모달 안에 체크박스를 요구한다면, 기존의 Modal 컴포넌트에서 수정해야 한다. 추가적인 요구사항에 따라 기존 컴포넌트가 변경된다면 의존성 관리가 매우 복잡해질 수 있다.
=> 컴포넌트를 잘게 나누어서 페이지에서 필요한 컴포넌트를 합성하여 원하는 Modal를 구현한다.

3. ISP : Modal 컴포넌트에서 상황에 따라 사용하지 않는 icon, label 등의 인터페이스가 있습니다. 필요에 따라 사용하지 않는 인터페이스를 그대로 사용한다면 해당 인터페이스의 의존성이 남아 있기 때문에 의존성을 분리해주어야 합니다.
=> iconlabelModal 컴포넌트의 인터페이스에서 제거하고 따로 합성할 수 있도록 변경합니다.

2-2) SOLID 설계 원칙으로 리팩토링한 코드

// Modal.tsx
import ModalTitle from './ModalTitle';
import ModalDescription from './ModalDescription';
import ModalButton from './ModalButton';
...

function Modal({children}: ReactNode;){
	...
    {ModalTitle && (<div>{ModalTitle}</div>)}
  	{ModalDescription && (<div>{ModalDescription}</div>)}
    {ModalButton && (<div>{ModalButton}</div>)}
    ...
}

export default Object.assign(Modal, {
  Title: ModalTitle,
  Description: ModalDescription,
  Button: ModalButton
  ...
});
// Index.tsx
import Modal from './Modal'
import ExclamationIcon from './Icon'
import { useSearchParams, ... } from "next/navigation";

export default function Index(){
  
  const searchParams = useSearchParams();
  const showModal = searchParams?.get("modal");
  ...
  
  return (
    ...
    <Link
      href={`${thisUrl}/?modal=true`}
      className="modal-open-button"
    >
      탈퇴하기
    </Link>
    {showModal && 
    	<Modal>
      		<ExclamationIcon />
    		<Modal.Title>정말 탈퇴하시겠습니까?</Modal.Title>
      		<Modal.Description>일기와 좋아요 기록이 모두 삭제됩니다.</Modal.Description>
      		<div style={{display: "flex", justifyContent: "space-evenly"}} >
      			<Modal.Button>취소</Modal.Button>
      			<Modal.Button>확인</Modal.Button>
      		</div>
    	</Modal>
    }
    ...
  );
}

합성 컴포넌트 패턴(Comfound Component Pattern)을 이용하여 Modal 컴포넌트의 책임을 분리했습니다.

합성 컴포넌트 패턴을 이용하면, 책임에 따라 분리하여 변경이 가능합니다. 예를 들면, "모달 제목의 크기를 3단계로 나누어서 표현해 주세요."라는 요구사항이 들어와도 모달 제목의 책임을 가진 ModalTitle 컴포넌트에서만 변경하면 됩니다.(SRP 원칙 충족)

또한, 하위 컴포넌트들이 분리가 되기 때문에 추가적인 컴포넌트의 요구사항이 들어와도 기존의 컴포넌트를 건드리지 않고 새로운 컴포넌트를 만들어 합성할 수 있는 구조가 됩니다.(OCP 원칙 충족)

상황에 따라 사용하지 않는 인터페이스(iconlabel)를 제거하고 구현하는 곳에서 직접 Icon 컴포넌트와 Label 컴포넌트를 불러올 수 있는 구조로 변경했습니다.(ICP 원칙 충족)

3. 느낀 점

늘 혼자서 코드를 구현했기 때문에 변경사항에 대한 고민을 못했습니다. 그래서인지 변경에 용이한 컴포넌트를 고민하지 못했고, 많은 개발자가 이야기 하는 컴포넌트 분리 기준인 재사용성, 복잡도 중 복잡도에 대한 이해가 많이 부족했습니다.

하지만 SOLID 설계원칙을 배우면서, 무엇 때문에 코드가 복잡하다고 느꼈는지를 보다 구체적으로 표현할 수 있었습니다. 의존성 관리라는 표현을 알게 되었고, 어떤 이유에서 의존성을 관리해주어야 하고, 또 의존성을 관리하는 원칙 5가지에 대해 이해할 수 있었습니다.

그래도 아직 실무 경험이 없어 실제 구현에서 알맞은 상황에 바로 적용시킬 수 있을지에 대한 걱정도 있습니다. 그래서 만약 코드가 복잡해질 것 같으면, 바로 코드를 구현하는 것이 아니라 먼저 의존성 관리 측면에서 생각해 보고 컴포넌트를 분리해 보는 습관을 가지고자 합니다.

또 SOLID 설계원칙에 대해 학습하기 이전엔 "UI/UX가 명확한 웹사이트가 좋은 웹사이트"라고 생각했는데, 현재는 "정말 좋은 웹사이트는 변화에 유연한 웹사이트이다"라고 생각하게 되었습니다.

4. 참고 사이트

"Principles of OOD", Robert C. Martin
"객체지향 개발 5대 원리: SOLID", 넥스트리소프트, 2021.01.06
"프론트엔드와 SOLID 원칙", 임성묵, 2023.03.30
"합성 (Composition) vs 상속 (Inheritance)", React 공식문서
"Applying SOLID principles in React", dailyjs, 2022.07.13
"[Study]IoC, DI, DIP 개념 잡기", vagabond95, 2020.07.03
"합성 컴포넌트로 재사용성 극대화하기", 방경민, 2022.07.31

profile
https://github.com/KANGPUNGYUN

0개의 댓글