Programming/Spring Framework

[SpringBoot] 공통 Response 포맷 적용하기

Deveun 2022. 1. 14. 01:19

사이드 프로젝트를 진행하면서 어떻게 체계적으로 Request에 대한 Response 공통 포맷을 적용할 수 있을지 고민해봤다. 문제 해결을 위한 과정은 이러했다.

 

1. Success / Error 상황을 모두 담을 수 있는 하나의 공통 Response Class를 설계하자.
2. Controller에서는 Success / Error Info 를 담은 객체만을 리턴하자.
  2-1. Success시 결과 클래스 생성
  2-2. Exception 발생 시 에러 결과 클래스 생성
3. Controller의 return값을 AOP를 통해 공통 Response Class에 담아 책임을 분산하자.
4. Controller Unit Test를 통해 정상적으로 동작하는지 테스트

 

각 스텝별로 개발 내용을 조금 상세하게 정리해보면 이렇다.

 

1. Success / Error 상황을 모두 담을 수 있는 하나의 공통 Response Class를 설계하자.

클라이언트의 요청에 대해서 다음과 같은 데이터를 포함하여 결과를 return하고자 한다.

- 결과가 정상적인지 예외사항을 포함하는지의 여부 => (success = TRUE or FALSE)

- 정상일 경우에는 요청정보를 포함한다. => (response = 결과 객체 or NULL)

- 실패일 경우에는 에러정보를 포함한다. => (error = NULL or { status, errMessage })

 

로그인 요청에 대한 성공 / 실패 결과값 예)

성공: Body = {"success":true,"response":{"userId":"testId","userName":"홍길동","userRole":"ORDERER"},"error":null}

실패: Body = {"success":false,"response":null,"error":{"status":400,"errMessage":"메세지"}}

 

이러한 구조를 위해 공통 포맷을 정의한 BasicResponse Class를 생성하였다.

 

- common/model/BasicResponse.java

@AllArgsConstructor
@Getter
//@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class BasicResponse<T> {
    private boolean success;
    private T response;
    private ErrorEntity error;
}

 

이 때 주의할 점으로, 해당 클래스는 꼭 getter를 생성(롬복 사용시 @Getter)하거나 / json 자동변환 시 private field에 접근 가능하도록 설정(@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY))을 해주어야한다. 요청을 받아서 처리하는 @RestController가 @ResponseBody를 포함하고 있고, 이는 객체에 담긴 정보를 json 타입으로 변경해주는 역할을 수행하는데 이를 위해서 내부적으로 객체의 인스턴스 변수에 접근하여 값을 가져와야하기 때문이다.

 

나는 처음에 이 부분을 놓치고 테스트 수행을 하다가 com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class ... Exception이 발생하여 해결방법을 찾는데 꽤나 시간이 많이 소요되었다. 공통 포맷인 BasicResponse뿐만 아니라 해당 클래스에서 필드로 가지고 있는 객체 타입의 클래스에서도 반드시 이 점을 주의해줘야 한다. 

 

2. Controller에서는 Success / Error Info 를 담은 객체만을 리턴하자.

사이드 프로젝트에서는 Login API를 개발하며 진행하였기 때문에 이 경우를 예로 들었다.

(상세한 내부 로직 설명은 생략한다)

 

  2-1. Success시 결과 클래스 생성

성공시 처리는 간단하다. 로그인 성공 시 유저정보를 LoginedUser 클래스에 담아 Controller에서 해당 결과 객체만을 리턴한다.

 

- domain/user/LoginController

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class LoginController {

    private final LoginService loginService;

    @PostMapping("/login")
    public Optional<LoginedUser> login(@RequestBody LoginRequest loginReq) {

        loginService.login(loginReq.getLoginId(), loginReq.getLoginPw());

        return loginService.getLoginInfo();
    }
}

 

  2-2. Exception 발생 시 에러 결과 클래스 생성

Exception 발생시에는 유형에 따른 ErrorEntity 객체를 생성하기 위해 다음과 같이 ExceptionHandler를 통해 처리해주었다.

 

- common/aop/ExceptionAdvice

@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {

//    @ExceptionHandler(value = {NoUserExistException.class, WrongPasswordException.class})
    @ExceptionHandler(FailedLoginException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorEntity handleLoginException(FailedLoginException e) {

        log.error("Login Exception({}) - {}", e.getClass().getSimpleName(), e.getMessage());
        return new ErrorEntity(e.getResponseCode());
    }
}

@ExceptionHandler에 선언된 Exception 클래스의 발생 시 AOP로 잡아내 처리작업을 수행하며, @ResponseStatus의 HttpStatus 값을 세팅하여 return 해준다.

 

3. Controller의 return값을 AOP를 통해 공통 Response Class에 담아 책임을 분산하자.

책임분리의 목적에서 Controller에서는 Client의 요청에 따른 결과만을 return하고, 공통포맷에 담아내는건 ResponseBodyAdvice를 구현한 클래스에서 수행하고, 실제로 결과를 BasicResponse포맷 형태로 만드는건 구체적인 기능을 수행하는 ResponseUtil에서 수행해야한다고 생각하여 각각을 다음과 같이 구현해주었다.

 

- common/aop/ResponseAdvice.java

@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if(body instanceof ErrorEntity)
            return ResponseUtil.error((ErrorEntity) body);
        return ResponseUtil.success(body);
    }
}

 

- common/utils/ResponseUtil

public class ResponseUtil {
    public static <T> BasicResponse<T> success(T response) {
        return new BasicResponse<> (true, response, null);
    }
    public static BasicResponse<?> error(ErrorEntity e) {
        return new BasicResponse<> (false, null, e);
    }
}

 

4. Controller Unit Test를 통해 정상적으로 동작하는지 테스트

- test/.../domain/user/controller/LoginControllerTest

@WebMvcTest(LoginController.class)
public class LoginControllerTest {

    @Autowired
    private MockMvc mockMvc;

    private ObjectMapper objectMapper = new ObjectMapper();

    @MockBean
    private UserLoginService loginService;

    @DisplayName("로그인 요청 성공")
    @Test
    void loginSuccessTest() throws Exception {

        String jsonLoginReq = objectMapper.writeValueAsString(new LoginRequest("testId", "12345678"));
        String expectedJson = "{\"success\":true,\"response\":{\"userId\":\"testId\",\"userName\":\"홍길동\",\"userRole\":\"ORDERER\"},\"error\":null}";

        when(loginService.getLoginInfo()).thenReturn(Optional.of(new LoginedUser("testId", "홍길동", UserRole.ORDERER)));

        mockMvc.perform(post("/user/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonLoginReq))
                .andExpect(status().isOk())
                .andExpect(content().json(expectedJson))
                .andDo(print());
    }

    @DisplayName("로그인 요청 실패 - 아이디 없음")
    @Test
    void loginFailTest() throws Exception {

        String jsonLoginReq = objectMapper.writeValueAsString(new LoginRequest("testId!!", "12345678"));
        String expectedJson = "{\"success\":false,\"response\":null,\"error\":{\"status\":400,\"errMessage\":\"해당 아이디가 존재하지 않습니다.\"}}";

        when(loginService.login("testId!!", "12345678")).thenThrow(new NoUserExistException());

        mockMvc.perform(post("/user/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonLoginReq))
                .andExpect(status().is(400))
                .andExpect(content().json(expectedJson))
                .andDo(print());
    }
 }

간단하게 위와 같이 "로그인 성공", "로그인 실패" 두가지의 테스트만 진행해보았다.

LoginService는 Mock객체를 주입받아 리턴을 설정해주었고, @WebMvcTest를 통해 Controller 요청에 대한 Unit test를 진행하였다. 

 

로그인 성공 결과
로그인 실패 결과

자세히 보면 log에 보이는 결과값의 한글이 깨지는데, 이문제는 천천히 해결하는걸로.........

 

 

 

스터디 목적으로 진행해본 내용이기 때문에 더 보편적으로 쓰이는 방법이나 좋은 방법이 있을지는 잘 모르겠으나, 우선은 설계한대로 정상적으로 잘 동작하는 것 같다 :)

 

 

[참고]

https://steady-hello.tistory.com/90

https://chung-develop.tistory.com/61

https://codinghack.tistory.com/78

https://javachoi.tistory.com/253