본문 바로가기
SpringBoot/구현 고민들

[Spring] GET 요청 쿼리 스트링이 많을 경우 @RequestBody vs @ModelAttribute

by rla124 2024. 8. 23.

프로젝트에서 검색 필터링 Nullable 조건의 API를 개발(구현된 PR 링크)하면서 GET mothod와 @RequestBody 어노테이션을 같이 썼었다. HTTP GET with payload body는 RFC 7231 스펙에 공식적으로 명시(공식 문서 링크)되어있듯이 이 둘의 양립이 허용되었으나 일부 클라이언트에서는 GET 요청과 함께 body를 보낼 경우 이를 무시하거나 POST로 바꿔버리는 문제가 있을 수 있다는 것을 알게 되었고 실제 프론트 친구로부터 GET + body 요청이 리액트 개발환경 상에서 안된다는 연락을 받게 되었다.

 

문제 상황

위에서 간략하게 언급했듯이 기능 자체가 "필터링 검색"이기 때문에 프론트에서 서버로 보내야 하는 필드 값이 많아서 GET 요청임에도 request dto record를 정의하여 @RequestBody로 받았던 것이기 때문에 이를 쿼리스트링으로 바꾸야 하는 상황이었다. 

 

처음에는 모든 Nullable이 가능한 검색 조건에 대해 하나하나 @RequestParam을 병렬적으로 열거하여 컨트롤러에서 받아온 값을 서비스 레이어로 념겨줄려고 했으나 기존 @RequestBody를 받아올 때 한꺼번에 하나의 request DTO 객체로 묶어버릴 수 있는 이점을 놓치고 싶지 않아서 어떤 방법이 좋을지 고민을 하였었다.

 

@RequestParam Map<String, Object> paramMap 도입?

그래서 생각한 방식은 Map, MultiValueMap을 컨트롤러에서 사용하여 쿼리 스트링을 한꺼번에 paramMap 변수에 할당하는 방식이었다. 컨트롤러에서 서비스 레이어로 paramMap 변수를 넘기기만하면 나는 서비스 레이어에서 검색 기능을 위해 JPA Specification 함수를 정의했으므로 paramMap.get("필드명") 이런 식으로 검색 함수 파라미터로 토스할 수 있겠다는 생각을 했었다. 하지만 필터링 검색 조건이 boolean, Long, Integer 등 매우 형식이 다양한 반면에 이 방식을 사용하면 항상 paramMap.get 한 값이 Object이기 때문에 원하는 타입으로 parsing을 해주어야 한다는 번거로움이 있었다.

 

그래서 다시 @RequestBody 처럼 내가 사전에 정의한 dto를 한꺼번에 매핑해줄 수 있는 방법은 없는지 찾아보았고 이전에 base64 인코딩된 이미지를 @ModelAttribute로 바로 받아올 수 있다는 점이 생각나서 @RequestParam과 @ModelAttribute를 키워드로 잡고 해결해보려고 했다.

 

@ModelAttribute 적용!

내가 검색 조건이 요구하는 서로 다른 타입(boolean, int 등)을 고려해서 쿼리 스트링으로 어떤 값이 주어지든 사전에 정의한 타입으로 바로 매핑이 가능한 @ModelAttribute가 최적이라고 판단하였다. 다만 기존에 request dto가 record로 정의되어있어서 자동 getter 선언은 보장되어있었지만 @ModelAttriute 어노테이션을 쓰기 위해서는 @Setter와 생성자 코드가 필요했고 서비스 레이어에서 이 request dto 필드를 이용하므로 get도 필요했기 때문에 이 둘을 모두 만족하는 @Data 어노테이션과 @NoArgsConstructor를 추가해야겠다고 생각했다. 그래서 아래와 같이 request dto를 record에서 class로 바꾸는 방식으로 수정했다. 

package com.sejong.sejongpeer.domain.study.dto.request;

import io.micrometer.common.lang.Nullable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class StudyPostSearchRequest {

	@Nullable
	@Schema(description = "원하는 스터디 모집 인원")
	Integer recruitmentPersonnel;

	@Nullable
	@Schema(description = "스터디 모집 여부에 대해서 모집 중은 true, 모집 마감은 false로 요청주세요.")
	Boolean isRecruiting;

	@Nullable
	@Schema(description = "검색어") String searchWord;

	@Nullable
	@Schema(description = "교내 게시글 검색의 경우 과목 id, 교외 게시글 검색의 경우에는 카테고리 id를 입력해주세요.")
	Long categoryId;

}

 

컨트롤러에서는 이제 위에서 정의한 네 개의 쿼리스트링으로 받아올 필드에 대해 한꺼번에 StudyPostSearchRequest 객체로 매핑하는 것이 가능하다.

@Operation(summary = "게시글 검색", description = "검색 조건에 해당하는 모든 게시글을 반환합니다.")
@GetMapping("/post/search")
public List<StudyTotalPostResponse> searchPosts(
    @Valid @NotNull @RequestParam(name = "studyType") StudyType studyType,
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size,
    @ModelAttribute StudyPostSearchRequest studyPostSearchRequest) {
    return studyService.getAllStudyPostBySearch(studyType, page, size, studyPostSearchRequest);
}

 

기존에는 postman에서 json raw로 받아올 @RequestBody 위치에 쿼리 스트링을 내가 원하는 객체로 바로 매핑해주는 @ModelAttribute으로만 대체하였다. 정말 간편하다는 걸 느꼈다. 아래와 같이 서비스 레이어에서 바인딩 여부를 확인하고자 디버깅 코드를 작성하였고 성공적이었음을 확인했다.

@Transactional(readOnly = true)
public List<StudyTotalPostResponse> getAllStudyPostBySearch(StudyType studyType, Integer page, Integer size, StudyPostSearchRequest request) {
    System.out.println(request.getRecruitmentPersonnel());
    System.out.println(request.getIsRecruiting());
    System.out.println(request.getSearchWord());
    System.out.println(request.getCategoryId());
    System.out.println(studyType.getValue());
    
    Specification<Study> spec = Specification.where(StudySpecification.checkStudyTypeMatching(studyType))
        .and(StudySpecification.checkRecruitmentPersonnelMatch(request.getRecruitmentPersonnel()))
        .and(StudySpecification.findByRecruitmentStatus(request.getIsRecruiting()))
        .and(StudySpecification.containsTitleOrContent(request.getSearchWord()));

    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
    Slice<Study> studyPage = studyRepository.findAll(spec, pageable);

    return studyPage.stream()
        .filter(study -> isCategoryNameMatching(study, request.getCategoryId()))
        .map(this::mapToCommonStudyTotalPostResponse)
        .collect(Collectors.toUnmodifiableList());

}

 

 

postman으로 아래와 같이 요청해보았을 때

 

@Nullable로 선언한 검색 조건 필드에 따라 요청하지 않았을 경우 null, 쿼리스트링으로 요청했으면 내가 요청한 값이 잘 뜬다.

 

최종 코드는 이 pr 링크에서 확인 가능하다. 

 

배포 이후 프론트랑 드디어 통신 성공!!! 둘다 행복해하는 중

 


GET 요청에 requestParam이 많을 경우 @ModelAttribute를 이용해보자 (결론)

이번 기회에 스프링부트에서 HTTP 요청으로 넘어오는 데이터를 받아오는 방식에 대해 정리할 수 있었다. 그 내용을 아래 간략하게 작성한다. 재밌네~~~~~~~

 

@RequestParam

- 하나의 파라미터를 요청받을 때 사용

- @RequestParam("받아올 key 값")로 파라미터 이름 지정 가능, 지정하지 않으면 변수명이 사용

- multipart도 받아올 수 있음

 

@ModelAttribute

- GET parameter, body, binary file 모두 바인딩 → 필드 내부에 생성자와 Setter가 필요한 이유 (이게 없다면 null)

 

@RequestBody

- 요청 시 body의 내용을 HttpMessageConverter를 통해 자바 object로 역직렬화 

(주로 json 형식을 자바 객체로 변환하기 위해 사용)

- 데이터 변환 목적이므로 생성자, setter 불필요 → record로 request dto 작성해도 되는 이유

- binary 형식(multipart 형식)은 받아오지 못함 

- header에 담긴 content-type을 툥해 어떤 converter를 사용해 자바 객체로 변환할지 정하기 때문에 타입 명시 필요

(json 형식으로 받기 위해서는 application/json 타입으로 지정 등)

 

@RequestPart

- content-type이 multipart/form-data 인 경우 → MultiPartResolver가 동작

- @RequestBody 사용 시 binary 데이터 포함되어있을 경우 사용