Spring Boot & PostgreSQL RLS: SaaS 멀티 테넌트 데이터 섞임, DB 레벨에서 원천 봉쇄하기

지난달, 운영 중인 B2B SaaS 플랫폼에서 아찔한 상황이 발생했습니다. 신규 입사자가 작성한 간단한 통계 조회 API에서 WHERE tenant_id = ? 조건이 누락된 채로 배포된 것입니다. 다행히 스테이징 환경에서의 부하 테스트 중에 발견되어 실제 고객 데이터가 섞이는 참사는 막았지만, 이 사건은 우리 팀에게 명확한 메시지를 던졌습니다. "애플리케이션 레벨(ORM)에서의 데이터 필터링은 언젠가 반드시 사람의 실수로 뚫린다."

애플리케이션 격리의 한계와 RLS의 필요성

우리가 운영하는 SaaS Multi-tenancy 환경은 수천 개의 기업 고객(Tenant) 데이터를 하나의 PostgreSQL 데이터베이스(Shared Schema)에 저장합니다. 지금까지는 Hibernate의 @Where 어노테이션이나 AOP를 통해 논리적으로 데이터를 격리해 왔습니다. 하지만 이 방식은 Native Query를 작성하거나, 데이터 분석 도구(BI 툴)가 DB에 직접 붙을 때 보안 구멍이 숭숭 뚫린다는 치명적인 단점이 있습니다.

환경 스펙은 다음과 같습니다.

  • OS: Amazon Linux 2023
  • Database: PostgreSQL 15.4 (RDS)
  • Application: Spring Boot 3.2, JPA
  • Traffic: 일 평균 50만 트랜잭션, 테넌트 수 2,400개
Critical Risk: 개발자가 실수로 findAll()을 호출하거나 SQL Injection이 발생할 경우, 다른 회사의 기밀 데이터가 전부 노출될 수 있습니다. 이는 단순 버그가 아니라 법적 소송감입니다.

우리는 이 문제를 해결하기 위해 애플리케이션 코드가 아닌, PostgreSQL Row Level Security (RLS) 기능을 도입하여 데이터베이스 보안 계층에서 물리적인 강제성을 부여하기로 결정했습니다. RLS는 쿼리에 WHERE 절이 없어도, DB 정책에 따라 현재 세션 사용자가 볼 수 있는 행(Row)만 반환합니다.

실패했던 접근: Hibernate Filter

처음에는 Hibernate의 @Filter 기능을 시도했습니다. 엔티티마다 필터를 걸어두고 세션 시작 시점에 테넌트 ID를 주입하는 방식이었습니다. 하지만 이 방식은 JPA를 통하지 않는 배치(Batch) 작업이나 JDBC 직접 호출 시에는 필터가 적용되지 않았습니다. 또한, 개발자가 실수로 필터를 enable() 하지 않는 경우를 방어할 수 없었습니다. 결국 "실수를 해도 안전한 시스템"을 만들기 위해서는 DB 엔진 레벨의 제어가 필요했습니다.

해결책: PostgreSQL RLS와 세션 변수 연동

핵심은 데이터베이스 연결(Connection)이 맺어지거나 트랜잭션이 시작될 때, 현재 요청의 tenant_id를 PostgreSQL의 세션 변수(Session Variable)에 심어주는 것입니다. 그리고 RLS 정책이 이 변수를 참조하여 데이터 격리를 수행합니다.

1. DB 스키마 및 정책 설정

먼저 테이블에 RLS를 활성화하고 정책(Policy)을 생성합니다. 여기서는 app.current_tenant라는 커스텀 세션 변수를 사용합니다.

-- 1. 테이블 생성 (테넌트 ID 컬럼 필수)
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id VARCHAR(50) NOT NULL,
    product_name VARCHAR(100),
    amount DECIMAL(10, 2)
);

-- 2. RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 3. 정책 생성: 현재 세션의 tenant_id와 일치하는 행만 조회/수정 가능
-- current_setting() 함수는 세션 변수 값을 가져옵니다.
-- 두 번째 인자 'true'는 변수가 설정되지 않았을 때 에러 대신 null을 반환하도록 합니다.
CREATE POLICY tenant_isolation_policy ON orders
    USING (tenant_id = current_setting('app.current_tenant', true)::varchar);

-- 4. 인덱스 최적화 (RLS 성능 핵심)
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);

이제 SET app.current_tenant = 'tenant_a';를 실행하지 않으면, SELECT * FROM orders;를 해도 아무 데이터도 조회되지 않습니다. (Superuser 제외)

2. Spring Boot DataSource 설정

이제 애플리케이션에서 커넥션을 획득할 때마다 자동으로 테넌트 ID를 세팅해줘야 합니다. 가장 확실한 방법은 DataSource를 래핑하거나, Hibernate의 CurrentTenantIdentifierResolver와 함께 AOP를 사용하는 것이지만, 여기서는 JDBC 레벨에서 확실하게 처리하기 위해 AbstractRoutingDataSource 혹은 커스텀 DataSource 로직을 응용한 방식을 설명합니다.

아래 코드는 HikariCP 연결을 가져올 때 테넌트 컨텍스트를 주입하는 예시입니다.

// TenantContext: ThreadLocal을 사용해 요청별 테넌트 ID 관리
public class TenantContext {
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

// Aspect: 트랜잭션 시작 전 DB 세션 변수 설정
@Aspect
@Component
@RequiredArgsConstructor
public class RlsAspect {

    private final EntityManager entityManager;

    @Before("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void setTenantId() {
        String tenantId = TenantContext.getTenantId();
        if (tenantId != null) {
            // 중요: SQL Injection 방지를 위해 파라미터 바인딩 사용 권장
            // 여기서는 이해를 돕기 위해 Native Query 예시를 듭니다.
            entityManager.createNativeQuery(
                String.format("SET app.current_tenant = '%s'", tenantId)
            ).executeUpdate();
        }
    }
}

위 코드에서 주의할 점은 SET 명령어가 현재 트랜잭션 혹은 세션 범위 내에서만 유효해야 한다는 점입니다. HikariCP와 같은 커넥션 풀을 사용할 경우, 커넥션이 반환될 때 반드시 세션 변수를 초기화(RESET) 해야 합니다. 그렇지 않으면 다음 요청자가 이전 요청자의 테넌트 권한을 이어받는 '세션 오염(Session Bleeding)'이 발생할 수 있습니다.

Tip: 더 안전한 구현을 위해서는 HikariCP의 setConnectionInitSql을 사용하기보다, DataSource.getConnection()을 오버라이딩하여 커넥션을 빌려줄 때 SET을, 반납할 때 RESET app.current_tenant를 실행하는 프록시 패턴을 권장합니다.

성능 검증 및 결과

PostgreSQL RLS 적용 시 성능 저하를 우려하는 경우가 많습니다. RLS는 쿼리 파싱 단계에서 조건절을 강제로 주입하기 때문에 오버헤드가 발생할 수 있습니다. 실제 프로덕션 데이터(테이블당 약 1,500만 건)를 대상으로 벤치마크를 진행했습니다.

Scenario App-Level Filtering (ms) PostgreSQL RLS (ms) Difference
Simple Select (PK) 1.2 ms 1.4 ms +0.2 ms (무시 가능)
Complex Join (3 tables) 145 ms 158 ms +9%
Bulk Insert (1k rows) 850 ms 910 ms +7%

테스트 결과, 약 5~10% 수준의 성능 오버헤드가 관찰되었습니다. 하지만 이는 tenant_id 컬럼에 인덱스가 적절히 걸려 있을 때의 수치입니다. 인덱스가 없다면 RLS는 모든 행을 스캔해야 하므로 성능이 급격히 저하될 수 있습니다. 우리는 이 정도의 성능 비용을 지불하고 완벽한 데이터 격리라는 보안성을 얻는 것이 훨씬 이득이라고 판단했습니다.

AWS SaaS Factory RLS 예제 코드 보기

주의사항 및 엣지 케이스

RLS 도입 시 반드시 고려해야 할 부작용들이 있습니다.

  1. Superuser의 권한: PostgreSQL의 Superuser(보통 postgres 계정)와 BYPASSRLS 속성을 가진 역할은 RLS 정책을 무시합니다. 운영 유지보수 스크립트를 돌릴 때, 실수로 관리자 계정을 사용하면 모든 테넌트의 데이터가 섞여서 조회될 수 있으므로 전용 애플리케이션 계정(app_user)을 별도로 생성하여 권한을 제한해야 합니다.
  2. 데이터 백업(pg_dump): pg_dump를 수행할 때 RLS가 적용된 상태라면, 백업을 수행하는 계정이 볼 수 있는 데이터만 백업됩니다. 전체 데이터 백업을 위해서는 반드시 BYPASSRLS 권한이 있는 계정을 사용해야 합니다.
  3. Connection Pool 오염: 앞서 언급했듯, 스레드 풀이나 커넥션 풀을 사용하는 환경에서는 이전 세션의 잔재가 남지 않도록 MDC.clear()와 유사하게 DB 세션 변수도 반드시 초기화해야 합니다.
Best Practice: RLS 정책 파일은 반드시 버전 관리 시스템(Git)에 포함시키고, Flyway나 Liquibase 같은 마이그레이션 도구를 통해 배포하여 정책이 누락되는 일이 없도록 하십시오.

결론

SaaS 애플리케이션에서 데이터 격리는 선택이 아닌 필수 생존 요건입니다. 개발자의 꼼꼼함에 의존하는 보안은 결국 뚫리게 되어 있습니다. PostgreSQL의 Row Level Security는 애플리케이션 코드의 복잡성을 줄여주면서도, 가장 확실한 최후의 방어선 역할을 해줍니다. 초기 설정이 다소 번거로울 수 있지만, 한번 구축해두면 "혹시 개발자가 WHERE 절을 빼먹지 않았을까?" 하는 불안감에서 영원히 해방될 수 있습니다.

Post a Comment