Skip to content

Supabase PostgREST vs Direct TCP: Vercel Serverless에서 직접 재본 성능 차이

Published:

Supabase와 Vercel 조합으로 운영 중인 서비스에서 API 응답 시간이 비정상적으로 느린 문제가 있었다. 홈페이지 진입 시 클라이언트가 API 9개를 호출하는데, 각 API 응답이 4초에서 12초까지 걸렸고 전체 페이지 로드 시간은 5.5초까지 늘어났다.

처음에는 DB 쿼리나 인덱스 문제를 의심했다. 하지만 EXPLAIN ANALYZE 결과 쿼리 자체는 대부분 10ms 안팎이었다. 병목은 DB가 아니라 Vercel Function과 Supabase Postgres 사이의 어딘가에 있었다.

이 글은 그 병목을 Sentry trace와 진단용 API로 좁혀가며, Supabase PostgREST를 Direct TCP 연결로 바꿨을 때 실제로 어느 정도 차이가 났는지 정리한 기록이다.

환경

항목값
프레임워크Next.js App Router
호스팅Vercel Serverless Function
데이터베이스Supabase Free Plan
Vercel 리전icn1
Supabase 리전ap-northeast-2
측정일2026-04-06
DB 규모stocks 5,768건, insider_transactions 146,551건, insider_filings 191,750건

중요한 조건이 하나 있다. 측정 시점 중 일부는 KST 새벽 2시에서 5시 사이로 트래픽이 거의 없는 시간이었다. Supabase Free Plan의 PostgREST 또는 Supavisor 레이어가 idle 상태에서 깨어나는 worst case에 가깝다.

Sentry에서 본 첫 번째 단서

홈페이지(/) pageload trace를 보면 총 70개 span 중 API 호출 9개가 전체 시간의 99%를 차지했다.

엔드포인트Duration
/api/stocks/AAPL/insider-transactions5,065ms
/api/stocks/TSLA/insider-transactions5,034ms
/api/stocks/NVDA/insider-transactions5,022ms
/api/stocks/PLTR/insider-transactions5,021ms
/api/stocks/AAPL4,240ms
/api/investors4,148ms
/api/stocks/TSLA4,144ms
/api/stocks/NVDA4,123ms
/api/stocks/PLTR4,122ms

정적 리소스는 문제가 아니었다. JS는 255ms, 이미지는 캐시로 0ms였고 페이지 navigation span도 72ms에서 78ms 수준이었다. 병목은 순수하게 API 응답 시간에 있었다.

서버 trace를 더 보면 Next.js route handler 내부 코드에서 시간을 쓰는 것도 아니었다.

executing api route /api/stocks/[ticker]/insider-transactions  (12.8s)
└─ GET supabase.co/rest/v1/insider_transactions                (12.8s)
   server code overhead                                        (about 0ms)

시간의 대부분은 supabase.co/rest/v1/... 호출에서 사라지고 있었다.

Cold와 Warm의 차이

같은 API라도 시간대에 따라 차이가 컸다.

시점 (KST)엔드포인트Duration상태
05:20/api/stocks/[ticker]/insider-transactions12,863msCold
05:20/api/stocks/[ticker]6,495msCold
06:45/api/stocks/[ticker]/insider-transactions2,664msWarm
06:45/api/stocks/[ticker]1,200msWarm

Warm 상태에서도 충분히 느렸지만, cold 상태에서는 실서비스에서 받아들이기 어려운 수준까지 튀었다.

DB 쿼리 자체는 빨랐다

Supabase MCP를 통해 같은 쿼리를 EXPLAIN ANALYZE로 실행했다.

쿼리PlanningExecution합계
stocks WHERE ticker = 'AAPL'7.6ms0.14ms약 8ms
insider_transactions JOIN insider_filings31ms7.8ms약 39ms
data_sync0.3ms0.1ms약 0.4ms

인덱스도 정상적으로 사용되고 있었다. 즉, Postgres가 느린 상황은 아니었다.

쿼리Sentry 기준 서버 to SupabaseDB 실제 실행
stocks1,100ms - 6,400ms0.14ms
insider_transactions2,600ms - 12,800ms7.8ms

수백 배 차이가 났다. 이 정도면 쿼리 튜닝으로 해결할 문제가 아니다. DB 앞단의 레이어를 의심해야 한다.

진단용 API로 구간을 나눠 재기

/api/debug/latency 엔드포인트를 만들어 Vercel Function 내부에서 구간별 시간을 쟀다.

먼저 Vercel Function 자체 오버헤드를 분리했다.

호출curl totalserver totalVercel 오버헤드
#1 cold3,964ms3,702ms262ms
#2 warm859ms745ms114ms
#3 warm1,324ms987ms337ms

이 측정에서 Vercel 오버헤드는 약 0.1초에서 0.3초 사이였다. 무시할 수 있는 값은 아니지만, 앞에서 본 4초에서 12초 수준의 API 응답 지연을 설명할 정도는 아니었다.

PostgREST를 통해 같은 쿼리를 실행하면 다음과 같았다.

측정 항목#1 cold#2 warming#3 warm
첫 번째 쿼리1,520ms334ms82ms
두 번째 쿼리707ms272ms92ms
insider_transactions JOIN1,165ms242ms300ms
data_sync309ms102ms56ms
합계3,702ms950ms531ms

호출이 반복되면서 빨라지는 패턴이 명확했다. PostgREST 또는 Supavisor의 connection pool이 warm-up되는 흐름으로 보였다.

Supabase Client 싱글턴은 해결책이 아니었다

처음에는 매 요청마다 createClient()를 호출하는 것이 문제일 수 있다고 봤다. 그래서 모듈 레벨 싱글턴으로 바꿨다.

// Before: 매번 새 인스턴스 생성
export function createServerClient() {
  return createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY);
}

// After: Function instance 안에서 재사용
let client: SupabaseClient | null = null;

export function createServerClient() {
  if (!client) {
    client = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY);
  }

  return client;
}

결과는 거의 차이가 없었다. client_create_ms가 1ms에서 0ms로 줄었을 뿐이다.

이유는 단순하다. Supabase JS Client는 서버에서 Postgres connection을 직접 들고 있는 클라이언트가 아니다. HTTP 요청을 만들고 PostgREST로 보내는 클라이언트에 가깝다. 실제 비용은 createClient()가 아니라 쿼리 실행 시점의 HTTP to PostgREST to Supavisor 구간에서 발생한다.

PostgREST와 Direct TCP 비교

다음으로 두 개의 독립된 엔드포인트를 만들었다.

엔드포인트경로클라이언트
/api/debug/latency/restVercel to HTTP to PostgREST to Supavisor to Postgres@supabase/supabase-js
/api/debug/latency/tcpVercel to TCP to Supavisor to Postgrespostgres

두 요청은 curl로 동시에 발사했다. 네트워크와 서버 상태를 최대한 같은 조건으로 맞추기 위해서다.

curl https://example.com/api/debug/latency/rest &
curl https://example.com/api/debug/latency/tcp &
wait

실행한 쿼리는 세 가지다.

쿼리목적
query_1SELECT ticker FROM stocks LIMIT 1, connection 수립 비용 포함
query_2같은 쿼리 재실행, connection 재사용 상태 확인
insider_txinsider_transactions JOIN insider_filings WHERE ticker = 'AAPL'

1차 측정

둘 다 Vercel cold start 상태였다.

구간RESTTCP
Vercel 오버헤드997ms1,088ms
query_1857ms330ms
query_2250ms2ms
insider_tx982ms22ms
server total2,088ms359ms
curl total3,086ms1,447ms

2차 측정

역시 둘 다 Vercel cold start 상태였다.

구간RESTTCP
Vercel 오버헤드721ms923ms
query_1375ms37ms
query_2137ms3ms
insider_tx285ms4ms
server total802ms48ms
curl total1,523ms971ms

3차 측정

둘 다 Vercel warm 상태였다.

구간RESTTCP
Vercel 오버헤드304ms360ms
query_11,802ms36ms
query_2397ms3ms
insider_tx1,101ms3ms
server total3,300ms44ms
curl total3,604ms404ms

흥미로운 점은 Vercel이 warm이어도 REST는 3.3초까지 튀었다는 것이다. 즉, Vercel cold start만으로 설명할 수 없는 지연이 PostgREST 경로에 있었다.

4차 측정

REST는 warm, TCP는 cold start 상태였다.

구간RESTTCP
Vercel 오버헤드310ms121ms
query_1334ms118ms
query_2309ms3ms
insider_tx381ms18ms
server total1,024ms143ms
curl total1,334ms264ms

Direct TCP는 5배에서 75배 빨랐다

네 번의 측정을 합치면 다음과 같다.

측정 항목REST 범위TCP 범위TCP 개선 배율
Vercel 오버헤드304ms - 997ms121ms - 1,088ms비슷함
query_1 cold334ms - 1,802ms36ms - 330ms5배 - 50배
query_2 warm137ms - 397ms2ms - 3ms50배 - 130배
insider_tx JOIN285ms - 1,101ms3ms - 22ms13배 - 367배
server total802ms - 3,300ms44ms - 359ms5배 - 75배

요청 경로를 단순화하면 이렇게 볼 수 있다.

REST: Vercel -> HTTP -> PostgREST -> Supavisor -> Postgres
TCP:  Vercel -> TCP  -> Supavisor -> Postgres

두 방식 모두 Supavisor를 거친다. 그런데 PostgREST를 포함한 REST 경로를 제외하는 것만으로 server total이 802ms에서 3,300ms 사이에서 44ms에서 359ms 사이로 줄었다.

이 측정에서는 병목이 Postgres 실행 시간이 아니라 PostgREST 경로에 있었다고 보는 것이 가장 자연스럽다.

Drizzle 싱글턴으로 한 번 더 재측정

위 TCP 라우트는 의도적으로 매 요청마다 postgres() 클라이언트를 만들고, 요청이 끝나면 sql.end()로 닫았다. 측정의 독립성을 위해서였다.

하지만 실제 프로덕션에서는 이 패턴을 쓰지 않는다. Vercel Function instance는 수 초에서 수 분 동안 살아 있을 수 있고, 그동안 모듈 레벨 변수를 재사용할 수 있다. 따라서 DB 클라이언트도 모듈 레벨 싱글턴으로 두는 것이 일반적이다.

Drizzle ORM 도입을 결정한 뒤 세 번째 엔드포인트를 추가했다.

엔드포인트클라이언트 패턴커넥션 라이프사이클
/api/debug/latency/rest@supabase/supabase-jsHTTP 기반, fetch 단위
/api/debug/latency/tcppostgres.js요청마다 생성 후 sql.end()
/api/debug/latency/drizzledrizzle-orm + postgres.js모듈 싱글턴, Function instance 안에서 재사용

세 엔드포인트 모두 같은 Supabase Transaction Pooler URL을 사용했다.

aws-1-ap-northeast-2.pooler.supabase.com:6543

TCP 라우트도 Transaction Pooler로 통일했고 prepare: false 옵션을 추가했다. Transaction Pooler 환경에서는 prepared statement가 connection 단위로 안정적으로 유지된다고 가정하기 어렵기 때문이다.

for run in 1 2 3 4; do
  curl https://example.com/api/debug/latency/drizzle &
  curl https://example.com/api/debug/latency/tcp &
  curl https://example.com/api/debug/latency/rest &
  wait
  sleep 3
done

Drizzle 비교 결과

Round상태REST totalTCP totalDrizzle total
1rest cold, tcp warm, drizzle warm2,679ms228ms31ms
2전부 cold768ms102ms123ms
3rest cold, tcp cold, drizzle warm1,774ms42ms30ms
4전부 warm685ms58ms29ms

쿼리별로 보면 차이가 더 선명하다.

쿼리RESTTCPDrizzle
query_1224ms - 1,005ms30ms - 115ms9ms - 316ms
query_2144ms - 341ms3ms - 5ms8ms - 10ms
insider_tx JOIN302ms - 2,278ms3ms - 107ms10ms - 115ms

Warm 상태 기준으로는 Drizzle이 29ms에서 31ms까지 내려갔다. 요청마다 connection을 새로 만드는 raw TCP 라우트보다 오히려 빨랐다.

다만 이 결과를 “Drizzle이라서 빠르다”거나 “싱글턴만 적용하면 성능이 개선된다”로 읽으면 안 된다. 이 비교는 클라이언트가 다르고 connection lifecycle도 다르다. 여기서 확인한 것은 raw TCP 라우트처럼 매 요청마다 connection을 열고 닫는 패턴에는 connection 수립 비용이 반복해서 들어간다는 점이다.

TCP 라우트의 한 invocation:
  postgres() 생성 -> connection 수립 -> query_1 -> query_2 -> insider_tx -> sql.end()
                    ^ 매 invocation마다 반복

Drizzle 라우트의 한 invocation:
  getDb() -> 캐시된 connection 반환 -> query_1 -> query_2 -> insider_tx
             ^ Function instance가 살아 있는 동안 재사용

TCP 라우트의 query_1에는 신규 connection 수립과 TLS handshake 비용이 포함된다. 같은 invocation 안의 query_2와 insider_tx가 3ms 수준으로 빠른 이유는 이미 열린 sql 객체를 재사용하기 때문이다.

반대로 다음 invocation에서는 sql.end()로 connection을 닫아버렸기 때문에 다시 connection 수립 비용을 낸다.

Drizzle 라우트는 getDb()가 모듈 레벨 싱글턴이다. Function instance가 살아 있는 동안 connection을 유지하므로 warm 상태에서는 각 쿼리가 9ms에서 11ms 범위에 머문다. 이 값은 Supabase Transaction Pooler를 거쳐 Postgres에 다녀오는 실제 왕복 시간에 가깝다.

따라서 이 절의 의미는 “싱글턴이 PostgREST 병목을 해결했다”가 아니다. PostgREST 병목은 Direct TCP로 우회하면서 해결했고, 싱글턴은 Direct TCP를 프로덕션에서 사용할 때 불필요한 connection 수립 비용을 반복하지 않기 위한 연결 관리 방식에 가깝다.

레이어별 비용을 다시 정리하면

이번 측정에서 비용은 대략 이렇게 나뉜다.

레이어비용측정 근거
Postgres 쿼리 실행10ms 미만EXPLAIN ANALYZE
Supavisor Transaction Pooler5ms - 10ms 수준Drizzle warm 쿼리 9ms - 11ms에서 추정
Postgres connection 수립 + TLS40ms - 100msTCP cold query_1과 warm query_2 차이
PostgREST를 포함한 REST 경로수백 ms - 수천 msREST와 TCP의 차이
Vercel Function cold start100ms - 1,000msmodule_loaded_at 기반 측정

물론 이 값은 Supabase Free Plan, 리전, 시간대, 프로젝트 idle 여부에 따라 달라질 수 있다. 하지만 적어도 이 환경에서는 PostgREST 경로가 압도적인 병목이었다.

변경 후 개선 지표

이 측정에서 핵심 지표는 API 서버 안에서 DB 조회에 걸린 시간이다. 브라우저에서 본 전체 pageload는 네트워크, Vercel cold start, 클라이언트 렌더링, 병렬 API 호출 구조의 영향을 함께 받는다. 반면 DB access path의 병목을 비교하려면 server total을 보는 편이 더 정확하다.

지표변경 전: PostgREST변경 후: Direct TCP변경 후: Drizzle 싱글턴
진단 API server total685ms - 2,679ms42ms - 228ms29ms - 123ms
warm 상태 server total685ms 이상수십 ms29ms - 31ms
단순 warm 쿼리137ms - 397ms2ms - 3ms8ms - 10ms
JOIN 쿼리302ms - 2,278ms3ms - 107ms10ms - 115ms
같은 round 기준 개선 폭-약 7배 - 42배약 6배 - 86배
warm 상태의 대표 응답 시간대수백 ms 이상수십 ms30ms 안팎

같은 round끼리 비교하면 PostgREST에서 Direct TCP로 바꿨을 때 DB access path의 server total은 약 7배에서 42배 줄었다. Drizzle 싱글턴 라우트까지 포함하면 전체 round 기준 약 6배에서 86배 차이가 났고, warm 상태만 놓고 보면 30ms 안팎까지 내려갔다.

Drizzle 싱글턴은 여기서 추가로 “항상 더 빠른 ORM”을 증명한 것이 아니라, 프로덕션에 가까운 connection 재사용 패턴에서는 매 요청마다 connection을 여닫는 비용을 피할 수 있음을 보여준다.

따라서 이 변경의 성공 기준은 다음처럼 잡을 수 있다.

결론: 핵심은 Direct TCP, 싱글턴은 연결 관리다

이번 서비스의 warm 상태 측정 결과를 기준으로 보면 성능은 다음 순서였다.

REST(PostgREST)                  685ms - 2,679ms
TCP(connection per request)       42ms - 228ms
Drizzle(singleton connection)     29ms - 31ms

PostgREST에서 Direct TCP로 바꾸는 것만으로 server total 기준 5배에서 75배 개선이 있었다. 이 글의 핵심 개선은 여기에 있다.

모듈 레벨 싱글턴은 별도의 큰 성능 개선 포인트라기보다, Serverless 환경에서 Direct TCP를 쓸 때 매 요청마다 connection을 새로 만들지 않기 위한 기본적인 연결 관리 방식이다. 이번 측정에서도 Drizzle 싱글턴 라우트가 빠르게 나온 이유는 ORM 자체의 성능 때문이라기보다, warm Function instance에서 이미 열린 connection을 재사용했기 때문이다.

그래서 이 서비스에서는 Supabase JS Client 기반의 PostgREST 호출을 API 서버 내부에서 제거하고, Drizzle ORM과 Supabase Transaction Pooler를 직접 사용하는 방향으로 정리했다.

이 서비스의 프로덕션 기준으로는 다음 구성이 가장 적합했다.

Supabase PostgREST는 빠르게 API를 만들 수 있는 좋은 기본값이다. 특히 CRUD 중심의 초기 제품에서는 생산성이 높다. 다만 Vercel Serverless에서 서버 API의 tail latency가 중요하고, 쿼리 자체가 이미 충분히 빠른 상황이라면 PostgREST를 계속 쓰는 것이 병목일 수 있다.

이럴 때는 추측으로 ORM을 바꾸거나 인덱스를 추가하기 전에, 먼저 같은 쿼리를 PostgREST와 Direct TCP로 나눠 재보는 편이 낫다. 측정해보면 어디를 고쳐야 하는지가 생각보다 빨리 드러난다.