데이터를 다룰 때 읽는 문서

데이터 구조

가장 두꺼운 문서입니다. 예외를 봐준 기록을 원본 위에 덮어쓰지 않고 따로 남기는 구조가 여기서 나왔습니다.

34,665자 · 시스템이 만든 그대로입니다

사람이 손댄 곳 2군데
  • · 문서와 화면 목록은 손대지 않았습니다. 이 사례에서 사람이 한 일은 수정 요청을 적어 보낸 것과, 검토가 되물은 것에 답한 것뿐입니다.
  • · 아래 ‘고쳐 가는 과정’의 요청문·검토문은 실제로 오간 원문입니다. 줄이거나 다듬지 않았습니다.

필라테스 공정 대기 예약 데이터 구조 문서

1. 설계 범위

이 문서는 기구 필라테스 스튜디오의 예약·취소·대기·회원권·출결 운영에 필요한 데이터를 정의한다.

핵심 기준은 다음과 같다.

  • 한 기구 필라테스 스튜디오는 자신의 회원, 수업, 예약·대기 기록만 관리한다. (FR-001)
  • 회원은 휴대폰 본인 확인 후 자신의 수업 예약, 대기 신청, 빈자리 수락만 처리한다. (FR-003, FR-005~FR-009, FR-014)
  • 대기 순번은 대기 신청 시각과 변경 불가능한 등록 순번으로 정한다. (FR-007)
  • 빈자리는 대기 순번이 빠른 회원에게 한 명씩 제안한다. (FR-011~FR-014)
  • 회원이 빈자리를 수락해도 원장 최종 예약 승인 전까지는 예약 확정이 아니다. (FR-015)
  • 회원권 결제와 충전은 서비스 밖에서 처리한다. 회원권의 등록·횟수 차감·복구·수정 이력만 관리한다. (FR-002, FR-010, FR-017)
  • 원본 데이터를 수정하거나 삭제하지 않고, 예외 복구와 오류 수정 기록을 추가로 남긴다. (FR-020, NFR-008)

2. 주요 데이터 표

표 이름사업 용어용도관련 요구사항
Studio기구 필라테스 스튜디오스튜디오별 데이터와 운영 정책의 기준FR-001, FR-004, FR-022
Owner원장원장 접근 권한과 처리자 기록FR-001, FR-010, FR-015, FR-020
Member회원예약·대기·회원권·출결 대상 회원FR-002, FR-003, FR-019
PhoneAuthChallenge휴대폰 본인 확인인증번호 발송·검증·재전송 제한 기록FR-003, NFR-005
MembershipPass회원권총 횟수, 잔여 횟수, 사용 기간 관리FR-002, FR-006, FR-017
MembershipHistory회원권 이력차감, 복구, 수동 조정의 원본 보존FR-010, FR-017, FR-020
Class수업날짜, 시간, 정원, 휴강, 예약·대기 가능 상태FR-004, FR-021
Reservation예약확정, 승인 대기, 취소 등 수업 자리 상태FR-006, FR-009, FR-015, FR-018
Cancellation취소취소 사유, 취소 시각, 무료 취소 여부FR-009, FR-010
WaitlistRequest대기 신청대기 신청 시각과 대기 순번의 기준FR-007, FR-008
VacancyOffer대기 제안빈자리 제안, 응답 마감, 수락·거절·만료 상태FR-011, FR-013, FR-014
VacancyAcceptance빈자리 수락회원의 수락 또는 거절 응답 기록FR-014
OwnerApproval원장 최종 예약 승인수락 후 승인·반려·좌석 보류 처리FR-015
SequentialMessage순차 문자 안내문자 수락 링크, 발송 결과, 실패·재시도 기록FR-012, NFR-010
AttendanceRecord출결 관리출석, 지각 출석, 노쇼, 보류 기록FR-018
ExceptionApproval예외 승인12시간 이내 취소 또는 노쇼의 차감·미차감 판단FR-010, FR-018, FR-019
ErrorCorrectionRecord오류 수정 기록원본 기록을 보존한 오류 수정·조정 사유FR-020
StudioPolicy운영 정책응답 시간, 무료 취소 기준 등 스튜디오별 설정FR-022
Subscription스튜디오용 월 구독료원장 월 구독 상태 및 결제 기록FR-023

3. 데이터 관계도

그림을 그리는 중…

Studio는 모든 운영 데이터의 분리 기준이다. 원장과 회원은 자기 스튜디오 데이터에만 접근한다.

Reservation은 수업 참여 상태의 중심 기록이며, 취소·출결·회원권 이력·대기 수락 승인 흐름이 예약에 연결된다.


4. 주요 표별 필드와 생명주기

4.1 Studio — 기구 필라테스 스튜디오

필드설명필수
id스튜디오 식별값필수
name기구 필라테스 스튜디오 이름필수
timezone수업·취소·응답 마감 계산 기준 시간대필수
status사용 중, 이용 중지 등 운영 상태필수
createdAt등록 시각필수
updatedAt마지막 수정 시각필수
  • 모든 수업, 회원, 회원권, 예약 데이터에는 studioId를 둔다.
  • 시간 계산은 스튜디오의 timezone을 기준으로 한다. (NFR-004)

관련 요구사항: FR-001, FR-004, FR-022, NFR-004, NFR-007


4.2 Member — 회원

필드설명필수
id회원 식별값필수
studioId소속 기구 필라테스 스튜디오필수
name회원 이름필수
phoneNumber휴대폰 본인 확인과 문자 발송에 쓰는 번호필수
phoneVerifiedAt마지막 휴대폰 본인 확인 완료 시각선택
status이용 가능, 이용 중지, 탈퇴 등필수
createdAt원장 등록 시각필수
updatedAt마지막 수정 시각필수

제약 조건

  • 같은 기구 필라테스 스튜디오 안에서는 같은 휴대폰 번호를 중복 등록할 수 없다.
  • 다른 기구 필라테스 스튜디오의 회원 데이터는 조회할 수 없다.
  • 회원 이름과 휴대폰 번호는 필요한 운영 목적에 한해 저장한다. (NFR-007)

관련 요구사항: FR-002, FR-003, FR-019, UIR-001, UIR-003, UIR-008


4.3 MembershipPass — 회원권

필드설명필수
id회원권 식별값필수
studioId소속 기구 필라테스 스튜디오필수
memberId회원권 보유 회원필수
name회원권 상품명. 예: 10회권, 20회권필수
totalCount총 횟수필수
remainingCount현재 잔여 횟수필수
startDate사용 시작일필수
expiresAt만료일선택
status사용 가능, 만료, 소진, 중지필수
createdByOwnerId등록한 원장필수
createdAt등록 시각필수
updatedAt마지막 수정 시각필수

검증 기준

  • remainingCounttotalCount보다 클 수 없다.
  • 시작일보다 만료일이 빠를 수 없다.
  • remainingCount는 회원권 이력의 변경 결과와 일치해야 한다. (NFR-009)
  • 회원권 결제 금액, 결제 수단, 충전 기능은 첫 개발 범위에 포함하지 않는다.

확인 필요

  • 원장이 회원권 횟수가 부족한 회원의 예약을 승인할 때 remainingCount를 음수로 허용할지, 회원권 정보를 먼저 조정한 뒤 승인하게 할지 확정이 필요하다.

관련 요구사항: FR-002, FR-006, FR-015, FR-017, FR-019, UIR-008, UIR-012, NFR-009


4.4 MembershipHistory — 회원권 이력

회원권 잔여 횟수를 변경하는 모든 처리는 이 표에 남긴다. 기존 이력을 직접 수정하지 않는다.

필드설명필수
id회원권 이력 식별값필수
membershipPassId대상 회원권필수
reservationId관련 예약선택
cancellationId관련 취소선택
attendanceRecordId관련 출결 관리 기록선택
sourceHistoryId복구 또는 조정의 원본 이력선택
type차감, 예외 복구, 수동 추가, 수동 차감, 노쇼 처리 등필수
deltaCount변동 횟수. 차감은 음수, 복구·추가는 양수필수
beforeRemainingCount처리 전 잔여 횟수필수
afterRemainingCount처리 후 잔여 횟수필수
reason처리 이유필수
processedByOwnerId처리 원장선택
processedAt처리 시각필수

생명주기

  1. 회원권 등록 시 최초 잔여 횟수 기준 이력을 남긴다.
  2. 예약 확정, 12시간 이내 취소, 출석 또는 노쇼에 따른 차감 기준이 확정되면 차감 이력을 만든다.
  3. 원장이 미차감 예외를 승인하면 기존 차감 이력을 삭제하지 않고 EXCEPTION_RESTORE 이력을 추가한다.
  4. 오류 수정은 원본 이력과 연결된 별도 조정 이력으로 남긴다.

확인 필요

회원권 차감 시점은 아직 확정되지 않았다.

  • 예약 확정 시 1회 차감 후 무료 취소 시 복구하는 방식
  • 출석 또는 12시간 이내 취소가 확정된 시점에 차감하는 방식

두 방식 중 하나를 운영 정책으로 확정해야 한다. 데이터 구조는 두 방식을 모두 기록할 수 있게 설계한다.

관련 요구사항: FR-010, FR-017, FR-020, NFR-008, NFR-009


4.5 Class — 수업

필드설명필수
id수업 식별값필수
studioId소속 기구 필라테스 스튜디오필수
name수업명필수
startsAt수업 시작 날짜·시각필수
endsAt수업 종료 날짜·시각필수
capacity정원. 5~15명필수
reservationEnabled신규 예약 가능 여부필수
waitlistEnabled대기 신청 가능 여부필수
freeCancellationDeadlineAt수업 시작 12시간 전 무료 취소 마감 시각필수
status예정, 마감, 진행, 종료, 휴강필수
suspensionReason휴강 사유선택
createdByOwnerId등록 원장필수
createdAt등록 시각필수
updatedAt수정 시각필수

수업 상태

상태의미
SCHEDULED예약 또는 대기 신청을 받을 수 있는 예정 수업
CLOSED원장이 예약 또는 대기 신청을 중지한 수업
IN_PROGRESS수업 시작 후 진행 중인 수업
COMPLETED출결 처리가 가능한 종료 수업
SUSPENDED휴강 처리된 수업

제약 조건

  • 종료 시각은 시작 시각보다 늦어야 한다.
  • 정원은 5~15명 범위다.
  • 이미 확정된 예약 인원보다 정원을 작게 변경할 수 없다.
  • 휴강 처리된 수업에는 신규 예약·대기 신청·대기 제안을 만들 수 없다.
  • 휴강 시 기존 예약, 대기 신청, 회원권 처리 기준은 FR-021 세부 정책에 따라 처리해야 한다.

관련 요구사항: FR-004, FR-005, FR-021, UIR-007, UIR-009, NFR-002, NFR-004


4.6 Reservation — 예약

필드설명필수
id예약 식별값필수
studioId소속 기구 필라테스 스튜디오필수
classId대상 수업필수
memberId예약 회원필수
membershipPassId판단 기준 회원권선택
status예약 상태필수
source직접 예약, 대기 제안 수락 후 승인 등 생성 경로필수
confirmedAt예약 확정 시각선택
cancelledAt취소 처리 시각선택
createdAt예약 생성 시각필수
updatedAt마지막 상태 변경 시각필수

예약 상태

상태의미
CONFIRMED정원을 점유하는 최종 확정 예약
PENDING_OWNER_APPROVAL빈자리 수락 후 원장 최종 예약 승인 대기
CANCELLED회원 또는 원장 처리로 취소된 예약
REJECTED원장이 빈자리 수락을 반려한 예약
SUSPENDED휴강으로 정상 진행할 수 없게 된 예약

제약 조건

  • 같은 회원은 같은 수업에 활성 예약 또는 활성 대기 신청을 동시에 가질 수 없다.
  • CONFIRMED 상태 예약 수는 수업 정원을 초과할 수 없다.
  • PENDING_OWNER_APPROVAL 예약은 좌석을 보류하므로 같은 좌석에 다른 대기 제안을 만들 수 없다.
  • 예약 확정, 취소, 승인, 반려 같은 상태 변경은 처리 시각과 처리자를 기록해야 한다.

관련 요구사항: FR-006, FR-008, FR-009, FR-015, FR-016, FR-018, NFR-002, NFR-003


4.7 Cancellation — 취소

필드설명필수
id취소 식별값필수
reservationId취소된 예약필수
cancelledByMemberId취소 요청 회원선택
cancelledByOwnerId원장 대리 취소 처리자선택
timingType무료 취소, 12시간 이내 취소필수
reasonCode지각, 건강, 개인 사정 등 선택 사유필수
reasonDetail회원이 추가 입력한 내용선택
cancelledAt취소 시각필수
deductionDecision미차감, 원장 판단 대기, 차감, 예외 미차감필수
createdAt취소 기록 생성 시각필수

취소 사유 코드

첫 버전에서는 아래 값을 기본 제공한다.

  • LATE
  • HEALTH
  • PERSONAL_REASON
  • OTHER

OTHER를 선택하거나 상세 설명이 필요한 경우 회원이 추가 내용을 입력할 수 있다.

처리 기준

  • 무료 취소 마감 시각 이전 취소: 기본 미차감
  • 12시간 이내 취소: 원장이 차감 또는 미차감을 결정하고 이유를 기록
  • 취소 완료 시 수업의 빈자리 처리 흐름을 시작

관련 요구사항: FR-009, FR-010, FR-011, UIR-005, UIR-007, UIR-008


4.8 WaitlistRequest — 대기 신청

필드설명필수
id대기 신청 식별값필수
studioId소속 기구 필라테스 스튜디오필수
classId대상 수업필수
memberId대기 신청 회원필수
requestedAt대기 신청 시각필수
sequenceNumber변경 불가능한 등록 순번필수
status대기 상태필수
endedAt대기 종료 시각선택
endReason대기 종료 사유선택

대기 신청 상태

상태의미
WAITING순번을 기다리는 유효한 대기 신청
OFFERED현재 대기 제안을 받은 상태
ACCEPTED_PENDING_APPROVAL수락 후 원장 승인 대기 상태
FULFILLED원장 승인 후 예약이 확정된 상태
EXPIRED수업 종료 또는 대기 제안 만료로 종료된 상태
CANCELLED회원 또는 원장에 의해 대기 신청이 취소된 상태

대기 순번 계산

현재 대기 순번은 아래 순서로 계산한다.

  1. status = WAITING 또는 현재 제안과 관련된 유효 신청만 조회한다.
  2. requestedAt 오름차순으로 정렬한다.
  3. 같은 시각이면 sequenceNumber 오름차순으로 정렬한다.
  4. 회원 화면에는 내 순번과 전체 대기 인원만 표시한다.

관련 요구사항: FR-007, FR-008, FR-011, FR-013, UIR-004, UIR-005, NFR-002


4.9 VacancyOffer — 대기 제안

필드설명필수
id대기 제안 식별값필수
studioId소속 기구 필라테스 스튜디오필수
classId빈자리가 생긴 수업필수
waitlistRequestId제안 대상 대기 신청필수
triggerCancellationId빈자리를 만든 취소 기록선택
status대기 제안 상태필수
offeredAt제안 생성 시각필수
responseDeadlineAt회원 응답 마감 시각필수
expiredAt만료 처리 시각선택
declinedAt회원 거절 시각선택
createdAt생성 시각필수
updatedAt마지막 상태 변경 시각필수

대기 제안 상태

상태의미
PENDING_RESPONSE문자 발송 후 회원 응답 대기
ACCEPTED회원이 빈자리를 수락함
DECLINED회원이 이번 제안을 거절함
EXPIRED응답 마감까지 응답하지 않음
MESSAGE_FAILED문자 발송 최종 실패
APPROVAL_PENDING수락 후 원장 최종 예약 승인 대기
APPROVED원장 승인으로 예약 확정 완료
REJECTED원장이 예약을 반려함
CANCELLED수업 휴강 또는 운영자 조치로 종료됨

응답 마감 계산

대기 제안 시점응답 시간
수업 시작 2시간 전까지제안 시각부터 1시간
수업 시작 2시간 이내제안 시각부터 10분

확인 필요

수업 시작 직전에 대기 제안이 생성될 때, 계산된 응답 마감이 수업 시작 후로 넘어갈 수 있다. 다음 중 운영 기준 확정이 필요하다.

  • 수업 시작 시각을 응답 마감 상한으로 둔다.
  • 수업 시작 후에도 응답과 원장 승인을 허용한다.
  • 수업 시작 전 특정 최소 시간에는 대기 제안을 중지한다.

관련 요구사항: FR-011, FR-013, FR-014, FR-015, FR-016, UIR-006, UIR-007, UIR-010, NFR-003


4.10 VacancyAcceptance — 빈자리 수락

필드설명필수
id빈자리 수락 식별값필수
vacancyOfferId응답한 대기 제안필수
memberId응답 회원필수
decision수락 또는 거절필수
respondedAt응답 시각필수
membershipCheckResult사용 가능, 횟수 부족, 만료, 확인 필요필수
createdAt기록 생성 시각필수

처리 기준

  • 회원권 횟수가 부족하거나 만료 상태여도 회원의 빈자리 수락 자체는 가능하다.
  • 수락 시 원장 최종 예약 승인 기록을 생성한다.
  • 거절 또는 만료 시 다음 대기 순번 회원에게 대기 제안을 생성한다.
  • 이미 응답 처리된 대기 제안에는 다시 수락 또는 거절할 수 없다.

관련 요구사항: FR-014, FR-015, UIR-006, NFR-006


4.11 OwnerApproval — 원장 최종 예약 승인

필드설명필수
id원장 최종 예약 승인 식별값필수
vacancyAcceptanceId대상 빈자리 수락필수
reservationId승인 대기 예약필수
ownerId처리 원장선택
status승인 대기, 승인, 반려, 만료필수
decisionReason승인 또는 반려 이유선택
seatHeldAt좌석 보류 시작 시각필수
seatHoldExpiresAt좌석 보류 만료 시각선택
decidedAt최종 처리 시각선택
createdAt생성 시각필수

승인 상태

상태의미
PENDING원장의 최종 판단 대기
APPROVED원장이 승인하여 예약 확정
REJECTED원장이 반려
EXPIRED좌석 보류 시간이 끝남

확인 필요

회원 수락 후 원장이 응답하지 않을 때의 좌석 보류 기준이 확정되지 않았다.

  • 승인 대기 좌석 보류 시간
  • 보류 만료 후 다음 대기 회원에게 넘길지 여부
  • 원장이 반려한 회원의 대기 신청을 종료할지, 다시 대기 순번에 둘지 여부

관련 요구사항: FR-015, FR-016, UIR-007, UIR-010, NFR-002, NFR-003


4.12 SequentialMessage — 순차 문자 안내

필드설명필수
id문자 발송 기록 식별값필수
vacancyOfferId연결된 대기 제안필수
memberId수신 회원필수
phoneNumberSnapshot발송 당시 휴대폰 번호필수
messageType휴대폰 인증, 빈자리 수락 링크 등필수
acceptanceTokenHash수락 링크용 비밀값의 해시값선택
tokenExpiresAt수락 링크 만료 시각선택
providerMessageId문자 발송 업체 메시지 식별값선택
status발송 요청, 성공, 실패, 재시도 중 등필수
failureCode실패 코드선택
attemptNumber발송 시도 횟수필수
requestedAt발송 요청 시각필수
sentAt발송 성공 확인 시각선택
deliveredAt전달 확인 시각선택

보안 기준

  • 문자 수락 링크에는 회원·대기 제안·만료 시각을 직접 노출하지 않는다.
  • 데이터베이스에는 원문 토큰이 아닌 해시값만 저장한다.
  • 링크 사용 시 휴대폰 본인 확인 상태와 수락 링크의 회원이 일치하는지 확인한다.
  • 링크는 한 번 처리되면 재사용할 수 없다.

관련 요구사항: FR-003, FR-012, FR-014, NFR-005, NFR-006, NFR-010


4.13 AttendanceRecord — 출결 관리

필드설명필수
id출결 기록 식별값필수
reservationId대상 예약필수
status출석, 지각 출석, 노쇼, 보류필수
recordedByOwnerId기록 원장필수
recordedAt기록 시각필수
note원장 메모선택
updatedAt마지막 수정 시각필수

출석 상태

상태의미
ATTENDED정상 출석
LATE_ATTENDED지각 출석
NO_SHOW예약 후 수업에 참여하지 않음
PENDING수업 종료 후 아직 출결 미처리

노쇼에 따른 회원권 차감 또는 예외 처리는 MembershipHistory, ExceptionApproval에 연결한다.

관련 요구사항: FR-018, FR-019, UIR-008


4.14 ExceptionApproval — 예외 승인

필드설명필수
id예외 승인 식별값필수
studioId소속 기구 필라테스 스튜디오필수
cancellationId관련 취소선택
attendanceRecordId관련 노쇼 또는 출결 기록선택
sourceHistoryId예외 복구 대상 원본 회원권 이력선택
ownerId승인 원장필수
decision차감, 미차감, 복구 승인필수
reason원장 판단 이유필수
createdAt승인 시각필수

처리 기준

  • 12시간 이내 취소 시 원장은 차감 또는 미차감을 선택하고 이유를 남긴다.
  • 이미 차감 이력이 있고 원장이 미차감을 승인하면, 원본 차감 이력은 남기고 MembershipHistory에 예외 복구 이력을 추가한다.
  • 노쇼의 예외 처리 기준은 데이터 구조에 포함하되, 구체적인 운영 정책은 확인이 필요하다.

관련 요구사항: FR-010, FR-017, FR-018, FR-019, NFR-008


4.15 ErrorCorrectionRecord — 오류 수정 기록

필드설명필수
id오류 수정 기록 식별값필수
studioId소속 기구 필라테스 스튜디오필수
targetType예약, 대기 신청, 대기 제안, 회원권 이력, 출결 등 수정 대상 종류필수
targetId수정 대상 원본 기록 식별값필수
sourceHistoryId대상이 회원권 이력일 때의 원본 이력선택
beforeSnapshot수정 전 핵심 값필수
afterSnapshot수정 후 핵심 값필수
reason수정 사유필수
correctedByOwnerId처리 원장필수
createdAt수정 기록 생성 시각필수

오류 수정 기록은 중복 예약, 대기 순서 누락, 잘못된 취소·노쇼·회원권 횟수 처리의 근거를 남긴다.

관련 요구사항: FR-020, UIR-011, NFR-008


5. 상태 흐름

5.1 예약과 빈자리 처리 흐름

그림을 그리는 중…

취소로 빈자리가 생기면 대기 신청 순번이 가장 빠른 회원에게만 먼저 제안한다.

회원이 수락해도 원장이 승인하기 전에는 최종 예약이 확정되지 않으며, 이 기간에는 좌석을 보류한다. (FR-011~FR-015)


5.2 취소와 회원권 처리 흐름

그림을 그리는 중…

무료 취소 마감 시각 전 취소는 기본적으로 미차감 처리한다. 12시간 이내 취소는 원장이 차감 또는 미차감을 선택하고 이유를 남긴다. (FR-009, FR-010)


6. 필요한 인덱스와 중복 방지 기준

대상 표인덱스 또는 제약 조건목적관련 요구사항
Member@@unique([studioId, phoneNumber])스튜디오 안의 같은 휴대폰 번호 중복 등록 방지FR-002
Class@@index([studioId, startsAt])원장 날짜별 수업 조회FR-004, UIR-007
Reservation@@index([classId, status])수업별 확정 예약 인원 계산FR-006, NFR-002
Reservation@@unique([classId, memberId])같은 회원의 동일 수업 중복 예약 방지FR-006
WaitlistRequest@@unique([classId, memberId])동일 수업 중복 대기 신청 방지FR-007
WaitlistRequest@@index([classId, status, requestedAt, sequenceNumber])대기 순번 계산과 다음 대상 조회FR-007, FR-011
VacancyOffer@@index([classId, status])수업별 진행 중 대기 제안 확인FR-011
VacancyOffer@@index([status, responseDeadlineAt])응답 마감 작업 조회FR-013, NFR-003
SequentialMessage@@index([vacancyOfferId, status])문자 발송 성공·실패 추적FR-012
MembershipHistory@@index([membershipPassId, processedAt])회원권 이력 시간순 조회와 잔여 횟수 검증FR-017, NFR-009
AttendanceRecord@@unique([reservationId])예약당 출결 기록 한 건 유지FR-018
ErrorCorrectionRecord@@index([studioId, targetType, targetId])원본 기록별 오류 수정 이력 조회FR-020

정원 초과 방지 구현 기준

수업 예약 확정 또는 원장 승인 처리 시 다음을 하나의 데이터베이스 처리 단위로 묶어야 한다.

  1. 현재 확정 예약 수를 다시 확인한다.
  2. 정원보다 적을 때만 예약 상태를 CONFIRMED로 변경한다.
  3. 예약 확정 인원과 정원이 같거나 많으면 승인하지 않는다.
  4. 동시에 여러 요청이 들어와도 한 좌석이 두 번 확정되지 않게 한다.

관련 요구사항: FR-006, FR-011, FR-015, NFR-002, NFR-003


7. Prisma 모델 초안

아래는 Next.js 웹서비스에서 사용할 Prisma 데이터 모델 초안이다. 실제 문자 발송 업체와 월 구독료 결제 업체가 확정되면 해당 연동 식별값을 구체화한다.

enum StudioStatus {
  ACTIVE
  SUSPENDED
}

enum MemberStatus {
  ACTIVE
  INACTIVE
  WITHDRAWN
}

enum MembershipPassStatus {
  ACTIVE
  EXHAUSTED
  EXPIRED
  SUSPENDED
}

enum ClassStatus {
  SCHEDULED
  CLOSED
  IN_PROGRESS
  COMPLETED
  SUSPENDED
}

enum ReservationStatus {
  CONFIRMED
  PENDING_OWNER_APPROVAL
  CANCELLED
  REJECTED
  SUSPENDED
}

enum ReservationSource {
  DIRECT_BOOKING
  VACANCY_OFFER_APPROVAL
  OWNER_CREATED
}

enum CancellationTimingType {
  FREE_CANCELLATION
  WITHIN_12_HOURS
}

enum CancellationReasonCode {
  LATE
  HEALTH
  PERSONAL_REASON
  OTHER
}

enum DeductionDecision {
  NOT_DEDUCTED
  OWNER_REVIEW_REQUIRED
  DEDUCTED
  EXCEPTION_NOT_DEDUCTED
}

enum WaitlistRequestStatus {
  WAITING
  OFFERED
  ACCEPTED_PENDING_APPROVAL
  FULFILLED
  EXPIRED
  CANCELLED
}

enum VacancyOfferStatus {
  PENDING_RESPONSE
  ACCEPTED
  DECLINED
  EXPIRED
  MESSAGE_FAILED
  APPROVAL_PENDING
  APPROVED
  REJECTED
  CANCELLED
}

enum VacancyAcceptanceDecision {
  ACCEPT
  DECLINE
}

enum MembershipCheckResult {
  AVAILABLE
  INSUFFICIENT_COUNT
  EXPIRED
  SUSPENDED
  REVIEW_REQUIRED
}

enum OwnerApprovalStatus {
  PENDING
  APPROVED
  REJECTED
  EXPIRED
}

enum MessageType {
  PHONE_AUTH
  VACANCY_OFFER
}

enum MessageStatus {
  REQUESTED
  SENT
  DELIVERED
  FAILED
  RETRYING
}

enum AttendanceStatus {
  ATTENDED
  LATE_ATTENDED
  NO_SHOW
  PENDING
}

enum MembershipHistoryType {
  INITIAL_REGISTER
  RESERVATION_DEDUCTION
  CANCELLATION_DEDUCTION
  ATTENDANCE_DEDUCTION
  NO_SHOW_DEDUCTION
  EXCEPTION_RESTORE
  MANUAL_ADD
  MANUAL_DEDUCT
  ERROR_CORRECTION
}

enum ExceptionDecision {
  DEDUCT
  NOT_DEDUCT
  RESTORE
}

model Studio {
  id            String       @id @default(cuid())
  name          String
  timezone      String       @default("Asia/Seoul")
  status        StudioStatus @default(ACTIVE)
  createdAt     DateTime     @default(now())
  updatedAt     DateTime     @updatedAt

  owners        Owner[]
  members       Member[]
  classes       Class[]
  policy        StudioPolicy?
  subscriptions Subscription[]
}

model Owner {
  id           String   @id @default(cuid())
  studioId     String
  name         String
  phoneNumber  String?
  email        String?
  isActive     Boolean  @default(true)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  studio       Studio   @relation(fields: [studioId], references: [id], onDelete: Cascade)
  createdPasses MembershipPass[] @relation("PassCreatedBy")
  approvals    OwnerApproval[]
  corrections  ErrorCorrectionRecord[]
  attendanceRecords AttendanceRecord[]

  @@index([studioId, isActive])
}

model Member {
  id              String       @id @default(cuid())
  studioId        String
  name            String
  phoneNumber     String
  phoneVerifiedAt DateTime?
  status          MemberStatus @default(ACTIVE)
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt

  studio          Studio       @relation(fields: [studioId], references: [id], onDelete: Cascade)
  membershipPasses MembershipPass[]
  reservations    Reservation[]
  waitlistRequests WaitlistRequest[]
  acceptances     VacancyAcceptance[]
  messages        SequentialMessage[]
  phoneAuthChallenges PhoneAuthChallenge[]

  @@unique([studioId, phoneNumber])
  @@index([studioId, status])
}

model PhoneAuthChallenge {
  id             String   @id @default(cuid())
  memberId       String?
  phoneNumber    String
  codeHash       String
  expiresAt      DateTime
  verifiedAt     DateTime?
  failedAttempts Int      @default(0)
  resendCount    Int      @default(0)
  createdAt      DateTime @default(now())

  member         Member?  @relation(fields: [memberId], references: [id], onDelete: SetNull)

  @@index([phoneNumber, expiresAt])
}

model MembershipPass {
  id               String               @id @default(cuid())
  studioId         String
  memberId         String
  name             String
  totalCount       Int
  remainingCount   Int
  startDate        DateTime
  expiresAt        DateTime?
  status           MembershipPassStatus @default(ACTIVE)
  createdByOwnerId String
  createdAt        DateTime             @default(now())
  updatedAt        DateTime             @updatedAt

  member           Member               @relation(fields: [memberId], references: [id], onDelete: Cascade)
  createdByOwner   Owner                @relation("PassCreatedBy", fields: [createdByOwnerId], references: [id])
  reservations     Reservation[]
  histories        MembershipHistory[]

  @@index([memberId, status, expiresAt])
}

model MembershipHistory {
  id                   String                @id @default(cuid())
  membershipPassId     String
  reservationId        String?
  cancellationId       String?
  attendanceRecordId   String?
  sourceHistoryId      String?
  type                 MembershipHistoryType
  deltaCount           Int
  beforeRemainingCount Int
  afterRemainingCount  Int
  reason               String
  processedByOwnerId   String?
  processedAt          DateTime              @default(now())

  membershipPass       MembershipPass        @relation(fields: [membershipPassId], references: [id], onDelete: Restrict)
  sourceHistory        MembershipHistory?    @relation("SourceHistory", fields: [sourceHistoryId], references: [id])
  restoredHistories    MembershipHistory[]   @relation("SourceHistory")
  exceptionApprovals   ExceptionApproval[]
  corrections          ErrorCorrectionRecord[]

  @@index([membershipPassId, processedAt])
  @@index([reservationId])
  @@index([cancellationId])
}

model Class {
  id                         String      @id @default(cuid())
  studioId                   String
  name                       String
  startsAt                   DateTime
  endsAt                     DateTime
  capacity                   Int
  reservationEnabled         Boolean     @default(true)
  waitlistEnabled            Boolean     @default(true)
  freeCancellationDeadlineAt DateTime
  status                     ClassStatus @default(SCHEDULED)
  suspensionReason           String?
  createdByOwnerId           String
  createdAt                  DateTime    @default(now())
  updatedAt                  DateTime    @updatedAt

  studio                     Studio      @relation(fields: [studioId], references: [id], onDelete: Cascade)
  reservations               Reservation[]
  waitlistRequests           WaitlistRequest[]
  vacancyOffers              VacancyOffer[]

  @@index([studioId, startsAt])
  @@index([studioId, status, startsAt])
}

model Reservation {
  id               String            @id @default(cuid())
  studioId         String
  classId          String
  memberId         String
  membershipPassId String?
  status           ReservationStatus @default(CONFIRMED)
  source           ReservationSource
  confirmedAt      DateTime?
  cancelledAt      DateTime?
  createdAt        DateTime          @default(now())
  updatedAt        DateTime          @updatedAt

  class            Class             @relation(fields: [classId], references: [id], onDelete: Restrict)
  member           Member            @relation(fields: [memberId], references: [id], onDelete: Restrict)
  membershipPass   MembershipPass?   @relation(fields: [membershipPassId], references: [id], onDelete: SetNull)
  cancellation     Cancellation?
  attendanceRecord AttendanceRecord?
  ownerApproval    OwnerApproval?

  @@unique([classId, memberId])
  @@index([classId, status])
  @@index([memberId, status, createdAt])
}

model Cancellation {
  id                String                 @id @default(cuid())
  reservationId     String                 @unique
  cancelledByMemberId String?
  cancelledByOwnerId  String?
  timingType        CancellationTimingType
  reasonCode        CancellationReasonCode
  reasonDetail      String?
  cancelledAt       DateTime
  deductionDecision DeductionDecision
  createdAt         DateTime               @default(now())

  reservation       Reservation            @relation(fields: [reservationId], references: [id], onDelete: Restrict)
  exceptionApproval ExceptionApproval?
  vacancyOffers     VacancyOffer[]

  @@index([timingType, cancelledAt])
}

model WaitlistRequest {
  id             String                @id @default(cuid())
  studioId       String
  classId        String
  memberId       String
  requestedAt    DateTime              @default(now())
  sequenceNumber BigInt
  status         WaitlistRequestStatus @default(WAITING)
  endedAt        DateTime?
  endReason      String?

  class          Class                 @relation(fields: [classId], references: [id], onDelete: Restrict)
  member         Member                @relation(fields: [memberId], references: [id], onDelete: Restrict)
  vacancyOffers  VacancyOffer[]

  @@unique([classId, memberId])
  @@index([classId, status, requestedAt, sequenceNumber])
}

model VacancyOffer {
  id                    String             @id @default(cuid())
  studioId              String
  classId               String
  waitlistRequestId     String
  triggerCancellationId String?
  status                VacancyOfferStatus @default(PENDING_RESPONSE)
  offeredAt             DateTime           @default(now())
  responseDeadlineAt    DateTime
  expiredAt             DateTime?
  declinedAt            DateTime?
  createdAt             DateTime           @default(now())
  updatedAt             DateTime           @updatedAt

  class                 Class              @relation(fields: [classId], references: [id], onDelete: Restrict)
  waitlistRequest       WaitlistRequest    @relation(fields: [waitlistRequestId], references: [id], onDelete: Restrict)
  triggerCancellation   Cancellation?      @relation(fields: [triggerCancellationId], references: [id], onDelete: SetNull)
  acceptance            VacancyAcceptance?
  messages              SequentialMessage[]

  @@index([classId, status])
  @@index([status, responseDeadlineAt])
}

model VacancyAcceptance {
  id                    String                    @id @default(cuid())
  vacancyOfferId        String                    @unique
  memberId              String
  decision              VacancyAcceptanceDecision
  membershipCheckResult MembershipCheckResult
  respondedAt           DateTime                  @default(now())
  createdAt             DateTime                  @default(now())

  vacancyOffer          VacancyOffer              @relation(fields: [vacancyOfferId], references: [id], onDelete: Restrict)
  member                Member                    @relation(fields: [memberId], references: [id], onDelete: Restrict)
  ownerApproval         OwnerApproval?
}

model OwnerApproval {
  id                  String              @id @default(cuid())
  vacancyAcceptanceId String              @unique
  reservationId       String              @unique
  ownerId             String?
  status              OwnerApprovalStatus @default(PENDING)
  decisionReason      String?
  seatHeldAt          DateTime            @default(now())
  seatHoldExpiresAt   DateTime?
  decidedAt           DateTime?
  createdAt           DateTime            @default(now())

  vacancyAcceptance   VacancyAcceptance   @relation(fields: [vacancyAcceptanceId], references: [id], onDelete: Restrict)
  reservation         Reservation         @relation(fields: [reservationId], references: [id], onDelete: Restrict)
  owner               Owner?              @relation(fields: [ownerId], references: [id], onDelete: SetNull)

  @@index([status, seatHoldExpiresAt])
}

model SequentialMessage {
  id                  String        @id @default(cuid())
  vacancyOfferId      String?
  memberId            String
  phoneNumberSnapshot String
  messageType         MessageType
  acceptanceTokenHash String?
  tokenExpiresAt      DateTime?
  providerMessageId   String?
  status              MessageStatus @default(REQUESTED)
  failureCode         String?
  attemptNumber       Int           @default(1)
  requestedAt         DateTime      @default(now())
  sentAt              DateTime?
  deliveredAt         DateTime?

  vacancyOffer        VacancyOffer? @relation(fields: [vacancyOfferId], references: [id], onDelete: SetNull)
  member              Member        @relation(fields: [memberId], references: [id], onDelete: Restrict)

  @@index([vacancyOfferId, status])
  @@index([memberId, messageType, requestedAt])
}

model AttendanceRecord {
  id                String           @id @default(cuid())
  reservationId     String           @unique
  status            AttendanceStatus @default(PENDING)
  recordedByOwnerId String
  recordedAt        DateTime         @default(now())
  note              String?
  updatedAt         DateTime         @updatedAt

  reservation       Reservation      @relation(fields: [reservationId], references: [id], onDelete: Restrict)
  recordedByOwner   Owner            @relation(fields: [recordedByOwnerId], references: [id], onDelete: Restrict)
  exceptionApproval ExceptionApproval?
}

model ExceptionApproval {
  id                 String            @id @default(cuid())
  studioId           String
  cancellationId     String?           @unique
  attendanceRecordId String?           @unique
  sourceHistoryId    String?
  ownerId            String
  decision           ExceptionDecision
  reason             String
  createdAt          DateTime          @default(now())

  cancellation       Cancellation?     @relation(fields: [cancellationId], references: [id], onDelete: SetNull)
  attendanceRecord   AttendanceRecord? @relation(fields: [attendanceRecordId], references: [id], onDelete: SetNull)
  sourceHistory      MembershipHistory? @relation(fields: [sourceHistoryId], references: [id], onDelete: SetNull)

  @@index([studioId, createdAt])
}

model ErrorCorrectionRecord {
  id              String   @id @default(cuid())
  studioId        String
  targetType      String
  targetId        String
  sourceHistoryId String?
  beforeSnapshot  Json
  afterSnapshot   Json
  reason          String
  correctedByOwnerId String
  createdAt       DateTime @default(now())

  sourceHistory   MembershipHistory? @relation(fields: [sourceHistoryId], references: [id], onDelete: SetNull)
  correctedByOwner Owner @relation(fields: [correctedByOwnerId], references: [id], onDelete: Restrict)

  @@index([studioId, targetType, targetId])
}

model StudioPolicy {
  id                          String   @id @default(cuid())
  studioId                    String   @unique
  freeCancellationHours       Int      @default(12)
  responseHoursBeforeTwoHours Int      @default(1)
  responseMinutesWithinTwoHours Int    @default(10)
  createdAt                   DateTime @default(now())
  updatedAt                   DateTime @updatedAt

  studio                      Studio   @relation(fields: [studioId], references: [id], onDelete: Cascade)
}

model Subscription {
  id                 String   @id @default(cuid())
  studioId           String
  status             String
  billingCycle       String
  providerCustomerId String?
  providerSubscriptionId String?
  currentPeriodEndsAt DateTime?
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  studio             Studio   @relation(fields: [studioId], references: [id], onDelete: Cascade)

  @@index([studioId, status])
}

8. 화면별 조회 데이터

화면화면에서 보여줄 핵심 데이터관련 요구사항
회원용 휴대폰 인증 화면휴대폰 번호, 인증번호 입력 상태, 인증 만료 시각, 재전송 가능 여부UIR-003, FR-003
회원용 수업 목록·예약·대기 신청 화면수업명, 날짜·시간, 정원, 확정 예약 인원, 예약 가능 여부, 대기 신청 가능 여부, 예상 대기 순번UIR-004, FR-005~FR-007
회원용 내 예약·대기 순번·취소 화면예약 상태, 무료 취소 마감 시각, 취소 사유, 현재 대기 순번, 전체 대기 인원, 대기 제안 응답 마감UIR-005, FR-008~FR-010
문자 링크 빈자리 수락 화면수업명, 날짜·시간, 응답 마감 시각, 수락·거절 버튼, 처리 완료 상태UIR-006, FR-014
원장용 날짜별 수업·예약 인원·대기 현황 화면수업별 정원, 확정 예약 수, 승인 대기 수, 대기 인원, 취소 기록, 대기 제안·문자·응답 상태UIR-007, FR-004, FR-011~FR-016
원장용 회원별 회원권 횟수·취소 예외·노쇼 기록 화면회원권 잔여 횟수, 회원권 이력, 취소 사유, 예외 승인 이유, 출결 상태, 노쇼 횟수UIR-008, FR-010, FR-017~FR-019
원장용 수업 등록·수정 화면수업명, 시작·종료 시각, 정원, 예약·대기 가능 여부, 휴강 사유UIR-009, FR-004, FR-021
원장용 빈자리 수락 승인 화면회원 정보, 수락 시각, 회원권 상태, 잔여 횟수, 승인·반려 이유, 좌석 보류 마감UIR-010, FR-015
원장용 오류 수정·조정 이력 화면수정 대상, 수정 전후 값, 원본 이력, 수정 사유, 처리 원장, 처리 시각UIR-011, FR-020
원장용 회원·회원권 초기 등록 화면회원 이름, 휴대폰 번호, 회원권명, 총 횟수, 잔여 횟수, 시작일, 만료일UIR-012, FR-002

9. 구현 전 확정이 필요한 데이터 정책

항목현재 확정 내용데이터 처리에서 확정할 내용
회원권 차감 시점무료 취소는 미차감, 12시간 이내 취소는 원장 판단예약 확정, 출석, 노쇼 중 어느 시점에 기본 차감 이력을 만들지 확인 필요
일반 예약의 회원권 부족빈자리 수락은 회원권 부족이어도 원장 승인 대상으로 진행자리가 있는 수업을 직접 예약할 때 회원권 부족 회원을 예약 확정할지, 승인 대기로 둘지 확인 필요
승인 대기 좌석 보류회원 수락 후 원장 최종 예약 승인이 필요좌석 보류 만료 시간과 만료 뒤 다음 대기 회원 제안 여부 확인 필요
수업 직전 대기 응답수업 시작 2시간 이내 제안은 10분 응답응답 마감을 수업 시작 시각을 넘기지 않게 할지 확인 필요
문자 발송 실패실패 코드와 재시도 횟수를 기록재발송 횟수, 원장 수동 처리, 다음 대기 회원 자동 제안 중 기준 확인 필요
노쇼의 운영 반영노쇼 기록을 남기고 원장이 운영 판단에 활용노쇼가 회원권 차감, 예약 제한, 대기 순번에 미치는 기준 확인 필요
휴강 처리수업 상태로 휴강을 관리기존 예약의 자동 취소, 회원권 복구, 대기 신청 종료, 회원 안내 방식 확인 필요
원장 로그인원장 접근 권한이 필요로그인 방식, 계정 생성·복구 방식 확인 필요
월 구독료 결제원장이 스튜디오용 월 구독료를 결제가격, 결제 주기, 결제 업체, 결제 실패 시 이용 제한 기준 확인 필요
데이터 구조 — 동네 필라테스 예약과 대기 순번 | Prometheon