오픈 소스 기여하기: TanStack Router(#4323, #5095)
👋 시작하기에 앞서서
사내 프로젝트에서 구글 로그인을 구현하게 되었다. 구글 GSI 문서에 따르면 자바스크립트 라이브러리를 로드해야 했고, TanStack Router의 head 옵션을 사용하면 되겠다고 생각했다.
export const Route = createFileRoute('/')(({
head: () => ({{
scripts: [
{
src: 'https://accounts.google.com/gsi/client',
async: true,
defer: true
},
],
}}),
}})
당연히 동작할 것으로 예상했지만, 로그인 버튼이 나타나지 않았다. 더 당황스러웠던 것은 개발자 도구의 반응이었다.
- Elements 탭:
<script>태그가 정상적으로 존재 - Network 탭: 네트워크 요청이 발생하지 않음
에러 로그도 없이 조용히 실패하는 상황이었다.
🤔 일단 범위부터 좁혀보자
"내 코드의 문제인가, 라이브러리의 문제인가?"부터 확인해야 했다.
가장 단순한 방법으로 index.html에 직접 스크립트 태그를 작성해봤다.
<!-- index.html -->
<script src="https://accounts.google.com/gsi/client" async defer></script>
이 경우에는 정상적으로 작동했다. 결론은 명확했다.
React 컴포넌트에서 동적으로 스크립트 태그를 렌더링할 때 발생하는 제약이 있다.
🔍 React는 왜 스크립트를 차단할까?
결론부터 말하면, React는 보안상의 이유로 컴포넌트 내 <script> 태그를 실행하지 않는다.
정확히는 DOM에는 추가하되 실행을 원천 차단하는 방식이다.
React는 <script> 태그를 생성할 때 직접 document.createElement('script')를 호출하지 않고, innerHTML을 사용하여 간접적으로 생성한다.
이렇게 생성된 스크립트는 브라우저에 의해 'parser-inserted' 플래그가 설정되며, HTML 표준에 따라 이 플래그가 있는 스크립트는 실행이 차단된다.
이는 XSS 공격을 방지하기 위한 의도적인 설계다. 사용자 입력이나 외부 데이터가 컴포넌트로 렌더링될 때, 악의적인 스크립트가 실행되는 것을 원천 차단한다.
실제 React 소스코드를 살펴보면 이 메커니즘을 명확히 확인할 수 있다: https://github.com/facebook/react/blob/e41fd1fc0605dde41cff53e01039633c8e852b6d/packages/react-dom/src/client/ReactDOMComponent.js#L390-L408
if (type === 'script') {
// innerHTML을 사용하여 의도적으로 parser-inserted 플래그를 설정하고
// 스크립트가 실행되지 않도록 한다
const div = ownerDocument.createElement('div');
div.innerHTML = '<script><' + '/script>';
const firstChild = ((div.firstChild: any): HTMLScriptElement);
domElement = div.removeChild(firstChild);
}
주석에도 명시되어 있듯이, React는 의도적으로 innerHTML을 사용하여 'parser-inserted' 플래그를 설정하고 스크립트 실행을 차단한다.
💡 첫 번째 해결: 동적 스크립트 주입
React가 렌더링 단계에서 스크립트를 차단한다면, DOM이 마운트된 이후(Side Effect)에 스크립트를 동적으로 주입하면 된다.
일반적인 React에서는 useEffect 훅으로 이를 구현한다: https://stackoverflow.com/questions/34424845/adding-script-tag-to-react-jsx
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
document.head.appendChild(script);
// 컴포넌트 언마운트 시 스크립트 제거
return () => {
document.head.removeChild(script);
};
}, []);
TanStack Router 이슈 탭을 확인해보니, 이미 많은 개발자가 같은 문제를 겪고 있었다.
문제는 TanStack Router의 head 옵션이 script 태그를 그대로 렌더링하고 있었다는 것이다. CSR 환경을 고려하여 head 옵션 내부에서 script 태그를 직접 렌더링하는 대신, useEffect를 사용해 동적으로 삽입하도록 구현을 변경해야 했다.
오픈소스 기여 시작
TanStack Router는 React뿐만 아니라 Solid 등 다양한 프레임워크를 지원한다. 각 프레임워크의 라이프사이클에 맞춰 스크립트를 주입하는 범용 로직을 작성했다.
PR #4323에 작업 내용을 제출했다.
처음 진행하는 오픈소스 기여였기 때문에 테스트 코드 작성이나 가이드라인 준수에 미숙했지만, maintainer와 소통하며 개선해나갔다.
🚨 두 번째 문제: SSR 환경에서의 중복 실행
머지된 후 얼마 지나지 않아 새로운 이슈가 보고되었다.
SSR 환경에서 스크립트가 중복 실행되는 문제였다.
왜 두 번 실행되는가?
SSR 환경에서는 다음과 같은 시나리오로 스크립트가 중복 실행된다:
- 서버에서 생성된 HTML에
<script>태그가 포함되어 브라우저로 전송 - 브라우저가 HTML을 파싱하면서 스크립트를 실행 (첫 번째 실행)
- React 하이드레이션 완료 후
useEffect로직이 실행 document.createElement('script')로 동일한 스크립트를 다시 생성하여 실행 (두 번째 실행)
이는 Google Analytics가 데이터를 중복 집계하거나, 외부 위젯이 중복으로 렌더링되는 심각한 사이드 이펙트를 발생시켰다.
정교한 중복 방지 로직
단순히 querySelector로 확인하는 것만으로는 불충분했다.
PR #5095에서 구현한 로직을 살펴보자.
1. 서버 환경 체크
const router = useRouter();
// 서버 환경에서는 DOM이 존재하지 않으므로 중복 검사를 수행하지 않음
if (!router.isServer) {
return null;
}
SSR 중에는 DOM이 존재하지 않으므로, 클라이언트 환경에서만 중복 검사를 수행한다.
2. 외부 스크립트 중복 검사
if (attrs?.src && typeof attrs.src === 'string') {
// URL 정규화 (상대 경로 → 절대 경로)
const normSrc = (() => {
try {
const base = document.baseURI || window.location.href;
return new URL(attrs.src, base).href;
} catch {
return attrs.src; // 파싱 실패 시 원본 반환
}
})();
// src 속성이 있는 스크립트만 검색 (성능 최적화)
const existingScript = Array.from(
document.querySelectorAll('script[src]'),
).find((el) => (el as HTMLScriptElement).src === normSrc);
if (existingScript) {
return; // 이미 존재하면 렌더링하지 않음
}
}
핵심 포인트:
querySelectorAll('script[src]'): src 속성이 있는 스크립트만 검색하여 성능 최적화- URL 정규화:
./script.js,/script.js등을 절대 URL로 변환하여 정확한 비교 - try-catch 처리: URL 파싱 실패 시에도 안전하게 처리
3. 인라인 스크립트 중복 검사
if (typeof children === 'string') {
// type 속성 정규화
const typeAttr =
typeof attrs?.type === 'string' ? attrs.type : 'text/javascript';
// nonce 속성 정규화
const nonceAttr = typeof attrs?.nonce === 'string' ? attrs.nonce : undefined;
// 인라인 스크립트만 검색
const existingScript = Array.from(
document.querySelectorAll('script:not([src])'),
).find((el) => {
if (!(el instanceof HTMLScriptElement)) return false;
const sType = el.getAttribute('type') ?? 'text/javascript';
const sNonce = el.getAttribute('nonce') ?? undefined;
// textContent, type, nonce를 모두 비교
return (
el.textContent === children && sType === typeAttr && sNonce === nonceAttr
);
});
if (existingScript) {
return; // 이미 존재하면 렌더링하지 않음
}
}
인라인 스크립트는 내용뿐만 아니라 type, nonce 속성까지 비교한다. 동일한 내용이라도 type이나 nonce가 다르면 별개의 스크립트로 처리한다.
4. Cleanup 로직
React.useEffect(() => {
const script = document.createElement('script');
// ... 스크립트 설정
document.head.appendChild(script);
// 라우트 변경 시 스크립트 제거
return () => {
document.head.removeChild(script);
};
}, []);
라우트 이동 시 해당 페이지에서만 필요한 스크립트를 정리하여 메모리 누수를 방지한다.
5. 테스트 추가
PR에는 다음 시나리오에 대한 E2E 테스트를 포함시켰다:
- SSR 환경에서의 스크립트 중복 방지
- 클라이언트 네비게이션 시 중복 방지
- 다중 라우트 이동 시 안정성
- 인라인 스크립트 중복 방지
✨ 끝내며
이 두 PR은 모두 머지되어 TanStack Router v1.131.46부터 포함되었다.
이슈를 확인한 결과 최소 10명 이상의 개발자가 같은 문제로 어려움을 겪고 있었는데, 이제 별도의 workaround 없이 head 옵션만으로 스크립트를 사용할 수 있게 되었다.
이 과정에서 배운 점들:
1. React의 설계 철학
React가 개발자 편의성보다 보안을 우선시한다는 것을 코드를 통해 체감했다. 불편해 보이는 제약도 수백만 사용자의 보안을 위한 의도적인 설계였다.
2. 라이브러리 코드의 책임
개인 로컬 환경에서 작동하는 코드와 전 세계 다양한 환경에서 작동해야 하는 라이브러리 코드는 완전히 다르다. SSR, 하이드레이션, 다양한 프레임워크 환경 등 엣지 케이스를 모두 고려해야 했다.
3. "Why"를 질문하는 태도
- "왜 작동하지 않을까?" → 근본 원인 파악
- "어떻게 해결할 수 있을까?" → 올바른 방향 설정
- "더 나은 방법은 없을까?" → 지속적인 개선
이런 질문을 반복하면서 단순한 버그 수정을 넘어 프레임워크를 깊이 이해할 수 있었다.
하나의 작은 버그에서 시작했지만, React 내부 메커니즘을 파헤치고 오픈소스에 기여하면서 많은 개발자들에게 도움을 줄 수 있었다. 앞으로도 문제를 마주했을 때 "Why"를 끊임없이 질문하는 엔지니어로 성장하고 싶다.
관련 링크: