시작 전에 이 글은 아래 블로그 글을 참고해서 작성되었음을 미리 알린다.
[Spring] Post 요청과 Content-Type의 관계
도움이 되시면 '광고'를 한번씩 눌러시면 감사하겠습니다 :) 실무에서 RestAPI를 만들면서 ...
blog.naver.com
"farmContent" key 값으로 150자 미만의 String data를 JSON 형식으로 받는 POST method를 controller에 정의했다.
관련된 컨트롤러 코드이다..
@RestController
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
@PostMapping("")
public CreateFarmLog.Response createFarmLog(
@Valid @RequestBody final CreateFarmLog.Request request) {
log.info("creating farm log request: {}",request);
return farmLogService.createFarmLog(request);
}
}
추가로 서비스 코드이다.
@Service
@RequiredArgsConstructor
public class FarmLogService {
private final FarmLogRepository farmLogRepository;
@Transactional
public CreateFarmLog.Response createFarmLog(CreateFarmLog.Request request) {
return CreateFarmLog.Response.fromEntity(
farmLogRepository.save(createFarmLogFromRequest(request))
);
}
private FarmLog createFarmLogFromRequest(CreateFarmLog.Request request) {
return FarmLog.builder().logContent(request.getLogContent()).build();
}
}
CreateFarmLog 내부에는 Request와 Response를 받는 DTO class를 각각 선언했다.
아무튼 위와 같은 방식으로 클라이언트로부터 POST 요청을 받는 로직을 만들고 postman을 이용해 요청을 날려봤는데,
으음~~~, x-www-form-urlencoded 타입으로 날린 뒤 돌아오는 에러메세지. Content-Type 매칭 오류 발생. 오직 plain raw JSON 형태로 요청을 날려야만 제대로된 응답이 돌아왔다.
스프링이 백엔드 개발이라 API 명세에 따라 처리될 클라이언트 요청을 프론트에서 잘 넘겨주길 기대하는 수밖에 없다지만, 일단은 동작이 어떻게 이뤄지는지, 그리고 그것을 어떻게 바꿀 수 있는지 정도의 지식은 제대로 숙지하고 있어야 한다.
spring에서는 spring mvc의 message converter가 클라이언트의 요청을 java에서 다룰 수 있는 오브젝트 형태로 바꿔준다. plain string data를 제외하고 자주 쓰이는 컨버터 몇 개를 보며 위의 코드를 어떻게 분석해야할지 생각해보자.
1. @RequestBody Object object
내가 코드에서 매칭에 사용했던 형식이다. MappingJacksonHttpMessageConverter가 사용되며, 자바 오브젝트와 JSON body가 매칭된다. 예시처럼 내가 바인딩하고자 하는 특정 타입의 class를 직접 지정할 수도 있고, HashMap을 하나 정의해서 유저가 보내는 JSON 데이터를 key-value 형식으로 따로 받아올 수도 있다.
@RestController
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
@PostMapping
public CreateFarmLog.Response createFarmLog(
@Valid @RequestBody final CreateFarmLog.Request request) {
return farmLogService.createFarmLog(request);
}
}
두 @PostMapping method는 JSON 데이터를 받아와 Response를 날린다는 점에서 완전 동일한 일을 하고있다고 보면 된다. (참고로 두 method를 동시에 담은 controller는 중복 문제로 컴파일 안된다)
위와 같은 post mapping 상태에서 x-www-form-urlencoded 타입으로 요청을 날리면 역시 지원하지 않는 media type이라는 415 에러가 뜬다.
2. @RequestBody HashMap<String, String> object
똑같이 MappingJacksonHttpMessageConverter가 사용된다. jackson object mapper가 이용되므로 JSON을 요청 body로 받으며, 매칭 파라미터를 Object로 직접 넣었을 때와는 다르게 요청된 JSON 자체가 그대로 HashMap의 key, value 형태로 매핑된다.
@RestController
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
@PostMapping
public CreateFarmLog.Response createFarmLogByHashMap(
@RequestBody final HashMap<String,String> request) {
return CreateFarmLog.Response.builder().logContent(request.get("logContent")).build();
}
}
1번에서 봤던 코드와 하는 기능은 100% 동일하며, 역시 x-www-form-urlencoded 형태로 값을 넘겨주면 415 에러를 띄운다.
3. @ModelAttribute Object object
Media Type이 application/x-www-form-urlencoded인 요청이나 폼 데이터에 대해 수행되는 FormHttpMessageConverter가 등장한다. 추가로 @ModelAttribute annotation은 생략 가능하며, 그 경우 스프링에서 자동으로 parameter 앞에 해당 어노테이션을 붙여준다.
@RestController
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
@PostMapping
public CreateFarmLog.Response createFarmLog(
@Valid @ModelAttribute final CreateFarmLog.Request request) {
return farmLogService.createFarmLog(request);
}
}
위 post mapping method를 작성한 상태에서 x-www-form-urlencoded 형태의 body를 담아 요청을 보내면 제대로된 post 동작이 이뤄진다. 이 때, JSON 형태의 body를 보내면 데이터가 제대로 파싱되지 않아 field 값에 빈 값이 올라가는 오류가 발생한다.
4. @RequestBody MultiValueMap<String,String> object
2번 코드에서 HashMap을 MultiValueMap으로 바꾸기만 하면 FormHttpMessageConverter가 작동된다. x-www-form-urlencoded 타입의 body data를 받는다는 뜻이다. 코드의 형태는 2번과 비슷하지만, 동작하는 방식은 3번과 동일하다. 이렇게 Map 자료구조를 활용하는 방식으로 폼 데이터를 주고받는 것이 가능하지만 실제로는 1번과 3번 방법처럼 직접 object를 매핑하는 것이 더 직관적이고 유용하므로 map을 사용하는 경우는 드물다.
그래서. JSON 타입과 urlencoded 타입을 모두 받으려면 어떻게 해야하나?
---> consumes attribute를 활용하자.
@RestController
@RequestMapping("farm-log")
@RequiredArgsConstructor
@Slf4j
public class FarmLogController {
private final FarmLogService farmLogService;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public CreateFarmLog.Response createFarmLogJSON(
@Valid @RequestBody final CreateFarmLog.Request request) {
log.info("creating farm log by JSON request: {}",request);
return farmLogService.createFarmLog(request);
}
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public CreateFarmLog.Response createFarmLogUrlEncoded(
@Valid @ModelAttribute final CreateFarmLog.Request request) {
log.info("creating farm log by x-www-form-urlencoded request: {}",request);
return farmLogService.createFarmLog(request);
}
}
header의 Content-Type 값에 따라 다른 method를 호출하는 것이다. 만약 body에 JSON 값이 담겨온다면 첫번째 createFarmLogJSON() method가 실행될 것이고, x-www-form-urlencoded 값이 담겨온다면 두번째 createFarmLogUrlEncoded() method가 실행될 것이다. return type은 동일하게 obect를 리턴하여 JSON으로 응답이 가게 구성했다.
실제 환경에선 요청으로 JSON을 받을지, form data를 받을지 명세에 모두 설계하고 개발을 진행할테지만 위 내용을 알아둬서 나쁠 것은 없으므로 한 번 정리해 보았다!