애플리케이션 vs. 데이터베이스: 권한은 어디에 있어야 할까? — Mergify

ko생성일: 2025. 9. 19.갱신일: 2025. 9. 21.

권한 검증이 여기저기 흩어지면 누락과 유출로 이어집니다. 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을 끌어들일까요?

이미지 1

답은 두 가지 기둥 위에 있습니다:

  • 안전성: RLS를 사용하면 애플리케이션 코드가 얼마나 일관성이 없거나 오류에 취약하더라도 데이터베이스 자체가 권한 없는 행 반환을 거부합니다. 잊힌 필터는 더 이상 위험이 아닙니다.
  • 일관성: 정책은 데이터베이스 스키마에 정의되어 애플리케이션 코드 전반에 흩어지지 않습니다. ORM이든, 생 SQL이든, 리포팅 도구든 모든 쿼리가 동일한 가드레일의 적용을 받습니다.

이는 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에 익숙하지 않은 개발자에게는 비직관적일 수 있습니다. 교육과 문서화가 필수입니다.
  • 서드파티 접근: 리포팅 도구나 애널리스트 역시 RLS를 준수해야 하며, 애플리케이션 외부에서도 일관되게 세션 파라미터를 설정해야 합니다. 마찰이 생기지만, 모든 접근 경로에서 일관된 집행을 보장합니다.

요컨대, RLS는 복잡성을 제거하지 않습니다. 복잡성을 재배치하여, 책임을 애플리케이션 코드에서 PostgreSQL 스키마 설계로 옮깁니다.

성능 고려사항

RLS는 쿼리가 접근하는 모든 행에 대해 평가됩니다. 단순한 경우에는 오버헤드가 미미하지만, 복잡한 조인은 예상치 못한 쿼리 플랜을 유발할 수 있습니다.

이미지 2

예를 들어:

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)은 비용을 기하급수적으로 늘립니다.

권장 모범 사례:

  • 정책 술어에 맞춘 인덱스를 설계합니다.
  • 정책 로직은 최소화하고, 무거운 계산은 미리 계산된 뷰로 위임합니다.
  • RLS 조건하에서 EXPLAIN으로 실행 계획을 주기적으로 검토합니다.

따라서 RLS는 보안을 집행하는 동시에, 스키마 설계를 데이터베이스 엔지니어의 관점으로 접근하도록 만듭니다.

운영상 과제

대규모로 RLS를 구현하면 여러 미묘한 문제가 발생합니다:

  • 세션 스코프: 항상 트랜잭션 내부에서 SET LOCAL을 사용하고, 전역 SET은 절대 사용하지 마십시오. 그렇지 않으면 한 사용자의 ID가 풀 연결을 통해 다른 사용자의 요청으로 새어 나갈 수 있습니다.
  • 마이그레이션 복잡성: 프로젝트 후반에 RLS를 추가하면 파급력이 큽니다. 이전에 "그냥 동작하던" 쿼리가 세션 변수가 올바르게 설정될 때까지 갑자기 빈 결과를 반환할 수 있습니다. 초기 도입이 더 간단합니다.
  • 서드파티 접근: 관리자 대시보드, 리포팅 도구, 데이터 사이언티스트 역시 세션 변수를 설정해야 합니다. 불편할 수 있지만, 모든 접근 경로에서의 일관성을 보장합니다.

RLS가 맞지 않은 경우

RLS는 강력하지만 만능은 아닙니다. 특정 시나리오는 애플리케이션 로직으로 처리하는 편이 낫습니다:

  • 접근 제어 목록(ACL): 예) "관리자는 프로젝트 메타데이터를 수정할 수 있지만, 삭제는 소유자만 가능" 같은 규칙
  • 기능 플래그: 조건부 기능 노출은 미들웨어에서 관리하는 편이 쉽습니다.
  • 복잡한 워크플로: 다단계 승인 프로세스나 행/열에 직접 매핑되지 않는 규칙은 SQL 정책으로 표현하기 어색합니다.

RLS는 전체 권한 시스템이 아니라 "구조적 가시성 규칙"(예: "사용자는 자신의 행만 볼 수 있다")에 가장 적합합니다. 이는 애플리케이션 레벨이 아니라 저장소 레벨에서 적절한 격리가 필요한 멀티 테넌트 데이터베이스에 잘 맞으며, 보안을 향상시킵니다.

트레이드오프 매트릭스

모든 엔지니어링 결정과 마찬가지로, 여러 차원의 트레이드오프로 요약됩니다.

애플리케이션 수준 필터링Row Level Security
안전성⚠️ 누락에 취약✅ 데이터베이스가 보장
투명성✅ 애플리케이션 코드에 명시적✅ 스키마에 암묵적
성능⚠️ 과도한 페치 가능성⚠️ 과도한 페치를 피우지만 일부 최적화를 방해할 수 있음
디버깅✅ 애플리케이션 개발자에게 친숙⚠️ 데이터베이스 전문성이 필요
이식성✅ 어떤 데이터베이스에서도 동작⚠️ PostgreSQL 특화
도입 비용✅ 낮음(기본 패턴)⚠️ 스키마 설계와 팀 교육 필요

우리의 생각

Mergify에서는 팀이 신뢰할 수 있는 CI 파이프라인을 만들고, 머지 속도를 높이며, 이 워크플로와 관련된 전체 비용을 줄이도록 돕는 도구와 제품을 만듭니다. 이를 달성하려면, PostgreSQL 데이터베이스의 다양한 리소스와 상호작용하는 수많은 API 라우트가 필요합니다.

엔드포인트 수가 늘고 사용자 기반, 제품 사용 사례, 데이터베이스 활동이 증가함에 따라, 기존 라우트 전반에 더 엄격한 권한을 집행하기 시작했습니다. 이는 곧 모든 곳에 필요한 권한 필터를 추가하고, 예기치 못한 엣지 케이스를 처리하며, 엔드포인트를 하나씩 리팩터링하는 길고 복잡한 작업이 되었습니다.

자연스럽게 중요한 질문이 떠올랐습니다. PostgreSQL을 떠날 계획이 없고, 개발자가 권한 세부 사항을 간과하기 쉬운 현실을 감안할 때, 접근 제어가 정말 애플리케이션 레벨에 남아 있어야 할까요? 아니면 PostgreSQL의 Row Level Security(RLS)를 활용해 개발자의 인지 부하와 실수 위험을 줄이고, 더 최적화된 쿼리로 데이터베이스 부하까지 줄이는 편이 더 효과적일까요?

필요에 의해 진행한 리팩터링은 이러한 문제를 아프게 드러냈습니다. 코드 복잡성을 키웠을 뿐 아니라, 가독성도 해쳤습니다. 동시에 우리의 데이터베이스 스키마는 이미 견고했고 RLS 같은 도구에 잘 맞았습니다. 우리의 맥락에서 트레이드오프는 곧 분명해졌고, 우리는 전환을 결정했습니다.

이미지 3

결론

PostgreSQL의 Row Level Security는 접근 제어를 데이터베이스 엔진으로 위임하는 강력한 메커니즘을 제공합니다. 애플리케이션에서는 애플리케이션 수준 필터링보다 더 강력한 안전 보장을 제공하지만, 스키마 설계, 디버깅, 운영 워크플로 측면에서 새로운 과제를 도입합니다.

RLS를 애플리케이션 로직의 보편적 대체재로 여겨서는 안 됩니다. 그 가치는 누락된 필터로 인한 무단 행 노출이라는 특정 계열의 취약점을 제거하는 데 있습니다.

가장 큰 도전은 순수 기술적인 것보다 문화적·운영상의 것입니다. 팀은 테스트 관행, 디버깅 워크플로, 데이터베이스 이해도를 적응시켜야 합니다. 성능 튜닝과 신중한 세션 관리가 성공의 핵심입니다.

궁극적으로 RLS 도입은 문법의 문제가 아니라 아키텍처 철학의 문제입니다. 접근 제어가 본질적으로 애플리케이션 레이어에 속하는지, 아니면 데이터베이스 자체에 속하는지 결정하는 일입니다. RLS는 안전성, 일관성, 규정 준수를 우선시하는 시스템에 설득력 있는 해법을 제공합니다.