본문으로 건너뛰기

API 계약

이 페이지는 reference 문서입니다. 개별 모듈 위에 놓인 백엔드 공통 API 계약을 다룹니다. OpenAPI 스펙이 어떻게 생성되는지, 어떤 응답 envelope이 안정적으로 유지되는지, 재실행해도 안전한 POST가 어떻게 동작하는지, 어떤 장기 작업 경로가 같은 202 Accepted 계약을 공유하는지, 그리고 어떤 네이밍 예외가 의도된 것인지를 설명합니다.

소스 경로

경로역할
lumie-backend/AGENTS.mdDTO의 Instant, PageResponse<T>, BusinessException 사용 같은 백엔드 전반 API 규칙
lumie-backend/app/build.gradle.ktsspringdoc-openapi 의존성과 snapshotOpenApi task
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiConfig.java기본 OpenAPI 설정, bearer 인증 스킴, LocalTime 문자열 매핑
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiResponseRequiredCustomizer.java코드 생성 정확도를 위해 응답 전용 스키마 속성을 required로 표시
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiPageableFlattenCustomizer.javaPageablepage, size, sort 쿼리 파라미터로 평탄화
lumie-backend/app/src/test/java/com/lumie/app/OpenApiSnapshotTest.java백엔드 측 OpenAPI 드리프트 차단 게이트
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/BusinessException.java기본 비즈니스 실패 타입
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/GlobalExceptionHandler.java전역 ProblemDetail 매퍼
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/QuotaExceededException.java추가 응답 속성을 가지는 타입 지정 예외 예시
lumie-backend/libs/common/src/main/java/com/lumie/common/api/PageResponse.java필수 목록 응답 envelope
lumie-backend/libs/common/src/main/java/com/lumie/common/api/JobAcceptedResponse.java장기 작업 수락 공통 응답 본문
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyHeaderInterceptor.java헤더 강제 정책
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyService.java재실행, fingerprint, 캐시된 응답 의미
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.java인라인 대량 가져오기, 207 Multi-Status, 파일 해시 멱등성 예시
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web/{ResultController,ReportController,JoossamengAiReportController}.java장기 작업 수락 계약과 선택적 멱등성 예시
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web/{ExamController,InternalOmrController}.java/full projection 예시
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/web/QnaController.java/v1/qna 네이밍 예외
.codex/knowledge/DOMAIN_GLOSSARY.md/v1/qna acronym-path 예외의 기준 설명
lumie-frontend/orval.config.ts커밋된 백엔드 스펙을 소비하는 쪽의 codegen 대상 배치

OpenAPI 전달 경로와 코드 생성

백엔드가 계약의 기준 원본을 소유합니다. 실제 스펙은 손으로 작성한 프론트엔드 타입이 아니라 /v3/api-docs에서 나옵니다.

이 스펙을 codegen에 안전하게 만드는 사용자 정의는 세 가지입니다.

  • OpenApiConfig는 공통 bearer 인증 스킴을 선언하고 java.time.LocalTime09:00 예시가 있는 문자열 스키마로 바꿉니다. 덕분에 프론트엔드 codegen은 시각 필드를 Java 객체 형태가 아니라 문자열로 인식합니다.
  • OpenApiResponseRequiredCustomizer는 응답 전용 DTO 속성을 required로 표시합니다. Java record가 항상 키를 내보내는 경우 생성된 TypeScript 타입이 field?: T가 아니라 field: T가 되도록 유지합니다.
  • OpenApiPageableFlattenCustomizer는 multipart 업로드 경로를 깨뜨리지 않으면서 중첩 Pageable 파라미터를 평탄한 page, size, sort 쿼리 파라미터로 바꿉니다.

프론트엔드로의 전달 경로도 명시적입니다.

  • OpenApiSnapshotTest는 실제 스펙을 app/src/test/resources/openapi/api-docs.json의 커밋된 백엔드 골든과 비교합니다.
  • ./gradlew snapshotOpenApi는 실행 중인 백엔드 스펙을 lumie-frontend/openapi.json으로 내보냅니다.
  • lumie-frontend/orval.config.ts는 이후 엔티티별 클라이언트를 src/entities/*/api/generated.ts에 생성합니다.

오류 envelope: ProblemDetailBusinessException

백엔드 규칙상 요청 처리 실패는 BusinessException 또는 그 하위 타입을 거쳐야 합니다. 일반적인 HTTP 계약 바깥의 startup 또는 인프라 즉시 실패 경로에만 원시 RuntimeException을 남겨 둡니다.

GlobalExceptionHandler는 이런 실패를 Content-Type: application/problem+json으로 매핑하고 응답 형태를 안정적으로 유지합니다.

private ProblemDetail buildProblemDetail(BusinessException ex, HttpServletRequest request) {
ErrorCode errorCode = ex.getErrorCode();
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatusCode.valueOf(errorCode.getStatus()),
ex.getMessage());
body.setType(URI.create(PROBLEM_TYPE_PREFIX + toKebab(errorCode.getCode())));
body.setTitle(errorCode.getMessage());
decorate(body, errorCode.getCode(), request);
return body;
}

안정적으로 유지되는 필드와 확장 속성:

  • 표준 RFC 7807 필드: type, title, status, detail, instance
  • Lumie 확장: code, timestamp, 그리고 MDC에 있으면 tenantSlug
  • ExtensionProperties에서 오는 타입 지정 예외 속성. 예를 들어 QuotaExceededExceptionmetricType, currentUsage, limit를 추가한다.
  • 검증 실패 시 field, message, rejectedValue를 담은 violations[]

GlobalExceptionHandlerQuotaExceededException에 맞춘 대표 오류 형태:

{
"type": "urn:lumie:error:student-001",
"title": "Quota exceeded",
"status": 409,
"detail": "Quota exceeded for tenant 'acme'. STUDENTS: 201/200",
"instance": "/v1/students",
"code": "STUDENT_001",
"timestamp": "2026-06-14T09:00:00Z",
"tenantSlug": "acme",
"metricType": "STUDENTS",
"currentUsage": 201,
"limit": 200
}

상태 코드는 컨트롤러마다 하드코딩하지 않습니다.

  • 비즈니스 예외는 ErrorCode.status를 사용합니다
  • 유니크 충돌과 낙관적 락 충돌은 409 Conflict로 매핑합니다
  • 비즈니스 규칙 위반은 422 Unprocessable Entity로 매핑될 수 있습니다
  • 검증 실패는 400 Bad Request로 매핑합니다
  • 예상하지 못한 실패는 500 Internal Server Error로 매핑합니다

페이지네이션과 공통 응답 형태

컨트롤러는 Spring Data Page<T>를 직접 노출하면 안 됩니다. 백엔드 규칙과 실제 컨트롤러는 모두 PageResponse<T>를 요구합니다.

public record PageResponse<T>(
List<T> items,
int page,
int perPage,
long total,
int totalPages,
boolean hasNext
) {}

이 wrapper가 student, class, exam, content, notification 등 페이지네이션 컨트롤러가 공통으로 사용하는 안정적인 공개 목록 계약입니다.

PageResponse, StudentController, QnaController에 맞춘 대표 목록 형태:

{
"items": [],
"page": 0,
"perPage": 20,
"total": 0,
"totalPages": 0,
"hasNext": false
}

요청 측 페이지네이션 형태도 의도적으로 정해져 있습니다.

  • 호출자는 page, size, 반복 가능한 sort 파라미터를 보냅니다
  • 호출자는 중첩 pageable 객체를 보내지 않습니다
  • 페이지네이션 엔드포인트는 @AllowedSortFields(...)도 함께 선언해야 합니다

멱등성 정책

멱등성 계층은 두 부분으로 나뉩니다.

HTTP 경계 강제:

  • IdempotencyHeaderInterceptor는 변경이 발생하는 billing 경로와 POST /v1/students/bulkIdempotency-Key를 강제합니다
  • 유효한 키는 16자 이상 128자 이하이며 [A-Za-z0-9._:-] 패턴과 일치해야 합니다
  • 예외 webhook 경로는 공급자 재시도 의미를 따르므로 이 규칙 밖에 둡니다

컨트롤러와 영속화 의미:

  • 컨트롤러는 여전히 IdempotencyService.executeOnce(...) 또는 executeOnceWithoutResponseBody(...)를 명시적으로 호출합니다
  • 범위는 (tenantSlug, endpointPath, idempotencyKey)입니다
  • 같은 키와 같은 canonical fingerprint면 캐시된 상태 코드와 본문을 재사용합니다
  • 같은 키와 다른 fingerprint면 IdempotencyConflictException을 발생시키고 409 Conflict를 반환합니다
  • 캐시 쓰기는 별도 REQUIRES_NEW 트랜잭션에서 커밋됩니다

fingerprint 규칙은 경로별로 payload를 반영해 다르게 적용됩니다.

  • StudentController.bulkImportStudents(...)fileSize + sha256을 fingerprint로 사용합니다
  • FileController.uploadFile(...)는 엔티티 컨텍스트와 파일 메타데이터, 바이트 해시를 합쳐 fingerprint를 계산합니다
  • SmsControllerexecuteOnceWithoutResponseBody(...)를 사용하므로 응답 본문 PII를 저장하지 않고 재실행 메타데이터만 캐시합니다

알아둘 현재 드리프트

검토한 소스는 장기 작업 생성 경로에 이 헤더가 필수인지에 대해 일치하지 않습니다.

  • .codex/knowledge/DOMAIN_GLOSSARY.md는 멱등성이 "billing + long-job creators"에 필요하다고 설명합니다
  • 실제 장기 작업 exam 컨트롤러는 현재 Idempotency-Key를 optional로 표시하고, 호출자가 헤더를 보낼 때만 재실행 보호를 켭니다

이 페이지는 ResultController, ReportController, JoossamengAiReportController의 실제 컨트롤러 코드를 기준으로 작성합니다.

장기 작업 202 + Location + statusUrl 계약

비동기 exam 측 POST는 같은 수락 envelope을 공유합니다.

  • HTTP 상태 202 Accepted
  • polling URL을 담은 Location 헤더
  • JobAcceptedResponse(Long jobId, String statusUrl) 본문
  • 같은 statusUrl에 대응하는 GET 상태 엔드포인트

ResultController.acceptedOmrJob(...)의 정확한 컨트롤러 패턴:

private ResponseEntity<JobAcceptedResponse> acceptedOmrJob(Long examId, OmrJobResponse response) {
String statusUrl = "/v1/exams/" + examId + "/omr-jobs/" + response.jobId();
return ResponseEntity.status(HttpStatus.ACCEPTED)
.location(URI.create(statusUrl))
.body(new JobAcceptedResponse(response.jobId(), statusUrl));
}

이 계약을 사용하는 실제 경로:

POST 경로수락 본문상태 엔드포인트
POST /v1/exams/{examId}/results/omr/batch/confirmJobAcceptedResponseGET /v1/exams/{examId}/omr-jobs/{jobId}
POST /v1/exams/{examId}/reports/batchJobAcceptedResponseGET /v1/exams/{examId}/reports/jobs/{jobId}
POST /v1/exams/{examId}/reports/ai-batchJobAcceptedResponseGET /v1/exams/{examId}/reports/ai-jobs/{jobId}

ResultController에 맞춘 정확한 예시:

POST /v1/exams/15/results/omr/batch/confirm
Idempotency-Key: omr-batch-15
Content-Type: application/json
{
"batchKey": "20260614-01",
"objectKeys": [
"tmp/15/omr/20260614-01/page-1.png",
"tmp/15/omr/20260614-01/page-2.png"
]
}
HTTP/1.1 202 Accepted
Location: /v1/exams/15/omr-jobs/341
{
"jobId": 341,
"statusUrl": "/v1/exams/15/omr-jobs/341"
}

의도적인 계약 예외:

  • POST /v1/exams/{examId}/results/omr는 여전히 동기식이며 채점 결과와 함께 201 Created를 반환합니다
  • POST /v1/students/bulk는 설계상 동기식이며 전체 BulkImportResult를 인라인으로 반환합니다

대량성 작업의 부분 성공

대량성 작업이라고 해서 항상 "job을 생성한다"는 뜻은 아닙니다. 현재 백엔드는 두 가지 다른 계약을 사용하며, 둘 다 상태 코드와 본문에서 의도적으로 드러납니다.

배치 수명주기 변경용 207 Multi-Status

StudentController는 다음 경로에 207 Multi-Status를 사용합니다.

  • POST /v1/students/batch/deactivate
  • POST /v1/students/batch/reactivate
  • POST /v1/students/batch/delete

이 경로는 BatchOperationResult를 반환합니다.

{
"total": 3,
"success": 2,
"failed": 1,
"failures": [
{
"id": 42,
"reason": "Student is still active"
}
]
}

동기식 가져오기용 200 OK + 행 수준 오류

POST /v1/students/bulk는 인라인으로 처리되며 일부 행이 실패해도 BulkImportResult를 반환합니다.

{
"totalRows": 2,
"successCount": 1,
"failureCount": 1,
"errors": [
{
"rowNumber": 3,
"column": "phone",
"value": "010-12",
"code": "STUDENT_502",
"message": "..."
}
]
}

호출자 규칙:

  • 207 Multi-Status이면 failures[]를 확인해야 합니다
  • 대량 가져오기의 200 OKfailureCounterrors[]를 반드시 확인해야 합니다

URL 및 네이밍 정책

실제 컨트롤러는 명사 우선 리소스 배치를 사용하지만, 모든 변경 작업을 단일 CRUD 형태로 강제하지는 않습니다.

현재 정책은 실제 경로 사용을 기준으로 합니다.

  • 리소스 컬렉션은 /v1/students, /v1/classes, /v1/exams 같은 복수 명사 아래에 둡니다
  • 단순 부분 갱신이 아닌 수명주기 전환이나 워크플로 단계는 명시적 action suffix를 사용합니다
  • 워커 전용 읽기 경로는 /internal/... 아래에 둡니다

현재 코드의 대표 action-style 경로:

  • /v1/students/{id}/deactivate
  • /v1/students/{id}/reactivate
  • /v1/students/{id}/reset-password
  • /v1/exams/{examId}/results/omr/batch/presign
  • /v1/exams/{examId}/results/omr/batch/confirm
  • /v1/qna/{id}/comments

/full은 sparse fieldset이 아니라 더 풍부한 projection을 뜻합니다

백엔드는 현재 범용 expand나 sparse-fieldset 시스템을 제공하지 않습니다. 두 번째로 더 풍부한 읽기 모델이 필요할 때는 /full suffix를 사용합니다.

  • GET /v1/exams/{id}/full
  • GET /internal/omr/exams/{id}/full

따라서 /full은 projection 계약이지, 호출자가 임의의 필드 집합을 고를 수 있다는 힌트가 아닙니다.

/v1/qna는 의도된 acronym-path 예외입니다

QnaController/v1/qna를 사용하고, 도메인 용어집은 이 경로를 인정된 acronym-namespace 예외로 문서화합니다.

현재 형태를 유지해야 합니다.

  • /v1/qnas로 복수화하지 않습니다
  • 기준 Qna* 도메인 용어가 함께 바뀌지 않는 한 /v1/questions로 이름을 바꾸지 않습니다

같은 용어집 항목은 /v1/sms, /v1/staff 같은 형제 예외도 함께 언급합니다.

날짜시간과 스칼라 형태 정책

백엔드의 wire shape는 내부 JVM 타입보다 의도적으로 더 좁게 유지됩니다.

  • DTO 타임스탬프는 LocalDateTime이 아니라 Instant를 사용합니다. 백엔드 규칙과 lint는 DTO의 timezone-naive LocalDateTime을 계약 버그로 간주합니다
  • nullable 응답 키는 @Schema(nullable = true)로 표시해야 codegen이 잘못된 non-null 타입이 아니라 T | null을 생성합니다
  • 응답 키는 여전히 항상 존재합니다. OpenApiResponseRequiredCustomizer가 응답 전용 속성을 required로 표시하기 때문입니다

검토한 DTO 예시:

  • ClassResponse.createdAt, updatedAtInstant
  • ExamResponse, ExamFullResponse, OmrJobStatusResponse도 타임스탬프를 Instant로 노출
  • SchedulePatternResponse.startTime, endTime, 중첩 session 시각은 LocalTime 객체가 아니라 문자열입니다. OpenApiConfigLocalTime"HH:mm" wire shape로 매핑하기 때문입니다

SchedulePatternResponse에 맞춘 대표 일정 형태:

{
"days": ["MON", "WED"],
"startTime": "09:00",
"endTime": "11:00",
"sessions": [
{
"day": "MON",
"startTime": "09:00",
"endTime": "11:00"
}
]
}

관측성과 검증

계약 관측성은 런타임 응답과 드리프트 게이트 양쪽에서 나옵니다.

  • GlobalExceptionHandlercode, timestamp, instance, MDC에서 파생한 tenantSlug를 추가해 클라이언트가 보는 오류를 로그와 연관 지을 수 있게 합니다
  • OpenApiSnapshotTest는 producer 측 계약 드리프트 게이트입니다
  • snapshotOpenApi와 orval 생성은 consumer 측 전달 경로입니다
  • 큐 기반 장기 작업의 런타임 세부사항은 백엔드 messaging과 exam 모듈 레퍼런스에 있습니다

유용한 검증 명령:

cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew :app:test --tests '*OpenApiSnapshotTest'
./gradlew :libs:common:test --tests '*Idempotency*'
./gradlew :modules:exam:test --tests '*ResultController*'
./gradlew :modules:student:test --tests '*StudentController*'

예상 성공 신호:

  • OpenApiSnapshotTest가 커밋된 백엔드 스냅샷과 계속 일치합니다
  • 멱등성 테스트가 재실행, 충돌, 헤더 검증 동작을 계속 증명합니다
  • exam과 student 컨트롤러 테스트가 이 페이지에서 설명한 공통 envelope을 계속 다룹니다