Published on

React로 시작하는 선언적 프로그래밍 Part 2

Authors
  • avatar
    Name
    Kim, Dong-Wook
    Twitter
Table of Contents

Intro

React로 시작하는 선언적 프로그래밍 Part1에서 이어지는 글 입니다. 이전 포스트를 읽고 오시는걸 추천합니다.

선언적 프로그래밍을 통해 React 앱을 개선시킬 수 있는 두번째 방법은 첫번째와 비슷하게 컴포넌트를 숨기지 않고 드러내기 입니다. 해당 문장의 의미는 제가 앱을 개선시키면서 작성했던 리엑트 컴포넌트 코드의 일부분을 보여드리면서 소개하겠습니다.

형상

모달 컴포넌트는 웹 개발에서 자주 쓰이는 컴포넌트이며, 여러분은 이미 웹 사이트에서 많이 사용하셨을 것 입니다. 해당 컴포넌트는 아래와 같이 생겼습니다.

Modal Component

보통 위와 같이, 화면 중앙에 사각형 박스안에 컨텐츠를 띄어주는 컴포넌트를 모달이라고 부릅니다. 또한 해당 모달창을 띄어주기 위해서 보통 버튼(위 화면에서 Edit Profile버튼)을 통해 모달창을 표시하는 형태를 주로 사용합니다.

기존 방식

해당 컴포넌트를 만들기 위해서, 버튼 컴포넌트와 모달 컴포넌트를 나누고, 각 컴포넌트에 모달이 열려있는지 나타내는 상태와 해당 상태를 바꾸는 함수를 전달하는 방식을 사용했습니다.

코드로 확인해볼까요?

//...Some import statements

//-------------타입 정보----------------//

export type Trigger = {
  className?: string
  setIsOpen: Dispatch<SetStateAction<boolean>>
}

export type Modal = {
  className?: string
  isOpen: boolean
  setIsOpen: Dispatch<SetStateAction<boolean>>
}

export type ModalWithTrigger = Omit<Trigger, 'className'> &
  Omit<Modal, 'className'> & {
    triggerClassName?: string
    modalClassName?: string
  }

//------------트리거 시키는 버튼 컴포넌트-----------------//

const Trigger: FunctionComponent<Trigger> = ({ className, setIsOpen }) => {
  return (
    <Button
      className={className}
      colorScheme="success"
      onClick={() => {
        setIsOpen((prev) => !prev)
      }}
    >
      Trigger Primary
    </Button>
  )
}

//-------------모달 컴포넌트----------------//

const Modal: FunctionComponent<Modal> = ({ className, isOpen, setIsOpen }) => {
  return (
    <div className={className}>
      {isOpen && (
        <div>
          <div>
            <h1>Modal</h1>
            <ButtonBase
              onClick={() => {
                setIsOpen?.((prev) => !prev)
              }}
            >
              Close Modal
            </ButtonBase>
          </div>
        </div>
      )}
    </div>
  )
}

//-------------모달과 모달을 트리거하는 컴포넌트를 묶는 컴포넌트----------------//

const ModalWithTrigger: FunctionComponent<ModalWithTrigger> = ({
  triggerClassName,
  modalClassName,
  isOpen,
  setIsOpen,
}) => {
  return (
    <div>
      <Trigger className={triggerClassName} setIsOpen={setIsOpen} />
      <Modal className={modalClassName} isOpen={isOpen} setIsOpen={setIsOpen} />
    </div>
  )
}

//-------------위 컴포넌트를 사용하는 앱----------------//

export const App = () => {
  const [isOpen, setIsOpen] = useState(false)

  return <ModalWithTrigger isOpen={isOpen} setIsOpen={setIsOpen} />
}

위 코드를 살펴보면, 우선 부모 컴포넌트에서 모달을 표시해줄지 나타내는 isOpen 상태를 정의합니다. 그리고 Modal 컴포넌트와 Trigger 컴포넌트를 묶는 ModalWithTrigger 컴포넌트에 열린 상태를 나타내는 isOpen과 열린 상태를 변경할 수 있는 setIsOpen 함수를 함께 넘겨줍니다.

그리고 ModalWithTrigger 내부적으로 isOpen과 setIsOpen props를 Trigger와 Modal에 적절하게 분배합니다.

기존 방식의 문제점

위의 방식의 문제점을 정리하면 다음과 같습니다

  1. Modal컴포넌트를 제어하는 상태와 해당 상태를 변경시킬 수 있는 상태가 외부에 드러납니다.
  • 다른 말로 표현하자면 컴포넌트를 통해 무엇을 나타낼지 집중하지 못하고, 해당 컴포넌트를 어떻게 제어하는지에 집중할 수 밖에 없다는 것 입니다. 개발자의 입장에서 해당 컴포넌트를 통해 화면에 무엇을 나타낼지 집중하고 싶지만 기존 방식은 해당 컴포넌트를 어떻게 사용할지를 함께 고려해야했습니다.
  1. 각 컴포넌트에 커스텀 스타일을 적용하기 위해선 더러운 Prop 설계가 필요합니다.
  • 만약 Modal과 Trigger 컴포넌트의 스타일을 변경하려면 어떻게 해야할까요? 우선 ModalWithTrigger 컴포넌트에 triggerClassName, modalClassName prop을 만들고 해당 prop을 Trigger과 Modal className prop에 적절하게 배치해야합니다.
// 생략
const ModalWithTrigger: FunctionComponent<ModalWithTrigger> = ({
  triggerClassName,
  modalClassName,
  isOpen,
  setIsOpen,
}) => {
  return (
    <div>
      <Trigger className={triggerClassName} setIsOpen={setIsOpen} />
      <Modal className={modalClassName} isOpen={isOpen} setIsOpen={setIsOpen} />
    </div>
  )
}

export const App = () => {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <ModalWithTrigger
      triggerClassName="rounded-full"
      modalClassName="rounded-none"
      isOpen={isOpen}
      setIsOpen={setIsOpen}
    />
  )
}

위와 같이 특정 컴포넌트에 특정 prop을 주기위해서는 ModalWithTrigger같은 합쳐진 컴포넌트에 이름을 구분해서 할당 시켜줘야합니다. 이는 Typescript Union을 쓰기 어렵게 만들며 해당 prop이 내부적으로 어떻게 쓰이는지 이름에 의존해야합니다.

개선된 방식

어떻게 설계해야 위의 단점들을 극복할 수 있을까요?

우리는 지역적 전역변수 사용하기를 통해 위의 단점을 극복할 수 있습니다. React에는 Context API라는 전역변수 관리 도구가 있습니다. 해당 API는 주로 리엑트 앱에서 깊이가 다른 컴포넌트간 데이터를 prop drilling없이 공유할때 사용합니다. 이를 앱 전역에서 GlobalStore로써 사용하는 것이 아닌, 컴포넌트 내에 LocalStore로 사용하는 것 입니다.

다음 코드를 살펴봅시다.

//...Some import statements

//-------------타입 정보----------------//

export type CompoundRootProps = {
  children?: React.ReactNode
}

export type CompoundTriggerProps = {
  className?: string
}

export type CompoundPortalProps = {
  className?: string
}

//-------------지역적 전역변수 Scope----------------//

// Root Context
const CompoundContext = createContext<{
  isOpen: boolean
  setIsOpen?: Dispatch<SetStateAction<boolean>>
}>({
  isOpen: false,
  setIsOpen: undefined,
})

// Root Context Hook
const useCompoundContext = () => useContext(CompoundContext)

// Root Component
const CompoundRoot: FunctionComponent<CompoundRootProps> = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <CompoundContext.Provider
      value={{
        isOpen,
        setIsOpen,
      }}
    >
      {children}
    </CompoundContext.Provider>
  )
}

//-------------개선된 Trigger 컴포넌트----------------//

const Trigger: FunctionComponent<CompoundTriggerProps> = ({ className }) => {
  const { setIsOpen } = useCompoundContext()

  return (
    <Button
      className={className}
      colorScheme="success"
      onClick={() => {
        setIsOpen?.((prev) => !prev)
      }}
    >
      Trigger With Context
    </Button>
  )
}

//-------------개선된 Modal 컴포넌트----------------//

const Modal: FunctionComponent<CompoundPortalProps> = ({ className, onClick }) => {
  const { isOpen, setIsOpen } = useCompoundContext()

  return (
    <div className={className}>
      {isOpen && (
        <div>
          <h1>Modal</h1>
          <ButtonBase
            onClick={() => {
              setIsOpen?.((prev) => !prev)
            }}
          >
            Close Modal
          </ButtonBase>
        </div>
      )}
    </div>
  )
}

const Root = CompoundRoot

export const App = () => {
  return (
    <Root>
      <Trigger />
      <Modal />
    </Root>
  )
}

차이점이 보이시나요? 기존에는 모달의 상태와 상태를 업데이트하는 책임이 컴포넌트 외부에 있었습니다. 해당 상태를 Context API를 통해 Root 컴포넌트 스코프 내부적으로 공유함으로써, 컴포넌트를 제어하는 로직은 숨기고 Trigger컴포넌트와 Portal 컴포넌트를 사용자(개발자)에게 그대로 드러낼 수 있게 되었습니다. 아래의 코드를 비교해보면 더욱 명확하게 확인할 수 있습니다.

//-------------기본 Modal 컴포넌트 사용예제----------------//

const ModalWithTrigger: FunctionComponent<ModalWithTrigger> = ({
  triggerClassName,
  modalClassName,
  isOpen,
  setIsOpen,
}) => {
  return (
    <div>
      <Trigger className={triggerClassName} setIsOpen={setIsOpen} />
      <Modal className={modalClassName} isOpen={isOpen} setIsOpen={setIsOpen} />
    </div>
  )
}

export const PrimaryApp = () => {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <ModalWithTrigger
      triggerClassName="rounded-full"
      modalClassName="rounded-none"
      isOpen={isOpen}
      setIsOpen={setIsOpen}
    />
  )
}

//-------------컴파운드 Modal 컴포넌트 사용예제----------------//

export const CompoundApp = () => {
  return (
    <Root>
      <Trigger className="rounded-full" />
      <Modal className="rounded-none" />
    </Root>
  )
}

기존 Modal 컴포넌트의 경우, 같은 상태(state)를 사용하는 컴포넌트를 묶기 위해서 ModalWithTrigger와 같은 묶는 컴포넌트가 필요했습니다. 때문에 Trigger와 Modal 컴포넌트를 묶는 컴포넌트안에 숨기게 되었습니다. 해당 방식때문에 발생한 문제점은 다음과 같습니다.

  1. 컴포넌트를 계층적으로 나타낼 수 없고(즉 선언적으로 사용불가), 다른 컴포넌트를 제어하기 위해서 더러운 Prop설계가 필요.
  2. 컴포넌트를 제어하는 데이터를 Prop형태로 받아야함

이를 Context API를 통해 개선한다면, 컴포넌트를 그대로 외부에 드러내기 때문에 시각적으로 컴포넌트의 구조를 나타낼 수 있으며, 더러운 Prop설계 없이 각 컴포넌트가 가지고 있는 prop을 그대로 사용할 수 있습니다.

그리고 우리는 이걸 Compound-Pattern이라고 부릅니다.

좀 더 강력한 예제를 보면서 Compound-Pattern을 알아봅시다.

Carousel이란 회전목마를 뜻합니다. 회전목마처럼 어떤 데이터를 돌아가면서 보여준다는 의미로 이름 붙여졌습니다.

형상

carousel shape

Carousel 컴포넌트의 형상은 위와 같이 어떤 컨탠츠(위에서는 카드형태)와 현재 페이지를 나타내는 인디케이터로 나눌 수 있습니다.

기존 방식

// ... some import statements

const Carousel: FunctionComponent<{
  enableIndicator?: boolean
  indicatorPosition?: "up" | "bottom"
  children: React.ReactNode
}> = ({ enableInfinite, enableIndicator, children }) => {
  const [numOfPages, setNumOfPages] = useState(0);
  const [selectedPage, setSelectedPage] = useState(0);

  //... some logic for calculate numOfPages and selectedPage
  {...}
  //

  return (
    <div>
      {enableIndicator && indicatorPosition === "up" && <CarouselIndicator numOfPages={numOfPages} selectedPage={selectedPage} />}
      <CarouselContent selectedPage={selectedPage}>
        {/* use Children API to render*/}
        {children}
      </CarouselContent>
      {enableIndicator && indicatorPosition === "bottom" && <CarouselIndicator numOfPages={numOfPages} selectedPage={selectedPage} />}
    </div>
  )
}

export const App = () => {
  return (
    <Carousel enableIndicator indicatorPosition="up">
      <div className="bg-green-500 h-96">abc1</div>
      <div className="bg-green-500 h-96">abc2</div>
      <div className="bg-green-500 h-96">abc3</div>
      <div className="bg-green-500 h-96">abc4</div>
    </Carousel>
  )
}

위의 Carousel 컴포넌트는 다음과 같은 기능이 지원됩니다

  1. Indicator를 끌거나 켤 수 있습니다.
  2. Indicator의 위치를 Carousel 컨텐츠의 위 또는 아래에 위치할 수 있습니다
  3. children을 넣으면 Children API를 통해 Carousel의 페이지를 자동으로 계산합니다.

하지만 해당 컴포넌트는 ModalWithTrigger와 비슷하게 다음과 같은 문제점을 가지고 있습니다.

  1. 한 컴포넌트에 2개의 컴포넌트에 대한 책임을 가지고 있다.
  • Carousel 컴포넌트의 Prop중 enableIndicator와 indicatorPosition은 실제로 CarouselIndicator 컴포넌트에 대한 Prop이며, CarouselContent 컴포넌트는 관심없는 Prop입니다. children prop의 경우 CarouselContent와 CarouselIndicator 둘 모두에게 책임이 있는 prop입니다.
  1. indicator의 위치를 결정하기 위해서 indicatorPosition이라는 Prop을 따로 설계해야하며, Indicator의 위치를 컴포넌트 개발자에게 의존해야한다.(만약 다른 위치를 원한다면 개발자에게 문의해야함)

위의 두 문제점 또한 Context API를 통해 해결할 수 있습니다.

개선된 방식

Indicator 컴포넌트와 Content 컴포넌트를 사용자(개발자)에게 그대로 들어내고 페이지를 계산하는 로직을 Context API를 통해 컴포넌트 내부에서 공유함으로써 해결할 수 있습니다.

아래의 코드를 확인해봅시다

// ... some import statement

// Root Context
const CarouselContext = createContext({
  numOfPages: 0,
  selectedPage: 0,
  setNumOfPages: null,
  setSelectedPage: null,
})

// Root Context Hook
const useCarouselContext = () => useContext(CarouselContext)

// Root Component
const Root = (props) => {
  const [numOfPages, setNumOfPages] = useState<number>(0)
  const [selectedPage, setSelectedPage] = useState<number>(0)

  return (
    <CarouselContext.Provider
      value={{
        numOfPages,
        selectedPage,
        setNumOfPages,
        setSelectedPage,
      }}
    >
      <div>{props.children}</div>
    </CarouselContext.Provider>
  )
}

const Content: FunctionComponent<CarouselContentProps> = ({ children }) => {
  const { selectedPage, setNumOfPages, setSelectedPage } = useCarouselContext()

  const currentNumOfPages = useMemo(() => Children.count(children), [children])

  // assign calculated num of pages to context
  useEffect(() => {
    setNumOfPages(currentNumOfPages)
  }, [currentNumOfPages])

  return (
    <CarouselContent selectedPage={selectedPage}>
      {/* use Children API to render*/}
      {children}
    </CarouselContent>
  )
}

const Indicator: FunctionComponent = () => {
  const { numOfPages, selectedPage } = useCarouselContext()

  return <CarouselIndicator numOfPages={numOfPages} selectedPage={selectedPage} />
}

사용예시는 아래와 같습니다

export const DeclarativeIndicator = () => {
  return (
    <Fragment>
      {/* Example */}
      <Root>
        <Content>
          <div className="h-96 bg-green-500">abc1</div>
          <div className="h-96 bg-green-500">abc2</div>
          <div className="h-96 bg-green-500">abc3</div>
          <div className="h-96 bg-green-500">abc4</div>
        </Content>
        <Indicator />
      </Root>
      {/* Example of how indicator position could be changed*/}
      <Root>
        <Indicator />
        <Content>
          <div className="h-96 bg-green-500">abc1</div>
          <div className="h-96 bg-green-500">abc2</div>
          <div className="h-96 bg-green-500">abc3</div>
          <div className="h-96 bg-green-500">abc4</div>
        </Content>
      </Root>
    </Fragment>
  )
}

선언적 인디케이터

즉 Context API를 통해서 한 컴포넌트에서 계산된 Page의 갯수, 선택된 Page의 Index를 Root Scope에서 공유함으로써 사용자(개발자)가 관심없는 데이터는 숨기고 컴포넌트 자체를 드러낼 수 있는 것 입니다.

또한 기존 Carousel의 경우 Indicator의 위치를 결정하기 위해서 별도의 prop을 설계해야했지만 컴포넌트를 사용자에게 그대로 들어냄으로써 시각적으로 Indicator의 위치를 사용자(개발자)에게 선언할 수 있도록 개선시킬 수 있는 것 입니다.

결론

React를 잘 사용하는 것은 어렵습니다. React를 이용해서 누구나 앱을 쉽게 만들 수 있지만, 재사용이 쉬우며 3, 4년 뒤에도 읽기 쉽고 유지보수하기 쉬운 앱을 만드는 것은 어렵습니다.

하지만 개발을 사랑하는 선구자분들이 제시하는 방법론, 예를 들면 대수적 효과와 선언적 프로그래밍 등을 잘 활용한다면 더 빠르게 더 좋은 제품을 만들 수 있다고 믿습니다.

그리고 다행히도 선구자분들은 이에 대한 컨닝페이퍼를 남겨놓았습니다. 오늘 소개한 Compound-pattern은 제 컨닝페이퍼 중 하나인 Radix ui를 기반으로 했습니다. 만약 Compound-pattern에 대해 더 공부하고 싶은 분들은 해당 컨닝페이퍼를 참고해주세요

외에도 Relay, React-Query, react-suspense 등 다양한 컨닝페이퍼가 있으니 여러분들도 참고하셨으면 좋겠습니다.