# 수강신청 시스템 요구사항 - 작성일: 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` 추가 |