Skip to content

[Feat/#7] 공통 API 응답 포맷 추가 - #8

Open
xeoxxn wants to merge 2 commits into
feat/#5-jwt-auth-filterfrom
feat/#7-api-response-format
Open

[Feat/#7] 공통 API 응답 포맷 추가#8
xeoxxn wants to merge 2 commits into
feat/#5-jwt-auth-filterfrom
feat/#7-api-response-format

Conversation

@xeoxxn

@xeoxxn xeoxxn commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

base가 main이 아니라 feat/#5-jwt-auth-filter입니다 (#6 위에 쌓은 stacked PR).
main을 base로 잡으면 #5의 13커밋이 함께 잡혀 diff가 28파일이 됩니다. 리뷰 범위를 이 작업의 3파일로 한정하기 위해 stacked로 올렸습니다.
#6이 머지된 뒤 git rebase --onto origin/main fb7d351 + force push 후 이 PR을 머지합니다.

🎯 해결하려는 문제가 무엇인가요?

api:common-api 모듈에 소스 파일이 하나도 없어서 모든 API가 공유할 응답 래퍼가 없습니다.

  • 컨트롤러가 응답 형태를 각자 정하게 되고, 클라이언트가 성공/실패를 판별할 공통 규약이 없습니다.
  • 이미 구현된 RestAuthenticationEntryPoint·RestAccessDeniedHandlerhandlerExceptionResolverBusinessException을 위임하는데, 이를 받아 직렬화할 응답 타입이 없습니다.

❓ 왜 해결해야 하나요?

컨트롤러를 만들기 전에 응답 규약이 있어야 합니다. 규약 없이 컨트롤러가 먼저 생기면 나중에 전부 고쳐야 합니다.

GlobalExceptionHandler(error-handling.md 4절)의 선행 조건이기도 합니다 — 그 핸들러가 ApiResponse.error(...)를 호출합니다.

⭐ 어떻게 해결했나요?

새 파일 3개. 기존 파일 수정 0건, build.gradle.kts 무변경입니다(필요한 의존성이 이미 다 선언되어 있습니다).

타입 모듈 / 패키지 형태
ApiResponse<T> api:common-api / kr.ac.kookmin.stream final class + @Getter + private 생성자 + 정적 팩토리 5개
CursorSliceResponse<T> api:common-api / kr.ac.kookmin.stream record + from(CursorSliceResult<T>)
CursorSliceResult<T> core:common / kr.ac.kookmin.stream.common record

정적 팩토리 5개 — success(T) / success() / error(BusinessException) / error(ErrorCode) / error(String, String). 마지막 것은 @Valid 필드 에러 메시지를 실으려고 둔 것으로 error-handling.md 5절이 규정한 시그니처입니다.

기존 ErrorCode 체계(core:common)를 그대로 받습니다. 두 가지가 설계에 영향을 줬습니다.

  1. 접근자가 fluent라 errorCode.name()·errorCode.message()를 씁니다.
  2. BusinessException.getMessage()는 두 생성자 모두 super(...)로 채우므로 null이 될 수 없습니다. 참고 구현의 exception.message ?: errorCode.message 폴백은 죽은 코드가 되어 e.getMessage() 단독으로 씁니다 — 가변 인자 생성자의 message().formatted(args) 결과가 그대로 응답에 실립니다.

ApiResponserecord가 아니라 final class로 둔 이유 — record의 정규 생성자는 record 자신보다 접근 범위를 좁힐 수 없어 private으로 감출 수 없습니다. 그러면 호출부가 팩토리를 우회해 new ApiResponse<>(true, "SOME_ERROR", ...) 같은 **모순 상태(success=true인데 code는 에러)**를 만들 수 있습니다. coding-style.md 2-2절이 지정한 형태이기도 합니다.

@Accessors(fluent = true)를 붙이지 않은 이유 — 이 저장소는 CommonErrorCode·UserAuthentication에서 fluent를 쓰지만, 여기 붙이면 접근자가 success()/code()가 되어 Jackson의 getter 규약에서 벗어나 프로퍼티가 인식되지 않습니다(JSON이 {}로 나갑니다).

status를 응답 본문에 넣지 않았습니다ErrorCode.status()가 있지만 HTTP 상태는 GlobalExceptionHandlerResponseEntity.status(...)로 전송 계층에 싣습니다. 본문에 중복하면 두 값이 어긋날 여지가 생깁니다.

🧩 이 PR의 한계 & 트레이드오프

  • GlobalExceptionHandler를 포함하지 않았습니다. ApiResponse.error(...)를 호출하는 주체가 그 핸들러라, 이 PR만으로는 401/403과 모든 예외가 여전히 공통 포맷으로 나가지 않습니다. ApiResponse는 "호출될 준비만 된" 상태입니다 → 후속 작업 필요
  • @Schema(Swagger) 없음 — 저장소에 springdoc 의존성이 없어 애노테이션을 import할 수 없습니다. springdoc 도입은 별개 작업이라 이 PR에서 라이브러리를 들이지 않았습니다. error-handling.md 6절의 @ApiErrorCode도 같은 이유로 미포함입니다
  • ApiResponse는 직렬화 전용입니다 — private 생성자에 @JsonCreator가 없어 역직렬화가 InvalidDefinitionException으로 실패합니다. 생성 경로를 팩토리로 모으는 설계의 결과입니다. 나중에 통합 테스트에서 응답 본문을 ApiResponse로 읽으려면 jsonPath("$.code")Map/JsonNode를 써야 합니다. CursorSliceResponse는 record라 역직렬화됩니다
  • JSON 키 순서가 알파벳 순입니다 (code,data,message,success) — Jackson 3의 기본 동작입니다. 키 순서는 JSON에서 의미가 없고 클라이언트는 키 이름으로 찾으므로 @JsonPropertyOrder를 붙이지 않았습니다. record인 CursorSliceResponse는 선언 순서를 유지합니다
  • PageResult/PageResponse 미포함coding-style.md 2-4절에 규정되어 있지만 오프셋 페이징을 요구하는 화면이 아직 없습니다
  • content 방어적 복사 없음List.copyOf를 쓰면 content가 null일 때 NPE가 되고 컨벤션에 규정이 없습니다. 참고 구현도 그대로 넘기며, 응답 직렬화 직전에만 쓰이는 객체라 실질 위험이 낮다고 봤습니다

⛓️ 기존 기능에 미치는 영향

없습니다. 새 파일 3개뿐이고 기존 파일 수정 0건, 빌드 스크립트 무변경입니다.

  • RestAuthenticationEntryPoint(401)·RestAccessDeniedHandler(403)는 handlerExceptionResolver 위임 구조라, GlobalExceptionHandler가 생기면 코드 수정 없이 자동으로 공통 포맷을 타게 됩니다
  • 의존 방향: api:common-apicore:common (architecture.md 7절 허용). 안쪽을 향합니다
  • Modulith: ApiResponse{basePackage} 자체에 놓여 어느 모듈에도 속하지 않습니다. verify() 통과를 확인했고, writeDocs()가 생성한 모듈 목록은 common/db/member/security 넷으로 변화가 없습니다. core:common의 shared module 선언도 필요하지 않았습니다

검증 (로컬, JDK 21):

./gradlew :api:common-api:compileJava                      BUILD SUCCESSFUL
./gradlew :bootstrap:test --tests "*ModularityTests"       BUILD SUCCESSFUL (2 tests, 0 failures)
./gradlew check                                            BUILD SUCCESSFUL
StreamServerApplicationTests.contextLoads()                통과

🔀 Edge Case & 실패 시나리오

실제 Jackson 3.1.4로 직렬화를 확인했습니다.

success(data)       {"code":"SUCCESS","data":{"memberId":1,"name":"이서준"},"message":"요청에 성공했습니다.","success":true}
success()           {"code":"SUCCESS","data":null,"message":"요청에 성공했습니다.","success":true}
error(ErrorCode)    {"code":"UNAUTHORIZED","data":null,"message":"인증이 필요합니다.","success":false}
error(exception)    {"code":"FORBIDDEN","data":null,"message":"접근 권한이 없습니다.","success":false}
error(포맷 인자)     {"code":"RENTAL_LIMIT_EXCEEDED","data":null,"message":"대여는 최대 3건까지 가능합니다.","success":false}
error(code,message) {"code":"INVALID_INPUT","data":null,"message":"학번은 숫자 8자리여야 합니다.","success":false}
cursor 다음 있음     {"content":[{...},{...}],"hasNext":true,"nextCursor":2}
cursor 마지막 페이지  {"content":[],"hasNext":false,"nextCursor":null}
상황 처리
data가 없는 응답 "data": null을 노출합니다. 키가 항상 존재하는 편이 클라이언트 파싱에 예측 가능합니다
마지막 페이지 hasNext: false, nextCursor: null. nextCursor를 박싱 Long으로 둔 이유입니다 — long이면 "없음"을 표현할 수 없습니다
ErrorCode.message()에 포맷 인자가 있을 때 BusinessException의 가변 인자 생성자가 적용한 결과가 그대로 실립니다 (위 RENTAL_LIMIT_EXCEEDED 예시)
error(String, String) @Valid 실패 시 필드 에러 메시지를 코드와 분리해 실을 수 있습니다
ApiResponse 역직렬화 실패합니다(위 "한계" 참조). 서버가 자기 응답을 역직렬화할 일은 없습니다

📋 검토한 대안과 선택 이유

대안 선택하지 않은 이유
ApiResponserecord 생성자를 private으로 감출 수 없어 팩토리 우회로 모순 상태를 만들 수 있습니다 (위 "어떻게 해결했나요")
@JsonInclude(NON_NULL)data 생략 참고 구현에 애노테이션이 없고, 키가 항상 존재하는 편이 클라이언트에 예측 가능합니다
CursorSliceResponse.of(content, hasNext, nextCursor)만 두고 CursorSliceResult 생략 Response가 아무 도메인 흐름에도 연결되지 않은 채 남고, 나중에 커서 페이징을 구현할 때 offrom으로 다시 열어 고쳐야 합니다. Result는 record 한 줄이고 새 의존성이 없어 함께 만들었습니다
List.copyOf로 방어적 복사 content가 null일 때 NPE. 컨벤션에 null 처리 규정이 없습니다
패키지를 stream.api.dto / stream.api.common.dto architecture.md 103줄이 common-api를 하위 패키지 없이 {basePackage}에 두라고 규정하고, 181줄이 Gradle 경로를 패키지에 반영하지 말라고 명시합니다. api.common은 정확히 그 금지에 해당합니다
stream.common.dto(공유 커널 안쪽) core:common 모듈의 패키지 트리에 프레젠테이션 타입이 얹히고, 하위 패키지라 Modulith 기준 "내부 타입"이 되어 컨트롤러들의 ApiResponse 참조가 전부 verify() 위반이 됩니다

💬 리뷰 포인트

  • [r] 패키지 위치kr.ac.kookmin.stream(하위 패키지 없음)입니다. architecture.md 103줄을 그대로 따랐고, 결과적으로 bootstrapStreamServerApplication과 같은 패키지를 공유합니다. 문서가 105·113줄에서 admin-api/app-api도 둘 다 {basePackage}.{팀}을 쓰게 규정하므로 의도된 구조로 판단했는데, 한 번 봐주시면 좋겠습니다
  • [r] GlobalExceptionHandler 후속 작업이 필요합니다 — 이 PR만으로는 에러 응답이 공통 포맷으로 나가지 않습니다
  • [c] JSON 키 순서 — 알파벳 순으로 나갑니다. @JsonPropertyOrder로 맞출지 의견 주세요
  • [a] Javadoc 없음 — 기존 record(Member, JwtPayload)와 맞췄습니다
  • 참고로 error-handling.md 203줄이 dto.ApiResponse로 적어놔 architecture.md 103줄(평탄 배치)과 어긋납니다. 이 PR은 architecture.md를 따랐고, 문서 정리는 별도 작업으로 남겼습니다

@xeoxxn
xeoxxn requested a review from tnals0924 August 20, 2026 18:13
@xeoxxn xeoxxn self-assigned this Aug 20, 2026
@xeoxxn xeoxxn linked an issue Aug 20, 2026 that may be closed by this pull request
5 tasks

@tnals0924 tnals0924 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빠른 작업 감사드립니다~
리뷰 남긴 거 한 번 확인 부탁해요👍


public record CursorSliceResponse<T>(List<T> content, boolean hasNext, Long nextCursor) {

public static <T> CursorSliceResponse<T> from(CursorSliceResult<T> result) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정적 팩토리 메서드로 객체 생성하는 거 좋습니다👍


@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class ApiResponse<T> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CursorSliceResponse는 record로 정의하셨는데, ApiResponse는 final class로 정의하신 이유가 따로 있을까요?!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 본문에서 확인했습니다!
일단 인증 PR 머지 전까지 대기해 주세요~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

공통 API 응답 포맷 추가 (ApiResponse, CursorSliceResponse)

2 participants