개발
[Checky] Safari에서만 터진 Firestore Listen CORS 에러, firestore/lite + 폴링으로 끝낸 실전 해결기
| 서론
안녕하세요, 팡일입니다.
혹시 firebase/firestore와 firebase/firestore/lite의 차이를 알고 계신가요?
저는 이번 이슈를 겪기 전까지는 “lite는 기능이 조금 빠진 경량 버전” 정도로만 생각하고 있었습니다. 실제 서비스(Checky)에서는 자연스럽게 firebase/firestore SDK를 사용해왔고, 실시간 구독(onSnapshot)도 문제 없이 동작하고 있었습니다.
그러던 중, Safari에서만 아래와 같은 에러가 반복적으로 발생하기 시작했습니다.

Fetch API cannot load https://firestore.googleapis.com/.../Firestore/Listen/channel?...
due to access control checks.(Chrome에서는 정상, Edge에서도 정상, 오직 Safari에서만 실패)
처음에는 Firestore Rules 문제처럼 보였습니다. 그래서 권한 설정을 다시 확인하고, 요청 경로를 점검하고, 인증 상태도 재검토했습니다. 하지만 결론은 전혀 다른 지점에 있었습니다.
최종적으로 저는 결국 firebase/firestore(실시간 Listen 포함) 경로를 과감히 버리고, firebase/firestore/lite + 폴링 구조로 전환해 안정적으로 해결했습니다.
이 글은 아래와 같은 부분을 정리한 기록입니다.
왜 이 선택이 합리적이었는지
어떻게 최소 리스크로 구조를 바꿨는지
실무에서 어떤 기준으로 의사결정을 내렸는지
비슷한 Safari 이슈로 시간을 쓰고 있다면, 이 경험이 분명 도움이 될 거라 생각합니다.
| 문제 한 줄 요약: Safari에서만 Listen/channel이 터진다
증상은 명확했습니다.
Safari 콘솔에서만 CORS 에러 반복 발생
Chrome / Edge 정상 동작
같은 계정, 같은 데이터, 같은 코드
permission-denied가 아니라 네트워크 레벨 access control 차단
즉 현재 마주한 문제의 핵심 포인트는 이것입니다.
이건 “DB 권한 문제”가 아니라 “브라우저 + SDK 전송 경로 문제”일 확률이 높다.
| 왜 firebase/firestore에서 막혔나
firebase/firestore는 onSnapshot을 통해 실시간 구독을 제공합니다. 이 과정에서 내부적으로 Listen/Watch 채널을 열게 됩니다.
문제는 Safari 환경에서 이 Listen 채널이 특정 조건에서 CORS / access control에 걸리는 사례가 존재한다는 점입니다. Safari 환경에서 Listen 스트림 요청이 특정 조건에서 access control에 막히는 경우가 존재하며, 이번 이슈 역시 해당 전송 경로에서 발생한 것으로 판단했습니다.
여기서 중요했던 관찰이 있었습니다.
onSnapshot을 줄였는데도 Listen/channel 요청이 계속 보임
“내가 안 쓴다” ≠ “SDK가 내부 경로를 절대 안 연다”
Rules 문제였다면 보통 Missing or insufficient permissions가 먼저 뜸
즉, 코드는 정상이었는데, 경로가 문제였습니다.
| 해결 전략: firestore/lite + 폴링
이 시점에서 선택지는 두 가지였습니다.
Safari에서 실시간 Listen이 왜 막히는지 끝까지 파고든다
실시간을 포기하더라도, 안정적인 구조로 빠르게 전환한다
저는 두 번째를 선택했습니다.
이 서비스에서 실시간성은 “있으면 좋은 기능”이었지, “반드시 필요한 핵심 기능”은 아니었기 때문입니다. 반면 Safari에서의 장애는 실제 사용자 경험을 깨고 있었습니다.
즉, 이번 의사결정의 기준은 명확했습니다.
-> 실시간성 < 서비스 안정성
1) 전환 방향
구조는 단순하지만, 전략적으로 명확했습니다.
firebase/firestore → firebase/firestore/lite
onSnapshot 제거
getDocs 기반 폴링 구조 도입
여기서 중요한 건 “코드를 조금 고친다” 수준이 아니라, 데이터 동기화 방식 자체를 바꾸는 선택이었다는 점입니다.
기존 구조는 SDK가 내부적으로 Listen/Watch 채널을 열어 서버와 지속 연결을 유지하는 방식이었습니다.
하지만 Lite SDK는 애초에 실시간 구독 기능을 제공하지 않습니다.
즉, 더 이상 Listen/channel 요청이 생성되지 않고, Safari에서 막히던 네트워크 경로 자체가 사라집니다.
2) 왜 이게 통했을까?
핵심은 이것입니다.
-> Lite SDK는 실시간 Listen 기능이 없습니다.
실시간 기능이 없다는 것은 곧,
Watch 스트림을 열지 않고
Listen 채널을 만들지 않으며
이번 이슈의 원인이었던 Listen 스트림 경로에 대한 CORS 리스크는 제거됩니다.
이건 단순한 우회가 아닙니다.
“Safari에서만 문제가 생기는 특정 네트워크 경로”를 설정으로 피한 것이 아니라, 그 경로 자체를 구조적으로 제거한 것입니다. 그래서 해결이 깔끔했습니다.
3) 왜 폴링이 현실적인 선택이었나
폴링은 흔히 “실시간의 하위 호환”처럼 느껴질 수 있습니다. 하지만 모든 서비스가 밀리초 단위 동기화를 필요로 하지는 않습니다.
Checky의 경우, 협업형 문서 편집 서비스가 아니고, 채팅 시스템도 아니며, 초 단위 즉시 반영이 필수 기능은 아니었습니다.
5초 주기의 폴링으로도 UX에 큰 문제가 없었고, 오히려 다음과 같은 장점이 생겼습니다.
Safari 안정성 확보
SDK 경량화
번들 크기 감소
실시간 스트림 관리 코드 제거
결과적으로 구조는 더 단순해졌습니다.
| 구현 방법
1) Firestore 초기화 변경
: 가장 먼저 한 작업은 SDK import 경로를 바꾸는 것이었습니다. 겉보기에는 단순히 import 한 줄을 수정하는 것처럼 보이지만, 실제로는 데이터 동기화 방식 자체를 변경하는 결정이었습니다. 기존에는 실시간 Watch 기능이 포함된 기본 Firestore SDK를 사용하고 있었습니다.
(1) 기존 (Watch 포함 가능):
import { getFirestore } from "firebase/firestore";
export const db = getFirestore(app);: firebase/firestore는 onSnapshot을 통해 Listen/Watch 스트림 기반의 실시간 연결을 유지합니다.Safari 이슈의 원인은 이 실시간 스트림 경로에서 발생했을 가능성이 높았습니다. 그래서 저는 Lite SDK로 전환했습니다.
(2) 변경 (Lite SDK):
import { getFirestore } from "firebase/firestore/lite";
export const db = getFirestore(app);: 이 변경의 핵심은 단순 경량화가 아닙니다. Lite SDK는 실시간 Listen 기능을 아예 포함하지 않기 때문에, 문제가 되었던 네트워크 경로 자체가 생성되지 않습니다. 즉, 설정을 조정한 것이 아니라 원인이 되는 기능을 구조적으로 제거한 것입니다.
2) 구독 제거 → 폴링 유틸 도입
실시간 구독을 제거한 뒤에는 데이터를 어떻게 갱신할지 결정해야 했습니다.
대안은 명확했습니다.
WebSocket 기반 실시간 유지
서버 중계 도입
폴링 방식 채택
서비스 특성과 복잡도를 고려해, 가장 단순하고 안정적인 폴링을 선택했습니다. 이를 위해 getDocs 기반의 폴링 유틸을 작성했습니다.
import { getDocs, type Query } from "firebase/firestore/lite";
const POLL_MS = 5000;
export const subscribeByPolling = <T>(
queryRef: Query<T>,
onNext: (snapshot: Awaited<ReturnType<typeof getDocs<T>>>) => void,
onError?: (error: unknown) => void
) => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const run = async () => {
try {
const snapshot = await getDocs(queryRef);
if (!stopped) onNext(snapshot);
} catch (error) {
onError?.(error);
} finally {
if (!stopped) timer = setTimeout(run, POLL_MS);
}
};
void run();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
};: 이 유틸의 장점은 다음과 같습니다.
기존 unsubscribe 패턴을 그대로 유지 가능
컴포넌트 언마운트 시 clean-up 가능
기존 실시간 구조와 유사한 사용 방식
즉, 코드 전환 비용을 최소화하면서 동기화 방식을 바꿀 수 있었습니다.
3) 사용처 치환
: 가장 중요한 단계는 실제 사용 코드의 교체였습니다. 기존에는 실시간 구독을 사용하고 있었습니다.
(1) 기존:
const unsubscribe = onSnapshot(q, (snapshot) => {
onChange(snapshot.docs.map(...));
});: 이 방식은 즉시 반영이라는 장점이 있지만, Safari에서 문제가 되었던 Listen 경로를 생성합니다. 이를 폴링 유틸로 교체했습니다.
(2) 변경:
const unsubscribe = subscribeByPolling(q, (snapshot) => {
onChange(snapshot.docs.map(...));
});: 여기서 의도한 것은 단순 교체가 아니라, “구독 구조는 유지하되, 내부 동작 방식만 바꾼다” 는 전략이었습니다. 덕분에 컴포넌트 레벨의 로직은 거의 건드리지 않았고, 동기화 방식만 안전한 경로로 변경할 수 있었습니다.
| 결과
적용 후 확인한 변화를 보면 아래와 같습니다.
Safari Listen/channel CORS 에러 재발 없음
CRUD 정상 동작
실시간 관련 코드가 제거되면서 번들 크기도 일부 감소
즉, 문제를 고치려고 바꿨는데, 번들까지 가벼워진 장점이 있었습니다.
| 트레이드오프
물론 단점도 있습니다.
기존에는 실시간을 즉시 반영했지만, 변경 후에는 폴링 주기만큼 지연되는 트레이드오프가 있었습니다.
그래도, Safari 안정성이 최우선 → Lite + 폴링 매우 실용적이며, 실시간 협업 UX 핵심 → 브라우저별 하이브리드 고려하여 이와 같은 선택을 내리게 되었습니다.
완벽한 정답은 없기에, 서비스 우선순위에 맞는 정답이 있을 뿐임을 고려하여 반영하였습니다.
| 체크리스트
만약 본인이 겪고 있는 문제 속에서 아래와 같은 증상이라면 바로 점검해보세요:
Safari에서만 Listen/channel 에러 발생
permission-denied가 아니라 CORS/access control 계열
onSnapshot 제거 후에도 Listen 요청 존재
Lite 전환 후 재현 사라짐
실시간성 저하를 서비스가 허용하는가
| 결론
이번 이슈의 교훈은 단순합니다.
브라우저별 네트워크 계층 문제가 의심될 때는 SDK 설정을 끝없이 건드리기보다, “문제 경로를 구조적으로 제거할 수 있는가?” 를 먼저 생각해보자.
저는 firebase/firestore → firestore/lite + 폴링 전환으로
장애를 멈췄고
서비스 안정성을 회복했고
코드 복잡도도 줄였습니다.
같은 증상을 겪고 있다면, Rules부터 의심하기 전에 이 전략을 먼저 검토해보세요. 시간을 크게 아낄 수 있습니다.