Vite ssrLoadModule 로 브라우저 없이 앱 모듈 통합테스트 — 그리고 잡아낸 tombstone 버그
import.meta.env 를 참조하는 앱 소스는 plain node 로 import 하면 터진다. Vite dev 서버의 ssrLoadModule 로 실제 소스를 Node 에서 그대로 실행하면 브라우저 없이 함수 단위 검증이 된다.
Firestore 로 클라우드 동기화를 구현하고 나서 실제로 되는지 확인해야 했다. 조건이 나빴다.
- 브라우저 자동화 도구가 없는 환경이었다
- Firebase 자격증명은 gitignore 된
.env.local에만 있었다 - 앱 소스가
import.meta.env.VITE_FIREBASE_*를 직접 참조해서, plaintsx나node로 import 하면import.meta.env가undefined라 바로 터진다
마지막 항목이 핵심이다. Vite 앱의 모듈은 Vite 가 변환해준 상태를 전제로 쓰여 있다. Node 가 그냥 읽을 수 없다.
Vite 에게 직접 로드시킨다
Vite dev 서버를 미들웨어 모드로 띄우고 ssrLoadModule 을 부르면, 앱의 .ts 모듈을 Vite 가 처리하는 것과 동일한 방식으로 Node 에서 로드할 수 있다. env 주입도 그대로 된다.
새 패키지를 설치할 필요도 없다. Vite 는 이미 devDependency 에 있다.
import { createServer } from 'vite';
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
const { auth, db } = await server.ssrLoadModule('/src/lib/firebase.ts'); // .env.local 포함 env 정상 주입
const vr = await server.ssrLoadModule('/src/lib/verseRecords.ts');
// 실제 export 를 그대로 호출
await vr.saveNote(...);
await vr.mergeRemote(uid);
await server.close();
앱이 실제로 쓰는 그 코드다. 테스트용 복제본이 아니다.
브라우저 전역은 최소 폴리필로 충분
localStorage 같은 브라우저 전역은 모듈이 호출 시점에 참조한다. localStorage.getItem(...) 은 import 시가 아니라 함수 실행 시 평가된다.
그래서 실행 전에 이것만 꽂으면 된다.
globalThis.localStorage = new MemoryStorage();
jsdom 이 필요 없다. 무엇이 실제로 필요한지 확인하지 않고 jsdom 부터 까는 습관이 있는데, 이 경우엔 과했다.
SDK 는 그냥 import 한다
firebase/auth, firebase/firestore 같은 SDK 함수는 node_modules 에서 직접 import 해서 위에서 받은 auth / db 인스턴스에 쓰면 된다. Node 18+ 는 global fetch 가 내장이라 Firebase Auth 의 REST 호출이 그대로 동작한다.
스크립트는 저장소 안에 둔다
.mjs 파일을 워크트리나 저장소 루트 안에 둬야 한다. module resolution 이 node_modules 를 찾아야 하기 때문이다. 저장소 밖 임시 디렉터리에 두면 이렇게 된다.
ERR_MODULE_NOT_FOUND
검증이 끝나면 스크립트 디렉터리를 지운다. 그리고 생성한 테스트 계정과 문서는 스크립트 안에서 스스로 정리하게 만든다(deleteDoc + deleteUser). 실 프로젝트를 대상으로 돌리는 것이므로 흔적을 남기면 안 된다.
여러 “기기”를 흉내 낼 때의 함정
한 Node 프로세스 안에서 globalThis.localStorage 를 스왑해 기기 A / B 를 흉내 냈다. 여기서 가짜 실패를 한참 쫓았다.
비동기 리스너가 스왑 타이밍과 어긋난다. 기기 B 로 watchRemote 구독을 걸어두고, localStorage 를 기기 A 로 바꿔 write 한 뒤 대기하면, 그 콜백이 fire 되는 시점에 localStorage 가 여전히 기기 A 를 가리킨다. 결과가 기기 A 스토어에 잘못 쓰인다.
로컬 write 는 동기다. 그러니 write 직후 바로 다음 기기 컨텍스트로 복귀한 뒤에 대기해야 콜백이 올바른 스토어를 본다.
setStorage(deviceA);
await vr.saveNote(...); // 동기 write 완료
setStorage(deviceB); // 대기 전에 복귀
await waitForSnapshot();
이렇게 해서 잡은 진짜 버그 — 삭제가 되살아난다
updatedAt 비교로 last-write-wins 병합을 하는 구조였다. 삭제를 deleteDoc 으로 처리했더니 삭제 시점 정보 자체가 사라졌다.
시나리오는 이렇다.
- 기기 A 가 노트를 작성해 원격에 push
- 기기 B 가 로그인하며 병합으로 그 노트를 pull. 로컬에 사본 보유
- 기기 A 가 노트를 삭제. 원격 문서를
deleteDoc으로 완전 삭제 - 기기 B 가 나중에 재병합. 원격에 해당 id 가 없음 → “로컬만 있으니 로컬이 최신”으로 오판 → 삭제된 노트를 원격에 재업로드해 되살림
4번이 문제다. LWW 는 두 값의 시각을 비교하는데, 한쪽이 사라지면 비교할 대상이 없다. “없음”은 “오래됨”과 구분되지 않는다.
해결 — tombstone
삭제할 때도 문서를 지우지 않는다. 모든 필드가 null / false 인 빈 레코드에 새 updatedAt 을 붙여 setDoc 한다.
병합과 실시간 구독 양쪽에서 “받은 레코드가 비어 있으면 로컬에서 제거”로 처리하면, 삭제 시점이 updatedAt 에 남아 있어 LWW 비교가 정확해진다.
로컬 저장소는 빈 레코드를 들고 있지 않게 정리하고, 원격에만 작은 tombstone 문서가 영구적으로 남는다. 그게 트레이드오프다. 문서 수가 줄지 않는 대신 삭제가 정확해진다.
이 버그의 성질
정상 케이스는 전부 통과한다. 특정 시간 순서에서만 터진다. 코드 리뷰로는 놓치기 쉬운 클래스다.
실제 Firestore 를 대상으로 시나리오를 재현해서야 잡혔다. 그게 이 테스트 방식을 만든 이유이기도 하다. 모킹으로는 “내가 상상한 순서”만 검증된다.
정리
ssrLoadModule 은 원래 SSR 을 위한 API 지만, Vite 가 변환한 앱 모듈을 Node 에서 그대로 실행하는 통로로 쓰면 브라우저 없이 통합테스트가 된다. env 주입이 그대로 따라오는 게 가장 큰 이점이다.
같은 프로젝트에서 이 방식으로 검증하다 ?url 로 로드한 에셋 경로 때문에 한 번 더 걸렸는데, 그건 30MB JSON 을 import 했더니 tsc 가 heap OOM 쪽에 fetch 보정 코드까지 적어뒀다.