SOLID 원칙과 React 개발

박기찬·2025년 4월 30일
0

Web tech

목록 보기
11/12
post-thumbnail

SOLID는 소프트웨어 설계에서 사용되는 다섯 가지 원칙 집합으로, 깨끗하고 유지보수하기 쉬운 코드를 작성하는 데 도움을 준다.
React와 같은 프론트엔드 애플리케이션에서도 SOLID 원칙을 적용하면 컴포넌트 구조를 명확히 하고, 코드의 품질과 확장성, 유지보수성을 높일 수 있다.

리액트 컴포넌트의 책임이 과도하게 겹치거나 의존성이 복잡해지면 코드를 이해하고 수정하기 어려워지지만, SOLID 원칙을 따르면 이러한 문제를 줄이고 안정적인 구조를 만들 수 있다. 다음 섹션에서 SOLID의 각 원칙(SRP, OCP, LSP, ISP, DIP)을 정의하고, 리액트 개발에서 왜 중요한지 예제와 함께 살펴보겠다.

단일 책임 원칙 (Single Responsibility Principle, SRP)

단일 책임 원칙은 클래스나 모듈(또는 컴포넌트 등)이 하나의 책임만 가져야 한다는 원칙이다.
즉, 변경 사유(책임)가 하나여야 하며, 그 컴포넌트는 한 가지 일만 담당해야 한다.

리액트 컴포넌트를 SRP에 따라 설계하면 각 컴포넌트가 명확한 역할을 수행하므로 코드가 모듈화되고 유지보수성이 향상된다. 예를 들어, UI 렌더링과 데이터 페칭 로직을 한 컴포넌트에 모두 두면 변경 사항이 있을 때 여러 이유로 코드를 수정해야 할 수 있지만, 이 둘을 분리하면 코드가 더 읽기 쉽고 테스트하기 쉬워진다. SRP를 적용하면 컴포넌트가 작은 단위로 분리되어 재사용성과 안정성이 높아진다.

사실상 프론트엔드 개발에 있어서 가장 중요한 원칙 중 하나라고 생각이 된다.

아래의 예제를 살펴보자.

interface Book { id: number; title: string; }

const BookList = () => {
  const [books, setBooks] = useState<Book[]>([]);
  
  useEffect(() => {
    fetch('https://api.example.com/books')
      .then(res => res.json())
      .then(data => setBooks(data));
  }, []);
  
  return (
    <ul>
      {books.map(book => (
        <li key={book.id}>{book.title}</li>
      ))}
    </ul>
  );
};

코드 자체에 문제는 없다. 간단한 코드에서는 BookList 라는 컴포넌트에 모든걸 때려박아도 문제가 없을 것이다. 하지만, 실무에서 이러한 단순한 기능만 존재하는 경우가 많을까? 실제 우리의 코드는 덕지덕지 기능과 UI/UX가 붙어있을 것이다. 혹여나 위의 예제처럼 간단한 코드가 있더라도, 통일성을 위해서라도 복잡한 코드를 분리하듯이, 간단한 코드도 명확한 규칙과 책임을 바탕으로 역할을 구분하는 것이 좋다.

function useFetchBooks() {
  const [books, setBooks] = useState<Book[]>([]);
  
  useEffect(() => {
    async function loadBooks() {
      const res = await fetch('https://api.example.com/books');
      const data: Book[] = await res.json();
      setBooks(data);
    }
    loadBooks();
  }, []);
  
  return books;
}

const BookList = () => {
  const books = useFetchBooks();
  return (
    <ul>
      {books.map(book => (
        <li key={book.id}>{book.title}</li>
      ))}
    </ul>
  );
};

이와 같이 코드의 역할을 나눠주게 되면, useFetchBooks는 데이터 패칭 로직을 담당하고, BookList 컴포넌트는 오직 UI 렌더링만 담당하게 됨으로써, 명확한 역할의 구분이 가능해진다.

어떻게 나눠야 할까?

  • 컴포넌트가 담당하는 역할을 하나로 명확히 구분하라. 상태 관리나 데이터 조회 로직은 커스텀 훅이나 별도의 서비스 함수로 분리하는 것이 좋다.

  • 컴포넌트 크기가 너무 커지면, 기능별로 분리하여 복잡도를 낮추자. 예를 들어, 리스트 렌더링과 항목 처리 기능을 별도의 컴포넌트로 나누는 식이다.

  • 반대로 책임을 너무 과도하게 분리하면 오히려 관리해야 할 파일이 많아지는 과잉 설계가 될 수 있다. 적절한 균형을 유지하면서 적용하자.


개방/폐쇄 원칙 (Open/Closed Principle, OCP)

개방/폐쇄 원칙은 소프트웨어 구성 요소(클래스, 모듈, 컴포넌트)가 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙이다. 즉, 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 한다.

리액트에서는 이 원칙을 따르면 컴포넌트를 재사용하거나 확장할 때 기존 코드를 직접 수정할 필요가 없어 코드 안정성이 향상된다. 예를 들어, props.children이나 HOC(고차 컴포넌트)를 활용하면 부모 컴포넌트를 변경하지 않고도 새로운 UI나 기능을 추가할 수 있다. 이렇게 하면 기능 추가 시 주요 컴포넌트를 건드리지 않으므로 사이드 이펙트 위험이 줄어들고 유지보수가 용이해진다.

설명만 보고는 감이 잘 안올수가 있다.

interface BookProps { title: string; image: string; type: 'Premium' | 'Free'; onClick: () => void; }

const Book = ({ title, image, type, onClick }: BookProps) => (
  <div>
    <img src={image} alt={title} />
    <p>{title}</p>
    {type === 'Premium' && <button onClick={onClick}>Buy Premium</button>}
    {type === 'Free' && <button onClick={onClick}>Read</button>}
  </div>
);

Book이라는 컴포넌트를 만들고, 이를 다른 곳에서 계속 사용한다고 생각해보자. 기능이 확장됨에 따라, 새로운 타입을 받을 경우가 생길 수도 있다. 그럴 경우에는 이 Book 컴포넌트 자체에 {type === 'newType ...} 이라는 새로운 조건문을 달아줘야 할 것이다. 즉, 기존 컴포넌트의 변경이 발생하는 것이다. 이렇게 기존에 공통으로 쓰이는, 다른 곳에서 사용되는 컴포넌트를 바꾸게 되었을 경우에는 신경써야할 사이드이펙트가 다수 발생할 것이다. 그렇기 때문에, 다른 코드에 피해를 끼치지 말고(변경 없이) 확장을 하라는 것이다.

const Book = ({ title, image, children }: { title: string; image: string; children: React.ReactNode }) => (
  <div>
    <img src={image} alt={title} />
    <p>{title}</p>
    {children}
  </div>
);

const PremiumBook = (props: { title: string; image: string; onClick: () => void }) => (
  <Book title={props.title} image={props.image}>
    <button onClick={props.onClick}>Buy Premium</button>
  </Book>
);

const FreeBook = (props: { title: string; image: string; onClick: () => void }) => (
  <Book title={props.title} image={props.image}>
    <button onClick={props.onClick}>Read</button>
  </Book>
);

코드를 위처럼 작성하게 되면, 타입이 아무리 많이 추가가 되고 props가 변경이 되어도, 기존 Book 컴포넌트를 건드릴 필요가 없어진다. 혹여나 새로운 타입을 추가헤서 생기는 이슈는 새로운 타입에서만 발생하는 것이다.

어찌보면 당연하게, 우리가 실생활에서도 같이 쓰는 도구는 조심해서 사용하는 것과 같이, 영향을 받는 범위를 줄이는 효과가 크다고 생각하면 조금 더 이해가 쉽다.

  • 컴포넌트를 확장할 때 기존 코드의 수정 없이 기능을 추가할 수 있도록 설계하라. 예를 들어, props.children이나 고차 컴포넌트를 사용하여 기능을 삽입한다.

  • 여러 조건문(if/else)이나 타입 검사로 컴포넌트를 분기하면 원칙을 위반하므로, 필요한 경우 컴포넌트를 분리하거나 공통 인터페이스를 사용해 처리하자.

  • 하지만 너무 과도하게 추상화하거나 분리하면 코드가 복잡해질 수 있다.


리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

리스코프 치환 원칙(LSP)은 상위 타입의 객체를 하위 타입의 객체로 바꿔도 프로그램의 동작이 변함이 없어야 한다는 원칙이다. 리액트에서는 이를 "부모 컴포넌트가 기대하는 인터페이스를 하위 컴포넌트가 그대로 준수해야 한다"는 의미로 해석할 수 있다.

LSP를 따르면 공통 인터페이스를 준수하는 다양한 컴포넌트를 서로 대체할 수 있어 코드의 재사용성과 안정성이 높아진다. 예를 들어, 버튼 컴포넌트의 기본 역할을 유지하면서 특정 스타일이나 기능을 추가한 하위 컴포넌트를 만들면, 부모 버튼 컴포넌트가 사용되던 곳에 아무런 수정 없이 해당 하위 컴포넌트를 사용할 수 있다.

아래의 예시를 살펴보자.

const DangerButton = () => {
  return <div>Danger</div>;
};

이 컴포넌트를, 다른곳에서 버튼으로 사용한다고 생각해보자. 버튼으로 사용할건데, 태그는 div 태그이다. 이럴 경우 LSP를 위반한다고 표현하며, 버튼으로 사용할 컴포넌트라면, 버튼 태그를 달아줘야 하는 것이다.

interface DangerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
}

const DangerButton = ({ children, ...props }: DangerButtonProps) => {
  return <button className="danger" {...props}>{children}</button>;
};

위의 예시처럼, 버튼의 모든 속성을 ...props를 통해 전달받고, 실제로도 button 요소를 반환한다. 이렇게 하면, DangerButton 인스턴스를 일반 버튼 대신 사용할 수 있어 기본 애플리케이션 버튼의 동작을 변경하지 않으면서 LSP를 만족시킨다.

  • 하위 컴포넌트가 부모 컴포넌트의 동작을 변경해서는 안 된다. DOM 요소를 대체할 때는 올바른 태그를 사용하고, 부모의 props 인터페이스를 모두 수용하도록 하자.

  • 필요하다면 React.ButtonHTMLAttributes 같은 타입 인터페이스를 상속하여 공통 속성을 잃지 않도록 한다.

  • 리액트는 함수형이고 조합적 방식을 사용하므로 전통적 OOP의 상속보다는 컴포넌트 합성(composition)을 활용하는 것이 일반적이다.


나머지 ISP와 DIP는 다음 게시글에서 서술하겠다.


참고 -
https://dev.to/mikhaelesa/liskov-substitution-principle-in-react-2p1n#:~:text=Now%20we%20have%20inherited%20all,with%20the%20Liskov%20Substitution%20Principle
https://dev.to/mikhaelesa/interface-segregation-principle-in-react-2501#:~:text=In%20React%2C%20the%20Interface%20Segregation,specific%20needs%2C%20avoiding%20unnecessary%20bloat
https://dev.to/mikhaelesa/open-closed-principle-in-react-1lgd#:~:text=This%20way%2C%20your%20Book%20component,extension%20and%20Closed%20for%20modification

0개의 댓글