스프링 MVC 에서
Json 형식의 Request Body 로 요청을 받고 Json 형식의 Response Body 로 응답하는 방법.

1. Jackson 의존 추가

Jackson : Java 객체를 JSON 형식으로 변환하는 것과
JSON 형식을 Java 객체로 변환하는 것을 해주는 Java 라이브러리

// Jackson core와 Jackson 어노테이션
implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4'

// Java8 date/time 지원을 위한 Jackson 모듈
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.4'

2. JSON 응답

2.1. 기본 사용법

  • @RestController
  • return (Response Body)
@RestController
public class MemberRestController {

   ...

    @GetMapping("/api/members")
    public List<Member> members() {
        return memberDao.selectAll();
    }
}

GET /api/members 요청 시 Response Body

[
    {
        "id": 62,
        "email": "test@test.com",
        "password": "test",
        "name": "test",
        "registerDateTime": [
            2021,
            3,
            5,
            16,
            7,
            39
        ]
    }
]

2.2. @JsonIgnore

@JsonIgnore 를 사용하면
Json 응답에 특정 필드가 들어가지 않게 할 수 있다.

...
import com.fasterxml.jackson.annotation.JsonIgnore;

public class Member {

    private Long id;
    private String email;
    @JsonIgnore
    private String password;
    private String name;
    private LocalDateTime registerDateTime;

GET /api/members 요청 시 Response Body

[
    {
        "id": 62,
        "email": "test@test.com",
        "name": "test",
        "registerDateTime": [
            2021,
            3,
            5,
            16,
            7,
            39
        ]
    }
]

2.3. 날짜 객체 응답

LocalDateTime -> Json 기본 변환 형태

앞서 예제에서
Member 객체의 java.time.LocalDateTime 필드가 다음과 같은 Json 형식으로 자동 변형된 것을 확인할 수 있다.

...
"registerDateTime": [
    2021,
    3,
    5,
    16,
    7,
    39
]
...

@JsonFormat을 이용해서 날짜 응답 형식을 커스터마이징할 수 있다.

@JsonFormat(shape = Shape.STRING)

ISO-8601 형식으로 변환

public class Member {

    private Long id;
    private String email;
    @JsonIgnore
    private String password;
    private String name;
    @JsonFormat(shape = Shape.STRING)
    private LocalDateTime registerDateTime;

    ...
}

GET /api/members 요청 시 Response Body

[
     {
         "id": 62,
         "email": "test@test.com",
         "name": "test",
         "registerDateTime": "2021-03-05T16:07:39"
     }
]

@JsonFormat(pattern="yyyyMMddHHmmss")

pattern 속성은 java.time.format.DateTimeFormatter 클래스나 java.text.SimpleDateFormat 클래스의 API 문서에 정의된 패턴을 따른다.

public class Member {

     private Long id;
     private String email;
     @JsonIgnore
     private String password;
     private String name;
     @JsonFormat(pattern = "yyyyMMddHHmmss")
     private LocalDateTime registerDateTime;

     ...
}

GET /api/members 요청 시 Response Body

[
     {
         "id": 62,
         "email": "test@test.com",
         "name": "test",
         "registerDateTime": "20210305160739"
     }
]

java.time.LocalDateTime/java.util.Date -> ISO-8601 형식

RestController에 다음과 같은 api 두개를 추가해보았다.

package controller;

import java.time.LocalDateTime;
import java.util.Date;
...

@RestController
public class MemberRestController {
    ...

    @GetMapping("/api/date")
    public Date date() {
        return new Date();
    }

    @GetMapping("/api/local-date")
    public LocalDateTime localDate() {
        return LocalDateTime.now();
    }
}

여기에 대한 기본적인 실행 결과는 다음과 같다.

/api/date

1615968465377

이 숫자는 유닉스 타임 스탬프, 즉 1970년 1월 1일 이후 흘러간 시간 (단위 : 초) 입니다.

/api/local-date

[
    2021,
    3,
    17,
    17,
    5,
    1,
    198000000
]

아래 코드는 Jackson이 날짜 형식을 출력할 때 유닉스 타임스탬프로 출력하는 기능을 비활성화한다.
이 기능을 비활성화하면 ObjectMapper는 날짜 타입의 값을 ISO-8601 형식으로 출력한다.

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .featuresToDisable(
                SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
        converters.add(8, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이때 주의할 점은, converters 에 커스텀 커버터를 add 할 때
커스텀 날짜 컨버터가 Default 날짜 컨버터보다 앞에 등록되어야
커스텀 날짜 컨버터가 사용된다는 점이다.

java.util.Date 타입 -> 원하는 Format

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .simpleDateFormat("yyyy-MM-dd HH:mm:ss")
            .build();
        converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이러면 java.util.Date 타입은 yyyy-MM-dd HH:mm:ss 형태의 문자열로 바뀐다.
LocalDateTime 타입에는 이 커스텀 컨버터가 적용되지 않고, ISO-8601 형식으로 변환된다.

java.time.LocalDateTime 타입 -> 원하는 Format

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(formatter))
            .build();
        converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이렇게 하면 LocalDateTime 은 yyyy-MM-dd HH:mm:ss 형태로 바뀌고,
Date 타입에는 이 커스텀 컨버터가 적용되지 않는다. 확인해보니 유닉스 타임 스탬프로 나왔다.

+a

  • converter 목록에 등록된 converter 보다 @JsonFormat 어노테이션 설정이 우선된다.
  • Json 형식의 response body 로 응답을 보내면 HttpResponse 의 Content-Type 헤더 값이 "application/json" 이 된다.

2.4. ResponseEntity

앞서 만들었던 @GetMapping("/api/date") 를 다음과 같이 바꿔보았다.

@GetMapping("/api/date")
public Date date(@RequestParam String name,  HttpServletResponse response)
        throws IOException {
    if (Objects.isNull(name) || name.isEmpty()) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "name 빼먹었다");
    }
    return new Date();
}

이러면 요청 시 query string 으로 name 파라미터를 전달해야한다.

## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=yelim

## 응답
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 17 Mar 2021 13:03:33 GMT
Keep-Alive: timeout=20
Connection: keep-alive

1615986213155

컨트롤러 메서드의 if 문 때문에,
name 에 빈 값을 전달하면 다음과 같은 응답이 온다.

## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=

## 응답
HTTP/1.1 400 
--응답 헤더 생략--

<!doctype html>
<html lang="ko">
<head><title>HTTP 상태 400 – 잘못된 요청</title>
  <style type="text/css">body {
    font-family: Tahoma, Arial, sans-serif;
  }

  h1, h2, h3, b {
    color: white;
    background-color: #525D76;
  }
  -- 생략 --
  </style>
</head>
<body><h1>HTTP 상태 400 – 잘못된 요청</h1>
<hr class="line"/>
<p><b>타입</b> 상태 보고</p>
<p><b>메시지</b> name 빼먹었다</p>
...

그런데 정상 응답은 Json 형식이고 오류 응답은 HTML 형식이다.

오류 시 HTML 대신 Json 형식의 에러메세지를 내려주도록 바꿔보자.

@RestController
public class MemberRestController {
    ...
    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        if (Objects.isNull(name) || name.isEmpty()) {
            return ResponseEntity.badRequest().body("name 빼먹었다");
        }
        return ResponseEntity.ok(new Date());
    }
    ...
}
## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=

## 응답
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 17 Mar 2021 13:19:07 GMT
Connection: close

"name 빼먹었다"

ResponseEntity.ok(new Date());,
ResponseEntity.badRequest().body("name 빼먹었다");
이렇게 ResponseEntity 로 return 하면 응답 헤더부분까지 컨트롤할 수 있어서 좋다.

2.5. @ExceptionHandler

GET /api/date, /api/local-date 컨트롤러 메서드에서 더 많은 예외처리를 추가해볼 것이다.

public class MemberService {
    ...
    public String hello(String name) {
        if (Objects.isNull(name) || name.isEmpty()) throw new EmptyNameException();
        if (name.length() > 10) throw new IllegalNameFormatException();
        return name + "님 반갑습니다!";
    }
}
@RestController
public class MemberRestController {
    ...
    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        try {
            String hello = memberService.hello(name);
            return ResponseEntity.ok(hello + new Date());
        } catch (EmptyNameException e) {
            return ResponseEntity.badRequest()
                .body("name 빼먹었다");
        } catch(IllegalNameFormatException e) {
            return ResponseEntity.badRequest()
                .body("이름 형식이 잘못되었음");
        }
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        try {
            return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
        } catch (EmptyNameException e) {
            return ResponseEntity.badRequest()
                .body("name 빼먹었다");
        } catch (IllegalNameFormatException e) {
            return ResponseEntity.badRequest()
                .body("이름 형식이 잘못되었음");
        }
    }
}
  • date() 메서드 안에서 예외처리를 구구절절 해주는게 지저분하다. 이 메서드에서 핵심 로직은 사실상 try 안에 있는 두줄이 전부다.
  • 에러처리 코드가 중복되고 있다.

에러 처리 코드 분리하기

에러처리 코드를 다음과 같이 분리할 수 있다.

@RestController
public class MemberRestController {

    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        String hello = memberService.hello(name);
        return ResponseEntity.ok(hello + new Date());
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
    }

    @ExceptionHandler(EmptyNameException.class)
    public ResponseEntity<String> handleEmptyNameException() {
        return ResponseEntity.badRequest()
            .body("name 빼먹었다");
    }

    @ExceptionHandler(IllegalNameFormatException.class)
    public ResponseEntity<String> handleIllegalNameFormatException() {
        return ResponseEntity.badRequest()
            .body("이름 형식이 잘못되었음");
    }
}
  • 컨트롤러 내부의 exception handling 메서드는 해당 컨트롤러에 대해서만 동작한다.

Exception Handling을 별도의 클래스로 분리하기

익셉션 핸들링을 별도의 클래스로 분리하여
하나의 Exception Handler를 여러 컨트롤러에 대해 적용할 수 있다.

@RestController
public class MemberRestController {
    ...

    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        String hello = memberService.hello(name);
        return ResponseEntity.ok(hello + new Date());
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
    }
}
@RestControllerAdvice("controller")  // @RestControllerAdvice(basePackages = "controller") 이거랑 같음
public class MemberRestExceptionHandler {

    @ExceptionHandler(EmptyNameException.class)
    public ResponseEntity<String> handleEmptyNameException() {
        return ResponseEntity.badRequest()
            .body("name 빼먹었다");
    }

    @ExceptionHandler(IllegalNameFormatException.class)
    public ResponseEntity<String> handleIllegalNameFormatException() {
        return ResponseEntity.badRequest()
            .body("이름 형식이 잘못되었음");
    }
}
@Configuration
public class ControllerConfig {
    ...
    @Bean
    public MemberRestExceptionHandler MemberRestExceptionHandler() {
        return new MemberRestExceptionHandler();
    }
}

3. Json 형식의 Request Body 를 커맨드 객체로 받기

3.1. 기본 사용법

커맨드 객체에 @ResponseBody 어노테이션을 붙이면 된다.

@RestController
public class MemberRestController {
    ...
    @PostMapping("/api/members")
    public ResponseEntity<Object> newMember(@RequestBody @Valid RegisterRequest request) throws IOException {
        try {
            Long newMemberId = memberService.register(request);
            return ResponseEntity.created(URI.create("/api/members/" + newMemberId))
                .build();
        } catch (DuplicateMemberException e) {
            return ResponseEntity.badRequest()
                .body(e.getMessage());
        }
    }
}

@Valid 어노테이션도 똑같이 사용할 수 있다.

실행 결과는 다음과 같다.

## 요청
POST http://localhost:8080/Gradle___springmvc_war/api/members
Content-Type: application/json

{
  "email": "test@email.com",
  "password": "1234",
  "confirmPassword": "1234",
  "name": "성이름"
}

## 응답
HTTP/1.1 201 
Location: /api/members/1
Content-Length: 0
Date: Fri, 19 Mar 2021 14:33:59 GMT
Keep-Alive: timeout=20
Connection: keep-alive

<Response body is empty>

status code가 201 Created 인 것과, Location 헤더값이 잘 들어있는 것을 확인할 수 있다.

같은 요청을 한번 더 보내면, 이미 test@email.com 으로 가입한 회원이 있어서 오류가 난다.

## 요청
POST http://localhost:8080/Gradle___springmvc_war/api/members
Content-Type: application/json

{
  "email": "test@email.com",
  "password": "1234",
  "confirmPassword": "1234",
  "name": "성이름"
}

## 응답
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 19 Mar 2021 14:37:44 GMT
Connection: close

"test@email.com은 이미 존재하는 회원입니다.\n"

Exception Handler 를 적용해서 컨트롤러 코드를 정리해주자.

@RestController
public class MemberRestController {
    ...
    @PostMapping("/api/members")
    public ResponseEntity<Void> newMember(@RequestBody @Valid RegisterRequest request) throws IOException {
        Long newMemberId = memberService.register(request);
        return ResponseEntity.created(URI.create("/api/members/" + newMemberId))
            .build();
    }
}

@RestControllerAdvice(basePackages = "controller")
public class MemberRestExceptionHandler {

    ...

    @ExceptionHandler(DuplicateMemberException.class)
    public ResponseEntity<String> handleDuplicateMemberException(Exception e) {
        return ResponseEntity.badRequest()
            .body(e.getMessage());
    }
}

매개변수 e 에는 throw 된 예외 객체가 들어온다. 실행 결과는 전과 같다.

+ Recent posts