서론
노트북에 설치된 데이터베이스 클라이언트 개수를 세어보세요. psql, sqlite3, mongosh, redis-cli, ClickHouse 클라이언트, 브라우저 탭의 Qdrant 대시보드, 혹은 그리드가 필요할 때 사용하는 DBeaver 등이 있습니다. 각각은 고유한 플래그와 테이블 출력 방식, 비밀번호가 포함된 자체 설정 파일, 그리고 '읽기 전용(read-only)'이 무엇을 의미하는지에 대한 자신만의 정의를 가지고 있습니다. 이들 중 어느 것도 MCP 서버가 무엇인지 알지 못하기 때문에, Claude Code나 Cursor 같은 도구에게 데이터베이스를 보게 하려고 할 때, 쿼리 결과를 채팅에 붙여넣거나 해당 엔진만을 위한 일회성(one-off) MCP 서버를 구축해야 합니다.
tobira (扉는 일본어로 '문'을 의미합니다)가 이 문제에 대한 저의 해답입니다. 이는 동시에 다음 역할을 수행하는 단일 Go 바이너리입니다:
- 단일 CLI:
tobira query를 사용하면 31개 데이터베이스 중 어느 곳이든 문(statement)을 실행하고, 스크립트가 신뢰할 수 있는 종료 코드와 함께 테이블, JSON, NDJSON 또는 CSV 형식으로 출력합니다.
- 단일 터미널 UI: 모든 곳에서 동일한 키를 사용하여 브라우징, 필터링, 행 편집, 쿼리 실행 및 변경 사항 추적(tail)이 가능합니다.
- 단일 AI 어시스턴트:
tobira ask는 Gemini, Claude, OpenAI, Vertex AI 또는 로컬 Ollama 모델을 사용하여 자체적으로 읽기 전용 쿼리를 실행함으로써 쿼리를 제안하거나 질문에 답변합니다.
- 단일 MCP 서버:
tobira mcp를 사용하면 연결된 데이터베이스들을 Claude Code, Claude Desktop, Cursor 또는 VS Code에 노출하며, 기본적으로 읽기 전용입니다.
- 단일 에이전트 스킬:
npx skills add xavidop/tobira는 코딩 에이전트에게 모든 명령어, 플래그 및 드라이버를 가르칩니다.
그리고 이 다섯 가지 기능 아래에는 쿼리 텍스트가 침범할 수 없는 **단일 읽기 전용 보호 장치(one read-only guard)**가 존재하며, 이것이야말로 데이터베이스를 모델에게 전달하는 것이 합리적인 이유입니다.
이 글은 기술 투어입니다. 여기에 언급된 모든 내용은 Go 1.27 환경의 tobira 1.4.1을 기준으로 실행되었습니다.
tobira architecture: TUI, query, ask and mcp on top of one driver contract, a read-only guard, and 31 drivers
왜 필요한가: 다섯 개의 클라이언트 대신 하나의 계약(규약)
모든 데이터베이스 클라이언트는 수직적인 단면(vertical slice)입니다. 즉, 자체 연결 처리 방식, 자체 출력 형식, 그리고 자체 셸을 가집니다. tobira는 이를 수평적인 계층(horizontal layers)으로 전환합니다. 모든 데이터베이스는 하나의 작은 Go 계약(Go contract)을 구현하는 **드라이버(driver)**가 됩니다. CLI, TUI, AI 어시스턴트 및 MCP 서버는 데이터베이스마다 한 번 작성되는 것이 아니라, 이 계약에 맞춰 단 한 번만 작성됩니다.
| tobira 미사용 시 (Without tobira) | tobira 사용 시 (With tobira) |
|---|
| 클라이언트 | psql, mongosh, redis-cli, 벡터 DB 대시보드, GUI 등 | 하나의 바이너리, tobira |
| ... | | |
드라이버 계약은 의도적으로 작습니다. Driver는 연결을 열고, Conn은 Query, Ping, Close, Namespaces 및 Collections 목록화, 그리고 컬렉션 Describe를 수행할 수 있습니다. 그 외 모든 것(페이지네이션을 사용한 브라우징, 인라인 편집, explain, 트랜잭션, 자동 완성, 내보내기, 변경 스트림, 벡터 검색, 시계열 읽기)은 **선택적 기능(optional capability)**이며, 연결이 구현할 수도 있고 아닐 수도 있는 인터페이스로, 런타임에 발견됩니다:
if b, ok := driver.As[driver.Browser](conn); ok { ... }
기능을 추가해도 드라이버가 깨지는 일은 없습니다. TUI는 각 연결에 무엇을 제공하는지 물어보고 그것만 보여줍니다: 트랜잭션이 있는 곳에는 :tx가, 벡터 스토어에는 :similar가, 시계열 데이터에는 :chart가 나타납니다.
그 결과로 현재 바이너리에는 31개의 드라이버가 포함됩니다: SQLite, PostgreSQL (pgvector 포함), MySQL, MariaDB, SingleStore, CockroachDB, Redshift, Greenplum, SQL Server, Oracle, libSQL/Turso, Cloudflare D1, ClickHouse, BigQuery, Snowflake, Vertica, MongoDB, Firestore, Cosmos DB, Redis, DynamoDB, Cassandra, Neo4j, Elasticsearch, OpenSearch, InfluxDB, Qdrant, Pinecone, Weaviate, Chroma 및 Milvus, 그리고 외부 플러그인으로서 DuckDB가 있습니다. 각 드라이버는 자체 Go 모듈이므로 루트 모듈은 데이터베이스 SDK를 절대 가져오지 않으며, 플러그인은 gRPC를 사용하는 모든 언어로 작성될 수 있습니다.
우리가 구축하는 것 (What We Are Building)
SQLite를 사용한 customers와 orders 테이블을 가진 작은 상점 데이터베이스가 있고, 그 안으로 들어가는 모든 문이 있습니다. 스크립트에서 실행하는 tobira query, 삭제를 막으려는 읽기 전용 가드(read-only guard), Gemini에서 수익 관련 질문에 답하는 tobira ask, 행 삭제를 요청했지만 할 수 없는 헤드리스(headless) 클로드 코드 세션에 데이터베이스를 제공하는 tobira mcp, 그리고 마지막으로 60줄의 Go 프로그램에 임베딩된 tobira의 드라이버 계층입니다.
SQLite는 서버가 필요 없기 때문에 데모로 사용되었습니다. 아래 모든 명령어는 PostgreSQL, MongoDB 또는 Qdrant에서도 동일하게 작동합니다. DSN(Data Source Name)만 교체하면 됩니다.
준비물 (What You Need)
- tobira 1.4.1 이상 버전 (소스에서 빌드하는 경우 Go 1.26+ 필요).
- 데모 파일을 초기화하기 위한
sqlite3.
- AI 단계를 위한
GEMINI_API_KEY (또는 OpenAI 또는 Anthropic 키, 또는 로컬에서 실행되는 Ollama).
- MCP 단계용 클로드 코드(Claude Code), 커서(Cursor), 클로드 데스크톱(Claude Desktop) 또는 VS Code.
파일 내의 문자 그대로의 API 키나 비밀번호는 경고(warning)가 아니라 **로드 에러(load error)**입니다. 비밀번호는 env:NAME, file:///path, dotenv://.env#KEY 또는 keyring:NAME 참조 형식을 사용하며, 연결이 열릴 때 mamori가 이를 해석(resolve)합니다. 이때 해석된 값은 driver.Secret으로 저장되며, 이 객체의 String, Format, LogValue, 그리고 MarshalJSON 모두 [redacted]를 출력합니다. 값을 반환하는 것은 오직 Reveal() 메서드뿐이며, 드라이버(driver)만이 해당 메서드를 호출할 수 있습니다.
tobira doctor는 이 모든 것을 확인하며, 표준 출력(stdout)이 터미널이 아닐 경우 JSON 형태로 결과를 제공하므로 CI 환경에 적합합니다:
tobira --config config.yaml doctor
{"section":"connections","name":"shop","status":"ok","detail":"reachable in 1ms (read-only open, driver sqlite)"}
{"section":"refs","name":"ai.api_key","status":"ok","detail":"env ref resolves"}
{"section":"ai","name":"model","status":"ok","detail":"gemini-3.8-flash"}
...
tobira doctor는 해당 경고가 발생하면 0이 아닌 종료 코드(non-zero)를 반환합니다. chmod 600 config.yaml을 실행하면 정상적으로 작동하며 녹색으로 표시됩니다.
Step 2: 스크립트 및 CI용 단일 쿼리 명령어
tobira query는 연결 이름 또는 원시 DSN(Data Source Name)과 데이터베이스 자체 언어로 작성된 구문(statement)을 받아 결과를 출력합니다:
tobira --config config.yaml query shop \
"select c.name, count(o.id) as orders, sum(o.total_cents)/100.0 as total
from customers c join orders o on o.customer_id=c.id group by c.id order by total desc"
name orders total
───────────────── ────── ─────
Margaret Hamilton 1 150
...
표(table)는 터미널의 기본 출력 형식입니다. 이 출력을 어디에 파이프(|)로 연결하더라도, 기본값은 타입이 지정된 컬럼과 통계가 포함된 JSON으로 변환되므로 jq와 같은 도구가 추측할 필요가 없습니다:
tobira --config config.yaml query shop "select id, name, country from customers where country='US'" --format json
{
"columns": [{"name": "id", "type": "int"}, {"name": "name", "type": "string"}, {"name": "country", "type": "string"}],
"rows": [
...
--format csv와 --format ndjson은 명시된 대로 작동하며, --output orders.csv는 크기에 관계없이 모든 결과 행을 파일로 스트리밍합니다. --explain은 쿼리를 실행하는 대신 계획(plan)을 출력하고, -f -는 표준 입력(stdin)에서 구문을 읽으며, --timeout은 전체 호출에 시간 제한을 설정합니다. 연결 기능이 포함됩니다.
종료 코드는 모든 드라이버에서 동일하며, 이것이 해당 명령어를 파이프라인에서 사용할 수 있게 만드는 요인입니다:
| 코드 | 의미 |
|---|
0 | 성공 |
| ... | |
에러는 모두 한 줄의 error: <메시지> 형태로 stderr로 전송되며, 항상 redactor를 거치기 때문에 DSN 비밀번호가 CI 로그에 남는 일이 없습니다. | |
3단계: 읽기 전용 가드 (The read-only guard)
이것은 다른 모든 것이 의존하는 부분입니다. shop 연결에서 쓰기 작업을 시도해 보세요:
tobira --config config.yaml query shop "delete from orders"; echo "exit=$?"
error: sqlite: query: only SELECT, WITH, VALUES, EXPLAIN and read-only PRAGMA statements are allowed on a read-only connection: read-only violation
exit=4
동일한 --read-only 플래그는 설정 파일이 전혀 없는 순수 DSN에서도 작동하며, 읽기 전용 작업 뒤에 쓰기를 은닉하는 것도 작동하지 않습니다. 왜냐하면 SQL 드라이버가 쿼리당 정확히 하나의 구문만 처리하기 때문입니다:
tobira query sqlite:./shop.db "update orders set status='paid'" --read-only; echo "exit=$?"
# exit=4
tobira --config config.yaml query shop "select 1; delete from orders"; echo "exit=$?"
...
이 가드는 연결 앞에 있는 정규 표현식(regex)이 아닙니다. 각 드라이버는 자신의 기능 목록에 CapReadOnly를 선언하며, 이 선언은 계약과 같습니다. ReadOnly로 전송된 쿼리는 내용이 무엇이든 반드시 ErrReadOnly와 함께 실패해야 하며 아무것도 변경해서는 안 됩니다. 각 드라이버는 자신이 가진 가장 강력한 데이터베이스 메커니즘을 사용하여 이를 강제하며, 그 앞에 구문 검사를 추가합니다:
- SQLite: 파일은 읽기 전용 핸들로 열리며, 풀링된 연결의 모든 구문(statement) 실행 전에
PRAGMA query_only=1이 재확립됩니다 (이전 텍스트에서 이를 해제했을 수 있음). 또한, SQLITE_LIMIT_ATTACHED=0을 통해 ATTACH가 비활성화되며, 구문은 허용 목록에 있는 키워드로 시작해야 합니다.
- PostgreSQL: 모든 호출은 자체적인
BEGIN READ ONLY 내에서 실행되고 항상 새로운 컨텍스트에서 ROLLBACK으로 종료되므로, 취소된 쿼리가 트랜잭션을 열어둘 수 없습니다. BEGIN READ ONLY가 COPY ... TO PROGRAM, DO 블록 또는 lo_export를 서버 파일 시스템에 접근하는 것을 막지 못하더라도, 이러한 기능들은 실행되기 전에 문법적으로 거부되며, 모든 문자열 따옴표 방언(dialect)을 가진 PostgreSQL로 구문 분석기(lexer)가 실행됩니다.
- CockroachDB 및 Redshift: 허용 목록이 적용되는데,
EXPORT, BACKUP, UNLOAD 및 crdb_internal 함수들은 데이터베이스 외부로 나가는 기능을 수행하기 때문입니다.
- MySQL: 구문 종류에 대한 허용 목록을 사용하고, 자체 연결에서 읽기 전용 트랜잭션을 실행하며, 해당 구문이 그 안에 머물렀는지 확인하는 사후 검사(post-check)를 수행합니다 (MySQL 8.0부터 8.4까지).
- MongoDB: 명령 문서가 읽기 명령어 허용 목록과 비교하여 전송되는지 확인되며,
$out, $merge 및 쓰기 관심 필드(write-concern fields)는 거부됩니다.
- Redis: 서버 자체의
COMMAND INFO 플래그와 비교하는 읽기 명령어 허용 목록이 적용되므로, 이름이 변경된 명령어도 여전히 거부됩니다.
- 벡터 저장소 (Vector stores): 쿼리는 절대 쓰기를 수행하지 않으며, 서버 측에서 데이터를 외부 임베딩 서비스로 전송하려는 모든 시도(Weaviate의
nearText, Milvus의 TEXTEMBEDDING, Qdrant의 document 입력 등)는 읽기 전용 경로에서도 거부됩니다.
싱글스토어(SingleStore)를 제외한 모든 드라이버는 읽기 전용 트랜잭션이 없으며, 따라서 흉내 내기보다는 아예 읽기 전용 연결을 거부하기 때문에 가드(guard)가 선언됩니다. 보안 페이지에서는 드라이버별로 가드가 볼 수 없는 한 가지를 나열합니다. 예를 들어 사용자 정의 함수(user-defined functions)나 링크드 서버(linked servers)처럼 이미 서버에 존재하는 코드입니다. 이 가드는 우발적이거나 모델이 생성한 쓰기 작업을 막습니다. 최소 권한(least-privilege) 데이터베이스 사용자가 여전히 실제 경계이며, 이 둘을 함께 사용하는 것이 Step 5 전에 원하는 것입니다.
Step 4: 자연어 질문하기 (Ask in plain language)
ai: 섹션이 준비되면, tobira ask는 세 가지 모드를 가집니다. 기본값은 쿼리를 제안할 뿐 실제로 실행하지 않습니다:
tobira --config config.yaml ask shop "which customers spent the most, with their order count" --collection orders
SELECT c.id, c.name, COUNT(o.id) AS order_count, SUM(o.total_cents) AS total_spent_cents FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.id, c.name ORDER BY total_spent_cents DESC
이 쿼리는 고객별로 주문을 집계하여 총 지출 금액(센트 단위)과 주문된 총 주문 건수를 계산합니다. 그런 다음, 가장 많이 지출한 고객이 먼저 나타나도록 총 지출액 기준으로 고객들을 내림차순 정렬합니다.
...
--run은 제안을 한 후 이를 읽기 전용(read-only)으로 실행하며, 이는 tobira query --read-only가 수행하는 방식과 정확히 같습니다: