bgyoonsContact

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할지를 정하는 옵션이다.

따라서 렌더가 두 번 일어난다.

  1. 서버: 컴포넌트를 실행해 HTML 문자열 생성(SSR) ← 여기서 터짐
  2. 브라우저: 받은 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)을 연다. 이것이 이번 문제의 출발점이다.

공식 문서

두 진입점이 내보내는 것

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을 적용하면 된다.

터지려면 조건이 모두 겹쳐야 한다:

  1. 패키지가 CJS 진입점 + exports 맵 부재 (오래된 React 래퍼)
  2. default export 사용
  3. 그 값이 서버 렌더 중 실제로 사용됨

dev/prod: externalize는 프로덕션 빌드에도 적용되므로 배포 후에도 동일하게 터진다.

SSR 페이지는 요청마다 500(배포는 성공하므로 늦게 발견), 프리렌더 페이지는 빌드 단계에서 실패(오히려 안전한 실패).