lottie-react가 Astro SSR에서만 터진 이유 - ESM/CJS interop 트러블슈팅
문제해결 | | 15분 읽기
lottie-react가 Astro SSR에서만 터진 이유
TL;DR
- 증상:
lottie-react의<Lottie>를 SSR 중에 렌더하면Element type is invalid: expected a string or a class/function but got: object→ 500. 브라우저에서는 정상. - 원인:
lottie-react@2.x는exports맵이 없다. 서버에서는 Vite가node_modules를 externalize하므로 Node ESM 로더가main(CJS)을 열고, CJS의module.exports객체({ default: Lottie, ... })를 통째로 default import에 담아준다. 클라이언트는 Vite가module(ESM)을 골라 컴포넌트를 그대로 받는다. - 운영이 멀쩡했던 이유: 유일한 사용처
Loading.tsx가loading === false일 때null을 반환해 서버 렌더 시<Lottie>에 도달하지 않았음. - 해결:
lottie-react@3.x에exports맵이 추가되어 업그레이드로 종결(현재^3.1.1). 업그레이드 전 우리 쪽 임시 해법은vite.ssr.noExternal: ['lottie-react'].
1. 사전 개념 정리
글을 설명하기 이전에 내가 헷갈려했던 부분들과 관련 개념들을 정리했다.
1-1. 두 개의 모듈 시스템
자바스크립트에는 두 개의 모듈 시스템이 있다. CommonJS(CJS)는 Node.js가 오래전부터 사용해오던 것이며, ES6에서 도입된 ESM(EcmaScript modules)은 자바스크립트 명세에 추가된 공식 표준 모듈 시스템이다.
| CJS (CommonJS) | ESM (ES Modules) | |
|---|---|---|
| 출처 | Node.js | JS 언어 표준 |
| 내보내기 | • 하나의 객체를 내보낼 경우: module.exports = ... • 여러 개의 객체를 내보낼 경우: exports.x = ... |
• 하나의 객체를 내보낼 경우: export default ... • 여러 개의 객체를 내보낼 경우: export const x |
| 가져오기 | const m = require('pkg') |
import X from 'pkg' |
핵심 차이: CJS에는 "default"라는 개념이 언어에 없다. module.exports라는 객체 하나만 존재한다. 그래서 ESM 코드가 CJS 패키지를 import X from으로 가져가면 누군가 "default가 무엇인지" 정해줘야(interop) 하고, 그 규칙이 처리 주체마다 다르다.
2. 발견 흐름
2-1. 시작: Node 버전 호환 확인
처음에는 Node 버전에 맞지 않으면 개발 환경도 시작이 안되었던 적이 있었어서 Node 문제인줄 알았다.
그러나 Node 22 / 24에서 동작에 차이가 없었고 astro build도 모두 통과했다.
2-2. 테스트 페이지에서 페이지가 깨지는 것 확인
src/pages/dev/lottie.astro에 <LottieColorfulLoading client:load />를 올렸더니 아래와 같은 에러가 떴다.
Error: Element type is invalid: expected a string (for built-in components)
or a class/function (for composite components) but got: object.
at renderElement (react-dom-server.node.development.js:6356:15)또한 사용하고 있는 부모 컴포넌트(아일랜드)를 client:only="react"로 바꾸면 정상 재생이 되었다.
즉, 서버 렌더 경로에서만 깨진다는 뜻이었다.
왜 **client:load**인데 서버에서 실행될까?
Astro client:* 지시어인 client:load / client:idle / client:visible은 어디서 렌더할지가 아니라 브라우저에서 언제 hydrate할지를 정하는 옵션이다.
따라서 렌더가 두 번 일어난다.
- 서버: 컴포넌트를 실행해 HTML 문자열 생성(SSR) ← 여기서 터짐
- 브라우저: 받은 HTML 위에 JS를 붙임(hydration)
서버 실행을 건너뛰는 유일한 지시어는 client:only="react". 그래서 이걸로 바꾸면 증상이 사라진 것이다.
대신 JS 로드 전까지 빈 공간이 보이고, Island 깊숙이 있으면 그 Island 전체의 SSR을 포기해야 한다.
2-3. 패키지 구조 확인 (lottie-react@2.x)
이 문제에 대해 AI와 원인을 찾던 도중, vite 때문에 번들링과 패키지 해석 주체가 달라진다는 점을 알게 되었다.
{
"main": "build/index.js", // CJS ← Node가 보는 곳
"module": "build/index.es.js", // ESM ← 번들러만 보는 곳
"browser": "build/index.umd.js",
// "type": 없음 → CJS 취급
// "exports": 없음 ← 문제의 출발점
}package.json 진입점 필드 — 누가 어떤 필드를 읽나
| 필드 | 표준 여부 | 누가 읽나 |
|---|---|---|
exports |
표준 | Node, 번들러 모두. 존재하면 main/module을 덮어쓰고 조건(import/require)별 진입점을 지정 |
main |
표준(레거시) | Node, 번들러 모두. exports 없을 때의 기본 진입점 |
module |
비표준 관례 | 번들러만. Node는 무시 |
browser |
비표준 관례 | 브라우저 타깃 번들러 |
exports가 없으면 "누가 해석하냐"에 따라 다른 파일이 열린다. Node는 main(CJS)을, 번들러는 module(ESM)을 연다. 이것이 이번 문제의 출발점이다.
공식 문서
- Node.js — Package entry points (
mainvsexports): https://nodejs.org/api/packages.html#package-entry-points- Node.js —
exports필드 / Conditional exports (import/require조건): https://nodejs.org/api/packages.html#conditional-exports
두 진입점이 내보내는 것
ESM 진입점 build/index.es.js 마지막 줄:
export { Lottie as default, useLottie, useLottieInteractivity };CJS 진입점 build/index.js:
// 3행
Object.defineProperty(exports, '__esModule', { value: true });
// ...
// 664~666행
exports["default"] = Lottie;
exports.useLottie = useLottie;
exports.useLottieInteractivity = useLottieInteractivity;module.exports = { ... } 한 줄로 할당하는 방식이 아니라, Node가 미리 만들어둔 빈 exports 객체에 프로퍼티를 하나씩 추가하는 방식. exports.X = ...는 module.exports.X = ...와 같고(같은 객체 참조), 파일 실행이 끝난 시점의 누적 결과가 최종 module.exports다. "담는 순간"이 한 곳에 없어서 찾기 어려웠던 이유.
결과적으로 CJS 파일의 최종 산출물은 module.exports = { __esModule: true, default: Lottie, useLottie, ... } ⇒ 컴포넌트가 아니라 컴포넌트를 담은 포장 객체다.
2-4. 왜 서버에서만 CJS 파일이 열리고, 열리면 왜 객체가 되나
(1) 서버는 externalize 때문에 Node가 파일을 고른다
externalize는 번들링의 반대다.
// 번들링: 패키지 코드를 산출물에 복사. import 해석은 빌드 시 Vite가 끝냄
const Lottie = function LottieComponent(props) { ... };
// externalize: import 문을 그대로 남김. 실행 시 Node가 node_modules에서 로드
import Lottie from 'lottie-react';Vite SSR의 기본 전략
- 클라이언트 → 전부 번들링
- 서버 →
node_modules는 externalize.
서버는 어차피 Node 위에서 돌고 Node가 node_modules를 직접 로드할 수 있으니 수십만 줄을 복사할 이유가 없다(빌드 속도, dev 재시작 속도). 이 전략은 dev 서버뿐 아니라 프로덕션 빌드(dist/server/)에도 동일하게 적용된다.
| 해석 시점 | 해석 주체 | 인식하는 것 | |
|---|---|---|---|
| 번들링 | 빌드 시 | Vite | module 필드, __esModule 존중 |
| externalize | 실행 시 | Node | exports 맵만 인정, CJS는 module.exports 통째 |
그래서 같은 import Lottie from 'lottie-react' 한 줄이 클라이언트에서는 Vite가(→ module → ESM), 서버에서는 Node가(→ main → CJS) 해석한다.
(2) CJS 파일을 연 다음, **default**를 무엇으로 볼지가 갈린다 — __esModule 표식과 interop 규칙 차이
2-3에서 본 __esModule: true는 "원래 ESM이었다. default import 시 exports.default를 달라"는 번들러 간의 관례다.
- Vite(번들러): 관례를 존중.
import X from 'cjs'→X = exports.default→ 컴포넌트 ✅ - Node ESM 로더: 관례를 무시(표준이 아님). ESM 스펙상 CJS의 default는 항상
module.exports그 자체 → 포장 객체 ❌
Node의 규칙을 정확히 쓰면 객체가 아니라 module.exports****에 담긴 값 그대로이다. module.exports = Lottie(함수 직접 할당)인 옛 CJS 패키지라면 Node에서도 정상 동작한다. lottie-react는 담긴 값이 하필 { default, useLottie, ... } 포장 객체였기에 문제가 됐다.
3. 한 문장으로 요약
CJS 파일은 항상 { default: 컴포넌트, ... } 객체를 내놓는다. 이걸 import Lottie from으로 받을 때 Vite는 그 안의 **.default**를 꺼내주고 Node는 객체를 통째로 넘겨서, Node가 해석하는 서버에서만 Lottie가 객체가 되어 렌더 시 에러가 났다."
내놓는 쪽(CJS 파일)은 하나뿐이고, 달라지는 건 vite, node의 규칙. 클라이언트는 module 필드의 ESM 파일을 열었기 때문에 interop 상황 자체가 없었다.
4. 해결책 비교
A. ssr.noExternal
// astro.config.mjs
vite: { ssr: { noExternal: ['lottie-react'] } }/dev/lottie + client:load로 실측:
- HTTP 500 → 200, HTML 정상 생성
- 콘솔 error/warn 0건, hydration mismatch 없음
- 서버가 뱉은 Lottie 마크업:
<div></div>
마지막 항목의 의미: lottie-react는 useEffect에서 lottie-web.loadAnimation()으로 컨테이너를 채우므로 SSR 산출물은 빈 div 하나. SSR로 얻는 시각적 이득은 0이고, 동시에 서버/클라 초기 렌더가 동일해 hydration mismatch도 없다. → "서버면 wrapper만 렌더" 식의 우회는 오히려 mismatch를 새로 만들므로 비추천.
B. 최종 해결: lottie-react@3.x 업그레이드
비슷한 문제를 겪은 유저들이 분명 있을 것이라 생각하여 깃헙 issues에 검색해봤는데, exports 맵 추가가 머지되어 있었다. (뭔가 허탈)
현재 설치본(3.1.1):
"exports": {
".": {
"import": { "types": "./build/index.d.ts", "default": "./build/index.js" },
"require": { "types": "./build/index.d.cts", "default": "./build/index.cjs" }
}
}Node가 import 조건으로 진짜 ESM 파일을 열게 되어 interop 단계 자체가 사라짐. externalize 전략은 유지되므로 noExternal 설정은 불필요.
5. 결론
해당 문제는 Astro 고유 문제가 아니라 "Vite SSR + externalize" 를 공유하는 모든 프레임워크의 공통 취약점이다. 해법도 동일하게 ssr.noExternal을 적용하면 된다.
터지려면 조건이 모두 겹쳐야 한다:
- 패키지가 CJS 진입점 +
exports맵 부재 (오래된 React 래퍼) - default export 사용
- 그 값이 서버 렌더 중 실제로 사용됨
dev/prod: externalize는 프로덕션 빌드에도 적용되므로 배포 후에도 동일하게 터진다.
SSR 페이지는 요청마다 500(배포는 성공하므로 늦게 발견), 프리렌더 페이지는 빌드 단계에서 실패(오히려 안전한 실패).