
Agentic AI 시스템은 데이터베이스 설계의 암묵적 가정을 위반한다
요약
기존 데이터베이스 아키텍처는 호출자가 인간이 작성하고 결정론적이며 예측 가능한 쿼리를 발행한다는 암묵적인 가정에 기반해 설계되어 왔습니다. 하지만 Agentic AI 시스템은 추론(reasoning)을 통해 예상치 못한 다양한 쿼리 경로를 생성하며, 이는 기존의 스키마, 인덱스, 커넥션 풀 등의 설계를 무력화시킵니다. 따라서 개발자는 에이전트가 데이터베이스에 미치는 영향을 고려하여 애플리케이션 레벨뿐만 아니라 역할(role) 레벨에서도 명확한 타임아웃 및 제한을 설정해야 합니다.
핵심 포인트
- Agentic AI는 기존의 결정론적 호출자 가정을 위반하며, 예측 불가능하고 다양한 쿼리를 생성한다.
- 에이전트가 실행하는 복잡한 조인이나 장시간 연결은 기존 인덱스나 커넥션 풀 설계를 초과할 수 있다.
- 기존 방어선(Statement timeouts) 외에도 역할(role) 레벨에서 타임아웃을 설정하여 에이전트의 리소스 사용을 제한해야 한다.
당신이 지금까지 내린 모든 데이터베이스 아키텍처 결정의 기초에는 암묵적인 계약이 존재합니다. 아마 당신은 그것을 한 번도 문서화하지 않았을 것입니다. 아무도 그렇게 하지 않으니까요. 그것은 그저... 존재해 왔습니다.
그 계약은 대략 다음과 같습니다: 호출자(caller)는 인간이 작성한 애플리케이션이며, 결정론적 코드 (deterministic code)를 실행하고, 예측 가능한 쿼리 (queries)를 발행하며, 배포 전에 개발자에 의해 검토됩니다. 쓰기 작업 (Writes)은 의도적입니다. 연결 (Connections)은 짧습니다. 무언가 잘못되면 인간이 이를 알아차립니다. 애플리케이션 계층 (application layer)이 똑똑하고 신중하기 때문에 데이터베이스는 멍청하고 빨라도 괜찮습니다.
40년 동안 이 계약은 유지되었습니다. 이 계약은 우리가 스키마 (schemas)를 설계하고, 커넥션 풀 (connection pools)의 크기를 정하며, 권한을 부여하고, 장애 모드 (failure modes)를 생각하는 방식을 형성했습니다. 그 가정이 옳았기 때문에 작동했습니다.
이제 그 가정은 더 이상 옳지 않습니다. Agentic AI 시스템은 모든 계층에서 동시에 이 계약을 위반합니다.
이 글에서 저는 어떤 가정들이 실패하고 있는지, 왜 그것이 중요한지, 그리고 구체적인 패턴과 코드를 통해 어떻게 대응해야 하는지를 상세히 분석합니다. 바로 시작해 봅시다...
가정 - 결정론적 호출자 (Deterministic Caller)
에이전트 (agents)를 배포하기 전의 모든 애플리케이션에서, 데이터베이스에 도달하는 쿼리는 인간에 의해 작성되었습니다.
- 개발자가 SQL을 작성했습니다.
- 개발자가 이를 코드 리뷰 (code-reviewed)했습니다.
- 개발자가 이를 테스트하고 배포했습니다.
이 가정은 매우 깊게 뿌리박혀 있어 도구(tooling)에도 자동으로 반영됩니다. Postgres 쿼리 플래너 (query planner)는 관찰된 쿼리 패턴을 기반으로 통계를 구축하고, 캐싱 계층 (caching layers)은 반복되는 쿼리에 대해 워밍업 (warm up)을 수행하며, 커넥션 풀은 알려진 복잡도를 가진 예상 동시 쿼리 수에 맞춰 조정됩니다.
에이전트는 다르게 작동합니다. 그들은 추론 (reasoning)을 통해 쿼리에 도달합니다. 서로 다른 추론 경로 (reasoning paths)가 동일한 테이블에 대해 서로 다른 쿼리를 생성합니다.
고객 분석 작업을 수행하는 에이전트 (Agent)는 이전에 한 번도 실행된 적 없는 5개 테이블 간의 조인 (join)을 실행하고, 결과에 대해 생각하는 동안 연결 (connection)을 유지한 다음, 완전히 다른 후속 작업을 실행할 수도 있습니다. 귀하의 인덱스 (indexes)는 정상적인 경로 (happy path)를 커버하도록 설계되어 있습니다. 귀하의 커넥션 풀 (connection pool)은 관찰된 피크 (peak) 수치에 맞춰 크기가 조정되어 있습니다. 하지만 에이전트가 필요한 데이터에 따라 어떤 쿼리든 생성할 수 있게 된다면, 이 중 어느 것도 버텨내지 못할 것입니다.
문장 타임아웃 (Statement Timeouts)
문장 타임아웃 (Statement timeouts)은 귀하의 첫 번째 방어선입니다. 사람이 작성한 쿼리가 30초가 걸린다면 누군가 알아챌 버그입니다. 하지만 에이전트의 쿼리가 30초가 걸린다면, 아무도 지켜보지 않는 추론 루프 (reasoning loop)일 수 있습니다.
따라서 애플리케이션 레벨 (application level)뿐만 아니라 역할 (role) 레벨에서도 타임아웃을 설정하십시오.
CREATE ROLE agent_worker;
ALTER ROLE agent_worker SET statement_timeout = '5s';
ALTER ROLE agent_worker SET idle_in_transaction_session_timeout = '10s';
idle_in_transaction_session_timeout은 특히 중요합니다. 추론 중간에 멈추면서 열려 있는 트랜잭션 (transaction)을 유지하는 에이전트는 정당한 상황일 수도 있기 때문입니다.
가정 - 쓰기는 의도적이다
데이터베이스 아키텍처 (database architecture)에서 가장 위험한 가정은 모든 쓰기 (write) 작업이 발생하기 전에 인간에 의해 검토되었다는 것입니다. 귀하의 커리어 전체 동안에는 기본적으로 사실이었겠지만, 이제는 그렇지 않습니다.
에이전트는 자율적으로 쓰기를 수행합니다. 에이전트는 작업에 대한 현재의 이해를 바탕으로 쓰기를 수행하며, 이는 틀릴 수도 있습니다. 에이전트는 도구 (tools)가 예상치 못한 결과를 반환할 때 루프 (loops) 내에서 쓰기를 수행합니다. 에이전트는 일시적인 네트워크 오류로 인해 첫 번째 시도가 실패했다고 '생각'할 때 재시도 (retries) 과정에서 쓰기를 수행합니다. 에이전트는 무언가 잘못되었다는 슬랙 (Slack) 알림을 받는 사이에 수천 개의 행 (rows)을 써버릴 수도 있습니다.
다음은 실제로 문서화된 실패 패턴입니다 - 레거시 API (legacy API)를 호출하는 에이전트가 HTTP 200을 받으면
빈 결과 집합(empty result set)을 반환했습니다. 하위 시스템(downstream)의 데이터베이스 연결 풀(connection pool)이 고갈되어 API가 조용히 실패한 것입니다. 에이전트는 "데이터 없음"을 "문제 없음"으로 해석하고, 불완전한 데이터를 가지고 500건의 트랜잭션을 처리하는 것을 계속합니다. 예외(exception)는 발생하지 않았습니다. 경고(alert)도 울리지 않았습니다. 로그에는 모든 레코드에 대해 "decision: approved"라고 표시되었습니다.
여기서 핵심적인 해결책은 호출자가 틀릴 수도 있고, 재시도할 수도 있으며, 결과를 지켜보고 있지 않을 수도 있다는 가정하에 쓰기 경로(write paths)를 설계하는 것입니다.
어디에서나 사용하는 소프트 삭제 (Soft Deletes)
에이전트가 그 어떤 것도 하드 삭제(hard-delete)하게 두지 마십시오. 에이전트가 쓸 수 있는 모든 테이블에 대해 소프트 삭제(soft deletes)를 기본값으로 사용하십시오.
ALTER TABLE orders ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE orders ADD COLUMN deleted_by TEXT; -- 'agent:customer-support-v2', 'user:abc123'
ALTER TABLE orders ADD COLUMN delete_reason TEXT;
...
deleted_by 컬럼은 보기보다 더 중요합니다. 2시간 전에 무슨 일이 일어났는지 디버깅할 때, "에이전트 X가 삭제한 모든 것을 보여줘"라는 쿼리를 실행하고 싶어질 것입니다.
추가 전용 이벤트 로그 (Append-only Event Logs)
금융 기록, 재고 변경, 사용자 상태 변이(user state mutations)와 같이 리스크가 더 큰 작업의 경우, 한 걸음 더 나아가 테이블을 추가 전용(append-only)으로 만드는 것을 고려하십시오. 에이전트는 UPDATE나 DELETE를 절대 실행하지 않습니다. 대신 새로운 상태와 이유를 담은 INSERT를 실행합니다.
CREATE TABLE order_state_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
...
);
이는 테이블 수준에서 적용된 이벤트 소싱(event sourcing) 패턴입니다. 가장 민감한 엔티티(entities)에 대해 단일 추가 전용 로그 테이블을 사용하면 완전한 감사 추적(audit trail)을 확보할 수 있으며, "실행 취소(undo)\
멱등성 키 (Idempotency key)는 에이전트가 모든 쓰기 작업과 함께 포함하는 안정적인 식별자입니다. 데이터베이스는 고유 제약 조건 (unique constraint)을 통해 중복된 요청을 조용히 거부합니다. 에이전트는 어떤 경우에도 성공 응답을 받게 됩니다. 작업을 두 번 실행하더라도 한 번 실행했을 때와 동일한 결과가 생성됩니다.
-- 에이전트는 이 키를 다음과 같이 생성합니다:
-- task_id + operation_type + target_id
-- 동일한 논리적 작업에 대해 결정론적 (deterministic)입니다.
...
실제로 에이전트는 다음과 같이 키를 구성합니다:
import hashlib
def make_idempotency_key(task_id: str,
operation: str, target_id: str) -> str:
...
작업 ID (task ID)는 오케스트레이션 계층 (orchestration layer)에서 제공되며, 동일한 논리적 작업의 재시도 (retry) 과정에서도 안정적으로 유지됩니다. 이는 에이전트가 필요한 만큼 얼마든지 재시도할 수 있음을 의미하며, 데이터베이스는 논리적 작업당 정확히 하나의 쓰기 작업만을 보게 됩니다.
가정 - 연결은 짧다
전통적인 커넥션 풀 (connection pool) 크기 산정은 단순한 사고 모델을 따릅니다. 애플리케이션이 N개의 동시 요청을 처리합니다. 각 요청은 짧은 시간 동안 하나의 데이터베이스 연결이 필요합니다. 예상되는 동시성 피크 (concurrency peak)보다 약간 높게, 약간의 여유분 (headroom)을 더해 풀 크기를 설정하면 끝납니다.
에이전트는 세 가지 방식으로 이 모델을 깨뜨립니다.
- 에이전트는 연결을 더 오래 유지합니다
다단계 추론 (multi-step reasoning) 작업은 쿼리를 실행하고, LLM으로 결과를 처리하기 위해 일시 중지하고, 다시 다른 쿼리를 실행하고, 또 다시 일시 중지하는 과정을 반복할 수 있습니다. 각 일시 중지 단계마다 연결이 열린 상태로 유지됩니다. 작업당 연결 시간은 더 이상 "쿼리 실행 시간"이 아니라 "쿼리 실행 시간 + LLM 추론 시간(inference time) x 추론 단계(reasoning steps)"가 됩니다.
- 에이전트는 팬 아웃 (fan out) 합니다
단일 고수준 에이전트 작업은 종종 병렬로 작업할 서브 에이전트 (sub-agents)들을 생성합니다. 하나의 작업이 다섯 개의 동시 데이터베이스 세션이 됩니다. 이는 긴 IO 대기 (IO waits) 동안 db.session을 열어두는 동시 에이전트 워크플로우가 발생할 때 커넥션을 고갈시킬 수 있으며, Postgres의 연결 슬롯 (connection slots)이 바닥날 때까지 이어질 수 있습니다.
- 에이전트는 예기치 않게 증식합니다
개발 환경에서는 에이전트가 3개였습니다. 운영 환경에서는 30개가 됩니다. 아무도 커넥션 풀 설정을 업데이트하지 않았습니다.
해결책은 에이전트 워크로드 (agent workloads)를 위한 전용 커넥션 풀 (connection pool)을 구축하는 것이며, 이는 사용자가 사용하는 트랜잭션 애플리케이션 (transactional application) 트래픽과는 독립적으로 크기를 설정해야 합니다.
# 경험칙 (Rule of thumb): (에이전트 워커 수 * 평균 동시 단계 수 * 0.5)
# 0.5는 대부분의 에이전트 단계가
# DB 시간이 아닌 LLM 시간을 소요한다는 점을 고려한 것입니다
...
pool_timeout=3 설정은 의도적인 것입니다. 에이전트가 3초 이내에 커넥션을 얻지 못하면, 무한정 대기하는 것이 아니라 즉시 실패 (fail fast)하고 지수 백오프 (backoff)를 적용하여 재시도해야 합니다. 포화 상태인 풀 (pool)에서 요청이 대기열에 쌓이는 것이 바로 연쇄 장애 (cascading failures)를 일으키는 원인입니다.
많은 에이전트를 동시에 실행하는 시스템의 경우, 에이전트와 Postgres 사이에 PgBouncer를 추가하십시오. PgBouncer는 트랜잭션 풀링 (transaction pooling) 모드로 작동하며, 이는 세션 전체 동안 커넥션을 유지하는 대신 각 트랜잭션이 끝날 때마다 커넥션을 즉시 풀로 반환함을 의미합니다. 이는 에이전트 워크로드에 대한 유효 커넥션 용량을 크게 증폭시켜 줍니다.
# pgbouncer.ini
[databases]
mydb = host=postgres_host dbname=mydb
...
트랜잭션 풀링 모드에서는 20개의 실제 Postgres 커넥션이 500개의 에이전트 커넥션을 처리할 수 있습니다. 각 에이전트가 전체 다단계 작업 (multi-step task) 동안이 아니라, 단일 트랜잭션이 지속되는 동안에만 Postgres 커넥션을 보유하기 때문입니다.
가정 - 잘못된 쿼리는 명확하게 실패한다
사람이 운영하는 시스템에서는 느리거나 잘못된 쿼리가 빠르게 드러납니다. 대시보드가 느리게 로드되거나, API가 타임아웃(timeout)이 발생합니다. 엔지니어가 EXPLAIN ANALYZE를 실행하여 문제를 찾아냅니다. 피드백 루프 (feedback loop)가 긴밀합니다.
에이전트는 그 피드백 루프를 닫아버립니다. 느린 쿼리 결과를 받은 에이전트는 그냥 그 결과를 사용해 버립니다. 빈 결과 집합 (empty result set)을 받은 에이전트는 데이터가 실제로 존재하지 않는 것인지, 아니면 쿼리가 잘못된 것인지 알지 못합니다. 에이전트는 작업을 계속 진행하며, 잠재적으로 잘못된 읽기 (bad read)를 바탕으로 의사결정을 내리게 됩니다.
이는 애플리케이션 에러 (application errors)와는 다른 차원의 실패입니다. 예외 (exception)는 관찰 가능하지만, 행 (rows)을 반환하는 의미론적으로 잘못된 (semantically wrong) 쿼리는 관찰할 수 없습니다.
해결책은 데이터베이스 액세스 계층 (database access layer)에 에이전트 전용 관찰 가능성 (observability)을 구축하는 것입니다. 표준적인 느린 쿼리 로그 (slow query logs)만으로는 충분하지 않습니다. 어떤 에이전트가, 어떤 작업을 수행 중이며, 어떤 추론 단계 (reasoning step)에서 쿼리를 생성했는지 알아야 합니다. Postgres에서 이를 수행하는 가장 실용적인 방법은 쿼리 주석 (query comments)을 사용하는 것입니다.
from sqlalchemy import text, event
from sqlalchemy.engine import Engine
@event.listens_for(Engine, "before_cursor_execute")
...
이러한 주석은 pg_stat_activity, pg_stat_statements 및 느린 쿼리 로그에 나타납니다. agent_id=fulfillment-v3, task_id=task-abc-123, step=check-inventory라는 태그가 붙은 채 느린 쿼리 로그에 나타나는 쿼리는 즉각적인 조치가 가능합니다. 이것이 없다면, 당신은 고고학을 하고 있는 셈입니다.
에이전트별로 그룹화된 쿼리를 드러내는 모니터링 뷰 (monitoring view)를 구축하십시오:
-- 쿼리 텍스트에서 에이전트 컨텍스트를 추출한 pg_stat_statements
SELECT
(regexp_match(query, 'agent_id=([^,]+)'))[1] AS agent_id,
...
특정 에이전트 유형이 전체 데이터베이스 시간의 60%를 차지하는 것을 확인하면, 어디를 살펴봐야 할지 알 수 있습니다.
가정 - 스키마는 엔지니어링과의 계약이다
이것은 대부분의 팀이 문제가 발생하기 전까지는 전혀 생각하지 못하는 가정입니다. 당신의 스키마는 개발자 편의성 (developer ergonomics)을 위해 설계되었습니다. 엔지니어들이 이해하기 쉽게 이름을 붙였고, 쿼리 편의성을 위해 구조화되었으며, 원래의 마이그레이션 주석 (migration comment)을 읽어야만 "의미가 있는" Null 허용 (nullable) 컬럼들을 포함하고 있습니다.
에이전트가 Text-to-SQL, 도구 정의 (tool definitions), 또는 데이터베이스를 래핑하는 MCP 서버를 통해 당신의 스키마를 볼 수 있게 되면, 스키마는 언어 모델 (language model)과의 계약이 됩니다. 이제 컬럼 이름, 테이블 구조, 그리고 Null 허용 여부는 LLM이 올바른 쿼리를 생성할지, 아니면 확신에 찬 어조의 헛소리 (confident-sounding nonsense)를 생성할지를 결정짓는 요소가 됩니다.
다음 두 가지 컬럼 정의의 차이를 고려해 보십시오.
-- 대부분의 스키마가 보이는 모습
CREATE TABLE orders (
id UUID PRIMARY KEY,
...
두 번째 스키마는 LLM 쿼리 (LLM queries)를 거의 자동으로 생성해냅니다. 첫 번째 스키마는 스키마 레벨에서 처리되었어야 할 부분을 보완하기 위해 광범위한 프롬프트 엔지니어링 (prompt engineering)을 필요로 합니다.
이름을 변경할 수 없는 스키마(레거시 시스템 (legacy systems), 마이그레이션 비용이 높은 테이블)의 경우, 에이전트 전용 뷰 레이어 (agent-facing view layer)를 구축하십시오.
-- 원본 테이블은 레거시 이름을 유지합니다
-- 에이전트는 이 뷰를 쿼리하며, 기본 테이블에는 직접 접근하지 않습니다
CREATE VIEW agent_orders AS
...
컬럼 코멘트 (column comments)를 마치 독스트링 (docstrings)인 것처럼 작성하십시오. Text-to-SQL 에이전트에게는 실제로 독스트링이기 때문입니다.
COMMENT ON COLUMN agent_orders.fulfillment_status IS
'이행 파이프라인 (fulfillment pipeline) 내 주문의 현재 상태입니다. '
'조치가 필요한 주문을 필터링하는 데 사용하십시오: pending 및 processing 상태의 주문은 활성 상태입니다. '
...
영향 범위 (Blast Radius) 제한하기
위의 모든 가정을 관통하는, 별도로 다룰 가치가 있는 또 다른 실패 모드 (failure mode)가 하나 더 있습니다. 바로 오작동하는 에이전트의 영향 범위 (blast radius)는 해당 에이전트에게 부여된 권한에 의해 결정된다는 점입니다.
전통적인 애플리케이션은 데이터베이스 역할 (database role)을 공유하거나, 기껏해야 서로 다른 서비스들을 위한 몇 개의 역할만을 가집니다. 이때의 가정은 애플리케이션 코드 (application code)가 가드레일 (guard rail) 역할을 한다는 것이었습니다. 만약 코드가 사용자가 자신의 레코드만 업데이트할 수 있도록 허용한다면, 데이터베이스 역할이 이를 강제할 필요는 없었습니다. 애플리케이션 레이어 (application layer)에서 이를 처리했기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN Design Systems의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기