본문으로 건너뛰기
오세두
프로젝트 목록

Talk532

서버 없이 돌아가는 실시간 채팅

연도
2026년
역할
개인 프로젝트 — 설계부터 운영까지
팀 규모
1명
기술 스택
  • Next.js
  • React
  • TypeScript
  • Supabase
  • PostgreSQL RLS
  • Web Push

친구들끼리 쓸 채팅방이 필요했습니다. 조건은 두 개였습니다 — 서버 비용을 내지 않는 것, 그리고 앱을 설치하지 않고 코드 여섯 글자로 들어올 수 있는 것.

그래서 서버를 두지 않았습니다. Vercel은 정적 파일만 서빙하고, 브라우저가 Supabase에 WebSocket을 직접 붙입니다. 로그인, 방 생성, 메시지 전송, 읽음 표시가 전부 클라이언트에서 Postgres로 갑니다. API 라우트도, 서버 액션도 없습니다. 이 구조를 고르는 순간 하나가 결정됩니다. 방어선이 서버 코드가 아니라 SQL이 됩니다.

anon key는 노출되는 게 정상입니다#

브라우저에 담긴 키로 DB에 바로 붙기 때문에, 요청을 검사할 서버가 없습니다. 정책이 곧 API입니다.

-- 참여 중인 방만 보인다. 코드로 방을 찾는 일은 join_room() 함수만 할 수 있다 —
-- 그래야 남의 방 목록을 긁어갈 수 없다.
create policy rooms_select_member on public.rooms
  for select to authenticated
  using (public.is_room_member(id));

is_room_member가 함수인 데는 이유가 있습니다. 정책 안에서 room_members를 그냥 조회하면 room_members 자신의 정책이 다시 평가되면서 무한 재귀로 떨어집니다. security definer 함수로 감싸 RLS를 한 겹 벗기는 것이 표준 해법인데, 문서를 읽고 알게 된 게 아니라 재귀 에러를 받고 나서 알았습니다.

메시지의 user_id와 작성자 이메일도 클라이언트가 채우지 않습니다. 트리거가 auth.uid()로 덮어씁니다. 클라이언트가 보내는 값 중 신뢰하는 것은 본문과 방 id뿐입니다.

링크가 아니라 코드로 로그인하기#

비밀번호를 두지 않고 이메일로 인증 코드를 받는 방식을 골랐습니다. 가입과 재로그인이 같은 흐름이 되고, 잊어버릴 비밀번호가 없습니다.

여기서 가장 오래 막힌 건 코드가 아니라 메일이었습니다. Supabase에서 링크로 갈지 코드로 갈지는 호출하는 메서드가 아니라 메일 템플릿이 결정합니다. signInWithOtp를 불러도 템플릿에 {{ .ConfirmationURL }}이 남아 있으면 링크가 나갑니다. 게다가 템플릿이 두 개입니다 — 신규 가입자는 Confirm signup, 이미 인증된 사람은 Magic Link를 탑니다.

그래서 이런 일이 벌어집니다. 내 계정으로 하는 테스트는 100% 통과합니다. 나는 이미 인증된 사용자라 Magic Link 경로를 타고, 그 템플릿은 내가 고쳤으니까요. 그런데 처음 가입하는 사람만 코드가 없는 링크 메일을 받고 코드 입력창 앞에서 멈춥니다. 친구에게 그 화면을 보여준 뒤에 알았습니다. 지금은 "한 번도 쓴 적 없는 주소로 가입해 볼 것"이 검증 절차의 별표 항목입니다.

내장 메일로는 서비스가 성립하지 않는다는 것도 여기서 배웠습니다. Supabase 내장 메일은 프로젝트 팀 멤버가 아닌 주소로는 발송을 거부하고, 한도도 프로젝트 전체 시간당 두 통입니다. 친구를 초대하는 앱에서 이 증상은 "메일이 안 온다"는 버그와 구분되지 않습니다. 커스텀 SMTP는 선택이 아니라 전제였습니다.

알림을 붙이기 전에 주소를 확정해야 했습니다#

처음에는 ohsedu.site/talk532 아래에 basePath로 얹혀 있었습니다. 웹 푸시를 붙이면서 chat.ohsedu.site로 옮겼습니다. 경로가 지저분해서가 아닙니다.

홈 화면 앱과 푸시 구독은 오리진에 묶입니다. 구독자가 생긴 뒤에 오리진을 바꾸면 DB에 저장된 endpoint가 전부 무효가 되고, 홈 화면 아이콘은 죽은 주소를 가리키고, iOS 사용자는 앱을 지우고 다시 추가한 뒤 알림을 다시 켜야 합니다. 사용자에게 사과해야 하는 종류의 비용이라, 순서를 뒤집어 알림보다 주소를 먼저 정했습니다. 덤으로 basePath와 포트폴리오가 앞단에서 프록시하는 Multi-Zones 계획이 통째로 사라졌고, next.config.mjs에는 서비스워커 캐시 헤더 한 줄만 남았습니다.

서버가 없는데 푸시는 누가 보내나#

Supabase Edge Function이 보냅니다. 메시지를 보낸 브라우저가 함수를 호출하면, 함수가 같은 방의 다른 참여자 구독을 모아 각자의 공개키로 본문을 암호화해 내보냅니다. 접속 중인 사람은 Realtime이 이미 처리하니까, 푸시는 꺼져 있는 기기 몫입니다.

한 가지는 일부러 보내지 않습니다. 입장·퇴장 안내입니다. 사람이 드나들 때마다 진동하면 사용자는 알림 자체를 꺼버리고, 한 번 꺼진 알림은 다시 켜지지 않습니다.

iOS는 따로 다뤄야 했습니다. 사파리 탭에서는 권한 요청 창조차 뜨지 않습니다 — 16.4 이상에서 홈 화면에 추가한 뒤 그 아이콘으로 실행해야 합니다. 그래서 앱이 실행 환경을 먼저 판별하고, 사파리 탭이면 알림 버튼 대신 "홈 화면에 추가하세요" 안내를 보여줍니다. 눌러도 아무 일이 없는 버튼을 보여주는 것보다는 낫습니다.

대신 치른 비용#

마이그레이션 순서가 계약이 됩니다. 스키마는 24개의 SQL 파일로 나뉘어 있고, 뒤의 파일이 앞의 결과에 기댑니다. 알림 한 줄에 실을 값을 계산하는 파일은 구독·1:1 여부·이전 대화 시점·마지막으로 본 시각·첨부·아바타를 전부 참조하기 때문에 언제나 마지막이어야 합니다. 서버가 없으니 마이그레이션 러너도 없고, 그 순서는 README의 표가 지키고 있습니다. 아직 자동화되지 않은 채로 남아 있는 자리입니다.

질의를 받치는 인덱스는 직접 봐야 합니다. 방을 열 때 도는 질의는 created_at이 아니라 id로 자르고 정렬합니다. 처음 만든 인덱스는 (room_id, created_at)이라 그 정렬을 받쳐주지 못했고, 방의 메시지를 통째로 읽어 정렬한 뒤 마흔 줄을 떼어 오고 있었습니다. 대화가 쌓일수록 방이 느리게 열리는 자리가 거기였습니다. (room_id, id) 한 벌이 양쪽 방향을 다 받칩니다 — btree는 거꾸로도 훑을 수 있으니 desc를 적을 필요가 없습니다.

클라이언트에 로직이 없으면 타입도 없습니다. 권한 판단이 SQL 안에 있으니, 정책을 고쳤을 때 깨지는 화면을 타입 검사가 알려주지 않습니다. RPC의 반환 모양이 바뀌면 런타임에서 만나게 됩니다.

다시 한다면#

무료 티어의 한도는 코드가 아니라 사람의 시간으로 청구됩니다. 메일 발송 한도는 Supabase 프로젝트 단위로 공유되기 때문에, 로컬에서 로그인을 몇 번 시험하면 그 시간의 배포본 테스트가 막힙니다. 알아채기 전까지는 "배포본에서만 안 되는" 문제로 보였습니다.

그리고 템플릿 두 개를 함께 고쳐야 한다는 사실을 코드로 붙잡을 방법이 없었습니다. 지금은 문서의 별표와 새 이메일 별칭으로 지키고 있는데, 이런 것은 결국 잊습니다. 가입 경로를 한 번 자동으로 태워 보는 검사가 있어야 할 자리입니다.