소개
이전 포스트에서는 Row Level Security(RLS)를 사용하여 Todo 앱의 권한을 관리하는 방법을 다뤘습니다.
이번에는 더 복잡한 비즈니스 로직을 안전하게 처리하기 위해 PostgreSQL Function을 활용하여 팀 관리 API를 구현해보겠습니다.
Supabase 개발 practice
먼저 supabase cli를 사용한 local, production의 개발 과정을 함께 고찰해 봅니다.
vscode를 사용할 경우 Postgres에 접속하고 SQL 구문을 바로 바로 실행할 수 있도록 해 주는 확장을 먼저 설치해 줍니다.
npx supabase status
명령을 사용하면 local 개발의 postgres 접속 설정을 알 수 있습니다.
이 값을 사용하여 vscode 확장의 연결 설정을 해 줍니다.
이렇게 해 주면 다음과 같이 Run
을 클릭하거나 Cmd + Enter
단축키를 사용해서 단위 구문별로 SQL을 실행할 수 있습니다.
바로바로 문법을 확인하고 local Postgres에 적용해 볼 수 있습니다.
원하는 함수를 만든 후에는 supabase.js 클라이언트를 사용하여 테스트를 합니다.
supabase gen types --lang=typescript --local > lib/schema.ts
명령으로 스키마를 생성한 뒤 rpc
함수를 사용하여 호출합니다.
export async function createTeam(name: string) {
const { data, error } = await supabase.rpc("create_team", {
team_name: name,
});
if (error) throw error;
return data;
}
vitest 등으로 테스트를 구성합니다.
it.skip('Create and delete', async () => {
await loginGuard(TEST_USER_01, async ({ user }) => {
expect((await getTeams()).length).toEqual(0)
const newTeam = await createTeam('test team')
expect((await getTeams()).length).toEqual(1)
if (newTeam.id) {
await deleteTeam(newTeam.id)
}
expect((await getTeams()).length).toEqual(0)
});
})
검증 이후에는 supabase db diff -f todo_functions
명령으로 remote project에 적용할 migration 파일을 생성합니다.
supabase db push
명령을 remote project에 새로운 migration을 적용합니다.
Postgres function으로 전환
teams
테이블에 있는 팀 정보 하나를 삭제하려면 team_members
가 외부키로 참조하는 team_id
와 todos
가 외부키로 참조하는 team_id
제약을 고려해야 합니다.
다시 말해 삭제하려는 team_id
값에 따라, todos
, team_members
테이블과 연관한 아이템을 삭제하고 마지막에 teams
테이블의 팀 정보를 삭제해야 합니다.
이를 supabase.js 클라이언트로 구현하려면, 최소 3번의 API 호출을 해야 팀 정보 하나를 삭제할 수 있습니다.
이러한 과정이 브라우저에서 이루어지기 때문에 느린 네트워크 환경이나 삭제 진행 과정 중 사용자 조작등으로 인해 무결성을 보장하기 어렵습니다.
따라서 Postgres function을 활용하여 하나의 API 호출로 3개의 SQL 구문을 하나의 transaction 안에서 구현하는 것이 바람직 합니다.
CREATE OR REPLACE FUNCTION public.delete_team(team_id bigint)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
_team_id ALIAS FOR team_id;
is_owner boolean;
BEGIN
SELECT owner_id = auth.uid() INTO is_owner
FROM teams
WHERE id = _team_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Team not found.';
-- RETURN FALSE;
END IF;
IF NOT is_owner THEN
RAISE EXCEPTION 'Only team owner can delete teams.';
-- RETURN FALSE;
END IF;
-- Delete all todos
DELETE FROM todos t
WHERE t.team_id = _team_id;
-- Delete all team member relations
DELETE FROM team_members tm
WHERE tm.team_id = _team_id;
DELETE FROM teams
WHERE id = _team_id AND owner_id = auth.uid();
RETURN FOUND;
END;
$function$
;
function을 활용한 구현 방식은 team_members
join 테이블의 RLS 설정을 고민하지 않고 SQL 구문으로 더욱 명시적으로 작성할 수 있습니다.
동시에 transaction을 적용하여 동시성 이슈에 대비할 수 있습니다.
feat: api를 rpc로 변경 · swcho/supabase-nextjs-todo-list@6a02c1f
견고한 RLS 설정
local에서 구동하는 supabase studio를 열고 RLS를 모두 삭제 한 뒤, 모든 테이블에 대해서 service_role
만 select
, insert
, update
, delete
가 가능하도록 설정합니다.
이렇게 하면, 잘못된 RLS 설정으로 인한 보안 사고나 오류를 걱정하지 않고 postgres function 만으로 비니지스 로직을 제어할 수 있습니다.
feat: 모든 RLS 정책을 service_role로 제한 · swcho/supabase-nextjs-todo-list@2ec6ce7
중첩한(Nested) 반환값의 사용
함수의 반환 값을 다음과 같이 중첩한 형태로 정의할 수 있습니다.
CREATE TYPE member_type AS (
id uuid,
aud varchar,
email varchar,
joined_at timestamp
);
CREATE TYPE team_type AS (
id bigint,
name text,
created_at timestamp,
members member_type[]
);
RLS vs Function
RLS 보다 Function 방식이 좋다고는 말할 수는 없습니다. 상황이나 보안의 중요도에 따라 신중하게 선택할 필요가 있습니다.
RLS (Row Level Security) 방식
JS 만으로 비지니스 로직 관리가 가능하나 이 때문에 abusing 위험성이 존재 합니다. 정책 변경 시, BE/FE간 배포 동기와로 인해 중단 배포를 고려해야 합니다.
장점
- 선언적이고 간단한 정책 설정
- 테이블 단위의 일관된 보안 적용
- 실수로 정책을 우회할 가능성이 낮음
- 기본적인 CRUD 작업에 적합
단점
- 복잡한 비즈니스 로직 구현이 어려움
- 여러 테이블을 걸친 작업에서 성능 이슈 가능성
- 정책이 복잡해질수록 유지보수가 어려워짐
- 테스트가 상대적으로 까다로움
Function 방식
Function 방식은 RLS의 문제점을 해결해 줄 수 있지만 몇 가지 관리 측면에서 신중하게 접근할 필요가 있습니다.
특별히 함수의 파라미터와 반환 값의 형태에 대한 신중한 결정이 필요합니다. 변경이 필요하다면 기존 함수를 drop하고 새로 작성하는 것 보다는 새로운 버전으로 생성합니다. 이후, FE에서는 새 버전을 호출하고 구 버전 호출을 삭제하여 배포합니다. 이렇게 하면, BE를 먼저 배포하더라도 구버전을 사용하고 있는 FE에는 영향이 없을 것 입니다.
장점
- 복잡한 비즈니스 로직 구현 가능
- 트랜잭션 관리가 용이
- 재사용 가능한 로직 모듈화
- 명시적인 API 인터페이스 제공
- 테스트가 상대적으로 용이
단점
- 보일러플레이트 코드 증가
- 실수로 보안 정책을 누락할 가능성
- Function 관리/문서화 필요
- API 버저닝 고려 필요
마치며
RLS와 function 방식의 FE, BE 연동 기법을 살펴 보았습니다.
이는 Supabase만의 기능이라기 보다는 Postgres라는 DB의 기능을 활용한 방식입니다.
일반적으로 Postgres를 활용하는 애플리케이션은 RLS나 function 보다는 node.js, java, go, python 등의 런타임에서 SQL을 수행하거나 ORM등을 사용하여 DB와 연동합니다.
Supabase는 BaaS로서 BE를 서비스를 지향하기 때문에 FE 작성만으로 애플리케이션 구현이 가능해야 합니다.
따라서 RLS를 사용하여 최소한의 보안 정책으로 FE단에서 SQL 구문을 조합하여 비지니스 로직을 구현하도록 지원하고 있습니다.
아직 Supabase에 익숙하지 않은 팀이나 prototyping 단계에 있는 서비스는 이러한 방식이 빠른 개발과 비지니스 로직 검증에 유리합니다.
하지만, 어느정도 성숙한 단계에 다다르면 보안, 성능, 안정성에 고민을 해야하며 이때 function 방식의 API 연동은 그 답이 되어 줄 것 입니다.