작업

생활 서비스 중개 플랫폼 · 2026

권한은 화면이 아니라 조회에서 갈린다

청소·수리처럼 사람이 방문하는 서비스는 요청 하나가 세 사람을 거칩니다. 고객이 부르고, 기사가 가고, 운영자가 배정과 정산을 맞춥니다.

세 역할이 같은 데이터를 보지만 볼 수 있는 것도, 할 수 있는 것도 다릅니다. 이 프로젝트는 그 차이를 어디에 두느냐에 관한 것입니다.

라이브 데모 열기

8 → 1

동시 수락 방어

이력도 정확히 한 건만 남는다

0개

화면에서만 가린 값

가릴 값은 조회 단계에서 빠진다

46건

자동 검증

단위 30 · 권한 9 · 라우팅 7

여덟 명이 같은 일감을 동시에 눌렀다

일감은 먼저 누른 기사가 가져갑니다. 흔한 구현은 '지금 미배정인지 확인하고, 맞으면 내 것으로 바꾸기'인데 이 두 문장 사이에 다른 기사가 끼어들 수 있습니다. 둘 다 확인을 통과하고 둘 다 자기 것으로 만듭니다.

확인과 변경을 한 문장으로 합쳤습니다. '미배정일 때만 나에게 배정하라'는 조건부 UPDATE를 보내고, 바뀐 행이 0이면 진 것으로 봅니다. 판정을 애플리케이션이 아니라 DB가 합니다.

이건 순수 함수 테스트로는 증명할 수 없습니다. 경쟁 상태가 없으니까요. 실제 DB에 여덟 명을 동시에 밀어넣어 정확히 한 명만 성공하고 이력도 한 건만 남는지 확인합니다.

// 확인하고 바꾸는 게 아니라, 조건을 걸고 바꾼다
const updated = await db.update(requests)
  .set({ status: "assigned", workerId: actor.id })
  .where(and(
    eq(requests.id, requestId),
    eq(requests.status, "requested"),
    isNull(requests.workerId),   // ← 아직 아무도 안 가져갔을 때만
  ))
  .returning({ id: requests.id });

// 0행이면 그 사이에 다른 기사가 가져간 것
if (updated.length === 0) {
  return { ok: false, reason: "이미 다른 기사가 수락한 요청입니다" };
}
조건부 UPDATE — 판정을 DB에 맡긴다

화면에서 가리는 건 가린 게 아니다

기사에게는 고객 결제액을 보여주지 않습니다. 수수료율이 드러나기 때문입니다. 고객에게는 기사 수령액을 보여주지 않습니다. 배정 전인 기사에게는 상세 주소와 연락처를 가립니다 — 수락도 안 한 사람에게 남의 집 주소를 알릴 이유가 없습니다.

이걸 화면에서 처리하면 API를 직접 호출하는 순간 전부 보입니다. 그래서 조회 함수가 역할에 따라 아예 다른 결과를 만듭니다. 볼 수 없는 건은 목록에서 빠지고, 가릴 값은 응답에서 사라집니다.

상세 화면도 목록과 같은 함수를 거칩니다. 상세만 따로 조회하면 목록에서 막은 것이 거기서 새어 나가기 때문입니다. 남의 요청 주소를 직접 치면 존재 여부조차 알리지 않고 404가 납니다.

배포한 뒤 실제 주소로도 확인했습니다. 기사 화면 HTML에 결제액 문자열이 없고 수령액만 있는지, 미배정 건의 주소·연락처가 없는지를 응답 본문에서 직접 찾습니다.

고객·기사·운영자는 같은 화면을 쓰지 않는다

처음에는 세 역할이 같은 목록 컴포넌트를 쓰고 필드만 달랐습니다. 권한은 맞게 동작했지만 세 화면이 똑같이 생겼습니다. 실제로는 하는 일이 다릅니다.

고객의 요청은 많아야 몇 건이고 궁금한 건 하나입니다 — 지금 어디까지 왔나. 그래서 목록이 아니라 배달 추적 같은 진행 타임라인입니다. 기사는 현장에서 한 손으로 씁니다. 시각을 왼쪽에 크게 두고 버튼을 키웠습니다. 운영자는 여러 건을 훑으며 이상한 것을 찾습니다. 카드가 아니라 열 개짜리 표입니다.

운영자 화면은 감싸는 틀부터 다릅니다. 가운데 정렬된 읽기 폭 대신 화면 전체를 쓰고, 상단 탭 대신 처리 대기 건수가 붙은 사이드바를 둡니다. 행간도 1.6이 아니라 1.45입니다 — 1.6은 읽는 화면의 값이고 운영자는 읽지 않고 훑습니다.

로딩 화면 하나가 404를 200으로 만들었다

목록에 스켈레톤을 넣으려고 최상위에 로딩 파일을 하나 뒀습니다. 화면은 더 좋아졌는데, 없는 요청을 열어도 404 대신 200이 나오고 비로그인 리다이렉트가 307 대신 200이 되었습니다.

스트리밍 때문입니다. 로딩 파일이 있으면 프레임워크가 껍데기를 즉시 내보내는데, 그 시점에 이미 헤더가 나가서 나중에 상태코드를 바꿀 수 없습니다.

브라우저에서는 멀쩡해 보입니다. 로그인 화면으로 잘 넘어가니까요. 눈으로는 절대 안 잡히고, 검색엔진과 모니터링에만 잘못 보입니다.

인증과 역할 라우팅을 렌더 이전 단계로 올려 제대로 된 상태코드가 나오게 하고, 스켈레톤은 404가 날 수 없는 경로에만 남겼습니다. 그리고 상태코드 7가지를 검사하는 스크립트를 검증 파이프라인에 넣었습니다. 이런 종류는 한 번 고치는 것보다 다시 못 생기게 하는 게 중요합니다.

이 설계의 한계

  • 결제가 붙어 있지 않습니다. 금액은 계산만 하고 실제 정산은 일어나지 않습니다
  • 등장하는 사람·주소·연락처·금액·평점은 전부 가상입니다. 리뷰를 받아 평점을 갱신하는 흐름은 없습니다
  • 기사 위치 추적과 실시간 알림이 없습니다. 상태 변경은 새로고침으로 반영됩니다
  • 데모라 로그인이 계정 선택으로 되어 있습니다. 실제 인증은 이 자리만 교체하면 됩니다

역할

기획 · 설계 · 개발 · 배포

스택

Next.js 16 · Drizzle · Supabase · PGlite · TypeScript

다음인테리어 견적 에이전트금액을 AI에게 계산시키지 않았다