About

AI 에게 본번 DB 를 보여줄 때 — 쓰기는 프롬프트가 아니라 DB 권한으로 막는다

exec 가 이미 있는데 조사용 리플리카와 발판 서버를 따로 만든 이유, 그리고 PostgreSQL 에서 직접 확인한 것

결론부터. AI 에이전트에게 본번 데이터를 보여줄 거면 쓰기는 DB 가 거부하게 만들고, 읽기도 고객이 쓰는 DB 와 떼어 놓는 게 맞다. 이 글을 읽으면 프롬프트 · 세션 설정 · 롤 권한 · 리플리카가 각각 어디까지 막아 주는지 알고, 우리 서비스에는 어느 층이 필요한지 고를 수 있다.

회사(Rails 로 만든 계약관리 SaaS, ECS Fargate + RDS PostgreSQL)에서 본번 환경에 조사용 리드 리플리카와 발판(bastion) 서버를 붙인다는 공지가 올라왔다.

처음 든 생각은 「이미 exec 로 본번 컨테이너에 들어가서 rails console 도 되고 SQL 도 되는데, 왜 길을 하나 더 내지?」 였다. 알아보니 기존 길은 개발자가 조심해서 쓰는 걸 전제로 만들어져 있었고, 이번에 쓰는 사람이 바뀌었다. 고객 대응 엔지니어가 AI 에이전트에게 본번 데이터 조사를 시키는 것이다. 사고가 나서 만든 게 아니라, 나기 전에 막으려는 것이다.

기존 길: ECS Exec 로 서비스 중인 컨테이너에 들어간다

ECS Exec 는 돌고 있는 컨테이너 안에서 셸을 여는 기능이다. 들어가면 앱과 같은 DB 접속 정보를 그대로 쓴다. 즉 쓰기가 되는 프라이머리(마스터) DB 에 앱 권한으로 붙는다.

Two ways for an AI agent to look at production dataBefore: ECS ExecLaptop + AI agenttold: read only, pleaseWeb containeralso serving customersPrimary DBapp credentials, writableWhat stops an UPDATE: a line in the agent instructions.After: bastion + replicaLaptop + AI agentlocalhost:5432Bastion (SSM)no inbound, no public IPInvestigation replicaSELECT-only role, standbyWhat stops an UPDATE: the database says no, even to a superuser. The primary is behind its own security group.

뜯어 보면 문제가 네 개였다.

쓰기 방지가 말에 기대고 있다. 조회용 스킬 문서에 「update · destroy 금지」 라고 적어 둔 게 전부였다. 사람이면 조심하면 된다. AI 에게 「읽기만 해」 는 확률적인 약속이다. 지시를 한 번 놓치거나 프롬프트 인젝션(데이터 안에 숨은 지시에 넘어가는 것) 한 번이면 update_all 이 나간다.

읽기도 공짜가 아니다. 조사 쿼리는 조건이 매번 달라서 인덱스를 잘 안 탄다. 수십 GB 테이블 풀스캔이 고객 요청과 같은 프라이머리에서 돈다. 게다가 exec 는 웹 요청을 받는 컨테이너 안에서 돈다.

exec 세션이 비대화형 사용과 안 맞는다. 로컬 stdin 이 EOF 가 되면 2초쯤 만에 세션이 끊겼다. AI 가 명령을 던지고 결과를 받는 용도로는 sleep N | 로 stdin 을 붙잡는 꼼수가 필요했다.

네트워크가 생각보다 열려 있었다. 기존 DB 보안 그룹은 VPC 대역 전체에 5432 를 열어 두었다. 발판을 VPC 안에 두기만 하면 프라이머리에도 닿는다. 그래서 리플리카 전용 보안 그룹을 새로 만들고 발판에서 오는 5432 만 열었다. 이건 권한 통제라기보다 「실수로 프라이머리에 붙는 사고」 를 막는 장치다.

무엇이 진짜로 쓰기를 거부하나 — 직접 해 봤다

「SELECT 만 되는 롤을 만들면 되잖아」 「세션을 read-only 로 열면 되잖아」 「리플리카는 원래 못 쓰잖아」. 셋 다 맞는 말 같은데 막는 힘이 다르다. 로컬에 PostgreSQL 18.6 프라이머리와 물리 리플리카를 띄우고, 20만 행짜리 contracts 테이블로 확인했다.

-- 조사 롤: SELECT 권한만 + default_transaction_read_only=on + statement_timeout=2s (프라이머리)
UPDATE contracts ...                   ERROR 25006 cannot execute UPDATE in a read-only transaction
SET default_transaction_read_only = off;
UPDATE contracts ...                   ERROR 42501 permission denied for table contracts
SELECT pg_sleep(5)                     ERROR 57014 canceling statement due to statement timeout (2.1초)
SET statement_timeout = 0;
SELECT pg_sleep(3)                     OK (3.0초)

-- 앱 롤: read-only 기본값만 걸었을 때 (프라이머리)
UPDATE contracts ...                   ERROR 25006
BEGIN READ WRITE; UPDATE ...; COMMIT;  OK

-- 리플리카: 슈퍼유저로
UPDATE contracts ...                   ERROR 25006
BEGIN READ WRITE                       ERROR 0A000 cannot set transaction read-write mode during recovery
CREATE ROLE x                          ERROR 25006 cannot execute CREATE ROLE in a read-only transaction

여기서 알게 된 게 세 가지다.

read-only 세션은 벽이 아니다. default_transaction_read_only 는 「기본값」 이라 세션이 BEGIN READ WRITE 한 줄로 넘어간다. 앱 롤에 이것만 걸어 두면 쓰기가 그냥 된다. 처음엔 이걸로 충분할 줄 알았다.

롤 권한은 벽이다. read-only 를 꺼도 SELECT 만 받은 롤은 permission denied 에서 멈춘다. 최소한 이건 있어야 한다.

물리 리플리카는 슈퍼유저도 못 쓴다. 복구(recovery) 중인 서버라 읽기 쓰기 모드로 바꾸는 것 자체가 거부된다. 그래서 조사용 롤도 리플리카에서는 못 만든다. 프라이머리에서 만들면 리플리카로 복제돼 넘어온다(위 실험에서도 그렇게 넘어왔다).

덤으로, 롤에 건 statement_timeout 도 기본값이라 세션이 0 으로 풀 수 있다. 회사 설계에서는 10분으로 잡았는데, 이건 무거운 쿼리를 깜빡 돌려 놓은 사고를 끊는 장치지 AI 를 묶는 장치가 아니다. 리플리카를 따로 두는 이유가 여기서도 나온다. 누가 타임아웃을 풀어도 고객 쪽 DB 는 안 무거워진다.

리플리카의 함정: 긴 조회가 중간에 취소된다

리플리카를 만들고 나면 바로 부딪히는 게 있다. 프라이머리에서 VACUUM(지운 행을 실제로 치우는 청소)이 돌면, 리플리카에서 그 행을 읽고 있던 조회와 충돌한다. 리플리카는 일정 시간 기다리다가 조회를 취소한다. 같은 실험 환경에서 6초짜리 조회를 걸어 두고 프라이머리에서 행을 지운 뒤 VACUUM 을 돌렸다.

리플리카 설정                        6초 조회        프라이머리 VACUUM
feedback off, 대기 1초               2.3초에 취소    지운 행 치움
feedback on,  대기 1초               끝까지 감       지운 66,667행 못 치움
feedback off, 대기 30초              끝까지 감       치움 (그동안 리플리카 반영이 멈춤)

hot_standby_feedback 를 켜면 리플리카가 「이 행 아직 읽는 중」 이라고 프라이머리에 알려서 조회는 안 끊긴다. 대신 프라이머리 VACUUM 이 그 행을 못 치운다. 조사 쿼리가 길수록 고객이 쓰는 큰 테이블이 부푼다. max_standby_streaming_delay 를 늘리면 반대로 프라이머리는 멀쩡하고, 리플리카의 데이터가 그만큼 늦어진다.

회사에서는 대기 시간을 늘리는 쪽을 골랐다. 큰 테이블이 십몇 GB 라 부풀면 고객 쪽이 아프다. 조사용이니 데이터가 몇 분 늦는 건 괜찮다. 리플리카를 따로 둔 목적(고객 DB 를 건드리지 않기)을 생각하면 자연스러운 선택이다.

어디까지 막을까 — 고르는 기준

막는 방법 막아 주는 것 뚫리는 곳 언제
프롬프트 · 스킬 문서에 「읽기만」 대부분의 실수 지시를 놓치거나 인젝션 한 번 사람이 옆에서 볼 때만
default_transaction_read_only 무심코 나가는 UPDATE 세션이 BEGIN READ WRITE 로 넘어감 롤 권한에 덧붙이는 보조로
SELECT 전용 롤 모든 쓰기 프라이머리 부하 · 민감 데이터 열람 최소한 이것
물리 리플리카 + SELECT 전용 롤 쓰기 + 고객 쪽 부하 데이터가 늦을 수 있다 · 인스턴스 비용 AI 에게 상시로 조사를 맡길 때

회사 구성은 마지막 줄이다. 발판은 공개 IP 도 inbound 도 없는 작은 EC2 이고, SSM 포트포워딩으로 로컬 localhost:5432 에 리플리카를 연결한다. AI 는 평범한 DB 에 붙는 것처럼 psql 을 쓰면 된다. 앱의 database.yml 에는 리플리카 설정이 없다. 앱은 리플리카를 한 번도 안 쓰고, 순수하게 조사 전용이다.

정리하면서 재밌었던 것

이 길로 접속하려면 원래 관리자 권한이 필요하다. 그 사람은 마음만 먹으면 뭐든 할 수 있다. 그러니 이 구성이 막는 건 악의가 아니라 사고다. 보안 경계라기보다 「기본값을 안전하게」 만드는 설계에 가깝다.

리플리카는 보통 읽기 성능 때문에 만든다. 여기선 격리가 목적이다. 그리고 전체 테이블 SELECT 라서 계약서 본문도 AI 가 읽게 된다. 이건 기술로 못 푼다. 시작하기 전에 사내에서 합의했다.

AI 에게 운영 데이터를 맡기는 구성을 짜고 있는데 어디까지 막아야 할지 애매하다면, negabaro 에 물어봐도 된다. 지금 접속 경로를 같이 그려 보고 필요한 층만 골라 볼 수 있다.

핵심 개념

1

AI 조사용 롤은 SELECT 권한만 준다 — read-only 세션 설정은 보조일 뿐이다

2

롤은 프라이머리에서 만든다. 물리 리플리카에서는 슈퍼유저도 못 만든다

3

조사는 고객이 안 쓰는 리플리카로, 보안 그룹으로 프라이머리 길을 막는다

4

긴 조회 취소는 hot_standby_feedback 대신 리플리카 대기 시간을 늘려 푼다

사용 사례

AI 에이전트 · 고객 대응 담당이 본번 데이터를 조사하는 구성 ECS Exec 로 rails console 을 여는 운영을 줄이고 싶을 때
← Azure OpenAI Batch 가 어느 날 전부 500 — 입력 파일 500개 한도와 그 400 을 덮은 중간 API 에이전트에게 부탁하지 말고 훅으로 강제하라 — oh-my-openagent 코드를 읽고 내 하네스에 가져온 것 →