작심십오일러의 스프링 시작하기(15)-2

서은경·2022년 9월 19일
0

Spring

목록 보기
26/43

@RequestBody로 JSON 요청 처리

JSON형식의 요청 데이터를 자바 객체로 변환하려면 어떻게 할까 ?! POST 방식이나 PUT 방식을 사용하면 name=이름&age=나이 와 같은 쿼리 문자열 형식이 아니라 JSON 형식의 데이터를 요청 데이터로 전송할 수 있다.

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

package controller;

import com.fasterxml.classmate.MemberResolver;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import spring.*;

import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.IOException;
import java.util.List;

@RestController
public class RestMemberController {

    private MemberDao memberDao;
    private MemberRegisterService memberRegisterService;

	..생략..
    
    @PostMapping("/api/members")
    public void newMember(@RequestBody @Valid RegisterRequest regReq, HttpServletResponse response) throws IOException {
        try {
            Long newMemberId = memberRegisterService.regist(regReq);
            response.setHeader("Location", "/api/members" + newMemberId);
            response.setStatus(HttpServletResponse.SC_CREATED);
        } catch (DuplicateMemberException duplicateMemberException) {
            response.sendError(HttpServletResponse.SC_CONFLICT);
        }
    }

@RequestBody 어노테이션을 커맨드 객체에 붙이면 JSON 형식의 문자열을 해당 자바 객체로 변환한다.

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

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

JSON 형식은 별도 설정을 하지 않으면 yyyy-MM-ddTHH:mm:ss 의 문자열을 LocalDateTime과 Date로 변환한다.

특정 패턴을 가진 문자열을 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)
            .deserializeByType(LocalDateTime.class, new LocalDateTimeDeserializer(formatter))
            .simpleDateFormat("yyyyMMdd HHmmss")
            .build();
            
            converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

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

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

요청 객체 검증하기

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

Validator를 사용할 경우 직접 상태 코드를 처리해야 한다.

    @PostMapping("/api/members")
    public void newMember(@RequestBody @Valid RegisterRequest regReq, Errors errors, HttpServletResponse response) throws IOException {
        try {
            new RegisterRequestValidator().validate(regReq, errors);
            if(errors.hasErrors()) {
            	response.sendError(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
            ...
        } catch (DuplicateMemberException duplicateMemberException) {
            response.sendError(HttpServletResponse.SC_CONFLICT);
        }
    }

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

response.sendError(HttpServletResponse.SC_NOT_FOUND);

위처럼 HttpServletResponse를 이용해서 404 응답을 하면 JSON 형식이 아닌 서버가 기본으로 제공하는 HTML을 응답 결과로 제공한다. API를 호출하는 프로그램 입장에선 HTML 응답 대신 JSON 형식의 응답 데이터를 전송해야 일관된 방법으로 응답을 처리할 수 있을 것이다!

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

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

package controller;

public class ErrorResponse {
    private String message;

    public ErrorResponse(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

ResonpseEntity를 이용하여 member() 메서드를 다시 구현해보쟈.

	@GetMapping("/api/members/{id}")
    public ResponseEntity<Object> 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 ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("no member"));
        }
        return ResponseEntity.status(HttpStatus.OK).body(member);
    }

스프링 MVC는 리턴 타입이 ResponseEntity이면 ResponseEntity의 body로 지정한 객체를 사용해서 변환을 처리한다.
ResponseEntity의 status로 지정한 값을 응답 상태 코드로 사용한다.

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

ResponseEntity.status(상태코드).body(객체)

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

ResponseEntity.ok(member);

만약 몸체 내용이 없다면 body를 지정하지 않고 바로 build로 생성한다.

ResponseEntity.status(HttpStatus.NOT_FOUND).build();

몸체 내용이 없는 경우 status() 메서드 대신 관련 메서드를 사용해도 된다.

ResponseEntity.notFound().build();

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

  • noContent() : 204
  • badRequest() : 400
  • notFound() : 404
    등이 있다.

newMember() 메서드는 201(Created) 상태 코드와 Location 헤더를 함께 전송했는데

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

같은 코드를 ResponseEntity로 구현하면 created() 메서드에 Location 헤더로 전달할 URI를 전달하면 된다.

	@PostMapping("/api/members")
    public void newMember(@RequestBody @Valid RegisterRequest regReq, HttpServletResponse response) throws IOException {
        try {
            Long newMemberId = memberRegisterService.regist(regReq);
            URI uri = URI.create("/api/members/" + newMemberId);
            return ResponseEntity.created(uri).build();
        } catch (DuplicateMemberException duplicateMemberException) {
            response.sendError(HttpServletResponse.SC_CONFLICT);
        }
    }

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

정상 응답과 에러 응답을 ResponseEntity로 생성하면 코드가 중복될 수 있다.

	@GetMapping("/api/members/{id}")
    public ResponseEntity<Object> member(@PathVariable Long id, HttpServletResponse response) throws IOException {
        Member member = memberDao.selectById(id);
        if (member == null) {
        	return ResponseEntity
            	.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("no member"));
        }
        return ResponseEntity.ok(member);
    }

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

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

	@GetMapping("/api/members/{id}")
    public ResponseEntity<Object> member(@PathVariable Long id, HttpServletResponse response) throws IOException {
        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"));
    }

회원 데이터가 존재하지 않으면 MemberNotFoundException을 발생시키고 @ExceptionHandler 어노테이션을 사용한 handleNoData() 메서드가 에러를 처리한다.

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

package controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import spring.MemberNotFoundException;

@RestControllerAdvice
public class ApiExceptionAdvice {

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

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

@Valid 어노테이션을 붙인 커맨드 객체가 값 검증에 실패하면 400 상태 코드를 응답한다. 문제는 HttpServletResponse를 이용해서 상태 코드를 응답했을 때와 마찬가지로 HTML응답을 전송한다는 점이다.

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

	@PostMapping("/api/members")
    public void newMember(@RequestBody @Valid RegisterRequest regReq, Errors errors){
    	if(errors.hasErrors()) {
        	String errorCodes = errors.getAllErrors()
            	.stream()
                .map(error->error.getCodes()[0])
                .collect(Collectors.joining(","));
          	return ResponseEntity
            	.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("errorCodes = " + errorCodes));
        }
        ... 생략
    }

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

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

	@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));
    }

💬 좀 더 꼼꼼하게 공부해야 하는데 집에 와서 녹초가 되다보니 수박 겉핥기 식으로 공부하는 것 같다.. 날 잡고 다시 쭉 훑어봐야겠다 흑흑

0개의 댓글