Accordion 만들기

Accordion 만들기

컴파운드 컴포넌트 패턴, ContextAPI 등을 사용해 아코디언 UI 컴포넌트 맨들어보기.

·

9 min read

✋🏻 들어가며

오늘은 지난번 모달 컴포넌트에 이어서 UI 컴포넌트 만들기 시리즈 두 번째 녀석으로 Accordion을 만들어봤다. 이 녀석은 접혀져서 내용을 숨기고 있는 친구인데 제목처럼 생긴놈을 하나씩 누르면 안에 숨기고 있던 내용이 촤르륵 펼쳐지는 형식의 UI 컴포넌트이다.

이번 Accordion 컴포넌트를 만들기 위한 사전 지식은 컴파운드 컴포넌트 패턴, ContextAPI , 그리고 transition 정도가 아닐까 싶다. 컴파운드 컴포넌트 패턴은 지난 번 모달 컴포넌트를 만들 때 사용했었는데 이번에는 상태 자체를 품고 있는 아이로 만들어서 적용해봤다.

그럼 Accordion 을 만들어보기 전에 간단하게나마 ContextAPI 를 짚고 넘어가볼까.


🌐 ContextAPI

Context는 컴포넌트 간에 특정한 값을 공유할 수 있게 도와주는 친구이다. 기본적으로 컴포넌트 사이에 특정한 값을 공유할 때는 props 를 많이 사용하는데, Context도 일종의 ‘값을 공유하는 방법’이라고 생각하면 좋을 것 같다.

props 로 값을 전달하면 되는데 왜?

우리가 일반적으로 많이 사용하는 props 는 전달하는 깊이가 얕으면 아주 간단히 사용히 가능하지만, 그 깊이가 깊어지거나 의존도가 높아진다면 상당히 개발자를 괴롭게 만들어준다. 아래와 같은 코드가 있다고 가정해보자.

function GrandParent() {
    const love = 3000;
  const word = `I love you,`
    return <Parent love={love} />
}

function Parent({love}) {
    return <Child love={love}/>
}

function Child({love}) {
    return <span>I love you, {love}</span>
}

만약 Child 컴포넌트가 GrandParent의 word 라는 변수의 값을 필요로 한다면 두 번을 거쳐 내려와야 하기 때문에 두 개의 컴포넌트를 수정하는 소요가 발생한다. 만약 love 라는 props 가 더 이상 필요하지 않다면 마찬가지로 두 번의 수정이 필요하다. 상당히 불편하기도 하고 의존적인 느낌도 강하다.

즉 위의 공식문서에서 제시된 그림과 같이 props는 위와 같이 전달해서 사용하는 것이다. 컴포넌트가 깊어질 수록 뭔가 모르게 복잡스럽다.

일반적으로 우리가 만들어가는 프로젝트는 그 규모가 크기 때문에 위의 예제보다 훨씬 복잡한 컴포넌트가 만들어질 것이고 그럼 더욱 복잡하게 설계가 될 것이 분명하다. 이런 문제를 ‘어느정도’ 해결해줄 수 있는 친구가 바로 Context 이다.

위 그림도 공식문서에서 가져왔는데, 어떤 것에 의존해서 가져오고 있는 모양이 아니라 그냥 저기서 쏙 뽑아와서 사용되고 있는 듯한 모습을 띄고 있다.

Context 간단하게 사용해보기

Context 사용 방법은 크게 어렵지는 않다.

  1. Context 만들기

  2. Context 를 공유할 수 있는 Provider 적용하기

  3. Provider 내부 컴포넌트에서 Context 가져와서 사용하기

위 세 가지만 기억하면 된다. 한 스텝 씩 따라가며 만들어볼까?

  1. Context 만들기

     import { createContext } from 'react';
    
     // 👉🏻 createContext 로 컨텍스트 만들기
     const LoveContext = createContext<number>(0);
    
  2. Context 를 공유할 수 있는 Provider 적용하기

     import { createContext } from 'react';
    
     const LoveContext = createContext<number>(0);
    
     function GrandParent() {
       const [value, setValue] = useState<number>(3000);
    
       return (
       // 👉🏻 Context 의 Provider 로 상태를 공유할 컴포넌트 랩핑해주기.
         <LoveContext.Provider value={value}>
             // 👉🏻 props 없이도 Provider 에 적용한 value 값을 사용할 수 있다.
             <Parent />
             <button onClick={() => setValue(prev => prev + 1)}/>
         </LoveContext.Provider>
         );
     };
    
  3. Provider 내부 컴포넌트에서 Context 가져와서 사용하기

     import { createContext, useContext } from 'react';
    
     const LoveContext = createContext<number>(0);
    
     function GrandParent() {
       const [value, setValue] = useState<number>(3000);
       return (
         <LoveContext.Provider value={value}>
             <Parent />
             <button onClick={() => setValue(prev => prev + 1)}/>
         </LoveContext.Provider>
         );
     };
    
     function Parent() {
         return <Child/>
     }
    
     function Child() {
       // 👉🏻 useContext 훅으로 공유받은 값 접근하기
       // 👉🏻 훅의 인자에는 공유받을 Context 넣어주기
       const love = useContext(LoveContext);
       return <span>I love you, {love}</span>
     }
    

✅ Context 에 대해서 더 공부해보려면,

React 공식문서를 보고 공부해보자.


👩🏻‍🌾 Accordion 만들어보기

이번에 만드려고 하는 Accordion 컴포넌트는 위와 같다. 대충 디자인 해봤는데 제목 부분을 클릭하면 내부 내용이 펼쳐지면서 보이는 구조이고, 여러 개를 열어둘 수도 있도록 기획했다.

1️⃣ 컴포넌트 구조 잡기

컴포넌트 구조를 어떤식으로 잡을까 고민을 하며 디자인을 확인해보니 위와 같이 나뉘어졌다.

  1. 상태 공유 - 아코디언을 구성하는 아이템이 열린 상태인지 아닌지를 저장하는 곳

  2. 아이템 - 아코디언을 구성하는 하나의 단위, 특정 value 값을 가짐

  3. 제목 - 아이템의 제목을 담당, 클릭 이벤트에 의해 아이템을 열고 닫을 수 있는 기능이 있음

  4. 내용 - 아이템의 세부 내용을 담당, 상태 변화에 따라 열려있거나 닫혀있음

그리고 이렇게 나눈 각각의 레고 조각들을 지난번 모달 컴포넌트를 만들었을 때와 마찬가지로 컴파운드 컴포넌트 패턴으로 만들었다. 그래서 얼마든지 자유롭게 아이템을 추가하거나 내부 내용을 커스텀할 수 있게 해봤다.

다만 이번에는 지난 번 모달 케이스와는 다른 점이 하나 있는데, 모달의 경우에는 그것을 사용하는 사용처에서 상태를 선언해서 컴포넌트로 넘겨주는 방식으로 만들었다면 이번에는 ✅ 아코디언 내부에 상태를 숨겨두고 단순히 내용만 넣으면 얼마든지 사용 가능하게 만들어주려 한다.

그럼 다시 돌아와서, 구성하려고 하는 아코디언 컴포넌트의 전반적인 그림을 미리 그려보면 아래와 같이 코드가 구성될 것이다.

<아코디언 컨텍스트> // 1. 상태공유
    <아이템 value={아이템값}> // 2. 아코디언을 구성하는 하나의 단위
        <제목 onClick={열고닫는함수}/>
        <내용 />
    </아이템>
</아코디언 컨텍스트>

2️⃣ Context 만들기

이번 Accordion 컴포넌트의 주된 기능(?)은 ‘열리고 닫히는 것’이다. 내가 어떤 아이템을 클릭했냐에 따라 해당 아이템이 열리거나 닫혀야 하고, 그 외 다른 아이템에는 영향이 가서는 안된다. 그리고 이 아코디언은 여러개가 열려있을 수도 있도록 만들어질 것이다.

위 사항에 따라서 ‘어떤’ 아코디언이 열려있는지를 저장해둘 상태를 만들 것인데, 이를 컨텍스트에 담아서 아코디언 컴포넌트 전체에서 공유를 하려고 한다. 그럼 컨텍스트를 만들어볼까?

import { createContext } from "react";

interface AccordionContextType {
  value: Set<string>;
  setter: (item: string) => void;
}

const AccordionContext = createContext<AccordionContextType | null>(null);

나는 어떤 아이템이 열려있는지에 대한 값을 Set 로 관리하고 이 값을 수정하는 setter 함수도 컴포넌트 전체에 공유해서 관리를 하려했다. 그래서 컨텍스트의 기본 타입 구조를 valuesetter 를 가지도록 잡았다.

위와 같이 컨텍스트를 만들고 나서, 아코디언 컴포넌트를 랩핑해줄 Provider 컴포넌트를 만들어줬다. 그리고 컴포넌트 내부에 useState 로 상태를 만들어서 AccordionContext.Providervalue 값으로 전달해줬다.

import { createContext } from "react";

interface AccordionContextType {
  value: Set<string>;
  setter: (item: string) => void;
}

const AccordionContext = createContext<AccordionContextType | null>(null);

function AccordionRoot(props: PropsWithChildren) {
  // #1. 어떤 아이템이 열려있는지 관리할 상태
  const [item, setItem] = useState<Set<string>>(new Set());

  // #2. 상태를 변경해줄 setter 함수
  const setter = useCallback(
    (value: string) => {
      const newItem = new Set(item);

      // #3. 이미 열려있다면 삭제, 닫혀있다면 추가
      if (item.has(value)) {
        newItem.delete(value);
        setItem(newItem);
      } else {
        newItem.add(value);
        setItem(newItem);
      }
    },
    [item]
  );

  return (
    <AccordionContext.Provider value={{ value: item, setter }}>
      {props.children}
    </AccordionContext.Provider>
  );
}

❓ Set 을 사용한 이유

처음에는 이 값을 하나의 string 배열로 저장을 해둘까 고민을 했었다. 하지만 이 값이 순서가 중요한 것도 아니었고 특정 인덱스가 필요하지도 않았다. 그렇다면 매번 값을 넣고 뺄 때마다 배열 메서드를 활용해서 모두 순회해야하는 로직을 작성하는 것은 비효율일 것이라고 생각했다. 그렇기 때문에 Set 과 Set의 메서드를 간단하게 사용해서 관리해주면 훨씬 좋을 것 같다고 판단했다. 🤔

이제 AccordionRootchildren 으로 들어갈 컴포넌트들은 자유롭게 상태를 공유받을 수 있게 되었다. 음.. 그런데 컴파운드 컴포넌트 패턴으로 만들다 보니 내부에 아직 세 개의 컴포넌트를 더 만들 예정이고 매번 컨텍스트를 가져다 쓰는건 귀찮으니, 훅을 하나 만들어줬다.

const useAccordionContext = () => {
  const context = useContext(AccordionContext);
  if (!context) {
    throw "Error";
  }

  return context;
};

AccordionContext 를 불러오고 예외처리까지 해주는 훅을 작성했다. 이제 AccordionRoot 컴포넌트 내부에서 공유받고 있는 상태값을 불러올 때는 useAccordionContext 훅을 사용할 것이다.

3️⃣ 아코디언 아이템 만들기

아코디언 아이템은 제목 부분과 내용부분을 감싸주면서 ‘고유의 값’을 가질 친구이다. 이 값을 가지고 아코디언은 열렸는지, 닫혔는지를 판별하게 된다. 그러니 고유의 값은 해당 컴포넌트에 props 로 전달해주게끔 작성해줬다.

interface AccordionItemProps extends PropsWithChildren {
  value: string; // 해당 아코디언 아이템의 고유한 값
}

function AccordionItem({ value, children }: AccordionItemProps) {
  return (
    <div>
      {children}
    </div>
  );
}

여기까지 작성하고 보니, 이 고유 값은 아이템만 가지고 있는다고 될 일은 아니고, 하위 컴포넌트들도 가지고 있어야 한다. (열고 닫을 때 사용되어야 하니 🤔) 물론 그것을 사용할 컴포넌트들에도 props 로 넘겨주면 되지만 그러기에는 너무 여러번 작성해야하는 불편함이 생겨서 그냥 여기도 컨텍스트로 관리해줬다.

const AccordionItemContext = createContext<string>("");

function AccordionItem({ value, children }: AccordionItemProps) {
  return (
    <AccordionItemContext.Provider value={value}>
      <div>
        {children}
      </div>
    </AccordionItemContext.Provider>
  );
};

const useAccordionItemContext = () => {
  const context = useContext(AccordionItemContext);
  if (!context) {
    throw "Error";
  }

  return context;
};

좋은 패턴인지는 지금도 고민이 된다. 그냥 클릭 이벤트가 일어날 곳에만 props 로 넘겨줘도 될 수도 있는데 굳이? 라는 생각도 들었고.. 그렇지만 우선은 추후에 해당 아코디언 아이템의 기능이 확장될 수도 있을 것을 생각하면 하위에서 사용될 모든 컴포넌트에 공유를 해주는 것이 좋겠다고 판단했다.

4️⃣ 제목 컴포넌트 만들기

이제 가장 큰 역할을 하게 될 제목 컴포넌트이다. 말이 제목 컴포넌트지 사실상 아코디언을 열고 닫을 trigger 역할을 해줄 것이다. 그래서 컴포넌트 이름을 AccordionTrigger 로 지어줬다.

function AccordionTrigger({ children }: { children: string }) {

  return (
    <div>
      <span>{children}</span>
    </div>
  );
}

위와 같이 대략적인 구조를 잡아두고, ‘열고 닫기’ 기능을 수행해주기 위해 공유받고 있는 컨텍스트를 가져와 로직을 작성해줬다.

function AccordionTrigger({ children }: { children: string }) {
  // 👉🏻 열려있는 아이템, setter 함수
  const { value, setter } = useAccordionContext();
  // 👉🏻 아이템의 고유값
  const label = useAccordionItemContext();

  // 👉🏻 Set 의 has 메서드를 사용해 아이템 값을 포함하고 있는지 확인
  const isExpanded = value.has(label);

  return (
    <div
      onClick={() => {
        setter(label);
      }}
    >
      <span>{children}</span>
      {/* 👉🏻 열림과 닫힘 여부에 따라 아이콘 조건부 렌더링 */}
      {isExpanded ? <IoIosArrowUp /> : <IoIosArrowDown />}
    </div>
  );
}

이제 이 친구를 클릭할 때 마다 AccordionContext 의 상태 값에는 아이템값이 들어갔다 나왔다할 것이다. 그리고 그 값이 있냐 없냐를 확인해서 그에 따라 아코디언이 동작할 수 있도록 해주면 간단..!

5️⃣ 내용 컴포넌트 만들기

내용 컴포넌트는 큰 역할은 없으니 간단했다. 내부에 렌더링 시킬 아이는 children 으로 받아서 렌더링되게끔 작성을 해주고, 이 컴포넌트가 보이냐 안보이냐에 따른 css 작업을 해주면 끝! 그래서 우선 아래와 같이 기초 작업을 해줬다.

function AccordionContent({ children }: PropsWithChildren) {
  const { value }  = useAccordionContext();
  const label = useAccordionItemContext();
  const isExpanded = value.has(label);

  return (
    <div>
      {children}
    </div>
  );
}

이제 isExpanded 라는 기준에 따라 요소를 보여주거나 숨기거나 할 것이다. 만약 여기서 크게 애니메이션이 들어가지 않는다면 아래와 같이 작성했을 것이다.

function AccordionContent({ children }: PropsWithChildren) {
    const item = useAccordionContext();
  const label = useAccordionItemContext();
  const isExpanded = item.value.has(label);

  // 👉🏻 열려있지 않으면 아무것도 보여주지 않습니다.
  if (!isExpanded) return null;
  return (
    <div>
      {children}
    </div>
  );
}

하지만 처음 기획했던 조건이 아코디언이 열고 닫힐 때 어느정도 스르륵 효과를 주기로 했기 때문에 조금은 다르게 작성해봤다.

  1. max-height 속성이 닫혀있을 때는 0 으로 설정해서 내용을 숨겨주기

  2. 높이가 0일 때는 내부 요소가 넘쳐버리니 overflow : hidden; 을 작성해서 함께 숨겨주기

  3. 스르륵 효과를 위해 transition 설정하기

    : transition은 CSS의 값이 변할 때 이 값이 변화하는 것이 설정한 시간 동안 일어날 수 있도록 하는 속성이다. 자세한 내용은 아래 문서를 참고하자.

    %[developer.mozilla.org/ko/docs/Web/CSS/CSS_t..

위 계획에 따라 작성을 해주니 아래와 같이 정리가 되었다.

function AccordionContent({ children }: PropsWithChildren) {
  const {value} = useAccordionContext();
  const label = useAccordionItemContext();
  const isExpanded = value.has(label);

  return (
    <div
      css={css`
        max-height: ${isExpanded ? "100%" : "0px"};
        overflow: hidden;
        transition: ${isExpanded ? "all 0.5s ease-in" : "all 0.3s ease-out"};
      `}
    >
      <div>
        {children}
      </div>
    </div>
  );
}

이제 스르륵이 잘 작동..! 되지 않는다. ㅋㅋㅋㅋ 될 줄 알았는데..! 🤢🤢🤢🤢🤢

왜 되지 않을까 찾아보니 transition 은 특정한 값이 아니라면 높이나 너비와 같은 수치 변화를 인식할 수가 없었다. 나는 "100%" 를 설정했었기 때문에 이를 감지하지 못했던 것. 그래서 아래와 같이 수정을 해주니 제대로 작동했다.

function AccordionContent({ children }: PropsWithChildren) {
  const {value} = useAccordionContext();
  const label = useAccordionItemContext();
  const isExpanded = value.has(label);

  return (
    <div
      css={css`
        max-height: ${isExpanded ? "500px" : "0px"};
        overflow: hidden;
        transition: ${isExpanded ? "all 0.5s ease-in" : "all 0.3s ease-out"};
      `}>
      <div>
        {children}
      </div>
    </div>
  );
}

🎉 Accordion 컴포넌트 완성!

이 모든 작업과 함께 조금의 스타일 작업까지 입혀서 나온 결과물은…!

너무나 마음에 들게 잘 나왔다. 뭔가 gif 로 변환하고 슬로우모션이 ㅋㅋㅋㅋ 걸려버려서 느리게 느껴지지만 아주 슝슝 잘 돌아간다! 이렇게 완성된 Accordion 컴포넌트는 아래와 같이 사용할 수 있다.

📌 기본 사용법

<Accordion.Root>
    <Accordion.Item value={itemValue}>
        <Accordion.Trigger>제목 입력</Accordion.Trigger>
        <Accordion.Content>내용 입력 또는 컴포넌트 작성</Accordion.Content>
    </Accordion.Item>
</Accordion.Root>

📌 실제 사용 예시

import * as Accordion from "../components/@core/accordion/Accordion";

export function AccordionPage() {
  return (
    <div>
      <h2>Accordion</h2>
      <p>아코디언 컴포넌트입니다.</p>
      <Accordion.Root>
        <Accordion.Item value={"option-1"}>
          <Accordion.Trigger>좋아하는 가수는 누구인가요?</Accordion.Trigger>
          <Accordion.Content>이트루는 이세계아이돌을 사랑합니다.</Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value={"option-2"}>
          <Accordion.Trigger>좋아하는 아이돌은 누구인가요?</Accordion.Trigger>
          <Accordion.Content>이트루는 이세계아이돌을 사랑합니다.</Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value={"option-3"}>
          <Accordion.Trigger>좋아하는 아이돌 그룹의 멤버는 누가 있나요?</Accordion.Trigger>
          <Accordion.Content>
            <ul>
              <li>아이네</li>
              <li>징버거</li>
              <li>릴파</li>
              <li>주르르</li>
              <li>고세구</li>
              <li>비챤</li>
            </ul>
          </Accordion.Content>
        </Accordion.Item>
        <Accordion.Item value={"option-4"}>
          <Accordion.Trigger>좋아하는 스트리머는 누구인가요?</Accordion.Trigger>
          <Accordion.Content>왁굳형 평생 방송해줘</Accordion.Content>
        </Accordion.Item>
      </Accordion.Root>
    </div>
  );
}

구현 관련 전체 코드


👩🏻‍🌾 마무리

이번 컴포넌트는 자체적으로 상태를 가지고 있는 공용 컴포넌트를 만들어 본 것과 조금은 까다로울 수 있는 스타일 작업이 나름의 과제였던 것 같은데 어느정도는 잘 풀어낸 것 같다. 다만 애니메이션이 조금은 더 부드러웠으면 좋겠는데, 이번주 토요일 UI 컴포넌트 만들기 스터디 때 아코디언 만들기 진행하니까 다른 분들은 어떻게 하는지 보면서 한 수 배워야겠다.

아! 그리고 아코디언 내부에 검색이 되게끔, 그리고 검색을 하면 자동으로 아코디언이 열리게끔 구현하는 것도 추가하고 싶어서 조만간 도전해봐야겠다!


💬 참고