티스토리 뷰

웹 개발/웹

댓글 모듈 iframe 개발기

곤이씨 2021. 8. 22. 00:26
 

GitHub - woowacourse-teams/2021-darass: 🧩 웹 페이지 어디든 간편하게 추가하는 댓글 모듈 서비스 "다라

🧩 웹 페이지 어디든 간편하게 추가하는 댓글 모듈 서비스 "다라쓰". Contribute to woowacourse-teams/2021-darass development by creating an account on GitHub.

github.com

 

 웹 페이지 어디든 쉽고 간편하게 추가하는 댓글 모듈 서비스 "다라쓰"를 만들면서 겪었던 많은 어려움 중 iframe을 사용하면서 마주했던 이슈들에 대해 회고하는 시간을 가져보고자 한다.

 

 다라쓰에서 iframe을 사용한 이유는 간단하다. 기존 페이지에 영향을 주지 않으면서 우리 서비스를 적용해야하기 때문이다. 

 

 iframe은 inline frame의 약자로 iframe을 이용하면 해당 웹 페이지 안에 어떠한 제한 없이 또 다른 하나의 웹 페이지를 삽입할 수 있다. iframe은 부모 document 안에 새로운 document가 생성된다. 그리고 이 둘은 서로 독립적이다. 부모에 적용된 스타일을 자식은 적용 받지 않는다. 따라서 모듈 서비스처럼 기존 서비스에 영향을 주거나 받지 않아야하는 경우에 사용하기 적합하다.

 

 만약 iframe을 사용하지 않고 부모 요소에 바로 element를 추가하는 식으로 코드를 작성한다면 어떤 일이 일어날까?

 

 바로, 우리가 작성한 스타일이 제대로 그려질 것이라는 보장할 수 없게 된다. 부모 요소에서 우선 순위가 높은 스타일이 우리가 작성한 스타일을 덮어씌울 수도 있다. 무엇보다도 부모 요소에서 어떤 영역에 댓글 모듈이 그려질지 알 수가 없기 때문에 반응형으로 코드를 짤 수가 없다.

 

 예를 들어, 부모 요소의 뷰포트가 1920px이지만 댓글 모듈은 화면의 가운데 1000px에 해당하는 영역으로 그려진다고 가정해보자. 댓글 모듈을 iframe으로 만들지 않으면 댓글 모듈이 실제로 그려지는 영역은 1000px이지만 1920px에 맞는 반응형 화면을 그리게 될 것이다. 따라서 댓글 모듈은 부자연스럽게 크게 그려질 것이고 짤리는 영역이 생길 것이다. 하지만 iframe을 사용하면 iframe이 그려지는 영역이 곧 iframe의 뷰포트 사이즈가 되기 때문에 반응형으로 만들기가 수월해진다.

 

 이제 iframe을 사용하면서 겪었던 문제점과 그것들을 어떻게 해결해나갔는지에 대해 알아보자.

 

1. iframe 높이 동적으로 변경하기

 iframe은 height 속성을 사용하여 iframe의 높이를 설정할 수 있다. 높이를 설정해주지 않으면 컨텐츠의 높이에 맞게 동적으로 높이가 설정될 것 같지만 그렇지 않다. height을 설정해놓지 않는 경우, 크롬 기준 높이는 자동으로 150px로 설정된다. 컨텐츠의 높이가 150px이 넘어가게 되면 스크롤이 등장하게 되고 굉장히 부자연스러워진다. 때문에 iframe의 높이를 컨텐츠의 높이로 일치 시켜줘야한다. 하지만 댓글 모듈처럼 댓글의 추가 혹은 삭제로 인해 컨텐츠의 높이가 계속해서 달라진다면 어떻게 해야할까?

 

 댓글이 0개인 경우가 가장 기본값이라고 판단하여 iframe의 height 을 596px로 고정하면 처음에는 다음과 같이 자연스럽게 iframe이 출력된다.

 

하지만 댓글이 추가되어 컨텐츠의 높이가 변경되면 다음과 같이 스크롤이 생기게 된다. 다라쓰를 블로그에 달고자 하는 사용자의 입장에서 댓글을 아래와 같이 스크롤링으로 확인해야한다면 사용하고 싶지 않을 것이다. 이는 치명적인 결함이다.

 

결국, 컨텐츠의 높이가 변경될 때 마다 iframe의 height도 동적으로 변경되야한다. 이를 위해서는 iframe과 부모 window가 서로 통신을 해야한다. 이를 위해 window.postMessage를 사용했다. 

 

// deploy-script (부모 window = iframe을 사용하는 쪽)
window.addEventListener("message", ({ data: { type, data } }) => {
  if (!type) return;

  if (type === POST_MESSAGE_TYPE.SCROLL_HEIGHT) {
    resizeElementHeight({ element: $replyModuleIframe, height: data });
    return;
  }
});


// reply-module (content window = iframe에 그려지는 페이지)
// 부모 window에 컨텐츠의 높이를 보내는 함수
const postScrollHeightToParentWindow = () => {
  window.parent.postMessage(
    { type: POST_MESSAGE_TYPE.SCROLL_HEIGHT, data: document.querySelector("#root")?.scrollHeight },
    "*"
  );
};

// 반응형으로 html font size가 바뀌었을 때 높이를 iframe에 메세지 형태로 전달
const onResize = () => {
  let throttle: NodeJS.Timeout | null;

  const runThrottle = () => {
    if (!throttle) {
      throttle = setTimeout(() => {
        throttle = null;
        postScrollHeightToParentWindow();
      }, 600);
    }
  };
  return runThrottle;
};

window.addEventListener("resize", onResize());

// 댓글에 따라 iframe 높이 변경
useEffect(() => {
  postScrollHeightToParentWindow();
}, [comments]);

 

 reply module은 iframe에 그려지는 페이지이다. reply module의 높이가 변할 때마다 부모 window에 postMessage로 변경된 높이를 보내고 있다. deploy-script는 부모 window로 iframe을 사용하는 쪽이다. deploy-script는 높이를 변경하는 message 이벤트가 발생할 때 iframe의 height을 변경시킨다. 이를 통해 댓글의 추가나 삭제 그리고 iframe의 뷰포트 변경에 따른 반응형 페이지의 높이도 동적으로 변경할 수 있게 된다.

 

스크롤 없이 댓글 모듈이 정상적으로 나오는 모습

 

 

2. iframe에서 모달 띄우기

 iframe에서 모달을 띄우려면 어떻게 해야할까? iframe에서 일반적인 방법으로 모달창을 띄우게 되면 iframe에 해당하는 영역만 dimmed 처리된다. 또한 모달창이 iframe의 정중앙에는 나올 수 있지만 부모 창의 중앙에는 나오지 않는다. 이는 일반적인 모달과는 상당히 거리가 있어보이고 사용자에게 어색함을 줄 수 있다.

 

 따라서 iframe에서 모달을 정상적으로 띄우기 위해 모달만을 위한 iframe을 별도로 분리하였다. 두 개의 iframe으로 분리한 이유는 뷰포트의 영역을 각기 다르게 주기 위함이다. 댓글 모듈은 width: 100%, height: 동적으로 변경, 모달은 width: 100vw, height: 100vh로 설정하였다. 또한, 모달 iframe은 초기에 display 속성이 none이였다가 이후 모달이 뜨는 상황에 display 속성이 block으로 변경된다.

 

 모달 iframe은 기존 댓글 모듈 코드에서 webpack의 entry point를 index.tsx와 modal.tsx로 나누어 index.html과 modal.html로 분리되도록 하였다. 이로써 댓글 모듈 iframe과 모달 iframe의 각각의 html을 바라볼 수 있게 된다.

 

 

Code Splitting | 웹팩

웹팩은 모듈 번들러입니다. 주요 목적은 브라우저에서 사용할 수 있도록 JavaScript 파일을 번들로 묶는 것이지만, 리소스나 애셋을 변환하고 번들링 또는 패키징할 수도 있습니다.

webpack.kr

 

const config = {
  entry: { replyModule: "./src/index.tsx", modal: "./src/Modal.tsx" },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: `[name]-${Package.version.replace("^", "")}.js`
  },
  ...
  plugins: [
    new HtmlWebpackPlugin({
      filename: "index.html",
      chunks: ["replyModule"],
      template: "./public/index.html"
    }),
    new HtmlWebpackPlugin({
      filename: "modal.html",
      chunks: ["modal"],
      template: "./public/modal.html"
    }),
  ],
  ...
}

module.exports = config;

 

댓글 모듈에서 모달을 띄우기 위한 흐름은 다음과 같다.

 

댓글 모듈 iframe에서 모달을 띄우는 이벤트 발생 -> 부모 window에 모달을 띄우라는 메시지 전송 -> 부모 window에서 모달 iframe에 모달을 띄우라는 메시지 전송 -> 모달 iframe에서 메시지를 받아 모달을 화면에 렌더링

 

모달을 닫는 흐름은 띄우는 것과 반대이다.

 

// reply-module (댓글 모듈 iframe)
// postOpenLikingUsersModal 함수 실행시 부모 window에 모달을 띄워달라는 메세지를 보냄
const postOpenLikingUsersModal = (likingUsers: Comment["likingUsers"]) => {
  window.parent.postMessage({ type: POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL, data: likingUsers }, "*");
};

// deply-script (부모 window)
window.addEventListener("message", ({ data: { type, data } }) => {
    if (!type) return;

	// iframe 컨텐츠 높이 변경시 iframe 높이 변경
    if (type === POST_MESSAGE_TYPE.SCROLL_HEIGHT) {
      resizeElementHeight({ element: $replyModuleIframe, height: data });
      return;
    }

	// 좋아요 모달 열기
    if (type === POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL) {
      $modalIframe.contentWindow.postMessage({ type: POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL, data }, "*");
      showElement($modalIframe);
      return;
    }

	// 모달 닫기
    if (type === POST_MESSAGE_TYPE.CLOSE_MODAL) {
      $replyModuleIframe.contentWindow.postMessage({ type: POST_MESSAGE_TYPE.CLOSE_MODAL }, "*");
      hideElement($modalIframe);
      return;
    }
 	
    // 컨펌 모달 열기
 	if (type === POST_MESSAGE_TYPE.OPEN_CONFIRM) {
      $modalIframe.contentWindow.postMessage({ type: POST_MESSAGE_TYPE.OPEN_CONFIRM, data }, "*");
      showElement($modalIframe);
      return;
    }

	// 컨펌 모달 닫기
    if (type === POST_MESSAGE_TYPE.CLOSE_CONFIRM) {
      $replyModuleIframe.contentWindow.postMessage({ type: POST_MESSAGE_TYPE.CLOSE_CONFIRM }, "*");
      hideElement($modalIframe);
      return;
    }
    ...
  });


// Modal.tsx (모달 iframe)
// 부모 window가 보낸 메시지를 받아 모달을 화면에 렌더링
const isValidMessageType = (type: string) =>
  [POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL, POST_MESSAGE_TYPE.OPEN_CONFIRM].some(_type => _type === type);

const Modal = () => {
  const [data, setData] = useState<{ type: string; data: any }>();

  useEffect(() => {
    window.addEventListener("message", ({ data }: MessageEvent) => {
      if (!isValidMessageType(data.type)) return;

      if (POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL) setData(data);
      if (POST_MESSAGE_TYPE.OPEN_CONFIRM) setData(data);
    });
  }, []);

  if (data?.type === POST_MESSAGE_TYPE.OPEN_LIKING_USERS_MODAL) return <LikingUsersModal users={data.data as User[]} />;
  if (data?.type === POST_MESSAGE_TYPE.OPEN_CONFIRM) return <ConfirmModal message={data.data as string} />;

  return null;
};

ReactDOM.render(
  <>
    <GlobalStyles />
    <Modal />
  </>,
  document.getElementById("root")
);

 

모달이 정상적으로 뜨는 모습