React환경에서 Kakao Map API를 사용하거나, 외부 API를 사용하기위해 <script>
태그를 index.html에 수작업으로 넣어주어야 하곤 합니다.
이 포스팅에서는 이러한 상황의 해결책을
방법 1. 가장 기초적인 구현인 index.html에 수작업으로 넣어주기.
방법 2. useExternalScript()훅을 구현하여 동적으로 넣어주기.
방법 3. react-dom v19에서 새로도입된 async script hoist 기능을 활용하여 동적으로 넣어주기
순으로 다뤄보겠습니다.
가장 기초적인 구현은 /public/index.html의 <head>
태그에 직접 <script>
태그를 넣어주는 것입니다.
하지만 API를 추가하기위해 index.html를 건드려야한다는 점,
꼭 필요한페이지가아닌 모든 페이지에서 불필요한 스크립트 요청이 발생한다는점 등이 매우 아쉬웠습니다.
그래서 기존에는 아래와같이 useExternalScript() 훅을 구현하는 방식으로 사용하는 경우가
많이 있었습니다.
// KakaoMap.tsx
function KakaoMap() {
const container = useRef(null);
const init = () => {
window.kakao.maps.load(() => {
const options = {
center: new window.kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
level: 3, //지도의 레벨(확대, 축소 정도)
};
const map = new window.kakao.maps.Map(container.current, options);
});
};
useExternalScript("http://dapi.kakao.com/v2/maps/sdk.js?appkey=[API_KEY]&autoload=false", init);
return (
<div>
<div>지도 컴포넌트입니다.</div>
<div
ref={container}
style={{ width: "400px", height: "500px" }}
></div>
</div>
);
}
export default KakaoMap;
// useExternalScript.ts
function useExternalScript(url:string, callback:()=>void) {
useEffect(()=>{
appendScript(callback);
},[])
}
function appendScript(url:string, callback:()=>void) {
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = callback;
script.onerror = error => {
console.error(`스크립트 로드중 에러가 발생하였습니다.`, error);
};
document.head.appendChild(script);
}
처럼 useExternalScript를 만들어 동적으로 <head>
태그에 append해줌으로써 동적으로 script를 로드할 수 있었습니다.
이 훅이 꼭 한번만 실행되게하기위해 url에따라 실행횟수를 기록해서 중복없이 단 한번만 로드하게 해야하고 (dedupe),
컴포넌트가 렌더된 후에야 head가 변경되어 script load가 시작되며 (로딩 성능문제),
훅 내부가 다소 선언적이지 못하다는 아쉬움이 있었습니다.
물론 dedupe또한 훅 내에서 실행횟수를 기록하기위한 구현을 하면 되긴합니다만 코드는 좀 더 길어질 것입니다.
하지만 이제 React19의 기능을 활용해 document.createElement로 DOM을 직접 다루지않고도 script를 동적으로 추가할 수 있게되었습니다.
<script>
dom 이용하기script special behavior - 공식문서
에 따르면,
React 19 이후로 async 속성을 가진 <script>
태그는 자동으로 index.html
의 <head>
의 children으로 호이스팅 되기 때문에 <script>
태그에 props만 제공해주면 됩니다.
뿐만아니라 dedupe(중복 삽입 방지) 기능까지 제공합니다.
따라서 react에서 <script>
태그를 사용하여 아래와 같이 구현하면
useExternalScript 없이도 훨씬 간결하게 구현할 수 있습니다.
function KakaoMap() {
const container = useRef(null);
const init = () => {
console.log("init");
window.kakao.maps.load(() => {
const options = {
center: new window.kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
level: 3, //지도의 레벨(확대, 축소 정도)
};
const map = new window.kakao.maps.Map(container.current, options);
});
};
return (
<div>
<script
async
onLoad={init}
src="http://dapi.kakao.com/v2/maps/sdk.js?appkey=[API_KEY]&autoload=false"
/>
<div>지도 컴포넌트입니다.</div>
<div
ref={container}
style={{ width: "400px", height: "500px" }}
></div>
</div>
);
}
export default KakaoMap;
또한, 공식문서에서 onLoad도 정식사용할 수 있는것으로 언급하고있으므로
공식문서대로라면 onLoad가 잘 작동해야합니다.
위와 같이 구현을 마치고 실제로 실행을 해보았을 때,
React19기준으로 onLoad로 넣은 콜백이 전혀 실행되지 않는 현상이 발생했습니다.
(공식문서상엔 onLoad는 정식 제공되는 prop인데도 불구하고)
react GitHub 의 issue를 조사해본 결과,
이 현상을 지적하는 두 이슈를 발견할 수 있었고 많은 사람들이 동일한 증상을 겪고 있습니다.
issue : script onLoad is not triggered #13863
issue : Feature Request: Allow executable script tags #28048
이는 react의 내부 구현 상 <script>
태그를 DOM으로 옮길때
createElement('div')하고 그 안에 innerHTML을 통해 script태그를 생성하는 내부 구현 때문입니다.
즉 react 공식문서상 script태그는 onLoad를 지원하는 것으로 기술되어 있음에도 불구하고, 내부구현상 문제로 <script>
태그의 onLoad는 동작하지 않아왔습니다.
(위 issue#28048는 훌륭한 지적이었지만 아쉽게도 조용히.. 묻혔습니다. )
이렇게 오랫동안 onLoad가 동작하지 않고 있을 수 있었던 건, 그동안은 React내부에서 <script>
hoist 기능을 제공하지도않았고, built-in script 태그에 리액트만의 extension을 추가한 것도 React 19에서 처음 도입된 것이기때문입니다.
하지만 React 19에서 script 태그의 dom을 canary가 아닌 정식으로 승격시킨 이상 사람들이 script 태그를 사용해보면서 이런 이슈가 다시 제기될 것이라고 생각합니다.
모든 것을 포기할 필요는 없습니다.
실제 동작하지 않는 것은 onLoad 뿐이므로,
onLoad만 동적으로 넣어주면 해당 문제가 해결되어요.
Script라는 래퍼컴포넌트를 만들어서 onLoad만 동적으로 삽입해줍니다.
KakaoMap Api를 사용하는 실제예시
// KakaoMap.tsx
import React, { useRef } from "react";
import Script from "./Script";
function KakaoMap() {
const container = useRef(null);
const init = () => {
window.kakao.maps.load(() => {
const options = {
center: new window.kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
level: 3, //지도의 레벨(확대, 축소 정도)
};
const map = new window.kakao.maps.Map(container.current, options);
});
};
return (
<div>
<Script
async
onLoad={init}
src="http://dapi.kakao.com/v2/maps/sdk.js?appkey=[API_KEY]&autoload=false"
/>
<div>지도 컴포넌트입니다.</div>
<div
ref={container}
style={{ width: "400px", height: "500px" }}
></div>
</div>
);
}
export default KakaoMap;
// Script.tsx
import React, { AllHTMLAttributes, Attributes, useEffect, useRef } from "react";
function Script({ onLoad, ...props }: AllHTMLAttributes<HTMLScriptElement>) {
const ref = useRef(null);
useEffect(() => {
ref.current.onload = onLoad;
}, []);
return <script async ref={ref} {...props} />;
}
export default Script;
선언적으로 구현한 <Script>
컴포넌트를 이용해 자동으로 head태그에 script를 주입하는 모습입니다.
아래와 같이 정상적으로 head태그에 주입되고 작동합니다.
리액트 19로 넘어오면서 react-dom의 <script>
hoist, dedupe 기능을 이용해 외부 script를 불러오는 기능을 좀 더 선언적으로 구현해보았습니다.
리액트의 내부구현상 onLoad props가 정상동작하지 않아 Script 컴포넌트로 작은 수고가 필요했지만 dedupe을 위한 기록이나, createElement없이도 구현할 수 있어 훨씬 보기 좋아졌습니다.
추후 React의 script 내부구현이 좀 더 개선되어 Script 컴포넌트 래핑이 필요없어지면 좋겠습니다.