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 |
댓글