dev.syw

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로 데이터베이스 함수를 호출하고, 집계 결과를 반환받을 수 있다.
  • rangekeyset(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)Range HTTP 헤더가 아니라 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_idprofiles 테이블을 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모든 단어 ANDsupabase postgres
phrase단어 순서 일치"supabase edge"
websearchGoogle 스타일 문법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);

반대로 삽입 후 서버에서 생성된 idcreated_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()를 생략해 네트워크 비용을 최소화한다.

연습문제

  1. orders 테이블에서 status'pending' 이거나 total_price가 100,000 이상인 주문의 id, status, total_price를 조회하는 코드를 작성하세요.

  2. articles 테이블에는 author_ideditor_id 두 컬럼이 모두 profiles.id를 참조합니다. 두 프로필의 username을 각각 authoreditor라는 키로 임베딩해 조회하는 코드를 작성하세요.

  3. products 테이블에서 페이지 크기 15로 keyset 페이지네이션을 구현하세요. 정렬 기준은 price 내림차순이며, 커서는 마지막 행의 price 값입니다.

  4. 카테고리별 게시글 수와 총 조회수를 반환하는 Postgres 함수 get_category_summary를 SQL로 정의하고, supabase-js에서 호출하는 코드를 작성하세요.

힌트 — 조인 힌트는 !<FK컬럼명>, keyset은 lt 또는 gt 필터, 집계 함수는 RPC 패턴을 활용하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Supabase 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.