프로젝트/하루한냥

[트러블 슈팅] 타입스크립트에 Context와 Promise를 싸서 드셔보세요

알파카털파카 2023. 9. 5. 22:36
[트러블 슈팅]
Context와 Promise에 타입스크립트를 싸서 드셔보세요

 

 

모달을 모듈화하는 리팩토링을 거치며 있었던 트러블 슈팅에 관한 글을 작성하려 한다. 개발하며 어려움을 겪은 부분과 새로 알게된 내용, CI에서 지속적으로 통과되지 못했던 오류 등을 담았다. 타입스크립트 오류가 생각보다 많아서 배우는 계기가 되었다. 각 파트가 별개의 것이 아니라 긴밀하게 연관되어 있어서 통합적인 이해가 중요했다.

 

🌱 1. useOverlay를 바로 이용하는 대신 useModal을 만든 이유
🌱 2. useModal을 재활용하지 않고 useConfirm을 새로 만든 이유
🌱 3. 백드롭 클릭 시 하위 요소들로 이벤트 전이 방지하기 - stopPropagation
💡 4. 상위에서 전달한 함수가 사용 시점에 타입 오류를 반환하는 이슈 - 옵셔널 파라미터
💡 5. PropsWithChildren으로 Props 타입 지정하기
📌 6. Context API 데이터 흐름 정리
📌 7. Promise 비동기 처리 정리

 

이 글은 이전 포스트 <OverlayProvider 구현하기>의 후속작 개념이기 때문에 이전글의 내용을 바탕으로 작성되었다. 에러를 해결하거나 새로운 기능을 추가하면서 두 포스트 사이에 변화한 코드가 있기도 하다.

 

 

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

[하루한냥] 모달 모듈화 구현 : OverlayProvider와 Context API 우여곡절 끝에 캘린더 CRUD 구현을 마쳤다. 캘린더 페이지에서 일기를 작성하거나 수정할 수 있다. 타임라인 페이지에서 달별 일기 목록을

shinjungoh.tistory.com

 

 


 

 

1. useOverlay를 바로 이용하는 대신 useModal을 만든 이유

모달 모듈화 로직의 기반이 되는 OverlayProvider는 Context API를 활용해 구현했다. OverlayContext를 생성하고 useOverlay로 다른 곳에서 context 값을 이용할 수 있다. 모달을 사용하는 곳에서 useOverlay를 바로 호출해도 되지만, useModal을 만들었다. 코드를 비교해보자.

 

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

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

  return context;
};

 

useOverlay에는 타입이 지정되어있지 않다. 

 

// src/lib/hooks/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은 useOverlay를 활용하는 래퍼 함수이며, 인자의 타입과 반환값의 타입이 지정되어 있다. 두 hook의 차이는 모달을 직접 사용하는 페이지에서 느낄 수 있다.

 

// 모달을 사용하는 페이지
export default function WritePostPage() {
  const overlay = useOverlay();
  const modal = useModal();

  const handleDiaryModalOpen = async () => {
    // ⚠️ useOverlay -> 오류
    const responseModal = await overlay<{ text: string }>(<DiaryModal diaryText={diary.text} />, {
      clickOverlayClose: true,
    });

    // ✅ useModal
    const responseModal = await modal<{ text: string }>(<DiaryModal diaryText={diary.text} />, {
      clickOverlayClose: true,
    });

    if (responseModal !== null) {
      setDiary({
        ...diary,
        text: responseModal.text, // <- 💡
      });
    }
  };
  
  // ...
}

 

useOverlay를 사용하면 오류가 발생한다. 글을 작성하는 DiaryModal 컴포넌트에서는 작성한 텍스트를 { text: modalInput }의 형태로 넘겨주고 있는데, useOverlay에는 어떤 타입도 지정되어 있지 않기 때문에 타입이 일치하지 않는 것이다. 

 

// DiaryModal

  const handleSubmit = () => {
    onSubmit?.({ text: modalInput });
    onClose?.();
  };

테스트

 

Provider에서 value로 넘겨주고 있는 openOvelay 함수의 타입은 OverlayOpenFn이다.

 

export type OverlayOpenFn = (children: ReactNode, option?: OverlayOption) => Promise<OverlaySubmitResult> | null;

 

이 타입을 살펴보면, 반환되는 Promise의 타입이 <OverlaySubmitResult> 이거나 혹은 null이다. OverlaySubmitResult의 타입은 unknown으로 지정되어 있다. 이것이 overlay를 사용한 반환 타입 Promise<{ text: string }> | null과 일치하지 않기 때문에 에러가 발생했고, 타입을 명확히 지정하기 위해 useModal을 생성했다.

 

 

 

 

2. useModal을 재활용하지 않고 useConfirm을 새로 만든 이유

타입을 정의해 오류를 방지하는 useModal을 만들고, DiaryModal을 호출할 때 사용했다. 한편 확인/취소 기능이 있는 ConfirmModal은 컨펌 모달 컴포넌트를 고정으로 넘겨주는 useConfirm hook을 새로 만들었다. 이전 포스트에는 없는 내용이다. useModal은 이 useConfirm 내에서 활용했다. 

 

ConfirmModal

 

component를 인자로 받는 useModal과는 달리, ConfirmModal 컴포넌트를 기본적으로 넣어주고 있다. Confirm의 종류는 부정적인 경우(저장하지 않고 나가기)와 긍정적인 경우(확인하기)가 있기 때문에 타입을 받고 있다. 모달에 넣어줄 props를 타입으로 묶었다. Confirm의 결과로는 true와 false뿐이기 때문에 반환되는 Promise의 타입에 불리언을 지정했다. 

 

// src/lib/hooks/useConfirm.tsx
import { OverlayOption } from '@ui/components/layout/overlay/OverlayProvider';
import useModal from '@lib/hooks/useModal';
import ConfirmModal, { ConfirmModalType } from '@ui/components/layout/modal/ConfirmModal';

type ConfirmModalProps = {
  modalType: ConfirmModalType;
  title: string;
  description: string;
  onBack: () => void;
};

export default function useConfirm() {
  const modal = useModal();

  const showConfirm = async (
    { modalType, title, description, onBack }: ConfirmModalProps,
    options?: OverlayOption,
  ): Promise<boolean> => {
    const submitResult = await modal<boolean>(
      <ConfirmModal 
        modalType={modalType} 
        title={title}
        description={description} 
        onBack={onBack} 
      />,
      options,
    );
    return submitResult;
  };

  return showConfirm;
}

 

컨펌 모달을 사용하는 페이지의 코드에서 모달을 커스텀할 수 있다. '확인' 버튼을 클릭했을 때 이전 페이지로 이동한다. 모달의 옵션은 객체로 묶어서 넘겨주고 있는데, 현재는 백드롭 클릭 시 모달이 꺼지도록하는 clickOverlayClose 외의 다른 옵션이 없다. 추후 여러가지 옵션이 생기게 된다면 객체로 한번에 넘겨줄 수 있어 편리하다. 

 

// 모달을 사용하는 페이지
const confirm = useConfirm();

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

  if (responseModal) {
    handleNavigateBack();
  }
};

 

confirmModal을 새로 만든 이유는 매번 컴포넌트를 넣어 사용하기 번거롭기 때문에, 범용성을 높인 모듈화를 위해서다. 이렇게 구현하면 모달에 필요한 내용을 props로 전달해서 원하는대로 커스텀할 수 있다. 타입을 지정하고 타입별로 imgae src등을 설정해두면 Confirm 모달의 종류를 여러가지 만들어서 사용할 수 있다. 

 

 

 

 

3. 백드롭 클릭 시 하위 요소들로 이벤트 전파 방지하기 

모바일이나 컴퓨터를 이용하면서 항상 정확한 포인트를 클릭하는 것은 은근히 귀찮고 번거로운 일이다. 모달의 경우도 항상 취소나 X 표시를 눌러야만 창을 닫을 수 있다면 피로감이 생길 수 있다. 이런 부분을 개선하기 위해 백드롭(모달 뒤 레이어의 어두운 부분)을 클릭해도 모달을 끌 수 있도록 하는 기능을 구현했다.

 

백드롭과 모달이 있는 페이지

 

백드롭과 모달을 담고 있는 Overlay 컴포넌트의 코드다. 백드롭 위에 모달을 얹었다. z-index를 주목해보자. 분명 하위 요소에 z-index를 더 높게 주어 띄워놓았는데, 모달을 클릭해도 BackDrop에 준 콘솔이 찍히고 있다. 

 

// Overlay.tsx
export default function Overlay({ onClose, onClickOverlayClose, children }: Props) {
  const handleBackDropClick = () => {
    console.log('backdrop'); // <- 콘솔
    if (onClickOverlayClose) {
      onClose();
    }
  };
 
  return (
    <BackDrop onClick={handleBackDropClick}> // <- 함수 전달
      <OverlayContainer>
        <>{children}</> // <- ⚠️ 모달을 클릭해도 콘솔이 찍힌다
      </OverlayContainer>
    </BackDrop>
  );
}

const BackDrop = styled.div`
  // ...
  z-index: 9; 
`;

const OverlayContainer = styled.div`
  // ...
  z-index: 100;
`;

 

이벤트가 하위로 전파되는 것을 방지하기 위해 stopPropagation 메소드를 사용했다. 이벤트는 상위에서 하위 요소로 전파되는 캡처링 단계를 거쳐, 정확한 실제 타겟에 도달하는 타겟 단계가 있고, 다시 하위에서 상위로 전파되는 버블링 단계가 있다. 이렇게 이벤트가 전파되는 것을 막기 위한 메소드다. 영단어가 생소해서 사전을 찾아봤더니 정치 이념 등의 용어로 사용되는 '프로파간다(Propaganda)'와 뿌리를 같이하는 단어다. 전파, 번식 등의 의미를 갖고 있다. 단어를 알고 있으면 메소드가 하는 역할도 금방 이해할 수 있다.

 

export default function Overlay({ onClose, onClickOverlayClose, children }: Props) {
  //... 

  const handleEventStopCapturing = (e: React.MouseEvent) => {
    e.stopPropagation();
  };

  return (
    <BackDrop onClick={handleBackDropClick}>
      <OverlayContainer onClick={handleEventStopCapturing}> // <- 추가
        <>{children}</>
      </OverlayContainer>
    </BackDrop>
  );
}

 

 

 

 

4. 상위에서 전달한 함수가 사용 시점에 타입 오류를 반환하는 이슈

가장 상위의 OverlayProvider 컴포넌트에서 작성해 하위 컴포넌트로 전달한 onClose, onSubmit 함수가, useModal hook을 사용해 모달을 불러오는 시점에서 오류가 발생했다. 이 함수들은 이미 Context를 통해 하위로 전달되었다. 그렇지만 Props 타입에 이 함수들의 타입이 명시되어 있기 때문에, 컴포넌트를 호출하는 시점에 해당 속성을 넣어주지 않아서 발생하는 오류다. 프로젝트의 기능은 정상적으로 동작하는 상태다. 

 

깃허브 액션 CI 오류
IDE 메시지
테스트 fail

 

이 문제는 ? (옵셔널) 처리를 해서 해결했다. Props 타입에서 옵셔널 파라미터로 만들었다. 옵셔널 체이닝을 사용할 때는 ?. 라고 표기하는데, 이는 null과 undefined만 체크하기 때문에 if문을 줄여서 사용할 수 있다.

 

// DiaryModal.tsx
type ModalProps = {
  diaryText: string;
  onClose?: () => void;
  onSubmit?: (result: unknown) => void;
};

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 handleClose = () => {
    onClose?.();
  };

  const handleSubmit = () => {
    onSubmit?.({ text: modalInput });
    onClose?.();
  };

  return (
    // ...
  )   
}

 

if문을 사용했다면 코드가 길어졌을 것이다. 

 

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

 

 

 

 

5. PropsWithChildren으로 Props 타입 지정하기 

PropsWithChildren은 React 18 버전에서 새로 추가된 타입이다. 이 타입의 사용법을 제대로 숙지하지 못한 채 이용해서 오류가 발생했다.

 

type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };

 

깃허브 액션 CI 오류

 

기존에 작성했던 코드는 이렇다. 

 

type OverlayProps = {
  onClose: () => void;
  onClickOverlayClose: boolean;
  children: PropsWithChildren;
};

export default function Overlay({ onClose, onClickOverlayClose, children }: OverlayProps) {
  // ...

 

아래는 수정한 코드다. PropsWithChildren은 children과 다른 props를 함께 포함하는 타입이기 때문에 제네릭을 이용해 나머지 props의 타입을 넣어주면 된다. 

 

type OverlayProps = {
  onClose: () => void;
  onClickOverlayClose: boolean;
};

type Props = PropsWithChildren<OverlayProps>;

export default function Overlay({ onClose, onClickOverlayClose, children }: Props) {
  // ...

 

 

 

 

6. Context API 데이터 흐름 정리

데이터 흐름을 머릿속으로 생각하려니 복잡해서 순서를 나눠 적어보았다. 컨텍스트 값이 변경되면 컨텍스트를 구독하는 컴포넌트가 자동으로 업데이트 되기 때문에 편리하다. 

 

1. createContext로 OverlayContext 생성

2. OverlayContext.Provider를 통해 Overlay -> {children} 로 value 전달

3. useContext를 이용한 useOverlay 생성 : context의 값이 변경될 때 context를 구독하는 모든 컴포넌트에서 리렌더링 발생

4-1. useOverlay를 사용하는 useModal 래퍼 함수 생성

4-2. useModal을 사용하는 useConfirm hook 생성

5. ⭐️ 모달을 이용하는 페이지에서 useModal/useConfirm을 이용해 모달 컴포넌트 호출 (<DiaryModal />, < ConfirmModal />)

6. 컴포넌트와 옵션을 인자로 전달하면 openOverlay 함수가 받아서 상태를 업데이트하고, <Overlay />에 전달

7. 컨텍스트에 의해 모달 컴포넌트로 전달된 onClose, onSubmit 함수를 이용해서 모달에서 필요한 작업 수행 (글 작성 또는 버튼 클릭 등)

8. ⭐️ 모달을 열면서 생성된 Promise가 모달에서 값을 제출하면서 종료됨

9-1. 모달을 호출하면서 선언한 객체에, 반환된 텍스트 값을 담고, setDiary 함수로 해당 텍스트를 일기 목록에 추가

9-2.  모달을 호출하면서 선언한 객체에, 반환된 버튼 클릭 값을 담고, 뒤로가기 처리 

 

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

 

5번의 로직을 구현하기 위해 Context API를 사용한 것이다. 기존의 useState를 사용하면 모달을 제어하는 주체가 페이지 내에 종속되기 때문에 좋지 않다. 이제 useModal을 이용해 모달을 어디서든 쉽게 불러올 수 있다. 

 

const modal = useModal();

 

 

 

 

7. Promise 비동기 처리 정리

컨텍스트의 흐름을 살펴보면서 어떻게 동작하는지 알아보았다. 구현하면서 헷갈리거나 어렵게 느껴진 내용이 대부분 Promise와 관련있다는 것을 깨닫게 됐다. 그래서 OverlayProvider 컴포넌트의 코드를 자세히 살펴보고 프로미스의 동작 원리를 정리해보려 한다. 

 

더보기는 전체 코드이다. Promise와 관계된 코드만 ✅ 체크 표시를 했다. 전체 코드를 보고 부분적으로 살펴볼 것이다. 

 

더보기
// OverlayProvider.tsx
export type OverlaySubmitResult = unknown;

export type OverlayOption = {
  clickOverlayClose?: boolean;
};

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

export type OverlayOpenFn = (children: ReactNode, option?: OverlayOption) => Promise<OverlaySubmitResult> | null;

export const OverlayContext = createContext<OverlayOpenFn | null>(null);

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));
        console.log(resolver);
      }); // 모달이 켜졌을 때 여기까지 진행된 상태
    }

    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}>
      {children}
      {overlay && (
        <Overlay onClose={handleCloseOverlay} onClickOverlayClose={overlay?.options?.clickOverlayClose || false}>
          <>
            {isValidElement(overlay.content) &&
              cloneElement(overlay.content as ReactElement, {
                onClose: handleCloseOverlay,
                onSubmit: handleSubmitOverlay,
              })}
          </>
        </Overlay>
      )}
    </OverlayContext.Provider>
  );
};

 

Overlay의 상태에 resolver를 정의했다. 이 값이 있을 수도, 없을 수도 있기 때문에 옵셔널하게 처리했다. 

 

export type OverlaySubmitResult = unknown;

export type OverlayOption = {
  clickOverlayClose?: boolean;
};

export type OverlayOpenFn = (children: ReactNode, option?: OverlayOption) => Promise<OverlaySubmitResult> | null;

export const OverlayContext = createContext<OverlayOpenFn | null>(null);

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

 

모달을 호출하는 버튼을 클릭하면, 모달 컴포넌트와 옵션을 인자로 받은 openOverlay 함수가 실행되며 모달이 열린다. 이때 콘솔에 '열기' 찍힌다. 인자로 넘어온 children이 리액트 엘리먼트일 경우, overlay 상태값이 업데이트 되며 프로미스 객체가 생성된다.

 

공을 주고 받는다고 생각해보면, 한 쪽이 던진 공이 아직 날아가고 있는 상태이다. 이때 콘솔에 resolver를 찍어보면 아직 제대로된 반환값이 없음을 확인할 수 있다.

 

Promise는 pending 상태를 거쳐 성공 시 resolve와 실패 시 reject로 나뉜다. 지금은 실패했을 때의 로직은 따로 처리하지 않았다. 

 

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));
        console.log(resolver); // <- ⚠️ 콘솔에 값이 제대로 찍히지 않음
      }); // 모달이 켜졌을 때 여기까지 진행된 상태
    }

    return null;
  }, []);

// ...

console.log(resolver)의 결과

 

submit 버튼을 누르면 사용자의 응답이 제출된다. resolver()를 호출해서 비동기 작업을 실행하며, 작업이 끝났음을 알린다. result는 unknown 타입이며, 결과에 따라 타입이 정해진다. resolver를 실행하므로써 Promise 비동기 작업이 마치게 된다. new Promise로 새로운 프로미스 객체를 생성하면서 던진 공을 리졸버를 호출하면서 받았다. 

 

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

 

모달을 사용하는 페이지에서는 resolver를 실행하면서 넘겨준 값(성공 시의 값)이 isModal에 담기게 된다. 이 값을 가지고 상태를 업데이트 시켜주면 된다.

 

// 모달을 사용하는 페이지
const handleDiaryModalOpen = async () => {
  const isModal = await modal<{ text: string }>(<DiaryModal diaryText={diary.text} />, {
    clickOverlayClose: true,
  });
  // resolver를 실행하면서 넘겨준 값이 담김
  
  if (isModal !== null) {
    setDiary({
      ...diary,
      text: isModal.text,
    });
  }
};

 

 

 

 

마치며

담고싶었던 내용을 간추렸다고 생각했는는데 예상보다 긴 글이 되었다. 그래도 이번 기회에 컨텍스트 api와 프로미스에 대해 실전 학습을 했다. 코드를 짤 때는 이해했다고 생각하면서 작성했어도, 막상 줄글로 풀어서 적으려면 논리적인 설명과 근거를 정리해야 하기 때문에 머리에 들어오는 내용이 풍성해진다. 블로그에 정리해두는 습관 덕분에 발전하는 느낌이다. 코드를 계속 보다보면 보완해야 할 부분이 계속 보이겠지만, 지금 시점에서 내가 배운 내용을 기록해놓는 것도 중요하다.