데이터 모델 명세

Vidding 데이터 모델 명세

새 Supabase 프로젝트(vidding-re)에 처음부터 구축하는 스키마다. 기존 서비스의 스키마를 이어받지 않으며, 데이터 이관도 없다. 기준 문서: PRD · 기능 스펙


1. 설계 원칙

#원칙이유
P1사용자 유형 컬럼을 만들지 않는다권한은 관계로 판정한다 (00-관계-판정)
P2관계는 저장하지 않고 도출한다주최자·참여자는 기존 컬럼으로 계산된다. 별도 필드를 두지 않는다
P3다대다 관계는 조인 테이블로 만든다배열 컬럼은 동시 수정 시 서로 덮어쓴다 (§7.1)
P4원장은 append-only로 쌓는다포인트 이력을 수정·삭제하지 않는다
P5파생 상태는 저장하지 않는다'마감 임박'은 마감 시각으로 계산한다
P6권한은 RLS로 강제한다클라이언트가 아니라 DB가 최종 판정한다 (§6)

2. 전체 구조

users ─┬─< addresses
       ├─< auctions ─┬─< episodes ──< episode_likes
       │             ├─< auction_favorites
       │             └─── chat_rooms ──< messages
       ├─< points
       └─< notifications
테이블역할
users계정
addresses배송지
auctions경매
episodes사연 (입찰)
episode_likes공감
auction_favorites
points포인트 원장
chat_rooms채팅방
messages메시지
notifications알림

3. 테이블 정의

3.1 users

컬럼타입제약설명
iduuidPK, FK → auth.users.id인증 사용자 ID
emailtextNOT NULL, UNIQUE
nick_nametextNOT NULL
avatar_urltextNULL소셜 프로필 이미지
point_balanceintegerNOT NULL, DEFAULT 0, CHECK ≥ 0현재 보유 포인트
created_attimestamptzNOT NULL, DEFAULT now()
updated_attimestamptzNULL

role 컬럼이 없다. 이 스키마에는 사용자 유형 개념이 존재하지 않는다 (P1).

  • point_balance가 잔액의 단일 출처다. points 테이블은 이력이며, 잔액을 합산해 구하지 않는다
  • CHECK ≥ 0 으로 잔액이 음수가 되는 것을 DB에서 막는다

3.2 addresses

컬럼타입제약설명
iduuidPK
user_iduuidNOT NULL, FK → users.id ON DELETE CASCADE
recipienttextNOT NULL받는 사람
phonetextNOT NULL연락처
zipcodetextNOT NULL우편번호 (주소 검색으로만 입력)
address1textNOT NULL기본 주소 (주소 검색으로만 입력)
address2textNULL상세 주소
created_attimestamptzNOT NULL, DEFAULT now()

제약UNIQUE (user_id) : 사용자당 배송지 1개

목록 화면도 기본 배송지 개념도 없다. 하나뿐이므로 구분할 대상이 없다 (F12 3.3).

3.3 auctions

컬럼타입제약설명
iduuidPK
user_iduuidNOT NULL, FK → users.id주최자
address_iduuidNOT NULL, FK → addresses.id발송지 스냅샷 참조
titletextNOT NULL
descriptiontextNOT NULL사연 요청 설명
image_urlstext[]NOT NULL, CHECK 길이 1~3이미지 (순서 있는 값 목록)
end_attimestamptzNOT NULL마감 시각
statustextNOT NULL, DEFAULT 'OPEN', CHECK IN ('OPEN','CLOSED')
winning_episode_iduuidNULL, FK → episodes.id낙찰 사연
closed_attimestamptzNULL마감 처리 완료 시각
created_attimestamptzNOT NULL, DEFAULT now()
updated_attimestamptzNULL

상태를 2개만 둔다OPEN / CLOSED. '마감 임박'은 end_at으로 계산하는 파생 상태이므로 저장하지 않는다 (P5).

입찰 포인트 컬럼이 없다. 입찰 단계는 전 서비스 공통 상수이므로 경매마다 저장하지 않는다.

1,000 → 1,500 → 2,000 → 2,500 → 3,000   (+500 단위, 상한 3,000)

주최자가 시작·최대 포인트를 정하지 않는다. 입력 항목이 줄고, 검증 규칙이 사라지고, 교환 비율이 항상 명확해진다. 낙찰은 어차피 공감이 결정하므로 잃는 것이 없다.

image_urls만 배열을 유지한다. 순서가 의미를 갖고 동시 수정 주체가 주최자 한 명이라 P3의 문제가 발생하지 않는다.

3.4 episodes

컬럼타입제약설명
iduuidPK
auction_iduuidNOT NULL, FK → auctions.id ON DELETE CASCADE
user_iduuidNOT NULL, FK → users.id작성자
titletextNOT NULL, CHECK 길이 2~50
contenttextNOT NULL, CHECK 길이 5~1000
bid_amountintegerNOT NULL, DEFAULT 0, CHECK ≥ 0본인이 건 포인트 누적액
created_attimestamptzNOT NULL, DEFAULT now()동점 판정 기준
updated_attimestamptzNULL

제약

제약내용
UNIQUE (auction_id, user_id)한 경매에 사연 1개 (F3)
CHECK bid_amount IN (0, 1000, 1500, 2000, 2500, 3000)고정 입찰 단계만 허용

주최자 배제는 애플리케이션과 RLS에서 처리한다 (§6).

created_at낙찰 동점 기준이다 (F5 3.2.1). 수정해도 갱신하지 않는다.

3.5 episode_likes

컬럼타입제약설명
iduuidPK
episode_iduuidNOT NULL, FK → episodes.id ON DELETE CASCADE
user_iduuidNOT NULL, FK → users.id공감한 사람
weightintegerNOT NULL, CHECK IN (10, 50)부여 시점의 가중치
created_attimestamptzNOT NULL, DEFAULT now()

제약UNIQUE (episode_id, user_id) : 한 사람이 한 사연에 한 번만

weight를 저장하는 이유

조건
50공감한 사람이 해당 경매의 주최자
10그 외

가중치를 코드 상수로만 두면, 나중에 값이 바뀌었을 때 공감 해제 시 다른 값이 차감된다. 부여 시점 값을 함께 저장해 F4 완료 조건 3(적용된 가중치와 같은 값 차감) 을 구조적으로 보장한다.

3.6 auction_favorites

컬럼타입제약
iduuidPK
auction_iduuidNOT NULL, FK → auctions.id ON DELETE CASCADE
user_iduuidNOT NULL, FK → users.id
created_attimestamptzNOT NULL, DEFAULT now()

제약UNIQUE (auction_id, user_id)

찜은 개인 북마크다. 찜 수를 공개하지 않으며 정렬·순위에 쓰지 않는다 (F7). 인기순 정렬 기준은 모인 사연 수다.

3.7 points

포인트 원장(ledger) 이다. 한 번 쌓인 행은 수정·삭제하지 않는다 (P4).

컬럼타입제약설명
iduuidPK
user_iduuidNOT NULL, FK → users.id
amountintegerNOT NULL, CHECK ≠ 0적립 + / 차감
balance_afterintegerNOT NULL처리 후 잔액 (F8 표시용)
typetextNOT NULL아래 표 참조
auction_iduuidNULL, FK → auctions.id관련 경매
episode_iduuidNULL, FK → episodes.id관련 사연
descriptiontextNOT NULL사용자에게 보일 사유
created_attimestamptzNOT NULL, DEFAULT now()

type

부호대상발생 시점
SIGNUP_BONUS+신규 가입자가입 시 5,000 P 1회 지급
BID참여자사연에 포인트를 걸 때 (F3)
BID_REFUND_LOST+미낙찰 참여자마감 시 반환 (F5)
BID_REFUND_VOID+참여자 전원유찰로 인한 반환 (F5)
BID_REFUND_CANCEL+참여자마감 전 사연 삭제로 인한 반환 (F3)
WIN_TRANSFER+주최자낙찰 확정 시 낙찰자가 건 포인트를 수취 (F5)

낙찰자에게는 정산 행이 추가되지 않는다. 입찰 시점의 BID 차감이 그대로 확정된다.

amountusers.point_balance같은 트랜잭션에서 함께 갱신한다 (§4).

포인트 순환 구조

물건을 나누면 포인트를 얻고, 물건을 받으면 포인트를 쓴다.

[받는 쪽]  사연 낙찰  →  건 포인트가 차감 확정
                            ↓  이전
[주는 쪽]  경매 낙찰  →  낙찰자가 건 포인트를 수취
항목내용
이전 금액낙찰 사연의 bid_amount (본인이 직접 건 포인트)
공감 가중치이전 대상이 아니다. 실제 포인트가 아니라 점수이기 때문이다 (F4)
유찰이전 없음. 참여자 전원에게 반환한다

총량이 보존된다. 낙찰 순환에서는 포인트가 생기거나 사라지지 않고 이동만 한다. 신규 발행은 SIGNUP_BONUS 하나뿐이다.

자전거래가 구조적으로 불가능하다. 주최자는 자기 경매에 사연을 쓸 수 없으므로(F3), 자기 경매에서 자기에게 포인트를 보낼 수 없다.

3.8 chat_rooms

컬럼타입제약설명
iduuidPK
auction_iduuidNOT NULL, UNIQUE, FK → auctions.id경매당 채팅방 1개
created_attimestamptzNOT NULL, DEFAULT now()

참여자 컬럼이 없다.

채팅은 낙찰 이후 주최자와 낙찰자 사이에서만 열린다 (F6). 두 사람 모두 경매에서 도출된다.

주최자  = auctions.user_id
낙찰자  = episodes.user_id  (auctions.winning_episode_id 가 가리키는 사연)

기존 스키마의 seller_id / buyer_id에 해당하는 컬럼이 필요 없다. 참여자를 별도로 저장하면 경매 데이터와 어긋날 수 있으므로 도출한다 (P2).

채팅방은 낙찰 확정 시에만 생성한다. 유찰된 경매에는 만들지 않는다.

3.9 messages

컬럼타입제약
iduuidPK
chat_room_iduuidNOT NULL, FK → chat_rooms.id ON DELETE CASCADE
sender_iduuidNOT NULL, FK → users.id
contenttextNOT NULL, CHECK 공백 제외 길이 ≥ 1
read_attimestamptzNULL
created_attimestamptzNOT NULL, DEFAULT now()

read_atNULL이면 읽지 않은 메시지다. 별도 플래그를 두지 않는다.

3.10 notifications

컬럼타입제약설명
iduuidPK
user_iduuidNOT NULL, FK → users.id ON DELETE CASCADE수신자
typetextNOT NULL아래 표 참조
titletextNOT NULL
bodytextNOT NULL
auction_iduuidNULL, FK → auctions.id이동 대상
chat_room_iduuidNULL, FK → chat_rooms.id이동 대상
read_attimestamptzNULL
created_attimestamptzNOT NULL, DEFAULT now()

type

발생
AUCTION_ENDING_SOON_3D마감 3일 전 (§11.3)
AUCTION_ENDING_SOON_1D마감 1일 전
AUCTION_ENDING_SOON_1H마감 1시간 전
EPISODE_CREATED사연 등록
AUCTION_RESULT마감 처리 완료
CHAT_MESSAGE메시지 도착

마감 임박을 단계별 type으로 나눈 이유가 아래 인덱스다. 하나의 값으로 두면 (사용자, 종류, 경매)당 1건 제한에 걸려 단계마다 보낼 수 없다. AUCTION_ENDING_SOON_% 접두어로 임박 알림 여부를 판정한다 (F9 3.4 경고색).

중복 방지 — 같은 사건에 대한 중복 발송을 막기 위해 부분 유니크 인덱스를 둔다 (F9).

UNIQUE (user_id, type, auction_id)
  WHERE type IN ('AUCTION_ENDING_SOON_3D','AUCTION_ENDING_SOON_1D',
                 'AUCTION_ENDING_SOON_1H','AUCTION_RESULT')

CHAT_MESSAGE는 반복 발생하므로 이 제약에서 제외한다.

단계마다 1건이다. 한 경매에 대해 한 사람이 같은 단계의 알림을 두 번 받지 않는다. 크론이 5분마다 돌아도 이 인덱스가 중복을 막으므로, 반복 실행이 곧 재시도가 된다.

푸시 알림을 만들지 않는다. 알림은 서비스 안에서만 확인한다 (F9).


4. 트랜잭션이 필요한 처리

다음 처리는 여러 테이블을 원자적으로 바꾼다. 각각 DB 함수(RPC)로 구현한다.

4.1 포인트 입찰 place_bid(episode_id, new_amount)

순서처리
1경매가 OPEN이고 마감 시각 이전인지 확인
2요청자가 사연 작성자인지 확인
3new_amount고정 단계 값인지 확인 (1000 / 1500 / 2000 / 2500 / 3000)
4new_amount > episodes.bid_amount 확인 — 올리기만 가능
5users.point_balance ≥ (new_amount − bid_amount) 확인
6episodes.bid_amount = new_amount
7users.point_balance −= 차액
8pointsBID 행 삽입 (차액만큼)

하나라도 실패하면 전부 롤백한다.

4.2 마감 처리 close_auction(auction_id)

순서처리
1status = 'CLOSED', closed_at 기록
2최종 점수 1위 사연을 winning_episode_id에 기록 (동점 시 created_at 빠른 쪽)
3미낙찰 참여자에게 BID_REFUND_LOST 반환 + point_balance 증가
4주최자에게 WIN_TRANSFER 지급 — 낙찰 사연의 bid_amount 만큼 + point_balance 증가
5사연이 0건이면 유찰 — winning_episode_idNULL, 참여자 전원 BID_REFUND_VOID 반환, 4번 생략
6낙찰이 있으면 chat_rooms 생성
7notifications 삽입

1~6은 한 트랜잭션이다. 마감됐는데 낙찰자가 없는 중간 상태를 만들지 않는다 (F5). 7(알림)은 실패해도 롤백하지 않는다 (F5·F9).

포인트 총량 검증 — 3번(반환)과 4번(이전)의 합이 해당 경매의 BID 차감 총액과 정확히 일치해야 한다. 트랜잭션 종료 시 이 등식이 깨지면 롤백한다.

4.3 사연 삭제 delete_episode(episode_id)

조건처리
경매가 OPENBID_REFUND_CANCELbid_amount 전액 반환 후 삭제
경매가 CLOSED + 미낙찰반환 없이 삭제 (마감 시 이미 반환됨)
경매가 CLOSED + 낙찰거부 (F3 3.4)

4.4 공감 toggle_episode_like(episode_id)

순서처리
1자기 사연이 아닌지, 경매가 OPEN인지 확인
2요청자가 해당 경매의 주최자면 weight = 50, 아니면 10
3행이 없으면 삽입, 있으면 삭제

점수를 직접 더하거나 빼지 않는다. 최종 점수는 §5의 뷰에서 집계하므로 연타·동시 조작으로 값이 어긋날 수 없다 (F4 완료 조건 6).


5. 뷰

5.1 v_episode_scores — 사연 최종 점수

episode_id
auction_id
user_id
bid_amount              -- 본인이 건 포인트
like_weight_sum         -- 받은 공감 가중치 합
total_score             -- bid_amount + like_weight_sum
like_count
created_at              -- 동점 판정용

낙찰자 선정과 랭킹 표시 모두 이 뷰를 쓴다.

ORDER BY total_score DESC, created_at ASC

F5 3.2.1의 동점 기준이 이 정렬 하나로 표현된다.

5.2 v_auction_summary — 목록·카드용

auction_id, title, thumbnail, end_at, status
episode_count           -- 모인 사연 수 (인기순 기준)
top_score               -- v_episode_scores 최고점

정렬 기준을 모두 이 뷰에서 처리한다 (F2).

정렬기준
마감 임박순 (기본)end_at ASC
인기순episode_count DESC
최신순created_at DESC

찜 수는 이 뷰에 포함하지 않는다. 공개 지표가 아니다.


6. RLS 정책

관계 판정을 DB에서 강제한다 (P6). 00-관계-판정 4절의 "클라이언트 판정과 서버 판정이 다르면 서버를 최종으로 한다"가 여기서 보장된다.

테이블조회생성수정·삭제
auctions전체 공개로그인 + 본인 address_id 보유user_id = auth.uid() AND 사연 0건
episodes전체 공개로그인 + 경매 주최자가 아님 + 경매 OPEN작성자 본인 (삭제는 §4.3)
episode_likes전체 공개로그인 + 자기 사연 아님 + 경매 OPEN본인 행만 삭제
auction_favorites본인 것만로그인 + 경매 주최자가 아님본인 행만 삭제
points본인 것만RPC만불가 (append-only)
chat_rooms주최자 또는 낙찰자만RPC만불가
messages해당 방 참여자만해당 방 참여자만sender_id = auth.uid()
notifications본인 것만서버만본인 읽음 처리만
addresses본인 것만본인본인

핵심 정책 두 가지

-- 주최자는 자기 경매에 사연을 쓸 수 없다 (F3)
episodes INSERT:
  auth.uid() <> (SELECT user_id FROM auctions WHERE id = auction_id)

-- 채팅은 주최자와 낙찰자만 (F6)
chat_rooms SELECT:
  auth.uid() IN (
    (SELECT user_id FROM auctions WHERE id = auction_id),
    (SELECT e.user_id FROM auctions a JOIN episodes e ON e.id = a.winning_episode_id
      WHERE a.id = auction_id)
  )

7. 기존 스키마 대비 주요 변경

7.1 배열 → 조인 테이블 (가장 중요한 변경)

기존신규
auctions.favorites text[]auction_favorites 테이블
episodes.likes text[]episode_likes 테이블

왜 바꾸는가

배열은 통째로 읽어서 통째로 쓴다. 두 사람이 동시에 공감을 누르면 나중 요청이 앞선 요청을 덮어쓴다.

A 읽기 [x]  →  A 쓰기 [x, A]
B 읽기 [x]  →  B 쓰기 [x, B]     ← A의 공감이 사라진다

기존 서비스가 "클릭 시점에 최신 값을 다시 조회한다"는 우회 처리를 두었던 이유가 이것이다. 조인 테이블 + UNIQUE 제약으로 바꾸면 삽입·삭제만으로 끝나고 경합 자체가 사라진다.

F4·F7의 "연타·동시 조작에도 값이 어긋나지 않는다" 완료 조건이 구조로 해결된다.

7.2 그 외

항목기존신규이유
users.roletext NOT NULL없음사용자 유형 개념 삭제 (A1)
경매 상태ongoing/urgent/OPEN/CLOSED 혼재OPEN/CLOSED마감 임박은 파생 상태 (P5)
채팅 참여자seller_id, buyer_id없음 — 경매에서 도출낙찰자 한정으로 단순화 (F6)
공감 가중치코드 상수episode_likes.weight 저장해제 시 정확한 차감 보장
사연 점수episodes.bid_point에 직접 가산bid_amount + 뷰 집계동시성·정합성
찜 수favorite_count 저장 + 인기순 기준비공개찜은 개인 북마크. 인기순은 사연 수 기준
잔액users.point + points.balance_after 이중users.point_balance 단일 출처불일치 방지
푸시 구독users.subscription JSON없음푸시 알림을 만들지 않는다 (F9)
알림테이블 없음notifications 신설알림 목록 필요 (F9)
사연 중복애플리케이션 검사UNIQUE (auction_id, user_id)DB 보장

8. 인덱스

테이블인덱스용도
auctions(status, end_at)마감 임박 정렬, 마감 대상 조회
auctions(user_id)내 경매 (F8)
episodesUNIQUE (auction_id, user_id)사연 1개 제한 + 관계 판정
episodes(auction_id, created_at)목록·동점 정렬
episodes(user_id)내 사연 (F8)
episode_likesUNIQUE (episode_id, user_id)중복 방지
auction_favoritesUNIQUE (auction_id, user_id) / (user_id)중복 방지 / 찜 목록
points(user_id, created_at DESC)포인트 내역
messages(chat_room_id, created_at)대화 조회
notifications(user_id, created_at DESC) / (user_id) WHERE read_at IS NULL목록 / 미읽음 배지

episodesUNIQUE (auction_id, user_id)는 제약이자 참여자 관계 판정용 인덱스다. 경매 상세에 들어갈 때마다 이 조회가 발생한다 (00-관계-판정 3.2).


9. 마감 처리 실행

auctions.end_at이 지난 경매를 주기적으로 찾아 close_auction()을 호출한다.

항목내용
실행Supabase 스케줄러 (pg_cron)
주기1분
대상status = 'OPEN' AND end_at <= now()
중복 방지status 변경이 트랜잭션 안에서 일어나므로 두 번 처리되지 않는다

화면에서는 end_at이 지나면 처리 완료 전이라도 마감 상태로 표시하고 참여 액션을 차단한다 (F5 예외 처리).


10. 포인트 경제 요약 — 확정

흐름금액시점
발행 — 가입 보너스+5,000 P가입 시 1회
차감 — 입찰 입찰액사연에 포인트를 걸 때
반환 — 미낙찰·유찰·사연 삭제+ 전액마감 시 또는 삭제 시
이전 — 낙찰낙찰자 주최자마감 시

가입 보너스 지급 방식

SIGNUP_BONUS가입 시점 트리거로 지급한다. 스케줄러(크론)로 처리하지 않는다.

크론은 실행 주기만큼 지급이 늦어져, 가입 직후 첫 참여 시 포인트가 없는 상태가 발생할 수 있다. 1회성 지급은 사건이 일어난 시점에 처리한다.

11. 고정 상수

코드가 아니라 여기가 단일 출처다. 값이 바뀌면 이 표를 먼저 고친다.

11.1 입찰

항목
시작1,000 P
단위+500 P
상한3,000 P
허용 값1000 · 1500 · 2000 · 2500 · 3000

11.2 공감 가중치

공감한 사람가중치
주최자50 P
그 외10 P

11.3 경매 기간

항목
선택지1일 · 3일 · 7일 (버튼 선택)
기본값1일 — 회전이 빠른 쪽을 기본으로 둔다
마감 시각등록 시점 + 선택한 기간
마감 임박 알림기간에 비례한 3단계 (아래)

마감 임박 알림 — 단계

단계는 3일 · 1일 · 1시간 셋으로 고정이다. 경매마다 분기하지 않고, 경매 전체 기간보다 짧은 단계만 보낸다는 규칙 하나로 결정된다.

등록 기간3일 전1일 전1시간 전합계
7일OOO3회
3일-OO2회
1일--O1회

기간과 같거나 긴 단계를 제외하는 이유 — 3일 경매에 '3일 전' 단계를 허용하면 등록되는 순간 발송된다. 임박이라는 말이 의미를 잃는다.

단계를 지난 뒤 참여한 사람에게는 그 단계를 보내지 않는다.

episodes.created_at < auctions.end_at - 단계

마감 임박 알림은 잊고 있을 수 있는 사람을 깨우는 것이다 (F9 2). 방금 사연을 쓴 사람은 마감이 임박한 것을 이미 알고 있으므로 깨울 대상이 아니다. 마감 30분 전에 참여한 사람에게 "1시간 전이에요"를 보내면 문구도 사실과 다르다.

episodes.created_at은 수정해도 갱신하지 않으므로(F5 3.2.1) 기준으로 삼기에 안전하다.

한 번에 한 단계만 보낸다. 정상 운영에서는 단계마다 스케줄러가 먼저 처리하므로 겹치지 않지만, 스케줄러가 며칠 멈췄다 재개하면 한 사람이 세 단계를 동시에 만족한다. 그때 "3일 전이에요"를 30분 전에 보내지 않도록 가장 급한 단계 하나만 고른다.

11.4 글자 수

항목범위
경매 제목1 ~ 50자
경매 설명1 ~ 500자
사연 제목2 ~ 50자
사연 내용5 ~ 1,000자

경매 쪽은 DB 가 btrim(...) > 0 만 강제한다. 상한은 애플리케이션이 지킨다. 사연과 달리 하한을 2자·5자로 올리지 않은 것은, 제목이 물건 이름 한 글자여도 말이 되기 때문이다 (F1 4.2 는 "길이 초과"만 요구한다).

11.5 이미지

항목
저장소Supabase Storage 공개 버킷
장수경매당 1 ~ 3장
용량장당 최대 5 MB
확장자jpg · png · webp

경매 삭제 시 해당 이미지도 함께 정리한다.

11.6 마감 처리

항목
실행pg_cron
주기1분