해당 포스트는 초보 웹 개발자를 위한 스프링 5 프로그래밍 입문 [최범균 저] 책 내용을 참고하였습니다.
JavaScript Object Notation의 약자인 JSON은 간단한 형식을 갖는 문자열로 데이터 교환에 사용된다.
{
"name" : "코너"
"age" : 2
"related" : [ "덕성여대", "컴퓨터공학과" ]
}
Jackson은 자바 객체와 JSON 형식 문자열 간 변환을 처리하는 라이브러리
<!--Jackson core와 Jackson Annotation 의존 추가-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.4</version>
</dependency>
<!-- java8 date/time -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.9.4</version>
</dependency>
아래와 같이 자바 객체와 JSON 사이의 변환을 처리한다.
public class Person { private String name; private int age; ...get/set 메서드 } |
← → |
{ "name" : "이름", "age" : 10 } |
스프링 MVC에서 JSON 형식으로 데이터를 응답하려면 @Controller 대신 @RestController를 사용하면 된다.
@RestController // @Controller 대신 @RestController 사용
public class RestMemberController {
private MemberDao memberDao;
private MemberRegisterService registerService;
// 요청 매핑 애노테이션 적용 메서드의 리턴 타입으로 일반 객체 사용
@GetMapping("/api/members")
public List<Member> members() {
return memberDao.selectAll();
}
// 일반 객체 리턴
@GetMapping("/api/members/{id}")
public Member member(@PathVariable Long id, HttpServeltResponse response) throws IOException {
Member member memberDao.selectById(id);
if (member == null) {
...
}
return member;
}
}
...
}
@RestController 애노테이션을 붙이면 스프링 MVC는 요청 매핑 애노테이션을 붙인 메서드가 리턴한 객체를 알맞은 형식으로 변환해서 응답 데이터로 전송한다.
클래스 패스에 Jackson이 존재하면 JSON 형식의 문자열로 변환해서 응답한다.
JSON에 password와 같은 민감한 정보는 제외시키는 것이 좋다.
Jackson이 제공하는 @JsonIgnore 애노테이션으로 간단히 제외 처리할 수 있다.
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;
}
registerDateTime은 LocalDateTime 타입인데 별도의 처리가 없다면 [2018, 03, 01, 11, 07, 49] 같은 배열로 JSON에 전달된다. 이것을 일반적인 날짜 형식으로 표현하고 싶다면 Jackson의 @JsonFormat 애노테이션을 사용한다.
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
public class Member {
private Long id;
private String email;
private String name;
@JsonFormat(shape = Shape.STRING) // ISO-8601 형식으로 변환
private LocalDateTime registerDateTime;
}
{
"id" : 1
"email" : "madvirus@madvirus.net"
"name" : "최범균"
"registerDateTime" : "2018-03-01T11:07:49"
}
shape = Shape.STRING 말고 pattern = "yyyyMMddHHmmss" 을 사용해서 다른 형식으로 변환할 수 있다.
@JsonFormat을 사용하지 않고 기본 설정을 바꿔서 모든 날짜 타입에 적용할 수 있다.
스프링 MVC는 자바 객체를 HTTP 응답으로 변환할 때 HttpMessageConverter를 사용한다.
예를 들어, Jackon을 이용해서 자바 객체를 JSON으로 변환할 때는 MappingJackson2HttpMessageConverter를 사용하고 Jaxb을 이용해서 XML로 변환할 때에는 Jaxb2RootElementHttpMessageConverter를 사용한다.
따라서 MappingJackson2HttpMessageConverter를 새롭게 등록해서 날짜 형식을 원하는 형식으로 변환하도록 설정하면 모든 날짜 형식에 규칙을 적용할 수 있다.
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
...
// WebMvcConfigurer 인터페이스에 정의된 메서드로 HttpMessageConverter를 추가로 설정할 때 사용
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// ObjectMapper를 쉽게 생성하기 위해 스프링이 제공하는 클래스
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
.json()
// Jackson이 날짜 형식을 출력할 때 유닉스 타임 스탬프로 출력하는 것 비활성화
// 비활성화하면 ObjectMapper는 날짜 타입의 값을 ISO-8601 형식으로 출력함
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
// 새로 생성한 HttpMessageConverter를 목록의 제일 앞에 위치시켜서 Jackson 보다 먼저 적용되도록함
converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
}
}
ISO-8601 형식 대신 다른 패턴을 설정하고 싶다면 serializerByType() 메서드로 직접 LocalDateTime 타입에 대한 JsonSerializer를 설정하면 된다.
@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));
}
이렇게 설정을 해도 개별 속성에 적용한 @JsonFormat 애노테이션 설정이 우선순위에 있다.
반대로 JSON 데이터를 자바 객체로 변환해보자.
JSON 형식으로 전송된 요청 데이터를 커맨드 객체로 전달 받으려면 커맨드 객체에 @RequestBody 만 붙이면 된다.
@RestController
public class RestMemberController {
private MemberDao memberDao;
private MemberRegisterService registerService;
...
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
// JSON 형식의 문자열을 RegisterRequest 타입 객체로 변환
@RequestBody @Valid RegisterRequest regReq,
HttpServletResponse response) throws IOException {
try {
Long newMemberId = registerService.regist(regReq);
response.setHeader("Location", "/api/members/" + newMemberId);
response.setStatus(HttpServletResponse.SC_CREATED);
} catch (DuplicateMemberException dupEx) {
response.sendError(HttpServletResponse.SC_CONFLICT);
}
}
...
}
별도 설정이 없으면 아래와 같은 패턴의 문자열을 LocalDateTime과 Date로 변환한다.
yyyy-MM-ddTHH:mm:ss
특정 패턴을 가진 문자열을 LocalDateTime이나 Date 타입으로 변환하고 싶다면 @JsonFormat 애노테이션의 pattern 속성을 사용해서 패턴을 지정한다.
@JsonFormat(pattern = "yyyyMMddHHmmss")
private LocalDateTime birthDateTime;
@JsonFormat(pattern = "yyyyMMdd HHmmss")
private Date birthDate;
특정 속성이 아니라 해당 타입을 갖는 모든 속성에 적용하고 싶다면 스프링 MVC 설정을 추가한다.
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
...
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
.json()
.featuresToEnable(SerializationFeature.INDENT_OUTPUT)
// JSON 데이터를 LocalDateTime 타입으로 변환할 때 사용할 패턴 지정
.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(formatter))
// Date 타입으로 변환할 때 사용할 패턴 지정
// Date 타입 -> JSON 데이터 변환도 사용 가능
.simpleDateFormat("yyyyMMdd HHmmss")
.build();
converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
}
}
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
@RequestBody @Valid RegisterRequest regReq,
HttpServletResponse response) throws IOException {
...
}
@Valid 애노테이션으로 JSON 데이터인지 요청 객체를 검증한다.
JSON 형식으로 전송한 데이터를 변환한 객체도 동일한 방식으로 @Valid이나 별도 Validator를 이용해서 검증할 수 있다.
@Valid 애노테이션을 사용했을 때 검증에 실패하면 400(Bad Request) 상태 코드로 응답한다.
지금까지는 상태 코드를 지정하기 위해 HttpServletResponse의 setStatus()와 sendError() 메서드를 사용했다.
이 방식으로 404 응답을 하면 JSON 형식이 아니라 HTML을 응답 결과로 제공하는 것이 문제이다.
API를 호출할 때 JSON을 기대했는데 HTML로 응답이 오는 것은 당황스러울 것이다.
비정상인 경우에도 JSON 응답을 전송하는 방법은 ResponseEntity를 사용하는 것이다.
에러 상황일 때 응답으로 사용할 ErrorResponse 클래스는 아래와 같다.
package controller;
public class ErrorResponse {
private String message;
public ErrorResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
ResponseEntity를 이용해 member() 메서드를 아래와 같이 구현한다.
@RestController
public class RestMemberController {
private MemberDao memberDao;
private MemberRegisterService registerService;
@GetMapping("/api/members/{id}")
public ResponseEntity<Object> member(@PathVariable Long id) {
Member member = memberDao.selectById(id);
if (member == null) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("no member"));
// 404(NOT_FOUND) 응답 상태 코드로 지정
// ErrorResponse 객체를 JSON으로 변환
}
// member 객체를 body로 지정. member 객체를 JSON으로 변환
// 200(OK)를 응답 상태 코드로 지정
return ResponseEntity.status(HttpStatus.OK).body(member);
}
...
}
위와 같이 각 메서드의 에러 응답마다 동일한 ResponseBody를 생성하면 코드가 많이 중복될 것이다.
이럴 때 @ExceptionHandler를 적용한 메서드에서 에러 응답을 처리하면 중복을 없앨 수 있다.
@GetMapping("/api/members/{id}")
public Member member(@PathVariable Long id) {
Member member = memberDao.selectById(id);
if (member == null) {
throw new MemberNotFoundException();
}
return member;
}
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNoData() {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorRespsone("no member"));
}
@RestControllerAdvice 애노테이션을 이용해서 에러 처리 코드를 별도 클래스로 분리할 수도 있다.
@ControllerAdivce 애노테이션과 동일하며 @RestController 애노테이션처럼 응답을 JSON이나 XML같은 형식으로 변환한다.
@RestControllerAdvice("controller")
public class ApiExceptionAdvice {
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNoData() {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("no member"));
}
}
@Valid 검증 에러도 위와 동일하게 HTML로 에러를 응답한다.
JSON 형식 응답을 제공하려면 Errors 타입 파라미터를 추가해서 직접 에러 응답을 생성한다.
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
@RequestBody @Valid RegisterRequest regReq, Errors errors) {
// 검증 에러가 존재하는지 확인
if (errors.hasErrors()) {
// 모든 에러 정보를 구하고, 각 에러 코드 값을 연결한 문자열을 생성, errorCodes 변수에 할당
String errorCodes = errors.getAllErrors() // List<ObjectError>
.stream()
.map(error -> error.getCodes()[0]) // error는 ObjectError
.collect(Collectors.joining(","));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("errorCodes = " + errorCodes));
}
...
}
혹은 @ExceptionHandler 애노테이션을 이용해서 에러 처리를 할 수 있다.
@RestControllerAdvice("controller")
public class ApiExceptionAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleBindException(MethodArgumentNotValidException ex) {
String errorCodes = ex.getBindingResult().getAllErrors()
.stream()
.map(error -> error.getCodes()[0])
.collect(Collectors.joining(","));
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("errorCodes = " + errorCodes));
}
}
Spring 1
EDITOR: OJO
[스프링1] 15장.간단한 웹 어플리케이션의 구조 (0) | 2022.12.08 |
---|---|
[스프링1] 14장.MVC4 : 날짜 값 변환, @PathVariable, 익셉션 처리 (0) | 2022.12.08 |
[스프링1] 13장. MVC3: 세션, 인터셉터, 쿠키 (0) | 2022.12.01 |
[스프링1] 12장. MVC2: 메시지, 커맨드 객체 검증 (0) | 2022.12.01 |
[스프링 1] 11장. MVC1: 요청 매핑, 커맨드 객체, 리다이렉트, 폼 태그, 모델 (0) | 2022.11.24 |