스프링 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 된 예외 객체가 들어온다. 실행 결과는 전과 같다.
'2021 Spring Study' 카테고리의 다른 글
Spring 20. 날짜 값 변환, @PathVariable, 예외처리 (0) | 2021.03.05 |
---|---|
Spring 19. 세션, 인터셉터, 쿠키 (0) | 2021.02.28 |
Spring 18. 커맨드 객체 validation (0) | 2021.02.18 |
Spring 17. 스프링 MVC 주요 기능 (0) | 2021.02.03 |
Spring 16. 서블릿 & 서블릿 컨테이너 & spring MVC (0) | 2021.01.29 |