SaaS에서 PostgreSQL 행 수준 보안(RLS)은 데이터베이스 차원에서 테넌트 격리를 강제합니다. 언제 도입해야 하는지, 정책 작성 방법, 피해야 할 실수를 알아보세요.

SaaS 앱에서 가장 위험한 보안 버그는 서비스가 확장된 다음에 나타나는 버그입니다. 처음에는 “사용자는 자신의 테넌트 데이터만 볼 수 있다”는 간단한 규칙으로 시작하지만, 새 엔드포인트를 빠르게 배포하거나 리포팅 쿼리를 추가하거나 조인이 체크를 건너뛰는 경우가 생깁니다.
애플리케이션만으로 권한을 관리하면 규칙이 여러 군데에 흩어지기 때문에 부담이 커집니다. 한 컨트롤러는 tenant_id를 검사하고, 다른 곳은 멤버십을 확인하며, 백그라운드 작업은 깜빡하고, "관리자 내보내기" 경로는 몇 달 동안 임시로 남아 있습니다. 심지어 신중한 팀도 한 군데를 빼먹습니다.
PostgreSQL 행 수준 보안(RLS)은 특정 문제를 해결합니다: 데이터베이스가 주어진 요청에 대해 어떤 행이 보이는지를 강제하도록 만듭니다. 사고 방식은 간단합니다. 모든 SELECT, UPDATE, DELETE가 인증 미들웨어가 요청을 필터링하는 것처럼 자동으로 정책으로 필터링됩니다.
"행(rows)"이라는 점이 중요합니다. RLS가 모든 것을 보호하는 것은 아닙니다:
구체적 예: 대시보드용으로 프로젝트를 나열하면서 인보이스와 조인하는 엔드포인트를 추가했다고 합시다. 애플리케이션만으로 권한을 처리하면 projects는 테넌트로 필터링하지만 invoices는 필터링을 잊거나 테넌트를 넘나드는 키로 조인할 수 있습니다. RLS를 사용하면 두 테이블 모두 테넌트 격리를 강제하므로 쿼리가 안전하게 실패하고 데이터 유출을 막습니다.
대가는 분명합니다. 반복되는 권한 코드가 줄고 유출 가능성이 있는 지점이 줄어듭니다. 하지만 새로운 일이 생깁니다: 정책을 신중히 설계하고 조기에 테스트해야 하며, 정책이 의도한 쿼리를 차단할 수 있음을 받아들여야 합니다.
앱이 몇 개의 엔드포인트만 있을 때는 RLS가 오히려 추가 작업처럼 느껴질 수 있습니다. 엄격한 테넌트 경계가 있고 많은 쿼리 경로(목록 화면, 검색, 내보내기, 관리자 도구)가 있다면 규칙을 데이터베이스에 두는 것이 같은 필터를 모든 곳에 추가해야 하는 부담을 없애줍니다.
RLS는 규칙이 단순하고 보편적일 때 잘 맞습니다: "사용자는 자신의 테넌트 행만 볼 수 있다" 또는 "사용자는 자신이 멤버인 프로젝트만 볼 수 있다" 같은 경우입니다. 이런 설정에서는 정책이 실수를 줄입니다. 새로운 쿼리가 추가되더라도 모든 SELECT, UPDATE, DELETE가 동일한 관문을 통과합니다.
읽기 중심 애플리케이션에서도 도움이 됩니다. 필터링 논리가 일관되게 유지된다면 유리합니다. 예를 들어 인보이스를 로드하는 방식이 15개나 된다면(RLS는 모든 쿼리에 테넌트 필터를 재구현할 필요를 없애주어 기능 개발에 집중하게 합니다).
반면 규칙이 행 단위가 아닐 때는 번거로움이 생깁니다. 예를 들어 "급여는 볼 수 있지만 보너스는 볼 수 없다"거나 "HR이 아니면 이 열을 마스킹하라" 같은 필드별 규칙은 어색한 SQL과 유지보수하기 힘든 예외로 이어지는 경우가 많습니다.
또한 폭넓은 접근이 실제로 필요한 대규모 리포팅에는 맞지 않을 수 있습니다. 팀이 "이 작업만 우회"용 역할을 만들어 사용하는데, 바로 그 지점에서 실수가 누적됩니다.
도입하기 전에 데이터베이스를 최종 관문으로 삼을지 결정하세요. 그렇다면 규율을 계획하세요: 데이터베이스 동작을 테스트(단순히 API 응답만이 아님), 마이그레이션을 보안 변경으로 취급, 빠른 우회는 피함, 백그라운드 작업의 인증 방식 결정, 정책을 작고 재사용 가능하게 유지.
백엔드를 생성해 주는 도구를 사용하면 전달 속도를 높일 수 있지만, 명확한 역할, 테스트, 단순한 테넌트 모델의 필요를 제거하지는 않습니다. 예를 들어 Koder.ai는 생성된 백엔드에 Go와 PostgreSQL을 사용하지만 RLS는 나중에 "덧붙이는" 것이 아니라 의도적으로 설계해야 합니다.
스키마가 누가 무엇을 소유하는지 명확히 알려줄수록 RLS는 쉬워집니다. 모호한 모델에서 정책으로 "고치려" 하면 쿼리가 느려지고 버그가 혼란스럽게 됩니다.
하나의 테넌트 키(예: org_id)를 골라 일관되게 사용하세요. 대부분의 테넌트 소유 테이블은 그것을 가져야 합니다. 다른 테이블을 참조하더라도 각 행에 테넌트 키가 있으면 정책 내부의 조인을 피하고 USING 검사를 단순하게 유지할 수 있습니다.
실용 규칙: 어떤 행이 고객이 취소하면 사라져야 한다면, 그 행은 아마도 org_id가 필요합니다.
RLS 정책은 보통 한 가지 질문에 답합니다: "이 사용자는 이 조직의 멤버인가, 그렇다면 무엇을 할 수 있나?" 임의의 열로부터 이를 추론하려고 하면 어려워집니다.
핵심 테이블을 작고 단순하게 유지하세요:
users (사람당 한 행)orgs (테넌트당 한 행)org_memberships (user_id, org_id, role, status)project_memberships이렇게 하면 정책이 하나의 인덱스 조회로 멤버십을 확인할 수 있습니다.
모든 테이블에 org_id가 필요하지는 않습니다. 국가, 상품 카테고리, 요금제 유형 같은 참조 테이블은 여러 테넌트에서 공유됩니다. 이런 테이블은 대부분 역할에 대해 읽기 전용으로 만들고 특정 org에 묶지 마세요.
테넌트 소유 데이터(프로젝트, 인보이스, 티켓)는 공유 테이블을 통해 테넌트 특정 세부정보를 끌어오지 않도록 하세요. 공유 테이블은 최소화하고 안정적으로 유지하세요.
외래 키는 RLS와 함께 여전히 작동하지만, 삭제 동작은 삭제 역할이 종속 행을 "볼 수 있는지"와 관련해 놀랄 수 있습니다. cascade를 신중히 계획하고 실제 삭제 흐름을 테스트하세요.
정책이 필터링하는 열, 특히 org_id와 멤버십 키에 인덱스를 추가하세요. "WHERE org_id = ..."처럼 보이는 정책이 테이블이 수백만 행에 이를 때 전체 스캔이 되지 않도록 하세요.
RLS는 테이블별 스위치입니다. 활성화되면 PostgreSQL은 앱 코드가 테넌트 필터를 기억한다고 더 이상 신뢰하지 않습니다. 모든 SELECT, UPDATE, DELETE는 정책으로 필터링되고, 모든 INSERT, UPDATE는 정책으로 검증됩니다.
가장 큰 사고 방식의 변화는: RLS가 켜지면 예전에는 데이터를 반환하던 쿼리가 오류 없이 0행을 반환할 수 있다는 점입니다. 그건 PostgreSQL이 접근 제어를 수행하고 있다는 뜻입니다.
정책은 테이블에 붙는 작은 규칙입니다. 두 가지 검사를 사용합니다:
USING은 읽기 필터입니다. 행이 USING을 만족하지 않으면 SELECT에서 보이지 않고 UPDATE나 DELETE의 대상이 될 수 없습니다.WITH CHECK는 쓰기 게이트입니다. INSERT나 UPDATE로 들어오는 새 행이나 변경된 행이 허용되는지를 결정합니다.일반적인 SaaS 패턴: USING은 사용자가 자신의 테넌트 행만 보게 하고, WITH CHECK는 누군가 임의의 tenant ID를 넣어 다른 테넌트에 행을 생성하지 못하게 합니다.
나중에 정책을 추가할 때 다음이 중요합니다:
PERMISSIVE(기본): 어떤 정책이든 허용하면 행이 허용됩니다.RESTRICTIVE: 제한적 정책 모두가 허용해야 행이 허용됩니다(퍼미시브 동작 위에 추가됨).테넌트 일치 + 역할 검사 + 프로젝트 멤버십 같은 규칙을 계층화할 계획이면 restrictive 정책이 의도를 더 분명히 해주지만, 한 조건을 잊으면 자신을 차단하기 쉬워집니다.
RLS는 신뢰할 수 있는 "누가 호출하는가" 값이 필요합니다. 일반적인 옵션:
app.user_id와 app.tenant_id).SET ROLE ...) 방식(운영 부담이 늘어납니다).한 가지 접근을 골라 모든 곳에 적용하세요. 서비스 간에 아이덴티티 소스를 섞으면 빠르게 혼란스러운 버그가 발생합니다.
스키마 덤프와 로그가 읽기 쉽도록 예측 가능한 규칙을 사용하세요. 예: {table}__{action}__{rule} — projects__select__tenant_match처럼.
RLS가 처음이라면 하나의 테이블과 작은 증명(proof)부터 시작하세요. 목표는 완벽한 적용이 아니라 앱 버그가 있어도 데이터베이스가 교차 테넌트 접근을 거부하게 만드는 것입니다.
간단한 projects 테이블을 가정합니다. 먼저 쓰기를 망가뜨리지 않는 방식으로 tenant_id를 추가하세요.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
다음으로 소유권과 접근을 분리하세요. 흔한 패턴은 하나의 역할이 테이블을 소유(app_owner), 다른 역할은 API에서 사용하는(app_user) 방식입니다. API 역할이 테이블 소유자가 아니어야 정책을 우회할 수 없습니다.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
이제 요청이 Postgres에 어떤 테넌트를 서비스하는지 알려주는 방법을 결정하세요. 한 가지 간단한 방법은 요청 범위의 설정입니다. 앱이 트랜잭션을 연 직후에 설정합니다.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
RLS를 활성화하고 먼저 읽기 접근을 시작하세요.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
두 개의 다른 테넌트로 시도해 행 수가 바뀌는지 확인하면 동작을 증명할 수 있습니다.
WITH CHECK)읽기 정책은 쓰기를 보호하지 않습니다. 삽입과 업데이트가 잘못된 테넌트로 행을 밀어넣지 못하게 WITH CHECK를 추가하세요.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
동작(실패 포함)을 검증하는 빠른 방법은 마이그레이션 후에 다시 실행할 수 있는 작은 SQL 스크립트를 유지하는 것입니다:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (실패해야 함)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (실패해야 함)ROLLBACK;이 스크립트를 실행해 항상 같은 결과가 나오면 다른 테이블에 RLS를 확장하기 전의 신뢰할 수 있는 기준선이 됩니다.
대부분의 팀은 모든 쿼리에 같은 권한 검사를 반복하는 것에 지치고 RLS를 도입합니다. 좋은 소식은 필요한 정책 형태가 보통 일관된다는 점입니다.
어떤 테이블은 자연스럽게 한 사용자가 소유합니다(노트, API 토큰 등). 다른 테이블은 테넌트에 속하고 접근은 멤버십에 따라 달라집니다. 이 둘은 다른 패턴으로 다뤄야 합니다.
소유자 전용 데이터는 정책이 종종 created_by = app_user_id() 같은 것을 검사합니다. 테넌트 데이터는 사용자가 조직의 멤버인지 여부를 확인합니다.
정책을 읽기 쉽도록 신원 관련 작은 SQL 헬퍼를 중앙화하고 재사용하는 것이 실용적입니다:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
읽기는 보통 쓰기보다 넓습니다. 예를 들어 모든 조직 멤버가 프로젝트를 SELECT할 수 있지만, 편집자만 UPDATE하고 소유자만 DELETE할 수 있을 수 있습니다.
명확하게 유지하세요: SELECT용 정책 하나(멤버십), INSERT/UPDATE용 WITH CHECK가 있는 정책 하나(역할), 그리고 DELETE용 정책 하나(종종 업데이트보다 더 엄격)처럼 구분합니다.
관리자를 위해 "RLS를 끄는" 것은 피하세요. 대신 app_is_admin() 같은 이스케이프 해치를 정책 내부에 추가해 공용 서비스 역할에 전체 접근 권한을 실수로 부여하지 않도록 하세요.
deleted_at이나 status를 사용한다면 SELECT 정책에 이를 포함하세요(deleted_at is null). 그렇지 않으면 앱이 최종으로 간주한 플래그를 누군가 뒤집어 행을 "부활"시킬 수 있습니다.
WITH CHECK 친화적으로 유지INSERT ... ON CONFLICT DO UPDATE는 쓰기 후 행이 WITH CHECK를 만족해야 합니다. 정책이 created_by = app_user_id()를 요구하면 업서트 시 삽입에 created_by를 설정하고 업데이트에서 덮어쓰지 않도록 하세요.
백엔드를 생성하는 경우 이러한 패턴을 내부 템플릿으로 만들어 새 테이블이 빈 슬레이트가 아니라 안전한 기본값으로 시작하게 하는 것이 가치가 있습니다.
RLS는 한 세부 사항 때문에 PostgreSQL이 "임의로" 데이터를 숨기거나 보여주는 것처럼 보이게 만들 수 있습니다. 아래 실수들이 가장 많은 시간을 낭비하게 합니다.
첫 함정은 INSERT와 UPDATE에 대한 WITH CHECK를 잊는 것입니다. USING은 당신이 볼 수 있는 것을 제어할 뿐, 생성할 수 있는 것을 막지 않습니다. WITH CHECK가 없으면 앱 버그로 잘못된 테넌트에 행을 쓸 수 있고, 같은 사용자가 그 행을 읽을 수 없으므로 눈치채기 어렵습니다.
또 다른 일반적인 유출은 "유출되는 조인"입니다. projects를 올바르게 필터링했지만 invoices, notes, files 같은 테이블이 동일하게 보호되지 않은 경우입니다. 해결책은 엄격하지만 간단합니다: 테넌트 데이터를 드러낼 수 있는 모든 테이블에 각자 정책을 넣고, 뷰가 단 하나의 테이블만 안전하다고 가정하게 하지 마세요.
초기에는 다음과 같은 실패 패턴이 자주 나타납니다:
WITH CHECK가 없음.같은 테이블을 참조하는(직접 또는 뷰를 통해) 정책은 재귀성 문제를 만들 수 있습니다. 정책이 멤버십을 확인하기 위해 보호된 테이블을 다시 읽는 뷰를 쿼리하면 오류, 느린 쿼리, 또는 절대 매치되지 않는 정책이 생길 수 있습니다.
역할 설정도 혼란의 원인입니다. 테이블 소유자와 권한이 높은 역할은 RLS를 우회할 수 있으므로, 테스트가 통과하지만 실제 사용자는 실패하거나 그 반대가 발생할 수 있습니다. 실제 앱이 사용하는 저권한 역할로 항상 테스트하세요.
SECURITY DEFINER 함수는 주의하세요. 이들은 함수 소유자의 권한으로 실행되므로 current_tenant_id() 같은 단순 헬퍼는 괜찮지만, 데이터를 읽는 편의성 함수는 설계에 따라 테넌트를 넘길 수 있습니다. 또한 security definer 함수 내부에서 안전한 search_path를 설정하세요. 설정하지 않으면 세션 상태에 따라 같은 이름의 다른 객체를 참조해 정책 로직이 다른 대상을 가리키는 문제가 생길 수 있습니다.
RLS 버그는 대개 문맥 누락이지 "나쁜 SQL"이 아닙니다. 정책은 종이에선 맞아도 세션 역할이 생각과 다르거나 요청이 정책이 기대하는 테넌트/사용자 값을 설정하지 않아 실패할 수 있습니다.
운영 보고서를 재현하는 신뢰 가능한 방법은 동일한 세션 설정을 로컬에 복제하고 정확한 쿼리를 실행하는 것입니다. 보통 다음을 포함합니다:
SET ROLE app_user; (또는 실제 API 역할)SELECT set_config('app.tenant_id', 't_123', true); 및 SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);어떤 정책이 적용되는지 확실하지 않다면 추측하지 말고 카탈로그를 확인하세요. pg_policies는 각 정책, 명령, USING과 WITH CHECK 표현식을 보여줍니다. 이를 pg_class와 함께 확인해 테이블에 RLS가 활성화되어 있고 우회되지 않았는지 확인하세요.
성능 문제는 권한 문제처럼 보일 수 있습니다. 멤버십 테이블을 조인하거나 함수를 호출하는 정책은 테이블이 커지면 정확하지만 느려질 수 있습니다. 재현된 쿼리에 대해 EXPLAIN (ANALYZE, BUFFERS)를 사용해 시퀀셜 스캔, 예상치 못한 중첩 루프, 늦게 적용되는 필터를 찾아보세요. (tenant_id, user_id)와 멤버십 테이블에 대한 누락된 인덱스가 흔한 원인입니다.
또한 요청당 세 가지 값을 앱 레이어에서 로그로 남기면 도움이 됩니다: 테넌트 ID, 사용자 ID, 요청에 사용된 DB 역할. 이 값들이 기대와 다르면 RLS는 "잘못" 동작할 것입니다. 테스트에서는 몇 개의 시드 테넌트를 유지하고 실패를 명확히 만드세요. 작은 테스트 모음에는 보통: "Tenant A는 Tenant B를 읽을 수 없다", "멤버십 없는 사용자는 프로젝트를 볼 수 없다", "소유자는 업데이트 가능, 뷰어는 불가", "INSERT는 컨텍스트와 일치하는 tenant_id가 아니면 차단", "관리자 우회는 의도한 곳에만 적용" 같은 항목이 포함됩니다.
RLS를 시트벨트처럼 취급하세요. 작은 실수는 "모두가 모두의 데이터를 본다"거나 "모든 쿼리가 0행을 반환한다"로 이어집니다.
테이블 설계와 정책 규칙이 테넌트 모델과 맞는지 확인하세요.
tenant_id)를 가져야 합니다. 없다면 이유를 문서화하세요(예: 전역 참조 테이블).FORCE ROW LEVEL SECURITY를 고려하세요.USING, 쓰기는 WITH CHECK로 삽입/업데이트가 다른 테넌트로 이동하지 않도록 하세요.tenant_id로 필터링하거나 멤버십 테이블을 통해 조인한다면 해당 인덱스를 추가하세요.간단한 건전성 시나리오: Tenant A 사용자는 자신의 인보이스를 읽을 수 있고, 인보이스를 Tenant A로만 삽입할 수 있으며, 인보이스의 tenant_id를 다른 값으로 업데이트할 수 없습니다.
RLS는 앱이 사용하는 역할만큼 강력합니다.
bypassrls 권한이 있는 어떤 역할로도 연결하지 않는지 확인하세요.기업(org)이 프로젝트를 가지고, 프로젝트는 작업(tasks)을 가지며 사용자는 여러 org에 속할 수 있고 일부 프로젝트에만 멤버일 수 있는 B2B 앱을 상상해 보세요. 이 구조는 API 엔드포인트가 필터를 잊어도 DB가 테넌트 격리를 강제하므로 RLS에 잘 맞습니다.
간단한 모델: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). tasks에 org_id를 두는 것은 의도적입니다. 정책을 단순하게 유지하고 조인 시 놀라움을 줄이기 위함입니다.
일반적인 유출은 tasks에 project_id만 있고 접근을 projects와의 조인으로 확인하는 경우 발생합니다. 한 번의 실수(조금 관대한 projects 정책, 조건을 제거하는 조인, 컨텍스트를 바꾸는 뷰)가 다른 조직의 tasks를 노출시킬 수 있습니다.
더 안전한 마이그레이션 경로는 프로덕션 트래픽을 깨뜨리지 않습니다:
tasks에 org_id 추가, 멤버십 테이블 추가)을 배포합니다.tasks.org_id를 projects.org_id에서 백필한 뒤 NOT NULL을 추가합니다.FORCE ROW LEVEL SECURITY)하고 나서 이전의 앱 측 필터를 제거합니다.지원 접근은 보통 RLS를 끄는 것으로 처리하지 말고 좁은 브레이크글래스 역할로 처리하세요. 일반 지원 계정과 분리하고 사용 시 명시적으로 기록하세요.
정책이 흩어지지 않도록 문서화하세요: 어떤 세션 변수를 설정해야 하는지(user_id, org_id), 어떤 테이블이 org_id를 가져야 하는지, "멤버"의 정의, 잘못된 org로 실행했을 때 0행을 반환해야 하는 SQL 예제 몇 개 등.
RLS는 제품 변경처럼 다루면 관리하기 쉽습니다. 작은 덩어리로 롤아웃하고 테스트로 동작을 증명하며 각 정책이 왜 존재하는지 명확히 기록하세요.
유효한 롤아웃 계획:
projects)가 있는 테이블 하나로 시작해 잠그세요.첫 테이블이 안정화되면 정책 변경을 신중히 하세요. 마이그레이션에 정책 검토 단계를 추가하고 의도(누가 무엇을 왜 접근해야 하는지)에 대한 짧은 설명과 일치하는 테스트 업데이트를 포함하세요. 이는 점점 "또 다른 OR을 추가"하는 방식으로 정책이 구멍이 되는 것을 막습니다.
빠르게 이동해야 한다면 Koder.ai와 같은 도구가 채팅으로 Go + PostgreSQL 시작점을 생성하는 데 도움을 줄 수 있지만, 그 위에 RLS 정책과 테스트를 수작업으로 의도적으로 쌓아야 안전합니다.
마지막으로 롤아웃 중 안전장치를 유지하세요. 정책 마이그레이션 전에 스냅샷을 찍고, 롤백 연습을 충분히 반복하고, 시스템 전체에서 RLS를 끄지 않는 작은 브레이크글래스 경로를 마련하세요.
RLS는 요청에 대해 어떤 행이 보이거나 쓰기가 허용되는지를 PostgreSQL이 강제하도록 합니다. 즉 테넌트 격리를 모든 엔드포인트가 항상 WHERE tenant_id = ... 필터를 기억해야 하는 상황에 의존하지 않도록 만듭니다. 주요 이점은 앱이 커지며 쿼리가 늘어날 때 발생하는 "하나의 누락된 체크" 버그를 줄여준다는 점입니다.
규칙이 일관되고 행 단위일 때(예: 테넌트 격리 또는 멤버십 기반 접근)와 다양한 쿼리 경로(검색, 내보내기, 관리자 화면, 백그라운드 작업)가 있을 때 유용합니다. 반대로 대부분 규칙이 필드 단위로 다르거나 예외가 많거나 넓은 범위의 리포팅이 필요하면 도입 비용이 더 큽니다.
RLS는 행 가시성 및 기본적인 쓰기 제어를 제공합니다. 열 수준 프라이버시는 뷰나 열 권한으로 해결해야 하고, 복잡한 비즈니스 규칙(예: 청구 소유권이나 승인 흐름)은 여전히 애플리케이션 로직이나 잘 설계된 DB 제약으로 처리해야 합니다.
API용 저권한 역할(테이블 소유자가 아닌)을 만들고 RLS를 활성화한 뒤 SELECT 정책과 WITH CHECK를 포함한 INSERT/UPDATE 정책을 추가하세요. 트랜잭션 시작 시 요청 범위의 세션 값(예: app.current_tenant)을 설정하고, 값을 바꿨을 때 어떤 행을 볼 수 있고 쓸 수 있는지 확인하면 안전하게 시작할 수 있습니다.
요청을 누가 하는지 일관되게 알려주는 세션 변수(트랜잭션 시작에 설정되는 app.tenant_id, app.user_id)를 사용하는 것이 일반적입니다. 또는 API 레이어에서 JWT 클레임을 세션 설정으로 매핑하거나, 요청마다 역할을 바꾸는(SET ROLE ...) 방법을 쓸 수 있습니다. 핵심은 모든 코드 경로(웹 요청, 작업, 스크립트)가 동일한 값을 설정하도록 일원화하는 것입니다.
USING은 읽기 필터입니다. 행이 USING을 만족하지 않으면 SELECT에서 보이지 않으며 UPDATE나 의 대상도 될 수 없습니다. 는 쓰기 게이트입니다. 나 로 생성되거나 변경되는 행이 허용되는지를 결정합니다. 따라서 은 기존 행의 가시성을, 는 쓰기 시의 유효성을 제어합니다.
USING만 추가하면 위험합니다. 그럴 경우 버그가 있는 엔드포인트가 잘못된 테넌트로 행을 삽입하거나 업데이트할 수 있고, 해당 사용자는 그 행을 읽을 수 없기 때문에 문제가 눈에 띄지 않습니다. 읽기 규칙을 추가할 때는 항상 쓰기 규칙(WITH CHECK)도 함께 추가해 잘못된 데이터가 생성되지 않도록 하세요.
정책을 간단하고 색인 친화적으로 유지하세요. 테넌트 키(예: org_id)를 테넌트 소유 테이블에 직접 두고, 멤버십을 명시하는 테이블(org_memberships, 선택적으로 project_memberships)을 만들어 한 번의 인덱스 조회로 권한을 확인하게 하면 정책이 복잡해지지 않습니다.
먼저 앱과 동일한 세션 설정을 로컬에서 재현하세요: 사용 역할(SET ROLE app_user)과 세션 설정(SELECT set_config('app.tenant_id', 't_123', true);)을 맞춘 뒤 앱이 실행한 동일한 SQL을 실행합니다. 그리고 pg_policies를 확인해 어떤 USING과 WITH CHECK 표현식이 적용되는지 보세요. 많은 RLS 문제는 SQL 자체의 잘못이 아니라 세션 컨텍스트가 누락되어 발생합니다.
예, 생성된 코드는 출발점일 뿐 보안 시스템이 아닙니다. Koder.ai로 Go + PostgreSQL 백엔드를 생성하더라도 테넌트 모델을 정의하고 세션 아이덴티티를 일관되게 설정하며 각 테이블에 적절한 정책과 테스트를 의도적으로 추가해야 합니다.
DELETEWITH CHECKINSERTUPDATEUSINGWITH CHECK