프론트엔드/React

[React] SOLID 원칙을 컴포넌트에 적용하기

반응형

좋은 컴포넌트 설계는 정답이 없습니다. 그래서 어렵죠.
저 또한 많은 시행착오를 겪으면서 여러 개념들을 찾아 공부하게 되었습니다.

그러다 객체지향에서 좋은 해결책을 얻게 되어 이를 정리해보고자 합니다.
가장 먼저 SOLID 원칙을 알아보며 이를 컴포넌트에 적용해보도록 하겠습니다.

아래 자료들을 참고했습니다.

 


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

개념

하나의 모듈은 하나의 액터에 대해서만 책임져야 합니다.

아래는 아마 흔하게 접할 수 있는 SRP 원칙의 설명들입니다.

  • 함수나 클래스는 단 한 개의 책임을 가져야 한다.
  • 함수나 클래스는 한 가지 일만 수행해야 한다.
  • 함수나 클래스를 변경하는 이유는 단 한 개여야 한다.


자칫 '단일 기능'의 의미로 해석하기 쉬운 개념입니다.
하지만 '기능'으로 바라보는 관점은, 보다 저수준인 리팩토링 원칙에 가깝습니다.
SRP 원칙은 액터, 즉 사용자, 이해관계자 등의 변경을 요청하는 사람들을 중심으로 합니다.
즉, SRP 원칙은 비즈니스 관점에서 책임을 분리하는 원칙으로 보는 것이 적합합니다.

(💡 공식 문서에 좋은 예시가 있으니 읽어보는 것을 추천합니다.)

예시

리액트에서도 SRP 원칙을 기준으로 책임을 분리할 필요가 있습니다.
컴포넌트가 다수의 책임을 가지면 아래와 같은 문제들이 발생하기 때문입니다.

  • 당연히 컴포넌트가 비대해지면서 가독성이 떨어집니다.
  • 서로 다른 액터 간에 의존성이 생겨 확장성이 떨어집니다.
  • 의도치 않게 연쇄적인 사이드 이펙트가 발생할 수 있습니다.
  • 이들은 곧 유지보수 비용과도 직결됩니다.


프론트엔드에서의 역할은 크게 4가지로 구분 지을 수 있습니다.
그리고 각각의 역할들은 코드를 나누는 일종의 가이드라인이 됩니다.

  • Component : 상태를 화면에 표시하는 역할
  • Reducer : 상태를 변경하는 역할
  • API : 서버로부터 데이터를 불러오는 역할
  • Hooks : 함수형 컴포넌트에 기능을 제공하는 역할


예시를 들어보겠습니다.

const ActiveUsersList = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  const weekAgo = new Date();
  weekAgo.setDate(weekAgo.getDate() - 7);

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

이 컴포넌트가 하는 일을 나열하면 다음과 같습니다.

  • 데이터 페칭
  • 데이터 필터링
  • 리스트 렌더링


먼저 각각의 역할에 따라 코드를 분리해보겠습니다.

const useUsers = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const loadUsers = async () => {
      const response = await fetch("/some-api");
      const data = await response.json();
      setUsers(data);
    };

    loadUsers();
  }, []);

  return { users };
};

연관된 useState와 useEffect는 Custom Hook으로 정리하는 것이 좋습니다.
동시에 데이터 페칭 로직 또한 분리할 수 있겠네요.

const UserItem = ({ user }) => {
  return (
    <li>
      <img src={user.avatarUrl} />
      <p>{user.fullName}</p>
      <small>{user.role}</small>
    </li>
  );
};

다음으로 매핑을 통해 렌더링 되는 UserItem 컴포넌트를 추출하겠습니다.

const getOnlyActive = (users) => {
  const weekAgo = new Date();
  weekAgo.setDate(weekAgo.getDate() - 7);

  return users.filter(
    (user) => !user.isBanned && user.lastActivityAt >= weekAgo
  );
};

마지막으로 활성 사용자만 필터링하는 로직도 getOnlyActive 함수로 분리합니다.
유틸 함수를 통해 책임 분리뿐 아니라, 재사용이 가능하다는 장점도 생기게 됩니다.

const ActiveUsersList = () => {
  const { users } = useUsers();

  return (
    <ul>
      {getOnlyActive(users).map((user) => (
        <UserItem key={user.id} user={user} />
      ))}
    </ul>
  );
};

결과적으로 메인 로직은 충분히 깔끔해졌습니다.
하지만 조금 더 파고들면 분리 가능한 부분이 더 있습니다.

const useActiveUsers = () => {
  const { users } = useUsers();

  const activeUsers = useMemo(() => {
    return getOnlyActive(users);
  }, [users]);

  return { activeUsers };
};

메인 컴포넌트는 단순히 데이터를 가져와 렌더링만 해주는 것이 더욱 이상적입니다.
그러므로 데이터 페칭과 필터링 로직을 묶어 Custom Hook으로 분리하겠습니다.

const ActiveUsersList = () => {
  const { activeUsers } = useActiveUsers();

  return (
    <ul>
      {activeUsers.map((user) => (
        <UserItem key={user.id} user={user} />
      ))}
    </ul>
  );
};

SRP에 기반하여 컴포넌트를 효과적으로 모듈화 하였습니다.
이를 통해 각각의 모듈은 단일된 책임을 가지며 테스트유지보수가 용이해졌습니다.


2️⃣ 개방 폐쇄 원칙 (OCP, Open Close Principle)

개념

소프트웨어의 구성요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려있고, 변경에는 닫혀있어야 합니다.
쉽게 말해 기존의 소스 코드를 변경하지 않고 확장할 수 있어야 합니다.

OCP 원칙은 소프트웨어 아키텍처의 중요한 원칙 중 하나입니다.
소프트웨어 설계의 목표는 비용을 최소화하고 생산성을 최대화하는 것인데,
OCP 원칙을 잘 지킨 요소는 특히 변경에 대한 비용을 획기적으로 낮출 수 있기 때문입니다.

예시

프로퍼티 활용

리액트에서는 주로 프로퍼티를 활용해 OCP 원칙을 적용할 수 있습니다.

const PostCardList = ({ posts }) => {
  return (
    <Wrapper>
      {posts.map((post) => (
        <PostCard key={post.id} />
      ))}
    </Wrapper>
  );
};

위 컴포넌트는 오직 post만 취급하기 때문에 확장성이 좋지 않습니다.
다양한 종류의 카드를 활용할 수 있도록 Card 프로퍼티를 추가하겠습니다.

const CardList = ({ datas, Card }) => {
  return (
    <Wrapper>
      {datas.map((data) => (
        <Card key={data.id} />
      ))}
    </Wrapper>
  );
};

 

컴포넌트 합성

const Header = () => {
  const { pathname } = useRouter();

  return (
    <header>
      <Logo />
      <Actions>
        {pathname === "/dashboard" && (
          <Link to="/events/new">Create event</Link>
        )}
        {pathname === "/" && <Link to="/dashboard">Go to dashboard</Link>}
      </Actions>
    </header>
  );
};

만약 페이지마다 헤더를 다르게 표현해야 한다고 가정해봅시다.
그렇다면 이 헤더 컴포넌트는 재사용성과 확장성이 좋지 않습니다.

이를 해결하기 위해 컴포넌트 합성을 적용할 수 있습니다.

const Header = ({ children }) => (
  <header>
    <Logo />
    <Actions>{children}</Actions>
  </header>
);

const HomePage = () => (
  <>
    <Header>
      <Link to="/dashboard">Go to dashboard</Link>
    </Header>
    <OtherHomeStuff />
  </>
);

const DashboardPage = () => (
  <>
    <Header>
      <Link to="/events/new">Create event</Link>
    </Header>
    <OtherDashboardStuff />
  </>
);

이제 헤더 컴포넌트는 무엇을 렌더링 할지 신경 쓰지 않고 children을 통해 내부 컴포넌트를 주입받습니다.
컴포넌트 간의 결합은 줄어들고 확장성과 재사용성이 높아지게 되었네요.


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

개념

부모(상위) 객체를 자식(하위) 객체로 치환해도 동작에 문제가 없어야 한다.

그러기 위해서는 하위 객체를 구현할 때 인터페이스 규약을 잘 지켜야 합니다.
LSP 원칙을 지켰을 때 상속과 다형성을 활용할 수 있기 때문입니다.

예시

컴포넌트에서 LSP 원칙을 적용할 일은 드물 듯합니다.
심지어 리액트에서도 상속보다는 합성을 권장하기 때문입니다.
(TypeScript는 상속을 통해 LSP 원칙을 활용할 수 있습니다.)


4️⃣ 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

개념

자신이 사용하지 않는 인터페이스는 구현하지 말아야 합니다.
불필요한 인터페이스는 자원 낭비뿐 아니라, 무엇보다 불필요한 의존성이 생기기 때문입니다.

예시

interface Video {
  title: string;
  duration: number;
  coverUrl: string;
};

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map((item) => (
        <Thumbnail key={item.title} video={item} />
      ))}
    </ul>
  );
};
interface Props {
  video: Video;
};

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />;
};

Thumnail은 video 객체를 통째로 전달받지만 정작 사용하는 값은 coverUrl 문자열뿐이죠.
오류는 없겠지만 확정성은 절대 좋지 않습니다.

interface LiveStream {
  name: string;
  previewUrl: string;
};

위와 같이 다른 미디어에 활용하고 싶을 때 문제가 생깁니다.
이를 해결하기 위해 ISP 원칙을 적용해봅시다.

interface Props {
  coverUrl: string;
};

const Thumbnail = ({ coverUrl }: Props) => {
  return <img src={coverUrl} />;
};

이제 Thumbnail은 필요한 coverUrl 프로퍼티만 제공받게 되었습니다.

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map((item) => {
        'coverUrl' in item ? (
          <Thumbnail coverUrl={item.coverUrl} /> // type: Video
        ) : (
          <Thumbnail coverUrl={item.previewUrl} /> // type: LiveStream
        );
      })}
    </ul>
  );
};

덕분에 Thumnail 컴포넌트는 새로운 미디어에 대한 확장성을 갖출 수 있습니다.


5️⃣ 의존성 역전 원칙 (DIP, Dependency Inversion Principle)

개념

고수준 모듈이 저수준 모듈의 구현에 의존해서는 안됩니다.
쉽게 말해 구체적인 함수나 클래스가 아닌, 인터페이스 등의 추상화에 의존해야 합니다.
저는 DIP 원칙에서 TypeScript가 객체지향 패러다임을 추구한다는 걸 많이 느꼈습니다.

(Redux도 DIP원칙의 예시 중 하나인데, 이는 더 공부해서 보충해보겠습니다.)

예시

import api from "~/common/api";

const LoginForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = async (evt) => {
    evt.preventDefault();
    await api.login(email, password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Log in</button>
    </form>
  );
};

LoginForm은 구체적인 함수인 handleSumit을 의존하고 있습니다.
때문에 api도 직접 참조하면서 결합성이 높아져버렸죠.
이는 코드 변경을 어렵게 만들기 때문에 DIP 원칙을 통해 결합을 없애야 합니다.

interface Props {
  onSubmit: (email: string, password: string) => Promise<void>;
}

const LoginForm = ({ onSubmit }: Props) => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = async (evt) => {
    evt.preventDefault();
    await onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Log in</button>
    </form>
  );
};

LoginForm을 onSubmit 함수 인터페이스에 의존하도록 만들었습니다.
구현을 상위 단계에 위임하면서 의존 관계를 형성할 수 있게 됩니다.

import api from "~/common/api";

const ConnectedLoginForm = () => {
  const handleSubmit = async (email, password) => {
    await api.login(email, password);
  };

  return <LoginForm onSubmit={handleSubmit} />;
};

상위 컴포넌트에서는 인터페이스만 준수해준다면 정상적으로 동작할 것입니다.


최근 객체지향을 공부하면서 컴포넌트 설계의 큰 해답을 얻고 있습니다.
하지만 공부할 것이 많기 때문에 포스팅을 망설이고 있었습니다.
이러다간 끝이 없을 것 같아 업로드 후 객체지향을 공부하며 글을 보완할까 합니다. 😊

반응형