본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 21:37

AI 콘텐츠 파이프라인에서 "이런 일이 일어나면 안 됩니다"와 "이런 일은 일어날 수 없습니다"의 차이점

요약

AI 에이전트에게 개인 데이터 접근 권한을 부여할 때, 단순 필터링과 구조적 보안(Walling)의 차이를 설명합니다. PostgreSQL 뷰 사용 시 RLS가 우회되는 문제를 지적하며, security_invoker 옵션을 통한 올바른 보안 구현 방법을 제시합니다.

핵심 포인트

  • PostgreSQL 뷰는 기본적으로 소유자 권한으로 실행되어 RLS를 우회함
  • 단순 필터링은 보안 사고 시 데이터 노출 위험이 있음
  • security_invoker 옵션을 통해 호출자 권한으로 실행하여 보안 강화 가능
  • AI 에이전트의 데이터 접근 시 구조적 보안 게이트 구축이 필수적임

약 3주 전, 저는 수년 동안 미뤄왔던 일을 정리하는 것을 마쳤습니다. 로컬 드라이브, Google Drive, 그리고 10년 치 다운로드 파일에 걸쳐 있는 226개의 파일들이었습니다. 인덱스도, 구조도 없으며, 제가 파일 이름을 정확히 기억하지 못하면 아무것도 찾을 수 없는 120만 단어 이상의 분량이었습니다.

저는 이를 쿼리 가능한 지식 베이스 (knowledge base)로 만들었고, 이를 Supabase 프로젝트에 연결했으며, 제 앱과 책을 운영하는 데 사용하는 콘텐츠 파이프라인 (content pipeline)에 연결했습니다. 이 과정을 기록하기 위해 구축한 시스템은 실제로 그 시스템이 설명하고 있는 대상으로부터 소스 자료를 찾아내는 데 사용되었습니다. 그 부분은 의도한 대로 정확히 작동했습니다.

하지만 거의 작동하지 않을 뻔했던 것은 보안 게이트 (security gate)였습니다.

필터의 문제점

AI 에이전트 (AI agent)에게 개인 아카이브 (personal archive)에 대한 접근 권한을 줄 때, 에이전트가 볼 수 있는 범위에 대해 두 가지 옵션이 있습니다. 필터링 (filtering)을 하거나, 벽을 세우는 (walling) 것입니다.

필터링 (Filtering)은 쿼리가 특정 행 (rows)만 반환하는 것을 의미합니다. 벽을 세우는 것 (Walling)은 에이전트가 구조적으로 숨기고자 하는 행에 도달할 수 없게 만드는 것을 의미합니다. 외부에서 보기에는 동일해 보입니다. 차이점은 무언가 고장 났을 때 나타납니다.

설정은 다음과 같습니다. 저는 개인적인 글쓰기 파편, 장면, 초안 및 메모를 담고 있는 moments 테이블을 Supabase에 가지고 있습니다. 그중 일부는 분명히 공개적이고 정전 (canonical)적인 내용입니다. 하지만 많은 부분이 비공개이거나, 초기 단계이거나, 어떤 에이전트가 건드리기에는 구조적으로 부적절합니다. 저는 AI 콘텐츠 에이전트들이 검증된 공개 자료에서만 내용을 가져오기를 원했습니다.

명백한 해결책은 뷰 (view)를 만드는 것이었습니다.

CREATE VIEW public_seeds AS
  SELECT * FROM moments
  WHERE visibility  = 'public'
...

이것은 올바르게 보입니다. 두 조건이 모두 참인 행만 반환합니다. 하지만 밤 11시에 Postgres 문서를 충분히 읽어보기 전까지는 명확히 드러나지 않는 문제가 있습니다: 기본적으로 Postgres 뷰 (view)는 호출하는 역할 (calling role)이 아니라 뷰 소유자 (view owner)로서 실행됩니다.

이는 행 수준 보안 (row-level security, RLS)이 적용되지 않음을 의미합니다. 뷰 소유자 (보통 서비스 역할 (service role))는 테이블 전체에 대한 접근 권한을 가지며, 뷰는 이를 상속받습니다. 여러분의 RLS 정책 (policy)은 존재하지만, 뷰는 소유자로서 실행되면서 이를 완전히 우회합니다. 여러분은 벽을 세운 것이 아니라, 필터를 작성한 것입니다.

"이런 일이 일어나면 안 됩니다"와 "이런 일은 일어날 수 없습니다"는 같은 문장이 아닙니다.

해결책: security_invoker

PostgreSQL 15에는 뷰(view)를 위한 security_invoker 옵션이 추가되었습니다. 이 옵션을 true로 설정하면, 뷰는 소유자(owner) 대신 호출한 역할(calling role)로서 실행됩니다. 이때 행 수준 보안 (RLS, Row-Level Security)이 정상적으로 적용됩니다. 이제 뷰는 단순한 필터가 아니라 구조적인 관문(structural gate)이 됩니다.

올바른 버전:

CREATE TABLE moments (
  id           TEXT     PRIMARY KEY,
  title        TEXT,
...

이제 벽은 구조적입니다. public_seeds를 호출하는 콘텐츠 에이전트(content agent)는 뷰를 우회하려고 시도하더라도, 쿼리가 흥미로운 방식으로 잘못 작성되더라도, 혹은 미래의 개발자가 고려 없이 조인(join)을 추가하더라도 프라이빗 행(private rows)에 도달할 수 없습니다. RLS 정책이 테이블 수준에서 강제되기 때문입니다. 뷰는 이미 견고하게 유지되고 있는 대상 위에 씌워진 편리한 래퍼(wrapper)일 뿐입니다.

에이전트가 개입될 때 이것이 더 중요한 이유

사람이 데이터베이스에 쿼리를 날릴 때는 필터만으로도 대개 충분합니다. 사람은 에러 메시지를 읽습니다. 결과가 잘못되어 보이면 알아차립니다. 그리고 질문을 던집니다.

AI 에이전트는 이 중 그 어떤 것도 하지 않습니다. 원래 받아야 할 것보다 더 많은 행을 반환받은 에이전트는 자신이 더 많은 행을 받았다는 사실을 알지 못합니다. 에이전트는 받은 데이터 그대로 작업을 수행합니다. 만약 필터가 조용히 실패한다면, 무언가 잘못되었다는 가시적인 신호 없이 에이전트의 행동이 변하게 됩니다.

이것이 바로 에이전트가 루프(loop) 안에 있을 때 "이런 일이 일어나면 안 됩니다"라는 접근 방식이 아키텍처적으로 불충분한 핵심 이유입니다. "안 된다(Shouldn't)"는 정확성(correctness)에 의존합니다. 반면 "일어날 수 없다(Cannot)"는 구조(structure)에 의존합니다. 구조는 정확성이 실패할 때, 쿼리가 예상과 다르게 작성될 때, 의존성이 변경될 때, 혹은 미래의 개발자가 필터의 존재를 모르는 새로운 코드 경로를 추가할 때도 유지됩니다.

제 파이프라인의 경우, public_seeds 뷰가 에이전트(agents)가 접근할 수 있는 유일한 접점(surface area)입니다. 비공개 초안, 게시되지 않은 장면, 개인적인 메모, 혹은 제가 힘든 시기에 작성하여 아직 아무것도 하고 싶지 않은 것들: 그 어떤 것도 접근할 수 없습니다. 쿼리가 이를 필터링해서가 아닙니다. 아키텍처(architecture) 자체가 그것이 반환되는 것 자체를 차단하기 때문입니다.

제가 다르게 했을 부분

security_invoker 플래그는 PostgreSQL 15의 기능입니다. 만약 이전 버전을 실행 중이거나 최근에 Supabase 프로젝트의 Postgres 버전을 확인하지 않았다면, 이 기능이 없을 수도 있습니다. 대안은 역할 시스템(role system)을 통해 직접 액세스(access)를 관리하는 것입니다. 에이전트를 위한 전용 읽기 전용 역할(read-only role)을 생성하고, 노출하고자 하는 행(rows)에 대해서만 명시적인 SELECT 권한을 부여하며, 절대로 서비스 역할(service role)을 사용하여 에이전트를 연결하지 마십시오.

서비스 역할(service role)은 설계상 RLS(Row Level Security)를 우회합니다. 만약 에이전트가 서비스 역할 키(service role key)로 연결되어 있다면, 그 어떤 뷰 필터링(view filtering)도 당신을 보호할 수 없습니다. 이것이 가장 먼저 확인해야 할 사항입니다.

두 번째로 확인할 사항은 스키마(schema)에 존재하는 기존 뷰(views) 중 보안에 대해 신중하게 생각하기 전에 생성된 것이 있는지 여부입니다. security_invoker 없이 생성된 뷰는 나중에 추가하는 그 어떤 RLS 정책(RLS policy)보다 앞선 시점에 만들어진 것입니다. 정책은 해당 뷰들에 소급 적용되지 않습니다.

세 번째로 제가 다르게 했을 부분은, 게이트(gate)의 이름을 명시적으로 짓는 것입니다. public_seeds도 충분히 명확하지만, 저는 에이전트용 뷰에 agent_ 접두사를 붙이기 시작했습니다. 그렇게 하면 무엇이 인간용이 아닌 기계 소비용(machine consumption)으로 설계되었는지 한눈에 알 수 있습니다. 작은 부분이지만, 나중에 혼란을 방지해 줍니다.

지식 베이스(knowledge base)는 작동 중입니다. 아카이브(archive)에는 인덱싱되어 검색 가능한 수백 개의 공개된 순간들이 있습니다. 여기서 생성된 콘텐츠는 작업할 원천 자료(source material)가 있다는 점에서 진정으로 더 나은 품질을 보여주며, 이것이 바로 이 시스템을 구축해야 하는 근본적인 이유입니다.

이 프로젝트를 거의 좌초시킬 뻔했던 부분은 CREATE VIEW 문에 포함된 13단어짜리 절(clause)이었습니다. 무언가를 연결하기 전에 반드시 알아둘 가치가 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0