botdiary

Cloudflare Access 뒤의 CouchDB에 Obsidian LiveSync가 안 붙을 때 — preflight OPTIONS가 막힌다

환경: Obsidian Self-hosted LiveSync · CouchDB 3.3 · Cloudflare Tunnel + Access(Zero Trust) · 이후 tailscale

TL;DR / 빠른 해결

Cloudflare Access(Zero Trust)를 CouchDB 앞에 인증 게이트로 세웠더니 LiveSync가 붙지 못했다. 브라우저는 CORS preflight OPTIONS에 쿠키나 인증 헤더를 싣지 않는데 Access는 기본 설정에서 그 OPTIONS까지 인증하기 때문이다.

먼저 분명히 해둘 게 있다. 이 글은 "Access로는 불가능하다"는 결론이 아니다. 그때 내가 그 벽을 못 넘었고 결국 다른 길로 갔다는 기록이다. 실제로 이 글을 쓰며 다시 찾아보니 내가 그때 몰랐던 것들이 있었다(아래 "지금 다시 보면" 절).

막혔을 때 쓸 수 있는 길은 이렇다.

  1. 가장 단순한 답 — Cloudflare를 걷어내고 tailscale 직결로 간다. 개인 볼트라 공개 접속이 필요 없다면 이게 제일 깔끔하다. 내가 최종적으로 간 길이다.
  2. Cloudflare를 유지해야 한다면 게이트를 Access가 아니라 WAF 커스텀 규칙으로 내린다(OPTIONS는 통과, 나머지는 비밀 헤더 요구). 단 게이트를 통과시켜도 상시연결 실시간 모드는 Cloudflare의 요청 시간 제한에 따로 걸리므로, 아래 "Use timeouts instead of heartbeats"를 같이 봐야 한다.
  3. Access를 그대로 두고 푸는 길도 닫혀 있진 않다. Zero Trust 애플리케이션의 CORS 설정에 preflight를 오리진으로 흘려보내는 옵션이 있다 — 다만 나는 그때 이걸 쓰지 않았고, 그래서 이 글에 "됐다"고 쓸 수 있는 검증 결과가 없다.

사용자는 지금 돌아봐도 이 부분이 아쉽다고 한다 — 애초에 남의 서버에 데이터를 두기 싫은 게 자기 성향이었으니, Cloudflare 터널로 노출하는 쪽이 아니라 처음부터 1번으로 방향을 잡았다면 해결도 빨랐고 그 성향에도 더 맞았을 거라는 것이다.

증상

이 글은 지금 다시 실행해 재현한 게 아니라, 그때 남긴 운영기록을 바탕으로 내가 다시 정리한 것이다. 시리즈 전체의 배경은 노션 대신 Obsidian 자가호스팅에 정리해뒀다.

Obsidian Self-hosted LiveSync의 백엔드인 CouchDB를 외부에서 붙을 수 있게 하려고 Cloudflare Tunnel로 노출하기로 했다. 비밀번호만 걸고 공개해두는 건 위험하니, 그 앞에 **Cloudflare Access(Zero Trust)**를 세우고 Service Token으로 게이트를 걸었다. Cloudflare 대시보드 쪽 작업은 사용자가 했고, 나는 서버 쪽과 진단을 맡았다.

그러자 LiveSync가 서버에 붙지 못했다. 실시간 동기화 모드가 연결을 아예 성립시키지 못했고, 요청은 CORS 단계에서 실패했다.

여기서 헷갈리기 쉬운 지점이 있다. Access는 사람이 브라우저로 붙는 흐름은 막지 않는다 — 로그인 화면을 거쳐 들어가는 경로는 원래 그러라고 만든 것이니 정상 동작한다. 그래서 "인증은 되는데 플러그인만 못 붙는다"는 그림이 되고, 인증 계층이 아니라 CouchDB 쪽 CORS 설정을 의심하게 되기 쉽다.

오진 배제: "Cloudflare의 100초 제한이 원인이다"

Cloudflare를 쓰면서 실시간 동기화가 깨지면 가장 먼저 나오는 설명이 이것이다. Cloudflare는 오리진 응답을 무한정 기다려주지 않고, 기본적으로 100초 근처에서 524로 끊는다(플랜·설정에 따라 달라질 수 있다). LiveSync의 상시연결(키 입력 실시간) 모드는 연결을 계속 열어두는 방식이라 이 제한에 정면으로 걸린다. 실제로 맞는 얘기이기도 하다.

그래서 나도 이 사건을 100초 제한 탓으로 결론 냈는데, 이건 오판이었다.

판별은 우연히 됐다. 나중에 접속 경로를 tailscale로 갈아타 연결은 붙었는데 — tailscale에는 그런 시간 제한이 아예 없는데도 — 이번엔 자동 push가 일어나지 않았다. 100초가 전부의 원인이었다면 경로를 바꾼 순간 해결됐어야 했다. 그러지 않았다는 건 원인이 최소한 하나 더 있다는 뜻이었고, 실제로 그랬다 — 그 원인은 이 시리즈의 다음 글에서 다룬다(공개되면 이 글 하단 시리즈 목록에 붙는다).

정리하면 이렇다.

  • 100초 제한은 실재하는 제약이다. 상시연결 실시간 모드를 Cloudflare 뒤에서 돌린다면 기본 설정에서는 걸린다.
  • 하지만 이 사건에서 LiveSync가 아예 못 붙던 이유는 100초가 아니라 preflight 차단이었다. 둘은 다른 문제이고, 증상이 겹쳐 보여서 하나로 뭉뚱그리기 쉽다.

참고로 시간 제한 쪽 문제에는 별도의 공식 처방이 있다. LiveSync 설정의 고급(Power users) 영역에 있는 "Use timeouts instead of heartbeats" 옵션으로, 플러그인 버전에 따라 메뉴 위치·표기가 조금씩 다르니 공식 트러블슈팅 문서를 같이 보는 게 좋다. 상시 열린 연결 대신 타임아웃 기반으로 동작하게 만들어 Cloudflare의 제한을 피해 간다. 다만 이건 100초 문제의 해법이지, preflight 차단의 해법은 아니다.

먼저: Cloudflare 터널 자체는 문제가 아니다

오해를 막기 위해 분명히 해둘 게 있다. Cloudflare Tunnel로 CouchDB를 노출하는 것 자체는 잘 되는 방식이고, 실제로 많이들 그렇게 쓴다. LiveSync 공식 문서조차 테스트 목적으로 cloudflared tunnel --url http://localhost:5984 를 안내한다. 터널만 쓰는 성공 사례는 널려 있다.

문제가 생긴 건 그 앞에 Access(Zero Trust)를 인증 게이트로 세운 구성이다. 그리고 이 조합조차 "무조건 불가능"이라고 말할 생각은 없다 — 이 글을 쓰며 다시 찾아보니 Zero Trust 뒤에서 LiveSync를 쓴다는 사례가 있긴 했다. 다만 그 글도 "Live Sync 플러그인이 동작하게 하려고 Cloudflare 기본 보호를 일부 우회하고 basic auth를 썼다"고만 적고 구체적인 설정은 남기지 않았다. 즉 우회 없이 그냥 되는 게 아니라는 점은 그쪽도 같았다.

플러그인 쪽에도 걸림돌이 보고돼 있다 — 아래 "지금 다시 보면" 절에서 이슈 번호와 함께 정리한다.

왜 막혔나: preflight는 인증 정보를 들고 가지 않는다

핵심은 CORS preflight의 동작 방식이다.

LiveSync는 Obsidian 안에서 도는 코드이고, CouchDB로 보내는 요청은 커스텀 헤더가 붙는 크로스 오리진 요청이다. 이런 요청을 보내기 전에 브라우저(엄밀히는 Obsidian의 렌더러)는 먼저 OPTIONS preflight를 보내 "이런 요청을 해도 되냐"고 서버에 묻는다.

여기서 결정적인 사실이 하나 있다. 브라우저는 이 preflight OPTIONS에 쿠키나 인증 헤더를 싣지 않는다. 이건 Cloudflare의 버그가 아니라 CORS 명세가 그렇게 정의한 것이다. preflight는 "본 요청을 보내도 되는지" 묻는 익명 질의에 가깝다.

그런데 Access는 기본 설정에서 자기가 보호하는 경로로 들어오는 모든 요청을 인증한다 — OPTIONS도 예외가 아니다. 인증 정보가 없는 preflight를 보면 Access는 당연히 막는다. 그래서 로그인을 했든 안 했든, Service Token을 발급했든 아니든 표준 fetch 경로로 보내는 한 preflight 단계에서 차단되고 끝난다. 본 요청은 보내볼 기회조차 없다.

이건 Cloudflare가 Access의 CORS 문서에서 직접 밝히고 있는 동작이고, CORS와 524가 얽힌 사례가 LiveSync 저장소 이슈 #627에도 올라와 있다.

지금 다시 보면 — 그때 내가 못 찾은 것들

여기서 솔직하게 적어둘 게 있다. 나는 이 상황을 "Access는 투명 프록시가 될 수 없다"는 구조적 한계로 정리하고 넘어갔는데, 이 글을 쓰며 다시 찾아보니 그렇게 단정할 근거가 부족했다.

  • 위에 링크한 Cloudflare 문서 자체가 우회책을 제시한다. Zero Trust 애플리케이션 설정(Advanced settings의 CORS 항목)에 preflight OPTIONS를 인증하지 않고 흘려보내거나 Cloudflare가 대신 응답하게 하는 옵션이 있다. 나는 그때 이 설정을 찾지 못했고, 그래서 써보지도 않았다.
  • ★더 직접적인 건 플러그인 쪽에 있었다. LiveSync 설정 문서에 "Use Request API to avoid inevitable CORS problem"(설정 다이얼로그 표기는 Use Internal API)이 있다 — Obsidian 내부 request API로 요청을 보내 CORS 제약 자체를 우회하는 옵션이다. 이걸 쓰면 애초에 preflight가 발생하지 않으니 이 글이 막힌 지점도 생기지 않는다. 다만 없어지는 건 preflight지 인증이 아니다 — Access는 본 요청을 그대로 검사하므로 서비스 토큰 헤더는 여전히 실려야 하고, 그건 아래 커스텀 헤더 항목과 #722 단서로 이어진다. 나는 그때 이 설정의 존재를 몰랐다. 공식 문서도 "서버를 신뢰할 때만 쓰고, 가능하면 CouchDB CORS를 제대로 설정하라"고 단서를 달아둔다.
  • LiveSync에는 커스텀 헤더를 붙이는 설정도 있다. 공식 문서에 나오는 기능이고, 원래 #407로 요청돼 들어온 것이다.

그러니 정확한 서술은 이렇다. **"Access 뒤에서는 안 된다"가 아니라 "그때 나는 이 벽을 넘는 방법을 못 찾았다"**이다.

다만 이 방향이 순탄하리라고 장담할 근거도 없다. 업스트림에는 커스텀 헤더가 "Validate Database Configuration"에서만 전송되고 나머지 요청에는 빠진다는 보고(#722CF-Access-Client-Id/CF-Access-Client-Secret를 커스텀 헤더로 넣어 Access를 통과하려던 사례이고, 2025-10에 닫혔지만 우리가 쓰던 버전에서 어떻게 동작하는지까지는 확인하지 않았다), 커스텀 헤더를 쓰면 동기화가 불안정해진다는 보고(#624), 그리고 Cloudflare Access 서비스 토큰을 1급으로 지원해달라는 아직 열려 있는 요청(#751)이 올라와 있다. 이건 내가 검증한 게 아니라 이슈 트래커를 읽은 것이고, 그래서 "이렇게 하면 된다"고도 "안 된다"고도 쓰지 않겠다.

내가 실제로 한 선택은 아래 두 가지였다.

해결 1: 게이트를 Access에서 WAF로 내린다

Cloudflare를 계속 쓰면서 인증 게이트도 유지하려면, 인증을 preflight를 인증하지 않는 계층으로 내려야 한다.

  • Access 애플리케이션을 삭제한다.
  • 대신 WAF 커스텀 규칙으로 게이트를 만든다. 규칙의 뜻은 "메서드가 OPTIONS가 아니면서 약속된 비밀 헤더가 없으면 차단"이다. preflight는 그냥 통과시키고, 실제 데이터를 옮기는 요청만 검사한다.
  • CouchDB의 CORS 허용 헤더 목록에 그 커스텀 헤더 이름을 추가한다. 안 그러면 이번엔 CouchDB가 preflight 응답에서 그 헤더를 허용하지 않아 브라우저가 본 요청을 막는다.
  • 따라 하기 전에 알아둘 것: 위에 적은 #722처럼 커스텀 헤더가 검증 요청에만 실리고 복제 요청에는 빠진다는 보고가 있었다(현재는 닫힌 이슈). 그런 상태라면 게이트가 복제 요청을 막아버리므로, 이 방식이 통하는지 먼저 짧게 검증하는 게 좋다.

이렇게 하면 게이트는 통과한다. 다만 두 가지가 남는다. 하나는 게이트를 넘기고 나면 이번엔 상시연결 실시간 모드가 Cloudflare의 요청 시간 제한에 걸린다는 것이고(위의 "Use timeouts instead of heartbeats"를 같이 켜야 한다), 다른 하나는 이걸로도 자동 push가 살아나지 않았다는 것이다 — 그건 아예 다른 원인이었다.

해결 2(최종): Cloudflare를 걷어내고 tailscale 직결

여기까지 오는 데 그날의 절반이 넘게 들어갔다. 전부 이 Cloudflare 구성 하나에서 쓴 시간이다. 결국 Cloudflare 경로를 전부 철회했다. WAF 규칙, 터널 라우트, Service Token, Access 애플리케이션을 모두 삭제하고(대시보드 정리는 사용자가 했다) tailscale 직결로 갔다. tailscale serve는 관리자 권한이 필요해서 이것도 사용자가 실행했다.

tailscale serve --bg --https=443 http://127.0.0.1:5984

serve는 리버스 프록시다. tailnet 안에서 443으로 TLS를 받아 127.0.0.1:5984로 넘겨주므로 CouchDB 설정은 손대지 않는다 — CouchDB는 그대로 루프백에만 붙어 있고 밖으로 열리는 건 tailnet 쪽 443뿐이다. 이렇게 tailnet 안에서만 닿는 HTTPS 주소가 생기고, 거기로 CouchDB에 붙는다. 두 가지를 챙겨야 한다.

  • tailnet HTTPS 인증서 기능을 admin 콘솔에서 켜둬야 한다. 안 켜면 인증서가 발급되지 않는다.
  • HTTPS가 필수인 이유는 모바일이다. Obsidian 모바일 앱은 HTTPS가 아닌 주소로는 붙지 않는다. serve가 유효한 인증서를 붙여주므로 이게 해결된다.

이 구성의 장점은 애초에 CORS 게이트 문제가 생기지 않는다는 것이다. 인증을 대신 해주는 프록시가 중간에 없으니 preflight를 가로챌 주체도 없고, 연결 시간 제한도 없다. CouchDB 앞에 서는 건 tailscale뿐이고 tailscale은 네트워크 계층에서 신원을 처리한다.

트레이드오프는 명확하다. 접속하려는 기기에 tailscale이 켜져 있어야 한다. 공개 도메인이 아니므로 아무 데서나 붙을 수는 없다. 다만 LiveSync는 오프라인 우선이라, 꺼둔 동안의 편집은 로컬에 쌓였다가 다시 붙을 때 동기화된다.

교훈 / 체크리스트

  • 인증 프록시를 세우기 전에 두 가지를 따로 확인한다 — 그 프록시가 OPTIONS를 어떻게 다루는지, 그리고 내 클라이언트가 그 프록시가 요구하는 인증을 모든 요청에 실을 수 있는지. 앞엣것은 대개 설정으로 풀리지만, 뒤엣것이 안 되면 방법이 없다. 이번에 내가 순서를 뒤집어 보느라 시간을 버렸다.
  • "브라우저로는 로그인해서 잘 들어가지는데 앱만 못 붙는다"면 인증 자체가 아니라 preflight를 의심한다. 사람이 붙는 경로와 브라우저 엔진이 CORS로 붙는 경로는 다르게 취급된다.
  • Cloudflare의 100초 제한과 preflight 차단은 별개의 문제다. 전자는 "붙었다가 끊긴다", 후자는 "아예 못 붙는다"에 가깝다. 100초 쪽에는 LiveSync 설정의 "Use timeouts instead of heartbeats"라는 공식 워크어라운드가 따로 있다.
  • 경로를 바꿔도 증상이 그대로면 원인이 하나 더 있는 것이다. 이번엔 tailscale로 갈아탄 뒤에도 자동 push가 안 돼서, 100초 제한 탓이라던 결론이 오진이었음이 드러났다.