|
|
|
@@ -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` 추가 |
|
|
|
|
|