docs: 스웨거 문서 정리 및 업로드 문제 해결 - #167
Conversation
에러는 GlobalExceptionHandler 가 전역에서 처리해서 컨트롤러에 흔적이 남지 않는다. 그래서 생성된 문서에는 성공 응답만 있고 실패 응답이 통째로 빠져 있었다. OperationCustomizer 로 모든 엔드포인트에 공통 에러 응답을 붙인다. 실제로 발생할 수 있는 상태 코드만 붙인다. - 400: 파싱할 본문이나 파라미터가 있는 경우 - 404: 경로 변수로 리소스를 찾는 경우 - 500: 전부 응답 스키마와 예시는 ApiResponse, ValidationErrorResponse, CommonErrorCode 에서 직접 뽑아낸다. 손으로 적어두면 응답이 바뀌었을 때 문서만 조용히 낡는다.
문서 전체에 인증 요구가 걸려 있어서 모든 엔드포인트가 토큰 필수로 읽혔다. 실제로는 SecurityConfig 의 permitAll 경로 19개가 토큰 없이 열려 있다. 세 가지로 나눠 표시한다. - 인증 불필요 11개: @SecurityRequirements 로 자물쇠를 뗀다 - 인증 선택 8개: 게스트 참여 경로. security 에 빈 요구사항을 함께 넣어 토큰이 있어도 없어도 되는 상태를 표현하고, 무엇이 달라지는지 설명한다 - 나머지: 401 을 문서화한다 401 은 인증이 필수인 곳에만 붙인다. 열려 있는 경로에 붙이면 발생하지 않는 상태 코드가 명세에 실린다. 동작은 그대로다. 접근 제어는 SecurityConfig 가 결정하고 이 커밋은 문서만 바꾼다.
문서는 애노테이션에서 조립되기 때문에 애노테이션을 빠뜨려도 빌드가 깨지지 않는다. 누락은 배포된 문서를 열어봐야 드러난다. 생성 결과를 직접 확인해서 먼저 깨지게 한다. - 모든 엔드포인트가 설명을 가진다 - 400/401/404/500 이 발생 조건에 맞게 문서화된다 - 실패 응답 스키마와 예시가 실제 응답 객체와 일치한다 - 검증 실패 본문 스키마가 참조로 살아 있다 기대값은 실제 응답 객체를 직렬화해서 만든다. 필드명을 테스트에 적어두면 그 하드코딩도 코드와 같이 낡는다.
설명이 있는 엔드포인트가 108개 중 69개뿐이라 명세가 덜 된 것처럼 보였다. 비어 있던 39개를 채워 108개 전부가 설명을 갖는다. summary 를 풀어 쓴 문장은 쓰지 않았다. 이름만 다시 적으면 공백은 그대로다. 대신 호출하는 쪽이 코드를 열어봐야 알 수 있던 제약을 적었다. - 권한: 프로젝트 수정/삭제는 ADMIN 만, 파일과 공지는 작성자 또는 ADMIN - 덮어쓰기 여부: 프로젝트 수정은 전체 교체, 파일 수정은 부분 수정 - 적용 범위: 프로젝트 고정은 개인별, 파일 고정은 멤버 전체 공유 - 제한값: 프로젝트 5개, 파일 100MB 와 허용 확장자, 초대 기본 72시간 - 조회 범위: 알림 목록은 최근 24시간, 전체 읽음은 제한 없음 - 응답 형태: 파일 다운로드만 공통 래퍼 대신 본문을 그대로 반환 내용은 전부 서비스 코드에서 확인했다. 이름만 보고 지으면 문서가 거짓말을 한다. 설명이 다시 비거나 summary 를 되풀이하면 깨지도록 회귀 테스트를 함께 넣었다.
문서 예시에 실제 팀원 이름이 그대로 들어가 있었다. 닉네임 필드는 규칙 문서 예시와 같은 "그린"으로, 실명 필드는 "차태훈"으로 바꾼다.
multipart 한도(파일 100MB, 요청 105MB)를 넘긴 요청은 본문을 읽는 단계에서 MaxUploadSizeExceededException 으로 끊긴다. 컨트롤러에 닿지 않으니 서비스의 파일 크기 검증(PROJECT_FILE_SIZE400 등)은 실행조차 되지 않는다. 전역 핸들러에 이 예외가 없어서 @ExceptionHandler(Exception.class) 가 받았고, 결과적으로 COMMON500 이 나갔다. 프론트는 "파일이 너무 큽니다" 대신 서버 오류를 표시하게 되고, 서버에는 ERROR 로그가 쌓인다. 앞단 nginx 도 한도를 넘기면 413 을 주므로 상태 코드를 413 으로 맞춘다. 프론트가 nginx 차단과 앱 차단을 한 갈래로 처리할 수 있다. 한도 초과 외의 multipart 해석 실패(본문이 잘림, boundary 불일치)도 함께 잡는다. 업로드 중 연결이 끊길 때마다 500 이 찍히던 것을 400 으로 내린다. MockMvc 는 multipart 요청을 테스트가 직접 조립해서 파싱 자체가 일어나지 않는다. 한도는 서블릿 컨테이너가 강제하므로 실제 포트를 띄워 진짜 HTTP 요청으로 검증한다.
프로젝트 수정 API 설명이 양쪽에서 함께 수정돼 충돌했다. 호출 규칙(ADMIN 전용, 미전송 필드 null 덮어쓰기)과 완료 전환 동작 설명을 둘 다 남긴다.
게스트 세션 토큰(#162)과 포트폴리오 참여 기간(#160)이 각자 V016 을 썼다. 파일명이 서로 달라 git 은 충돌로 잡지 못했고, main 에 합쳐진 뒤에야 Flyway 가 Found more than one migration with version 016 으로 기동을 막았다. CI 는 flywayInfo 에서, CD 는 앱 기동 중 flywayInitializer 에서 실패했다. 게스트 쪽 V016·V017 은 이미 운영에 적용돼 이력에 남아 있어 번호를 바꿀 수 없다. 아직 어디에도 적용되지 않은 포트폴리오 쪽을 다음 빈 번호인 018 로 옮긴다. 두 파일은 건드리는 테이블이 겹치지 않아 실행 순서는 결과에 영향이 없다. SQL 내용은 그대로다.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughSwagger에 인증 방식과 공통 오류 응답을 반영했습니다. 주요 API 설명을 보강했습니다. multipart 업로드 오류 처리와 Nginx 배포 단계를 추가했습니다. 포트폴리오 참여 기간 컬럼을 추가했습니다. ChangesAPI 인증 및 오류 문서화
multipart 업로드 및 배포
포트폴리오 참여 기간 스키마
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant OpenAPI
participant SwaggerAuthenticationCustomizer
participant SwaggerErrorResponseCustomizer
participant OpenApiDocumentationTest
Client->>OpenAPI: API 문서 요청
OpenAPI->>SwaggerAuthenticationCustomizer: 엔드포인트 인증 정보 전달
SwaggerAuthenticationCustomizer-->>OpenAPI: security 요구사항 및 안내 문구 설정
OpenAPI->>SwaggerErrorResponseCustomizer: 작업 정보 전달
SwaggerErrorResponseCustomizer-->>OpenAPI: 조건별 오류 응답 추가
OpenApiDocumentationTest->>OpenAPI: 생성 문서 조회
OpenAPI-->>OpenApiDocumentationTest: 인증·오류 스키마 및 예시 반환
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/cd-prod.yml:
- Around line 83-89: Update the Nginx replacement flow around TARGET and the
nginx -t check to back up the existing TARGET before copying SOURCE. On
validation failure, restore that backup when TARGET originally existed; only
remove TARGET when no original file was present, preserving the current failure
exit behavior.
In
`@src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java`:
- Around line 34-39: Update the description in the `@Operation` annotation for
ProjectInvitationController so it states that users with the inviteUrl can
retrieve the invitation information again, while losing the original token
prevents the server from recovering or reissuing it.
In `@src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java`:
- Around line 44-65: Update SwaggerErrorResponseCustomizer to add HTTP 413 for
multipart endpoints, using the ErrorResponse schema and PAYLOAD_TOO_LARGE
example while preserving existing response conditions. Extend
OpenApiDocumentationTest to verify the generated 413 response for all three
multipart endpoints; apply the customizer change in
src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java:44-65
and the corresponding assertions in
src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java:96-108.
In `@src/main/java/com/slatto/global/response/ApiResponse.java`:
- Line 24: Update the `@Schema` description on the result field in ApiResponse to
state that result is null for general failures and contains a field-error list
for validation failures, matching ApiResponse.failure(errorCode, response).
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8f346306-ca4e-4404-9e21-33c4db021473
📒 Files selected for processing (29)
.github/workflows/cd-prod.ymlinfra/nginx/conf.d/upload-limits.confsrc/main/java/com/slatto/domain/feedback/controller/FeedbackController.javasrc/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.javasrc/main/java/com/slatto/domain/feedback/dto/response/FeedbackResponse.javasrc/main/java/com/slatto/domain/notification/controller/NotificationController.javasrc/main/java/com/slatto/domain/notification/controller/RecentActivityController.javasrc/main/java/com/slatto/domain/project/controller/ProjectController.javasrc/main/java/com/slatto/domain/project/controller/ProjectFileController.javasrc/main/java/com/slatto/domain/project/controller/ProjectInvitationController.javasrc/main/java/com/slatto/domain/project/controller/ProjectMemberController.javasrc/main/java/com/slatto/domain/project/controller/ProjectNoticeController.javasrc/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.javasrc/main/java/com/slatto/domain/schedule/dto/ScheduleDailyResponse.javasrc/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.javasrc/main/java/com/slatto/domain/video/dto/response/VideoResponse.javasrc/main/java/com/slatto/global/config/EndpointAuthentication.javasrc/main/java/com/slatto/global/config/OptionalAuthentication.javasrc/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.javasrc/main/java/com/slatto/global/config/SwaggerConfig.javasrc/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.javasrc/main/java/com/slatto/global/exception/GlobalExceptionHandler.javasrc/main/java/com/slatto/global/exception/ValidationErrorResponse.javasrc/main/java/com/slatto/global/health/controller/HealthCheckController.javasrc/main/java/com/slatto/global/response/ApiResponse.javasrc/main/java/com/slatto/global/response/code/CommonErrorCode.javasrc/main/resources/db/migration/V018__portfolio_period.sqlsrc/test/java/com/slatto/global/config/OpenApiDocumentationTest.javasrc/test/java/com/slatto/global/exception/MultipartUploadLimitTest.java
| sudo cp "$SOURCE" "$TARGET" | ||
|
|
||
| # 문법 검사를 통과할 때만 reload 한다. 실패하면 실행 중인 nginx 는 그대로 둔다. | ||
| if ! sudo nginx -t; then | ||
| echo "nginx 설정 검사 실패. 이전 설정으로 되돌립니다." | ||
| sudo rm -f "$TARGET" | ||
| exit 1 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
기존 Nginx 설정을 삭제하지 말고 복원하십시오.
Line 86의 nginx -t가 실패하면 Line 88이 현재 TARGET을 삭제합니다. 기존 upload-limits.conf가 있던 서버에서는 이전 설정이 복원되지 않습니다. 이후 Nginx가 재시작되면 업로드 제한과 타임아웃 설정이 사라집니다.
교체 전 TARGET을 백업하십시오. 검증 실패 시 백업을 다시 복원하십시오. 대상 파일이 원래 없던 경우에만 삭제하십시오.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/cd-prod.yml around lines 83 - 89, Update the Nginx
replacement flow around TARGET and the nginx -t check to back up the existing
TARGET before copying SOURCE. On validation failure, restore that backup when
TARGET originally existed; only remove TARGET when no original file was present,
preserving the current failure exit behavior.
| @Operation( | ||
| summary = "프로젝트 초대 링크 생성", | ||
| description = """ | ||
| ADMIN 만 만들 수 있다. expirationPeriod 로 유효기간을 정하며 기본값은 72시간이다. | ||
| 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장되므로, 같은 링크를 나중에 다시 조회할 수 없다.""" | ||
| ) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
초대 링크 재조회 제한 설명을 수정하십시오.
Line 38의 설명은 실제 동작과 다릅니다. getInvitation은 요청 토큰을 해시하여 저장된 초대를 조회하므로, inviteUrl을 가진 사용자는 링크 정보를 다시 조회할 수 있습니다.
원본 토큰을 잃으면 서버가 토큰을 복구하거나 재발급할 수 없다는 점을 설명하십시오.
수정 예시
- 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장되므로, 같은 링크를 나중에 다시 조회할 수 없다."""
+ 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장된다.
+ 응답을 잃으면 서버에서 원본 토큰을 복구하거나 같은 링크를 재발급할 수 없다."""📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Operation( | |
| summary = "프로젝트 초대 링크 생성", | |
| description = """ | |
| ADMIN 만 만들 수 있다. expirationPeriod 로 유효기간을 정하며 기본값은 72시간이다. | |
| 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장되므로, 같은 링크를 나중에 다시 조회할 수 없다.""" | |
| ) | |
| `@Operation`( | |
| summary = "프로젝트 초대 링크 생성", | |
| description = """ | |
| ADMIN 만 만들 수 있다. expirationPeriod 로 유효기간을 정하며 기본값은 72시간이다. | |
| 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장된다. | |
| 응답을 잃으면 서버에서 원본 토큰을 복구하거나 같은 링크를 재발급할 수 없다.""" | |
| ) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java`
around lines 34 - 39, Update the description in the `@Operation` annotation for
ProjectInvitationController so it states that users with the inviteUrl can
retrieve the invitation information again, while losing the original token
prevents the server from recovering or reissuing it.
| // 400 은 파싱할 본문이나 파라미터가 있어야 발생한다. 둘 다 없으면 날 수 없다. | ||
| if (hasParameters(operation) || operation.getRequestBody() != null) { | ||
| addIfAbsent(responses, CommonErrorCode.BAD_REQUEST, badRequestContent()); | ||
| } | ||
|
|
||
| // 401 은 토큰이 필수인 엔드포인트에서만 발생한다. | ||
| // 게스트 참여 경로는 토큰이 없어도 통과하기 때문에 여기서 제외된다. | ||
| if (EndpointAuthentication.isRequired(handlerMethod)) { | ||
| addIfAbsent(responses, CommonErrorCode.UNAUTHORIZED, singleExampleContent(CommonErrorCode.UNAUTHORIZED)); | ||
| } | ||
|
|
||
| // 404 는 경로로 리소스를 찾는 엔드포인트에서만 발생한다. | ||
| if (hasPathParameter(operation)) { | ||
| addIfAbsent(responses, CommonErrorCode.NOT_FOUND, singleExampleContent(CommonErrorCode.NOT_FOUND)); | ||
| } | ||
|
|
||
| // 500 은 처리되지 않은 예외를 잡는 핸들러가 있어 모든 엔드포인트에서 가능하다. | ||
| addIfAbsent( | ||
| responses, | ||
| CommonErrorCode.INTERNAL_SERVER_ERROR, | ||
| singleExampleContent(CommonErrorCode.INTERNAL_SERVER_ERROR) | ||
| ); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
# multipart 엔드포인트와 413 Swagger 응답 선언을 확인한다.
rg -n -C 6 --type java \
'MultipartFile|`@RequestPart`|PAYLOAD_TOO_LARGE|responseCode\s*=\s*"413"|`@ApiResponse`' \
src/main/java
# 생성 OpenAPI 문서의 413 회귀 테스트가 있는지 확인한다.
rg -n -C 5 --type java \
'path\("413"\)|PAYLOAD_TOO_LARGE|MultipartFile|`@RequestPart`' \
src/test/javaRepository: SLAT-TO/SLATE-TO-BE
Length of output: 46496
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- SwaggerErrorResponseCustomizer.java ---'
cat -n src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java
printf '%s\n' '--- OpenApiDocumentationTest.java (relevant sections) ---'
sed -n '1,180p' src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java
printf '%s\n' '--- related response/error helpers and annotations ---'
rg -n -C 8 --type java \
'class ErrorResponse|record ErrorResponse|ErrorResponse|singleExampleContent|badRequestContent|addIfAbsent|OpenAPI|OperationCustomizer|SwaggerErrorResponseCustomizer|PAYLOAD_TOO_LARGE|MaxUploadSizeExceededException' \
src/main/java src/test/javaRepository: SLAT-TO/SLATE-TO-BE
Length of output: 50375
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- multipart controller mappings ---'
rg -n -B 8 -A 18 --type java \
'consumes\s*=\s*MediaType\.MULTIPART_FORM_DATA_VALUE|consumes\s*=\s*"multipart/form-data"|MultipartFile' \
src/main/java/*/ 2>/dev/null || true
rg -n -B 8 -A 18 --type java \
'consumes\s*=\s*MediaType\.MULTIPART_FORM_DATA_VALUE|consumes\s*=\s*"multipart/form-data"' \
src/main/java
printf '%s\n' '--- upload-size configuration ---'
rg -n -C 5 \
'max-file-size|max-request-size|multipart|MaxUploadSizeExceededException|PAYLOAD_TOO_LARGE' \
src/main/resources src/main/java src/test/resources 2>/dev/null || true
printf '%s\n' '--- all 413 declarations/assertions ---'
rg -n -C 4 --type java \
'"413"|path\("413"\)|HttpStatus\.PAYLOAD_TOO_LARGE|PAYLOAD_TOO_LARGE|responseCode\s*=\s*413|responseCode\s*=\s*"413"' \
src/main/java src/test/java || true
printf '%s\n' '--- OpenAPI test helper and remaining tests ---'
sed -n '180,380p' src/test/java/com/slatto/global/config/OpenApiDocumentationTest.javaRepository: SLAT-TO/SLATE-TO-BE
Length of output: 50375
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
root = Path("src/main/java")
mapping = re.compile(
r'@(?P<kind>GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)'
r'\s*(?:\([^)]*\))?',
re.S
)
for path in sorted(root.rglob("*.java")):
text = path.read_text()
if "MULTIPART_FORM_DATA_VALUE" not in text and "multipart/form-data" not in text:
continue
print(f"FILE {path}")
for match in mapping.finditer(text):
end = min(len(text), match.end() + 500)
block = text[match.start():end]
if "MULTIPART_FORM_DATA_VALUE" in block or "multipart/form-data" in block:
print(" " + " ".join(block.split())[:350])
print("413 references:")
for base in (Path("src/main/java"), Path("src/test/java"), Path("src/main/resources"), Path("src/test/resources")):
if not base.exists():
continue
for path in sorted(base.rglob("*")):
if path.is_file():
text = path.read_text(errors="ignore")
if any(token in text for token in ("PAYLOAD_TOO_LARGE", '"413"', "path(\"413\")", "responseCode = 413")):
for i, line in enumerate(text.splitlines(), 1):
if any(token in line for token in ("PAYLOAD_TOO_LARGE", '"413"', "path(\"413\")", "responseCode = 413")):
print(f"{path}:{i}:{line.strip()}")
PY
printf '%s\n' '--- multipart-related resource configuration ---'
rg -n -C 3 -i \
'multipart|max-file-size|max-request-size' \
src/main/resources src/test/resources 2>/dev/null || true
printf '%s\n' '--- OpenAPI test method names ---'
rg -n --type java \
'^(\s*)(`@Test`|void |`@DisplayName`)' \
src/test/java/com/slatto/global/config/OpenApiDocumentationTest.javaRepository: SLAT-TO/SLATE-TO-BE
Length of output: 5315
multipart 엔드포인트에 HTTP 413 응답을 문서화하십시오.
GlobalExceptionHandler는 업로드 한도 초과를 PAYLOAD_TOO_LARGE(COMMON413)와 HTTP 413으로 반환합니다. SwaggerErrorResponseCustomizer는 413을 등록하지 않으며, OpenApiDocumentationTest에도 검증이 없습니다. 세 multipart 엔드포인트에 ErrorResponse 스키마와 PAYLOAD_TOO_LARGE 예시를 추가하고, 생성된 OpenAPI 문서의 413 응답을 검증하십시오.
📍 Affects 2 files
src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java#L44-L65(this comment)src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java#L96-L108
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java`
around lines 44 - 65, Update SwaggerErrorResponseCustomizer to add HTTP 413 for
multipart endpoints, using the ErrorResponse schema and PAYLOAD_TOO_LARGE
example while preserving existing response conditions. Extend
OpenApiDocumentationTest to verify the generated 413 response for all three
multipart endpoints; apply the customizer change in
src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java:44-65
and the corresponding assertions in
src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java:96-108.
| @Schema(description = "응답 메시지", example = "요청에 성공했습니다.") | ||
| private final String message; | ||
|
|
||
| @Schema(description = "응답 데이터. 실패 시 null 이며, 검증 실패에서만 필드 오류 목록이 담긴다.") |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
result의 실패 응답 설명을 실제 형태와 맞추십시오.
검증 실패는 ApiResponse.failure(errorCode, response)를 사용하므로 result가 null이 아닙니다. 현재 설명은 모든 실패 응답의 result가 null인 것처럼 보입니다.
일반 실패에서는 null이며, 검증 실패에서는 필드 오류 목록이 담긴다로 수정하십시오.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/slatto/global/response/ApiResponse.java` at line 24, Update
the `@Schema` description on the result field in ApiResponse to state that result
is null for general failures and contains a field-error list for validation
failures, matching ApiResponse.failure(errorCode, response).
nginx -t 가 실패하면 방금 복사한 파일을 rm 으로 지웠다. 서버에 이미 설정이 있었다면 되돌리는 게 아니라 없애는 동작이다. 실행 중인 nginx 는 설정을 메모리에 들고 있어 당장은 멀쩡하다. 문제는 다음 reload 다. certbot 갱신 훅이 reload 를 돌리는 순간 파일이 없으면 client_max_body_size 가 기본값으로 돌아가 업로드가 전부 nginx 단에서 413 이 된다. 배포 실패 시점과 장애 시점이 떨어져 있어 원인 찾기도 어렵다. 덮어쓰기 전에 원본을 남기고, 검사에 실패하면 되돌린다. 원래 파일이 없었을 때만 지운다. 백업은 include 대상이 되지 않도록 conf.d 밖에 둔다.
"같은 링크를 나중에 다시 조회할 수 없다" 고 적었는데 사실과 다르게 읽힌다. getInvitation 은 받은 토큰을 해시해서 찾으므로 inviteUrl 을 가진 사람은 몇 번이든 다시 조회할 수 있다. 서버가 원본 토큰을 갖고 있지 않다는 뜻이었으므로, 응답을 잃으면 같은 링크를 다시 받을 수 없고 새로 만들어야 한다는 문장으로 바꾼다.
COMMON413 과 전역 핸들러는 추가했는데 문서에는 413 이 어디에도 없었다. 발생하는 실패 응답이 명세에서 빠져 있으면 프론트가 문서만 보고 처리 갈래를 만들 수 없다. 413 은 서블릿 컨테이너가 강제하는 업로드 한도에서 나므로 multipart 본문을 받는 엔드포인트에서만 발생한다. 조건 없이 붙이면 실제로 나지 않는 상태 코드가 실리므로, 400/401/404 와 같은 방식으로 조건부 주입한다. 컨트롤러마다 애노테이션을 붙이지 않는 이유는 같다. 손으로 적은 문서는 코드가 바뀔 때 조용히 낡는다. 붙는 범위가 어긋나면 드러나도록 문서 검증 테스트에 단언을 추가한다. 주입을 끈 상태에서 이 단언이 실패하는 것까지 확인했다.
🔗 관련 이슈 (Related Issue)
📝 작업 내용
Swagger 문서가 실제 API 동작을 그대로 보여주도록 정비하고, 그 과정에서 드러난 업로드 관련 버그 두 개를 함께 고쳤습니다.
문서 쪽 공백은 세 가지였습니다.
첫째, 실패 응답이 통째로 빠져 있었습니다. 에러는
GlobalExceptionHandler가 전역에서 처리하기 때문에 컨트롤러에는 흔적이 남지 않고, 그래서 문서에는 성공 응답만 실렸습니다. 108개 엔드포인트 중 실패 응답이 문서화된 곳은 0개였습니다.둘째, 인증 방식이 구분되지 않았습니다. 문서 전체에 인증 요구가 걸려 있어서 모든 엔드포인트가 토큰 필수로 읽혔습니다. 실제로는
SecurityConfig의permitAll경로 19개가 토큰 없이 열려 있고, 그중 8개는 토큰이 있어도 되고 없어도 되는 게스트 참여 경로입니다.셋째, 엔드포인트 설명이 있는 곳과 없는 곳이 섞여 있었습니다. 108개 중 69개만 설명(
description)을 갖고 있었고, 나머지 39개는 이름(summary)만 있었습니다.문서를 맞추는 과정에서 업로드 경로의 버그 두 개를 찾았습니다.
앱과 프론트는 100MB로 맞춰져 있는데 운영에서는 1MB만 넘어도 업로드가 막히고 있었습니다. nginx에
client_max_body_size가 설정된 적이 없어 기본값 1MB로 동작하고 있었습니다.그리고 nginx를 열고 보니, 앱 한도를 넘긴 요청은 500(COMMON500)으로 나가고 있었습니다. multipart 한도 초과 예외가 전역 핸들러에 등록되어 있지 않았습니다.
그리고 지금 main이 깨져 있는 문제(마이그레이션 버전 016 중복)를 7번에서 함께 고쳤습니다. 급하면 그 커밋만 따로 떼어 먼저 머지해도 됩니다.
런타임 동작 변경 범위: 1
4번은 문서만 바꿉니다. 56번은 동작이 바뀝니다 — nginx가 105MB까지 통과시키고, 한도 초과 응답이 500에서 413으로 바뀝니다. 7번은 파일 이름만 바꿉니다.1. 공통 에러 응답 전역 문서화
모든 엔드포인트에 공통 실패 응답을 붙였습니다. 다만 실제로 발생할 수 있는 상태 코드만 붙입니다.
조건을 두지 않고 일괄로 붙이면, 문서에는 있지만 실제로는 나지 않는 상태 코드가 생깁니다. 그건 명세와 구현이 어긋난 것과 같아서 없느니만 못하다고 판단했습니다. 파라미터도 본문도 없는 엔드포인트에 400이 붙지 않는 이유가 그것입니다.
엔드포인트가
@ApiResponse로 직접 선언한 응답이 있으면 그쪽이 우선합니다. 여기서 덮어쓰면 개별 문서화가 무의미해집니다.400은 두 가지 예시를 함께 보여줍니다.
2. 스키마와 예시를 코드에서 뽑아내기
문서에 값을 손으로 적어두면, 응답 클래스나 에러 코드가 바뀌었을 때 코드는 정상인데 문서만 조용히 낡습니다. 그래서 문서에 들어가는 값을 전부 코드에서 가져옵니다.
ApiResponse클래스에서 추출합니다.code와message는CommonErrorCodeenum에서 그대로 가져옵니다.ApiResponse.failure(...)를 직렬화해서 만듭니다.ApiResponse에 붙은 예시는 성공 기준(COMMON200)이라 실패 스키마에 그대로 쓰면 문서가 거짓말을 합니다. 실패 스키마에서는 실제 실패 응답 값으로 덮어씁니다.검증 실패 본문(
ValidationErrorResponse)은 실패 응답의result가 직접 참조하도록 연결했습니다. 참조되지 않는 스키마는 springdoc이 문서에서 걷어내기 때문에, 이 연결이 곧 등록 조건이기도 합니다.3. 인증 방식 명시
SecurityConfig의permitAll경로를 문서에 그대로 옮겼습니다. 세 가지로 나뉩니다.인증 불필요 (11개) —
@SecurityRequirements로 자물쇠를 뗍니다.로그인/회원가입/토큰 재발급 등 인증 관련 7개는 이미 표시되어 있었고, 이번에
GET /api/v1/health,GET /api/v1/project-invitations/{token},GET /api/v1/share-links/{token},POST /api/v1/share-links/{token}/guests4개를 추가했습니다.인증 선택 (8개) — 게스트 피드백/답글 참여 경로입니다.
이 경로들은 토큰 없이 열려 있지만, 토큰을 함께 보내면 로그인 사용자로 처리됩니다. 문서에서 이 둘은 구분되지 않았습니다. 자물쇠만 보면 토큰이 필수처럼 읽히고, 자물쇠를 떼면 로그인 사용자로 호출할 방법이 없는 것처럼 읽힙니다.
OpenAPI는
security목록에 빈 요구사항을 함께 넣으면 둘 다 허용이라는 뜻이 됩니다. 그 표현을 쓰고, 토큰 유무에 따라 무엇이 달라지는지 설명으로 덧붙였습니다.대상은
POST/GET /videos/{videoId}/feedbacks,PATCH/DELETE /feedbacks/{feedbackId},POST/GET /feedbacks/{feedbackId}/replies,PATCH/DELETE /replies/{replyId}입니다.PATCH /feedbacks/{feedbackId}/status와PATCH /replies/{replyId}/status는permitAll에 없어서 인증 필수로 두었습니다.나머지 (89개) — 401을 문서화합니다.
자물쇠 표시와 401 문서화가 서로 어긋나지 않도록, 두 판정을
EndpointAuthentication한 곳에서 가져다 씁니다.4. 비어 있던 엔드포인트 설명 39개
설명이 빠져 있던 곳은 프로젝트 계열에 몰려 있었습니다.
비어 있던 39개를 채워 108개 전부가 설명을 갖습니다.
summary를 풀어 쓴 문장은 쓰지 않았습니다.
설명을 채우는 가장 쉬운 방법은 이름을 문장으로 늘리는 것입니다.
이건 읽는 사람에게 아무것도 주지 않으면서 문서만 길어집니다. 그래서 호출하는 쪽이 지금까지 코드를 열어봐야 알 수 있던 것만 적었습니다.
서비스 코드와 쿼리를 읽고 확인한 것만 적었습니다. 크게 여섯 갈래입니다.
권한 — 누가 호출할 수 있는지가 엔드포인트마다 다릅니다.
덮어쓰기 여부 — 같은 PATCH인데 동작이 반대인 곳이 있습니다.
PATCH /projects/{projectId}: 보내지 않은 필드가 null로 덮어써집니다PATCH /projects/{projectId}/files/{fileId}: 보내지 않은 필드는 기존 값이 유지됩니다둘 다 PATCH라서 문서만 봐서는 구분할 수 없었습니다. 프로젝트 수정 쪽은 "바꾸지 않을 값도 함께 보내야 한다"를 명시했습니다.
적용 범위 — 같은 "고정"인데 보이는 범위가 다릅니다.
제한값
pdf/jpg/jpeg/png/doc/docx만, 확장자와 Content-Type 일치 필요size기본 20 / 최대 50조회 범위
GET /notifications: 최근 24시간 이내 갱신된 알림만 반환, 읽지 않은 것 우선 정렬PATCH /notifications/read-all: 24시간 제한이 없어 목록에 보이지 않는 오래된 알림도 함께 처리됩니다목록에 3개만 보이는데 전체 읽음을 누르면 그보다 많이 처리되는 이유입니다.
응답 형태
GET /projects/{projectId}/files/{fileId}/download만 공통 응답 래퍼를 쓰지 않고 파일 본문을 그대로 반환합니다 (Content-Disposition: attachment)그 외에 초대 링크는 원본 토큰이 응답의
inviteUrl에만 담기고 서버에는 해시로 저장되어 나중에 같은 링크를 다시 조회할 수 없다는 점, 구인 공고 상세는 조회수를 먼저 올리고 읽어서 응답의viewCount에 이번 조회가 반영된다는 점, 멤버 상세의memberId가 사용자 ID가 아니라 프로젝트 멤버 ID라는 점을 적었습니다.이름만 보고 적었으면 틀렸을 것 두 가지
p.id desc라 최근에 만들어진 순이었습니다.ON DUPLICATE KEY UPDATE read_at = :now라 읽은 시각이 갱신됩니다.둘 다 코드를 안 봤으면 그대로 나갔을 내용입니다.
문서 예시에 실제 팀원 이름이 들어가 있던 것도 함께 정리했습니다. 닉네임 필드는 규칙 문서 예시와 같은 "그린", 실명 필드는 제 이름으로 바꿨습니다.
5. nginx 업로드 한도 미설정 (운영 반영 완료)
파일 업로드 한도를 문서에 적으려고 확인하다가 발견했습니다. 앱은 100MB, 프론트도 100MB로 안내하는데 운영에서는 1MB를 넘으면 업로드가 실패하고 있었습니다.
nginx
client_max_body_size의 기본값이 1MB인데, 이 설정을 어디에도 두지 않았습니다. 운영에서 크기를 바꿔가며 확인한 결과입니다.PROJECT_FILE_INVALID_TYPE400(공통 응답 JSON)문제가 두 겹입니다. 요청이 스프링에 닿지 않으니 서비스의 검증 로직은 실행되지 않고, 응답도 공통 래퍼가 아니라 nginx 기본 HTML이라 프론트가 원인을 표시할 수 없습니다.
infra/nginx/conf.d/upload-limits.conf를 추가했습니다.spring.servlet.multipart.max-request-size와 맞춘 값입니다. 개별 파일 한도(100MB)에 JSON 파트와 멀티파트 경계가 더해지므로 그보다 커야 합니다.conf.d에 두어 http 레벨에 적용했습니다. certbot이 관리하는 server 블록은 건드리지 않고, 모든 server/location으로 상속됩니다.설정을 서버에만 두면 인스턴스를 새로 띄울 때 기본값으로 돌아갑니다. 리포의 파일이 매 배포마다 반영되도록
cd-prod.yml에 단계를 넣었습니다.nginx -t를 통과할 때만 reload하고, 실패하면 복사한 파일을 지우고 실행 중인 nginx는 그대로 둡니다.운영에는 이미 수동으로 같은 설정을 반영해 두었습니다. 이 PR은 그 상태를 코드로 고정하는 쪽입니다.
6. 업로드 한도를 넘긴 요청이 500으로 나가던 문제
nginx를 열고 나니 앱 한도(100MB)를 넘긴 요청이 다음 벽입니다. 그 응답을 확인해 보니 500이었습니다.
multipart 한도 초과는 요청 본문을 읽는 단계에서
MaxUploadSizeExceededException으로 끊깁니다. 컨트롤러에 닿지 않으니 서비스의 파일 크기 검증(PROJECT_FILE_SIZE400,USER_PROFILE_IMAGE_SIZE400등)은 실행조차 되지 않습니다. 전역 핸들러에 이 예외가 없어서@ExceptionHandler(Exception.class)가 받았고, 결과적으로COMMON500이 나갔습니다. 프론트는 "파일이 너무 큽니다" 대신 서버 오류를 표시하고, 서버에는 사용자 실수 때마다 ERROR 로그가 쌓입니다.CommonErrorCode에COMMON413을 추가하고 두 핸들러를 등록했습니다.MaxUploadSizeExceededExceptionCOMMON413"업로드 용량이 허용된 한도를 초과했습니다."MultipartException(그 외 해석 실패)COMMON400413으로 맞춘 이유는 5번과 같은 갈래로 처리되게 하기 위해서입니다. nginx도 한도를 넘기면 413을 주므로, 프론트는 105MB에서 막히든 100MB에서 막히든 413 하나만 보면 됩니다.
400을 함께 잡은 이유는 업로드 중 연결이 끊기면(본문이 잘림, boundary 불일치) 지금은 500이 찍히기 때문입니다. 클라이언트 요청 문제이므로 400으로 내렸습니다.
MaxUploadSizeExceededException은MultipartException의 하위 타입이고, 스프링은 더 구체적인 핸들러를 고르므로 둘을 함께 등록해도 안전합니다.검증:
MultipartUploadLimitTest를 추가했습니다. 실제 포트를 띄워(RANDOM_PORT+TestRestTemplate) 한도를 1KB로 낮추고 진짜 HTTP 요청을 보냅니다. MockMvc로는 재현되지 않습니다 —MockMvcRequestBuilders.multipart()는 테스트가 요청 객체를 직접 조립하기 때문에 파싱 자체가 일어나지 않고, 한도는 서블릿 컨테이너가 강제하는 값이라 검사도 일어나지 않습니다.핸들러를 임시로 되돌린 상태에서 이 테스트가 실패하는 것까지 확인했습니다.
7. 마이그레이션 버전 016 중복 (현재 main 배포 불가 상태)
이 브랜치와는 별개로 지금 main이 깨져 있어 함께 고쳤습니다.
V016__guest_session_token.sql과V016__portfolio_period.sql이 같은 버전 번호를 씁니다. 두 브랜치가 각자 016을 집었는데, 파일 이름이 달라서 git은 충돌로 잡지 못합니다. 그냥 다른 파일 두 개로 보여 깨끗하게 머지됩니다. 같은 버전이라는 건 Flyway만 압니다.각 PR의 CI는 자기 브랜치에 016이 하나뿐이라 통과했고, main에 합쳐진 뒤에야 터졌습니다.
flywayInfo에서Found more than one migration with version 016flywayInitializer에서 같은 예외 → Health Check 실패 → 자동 롤백포트폴리오 쪽을 V018로 옮겼습니다. 게스트 쪽 V016·V017은 이미 운영에 적용되어 이력에 남아 있어 번호를 바꿀 수 없습니다. 아직 적용되지 않은 것은 포트폴리오 쪽 하나뿐이고, 017까지 차 있어 다음 빈 번호가 018입니다.
두 파일은 건드리는 테이블이 겹치지 않아(
guestvsuser_portfolio) 실행 순서는 결과에 영향이 없습니다. SQL 내용은 그대로입니다.이 브랜치가 main보다 뒤처져 있어 main을 먼저 병합했습니다.
PATCH /projects/{projectId}설명이 양쪽에서 함께 수정되어 충돌했고, 호출 규칙(ADMIN 전용, 미전송 필드 null 덮어쓰기)과 완료 전환 동작 설명을 둘 다 남겼습니다.재발 방지 제안: main 브랜치 보호에 "Require branches to be up to date before merging"을 켜면 이 유형은 막힙니다. 이번엔 이틀 전 main 기준으로 통과한 CI 결과로 머지됐습니다. 켜두면 먼저 들어간 쪽이 있을 때 나중 브랜치가 main을 다시 당기고 CI를 돌려야 하고, 그때
flywayInfo가 잡습니다.8. 생성된 문서 검증 테스트
문서는 애노테이션에서 조립되기 때문에 애노테이션을 빠뜨려도 빌드가 깨지지 않습니다. 누락은 배포된 문서를 열어봐야 드러납니다. 그래서
/v3/api-docs를 실제로 호출해서 결과를 검증하는 테스트를 추가했습니다.summary)을 가진다summary를 되풀이하지 않는 설명(description)을 가진다기대값은 실제 응답 객체를 직렬화해서 만듭니다. 필드명을 테스트에 적어두면 그 하드코딩도 코드와 같이 낡기 때문입니다.
문서에서 감추려는 엔드포인트는
@Hidden을 붙이면 검증 대상에서 빠집니다. 현재GET /api/v1/auth/callback/google하나가 여기 해당합니다.✅ PR 체크리스트
./gradlew compileJava로 컴파일을 확인했습니다../gradlew test로 테스트를 확인했습니다. (136개 통과, main 병합 후 기준)/v3/api-docs에서 설명 없는 엔드포인트가 0개임을 확인했습니다. (108/108)Summary by CodeRabbit
새 기능
개선 사항