본문 바로가기
Spring boot

ResponseResult 개발 진행

by Subi 2023. 7. 26.

Controller에서 Return 값을 설정할 때 Response 을 쓰는 것에 대해서

JSP 를 사용할 때는 Model를 사용했고

Jackson을 사용할 때는 Dto 를 사용했습니다.

그리고 Spring http를 사용할 때 ResponseEntity를 사용했습니다.

 

하지만 API가 늘어나고 서버가 늘어남에 따라 에러 케이스 또한 상세히 분류하게 되어 단순 HTTP ResponseEntity에서 제공하는 코드가 아니라, 우리 서비스의 상태 코드와 Response 형식으로 Template을 만들어야 할 필요성을 느꼈습니다.

그리고 같이 협업을 하는 Front End 의 Rest API 에 대한 요청 사항을 맞춰줘야 하는 부분이 있었습니다.

 

Front End 과의 협의 된 Template 요청 사항은

  • 어떠한 요청이든 동일한 형태로 Response 보내줬으면 좋겠다.
  • 형태가 다른 데이터도 Contents 라는 객체로 감싸서 왔으면 좋겠다.
  • Response에 첫번째 객체는 code 가 되었으면 좋겠다.

그리하여 몇 개의 방법을 시도해 보았습니다.

1. ResponseEntity를 상속 받는 것 입니다.

public class ResponseResult<T> extends ResponseEntity<T> {

    private String message;
    public ResponseResult(HttpStatusCode status) {
        super(status);
    }

    public ResponseResult(T body, HttpStatusCode status) {
        super(body, status);
    }

    public ResponseResult(MultiValueMap<String, String> headers, HttpStatusCode status) {
        super(headers, status);
    }

    public ResponseResult(T body, MultiValueMap<String, String> headers, HttpStatusCode status) {
        super(body, headers, status);
    }

    public ResponseResult(T body, MultiValueMap<String, String> headers, int rawStatus) {
        super(body, headers, rawStatus);
    }

    public ResponseResult(HttpStatusCode status,T body,String message){
        super(body,status);
        this.message = message;
    }
}
  • 장점
    • 기본적인 형태와 Header에 HttpStatus 를 Custom 하는 게 가능하다.
  • 단점
    • Template 으로 형태를 만드는 것이 힘들어서 Handling이 어렵다.
    • 부모가 되는 ResponseEntity에서 오버로딩 된 매게변수들 때문에 문제가 발생

2. Template 형태를 ResponseDTO로 만들어서 사용

@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)             
public class ResponseResult<T> {

    @Schema(description = "HTTP 상태 코드")
    private int code;

    @Schema(description = "HTTP 상태")
    private String status;

    @Schema(description = "HTTP 메시지")
    private String message;

    @Schema(description = "단일데이터")
    private T content;

    @Schema(description = "페이징 정보")
    private PageableResult pageable;

    public ResponseResult(int code, String status, String message, T content, PageableResult pageable) {
        this.code = code;
        this.status = status;
        this.message = message;
        this.content = content;
        this.pageable = pageable;
    }

    public static <T>ResponseResult<T> response(T content){
        return ResponseResult.<T>builder()
                .code(StatusCode.OK)
                .status(Status.OK)
                .message(ResponseMessage.OK)
                .content(validationCheckContents(content))
                .pageable(validationCheckPageResult(content))
                .build();
    }

    public static <T>ResponseResult<T> responseNoContents(){
        return ResponseResult.<T>builder()
                .code(StatusCode.NO_CONTENTS)
                .status(Status.NO_CONTENTS)
                .message(ResponseMessage.NO_CONTENTS)
                .content(validationCheckContents(null))
                .build();
    }

    @Data
    static class PageableResult {

        private int size;

        private int page;

        Long totalElement;

        public PageableResult(Pageable pageable, Long totalElement) {
            this.size = pageable.getPageSize();
            this.page = pageable.getPageNumber()+1;
            this.totalElement = totalElement;
        }
    }
}
  • 장점
    • Response에 대한 Template을 손쉽게 Handling 가능하다.
    • 제네릭 <T> 를 통해 Contents 에 모든 객체를 담을 수 있다.
  • 단점
    • Controller Test 를 진행 할 때, HTTP에 대한 통신은 테스트가 가능하지만 Detail한 부분에 대한 테스트는 힘들다.

그렇게 만든 Template의 일반적인 형태는

//List
{
  "code": int,
  "status": "string",
  "message": "string",
  "content": [
    {list1 : data1},
	 {list2 : data2}
  ],
	  "pageable": {
	    "pageSize": int,
	    "pageNumber": int,
	    "totalElement": Long
  }
}

//GetData
{
  "code": int,
  "status": "string",
  "message": "string",
  "content": {
	   parameter1 : data1 ,
	   parameter2 : data2
  }

//Insert, Update, Delete
{
  "code": int,
  "status": "string",
  "message": "string",
  "content": id(Long)
}

현재 GIMS 는 현 Template의 형태로 Response 되고 있고,

Error 에 대한 Repsonse 또한 최대한 형태를 맞추어 아래의 Template으로 Response 되고 있습니다.

{
    "code": 500,
    "status": "INTERNAL_SERVER_ERROR",
    "message": "No value present",
    "errors": "오류가 발생했습니다."
}

이러한 형태를 이루어 Front End 에선 첫번째 객체인 code 로써 HttpStatus 에 대한 판단이 가능하여 Rest API 와의 통신 처리가 가능해졌습니다.

3. AOP를 사용하여 Controller 이후 시점에서 ResopnseDTO

2번의 형태로 GIMS를 개발했고 잘 개발했다고 생각을 했지만, 한 가지 아쉬운 점이 있었습니다.

그건 Controller 에 대한 Detail한 Test가 불가능 한 부분 이였습니다.

보통 Controller에 대한 테스트는 Http 통신만을 테스트하여 Status 값만 비교를 하지만

조금 더 나아가 Detail 한 테스트로 세분화를 해보면 좋지 않을까 라는 생각에서 비롯된 방법입니다.

AOP 는 관점 지향 프로그래밍이다.

여러 클래스에 나뉜 책임을 한 곳으로 모아서 처리하는 접근 방식이다.

  • OOP 의 단점을 보안하기 위해서 나온 프로그래밍인데, 클래스가 나눠져 있어 공통에 대한 부분을 모듈화 하기 어렵다.
  • 그래서 Aspect 라는 AOP에서 모듈화 할 대상을 만들어 공통 모듈을 만든다.
  • 시점 (Pointcut)은 Before (실행 전) / After (실행 후) / Around (둘 다) 로 설정 가능하다.

이걸 이용해 컨트롤러 이 후(After)의 Response를 ResponseResult로 감싸면 되지 않을까 라는 생각 이였다.

@Aspect
@Component
public class ResponseAop {
    @AfterReturning(pointcut = "execution(* com.giantstep.unionapiserver.domain..*Controller.*(..))", returning = "response")
    public void afterUserLogCreateAop(JoinPoint joinPoint, Object response) {
        response = ResponseResult.response(response);
    }
}

Object 로 Response를 받아서 Response를 커스텀하는 방식으로 구현을 해봤지만

Response 의 값은 바뀌지 않았다.

결론 : 실패

response Before : Page 1 of 1 containing 
com.giantstep.unionapiserver.domain.userLog.dto.UserLogResponse instances

ResponseResult.response(response) : 
ResponseResult(code=200, status=SUCCESS, message=OK, 
							content=[
									com.giantstep.unionapiserver.domain.userLog.dto.UserLogResponse@2e573f8f], 
							pageable=ResponseResult.PageableResult(size=10, page=1, totalElement=1)
							)

AOP Class 내부에서의 Response는 변화하였지만,

실제 반환되는 Response의 값은 변하지 않았다.

AOP는 Class를 그 안에 데이터 확인까지는 가능하지만 Custom은 가능하지 않았다.

4. Service 에서 ResponseResult를 감싸는 방법

우아한 형제들 블로그에 기재된 방식을 바탕으로 테스트를 해보았다.

네번째 방법을 적용하기 전 ResponseStatus 에 대한 부분을 조금 수정해 보았다.

기존에 코드는 Response 클래스에서 사용하는 객체들을

다른 클래스로 정리하여 그것을 불러와 넣어줬던 방식이다.

이 방식은 가장 큰 단점이 있다. 하나의 상태 코드를 추가 해 줄려면 3개의 클래스를 수정해야 된다는 단점이 존재 하였다.

그리하여 수정한 방법이 ResponseStatus 라는 Enum Class 를 만들어 한번에 관리하는 것이였다

  • 기존 코드
public class ResponseResult<T> {

    @Schema(description = "HTTP 상태 코드")
    private int code;

    @Schema(description = "HTTP 상태")
    private String status;

    @Schema(description = "HTTP 메시지")
    private String message;

    @Schema(description = "단일데이터")
    private T content;

    @Schema(description = "페이징 정보")
    private PageableResult pageable;

    public ResponseResult(int code, String status, String message, T content, PageableResult pageable) {
        this.code = code;
        this.status = status;
        this.message = message;
        this.content = content;
        this.pageable = pageable;
    }

    public static <T>ResponseResult<T> response(T content){
        return ResponseResult.<T>builder()
                .code(StatusCode.OK)
                .status(Status.OK)
                .message(ResponseMessage.OK)
                .content(validationCheckContents(content))
                .pageable(validationCheckPageResult(content))
                .build();
    }
}
  • 수정된 코드
public class ResponseResult<T> {

    @Schema(description = "HTTP 상태 코드")
    private int code;

    @Schema(description = "HTTP 메시지")
    private String message;

    @Schema(description = "단일데이터")
    private T content;

    @Schema(description = "페이징 정보")
    private PageableResult pageable;

    public ResponseResult(int code, String message, T content, PageableResult pageable) {
        this.code = code;
        this.message = message;
        this.content = content;
        this.pageable = pageable;
    }

    public static <T>ResponseResult<T> response(ResponseStatus responseStatus , T content){
        return ResponseResult.<T>builder()
                .code(responseStatus.getCode())
                .message(responseStatus.getMessage())
                .content(validationCheckContents(content))
                .pageable(validationCheckPageResult(content))
                .build();
    }
}

public enum ResponseStatus {

    SUCCESS(200, "Success"),
    CREATE(201, "Success Create");

    private final int code;
    private final String message;

    ResponseStatus(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

}

이 방법으로 수정한 뒤 Status 에 대한 추가가 쉬워지고 훨씬 Detail한 수정이 가능해졌다.

(개인적으로는 공통된 코드보단 Service 단에서 Status를 주입하는게 훨씬 OOP 를 위한 관점에선 좋아 보인다.)

본론으로 돌아가서 Service 단에서 ResponseResult 를 만들어 보면

  • 기존코드
public class UserLogController {

    private final UserLogService userLogService;

    @Operation(summary = "목록")
    @GetMapping("/manage/user-log")
    public ResponseResult<Page<UserLogResponse>> list(UserLogSearchCondition condition, @ParameterObject @PageableDefault(page = 1) Pageable pageable) {
        return ResponseResult.response(userLogService.getUserLogList(condition, pageable));
    }

    @Operation(summary = "저장")
    @PostMapping("/manage/user-log/create")
    public ResponseResult<Long> userLogCreate(@RequestBody @Valid UserLogRequest userLogRequest) {
        Long saveId = userLogService.createUserLog(userLogRequest);
        return ResponseResult.response(saveId);
    }
}

public class UserLogService {

    private final UserLogRepository userLogRepository;

    /**
     * 사용자 로그 관리 저장
     * @param userLogRequest
     *
     */
    public Long createUserLog(UserLogRequest userLogRequest) {
        UserLogDto userLogDto = new UserLogDto();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated()) {
            userLogDto.setUserId(((Jwt) authentication.getPrincipal()).getClaimAsString("preferred_username"));
            userLogDto.setEmployeeNumber(((Jwt) authentication.getPrincipal()).getClaimAsString("given_name"));
            userLogDto.setUsername(((Jwt) authentication.getPrincipal()).getClaimAsString("family_name"));
        }
        UserLog saveUserLog = userLogRepository.save(userLogRequest.toEntity(userLogDto));
        return saveUserLog.getId();
    }

    /**
     * 사용자 로그 검색 목록
     * @param condition
     * @param pageable
     */
    public Page<UserLogResponse> getUserLogList(UserLogSearchCondition condition, Pageable pageable){
        return userLogRepository.searchList(condition,pageable);
    }

}
  • 수정 코드
public class UserLogController {

    private final UserLogService userLogService;

    @Operation(summary = "저장")
    @PostMapping(value = "/userLog/create")
    public ResponseEntity<Object> createUserLog(@RequestBody @Valid UserLogRequest userLogRequest){
        return new ResponseEntity<>(userLogService.createUserLog(userLogRequest) , HttpStatus.CREATED);
    }

    @Operation(summary = "목록")
    @GetMapping("/userLog/list")
    public ResponseEntity<Object> listUserLog(UserLogSearchCondition condition, @ParameterObject @PageableDefault(page = 1) Pageable pageable) {
        return new ResponseEntity<>(userLogService.listUserLog(condition, pageable) , HttpStatus.OK);
    }
}

public class UserLogService {

    private final UserLogRepository userLogRepository;

    public ResponseResult<Long> createUserLog(UserLogRequest userLogRequest){
        UserLogDto userLogDto = new UserLogDto();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null && authentication.isAuthenticated()) {
            userLogDto.setUserId(((Jwt) authentication.getPrincipal()).getClaimAsString("preferred_username"));
            userLogDto.setEmployeeNumber(((Jwt) authentication.getPrincipal()).getClaimAsString("given_name"));
            userLogDto.setUsername(((Jwt) authentication.getPrincipal()).getClaimAsString("family_name"));
        }
        UserLog saveUserLog = userLogRepository.save(userLogRequest.toEntity(userLogDto));
        return ResponseResult.response(ResponseStatus.CREATE , saveUserLog.getId());
    }

    public ResponseResult<Page<UserLogResponse>> listUserLog(UserLogSearchCondition userLogSearchCondition, Pageable pageable){
        return ResponseResult.response(ResponseStatus.SUCCESS , userLogRepository.listUserLog(userLogSearchCondition,pageable));
    }
}

즉, Service 단에서 ResponseResult로 객체를 받게 되고, Controller 단에서 ResponseEntity 로 받은 객체를 넣어주고 Header 의 HttpStauts 를 설정하게 된다.

이렇게 하게 되면 하나의 기능에 대한 훨씬 Detail 한 독립적이고 유연한 Transform이 가능하다.

그리고 API를 호출했을 시에 Response에 대한 Template은 기존의 형태와 똑같이 유지가 가능하다.

*CotrollerTest는 조금 더 R&D 필요

  • 장점
    • Controller 에선 객체와 Header에 대한 HttpStatus를 설정할 수 있게 되므로 API 적인 측면에서는 조금 더 좋은 코드가 된다.
    • Service에서 ResponseStatus 를 매개변수로 받게 되어 상태에 대한 설정이 쉬워진다.
    • Controller 에서는 API 에 대한 객체 반환 역할에 더욱 집중하게 되고, Service 단에서 비즈니스 로직에 대한 조합이라는 기존 역할에 충실하게 된다.
    • 기존의 형태에 대해서는 유지가 가능하다.
  • 단점
    • ServiceTest를 진행할 때 기존에 코드에서 getContents() 를 추가로 호출해줘야 된다.

+추가

Service에서 ResponseResult로 감싸다보니 문제가 생겼다.

Service에 대한 TestCode를 작성하는 중 캐스팅 문제가 발생하였다.

List에 대한 Service 메서드는 Page로 리턴을 해서 넣어주다 보니 ResponseResult에서 Contents를 꺼내는 작업에서 발생하는 에러였다.

java.lang.ClassCastException: class java.util.Collections$UnmodifiableRandomAccessList cannot be cast to class org.springframework.data.domain.Page

//Service Method 를 호출 했을 때의 자료형
ResponseResult<Page<UserLogResponse>> pageResponseResult = userLogService.listUserLog(userLogSearchCondition, getPageable(0, 10));

//getContent() 를 했을 때의 자료형
Page<UserLogResponse> content = pageResponseResult.getContent();

ResponseResult에서 getContent를 했을 시 Page가 호출되며 Error가 나오게 된다.

이걸 해결위해선 자료형을 명시해줘서 캐스팅에 대한 부분을 해결해야 되는데

기본적인 해결법은

List<UserLogResponse> list = (List) pageResponseResult.getContent();

이 형태로 캐스팅을 진행하여 해결하는 법이다.

하지만 Service TestCode 를 진행할 때 마다 저런 식으로 사용하는 건 너무 비효율적이라 생각하여

ResponseResult Class에 Method를 만들어 사용했다.

public class ResponseResult<T> {

	public <T>List<T> getResultToList() {
        return (List<T>) this.content;
    }

    public Long getResultToLong() {
        return (Long) this.content;
    }

}

이런식으로 getContent 의 행위에 자료형을 명시적으로 선언하여 Retrun 해주는 Method를 만들었다.

  • 사용 예시
//getResultToList 사용 예시
ResponseResult<Page<UserLogResponse>> pageResponseResult = userLogService.listUserLog(userLogSearchCondition, getPageable(0, 10));
List<UserLogResponse> list = pageResponseResult.getResultToList();

//getResultToLong 사용 예시
ResponseResult userLogResult = userLogService.createUserLog(userLogRequest);
Long resultToLong = userLogResult.getResultToLong();

이런식으로 사용하게 되면 TestCode에서 사용이 가능한 형태가 출력이 가능하다.

'Spring boot' 카테고리의 다른 글

i18N 다국어 API 개발  (0) 2023.08.14
Request Test  (0) 2023.08.01
EnumVaild Anotation  (0) 2023.08.01
ResponseEntity Code Convention  (0) 2023.07.26
Spring Filter  (0) 2022.05.11

댓글