개발새발 로그

너희 Modal은 어떻게 만들어? 본문

React

너희 Modal은 어떻게 만들어?

이즈흐 2024. 3. 31. 14:24

😁Modal 어떻게 만드니?

Modal은 정말 많이 쓰이는 기능이다.

 

보통 다들 Modal 만들 때 어떻게 할까?

리액트로 Modal을 만든다고 하면 아마 아래와 같은 방법을 사용할 것이다.

const Page = () => {
  const {isOpen, setIsOpen } = useState(false);

  return (
    <div>
      <button onClick={()=>setIsOpen(true)}>모달 열기</button>
      {isOpen && (
        <Modal>
          <div>모달 테스트</div>
        </Modal>
      )}
    </div>
  );
};

이렇게 간단하게 만들 수 있지만 단점이 존재한다.

렌더링 성능면에서 모달의 내용이 복잡하고, 빈번하게 열린다면 성능저하게 올 수있고,

CreatePortal을 사용하지 않아 DOM 계층 구조에서의 위치문제가 생길 수있다.히

 

특히 Modal의 transition이나 애니메이션을 적용할 때 어려울 수 있다.

왜냐하면 isOpen이라는 상태로 나타났다가 사라지므로 특히 Fade Out 상황에서 transition이나 애니메이션이 작동하기 전에 사라진다.

 

🤔그럼 어떻게 만들어?

이제 내가 했던 방식들을 순서대로 설명해보려고한다.

나는 Modal을 구현하면서 다양한 방식을 사용해봤다.

 

먼저 첫번 째 방식이다.

NextJS에서 구현했었던 Modal 방식이다.

const useModal = ({
  initialValue = false,
  width = 100,
  height = 100,
  modalType = "default",
  elementId = "global-modal",
  animate = "grow",
  className
}: useModalParams): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(initialValue);
  const [isUnmount, setIsUnmount] = useState(false);

  const open = useCallback(() => {
    setIsUnmount(false);
    setTimeout(() => {
      setOpen(true);
    }, 300);
    setOpen(true);
  }, [setOpen]);

  const close = useCallback(() => {
    setIsUnmount(true);
    setTimeout(() => {
      setOpen(false);
    }, 300);
  }, [setOpen]);

  const Modal = ({ children }: { children: ReactNode }) => {
    const ModalContent = ({ children }: { children: ReactNode }) => {
      const ref = useClickAway<HTMLDivElement>(() => {
        close();
      });
      return (
        <div
          ref={ref}
          className={cn(
            modalTypeVariants({ modalType }),
            isOpen ? "flex" : "hidden",
            isUnmount ? animateOut[animate] : animateIn[animate],
            elementId ? "z-50" : "",
            className
          )}
          style={{
            width: `${modalType === "fullScreen" ? "100%" : `${width}px`}`,
            height: `${modalType === "fullScreen" ? "100%" : `${height}px`}`
          }}>
          {children}
        </div>
      );
    };

    if (!isOpen) return <></>;

    return createPortal(
      <>
        <div
          className={cn(
            "fixed top-0 left-0 bottom-0 right-0 w-screen h-[100vh] bg-[rgba(0,0,0,0.5)]",
            isOpen ? "block" : "hidden",
            elementId ? "z-50" : ""
          )}
        />
        <ModalContent>{children}</ModalContent>
      </>,
      modalType === "fullScreen"
        ? document.getElementById("global-modal")!
        : document.getElementById(elementId)!
    );
  };

  return { Modal, open, close, isOpen };
};
export default useModal;

코드만 보면 모르니 간단하게 설명하면

Modal의 상태와 Modal 컴포넌트를 useModal이라는 훅에서 한번에 관리하고, 이를 내보내서 사용한다.
애니메이션을 3가지 정도 표현할 수 있고, createPortal도 위치를 지정할 수 있도록 했다.

 

근데 이 코드에는 치명적인 문제가 있다.

Modal 컴포넌트가 내부에서 선언되어있다.

바로 Modal 컴포넌트가 useModal 내에서 선언되고 있다는 점이다.

이로 인해 useModal의 상태가 바뀌면 Modal 컴포넌트가 초기화되고, 

초기화되면서 적용했던 transition이나 애니메이션이 적용이 안되는 상황이 생긴다.

애니메이션을 위해 불필요하게 setTimeout을 적용했다.

초기화되는 문제때문에 내가 적용한 것이 setTimeout이다.

setTimeout을 적용해놓으면 Modal이 초기화되기 전에 애니메이션을 실행하고 종료하게 된다.

 

그리고 가장 큰 문제는

Modal의 on/off 상태의 스타일을 "flex"와 "hidden"으로 적용한 것이다.

이게 왜 문제인지 간단히 설명하면

flex, hidden은 요소 자체를 사라지게 하는 것이다.

분명 Modal을 보이고, 안보이게 하는 것이 편할 수 있지만

Reflow를 일으키는 스타일이다.

즉 Modal이 켜지고 꺼질 때 레이아웃 계산이 다시 수행되어야 한다.

되도록이면 Reflow는 일어나서는 안된다.

 

🤔어떻게 개선할까?

개선할 점을 정리해보자면

1. Modal이 초기화 되는 문제

2. setTimeout을 이용하지않아도 애니메이션이나 transition이 적용되어야 함

3. Reflow를 일으키지 않는 css 이용

 

그래서 나는 두 번째로 아래와 같이 만들었다.

interface ModalProps extends VariantProps<typeof modalTypeVariants> {
  initialValue?: boolean;
  width?: number;
  height?: number;
  elementId?: string;
  modalType?: "default" | "dropBox" | "fullScreen";
  animate?: "grow" | "slide";
  children: ReactNode;
  className?: string;
  isOpen?: boolean;
  close: () => void;
}
const animateIn = {
  grow: "scale-100 mx-auto",
  slide: "fixed translate-x-[calc(100vw - 360px)]"
};
const animateOut = {
  grow: "scale-0 mx-auto",
  slide: "fixed translate-x-[100vw]"
};

const Modal = ({
  children,
  className,
  width = 100,
  height = 100,
  modalType = "default",
  elementId = "global-modal",
  animate = "grow",
  close,
  isOpen = false
}: ModalProps) => {
  const [portalElement, setPortalElement] = useState<Element | null>(null);

  useEffect(() => {
    setPortalElement(document.getElementById("global-modal"));
  }, []);

  if (!portalElement) return null;

  return createPortal(
    <>
      <div
        onClick={close}
        className={cn(
          "fixed top-0 left-0 bottom-0 right-0 w-screen h-[100vh] dark:bg-gray-100/50 bg-black/50 transition-opacity",
          isOpen ? "opacity-100 z-10" : "opacity-0 -z-10"
        )}
      />
      <div
        className={cn(
          modalTypeVariants({ modalType }),
          isOpen
            ? `z-10 ${animateIn[animate]}`
            : `-z-10 ${animateOut[animate]}`,
          className
        )}
        style={{
          width: `${modalType === "fullScreen" ? "100%" : `${width}px`}`,
          height: `${modalType === "fullScreen" ? "100%" : `${height}px`}`
        }}>
        {children}
      </div>
    </>,
    modalType === "fullScreen"
      ? portalElement!
      : document.getElementById(elementId)!
  );
};

export default Modal;
interface UseModalResult {
  open: () => void;
  close: () => void;
  isOpen: boolean;
}

const useModalState = (): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(false);

  const open = () => {
    setOpen(true);
  };

  const close = () => {
    setOpen(false);
  };

  return { open, close, isOpen };
};
export default useModalState;

 

 

코드를 간단히 설명해보자면

일단 코드가 두 개의 파일로 분리되었다.

Modal이 내부에서 초기화되는 문제

Modal 컴포넌트와 useModal로 분리해서 해결했다.

Reflow를 일으키던 문제

기존의 display속성을 변경하던 것을 opacity와 scale로 바꿔서 Modal이 켜져있지 않아도 DOM상에서 존재하도록 했다.

이를 통해서 굳이 setTimeout을 주지않아도 transition이 정상적으로 작동했다.

-> 여기서 나는 Modal이 꺼졌음에도 DOM트리내에 존재하는 문제에 대해 생각하게 됐다. 이후 이 부분에 대해서 정리할 예정 

 

 

 

🥲두 개로 분리하니 생기는 불편함..

Modal과 useModalState로 분리하니 문제는 해결됐지만 Modal을 사용하려면 두 파일을 모두 불러와야 했다.

이 부분이 상당히 불편했다.

서로 의존이 되어있다는 것이 좋지 않았다.

그래서 useModal이라는 틀은 그대로 하되 첫 번째 useModal에서 존재하던 문제들을 해결하려고 했다.

 

 

🤔어떻게 Modal이 초기화되는 문제를 해결할까?

일단 useModal 내부에서 선언이 되면 안되겠다라는 생각을 했다.

그래서 useModal.ts 내에서 전역으로 Modal 컴포넌트를 선언했다.

 

그리고 useModal 틀은 그대로 유지한 다음 useModal을 사용할 때 isOpen과 close를 등록하게 했다.

import { ReactNode, useState } from 'react'
import { createPortal } from 'react-dom'
import { ModalContent, ModalOverlay } from './styled'

interface UseModalResult {
  Modal: ({ children , isOpen, close}:ModalComponentProps) => JSX.Element
  open: () => void
  close: () => void
  isOpen: boolean
}
interface ModalComponentProps {
  children: ReactNode
  isOpen: boolean
  close: () => void
}

const ModalComponent = ({ isOpen, close, children }: ModalComponentProps) => {
  return createPortal(
    <>
      <ModalOverlay
        isOpen={isOpen}
        onClick={close}
      />
      <ModalContent isOpen={isOpen}>{children}</ModalContent>
    </>,
    document.body
  )
}

export const useModal = (initialValue = false) :UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(initialValue)

  const open = () => setOpen(true)
  const close = () => setOpen(false)

  return { Modal: ModalComponent, open, close, isOpen }
}
export default useModal

그래서 이런 코드를 만들었다.

그러면 아래와 같이 사용이 가능하다.

export default function MainPage() {
  const {Modal,isOpen,open,close} = useModal();
  return <section>메인 페이지

    <button onClick={open}>모달오픈</button>
    <Modal isOpen={isOpen} close={close}>
        모달 내용
    </Modal>
  </section>
}

정말 간단하게 사용이 가능했다.

추가로 만약 2개의 Modal을 사용하려고 할 때도 정상적으로 작동했다.\

 

 

근데 여기서 정말 고민되는 부분이 있었다.

현재 Modal이 보이지는 않지만 MainPage내에서 미리 렌더링 되고, open했을 때 나타나고 있다.
즉, Modal을 사용하고 있지 않아도 Modal이 DOM트리내에 존재하고 있고, 리소스 낭비가 예상된다.

그래서 아래와 같이 낭비하지 못하도록 처리해 줄 수 있다.

const ModalComponent = ({ isOpen, close, children }: ModalComponentProps) => {
  if (isOpen === false) {
    return null
  }
  return createPortal(
    <>
      <ModalOverlay
        isOpen={isOpen}
        onClick={close}
      />
      <ModalContent isOpen={isOpen}>{children}</ModalContent>
    </>,
    document.body
  )
}

이렇게 구성하면 아래와 같이 적용했던 transition이 나타나지 않는다.

하지만 이것도 문제가 있었다.

모달을 열거나 닫을 때마다 컴포넌트가 생성되거나 제거되므로 레이아웃 계산과 DOM 조작이 필요하게 된다.
이로 인해 성능 저하가 발생할 수 있다.

그래서 성능 검사를 해보았다.

조건부 렌더링을 사용하지 않았을 때

조건부 렌더링을 사용했을 때

 

이를 통해서 아래와 같이 결론을 도출할 수 있었다.

 

애플리케이션의 특정 요구 사항에 따라 적절한 방식을 선택해야 한다.
예를 들어, 메모리 사용량을 최소화하고 초기 로드 성능을 최적화하는 것이 중요하다면 조건부 렌더링 방식이 더 적합할 수 있다.
반면, 사용자 인터페이스의 반응 속도를 최대한 빠르게 하고 레이아웃 재계산을 방지하고 싶다면 모달을 미리 렌더링 해두는 방식을 고려할 수 있다.

 

하지만 개인적으로 아무리 생각해도 Modal을 사용하지 않는데 띄워두는 것은 이상한 것 같았다.

그래서 웹사이트에서는 어떻게 사용하는지 찾아봤는데

"중고나라" 에서는 Modal을 조건부 렌더링으로 하는 것 같았고,

transition이나 애니메이션은 setTimeout을 사용해서 애니메이션을 보여주고 나타나거나 사라지게 하고 있었다.

 

그래서 setTimeout을 추가해서 애니메이션을 넣고, 조건부 렌더링을 사용하려고 한다! 

 

✨조건부 렌더링 + setTimeout 으로 transition 효과 적용하기

먼저 조건부 렌더링을 아래와 같이 붙여준다.

const ModalComponent = ({
  isOpen,
  close,
  children
}: ModalComponentProps) => {
  if (!isOpen) return null

  return createPortal(
    <>
      <ModalOverlay
        isOpen={isAnimating}
        onClick={close}
      />
      <ModalContent isOpen={isAnimating}>{children}</ModalContent>
    </>,
    document.body
  )
}

이제 Modal은 꺼져있을 때는 DOM요소에 나타나지 않는다.

 

그렇지만 이제 transition효과가 적용되지 않은 채로 켜지고 꺼질 것이다.

그럼 어떻게 transtion효과를 적용하는 걸까?

 

조건부 렌더링으로 인한 transition 효과 미적용

말 그대로 조건부 렌더링을 하게 되면 transition이 적용이안된다.

 

transition은 예를 들어서 opacity : 0 에서 opacity : 1 로 되는 순서가 있어야하는데

조건부렌더링을 하게되면 opacity : 0 에서 opacity : 1 이 아닌 

요소가 없는 상태에서 -> opacity : 1 로 되기 때문이다.

 

그러면 어떻게 적용해야할까?

 

나같은 경우 setTimeout을 이용했다.

먼저 Modal이 열리고 닫히는 상태 isOpen

transtion을 보여주기 위해 Modal이 열리고 실제 모달을 보여주고,

Modal을 닫기전에 실제 모달을 사라지게하고 Modal을 닫는 isAnimating 상태, 두 가지가 필요하다.

 

그리고 모달이 열리고 닫히는 로직을 아래와 같이 적용해주면 된다.

export const useModal = (initialValue = false): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(initialValue)
  const [isAnimating, setAnimating] = useState(false)

  const open = () => {
    setOpen(true)
    setTimeout(() => {
      setAnimating(true)
    }, 0)
  }

  const close = () => {
    setAnimating(false)
    setTimeout(() => {
      setOpen(false)
    }, 1000)
  }

  return { Modal: ModalComponent, open, close, isOpen, isAnimating }
}
export default useModal

이렇게 하면 실제로 Modal이 열리거나 닫히기 전에 먼저 transition효과를 보여주고 Modal 요소를 없애는 것이다.

 

 

 

근데 이렇게만 구성하게되면 문제가 생긴다.

gif에서 보이듯이 Modal을 열고 닫을 때 1초가 setTimeout이 되어있는데 open을 하면 Modal이 꼬여버린다.

 

 

 

그래서 아래와 같이 setTimeout의 id를 저장해놓고 close 후에 open을 바로 하게 되면 close했을 때의 setTimeout을 없애주면 어떨까? 생각했다.

  const openTimeoutId = useRef<number | null>(null)
  const closeTimeoutId = useRef<number | null>(null)

  const open = () => {
    if (closeTimeoutId.current) {
      clearTimeout(closeTimeoutId.current)
      closeTimeoutId.current = null
      return
    }
    setOpen(true)
    const id = setTimeout(() => {
      setAnimating(true)
    }, 0)
    openTimeoutId.current = id
  }

  const close = () => {
    if (openTimeoutId.current) {
      clearTimeout(openTimeoutId.current)
      openTimeoutId.current = null
      return
    }
    setAnimating(false)
    const id = setTimeout(() => {
      setOpen(false)
    }, 1000)
    closeTimeoutId.current = id
  }

 

하지만 위 코드처럼 구성해도 modal 열기와 닫기를 연타(?)하게 되면 Modal 상태가 꼬여버린다.

그래서 위 방법은 적절하지 못하다고 판단했고, setTimeout이라는 비동기함수를 사용하니까

Loading을 만들어주자! 라고 판단했다.

 

 

최종적으로 코드는 아래와 같이 구성했다.

export const useModal = (initialValue = false): UseModalResult => {
  const [isOpen, setOpen] = useState<boolean>(initialValue)
  const [isAnimating, setAnimating] = useState(false)

  const isLoading = useRef<boolean>(false)

  const open = () => {
    if (isLoading.current) return

    setOpen(true)
    isLoading.current = true
    setTimeout(() => {
      setAnimating(true)
      isLoading.current = false
    }, 0)
  }

  const close = () => {
    if (isLoading.current) return

    setAnimating(false)
    isLoading.current = true
    setTimeout(() => {
      setOpen(false)
      isLoading.current = false
    }, 1000)
  }

  return { Modal: ModalComponent, open, close, isOpen, isAnimating }
}
export default useModal

최종적으로 모달을 연타해도 상태가 꼬이지 않게 되었다.

 

🎉결과 (그래도 해소되지않은 고민..)

최종 코드에서는 useModal의 틀을 그대로 사용하고,

조건부 렌더링을 추가해서 리소스 낭비를 최소화하면서

setTimeout을 추가해 transition효과도 나타낼 수 있도록 했다.

 

코드를 수정하면서 조건부 렌더링으로 인해 Layout 단계가 발생하긴 하지만 Modal 내용이 계속 화면에 존재하는 것보단 낫다고 판단했다.

그렇지만 이 두 부분(조건부 렌더링과 setTimeout을 사용하는 것과 미리 모달이 띄워지도록 하는 것)을 계속 고민하게 됐다.

일단은 현재 많은 사이트들이 모달은 클릭했을 때 띄워지는 기능을 하고 있으므로 최종적으로 조건부렌더링을 사용했지만
어차피 모달 내부의 내용은 정적인 경우가 많으니 괜찮지 않을까? 라는 생각을 하게 됐다.

또한 정적이라고 해도 15번 게시글을 클릭했을 때  클릭한 이후에 데이터가 생기는 거니까 괜찮지 않을까 생각했다.

아무튼 이 부분에 대해서는 계속 고민할 예정이다!

 

 


 

 

하지만 이렇게 끝을 내지 않고 어떤 방법이 가장 최적화된 방법인지 찾으려고 했다.

아래 context를 이용해서 다중모달을 관리하는 방법도 한번 만들어 봤다.

 

 

+ context를 통해 Modal 관리

Modal을 어떻게 관리해야 할까 고민하다가 ContextAPI를 이용하면 어떨까 생각이 들었다.

Context로 모달의 콘텐츠와 상태를 관리하고 보여주는 것이다.

import { createContext, useContext, useState, ReactNode } from 'react';
import ModalComponent from './ModalComponent';

interface ModalProps {
  isOpen: boolean;
  content?: ReactNode;
}

interface ModalContextType {
  openModal: (content: ReactNode) => void;
  closeModal: () => void;
}

const ModalContext = createContext<ModalContextType | undefined>(undefined);

const ModalProvider = ({ children }:{children:ReactNode}) => {
  const [modalProps, setModalProps] = useState<ModalProps>({ isOpen: false });

  const openModal = (content: ReactNode) => {
    setModalProps({ isOpen: true, content });
  };

  const closeModal = () => {
    setModalProps({ isOpen: false });
  };

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      {children} 
        <ModalComponent isOpen={modalProps.isOpen} close={closeModal}>
          {modalProps.content}
        </ModalComponent>
    </ModalContext.Provider>
  );
};


const useModal = (): ModalContextType => {
  const context = useContext(ModalContext);
  if (context === undefined) {
    throw new Error('useModal은 ModalProvider 내에 있어야합니다');
  }
  return context;
};

export { ModalProvider, useModal };

일단 조건부 렌더링을 넣어주지 않았다. (transition 효과를 보기 위해)

 

 

그리고 아래와 같이 Provider로 감싸준다.

import { ModalProvider } from './hooks/useModal/ModalContext'

function App() {
  return (
    <>
        <ModalProvider>
              <RouterProvider router={router} />
        </ModalProvider>
    </>
  )
}

 

 

이후 아래와 같이 사용가능하다.

import { useModal } from '~/hooks/useModal/ModalContext'

export default function MainPage() {
  const { openModal } = useModal()
  return (
    <section>
      메인 페이지
      <button
        style={{ color: 'black' }}
        onClick={() =>
          openModal(
            <div>
              첫 번째 모달 내용입니다
              <button
                style={{ color: 'black' }}
                onClick={() => openModal(<div>두 번째 모달 내용입니다</div>)}>
                모달 열기
              </button>
            </div>
          )
        }>
        모달 열기
      </button>
    </section>
  )
}

중첩 모달로 구성해봤다.

 

 

그럼 아래와 같이 실행되는데 보시다시피 문제가 있다.

중첩된 두번째 모달이 첫번째 모달에서 내용 부분만 바껴서 두 번째 모달을 닫았을 때 첫번째 모달로 돌아갈 수 없다는 것이었다.

 

 

🤔context 모달관리에서 다중 모달을 관리하려면?

그럼 context를 사용했을 때 다중 모달을 관리하려면 어떻게 해야할까?

Modal을 배열에 넣으면서 stack처럼 관리하는 방법이 있었다!

Modal을 생성할 때 배열에 넣듯이 생성하고,

삭제할 때는 이전에 넣어줬던 key값으로 제거하거나 pop()으로 제거하는 것이었다.

 

바로 코드로 보면 알기 쉽다.

import { createContext, useContext, useState, ReactNode, Fragment } from 'react'
import ModalComponent from './ModalComponent'
import _ from 'lodash'

interface ModalInstance {
  key: string
  component: ReactNode
}

interface ModalContextType {
  pushModal: (modalInstance: ModalInstance) => void
  popModal: () => void
  removeModal: (key: string) => void
  clearModals: () => void
  updateModal: (key: string, component: ReactNode) => void
}

const ModalContext = createContext<ModalContextType | undefined>(undefined)

const ModalProvider = ({ children }: { children: ReactNode }) => {
  const [modalStack, setModalStack] = useState<ModalInstance[]>([])

  const pushModal = (modalInstance: ModalInstance) => {
    setModalStack(currentStack => [...currentStack, modalInstance])
  }

  const popModal = () => {
    setModalStack(currentStack => currentStack.slice(0, -1))
  }

  const removeModal = (key: string) => {
    setModalStack(currentStack =>
      currentStack.filter(modal => modal.key !== key)
    )
  }

  const clearModals = () => {
    setModalStack([])
  }

  //lodash를 사용했다.
  const updateModal = _.curry((key: string, component: ReactNode) => {
    setModalStack(currentStack =>
      currentStack.map(modal => {
        if (modal.key === key) {
          return { ...modal, component }
        }
        return modal
      })
    )
  })

  return (
    <ModalContext.Provider
      value={{ pushModal, popModal, removeModal, clearModals, updateModal }}>
      {children}
      {modalStack.map(modal => (
        <Fragment key={modal.key}>
          <ModalComponent
            isOpen={true}
            close={() => removeModal(modal.key)}>
            {modal.component}
          </ModalComponent>
        </Fragment>
      ))}
    </ModalContext.Provider>
  )
}

const useModal = (): ModalContextType => {
  const context = useContext(ModalContext)
  if (context === undefined) {
    throw new Error('useModal은 ModalProvider 내에 있어야합니다')
  }
  return context
}

export { ModalProvider, useModal }

그리고 아래와 같이 사용하면 된다.

import { useModal } from '~/hooks/useModal/ModalContext'

export default function MainPage() {
  const { pushModal } = useModal()

  const showModal1 = () => {
    pushModal({
      key: 'uniqueKey1', // 모달을 구별할 수 있는 고유 키
      component: ( // 모달의 내용
        <div>
          <div>첫 번째모달 내용입니다</div>
          <button onClick={showModal2}>모달 보이기</button>
        </div>
      ) 
    })
  }
  const showModal2 = () => {
    pushModal({
      key: 'uniqueKey2',
      component: <div>두 번째 모달 내용입니다</div>
    })
  }

  return (
    <div>
      <button onClick={showModal1}>모달 보이기 </button>
    </div>
  )
}

기능도 아래와 같이 여러가지 사용할 수 있다.

// 모달 제거
const { removeModal } = useModal();
removeModal('uniqueKey');

//모달 업데이트
const { updateModal } = useModal();
updateModal('uniqueKey', <div>새로운 모달 내용입니다</div>);

// 모든 모달 제거
const { clearModals } = useModal();
clearModals();

 

그럼 아래 영상처럼 가능하다

하지만 transition은 적용되지않는다

배열을 map으로 처리하고 있기 때문이다.

Layout도 당연히 발생한다.

 

어떤 방법을 쓸지는 정말 필요한 상황이 어떻냐 에 따라서 나뉠 것 같았다.

 

🤔그럼 어떤 방식을 사용해야할까?

ContextAPI를 사용하지 않는 방법을 쓴다면...

Context API를 사용하지 않고 쓴 위에서의 방법은 MainPage가 렌더링 될 때 MainPage 전체가 리-렌더링 된다.

import { useState } from 'react'
import useModal from '~/hooks/useModal'

export default function MainPage() {
  const { Modal, isOpen, open, close } = useModal()
  const {
    Modal: FModal,
    isOpen: fIsOpen,
    open: fOpen,
    close: fClose
  } = useModal()
  const [count, setCount] = useState(0)
  return (
    <section>
      <div>{count}</div>
      <button onClick={() => setCount(prev => prev + 1)}>증가!</button>
      메인 페이지
      <button onClick={fOpen}>모달오픈</button>
      <FModal
        isOpen={fIsOpen}
        close={fClose}>
        하하
        {count}
        <button onClick={() => setCount(prev => prev + 1)}>증가!</button>
        <button onClick={open}>모달오픈</button>
      </FModal>
      <Modal
        isOpen={isOpen}
        close={close}>
        gggg
        {count}
      </Modal>
    </section>
  )
}

그래서 아래와 같이 state가 바뀌면 Modal도 리-렌더링이 되어 state가 반영된다.

ContextAPI를 사용한다면...

만약 ContextAPI를 사용한다면 state부분만 리-렌더링이된다.

import { useState } from 'react'
import { useModal } from '~/hooks/useModal/ModalContext'

export default function MainPage() {
  const { pushModal } = useModal()
  const [count, setCount] = useState(0)
  const showModal1 = () => {
    pushModal({
      key: 'uniqueKey1',
      component: (
        <div>
          <div>첫 번째모달 내용입니다</div>
          {count}
          <button onClick={() => setCount(prev => prev + 1)}>증가!</button>
          <button onClick={showModal2}>모달 보이기</button>
        </div>
      )
    })
  }
  const showModal2 = () => {
    pushModal({
      key: 'uniqueKey2',
      component: <div>두 번째 모달 내용입니다</div>
    })
  }

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(prev => prev + 1)}>증가!</button>
      <div>
        <button onClick={showModal1}>모달 보이기 </button>
      </div>
    </div>
  )
}

하지만 모달을 열어서 확인하면 해당 state값이 Modal내에서는 바뀌지않는다.

 

이렇게 context로도 Modal을 관리해봤다.

 

이 방법은 간단하게 보면 Modal이 context에서 관리되므로 Modal을 사용하는 컴포넌트 내에서 리-렌더링이 일어나도 영향을 받지 않는 장점이 있었다.

사실 모달은 켜져있을 때 리-렌더링이 일어나는 것은 괜찮지만 꺼져있는데도 리-렌더링이 되는 것은 좋지 않다고 생각됐다.

 

그렇지만 현재 최종적으로는 Context를 사용하지않고 구현한 useModal은 조건부 렌더링을 사용하고 있기때문에 이 부분에서는 괜찮다고 판단했다.

 

결과적으로는 context를 사용하는 것도 좋지만 코드가 간결하고 사용하는 방법 또한 간단한 context를 제외한 useModal 방법이 가장 낫다고 판단했다.

 

그래서 정말 어떤 방법을 써야할지는 서비스에서 어떤 작업이 더 많은지 모달이 어떤 용도로 쓰이는지 확인하고 써야할 것 같았다.

 

+ 추가 성능 최적화

Opacity를 사용하면 Reflow가 안일어난다고...? 진짜일까?

나는 opacity를 사용하면 Reflow가 안생긴다고 알고있었다.

근데 Opacity를 쓸 때 알고 쓰지 않는다면 Reflow가 생길 수 있다는 것을 알 게 되었다.

 


먼저 내가 지정한 스타일을 보자
styled-component로 아래와 같이 구성했다.

import styled from '@emotion/styled'

interface ModalOverlayAndContentProps {
  isOpen: boolean
}

export const ModalOverlay = styled.div<ModalOverlayAndContentProps>`
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 40;
  transition: opacity 1s ease;
  opacity: ${props => (props.isOpen ? 1 : 0)}; // opacity를 1로 지정해줬다.
`

export const ModalContent = styled.div<ModalOverlayAndContentProps>`
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  width: 70%;
  height: 70%;
  margin: auto;
  border-radius: 0.5rem;
  background-color: white;
  z-index: 40;
  color: black;
  overflow: scroll;
  -ms-overflow-style: none;
  scrollbar-width: none;
  transform: translateY(${props => (props.isOpen ? 0 : '100vh')});
  transition: transform 1s ease;
  &::-webkit-scrollbar {
    display: none;
  }
`


근데 여기서 opacity 값이 1미만이 아니라면 Reflow, 즉 Layout이 발생한다는 것이다.
그래서 비교를 해보았다.

단 아래 상황은 조건부렌더링이 없는 상황이다.

opacity : 1 일 때

opacity : 0.99일 때


위 처럼 단지 opacity를 1로 설정함으로 인해 layout단계가 실행되는 것이었다.

이게 왜 그런지 알아보았다.

일단 최신 브라우저에서는 웹 화면을 한 장의 비트맵처럼 처리하지 않고, 여러 레이어를 나누어서 처리한다.
이 과정에서 필요한 것이 Update Layer Tree Composite이다.


Update Layer Tree과정에서는 Render Tree의 결과물 중에서 렌더링에 사용되는 부분을 레이어로 만들어 주는 과정
이때 Paint  Layer Graphics Layer로 나누어서 레이어를 생성하게 된다.

실제로 크롬 개발자 도구의 Performance 탭에서 아래와 같이 Update Layer Tree를 확인할 수 있다.

현재 구글 크롬에서 Update Layer Tree가 안보이는 현상 찾는 중



그리고 이렇게 만들어진 레이어들을 합성하는 것이 Composite이다.

Paint Layer

CRP 과정에서 생성되는 Render Tree를 이용해서 화면에 표횐될 레이어를 생성
각 DOM 요소인 Layout Object들은 화면에 보다 효율적으로 요소들을 뿌려주기 위해서 레이어형태로 구성될 수 있는데 이것이 바로 Paint Layer 이다.

일반적으로 동일한 좌표공간을 가지는 요소들은 같은 Paint Layer에 위치한다.
하지만 필요에 따라 몇가지 특수 조건을 만족하면 추가적인 Layer를 생성하여 별도의 공간에 요소들을 그릴 준비를 하게 된다.

그리고 그렇게 만들어진 Paint Layer가 CRP의 마지막 과정인 Composite에서 합쳐져서 우리 눈에 보여지게 된다.

아래는 Paint Layer가 생성되는 대표적인 조건들이다.
- Root Element인 경우 (Root Element에 대해서는 Root Layer가 생성)
- 명시적인 position 속성값을 가지고 있는 경우 (relative, fixed, sticky, absolute)
- 투명도가 1 미만인 경우
- filter, mask, transform, mix-blend-mode를 가진 경우
- backface-visibility attribute가 hidden인 경우
- auto가 아닌 column-count, column-width 속성을 가진 경우
- reflection 속성을 가진 경우
- 브라우저가 내부적으로 생성하는 경우


추가로 Graphics Layer도 있는데 이 부분은 생략하겠다.

 

아무튼 아래와 같은 이미지의 형태로 Layer가 쌓인다고 한다.

그래서 Paint Layer 또한 신경써서 쌓아야 보다 효율적으로 Layout Object들을 뿌려줄 수 있다.

 

내가 만든 Modal 같은 경우에는 조건부렌더링을 했기 때문에 어쩔 수 없이 Layout 단계가 일어난다..!

 

 

 

https://velog.io/@leitmotif/%EB%86%93%EC%B9%98%EA%B8%B0-%EC%89%AC%EC%9A%B4-reflow-repaint

 

놓치기 쉬운 Reflow, Repaint

밥 아저씨는 맨날 쉽다고 하셨어

velog.io

https://ssocoit.tistory.com/259#0.1._Graphics_Layer

 

[CSS] 브라우저의 Layer Model과 하드웨어 가속을 이용한 렌더링 최적화에 대해 알아보자!

이전 글에 이어서, 이번에는 Layer Model에 대한 복습과 함께 CRP 과정에서 발생할 수 있는 하드웨어(GPU) 가속에 대해 조금 더 자세하게, 그리고 이해하기 쉽게 뜯어보려고 합니다. 사실 지난 포스팅

ssocoit.tistory.com

 

 

 

728x90
반응형
LIST