PostgreSQL RLS란? 행 수준 보안의 핵심 개념
PostgreSQL의 Row-Level Security(RLS)는 테이블의 각 행(row)에 대해 사용자별 접근 정책을 정의하는 보안 메커니즘입니다. 일반적인 GRANT/REVOKE는 테이블 단위 권한만 제어하지만, RLS는 행 단위로 SELECT, INSERT, UPDATE, DELETE를 제어할 수 있어 멀티 테넌시 SaaS 애플리케이션에서 필수적인 기술입니다.
RLS 활성화와 기본 정책 생성
RLS를 사용하려면 테이블에 명시적으로 활성화해야 합니다. 활성화 후 정책(Policy)이 없으면 모든 행이 차단됩니다(기본 거부 원칙).
-- 1. 테넌트별 데이터 테이블
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
product TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 3. 기본 정책: 현재 세션의 tenant만 접근
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant'));
-- 4. 세션 변수로 테넌트 설정
SET app.current_tenant = 'acme_corp';
SELECT * FROM orders; -- acme_corp 데이터만 조회됨
current_setting() 함수는 PostgreSQL 세션 변수를 읽습니다. 애플리케이션에서 커넥션을 획득할 때마다 SET app.current_tenant를 실행하면 자동으로 행 필터링이 적용됩니다.
USING vs WITH CHECK: 읽기와 쓰기 정책 분리
RLS 정책에는 두 가지 표현식이 있습니다:
| 절 | 적용 대상 | 역할 |
|---|---|---|
USING |
SELECT, UPDATE(기존 행), DELETE | 기존 행에 접근 가능한지 필터링 |
WITH CHECK |
INSERT, UPDATE(새 값) | 새로 삽입/수정되는 행이 유효한지 검증 |
-- 읽기: 자기 테넌트만 볼 수 있음
-- 쓰기: 다른 테넌트 데이터를 삽입할 수 없음
CREATE POLICY tenant_rw ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
-- 테넌트 A가 테넌트 B의 행을 INSERT하려 하면 에러
SET app.current_tenant = 'acme_corp';
INSERT INTO orders (tenant_id, product, amount)
VALUES ('other_corp', 'widget', 100.00);
-- ERROR: new row violates row-level security policy
명령별 세분화 정책 (Per-Command Policy)
실무에서는 CRUD 각 명령에 대해 서로 다른 정책이 필요합니다:
-- SELECT: 자기 테넌트 + 공개(public) 주문도 볼 수 있음
CREATE POLICY select_policy ON orders
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant')
OR tenant_id = 'public'
);
-- INSERT: 반드시 자기 테넌트로만 생성
CREATE POLICY insert_policy ON orders
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
-- UPDATE: 자기 테넌트 행만 수정, 테넌트 변경 불가
CREATE POLICY update_policy ON orders
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
-- DELETE: 자기 테넌트 행만 삭제
CREATE POLICY delete_policy ON orders
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant'));
역할 기반 정책: 관리자 바이패스
테이블 소유자나 SUPERUSER는 기본적으로 RLS를 우회합니다. 특정 역할에 대해 바이패스를 설정하거나 강제할 수 있습니다:
-- 관리자 역할에 RLS 바이패스 부여
ALTER ROLE admin_role BYPASSRLS;
-- 테이블 소유자에게도 RLS 강제 적용
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 관리자 전용 정책: 모든 행 접근 가능
CREATE POLICY admin_all ON orders
TO admin_role
USING (true)
WITH CHECK (true);
FORCE ROW LEVEL SECURITY는 테이블 소유자에게도 RLS를 강제 적용합니다. 실무에서 이 설정 없이 운영하면 소유자 계정이 모든 데이터를 볼 수 있어 보안 구멍이 생깁니다.
Spring Boot + RLS 통합 패턴
Spring Boot에서 RLS를 활용하려면 커넥션 획득 시 세션 변수를 설정해야 합니다. HikariCP의 커넥션 커스터마이저를 활용하면 깔끔하게 구현됩니다:
@Component
public class TenantConnectionCustomizer {
@Bean
public HikariDataSource dataSource(DataSourceProperties props) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(props.getUrl());
config.setUsername(props.getUsername());
config.setPassword(props.getPassword());
// 커넥션 반환 시 테넌트 초기화
config.setConnectionInitSql("SET app.current_tenant = ''");
return new HikariDataSource(config);
}
}
@Component
@RequiredArgsConstructor
public class TenantInterceptor implements HandlerInterceptor {
private final DataSource dataSource;
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse res,
Object handler) throws Exception {
String tenantId = req.getHeader("X-Tenant-Id");
if (tenantId == null) {
res.sendError(400, "X-Tenant-Id header required");
return false;
}
// 현재 커넥션에 테넌트 설정
try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement(
"SELECT set_config('app.current_tenant', ?, true)")) {
stmt.setString(1, tenantId);
stmt.execute();
}
return true;
}
}
더 안정적인 패턴은 AOP + TransactionSynchronization을 사용해 트랜잭션 시작 시점에 세션 변수를 설정하는 것입니다:
@Aspect
@Component
public class RlsTenantAspect {
@Autowired
private JdbcTemplate jdbcTemplate;
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void setTenant() {
String tenantId = TenantContext.getCurrentTenant();
jdbcTemplate.execute(
"SELECT set_config('app.current_tenant', '"
+ tenantId + "', true)"
);
// set_config의 3번째 파라미터 true = 트랜잭션 로컬
// 트랜잭션 종료 시 자동으로 초기화됨
}
}
NestJS + RLS 통합: Prisma/TypeORM 패턴
NestJS에서는 미들웨어 또는 인터셉터에서 테넌트를 설정합니다:
// Prisma + RLS: $executeRawUnsafe로 세션 변수 설정
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private prisma: PrismaService) {}
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new BadRequestException('X-Tenant-Id header required');
}
// Prisma 트랜잭션 내에서 RLS 세션 변수 설정
await this.prisma.$executeRawUnsafe(
`SELECT set_config('app.current_tenant', $1, false)`,
tenantId
);
next();
}
}
// TypeORM: QueryRunner로 세션 변수 설정
@Injectable()
export class RlsInterceptor implements NestInterceptor {
constructor(private dataSource: DataSource) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const tenantId = req.headers['x-tenant-id'];
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.query(
`SELECT set_config('app.current_tenant', $1, true)`,
[tenantId]
);
return next.handle().pipe(
finalize(() => queryRunner.release())
);
}
}
성능 최적화: RLS와 인덱스 전략
RLS 정책은 모든 쿼리에 WHERE 절을 자동 추가합니다. 따라서 tenant_id 컬럼에 적절한 인덱스가 없으면 성능이 급격히 저하됩니다:
-- 복합 인덱스: tenant_id를 선두에 배치
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);
-- 파티셔닝 + RLS: 대규모 멀티테넌트 최적화
CREATE TABLE orders (
id SERIAL,
tenant_id TEXT NOT NULL,
product TEXT NOT NULL,
amount NUMERIC(12,2),
created_at TIMESTAMPTZ DEFAULT now()
) PARTITION BY LIST (tenant_id);
-- 테넌트별 파티션 생성
CREATE TABLE orders_acme PARTITION OF orders
FOR VALUES IN ('acme_corp');
CREATE TABLE orders_beta PARTITION OF orders
FOR VALUES IN ('beta_inc');
-- RLS + 파티션 프루닝 = 최적 성능
-- PostgreSQL이 tenant_id 조건으로 불필요한 파티션을 스킵
EXPLAIN ANALYZE로 RLS 정책 확인:
SET app.current_tenant = 'acme_corp';
EXPLAIN ANALYZE SELECT * FROM orders WHERE amount > 100;
-- 실행 계획에 RLS 필터가 추가됨:
-- Filter: (tenant_id = current_setting('app.current_tenant'))
-- Index Cond: (tenant_id = 'acme_corp')
보안 강화: SQL Injection 방지와 정책 테스트
RLS 정책의 세션 변수를 설정할 때 반드시 파라미터 바인딩을 사용해야 합니다:
-- ❌ 위험: SQL Injection 가능
EXECUTE format('SET app.current_tenant = %L', user_input);
-- ✅ 안전: set_config + 파라미터 바인딩
SELECT set_config('app.current_tenant', $1, true);
-- 정책 테스트: 다른 테넌트 데이터 접근 시도
SET ROLE app_user;
SET app.current_tenant = 'acme_corp';
-- 다른 테넌트 데이터 조회 시도 → 빈 결과
SELECT * FROM orders WHERE tenant_id = 'other_corp';
-- (0 rows) ← RLS가 필터링
-- 다른 테넌트로 INSERT 시도 → 에러
INSERT INTO orders (tenant_id, product, amount)
VALUES ('other_corp', 'hack', 0);
-- ERROR: new row violates row-level security policy
실전 패턴: 소프트 삭제 + 감사 로그
RLS를 소프트 삭제 및 감사 로그와 결합하면 더 강력한 보안 체계를 구축할 수 있습니다:
-- 소프트 삭제 컬럼 추가
ALTER TABLE orders ADD COLUMN deleted_at TIMESTAMPTZ;
-- 소프트 삭제 + 테넌트 격리 정책
CREATE POLICY active_tenant_rows ON orders
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant')
AND deleted_at IS NULL
);
-- 감사 로그: 누가 언제 어떤 행을 수정했는지 기록
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
row_id INTEGER NOT NULL,
tenant_id TEXT NOT NULL,
action TEXT NOT NULL,
old_data JSONB,
new_data JSONB,
changed_by TEXT DEFAULT current_setting('app.current_user', true),
changed_at TIMESTAMPTZ DEFAULT now()
);
-- 트리거로 자동 감사 로그
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, row_id, tenant_id, action, old_data, new_data)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
current_setting('app.current_tenant'),
TG_OP,
CASE WHEN TG_OP != 'INSERT' THEN to_jsonb(OLD) END,
CASE WHEN TG_OP != 'DELETE' THEN to_jsonb(NEW) END
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_trigger();
RLS 정책 디버깅과 운영 팁
RLS 운영 시 자주 겪는 문제와 해결법입니다:
-- 현재 활성화된 RLS 정책 조회
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'orders';
-- 세션 변수 미설정 시 에러 방지
CREATE POLICY safe_tenant ON orders
USING (
tenant_id = COALESCE(
current_setting('app.current_tenant', true), -- true = 미설정 시 NULL 반환
'' -- 기본값: 빈 문자열 → 아무 행도 매칭 안 됨
)
);
-- PERMISSIVE vs RESTRICTIVE 정책
-- PERMISSIVE(기본): OR 조합 — 하나라도 통과하면 접근 허용
-- RESTRICTIVE: AND 조합 — 모든 조건을 만족해야 접근 허용
CREATE POLICY base_tenant ON orders AS PERMISSIVE
USING (tenant_id = current_setting('app.current_tenant'));
CREATE POLICY ip_restriction ON orders AS RESTRICTIVE
USING (
current_setting('app.client_ip', true) IS NOT NULL
AND current_setting('app.client_ip') = ANY(
SELECT allowed_ip FROM tenant_ip_whitelist
WHERE tenant_id = current_setting('app.current_tenant')
)
);
-- 결과: tenant_id 일치 AND IP 화이트리스트 통과 시에만 접근
마이그레이션 전략: 기존 테이블에 RLS 적용
이미 운영 중인 테이블에 RLS를 적용할 때는 단계적 접근이 필요합니다:
-- Step 1: 정책 먼저 생성 (RLS 활성화 전)
CREATE POLICY tenant_policy ON orders
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
-- Step 2: 애플리케이션에서 세션 변수 설정 코드 배포
-- Step 3: RLS 활성화 (이 시점부터 정책 적용)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Step 4: FORCE RLS로 소유자에게도 적용
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 롤백이 필요하면:
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
핵심 정리
| 항목 | 핵심 포인트 |
|---|---|
| 활성화 | ENABLE ROW LEVEL SECURITY + FORCE 필수 |
| USING vs WITH CHECK | 읽기 필터 vs 쓰기 검증 — 반드시 둘 다 설정 |
| 세션 변수 | set_config + 파라미터 바인딩으로 SQL Injection 방지 |
| 인덱스 | tenant_id 선두 복합 인덱스 + 파티셔닝 고려 |
| 정책 조합 | PERMISSIVE(OR) vs RESTRICTIVE(AND) 이해 필수 |
PostgreSQL RLS는 애플리케이션 레벨 WHERE 절과 달리 데이터베이스 엔진이 강제하는 보안이므로, 어떤 클라이언트나 쿼리 도구로 접근해도 정책이 적용됩니다. 멀티 테넌시 SaaS를 구축한다면 RLS는 선택이 아닌 필수입니다. Spring 멀티 테넌시 설계와 NestJS 멀티테넌시 설계도 함께 참고하세요.