상세 컨텐츠

본문 제목

[React] Portal이란?(w. 모달 구현)

Programming/React

by 겨리! 2024. 2. 2. 19:19

본문

 

 

최근 면접스터디를 준비하면서 리액트의 Portal에 대해 공부한 적이 있었다.

백문이불여일타라고...🧐

설명을 여러번 보는 것보단 직접 구현해 보는 것이 도움이 될 것 같아 직접 시도해 봤다!
이에 대한 과정을 정리해보고자 한다.

 

보통 모달을 생각하면 화면 중앙에 예쁘게 뜨는 창 하나를 떠올릴 수 있을 것이다.

먼저 그런 일반적인 모달을 구현해봤다.

 

모달 컴포넌트 구현하기

// Modal.tsx
...
const ModalContainer = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const StyledModal = styled.div`
  width: 250px;
  background-color: white;
  padding: 20px;
  border-radius: 4px;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 1);
`;

const Modal = ({ visible, children }: IModal) => {
  if (!visible) return null;
  return (
    <ModalContainer>
      <StyledModal>{children}</StyledModal>
    </ModalContainer>
  );
};
export default Modal;

 

모달 컴포넌트 사용하기

// Home.tsx
...
const StyledDiv = styled.div`
  background-color: #a0a0a0;
  width: 400px;
  height: 400px;
`;

const Home = () => {
	...
    <StyledDiv>
      <h3>modal 테스트</h3>
      <Button variant="contained" onClick={onOpen}>
        열기
      </Button>
      <Modal visible={visible}>
        <h1>hello world</h1>
        <h3>it's modal 👻</h3>
        <Button variant="contained" onClick={onClose}>
          닫기
        </Button>
      </Modal>
    </StyledDiv>
};

export default Home;

 

 

결과

위에서 말한 대로 화면 가운데에 예쁘게 떠있는 모달을 확인할 수 있다!

 

하지만 여기서...모달의 상위 엘리먼트인 div 태그에 transform 속성을 하나 추가해 준다면?

// Home.tsx
...
const StyledDiv = styled.div`
  background-color: #a0a0a0;
  width: 400px;
  height: 400px;
  // transform 속성 추가
  transform: translate(30px, 20px) rotate(10deg);
`;

const Home = () => {
	...
    <StyledDiv>
      <h3>modal 테스트</h3>
      <Button variant="contained" onClick={onOpen}>
        열기
      </Button>
      <Modal visible={visible}>
        <h1>hello world</h1>
        <h3>it's modal 👻</h3>
        <Button variant="contained" onClick={onClose}>
          닫기
        </Button>
      </Modal>
    </StyledDiv>
};

export default Home;

 

 

엥? 😮😮

의도한 것과 다르게 스타일이 적용된다.🤨

상위 엘리먼트의 스타일에 영향을 받은 모달

 

 

원인

나는 모달을 구현하기 위해 모달 쪽 div태그에 position 속성을 fixed로 주었다.

하지만 이 경우 의도하지 않은 스타일이 적용이 될 수 있다!

 

엘리먼트의 조상 중 하나가 tansform, perspective, filter 속성 중 하나를 가지고 있다면 뷰 포트 대신 해당 조상을 기준으로 삼게 되기 때문이다.

단, 요소의 조상 중 하나가 transform, perspective, filter 속성 중 어느 하나라도 none이 아니라면 (CSS Transforms 명세 참조) 뷰포트 대신 그 조상을 컨테이닝 블록으로 삼습니다. - MDN Web Docs

 

위 케이스에서 확인할 수 있듯이

CSS는 상속이 된다. 

이로 인해 모달 엘리먼트가 상위 엘리먼트와 함께 사용이 되면 상위 엘리먼트의 스타일에 영향을 받을 수 있다.

 

해결 방법

조상 엘리먼트의 스타일에 영향을 받지 않도록 돔 트리에서 모달의 위치를 옮기면 된다!

즉, 모달이 선언되어 있는 div태그 내부가 아닌(위 사진 자료 중 elements에 선택되어 있는 부분) 외부의 다른 돔에서 렌더링 되도록 처리하면 된다.

 

이를 위해 리액트 포탈을 사용해 보았다.

 

리액트 포탈(Portal)이란?

컴포넌트가 종속되어 있는 돔 트리를 벗어나 외부의 다른 돔으로 렌더하는 기능이다.

이를 이용하기 위해선 createPortal API를 사용해야 한다.

createPortal는 react-dom에서 제공하는 함수이며, 사용방법은 다음과 같다

 

createPortal(children, domNode, key?)

 children : domNode의 childrend이 될 컴포넌트 or jsx 문법으로 작성한 html 태그
domNode : children의 부모가 될 domDone(미리 생성되어 있는 돔 노드 정보를 가지고 있어야 함)
key? : 선택인자로. 포탈의 키로 사용할 고유한 문자열 or 숫자

 

포탈 사용해서 모달 구현하기

index.html 내 root와 같은 레벨에 id가 modal-root인 div 태그 추가

<!-- index.html -->
...
<div id="root"></div>
<div id="modal-root"></div>
...

 

PortalModal 컴포넌트 구현

// PortalModal.tsx
...
import { createPortal } from 'react-dom';
...
const PortalModal = ({ visible, children }: IModal) => {
  // 부모 요소가 될 요소 정보 찾기
  const modalRoot = document.getElementById('modal-root') as HTMLElement;
  if (!visible) return null;
  return createPortal(
    <ModalContainer>
      <StyledModal>{children}</StyledModal>
    </ModalContainer>,
    modalRoot
  );
};

export default PortalModal;

 

적용 방식은 변함이 없기 때문에 생략

 

 

결과

구현한 모달이 root 내부가 아닌 modal-root 내부로 렌더 되는 것을 확인할 수 있었다!

 

 

이벤트 

다른 돔트리로 렌더링이 되는 거면 이벤트는 어떤 식으로 처리가 될까?

이벤트 전파가 불가능하지 않을까?라고 생각했으나 틀린 판단이었다! 🙄

공식문서에서는 아래와 같이 설명하고 있다.

포탈 내부에서 발생한 이벤트는 리액트 트리에 포함된 상위로 전파될 것입니다.
DOM 트리에서는 그 상위가 아니라 하더라도 말입니다. 

 

포탈이 돔트리의 어디에든 존재할 수 있다 하더라도 모든 다른 면에서는 일반적인 리액트 지식처럼 동작한다.

context와 같은 기능은 포탈이건 아니건 상관없이 같이 동작한다.

이렇게 동작하는 이유는 포탈이 돔트리에서의 위치와 관계없이 리액트 트리에 존재하기 때문이다.

  

 

확인해 보기

모달에 버튼을 추가하고 부모 엘리먼트에 이벤트 버블링이 발생하는지 확인해 봤다.

 

버튼과 핸들러 함수 추가

// PortalModal

const PortalModal = ({ visible, children }: IModal) => {
  const modalRoot = document.getElementById('modal-root') as HTMLElement;
  if (!visible) return null;
  return createPortal(
    <ModalContainer>
      <StyledModal>
        {children}
    	<!-- 이 버튼에서의 클릭 이벤트는 부모로 버블링 된다. 
        'onClick'의 속성이 정의되지 않았기 때문!  -->
        <button>다른 버튼</button>
      </StyledModal>
    </ModalContainer>,
    modalRoot
  );
};

export default PortalModal;

 

// Home.tsx

const Home = () => {
	const handleClick = (e: React.MouseEvent<HTMLElement>) => {
    	// 버블링이 발생한다면 로그가 출력되겠지!
		console.log(e.target);
	}; 
    
    return(
        <StyledDiv>
          <h3>modal 테스트</h3>
          ...
        </StyledDiv>
    );
};

export default Home;

 

 

실제화면과 콘솔창

 

modal에 있는 버튼을 클릭했더니 이벤트 버블링 때문에 Home.tsx의 handleClick 함수가 실행된다!

(modal에는 버튼만 만들어뒀지 이벤트 처리는 하지 않았다.)

 

테스트를 통해 엘리먼트가 다른 돔을 사용하더라도 포탈이 어플리케이션 트리 모양대로 이벤트를 전파한다는 것을 확인할 수 있었다.

 

밑의 사진을 보면 리액트 컴포넌트 트리에서는 포탈 모달 컴포넌트는 여전히 홈의 div 내부에 위치한다.

돔트리와 리액트 컴포넌트 트리 비교하기

 

 

결론

리액트 포탈을 사용하면 css 상속 구조에 유연하게 대처할 수 있다.

포탈로 만든 엘리먼트는 실제 돔 구조와 달리 리액트 어플리케이션 컴포넌트 트리 구조를 따르며 이벤트 또한 전파된다.

 

느낀 점

사실 여태껏 포탈을 사용해본 적이 없었다. 😅
가끔씩 프론트엔드 오픈카톡방에서 관련된 질문이 올라올 때 스쳐가듯이 본 적은 있었지만

직접 사용해보지 않았으니..이게 정확히 무슨 기능인지, 어떤 장점이 있어 사용을 하는 건지 알 수 없었다. 

특히 CSS 상속과 같은 문제는 실제로 충분히 경험할 수 있는 케이스라고 생각해서 포탈이라는 기능이 굉장히 실용적이라는 생각이 들었다(물론 다른 방법으로 대처할 수도 있겠지만..!)

학습 차원에서 테스트해보느라 대충 만들어서 아쉬운 감이 없지 않아 있는데..

나중에 모달이나 툴팁을 구현할 일이 생긴다면 포탈을 이용해서 구현해 봐야겠다!

 


Reference

 

https://react.dev/reference/react-dom/createPortal#reference

 

createPortal – React

The library for web and native user interfaces

react.dev

 

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

 

Portals – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

 

https://jeonghwan-kim.github.io/2022/06/02/react-portal

 

리액트 Portal

jeonghwan-kim.github.io

 

 

 

관련글 더보기

댓글 영역