PostgreSQL 다중 테넌트 행 수준 보안 구축: 실전 패턴
Source: Dev.to
PostgreSQL에서 다중 테넌트 행 수준 보안 구축: 실제 적용 패턴
대부분의 다중 테넌트 SaaS 애플리케이션은 애플리케이션 레이어에서 테넌트 격리를 구현합니다. 요청에서 tenant_id를 확인하고, 서비스 레이어에서 소유권을 검증하고, ID가 일치하지 않을 경우 예외를 발생시키는 미들웨어를 추가하죠. 이것은 작동합니다—하지만 작동하지 않을 때도 있습니다.
이 패턴 때문에 실제 운영 시스템이 고장 나는 모습을 여러 번 보았습니다. 주니어 개발자가 인증 검사를 하나 빼먹는다. 리팩터링 과정에서 로직이 옮겨지면서 방어 장치가 사라진다. 권한이 상승된 크론 잡이 실행돼 경쟁사의 데이터를 내보낸다. 이들은 가상의 시나리오가 아니라, 제가 CitizenApp에서 직접 디버깅한 실제 사례입니다.
데이터베이스 차원의 행 수준 보안(RLS)은 모델을 뒤바꿉니다: 데이터베이스 자체가 테넌트에 속하지 않은 행을 반환하지 않으므로, 코드가 무엇을 시도하든 관계없이 보호됩니다. 이것은 벨트와 서스펜더를 동시에 착용하는 것과 같은데, 실제로 벨트가 작동합니다.
직설적으로 말하자면: 애플리케이션 레이어 격리는 제안일 뿐 보장되지 않습니다.
전형적인 FastAPI 패턴
@router.get("/users")
async def list_users(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Authorization happens here
return db.query(User).filter(User.tenant_id == current_user.tenant_id).all()
보이는 대로 안전해 보이지만, 다음과 같은 문제점이 있습니다.
- 잊힌 필터: 새로운 엔드포인트가
tenant검증 없이User를 조회한다. 흔한 실수. - 범위 확장: 관리 패널이 모든 테넌트의 사용자를 보여줘야 해서 필터를 우회한다. 이제 그 코드 경로가 존재하고 누군가 복사한다.
- N+1 관계: 사용자를 로드한 뒤 루프를 돌며 감사 로그를 로드한다. 두 번째 쿼리에서 테넌트 필터를 빼먹는다.
- 백그라운드 작업: Celery 작업이
tenant_id = None인 “시스템 사용자”로 실행된다. 이제 모든 데이터를 볼 수 있다.
가장 안타까운 점은, 이러한 버그는 악용될 때까지 눈에 보이지 않는다는 것입니다. 테스트는 단일 테넌트 컨텍스트 내에서만 실행되기 때문에 통과하고, 모니터링도 데이터를 “올바르게” 접근했으니(단지 잘못된 사람에 의해) 잡아내지 못합니다.
RLS 정책은 데이터베이스에 존재합니다. PostgreSQL은 행을 반환하기 전에 정책을 평가합니다. 읽을 권한이 없는 데이터는 읽을 수 없으며, 데이터베이스가 이를 차단합니다.
제가 사용하는 패턴
-- users 테이블에 RLS 활성화
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- 자신의 테넌트에만 접근을 허용하는 정책 생성
CREATE POLICY users_tenant_isolation ON users
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- 필요하다면 슈퍼유저용 별도 정책 생성
CREATE POLICY users_admin_all ON users
FOR ALL
USING (current_setting('app.is_admin')::boolean = true);
-- 데이터베이스 소유자에게는 RLS 비활성화 (마이그레이션 스크립트에 필요)
ALTER TABLE users FORCE ROW LEVEL SECURITY;
핵심은 current_setting() 입니다. 이 PostgreSQL 함수는 세션 변수를 읽어옵니다. 인증이 끝난 뒤 애플리케이션이 해당 변수를 설정하고, 데이터베이스는 이를 자동으로 쿼리 필터링에 사용합니다.
FastAPI + SQLAlchemy에 적용하기
from sqlalchemy import create_engine, text, event
from sqlalchemy.orm import sessionmaker, Session
from typing import Optional
engine = create_engine("postgresql://...", echo=False)
SessionLocal = sessionmaker(bind=engine)
def set_rls_context(session: Session, tenant_id: str, is_admin: bool = False):
"""쿼리를 실행하기 전에 RLS 컨텍스트를 설정합니다."""
session.execute(
text("SET app.current_tenant_id = :tenant_id"),
{"tenant_id": tenant_id}
)
session.execute(
text("SET app.is_admin = :is_admin"),
{"is_admin": is_admin}
)
async def get_db(
current_user: User = Depends(get_current_user)
) -> Session:
"""RLS 컨텍스트가 포함된 세션을 생성하는 의존성."""
session = SessionLocal()
try:
set_rls_context(
session,
tenant_id=str(current_user.tenant_id),
is_admin=current_user.role == "admin"
)
yield session
finally:
session.close()
이제 쿼리는 아주 간단합니다.
@router.get("/users")
async def list_users(db: Session = Depends(get_db)):
# 테넌트 필터가 필요 없습니다—RLS가 처리합니다
return db.query(User).all()
PostgreSQL은 세션 컨텍스트에 따라 자동으로 행을 필터링합니다. 현재 사용자가 abc-123 테넌트에 속한다면, tenant_id = 'abc-123'인 행만 반환됩니다. SELECT * FROM users를 실행해도 자신 테넌트의 행만 보게 됩니다.
모델은 그대로 유지됩니다
from sqlalchemy import Column, String, UUID, ForeignKey
from sqlalchemy.orm import declarative_base
import uuid
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(UUID, primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID, ForeignKey("tenants.id"), nullable=False)
email = Column(String, nullable=False)
role = Column(String, default="user")
특별한 ORM 마법이 필요 없습니다. SQLAlchemy는 RLS를 알 필요가 없으며, 바로 그 점이 핵심입니다. 데이터베이스가 모든 것을 강제합니다.
여러 테이블에 RLS 적용하기
조직, 프로젝트, 감사 로그, 인보이스 등 모든 테이블에 RLS가 필요하지만, 컨텍스트 설정은 한 번만 하면 됩니다.
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_tenant_isolation ON organizations
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_tenant_isolation ON projects
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_tenant_isolation ON audit_logs
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
set_rls_context()를 호출하면 모든 테이블에 대한 쿼리가 자동으로 테넌트 경계를 준수합니다. 프로젝트와 감사 로그를 JOIN하든, 세 개 테이블을 한 트랜잭션에 묶든, 모두 필터링됩니다. 미래에 새로운 테이블을 추가하고 RLS 정책을 잊어버리면, PostgreSQL이 쓰기를 거부하므로 실수를 바로 알 수 있습니다.
마이그레이션과 스크립트 실행 시 RLS 비활성화
Alembic 마이그레이션은 데이터베이스 소유자 권한으로 실행되므로, RLS가 적용돼 있으면 일부 작업이 실패합니다. 이를 처리하는 방법은 다음과 같습니다.
# alembic/env.py
def run_migrations_online():
with connectable.connect() as connection:
# FORCE ROW LEVEL SECURITY가 설정되지 않은 경우 슈퍼유저에게는 RLS가 적용되지 않음
# 안전을 위해 마이그레이션 중에는 RLS를 비활성화
connection.execute(text("ALTER ROLE myapp_user BYPASSRLS"))
with connection.begin():
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
또한 current_setting()은 설정되지 않으면 NULL을 반환합니다. 즉, 컨텍스트가 없는 상태에서 쿼리하면 결과가 0행이 되는데, 이는 안전한 기본값이지만 로컬 개발 시 혼란을 줄 수 있습니다. 그래서 저는 테스트용 컨텍스트를 항상 설정합니다.
# conftest.py for pytest
@pytest.fixture
def db_with_rls():
session = SessionLocal()
set_rls_context(session, tenant_id="test-tenant-123")
yield session
session.close()
CitizenApp에서 RLS를 도입한 순간, 더 이상 권한 버그에 대해 걱정하지 않게 되었습니다. 실수를 멈춘 것이 아니라, 데이터베이스가 그 실수를 불가능하게 만들었기 때문입니다.
모든 엔드포인트, 모든 백그라운드 작업, 앞으로 추가될 모든 기능은 동일한 철벽 같은 보장을 상속받습니다: 어떤 코드 경로