team logo icon
article content thumbnail

[React] 계층분리 & 컴포넌트 설계 & 디자인 패턴

코드 잘 짜고 싶은 사람~?

React의 장점으로 꼽히는 것들 중 대표적인 것이 바로 컴포넌트이다. 그렇다면, 컴포넌트를 어떻게 설계하는 것이 좋을까??


사실 컴포넌트를 잘 설계한다는 것에 대한 명확한 정답은 존재하지 않는다. 그리고, 개발자마다 컴포넌트를 설계하는 기준이 제각각이다.


그래서 이번 포스트에서는 보편적으로 알려진 컴포넌트 설계 방법에 대해 알아보려고 한다.



React의 계층분리과정


React 공식문서에서는 React의 계층 분리 과정을 5단계로 정의하고 있다.


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


UI를 구성하는 컴포넌트를 하위 컴포넌트로 분리해야 할 때, 여러 관점에 따라 분리할 수 있다.


디자인 팀에서 이미 정해놓은 계층이 있다면, 그 계층을 기반으로 컴포넌트를 분리해도 좋다.


프로그래밍 관점에서 보았을 때, SRP(단일 책임 원칙)에 따라 컴포넌트를 분리하는 것이 좋다. SRP 원칙에 대한 내용은 후술할 SOLID 원칙 부분을 참고하자.


만약 JSON이 잘 구조화되어 있다면 응답값을 기준으로 컴포넌트를 분리할 수도 있다. UI와 데이터 모델이 동일한 구조를 가지는 경우가 많기 때문이다.


2. 정적인 버전 구현하기


컴포넌트 계층과 구조를 정했다면, 이제 직접 구현할 차례이다.


가장 간단한 접근 방식은 상호 작용을 추가하지 않고 UI를 데이터 모델에서 렌더링하는 버전을 구축하는 것이다.


우리가 재사용성을 위한 개발을 할 때, 주로 상위 컴포넌트에서 하위 컴포넌트로 props를 통해 데이터를 넘겨주는 컴포넌트 구조를 많이 사용할 것이다. 이때, props로 state를 넘겨주는 방식은 고려하지 않아야 한다. state는 상호작용이 필요하고 시간에 따라 데이터가 변하는 곳에 사용하기 때문에 정적인 버전을 구축할 수 없다.


컴포넌트 계층 구조에 따라 상층부 컴포넌트부터 개발을 시작하는 Top-Down 방식을 사용하거나, 최하위 컴포넌트부터 개발을 시작하는 Bottom-Up 방식을 사용할 수 있다.

간단한 프로젝트에서는 Top-Down 방식이 더 좋지만, 복잡한 프로젝트일수록 Bottom Up 방식이 더 유리하다.


3. 최소한의 데이터로 완전히 표현 가능한 UI state 찾기


UI의 상호작용을 위해서 state를 활용해야 한다.


state를 구성하는 가장 중요한 원칙은 DRY(Don’t Repeat Yourself) 원칙이다. 애플리케이션이 필요로 하는 최소한의 state를 파악하고, 나머지는 필요에 따라 실시간으로 계산하게 하는 것이 좋다. 부모 컴포넌트로부터 props를 통해 전달되는 데이터나, 컴포넌트 내부에서 다른 값을 통해 계산이 가능한 데이터라면 state가 아니다.


4. state를 사용해야 할 구역 식별하기


앱의 최소 state 데이터를 확인한 후, 어떤 컴포넌트가 이 state를 변경하는지, 아니면 state를 소유하고 있는지 확인해야 한다.


리액트는 단방향 데이터 흐름을 사용하여 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달한다. 이 점을 활용하면 해당 state를 기반으로 렌더링하는 모든 컴포넌트를 찾은 다음, 그들과 가장 가까운 공통 컴포넌트에 state를 위치시키면 된다.


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


지금까지의 과정을 거치면 컴포넌트 계층을 따라 아래로 내려가는 props와 state 구성이 완료되었을 것이다.


사용자의 입력에 따라 state를 변경시키고 싶다면, 역데이터 흐름을 추가하여 사용자의 입력을 상위 컴포넌트로 전달하여야 한다.

이 경우, 이벤트 핸들러를 추가하여 state를 변경하도록 구현한다.




컴포넌트 설계 방법


React 공식 문서에서는 Component를 다음과 같이 정의하고 있다.


Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.


이를 참고하면, 우리는 컴포넌트를 "독립적"이고, "재사용이 가능"하고, 각각 "관심사를 분리"하여 설계해야 한다는 것을 알 수 있다.


이와 같이 컴포넌트를 설계하는 방법으로 SOLID 원칙이 있다.


SOLID 원칙


SOLID 원칙은 객체지향 프로그래밍을 하면서 지켜야 할 5대 원칙이다.


컴포넌트도 일종의 모듈 개념이기 때문에 SOLID 원칙을 적용해 볼 수 있다. SOLID 원칙을 지키며 컴포넌트를 개발하면 유지보수가 용이한 컴포넌트를 설계할 수 있다.


이 5대 원칙들 중 가장 중요한 원칙은 SRP 원칙이다. React 공식 문서에서도 단일 책임 원칙을 적용하여 컴포넌트를 개발하는 것을 권장하고 있다.


각 원칙들을 하나하나 살펴보며 React 코드에 직접 적용해보자.


1. 단일 책임 원칙(SRP)


React에서 컴포넌트에도 SRP 원칙을 적용할 수 있다. SRP 원칙은 컴포넌트를 설계할 때 고려해야 할 가장 중요한 요소이기도 하다.


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

여기서 '책임'이란, 역할 & 일을 의미한다. 즉, 하나의 모듈이 하나의 역할을 담당해야 한다는 의미이다.


컴포넌트가 다수의 책임(역할)을 가지게 되면 다음과 같은 문제점들이 발생한다.


• 컴포넌트가 비대해지면서 가독성이 떨어진다.

• 서로 다른 액터 간에 의존성이 생겨 확장성이 떨어진다.

• 의도치 않게 연쇄적인 사이드 이펙트가 발생할 수 있다.

• 유지보수 비용이 많이 들게 된다.


다음과 같은 코드를 보자.


function SomePage() {
  const [data, setData] = useState(null);
  const [inputValue, setInputValue] = useState("");
  function handleChange(e) {
    setInputValue(e.target.value);
  }
  async function handleSubmit(inputValue) {
    const response = await fetch(`https://example.com/search/${inputValue}`);
    const res = await response.json();
    if (res.ok) {
      setData(res.data);
    }
  }
  return (
    <div>
      <header>{/* 많은 양의 헤더 코드 */}</header>
      <input value={inputValue} onChange={handleChange} />
      <button onClick={handleSubmit}>Save</button>
      {data.type === "A" ? <p>A type is ....</p> : null}
      {data.type === "B" ? <p>B type is ....</p> : null}
    </div>
  );
}


SomePage 컴포넌트는 한 가지 기능이 아닌 여러 가지 기능을 수행하고 있다. 즉, SRP 원칙을 위반하고 있다.


위 코드의 문제점을 살펴보면 먼저, header 태그 안의 코드가 매우 길어서 가독성이 떨어진다. 또, inputValue의 값이 업데이트될때마다 전체 컴포넌트가 리렌더링 되는 문제가 있다. 마지막으로, data의 type값을 처리해 주는 부분이 확장성이 좋지 않다.


이를 해결하기 위해 SRP 원칙을 적용하면 다음과 같은 코드로 리팩토링 할 수 있다.

function SomePage() {
  const [data, setData] = useState(null)
  async function handleSubmit(inputValue) {
    const response = await fetch(`https://example.com/search/${inputValue}`)
    const res = await response.json()
    if (res.ok) {
      setData(res.data)
    }
  }
  return (
    <div>
      <Header />
      <Form handleSubmit={handleSubmit} />
      <Description type={data?.type} />
    </div>
  )
}
function Header() {
  return <header>{/* 많은 양의 헤더 코드 */}</header>
}
function Description({ type }) {
  switch (type) {
    case "A":
      return <p>A type is ....</p>
    case "B":
      return <p>B type is ....</p>
    default:
      return null
  }
}
function Form({ handleSubmit }) {
  const [inputValue, setInputValue] = useState("")
  function handleChange(e) {
    setInputValue(e.target.value)
  }
  return (
    <div>
      <input value={inputValue} onChange={handleChange} />
      <button onClick={() => handleSubmit(inputValue)}>Save</button>
    </div>
  )
}


Header의 역할을 하는 Header 컴포넌트, inputValue를 처리하는 역할을 하는 Form 컴포넌트, data type을 핸들링하는 Description 컴포넌트로 역할을 각각 분리하여 위에 나열된 문제들을 해결하였다.


2. 개방 폐쇄 원칙 (OCP)


'컴포넌트는 확장에는 열려있고, 변경에는 닫혀있어야 한다.'


이 원칙은 기존의 소스 코드를 변경하지 않고 확장할 수 있어야 한다는 원칙이다. OCP를 잘 지킨 컴포넌트는 변경에 대한 비용을 최소화할 수 있다.


다음과 같은 코드가 있다고 하자.

sections.map((section) => {
  if(section.type === "BANNER"){
    return section.items.map((item) => <Banner item={item} />);
  } else if(section.type === "RECENTLY_VIEWED"){
    return section.items.map((item) => <PosterView item={item} />);
  }
})


만약 새로운 섹션 타입이 추가된다면 else if문을 추가하여 로직을 직접 수정해야 하는 일이 발생한다.


이 코드를 OCP 원칙에 맞게 리팩토링해보면...

sections.map((section) =>
  <Section section={section}>
    {section.items.map((item) =>
      <Item section={section} item={item} />
    )}
  </Section>
))


이 코드는 확장에 열려 있는 코드가 된다. 새로운 섹션 타입이 추가되더라도 내부 로직은 수정할 필요가 없고, Props로 내려주기만 하면 된다.


3. 리스코프 치환 원칙 (LSP)


'하위 클래스는 상위 클래스를 대체할 수 있어야 한다.'


이 원칙은 클래스에 적용하는 원칙인데, 함수형 컴포넌트 구조로 가고있는 React에서는 거의 적용할 수 없는 원칙이다.


게다가, React 공식문서에서는 LSP 원칙에서 불가피한 '상속' 대신 '합성'을 권장하고 있다.


4. 인터페이스 분리 원칙 (ISP)


'자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다.'


불필요한 인터페이스는 자원 낭비뿐 아니라, 불필요한 의존성이 생기게 된다.


다음과 같이 Video 썸네일 리스트를 렌더링하는 코드를 생각해보자.

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


다음은 Thumbnail 컴포넌트이다.

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


Thumbnail 컴포넌트는 문제가 있다. 전체 video 객체를 Props로 전달받지만, coverUrl 프로퍼티만 사용하고 있다. 이 부분이 어떤 문제점을 초래하는지 살펴보자.

type LiveStream = {
  name: string
  previewUrl: string
}


새로운 LiveStream 객체의 프로퍼티와 타입을 정의했다. 우리는 LiveStream 썸네일도 렌더하고 싶은 상황이다.

type Props = {
  items: Array<Video | LiveStream>,
}
const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ("coverUrl" in item) {
          // 비디오 썸네일 렌더
          return <Thumbnail video={item} />
        } else {
          // 라이브스트림 썸네일 렌더
        }
      })}
    </ul>
  )
}


문제가 발생했다. Thumbnail 컴포넌트는 Video 객체 타입을 Props로 받는데, Video 객체와 LiveStream 객체가 호환되지 않고, 결국 LiveStream 객체를 Props로 받지 못하는 문제가 발생한다.


ISP 원칙에 맞게 코드를 수정해보자.

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


이렇게 해주면 Video와 LiveStream을 모두 다음과 같이 렌더시킬 수 있다.

type Props = {
  items: Array<Video | LiveStream>,
}
const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ("coverUrl" in item) {
          // 비디오 썸네일 렌더
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          // 라이브스트림 썸네일 렌더
          return <Thumbnail coverUrl={item.previewUrl} />
        }
      })}
    </ul>
  )
}


5. 의존성 주입 원칙 (DIP)


'고수준 모듈이 저수준 모듈의 구현에 의존해서는 안 된다.'


이 원칙은 구체적인 함수나 클래스가 아닌 인터페이스 등의 추상화에 의존해야 한다는 원칙이다.


다음과 같이 유저 이메일과 비밀번호를 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>
  )
}


지금 LoginForm 컴포넌트가 api 모듈을 직접 참조해서 둘 사이 강한 결합이 생겼다. 이런 모듈 간의 의존성은 코드 수정을 어렵게 만들고 서로 영향을 미치기 때문에 좋지 않다.


이를 해결하기 위해 먼저 LoginForm 내부에서 api 모듈에 대한 직접 참조를 제거해준다.

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 전송 로직은 onSubmit 함수를 통해 추상화되었고, 로직의 상세 부분을 구현하는 것은 부모 컴포넌트 책임이 되었다.


부모 컴포넌트에서는 분리된 api 전송 로직을 다음과 같이 구현해서, 로직을 포함한 함수를 LoginForm으로 내려주기만 하면 된다.

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


etc) 중복 배제 원칙 (DRY)


SOLID 원칙에 속하지는 않지만, state를 설계할 때 유용한 원칙이다.


계층 분리 과정에서 언급했듯이, React 공식 문서에서도 중복 배제 원칙을 사용하여 최소한의 state를 찾아내야 한다고 말하고 있다. 가장 최소한의 state를 찾고 이를 통해 나머지 모든 것들이 필요에 따라 그때그때 계산되도록 만들어야 한다.


예를 들면, 어떤 배열 state가 있을 때 이 state의 요소 개수를 화면에 렌더링해야 한다면 이 요소 개수를 관리하는 state를 별도로 만들지 말고, 그냥 이 배열의 length를 가져오는 방식으로 컴포넌트를 구현해야 한다.


React 공식 문서에서는 state인지 아닌지 판단이 어려울 때는 밑의 3가지 질문을 통해 결정하면 된다고 제안한다.

• 부모로부터 Props를 통해 전달됩니까? 그러면 확실히 state가 아닙니다.

• 시간이 지나도 변하지 않나요? 그러면 확실히 state가 아닙니다.

• 컴포넌트 안의 다른 state나 Props를 가지고 계산 가능한가요? 그렇다면 state가 아닙니다.



React 디자인 패턴


효율적으로 웹 사이트를 구축하고 유지 보수를 하기 위해 컴포넌트를 어떻게 구성하느냐가 굉장히 중요한 이슈가 되었고, 컴포넌트를 구성하는 여러 방법들이 등장하게 되었는데, 이들을 React 디자인 패턴이라고 한다.


정말 다양한 디자인 패턴들이 존재하지만, 대표적인 5가지 디자인 패턴을 알아보고, 다음 예제 코드에 적용해볼 것이다.

return (
  <Counter
    label="label"
    max={10}
    iconDecrement="minus"
    iconIncrement="plus"
    onChange={handleChangeCounter}
  />
)


1. 합성 컴포넌트 패턴


합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미한다.


이 패턴은 불필요한 Props Drilling 없이 표현적이고 선언적인 컴포넌트를 만들 수 있게 도와준다.

return (
  <Counter onChange={handleChangeCounter}>
    <Counter.Decrement icon="minus" />
    <Counter.Label>Counter</Counter.Label>
    <Counter.Count max={10} />
    <Counter.Increment icon="plus" />
  </Counter>
)


장점

부분부분을 합치고 빼고 할 수 있으므로 유연하게 UI를 구현할 수 있다.

사용하는 곳에서 컴포넌트의 조합을 잘 활용할 수 있다면 높은 재사용성을 만족하면서 다양한 상황에 사용할 수 있다는 장점이 있다.

관심사의 분리가 잘 되어 있다.


단점

JSX 구문이 길어진다.

UI 구현 측면에서 너무 강한 유연성은 기대하지 않은 사이드 이펙트를 만들 수 있다.


2. 제어 Props 패턴


부모 컴포넌트가 자식 컴포넌트에게 제공하는 Props를 Getter Function으로 제공하는 패턴이다.

function Usage() {
  const [count, setCount] = useState(0)
  const handleChangeCounter = newCount => {
    setCount(newCount)
  }
  return (
    <Counter value={count} onChange={handleChangeCounter}>
      <Counter.Decrement icon={"minus"} />
      <Counter.Label>Counter</Counter.Label>
      <Counter.Count max={10} />
      <Counter.Increment icon={"plus"} />
    </Counter>
  )
}


장점

•메인 State가 컴포넌트 외부에 드러나 있기 때문에 사용자가 외부에서 state 제어를 쉽게 할 수 있다.


단점

•사용하는 것이 복잡하다. 이전에는 JSX에만 구현하는 것만으로도 컴포넌트를 동작하게끔 하는 것이 가능했지만, 이제는 JSX, useState, handleChange 세 곳 모두를 체크해야 한다.


3. Custom Hook 패턴


메인 로직이 custom hook으로 들어간다. hook은 State, Handler와 같은 내부 로직들을 포함하며 유저에게 더 많은 통제권을 준다.

function Usage() {
  const { count, handleIncrement, handleDecrement } = useCounter(0)
  const handleClickIncrement = () => {
    alert("새싹웹팟 화이팅!!!!!")
    if (count < 6) {
      handleIncrement()
    }
  }
  return (
    <>
      <Counter value={count}>
        <Counter.Decrement icon={"minus"} onClick={handleDecrement} />
        <Counter.Increment icon={"plus"} onClick={handleClickIncrement} />
      </Counter>
    </>
  )
}


장점

• 제어 Props 패턴보다 외부에 더더욱 많은 제어권을 준다.

• Custom Hook 패턴에서는 유저가 hook과 JSX 사이에 자신만의 로직을 넣을 수 있다.


단점

구현 복잡도가 매우 높다. 로직이 렌더링하는 부분과 분리되어 있으며, 유저는 둘을 이어줘야 한다.


4. Props Getter 패턴


Custom hook pattern이 유저에게 많은 통제권을 주는 장점이 있긴 하지만, 그만큼 컴포넌트 구현 복잡도가 높다는 단점이 있었다.


Props Getter 패턴은 이 단점을 해결하기 위해 Props를 직접 노출시키는 대신 Props를 가져올 수 있는 Getter 메소드를 제공한다.

const { getCounterProps, getIncrementProps, getDecrementProps } = useCounter({
  initial: 0,
  max: 10,
})
<Counter {...getCounterProps()}>
  <Counter.Decrement icon={"minus"} {...getDecrementProps()} />
  <Counter.Increment icon={"plus"} {...getIncrementProps()} />
</Counter>


장점

• 사용하기 쉽다. 복잡한 로직은 가려져 있고, Props를 사용하는 쉬운 방법을 제공한다. 유저는 올바른 Props Getter 메소드를 사용하려는 JSX 요소에 사용하기만 하면 된다.


단점

• Getter를 통해 사용자로 하여금 컴포넌트를 사용하기 쉽게 만들어주지만, 로직이 숨겨져 있으므로 불투명하고 잘 보이지가 않는다는 단점이 있다.


5. State Reducer 패턴


제어의 역전 측면에서 가장 고도화된 패턴이다. 이 패턴은 유저에게 컴포넌트를 내부적으로 제어할 수 있는 더 발전된 방법을 제공한다. Custom Hook 패턴과 유사하지만, 사용자가 Hook에 전달하는 reducer를 정의한다.

const reducer = (state, action) => {
  switch (action.type) {
    case "decrement":
      return { count: Math.max(0, state.count - 2) }
    default:
      return useCounter.reducer(state, action)
  }
}
const { count, handleDecrement, handleIncrement } = useCounter(
  { initial: 0, max: 10 },
  reducer
)
return (
  <Counter value={count}>
    <Counter.Decrement icon={"minus"} onClick={handleDecrement} />
    <Counter.Increment icon={"plus"} onClick={handleIncrement} />
  </Counter>
)


장점

• 복잡한 로직에서 state reducer를 사용하는 것은 사용자에게 제어권을 넘기는 가장 좋은 패턴이다. 모든 내부 컴포넌트 작업을 외부에서 접근할 수 있으며 재정의하는 것 또한 가능하다.


단점

• 지금까지 소개했던 패턴들 중 구현이 가장 복잡한 패턴이다.




지금까지 널리 알려진 많은 컴포넌트 설계 방법과 디자인 패턴에 대해 알아보았다.


아티클의 초장에서 말했듯이, 컴포넌트 설계 방법에 정답은 없다. 자신이 맞다고 생각하는 방향이 있다면, 그 방식을 고수해도 좋다. 단, 항상 선택에는 효율성이 근거로 뒷받침되어야 한다고 생각한다.


이 아티클에서 들었던 예시는 효율성이 근거로 뒷받침되어 많은 선택을 받은 설계 방법들이다. 앞으로 컴포넌트 설계를 할 때 꼭 도움이 되었으면 좋겠다 :)


최신 아티클
lighthouse에 대해
문성희
|
2024.05.13
lighthouse에 대해
lighthouse에 대해
prettier, eslint, styleLint에 대해
이진
|
2024.05.10
prettier, eslint, styleLint에 대해
4주차 공유과제
Article Thumbnail
박채연
|
2024.05.10
Prettier, ESLint, StyleLint
prettier, eslint, stylelint