본문 바로가기

업무

[Next.js] Script 추가하기 (채널톡 추가하기, next/script)

최근 업무에서 웹뷰 서비스를 개발 중에 채널톡 서비스를 추가하는 일이 있었다.

카카오 공유하기 기능처럼 외부 플러그인이기 때문에

스크립트를 로딩하는 일이 필요했다.

도입하는 과정에서 알게된 지식들을 이 포스트에서 정리하려고 한다.

 

스크립트 로딩 원리

브라우저에서 스크립트를 읽는 과정은 html을 파싱하면서 시작된다.

서버로부터 html을 받으면 파싱하기 시작하고 순차적으로 읽으면서 script 태그를 만나면 특별한 예외 지시자가 없을시 스크립트를 로딩한다.

 

외부 자바스크립트를 로드하는 것도 원리는 동일하다.

html에 script 코드를 추가하면 된다.

 

원본 html 파일에 특정 코드를 추가시키는 DOM 메서드는 innerHTML인데

이것은 크로스 사이트 스크립트 보안 문제가 있다.

원리는 똑같이 악성 사용자가 악성 자바스크립트 파일을 html에 삽입하여 로딩되도록 하는 것이다.

예를 들면 필자도 당한 기억이 있는데 특정 게시판 글을 읽었더니 컴퓨터 내 모든 파일들이 암호화되었다 (...)

 

리액트에서는 innerHTML DOM 메서드가 보안적으로 취약하다는 것을 강조하기 위해

dangerouslySetInnerHTML 프로퍼티를 제공한다. (https://ko.reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml)

 

const App = () => {
    const markup = () => {
    	return {__html : 'Hello'}
    };
    
    return(
    	<div dangerouslySetInnerHTML={markup()}></div>
    )
};

ReactDOM.render(
	<App />, document.getElementById('root');
)

대충 이런 식으로 사용하는데 dangerously에서 유추할 수 있듯이

크로스 사이트 스크립팅 문제를 해결해주는 것이 아니다.

여전히 존재하는 문제이고 이름으로부터 사용자에게 조심하라고 일깨워주는 것이다.

즉, 보안 문제를 직접적으로 해결해주는 것이 아니다.

 

예를 들어 유명 오픈소스의 경우 위와 같이 React의 xss 취약점 문제를 패치했다.

html 메시지를 직접 로딩하게 하지 않고 컴포넌트를 만들어서 처리하게 했다.

 

위와 같이 dangerouslySetInnerHTML에 동적 변수를 입력시키는 행위를 매우 조심해야 한다.

 

만약 위 경우처럼 외부에서 입력받는 변수를 dangerouslySetInnerHTML에 넣어야만 하는 경우는

그 변수값에서 악성 코드를 제거해주고 깨끗하게 만들어주는 함수를 써야 한다.

 

그런 라이브러리가 있다.

dompurify 라는 라이브러리인데

이게 그런 역할을 해준다.

https://www.npmjs.com/package/dompurify

 

여기까지 스크립트가 로딩되는 과정과 발생할 수 있는 취약점 문제를 살펴봤다.

앞으로 dangerouslySetInnerHTML을 만나면 그런 문제가 있을 수 있다는 것만 기억하면 될 것 같다.

 

채널톡 추가하기

 

이러한 상담 기능을 서비스에 도입하는 것이 필요했다.

 

 

<script
   dangerouslySetInnerHTML={{__html: 
   `     
   (function() {
    var w = window;
    if (w.ChannelIO) {
      return (window.console.error || window.console.log || function(){})('ChannelIO script included twice.');
    }
    var ch = function() {
      ch.c(arguments);
    };
    ch.q = [];
    ch.c = function(args) {
      ch.q.push(args);
    };
    w.ChannelIO = ch;
    function l() {
      if (w.ChannelIOInitialized) {
        return;
      }
      w.ChannelIOInitialized = true;
      var s = document.createElement('script');
      s.type = 'text/javascript';
      s.async = true;
      s.src = 'https://cdn.channel.io/plugin/ch-plugin-web.js';
      s.charset = 'UTF-8';
      var x = document.getElementsByTagName('script')[0];
      x.parentNode.insertBefore(s, x);
    }
    if (document.readyState === 'complete') {
      l();
    } else if (window.attachEvent) {
      window.attachEvent('onload', l);
    } else {
      window.addEventListener('DOMContentLoaded', l, false);
      window.addEventListener('load', l, false);
    }
  })();
  ChannelIO('boot', {
    "pluginKey": "YOUR_PLUGIN_KEY", //please fill with your plugin key
    "memberId": "YOUR_USER_ID", //fill with user id
    "profile": {
      "name": "YOUR_USER_NAME", //fill with user name
      "mobileNumber": "YOUR_USER_MOBILE_NUMBER", //fill with user phone number
      "CUSTOM_VALUE_1": "VALUE_1", //any other custom meta data
      "CUSTOM_VALUE_2": "VALUE_2"
    }
  });
  `,
  }}
/>

페이지 파일에다가 추가하면 된다. dangerously에 입력시키지만 하드코딩이기 때문에 괜찮다.

 

 

SPA에서 채널톡 추가하기

 

이 방법은 SPA에서 유용하다.

왜냐하면 위 script 태그를 사용할 경우 SPA는 페이지가 하나이기 때문에 script 태그를 하나뿐인 페이지에 달아야 한다.

그렇게 되면 모든 서비스에 채널톡이 추가될 것이다.

그걸 원한다면 위의 코드처럼 하면 되고 나처럼 특정 몇몇 페이지만 채널톡이 활성화되길 원하고 리액트 훅처럼 손쉽게 제어하길 원한다면 아래 방법을 써보자.

 

class ChannelTalkService {
  constructor() {
    this.loadScript();
  }

  loadScript() {
    if (window.ChannelIO) {
      return;
    }
    const ch = function (...args) {
      ch.c(args);
    };
    ch.q = [];
    ch.c = function (args) {
      ch.q.push(args);
    };
    window.ChannelIO = ch;
    function l() {
      if (window.ChannelIOInitialized) {
        return;
      }
      window.ChannelIOInitialized = true;
      const s = document.createElement('script');
      s.type = 'text/javascript';
      s.async = true;
      s.src = 'https://cdn.channel.io/plugin/ch-plugin-web.js';
      s.charset = 'UTF-8';
      const x = document.getElementsByTagName('script')[0];
      x.parentNode.insertBefore(s, x);
    }
    if (document.readyState === 'complete') {
      l();
    } else if (window.attachEvent) {
      window.attachEvent('onload', l);
    } else {
      window.addEventListener('DOMContentLoaded', l, false);
      window.addEventListener('load', l, false);
    }
  }

  boot(settings) {
    window.ChannelIO('boot', settings);
  }

  shutdown() {
    window.ChannelIO('shutdown');
  }

  setPage(page) {
    window.ChannelIO('setPage', page);
  }

  openChat(chatId) {
    window.ChannelIO('openChat', chatId);
  }

  onHide(callback) {
    window.ChannelIO('onHideMessenger', callback);
  }
}

export default ChannelTalkService;

 

 

import React, { useEffect } from 'react';

import { useRouter } from 'next/router';

import ChannelTalkService from './hooks/ChannelTalkService';

const CHANNEL_TALK_PLUGIN_KEY = '6aa23fsdfef';
const REJECT_CHATROOM_ID = '63xcvvsdde32';

function ContractChannelTalk() {

  useEffect(() => {
    const channelTalk = new ChannelTalkService();
    
    channelTalk.boot({
     pluginKey: CHANNEL_TALK_PLUGIN_KEY,
     openChatDirectlyAsPossible: true,
     mobileMessengerMode: 'iframe',
     zIndex: 1,
     hidePopup: false,
    });
    
    channelTalk.setPage(
      'page',
    );
    channelTalk.openChat(REJECT_CHATROOM_ID);
    channelTalk.onHide(() => {
      //
    });
    return () => {
      channelTalk.shutdown();
    };
  }, []);

  return <></>;
}

export default ContractChannelTalk;

 

 

Next/Script 패키지 이용하기

 

Next.js에서 스크립트를 로딩하는 것은 매우 간단하다.

11.0 버전부터 Script 패키지를 제공하기 때문이다.

 

import Script from "next/script";

 

import Script from 'next/script'

export default function Home() {
  return (
    <>
      <Script
        onError={(e) => {
          console.error('Script failed to load', e)
        }}
	dangerouslySetInnerHTML={{
    	  __html: ` (function() {
    var w = window;
    if (w.ChannelIO) {
      return (window.console.error || window.console.log || function(){})('ChannelIO script included twice.');
    }
    var ch = function() {
      ch.c(arguments);
    };
    ch.q = [];
    ch.c = function(args) {
      ch.q.push(args);
    };
    w.ChannelIO = ch;
    function l() {
      if (w.ChannelIOInitialized) {
        return;
      }
      w.ChannelIOInitialized = true;
      var s = document.createElement('script');
      s.type = 'text/javascript';
      s.async = true;
      s.src = 'https://cdn.channel.io/plugin/ch-plugin-web.js';
      s.charset = 'UTF-8';
      var x = document.getElementsByTagName('script')[0];
      x.parentNode.insertBefore(s, x);
    }
    if (document.readyState === 'complete') {
      l();
    } else if (window.attachEvent) {
      window.attachEvent('onload', l);
    } else {
      window.addEventListener('DOMContentLoaded', l, false);
      window.addEventListener('load', l, false);
    }
  })();
  ChannelIO('boot', {
    "pluginKey": "YOUR_PLUGIN_KEY", //please fill with your plugin key
    "memberId": "YOUR_USER_ID", //fill with user id
    "profile": {
      "name": "YOUR_USER_NAME", //fill with user name
      "mobileNumber": "YOUR_USER_MOBILE_NUMBER", //fill with user phone number
      "CUSTOM_VALUE_1": "VALUE_1", //any other custom meta data
      "CUSTOM_VALUE_2": "VALUE_2"
    }
  });`,
  	}}
      />
    </>
  )
}

나는 이 방법을 선택했는데 이유는 만들고 있는 서비스는 SPA이지만 SPA가 아니다.

이전 포스트에서 설명했지만 next/router를 이용해서 next.js 환경에서 만들고 있기 때문에 SPA가 아니라 유사 SPA 환경이라서

각 페이지마다 스크립트 로드를 분리할 수 있는 뜻밖의 장점을 얻었다.

 

그리고 나는 가급적 next.js의 패키지들을 최대한 활용하려고 노력한다.

신뢰도가 높아서 next/image도 그렇고 스크립트도 쓰기로 했다.

 

단순히 vercel에 대한 신뢰도가 높은 것보다도

Script 패키지 내용을 살펴보면 유틸리티가 많다.

 

우선 <Script />는 리액트 훅 기반이고 html의 script 태그를 wrapper하는 구조다.

 

next/script의 장점

 

첫 번째로 스크립트 태그는 언제 로드하느냐가 성능에 아주 중요한 이슈다.

브라우저가 바쁠 때 로드하게 되면 그만큼 다른 작업이 interactive 되기 까지 오래걸리겠지만

로드하는 스크립트는 더 빨리 만나볼 수 있게 된다.

 

만약 중요하지 않다면 좀 한가할 때 불러오게 하면 성능에 큰 향상을 줄 수 있는데

next/script가 그거를 아주 쉽게 해준다.

 

그리고 next.js는 SSR 프레임워크 이기 때문에 next.js를 쓰는 이상

서버사이드렌더링도 고민해야 한다.

next/script는 그런 걸 고민해줘서 서버사이드에서 html에 미리 script를 추가해줄 수도 있고

아니면 클라이언트 사이드 렌더링을 시킬 수도 있다. (하이드레이션 이후에 동작하게끔)

 

기본적으로 4가지 로드 전략을 제공한다.

 

afterInteractive

Script에서 strategy 속성을 입력하지 않으면 기본값으로 적용되는 값이다.

하이드레이션이 일어난 후에 로드되기 때문에 클라이언트사이드에서 로드된다.

크게 성능 고려를 할 필요가 없다면 그냥 이게 많이 쓰일 것 같다. 그래서 기본값으로 보인다.

 

 

beforeInteractive

서버사이드에서 html에 미리 추가되어서 브라우저에 전달되기 원할 때 사용한다.

전체 페이지에 영향을 끼치기 때문에

이 값은 _document 파일에서 Script를 쓸 때 사용하는 것을 권장한다.

 

lazyOnload

내가 선택한 전략이다.

기본적으로 클라이언트사이드라서 afterInteractive와 유사하게 동작하지만

살짝 동작이 다른데

 

lazyOnload를 처리하는 next.js 내부 코드인데 load 이벤트에 콜백으로 

requestIdleCallback 이라는 2015년쯤에 도입된 브라우저의 API를 사용한다. (크롬은 지원하지만 일부 브라우저에선 동작하지 않을 수 있는 이슈가 있지만 그런 경우 setTimeout을 이용해 폴리필이 많이 사용된다. next.js도 그렇게 동작한다.)

이 API는 브라우저의 메인쓰레드가 한가할 때 처리할 콜백을 등록하여 사용한다.

브라우저 메인쓰레드가 바쁠 때 로드하게 되면 다른 중요한 작업들이 지장을 받기 때문이다.

즉, 중요한 일 다 끝나고 이 스크립트를 로딩해라 할 때 사용한다.

내가 이걸 선택한 이유도 다른 작업을 빨리 로드하는 게 더 중요해서다.

 

다른 하나 전략은 worker인데 이건 아직 실험적인 기능이라 생략하겠다. 결국 목적은 메인쓰레드가 바쁘더라도 빠르게 같이 로드하게 하는 목적으로 사용하기 위해 나온 것으로 보인다.

 

그 외에 로드 성공 후 실행할 콜백을 등록할 수도 있고 에러났을 때 콜백도 추가할 수 있어서 활용도가 높아보인다.