🖥️ 컴퓨터 공부/Spring

API 서버 - 응답 통일 어노테이션 만들기 ( @ApiSuccess )

le2donguk 2026. 2. 17. 00:47

개요

사이드 프로젝트를 하기 전의 우리들만의 아키텍처 규칙을 만들고 싶었다 특히 김영한 님의 스프링 강의를 보면서 MessageSource와 나만의 Annotation을 만들어서 최대한 개발하는데 편의성을 높이고 싶었다

 

그래서 이번에는 API 응답의 통일성을 가져가고 이를 하면서 느낀 트러블 슈팅 과정을 기록해 볼려고 합니다


목표

1. 성공 응답과 실패 응답을 우리들만의 Spec 을 만들어서 프런트 엔드 분에게 편의성을 제공하자 

예시) 성공응답 

{
  "success": true,
  "code": 200,
  "message": "요청에 성공했습니다.",
  "data": {
    "id": 1,
    "name": "dongwook"
  }
}

 

예시2 ) data 가 없는 성공 응답 

{
  "success": true,
  "code": 200,
  "message": "요청에 성공했습니다.",
  "data": null
}

 

예시 3) 실패 응답 

{
  "success": false,
  "code": 400,
  "message": "잘못된 요청입니다.",
  "data": null
}

 

2. 이때 message 부분을 하드코딩 하지 않고 Spring의 MessageSource를 써서 확장성을 높이자 
    + 국제화 까지도..? 


구현 시작

통일성 있는 응답을 내보내기 위해서는 그거에 맞는 DTO를 설계 하면 됐었습니다 

DTO는 다음과 같이 구성하였습니다

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

    private boolean success;
    private int code;
    private String message;
    private T data;

 

    // data 있는 성공 응답
    public static <T> ApiResponse<T> success(int code, String message, T data) {
        return new ApiResponse<>(true, code, message, data);
    }

    // 실패 응답
    public static ApiResponse<?> fail(int code, String message) {
        return new ApiResponse<>(false, code, message, null);
    }
}

 

 

첫 번째 문제 사항 

사실 이 DTO 구조를 짜는 데는  고민이 있었습니다 

1. static method를 활용해서 응답을 통일시킬 것인가?

2. 나중에  프런트엔드의 요구사항에 맞게 응답형식을 유연함을 갖추기 위해  인터페이스를 만들고 + DI 주입으로 빈으로 관리할 것인가?

 

고민한 결과 저의 결론은 1번으로도 충분히 유연한 재사용이 가능하고 , JSON 응답 스펙은 한번 결정하면 거의 바뀌지 않으니 2번은 너무 투머치 같았습니다

그래서 저는 1번을 통해서 구현하였습니다

 

 

두 번째 문제사항

1번을 선택서 응답이 통일화되었습니다. 이렇게 실제로 저는 이러한 응답 아키텍처 구조를 가지고 헤커톤 때 팀원들과 프로젝트를 진행하였는데 어떤 팀원은 static Method를 쓰는 팀원도 있지만 어떤 팀원은 그냥 객체를 body에 리턴하는 팀원이 있었습니다.

 

사용 방법을 알려주고 설명을 해줬는데도 익숙하지 않은 코드였기에 팀원들이 힘들어하는 게 보였습니다.

개발의 생산성을 높이기 위해 도입한 기술이었는데 오히려 팀원들의 개발의 생산성을 떨어트린 상황을 맞이했습니다

 

 

영감 받은 포인트

그래서 김영한 강사님의 스프링 강의 중에 한 내용이 떠올라서 영감을 받았습니다. Transaction을 편하게 사용하기 위해 Transaction Template 이 만들어졌지만 정작 저희는 템플릿을 쓰지 않고 @Transactional 이란 어노테이션을 쓰고 있었습니다 

그래서 저도 어노테이션을 만들어서 팀원들이 편하게 쓰면 좋을 것 같다 생각이 들었고 

 

이전에도  Session에서 자동 바인딩 + Model에 자동 바인딩 어노테이션을 만들어본 기억이 있었기에 그렇게 저희만의 어노테이션인 @ApiSuccess 이 탄생하였습니다 


어노테이션 설정 

최종 사용 모습 

@ApiSuccess (code = 201 , message = "success.login")
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable Long id) {
    return memberService.findById(id);
}

 

해당 어노테이션은 보기 코드와 같이 Controller Method를 대상으로 설계하였습니다

그리고 인자값으로 StatusCode와 MessageSource Code를 받으려고 설게 했습니다 

 

 

 

 

어노테이션 코드 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSuccess {
    int statusCode() default 200;
    String message() default "요청에 성공 하였습니다.";
}

 

혹시 모를 상황을 대비해서 default 값을 넣어주었습니다

파라미터가 있는 어노테이션을 처음 만들어서 어떻게 설정하는지 몰랐는데 @GetMapping 코드를 뜯어서 어떻게 선언하는지 참고해서 만들어 봤습니다 

 

이제 Annotation은 설정했지만 Controller가 응답하는 값을 우리만의 응답형식으로 바꾸는 부분을 어떻게 해야 할지 모르겠습니다

처음에는 Interceptor를 사용해서 구현해야 하나.. 아니면 최종적으로 필터를 통해 구현해야 하나..? 고민하면서 인터넷을 찾아보니 

 

ResponseBodyAdvice를 알게 되었습니다

ResponseBodyAdvice는 핸들러 어뎁터가 컨트롤러를 수행하고 반환하는 값이 Http MessageConverter에 들어가기 직전에 반환값을 교체할 수 있는 기능을 제공한다는 것을 확인했습니다

 

그래서 Controller 가 반환하는 값을 우리가 위에서 만든 ApiResponse 인 Wrapping Class로 감싸서 내보내면 쉬울 것 같았습니다

 

관련은 없을 수 있지만 뭔가 여기서 @ReqeustMapping을 사용하는  컨트롤러 메서드 정보와 관련 메타 데이터를 감싸는 래퍼 클래스인 HandlerMethod 와 구조가 비슷한 것 같다는 생각이 들었습니다

 


ResponseBodyAdvice 구현 

@RestControllerAdvice
@RequiredArgsConstructor
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {

    private final MessageSource messageSource;

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
          return returnType.getMethodAnnotation(ApiSuccess.class) != null;

    }

    @Override
    public @Nullable Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        //  ApiResponse 인 경우 중복 wrapping 방지
        if (body instanceof ApiResponse) {
            return body;
        }


        ApiSuccess methodAnnotation = returnType.getMethodAnnotation(ApiSuccess.class);
        int statusCode = methodAnnotation.statusCode();
        String messageCode = methodAnnotation.message();
        String message = messageSource.getMessage(messageCode, null, LocaleContextHolder.getLocale());


        return ApiResponse.success(statusCode, message, body);



    }
}

 

해당 class를 처음으로 구현하면서 새롭게 알게 된 사실을 기록해 보겠습니다 

일단 인자로 들어오는 MethodParameter입니다 

 

MethodParameter

ResponseBodyAdviceArgumentResolver에서 쓰이는 MethodParameter는 컨트롤러 메서드 혹은 파라미터 정보를 감싸는 래퍼 객체다

 

핵심메서드 

getMethod() 컨트롤러 메서드(Method 객체)
getParameterIndex() 파라미터 위치 (0,1,2…)
getParameterType() 파라미터 타입(Class)
getParameterName() 파라미터 이름 (컴파일 옵션 필요)
getParameterAnnotations() 해당 파라미터에 붙은 어노테이션 배열
hasParameterAnnotation(Class) 특정 파라미터 어노테이션 존재 여부
getMethodAnnotation(Class) 메서드에 붙은 특정 어노테이션 반환
getContainingClass() 메서드가 속한 클래스

 

 

메서드 사용하여

returnType.getMethodAnnotation(ApiSuccess.class) != null;

 

이 코드로 target 객체를 식별하고 

 

        //  ApiResponse 인 경우 중복 wrapping 방지
        if (body instanceof ApiResponse) {
            return body;
        }


        ApiSuccess methodAnnotation = returnType.getMethodAnnotation(ApiSuccess.class);
        int statusCode = methodAnnotation.statusCode();
        String messageCode = methodAnnotation.message();
        String message = messageSource.getMessage(messageCode, null, LocaleContextHolder.getLocale());


        return ApiResponse.success(statusCode, message, body);

 

Anntotaion Class를 가지고 와서 WrappingClass에 필요한 정보를 넣어줬습니다 

그리고 여기서 MessageSource를 스프링 빈으로부터 주입받아서 구현하였습니다 

 

 

실제 테스트 

Mock을 활용해 Juit으로 테스트 코드를 짜도 되지만 이번에는 간편하게 Postman으로 테스트하였습니다

 

테스트는  총 3번 했습니다 

1. Body에 data 가 없는 경우

2. Body에 data 가 있는 경우

3. MessageSource를 변경해서 message가 변경이 잘 반영되는지

 

 


1번 테스트 ) - Body에 data 가 없는 경우

코드 

    @ApiSuccess( message = "success.test")
    @GetMapping("")
    public void testAnnotation() {
//        return new MemberDto("test@naver.com", 20);
    }

 

1번 테스트 결과 

{
    "success": true,
    "code": 200,
    "message": "어노테이션 테스트 성공",
    "data": null
}

 


2번 테스트 - Body에 data 가 있는 경우

코드 

    @ApiSuccess( message = "success.test")
    @GetMapping("")
    public MemberDto testAnnotation() {
        return new MemberDto("test@naver.com", 20);
    }

 

2번 테스트 결과 

{
    "success": true,
    "code": 200,
    "message": "어노테이션 테스트 성공",
    "data": {
        "memberEmail": "test@naver.com",
        "age": 20
    }
}

 

 


3번 테스트 

messages.properties

success.test = !성공하자~!!! 취업 성공하자!!!

 

결과

{
    "success": true,
    "code": 200,
    "message": "!성공하자~!!! 취업 성공하자!!!",
    "data": {
        "memberEmail": "test@naver.com",
        "age": 20
    }
}

 

 

끝~!!!!

 

'🖥️ 컴퓨터 공부 > Spring' 카테고리의 다른 글

SpringSecurity  (1) 2025.08.27