LOOKING FOR FRIEND / ENGINEERING NOTE 01

검색 속도보다 먼저,
요청 경계부터.

검색 실패의 원인을 분리하고, 쿼리의 비용과 결과의 정확성을 함께 검증한 기록.

검색 SQL 전에 실패하는 요청

당시에는 검색어가 있는 요청에서만 디렉터리 조회 전에 EXPLORE_SEARCH 레이트 리밋을 확인했습니다. 이 검사가 service-role RPC에 의존하고, 의존성 실패나 750ms timeout에 fail-closed로 응답하면서 검색 SQL 실행 전에 503이 반환될 수 있었습니다.

검색어 없는 디렉터리는 같은 가드를 거치지 않았습니다. 그래서 증상만 보면 “목록은 열리는데 검색 조건이 고장 난 것”처럼 보이지만, 쿼리와 요청 경계의 실패를 먼저 나눠야 했습니다.

검색 요청레이트 리밋 의존성인증된 actor 확인검색 RPC

SQL 실행 시간을 줄여도 그 앞에서 종료된 요청은 복구되지 않습니다. 어느 구간이 실제로 실행됐는지부터 확인해야 합니다.

예외 정책은 제한된 연산에만 적용

정상 상황의 공통 Postgres 레이트 리밋은 유지했습니다. 다만 이 검색 bucket은 의존성이 불가용할 때 fail-open을 허용하고, 검색 RPC 자체에서 쿼리 길이·필터 개수·결과 크기를 제한했습니다.

이 선택은 모든 쓰기나 외부 효과에 대한 일반 규칙이 아닙니다. 검색의 가용성과 DB 비용 제한을 함께 다룬 좁은 정책입니다. 입력과 비용이 무제한인 연산을 그대로 열어 두는 방식과도 다릅니다.

DB 함수는 브라우저가 임의로 호출하지 못하도록 anon·authenticated의 EXECUTE를 회수했습니다. 서버가 확인한 viewer를 service-only wrapper에 전달하고, DB 내부의 공개 범위·제재·양방향 차단 판정에 연결했습니다. 함수에는 빈 search_path와 명시적 입력 검증을 사용했습니다.

당시 서버 전용 RPC 경계

많이 만든 뒤 줄이지 않고, 먼저 제한하기

① 페이지를 먼저 제한하고 집계

프로필·게임·태그를 모두 JOIN한 큰 결과를 만든 뒤 정리하는 대신, 게임·태그 조건을 EXISTS / anti-EXISTS로 확인합니다. 최대 31개 프로필을 정한 다음 표시할 게임 라벨을 집계합니다. 화면에는 30개를 보여 주고 한 행은 다음 페이지 존재 확인에 사용합니다.

② 정렬 기준과 커서 기준 일치

정렬은 bumped_at DESC, profile_id DESC입니다. 같은 bump 시각을 가진 프로필도 UUID로 순서를 정합니다. 다음 페이지는 마지막 행의 복합 키 뒤에서 이어 읽습니다.

-- 개념을 보이기 위한 축약 SQL
WHERE (bumped_at, profile_id) < (:cursor_time, :cursor_id)
ORDER BY bumped_at DESC, profile_id DESC
LIMIT 31;
당시 앱 커서에 남아 있던 한계
기준 버전 b1454f1에서는 커서를 만들 때 new Date(lastRow.bumped_at).toISOString()을 사용해 DB의 microsecond 정밀도가 millisecond로 줄었습니다. UUID tie-breaker만으로 이 시간 손실을 보완할 수는 없습니다. 후속 수정에서는 표시용 Date와 커서용 원본 timestamp를 분리했습니다. 아래 burst 시험은 이 페이지 경계 문제까지 검증한 결과가 아닙니다.

당시 커서 생성 · 후속 정밀도 회귀검사

부분 인덱스와 복합 정렬 인덱스를 조회 조건에 맞추고, 텍스트 검색에는 trigram 인덱스를 사용했습니다. 인덱스가 있다는 사실과 실제 실행 계획에서 사용됐다는 증거는 구분해 확인했습니다.

③ 필터를 조용히 넓히지 않기

텍스트·게임·태그 그룹은 AND로 결합합니다. 미등록·비활성 필터 ID를 무시하면 사용자가 요청한 범위보다 결과가 넓어질 수 있어 거부합니다. 검색 문자열의 %·_ 등도 LIKE wildcard로 해석되지 않도록 처리했습니다.

HTTP 200만으로는 검색 성공이 아니다

부하 데이터의 게임과 첫 태그는 짝수 번째 프로필에, 두 번째 태그는 3의 배수 프로필에 넣었습니다. 두 태그가 일부만 겹치게 만들어 ALL과 ANY의 결과가 분명히 달라지도록 구성했습니다.

combined 필터, 게임 필터, multi-tag ALL/ANY, literal wildcard 검색을 순환하면서 예상 프로필 ID·개수·순서를 대조했습니다. 필터 하나를 빼거나 ALL을 ANY로 바꾸어도 통과하는 시험이 되지 않도록 한 것입니다.

service role로 정확히 같은 RPC가 실행되는지 확인한 뒤, anon의 직접 호출이 401로 거부되는지도 확인했습니다. 성능과 별개로 결과의 의미와 접근 경계를 검증했습니다.

실제로 측정한 결과

2026-08-15, 합성 프로필 10,000개를 넣은 로컬 Supabase에서 Data API → Postgres 경로를 측정했습니다. 각 동시성 단계는 동기화한 burst 5회이며, 요청 timeout은 5초이고 retry는 없습니다.

검색 RPC의 warm local Data API 부하 검증
동시 요청누적 요청오류율p95p99
251250%142.57ms146.07ms
502500%150.91ms158.59ms
1005000%470.17ms491.63ms
2001,0000%725.95ms825.68ms

최고 구간의 처리량은 182.03req/s였고, deadlock·temp bytes는 0이었습니다. 해당 구간에서 샘플링한 active query 최대값은 11입니다. 샘플링 값이므로 순간 최대 연결 수나 전체 열린 커넥션 수를 입증하지는 않습니다.

구분해야 하는 다른 시험
50,000개 프로필에 대한 로컬 실행 계획 확인도 별도로 있습니다. 이는 이 표의 10,000개 fixture 기반 Data API 부하 시험과 다른 조건이며, 실제 운영 사용자 50,000명을 검증했다는 뜻이 아닙니다.

이 결과에서 말할 수 없는 것

  • Vercel·앱 loader·Discord OAuth를 포함한 production E2E 성능이 아닙니다.
  • 200개 동시 요청 × 5회는 누적 1,000요청입니다. 1,000 동시 사용자나 1,000RPS가 아닙니다.
  • 측정한 RPC 경로는 앱의 별도 Drizzle/postgres-js 2개 커넥션 풀을 통과하지 않습니다.
  • 동일 조건의 변경 전 수치가 없으므로 “몇 % 빨라졌다”는 개선율을 계산하지 않습니다.
  • 후속 소스에는 검색 _v2 wrapper와 요청 gate 변경이 있습니다. 이 과거 측정값을 그 버전의 재검증 결과로 재사용하지 않습니다.

다음 검증에서는 실제 앱 경로의 인증·loader·DB 시간을 나눠 보고, 다중 인스턴스의 대기열·DB 연결·CPU와 timeout 뒤 잔존 작업을 함께 관찰해야 합니다. 이는 다음 측정 항목이며 이번에 완료한 시험은 아닙니다.

설계에서 남길 기준

실패가 발생한 계층을 먼저 분리하고, 연산의 입력과 결과 크기를 제한합니다. 그다음 실행 계획과 부하를 보되, 빠르게 잘못된 결과를 반환하지 않는지 정확성을 함께 검증합니다. 측정값에는 대상 버전과 환경을 붙여야 이후 변경의 근거로 사용할 수 있습니다.

관련 기록: 초기 실행 계획·인증 왕복 개선과 분산 시험 ↗
이 글의 8월 15일 검색 RPC burst와 다른 환경·시점의 결과를 구분해 정리했습니다.

근거