supabase-js가 호출하는 PostgREST의 동작 원리를 이해하고, 복잡한 조인·집계·필터를 효율적으로 표현한다.
PostgREST 쿼리 엔진 완전 정복
입문편 4·5강에서 select, insert, update, delete의 기본 문법을 익혔다면, 이제 그 뒤에서 실제로 무슨 일이 벌어지는지 파악할 차례입니다. supabase-js는 단순한 래퍼 라이브러리가 아닙니다. 모든 쿼리는 PostgREST라는 오픈소스 REST API 서버를 거쳐 Postgres로 전달되며, 그 변환 규칙을 이해하면 복잡한 조인, 집계, 전문 검색을 훨씬 정확하고 효율적으로 다룰 수 있습니다.
이 강의에서는 supabase-js가 HTTP 요청을 어떻게 구성하는지 내부를 살펴보고, 중첩 select를 활용한 관계 임베딩, 복합 필터, 집계 우회 패턴, RPC, 페이지네이션 전략, 응답 형태 제어까지 실무에서 바로 쓸 수 있는 고급 패턴을 집중적으로 다룹니다.
학습 목표
- supabase-js 쿼리가 PostgREST HTTP 요청으로 변환되는 원리를 설명할 수 있다.
- 중첩 select와 조인 힌트로 복잡한 관계 데이터를 임베딩할 수 있다.
or,and, 텍스트 검색, 범위/배열 필터 연산자를 정확하게 사용할 수 있다.- RPC로 데이터베이스 함수를 호출하고, 집계 결과를 반환받을 수 있다.
range와 keyset(cursor) 페이지네이션의 차이를 이해하고 상황에 맞게 선택할 수 있다.
supabase-js가 HTTP 요청으로 변환되는 원리
PostgREST는 Postgres 스키마를 분석해 테이블·뷰·함수를 REST 엔드포인트로 자동 노출합니다. supabase-js는 이 API를 호출하는 클라이언트에 불과하며, 체이닝 메서드는 결국 URL 쿼리스트링과 HTTP 헤더로 직렬화됩니다.
예를 들어 다음 코드를 실행하면:
const { data } = await supabase
.from('posts')
.select('id, title')
.eq('published', true)
.order('created_at', { ascending: false })
.limit(10);
실제 HTTP 요청은 아래와 같습니다.
GET /rest/v1/posts?select=id,title&published=eq.true&order=created_at.desc&limit=10
각 메서드가 어떤 쿼리스트링으로 바뀌는지 대응 관계를 파악해 두면, 예상치 못한 동작을 디버깅할 때 큰 도움이 됩니다.
| supabase-js 메서드 | PostgREST 쿼리스트링 |
|---|---|
.select('id, title') | ?select=id,title |
.eq('col', val) | &col=eq.val |
.gt('col', val) | &col=gt.val |
.order('col', { ascending: false }) | &order=col.desc |
.limit(n) | &limit=n |
.range(from, to) | &offset=from&limit=(to-from+1) |
💡 TIP —
.range(from, to)는RangeHTTP 헤더가 아니라offset/limit쿼리 파라미터로 직렬화됩니다. supabase-js v2의 postgrest-js 기준으로?offset=from&limit=(to-from+1)형태이므로, 네트워크 탭에서는 헤더가 아닌 쿼리스트링에서 확인해야 합니다.
💡 TIP — 브라우저 개발자 도구 네트워크 탭을 열어
rest/v1요청을 직접 확인해 보세요. 쿼리가 예상대로 변환됐는지 빠르게 검증할 수 있습니다.
supabase-js v2는 내부적으로 postgrest-js 패키지를 사용합니다. 따라서 PostgREST 공식 문서의 필터 문법은 supabase-js API와 1:1로 대응됩니다.
중첩 select로 관계 데이터 임베딩과 조인 힌트
기본 중첩 select
입문편에서 간단히 소개했지만, 실무에서는 훨씬 복잡한 관계를 다루게 됩니다. PostgREST는 외래 키 메타데이터를 읽어 자동으로 조인 경로를 결정합니다.
// posts → profiles (author), posts → categories (다대일)
const { data } = await supabase
.from('posts')
.select(`
id,
title,
published_at,
profiles ( id, username, avatar_url ),
categories ( name, slug )
`);
중첩된 테이블에도 필터와 정렬을 적용할 수 있습니다.
const { data } = await supabase
.from('posts')
.select(`
id,
title,
comments (
id,
body,
created_at,
profiles ( username )
)
`)
.eq('id', 42)
.order('created_at', { referencedTable: 'comments', ascending: false });
조인 힌트
두 테이블 사이에 외래 키가 여러 개 있거나, 관계가 모호한 경우 PostgREST는 어떤 경로로 조인해야 할지 알 수 없어 오류를 반환합니다. 이 때 **조인 힌트(join hint)**로 사용할 외래 키를 명시합니다.
-- 예: messages 테이블이 sender_id와 receiver_id 두 FK로 profiles를 참조할 때
CREATE TABLE messages (
id bigint PRIMARY KEY,
sender_id uuid REFERENCES profiles(id),
receiver_id uuid REFERENCES profiles(id),
body text
);
// ❌ 모호 오류: 어떤 FK를 써야 할지 모름
const { data, error } = await supabase
.from('messages')
.select('id, body, profiles ( username )');
// ✅ 조인 힌트: !<외래키_컬럼명> 또는 !<제약조건명> 사용
const { data } = await supabase
.from('messages')
.select(`
id,
body,
sender:profiles!sender_id ( username ),
receiver:profiles!receiver_id ( username )
`);
sender:profiles!sender_id는 profiles 테이블을 sender_id FK로 조인하고, 결과 키를 sender로 별칭 지정합니다.
다대다 조회
다대다 관계는 중간 테이블(junction table)을 통해 표현합니다. PostgREST는 중간 테이블을 경유하는 경로를 자동으로 찾습니다.
-- 스키마 예시
-- posts ← post_tags → tags
CREATE TABLE post_tags (
post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
tag_id bigint REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
// posts와 tags를 중간 테이블 없이 바로 임베딩
const { data } = await supabase
.from('posts')
.select(`
id,
title,
tags ( id, name, slug )
`);
// 결과: data[0].tags = [{ id: 1, name: 'Supabase', slug: 'supabase' }, ...]
⚠️ 주의 — 중간 테이블에 추가 컬럼(예:
assigned_at)이 있고 그 값도 필요하다면, 중간 테이블을 명시적으로 select에 포함시켜야 합니다. 자동 경유만으로는 중간 테이블 컬럼에 접근할 수 없습니다.
// 중간 테이블 컬럼도 필요한 경우
const { data } = await supabase
.from('posts')
.select(`
id,
title,
post_tags (
assigned_at,
tags ( id, name )
)
`);
필터 연산자 심화
or / and 조합
체이닝으로 연결된 필터는 기본적으로 AND입니다. OR 조건이 필요하면 .or() 메서드를 사용합니다.
// published = true OR views > 1000
const { data } = await supabase
.from('posts')
.select('id, title, views, published')
.or('published.eq.true,views.gt.1000');
.or() 내부 문자열은 PostgREST 필터 문법을 그대로 사용합니다. 복잡한 중첩 AND/OR도 표현 가능합니다.
// (published = true AND views > 100) OR (featured = true)
const { data } = await supabase
.from('posts')
.select('*')
.or('and(published.eq.true,views.gt.100),featured.eq.true');
중첩 관계 테이블에 OR 필터를 적용할 때는 { referencedTable } 옵션을 함께 넘깁니다.
const { data } = await supabase
.from('posts')
.select('id, title, comments ( id, body )')
.or('body.ilike.%supabase%,body.ilike.%postgres%', { referencedTable: 'comments' });
텍스트 검색
like / ilike는 단순 패턴 매칭입니다. **전문 검색(Full-text search)**이 필요하면 textSearch()를 사용합니다. 이는 Postgres의 to_tsvector / to_tsquery를 활용합니다.
// 기본 텍스트 검색 (plainto_tsquery)
const { data } = await supabase
.from('posts')
.select('id, title, body')
.textSearch('body', 'supabase postgres', {
type: 'plain', // 'plain' | 'phrase' | 'websearch'
config: 'english'
});
| type | 설명 | 예시 |
|---|---|---|
plain | 모든 단어 AND | supabase postgres |
phrase | 단어 순서 일치 | "supabase edge" |
websearch | Google 스타일 문법 | supabase OR postgres |
💡 TIP — 전문 검색 성능을 위해 Postgres의
GIN인덱스를 생성하세요.CREATE INDEX idx_posts_fts ON posts USING gin(to_tsvector('english', body));
범위·배열 연산자
Postgres는 int4range, tstzrange 같은 범위 타입과 배열 타입을 지원합니다. supabase-js도 이에 대응하는 필터를 제공합니다.
// 배열 컬럼에 특정 값이 포함되는지 (배열 contains)
const { data } = await supabase
.from('posts')
.select('*')
.contains('tags', ['supabase', 'postgres']); // tags 배열에 두 값이 모두 포함
// 배열 컬럼이 주어진 배열과 교집합이 있는지
const { data: overlap } = await supabase
.from('posts')
.select('*')
.overlaps('tags', ['supabase', 'vue']);
// 범위 타입 컬럼: 예약 기간이 주어진 날짜를 포함하는지
const { data: reservations } = await supabase
.from('reservations')
.select('*')
.contains('period', '[2025-07-01,2025-07-07]');
containedBy는 반대로 컬럼 값이 주어진 범위 안에 속하는지를 검사합니다.
// 가격 범위가 [0, 50000] 안에 완전히 들어오는 상품
const { data } = await supabase
.from('products')
.select('*')
.containedBy('price_range', '[0,50000]');
집계: count와 group by 우회 패턴
PostgREST는 SQL의 GROUP BY를 직접 지원하지 않습니다. 하지만 실무에서 집계가 필요한 경우가 많기 때문에 몇 가지 우회 패턴을 알아두어야 합니다.
count 헤더 활용
행 개수만 필요할 때는 count 옵션을 사용합니다. 데이터 자체를 전송하지 않으므로 네트워크 비용이 줄어듭니다.
// 전체 행 수만 가져오기 (데이터는 빈 배열)
const { count, error } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('published', true);
console.log(count); // 예: 342
count 옵션 값에 따라 동작이 달라집니다.
| 옵션 | 동작 | 비고 |
|---|---|---|
'exact' | COUNT(*) 정확한 값 | 대규모 테이블에서 느릴 수 있음 |
'planned' | Postgres 통계 기반 추정치 | 빠르지만 부정확할 수 있음 |
'estimated' | 테이블 크기에 따라 자동 선택 | 균형 잡힌 선택 |
PostgreSQL 뷰로 group by 구현
복잡한 집계는 Postgres에 뷰(view)나 Materialized View로 미리 정의해 두고, PostgREST를 통해 조회하는 것이 가장 깔끔합니다.
-- Supabase SQL Editor에서 실행
CREATE VIEW post_stats AS
SELECT
author_id,
COUNT(*) AS post_count,
SUM(views) AS total_views,
AVG(views)::int AS avg_views,
MAX(created_at) AS last_posted_at
FROM posts
WHERE published = true
GROUP BY author_id;
// 뷰를 일반 테이블처럼 조회
const { data } = await supabase
.from('post_stats')
.select('author_id, post_count, total_views')
.order('total_views', { ascending: false })
.limit(10);
⚠️ 주의 — RLS 정책은 뷰에 직접 적용되지 않습니다. 뷰가 참조하는 원본 테이블에 RLS가 설정돼 있어야 하며,
security_invoker = true옵션을 명시해야 호출자의 권한으로 RLS가 평가됩니다.
CREATE VIEW post_stats WITH (security_invoker = true) AS
...;
RPC로 데이터베이스 함수 호출하기
PostgREST는 POST /rpc/<함수명> 엔드포인트로 Postgres 함수를 호출할 수 있게 해줍니다. 복잡한 비즈니스 로직, 트랜잭션, 집계, 집합 반환이 모두 가능합니다.
기본 RPC 호출
-- 함수 정의 (SQL Editor)
CREATE OR REPLACE FUNCTION get_popular_posts(min_views int DEFAULT 100)
RETURNS TABLE (
id bigint,
title text,
views int,
username text
)
LANGUAGE sql
STABLE -- 읽기 전용임을 명시 (최적화 힌트)
AS $$
SELECT p.id, p.title, p.views, pr.username
FROM posts p
JOIN profiles pr ON pr.id = p.author_id
WHERE p.published = true
AND p.views >= min_views
ORDER BY p.views DESC;
$$;
const { data, error } = await supabase
.rpc('get_popular_posts', { min_views: 500 });
RPC에서 필터 체이닝
RETURNS TABLE 함수의 경우 .rpc() 호출 결과에도 supabase-js 필터를 추가로 체이닝할 수 있습니다. PostgREST는 이를 서브쿼리로 감싸서 실행합니다.
// ✅ RPC 결과에 추가 필터 적용
const { data } = await supabase
.rpc('get_popular_posts', { min_views: 100 })
.ilike('title', '%supabase%')
.limit(5);
집계 결과 반환 RPC
GROUP BY가 필요한 복잡한 집계는 RPC로 캡슐화하는 것이 권장 패턴입니다.
CREATE OR REPLACE FUNCTION get_category_stats()
RETURNS TABLE (
category_name text,
post_count bigint,
total_views bigint
)
LANGUAGE sql STABLE AS $$
SELECT c.name, COUNT(p.id), COALESCE(SUM(p.views), 0)
FROM categories c
LEFT JOIN posts p ON p.category_id = c.id AND p.published = true
GROUP BY c.id, c.name
ORDER BY total_views DESC;
$$;
const { data, error } = await supabase.rpc('get_category_stats');
if (error) throw error;
// data: [{ category_name: 'Supabase', post_count: 42, total_views: 98234 }, ...]
💡 TIP — 쓰기 작업이 없는 함수는 반드시
LANGUAGE sql STABLE또는IMMUTABLE을 명시하세요. PostgREST가GET요청으로 처리해 캐싱 가능성이 높아집니다. 쓰기가 있으면VOLATILE(기본값)을 유지해야 합니다.
페이지네이션 전략: range vs keyset(cursor)
range 페이지네이션
range(from, to) 메서드는 SQL OFFSET / LIMIT을 사용합니다. 구현이 단순하지만 깊은 페이지로 갈수록 성능이 저하되는 구조적 한계가 있습니다.
const PAGE_SIZE = 20;
async function getPage(page: number) {
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.eq('published', true)
.order('created_at', { ascending: false })
.range(from, to);
return { data, total: count };
}
⚠️ 주의 —
OFFSET 10000이면 Postgres는 앞의 10,000행을 읽고 버린 뒤 결과를 반환합니다. 데이터가 많은 테이블에서 후반 페이지는 매우 느립니다.
keyset(cursor) 페이지네이션
마지막으로 받은 행의 정렬 기준 값을 커서(cursor)로 사용해, 다음 페이지를 WHERE cursor_col > last_value 형태로 조회합니다. OFFSET을 전혀 사용하지 않으므로 어느 페이지에서나 일정한 성능을 보장합니다.
async function getNextPage(cursor: string | null) {
let query = supabase
.from('posts')
.select('id, title, created_at')
.eq('published', true)
.order('created_at', { ascending: false })
.order('id', { ascending: false }) // 동일 created_at 시 id로 2차 정렬
.limit(20);
if (cursor) {
// cursor = 마지막 행의 created_at 값
query = query.lt('created_at', cursor);
}
const { data, error } = await query;
const nextCursor = data && data.length > 0
? data[data.length - 1].created_at
: null;
return { data, nextCursor };
}
// 사용 예
let cursor: string | null = null;
const { data: page1, nextCursor: c1 } = await getNextPage(cursor);
const { data: page2, nextCursor: c2 } = await getNextPage(c1);
두 방식을 비교하면 다음과 같습니다.
| 항목 | range (OFFSET) | keyset (cursor) |
|---|---|---|
| 구현 복잡도 | 낮음 | 중간 |
| 깊은 페이지 성능 | 나쁨 | 일정 |
| 전체 페이지 수 계산 | 가능 | 불가 |
| 중간 삽입·삭제 시 | 항목 누락/중복 가능 | 안전 |
| 적합한 UI | 번호 페이지네이션 | 무한 스크롤·더보기 |
응답 형태 제어와 부분 응답, returning 최적화
부분 응답(Partial Response)
select에 필요한 컬럼만 명시하면 네트워크 전송량을 줄이고 Postgres가 스캔하는 데이터를 최소화할 수 있습니다.
// ❌ 불필요한 컬럼까지 전송
const { data } = await supabase.from('posts').select('*');
// ✅ 필요한 컬럼만 지정
const { data } = await supabase
.from('posts')
.select('id, title, slug, published_at');
중첩 관계에서도 동일하게 적용됩니다.
// ✅ 중첩 관계도 필요한 컬럼만
const { data } = await supabase
.from('posts')
.select('id, title, profiles ( username, avatar_url )');
Mutation 후 returning 최적화
insert, update, delete 후에 결과를 받고 싶지 않다면 .select()를 생략합니다. 이는 PostgREST에 Prefer: return=minimal 헤더를 보내 응답 본문 없이 204 No Content를 받게 합니다.
// ❌ 불필요한 데이터를 반환받음
const { data } = await supabase
.from('posts')
.update({ views: views + 1 })
.eq('id', postId)
.select();
// ✅ 결과가 필요 없으면 select 생략 (204 반환, 전송 비용 0)
const { error } = await supabase
.from('posts')
.update({ views: views + 1 })
.eq('id', postId);
반대로 삽입 후 서버에서 생성된 id나 created_at 같은 값이 필요하다면 .select()를 명시합니다.
// ✅ 삽입 후 서버 생성 필드만 반환
const { data, error } = await supabase
.from('posts')
.insert({ title: 'New Post', author_id: userId })
.select('id, created_at')
.single();
단일 객체 응답 제어
| 메서드 | 동작 |
|---|---|
.single() | 정확히 1건을 기대, 0건이나 2건 이상이면 에러 |
.maybeSingle() | 0건이면 null, 2건 이상이면 에러 |
| 없음(기본) | 항상 배열 반환 |
// 존재 여부가 불확실한 단일 행 조회
const { data: profile, error } = await supabase
.from('profiles')
.select('id, username')
.eq('username', 'herosyw')
.maybeSingle(); // null or 객체
if (!profile) {
console.log('사용자 없음');
}
요약
- supabase-js 쿼리는 **PostgREST HTTP 요청(URL 쿼리스트링 + 헤더)**으로 변환되며, 네트워크 탭에서 직접 확인할 수 있다.
- 외래 키가 여러 개일 때는
!<FK컬럼명>조인 힌트로 경로를 명시하고, 별칭(:)으로 키 이름을 정리한다. or()내부에 PostgREST 필터 문자열을 사용하면 복잡한 AND/OR 조합이 가능하며, 전문 검색은textSearch()로 처리한다.- 집계(group by)는 Postgres 뷰 또는 RPC 함수로 캡슐화하고 supabase-js에서 조회하는 패턴이 권장된다.
- 무한 스크롤·피드처럼 대량 데이터 페이지네이션에는 OFFSET 대신 keyset(cursor) 방식을 사용해야 성능을 보장할 수 있다.
- 불필요한 컬럼 선택을 피하고, 결과가 필요 없는 mutation에서는
.select()를 생략해 네트워크 비용을 최소화한다.
연습문제
-
orders테이블에서status가'pending'이거나total_price가 100,000 이상인 주문의id,status,total_price를 조회하는 코드를 작성하세요. -
articles테이블에는author_id와editor_id두 컬럼이 모두profiles.id를 참조합니다. 두 프로필의username을 각각author와editor라는 키로 임베딩해 조회하는 코드를 작성하세요. -
products테이블에서 페이지 크기 15로 keyset 페이지네이션을 구현하세요. 정렬 기준은price내림차순이며, 커서는 마지막 행의price값입니다. -
카테고리별 게시글 수와 총 조회수를 반환하는 Postgres 함수
get_category_summary를 SQL로 정의하고, supabase-js에서 호출하는 코드를 작성하세요.
힌트 — 조인 힌트는
!<FK컬럼명>, keyset은lt또는gt필터, 집계 함수는 RPC 패턴을 활용하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Supabase 심화” 강좌에 대한 댓글입니다.