iframe 을 사용해서 게시판 뷰어 만들기

jisu·2023년 7월 29일
2

최근 구버전의 사내서비스를 마이그레이션(거의 리빌딩..) 하는 업무를 진행하면서 컴포넌트들을 하나씩 옮기고 있습니다. 그 과정에서 게시판 기능을 하는 공지사항 페이지를 옮기던 중에 겪은 문제와 해결 방안을 기록해보려고 합니다.

목표

마이그레이션을 하기 전에 유관부서 미팅을 진행했는데, 담당자 분이 “실제 관리자 페이지에서 글을 작성할 때 적용한 스타일과, 화면에 표시되는 스타일이 달라요” 라는 말씀을 하셨습니다.

관리자 페이지의 에디터에서 미리보기를 보여줄 때의 css 와 게시판의 css 가 서로 달라서 발생한 문제였습니다. 게시글에 적용되는 스타일은 에디터 스타일과 동일해야 하고, 서비스 전체에 적용된 global 스타일을 무시해야 완전히 동일하게 표시될 수 있습니다.

블로그의 텍스트 에디터

블로그나 게시판 기능을 이용해봤다면, 아래와 같은 에디터 페이지를 본적이 있을 겁니다. 에디터에서는 폰트, 글자 크기, 정렬 등 다양한 옵션으로 글을 작성할 수 있습니다.

이렇게 작성된 글은 html 형식이나 md 파일로 저장됩니다. 저장된 파일에는 에디터에서 적용한 스타일도 같이 저장되어 화면에 표시됩니다. 여기서 지정한 스타일 그대로 화면에 표시 하기 위해서는 몇가지 고려가 필요합니다.

해결 방안

가장 먼저 생각한 방법은 dangerouslysetinnerhtml 로 엘리먼트에 html 을 주입하는 방법이었습니다. 하지만, 일반 엘리먼트에 삽입하게 되면 게시글에 추가한 스타일들이 link 로 추가되고 서비스 전체 범위에 영향을 주게 됩니다. 반대로 글로벌 스타일과 충돌하는 스타일들은 제대로 적용되지 않는 문제가 발생하기도 합니다.

다른 방법으로는 에디터에서 적용하는 classname 과 공지사항 서비스에서 사용하는 스타일은 완전히 다른 이름으로 오버라이딩 되지 않게 하는 것이었습니다. 하지만 이 방법은 양쪽 모두를 바꿔야하고 메인 기능이 아닌 공지사항 스타일 때문에 전체 영역을 수정하는 것은 오버헤드가 크다고 판단했습니다.

그래서 게시물이 외부 스타일에 영향을 받지도 주지도 않고 독립적으로 스타일링 할 수 있는 방법을 생각하다 iframe 을 사용하면 되겠다고 생각했습니다.

삽입된 브라우징 맥락은 각자 자신만의 세션 기록과 문서를 가집니다. 다른 브라우징 맥락을 포함하고 있는 맥락은 "부모 브라우징 맥락"이라고 부릅니다. 부모를 가지지 않는, 즉 최상위 브라우징 맥락은 대개 브라우저 창으로서, Window 객체로 나타냅니다.

iframe 은 부모 컨테이너와 완전히 독립적인 환경을 가집니다. 자기자신의 window 객체를 가지기도 하고 postMessage 메서드를 사용하지 않고는 부모와 완전히 별개의 환경으로 상호작용할 수 없습니다. 그렇기 때문에 부모에 적용된 스타일이 적용되지 않고, iframe 에 추가된 stylesheet 도 부모에게 전파되지 않습니다.

iframe 을 사용한 html viewer 만들기

iframe 에 string 추가하기

iframe 의 src 는 기본적으로 url 형식이어야 합니다. 하지만, 제가 사용하는 콘텐츠는 html 이 string 이기 때문에 src 속성을 사용할 수 없었습니다. string 형식을 iframe 에 사용하기 위해서는 srcdoc 속성을 사용할 수 있습니다.

srcdoc
Inline HTML to embed, overriding the src attribute. If a browser does not support the srcdoc attribute, it will fall back to the URL in the src attribute.

import React, { useRef } from 'react';
import { css } from '@emotion/css';

const iframeStyle = css`
  width: 100%;
`;

interface Props {
  html: string;
}

const HtmlViewer = ({ html }: Props) => {
  return (
    <iframe
      className={iframeStyle}
      srcDoc={html}
    />
  );
};

export default HtmlViewer;
 

iframe 에 스타일 추가하기

위 코드에서 className 으로 추가한 스타일은 iframe 자체에만 적용이 되고, iframe 안의 html, body element 에는 적용이 되지 않습니다. iframe 내부의 컨텐츠에 스타일을 추가하려면 직접 link 태그를 추가해주어야 합니다.

import React, { useRef } from 'react';
import { css } from '@emotion/css';

const iframeStyle = css`
  width: 100%;
`;

interface Props {
  html: string;
}

const HtmlViewer = ({ html }: Props) => {
  const onLoad: React.ReactEventHandler<HTMLIFrameElement> = (e) => {
    const iframeDocument = (e.target as HTMLIFrameElement).contentDocument;
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.href = '../htmlStyle.css';
    const headElement = iframeDocument?.querySelector('head');
    headElement?.appendChild(linkElement);
  };
  
  return (
    <iframe
      className={iframeStyle}
      srcDoc={html}
      onLoad={onLoad}
    />
  );
};

export default HtmlViewer;

iframe 이 load 되고 나면 link 태그를 추가해서 스타일시트를 주입합니다. css 파일에는 위에서 언급한 것 처럼 에디터에 적용된 것과 동일한 스타일이 적용되어 있으면 됩니다.

iframe 높이 구하기

스타일 추가까지 완료한 후에 화면을 보면 iframe 의 height 이 150px 밖에 되지 않습니다. 기본 높이가 150px 이기 때문입니다. html, body 의 높이는 콘텐츠들이 들어가서 실제 높이가 되었지만, iframe 은 여전히 150px 입니다. iframe 스타일에 height: 100% 를 지정해도 iframe 은 여전히 기본값이 150px 로 고정되어 있습니다.

iframe의 높이가 150px 로 고정된 이유는 iframe 요소에 직접 높이를 설정하지 않았기 때문입니다. 따라서 iframe 의 높이가 기본적으로 설정된 값으로 표시됩니다. iframe 의 높이를 내부 콘텐츠의 높이에 따라 자동으로 조절하려면 JavaScript를 사용하여 iframe의 높이를 동적으로 설정해야 합니다. 내부 콘텐츠의 높이를 가져와서 iframe 의 높이로 설정하는 방법은 다음과 같습니다.

const resizeIframe = (iframe) => {
  // iframe의 내부 콘텐츠 높이를 가져옵니다.
  const contentHeight = iframe.contentWindow.document.body.scrollHeight;

  // iframe의 높이를 내부 콘텐츠 높이에 맞게 설정합니다.
  iframe.style.height = contentHeight + 'px';
}

resizeIframe 도 onLoad 되었을 때 실행되게 하기 위해서 onLoad 함수에 추가해주면 됩니다.

그런데 위에서 추가했던 스타일시트에 이미지 사이즈를 조절하는 스타일(이미지 사이즈가 큰 경우 100%로 조정하는 등) 이 있어서 스타일이 로드 되기 전에 resize 가 되어 실제 높이와 다르게 표시되는 문제가 있었습니다. 스타일이 로드 된 후에 높이 계산을 하도록 아래처럼 수정하여 해결했습니다.

const iframeRef = useRef<HTMLIFrameElement>(null);

const resizeIframe = () => {
  const iframeDocument = iframeRef.current?.contentDocument;
  if (!iframeDocument) return;
  const contentHeight = iframeDocument.documentElement.scrollHeight;
  iframeRef.current.style.height = contentHeight + 'px';
}

const onLoad: React.ReactEventHandler<HTMLIFrameElement> = (e) => {
    const iframeDocument = (e.target as HTMLIFrameElement).contentDocument;
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.href = '../htmlStyle.css';
    linkElement.onload = resizeIframe;
    const headElement = iframeDocument?.querySelector('head');
    headElement?.appendChild(linkElement);
  };

이미지 로드 후 사이즈 변경

여기까지 했을 때 대부분의 컨텐츠들이 정상적으로 표시가 되었지만, 간혹 사이즈가 큰 이미지들이 로드 되기 전에 높이가 결정되어 일부가 잘리는 문제가 있었습니다. 원인은 iframe 의 onload 시점이었습니다.

iframe 의 onload 이벤트는 iframe 요소가 로드되는 시점을 가리키며, 이 이벤트가 발생하는 시점은 iframe의 외부 콘텐츠(예: 페이지 URL)가 로드된 후입니다. 따라서 onload 이벤트는 iframe 자체가 로드된 시점을 나타냅니다. 그러나 onload 이벤트는 iframe 내부의 콘텐츠(예: 이미지, 비디오 등)가 로드된 시점을 보장하지 않습니다.

위 문제를 해결하기 위해 iframe 안의 이미지가 로드 된 이후에 높이 계산을 하도록 코드를 수정했습니다. 보통의 경우라면 image 태그에 onload 이벤트를 추가하면 되지만, srcDoc 속성으로 추가한 컨텐츠는 load 이벤트가 발생하지 않습니다. srcDoc 속성을 사용하여 iframe에 추가한 컨텐츠는 외부 리소스가 아니기 때문에 별도의 로드 이벤트가 발생하지 않습니다.

대신 complete 속성을 사용해서 로드 여부를 확인할 수 있습니다.

const HtmlViewer = ({ html }: Props) => {
  const iframeRef = useRef<HTMLIFrameElement>(null);

  if (!html) {
    return null;
  }

  const resizeIframe = () => {
    const iframeDocument = iframeRef.current?.contentDocument;
    if (!iframeDocument) return;

    const contentHeight = iframeDocument.documentElement.scrollHeight;
    iframeRef.current.style.height = `${contentHeight}px`;
  };

  const trackAllImageLoad = () => {
    const iframeDocument = iframeRef.current?.contentDocument;
    if (!iframeDocument) return;
    const imgElements = iframeDocument.querySelectorAll('img');

    const trackImageLoad = () => {
      let allImagesLoaded = true;

      imgElements.forEach((imgElement) => {
        if (!imgElement.complete) {
          allImagesLoaded = false;
        }
      });

      if (allImagesLoaded) {
        resizeIframe(); // 2️⃣ 모든 이미지 로드 확인 -> 리사이즈
      } else {
        setTimeout(trackImageLoad, 100);
      }
    };

    trackImageLoad();
  };

  const onLoad: React.ReactEventHandler<HTMLIFrameElement> = (e) => {
    const iframeDocument = (e.target as HTMLIFrameElement).contentDocument;
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.href = '../htmlStyle.css';
    linkElement.onload = trackAllImageLoad; // 1️⃣ 스타일 로드 -> 이미지 로드
    const headElement = iframeDocument?.querySelector('head');
    headElement?.appendChild(linkElement);
  };

  return (
    <iframe
      className={iframeStyle}
      ref={iframeRef}
      srcDoc={html}
      onLoad={onLoad}
    />
  );
};

export default HtmlViewer;

결론

결론적으로 다음과 같은 프로세스가 진행됩니다.

  1. iframe 로드
  2. iframe 에 스타일 적용
  3. 스타일 적용 후에 이미지 로드 확인
  4. 모든 이미지가 로드 되었는지 확인
  5. iframe 의 body 사이즈로 iframe 사이즈 변경

이렇게 해서 에디터로 작성한 내용과 동일한 스타일을 적용할 수 있고, 서비스의 글로벌 스타일과 충돌하지 않는 독자적인 스타일 공간에서 로드 되는 htmlViewer 를 만들 수 있었습니다.

마무리

아마 비슷한 기능을 하는 라이브러리가 있는 것으로 알고 있습니다. 전에 개인 블로그 를 만들때는 react-markdown 라이브러리를 사용했었는데, 기본적인 마크다운 스타일과 추가 스타일링을 오버라이드 할 수 있게 해주는 라이브러리였습니다.

html rendering 하는 역할로는 react-html-renderer 이 라이브러리도 비슷한 동작을 하는 것 같습니다. 그럼에도 iframe 을 사용한 이유는 글로벌 스타일과 완전히 독립적인 공간을 제공해 준다는 장점이 필요해서 직접 만들어 보았습니다. 만약 그럴 필요가 없는 서비스 자체가 블로그 기능이 메인이 서비스라면 라이브러리를 사용하는 것도 좋을 것 같습니다!

profile
(이제부터라도) 기록하려는 프론트엔드 디벨로퍼입니다 XD

0개의 댓글