방과후 운영 한눈에 데이터 구조 문서
1. 설계 범위
이 데이터 구조는 첫 개발 범위인 다음 업무를 저장하고 조회하기 위한 것이다.
- 위탁업체 계정과 운영자·강사 역할 관리
- 학교·강사·학기·교시 템플릿 관리
- 수업 계획 직접 등록과 엑셀 일괄 등록
- 실제 시작·종료 시각 기준 시간 중복 검사
- 같은 학교 시간 겹침 예외 사유 기록
- 전체 시간표 확인
- 확정 시간표 생성과 공개
- 강사 초대, 개인 시간표, 알림함
서류 상태, 대체강사 탐색, 정산, 민원, 수업 회차, 재량휴업일, 단축수업별 운영, 직전 학기 복사는 첫 개발 범위에서 제외한다. 따라서 관련 표도 이번 데이터 구조에 넣지 않는다.
민감한 서류 원본 파일, 성범죄 경력·아동학대 전력 조회 회신서 원본, 카카오톡 대화 내용은 저장하지 않는다. (NFR-005)
2. 데이터 설계 원칙
| 원칙 | 적용 방식 | 관련 요구사항 |
|---|
| 위탁업체별 데이터 분리 | 학교, 강사, 수업 계획, 공개본, 알림, 엑셀 임시 등록 데이터에 agencyId를 둔다. 조회·수정 시 로그인한 사용자의 위탁업체 식별값과 항상 비교한다. | NFR-001, FR-002 |
| 이름이 아닌 고유 식별값 사용 | 학교명·강사명이 같아도 schoolId, instructorId로 구분한다. | FR-003, FR-004, FR-008 |
| 실제 시간으로 충돌 판단 | 교시명은 화면 표시와 입력 편의를 위해 저장하되, 시간 중복 판단은 actualStartTime, actualEndTime으로 한다. | FR-005, FR-009, FR-010, NFR-003 |
| 공개 시간표는 당시 모습 보존 | 수업 계획을 나중에 수정해도 과거에 강사에게 공개한 시간표를 확인할 수 있도록 공개본에 수업 내용을 복사해 저장한다. | FR-014, FR-019 |
| 엑셀은 저장 전 임시 검증 | 업로드 파일을 즉시 수업 계획으로 저장하지 않는다. 행별 오류를 수정한 뒤 한 번에 확정한다. | FR-007, FR-008, NFR-006 |
| 비밀번호·민감 파일 최소화 | 비밀번호는 원문이 아니라 암호화 해시값만 저장한다. 민감 서류 원본은 저장하지 않는다. | NFR-004, NFR-005 |
3. 주요 데이터와 관계
ClassPlan은 실제 운영 시간표의 원본이다. 운영자는 이 데이터를 등록·수정하며, 강사는 공개 전에는 볼 수 없다.
ConfirmedTimetableItem은 특정 시점에 확정·공개한 수업 계획의 복사본이다. 이후 원본 수업 계획이 바뀌어도 이전 공개 이력을 유지한다.
ImportBatch, ImportRow는 엑셀을 올린 뒤 검토하는 임시 데이터다. 오류 없는 행만 수업 계획으로 확정한다.
표 이름과 사업 용어
| 영문 표 이름 | 사업 용어 | 용도 | 관련 요구사항 |
|---|
Agency | 위탁업체 | 서비스 이용 업체와 업체별 데이터 분리 기준 | FR-001, FR-002, NFR-001 |
User | 사용자 계정 | 로그인 가능한 운영자·강사 계정 | FR-001, FR-002, FR-015, NFR-004 |
AgencyMember | 위탁업체 운영자 권한 | 운영자 계정과 위탁업체의 연결, 역할 관리 | FR-001, FR-002 |
School | 학교 | 위탁업체가 수업을 운영하는 초등학교·중학교 | FR-003 |
Instructor | 강사 | 수업 담당 강사와 강사 계정 연결 정보 | FR-004, FR-015, FR-016 |
Semester | 학기 | 학교별 시간표 운영 기간 | FR-005, FR-009, FR-013, FR-014 |
PeriodTemplate | 교시 템플릿 | 특정 학기·적용 기간의 교시표 설정 | FR-005 |
PeriodEntry | 교시표 항목 | 요일별 교시명과 실제 시작·종료 시각 | FR-005 |
ClassPlan | 수업 계획 | 학교·강사·요일·교시·실제 시간으로 구성된 반복 수업 | FR-008~FR-014 |
SameSchoolOverlapException | 같은 학교 시간 겹침 예외 | 같은 학교 안 시간 겹침의 사유와 처리 기록 | FR-012, FR-013, FR-014, FR-019 |
ConfirmedTimetable | 확정 시간표 | 충돌 검사를 통과해 공개 가능한 수업 묶음 | FR-014, FR-019 |
ConfirmedTimetableItem | 확정 시간표 수업 항목 | 공개 당시 수업 계획을 보존한 항목 | FR-014, FR-016, FR-019 |
TimetablePublication | 시간표 공개 | 확정 시간표의 공개 시각과 공개 식별값 | FR-014, FR-017, FR-019 |
InstructorInvitation | 강사 초대 | 강사 정보와 로그인 계정을 안전하게 연결하는 초대 | FR-015 |
Notification | 변경 알림 | 시간표 공개·변경 사실을 강사 알림함에 전달 | FR-017, FR-018 |
ImportBatch | 엑셀 등록 묶음 | 한 번의 엑셀 업로드 검증 작업 | FR-007, FR-008 |
ImportRow | 엑셀 행 | 업로드된 개별 수업 행, 오류와 수정값 | FR-007, FR-008 |
4. 표별 데이터 구조
4.1 계정·위탁업체·권한
Agency — 위탁업체
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 위탁업체 고유 식별값 |
name | 문자열 | 예 | 위탁업체 표시 이름 |
createdAt | 일시 | 예 | 업체 계정 시작 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
- 위탁업체 생성과 대표 운영자 권한 부여는 하나의 처리로 완료해야 한다. 업체만 생성되고 운영자가 연결되지 않은 불완전 데이터가 남으면 안 된다. (FR-001)
- 위탁업체 이름은 표시용이다. 다른 위탁업체와 이름이 같더라도 서비스 내부에서는
id로 구분한다.
User — 사용자 계정
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 사용자 계정 고유 식별값 |
email | 문자열 | 예 | 로그인과 초대 연결에 사용할 이메일 |
passwordHash | 문자열 | 예 | 암호화된 비밀번호 해시값 |
displayName | 문자열 | 예 | 화면에 표시할 이름 |
createdAt | 일시 | 예 | 계정 생성 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
- 원문 비밀번호는 저장하지 않는다.
- 로그인 실패 시 이메일 존재 여부를 과도하게 알려주지 않는다. (FR-002, NFR-004)
AgencyMember — 위탁업체 운영자 권한
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 운영자 권한 식별값 |
agencyId | UUID | 예 | 소속 위탁업체 |
userId | UUID | 예 | 운영자 사용자 계정 |
role | 열거형 | 예 | 첫 버전에서는 OPERATOR만 사용 |
createdAt | 일시 | 예 | 권한 생성 시각 |
제약 조건
- 같은 사용자가 같은 위탁업체에 운영자로 중복 연결되지 않도록
agencyId + userId를 유일하게 관리한다.
- 운영자 화면 요청 시
AgencyMember 연결 여부를 확인한다. (FR-002, NFR-001)
4.2 학교·강사·학기·교시표
School — 학교
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 학교 고유 식별값 |
agencyId | UUID | 예 | 관리하는 위탁업체 |
name | 문자열 | 예 | 학교명 |
createdAt | 일시 | 예 | 등록 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
제약 조건
- 학교명은 비어 있을 수 없다.
- 같은 위탁업체 안에서 같은 학교명을 새로 등록하려 할 때 기존 학교인지 확인 경고를 보인다.
- 학교명을 바꿔도 수업 계획은
schoolId로 연결되므로 관계가 끊기지 않는다. (FR-003)
Instructor — 강사
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 강사 고유 식별값 |
agencyId | UUID | 예 | 소속 위탁업체 |
userId | UUID | 아니오 | 초대 연결이 완료된 강사 계정 |
name | 문자열 | 예 | 강사명 |
createdAt | 일시 | 예 | 등록 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
제약 조건
- 강사명은 비어 있을 수 없다.
- 같은 이름의 강사는 허용한다. 엑셀 업로드 때 이름만으로 자동 연결하지 않고 운영자가 강사를 선택한다. (FR-004, FR-008)
userId가 비어 있으면 아직 강사 초대 연결이 끝나지 않은 상태다. (FR-015)
Semester — 학기
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 학기 고유 식별값 |
schoolId | UUID | 예 | 학기를 운영하는 학교 |
name | 문자열 | 예 | 예: 2026학년도 1학기 |
startDate | 날짜 | 예 | 학기 시작일 |
endDate | 날짜 | 예 | 학기 종료일 |
createdAt | 일시 | 예 | 등록 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
검증 기준
endDate는 startDate보다 빠를 수 없다.
- 같은 학교 안에서 동일한 학기명이 중복되지 않도록 관리한다.
- 수업 계획의 운영 기간은 원칙적으로 연결된 학기 범위 안에 있어야 한다. 학기 범위를 벗어나면 저장 전 운영자에게 확인할 항목으로 표시한다. (FR-005, FR-009)
PeriodTemplate — 교시 템플릿
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 교시 템플릿 식별값 |
semesterId | UUID | 예 | 적용 학기 |
name | 문자열 | 예 | 예: 기본 교시표 |
effectiveStartDate | 날짜 | 예 | 적용 시작일 |
effectiveEndDate | 날짜 | 예 | 적용 종료일 |
createdAt | 일시 | 예 | 등록 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
PeriodEntry — 교시표 항목
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 교시표 항목 식별값 |
periodTemplateId | UUID | 예 | 소속 교시 템플릿 |
dayOfWeek | 열거형 | 예 | 월요일~일요일 중 적용 요일 |
periodName | 문자열 | 예 | 예: 3교시 |
startTime | 시각 | 예 | 실제 시작 시각 |
endTime | 시각 | 예 | 실제 종료 시각 |
sortOrder | 숫자 | 예 | 화면 정렬 순서 |
검증 기준
- 종료 시각은 시작 시각보다 늦어야 한다.
- 같은 교시 템플릿 안에서 동일 요일·교시명이 중복되지 않도록 한다.
- 같은 학교·학기·요일·적용 기간에서 어느 교시 템플릿을 적용할지 하나로 정할 수 없으면 저장을 막는다. (FR-005)
4.3 수업 계획과 시간 중복 예외
ClassPlan — 수업 계획
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 수업 계획 고유 식별값 |
agencyId | UUID | 예 | 위탁업체 데이터 분리 기준 |
schoolId | UUID | 예 | 수업 운영 학교 |
semesterId | UUID | 예 | 수업이 속한 학기 |
instructorId | UUID | 예 | 담당 강사 |
periodEntryId | UUID | 아니오 | 교시표 항목으로 등록한 경우의 연결값 |
subject | 문자열 | 예 | 과목 |
dayOfWeek | 열거형 | 예 | 반복 수업 요일 |
operatingStartDate | 날짜 | 예 | 운영 기간 시작일 |
operatingEndDate | 날짜 | 예 | 운영 기간 종료일 |
periodName | 문자열 | 아니오 | 화면 표시용 교시명 |
actualStartTime | 시각 | 예 | 시간 중복 판단 기준 시작 시각 |
actualEndTime | 시각 | 예 | 시간 중복 판단 기준 종료 시각 |
timeSource | 열거형 | 예 | PERIOD_TEMPLATE 또는 MANUAL |
status | 열거형 | 예 | TEMPORARY 또는 CONFIRMED |
createdAt | 일시 | 예 | 등록 시각 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
사용 규칙
- 교시를 선택해 등록하면 교시 템플릿의 실제 시작·종료 시각을 수업 계획에 복사해 저장한다.
- 실제 시간을 직접 입력하면
timeSource를 MANUAL로 기록한다.
- 시간 중복은
periodName이 아니라 actualStartTime, actualEndTime으로 판단한다.
14:00~14:40, 14:40~15:20처럼 앞 수업 종료 시각과 다음 수업 시작 시각이 같으면 시간 중복이 아니다.
- 수업 계획의 공개 여부는 원본 수업 계획에 단순 표시하지 않는다. 어떤 수업이 언제 공개되었는지는 확정 시간표와 시간표 공개 기록으로 판단한다. (FR-009, FR-010, FR-014)
SameSchoolOverlapException — 같은 학교 시간 겹침 예외
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 예외 기록 식별값 |
agencyId | UUID | 예 | 위탁업체 |
schoolId | UUID | 예 | 시간이 겹치는 동일 학교 |
classPlanAId | UUID | 예 | 첫 번째 수업 계획 |
classPlanBId | UUID | 예 | 두 번째 수업 계획 |
reason | 문자열 | 예 | 운영자가 입력한 예외 사유 |
createdByMemberId | UUID | 예 | 예외를 처리한 운영자 권한 |
createdAt | 일시 | 예 | 예외 저장 시각 |
검증 기준
- 두 수업 계획은 서로 달라야 한다.
- 두 수업 계획은 같은 위탁업체, 같은 학교, 같은 강사여야 한다.
- 운영 기간, 요일, 실제 시간이 실제로 겹칠 때만 예외 기록을 허용한다.
- 서로 다른 학교의 시간 중복은 예외 사유를 입력해도 저장할 수 없다. (FR-010, FR-012)
- 수업의 학교·강사·요일·운영 기간·실제 시간이 수정되면 기존 예외가 여전히 유효한지 재검사한다. 유효하지 않으면 예외를 해제하고 다시 충돌 검사를 수행한다.
4.4 확정 시간표·공개본·알림
ConfirmedTimetable — 확정 시간표
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 확정 시간표 식별값 |
agencyId | UUID | 예 | 위탁업체 |
title | 문자열 | 예 | 운영자가 구분할 수 있는 확정 시간표 이름 |
createdByMemberId | UUID | 예 | 확정 처리한 운영자 |
createdAt | 일시 | 예 | 확정 시각 |
- 확정 전에는 포함할 모든 수업 계획에 대해 시간 중복을 다시 검사한다.
- 같은 학교 시간 겹침은 유효한 예외 기록이 있을 때만 확정할 수 있다. (FR-014)
ConfirmedTimetableItem — 확정 시간표 수업 항목
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 항목 식별값 |
confirmedTimetableId | UUID | 예 | 소속 확정 시간표 |
classPlanId | UUID | 예 | 원본 수업 계획 |
schoolNameSnapshot | 문자열 | 예 | 확정 당시 학교명 |
instructorId | UUID | 예 | 공개 대상 강사 |
subjectSnapshot | 문자열 | 예 | 확정 당시 과목 |
dayOfWeekSnapshot | 열거형 | 예 | 확정 당시 요일 |
periodNameSnapshot | 문자열 | 아니오 | 확정 당시 교시 |
actualStartTimeSnapshot | 시각 | 예 | 확정 당시 시작 시각 |
actualEndTimeSnapshot | 시각 | 예 | 확정 당시 종료 시각 |
operatingStartDateSnapshot | 날짜 | 예 | 확정 당시 운영 기간 시작일 |
operatingEndDateSnapshot | 날짜 | 예 | 확정 당시 운영 기간 종료일 |
- 강사 개인 시간표에는 이 표의 공개된 최신 항목을 사용한다.
- 수업 계획이 수정된 뒤 다시 공개하면 새 확정 시간표 항목을 생성한다. 이전 항목은 삭제하지 않는다. (FR-014, FR-016, FR-019)
TimetablePublication — 시간표 공개
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 공개본 식별값 |
agencyId | UUID | 예 | 위탁업체 |
confirmedTimetableId | UUID | 예 | 공개한 확정 시간표 |
publishedAt | 일시 | 예 | 실제 공개 시각 |
publishedByMemberId | UUID | 예 | 공개 처리한 운영자 |
- 하나의 확정 시간표는 첫 버전에서 한 번만 공개한다.
- 수정된 시간표를 강사에게 다시 보이게 하려면 새 확정 시간표와 새 시간표 공개 기록을 생성한다.
- 공개 처리와 알림 생성은 하나의 데이터베이스 처리로 완료한다. 일부 강사에게만 공개되거나 일부 알림만 생성되는 상태가 남으면 안 된다. (FR-014, FR-017)
Notification — 변경 알림
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 알림 식별값 |
agencyId | UUID | 예 | 위탁업체 |
instructorId | UUID | 예 | 알림 수신 강사 |
publicationId | UUID | 예 | 알림을 만든 시간표 공개 |
type | 열거형 | 예 | TIMETABLE_PUBLISHED 또는 TIMETABLE_CHANGED |
title | 문자열 | 예 | 알림 제목 |
message | 문자열 | 예 | 강사가 읽을 알림 내용 |
readAt | 일시 | 아니오 | 강사가 읽은 시각 |
createdAt | 일시 | 예 | 알림 생성 시각 |
- 첫 공개 시 영향을 받는 강사에게
TIMETABLE_PUBLISHED 알림을 만든다.
- 공개된 시간표가 변경되어 다시 공개되면 변경된 수업의 강사에게
TIMETABLE_CHANGED 알림을 만든다.
- 강사는 자신에게 연결된
instructorId의 알림만 조회할 수 있다. (FR-017, FR-018)
4.5 강사 초대
InstructorInvitation — 강사 초대
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 초대 식별값 |
agencyId | UUID | 예 | 위탁업체 |
instructorId | UUID | 예 | 초대 대상 강사 |
tokenHash | 문자열 | 예 | 초대 링크 토큰의 해시값 |
status | 열거형 | 예 | PENDING, ACCEPTED, EXPIRED, REVOKED |
expiresAt | 일시 | 예 | 초대 링크 만료 시각 |
acceptedAt | 일시 | 아니오 | 강사가 초대를 수락한 시각 |
createdByMemberId | UUID | 예 | 초대를 만든 운영자 |
createdAt | 일시 | 예 | 초대 생성 시각 |
처리 규칙
- 초대 링크에는 원문 토큰을 사용하되 데이터베이스에는
tokenHash만 저장한다.
- 강사가 초대를 수락하면
Instructor.userId를 로그인 계정과 연결하고 초대 상태를 ACCEPTED로 바꾼다.
- 이미 다른 사용자 계정에 연결된 강사에게는 중복 연결하지 않는다.
- 초대 링크 전달은 운영자가 링크를 복사해 직접 전달하는 방식까지 지원한다. 카카오톡 자동 발송은 첫 개발 범위에 넣지 않는다. (FR-015)
4.6 엑셀 임시 등록
ImportBatch — 엑셀 등록 묶음
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 업로드 작업 식별값 |
agencyId | UUID | 예 | 위탁업체 |
uploadedByMemberId | UUID | 예 | 업로드한 운영자 |
originalFilename | 문자열 | 예 | 사용자가 확인할 파일명 |
status | 열거형 | 예 | UPLOADED, VALIDATING, READY, CONFIRMED, DISCARDED, FAILED |
createdAt | 일시 | 예 | 업로드 시각 |
confirmedAt | 일시 | 아니오 | 수업 계획 확정 시각 |
ImportRow — 엑셀 행
| 필드 | 형식 | 필수 | 설명 |
|---|
id | UUID | 예 | 행 식별값 |
importBatchId | UUID | 예 | 소속 엑셀 등록 묶음 |
rowNumber | 숫자 | 예 | 원본 엑셀 행 번호 |
schoolNameInput | 문자열 | 아니오 | 업로드·수정된 학교명 |
semesterNameInput | 문자열 | 아니오 | 업로드·수정된 학기명 |
instructorNameInput | 문자열 | 아니오 | 업로드·수정된 강사명 |
subjectInput | 문자열 | 아니오 | 업로드·수정된 과목 |
dayOfWeekInput | 열거형 | 아니오 | 업로드·수정된 요일 |
operatingStartDateInput | 날짜 | 아니오 | 운영 기간 시작일 |
operatingEndDateInput | 날짜 | 아니오 | 운영 기간 종료일 |
periodNameInput | 문자열 | 아니오 | 입력 교시명 |
actualStartTimeInput | 시각 | 아니오 | 입력 시작 시각 |
actualEndTimeInput | 시각 | 아니오 | 입력 종료 시각 |
selectedSchoolId | UUID | 아니오 | 운영자가 확정한 학교 |
selectedSemesterId | UUID | 아니오 | 운영자가 확정한 학기 |
selectedInstructorId | UUID | 아니오 | 운영자가 확정한 강사 |
validationStatus | 열거형 | 예 | VALID, WARNING, ERROR |
validationErrors | JSON | 아니오 | 오류 코드, 오류 문구, 대상 필드 |
updatedAt | 일시 | 예 | 마지막 수정 시각 |
저장 방식
- 원본 엑셀 파일 자체는 장기 보관하지 않는다.
- 서비스는 읽어낸 행과 검증 결과만 임시로 저장한다.
- 운영자가 확정하면 행별 데이터를
ClassPlan으로 저장하고 등록 묶음 상태를 CONFIRMED로 바꾼다.
- 사용자가 취소하거나 업로드 처리에 실패하면 임시 행을 폐기한다.
- 장기간 미확정 상태인 임시 데이터의 보관 기간은 실제 운영 정책을 정한 뒤 적용해야 한다. 확인 필요. (FR-007, FR-008, NFR-005, NFR-006)
5. 수업 계획 생명주기
Temporary는 강사에게 아직 보이지 않는 임시 배정이다.
Confirmed는 서로 다른 학교 간 시간 중복이 없고, 필요한 같은 학교 시간 겹침 예외 사유가 확인된 상태다.
Published는 원본 수업 계획 상태가 아니라 ConfirmedTimetable, TimetablePublication으로 남기는 공개 이력이다. 수업이 바뀌면 새 공개본을 만들고, 이전 공개본은 Archived 성격의 이력으로 조회한다. (FR-014, FR-019)
6. 시간 중복 검사와 데이터베이스 구현 기준
6.1 판정 조건
같은 강사의 두 수업 계획이 아래 조건을 모두 만족하면 시간 중복이다.
- 같은 위탁업체에 속한다.
- 같은 강사 고유 식별값을 가진다.
- 요일이 같다.
- 운영 기간이 겹친다.
- 실제 시작·종료 시각이 겹친다.
기존 수업 시작 < 새 수업 종료
그리고
새 수업 시작 < 기존 수업 종료
서로 다른 학교에서 발생한 시간 중복은 저장할 수 없다. (FR-010)
6.2 저장 처리 기준
| 상황 | 처리 | 관련 요구사항 |
|---|
| 다른 학교, 같은 강사, 실제 시간 겹침 | 저장 차단 후 충돌한 두 수업을 나란히 표시 | FR-010, FR-011 |
| 같은 학교, 같은 강사, 실제 시간 겹침 | 사유를 입력한 같은 학교 시간 겹침 예외가 있을 때만 저장 | FR-012 |
| 시간이 맞닿기만 함 | 예: 14:00~14:40, 14:40~15:20은 저장 허용 | FR-010 |
| 운영 기간이 겹치지 않음 | 저장 허용 | FR-010 |
| 이름은 같지만 다른 강사 식별값 | 저장 허용 | FR-004, FR-010 |
| 공개 직전 새 충돌 발견 | 확정·공개 차단, 충돌 수업 수정 화면으로 이동 | FR-014 |
6.3 서버와 데이터베이스 이중 차단
운영자 화면의 경고만으로는 동시에 저장하는 상황을 막을 수 없다. 따라서 다음 두 단계를 모두 적용한다.
-
서버 검증
수업 계획 저장 요청마다 충돌 후보를 조회하고, 충돌 수업과 수정 가능한 항목을 화면에 반환한다.
-
데이터베이스 검증
저장 직전에도 동일한 충돌 검사를 수행한다. 동시에 두 운영자가 저장해도 서로 다른 학교의 중복 수업이 남지 않게 한다.
Prisma 모델만으로는 “날짜 범위와 시각 범위가 겹치는 행 금지” 및 “같은 학교 예외 사유가 있을 때만 허용” 조건을 완전히 표현하기 어렵다. PostgreSQL 데이터베이스에서는 별도 마이그레이션으로 트리거 또는 제약 조건을 추가해야 한다. (NFR-002)
7. 주요 인덱스와 유일성 조건
| 대상 표 | 인덱스 또는 제약 조건 | 이유 | 관련 요구사항 |
|---|
AgencyMember | 유일: agencyId + userId | 운영자 권한 중복 방지 | FR-001, FR-002 |
School | 유일: agencyId + name | 같은 위탁업체 안 학교명 중복 경고 | FR-003 |
Instructor | 인덱스: agencyId + name | 강사 목록 검색과 엑셀 이름 후보 검색 | FR-004, FR-008 |
Semester | 유일: schoolId + name | 학교별 학기 중복 방지 | FR-005 |
PeriodEntry | 유일: periodTemplateId + dayOfWeek + periodName | 같은 교시 템플릿 안 교시명 중복 방지 | FR-005 |
ClassPlan | 인덱스: agencyId + instructorId + dayOfWeek | 동일 강사 시간 중복 후보 조회 | FR-010, NFR-002 |
ClassPlan | 인덱스: schoolId + semesterId + dayOfWeek | 학교별 시간표 조회 | FR-009, FR-013 |
ClassPlan | 인덱스: agencyId + semesterId + schoolId + instructorId | 전체 시간표 필터링 | FR-013 |
SameSchoolOverlapException | 유일: 수업 계획 쌍의 정규화된 조합 | 같은 두 수업의 예외 기록 중복 방지 | FR-012, FR-019 |
ConfirmedTimetableItem | 유일: confirmedTimetableId + classPlanId | 한 확정 시간표에 같은 수업 중복 포함 방지 | FR-014 |
Notification | 인덱스: instructorId + readAt + createdAt | 강사 알림함의 읽지 않은 알림·최신순 조회 | FR-018 |
InstructorInvitation | 인덱스: instructorId + status | 강사 목록에서 초대 상태 조회 | FR-015 |
ImportRow | 유일: importBatchId + rowNumber | 엑셀 행 번호 중복 방지 | FR-007, FR-008 |
8. Prisma 모델 초안
아래 초안은 PostgreSQL과 Prisma를 기준으로 한다. 시간 중복의 최종 차단 규칙은 Prisma 스키마 외에 데이터베이스 마이그레이션으로 구현한다. (NFR-002)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum AgencyRole {
OPERATOR
}
enum DayOfWeek {
MON
TUE
WED
THU
FRI
SAT
SUN
}
enum ClassPlanStatus {
TEMPORARY
CONFIRMED
}
enum TimeSource {
PERIOD_TEMPLATE
MANUAL
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum NotificationType {
TIMETABLE_PUBLISHED
TIMETABLE_CHANGED
}
enum ImportBatchStatus {
UPLOADED
VALIDATING
READY
CONFIRMED
DISCARDED
FAILED
}
enum ImportRowStatus {
VALID
WARNING
ERROR
}
model Agency {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members AgencyMember[]
schools School[]
instructors Instructor[]
classPlans ClassPlan[]
overlapExceptions SameSchoolOverlapException[]
confirmedTimetables ConfirmedTimetable[]
publications TimetablePublication[]
notifications Notification[]
invitations InstructorInvitation[]
importBatches ImportBatch[]
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
displayName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships AgencyMember[]
instructorLinks Instructor[]
}
model AgencyMember {
id String @id @default(uuid())
agencyId String
userId String
role AgencyRole @default(OPERATOR)
createdAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdTimetables ConfirmedTimetable[]
createdPublications TimetablePublication[]
createdExceptions SameSchoolOverlapException[]
createdInvitations InstructorInvitation[]
uploadedBatches ImportBatch[]
@@unique([agencyId, userId])
@@index([userId])
}
model School {
id String @id @default(uuid())
agencyId String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
semesters Semester[]
classPlans ClassPlan[]
@@unique([agencyId, name])
@@index([agencyId])
}
model Instructor {
id String @id @default(uuid())
agencyId String
userId String?
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
classPlans ClassPlan[]
invitations InstructorInvitation[]
notifications Notification[]
timetableItems ConfirmedTimetableItem[]
@@index([agencyId, name])
@@index([userId])
}
model Semester {
id String @id @default(uuid())
schoolId String
name String
startDate DateTime @db.Date
endDate DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)
periodTemplates PeriodTemplate[]
classPlans ClassPlan[]
@@unique([schoolId, name])
@@index([schoolId, startDate, endDate])
}
model PeriodTemplate {
id String @id @default(uuid())
semesterId String
name String
effectiveStartDate DateTime @db.Date
effectiveEndDate DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
semester Semester @relation(fields: [semesterId], references: [id], onDelete: Cascade)
entries PeriodEntry[]
@@index([semesterId, effectiveStartDate, effectiveEndDate])
}
model PeriodEntry {
id String @id @default(uuid())
periodTemplateId String
dayOfWeek DayOfWeek
periodName String
startTime DateTime @db.Time(0)
endTime DateTime @db.Time(0)
sortOrder Int
periodTemplate PeriodTemplate @relation(fields: [periodTemplateId], references: [id], onDelete: Cascade)
classPlans ClassPlan[]
@@unique([periodTemplateId, dayOfWeek, periodName])
@@index([periodTemplateId, dayOfWeek, sortOrder])
}
model ClassPlan {
id String @id @default(uuid())
agencyId String
schoolId String
semesterId String
instructorId String
periodEntryId String?
subject String
dayOfWeek DayOfWeek
operatingStartDate DateTime @db.Date
operatingEndDate DateTime @db.Date
periodName String?
actualStartTime DateTime @db.Time(0)
actualEndTime DateTime @db.Time(0)
timeSource TimeSource
status ClassPlanStatus @default(TEMPORARY)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
school School @relation(fields: [schoolId], references: [id], onDelete: Restrict)
semester Semester @relation(fields: [semesterId], references: [id], onDelete: Restrict)
instructor Instructor @relation(fields: [instructorId], references: [id], onDelete: Restrict)
periodEntry PeriodEntry? @relation(fields: [periodEntryId], references: [id], onDelete: SetNull)
exceptionAsA SameSchoolOverlapException[] @relation("ExceptionClassPlanA")
exceptionAsB SameSchoolOverlapException[] @relation("ExceptionClassPlanB")
timetableItems ConfirmedTimetableItem[]
@@index([agencyId, instructorId, dayOfWeek])
@@index([schoolId, semesterId, dayOfWeek])
@@index([agencyId, semesterId, schoolId, instructorId])
}
model SameSchoolOverlapException {
id String @id @default(uuid())
agencyId String
schoolId String
classPlanAId String
classPlanBId String
reason String
createdByMemberId String
createdAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
school School @relation(fields: [schoolId], references: [id], onDelete: Restrict)
classPlanA ClassPlan @relation("ExceptionClassPlanA", fields: [classPlanAId], references: [id], onDelete: Cascade)
classPlanB ClassPlan @relation("ExceptionClassPlanB", fields: [classPlanBId], references: [id], onDelete: Cascade)
createdByMember AgencyMember @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)
@@index([agencyId, schoolId])
@@index([classPlanAId])
@@index([classPlanBId])
}
model ConfirmedTimetable {
id String @id @default(uuid())
agencyId String
title String
createdByMemberId String
createdAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
createdByMember AgencyMember @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)
items ConfirmedTimetableItem[]
publication TimetablePublication?
}
model ConfirmedTimetableItem {
id String @id @default(uuid())
confirmedTimetableId String
classPlanId String
instructorId String
schoolNameSnapshot String
subjectSnapshot String
dayOfWeekSnapshot DayOfWeek
periodNameSnapshot String?
actualStartTimeSnapshot DateTime @db.Time(0)
actualEndTimeSnapshot DateTime @db.Time(0)
operatingStartDateSnapshot DateTime @db.Date
operatingEndDateSnapshot DateTime @db.Date
confirmedTimetable ConfirmedTimetable @relation(fields: [confirmedTimetableId], references: [id], onDelete: Cascade)
classPlan ClassPlan @relation(fields: [classPlanId], references: [id], onDelete: Restrict)
instructor Instructor @relation(fields: [instructorId], references: [id], onDelete: Restrict)
@@unique([confirmedTimetableId, classPlanId])
@@index([instructorId])
}
model TimetablePublication {
id String @id @default(uuid())
agencyId String
confirmedTimetableId String @unique
publishedByMemberId String
publishedAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
confirmedTimetable ConfirmedTimetable @relation(fields: [confirmedTimetableId], references: [id], onDelete: Restrict)
publishedByMember AgencyMember @relation(fields: [publishedByMemberId], references: [id], onDelete: Restrict)
notifications Notification[]
@@index([agencyId, publishedAt])
}
model Notification {
id String @id @default(uuid())
agencyId String
instructorId String
publicationId String
type NotificationType
title String
message String
readAt DateTime?
createdAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
instructor Instructor @relation(fields: [instructorId], references: [id], onDelete: Cascade)
publication TimetablePublication @relation(fields: [publicationId], references: [id], onDelete: Cascade)
@@index([instructorId, readAt, createdAt])
@@index([agencyId, createdAt])
}
model InstructorInvitation {
id String @id @default(uuid())
agencyId String
instructorId String
tokenHash String @unique
status InvitationStatus @default(PENDING)
expiresAt DateTime
acceptedAt DateTime?
createdByMemberId String
createdAt DateTime @default(now())
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
instructor Instructor @relation(fields: [instructorId], references: [id], onDelete: Cascade)
createdByMember AgencyMember @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)
@@index([instructorId, status])
@@index([agencyId, status])
}
model ImportBatch {
id String @id @default(uuid())
agencyId String
uploadedByMemberId String
originalFilename String
status ImportBatchStatus @default(UPLOADED)
createdAt DateTime @default(now())
confirmedAt DateTime?
agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade)
uploadedByMember AgencyMember @relation(fields: [uploadedByMemberId], references: [id], onDelete: Restrict)
rows ImportRow[]
@@index([agencyId, status, createdAt])
}
model ImportRow {
id String @id @default(uuid())
importBatchId String
rowNumber Int
schoolNameInput String?
semesterNameInput String?
instructorNameInput String?
subjectInput String?
dayOfWeekInput DayOfWeek?
operatingStartDateInput DateTime? @db.Date
operatingEndDateInput DateTime? @db.Date
periodNameInput String?
actualStartTimeInput DateTime? @db.Time(0)
actualEndTimeInput DateTime? @db.Time(0)
selectedSchoolId String?
selectedSemesterId String?
selectedInstructorId String?
validationStatus ImportRowStatus @default(ERROR)
validationErrors Json?
updatedAt DateTime @updatedAt
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
selectedSchool School? @relation(fields: [selectedSchoolId], references: [id], onDelete: SetNull)
selectedSemester Semester? @relation(fields: [selectedSemesterId], references: [id], onDelete: SetNull)
selectedInstructor Instructor? @relation(fields: [selectedInstructorId], references: [id], onDelete: SetNull)
@@unique([importBatchId, rowNumber])
@@index([importBatchId, validationStatus])
}
9. 화면별 사용 데이터
| 화면 | 읽는 데이터 | 생성·수정 데이터 | 관련 요구사항 |
|---|
| 운영자 로그인 및 업체 시작 화면 | User, AgencyMember | User, Agency, AgencyMember | UIR-001, FR-001, FR-002 |
| 운영자 화면 | Agency, School, Instructor, ClassPlan, Notification 요약 | 없음 | UIR-002, FR-002, FR-013 |
| 학교·강사 목록 관리 화면 | School, Instructor, InstructorInvitation | School, Instructor, InstructorInvitation | UIR-003, FR-003, FR-004, FR-015 |
| 학교별 학기·교시표 설정 화면 | School, Semester, PeriodTemplate, PeriodEntry | Semester, PeriodTemplate, PeriodEntry | UIR-004, FR-005 |
| 학교별 수업 및 강사 배정 등록 화면 | School, Semester, Instructor, PeriodEntry, ClassPlan | ClassPlan | UIR-005, FR-009~FR-012 |
| 엑셀 업로드 확인 및 수정 화면 | ImportBatch, ImportRow, School, Semester, Instructor, PeriodEntry | ImportBatch, ImportRow, 확정 시 ClassPlan | UIR-006, FR-006~FR-008 |
| 전체 시간표와 중복 배정 경고 화면 | ClassPlan, SameSchoolOverlapException, School, Instructor, Semester | ClassPlan, SameSchoolOverlapException, ConfirmedTimetable, TimetablePublication | UIR-007, FR-010~FR-014, FR-019 |
| 강사 로그인 및 초대 연결 화면 | InstructorInvitation, Instructor, User | User, Instructor, InstructorInvitation | UIR-008, FR-002, FR-015 |
| 강사 개인 화면 | ConfirmedTimetableItem, TimetablePublication, Notification | 없음 | UIR-009, FR-016~FR-018 |
| 강사 개인 시간표 화면 | 최신 TimetablePublication의 ConfirmedTimetableItem | 없음 | UIR-010, FR-016 |
| 강사 알림함 화면 | Notification | Notification.readAt | UIR-011, FR-017, FR-018 |
10. 개인정보와 보관 기준
| 데이터 | 저장 여부 | 저장 목적 | 처리 기준 | 관련 요구사항 |
|---|
| 운영자·강사 이메일 | 저장 | 로그인, 강사 초대 연결 | 계정 식별과 초대에 필요한 최소 범위만 저장 | FR-001, FR-015, NFR-004 |
| 사용자 표시 이름·강사명 | 저장 | 시간표 표시, 운영자 식별 | 수업 운영에 필요한 범위만 저장 | FR-004, FR-016 |
| 비밀번호 | 원문 저장 금지 | 로그인 인증 | passwordHash만 저장 | NFR-004 |
| 학교·수업·시간표 정보 | 저장 | 배정, 중복 검사, 공개 | 위탁업체별로 분리 | NFR-001 |
| 민감 서류 원본 | 저장하지 않음 | 해당 없음 | 후속 서류 기능이 생겨도 원본 저장 여부는 별도 검토 필요 | NFR-005 |
| 엑셀 원본 파일 | 장기 저장하지 않음 | 업로드 행 검증 | 파일명과 읽어낸 행 데이터만 임시 관리 | FR-007, NFR-005 |
| 공개 시간표 이력·예외 사유 | 저장 | 운영 이력 확인 | 공개본과 예외 기록 조회에 사용 | FR-019 |
계정 탈퇴, 위탁업체 이용 종료, 엑셀 임시 데이터 삭제 시점은 실제 고객 계약과 운영 방식에 맞춰 별도 보관 정책으로 확정해야 한다. 확인 필요.