[Feat/#7] 공통 API 응답 포맷 추가 - #8
Open
xeoxxn wants to merge 2 commits into
Open
Conversation
5 tasks
tnals0924
approved these changes
Aug 21, 2026
tnals0924
left a comment
Member
There was a problem hiding this comment.
빠른 작업 감사드립니다~
리뷰 남긴 거 한 번 확인 부탁해요👍
|
|
||
| public record CursorSliceResponse<T>(List<T> content, boolean hasNext, Long nextCursor) { | ||
|
|
||
| public static <T> CursorSliceResponse<T> from(CursorSliceResult<T> result) { |
|
|
||
| @Getter | ||
| @RequiredArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public final class ApiResponse<T> { |
Member
There was a problem hiding this comment.
CursorSliceResponse는 record로 정의하셨는데, ApiResponse는 final class로 정의하신 이유가 따로 있을까요?!
Member
There was a problem hiding this comment.
PR 본문에서 확인했습니다!
일단 인증 PR 머지 전까지 대기해 주세요~
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
api:common-api모듈에 소스 파일이 하나도 없어서 모든 API가 공유할 응답 래퍼가 없습니다.RestAuthenticationEntryPoint·RestAccessDeniedHandler가handlerExceptionResolver로BusinessException을 위임하는데, 이를 받아 직렬화할 응답 타입이 없습니다.❓ 왜 해결해야 하나요?
컨트롤러를 만들기 전에 응답 규약이 있어야 합니다. 규약 없이 컨트롤러가 먼저 생기면 나중에 전부 고쳐야 합니다.
GlobalExceptionHandler(error-handling.md4절)의 선행 조건이기도 합니다 — 그 핸들러가ApiResponse.error(...)를 호출합니다.⭐ 어떻게 해결했나요?
새 파일 3개. 기존 파일 수정 0건,
build.gradle.kts무변경입니다(필요한 의존성이 이미 다 선언되어 있습니다).ApiResponse<T>api:common-api/kr.ac.kookmin.streamfinal class+@Getter+ private 생성자 + 정적 팩토리 5개CursorSliceResponse<T>api:common-api/kr.ac.kookmin.streamrecord+from(CursorSliceResult<T>)CursorSliceResult<T>core:common/kr.ac.kookmin.stream.commonrecord정적 팩토리 5개 —
success(T)/success()/error(BusinessException)/error(ErrorCode)/error(String, String). 마지막 것은@Valid필드 에러 메시지를 실으려고 둔 것으로error-handling.md5절이 규정한 시그니처입니다.기존
ErrorCode체계(core:common)를 그대로 받습니다. 두 가지가 설계에 영향을 줬습니다.errorCode.name()·errorCode.message()를 씁니다.BusinessException.getMessage()는 두 생성자 모두super(...)로 채우므로 null이 될 수 없습니다. 참고 구현의exception.message ?: errorCode.message폴백은 죽은 코드가 되어e.getMessage()단독으로 씁니다 — 가변 인자 생성자의message().formatted(args)결과가 그대로 응답에 실립니다.ApiResponse를record가 아니라final class로 둔 이유 — record의 정규 생성자는 record 자신보다 접근 범위를 좁힐 수 없어private으로 감출 수 없습니다. 그러면 호출부가 팩토리를 우회해new ApiResponse<>(true, "SOME_ERROR", ...)같은 **모순 상태(success=true인데 code는 에러)**를 만들 수 있습니다.coding-style.md2-2절이 지정한 형태이기도 합니다.@Accessors(fluent = true)를 붙이지 않은 이유 — 이 저장소는CommonErrorCode·UserAuthentication에서 fluent를 쓰지만, 여기 붙이면 접근자가success()/code()가 되어 Jackson의 getter 규약에서 벗어나 프로퍼티가 인식되지 않습니다(JSON이{}로 나갑니다).status를 응답 본문에 넣지 않았습니다 —ErrorCode.status()가 있지만 HTTP 상태는GlobalExceptionHandler가ResponseEntity.status(...)로 전송 계층에 싣습니다. 본문에 중복하면 두 값이 어긋날 여지가 생깁니다.🧩 이 PR의 한계 & 트레이드오프
GlobalExceptionHandler를 포함하지 않았습니다.ApiResponse.error(...)를 호출하는 주체가 그 핸들러라, 이 PR만으로는 401/403과 모든 예외가 여전히 공통 포맷으로 나가지 않습니다.ApiResponse는 "호출될 준비만 된" 상태입니다 → 후속 작업 필요@Schema(Swagger) 없음 — 저장소에 springdoc 의존성이 없어 애노테이션을 import할 수 없습니다. springdoc 도입은 별개 작업이라 이 PR에서 라이브러리를 들이지 않았습니다.error-handling.md6절의@ApiErrorCode도 같은 이유로 미포함입니다ApiResponse는 직렬화 전용입니다 — private 생성자에@JsonCreator가 없어 역직렬화가InvalidDefinitionException으로 실패합니다. 생성 경로를 팩토리로 모으는 설계의 결과입니다. 나중에 통합 테스트에서 응답 본문을ApiResponse로 읽으려면jsonPath("$.code")나Map/JsonNode를 써야 합니다.CursorSliceResponse는 record라 역직렬화됩니다code,data,message,success) — Jackson 3의 기본 동작입니다. 키 순서는 JSON에서 의미가 없고 클라이언트는 키 이름으로 찾으므로@JsonPropertyOrder를 붙이지 않았습니다. record인CursorSliceResponse는 선언 순서를 유지합니다PageResult/PageResponse미포함 —coding-style.md2-4절에 규정되어 있지만 오프셋 페이징을 요구하는 화면이 아직 없습니다content방어적 복사 없음 —List.copyOf를 쓰면content가 null일 때 NPE가 되고 컨벤션에 규정이 없습니다. 참고 구현도 그대로 넘기며, 응답 직렬화 직전에만 쓰이는 객체라 실질 위험이 낮다고 봤습니다⛓️ 기존 기능에 미치는 영향
없습니다. 새 파일 3개뿐이고 기존 파일 수정 0건, 빌드 스크립트 무변경입니다.
RestAuthenticationEntryPoint(401)·RestAccessDeniedHandler(403)는handlerExceptionResolver위임 구조라,GlobalExceptionHandler가 생기면 코드 수정 없이 자동으로 공통 포맷을 타게 됩니다api:common-api→core:common(architecture.md7절 허용). 안쪽을 향합니다ApiResponse는{basePackage}자체에 놓여 어느 모듈에도 속하지 않습니다.verify()통과를 확인했고,writeDocs()가 생성한 모듈 목록은common/db/member/security넷으로 변화가 없습니다.core:common의 shared module 선언도 필요하지 않았습니다검증 (로컬, JDK 21):
🔀 Edge Case & 실패 시나리오
실제 Jackson 3.1.4로 직렬화를 확인했습니다.
data가 없는 응답"data": null을 노출합니다. 키가 항상 존재하는 편이 클라이언트 파싱에 예측 가능합니다hasNext: false,nextCursor: null.nextCursor를 박싱Long으로 둔 이유입니다 —long이면 "없음"을 표현할 수 없습니다ErrorCode.message()에 포맷 인자가 있을 때BusinessException의 가변 인자 생성자가 적용한 결과가 그대로 실립니다 (위RENTAL_LIMIT_EXCEEDED예시)error(String, String)@Valid실패 시 필드 에러 메시지를 코드와 분리해 실을 수 있습니다ApiResponse역직렬화📋 검토한 대안과 선택 이유
ApiResponse를record로private으로 감출 수 없어 팩토리 우회로 모순 상태를 만들 수 있습니다 (위 "어떻게 해결했나요")@JsonInclude(NON_NULL)로data생략CursorSliceResponse.of(content, hasNext, nextCursor)만 두고CursorSliceResult생략Response가 아무 도메인 흐름에도 연결되지 않은 채 남고, 나중에 커서 페이징을 구현할 때of→from으로 다시 열어 고쳐야 합니다.Result는 record 한 줄이고 새 의존성이 없어 함께 만들었습니다List.copyOf로 방어적 복사content가 null일 때 NPE. 컨벤션에 null 처리 규정이 없습니다stream.api.dto/stream.api.common.dto로architecture.md103줄이common-api를 하위 패키지 없이{basePackage}에 두라고 규정하고, 181줄이 Gradle 경로를 패키지에 반영하지 말라고 명시합니다.api.common은 정확히 그 금지에 해당합니다stream.common.dto(공유 커널 안쪽)core:common모듈의 패키지 트리에 프레젠테이션 타입이 얹히고, 하위 패키지라 Modulith 기준 "내부 타입"이 되어 컨트롤러들의ApiResponse참조가 전부verify()위반이 됩니다💬 리뷰 포인트
[r]패키지 위치 —kr.ac.kookmin.stream(하위 패키지 없음)입니다.architecture.md103줄을 그대로 따랐고, 결과적으로bootstrap의StreamServerApplication과 같은 패키지를 공유합니다. 문서가 105·113줄에서admin-api/app-api도 둘 다{basePackage}.{팀}을 쓰게 규정하므로 의도된 구조로 판단했는데, 한 번 봐주시면 좋겠습니다[r]GlobalExceptionHandler후속 작업이 필요합니다 — 이 PR만으로는 에러 응답이 공통 포맷으로 나가지 않습니다[c]JSON 키 순서 — 알파벳 순으로 나갑니다.@JsonPropertyOrder로 맞출지 의견 주세요[a]Javadoc 없음 — 기존 record(Member,JwtPayload)와 맞췄습니다error-handling.md203줄이dto.ApiResponse로 적어놔architecture.md103줄(평탄 배치)과 어긋납니다. 이 PR은architecture.md를 따랐고, 문서 정리는 별도 작업으로 남겼습니다