Logo

React에서 <script> 태그로 자바스크립트 불러오기

React 프로젝트에서 대부분의 외부 스크립트는 npm 패키지로 설치해서 불러올 수 있지만 간혹 npm 패키지가 제공되지 않는 경우도 있습니다. 이럴 경우 어쩔 수 없이 웹에서 전통적으로 외부 스크립트를 불러오는 방법인 HTML 문서의 <script> 태그를 사용할 수 밖에 없는데요.

이번 포스팅에서는 React 컴포넌트에서는 어떻게 <script> 태그로 외부 자바스크립트 불러울 수 있는지에 대해서 알아보도록 하겠습니다.

index.html 파일 안에 script 태그 추가하기

일반적으로 React 프로젝트에는 public 폴더나 static 폴더 안에 <div> 요소 하나가 덩그러니 있는 index.html 파일이 존재하기 마련인데요. React 라이브러리는 이 id 속성이 root로 설정되어 있는 <div> 요소의 내용을 동적으로 채워주는 방식으로 동작합니다. 즉, HTML 문서를 만들어내는 과정이 모두 자바스크립트를 통해 브라우저에서 일어나게 되죠.

그러므로 index.html 파일을 열고 <script> 요소를 넣어주기만 하면 일반 웹사이트처럼 자바스크립트를 불러올 수 있습니다. 아마도 이 방법이 React 앱에서 <script> 태그를 사용하는 가장 간단한 방법일 것입니다.

예를 들어, Create React App으로 만든 React 앱에서 Lodash 라이브러리를 불러오고 싶다면 다음과 같이 public 폴더 안에 있는 index.html파일에 <script> 태그만 추가해주면 되겠죠?

public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script async src="https://unpkg.com/lodash"></script>  </body>
</html>

그러나 이 방법의 한 가지 큰 단점이 있는데요. 외부 자바스크립트가 필요한 컴포넌트에서만 선택적으로 불러올 수 없기 때문에 그에 따른 비효율이 발생한다는 것입니다. 따라서 통계나 모니터링 용 스크립트를 처럼 항상 불러와야하는 경우를 제외하고는 사용하기 부적한 방법입니다.

여기서 <script> 요소를 사용할 때 왜 async 옵션을 사용했는지가 궁금하신 분들은 관련 포스팅을 먼저 읽어보시기를 추천드릴게요.

자바스크립트로 script 태그를 동적으로 삽입하기

사실 자바스크립트를 사용하면 얼마든지 자유롭게 HTML 문서를 조작할 수 있기 때문에 굳이 위와 같이 index.html 파일을 수정할 필요는 없습니다.

아래와 같이 단 4줄의 순수한(vanilla) 자바스크립트 코드로 HTML 문서 안에 <script> 요소를 동적으로 삽입하여 동일한 효과를 낼 수 있어요.

const script = document.createElement("script");
script.src = "https://unpkg.com/lodash";
script.async = true;
document.body.appendChild(script);

정말 별 거 없죠? 😄

React 컴포넌트에서 script 태그를 동적으로 삽입하기

자 그럼, 이제 React 컴포넌트로 넘어가볼께요.

외부 자바스크립트를 불러오는 것과 같은 작업은 전형적인 사이드 이펙트(side effect)이므로 useEffect() 훅 함수를 쓰면 되겠죠?

Input.jsx
import { useEffect } from "react";

function Input() {
  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://unpkg.com/lodash";
    script.async = true;
    document.body.appendChild(script);
  });

  return <input />;
}

이렇게 해주면 이 <Input/> 컴포넌트가 랜더링되면서 부수적으로 HTML 문서 안에 <script> 요소도 삽입되겠지요?

  • React의 useEffect() 훅 함수에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고하세요.

script 요소 중복 삽입 방지하기

만약에 여기서 <input> 요소의 상태 관리를 하도록 <Input/> 컴포넌트의 코드를 수정해보면 어떨까요?

Input.jsx
import { useEffect, useState } from "react";

function Input() {
  const [value, setValue] = useState("");
  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://unpkg.com/lodash";
    script.async = true;
    document.body.appendChild(script);
  });

  return (
    <input      value={value}      onChange={({ target: { value } }) => setValue(value)}    />  );
}

이렇게 되면 <input> 요소에 글자를 입력할 때 마다 동일한 <script> 요소가 중복해서 삽입이 될 거에요. 왜냐하면 <input> 요소에 change 이벤트가 발생할 때 마다 상태 변화가 일어나 컴포넌트가 다시 랜더링(rendering)될 테니까요.

useEffect() 훅 함수의 두 번째 인자로 빈 배열을 넘기면 이 문제가 해결이 될까요?

Input.jsx
import { useEffect, useState } from "react";

function Input() {
  const [value, setValue] = useState("");

  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://unpkg.com/lodash";
    script.async = true;
    document.body.appendChild(script);
  }, []);
  return (
    <input
      value={value}
      onChange={({ target: { value } }) => setValue(value)}
    />
  );
}

글쎄요… 🤔 제가 보기에는 근본적인 해결책은 되지 않을 것 같아요. 만약에 이 <Input/> 컴포넌트가 애플리케이션 내에서 여러 번 사용된다면 동일한 문제가 발생할테니까요.

Form.jsx
function Form() {
  return (
    <form>
      <Input />
      <Input />
      <Input />
    </form>
  );
}

HTML 문서에 <script> 요소가 중복해서 삽입되는 것을 원천적으로 막으려면 HTML 문서에서 해당 <script> 요소가 이미 존재하는지 확인하는 것이 가장 확실하겠죠?

Input.jsx
import { useEffect, useState } from "react";

function Input() {
  const [value, setValue] = useState("");

  useEffect(() => {
    if (      document.querySelector(        `script[src="https://unpkg.com/lodash"]`      )    )      return;
    const script = document.createElement("script");
    script.src = "https://unpkg.com/lodash";
    script.async = true;
    document.body.appendChild(script);
  }, []);

  return (
    <input
      value={value}
      onChange={({ target: { value } }) => setValue(value)}
    />
  );
}

이렇게 동일한 주소로 부터 자바스크립트를 불러오는 <script> 요소가 없는 경우에만 <script> 요소를 추가하도록 수정해주면 같은 컴포넌트가 여러 번 사용되더라도 HTML 문서에 <script> 요소가 딱 한 번만 삽입될 거 에요.

React의 useState() 훅 함수에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고하세요.

자바스크립트의 불러오기가 완료되기를 기다리기

<script> 태그로 자바스크립트를 불러올 때 많은 경우 불러오기가 완료되기를 기다려야하는데요. 특히 해당 자바스크립트 코드에서 제공하는 변수나 함수, 클래스를 사용해야할 때 그렇죠?

간단한 실습을 위해 prop으로 넘어온 문자열을 낙타(camel) 케이스로 변환해주는 <CamelCase/> 컴포넌트를 작성해볼께요. 컴포넌트가 반환하는 JSX 코드 내에서 Lodash 라이브러리의 camelCase() 함수를 사용하려고 합니다.

CamelCase.jsx
import { useEffect, useState } from "react";

function CamelCase({ children }) {
  useEffect(() => {
    if (
      document.querySelector(
        `script[src="https://unpkg.com/lodash"]`
      )
    )
      return;

    const script = document.createElement("script");
    script.src = "https://unpkg.com/lodash";
    script.async = true;
    document.body.appendChild(script);
    }
  }, []);

  return <p>{_.camelCase(children)}</p>;}

이 컴포넌트를 랜더링해보면 아래와 같은 오류가 발생할 것입니다. 왜냐하면 camelCase() 함수가 호출되는 시점에 아직 Lodash 라이브러리가 다 불러와지지 않았기 때문이죠.

Console
CamelCase.tsx:8 Uncaught ReferenceError: _ is not defined

따라서 <script> 태그로 불러 온 자바스크립트 코드가 제공하는 변수나 함수, 클래스를 사용하고 싶다면 불러오기가 완료될 될 때 기다려야한다는 것을 알 수 있는데요.

외부 자바스크립트를 불러오는 중에는 로딩 중 ... 메시지를 보여주고, 불러오기가 끝나면 camelCase() 함수를 호출한 결과를 보여주도록 코드를 수정해보겠습니다.

CamelCase.jsx
import { useEffect, useState } from "react";

function CamelCase({ children }) {
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    let script = document.querySelector(
      `script[src="https://unpkg.com/lodash"]`
    );

    if (!script) {
      script = document.createElement("script");
      script.src = "https://unpkg.com/lodash";
      script.async = true;
      document.body.appendChild(script);
    }

    const handleLoad = () => setLoading(false);
    script.addEventListener("load", handleLoad);
    return () => {      script.removeEventListener("load", handleLoad);    };  }, []);

  if (loading) return <p>로딩 중 ...</p>;  return <p>{_.camelCase(children)}</p>;
}

우선 loading 상태를 추가하고 true로 초기화하였고요. <script> 요소에서는 불러오기가 완료되었을 때 load 이벤트가 발생하는데 이 이벤트를 잡아서 loading 상태를 false로 변경해주고 있습니다. 그리고 컴포넌트가 언마운트(unmount)되면 해당 이벤트 핸들러를 제거하도록 해주었습니다.

자바스크립트의 불러오기가 실패하다면?

프로그래밍을 하면서 항상 행복한 경우만 생각할 수는 없겠죠? 네트워크 문제든 스크립트 주소가 틀렸든 외부 자바스크립트를 불러오는데 문제가 생기면 어떻게 처리해야할까요?

우선 error라는 새로운 상태를 추가하고 null로 초기화합니다. 그리고 불러오기가 실패했을 때 <script> 요소에서는 발생하는 error 이벤트를 잡아서 이 error 상태에 저장하면 됩니다. 마지막으로 error 상태에 무엇가가 저장되어 있으면 오류!라고 화면에 표시해주면 되겠습니다.

CamelCase.jsx
import { useEffect, useState } from "react";

function CamelCase({ children }) {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => {
    let script = document.querySelector(
      `script[src="https://unpkg.com/lodash"]`
    );

    if (!script) {
      script = document.createElement("script");
      script.src = "https://unpkg.com/lodash";
      script.async = true;
      document.body.appendChild(script);
    }

    const handleLoad = () => setLoading(false);
    const handleError = (error) => setError(error);
    script.addEventListener("load", handleLoad);
    script.addEventListener("error", handleError);
    return () => {
      script.removeEventListener("load", handleLoad);
      script.removeEventListener("error", handleError);    };
  }, []);

  if (error) return <p>오류!</p>;  if (loading) return <p>로딩 중 ...</p>;
  return <p>{_.camelCase(children)}</p>;
}

마찬가지로 useEffect() 함수가 반환하는 함수에 error 이벤트 핸들러를 제거해주는 코드를 추가하는 것을 잊지마세요!

커스텀 훅 함수로 추출하기

어이쿠! 😆 <script> 태그로 외부 자바스크립트를 불러오는 코드가 이제 제법 길어졌습니다. 이렇게 UI와 직접적으로 관련이 없는 로직이 컴포넌트 함수에서 많은 부분을 차지하면 코드가 읽기 어려워지고 유지보수가 힘들어질텐데요.

사실 <script> 태그로 외부 자바스크립트를 불러오는 작업은 애플리케이션의 여기저기에서 필요할 경우가 많죠? 따라서 이 로직을 커스텀(custom) 훅(hook) 함수로 빼낸 후 다른 주소에 있는 자바스크립트도 불러올 수 있도록 살짝 사용성을 개선해주면 좋을 것 같습니다.

useScript.js
import { useEffect, useState } from "react";

function useScript(src) {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let script = document.querySelector(`script[src="${src}"]`);

    if (!script) {
      script = document.createElement("script");
      script.src = src;
      script.async = true;
    }

    const handleLoad = () => setLoading(false);
    const handleError = (error) => setError(error);

    script.addEventListener("load", handleLoad);
    script.addEventListener("error", handleError);

    document.body.appendChild(script);

    return () => {
      script.removeEventListener("load", handleLoad);
      script.removeEventListener("error", handleError);
    };
  }, [src]);

  return [loading, error];
}

그러면 컴포넌트에서는 이제 이 커스텀 훅 함수만 호출하면 되서 코드가 훨씬 깔끔해지겠죠? ✨

CamelCase.jsx
function CamelCase({ children }) {
  const [loading, error] = useScript("https://unpkg.com/lodash");
  if (error) return <p>Error!</p>;
  if (loading) return <p>Loading...</p>;
  return <p>{_.camelCase(children)}</p>;
}

이제 이 컴포넌트 뿐만 아니라 다른 컴포넌트에서도 <script> 태그로 외부 자바스크립트를 불러오고 싶을 때 useScript() 훅 함수를 사용하면 됩니다. 어떤가요? 중복되는 코드가 줄어서 훨씬 좋지요? 👍

전체 코드

제가 포스팅에서 작성한 전체 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.

마치면서

어렵게 느끼실까봐 글 초반부에는 일부로 React 컴포넌트에서 <script> 태그로 자바스크립트를 불러오는 게 별 거 아닌 듯 얘기했는데요. 후반부로 오시면서 느끼셨겠지만 의외로 고려해줘야 할 부분이 많다는 것을 느끼셨을 거에요. 😮‍💨

학습용으로 이러한 코드를 직접 작성해보시는 것은 큰 도움이 될 수 있으나 실제 프로젝트에서는 좀 더 검증된 코드를 쓰시라고 권유드리고 싶습니다. 왜냐하면 실제 브라우저 환경에서는 제가 미처 다루지 못한 다양한 엣지 케이스(edge case)들이 있을 수 있거든요.

따라서 이미 수 많은 프로젝트에서 사용되고 있는 오픈 소스를 복사해서 사용하시거나 npm 패키지로 설치해서 쓰시는 것이 더 안전할 것입니다. React 커뮤니티에서 잘 알려진 다음 2개의 링크를 공유해드릴테니 구현에 참고바라겠습니다. 😄