WHERE 절은 보안 경계가 아닙니다 (pgvector + RLS를 이용한 멀티 테넌트 RAG)
요약
멀티 테넌트 RAG 시스템에서 애플리케이션 계층의 WHERE 절 필터링은 보안 취약점이 될 수 있습니다. Postgres의 RLS(Row Level Security)를 활용하여 데이터베이스 수준에서 테넌트 격리를 강제함으로써 보안 경계를 구축하는 방법을 제시합니다.
핵심 포인트
- 애플리케이션의 WHERE 절 필터링은 단일 장애점(SPOF)이 될 수 있음
- RLS를 통해 데이터베이스 수준에서 테넌트 격리를 강제해야 함
- 보안 경계는 코드의 약속이 아닌 데이터베이스의 규칙이어야 함
- pgvector와 RLS를 결합하여 안전한 벡터 검색 환경 구축 가능
요약(TL;DR): 애플리케이션 계층의 필터링은 단일 장애점(Single Point of Failure)입니다. RLS를 통해 테넌트 격리(Tenant Isolation)를 Postgres로 밀어 넣으세요. 그리고 벡터 검색 함수에서 security definer 함정에 주의하십시오.
Your WHERE clause is not a security boundary
제 앱은 부모들을 위한 AI 웰니스 코치입니다. 모든 사용자의 데이터는 그들이 가진 가장 사적인 것, 즉 실제로 어떻게 대처하고 있는지에 관한 것입니다. 그들의 체크인, 힘든 밤, 차마 입 밖으로 내뱉지 못할 이야기들 말이죠. 제품 전체는 검색(Retrieval)을 기반으로 작동합니다. 누군가 코치와 대화할 때, 시스템은 벡터 스토어(Vector Store)에서 관련 기록을 가져와 응답의 근거(Grounding)로 삼습니다.
이는 제가 상상할 수 있는 가장 무서운 버그가 시스템 충돌이 아니라는 것을 의미합니다. 그것은 사용자 A가 질문을 했을 때, 검색 과정에서 사용자 B의 개인적인 기록 조각이 조용히 반환되는 것입니다. 에러도 없고, 스택 트레이스(Stack Trace)도 없습니다. 그저 한 사람의 가장 힘들었던 밤이 다른 사람의 대화 속에 나타나는 것뿐입니다.
멀티 테넌트(Multi-tenant) 앱에서, 그러한 버그는 언제든 코드 한 줄을 잊어버림으로써 발생할 수 있습니다. 제가 어떻게 이런 일이 일어나지 않도록 보장하는지, 그리고 어떤 튜토리얼도 경고해주지 않는 부분에 대해 말씀드리겠습니다.
명백한 해결책은 단일 장애점(Single Point of Failure)입니다
테넌트들을 분리하는 본능적인 방법은 쿼리에서 필터링하는 것입니다:
select * from embeddings
where user_id = $current_user
order by embedding <=> $query
limit 5;
이 방식은 작동합니다. 하지만 이 방식은 지친 인간인 제가, 앞으로 만들 기능들을 포함하여 해당 테이블에 닿는 모든 쿼리에 where user_id = ...를 영원히 작성해야 한다는 사실을 기억하는 것에 의존합니다.
그것은 보안 경계(Security Boundary)가 아닙니다. 그것은 약속(Promise)입니다. 그리고 약속의 실패 모드(Failure Mode)는, 당신이 그것을 잊어버리는 날 — 혹은 새로운 쿼리 경로가 그것을 건너뛰거나 리팩토링 과정에서 누락되는 날 — 당신을 받아줄 밑바탕이 아무것도 없다는 것입니다. 앱은 잘못된 테넌트의 데이터를 반환하면서도 완전히 정상적으로 작동하는 것처럼 보입니다. 이것이 바로 제가 예전에 자체 감사(Audit) 중에 발견했던 버그의 형태였습니다. 저는 다시는 그런 실수를 하지 않겠다는 다짐에 의존하고 싶지 않았습니다.
격리(Isolation)는 애플리케이션이 아니라 데이터베이스에 속해야 합니다.
해결책은 경계(boundary)를 한 단계 아래인 Postgres 자체로 옮기는 것이며, 이를 위해 행 수준 보안 (Row Level Security, RLS)을 사용합니다. RLS를 사용하면 쿼리가 무엇을 요청하든 상관없이, 데이터베이스가 사용자가 볼 수 있는 행을 강제로 제한할 수 있습니다.
alter table embeddings enable row level security;
create policy "Users read their own embeddings"
on embeddings for select
using (auth.uid() = user_id);
이제 규칙은 "필터링하는 것을 기억해 주세요"가 아닙니다. 규칙은 다음과 같습니다: 데이터베이스가 해당 행을 반환하지 않기 때문에, 이 사용자는 물리적으로 다른 사용자의 행을 선택할 수 없습니다. 필터링을 잊어버린 쿼리라도 여전히 격리된 상태로 결과를 반환합니다. 격리 기능이 더 이상 쿼리에 있는 것이 아니라 테이블에 있기 때문입니다.
이것은 보안 전문가들이 수십 년 동안 의존해 온 원칙인 심층 방어 (Defense in depth)입니다. 애플리케이션 계층의 필터는 여전히 첫 번째 방어선으로서 존재합니다. RLS는 그 첫 번째 방어선에서 실수가 발생하더라도 재앙이 아닌 생존 가능한 수준으로 만들어주는 최후의 보루 (backstop) 역할을 합니다. 한 계층이 실패하더라도 전체적인 보장(guarantee)이 무너지지 않습니다.
아무도 언급하지 않는 pgvector의 함정
여기서부터 흥미로운 지점이 나타나며, 제가 실제로 확신하는 부분은 대부분의 "Supabase에서 RAG 구축하기" 튜토리얼들이 조용히 결함을 가지고 있다는 점입니다.
벡터 유사도 검색 (Vector similarity search)은 보통 SQL 함수로 래핑됩니다. 즉, 애플리케이션에서 깔끔하게 호출하고 근사 최근접 이웃 (ANN) 인덱스를 효율적으로 유지할 수 있도록 match_documents 스타일의 RPC를 사용합니다.
create function match_user_docs(query_embedding vector(1536), match_count int)
returns setof embeddings
language sql
as $$
select *
from embeddings
order by embedding <=> query_embedding
limit match_count;
$$;
문제의 원인(footgun)은 함수의 보안 모드(security mode)에 있습니다. 만약 권한 문제를 쉽게 해결하기 위해 많은 복사-붙여넣기식 벡터 검색 예제들이 사용하는 것처럼 함수를 security definer로 설정한다면, 해당 함수는 정의자(definer)의 권한으로 실행되며 호출자의 RLS(Row Level Security)를 완전히 우회하게 됩니다. 테이블에 행 수준 보안(RLS)을 정성껏 설정해 놓고도, 그 보호 기능을 꺼버리는 함수를 통해 호출하게 되는 셈이며, 이를 알아차리기는 매우 어렵습니다. 함수는 결과를 반환하고, 앱은 데모에서 잘 작동하며, 모든 테넌트(tenant)의 벡터들이 단 한 번의 호출을 통해 조용히 접근 가능해지기 때문입니다.
해결책은 지루하지만 매우 중요합니다. 검색 함수를 security invoker로 유지하여 호출자의 RLS가 여전히 적용되도록 하거나, 만약 반드시 security definer를 사용해야 한다면 함수 내부에서 auth.uid()로 필터링하고 search_path를 고정(pin)해야 합니다. 핵심은 편의를 위해 만든 래퍼(wrapper)가 방금 구축한 벽에 구멍을 내는 도구가 되지 않도록 하는 것입니다.
한 가지 더 고려할 점: 필터링과 근사 검색(approximate search) 사이의 충돌
알아둘 만한 미묘한 성능 상호작용이 있습니다. pgvector의 인덱스(HNSW 또는 IVFFlat)는 근사 최근접 이웃(approximate nearest-neighbor) 검색을 수행합니다. 즉, 대략적으로 가장 가까운 벡터들을 빠르게 반환합니다. 여기에 RLS를 추가하면, 격리 필터(isolation filter)가 해당 후보군을 현재 테넌트의 행으로 축소합니다.
만약 인덱스에 전역(global) 기준 상위 5개를 요청했는데 격리 과정에서 본인의 것이 아닌 데이터들이 제거된다면, 결과가 5개보다 적게 나오거나, 데이터가 많은 테이블에서는 결과가 하나도 없을 수도 있습니다. 따라서 권장되는 패턴은 '오버 페치(over-fetch)'하는 것입니다. 즉, 인덱스에 필요한 것보다 더 많은 후보를 요청하여, 격리 과정을 거친 후에도 좋은 답변을 생성할 수 있는 충분한 근거(grounding) 데이터가 남도록 하는 것입니다. 이는 실제 멀티 테넌트 부하 상황에서만 나타나는 작은 문제이지만, 그렇기에 반드시 짚고 넘어가야 할 가치가 있습니다.
결론
모델이 모든 관심을 받지만, AI 앱에서 확실성이 보장되어야 하는 부분은 모델인 경우가 드뭅니다. 여기서는 데이터 경계(data boundary)가 바로 그 부분입니다. 그리고 애플리케이션 코드에서 강제하는 경계는 당신이 가장 컨디션이 좋지 않은 날의 기억력만큼만 강력할 뿐입니다.
그래서 저는 이를 잊혀질 수 없는 곳으로 밀어 넣습니다. 애플리케이션은 마땅히 그래야 하기에 필터링을 수행합니다. 데이터베이스는 반드시 그래야 하기에 격리(Isolate)를 수행합니다. 잊혀진 하나의 WHERE 절이 보안 침해(Breach)가 아닌 아무런 사건도 아닌 일이 되어야 하며, 이를 보장하는 유일한 방법은 쿼리(Query)를 신뢰하는 것을 멈추고 테이블(Table)을 신뢰하기 시작하는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기