권한 검증이 여기저기 흩어지면 누락과 유출로 이어집니다. PostgreSQL의 RLS는 검증을 데이터베이스로 옮겨 안전성과 일관성을 높이지만, 디버깅·성능·운영상의 트레이드오프가 따릅니다.
권한 드리프트는 현실입니다: 여기저기 흩어진 검사, 잊힌 필터, 그리고 데이터 유출. PostgreSQL의 Row Level Security(RLS)는 판을 뒤집습니다. 검증을 데이터베이스로 밀어 넣어 안전성을 강화하지만, 디버깅과 성능 면에서의 트레이드오프가 있습니다.
권한은 어떤 애플리케이션을 만들든 가장 복잡한 부분 중 하나입니다. 접근 제어는 근본적인 시스템 설계 요구사항이며, 대부분의 구현은 애플리케이션 레이어에서 검사를 강제하는 것부터 시작합니다.
예를 들어:
@app.get("/projects")
async def list_projects(user: User, db: Session = Depends(get_db)):
return db.query(Project).filter(Project.user_id == user.id).all()
쿼리는 금세 명시적인 WHERE
절을 쌓아가고, 미들웨어는 제약을 강제하며, 가드 로직은 코드베이스 전반에 흩어집니다. 기능적으로는 동작하지만, 이 접근법은 일관성 결여, 누락, 취약점에 취약합니다.
시간이 지나면 진짜 단일한 신뢰 원천이 어디에 있는지 누구도 확신하지 못하게 됩니다. 혼란이 생기고, 권한은 표류하며, 한 줄의 누락된 절 때문에 데이터가 엉뚱한 사람에게 유출될 수 있습니다.
다행히 이 불확실성을 해결할 도구가 있습니다. 그중 하나가 권한을 강제하는 일급 메커니즘을 제공하는 PostgreSQL의 Row Level Security(RLS)입니다. FastAPI 서비스 맥락에서 보면, RLS는 더 넓은 질문을 이렇게 다시 묻습니다:
권한 검증은 실제로 어디에 있어야 할까? 애플리케이션인가, 데이터베이스인가?
처음엔 권한을 데이터베이스로 옮기는 것이 직관에 반할 수 있습니다. FastAPI 같은 애플리케이션 프레임워크는 이미 미들웨어, 의존성 주입, 훅 등을 통해 검사를 제공합니다. 왜 PostgreSQL을 끌어들일까요?
답은 두 가지 기둥 위에 있습니다:
이는 PostgreSQL을 단순 저장 계층에서 신뢰의 집행자로 변모시킵니다.
전통적으로는 앞선 코드 스니펫에서 보듯 애플리케이션 코드가 검사를 수행합니다:
db.query(Project).filter(Project.user_id == user.id).all()
이는 곧 다음을 의미합니다:
RLS는 이 관행을 뒤집습니다. 애플리케이션은 단지 세션 파라미터를 설정하고(FastAPI라면 SET LOCAL app.current_user_id
에 해당) 쿼리를 실행합니다. PostgreSQL이 정책을 집행합니다.
RLS에서는 데이터베이스가 이 규칙을 직접 강제합니다:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_can_view_projects
ON projects
FOR SELECT
USING (
EXISTS (
SELECT 1
FROM memberships m
WHERE m.project_id = projects.id
AND m.user_id = current_setting('app.current_user_id')::uuid
)
);
이는 아키텍처적 질문을 제기합니다:
실제로는 통제력과 안전성 사이의 트레이드오프입니다. 애플리케이션 수준 검사는 명시적이지만 깨지기 쉽고, 데이터베이스 수준 검사는 암묵적이지만 견고합니다.
RLS 도입은 기술적 변화이자 문화적 변화입니다. FastAPI 엔드포인트 디버깅에 익숙한 개발자는 이제 "결과가 비어 있음"이 파이썬에서 필터가 잘못되어서가 아니라, 데이터베이스 정책의 결과일 수 있음을 인지해야 합니다. 디버깅의 초점은 "이 필터가 왜 틀렸지?"에서 "어떤 정책이 적용됐지?"로 이동합니다.
FastAPI에서는 요약하면 현재 사용자의 신원을 PostgreSQL에 전달하는 문제로 귀결됩니다:
async def get_db(user: User):
async with async_session() as session:
# RLS를 위해 현재 사용자 설정
await session.execute(
text("SET LOCAL app.current_user_id = :uid"),
{"uid": str(user.id)}
)
yield session
이 값이 설정되면, 트랜잭션 내 모든 쿼리는 RLS 정책에 따라 평가됩니다.
이제 RLS를 활성화하면, 엔드포인트는 더 이상 명시적인 필터를 필요로 하지 않으며 다음과 같이 설계할 수 있습니다:
@app.get("/projects")
async def list_projects(db: AsyncSession = Depends(get_db)):
return db.query(Project).all()
쿼리에 WHERE
절이 없어도 PostgreSQL이 허용된 행만 반환하도록 보장합니다.
이제 개발자는 엔드포인트에서 직접 권한을 강제하지 않고도 자연스럽게 테이블을 질의할 수 있습니다. 이런 구조는 몇 가지 필요한 조정과 성찰을 수반합니다:
SET LOCAL
을 잘못 적용하면 사용자 식별자가 풀 연결 간에 지속되어 데이터가 노출될 수 있습니다. 올바른 트랜잭션 스코핑이 중요합니다.요컨대, RLS는 복잡성을 제거하지 않습니다. 복잡성을 재배치하여, 책임을 애플리케이션 코드에서 PostgreSQL 스키마 설계로 옮깁니다.
RLS는 쿼리가 접근하는 모든 행에 대해 평가됩니다. 단순한 경우에는 오버헤드가 미미하지만, 복잡한 조인은 예상치 못한 쿼리 플랜을 유발할 수 있습니다.
예를 들어:
USING (
EXISTS (
SELECT 1
FROM memberships
WHERE memberships.project_id = projects.id
AND memberships.user_id = current_setting('app.current_user_id')::uuid
)
)
겉보기엔 단순하지만 다음과 같은 점을 유의해야 합니다:
memberships
에 인덱스가 없으면 성능이 심각하게 저하될 수 있습니다.projects → tasks → comments
)은 비용을 기하급수적으로 늘립니다.권장 모범 사례:
EXPLAIN
으로 실행 계획을 주기적으로 검토합니다.따라서 RLS는 보안을 집행하는 동시에, 스키마 설계를 데이터베이스 엔지니어의 관점으로 접근하도록 만듭니다.
대규모로 RLS를 구현하면 여러 미묘한 문제가 발생합니다:
SET LOCAL
을 사용하고, 전역 SET
은 절대 사용하지 마십시오. 그렇지 않으면 한 사용자의 ID가 풀 연결을 통해 다른 사용자의 요청으로 새어 나갈 수 있습니다.RLS는 강력하지만 만능은 아닙니다. 특정 시나리오는 애플리케이션 로직으로 처리하는 편이 낫습니다:
RLS는 전체 권한 시스템이 아니라 "구조적 가시성 규칙"(예: "사용자는 자신의 행만 볼 수 있다")에 가장 적합합니다. 이는 애플리케이션 레벨이 아니라 저장소 레벨에서 적절한 격리가 필요한 멀티 테넌트 데이터베이스에 잘 맞으며, 보안을 향상시킵니다.
모든 엔지니어링 결정과 마찬가지로, 여러 차원의 트레이드오프로 요약됩니다.
애플리케이션 수준 필터링 | Row Level Security | |
---|---|---|
안전성 | ⚠️ 누락에 취약 | ✅ 데이터베이스가 보장 |
투명성 | ✅ 애플리케이션 코드에 명시적 | ✅ 스키마에 암묵적 |
성능 | ⚠️ 과도한 페치 가능성 | ⚠️ 과도한 페치를 피우지만 일부 최적화를 방해할 수 있음 |
디버깅 | ✅ 애플리케이션 개발자에게 친숙 | ⚠️ 데이터베이스 전문성이 필요 |
이식성 | ✅ 어떤 데이터베이스에서도 동작 | ⚠️ PostgreSQL 특화 |
도입 비용 | ✅ 낮음(기본 패턴) | ⚠️ 스키마 설계와 팀 교육 필요 |
Mergify에서는 팀이 신뢰할 수 있는 CI 파이프라인을 만들고, 머지 속도를 높이며, 이 워크플로와 관련된 전체 비용을 줄이도록 돕는 도구와 제품을 만듭니다. 이를 달성하려면, PostgreSQL 데이터베이스의 다양한 리소스와 상호작용하는 수많은 API 라우트가 필요합니다.
엔드포인트 수가 늘고 사용자 기반, 제품 사용 사례, 데이터베이스 활동이 증가함에 따라, 기존 라우트 전반에 더 엄격한 권한을 집행하기 시작했습니다. 이는 곧 모든 곳에 필요한 권한 필터를 추가하고, 예기치 못한 엣지 케이스를 처리하며, 엔드포인트를 하나씩 리팩터링하는 길고 복잡한 작업이 되었습니다.
자연스럽게 중요한 질문이 떠올랐습니다. PostgreSQL을 떠날 계획이 없고, 개발자가 권한 세부 사항을 간과하기 쉬운 현실을 감안할 때, 접근 제어가 정말 애플리케이션 레벨에 남아 있어야 할까요? 아니면 PostgreSQL의 Row Level Security(RLS)를 활용해 개발자의 인지 부하와 실수 위험을 줄이고, 더 최적화된 쿼리로 데이터베이스 부하까지 줄이는 편이 더 효과적일까요?
필요에 의해 진행한 리팩터링은 이러한 문제를 아프게 드러냈습니다. 코드 복잡성을 키웠을 뿐 아니라, 가독성도 해쳤습니다. 동시에 우리의 데이터베이스 스키마는 이미 견고했고 RLS 같은 도구에 잘 맞았습니다. 우리의 맥락에서 트레이드오프는 곧 분명해졌고, 우리는 전환을 결정했습니다.
PostgreSQL의 Row Level Security는 접근 제어를 데이터베이스 엔진으로 위임하는 강력한 메커니즘을 제공합니다. 애플리케이션에서는 애플리케이션 수준 필터링보다 더 강력한 안전 보장을 제공하지만, 스키마 설계, 디버깅, 운영 워크플로 측면에서 새로운 과제를 도입합니다.
RLS를 애플리케이션 로직의 보편적 대체재로 여겨서는 안 됩니다. 그 가치는 누락된 필터로 인한 무단 행 노출이라는 특정 계열의 취약점을 제거하는 데 있습니다.
가장 큰 도전은 순수 기술적인 것보다 문화적·운영상의 것입니다. 팀은 테스트 관행, 디버깅 워크플로, 데이터베이스 이해도를 적응시켜야 합니다. 성능 튜닝과 신중한 세션 관리가 성공의 핵심입니다.
궁극적으로 RLS 도입은 문법의 문제가 아니라 아키텍처 철학의 문제입니다. 접근 제어가 본질적으로 애플리케이션 레이어에 속하는지, 아니면 데이터베이스 자체에 속하는지 결정하는 일입니다. RLS는 안전성, 일관성, 규정 준수를 우선시하는 시스템에 설득력 있는 해법을 제공합니다.