chore: entity 추가

This commit is contained in:
DongHyeonka
2026-09-17 19:35:30 +09:00
parent f5508a48ea
commit ed5441ba33
4 changed files with 708 additions and 1 deletions
+631
View File
@@ -0,0 +1,631 @@
# 수강신청 시스템 요구사항
- 작성일: 2026-09-16
- 대상 코드베이스: `course-registration` (Spring Boot 4.1.1 / Java 21 / JPA / H2(local) / PostgreSQL(prod))
- 상태: 설계 확정, 구현 전
---
## 1. 목표와 범위
### 1.1 목표
학생이 개설된 강의에 수강신청하고 취소할 수 있는 API를 만든다. 이 문서의 핵심은 엔드포인트 개수가 아니라 **"어떤 신청을 거부해야 하는가"** 와 **"동시에 신청이 몰릴 때 어떻게 정확성을 지키는가"** 이다.
### 1.2 범위 안 (In scope)
- 수강신청 / 취소 / 내 신청 목록 조회
- 개설 강의 목록 / 상세 조회
- 신청 거부 규칙 8종 (4절)
- 동시 신청 시 정원·학점 정확성 보장 (5절)
### 1.3 범위 밖 (Non-goals)
아래는 **이번에 만들지 않는다.** 필요해지면 별도 문서로 다시 설계한다.
| 항목 | 이유 |
|---|---|
| 인증 / 인가 (로그인, JWT) | 요청에 `userId`를 직접 담는다. 인증은 별도 프로젝트 크기의 일감이고, 수강신청 도메인 로직과 분리 가능하다 |
| 강의 / 과목 / 교수 CRUD | 마스터 데이터는 `DataSeeder`로 투입한다 |
| 신청 건 수정(U) API | 분반 변경은 "취소 후 재신청"이지 신청 건의 수정이 아니다 (7.3 참고) |
| 대기열(waitlist), 장바구니 | 정원 초과 시 즉시 거부한다 |
| 성적, 재수강, 선수과목 | 수강 이력 도메인이 따로 필요하다 |
---
## 2. 용어
| 용어 | 의미 |
|---|---|
| **Subject (과목)** | 교육과정상의 과목. "자료구조", 코드 `CS202`, 3학점. 학기와 무관한 정의 |
| **Lesson (강의)** | 특정 학기에 특정 교수가 여는 Subject의 **분반**. "2026-1학기 자료구조 (최태영)" |
| **LessonSchedule (시간 블록)** | Lesson이 매주 열리는 요일+시각 한 칸. "매주 월요일 09:00~11:00" |
| **Registration (수강신청 건)** | 학생 한 명이 Lesson 하나를 신청한 기록. 엔티티 이름은 `UserLesson` |
| **유효 신청** | `canceledAt IS NULL` 인 수강신청 건. 정원·학점·충돌 계산의 기준 |
| **Semester (학기)** | 신청 기간과 학점 상한을 담는 단위 |
---
## 3. 도메인 모델
### 3.1 관계도
```
Semester 1 ──< N Lesson >── 1 Professor
├── 1 Subject
├──< N LessonSchedule
└──< N UserLesson >── 1 User
```
### 3.2 엔티티별 필드
기존 코드 대비 변경분을 `[신규] [추가] [삭제]`로 표시한다.
#### Semester `[신규]`
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | String (UUID) | PK | `BaseTimeZoneEntity` 상속 |
| name | String | not null, unique | `"2026-1"` |
| startDate | LocalDate | not null | 학기 시작일 |
| endDate | LocalDate | not null | 학기 종료일 |
| registrationStartAt | Instant | not null | 수강신청 시작 시각 |
| registrationEndAt | Instant | not null | 수강신청 마감 시각 |
| maxCredits | Integer | not null | 이 학기 1인당 신청 학점 상한 (예: 18) |
> 학점 상한을 Semester에 두는 이유: "이번 학기는 18학점까지"처럼 학기 단위 정책이기 때문이다. 역할별 차등(대학원생 12학점 등)이 필요해지면 `maxCredits`를 역할별 맵으로 확장한다. 이번엔 단일 값이다.
#### Subject `[수정]`
| 필드 | 타입 | 제약 | 비고 |
|---|---|---|---|
| id, name, code, description | | | 기존 유지 |
| **credit** | Integer | not null, 1 이상 | `[추가]` 학점. 학점 상한 검증의 근거 |
#### Professor
변경 없음.
#### Lesson `[수정]`
| 필드 | 타입 | 제약 | 비고 |
|---|---|---|---|
| id, name | | | 기존 유지 |
| subject | ManyToOne | not null | 기존 유지 |
| professor | ManyToOne | not null | 기존 유지 |
| ~~startTime~~ | ~~Instant~~ | | `[삭제]``LessonSchedule`로 이동 |
| ~~endTime~~ | ~~Instant~~ | | `[삭제]``LessonSchedule`로 이동 |
| **semester** | ManyToOne | not null | `[추가]` 어느 학기 강의인가 |
| **capacity** | Integer | not null, 1 이상 | `[추가]` 정원 |
| **minGrade** | Long | nullable | `[추가]` 이 학년 **이상**만 수강 가능. `null` = 전 학년 |
| **allowedRole** | UserRole | nullable | `[추가]` 이 역할만 수강 가능. `null` = 전체 |
> `startTime/endTime`을 삭제하는 이유: `Instant`는 "2026-03-02T09:00Z"라는 **특정 시점 1회**를 뜻한다. "매주 월요일 9시"라는 반복을 표현할 수 없고, 강의 하나가 월·수 주 2회인 경우도 담을 수 없다.
#### LessonSchedule `[신규]`
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| id | String (UUID) | PK | |
| lesson | ManyToOne | not null | 소속 강의 |
| dayOfWeek | java.time.DayOfWeek | not null | MONDAY ~ FRIDAY |
| startTime | LocalTime | not null | `09:00` |
| endTime | LocalTime | not null | `11:00`. `startTime`보다 커야 한다 |
예시 — "자료구조"가 월·수 주 2회인 경우 행 2개:
```
lesson_schedules
id lesson_id day_of_week start_time end_time
s1 l2 MONDAY 09:00 11:00
s2 l2 WEDNESDAY 09:00 11:00
```
#### User `[수정]`
| 필드 | 타입 | 비고 |
|---|---|---|
| id, name, grade, role | | 기존 유지 |
필드 변경은 없다. 다만 5절에서 **User 행에 비관적 락을 건다.**
#### UserLesson `[수정]`
| 필드 | 타입 | 제약 | 비고 |
|---|---|---|---|
| **id** | String (UUID) | PK | `[추가]` 복합키를 대리키로 교체 |
| user | ManyToOne | not null | `user_id` FK, 기존 유지 |
| lesson | ManyToOne | not null | `lesson_id` FK, 기존 유지 |
| createdAt | Instant | not null | 신청 시각. `BaseCreateEntity` |
| **canceledAt** | Instant | nullable | `[추가]` 취소 시각. `null` = 유효 신청 |
동반 변경:
- `UserLessonId.java` **삭제** — 복합키를 쓰지 않는다
- `BaseCreateEntity``BaseEntity`를 상속하도록 변경 (id + createdAt)
- `UserLessonRepository`의 ID 타입: `UserLessonId``String`
> **`(user_id, lesson_id)`에 UNIQUE 제약을 걸지 않는다.** 취소 후 재신청하면 같은 조합의 행이 여러 개 생기기 때문이다. "유효한 행만 유일" 이라는 부분 유니크 인덱스는 H2가 지원하지 않는다. 중복 신청은 5절의 락이 보장한다.
---
## 4. 비즈니스 규칙
### 4.1 신청 거부 규칙 8종
아래 조건 중 하나라도 걸리면 신청을 거부하고, **가장 먼저 걸린 규칙의 에러 코드 하나만** 반환한다.
| # | 규칙 | 판정 조건 | 에러 코드 | HTTP |
|---|---|---|---|---|
| R1 | 신청 기간 | `now``[registrationStartAt, registrationEndAt]` 밖 | `REGISTRATION_PERIOD_CLOSED` | 409 |
| R2 | 학년 제한 | `lesson.minGrade != null && user.grade < lesson.minGrade` | `NOT_ELIGIBLE_GRADE` | 403 |
| R3 | 역할 제한 | `lesson.allowedRole != null && user.role != lesson.allowedRole` | `NOT_ELIGIBLE_ROLE` | 403 |
| R4 | 중복 신청 | 같은 `lesson`의 유효 신청이 이미 있음 | `ALREADY_REGISTERED` | 409 |
| R5 | 동일 과목 중복 | 같은 학기·같은 `subject`**다른 분반** 유효 신청이 있음 | `DUPLICATE_SUBJECT` | 409 |
| R6 | 시간표 충돌 | 신청 강의의 시간 블록이 기존 유효 신청과 겹침 | `SCHEDULE_CONFLICT` | 409 |
| R7 | 학점 상한 | `기존 유효 신청 학점 합 + 신청 강의 학점 > semester.maxCredits` | `CREDIT_LIMIT_EXCEEDED` | 409 |
| R8 | 정원 초과 | `해당 강의 유효 신청 수 >= lesson.capacity` | `CAPACITY_EXCEEDED` | 409 |
> R4는 R5에 포함되는 관계지만(같은 분반이면 같은 과목이므로) **분리한다.** "이미 신청한 강의입니다"와 "같은 과목의 다른 분반을 이미 신청했습니다"는 학생이 취해야 할 행동이 다르기 때문이다.
### 4.2 R5·R6·R7의 계산 범위
셋 다 "이 학생의 다른 신청"을 봐야 하는 규칙이다. 조회 범위를 명확히 한다.
- 대상: `user_id = 신청자` AND `canceled_at IS NULL` AND `lesson.semester_id = 신청 강의의 학기`
- **다른 학기 신청은 계산에 넣지 않는다.** 2026-1학기 18학점은 2026-2학기 신청과 무관하다.
### 4.3 시간표 충돌 판정
두 시간 블록 A, B가 겹친다는 것은:
```
A.dayOfWeek == B.dayOfWeek
AND A.startTime < B.endTime
AND B.startTime < A.endTime
```
**경계는 겹침이 아니다.** 09:00~11:00 강의와 11:00~13:00 강의는 신청 가능하다. (`A.end == B.start` 이면 두 번째 조건이 거짓)
신청 강의의 시간 블록이 여러 개이므로, **모든 조합**을 검사해서 하나라도 겹치면 거부한다.
### 4.4 취소 규칙
| 규칙 | 조건 | 에러 코드 | HTTP |
|---|---|---|---|
| C1 | 신청 건이 존재하지 않음 | `REGISTRATION_NOT_FOUND` | 404 |
| C2 | 경로의 `userId`와 신청 건의 소유자가 다름 | `REGISTRATION_FORBIDDEN` | 403 |
| C3 | 이미 취소된 건 (`canceledAt != null`) | `ALREADY_CANCELED` | 409 |
| C4 | 신청 기간이 지남 | `REGISTRATION_PERIOD_CLOSED` | 409 |
취소는 행을 삭제하지 않고 `canceledAt = now()`를 기록한다.
### 4.5 데이터 무결성 규칙
마스터 데이터 투입(Seeder) 시 지켜야 할 것. 애플리케이션이 검증하지는 않지만 위반 시 동작이 깨진다.
- `Lesson.capacity >= 1`
- `Subject.credit >= 1`
- `LessonSchedule.startTime < endTime`
- `Semester.registrationStartAt < registrationEndAt`
- 같은 Lesson 안의 시간 블록끼리는 서로 겹치지 않는다
---
## 5. 동시성 설계 — 이 문서에서 가장 중요한 부분
### 5.1 문제
정원 30명, 현재 29명인 강의에 두 학생이 동시에 신청한다.
```
시각 요청 A 요청 B
t1 COUNT -> 29 (29 < 30 통과)
t2 COUNT -> 29 (29 < 30 통과)
t3 INSERT
t4 INSERT
결과: 31명
```
단순 COUNT 검증으로는 막을 수 없다. 읽기와 쓰기 사이에 다른 트랜잭션이 끼어들기 때문이다.
### 5.2 Lesson 락만으로는 부족하다
"신청 시 Lesson 행을 `FOR UPDATE`로 잠근다"는 R8(정원)은 해결하지만, **R5·R6·R7은 여전히 뚫린다.**
R5·R6·R7은 "이 학생의 다른 신청"을 보는 규칙이다. 한 학생이 **서로 다른 두 강의**를 동시에 신청하면 잠기는 Lesson 행이 서로 달라서 두 요청이 나란히 진행된다.
```
학생 u1, 현재 15학점, 상한 18학점
t1 요청 A: lesson_X(3학점) 신청, lessons.X 잠금
t2 요청 B: lesson_Y(3학점) 신청, lessons.Y 잠금 <- 다른 행이라 안 막힘
t3 A: 15 + 3 = 18 <= 18 통과
t4 B: 15 + 3 = 18 <= 18 통과 <- B는 A의 INSERT를 아직 못 봄
t5 둘 다 INSERT -> 21학점
```
시간표 충돌(R6)도 같은 방식으로 뚫린다.
### 5.3 해결: User 락 → Lesson 락, 순서 고정
```
BEGIN
1. SELECT * FROM users WHERE id = :userId FOR UPDATE
-> 같은 학생의 신청 요청을 전부 직렬화한다. R5·R6·R7을 보장
2. SELECT * FROM lessons WHERE id = :lessonId FOR UPDATE
-> 같은 강의의 신청 요청을 전부 직렬화한다. R8을 보장
3. 검증 R4 ~ R8 수행
4. INSERT INTO user_lessons (id, user_id, lesson_id, created_at, canceled_at)
VALUES (:uuid, :userId, :lessonId, now(), NULL)
COMMIT
```
**락 획득 순서를 항상 User → Lesson으로 고정한다.** 순서를 섞으면 데드락이 생긴다.
> 예: 요청 A가 `user1 → lessonX` 순으로 잠그고, 요청 B가 `lessonX → user1` 순으로 잠그면, A는 lessonX를, B는 user1을 서로 기다리며 영원히 멈춘다. 항상 같은 순서로만 잠그면 이 상황이 만들어지지 않는다.
### 5.4 검증 위치 — 락 밖 / 락 안
| 검증 | 위치 | 이유 |
|---|---|---|
| 사용자·강의 존재 | 락 밖 | 없는 리소스에 락을 걸 이유가 없다 |
| R1 신청 기간 | 락 밖 | 다른 트랜잭션의 영향을 받지 않는 값이다 |
| R2 학년 / R3 역할 | 락 밖 | 마찬가지. 신청 중에 변하지 않는다 |
| R4 ~ R8 | **락 안** | 모두 "다른 신청 건의 존재 여부"에 의존한다 |
락 밖 검증을 먼저 하는 이유는 **락을 잡는 시간을 줄이기 위해서**다. 신청 기간이 아닌데 락을 잡고 줄 세울 이유가 없다.
### 5.5 구현 메모
- JPA에서는 Repository 메서드에 `@Lock(LockModeType.PESSIMISTIC_WRITE)`를 붙인다
- 서비스 메서드 전체에 `@Transactional`을 건다. 락은 커밋 시점에 풀린다
- H2와 PostgreSQL 모두 `SELECT ... FOR UPDATE`를 지원한다. local/prod 동작이 같다
- 락 대기 타임아웃을 설정한다 (`jakarta.persistence.lock.timeout`). 무한 대기를 피한다
### 5.6 이 설계의 비용
같은 학생의 신청 요청은 직렬화된다. 같은 강의의 신청 요청도 직렬화된다. **처리량은 떨어진다.** 수강신청은 초당 수만 건을 받아야 하는 도메인이 아니고, 정원 초과와 학점 초과가 실제 사고로 이어지는 도메인이므로 정확성을 택한다.
---
## 6. API 명세
공통:
- Base path: `/api/v1`
- Content-Type: `application/json`
- 시각은 ISO-8601 UTC (`2026-03-02T00:00:00Z`)
- 인증 없음. 신청자는 경로의 `{userId}`로 식별한다
### 6.1 수강신청
```
POST /api/v1/users/{userId}/registrations
```
Request
```json
{
"lessonId": "b2c4f1a0-..."
}
```
Response `201 Created`
```json
{
"registrationId": "e8d1...",
"userId": "a1b2...",
"lessonId": "b2c4...",
"lessonName": "자료구조",
"subjectCode": "CS202",
"credit": 3,
"registeredAt": "2026-02-01T01:00:00Z",
"totalCredits": 18
}
```
`totalCredits`는 신청 후 해당 학기 누적 학점이다. 클라이언트가 재조회 없이 남은 학점을 보여줄 수 있다.
에러: R1~R8 (4.1절 표)
### 6.2 내 신청 목록 조회
```
GET /api/v1/users/{userId}/registrations?semesterId={semesterId}
```
`semesterId` 생략 시 현재 진행 중인 학기(오늘이 `[startDate, endDate]` 안인 학기)를 쓴다.
Response `200 OK`
```json
{
"userId": "a1b2...",
"semesterId": "s1...",
"semesterName": "2026-1",
"totalCredits": 18,
"maxCredits": 18,
"registrations": [
{
"registrationId": "e8d1...",
"lessonId": "b2c4...",
"lessonName": "자료구조",
"subjectCode": "CS202",
"subjectName": "자료구조",
"credit": 3,
"professorName": "최태영",
"schedules": [
{ "dayOfWeek": "MONDAY", "startTime": "09:00", "endTime": "11:00" },
{ "dayOfWeek": "WEDNESDAY", "startTime": "09:00", "endTime": "11:00" }
],
"registeredAt": "2026-02-01T01:00:00Z"
}
]
}
```
**취소된 건은 포함하지 않는다.** (`canceledAt IS NULL`만 조회)
### 6.3 수강신청 취소
```
DELETE /api/v1/users/{userId}/registrations/{registrationId}
```
Response `204 No Content`
에러: C1~C4 (4.4절 표)
### 6.4 개설 강의 목록
```
GET /api/v1/lessons?semesterId={id}&subjectId={id}&page=0&size=20
```
`semesterId` 생략 시 현재 진행 중인 학기. `subjectId`는 선택 필터.
Response `200 OK`
```json
{
"page": 0,
"size": 20,
"totalElements": 3,
"lessons": [
{
"lessonId": "b2c4...",
"lessonName": "자료구조",
"subjectCode": "CS202",
"subjectName": "자료구조",
"credit": 3,
"professorName": "최태영",
"capacity": 30,
"enrolledCount": 29,
"minGrade": 2,
"allowedRole": null,
"schedules": [
{ "dayOfWeek": "MONDAY", "startTime": "09:00", "endTime": "11:00" },
{ "dayOfWeek": "WEDNESDAY", "startTime": "09:00", "endTime": "11:00" }
]
}
]
}
```
> `enrolledCount`는 조회 시점의 스냅샷이다. 표시가 29여도 신청이 `CAPACITY_EXCEEDED`로 실패할 수 있다. 정원의 진짜 판정은 5.3절 락 안에서만 일어난다. 클라이언트는 이 값을 참고용으로만 써야 한다.
**N+1 주의:** 강의마다 `schedules``enrolledCount`를 따로 조회하면 쿼리가 폭발한다. `schedules`는 fetch join, `enrolledCount``GROUP BY lesson_id` 집계 한 방으로 가져온다.
### 6.5 강의 상세
```
GET /api/v1/lessons/{lessonId}
```
Response `200 OK` — 6.4의 항목 하나 + `subjectDescription`, `semesterName`, `registrationStartAt`, `registrationEndAt`
에러: `LESSON_NOT_FOUND` 404
### 6.6 CRUD 매핑
| CRUD | 엔드포인트 |
|---|---|
| Create | 6.1 수강신청 |
| Read | 6.2 내 신청 목록 / 6.4 강의 목록 / 6.5 강의 상세 |
| Update | **없음** — 7.3 참고 |
| Delete | 6.3 취소 (soft delete) |
---
## 7. 설계 판단 근거
설계 리뷰 시 다시 논의될 만한 결정들. 뒤집을 때 무엇을 감수해야 하는지 적어둔다.
### 7.1 왜 시간표를 별도 테이블로 뺐나
`Lesson``Instant startTime/endTime` 한 쌍을 두면 "특정 날짜 1회"만 표현된다. 대학 강의는 (a) 학기 내내 매주 반복되고 (b) 주 2회로 쪼개지는 경우가 흔하다. 두 성질 모두 1:N 테이블이 아니면 담을 수 없다.
대안이었던 "요일 + 교시(정수)"는 겹침 판정이 정수 비교라 더 단순하지만, 교시↔실제 시각 매핑표가 따로 필요하고 30분 단위 변칙 강의를 담지 못한다.
### 7.2 왜 UNIQUE 제약 대신 락에 의존하나
`(user_id, lesson_id)` UNIQUE는 취소 후 재신청을 막아버린다. "유효한 행만 유일"이라는 부분 유니크 인덱스는 PostgreSQL에는 있지만 **H2에는 없어서** local과 prod의 동작이 갈린다. 5.3절의 User 락이 같은 학생의 신청을 직렬화하므로 중복은 애플리케이션 레벨에서 확실히 막힌다.
뒤집을 경우: `status` 컬럼 방식(복합키 유지 + REGISTERED/CANCELED)으로 가면 DB가 중복을 구조적으로 막아주지만, 신청↔취소 이력이 최신 1건만 남는다.
### 7.3 왜 수정(U) API가 없나
수강신청에서 "A분반을 B분반으로 바꾼다"는 신청 건의 필드 수정이 아니다. **A를 취소해서 정원 1칸을 반납하고, B에 새로 신청해서 정원 1칸을 점유하는 두 개의 사건**이다. 이걸 하나의 UPDATE로 만들면 두 강의의 정원을 동시에 건드리게 되고, 락을 두 Lesson에 걸어야 해서 데드락 위험이 생긴다. 취소 + 신청 두 번의 호출로 두면 각 호출이 5.3절 절차를 그대로 따른다.
뒤집을 경우: "변경" 엔드포인트를 만들려면 Lesson 락을 `lessonId` 오름차순으로 두 개 잡는 규칙을 추가해야 한다.
### 7.4 왜 인증이 없나
이번 목표는 수강신청 도메인 규칙이다. 인증은 그 자체로 별도 설계가 필요하고, 나중에 붙일 때 서비스 계층을 건드리지 않는다(컨트롤러가 `userId`를 어디서 얻느냐만 바뀐다). 지금 넣으면 검증 로직에 도달하기 전에 시간을 다 쓴다.
**보안상 주의:** 현재 설계는 누구든 남의 `userId`로 신청·취소할 수 있다. 학습용이라 허용하지만, 공개 배포 대상이 아니다.
---
## 8. 에러 응답
### 8.1 공통 형식
```json
{
"code": "CAPACITY_EXCEEDED",
"message": "정원이 가득 찼습니다. (30/30)",
"timestamp": "2026-02-01T01:00:00Z"
}
```
`@RestControllerAdvice` 하나로 처리한다.
### 8.2 에러 코드 전체
| code | HTTP | 발생 조건 |
|---|---|---|
| `VALIDATION_ERROR` | 400 | 요청 바디 형식 오류 (`lessonId` 누락 등) |
| `USER_NOT_FOUND` | 404 | `userId`에 해당하는 사용자 없음 |
| `LESSON_NOT_FOUND` | 404 | `lessonId`에 해당하는 강의 없음 |
| `SEMESTER_NOT_FOUND` | 404 | `semesterId` 없음 / 진행 중인 학기 없음 |
| `REGISTRATION_NOT_FOUND` | 404 | `registrationId` 없음 |
| `NOT_ELIGIBLE_GRADE` | 403 | R2 학년 미달 |
| `NOT_ELIGIBLE_ROLE` | 403 | R3 역할 불일치 |
| `REGISTRATION_FORBIDDEN` | 403 | C2 남의 신청 건 취소 시도 |
| `REGISTRATION_PERIOD_CLOSED` | 409 | R1 / C4 신청 기간 밖 |
| `ALREADY_REGISTERED` | 409 | R4 같은 강의 중복 |
| `DUPLICATE_SUBJECT` | 409 | R5 같은 과목 다른 분반 |
| `SCHEDULE_CONFLICT` | 409 | R6 시간표 충돌 |
| `CREDIT_LIMIT_EXCEEDED` | 409 | R7 학점 상한 초과 |
| `CAPACITY_EXCEEDED` | 409 | R8 정원 초과 |
| `ALREADY_CANCELED` | 409 | C3 이미 취소된 건 |
| `LOCK_TIMEOUT` | 503 | 락 대기 타임아웃 (5.5절) |
`message`는 한국어로, 가능하면 **숫자를 포함한다.** "정원이 찼습니다"보다 "정원이 가득 찼습니다. (30/30)"가, "학점 초과"보다 "신청 학점이 상한을 넘습니다. (18 + 3 > 18)"가 학생이 다음 행동을 정하는 데 쓸모 있다.
---
## 9. 시드 데이터 요구사항
`DataSeeder`(profile: `local`)가 아래를 만들어야 한다. 10절의 테스트 시나리오가 이 데이터를 전제한다.
- Semester 1개: `2026-1`, 신청 기간이 **현재 시각을 포함**하도록 (`now - 1일` ~ `now + 7일`), `maxCredits = 18`
- Professor 3명 (기존 유지)
- Subject 3개 + `credit` (예: 3, 3, 3)
- Lesson 3개 이상, 아래 조건을 만족하도록 구성:
- **시간표가 겹치는 강의 쌍**이 최소 1개 (R6 테스트용)
- **같은 Subject의 다른 분반**이 최소 1쌍 (R5 테스트용)
- **정원이 1인 강의**가 최소 1개 (R8 테스트용)
- **`minGrade = 3`인 강의**가 최소 1개 (R2 테스트용)
- **`allowedRole = POSTGRADUATE`인 강의**가 최소 1개 (R3 테스트용)
- User 3명 (기존 유지: 1학년 학생, 2학년 학생, 대학원생)
> 기존 `DataSeeder`는 `2026년 3월`로 하드코딩된 `kst()` 헬퍼를 쓴다. 신청 기간은 **실행 시각 기준 상대값**으로 바꿔야 한다. 고정 날짜로 두면 시간이 지나면서 모든 신청이 `REGISTRATION_PERIOD_CLOSED`로 실패한다.
>
> `Instant base` 변수는 현재 선언만 되고 쓰이지 않는다. 정리 대상이다.
---
## 10. 테스트 시나리오
구현 시 이 목록이 테스트 케이스가 된다. 각 항목은 **하나의 규칙만** 검증한다.
### 10.1 정상 경로
| # | 시나리오 | 기대 |
|---|---|---|
| T1 | 조건을 모두 만족하는 신청 | 201, `user_lessons` 행 1개 생성 |
| T2 | 신청 후 목록 조회 | 200, 해당 건 포함, `totalCredits` 일치 |
| T3 | 신청 후 취소 | 204, `canceledAt` 세팅됨 |
| T4 | 취소 후 같은 강의 재신청 | 201, 행이 2개(취소 1 + 유효 1) |
| T5 | 취소 후 목록 조회 | 취소 건은 목록에 없음 |
| T6 | 정원이 찬 강의를 누가 취소하면 | 다음 신청이 성공 |
### 10.2 거부 경로 — 규칙별 1개씩
| # | 시나리오 | 기대 |
|---|---|---|
| T7 | 신청 기간 전/후에 신청 | 409 `REGISTRATION_PERIOD_CLOSED` |
| T8 | 1학년이 `minGrade=3` 강의 신청 | 403 `NOT_ELIGIBLE_GRADE` |
| T9 | 학부생이 `allowedRole=POSTGRADUATE` 강의 신청 | 403 `NOT_ELIGIBLE_ROLE` |
| T10 | 같은 강의 두 번 신청 | 409 `ALREADY_REGISTERED` |
| T11 | 같은 과목의 다른 분반 신청 | 409 `DUPLICATE_SUBJECT` |
| T12 | 시간이 겹치는 강의 신청 | 409 `SCHEDULE_CONFLICT` |
| T13 | 상한을 넘는 학점 신청 | 409 `CREDIT_LIMIT_EXCEEDED` |
| T14 | 정원 1인 강의에 두 번째 신청 | 409 `CAPACITY_EXCEEDED` |
| T15 | 남의 신청 건 취소 | 403 `REGISTRATION_FORBIDDEN` |
| T16 | 이미 취소한 건 또 취소 | 409 `ALREADY_CANCELED` |
### 10.3 경계값
| # | 시나리오 | 기대 |
|---|---|---|
| T17 | 09:00~11:00 신청 후 11:00~13:00 신청 | 201 (경계는 충돌 아님) |
| T18 | 정확히 상한 학점이 되는 신청 (15 + 3 = 18) | 201 |
| T19 | 정원 마지막 1칸 신청 (29/30 → 30/30) | 201 |
| T20 | `registrationEndAt` 정각에 신청 | 201 (경계 포함) |
| T21 | 요일은 겹치는데 시간은 안 겹침 (월 09-11 vs 월 13-15) | 201 |
| T22 | 시간은 겹치는데 요일이 다름 (월 09-11 vs 화 09-11) | 201 |
### 10.4 동시성 — 5절 검증
이게 이 프로젝트의 핵심 테스트다. `ExecutorService` + `CountDownLatch`로 동시 요청을 만든다.
| # | 시나리오 | 기대 |
|---|---|---|
| T23 | 정원 1인 강의에 **동시 10명** 신청 | 성공 1건, 실패 9건, 유효 행 정확히 1개 |
| T24 | 정원 30인 강의에 **동시 50명** 신청 | 성공 정확히 30건, 유효 행 30개 |
| T25 | **한 학생**이 서로 다른 강의 2개를 **동시** 신청 (각 3학점, 잔여 3학점) | 성공 1건, 실패 1건 `CREDIT_LIMIT_EXCEEDED`. **5.2절의 구멍이 막혔는지 보는 테스트** |
| T26 | **한 학생**이 시간이 겹치는 두 강의를 **동시** 신청 | 성공 1건, 실패 1건 `SCHEDULE_CONFLICT` |
| T27 | **한 학생**이 같은 강의를 **동시** 2번 신청 | 성공 1건, 실패 1건 `ALREADY_REGISTERED` |
> T25와 T26은 **User 락이 없으면 반드시 실패한다.** 5.2절에서 지적한 구멍을 그대로 재현하는 테스트이므로, 락 설계를 바꿀 때 회귀를 잡아주는 기준선이 된다.
---
## 11. 구현 시 건드릴 파일
| 파일 | 작업 |
|---|---|
| `entity/Semester.java` | 신규 |
| `entity/LessonSchedule.java` | 신규 |
| `entity/Subject.java` | `credit` 추가 |
| `entity/Lesson.java` | `semester`/`capacity`/`minGrade`/`allowedRole` 추가, `startTime`/`endTime` 제거 |
| `entity/UserLesson.java` | 대리키 PK로 변경, `canceledAt` 추가 |
| `entity/UserLessonId.java` | **삭제** |
| `entity/BaseCreateEntity.java` | `BaseEntity` 상속하도록 변경 |
| `repository/SemesterRepository.java` | 신규 |
| `repository/LessonScheduleRepository.java` | 신규 |
| `repository/UserRepository.java` | `findByIdForUpdate` (비관적 락) 추가 |
| `repository/LessonRepository.java` | `findByIdForUpdate` (비관적 락), 목록 조회 쿼리 추가 |
| `repository/UserLessonRepository.java` | ID 타입 `String`으로 변경, 유효 신청 조회 쿼리 추가 |
| `service/CourseRegistrationService.java` | 신청/취소/조회 구현 (5.3절 절차) |
| `controller/CourseRegistrationController.java` | 엔드포인트 5개 |
| `dto/` | `RequestDto`/`ResponseDto` 삭제하고 용도별 DTO로 분리 |
| `exception/` | 신규 — 도메인 예외 + `@RestControllerAdvice` |
| `enums/` | `RegistrationErrorCode` 신규 |
| `seeder/DataSeeder.java` | 9절 데이터로 교체, 신청 기간을 상대 시각으로 |
---
## 12. 열려 있는 항목
구현 중 결정해도 되는 것들. 지금 막히지 않는다.
| 항목 | 메모 |
|---|---|
| 락 타임아웃 값 | 3초로 시작하고 T24에서 조정 |
| 강의 목록 페이지 기본 size | 20 |
| `maxCredits` 역할별 차등 | 단일 값으로 시작. 필요해지면 확장 |
| `minGrade` vs 정확한 학년 지정 | "이 학년 이상"으로 시작. "2~3학년만" 같은 요구가 생기면 `maxGrade` 추가 |
@@ -6,6 +6,8 @@ import lombok.NoArgsConstructor;
import java.time.Instant;
import com.study.course_registration.enums.UserRole;
@Entity
@NoArgsConstructor
@Getter
@@ -23,11 +25,29 @@ public class Lesson extends BaseTimeZoneEntity {
private Instant endTime;
public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime) {
@ManyToOne(fetch = FetchType.LAZY)
@Column (name = "semester_id", nullable = false)
private Semester semester;
@Column (name = "capacity", nullable = false)
private Integer capacity;
@Column (name = "min_grade", nullable = false)
private Long minGrade;
@Column (name = "allowed_role", nullable = false)
private UserRole allowedRole;
public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime, Semester semester, Integer capacity, Long minGrade, UserRole allowedRole) {
this.name = name;
this.subject = subject;
this.professor = professor;
this.startTime = startTime;
this.endTime = endTime;
this.semester = semester;
this.capacity = capacity;
this.minGrade = minGrade;
this.allowedRole = allowedRole;
}
}
@@ -0,0 +1,27 @@
package com.study.course_registration.entity;
import java.time.DayOfWeek;
import java.time.LocalTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
public class LessonSchedule extends BaseTimeZoneEntity {
@ManyToOne(fetch = FetchType.LAZY)
@Column()
private Lesson lesson;
@Column()
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
}
@@ -0,0 +1,29 @@
package com.study.course_registration.entity;
import java.time.Instant;
import java.time.LocalDate;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
public class Semester extends BaseTimeZoneEntity {
private String name;
private LocalDate startDate;
private LocalDate endDate;
private Instant registrationStartAt;
private Instant registrationEndAt;
private Integer maxCredits;
public Semester(String name) {
this.name = name;
}
}