26 KiB
수강신청 시스템 요구사항
- 작성일: 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 | 기존 유지 |
[삭제] → LessonSchedule로 이동 |
|||
[삭제] → 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 = 신청자ANDcanceled_at IS NULLANDlesson.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:0011:00 강의와 11:0013: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 >= 1Subject.credit >= 1LessonSchedule.startTime < endTimeSemester.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
{
"lessonId": "b2c4f1a0-..."
}
Response 201 Created
{
"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
{
"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
{
"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 공통 형식
{
"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 |
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 추가 |