🔑 핵심 요약
✦ AI·GEN홈랩에 흩어진 데이터베이스——블로그·n8n·Uptime Kuma·3x-ui·Jarvis·NAS의 여섯 SQLite에 MongoDB 하나——를 어디서든 열 수 있고 앞에 2FA를 둔 하나의 web 콘솔로 통합하기까지의 기록. 먼저 선정부터: 왜 "바꾸기 위해" Postgres로 옮기지 않는지, sqlite-web·Adminer·Beekeeper·CloudBeaver를 한 바퀴 돌아 결국 Outerbase Studio에 안착한 과정, 외부 게이트를 Cloudflare Tunnel이 아닌 자체 호스팅 Authelia로 정한 이유. 이어 구축 중의 함정을 하나씩: 공식 Docker image는 로컬 파일을 못 읽고(오직 npm CLI만 가능), ATTACH는 데이터는 보이나 테이블은 안 보이며, WAL은 읽기 전용 마운트가 안 되고(끝내 "메인 파일 읽기 전용, 디렉터리 쓰기 가능" 두 줄로 해결), libSQL과 표준 SQLite의 동시 쓰기 실측, 호스트의 mongod에 못 닿는 컨테이너, 그리고 Service Worker가 데이터베이스 서브도메인을 블로그로 바꿔치기하는 골칫거리. 마지막으로 forward-auth로 전체를 하나의 Authelia 2FA 게이트 뒤에 두고, 이 함정들 중 정말 어려운 건 없었으며——어려운 건 '컨테이너가 떠서 200을 반환한다'를 너무 일찍 '완료'로 친 것이었다고 돌아본다.
제 서버에는 "내 데이터베이스"가 여기저기 흩어져 있습니다. 블로그, n8n, Uptime Kuma, 3x-ui, Jarvis, NAS가 각각 SQLite를 가지고 있고, 다른 프로젝트에는 MongoDB가 하나 더 있죠. 데이터를 좀 보려 할 때마다 컨테이너에 ssh로 들어가 sqlite3를 치거나, 일회용 웹을 띄우거나, 노트북에 또 Compass를 까는 식이었습니다.
진짜 아픈 건 SQLite가 나빠서가 아니라, 그게 서버 안에 묻힌 파일 하나라서, 실시간으로 들여다보기 어렵고 손보기는 더 어렵다는 점이었습니다.
원하는 건 단순했습니다. 어디서든 열어 보고 수정할 수 있고, 앞에 2FA가 걸려 있는 관리 콘솔 하나. 그런데 이 "단순함"이 Docker, SQLite의 WAL, libSQL, 방화벽, Service Worker에 얽힌 자잘한 이야기들을 줄줄이 끌어냈습니다. 이 글에는 밟은 함정과 빠져나온 방법을——도구를 고르며 돌아간 길까지 포함해——하나씩 적어 둡니다.
SQLite를 서둘러 Postgres로 갈아타지 말 것#
첫 갈림길은 도구가 아니라, 아예 SQLite를 버릴지였습니다. "Postgres로 가야 하나?" 한번 생각했고 Innei가 뭘 쓰는지도 찾아봤죠——결과는 MongoDB + Redis. 하지만 그건 그의 백엔드가 여러 사람이 설치하는 문서형 CMS이기 때문이고, 단일 머신·단일 백엔드·읽기 위주인 제 개인 사이트와는 완전히 다른 세계입니다. 따라 할 이유가 못 됩니다.
제대로 따져 본 결론: 제게 SQLite는 타협이 아니라 정답입니다. Postgres로 갈아탈 유일하게 값어치 있는 이유는 언젠가 AI 의미 검색에 pgvector가 필요할 때——그리고 그 규모의 변경은 "백엔드를 통째로 Rust로 다시 쓰기"와 묶어서 해야지, 지금 바꾸기 위해 바꿀 일이 아닙니다.
그래서 문제를 다시 정의했습니다: 아픈 곳은 SQLite가 아니라 "파일에 묻힌 데이터베이스는 실시간으로 보고 고치기 어렵다"는 것. 이건 도구로 풀 일이지, DB를 갈아탈 일이 아니다.
예쁘면서 원격 파일도 여는 도구 찾기#
조건은 둘: UI가 예뻐야 하고(못생긴 도구엔 인내심이 없다), 서버 위의 로컬 파일을 열 수 있어야 한다. 이 둘이 겹치니 의외로 어려웠습니다:
- VS Code의 SQLite 확장: 지금 쓰는 것. 보기만 되고 편집이 불편해서——이게 통증의 출발점.
- sqlite-web / Adminer: 컨테이너 하나로 돌고 뭐든 먹지만, 그 밋밋한 Flask / PHP 도구 느낌에 한눈에 탭을 닫게 된다.
- Beekeeper Studio / TablePlus: 데스크톱 진영은 UI가 예쁘지만, 내 SQLite는 원격 파일이라 데스크톱 도구는 먼저 sftp나 동기화로 내려받아야 해서 굼뜨다.
- CloudBeaver(DBeaver의 web 버전): 서버 측 Java, JDBC SQLite driver 내장으로 서버 위 파일을 직접 열 수 있고 WAL 멀티프로세스도 안전——기술적으론 가장 탄탄해서 하마터면 이걸 쓸 뻔했다.
그래도 저는 Outerbase Studio가 갖고 싶었습니다. 이 중 가장 현대적이고 눈에 가장 편했으니까요(Notion 느낌이 좀 난다). 그런데 공식 Docker image를 받아도 서버의 .sqlite를 못 읽습니다. 의존성을 보면 이유가 분명하죠:
$ docker run --rm --entrypoint cat outerbase/studio /app/package.json | grep -iE "sqlite|libsql"
"@libsql/client": "^0.5.3"@libsql/client뿐. 서버 쪽에서 로컬 파일을 열 수 있는 better-sqlite3 같은 건 없습니다——이 image는 browser-first라 설계상 Turso / libSQL / D1 같은 네트워크 DB에 붙고, "로컬 파일"은 브라우저로 여는 내 PC의 파일을 뜻합니다.
"Outerbase로는 안 되니 CloudBeaver로 바꾸자"고 결론 낼 뻔했습니다——npm을 파헤치기 전까지는. 별도로 CLI @outerbase/studio도 배포하는데, studio <path>는 호스트에 서버를 띄워 서버의 파일을 직접 열고, basic auth까지 내장돼 있습니다. 그래서 그 CLI만 돌리는 아주 작은 컨테이너를 직접 만들었습니다:
FROM node:20-alpine
RUN npm install -g @outerbase/studio@0.2.7 # 버전 고정, runtime이 latest를 받지 않게
ENTRYPOINT ["studio"]TIP
여기서의 교훈은 직설적입니다: 하나의 산출물(공식 Docker image)만 보고 도구 전체를 단정하지 말 것. 어떤 도구의 npm CLI가 하는 일은 그 Docker image가 하는 일과 완전히 별개다. CloudBeaver로 돌아갔다 온 건, 바로 "image가 못 읽는다"를 "이 도구는 안 된다"로 성급히 단정했기 때문.
DB 하나에, 인스턴스 하나#
SQLite에는 ATTACH DATABASE가 있어서, 이론상 여섯 개 DB를 한 연결에 매달아 한 번에 볼 수 있습니다. 실제로 붙여 봤지만, Outerbase의 테이블 브라우저는 메인 DB의 스키마만 인식합니다. attach한 다섯 DB는 쿼리(SELECT * FROM 별칭.테이블)로는 나오는데, 사이드바엔 테이블이 하나도 안 뜹니다.
그래서 DB당 인스턴스 하나로 바꾸고, 각각 --base-path를 주고, nginx로 경로별로 한 도메인에 모았습니다:
# docker-compose.yml(발췌)
ob-web: { command: ["/data/db.sqlite", "--port","4000","--base-path","/web"] }
ob-n8n: { command: ["/data/database.sqlite","--port","4002","--base-path","/n8n"] }
ob-kuma: { command: ["/data/kuma.db", "--port","4003","--base-path","/kuma"] }
# …xui / jarvis / nas 도 동일location /web/ { proxy_pass http://127.0.0.1:4000; }
location /n8n/ { proxy_pass http://127.0.0.1:4002; }
location /kuma/ { proxy_pass http://127.0.0.1:4003; }여섯 인스턴스는 같은 db.koimsurai.com 아래에 모입니다(루트 경로는 자동으로 /web로). 매번 어느 경로가 어느 DB인지 외우기 귀찮아서, 직접 만든 다크 카드 메뉴를 첫 화면으로 두고 카드 여섯 장이 각각 한 DB로 들어가게 했습니다. 이 SPA가 base-path 아래에서 에셋 경로 문제로 깨지지 않는지 확인하려고, 헤드리스 브라우저로 실제로 돌려 완전히 렌더링되는 걸 확인한 뒤 마무리했습니다.
읽기 전용: 크게 돌아온 끝의 두 줄#
n8n, Kuma, 3x-ui 같은 서드파티 서비스는 테이블 구조를 몰라서 보기만 하고 손이 미끄러져 망가뜨리고 싶지 않습니다. 이 길에선 두 번 헛걸음을 했습니다.
첫 번째는 snapshot: 5분마다 sqlite3 .backup으로 사본을 만들고 Outerbase가 사본을 읽게 하는 sidecar. 그런데 .backup의 사본은 원본의 WAL 모드를 물려받아 사본 자체도 WAL이라 여전히 읽기 전용으로 안 열리고, PRAGMA journal_mode=DELETE로 비-WAL로 바꿔야 했습니다. 하지만 더 근본적인 문제는, 이때 보이는 건 사본이지 실시간 데이터가 아니라는 것——처음의 "실시간으로 보고 싶다"는 요구와 정면으로 어긋납니다. snapshot은 통째로 폐기.
두 번째가 본론: 파일을 직접 읽기 전용으로 마운트하기. 가장 직관적인 docker :ro는 여는 순간 터집니다:
LibsqlError: SQLITE_CANTOPEN: unable to open database file
원인은 . WAL 모드는 읽기에도 공유 메모리 인덱스 가 필요하고, 파일시스템 전체를 읽기 전용으로 하면 -shm을 못 만들어 안 열립니다. 이어서 연결 문자열에 SQLite의 읽기 전용 파라미터를 넘겨 봤더니, @libsql/client가 딱 잘라 거부합니다:
LibsqlError: URL_PARAM_NOT_SUPPORTED: Unsupported URL query parameter "mode"
?mode=ro도 ?immutable=1도 안 받습니다. 여기서 막혀, 문제를 가장 원초적인 형태로 줄여 실험했습니다——메인 파일만 읽기 전용으로 하고, 디렉터리는 쓰기 가능하면?
// 백그라운드 프로세스가 계속 쓴다(app이 돌아간다고 가정해 -shm을 살려 둠); 메인 파일은 chmod 444 완료
const db = createClient({ url: "file:/tmp/w.db" });
await db.execute("SELECT count(*) FROM t"); // → 56행 읽힘(백그라운드가 방금 쓴 것 포함)
await db.execute("INSERT INTO t(v) VALUES('x')");
// → SQLITE_READONLY: attempt to write a readonly database됐습니다. 원리는 이렇습니다: SQLite는 "메인 DB 파일이 쓰기 가능한가"로 연결 전체가 읽기 전용인지 결정한다. 메인 파일이 읽기 전용이면 연결도 읽기 전용, 쓰기는 일괄 거부. 한편 -shm / -wal은 쓰기 가능한 디렉터리에 있으니 WAL의 라이브 데이터는 그대로 읽힙니다. Docker로는 두 줄——디렉터리는 rw, 메인 파일에 :ro를 한 겹 덧씌운다:
volumes:
- /path/service-data:/data # 디렉터리 rw: -shm용
- /path/service-data/db.sqlite:/data/db.sqlite:ro # 메인 파일 읽기 전용: 쓰기 차단컨테이너 안에서는 root도 못 씁니다——파일시스템 계층()과 SQLite 계층(SQLITE_READONLY)에서 이중으로 막힙니다:
$ docker exec ob-n8n sh -c 'dd if=/dev/zero of=/data/database.sqlite bs=1 count=1'
dd: can't open '/data/database.sqlite': Read-only file system쓰기 가능한 DB는 깨지지 않을까#
web, Jarvis, NAS는 제 것이라 쓰기 가능으로 마운트합니다. 그런데 libSQL 엔진과 app이 쓰는 표준 SQLite가 같은 WAL 파일을 동시에 쓰면——충돌해서 깨뜨리지 않을까? 추측 대신, 두 프로세스(Python 내장 sqlite3를 "표준 엔진", @libsql/client를 Outerbase 엔진으로)로 같은 파일을 5초간 두드려 검증했습니다:
# 1라운드(libSQL busy_timeout 없음)
표준 sqlite3 삽입: 24803건
libSQL: 0건 — SQLITE_BUSY: database is locked
integrity_check: ok
# 2라운드(libSQL에 busy_timeout=8000)
양쪽 다 삽입됨
integrity_check: ok핵심은 1라운드의 SQLITE_BUSY. 이건 libSQL이 표준 SQLite의 락을 보고 있고 존중한다는 증거로, 줄을 서는 것이지 제멋대로 같이 쓰는 게 아닙니다(libSQL엔 "Virtual WAL"이라는 자체 인터페이스가 있어 그게 처음 불안의 씨앗이었습니다); 그리고 integrity_check는 두 번 다 ok. 최악이라도 쓰기가 락을 기다릴 뿐, 파일은 깨지지 않습니다.
WARNING
"WAL이 깨진다"를 검증으로 뒤집고 나니, 진짜 위험이 오히려 드러났습니다: 라이브 데이터를 손이 미끄러져 망가뜨리기. Outerbase는 라이브 DB를 직접 편집하고 undo가 없습니다. 그래서 최종 구성은——web / Jarvis / NAS(제 것)는 편집 가능, n8n / Kuma / 3x-ui는 라이브 읽기 전용으로 저장을 누르는 순간 SQLITE_READONLY. 위험한 건 엔진 병행성이 아니라, 미끄러지는 손이었습니다.
Mongo: 벽 두 개가 겹친다#
다른 프로젝트에 MongoDB가 하나 있어 Mongoku(현대적 web 버전 Compass)를 쓰고 싶었습니다. 여기엔 벽이 두 개 겹쳐 있습니다:
mongod가127.0.0.1에만 바인딩되어 있다.- 이 호스트의 방화벽이 "컨테이너 → 호스트" 신규 연결을 전부 떨어뜨린다.
두 번째는 이렇게 진단했습니다——호스트에서 붙으면 되는데, 어떤 컨테이너(Docker 정품 host.docker.internal 포함)든 타임아웃이 납니다:
# 호스트에서 직접: 됨
$ mongosh "mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin" --eval "db.adminCommand('listDatabases')"
→ 데이터베이스 목록을 정상적으로 반환
# 컨테이너에서 같은 주소: 타임아웃(방화벽에 막힘)
$ docker run --rm --network db-admin_default mongo:7 mongosh "mongodb://…@172.17.0.1:27017/…"
→ MongoServerSelectionError: connect timed outsocat 릴레이를 세워 봐도 안 통합니다——Mongoku는 db-admin_default 네트워크에 있고 socat은 default bridge에 바인딩돼, Docker 네트워크 너머로 격리되기 때문입니다. 게다가 Mongoku 이미지는 리슨 포트를 3100으로 하드코딩해 두었는데, 제 서버에선 3100이 이미 다른 next-server에 점유돼 있었고 PORT를 설정해도 안 먹혔습니다.
돌파구는 둘. 첫째, host-network 컨테이너는 호스트 자신의 loopback을 타기 때문에 컨테이너를 막는 그 방화벽을 거치지 않는다——그래서 Mongoku를 network_mode: host로 돌려 127.0.0.1:27017에 직접 붙였습니다. 둘째, PORT가 안 먹힌 건 SvelteKit으로 빌드돼 환경 변수 접두사가 MONGOKU_SERVER_여야 하기 때문:
mongoku:
image: huggingface/mongoku:latest
network_mode: host
environment:
- MONGOKU_SERVER_HOST=127.0.0.1 # loopback에만 바인딩, 외부 비공개
- MONGOKU_SERVER_PORT=4001 # 점유된 3100 회피
- MONGOKU_SERVER_ORIGIN=http://127.0.0.1:4001 # 쓰기가 필요할 때만 필요
- MONGOKU_DEFAULT_HOST=mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin열어 보니, 그 Mongo의 데이터베이스를 얌전히 나열해 주었습니다.
Service Worker에 납치된 서브도메인#
연결한 뒤 골치 아픈 현상이 생겼습니다: db.koimsurai.com에 들어가면 가끔 제 블로그가 나옵니다. 처음엔 port 80이 블로그의 wildcard에 잡혔다고 오진해 한참을 쫓았지만, 아니었습니다.
진범은 . 이 서브도메인이 데이터베이스 콘솔이 되기 전에, 저는 이걸로 블로그를 열어 본 적이 있었습니다; 블로그는 PWA라 그 service worker가 db.koimsurai.com 출처에 등록돼 app shell을 통째로 캐시해 두었고——그래서 이동한 순간 캐시로 블로그를 "가로채" 내놓은 겁니다. Ctrl + Shift + R로 service worker를 건너뛰면 정상. 근치하려면 그 사이트의 site data를 지우거나, 옛 경로에 스스로 등록 해제하는 sw.js를 두면 됩니다. 이 함정은 데이터베이스와 아무 상관이 없었고, 순전히 브라우저가 기억을 너무 오래 붙들고 있었을 뿐이었습니다.
하나의 2FA로 전부를 덮다#
마지막은 바깥의 자물쇠. 조건은 분명했습니다: URL로 들어갈 수 있되, 아이디/비밀번호만으로는 안전하지 않다——2FA가 필요하다, 그리고 WireGuard식 내부망은 하기 싫다.
처음 추천받은 건 Cloudflare Tunnel + Access: inbound 포트 노출 제로, 엣지에서 passkey, 무료. 기술적으론 아름답지만 직감에 걸렸습니다——모든 트래픽이 Cloudflare를 우회하는데, 느려지지 않을까? 게다가 직접 쥐는 감각도 원해서, 결국 자체 호스팅 Authelia를 골랐습니다(Tailscale과 Authentik도 봤지만, 전자는 클라이언트가 필요하고 저는 VPN이 익숙지 않습니다). 마음가짐은: "포트를 열어도 상관없다——스캔돼도 못 들어오고, 설정을 잘못해 나를 잠가도 그건 내 문제".
Authelia는 로 전체를 덮고, nginx는 어느 DB 콘솔에 닿기 전에도 Authelia의 로그인 + passkey를 거칩니다:
auth_request /internal/authelia/authz;
auth_request_set $redirect $scheme://$http_host$request_uri;
error_page 401 =302 https://auth.koimsurai.com/?rd=$redirect;설정할 때 기억해 둘 자잘한 두 가지. 하나, Authelia는 기본적으로 확인 메일 발송용 SMTP를 요구하지만, 전부 자체 호스팅인데 SMTP 한 대를 세우는 건 이상해서 notifier.filesystem으로 바꿔 확인을 로컬 파일로 씁니다; 첫 2FA 등록에선 docker exec authelia cat /config/notification.txt로 링크를 캐내면 됩니다. 둘, TOTP는 자체 SQLite에 username을 키로 저장되므로, 나중에 계정을 timo에서 timo9378로 바꿨을 때 그 테이블도 같이 개명해야 했고, 안 그러면 OTP가 안 맞아 재등록해야 합니다.
Authelia는 일부러 독립된 한 덩어리(전용 repo)로 두어서, 앞으로 2FA를 붙이고 싶은 서비스는 nginx 한 구획만 그쪽으로 향하면 됩니다——완전히 꽂아 쓰는 방식. 세 서브도메인 auth. / db. / mongo.의 A 레코드는 전부 DNS-only(Cloudflare 오렌지 클라우드 프록시를 거치지 않음)이고, 인증서는 기존 와일드카드 *.koimsurai.com을 그대로 재사용해——서브도메인마다 따로 발급할 필요가 없습니다.
먼저 검증하고, 그다음에 만든다#
최종 모습: 여섯 개의 SQLite(내 것은 편집 가능, 서드파티는 읽기 전용, 전부 라이브)에 Mongo 하나, 모두 한 도메인과 한 2FA 게이트 뒤에.
돌아보면, 이 함정들 중 정말로 어려운 건 하나도 없었습니다. Outerbase의 CLI, ATTACH의 한계, WAL의 읽기 전용, 호스트에 못 닿는 컨테이너, service worker에 납치된 서브도메인——모든 답은 "먼저 그게 실제로 어떻게 동작하는지 알아본다"는 한 걸음 너머에 있었을 뿐입니다. 멀리 돌아간 건, 거의 다 "컨테이너가 떠서 200을 반환한다"를 너무 일찍 "완료"로 친 탓이었습니다. 먼저 검증하고, 그다음에 만든다.
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요