feat : 수강신청 도메인 구성

This commit is contained in:
donghyeon-ka
2026-09-18 15:20:54 +09:00
parent 3e33388e02
commit 70d71008c6
61 changed files with 2835 additions and 258 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.gradle
build
.idea
.vscode
.env
.env.local
*.log
+10
View File
@@ -0,0 +1,10 @@
SPRING_PROFILES_ACTIVE=dev
SERVER_ADDRESS=127.0.0.1
SERVER_PORT=8080
DB_HOST=postgres
DB_PORT=5432
DB_NAME=course_registration
DB_USERNAME=course
DB_PASSWORD=change-me
APP_SEED_ENABLED=true
SWAGGER_ENABLED=true
+6
View File
@@ -35,3 +35,9 @@ out/
### VS Code ###
.vscode/
### Local environment ###
.env
.env.local
.env.*.local
!.env.example
+17
View File
@@ -0,0 +1,17 @@
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /workspace
COPY gradlew gradlew
COPY gradle gradle
COPY build.gradle settings.gradle ./
RUN chmod +x gradlew
COPY src src
RUN ./gradlew bootJar --no-daemon
FROM eclipse-temurin:21-jre-jammy
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /workspace/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+144
View File
@@ -1,2 +1,146 @@
# course-registration
Spring Boot 4.1.1 / Java 21 기반 수강신청 API 예제입니다. 핵심은 CRUD 개수보다 **수강신청 거부 규칙(R1~R8)** 과 **동시 신청 시 정원·학점·시간표 정확성**을 지키는 것입니다.
## Scope
- 수강신청(Create)
- 내 수강신청 목록 / 강의 목록·상세(Read)
- 수강취소(Delete, soft cancel)
- 신청 수정(Update) API 없음 — 취소 후 재신청
- 마스터 데이터 CRUD 없음 — Seeder 사용
- 인증/JWT 없음
- k6 부하 테스트는 후속 범위
상세 규칙은 [`docs/Requirements.md`](docs/Requirements.md)를 기준으로 합니다.
## Run local
```bash
./gradlew bootRun
```
기본 profile은 `local`이며 H2 + Seeder가 자동 활성화됩니다.
- API: `http://127.0.0.1:8080/api/v1`
- Swagger UI: `http://127.0.0.1:8080/swagger-ui.html`
- OpenAPI JSON: `http://127.0.0.1:8080/v3/api-docs`
- Health: `http://127.0.0.1:8080/actuator/health`
- H2 console: `http://127.0.0.1:8080/h2-console`
## Run dev with PostgreSQL
```bash
cp .env.example .env
# 필요한 값 수정
docker compose up --build
```
Compose는 host에 `127.0.0.1`로만 노출합니다.
```bash
docker compose down
# DB volume까지 초기화하려면
docker compose down -v
```
## Profiles
| profile | DB | ddl-auto | Seeder | Swagger |
|---|---|---|---|---|
| local | H2 | create-drop | ON | ON |
| dev | PostgreSQL | update | `APP_SEED_ENABLED` | ON |
| prod | PostgreSQL | validate | OFF | `SWAGGER_ENABLED` 기본 false |
운영 profile의 DB 접속 정보는 반드시 환경변수로 주입합니다.
## API
```text
POST /api/v1/users/{userId}/registrations
GET /api/v1/users/{userId}/registrations?semesterId={semesterId}
DELETE /api/v1/users/{userId}/registrations/{registrationId}
GET /api/v1/lessons?semesterId={id}&subjectId={id}&page=0&size=20
GET /api/v1/lessons/{lessonId}
```
### Concurrency invariant
모든 registration mutation은 아래 순서로 row lock을 획득합니다.
```text
User FOR UPDATE
-> Lesson FOR UPDATE
-> R4~R8 검증
-> INSERT / cancel
```
User lock은 동일 학생의 학점·동일과목·시간표 경쟁을 직렬화하고, Lesson lock은 정원 경쟁을 직렬화합니다. 락 순서는 항상 `User -> Lesson`으로 고정합니다.
## Local seed IDs
Postman과 동일한 고정 ID를 사용합니다.
```text
Semester
00000000-0000-0000-0000-000000000001
1학년 학생
30000000-0000-0000-0000-000000000001
2학년 학생
30000000-0000-0000-0000-000000000002
대학원생
30000000-0000-0000-0000-000000000003
자료구조 01분반
40000000-0000-0000-0000-000000000001
자료구조 02분반 (동일 Subject)
40000000-0000-0000-0000-000000000002
운영체제 (자료구조 01과 월요일 시간 충돌)
40000000-0000-0000-0000-000000000003
정원 1명 강의
40000000-0000-0000-0000-000000000004
최소 3학년 강의
40000000-0000-0000-0000-000000000005
대학원생 전용 강의
40000000-0000-0000-0000-000000000006
월요일 11:00~13:00 경계 테스트 강의
40000000-0000-0000-0000-000000000007
```
## Postman
```text
postman/
course-registration.postman_collection.json
local.postman_environment.json
dev.postman_environment.json
```
`Register lesson` 요청은 성공 시 응답의 `registrationId`를 environment에 자동 저장합니다.
## Tests
```bash
./gradlew test
./gradlew build
```
테스트에는 다음이 포함됩니다.
- R1~R8 규칙 및 경계값
- 신청/조회/취소/재신청
- 취소 이력 유지
- 정원 1명에 동시 10명
- 정원 30명에 동시 50명
- 동일 학생의 학점 한도 경쟁
- 동일 학생의 시간표 충돌 경쟁
- 동일 학생의 같은 강의 중복 경쟁
+3 -1
View File
@@ -28,9 +28,11 @@ dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
//Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
//Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
//Jackson
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
//H2
+44
View File
@@ -0,0 +1,44 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME:-course_registration}
POSTGRES_USER: ${DB_USERNAME:-course}
POSTGRES_PASSWORD: ${DB_PASSWORD:-course}
ports:
- "127.0.0.1:${DB_EXPOSE_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-course} -d ${DB_NAME:-course_registration}"]
interval: 5s
timeout: 3s
retries: 20
app:
build:
context: .
environment:
SPRING_PROFILES_ACTIVE: dev
SERVER_ADDRESS: 0.0.0.0
SERVER_PORT: 8080
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${DB_NAME:-course_registration}
DB_USERNAME: ${DB_USERNAME:-course}
DB_PASSWORD: ${DB_PASSWORD:-course}
APP_SEED_ENABLED: ${APP_SEED_ENABLED:-true}
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-true}
ports:
- "127.0.0.1:${APP_PORT:-8080}:8080"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
interval: 10s
timeout: 3s
retries: 20
volumes:
postgres-data:
@@ -0,0 +1,64 @@
# Course Registration Implementation Plan
> **For agentic workers:** Implement task-by-task with test-first verification for behavior changes.
**Goal:** Implement the Requirements.md course-registration API, concurrency rules, query APIs, seed data, OpenAPI/Postman assets, environment profiles, and Docker Compose while leaving k6 for a later phase.
**Architecture:** Keep transaction/lock orchestration in application services, isolate deterministic registration rules in a validator/schedule checker, use JPA repositories for fixed User -> Lesson pessimistic locking, and separate lesson query concerns from registration mutation concerns. Use bulk schedule/enrollment queries to avoid per-row N+1 behavior.
**Tech Stack:** Java 21, Spring Boot 4.1.1, Spring Data JPA, H2 local/test, PostgreSQL dev/prod, springdoc-openapi 3.x, JUnit 5.
**Spec:** `docs/Requirements.md`
## Global Constraints
- No master-data CRUD APIs.
- No update-registration API; section changes are cancel + re-register.
- No authentication/JWT in this scope.
- Lock order is always User -> Lesson.
- Registration rejection order is R1 through R8 exactly as specified.
- Cancellation rejection order is C1 through C4.
- k6 is explicitly deferred.
- Existing tracked `.env` is not read, printed, or rewritten; provide `.env.example` separately.
---
### Task 1: Normalize persistence model and restore a compilable baseline
- Modify BaseEntity/BaseCreateEntity/BaseTimeZoneEntity, Semester, Subject, Lesson, LessonSchedule, UserLesson.
- Remove composite UserLessonId usage and use String UUID surrogate IDs.
- Add domain integrity checks for capacity, credit, schedule range, and semester ranges.
- Add repository types needed by the new model.
- Verify `compileJava` before adding behavior.
### Task 2: Add registration rule tests and implementation
- Add tests for period boundaries, grade/role, duplicate lesson/subject, schedule overlap boundaries, credit boundary, and capacity boundary.
- Implement RegistrationErrorCode, RegistrationException, ScheduleConflictChecker, and RegistrationValidator.
- Run targeted unit tests red -> green.
### Task 3: Implement transaction and locking flow
- Add User and Lesson pessimistic lock repository methods with 3s lock hints.
- Implement CourseRegistrationService register/cancel/list using fixed User -> Lesson lock ordering.
- Add integration tests for success/cancel/re-register and core rejection paths.
- Add concurrency tests for capacity, same-student credit, schedule, and duplicate requests.
### Task 4: Implement HTTP API and lesson queries
- Add typed DTO records and validation.
- Add registration controller POST/GET/DELETE endpoints.
- Add LessonQueryService and LessonController list/detail endpoints.
- Bulk load schedules and enrollment counts for paged lesson results.
- Add common error response and controller advice.
### Task 5: Seed data and runtime profiles
- Replace DataSeeder with deterministic, idempotent scenario-oriented local/dev seed data controlled by `app.seed.enabled`.
- Split shared/local/dev/prod config; local H2, dev PostgreSQL update, prod PostgreSQL validate.
- Provide Clock bean for deterministic service tests.
### Task 6: Swagger, Postman, Docker
- Upgrade springdoc to Boot-4-compatible 3.x and configure OpenAPI metadata.
- Add Postman collection plus local/dev environments using deterministic seed IDs.
- Add Actuator health, Dockerfile, `.dockerignore`, `.env.example`, and docker-compose with PostgreSQL + app health checks.
### Task 7: Final verification
- Run full `./gradlew test` and `./gradlew build` as managed tasks.
- Validate Docker Compose config when Docker Compose is available.
- Inspect git diff/status and report implemented scope and any remaining environmental limitations.
@@ -0,0 +1,298 @@
{
"info": {
"name": "Course Registration API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Seed dataset 기반 수강신청 API 검증 컬렉션. k6 부하 테스트는 별도 후속 범위입니다."
},
"item": [
{
"name": "Lessons",
"item": [
{
"name": "List lessons",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/v1/lessons?semesterId={{semesterId}}&page=0&size=20",
"host": [
"{{baseUrl}}"
]
}
}
},
{
"name": "Get lesson",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/v1/lessons/{{lessonId}}",
"host": [
"{{baseUrl}}"
]
}
}
}
]
},
{
"name": "Registrations",
"item": [
{
"name": "Register lesson",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{lessonId}}\"}"
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('201 Created', () => pm.response.to.have.status(201));",
"const body = pm.response.json();",
"pm.environment.set('registrationId', body.registrationId);"
],
"type": "text/javascript"
}
}
]
},
{
"name": "My registrations",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations?semesterId={{semesterId}}",
"host": [
"{{baseUrl}}"
]
}
}
},
{
"name": "Cancel registration",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations/{{registrationId}}",
"host": [
"{{baseUrl}}"
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('204 No Content', () => pm.response.to.have.status(204));"
],
"type": "text/javascript"
}
}
]
}
]
},
{
"name": "Error Scenarios",
"item": [
{
"name": "Grade restriction",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{minGradeLessonId}}\"}"
}
}
},
{
"name": "Role restriction",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{postgraduateOnlyLessonId}}\"}"
}
}
},
{
"name": "Capacity first seat",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{studentId}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{capacityOneLessonId}}\"}"
}
}
},
{
"name": "Capacity exceeded",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{capacityOneLessonId}}\"}"
}
}
},
{
"name": "Duplicate registration first",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{dataStructures01Id}}\"}"
}
}
},
{
"name": "Duplicate registration again",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{dataStructures01Id}}\"}"
}
}
},
{
"name": "Duplicate subject other section",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{dataStructures02Id}}\"}"
}
}
},
{
"name": "Schedule conflict",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{baseUrl}}/api/v1/users/{{student2Id}}/registrations",
"host": [
"{{baseUrl}}"
]
},
"body": {
"mode": "raw",
"raw": "{\"lessonId\": \"{{overlapLessonId}}\"}"
}
}
}
]
}
]
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "course-registration-dev",
"values": [
{
"key": "baseUrl",
"value": "http://127.0.0.1:8080",
"enabled": true
},
{
"key": "semesterId",
"value": "00000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "studentId",
"value": "30000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "student2Id",
"value": "30000000-0000-0000-0000-000000000002",
"enabled": true
},
{
"key": "postgraduateId",
"value": "30000000-0000-0000-0000-000000000003",
"enabled": true
},
{
"key": "lessonId",
"value": "40000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "dataStructures01Id",
"value": "40000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "dataStructures02Id",
"value": "40000000-0000-0000-0000-000000000002",
"enabled": true
},
{
"key": "overlapLessonId",
"value": "40000000-0000-0000-0000-000000000003",
"enabled": true
},
{
"key": "capacityOneLessonId",
"value": "40000000-0000-0000-0000-000000000004",
"enabled": true
},
{
"key": "minGradeLessonId",
"value": "40000000-0000-0000-0000-000000000005",
"enabled": true
},
{
"key": "postgraduateOnlyLessonId",
"value": "40000000-0000-0000-0000-000000000006",
"enabled": true
},
{
"key": "boundaryLessonId",
"value": "40000000-0000-0000-0000-000000000007",
"enabled": true
},
{
"key": "registrationId",
"value": "",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "course-registration-local",
"values": [
{
"key": "baseUrl",
"value": "http://127.0.0.1:8080",
"enabled": true
},
{
"key": "semesterId",
"value": "00000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "studentId",
"value": "30000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "student2Id",
"value": "30000000-0000-0000-0000-000000000002",
"enabled": true
},
{
"key": "postgraduateId",
"value": "30000000-0000-0000-0000-000000000003",
"enabled": true
},
{
"key": "lessonId",
"value": "40000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "dataStructures01Id",
"value": "40000000-0000-0000-0000-000000000001",
"enabled": true
},
{
"key": "dataStructures02Id",
"value": "40000000-0000-0000-0000-000000000002",
"enabled": true
},
{
"key": "overlapLessonId",
"value": "40000000-0000-0000-0000-000000000003",
"enabled": true
},
{
"key": "capacityOneLessonId",
"value": "40000000-0000-0000-0000-000000000004",
"enabled": true
},
{
"key": "minGradeLessonId",
"value": "40000000-0000-0000-0000-000000000005",
"enabled": true
},
{
"key": "postgraduateOnlyLessonId",
"value": "40000000-0000-0000-0000-000000000006",
"enabled": true
},
{
"key": "boundaryLessonId",
"value": "40000000-0000-0000-0000-000000000007",
"enabled": true
},
{
"key": "registrationId",
"value": "",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
@@ -0,0 +1,14 @@
package com.study.course_registration.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
@@ -0,0 +1,18 @@
package com.study.course_registration.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@Configuration
public class OpenApiConfig {
@Bean
OpenAPI courseRegistrationOpenApi() {
return new OpenAPI().info(new Info()
.title("Course Registration API")
.version("v1")
.description("수강신청, 취소, 신청 목록 및 개설 강의 조회 API"));
}
}
@@ -1,38 +1,70 @@
package com.study.course_registration.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.dto.registration.RegistrationListResponse;
import com.study.course_registration.service.CourseRegistrationService;
import com.study.course_registration.dto.RequestDto;
import com.study.course_registration.dto.ResponseDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@RestController
@RequestMapping ("/api/v1")
@Validated
@RequestMapping("/api/v1/users/{userId}/registrations")
@Tag(name = "Registrations", description = "수강신청/조회/취소 API")
public class CourseRegistrationController {
private final CourseRegistrationService courseRegistrationService;
public CourseRegistrationController(CourseRegistrationService courseRegistrationService) {
this.courseRegistrationService = courseRegistrationService;
}
@PostMapping("/course-register")
@Operation(summary = "Register a course",
description = "Registers a new course with the provided details.")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "수강신청")
@ApiResponses({
@ApiResponse (responseCode = "200", description = "Course registered successfully"),
@ApiResponse (responseCode = "400", description = "Invalid request data")
@ApiResponse(responseCode = "201", description = "신청 성공"),
@ApiResponse(responseCode = "403", description = "학년 또는 역할 제한", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자 또는 강의 없음", content = @Content),
@ApiResponse(responseCode = "409", description = "기간/중복/시간표/학점/정원 충돌", content = @Content),
@ApiResponse(responseCode = "503", description = "락 대기 타임아웃", content = @Content)
})
public ResponseDto registerCourse(@Validated RequestDto requestDto) {
return courseRegistrationService.registerCourse(requestDto);
public CourseRegistrationResponse register(
@PathVariable String userId,
@Valid @RequestBody CourseRegistrationRequest request) {
return courseRegistrationService.register(userId, request);
}
@GetMapping
@Operation(summary = "내 수강신청 목록 조회")
public RegistrationListResponse getRegistrations(
@PathVariable String userId,
@RequestParam(required = false) String semesterId) {
return courseRegistrationService.getRegistrations(userId, semesterId);
}
@DeleteMapping("/{registrationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "수강신청 취소")
public void cancel(
@PathVariable String userId,
@PathVariable String registrationId) {
courseRegistrationService.cancel(userId, registrationId);
}
}
@@ -0,0 +1,45 @@
package com.study.course_registration.controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.study.course_registration.dto.lesson.LessonDetailResponse;
import com.study.course_registration.dto.lesson.LessonListResponse;
import com.study.course_registration.service.LessonQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@RestController
@Validated
@RequestMapping("/api/v1/lessons")
@Tag(name = "Lessons", description = "개설 강의 조회 API")
public class LessonController {
private final LessonQueryService lessonQueryService;
public LessonController(LessonQueryService lessonQueryService) {
this.lessonQueryService = lessonQueryService;
}
@GetMapping
@Operation(summary = "개설 강의 목록 조회")
public LessonListResponse getLessons(
@RequestParam(required = false) String semesterId,
@RequestParam(required = false) String subjectId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return lessonQueryService.getLessons(semesterId, subjectId, page, size);
}
@GetMapping("/{lessonId}")
@Operation(summary = "개설 강의 상세 조회")
public LessonDetailResponse getLesson(@PathVariable String lessonId) {
return lessonQueryService.getLesson(lessonId);
}
}
@@ -1,7 +0,0 @@
package com.study.course_registration.dto;
public record RequestDto(
) {
}
@@ -1,7 +0,0 @@
package com.study.course_registration.dto;
public record ResponseDto(
) {
}
@@ -0,0 +1,15 @@
package com.study.course_registration.dto;
import java.time.DayOfWeek;
import java.time.LocalTime;
import com.study.course_registration.entity.LessonSchedule;
public record ScheduleResponse(
DayOfWeek dayOfWeek,
LocalTime startTime,
LocalTime endTime) {
public static ScheduleResponse from(LessonSchedule schedule) {
return new ScheduleResponse(schedule.getDayOfWeek(), schedule.getStartTime(), schedule.getEndTime());
}
}
@@ -0,0 +1,25 @@
package com.study.course_registration.dto.lesson;
import java.time.Instant;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.enums.UserRole;
public record LessonDetailResponse(
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
String subjectDescription,
Integer credit,
String professorName,
String semesterName,
Integer capacity,
Long enrolledCount,
Long minGrade,
UserRole allowedRole,
Instant registrationStartAt,
Instant registrationEndAt,
List<ScheduleResponse> schedules) {
}
@@ -0,0 +1,20 @@
package com.study.course_registration.dto.lesson;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.enums.UserRole;
public record LessonItemResponse(
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
Integer credit,
String professorName,
Integer capacity,
Long enrolledCount,
Long minGrade,
UserRole allowedRole,
List<ScheduleResponse> schedules) {
}
@@ -0,0 +1,10 @@
package com.study.course_registration.dto.lesson;
import java.util.List;
public record LessonListResponse(
Integer page,
Integer size,
Long totalElements,
List<LessonItemResponse> lessons) {
}
@@ -0,0 +1,7 @@
package com.study.course_registration.dto.registration;
import jakarta.validation.constraints.NotBlank;
public record CourseRegistrationRequest(
@NotBlank(message = "lessonId는 필수입니다.") String lessonId) {
}
@@ -0,0 +1,14 @@
package com.study.course_registration.dto.registration;
import java.time.Instant;
public record CourseRegistrationResponse(
String registrationId,
String userId,
String lessonId,
String lessonName,
String subjectCode,
Integer credit,
Instant registeredAt,
Integer totalCredits) {
}
@@ -0,0 +1,18 @@
package com.study.course_registration.dto.registration;
import java.time.Instant;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
public record RegistrationItemResponse(
String registrationId,
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
Integer credit,
String professorName,
List<ScheduleResponse> schedules,
Instant registeredAt) {
}
@@ -0,0 +1,12 @@
package com.study.course_registration.dto.registration;
import java.util.List;
public record RegistrationListResponse(
String userId,
String semesterId,
String semesterName,
Integer totalCredits,
Integer maxCredits,
List<RegistrationItemResponse> registrations) {
}
@@ -5,11 +5,23 @@ import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
@Getter
@MappedSuperclass
@EntityListeners (AuditingEntityListener.class)
public class BaseCreateEntity {
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseCreateEntity extends BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
protected BaseCreateEntity() {
}
protected BaseCreateEntity(String id) {
super(id);
}
}
@@ -1,15 +1,31 @@
package com.study.course_registration.entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import lombok.Getter;
@Getter
@MappedSuperclass
public class BaseEntity {
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36, nullable = false, updatable = false)
private String id;
protected BaseEntity() {
}
protected BaseEntity(String id) {
this.id = id;
}
@PrePersist
protected void assignId() {
if (id == null) {
id = UUID.randomUUID().toString();
}
}
}
@@ -5,15 +5,28 @@ import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
@Getter
@MappedSuperclass
@EntityListeners (AuditingEntityListener.class)
public class BaseTimeZoneEntity extends BaseEntity {
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeZoneEntity extends BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
protected BaseTimeZoneEntity() {
}
protected BaseTimeZoneEntity(String id) {
super(id);
}
}
@@ -1,53 +1,74 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Objects;
import com.study.course_registration.enums.UserRole;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@Table(name = "lessons")
public class Lesson extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subject_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_subject"))
private Subject subject;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "professor_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_professor"))
private Professor professor;
private Instant startTime;
private Instant endTime;
@ManyToOne(fetch = FetchType.LAZY)
@Column (name = "semester_id", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_semester"))
private Semester semester;
@Column (name = "capacity", nullable = false)
@Column(nullable = false)
private Integer capacity;
@Column (name = "min_grade", nullable = false)
@Column(name = "min_grade")
private Long minGrade;
@Column (name = "allowed_role", nullable = false)
@Enumerated(EnumType.STRING)
@Column(name = "allowed_role")
private UserRole allowedRole;
public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime, Semester semester, Integer capacity, Long minGrade, UserRole allowedRole) {
public Lesson(String name, Subject subject, Professor professor, Semester semester,
Integer capacity, Long minGrade, UserRole allowedRole) {
this(null, name, subject, professor, semester, capacity, minGrade, allowedRole);
}
public Lesson(String id, String name, Subject subject, Professor professor, Semester semester,
Integer capacity, Long minGrade, UserRole allowedRole) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (capacity == null || capacity < 1) {
throw new IllegalArgumentException("capacity must be at least 1");
}
if (minGrade != null && minGrade < 1) {
throw new IllegalArgumentException("minGrade must be at least 1 when present");
}
this.name = name;
this.subject = subject;
this.professor = professor;
this.startTime = startTime;
this.endTime = endTime;
this.semester = semester;
this.subject = Objects.requireNonNull(subject, "subject");
this.professor = Objects.requireNonNull(professor, "professor");
this.semester = Objects.requireNonNull(semester, "semester");
this.capacity = capacity;
this.minGrade = minGrade;
this.allowedRole = allowedRole;
}
}
@@ -2,26 +2,51 @@ package com.study.course_registration.entity;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.util.Objects;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
@Table(name = "lesson_schedules")
public class LessonSchedule extends BaseTimeZoneEntity {
@ManyToOne(fetch = FetchType.LAZY)
@Column()
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_schedule_lesson"))
private Lesson lesson;
@Column()
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private DayOfWeek dayOfWeek;
@Column(nullable = false)
private LocalTime startTime;
@Column(nullable = false)
private LocalTime endTime;
public LessonSchedule(Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this(null, lesson, dayOfWeek, startTime, endTime);
}
public LessonSchedule(String id, Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
super(id);
this.lesson = Objects.requireNonNull(lesson, "lesson");
this.dayOfWeek = Objects.requireNonNull(dayOfWeek, "dayOfWeek");
this.startTime = Objects.requireNonNull(startTime, "startTime");
this.endTime = Objects.requireNonNull(endTime, "endTime");
if (!startTime.isBefore(endTime)) {
throw new IllegalArgumentException("startTime must be before endTime");
}
}
}
@@ -1,6 +1,8 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -9,9 +11,18 @@ import lombok.NoArgsConstructor;
@Getter
@Table(name = "professors")
public class Professor extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
public Professor(String name) {
this(null, name);
}
public Professor(String id, String name) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
this.name = name;
}
}
@@ -2,28 +2,67 @@ package com.study.course_registration.entity;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@NoArgsConstructor
@Table(name = "semesters", uniqueConstraints = @UniqueConstraint(name = "uk_semester_name", columnNames = "name"))
public class Semester extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private LocalDate startDate;
@Column(nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private Instant registrationStartAt;
@Column(nullable = false)
private Instant registrationEndAt;
@Column(nullable = false)
private Integer maxCredits;
public Semester(String name) {
this.name = name;
public Semester(String name, LocalDate startDate, LocalDate endDate,
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
this(null, name, startDate, endDate, registrationStartAt, registrationEndAt, maxCredits);
}
public Semester(String id, String name, LocalDate startDate, LocalDate endDate,
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
super(id);
this.name = requireText(name, "name");
this.startDate = Objects.requireNonNull(startDate, "startDate");
this.endDate = Objects.requireNonNull(endDate, "endDate");
this.registrationStartAt = Objects.requireNonNull(registrationStartAt, "registrationStartAt");
this.registrationEndAt = Objects.requireNonNull(registrationEndAt, "registrationEndAt");
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("endDate must be on or after startDate");
}
if (!registrationStartAt.isBefore(registrationEndAt)) {
throw new IllegalArgumentException("registrationStartAt must be before registrationEndAt");
}
if (maxCredits == null || maxCredits < 1) {
throw new IllegalArgumentException("maxCredits must be at least 1");
}
this.maxCredits = maxCredits;
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
}
@@ -1,23 +1,48 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@Table(name = "subjects")
@Table(name = "subjects", uniqueConstraints = @UniqueConstraint(name = "uk_subject_code", columnNames = "code"))
public class Subject extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String code;
@Column(nullable = false, length = 1000)
private String description;
public Subject(String name, String code, String description) {
this.name = name;
this.code = code;
this.description = description;
@Column(nullable = false)
private Integer credit;
public Subject(String name, String code, String description, Integer credit) {
this(null, name, code, description, credit);
}
public Subject(String id, String name, String code, String description, Integer credit) {
super(id);
if (credit == null || credit < 1) {
throw new IllegalArgumentException("credit must be at least 1");
}
this.name = requireText(name, "name");
this.code = requireText(code, "code");
this.description = description == null ? "" : description;
this.credit = credit;
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
}
@@ -1,8 +1,14 @@
package com.study.course_registration.entity;
import java.util.Objects;
import com.study.course_registration.enums.UserRole;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -11,16 +17,30 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@Table(name = "users")
public class User extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Long grade;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role;
public User(String name, Long grade, UserRole role) {
this(null, name, grade, role);
}
public User(String id, String name, Long grade, UserRole role) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (grade == null || grade < 1) {
throw new IllegalArgumentException("grade must be at least 1");
}
this.name = name;
this.grade = grade;
this.role = role;
this.role = Objects.requireNonNull(role, "role");
}
}
@@ -1,6 +1,15 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.Objects;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -9,28 +18,35 @@ import lombok.NoArgsConstructor;
@Getter
@Table(name = "user_lessons")
public class UserLesson extends BaseCreateEntity {
@EmbeddedId
private UserLessonId id;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn (
name = "user_id",
foreignKey = @ForeignKey(name = "fk_user_lesson_user_id")
)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_user"))
private User user;
@MapsId("lessonId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn (
name = "lesson_id",
foreignKey = @ForeignKey(name = "fk_user_lesson_lesson_id")
)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_lesson"))
private Lesson lesson;
@Column(name = "canceled_at")
private Instant canceledAt;
public UserLesson(User user, Lesson lesson) {
this.user = user;
this.lesson = lesson;
this(null, user, lesson);
}
public UserLesson(String id, User user, Lesson lesson) {
super(id);
this.user = Objects.requireNonNull(user, "user");
this.lesson = Objects.requireNonNull(lesson, "lesson");
}
public void cancel(Instant canceledAt) {
if (this.canceledAt != null) {
throw new IllegalStateException("registration is already canceled");
}
this.canceledAt = Objects.requireNonNull(canceledAt, "canceledAt");
}
public boolean isCanceled() {
return canceledAt != null;
}
}
@@ -1,21 +0,0 @@
package com.study.course_registration.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Embeddable
@Getter
@NoArgsConstructor
@EqualsAndHashCode
public class UserLessonId implements Serializable {
private String userId;
private String lessonId;
public UserLessonId(String userId, String lessonId) {
this.userId = userId;
this.lessonId = lessonId;
}
}
@@ -0,0 +1,32 @@
package com.study.course_registration.enums;
import org.springframework.http.HttpStatus;
public enum RegistrationErrorCode {
VALIDATION_ERROR(HttpStatus.BAD_REQUEST),
USER_NOT_FOUND(HttpStatus.NOT_FOUND),
LESSON_NOT_FOUND(HttpStatus.NOT_FOUND),
SEMESTER_NOT_FOUND(HttpStatus.NOT_FOUND),
REGISTRATION_NOT_FOUND(HttpStatus.NOT_FOUND),
NOT_ELIGIBLE_GRADE(HttpStatus.FORBIDDEN),
NOT_ELIGIBLE_ROLE(HttpStatus.FORBIDDEN),
REGISTRATION_FORBIDDEN(HttpStatus.FORBIDDEN),
REGISTRATION_PERIOD_CLOSED(HttpStatus.CONFLICT),
ALREADY_REGISTERED(HttpStatus.CONFLICT),
DUPLICATE_SUBJECT(HttpStatus.CONFLICT),
SCHEDULE_CONFLICT(HttpStatus.CONFLICT),
CREDIT_LIMIT_EXCEEDED(HttpStatus.CONFLICT),
CAPACITY_EXCEEDED(HttpStatus.CONFLICT),
ALREADY_CANCELED(HttpStatus.CONFLICT),
LOCK_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE);
private final HttpStatus status;
RegistrationErrorCode(HttpStatus status) {
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}
@@ -0,0 +1,6 @@
package com.study.course_registration.exception;
import java.time.Instant;
public record ErrorResponse(String code, String message, Instant timestamp) {
}
@@ -0,0 +1,54 @@
package com.study.course_registration.exception;
import java.time.Clock;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.study.course_registration.enums.RegistrationErrorCode;
import jakarta.persistence.LockTimeoutException;
import jakarta.persistence.PessimisticLockException;
import jakarta.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private final Clock clock;
public GlobalExceptionHandler(Clock clock) {
this.clock = clock;
}
@ExceptionHandler(RegistrationException.class)
ResponseEntity<ErrorResponse> handleRegistrationException(RegistrationException exception) {
return response(exception.getErrorCode(), exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return response(RegistrationErrorCode.VALIDATION_ERROR, message);
}
@ExceptionHandler({ConstraintViolationException.class, HttpMessageNotReadableException.class})
ResponseEntity<ErrorResponse> handleBadRequest(Exception exception) {
return response(RegistrationErrorCode.VALIDATION_ERROR, "요청 값이 올바르지 않습니다.");
}
@ExceptionHandler({LockTimeoutException.class, PessimisticLockException.class, PessimisticLockingFailureException.class})
ResponseEntity<ErrorResponse> handleLockTimeout(Exception exception) {
return response(RegistrationErrorCode.LOCK_TIMEOUT, "다른 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요.");
}
private ResponseEntity<ErrorResponse> response(RegistrationErrorCode code, String message) {
return ResponseEntity.status(code.getStatus())
.body(new ErrorResponse(code.name(), message, clock.instant()));
}
}
@@ -0,0 +1,16 @@
package com.study.course_registration.exception;
import com.study.course_registration.enums.RegistrationErrorCode;
public class RegistrationException extends RuntimeException {
private final RegistrationErrorCode errorCode;
public RegistrationException(RegistrationErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public RegistrationErrorCode getErrorCode() {
return errorCode;
}
}
@@ -1,9 +1,34 @@
package com.study.course_registration.repository;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.Lesson;
public interface LessonRepository extends JpaRepository<Lesson, String> {
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
public interface LessonRepository extends JpaRepository<Lesson, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select l from Lesson l where l.id = :id")
Optional<Lesson> findByIdForUpdate(@Param("id") String id);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
Page<Lesson> findBySemester_Id(String semesterId, Pageable pageable);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
Page<Lesson> findBySemester_IdAndSubject_Id(String semesterId, String subjectId, Pageable pageable);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
@Query("select l from Lesson l where l.id = :id")
Optional<Lesson> findDetailedById(@Param("id") String id);
}
@@ -0,0 +1,14 @@
package com.study.course_registration.repository;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.study.course_registration.entity.LessonSchedule;
public interface LessonScheduleRepository extends JpaRepository<LessonSchedule, String> {
List<LessonSchedule> findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(String lessonId);
List<LessonSchedule> findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(Collection<String> lessonIds);
}
@@ -0,0 +1,13 @@
package com.study.course_registration.repository;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.study.course_registration.entity.Semester;
public interface SemesterRepository extends JpaRepository<Semester, String> {
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(
LocalDate startDate, LocalDate endDate);
}
@@ -1,10 +1,54 @@
package com.study.course_registration.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.entity.UserLessonId;
public interface UserLessonRepository extends JpaRepository<UserLesson, UserLessonId> {
public interface UserLessonRepository extends JpaRepository<UserLesson, String> {
@EntityGraph(attributePaths = {"lesson", "lesson.subject", "lesson.professor", "lesson.semester"})
@Query("""
select ul from UserLesson ul
where ul.user.id = :userId
and ul.lesson.semester.id = :semesterId
and ul.canceledAt is null
order by ul.createdAt asc
""")
List<UserLesson> findActiveByUserIdAndSemesterId(
@Param("userId") String userId,
@Param("semesterId") String semesterId);
long countByLesson_IdAndCanceledAtIsNull(String lessonId);
@Query("""
select ul.user.id as userId, ul.lesson.id as lessonId
from UserLesson ul
where ul.id = :registrationId
""")
Optional<RegistrationTarget> findTargetById(@Param("registrationId") String registrationId);
@Query("""
select ul.lesson.id as lessonId, count(ul) as enrolledCount
from UserLesson ul
where ul.lesson.id in :lessonIds
and ul.canceledAt is null
group by ul.lesson.id
""")
List<LessonEnrollmentCount> countActiveByLessonIds(@Param("lessonIds") Collection<String> lessonIds);
interface RegistrationTarget {
String getUserId();
String getLessonId();
}
interface LessonEnrollmentCount {
String getLessonId();
long getEnrolledCount();
}
}
@@ -1,9 +1,21 @@
package com.study.course_registration.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.User;
public interface UserRepository extends JpaRepository<User, String> {
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
public interface UserRepository extends JpaRepository<User, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") String id);
}
@@ -1,91 +1,121 @@
package com.study.course_registration.seeder;
import org.springframework.context.annotation.Profile;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.UserRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.repository.UserLessonRepository;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.UserRepository;
@Component
@Profile ("local")
@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true")
public class DataSeeder implements CommandLineRunner {
public static final String SEMESTER_ID = "00000000-0000-0000-0000-000000000001";
public static final String STUDENT_GRADE_1_ID = "30000000-0000-0000-0000-000000000001";
public static final String STUDENT_GRADE_2_ID = "30000000-0000-0000-0000-000000000002";
public static final String POSTGRADUATE_ID = "30000000-0000-0000-0000-000000000003";
public static final String DATA_STRUCTURES_01_ID = "40000000-0000-0000-0000-000000000001";
public static final String DATA_STRUCTURES_02_ID = "40000000-0000-0000-0000-000000000002";
public static final String OVERLAP_OS_ID = "40000000-0000-0000-0000-000000000003";
public static final String CAPACITY_ONE_ID = "40000000-0000-0000-0000-000000000004";
public static final String MIN_GRADE_ID = "40000000-0000-0000-0000-000000000005";
public static final String POSTGRADUATE_ONLY_ID = "40000000-0000-0000-0000-000000000006";
public static final String BOUNDARY_LESSON_ID = "40000000-0000-0000-0000-000000000007";
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final UserRepository userRepository;
private final LessonRepository lessonRepository;
private final SubjectRepository subjectRepository;
private final Clock clock;
private final SemesterRepository semesterRepository;
private final ProfessorRepository professorRepository;
private final UserLessonRepository userLessonRepository;
private final SubjectRepository subjectRepository;
private final LessonRepository lessonRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserRepository userRepository;
public DataSeeder(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
this.userRepository = userRepository;
this.lessonRepository = lessonRepository;
this.subjectRepository = subjectRepository;
public DataSeeder(Clock clock,
SemesterRepository semesterRepository,
ProfessorRepository professorRepository,
SubjectRepository subjectRepository,
LessonRepository lessonRepository,
LessonScheduleRepository lessonScheduleRepository,
UserRepository userRepository) {
this.clock = clock;
this.semesterRepository = semesterRepository;
this.professorRepository = professorRepository;
this.userLessonRepository = userLessonRepository;
this.subjectRepository = subjectRepository;
this.lessonRepository = lessonRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userRepository = userRepository;
}
@Override
@Transactional
public void run(String... args) throws Exception {
if(userRepository.count() > 0) {
public void run(String... args) {
if (semesterRepository.existsById(SEMESTER_ID)) {
return;
}
Instant now = clock.instant();
LocalDate today = LocalDate.now(clock);
Semester semester = semesterRepository.save(new Semester(
SEMESTER_ID, "2026-1", today.minusDays(30), today.plusDays(120),
now.minusSeconds(86_400), now.plusSeconds(7 * 86_400), 18));
List<Professor> professors = professorRepository.saveAll(List.of(
new Professor("김영한"),
new Professor("최태영"),
new Professor("조재한")
));
new Professor("10000000-0000-0000-0000-000000000001", "김영한"),
new Professor("10000000-0000-0000-0000-000000000002", "최태영"),
new Professor("10000000-0000-0000-0000-000000000003", "조재한")));
List<Subject> subjects = subjectRepository.saveAll(List.of(
new Subject("자바 프로그래밍", "CS201", "자바 프로그래밍 기초"),
new Subject("자료구조", "CS202", "자료구조 및 알고리즘"),
new Subject("데이터베이스", "CS203", "데이터베이스 시스템")
));
new Subject("20000000-0000-0000-0000-000000000001", "자바 프로그래밍", "CS201", "자바 프로그래밍 기초", 3),
new Subject("20000000-0000-0000-0000-000000000002", "자료구조", "CS202", "자료구조 및 알고리즘", 3),
new Subject("20000000-0000-0000-0000-000000000003", "데이터베이스", "CS203", "데이터베이스 시스템", 3),
new Subject("20000000-0000-0000-0000-000000000004", "컴퓨터 네트워크", "CS204", "네트워크 기초", 3),
new Subject("20000000-0000-0000-0000-000000000005", "운영체제", "CS301", "운영체제 핵심", 3),
new Subject("20000000-0000-0000-0000-000000000006", "대학원 세미나", "CS401", "대학원 전용 세미나", 3)));
Instant base = Instant.now().truncatedTo(ChronoUnit.HOURS);
List<Lesson> lessons = lessonRepository.saveAll(List.of(
new Lesson("자바 프로그래밍", subjects.get(0), professors.get(0), kst(3, 2, 9), kst(3, 2, 11)),
new Lesson("자료구조", subjects.get(1), professors.get(1), kst(3, 2, 13), kst(3, 2, 17)),
new Lesson("데이터베이스", subjects.get(2), professors.get(2), kst(3, 3, 10), kst(3, 3, 14))
));
Lesson data01 = new Lesson(DATA_STRUCTURES_01_ID, "자료구조 01분반", subjects.get(1), professors.get(0), semester, 30, null, null);
Lesson data02 = new Lesson(DATA_STRUCTURES_02_ID, "자료구조 02분반", subjects.get(1), professors.get(1), semester, 30, null, null);
Lesson os = new Lesson(OVERLAP_OS_ID, "운영체제", subjects.get(4), professors.get(1), semester, 30, null, null);
Lesson capacityOne = new Lesson(CAPACITY_ONE_ID, "데이터베이스 소수정예", subjects.get(2), professors.get(2), semester, 1, null, null);
Lesson minGrade = new Lesson(MIN_GRADE_ID, "고급 네트워크", subjects.get(3), professors.get(2), semester, 30, 3L, null);
Lesson postgraduate = new Lesson(POSTGRADUATE_ONLY_ID, "대학원 세미나", subjects.get(5), professors.get(0), semester, 20, null, UserRole.POSTGRADUATE);
Lesson boundary = new Lesson(BOUNDARY_LESSON_ID, "자바 프로그래밍", subjects.get(0), professors.get(0), semester, 30, null, null);
lessonRepository.saveAll(List.of(data01, data02, os, capacityOne, minGrade, postgraduate, boundary));
List<User> users = userRepository.saveAll(List.of(
new User("학생1", 1L, UserRole.STUDENT),
new User("학생2", 2L, UserRole.STUDENT),
new User("대학원생", 3L, UserRole.POSTGRADUATE)
));
lessonScheduleRepository.saveAll(List.of(
schedule("50000000-0000-0000-0000-000000000001", data01, DayOfWeek.MONDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000002", data02, DayOfWeek.TUESDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000003", os, DayOfWeek.MONDAY, 10, 12),
schedule("50000000-0000-0000-0000-000000000004", capacityOne, DayOfWeek.WEDNESDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000005", minGrade, DayOfWeek.THURSDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000006", postgraduate, DayOfWeek.FRIDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000007", boundary, DayOfWeek.MONDAY, 11, 13)));
List<UserLesson> userLessons = userLessonRepository.saveAll(List.of(
new UserLesson(users.get(0), lessons.get(0)),
new UserLesson(users.get(0), lessons.get(1)),
new UserLesson(users.get(1), lessons.get(1)),
new UserLesson(users.get(2), lessons.get(2))
));
userRepository.saveAll(List.of(
new User(STUDENT_GRADE_1_ID, "학생1", 1L, UserRole.STUDENT),
new User(STUDENT_GRADE_2_ID, "학생2", 2L, UserRole.STUDENT),
new User(POSTGRADUATE_ID, "대학원생", 3L, UserRole.POSTGRADUATE)));
}
private Instant kst(int month, int day, int hour) {
return ZonedDateTime.of(2026, month, day, hour, 0, 0, 0, KST).toInstant();
private static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
}
@@ -1,36 +1,183 @@
package com.study.course_registration.service;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.study.course_registration.dto.RequestDto;
import com.study.course_registration.dto.ResponseDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.dto.registration.RegistrationItemResponse;
import com.study.course_registration.dto.registration.RegistrationListResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.UserLessonRepository;
import com.study.course_registration.repository.UserRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.service.policy.RegistrationValidator;
@Service
public class CourseRegistrationService {
private final Clock clock;
private final UserRepository userRepository;
private final LessonRepository lessonRepository;
private final SubjectRepository subjectRepository;
private final ProfessorRepository professorRepository;
private final SemesterRepository semesterRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserLessonRepository userLessonRepository;
private final RegistrationValidator validator;
public CourseRegistrationService(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
public CourseRegistrationService(Clock clock,
UserRepository userRepository,
LessonRepository lessonRepository,
SemesterRepository semesterRepository,
LessonScheduleRepository lessonScheduleRepository,
UserLessonRepository userLessonRepository,
RegistrationValidator validator) {
this.clock = clock;
this.userRepository = userRepository;
this.lessonRepository = lessonRepository;
this.subjectRepository = subjectRepository;
this.professorRepository = professorRepository;
this.semesterRepository = semesterRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userLessonRepository = userLessonRepository;
this.validator = validator;
}
public ResponseDto registerCourse(RequestDto requestDto) {
@Transactional
public CourseRegistrationResponse register(String userId, CourseRegistrationRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lesson = lessonRepository.findById(request.lessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
return new ResponseDto();
var now = clock.instant();
validator.validatePreconditions(user, lesson, now);
// Lock order is a system invariant: User -> Lesson.
User lockedUser = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lockedLesson = lessonRepository.findByIdForUpdate(request.lessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(
lockedUser.getId(), lockedLesson.getSemester().getId());
Map<String, List<LessonSchedule>> schedules = loadSchedules(activeRegistrations, lockedLesson);
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lockedLesson.getId());
validator.validateLocked(lockedLesson, activeRegistrations, schedules, enrolledCount);
int totalCredits = validator.totalCreditsAfter(lockedLesson, activeRegistrations);
UserLesson registration = userLessonRepository.saveAndFlush(new UserLesson(lockedUser, lockedLesson));
return new CourseRegistrationResponse(
registration.getId(),
lockedUser.getId(),
lockedLesson.getId(),
lockedLesson.getName(),
lockedLesson.getSubject().getCode(),
lockedLesson.getSubject().getCredit(),
registration.getCreatedAt(),
totalCredits);
}
@Transactional(readOnly = true)
public RegistrationListResponse getRegistrations(String userId, String semesterId) {
if (!userRepository.existsById(userId)) {
throw error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.");
}
Semester semester = resolveSemester(semesterId);
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(userId, semester.getId());
Set<String> lessonIds = activeRegistrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, List<LessonSchedule>> schedulesByLesson = groupSchedules(
lessonIds.isEmpty() ? List.of() : lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
List<RegistrationItemResponse> items = activeRegistrations.stream()
.map(registration -> {
Lesson lesson = registration.getLesson();
List<ScheduleResponse> schedules = schedulesByLesson.getOrDefault(lesson.getId(), List.of()).stream()
.map(ScheduleResponse::from)
.toList();
return new RegistrationItemResponse(
registration.getId(), lesson.getId(), lesson.getName(),
lesson.getSubject().getCode(), lesson.getSubject().getName(), lesson.getSubject().getCredit(),
lesson.getProfessor().getName(), schedules, registration.getCreatedAt());
})
.toList();
return new RegistrationListResponse(
userId, semester.getId(), semester.getName(), validator.currentCredits(activeRegistrations),
semester.getMaxCredits(), items);
}
@Transactional
public void cancel(String userId, String registrationId) {
UserLessonRepository.RegistrationTarget target = userLessonRepository.findTargetById(registrationId)
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
if (!target.getUserId().equals(userId)) {
throw error(RegistrationErrorCode.REGISTRATION_FORBIDDEN, "다른 사용자의 수강신청은 취소할 수 없습니다.");
}
// Mutation paths use the same fixed lock order as registration.
userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lesson = lessonRepository.findByIdForUpdate(target.getLessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
UserLesson registration = userLessonRepository.findById(registrationId)
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
if (registration.isCanceled()) {
throw error(RegistrationErrorCode.ALREADY_CANCELED, "이미 취소된 수강신청입니다.");
}
var now = clock.instant();
validator.validateRegistrationPeriod(lesson, now);
registration.cancel(now);
}
private Semester resolveSemester(String semesterId) {
if (semesterId != null && !semesterId.isBlank()) {
return semesterRepository.findById(semesterId)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
}
LocalDate today = LocalDate.now(clock);
return semesterRepository
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
}
private Map<String, List<LessonSchedule>> loadSchedules(List<UserLesson> registrations, Lesson candidate) {
Set<String> lessonIds = registrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
lessonIds.add(candidate.getId());
return groupSchedules(lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
}
private static Map<String, List<LessonSchedule>> groupSchedules(List<LessonSchedule> schedules) {
return schedules.stream().collect(Collectors.groupingBy(
schedule -> schedule.getLesson().getId(),
LinkedHashMap::new,
Collectors.toList()));
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
// 저장 전 log
// 검증 로직
//
@@ -0,0 +1,126 @@
package com.study.course_registration.service;
import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.dto.lesson.LessonDetailResponse;
import com.study.course_registration.dto.lesson.LessonItemResponse;
import com.study.course_registration.dto.lesson.LessonListResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.UserLessonRepository;
@Service
@Transactional(readOnly = true)
public class LessonQueryService {
private final Clock clock;
private final LessonRepository lessonRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserLessonRepository userLessonRepository;
private final SemesterRepository semesterRepository;
public LessonQueryService(Clock clock,
LessonRepository lessonRepository,
LessonScheduleRepository lessonScheduleRepository,
UserLessonRepository userLessonRepository,
SemesterRepository semesterRepository) {
this.clock = clock;
this.lessonRepository = lessonRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userLessonRepository = userLessonRepository;
this.semesterRepository = semesterRepository;
}
public LessonListResponse getLessons(String semesterId, String subjectId, int page, int size) {
Semester semester = resolveSemester(semesterId);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("name"), Sort.Order.asc("id")));
Page<Lesson> lessonPage = subjectId == null || subjectId.isBlank()
? lessonRepository.findBySemester_Id(semester.getId(), pageable)
: lessonRepository.findBySemester_IdAndSubject_Id(semester.getId(), subjectId, pageable);
List<String> lessonIds = lessonPage.getContent().stream().map(Lesson::getId).toList();
Map<String, List<LessonSchedule>> schedules = loadSchedules(lessonIds);
Map<String, Long> counts = loadCounts(lessonIds);
List<LessonItemResponse> items = lessonPage.getContent().stream()
.map(lesson -> toItem(lesson, schedules.getOrDefault(lesson.getId(), List.of()), counts.getOrDefault(lesson.getId(), 0L)))
.toList();
return new LessonListResponse(page, size, lessonPage.getTotalElements(), items);
}
public LessonDetailResponse getLesson(String lessonId) {
Lesson lesson = lessonRepository.findDetailedById(lessonId)
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
List<ScheduleResponse> schedules = lessonScheduleRepository.findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(lessonId)
.stream().map(ScheduleResponse::from).toList();
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonId);
return new LessonDetailResponse(
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
lesson.getSubject().getDescription(), lesson.getSubject().getCredit(), lesson.getProfessor().getName(),
lesson.getSemester().getName(), lesson.getCapacity(), enrolledCount, lesson.getMinGrade(), lesson.getAllowedRole(),
lesson.getSemester().getRegistrationStartAt(), lesson.getSemester().getRegistrationEndAt(), schedules);
}
private LessonItemResponse toItem(Lesson lesson, List<LessonSchedule> schedules, long enrolledCount) {
return new LessonItemResponse(
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
lesson.getSubject().getCredit(), lesson.getProfessor().getName(), lesson.getCapacity(), enrolledCount,
lesson.getMinGrade(), lesson.getAllowedRole(), schedules.stream().map(ScheduleResponse::from).toList());
}
private Map<String, List<LessonSchedule>> loadSchedules(List<String> lessonIds) {
if (lessonIds.isEmpty()) {
return Map.of();
}
return lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds).stream()
.collect(Collectors.groupingBy(
schedule -> schedule.getLesson().getId(), LinkedHashMap::new, Collectors.toList()));
}
private Map<String, Long> loadCounts(List<String> lessonIds) {
if (lessonIds.isEmpty()) {
return Map.of();
}
return userLessonRepository.countActiveByLessonIds(lessonIds).stream()
.collect(Collectors.toMap(
UserLessonRepository.LessonEnrollmentCount::getLessonId,
UserLessonRepository.LessonEnrollmentCount::getEnrolledCount,
(left, right) -> left,
LinkedHashMap::new));
}
private Semester resolveSemester(String semesterId) {
if (semesterId != null && !semesterId.isBlank()) {
return semesterRepository.findById(semesterId)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
}
LocalDate today = LocalDate.now(clock);
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
@@ -0,0 +1,100 @@
package com.study.course_registration.service.policy;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
@Component
public class RegistrationValidator {
private final ScheduleConflictChecker scheduleConflictChecker;
public RegistrationValidator(ScheduleConflictChecker scheduleConflictChecker) {
this.scheduleConflictChecker = scheduleConflictChecker;
}
public void validatePreconditions(User user, Lesson lesson, Instant now) {
validateRegistrationPeriod(lesson, now);
if (lesson.getMinGrade() != null && user.getGrade() < lesson.getMinGrade()) {
throw error(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
"수강 가능한 학년이 아닙니다. (현재 " + user.getGrade() + "학년, 최소 " + lesson.getMinGrade() + "학년)");
}
if (lesson.getAllowedRole() != null && user.getRole() != lesson.getAllowedRole()) {
throw error(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
"수강 가능한 역할이 아닙니다. (필요 역할: " + lesson.getAllowedRole() + ")");
}
}
public void validateRegistrationPeriod(Lesson lesson, Instant now) {
if (now.isBefore(lesson.getSemester().getRegistrationStartAt())
|| now.isAfter(lesson.getSemester().getRegistrationEndAt())) {
throw error(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED, "수강신청 기간이 아닙니다.");
}
}
public void validateLocked(Lesson lesson,
List<UserLesson> activeRegistrations,
Map<String, List<LessonSchedule>> schedulesByLessonId,
long enrolledCount) {
if (activeRegistrations.stream().anyMatch(registration -> registration.getLesson().getId().equals(lesson.getId()))) {
throw error(RegistrationErrorCode.ALREADY_REGISTERED, "이미 신청한 강의입니다.");
}
if (activeRegistrations.stream().anyMatch(registration ->
registration.getLesson().getSubject().getId().equals(lesson.getSubject().getId()))) {
throw error(RegistrationErrorCode.DUPLICATE_SUBJECT, "같은 과목의 다른 분반을 이미 신청했습니다.");
}
List<LessonSchedule> candidateSchedules = schedulesByLessonId.getOrDefault(lesson.getId(), List.of());
for (UserLesson registration : activeRegistrations) {
List<LessonSchedule> existingSchedules = schedulesByLessonId.getOrDefault(registration.getLesson().getId(), List.of());
for (LessonSchedule candidate : candidateSchedules) {
for (LessonSchedule existing : existingSchedules) {
if (scheduleConflictChecker.overlaps(candidate, existing)) {
throw error(RegistrationErrorCode.SCHEDULE_CONFLICT,
"기존 수업과 시간이 겹칩니다. (" + candidate.getDayOfWeek() + " "
+ candidate.getStartTime() + "-" + candidate.getEndTime() + ")");
}
}
}
}
int currentCredits = currentCredits(activeRegistrations);
int requestedCredits = lesson.getSubject().getCredit();
int maxCredits = lesson.getSemester().getMaxCredits();
if (currentCredits + requestedCredits > maxCredits) {
throw error(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
"신청 학점이 상한을 넘습니다. (" + currentCredits + " + " + requestedCredits + " > " + maxCredits + ")");
}
if (enrolledCount >= lesson.getCapacity()) {
throw error(RegistrationErrorCode.CAPACITY_EXCEEDED,
"정원이 가득 찼습니다. (" + enrolledCount + "/" + lesson.getCapacity() + ")");
}
}
public int totalCreditsAfter(Lesson lesson, List<UserLesson> activeRegistrations) {
return currentCredits(activeRegistrations) + lesson.getSubject().getCredit();
}
public int currentCredits(List<UserLesson> activeRegistrations) {
return activeRegistrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getSubject)
.mapToInt(subject -> subject.getCredit())
.sum();
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
@@ -0,0 +1,14 @@
package com.study.course_registration.service.policy;
import org.springframework.stereotype.Component;
import com.study.course_registration.entity.LessonSchedule;
@Component
public class ScheduleConflictChecker {
public boolean overlaps(LessonSchedule left, LessonSchedule right) {
return left.getDayOfWeek() == right.getDayOfWeek()
&& left.getStartTime().isBefore(right.getEndTime())
&& right.getStartTime().isBefore(left.getEndTime());
}
}
+16
View File
@@ -0,0 +1,16 @@
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:course_registration}
username: ${DB_USERNAME:course}
password: ${DB_PASSWORD:course}
jpa:
hibernate:
ddl-auto: update
show-sql: ${JPA_SHOW_SQL:false}
h2:
console:
enabled: false
app:
seed:
enabled: ${APP_SEED_ENABLED:false}
+22 -18
View File
@@ -1,21 +1,25 @@
spring:
datasource:
url: jdbc:h2:mem:course_db
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
url: jdbc:h2:mem:course_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
app:
seed:
enabled: true
logging:
level:
org.hibernate.SQL: debug
level:
org.hibernate.SQL: debug
+22
View File
@@ -0,0 +1,22 @@
spring:
datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
h2:
console:
enabled: false
app:
seed:
enabled: false
springdoc:
api-docs:
enabled: ${SWAGGER_ENABLED:false}
swagger-ui:
enabled: ${SWAGGER_ENABLED:false}
+26 -24
View File
@@ -1,28 +1,30 @@
server:
application:
name: ${APP_NAME:course-registration}
profiles:
active: ${APP_PROFILE:local}
address: ${SERVER_ADDRESS:127.0.0.1}
port: ${SERVER_PORT:8080}
spring:
jackson:
time-zone: UTC
h2:
console:
enabled: true
path: /h2-console
datasource:
url: jdbc:h2:mem:course_db
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
application:
name: ${APP_NAME:course-registration}
profiles:
default: local
jackson:
time-zone: UTC
jpa:
open-in-view: false
logging.level:
org.hibernate.SQL: debug
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
path: /swagger-ui.html
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true
@@ -0,0 +1,202 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.*;
@SpringBootTest
@ActiveProfiles("test")
class CourseRegistrationConcurrencyIntegrationTest {
@Autowired CourseRegistrationService service;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private ExecutorService executor;
private Semester semester;
private Professor professor;
@BeforeEach
void setUp() {
clearData();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
professor = professorRepository.save(new Professor("professor", "교수"));
}
@AfterEach
void tearDown() throws InterruptedException {
if (executor != null) {
executor.shutdownNow();
executor.awaitTermination(5, TimeUnit.SECONDS);
}
}
@Test
void capacityOneWithTenConcurrentUsersHasExactlyOneSuccess() throws Exception {
Lesson lesson = lesson("lesson-cap1", "subject-cap1", 3, 1, DayOfWeek.MONDAY, 9, 11);
List<User> users = users(10, "cap1-user-");
List<RegistrationErrorCode> results = runConcurrent(users.stream()
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
.toList());
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(9);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(1);
}
@Test
void sameStudentConcurrentLessonsCannotExceedCreditLimit() throws Exception {
replaceSemesterWithMaxCredits(3);
User user = userRepository.save(new User("credit-user", "학생", 2L, UserRole.STUDENT));
Lesson lessonA = lesson("credit-a", "credit-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
Lesson lessonB = lesson("credit-b", "credit-subject-b", 3, 30, DayOfWeek.TUESDAY, 9, 11);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED)).hasSize(1);
}
@Test
void sameStudentConcurrentOverlappingLessonsRejectOneConflict() throws Exception {
User user = userRepository.save(new User("schedule-user", "학생", 2L, UserRole.STUDENT));
Lesson lessonA = lesson("schedule-a", "schedule-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
Lesson lessonB = lesson("schedule-b", "schedule-subject-b", 3, 30, DayOfWeek.MONDAY, 10, 12);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.SCHEDULE_CONFLICT)).hasSize(1);
}
@Test
void sameStudentSameLessonConcurrentRequestsRejectDuplicate() throws Exception {
User user = userRepository.save(new User("duplicate-user", "학생", 2L, UserRole.STUDENT));
Lesson lesson = lesson("duplicate-lesson", "duplicate-subject", 3, 30, DayOfWeek.WEDNESDAY, 9, 11);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.ALREADY_REGISTERED)).hasSize(1);
}
@Test
void capacityThirtyWithFiftyConcurrentUsersStopsAtThirty() throws Exception {
Lesson lesson = lesson("lesson-cap30", "subject-cap30", 3, 30, DayOfWeek.THURSDAY, 9, 11);
List<User> users = users(50, "cap30-user-");
List<RegistrationErrorCode> results = runConcurrent(users.stream()
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
.toList());
assertThat(results.stream().filter(code -> code == null)).hasSize(30);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(20);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(30);
}
private void replaceSemesterWithMaxCredits(int maxCredits) {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
semesterRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), maxCredits));
}
private void clearData() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
}
private Lesson lesson(String lessonId, String subjectId, int credit, int capacity,
DayOfWeek day, int startHour, int endHour) {
Subject subject = subjectRepository.save(new Subject(subjectId, subjectId, subjectId, subjectId, credit));
Lesson lesson = lessonRepository.save(new Lesson(lessonId, lessonId, subject, professor, semester, capacity, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-" + lessonId, lesson, day,
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0)));
return lesson;
}
private List<User> users(int count, String prefix) {
List<User> users = new ArrayList<>();
for (int index = 0; index < count; index++) {
users.add(new User(prefix + index, "학생" + index, 2L, UserRole.STUDENT));
}
return userRepository.saveAll(users);
}
private List<RegistrationErrorCode> runConcurrent(List<ThrowingAction> actions) throws Exception {
executor = Executors.newFixedThreadPool(actions.size());
CountDownLatch ready = new CountDownLatch(actions.size());
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(actions.size());
List<RegistrationErrorCode> results = Collections.synchronizedList(new ArrayList<>());
List<Throwable> unexpected = Collections.synchronizedList(new ArrayList<>());
for (ThrowingAction action : actions) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
action.run();
results.add(null);
} catch (RegistrationException exception) {
results.add(exception.getErrorCode());
} catch (Throwable throwable) {
unexpected.add(throwable);
} finally {
done.countDown();
}
});
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
assertThat(unexpected).isEmpty();
return results;
}
@FunctionalInterface
private interface ThrowingAction {
void run() throws Exception;
}
}
@@ -0,0 +1,171 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.UserLessonRepository;
import com.study.course_registration.repository.UserRepository;
@SpringBootTest
@ActiveProfiles("test")
class CourseRegistrationServiceIntegrationTest {
@Autowired CourseRegistrationService service;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private Semester semester;
private User user;
private Subject subjectA;
private Subject subjectB;
private Professor professor;
private Lesson lessonA;
private Lesson lessonB;
@BeforeEach
void setUp() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
professor = professorRepository.save(new Professor("professor", "교수"));
subjectA = subjectRepository.save(new Subject("subject-a", "과목A", "A101", "A", 3));
subjectB = subjectRepository.save(new Subject("subject-b", "과목B", "B101", "B", 3));
user = userRepository.save(new User("user", "학생", 2L, UserRole.STUDENT));
lessonA = lessonRepository.save(new Lesson("lesson-a", "강의A", subjectA, professor, semester, 30, null, null));
lessonB = lessonRepository.save(new Lesson("lesson-b", "강의B", subjectB, professor, semester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-a", lessonA, DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
lessonScheduleRepository.save(new LessonSchedule("schedule-b", lessonB, DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
}
@Test
void registerCreatesActiveRegistrationAndReturnsTotalCredits() {
CourseRegistrationResponse response = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThat(response.userId()).isEqualTo(user.getId());
assertThat(response.lessonId()).isEqualTo(lessonA.getId());
assertThat(response.totalCredits()).isEqualTo(3);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonA.getId())).isEqualTo(1);
}
@Test
void duplicateRegistrationIsRejected() {
service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThatThrownBy(() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_REGISTERED));
}
@Test
void cancelThenReregisterKeepsHistoryAndOneActiveRow() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
CourseRegistrationResponse second = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThat(second.registrationId()).isNotEqualTo(first.registrationId());
assertThat(userLessonRepository.findAll()).hasSize(2);
assertThat(userLessonRepository.findAll().stream().filter(it -> it.getCanceledAt() == null)).hasSize(1);
}
@Test
void otherUserCannotCancelRegistration() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
User other = userRepository.save(new User("other-user", "다른학생", 2L, UserRole.STUDENT));
assertThatThrownBy(() -> service.cancel(other.getId(), first.registrationId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_FORBIDDEN));
}
@Test
void cancelingAlreadyCanceledRegistrationIsRejected() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
assertThatThrownBy(() -> service.cancel(user.getId(), first.registrationId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_CANCELED));
}
@Test
void cancelingFreesCapacityForNextRegistration() {
Lesson capacityOne = lessonRepository.save(new Lesson("capacity-one", "정원1", subjectB, professor, semester, 1, null, null));
lessonScheduleRepository.save(new LessonSchedule("capacity-one-schedule", capacityOne, DayOfWeek.WEDNESDAY, LocalTime.of(13, 0), LocalTime.of(15, 0)));
User other = userRepository.save(new User("capacity-other", "다른학생", 2L, UserRole.STUDENT));
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(capacityOne.getId()));
service.cancel(user.getId(), first.registrationId());
CourseRegistrationResponse second = service.register(other.getId(), new CourseRegistrationRequest(capacityOne.getId()));
assertThat(second.userId()).isEqualTo(other.getId());
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(capacityOne.getId())).isEqualTo(1);
}
@Test
void cancellationOutsideRegistrationPeriodIsRejected() {
CourseRegistrationResponse registration = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
Semester closedSemester = semesterRepository.save(new Semester("closed-semester", "closed", LocalDate.now().minusDays(100),
LocalDate.now().plusDays(100), Instant.now().minusSeconds(7200), Instant.now().minusSeconds(3600), 18));
Lesson closedLesson = lessonRepository.save(new Lesson("closed-lesson", "마감강의", subjectB, professor, closedSemester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("closed-schedule", closedLesson, DayOfWeek.THURSDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
var closedRegistration = userLessonRepository.saveAndFlush(new com.study.course_registration.entity.UserLesson(user, closedLesson));
assertThatThrownBy(() -> service.cancel(user.getId(), closedRegistration.getId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED));
assertThat(userLessonRepository.findById(registration.registrationId())).isPresent();
}
@Test
void activeRegistrationListOmitsCanceledRows() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()));
var response = service.getRegistrations(user.getId(), semester.getId());
assertThat(response.totalCredits()).isEqualTo(3);
assertThat(response.registrations()).extracting(it -> it.lessonId()).containsExactly(lessonB.getId());
}
}
@@ -0,0 +1,96 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.*;
@SpringBootTest
@ActiveProfiles("test")
class LessonQueryServiceIntegrationTest {
@Autowired LessonQueryService lessonQueryService;
@Autowired CourseRegistrationService registrationService;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private Semester semester;
private Lesson lesson;
private User user;
@BeforeEach
void setUp() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester-query", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
Professor professor = professorRepository.save(new Professor("prof-query", "조회교수"));
Subject subject = subjectRepository.save(new Subject("subject-query", "자료구조", "CS202", "자료구조 설명", 3));
lesson = lessonRepository.save(new Lesson("lesson-query", "자료구조 01", subject, professor, semester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-query", lesson, DayOfWeek.MONDAY,
LocalTime.of(9, 0), LocalTime.of(11, 0)));
user = userRepository.save(new User("user-query", "조회학생", 2L, UserRole.STUDENT));
registrationService.register(user.getId(), new CourseRegistrationRequest(lesson.getId()));
}
@Test
void listUsesCurrentSemesterAndReturnsBulkDerivedFields() {
var response = lessonQueryService.getLessons(null, null, 0, 20);
assertThat(response.totalElements()).isEqualTo(1);
assertThat(response.lessons()).singleElement().satisfies(item -> {
assertThat(item.lessonId()).isEqualTo(lesson.getId());
assertThat(item.enrolledCount()).isEqualTo(1);
assertThat(item.schedules()).hasSize(1);
});
}
@Test
void detailContainsSubjectAndSemesterRegistrationMetadata() {
var response = lessonQueryService.getLesson(lesson.getId());
assertThat(response.subjectDescription()).isEqualTo("자료구조 설명");
assertThat(response.semesterName()).isEqualTo(semester.getName());
assertThat(response.enrolledCount()).isEqualTo(1);
assertThat(response.registrationStartAt().toEpochMilli()).isEqualTo(semester.getRegistrationStartAt().toEpochMilli());
}
@Test
void unknownSemesterIsRejected() {
assertThatThrownBy(() -> lessonQueryService.getLessons("missing", null, 0, 20))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.SEMESTER_NOT_FOUND));
}
}
@@ -0,0 +1,163 @@
package com.study.course_registration.service.policy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
class RegistrationValidatorTest {
private final RegistrationValidator validator = new RegistrationValidator(new ScheduleConflictChecker());
@Test
void registrationEndAtIsInclusive() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3), semester, 30, null, null);
assertThatCode(() -> validator.validatePreconditions(user, lesson, semester.getRegistrationEndAt()))
.doesNotThrowAnyException();
}
@Test
void rejectsClosedRegistrationPeriodBeforeEligibilityChecks() {
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.closedSemester(), 30, 3L, UserRole.POSTGRADUATE);
assertError(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void rejectsGradeBelowMinimum() {
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, 3L, null);
assertError(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void rejectsRoleMismatch() {
User user = TestFixtures.student("user", 3);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, null, UserRole.POSTGRADUATE);
assertError(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void duplicateLessonTakesPrecedenceOverDuplicateSubject() {
Semester semester = TestFixtures.semester(18);
Subject subject = TestFixtures.subject("subject", 3);
User user = TestFixtures.student("user", 2);
Lesson lesson = TestFixtures.lesson("lesson", subject, semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, lesson);
assertError(RegistrationErrorCode.ALREADY_REGISTERED,
() -> validator.validateLocked(lesson, List.of(existing), Map.of(), 1));
}
@Test
void rejectsDifferentSectionOfSameSubject() {
Semester semester = TestFixtures.semester(18);
Subject subject = TestFixtures.subject("subject", 3);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", subject, semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", subject, semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertError(RegistrationErrorCode.DUPLICATE_SUBJECT,
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
}
@Test
void rejectsScheduleConflict() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 10, 12);
assertError(RegistrationErrorCode.SCHEDULE_CONFLICT,
() -> validator.validateLocked(newLesson, List.of(existing),
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1));
}
@Test
void scheduleBoundaryIsAllowed() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 11, 13);
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing),
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1))
.doesNotThrowAnyException();
}
@Test
void exactCreditLimitIsAllowed() {
Semester semester = TestFixtures.semester(6);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1))
.doesNotThrowAnyException();
assertThat(validator.totalCreditsAfter(newLesson, List.of(existing))).isEqualTo(6);
}
@Test
void rejectsCreditAboveLimit() {
Semester semester = TestFixtures.semester(5);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertError(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
}
@Test
void lastCapacitySlotIsAllowedButFullClassIsRejected() {
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, null, null);
assertThatCode(() -> validator.validateLocked(lesson, List.of(), Map.of(), 29))
.doesNotThrowAnyException();
assertError(RegistrationErrorCode.CAPACITY_EXCEEDED,
() -> validator.validateLocked(lesson, List.of(), Map.of(), 30));
}
private static void assertError(RegistrationErrorCode code, Runnable action) {
assertThatThrownBy(action::run)
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(code));
}
}
@@ -0,0 +1,46 @@
package com.study.course_registration.service.policy;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.DayOfWeek;
import java.time.LocalTime;
import org.junit.jupiter.api.Test;
import com.study.course_registration.entity.LessonSchedule;
class ScheduleConflictCheckerTest {
private final ScheduleConflictChecker checker = new ScheduleConflictChecker();
@Test
void overlappingTimesOnSameDayConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 10, 12);
assertThat(checker.overlaps(a, b)).isTrue();
}
@Test
void touchingBoundaryDoesNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 11, 13);
assertThat(checker.overlaps(a, b)).isFalse();
}
@Test
void separatedTimesOnSameDayDoNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 13, 15);
assertThat(checker.overlaps(a, b)).isFalse();
}
@Test
void sameTimeOnDifferentDayDoesNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.TUESDAY, 9, 11);
assertThat(checker.overlaps(a, b)).isFalse();
}
}
@@ -0,0 +1,63 @@
package com.study.course_registration.service.policy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.UserRole;
final class TestFixtures {
static final Instant NOW = Instant.parse("2026-09-17T10:00:00Z");
private TestFixtures() {
}
static Semester semester(int maxCredits) {
return new Semester("semester-1", "2026-1", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
NOW.minusSeconds(3600), NOW.plusSeconds(3600), maxCredits);
}
static Semester closedSemester() {
return new Semester("semester-closed", "closed", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1),
NOW.minusSeconds(7200), NOW.minusSeconds(3600), 18);
}
static User student(String id, long grade) {
return new User(id, id, grade, UserRole.STUDENT);
}
static User postgraduate(String id) {
return new User(id, id, 3L, UserRole.POSTGRADUATE);
}
static Lesson lesson(String id, Subject subject, Semester semester, int capacity, Long minGrade, UserRole role) {
return new Lesson(id, id, subject, new Professor("prof-" + id, "prof"), semester, capacity, minGrade, role);
}
static Subject subject(String id, int credit) {
return new Subject(id, id, id, id, credit);
}
static UserLesson registration(String id, User user, Lesson lesson) {
return new UserLesson(id, user, lesson);
}
static LessonSchedule schedule(DayOfWeek day, int startHour, int endHour) {
Subject subject = subject("subject-" + day + startHour, 3);
Lesson lesson = lesson("lesson-" + day + startHour, subject, semester(18), 30, null, null);
return new LessonSchedule("schedule-" + day + startHour, lesson, day,
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
}
+21
View File
@@ -0,0 +1,21 @@
spring:
datasource:
url: jdbc:h2:mem:course_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
h2:
console:
enabled: false
app:
seed:
enabled: false
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false