Files
2026-09-17 19:35:30 +09:00

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 기존 유지
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 삭제 — 복합키를 쓰지 않는다
  • BaseCreateEntityBaseEntity를 상속하도록 변경 (id + createdAt)
  • UserLessonRepository의 ID 타입: UserLessonIdString

(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: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 >= 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

{
  "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 주의: 강의마다 schedulesenrolledCount를 따로 조회하면 쿼리가 폭발한다. schedules는 fetch join, enrolledCountGROUP 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 왜 시간표를 별도 테이블로 뺐나

LessonInstant 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학년 학생, 대학원생)

기존 DataSeeder2026년 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:0011:00 신청 후 11:0013: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 추가