프로젝트/하루한냥

[하루한냥] 모달 모듈화 구현 : OverlayProvider와 Context API

알파카털파카 2023. 9. 2. 09:00
[하루한냥]
모달 모듈화 구현 : OverlayProvider와 Context API

 

 

우여곡절 끝에 캘린더 CRUD 구현을 마쳤다. 캘린더 페이지에서 일기를 작성하거나 수정할 수 있다. 타임라인 페이지에서 달별 일기 목록을 조회하고 삭제할 수 있다. 이 기능을 바탕으로 필요한 부분에 살을 덧붙이고 있다. 일기 텍스트를 작성하거나, 저장하지 않고 뒤로가기를 클릭하는 경우 모달창이 뜬다. 우선 모달을 useState로 구현했고, 이를 Context API를 기반으로 하는 OverlayProvider로 리팩토링하는 과정을 거쳤다. 이번 글에서는 이렇게 모달을 모듈화해서 모달 관련한 중복 코드를 줄이고, 기존에 페이지에 종속되었던 모달을 범용적으로 사용할 수 있도록 하는 모달 모듈화 내용을 담아보았다. 

 

 

💡 리액트에서 모달을 우아하게 관리하기
  - 기존의 모달 관리 방식
  - 선언적 모달 관리로 비동기 처리 간소화하기
  - Context API의 활용과 최적화 계획 
1. 모달 기능 분석하기

  - DiaryModal
  - ConfirmModal
2. OverlayContext 생성 - createContext
3. OverlayProvider 구현 - Context.Provider
4. useOverlay 구현 - useContext
5. useModal 구현
  - useModal의 필요성
6. 페이지에서 적용하기
  - DiaryModal
  - ConfirmModal
📖 참고 레퍼런스

 

 


 

 

리액트에서 모달을 우아하게 관리하기

기존의 모달 관리 방식

이전의 모달 구현 방식은 모달이 페이지에 종속되도록 되어있었다. 모달의 활성화 상태(open/close)와 관련 데이터를 페이지나 컴포넌트에서 개별적으로 관리해야 했다. 상태 관리 로직의 중복, 모달 간 일관성 유지가 어려운 점, 복잡한 상태 관리 구조 등 여러 문제가 있었다.

 

이러한 문제를 해결하기 위해, 리액트의 공통의 추상화 원칙을 적용할 수 있었다. 리액트로 개발하면 공통된 로직을 추상화하고, 모듈화해서 범용적으로 쓸 수 있도록 만들게 된다. 모달 관리에 있어서도 다음과 같은 질문을 생각해볼 수 있었다.

 

1. 모달의 활성화 상태를 개발자가 항상 직접 관리해야 할까?

2. 페이지에서 사용하는 모달은 반드시 페이지 컴포넌트에 종속되어야만 할까?

3. 모달이 필요한 함수에서 선언적으로 호출하고, 그 응답을 받을 수는 없을까?

 

const [isOpenModal, setIsOpenModal] = useState(false);
const [result, setResult] = useState({});

const openModal = () => setIsOpenModal(true);
const closeModal = () => setIsOpenModal(false);
const handleSubmitModal = (value) => {
  setResult(value);
};

// 모달에서 반환된 상태를 변경할 필요가 있을 경우
// -> 사이드 이펙트를 감당해야 함
useEffect(() => {
  // 기능 처리
  ...
}, [result]);

return (
  <div>
    <button onClick={openModal}>Open Modal</button>
    {isOpenModal && <Modal onClose={closeModal} onSubmit={handleSubmitModal} />}
  </div>
);

 

 

 

 

선언적 모달 관리로 비동기 처리 간소화하기

모달을 다루는 과정은 많은 상태 관리 로직과 복잡성이 있다. 이를 해결하기 위해, 함수에서 필요한 시점에 모달을 주입하고, 그 응답을 처리하는 방법을 고민했다. 

 

const modal = useModal();

const handleDiaryModal = async () => {
  const responseDiaryText = await modal<{ text: string }>(<DiaryModal diaryText={diary.text} />);
  console.log('responseDiaryText:', responseDiaryText);

  // 추가로 필요한 로직 처리
  ...
};

return (
  <div>
    <button onClick={handleDiaryModal}>Open Modal</button>
  </div>
);

 

모달의 중복되는 상태를 모듈화함으로써, 개발자는 비즈니스 로직에 집중할 수 있게 된다. 함수 내에서 모달을 선언적 접근 방식을 통해 호출함으로써 로직의 이해를 높일 수 있다. 이러한 방법을 통해 개발자는 직관적이고 예측 가능한 방식으로 코드를 관리할 수 있다. 모달로부터 응답을 비동기적으로 처리하는 과정이 간결해지면서, 사용자에게 더 나은 인터렉션을 제공할 수 있는 기반을 마련했다.

 

 

 

 

Context API의 활용과 최적화 계획

Context API는 리액트 내장 API이다. 다른 라이브러리를 설치하고, 이용하지 않아도 리액트 애플리케이션에서 바로 사용할 수 있다. 모달 관리 로직을 모듈화하는 과정에서, 접근성과 편리함 때문에 Context API를 선택하게 됐다. 원래는 useOverlay를 라이브러리로 만들어 NPM에 등록하고 싶었지만, 아직 최적화를 완료하지 못해 계획이 밀리게 되었다. 

 

Context API는 상태가 변경되면 해당 컨텍스트를 구독하는 컴포넌트가 모두 업데이트 되는 특성이 있다. 이러한 특징은 유용하기도 하지만, 불필요한 리렌더링을 유발할 수 있기 때문에 성능 저하의 원인이 될 수 있다. 그렇기 때문에 useCallback등의 리액트 최적화 기법을 이용해 불필요한 리렌더링을 줄이는 최적화가 필요하다. 이 부분을 위주로 개선해서 최적화된 useOverlay를 NPM에 라이브러리로 등록하는 것을 최종 목표로 가지고 있다. 

 

 

 

 

1. 모달 기능 분석하기

본격적으로 모듈화를 구현하기 앞서, 모달 기능을 분석했다. 달력 CRUD 과정에서 이미 모달의 기능을 다 구현했지만, 모듈화 리팩토링을 거치며 모달 로직을 건드려야하기 때문에 모달을 다시 파악하고 넘어가야 한다. 초기 기획 단계에서 나온 모달은 DiaryModal과 ConfirmModal 두 종류다. 전자는 일기 작성 및 수정 페이지에서 텍스트를 작성하기 위해 사용된다. 후자는 내용 변경 후 저장하지 않고 헤더의 < 버튼을 눌렀을 경우, 내용이 저장되지 않음을 알리는 안내 모달이다.

 

DiaryModal과 ConfirmModal 이미지

 

 

1-1. DiaryModal

DiaryModal

 

작성 및 수정 페이지 input

 

  • 일기 작성 및 수정 페이지에서 input을 클릭하면 모달이 켜진다. 모달에서 작성하고 확인 버튼을 누르면 내용이 input에 미리보기로 보여진다.
  • 모달에는 글을 작성하는 textarea가 있다. 일기 텍스트를 작성할 수 있다.
  • 취소 버튼을 클릭하면 모달창을 닫을 수 있다. 모달 끄기(close) 기능만 있다.
  • 확인(작성완료) 버튼을 클릭하면 모달이 꺼지고(close), 작성한 내용이 diary의 text에 내용이 추가된다. setDiary를 이용한다. 

 

 

 

1-2. ConfirmModal

ConfirmModal

 

  • 뒤로가기 버튼을 누르면 모달이 켜진다.
  • 취소 버튼을 클릭하면 모달창을 닫을 수 있다. 모달 끄기(close) 기능만 있다.
  • 확인(나가기) 버튼을 클릭하면 모달이 꺼지고(close), 이전 페이지로 뒤로가기(navigate(-1)) 이동 된다. 작성 중인 일기는 무효 처리된다.

 

 

 

 

2. OverlayContext 생성 - createContext

Context API를 이용하는 OverlayProvider를 만들고,

OverlayContext.Provider 내부에 children으로 들어온 모달이 있을 경우 모달창을 띄우는 기능을 구현할 것이다. 

 

먼저 createContext로 새로운 Context 객체를 생성한다. 

 

// createContext로 Context 객체 생성
export const OverlayContext = createContext<OverlayOpenFn | null>(null);

 

Context 객체는 모달 컴포넌트가 들어갈 children과, 오버레이 백드롭 부분을 클릭했을 때 모달이 꺼지거나 켜지도록 하는 option으로 구성된 함수이다. 이 함수는 Promise 객체를 반환하며, 모달을 제출했을 때(확인 버튼을 눌렀을 때)의 값 또는 null이 될 수 있다. 

 

// Context 객체의 타입
export type OverlayOpenFn = (children: ReactNode, option?: OverlayOption) => Promise<OverlaySubmitResult> | null;
 
// option
export type OverlayOption = {
  clickOverlayClose?: boolean;
};

const defaultOverlayClickOption: OverlayOption = {
  clickOverlayClose: false,
};

// 반환값
export type OverlaySubmitResult = unknown;

 

 

 

 

3. OverlayProvider 구현 - Context.Provider

App.tsx에 추가할 OverlayProvider 컴포넌트를 생성한다. 이 컴포넌트에서는 하위 컴포넌트에 value 값을 전달할 것이다. 하위에 전달될 값과 필요한 함수를 구현해야 한다. 

 

value로 들어갈 openOverlay 함수는 Context 객체의 타입과 동일하다. 모달을 호출하는 액션이 발생하면 이 함수가 동작하며 children으로 들어온 값이 리액트 엘리먼트인지 확인하고, overlay 값을 업데이트한다. 여기서 주의할 점은 openOverlay함수에 useCallback을 사용해야 한다는 것이다. 매번 렌더링할 때마다 openOverlay 함수가 변경되므로, useCallback으로 묶어줘야 한다. value가 바뀌면 useContext를 사용하는 모든 컴포넌트가 리렌더된다. 

 

❌ ESLint: The ‘openOverlay’ function expression (at line 27) passed as the value prop to the Context provider (at line 52) changes every render. To fix this consider wrapping it in a useCallback hook.(react/jsx-no-constructed-context-values)

 

openOverlay 함수의 리턴값 부분에서는 Promise 객체를 생성하고 resolver를 overlay 상태값 안에 업데이트 해준다. 모달을 닫을 때 setOverlay(null) 처리를 해주기 때문에, 오버레이의 상태값이 null이 되는 경우도 있다. 이런 상황을 위해 null이 들어오면 null을 그대로 반환하도록 구현한다. 

 

필요한 함수에는 모달 컴포넌트에서 쓰이는 onClose와 onSubmit이 있다. 각각 취소 버튼과 확인 버튼에서 사용된다. 콘솔을 이용하면 버튼이 제때 제대로 동작하는지 확인할 수 있다. openOverlay 함수에서 던져진 Promise 공을 handleSubmitOverlay 함수에서 받아 처리한다. 

 

type OverlayState = {
  content: ReactNode;
  options: OverlayOption;
  resolver?: (value: unknown) => void;
};

export const OverlayProvider = ({ children }: PropsWithChildren) => {
  const [overlay, setOverlay] = useState<OverlayState | null>(null);

  const openOverlay: OverlayOpenFn = useCallback((children, option) => {
    console.log('열기');
    if (isValidElement(children)) {
      setOverlay({
        content: children,
        options: { ...defaultOverlayClickOption, ...(option ?? {}) },
      });

      return new Promise((resolver) => {
        console.log('Promise 객체 생성됨');
        setOverlay((prevOverlay) => (prevOverlay ? { ...prevOverlay, resolver } : prevOverlay));
      });
    }

    return null;
  }, []);

  const handleCloseOverlay = () => {
    setOverlay(null);
    console.log('닫기');
  };

  const handleSubmitOverlay = (result: OverlaySubmitResult) => {
    console.log('제출');
    overlay?.resolver?.(result);
    console.log('Promise가 끝남!');
    handleCloseOverlay();
  };

  return (
  // 생략
  );
};

 

반환값은 아래와 같다. OverlayContext.Provider로 감싸고 하위 컴포넌트로 전달할 value에 openOverlay를 넣어주었다. Provider는 기본값을 사용하지 않기 때문에 value가 없으면 오류가 발생한다. 하위 컴포넌트는 value가 바뀔 때마다 리렌더링이 발생한다. 

 

prop으로 넘겨받은 children을 렌더링하므로 하위 컴포넌트들이 그려지게 된다. 거기에 추가로 overlay가 켜질 때 Overlay 컴포넌트가 렌더링된다. Overlay 컴포넌트는 모달 컴포넌트를 포함하며, 백드롭 등의 구현이 되어있는 컴포넌트다. Overlay의 안에서 렌더되는 모달 컴포넌트에 모달에서 사용될 onClose, onSubmit 등의 함수를 전달한다.

 

// OverlayContext.Provider
return (
  <OverlayContext.Provider value={openOverlay}>
    {children}
    {overlay && (
      <Overlay
        onClose={handleCloseOverlay}
        onSubmit={handleSubmitOverlay}
        onClickOverlayClose={overlay.options.clickOverlayClose}
      >
        <>
          {isValidElement(overlay.content) &&
            cloneElement(overlay.content, {
              onClose: handleCloseOverlay,
              onSubmit: handleSubmitOverlay,
            })}
        </>
      </Overlay>
    )}
  </OverlayContext.Provider>
);

 

상위 컴포넌트 App.tsx에 OverlayProvider를 추가한다.

 

// App.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Global } from '@emotion/react';

import { OverlayProvider } from '@ui/components/layout/overlay/OverlayProvider';
import globalStyle from '@ui/styles/globalStyle.css';
import routes from './routes';

const router = createBrowserRouter(routes);

export default function App() {
  return (
    <OverlayProvider> // ✅ 추가
      <RouterProvider router={router} />
      <Global styles={globalStyle} />
    </OverlayProvider>
  );

 

이번 프로젝트에서는 라우터 처리를 할 때 Context API를 활용한 Router 객체를 만들어 적용했기 때문에 RouterProvider도 추가되어 있는 것을 볼 수 있다. 브라우저 개발자 도구에서 본 컴포넌트 구조의 스크린샷이다. 

 

개발자 도구 컴포넌트 구조

 

 

 

 

4. useOverlay 구현 - useContext

값을 조회하고 이용하는 useContext를 사용해 useOverlay hook을 만든다. OverlayContext가 null인 경우도 있기 때문에 분기 처리를 해준다. 

 

// useContext
export const useOverlay = () => {
  const context = useContext(OverlayContext);

  if (context === null) {
    throw new Error('useOverlay를 사용하려면 OverlayProvider를 상위에 제공해야 합니다.');
  }

  return context;
};

 

 

 

 

5. useModal 구현 

useOverlay를 이용하는 useModal hook을 생성한다. 페이지에서는 이 useModal을 호출해 모달을 활성화하게 된다. 

 

// useModal.tsx
import { ReactElement } from 'react';
import { OverlayOption, useOverlay } from '@ui/components/layout/overlay/OverlayProvider';

export default function useModal() {
  const overlay = useOverlay();

  const showModal = async <T = any,>(component: ReactElement, options?: OverlayOption): Promise<T> => {
    const submitResult = await overlay(component, options);
    return submitResult as T;
  };

  return showModal;
}

 

 

useModal의 필요성 

useModal을 이용하면, 모달을 호출하는 페이지에서 하나의 함수를 만들어 모달의 켜짐과 닫음을 한 번에 관리할 수 있다. 필요할 때 호출해서 사용하고, 직관적이다. 호출해야하는 모달 갯수가 늘어나도 모달 호출 함수만 더 추가하면 된다. 모달을 이용하는데 필요한 공통 로직을 모듈화해서 간편해진다. 기존에 모달의 close, submit 함수 로직은 컨텍스트에 담겨있다. 모달의 여닫음을 위해 매번 close, submit 함수를 새로 만들 필요가 없다.

 

 

 

 

6. 페이지에서 적용하기 

이제 페이지에서 적용해볼 차례다.

기존에 useState를 이용해 [isModalOpen, setIsModalOpen] = useState(false); 로 작성했던 로직은 지운다.

DiaryModal과 Confirm 모달 각각 분리해서 적용하는 과정을 살펴보자.

 

1-1. DiaryModal

모달과 관련된 코드만 모았다. useModal을 호출해 modal에 <DiaryModal /> 컴포넌트를 렌더링하도록 넘겨주고, 모달의 결과인 일기 텍스트를 받아 setDiary로 값을 넣어주고 있다. 

 

// WritePostPage.tsx
export default function WritePostPage() {
  const modal = useModal(); // ✅

  const [diary, setDiary] = useState<newDiaryType>({
    feel: todayFeeling || null,
    emotions: [],
    text: '',
    date: {
      year: parseYear,
      month: parseMonth,
      date: parseDate,
    },
  });

// 모달 호출 및 값 반영하기
  const handleDiaryModalOpen = async () => {
    const isModal = await modal(<DiaryModal diaryText={diary.text} />);

    if (isModal !== null) {
      setDiary({
        ...diary,
        text: isModal.text,
      });
    }
  };

  return (
    <>
      <WritePostHeader 
        year={parseYear} 
        month={parseMonth} 
        date={parseDate} 
      />
      <Body>
        <Container>
          <FeelingContainer 
            diary={diary} 
            onClick={handleClickDiaryFeeling} 
          />
          <EmotionContainer 
            diary={diary}
            onClick={handleClickDiaryEmotion} 
          />
          <DiaryContainer>
            <label htmlFor="diary">한줄일기</label>
            <InputField id="diary" onClick={handleDiaryModalOpen}> // ✅
              {diary.text.length > 0 ? diary.text : '내용을 입력해 주세요'}
            </InputField>
          </DiaryContainer>
          <Button 
            type="button" 
            onClick={handlePostNewDiary} 
            disabled={!isDisabled}
          >
            작성완료
          </Button>
        </Container>
      </Body>
    </>
  );
}

 

DiaryModal의 코드는 이렇다. 모달 내에서 따로 modalInput 상태를 만들어 textarea에 입력되는 값을 저장했다가, 확인 버튼(작성완료)을 누르면 일기 작성 페이지에서 값을 받아 diary 상태에 추가하게 된다.

 

// DiaryModal.tsx
export default function DiaryModal({ diaryText, onClose, onSubmit }: ModalProps) {
  const [modalInput, setModalInput] = useState<string>(diaryText || '');

  const handleChangeModalInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
    const { value } = e.target;
    setModalInput(value);
  };

  const handleClickSubmit = () => {
    onSubmit({ text: modalInput });
    onClose();
  };

  return (
  // 생략
  );
 }

 

 

 

1-2. ConfirmModal

useModal을 사용해 헤더의 < 버튼을 누를 때 확인 모달창이 뜨도록 했다. 

 

// WritePostHeader.tsx
export default function WritePostHeader({ year, month, date }: WritePostHeaderProps) {
  const navigate = useNavigate();
  const modal = useModal();

  const handlePageBack = async () => {
    await modal(
      <ConfirmModal
        title="감정일기 글쓰기"
        description={'기록한 내용이 저장되지 않습니다.\n그래도 나가시겠습니까?'}
        onBack={handleNavigateBack}
      />,
    );
  };

  const handleNavigateBack = () => {
    navigate(-1);
  };

  return (
    <>
      <Container>
        <BackArrow 
          src="/images/icon/back.png" 
          alt="back" 
          onClick={handlePageBack} 
        />
        <SelectedDate>
          {month}월 {date}일 {dayOfWeek}요일
        </SelectedDate>
      </Container>
    </>
  );
}

 

ConfirmModal의 코드는 다음과 같다. 이 모달은 저장하지 않고 뒤로가기 시에 경고를 하는 팝업 모달이기 때문에 null을 반환하고 뒤로 이동하도록 구현이 되어 있다. 

 

// ConfirmModal.tsx
export default function ConfirmModal({ title, description, onBack, onClose, onSubmit }: ConfirmModalProps) {
  const handleSubmit = () => {
    onSubmit(null);
    onBack();
  };

  return (
   //생략
  );
}

 

 

 

 

참고 레퍼런스

https://slash.page/ko/libraries/react/use-overlay/src/useoverlay.i18n/

https://ko.legacy.reactjs.org/docs/design-principles.html

https://ko.legacy.reactjs.org/docs/context.html

 

 

 

 

마치며

원래 늘 하던 useState 방식으로 모달을 구현하면 코드를 작성할 때는 편리하다. 이미 알고 있는 내용이기 때문이다. 이번에 모듈화를 진행하면서 Context API를 복습할 수 있었다. 모달을 페이지에 종속되도록 구현하지 않고 범용적으로 사용할 수 있는 코드로 리팩토링해서 유용하게 쓸 수 있다. 이번 과정에서 가장 짜릿했던 순간은 페이지에 잔뜩 있는 모달 관련 코드를 다 삭제할 때였다. 아직 해결하지 못한 오류가 조금 남았는데 이 부분을 보완해서 다음 포스트에 트러블 슈팅을 적어보면 어떨까 생각 중이다. context api 데이터 흐름이 복잡해서 노트에 필기해가며 진행했기 때문에 잘 정리해두면 추후에 또 비슷한 상황에 도움이 될 것 같다. 

 

AI 때문에 사고력이 떨어지고 의존도가 늘어나는 느낌이라 챗GPT 디톡스 중이다. 개발 관련 용도가 아니어도 번역 용무로 사용하곤 했는데 아예 챗GPT를 켜지도 않도록 하려고 DeepL을 사용하기 시작했다. 프로젝트 초기에 기획한 내용이 얼추 다 끝나간다. 올해가 가기 전에 얼른 취업하고 싶다.