6 분 소요

1. @RequestBody로 JSON 요청 처리

지금까지 응답을 JSON으로 변환하는 것에 대해 살펴봤다. 이제 반대로 JSON 형식의 요청 데이터를 자바 객체로 변환하는 기능에 대해 살펴보자. POST방식이나 PUT방식을 사용하면 name=이름&age=17과 같은 쿼리 문자열 형식이 아니라 다음과 같은 JSON 형식의 데이터를 요청 데이터로 전송할 수 있다.

{“name”:”이름”,”age”:17}

JSON 형식으로 전송된 요청 데이터를 커맨드 객체로 전달받는 방법은 매우 간단하다. 커맨드 객체에 @RequestBody 애노테이션을 붙이기만 하면 된다. 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.web.bind.annotation.RequestBody;
...

@RequestController
public class RestMemberController{
    ...

	@PostMapping("/api/members")
	public void newMember(
			@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);
		}
	}
}

@RequestBody 애노테이션을 커맨드 객체에 붙이면 JSON 형식의 문자열을 해당 자바 객체로 변환한다. 에를 들어 다음과 같은 JSON 데이터를 10행의 RegisterRequest 타입 객체로 변환할 수 있다.

1
2
3
4
5
6
{
    "email" : "bkchoi@bkchoi.com",
    "password" : "1234",
    "confirmPassword" : "1234",
    "name" : "최범균"
}

스프링 MVC가 JSON형식으로 전송된 데이터를 올바르게 처리하려면 요청 컨텐츠 타입이 application/json이어야 한다. 보통 POST 방식의 폼 데이터는 쿼리 문자열인 “p1=v1&p2=v2”로 전송되는데 이때 컨텐츠 타입은 application/x-www-form-urlencoded이다.
쿼리 문자열 대신 JSON 형식을 사용하려면 application/json 타입으로 데이터를 전송할 수 있는 별도 프로그램이 필요하다.

크로 브라우저에는 Advanced REST client 확장 프로그램이나 Postman 등 JSON 형식의 데이터를 보낼 수 있는 확장 프로그램이 존재한다.

JSON 형식을 전송할 수 있는 확장 프로그램에서 POST방식으로 알맞은 JSON 데이터를 전소하면 아래와 같은 결과를 보여 준다.

json7

newMember() 메서드는 회원 가입을 정상적으로 처리하면 응답 코드로 201(CREATED)을 전송한다. 위 그림을 보면 응답 상태 코드가 201인 것을 알 수 있다.
또한 “Location” 헤더를 응답에 추가했는데 Location 헤더가 응답 결과에 포함되어 있다.

1.1 JSON 데이터의 날짜 형식 다루기

JSON 형식의 데이터를 날짜 형식으로 변환하는 방법을 살펴보자. 별도 설정을 하지 않으면 다음 패턴(시간대가 없는 JSR-8601 형식)의 문자열을 LocalDateTime과 Date로 변환한다.

1
yyyy-MM-ddTHH:mm:ss

특정 패턴을 가진 문자열을 LocalDateTime이나 Date 타입으로 변환하고 싶다면 @JsonFormat 애노테이션의 pattern 속성을 사용해서 패턴을 지정한다.

1
2
@JsonFormat(pattern = "yyyyMMddHHmmss")
private LocalDateTime birthDateTime;

특정 속성이 아니라 해당 타입을 갖는 모든 속성에 적용하고 싶다면 스프링 MVC 설정을 추가하면 된다. 다음은 그 예이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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()
				.featuresToDisable(
						SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
				.deserializerByType(LocalDateTime.class,
						new LocalDateTimeDeserializer(formatter))
				.simpleDateFormat("yyyyMMddHHmmss")
				.build();
		converters.add(0,
				new MappingJackson2HttpMessageConverter(objectMapper));
	}
}

deserializerByType()은 JSON 데이터를 LocalDateTime 타입으로 변환할 때 사용할 패턴을 지정하고 simpleDateFormat()은 Date 타입으로 변환할 때 사용할 패턴을 지정한다.

simpleDateFormat()은 Date타입을 JSON 데이터로 변환할 때에도 사용된다는 점에 유의한다.

1.2 요청 객체 검증하기

newMember() 메서드를 다시 보자. 자세히 보면 regReq 파라미터에 @Valid 애노테이션이 붙어 있다.

JSON 형식으로 전송한 데이터를 변환환 객체도 동일한 방식으로 @Valid 애노테이션이나 별도 Validator를 이용해서 검증할 수 있다. @Valid 애노테이션을 사용한 경우 검증에 실패하면 400(Bad Request) 상태 코드를 응답한다.

2. ResponseEntity로 객체 리턴하고 응답 코드 지정하기

지금까지 예제 코드는 상태 코드를 지정하기 위해 HttpServletResponse의 setStatus() 메서드와 sendError() 메서드를 사용했다.

1
2
3
4
5
6
7
8
9
10
@GetMapping("/api/members/{id}")
public Member member(@PathVariable Long id,
		HttpServletResponse response) throws IOException{
	Member member = memberDao.selectById(id);
	if(member == null) {
		response.sendError(HttpServletResponse.SC_NOT_FOUND);
		return null;
	}
	return member;
}

문제는 위와 같이 HttpServletResponse를 이용해서 409 응답을 하면 JSON 형식이 아닌 서버가 기본으로 제공하는 HTML을 응답 결과로 제공한다는 점이다. 예를 들어 위 코드는 ID에 해당하는 Member가 존재하면 해당 객체를 리턴하고 존재하지 않으면 409 응답을 리턴한다.

image

API를 호출하는 프로그램 입장에서 JSON 응답과 HTML 응답을 모두 처리하는 것은 부담스럽다. 409나 500과 같이 처리에 실패한 경우 HTML 응답 데이터 대신에 JSON 형식의 응답 데이터를 전송해야 API 호출 프로그램이 일관된 방법으로 응답을 처리할 수 있을 것이다.

2.1 ResponseEntity를 이용한 응답 데이터 처리

정상인 경우와 비정상인 경우 모두 JSON 응답을 전송하는 방법은 ResponseEntity를 사용하는 것이다.

먼저 에러 상황일 때 응답으로 사용할 ErrorResponse 클래스를 다음과 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
public class ErrorResponse {
	private String message;
	
	public ErrorResponse(String message) {
		this.message = message;
	}
	
	public String getMessage() {
		return message;
	}
}

ResponseEntity를 이용하면 member() 메서드를 아래와 같이 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
public class RestMemberController {
	...

	@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"));
		}
		return ResponseEntity.status(HttpStatus.OK).body(member);
	}
}

스프링 MVC는 리턴 타입이 ResponseEntity이면 ResponseEntity의 body로 지정한 객체를 사용해서 변환을 처리한다. 예를 들어 13행에서는 8행에서 구한 member를 body로 지정했는데, 이 경우 member 객체를 JSON으로 변환한다. 동일하게 10~11행에서는 ErrorResponse 객체를 body로 지정했으므로 ErrorResponse를 JSON으로 변환한다.

ResponseEntity의 status로 지정한 값을 응답 상태 코드로 사용한다. 10~11행은 404(NOT_FOUND)상태 코드로 응답하고 13행은 200(OK)을 상태 코드로 응답한다.

image

ResponseEntity를 생성하는 기본 방법은 status와 body를 이용해서 상태 코드와 JSON으로 변환할 객체를 지정하는 것이다.

1
2
ResponseEntity.status(상태코드).body(객체)
ResponseEntity.ok(member)

상태 코드는 HttpStatus 열거 타입에 정의된 값을 이용해서 정의한다.
200(OK) 응답 코드와 몸체 데이터를 생성할 경우 다음과 같이 ok() 메서드를 이용해서 생성할 수도 있다.

만약 몸체 내용이 없다면 다음과 같이 body를 지정하지 않고 build()로 바로 생성한다.
몸체 내용이 없는 경우 status() 메서드 대신에 다음과 같이 관련 메서드를 사용해도 된다.

1
2
ResponseEntity.status(Http.NOT_FOUND).build()
ResponseEntity.notFound().build()

몸체가 없을 때 status() 대신 사용할 수 있는 메서드는 다음과 같다.

  • noContent() : 204
  • badRequest() : 400
  • notFound() : 404

newMember() 메서드는 다음과 같이 201(Created) 상태 코드와 Location 헤더를 함께 전송했다.

1
2
response.setHeader("Location", "/api/members/" + newMemberId);
response.setStatus(HttpServletResponse.SC_CREATED);

같은 코드를 ResponseEntity로 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
		@RequestBody @Valid RegisterRequest regReq){
	try {
		Long newMemberId = registerService.regist(regReq);
        URI uri = URI.create("/api/members/" + newMemberId);
        return ResponseEntity.created(uri).build();
    }catch(DuplicateMemberException dupEx) {
		return ResponseEntity.status(HttpStatus.CONFLICT).build();
	}
}

2.2 @ExceptionHandler 적용 메서드에서 ResponseEntity로 응답하기

한 메서드에서 정상 응답과 에러 응답을 ResponseBody로 생성하면 코드가 중복될 수 있다. 예를 들어 다음 코드를 보자

1
2
3
4
5
6
7
8
9
10
@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"));
	}
	return ResponseEntity.status(HttpStatus.OK).body(member);
}

이 코드는 member가 존재하지 않을 때 기본 HTML 에러 응답 대신에 JSON 응답을 제공하기 위해 ResponseEntity를 사용했다. 그런데 회원이 존재하지 않을 경우 404 상태 코드를 응답해야 하는 기능이 많다면 에러 응답을 위해 ResponseEntity를 생성하는 코드가 여러 곳에 중복된다.

이럴 때 @ExceptionHandler 애노테이션을 적용한 메서드에서 에러 응답을 처리하도록 구현하면 중복을 없앨 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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 ErrorResponse("no member"));	
}

이 코드에서 member() 메서드는 Member 재체를 리턴한다. 회원 데이터가 존재하면 Member 객체를 리턴하므로 JSON으로 변환한 결과를 응답한다. 회원 데이터가 존재하지 않으면 MemberNotFoundException을 발생한다. 이 엑셉션이 발생하면 handleNoData() 메서드가 에러를 처리한다.

@RestControllerAdvice 애노테이션을 이용해서 에처 처리 코드를 별도 클래스로 분리할 수도 있다. @RestControllerAdvice 애노테이션은 @ControllerAdvice 애노테이션과 동일하다. 차이라면 @RestControlle 애노테이션과 동일하게 응답을 JSON이나 XML과 같은 형식으로 변환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice("controller")
public class ApiExceptionAdvice {
	
	@ExceptionHandler(MemberNotFoundException.class)
	public ResponseEntity<ErrorResponse> handleNoData(){
		return ResponseEntity
				.status(HttpStatus.NOT_FOUND)
				.body(new ErrorResponse("no member"));
	}
}

@RestControllerAdvice 애노테이션을 사용하면 에러 처리 코드가 한 곳에 모여 효과적으로 에러 응답을 관리할 수 있다.

2.3 @Valid 에러 결과를 JSON으로 응답하기

@Valid 애노테이션을 붙인 커맨드 객체가 값 검증에 실패하면 400 상태 코드를 응답한다.

문제는 HttpServletResponse를 이용해서 상태 코드를 응답했을 때와 마찬가지로 HTML 응답을 전송한다는 점이다. 실제로 검증 실패했을 때 결과는 아래와 같다.

image

@Valid 애노테이션을 이용한 검증에 실패했을 때 HTML 응답 데이터 대신에 JSON 형식을 제공하고 싶다면 다음과 같이 Errors 타입 파라미터를 추가해서 직접 에러 응답을 생성하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
		@RequestBody @Valid RegisterRequest regReq,
		Errors errors){
	if(errors.hasErrors()) {
		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("errorCode = " + errorCodes));
	}
    ...
}

이 코드는 hasErrors() 메서드를 이용해서 검증 에러가 존재하는지 확인한다. 검증 에러가 존재하면 getAllErrors()메서드로 모든 에러 정보를 구하고 각 에러의 코드 값을 연결한 문자열을 생성해서 errorCodes 변수에 할당한다.

image

@RequestBody 애노테이션을 붙인 경우 @Valid 애노테이션을 붙인 객체의 검증에 실패했을 때 Errors 타입 파라미터가 존재하지 않으면 MethodArgumentNotValidException이 발생한다. 이를 @ExceptionHandler 애노테이션을 이용해서 검증 실패시 에러 응답을 생성해도 된다.

Ref.

  • 최범균, 스프링프로그래밍입문5, 가메출판사.

카테고리:

업데이트: