리액트와 관심사 분리

#React#디자인패턴#추상화2024-04-10

프로젝트를 진행하며 리드미를 작성하는 과정에서, 커스텀 컴포넌트를 재사용한 작업에 대해 별 생각 없이 다음과 같이 작성했었습니다. “커스텀 컴포넌트 구현시 컴포넌트 분리와 props를 활용한 재사용성 및 유지보수성 증가” 그런데 해당 문장에 대해 아래와 같은 피드백을 받았습니다.

“재사용성 및 유지보수성을 높인 것을 표현할 때, 관심사 분리라는 용어를 활용하면 좋을 것 같아요.”

“컴포넌트 분리와 props를 활용했다는 것만으로 유지보수성을 증가시켰다는 것에 대한 설득력이 부족할 수 있을 것 같아요.”

무언가 제가 잘못 알고 있다는 것을 느꼈고, 곧바로 관심사 분리에 대해, 더 나아가서는 리액트와 관심사 분리에 대해 보다 자세히 공부하게 되었고, 그 과정에서 알게 된 것을 정리하고자 글을 쓰게 되었습니다.

관심사 분리란 무엇일까?

컴퓨터 과학에서  관심사 분리(separation of concerns, SoC)는 컴퓨터 프로그램을 구별된 부분으로 분리시키는 디자인 원칙으로, 각 부문은 개개의 관심사를 해결한다.

관심사란 컴퓨터 프로그램 코드에 영향을 미치는 정보의 집합이다. 관심사는 코드 최적화가 필요한 하드웨어의 세세한 부분만큼 포괄적이거나, 시작할 클래스의 이름처럼 구체적일 수 있다.

관심사 분리를 이용하면 디플로이, 이용의 일부 관점에 더 높은 정도의 자유가 생긴다. 이 가운데 일반적인 것은 코드의 단순화 및 유지보수의 더 높은 수준의 자유이다. 관심사가 잘 분리될 때 독립적인 개발과 업그레이드 외에도 모듈 재사용을 위한 더 높은 정도의 자유가 있다. - wikipedia

위에서 설명한 것을 토대로, 관심사 분리란 하나의 큰 부분을 구별된 부분으로 분리시키는 것으로, 재사용성과 유지보수성을 높일 수 있다는 것을 알 수 있습니다. 추가로, 이러한 관심사 분리는 추상화의 일종으로 볼 수도 있습니다.

그렇다면 이 관심사 분리, 즉 정보의 집합(관심사)를 부분으로 분리한 것이 왜 리액트에서 중요한지, 재사용성과 유지보수성을 어떻게 높일 수 있는지, 그리고 추상화란 무엇인지 자세하게 알아보도록 하겠습니다.

리액트 개발에서의 관심사 분리가 왜 중요할까?

리액트에서 관심사 분리는 왜 중요할까요? 이에 대해 답하기 위해 리액트를 사용하며 마주치는 주요한 문제들에 대해 아래와 같이 정리해볼 수 있습니다.

  1. 유지보수성
  2. 재사용성
  3. 확장성
  4. 테스트 용이성

유지보수성

유지보수성은 애플리케이션이 시간이 지남에 따라 쉽게 변경, 업데이트 및 수정될 수 있는 능력과 직접적으로 관련이 있습니다. 초기 개발시 애플리케이션과 컴포넌트 자체는 경량화된 상태로 시작할 것입니다. 이때까지는 쉽게 유지보수를 할 수 있습니다. 하지만 개발 과정중 요구사항이 증가되고 각종 비즈니스 로직이 추가되면서 컴포넌트는 점점 복잡해지고 커지게 됩니다. 그렇게 복잡해진 컴포넌트는 유지보수를 어렵게 만듭니다.

예를 들어, JSX가 조건부 렌더링(삼항 연산자 혹은 && 연산자)이 많아지고, prop과 상태값들에 따라 렌더링 되는 로직이 점점 많아진다면 어떤 컴포넌트가 어떤 상태와 관련이 있는지, 어떤 props와 관련이 있는지 한눈에 파악하기가 어렵습니다. 이 경우 우리는 모든 로직을 하나씩 확인하고 코드를 이해하기 위해 노력해야 할 것입니다. 따라서, 컴포넌트를 명확하고 단순하게 유지하는 것이 유지보수를 용이하게 하고 전체 개발 과정을 효율적으로 만듭니다.

재사용성

재사용성은, 유지보수성 측면과 마찬가지로 개발시간과 비용을 절약하는데 중요한 역할을 합니다. 잘 설계된 컴포넌트는 다양한 부분에서 재사용 될 수 있으며, 이는 일관된 사용자 경험과 빠른 기능 개발을 가능하게 합니다. 예를 들어, 버튼, 입력 필드, 또는 특정 데이터를 표시하는 카드 같은 기본 UI 요소는 애플리케이션 전반에서 재사용될 수 있습니다.

재사용 가능한 컴포넌트를 만들기 위해서는, 그 컴포넌트가 특정 데이터 또는 환경에 강하게 의존하지 않도록 설계하는 것이 중요합니다. 예를 들어, 과거에는 데이터를 로딩하거나 상태를 관리하는 로직을 컨테이너 컴포넌트로 분리함으로써, 프레젠테이션 컴포넌트를 더 다양한 맥락에서 재사용했습니다. (훅이 등장한 이후로는 많이 사용하지 않습니다)

  • 프레젠테이션 컴포넌트와 컨테이너 컴포넌트

    React에서 컨테이너 컴포넌트와 프레젠테이션 컴포넌트는 각각 구분된 역할을 수행합니다.

    Container Component(컨테이너 컴포넌트)

    컨테이너 컴포넌트들은 데이터 가져오기, 상태 관리, 로직을 담당합니다. 데이터와 프레젠테이션 컴포넌트 사이의 중개자 역할을 합니다. 컨테이너 컴포넌트는 API에서 데이터를 가져오고, 어플리케이션의 상태를 관리하며, 데이터와 함수를 프레젠테이션 컴포넌트에 전달합니다.

    Presentation Component(프레젠테이션 컴포넌트)

    프레젠테이션 컴포넌트들은 UI 요소를 렌더링하고 컨테이너 컴포넌트로부터 받은 데이터를 표시하는 데 집중합니다. 데이터가 어디서 오는지나 어떻게 조작되는지에 대해 알지 못하며, 오직 데이터를 표시하고 사용자와 상호작용하는 것이 유일한 목적입니다.

확장성

사실 이 확장성은 리액트에만 적용된다기 보다, 일반적으로 소프트웨어에 모두 적용됩니다. 확장성이란, 소프트웨어나 애플리케이션이 증가하는 사용자 수나 데이터 양에 맞추어 그 기능을 수월하게 확대할 수 있는 능력을 의미하며 유지보수성과 밀접하게 연결되어 있습니다. 잘 설계된 확장성 있는 시스템은 시간이 지남에 따라 비용 효율적으로 성장할 수 있습니다.

애플리케이션의 일부분만 수정하거나 확장해야 할 때, 잘 정의된 컴포넌트 경계 덕분에 해당 부분만 집중하여 개발할 수 있습니다. 이는 전체 구조를 파악하지 않고도 필요한 부분만을 개선할 수 있게 해줍니다.

리액트를 사용할 때 확장성은 주로 컴포넌트 구조와 상태 관리 방법에 의해 영향을 받습니다. 잘 정의된 컴포넌트 아키텍처와 효율적인 상태 관리 전략은 애플리케이션을 쉽게 확장할 수 있도록 도와줍니다. 예를 들어, Redux나 Zustand, Context API를 사용하여 애플리케이션의 상태를 중앙에서 관리하면, 컴포넌트 간의 데이터 흐름을 더 명확하게 하고, 대규모 애플리케이션에서도 상태 관리의 복잡성을 줄일 수 있습니다.

리액트에서는 어떻게 관심사 분리를 할 수 있을까?

리액트의 관심사 분리를 알아보기 전에, 위에서 잠깐 언급했던 추상화에 대해 먼저 알아보겠습니다. 앞서 관심사 분리는 추상화의 일종이라고 말씀 드렸습니다. 이에 대해 아래에서 좀 더 자세히 알아보겠습니다.

추상화란 무엇일까?

추상이라는 단어의 사전적 의미는 다음과 같습니다. “사물을 정확하게 이해하기 위해서는 사물이 지니고 있는 여러 가지 측면 가운데서 특정한 측면만을 가려내어 포착하는 것” 이러한 추상을 활용한 추상화는 현실의 것보다 추상화된, 즉 디테일한 특징들은 배제되고 대상 자체를 표현하는 특정한 특징들만이 강하게 드러나게 됩니다.

이렇게 대상의 본질만을 표현하고, 디테일한 요소들을 배제하면 필연적으로 표현이 단순해지게 됩니다. 결국 추상화된 대상은 어떤 대상의 핵심적인 특징만을 남기고 복잡한 디테일 등 나머지는 제거한, 단순화시킨 대상으로 볼 수 있습니다.

그렇다면 프로그래밍에서 추상화가 필요한 이유는 무엇일까요? 위에서 언급한 확장성과도 연결시킬 수 있는 부분으로, 바로 추상화를 통해 더 복잡하고 어려운 것들을 만들기 위해서 입니다.

좀 더 익숙한 것들로 예시를 들어본다면, 리액트의 CRA를 예시로 들 수 있습니다. 리액트로 개발을 시작할 때, CRA와 같은 명령을 활용해 Webpack, Babel등의 설정을 하지 않아도 개발을 시작할 수 있습니다. 예전에는 Webpack을 개발자가 직접 세팅하여 어플리케이션을 번들링하는 작업을 거쳤지만, 이제는 CRA같은 CLI 혹은 Next.js, Vite같은 프레임워크가 자체적으로 Webpack Configuration을 만들어주고, 심지어 이를 감춰서 추상화해버리기 때문에 ****Webpack 설정에 대한 맥락을 몰라도 개발하는데 무리가 없습니다.

따라서 관심사의 분리는 하나의 큰 부분을 구별된 부분으로 분리시킴으로써 추상화의 수준을 높이는 역할을 합니다. 그렇다면 리액트에서는 어떤 방식으로 관심사의 분리를 통해 추상화의 수준을 높일 수 있을지 자세히 살펴보겠습니다.

컴포넌트의 분리와 단일책임원칙(SRP)

SOLID원칙의 단일책임원칙(Single Responsibility Principle)에 따라 리액트의 컴포넌트에서도 역시 하나의 컴포넌트가 하나의 책임만을 담당하는 것이 좋다고 말합니다.

그렇다면 책임이란 무엇일까요? 간단히 말하면 책임은 어떤 “컴포넌트가 수행하는 동작이자 변경의 이유”라고 볼 수 있습니다. 이때 리액트에서의 책임의 의미는 컴포넌트가 수행하는 동작에 보다 가깝습니다.

  • 책임의 이중적 의미

    1. 행동 (Action or Behavior): 컴포넌트의 책임이란 해당 컴포넌트가 수행하는 구체적인 행동을 의미합니다. 예를 들어, 사용자 요청을 처리하는 서버, 데이터를 조회하고 수정하는 데이터베이스 관리 시스템, 또는 입력을 받아 처리하는 사용자 인터페이스 등이 이에 해당합니다. 이러한 행동은 그 컴포넌트의 주된 역할을 정의하며, 그 역할을 수행하기 위해 필요한 로직과 데이터를 포함합니다.

    2. 변경의 이유 (Reason to Change): 단일 책임 원칙에서는 한 클래스나 모듈이 변경되어야 할 이유가 오직 하나만 존재해야 한다고 강조합니다. 이는 해당 컴포넌트가 다루는 관심사가 명확하고 한정적임을 의미합니다. 만약 컴포넌트가 너무 많은 기능을 수행하거나 서로 관련 없는 여러 역할을 맡고 있다면, 한 가지 변경이 다른 기능에 영향을 미칠 가능성이 높아지며, 이는 유지보수와 확장성을 저해합니다.

컴포넌트들을 각각의 책임에 맞게, 즉 각각이 수행하는 동작에 해당하는 로직만으로 구성한다면 응집도가 높아집니다. 또한 다른 컴포넌트와의 결합도는 줄어들고, 컴포넌트 특성상 외부 노출이 불필요한 부분은 자연스럽게 캡술화가 되게 됩니다. 하지만 적절한 책임에 맞게 컴포넌트를 분리하여 추상화하는 것은 생각보다 어려운 일입니다.

그렇다면 리액트 컴포넌트에서는 어떻게 책임을 분리하는 것이 좋을까요?

범용적인 컴포넌트를 구성하기

애플리케이션의 데이터를 중심으로 보면 컴포넌트는 특정 도메인이나 상황에 종속되지 않는, 즉 넓은 사용 범위를 가지도록 설계된 범용적 컴포넌트와 특정 도메인이나 상황에 종속되는 비즈니스 컴포넌트가 있습니다.

범용적 컴포넌트의 경우, 헤더나 버튼 등 한 애플리케이션 내에서 범용적으로 재사용할 수 있는 요소입니다. 이러한 컴포넌트를 추상화할 때는 특정 도메인이나 외부 데이터 구조나 비즈니스 로직의 결합도를 낮추고 재사용성을 높이는 것이 중요하며, 너무 많은 역할을 수행하지 않도록 주의해야 합니다.

아래 예시를 보며 확인해보겠습니다.

const Header = () => {
  const { pathname } = useLocation()

  return (
    <header>
      <Logo />
      <Gradient>{pathname === "/someUrl" && <Pattern />}</Gradient>
    </header>
  )
}

위 예시에서는 특정 페이지에 따라 Pattern 컴포넌트를 렌더링합니다. 이러한 코드는 장기적으로 보았을 때 유지보수에 취약한 코드가 될 가능성이 높습니다. Header 컴포넌트가 특정 경로에 의존하므로, 이 경로가 변경되거나 추가될 경우 Header 컴포넌트 자체가 수정되어야 하며, 비슷한 Pattern을 필요로 하는 새로운 페이지 추가시에도 Header 컴포넌트의 코드는 업데이트 되어야 합니다.

또한 Header 컴포넌트는 특정 상황에 맞춰져 있어 다른 맥락이나 다른 URL 패턴에서 재사용하기 어려울 뿐만 아니라 프로젝트가 성장하면서 더 많은 페이지나 기능이 추가될 때, 각각의 새로운 요구사항에 대응하기 위해 계속해서 조건부 렌더링 로직을 추가해야 할 수 있습니다.

합성을 활용하기

특정도메인 및 상황과의 의존성을 낮추고 책임을 적절히 나누어 결헙도를 낮추면 본연의 책임에 더 집중할 수 있고 유지보수성 및 재사용성을 높일 수 있다는 것을 확인했습니다. 하지만 의존성과는 별개로 컴포넌트 자체가 수행하는 책임이 큰 경우가 있을 수 있습니다.

하나의 범용적인 컴포넌트에 스타일을 커스터마이징할 뿐만 아니라 동적으로 UI의 변경사항이 생긴다면 어떨까요? 물론 prop들을 추가하여 요구사항에 대응할 수 있지만, 너무 많은 prop들이 생기거나 해당 prop들을 활용한 DOM구조가 복잡해질 가능성이 높습니다.

아래 예시를 통해 확인해보겠습니다.

// prop 추가로 복잡해진 카드 컴포넌트
const CardItem = ({
  imageUrl,
  tagNumber,
  name,
  description,
  rounded,
  lineCnt,
  className,
  ...
}: Props) => {
  ...
  return (
    <div className={className}>
      {imageUrl && (
        <img
          src={imageUrl}
          style={rounded ? { borderRadius: "50%" } : undefined}
        />
      )}
      <div>
        <span>{tagNumber}</span>
        <span>{name}</span>
      </div>
      {description && <div>...</div>}
      ...
    </div>
  )
}

이 경우에는 CardItem컴포넌트의 책임을 보다 세분화하여 문제를 해결할 수 있습니다.

현재 CardItem에는 rounded를 조건부로 스타일을 적용하고 있으며, lineCnt를 prop으로 전달해 타이틀이 긴 경우의 라인수를 조절합니다. 이 경우에는 카드 형태의 아이템을 보여주는 것이라는 책임을 아래와 같이 더 세분화할 수 있습니다.

  • 아이템의 모양이 둥근 형태를 보여준다
  • 아이템의 모양이 사각형인 형태를 보여준다
  • 타이틀에 tagNumber를 보여준다
  • 타이틀에 들어갈 제목이 긴 경우 아이템의 타이틀의 라인을 늘린다
  • 이미지가 여러개일 경우 여러개를 하나의 이미지에 합쳐서 보여준다

이 경우 CardItem을 RoundCardItem, SquareCardItem, MultiThumbnailCard, SingleThumbnailCard, TagNumberCardTitle, NameCardTitle 등으로 나눈다면 불필요한 의존성을 줄이고 복잡한 컴포넌트를 분리할 수 있습니다. 하지만 이렇게 나누게 될 경우 각각의 컴포넌트의 책임이 중복될 수도 있다는 것을 예상할 수 있습니다.

그렇다면 이러한 컴포넌트들을 어떻게 다시 분리할 수 있을까요? 위 분류를 다시 크게 CardItem은 크게 썸네일 이미지가 들어갈 요소, 타이틀과 같이 텍스트가 들어갈 요소들과, 이를 담을 Body 요소 등 보다 기능적인 측면에 집중하여 나눌 수도 있습니다.

즉 CardItem의 책임을 UI요소가 아닌 CardThumbnail, CardBody, CardTitle과 같이 어떠한 기능을 책임지는 컴포넌트로 분리하여 추상화를 진행하고, 각 컴포넌트 요소에서 UI를 결정하게 한다면 아래와 같이 하나의 책임을 가지면서도 다양한 형태의 UI에 대응하는 CardItem 컴포넌트를 만들 수 있습니다.

const CardThumbnail = ({ url, size, rounded, className }: Props) => ...
const CardBody = ({ className, align, children }: Props) => ...
const CardTitle = ({ className, lineCnt, children }: Props) => ...
const CardIcon = () => ...

const RoundCardItem = (...) => {
  return (
    <div>
      <CardThumbnail url={imageUrl} rounded />
      <CardBody align="center">
        <CardTitle>{tagNumber}</CardTitle>
        <CardTitle lineCnt={2}>{name}</CardTitle>
      </CardBody>
    </div>
  )
}
...

리액트에서 이렇게 컴포넌트들을 조합하는 것을 합성 이라고 합니다. 합성은 하나의 컴포넌트가 가진 책임을 세분화하여 유연한 대응을 할 수 있도록 도와줍니다. 합성은 어디서나 유연한 구조로 컴포넌트를 설계하기 좋은 패턴이지만, 무분별하게 책임을 너무 세세하게 나누어 합성하는 구조는 지양해야 합니다.

prop만으로 컴포넌트를 만들 수 있음에도 불구하고 책임을 너무 잘게 나누게 되면 오히려 관리 복잡성이 증가할 수 있으며, prop만으로 컴포넌트를 만드는 것이 훨씬 단순하고 유지보수 및 가독성 측면에서도 좋을 수 있으니 적절히 사용해야 합니다.

👩🏻‍💻 리액트에서 합성의 장점을 극대화한 디자인 패턴으로 Compound Component 패턴이 있습니다. Compound Component 패턴은 링크에서 자세히 다루고 있으니 필요시 참고하시기 바랍니다.

커스텀훅(Custom Hook)을 활용하기

리액트의 Hook은 비즈니스 로직들을 추상화하기 좋은 API입니다. 그 중에서도 커스텀 훅은 로직을 컴포넌트로부터 분리하여 재사용 가능하게 만듭니다. 데이터 페칭, 이벤트 리스너 등록, 상태 관리 로직 등을 커스텀 훅 안에 캡슐화하고, 이를 필요한 곳에서 재사용할 수 있습니다. 이는 코드의 중복을 줄이고, 로직의 테스트와 유지보수를 용이하게 합니다.

아래 예시를 통해 살펴보겠습니다.

const ScreenDimensions = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  })

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }
    window.addEventListener("resize", handleResize)
    handleResize()
    return () => window.removeEventListener("resize", handleResize)
  }, [])

  return (
    <>
      <p>Current screen width: {windowSize.width}</p>
      <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

위 로직에서 handleResize에서는 현재 스크린 사이즈에 맞춰 화면의 width, height를 동적으로 상태 관리를 하고 있습니다. 만약 해당 로직을 다른 컴포넌트에서도 사용하여 각각의 다른 UI를 렌더링 하고자 한다면 어떻게 할 수 있을까요? (ex. 모바일앱, 데스크탑)

물론 위 로직을 그대로 다른 컴포넌트에 작성하는 방법도 있을 수 있습니다. 하지만 이는 DRY(Don’t Repeat Yourself)로부터 알 수 있듯이 좋은 방법은 아닙니다. 만약 해당 로직을 수정하게 될 경우, 동일한 로직을 사용한 모든 컴포넌트에서 모두 수정해야 하기 때문입니다.

이 경우 해당 비즈니스 로직을 커스텀 훅으로 추상화하면 문제를 깔끔하게 해결할 수 있습니다.

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  })

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }
    window.addEventListener("resize", handleResize)
    handleResize()
    return () => window.removeEventListener("resize", handleResize)
  }, [])

  return windowSize
}
const ScreenDimensions = () => {
  const windowSize = useWindowSize()

  return (
    <>
      <p>Current screen width: {windowSize.width}</p>
      <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

위처럼 useWindowSize라는 훅으로 비즈니스 로직을 추상화함으로써 아래와 같은 장점들을 얻을 수 있습니다.

  • 로직들을 훅으로 분리함으로써 컴포넌트에는 렌더링을 위한 코드만이 응집되고 가독성이 향상됩니다.
  • 다른 컴포넌트에서도 해당 로직이 필요할 경우 컴포넌트 자체의 변경이 없이 useWindowSize 훅을 사용하여 쉽게 대응할 수 있습니다.
  • 별도의 훅으로 분리하였기 때문에 각각을 단위 테스트로 검증하기 좋습니다. (앞서 언급한 관심사 분리의 중요성 중 4번 항목과 관련됩니다.)

이렇게 커스텀 훅을 사용하면 과거 리액트에서 비즈니스 로직을 분리하기 위해 사용했던 Render Props / HOC 패턴에 비해 훨씬 단순하게 비즈니스 로직을 컴포넌트에 합성할 수 있습니다.

전역상태관리를 통한 의존성 낮추기

앞서 살펴본 것처럼 훅을 통한 책임의 분리는 컴포넌트의 응집도를 높이고 도메인과의 결합도를 낮춰줍니다. 하지만 애플리케이션 전반에 걸쳐 사용되는 데이터 처리 로직이 많을 경우는 몇 개의 훅을 사용하는 것만으로는 분리하기가 어렵습니다. 만약 분리를 하더라도 데이터 처리에 대한 로직이 여러 훅으로 나뉘게 되며 데이터 처리 과정을 파악하고 오류를 찾기 어려울 것입니다.

이때 전역 상태 관리 방법을 사용하면 컴포넌트 간의 의존성을 줄이고 애플리케이션의 상태를 쉽게 관리할 수 있습니다. Redux, MobX, React Context API, Zustand와 같은 도구를 사용하여 애플리케이션의 상태를 중앙에서 관리하고, 필요한 컴포넌트에 상태를 쉽게 전달할 수 있습니다.

어떤 라이브러리를 사용할 것인지도 중요하지만, 상태 관리 메커니즘 안에서 데이터간의 의존성을 낮추는것이 매우 중요합니다. 아래 예시를 통해 데이터간 의존성의 중요성을 살펴보겠습니다.

// 재생 중인 곡에 대한 상태 관리
/**
 * playingSong: 재생 중인 곡의 메타 정보(곡, 앨범, 아티스트 명).
 * playingSongLyrics: 재생 중인 곡의 가사.
 * seek: 현재 재생 중인 곡의 재생시간.
 * playtime: 재생 중인 곡의 전체 재생시간.
 */
interface SongState {
  playingSong: Song | null
  playingSongLyrics: Lyrics | null
  seek: number
  playtime: number
}

setPlayingSong(song) {
  state.songs = song
}
...

// 재생목록 상태 관리
/**
 * songs: 곡들의 id를 저장하고 있는 재생목록.
 * playingSongIndex: 현재 재생 중인 곡의 index.
 * repeatType: 반복 설정 상태.
 */
interface SongListState {
  songs: number[]
  playingSongIndex: number
  repeatType: RepeatType
}

setSongs(songs) {
  state.songs = songs
}

setPlayingSongIndex(index) {
  state.playingSongIndex = index
}
...

위 코드는 리덕스를 활용하여 재생 중인 곡과 재생목록으로 데이터를 나누어 각각 상태관리를 하도록 설계되었습니다. 재생목록에서 특정 곡의 재생이 시작되면 id를 기반으로 서버에서 곡 정보를 받아와 SongState에 설정합니다. 그리고 해당 정보를 여러 컴포넌트에서 조회하여 보여줄 것입니다. 또한 재생목록을 보여주는 컴포넌트에서는 SongListState를 통해 원하는 정보를 보여줄 수 있습니다.

하지만 데이터의 의존성 측면에서 문제를 발견할 수 있습니다. 바로 SongState와 SongListState 의 데이터가 서로 의존하고 있어 각각 변경될 때마다 다른 한쪽의 데이터 역시 변경된다는 점입니다.

  1. 재생 중인 곡(SongState)은 재생목록(SongListState)의 id를 기준으로 설정되기 때문에 재생목록이 변경되면 재생 중인 곡의 정보도 갱신이 필요합니다.
  2. 반복 설정 값(SongListState)에 따라 다음에 재생할 곡(SongState)의 정보도 달라집니다.
// 컴포넌트에서 재생목록 갱신 코드
const songs = [...]

dispatch(setSongs(songs)) // (1)

if (songs.length) {
  const songId = songs[0] // (2)
  const { data } = await getSongDetailInfo(songId)

  dispatch(setPlayingSong(data)) // (3)
  dispatch(setPlayingSongIndex(0))
}

위 코드는 재생목록을 갱신시 재생목록의 첫번째 인덱스 즉, 첫 곡을 재생하도록 하는 코드입니다. 위 컴포넌트에서는 아래와 같이 책임을 3단계에 나눠 수행하게 됩니다.

  1. 변경된 재생목록을 SongListState에 반영합니다. (setSongs(songs))
  2. SongState는 재생목록에 대한 정보가 없기 때문에 id를 기반으로 곡 정보를 얻기 위한 전처리가 필요하므로, 재생목록의 첫 번째 곡 정보를 조회(getSongDetailInfo(songId))하여 SongState에 설정합니다.
  3. 재생목록에서 재생 중인 곡이 몇 번째 곡인지 알기 위해 SongListState에 index 정보를 반영합니다.

만약 여기서 SongState가 id에 대한 정보를 알고 있다면, SongListState에서 songId = songs[0] 와 같이 의존하지 않고, getSongDetailInfo(songId) 과 같이 세부 데이터를 얻어오는 로직을 Redux 내부로 이동시켜 캡슐화 함으로써 컴포넌트간의 결합도를 낮출 수 있을 것입니다.

변경된 코드는 아래와 같습니다.

/**
 * playingSong: 재생 중인 곡의 메타 정보(곡, 앨범, 아티스트 명).
 * playingSongLyrics: 재생 중인 곡의 가사.
 * seek: 현재 재생 중인 곡의 재생시간.
 * playtime: 재생 중인 곡의 전체 재생시간.
 * songs: 곡들의 id를 저장하고 있는 재생목록.
 * playingSongIndex: 현재 재생 중인 곡의 index.
 * repeatType: 반복 설정 상태.
 */
interface SongState {
  songs: number[]
  playingSongIndex: number
  repeatType: RepeatType
  playingSong: Song | null
  playingSongLyrics: Lyrics | null
  seek: number
  playtime: number
}

// 컴포넌트에서 목록 갱신 코드
const songs = [...]

dispatch(setSongs(songs))

if (songs.length) {
  dispatch(setPlayingSongIndex(0))
}

이전 코드의 SongState와 SongListState를 하나로 합쳐 데이터 구조를 통합하여 간결하게 쓸 수 있습니다.

  1. 현재 재생곡부터의 목록(변경된 재생목록)을 SongState에 반영합니다.

  2. 첫번째 곡 재생을 위해 플레이 인덱스를 0으로 재설정합니다.

    1. 이때 SongState에는 재생목록과 곡의 인덱스 정보가 모두 있으므로 곡의 세부 정보 역시 바로 조회하고 설정할 수 있습니다.

      따라서 이전에 실행했었던 컴포넌트에서 재생목록 갱신 코드의 기능을 리덕스에서 온전히 처리하고 있습니다.

출처

https://react-ko.dev/learn/passing-props-to-a-component

https://blog.bravebits.co/separation-of-concerns-in-react-simple-yet-difficult/

https://www.freecodecamp.org/korean/news/best-practices-for-react/

https://fe-developers.kakaoent.com/2022/221020-component-abstraction/

https://evan-moon.github.io/2023/01/15/what-is-abstract/