React에 SOLID 원칙 적용해보기

햄햄·2022년 7월 25일
12

Applying SOLID principles in React를 번역 및 요약하였습니다.

Single responsibility principle (SRP) - 단일 책임 원칙

  • 너무 큰 컴포넌트는 작은 컴포넌트로 쪼갠다.
  • 컴포넌트의 주요 기능과 관계 없는 코드는 별도의 유틸함수로 추출한다.
  • 연관된 기능은 커스텀 훅으로 캡슐화한다.
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>    
  )
}

상대적으로 짧은 컴포넌트지만 데이터를 불러오고, 필터링하고, 렌더링하는 여러가지 일을 하고 있다. 이 컴포넌트를 쪼개는 법을 살펴보자.

먼저, 서로 연관된 useStateuseEffect 훅이 있으면 커스텀 훅으로 추출하기 좋다.

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 }
}

const ActiveUsersList = () => {
  const { users } = useUsers()
  
  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>    
  )
}

이제 useUser 훅은 API에서 사용자를 가져오는 한가지 관심사에만 집중되게 된다. 이로 인해 메인 컴포넌트는 더 짧아지고 읽기 쉬워졌다.

다음은 컴포넌트가 렌더링하는 JSX를 보자. obejct의 array를 매핑하는 루프가 있으면 JSX의 복잡성 때문에 주의를 기울여야 한다. 어떤 이벤트 핸들러도 붙지 않은 한줄짜리는 괜찮지만, 마크업이 복잡할수록 별도의 컴포넌트로 추출하는 것이 좋다.

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

const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)
  
  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

마지막으로, API에서 가져온 모든 사용자 중 비활성화된 사용자를 필터링하는 로직이 있다. 이 로직은 상대적으로 고립되어있고 어플리케이션의 다른 부분에서 재사용 될 수 있다. 그러므로 이를 유틸 함수로 쉽게 추출할 수 있다.

const getOnlyActive = (users) => {
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)
  
  return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}

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 }
}

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

데이터를 불러오고 필터링하는 로직을 관리하는 useActiveUsers 훅을 생성하였고, 메인 컴포넌트는 훅에서 가져온 데이터를 렌더링하는 최소한의 작업만 하게 되었다.

요약하자면, 단일 책임 원칙에 따라 큰 하나의 코드 조각을 효과적으로 모듈화했다. 모듈화는 매우 좋다. 코드를 더 쉽게 추론할 수 있게 하고, 더 작은 모듈은 테스트 및 수정하기 쉽고, 의도하지 않은 코드 중복이 발생할 가능성이 줄어들고, 결과적으로 코드를 보다 쉽게 유지 관리할 수 있기 때문이다.

위 예제는 다소 인위적이고, 실제 컴포넌트에서는 서로 다른 부분끼리 종속성이 훨씬 얽혀있을 것이다. 보통 나쁜 추상화 사용, 광범위하게 모든 것을 하는 컴포넌트 생성, 부적절한 데이터 스코핑 등등은 나쁜 디자인의 지표가 될 수 있다. 따라서 더 광범위한 리팩토링으로 이것들을 풀 수 있다.

Open-closed principle(OPC) - 개방 폐쇄 원칙

OCP는 "소프트웨어 엔터티는 확장에 열려 있어야 하고 수정에 닫혀있어야 한다."는 의미이다. 개방 폐쇄 원칙은 기존 소스 코드를 변경하지 않으면서 컴포넌트를 확장할 수 있도록 설계하는 것을 지향한다. 예제를 보기 전에 다음 시나리오를 생각해보자. 작업중인 어플리케이션의 여러 페이지에서 공통 Header 컴포넌트를 사용하고 있고, 머무르는 페이지에 따라 Header는 약간씩 다른 UI를 렌더링해야 한다.


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 HomePage = () => (
  <>
    <Header />
    <OtherHomeStuff />
  </>
)

const DashboardPage = () => (
  <>
    <Header />
    <OtherDashboardStuff />
  </>
)

위 예제는 현재 페이지에 따라 다른 페이지 컴포넌트로 가는 링크를 다르게 렌더링하고 있다. 페이지를 더 추가하기 시작할 때 벌어질 일들을 생각하면 이 구현이 나쁘다는 것을 쉽게 알아챌 수 있다. 새로운 페이지가 생성될 때마다 Header 컴포넌트로 돌아가 렌더링할 액션 링크가 무엇인지 알도록 구현을 수정해야 한다. 이런 접근법은 Header 컴포넌트를 취약하게 만들고 사용되는 맥락에 강하게 결합되게 만들어 개방 폐쇄 원칙에 어긋나게 된다.

이를 해결하기 위해 컴포넌트 합성을 사용할 수 있다. Header 컴포넌트는 내부에서 무엇이 렌더링 될지 알 필요가 없다. 대신 children prop을 사용해 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 />
  </>
)

이 방법을 통해 Header 컴포넌트 내부에 있는 다양한 로직을 완전히 제거할 수 있고 합성을 사용하여 컴포넌트 자체를 수정하지 않고 우리가 원하는 무엇이든 추가할 수 있다. 연결될 수 있는 컴포넌트에 placeholder를 제공한다고 이해하면 좋다. 그리고 여러 확장 포인트가 필요한 경우 컴포넌트 하나당 하나의 placeholder로 제한하지 않고 props를 많이 사용해도 된다. 어떤 맥락을 Header 에서 이를 사용하는 컴포넌트로 전달해야 한다면 render props pattern을 사용할 수 있다.

Liskov substitution principle (LSP) - 리스코프 치환 원칙

매우 단순하게 말하자면, LSP는 "하위 객체가 상위 객체를 대체할 수 있어야 한다"는 객체간의 관계 유형으로 정의될 수 있다. 이 원칙은 수퍼타입과 서브타입 관계를 정의하는 클래스 상속에 강하게 의존하고 있지만, 우리는 클래스 상속은 고사하고 클래스를 거의 다루지 않기 때문에 리액트에 적용하기는 어렵다. 그러므로 이 원칙은 생략하도록 한다.

Interface segregation principle (ISP) - 인터페이스 분리 원칙

ISP에 따르면 "클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다." 리액트 어플리케이션에서는 이를 "컴포넌트는 자신이 사용하지 않는 props에 의존하지 않아야 한다"고 해석할 수 있다. ISP가 타게팅하는 문제를 더 잘 설명하기 위해 타입스크립트 예제를 사용할 것이다. 비디오 리스트를 렌더링하는 어플리케이션을 생각해보자.


type Video = {
  title: string
  duration: number
  coverUrl: string
}

type Props = {
  items: Array<Video>
}

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

각 item에 사용된 Thumbnail 컴포넌트는 다음과 같이 생겼을 것이다.


type Props = {
  video: Video
}

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

Thumbnail 컴포넌트는 작고 심플하지만 한가지 문제가 있다. vidoe 객체 중 한 개의 프로퍼티만 사용하지만 전체 vidoe 객체가 props로 전달되고 있는 것이다.

비디오 뿐 아니라 라이브 스트림의 썸네일도 똑같은 리스트 안에서 보여주기로 했다고 생각해보면 왜 문제인지 알 수 있다.

라이브 스트림 객체를 정의한 새로운 타입을 보자.

type LiveStream = {
  name: string
  previewUrl: string
}

업데이트된 VideoList 컴포넌트이다.

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // 여기는 비디오이다.
          return <Thumbnail video={item} />
        } else {
          // 여기는 라이브 스트림이다. 그런데 무엇을 할 수 있을까?
        }
      })}
    </ul>
  )
}

VidoeLiveStream이 호환되지 않기 때문에 Thumbnail 컴포넌트에 후자를 전달할 수 없게 된다. 이것이 컴포넌트가 실제로 필요한것보다 더 많은 props에 의존하게 되면 생기는 문제의 핵심이다. 바로 재사용성이 떨어진다는 것이다. 한번 수정해보자.

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

이제 비디오와 라이브 스트림의 썸네일을 렌더링하는 데 모두 사용할 수 있게 되었다.


type Props = {
  items: Array<Video | LiveStream>
}
const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          // it's a live stream
          return <Thumbnail coverUrl={item.previewUrl} />
        }
      })}
    </ul>
  )
}

인터페이스 분리 원칙은 컴포넌트 사이의 의존성을 최소화하여 결합을 낮추고 재사용성을 높이는 것을 지향한다.

Dependency inversion principle(DIP) - 의존관계 역전 원칙

의존관계 역전 원칙은 "구체화가 아닌 추상화에 의존해야 한다"는 의미이다. 즉, 한 컴포넌트가 다른 컴포넌트에 직접 의존하기 보다는 두 컴포넌트 모두 공통된 추상화에 의존해야한다는 것이다. 여기서 "컴포넌트"는 React 컴포넌트, 유틸 함수, 모듈, 3rd party 라이브러리와 같이 어플리케이션의 모든 부분을 가르킨다. 예제를 보자.

아래 LoginForm 컴포넌트는 form이 제출되었을 때 유저의 credentials를 어떤 API에 전송한다.

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>
  )
}

이 코드에서 LoginFormapi 모듈을 직접 참조해서 둘 사이 강한 결합이 생겼다. 이런 의존성은 코드를 수정하기 어렵게 만들고, 한 컴포넌트에서의 변경이 다른 컴포넌트들에 영향을 미치게 되기 때문에 나쁘다. 의존관계 역전 원칙은 이런 결합을 깨는 것을 지향한다. 이 원칙을 어떻게 달성할 수 있는지 보자.

먼저 LoginForm 내부의 api 모듈에 대한 직접 참조를 제거할 것이다. 대신 props를 통해 기능을 주입받도록 할 것이다.

type 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 컴포넌트는 더 이상 api 모듈에 의존적이지 않게 되었다. API에 credentials를 제출하는 로직은 onSubmit 콜백을 통해 추상화되었고, 이제 이 로직을 상세히 구현하는 것은 부모 컴포넌트의 책임이 되었다.

이를 위해 form을 제출하는 로직을 api 모듈에 위임하는 연결된 버전의 LoginForm을 생성할 것이다.


import api from '~/common/api'

const ConnectedLoginForm = () => {
  const handleSubmit = async (email, password) => {
    await api.login(email, password)
  }
  
  return (
    <LoginForm onSubmit={handleSubmit} />
  )
}

apiLoginForm 은 서로 완전히 독립적이지만 ConnectedLoginForm 컴포넌트는 apiLoginForm 사이의 접착제와 같은 역할을 한다. 의존적인 조각이 없기 때문에 깨지는 것을 걱정하지 않고 독립적으로 반복하고 테스트할 수 있다. 그리고 apiLoginForm이 모두 합의된 공통 추상화를 준수하는 한, 전체 어플리케이션은 예상대로 작동할 것이다.

결론적으로 의존관계 역전 원칙은 어플리케이션의 서로 다른 컴포넌트 사이의 결합을 최소화하는 것을 목적으로 한다. 눈치챘겠지만, 개별 컴포넌트의 책임 범위의 최소화부터 서로 다른 컴포넌트끼리의 의존성 최소화까지, 최소화는 SOLID 원칙 전반에 반복되는 주제이다.

결론

SOLID는 OOP 세계의 문제를 해결하기 위해 탄생했지만 이를 넘어 적용될 수 있다. 이 아티클에서는 이 원칙들을 유연하게 해석하여 어떻게 리액트 코드에 적용하여 유지보수하기 쉽고 강력하게 만드는지 보았다.

하지만 명심해야할 것이다. 독단적이고 신앙적으로 이 원칙들을 따르는 것은 오버엔지니어링된 코드로 이어질 수 있다. 그러므로 우리는 컴포넌트의 분해 혹은 디커플링이 복잡성을 높여 전혀 이득이 되지 않는 경우를 인식하는 법을 배워야 한다.

profile
@Ktown4u 개발자

0개의 댓글