필라테스 공정 대기 예약 데이터 구조 문서
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 | 마지막 수정 시각 | 필수 |
검증 기준
remainingCount는totalCount보다 클 수 없다.- 시작일보다 만료일이 빠를 수 없다.
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 | 처리 시각 | 필수 |
생명주기
- 회원권 등록 시 최초 잔여 횟수 기준 이력을 남긴다.
- 예약 확정, 12시간 이내 취소, 출석 또는 노쇼에 따른 차감 기준이 확정되면 차감 이력을 만든다.
- 원장이 미차감 예외를 승인하면 기존 차감 이력을 삭제하지 않고
EXCEPTION_RESTORE이력을 추가한다. - 오류 수정은 원본 이력과 연결된 별도 조정 이력으로 남긴다.
확인 필요
회원권 차감 시점은 아직 확정되지 않았다.
- 예약 확정 시 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 | 취소 기록 생성 시각 | 필수 |
취소 사유 코드
첫 버전에서는 아래 값을 기본 제공한다.
LATEHEALTHPERSONAL_REASONOTHER
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 | 회원 또는 원장에 의해 대기 신청이 취소된 상태 |
대기 순번 계산
현재 대기 순번은 아래 순서로 계산한다.
status = WAITING또는 현재 제안과 관련된 유효 신청만 조회한다.requestedAt오름차순으로 정렬한다.- 같은 시각이면
sequenceNumber오름차순으로 정렬한다. - 회원 화면에는 내 순번과 전체 대기 인원만 표시한다.
관련 요구사항: 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 |
정원 초과 방지 구현 기준
수업 예약 확정 또는 원장 승인 처리 시 다음을 하나의 데이터베이스 처리 단위로 묶어야 한다.
- 현재 확정 예약 수를 다시 확인한다.
- 정원보다 적을 때만 예약 상태를
CONFIRMED로 변경한다. - 예약 확정 인원과 정원이 같거나 많으면 승인하지 않는다.
- 동시에 여러 요청이 들어와도 한 좌석이 두 번 확정되지 않게 한다.
관련 요구사항: 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분 응답 | 응답 마감을 수업 시작 시각을 넘기지 않게 할지 확인 필요 |
| 문자 발송 실패 | 실패 코드와 재시도 횟수를 기록 | 재발송 횟수, 원장 수동 처리, 다음 대기 회원 자동 제안 중 기준 확인 필요 |
| 노쇼의 운영 반영 | 노쇼 기록을 남기고 원장이 운영 판단에 활용 | 노쇼가 회원권 차감, 예약 제한, 대기 순번에 미치는 기준 확인 필요 |
| 휴강 처리 | 수업 상태로 휴강을 관리 | 기존 예약의 자동 취소, 회원권 복구, 대기 신청 종료, 회원 안내 방식 확인 필요 |
| 원장 로그인 | 원장 접근 권한이 필요 | 로그인 방식, 계정 생성·복구 방식 확인 필요 |
| 월 구독료 결제 | 원장이 스튜디오용 월 구독료를 결제 | 가격, 결제 주기, 결제 업체, 결제 실패 시 이용 제한 기준 확인 필요 |