PROJECT 01 / SYSTEM DESIGN CASE STUDY

Looking For Friend

함께 게임할 사람을 찾고, 서로 동의한 뒤 대화를 시작하는 서비스.
탐색에서 대화까지 이어지는 흐름에 권한·정합성·복구 경로를 함께 설계했습니다.

탐색부터 대화와 관계 관리까지.

Discord OAuth 로그인 뒤 프로필·게임·태그를 설정하고, 조건에 맞는 사람을 찾습니다. DM은 요청과 수락을 거쳐 시작하며, 팔로우·차단·신고·관리자 제재까지 연결됩니다.

Discord 로그인프로필·게임·태그 설정검색·프로필 탐색DM 요청·수락실시간 대화

핵심 과제는 같은 사용자의 상태가 화면·API·DB에서 일관되게 적용되는 것이었습니다. 차단된 상대가 검색이나 DM에 노출되지 않아야 하고, 연결이 끊기거나 저장 응답이 유실돼도 데이터를 다시 확인할 수 있어야 했습니다.

AI를 코드 작성·분석·테스트에 활용하며 제품 범위와 설계 판단, 검증 결과 확인 및 운영 대응을 책임졌습니다.

요청은 서버로, 상태는 Postgres로.

React Router의 loader·action·resource route에 일반 조회와 변경 요청을 모았습니다. 서버에서 신원과 입력을 확인하고, DB에서는 행·함수 접근 권한과 소유자·참여자 조건을 검사합니다. 기능 폴더를 나눈 하나의 풀스택 앱을 Vercel에 배포하는 구조입니다.

Browser / React사용자 화면 · 입력 · Realtime 구독
HTTPS · 세션 쿠키
React Router / VercelSSR · loader · action · resource route
인증 · 입력 검증 · 작업별 인가
사용자 JWT 또는 제한된 서버 전용 권한
AuthDiscord OAuth
사용자 신원·세션
PostgresRLS · RPC
관계·메시지·읽음·작업 상태
Storage이미지 바이트
버전별 파일 경로
실시간

Postgres 변경 → Realtime → Browser
수신·재연결·포커스 복귀 시 서버 데이터 재조회

외부 전송

Discord 서명 이벤트 → 서버 → DB outbox
→ 전달 worker → Discord DM
전송할 작업을 저장하고 lease·멱등 키로 처리

운영

cleanup·health / Storage 백업 / Sentry
작업 상태 확인 · 파일 복원 검증 · 허용 정보만 오류 분류

DB는 프로필·관계·메시지·읽음·작업 상태의 정본입니다. Storage 파일과 Discord 전송은 DB 트랜잭션 밖에 있으므로 별도의 복구 계약을 둡니다. 도식은 2026년 9월 코드와 시스템 설명서를 바탕으로 정리했습니다.

이 구조에서 서버의 인증 확인과 DB의 인가는 서로 다른 역할입니다. 특히 service-role 경로는 RLS가 자동으로 보호해 준다고 가정할 수 없어, 입력과 호출자 검사를 명시적으로 제한했습니다.

어떻게 설계했고, 왜 그렇게 선택했나.

핵심 선택을 요구사항·구현·감수한 조건으로 나누어 설명합니다. 각 근거는 고정된 커밋의 코드와 당시 검증 기록으로 연결됩니다.

01 / IDENTITY & AUTHORIZATION

로그인 상태와 데이터 소유권을 구분했습니다.

필요했던 것
로그인한 사용자도 다른 사람의 프로필·대화·관리 기능을 임의로 읽거나 변경할 수 없어야 합니다.
선택한 설계
HTTP의 인증·role·Origin 검사, 테이블·함수 GRANT, RLS 또는 RPC 내부 인가를 구분했습니다. 일반 사용자 작업은 검증된 JWT를 전달하고 auth.uid()에서 주체를 얻습니다. 관리자와 owner 권한도 작업별로 검사합니다.
선택 이유
authenticated는 로그인 여부일 뿐 행의 소유권이 아닙니다. UI 가드가 빠지거나 API를 직접 호출해도 데이터 접근 조건이 유지되도록 DB에서도 관계·소유자·참여자를 확인합니다.
변경과 조건
후속 PR #41에서 관계 목록과 차단 해제를 직접 SQL에서 authenticated RPC로 옮겼습니다. 서버 전용 검색처럼 더 강한 권한이 필요한 경로는 호출 역할·입력·내부 인가를 따로 제한합니다. RPC 사용 자체가 안전성을 보장하는 것은 아닙니다.
구현·검증 근거

관계 목록·차단 해제 RPC는 임의 사용자 ID를 권한 근거로 받지 않고 auth.uid()를 사용합니다. PR #41에는 운영 RPC 권한 확인과 직접 DB 접속정보를 제거한 배포 검증이 기록돼 있습니다.

PR #41 · 관계 조회 코드

02 / SEARCH & CAPACITY

검색의 작업량과 대기열을 각각 제한했습니다.

필요했던 것
게임·태그·텍스트 필터를 정확히 적용하면서, 조합 검색이 큰 중간 결과나 무한 대기를 만들지 않아야 합니다.
선택한 설계
검색 RPC에서 입력·필터 수·결과 크기를 제한했습니다. EXISTS 필터와 keyset pagination을 사용하고, 페이지를 제한한 뒤 표시용 데이터를 집계했습니다. 후속 서버 gate에서는 동시 RPC 8개, 대기 64개, 대기 750ms, RPC 예산 4초를 기본값으로 둡니다.
선택 이유
SQL에서 처리할 데이터와 서버에서 기다리는 요청을 함께 묶어야 과부하의 확산을 줄일 수 있습니다. 익명 결과만 짧게 재사용하고, 로그인 사용자의 결과는 캐시·single-flight를 우회합니다. 사용자별 차단 상태가 다른 검색 결과를 섞지 않기 위해서입니다.
감수한 조건
서버 gate는 warm process별 제한입니다. 인스턴스가 늘면 서비스 전체의 동시 실행 수는 8을 넘을 수 있습니다. 또한 AbortSignal을 전달했다는 사실만으로 DB 내부 SQL의 즉시 중단이 증명되지는 않습니다.
정확성과 버전별 측정 근거

게임·태그의 겹침이 다른 합성 데이터를 만들어 ALL/ANY, 반환 ID·개수·순서를 확인했습니다. 8월의 로컬 부하 결과와 9월의 요청 gate 개선은 다른 시점의 근거로 구분합니다. 커서는 DB microsecond 원본 문자열을 유지하고 표시용 Date와 분리한 후속 수정도 있습니다.

검색 기술 글 · 서버 요청 gate · 커서 정밀도 회귀

03 / MESSAGING & CONSISTENCY

실시간 이벤트를 놓쳐도 DB에서 복구하도록 했습니다.

필요했던 것
탭이 백그라운드로 가거나 연결이 끊겨도 메시지와 읽음 상태를 이어가야 하고, 재전송으로 메시지가 중복 저장되지 않아야 합니다.
선택한 설계
Realtime은 재조회 신호로, 메시지·읽음 마커·unread 상태는 DB 정본으로 관리합니다. 사용자·대화별 마지막 읽은 메시지는 high-water mark로 유지합니다. DB 트리거가 대화별 unread와 사용자별 navigation counter를 갱신하고, 전역 메뉴는 저장된 집계를 조회합니다. 같은 전송은 멱등 키로 식별하고, 대화 쌍과 thread의 잠금 순서로 동시 변경을 다룹니다.
선택 이유
브라우저 이벤트 수만 unread의 정본으로 쓰면 이벤트 유실이 상태 유실로 이어집니다. DB 기록을 다시 읽으면 재연결·포커스 복귀 뒤에도 복구할 기준이 생깁니다. 저장된 집계는 메뉴를 읽을 때마다 전체 메시지를 다시 세는 비용도 줄입니다. 읽음은 문서의 visible·focus와 최신 메시지의 표시 조건에 연결합니다.
감수한 조건
화면과 DB 사이에는 일시적인 지연이 생길 수 있습니다. 재조회 범위를 notification·DM resource로 좁혀 전체 페이지 loader의 재실행과 관련 없는 통계 갱신을 줄였습니다.
경계 조건 검증

유효하지만 낮은 읽음 마커가 재전송되면 greatest로 기존 값을 유지합니다. 유효하지 않은 메시지 ID의 거부와는 다른 동작이며, GET은 읽음 상태를 변경하지 않습니다.

격리된 로컬 Chromium에서는 background defer, 재연결·event-loss 복구와 참여자·차단·제재 경계를 확인했습니다. headless 환경의 visibility·focus 신호는 테스트에서 강제했습니다. 별도 staging 기록은 RPC 부하·DB 계약 검증이며 같은 브라우저 시나리오의 실행 증거로 합치지 않습니다. Broadcast 검토 이력과 별개로 여기서는 DB 상태와 postgres_changes wake-up을 기준으로 설명합니다.

로컬 durable unread E2E · 저장형 unread 집계 · 검증 환경·범위

04 / STORAGE & PARTIAL FAILURE

DB 저장과 파일 업로드 사이의 실패를 구분했습니다.

필요했던 것
사진 교체가 실패해도 기존 사진이 유지돼야 하고, 저장 성공 여부가 불명확할 때 정상 파일을 삭제하면 안 됩니다.
선택한 설계
실제 파일 바이트·크기·해상도를 검사하고, 동일 URL을 덮어쓰지 않는 버전별 경로에 업로드합니다. DB 메타데이터는 원자적 저장 RPC로 변경하고, upload 전에 durable cleanup intent를 남깁니다. 응답 timeout은 같은 request ID로 결과를 다시 확인합니다.
선택 이유
Storage와 Postgres는 하나의 트랜잭션이 아닙니다. “응답 실패”가 “DB commit 실패”와 같다고 가정할 수 없습니다. 참조 채택 여부를 확인한 뒤 정리해야 이미 저장된 파일을 보호할 수 있습니다. 버전별 URL은 브라우저·CDN의 옛 이미지 잔존도 줄입니다.
감수한 조건
미참조 파일이 일시적으로 남을 수 있어 정리 의도·재시도·참조 재검증이 필요합니다. 실제 복원 시험 중 발견한 레거시 avatar 경로의 정리 문제도 후속 수정에 반영했습니다.
구현과 당시 복원 검증

2026-09-13, 격리된 로컬 환경에 실제 백업 객체 2개와 합성 DB 메타데이터를 복원했습니다. 객체 해시·앱의 이미지 디코드·직접 업로드 거부·계정 삭제 후 파일 정리를 확인했습니다. 전체 Auth·운영 DB·트래픽 전환을 묶은 DR 시험은 수행하지 않았습니다.

cleanup intent · 프로필 저장·결과 재확인 · 레거시 정리 수정 · PR #41

05 / EXTERNAL DELIVERY

외부 전송은 작업을 먼저 남기고 처리했습니다.

필요했던 것
Discord 환영 DM 전송 중 프로세스가 종료되거나 외부 응답이 유실돼도 처리할 작업과 상태가 남아 있어야 합니다. 앱 내부 DM과는 별개의 외부 전송 경로입니다.
선택한 설계
서명 이벤트를 확인하고 DB outbox enqueue를 commit한 뒤 ACK합니다. dispatcher lease로 처리 주체를 제한하고, 고유 이벤트 키·message nonce와 retry/backoff를 사용합니다. 전송 결과가 불확실해진 작업은 자동 재발송을 멈추도록 했습니다.
선택 이유
외부 Discord 전송과 DB의 완료 표시를 같은 트랜잭션으로 확정할 수 없습니다. DB에 할 일을 먼저 남겨 후속 worker가 복구하게 하고, 도착 여부가 불명확한 전송은 중복 방지를 우선합니다.
감수한 조건
이 선택은 일부 환영 DM이 미발송 상태로 남을 가능성을 받아들입니다. 중복과 누락을 모두 제거하는 exactly-once 보장이 아니며, 불확실한 작업에는 별도 확인이 필요합니다.
outbox·복구 worker 근거

최초 enqueue·lease 구현과 후속 PR #29의 불확실한 전달 처리 정책을 함께 봅니다. Discord 앱 설치 이벤트와 웹사이트 OAuth 가입은 다른 사건이므로 한 경로의 수신 성공을 다른 경로의 검증으로 합치지 않습니다.

이벤트 수신 · outbox 처리 · 불확실한 전달 처리 · 후속 개선 · PR #29

06 / OBSERVABILITY & RECOVERY

개인정보 없이 진단하고, 복구는 실제 결과로 확인했습니다.

필요했던 것
개인 메시지·토큰·요청 원문을 남기지 않으면서도 오류가 발생한 기능과 근거를 구분하고, 정기 작업과 백업이 실제로 실행되는지 확인해야 했습니다.
선택한 설계
Sentry에서 허용된 구조적 코드·고정 오류명·상태·기능으로 분류하고 원문 필터를 유지합니다. 정기 cleanup 실행과 health 확인은 별도 workflow로 나눴습니다. Storage 백업은 매니페스트·해시와 복원 후 앱 표시까지 확인합니다.
선택 이유
원문을 모두 제거하면 기능 구분이 어려워지므로 제한된 분류 정보가 필요했습니다. 근거가 없으면 unknown으로 남깁니다. 예약 설정이나 백업 파일의 존재만으로 다음 실행과 실제 복원을 보장할 수 없어 각각의 결과를 확인했습니다.
당시 검증과 후속 기록

2026-09-20 PR #49에는 305개 회귀검사·typecheck·CI 통과와 서로 다른 오류 기능이 분리 수신된 운영 기록이 있습니다. source-map 업로드와 실제 앱 파일·행 복원도 별도 완료 조건으로 다뤘습니다.

검증 기록은 오류 분류·이벤트 수신과 avatars·profile-backgrounds의 부분 복원을 대상으로 합니다.

오류 분류 설계 · PR #49 · 후속 부분 복원 결과 · 2026-09-13

쿼리, 인증 왕복, 반복 집계를 나누어 줄였습니다.

부하 개선은 검색 RPC 하나에서 끝나지 않았습니다. 초기 시스템 설계 감사부터 실제 앱 계측·인증 왕복 개선·분산 시험으로 이어졌고, 후속 구현에서는 요청 gate와 상태 복구를 보강했습니다.

  1. 실제 경로 계측: Data API 직접 호출과 앱의 .data 요청을 구분하고, 인증·DB RPC를 Server-Timing으로 나눴습니다. Vercel Function은 DB와 가까운 서울 리전으로 맞췄습니다.
  2. 검색 작업량 축소: 50,000개 합성 프로필의 특정 문자열 검색에서 순차 스캔 계획 약 83.1ms를 관측한 뒤, 검색식과 GIN trigram 인덱스를 맞춘 실행은 2.546ms였습니다. 로컬 실행 계획 비교이며 전체 검색의 개선율은 아닙니다.
  3. 인증 왕복 축소: Navigation 읽기는 공유 claims verifier로 JWT를 검증하고 JWKS 캐시를 재사용했습니다. 6개 세션을 공유한 50 VU Preview 비교에서 p99는 1,253ms → 492.1ms로 관측됐습니다. 짧은 실행의 비교이며 통제된 A/B는 아닙니다.
  4. 반복 집계와 폭주 제어: Navigation 조회를 RPC로 모으고 unread를 저장형 카운터로 관리했습니다. 후속 검색 gate는 프로세스별 실행 수·대기열을 제한합니다. 각 개선은 적용 시점과 범위가 다릅니다.

이후 고유 인증 세션 100·250·500개로 각각 약 30분의 Navigation 분산 시험도 진행했습니다. 500 세션에서 900,092요청의 오류는 0이었지만 worst shard p99 765.21ms로 750ms 목표를 넘었습니다. 공개 p95 목표도 충족하지 못해 전체 SLO 통과로 표현하지 않습니다.

개선 전후와 100·250·500 세션 측정 기록 읽기 ↗

설계 의도와 확인한 결과를 연결합니다.

검색 정확성·부하

2026-08-15, 합성 프로필 10,000개의 warm local Data API → Postgres 구간. 최고 200개 동시 요청 × 5회, 누적 1,000요청에서 오류율 0%, p95 725.95ms, p99 825.68ms를 기록했습니다. 필터·반환 개수·순서도 확인했습니다.

이 시험의 대상 버전은 b1454f1입니다. 후속 요청 gate나 현재 production E2E의 성능으로 해석하지 않습니다.

DM 상태 복구·권한

격리된 로컬 Chromium에서 읽음 상태·백그라운드·이벤트 유실 복구와 권한 경계를 검증했습니다. headless의 visibility·focus는 테스트에서 강제했습니다. 별도 staging에서는 RPC 부하와 DB 계약을 확인했습니다.

파일 저장·부분 복원

실제 백업 파일과 합성 DB 메타데이터를 복원해 해시·앱 표시·업로드 권한·삭제 후 정리를 확인한 후속 기록이 있습니다. 외부 OAuth·전체 Auth·DNS 전환을 포함한 종합 DR은 이 시험의 범위 밖입니다.

하나의 문제를 더 깊게.

코드와 기록으로 이어지는 근거.

기존 전체 시스템 설명서와 후속 검증 기록을 바탕으로, 공개 가능한 설계와 선택 이유를 추렸습니다. 일반 구조·후속 구현은 f7cb415, 검색 측정은 b1454f1을 기준으로 구분했습니다.

2026년 8~9월의 코드·검증 기록을 대조한 문서입니다. 페이지 작성 과정에서 운영 DB 조회나 새 부하 시험을 수행하지 않았습니다.