본문 바로가기
Back-end/Spring

[Spring] File과 JSON 데이터를 multipart/form-data로 받아오는 방법

by 안녕주 2024. 1. 19.

 

 

목차

  1. 이글을 쓴 이유
  2. Case 1의 경우에서 문제상황
  3. Case 1의 문제 상황에서 파싱을 하면서 까지 해결하고 싶었던 이유
  4. Case 2를 사용하게 된 이유
  5. Case 2에서 아직도 고민이 되는 부분
  6. Case 2를 사용한 이유
  7. 결론

안녕하세요 안녕주입니다.

 

이번 프로젝트를 하면서 POST 메소드에 Request Body로 MultiPartFile을 받는 경우가 많았습니다.

MultiPartFile을 받을 경우 클라이언트 선생님들께서 FormData 헤더로  보내주셔야합니다.

FormData란 HTML 폼 데이터를 나타냅니다.
이때 브라우저가 보내는 HTTP 메시지는 인코딩되고 Content-Type 속성은 multipart/form-data로 지정된 후 전송됩니다.

 

 

1️⃣ 이글을 쓴 이유

이글을 쓴 이유는 MultiPartFile과 나머지 데이터들 중에 List<Dto> 타입, ENUM 타입을 requestBody로 받는 방법에 대한 정보가 부족했기 때문에 적게 되었습니다.

예를 들어 아래와 같이!!! RequestDto를 만들고 MultiPartFileList<HairStyle>라는 리스트 안에 객체가 있을 경우 그리고 HairLength hairLength와 같은 자체 ENUM을 인식하지 못함 && @RequestBody  또는 @ModelAttribute 와 같은 requestBody를 받아오는 대표적인 어노테이션을 사용해서 데이터를 가져오는데 어려움이 있습니다. 

public record ModelApplicationRequest(
        HairLength hairLength,
        List<HairStyle> preferHairStyles,
        String hairDetail,
        List<ModelHairServiceRequest> hairServiceRecords,
        String instagramId
) {}

 

그냥 Multipart File과 List<String> 과 데이터 타입은 같이 받기 가능.. but..Multipart File과 List<Dto> 타입은…같이 받기 힘든 상황

 

저희는 MultiPartFile과 더불어 (List<객체> 데이터)를 받아올때 두가지 형태로 받아오는 방법을 진행했습니다.

💡 Case 1
하나의 헤더(Content-Type - multipart/form-data)와 하나의 데이터 형식으로 Body를 받아오기
💡 Case 2
MultiPartFILE들은 (Content-Type - multipart/form-data) 헤더
나머지 객체들은 json으로 받는 방법으로 나눠서 받았습니다. 

 

2️⃣ Case 1의 경우에서 문제상황

  1. 하나의 multipart/form-data 헤더와 하나의 request Body
  2. @ModelAttribute 어노테이션을 사용해서 request Body를 받아오고 싶었음
  3. multipart/form-data 헤더 + List<객체> 를 받아오는데 큰 문제 상황 발생
  4. 문제 상황은 값을 제대로 매핑하지 못하는 문제, 즉 List<객체>의 값만 json으로 데이터가 매핑되지 않는다.
  5. → 그래서 List 안에 있는 객체를 파싱해서 우리의 원하는 객체 형식으로 매핑이 되도록 파싱 커스텀 메소드가 필요했기에 모든 데이터를 String으로 가져와야했음

일반적인 application/json 헤더와 request Body는 @ModelAttribute 어노테이션을 사용하고 List<객체>를 알아서 매핑해서 사용할 있습니다.

multipart/form-data 헤더로 request Body를 받아올 경우 List<객체>의 값만 객체로 매핑이 되지 않고 String으로 넘어오게 되어 직접 파싱을 해야하는 문제가 생겼습니다. 

 

즉 아래와 같은 List 안의 객체들이  매핑이 되지 않고 String으로 넘어온다! 

"hairServiceRecords": [
	{
		"hairService": "PERM",
		"hairServiceTerm": "UNDER_ONE"
	},
	{
		"hairService": "BLACK",
		"hairServiceTerm": "ABOVE_TWELVE"
	}
]

 

 

그래서 아래처럼 Binder를 커스텀을 해서 List 안에 있는 값을 가져와서 객체에 매핑해주는 커스텀 함수를 만들었습니다.

 

(우리의 Controller) :

  • @ModelAttribute ModelApplicationRequest request 
  • 하나의 어노테이션, 하나의 객체 바디를 가져오는 방법
@Operation(summary = "[JWT] 모델 지원서 작성", description = "모델 지원서 작성 API입니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "모델 지원서 작성 성공"),
            @ApiResponse(responseCode = "400", description = "인증 오류 입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
            @ApiResponse(responseCode = "500", description = "서버 내부 오류 입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
    })
    @SecurityRequirement(name = "JWT Auth")
    @PostMapping(value = "/application", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
    public SuccessNonDataResponse submitModelApplication(
            @Parameter(hidden = true) @UserId Long userId,
            @ModelAttribute ModelApplicationRequest request
    ) {
        modelService.postApplication(userId, request);
        return SuccessNonDataResponse.success(SuccessCode.CREATE_MODEL_APPLICATION_SUCCESS);
    }

 

(기존의 RequstBody) : 자체 ENUM을 사용할 수 없고, 무조건 기본 타입을 사용해야함(String.. 등등) + 해당 Dto 에 MultiPartFile도 같이 받음

public record ModelApplicationRequest(
        @Schema(description = "모델의 현재 머리 기장 예시입니다.", example ="SHORT")
        String hairLength,
        @Schema(description = "PreferHaireStyle의 예시 JSON 배열 포맷입니다.", example ="[\\"NORMAL_CUT\\", \\"ALL_COLOR\\"]")
        List<String> preferHairStyles,
        @Schema(description = "모델이 원하는 헤어스타일 예시입니다.", example = "안녕하세요 저는 숱을 많이 친 허쉬컷이 하고 싶어요 근데 머리가 곱슬이라 매직도 같이 해야지 이쁘게 될것 같아요. 그리고 머리가 얇아서 그거 감안하고 해야할것 같습니다.")
        String hairDetail,
        @Schema(description = "HairServiceRecords 의 예시 JSON 배열 포맷입니다.", example = "[{\\"hairService\\": \\"PERM\\", \\"hairServiceTerm\\": \\"UNDER_ONE\\"}, {\\"hairService\\": \\"BLACK\\", \\"hairServiceTerm\\": \\"ABOVE_TWELVE\\"}]")
        List<ModelHairServiceRequest> hairServiceRecords,
        MultipartFile modelImgUrl,
        @Schema(description = "모델의 인스타그램 예시입니다.", example ="hizo0")
        String instagramId,
        MultipartFile applicationCaptureImgUrl
) {}

 

(Binder 커스텀할것이다! 선언 ) : hairServiceRecords 객체를 커스텀 하겠다.!

@InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(List.class, "hairServiceRecords", new ListPropertyEditor(ModelHairServiceRequest.class));
    }

 

(커스텀한 함수) : List의 값을 가져와서 매핑해주기

public class ListPropertyEditor extends PropertyEditorSupport {

    private final Class<?> elementType;
    private final ObjectMapper objectMapper;

    public ListPropertyEditor(Class<?> elementType) {
        this.elementType = elementType;
        this.objectMapper = new ObjectMapper();
    }

    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        if (StringUtils.hasText(text)) {
            try {
                List<?> list = objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, elementType));
                setValue(list);
            } catch (IOException e) {
                throw new IllegalArgumentException("Failed to convert value to List: " + text, e);
            }
        } else {
            setValue(null);
        }
    }

    @Override
    public String getAsText() {
        Object value = getValue();
        if (value == null) {
            return "";
        }
        try {
            return objectMapper.writeValueAsString(value);
        } catch (IOException e) {
            throw new IllegalArgumentException("Failed to convert value to JSON: " + value, e);
        }
    }
}

 

3️⃣ Case 1의 문제 상황에서 파싱을 하면서 까지 해결하고 싶었던 이유

네트워크를 공부하다보면 일반적으로 하나의 헤더와 하나의 데이터가 일반적입니다.

편의를 위해 @ModelAttribute 를 사용해서 하나의 헤더와 하나의 데이터 형식으로 가져오는것이 뭔가 CS 적으로 좋지 않을까? 생각을 했습니다….

해당 방법으로 진행을 하던도중 클라이언트 선생님들고 데이터를 파싱해서 보내줘야하는 문제점이 발생해서, 클라이언트& 서버 둘다 파싱을 하는 방법은 좋은 방법이 아니라 생각이 들어 Case2의 방법으로 바꾸게 되었습니다.

 

4️⃣ Case 2를 사용하게 된 이유

Case2를 사용하게된 이유는 서버에서는 request body들이 json으로 파싱이 되지 않아 String으로 넘어오게 되는 데이터들을 직접 우리의 객체에 맞게 매핑을 해줬다고 위에서 이야기 했습니다. 그래서 실제로 테스트를 해보면 모든 객체 데이터들이 String으로 넘어와야했습니다.

 

하지만 클라이언트 선생님들께서는 데이터를 보내주실때 객체타입으로 데이터들을 넘겨주시면 알아서 객체를 axios가 json으로 변경 → 서버에서 json을 우리 프로젝트의 객체로 다시 매핑 이런식으로 진행되다보니, 클라이언트 선생님들께서도 객체들을 다 String으로 파싱해서 보내줘야하는 번거로움이 생겼고

개발하는 입장에서는 클라이언트 & 서버 둘다 파싱을해서 데이터를 주고받는게 좋은 방법이다 생각하지 않게 되어 급히 수정을 하게 되었습니다.

 

5️⃣ Case 2에서 아직도 고민이 되는 부분

Case 2의 방법은 MultiPartFile들은 (Content-Type - multipart/form-data), 나머지 객체 데이터들은 application/json 형식으로 가져와야하는 두가지 헤더를 직접 정해서 받아야하는 방법입니다.

  • 달라진점 :  @RequestPart를 사용해서 데이터를 받아오기, RequestDto에 ENUM 으로 값 받아오기 가능
  • 하지만 하나의 Dto로 못 받아오고, 멀티파트 파일은 따로 그리고 나머지 Dto 를 받아와야하는 문제
@Operation(summary = "[JWT] 모델 지원서 작성", description = "모델 지원서 작성 API입니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "모델 지원서 작성 성공"),
            @ApiResponse(responseCode = "400", description = "인증 오류 입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
            @ApiResponse(responseCode = "500", description = "서버 내부 오류 입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
    })
    @SecurityRequirement(name = "JWT Auth")
    @PostMapping(value = "/application", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
    public SuccessNonDataResponse submitModelApplication(
            @Parameter(hidden = true) @UserId Long userId,
            @RequestPart(value = "modelImgUrl", required = false) MultipartFile modelImgUrl,
            @RequestPart(value = "applicationCaptureImgUrl", required = false) MultipartFile applicationCaptureImgUrl,
            @RequestPart(value = "applicationInfo") ModelApplicationRequest applicationInfo) {
        modelService.postApplication(userId, modelImgUrl, applicationCaptureImgUrl, applicationInfo);
        return SuccessNonDataResponse.success(SuccessCode.CREATE_MODEL_APPLICATION_SUCCESS);
    }

 

(분리된 RequestDto) : 원래 자체 ENUM 사용 가능, List<객체> 사용 가능 but MultiPartFile은 따로 받아야함...

public record ModelApplicationRequest(
        @Schema(description = "모델의 현재 머리 기장 예시입니다.", example ="SHORT")
        HairLength hairLength,
        @Schema(description = "PreferHaireStyle의 예시 JSON 배열 포맷입니다.", example ="[\\"NORMAL_CUT\\", \\"ALL_COLOR\\"]")
        List<HairStyle> preferHairStyles,
        @Schema(description = "모델이 원하는 헤어스타일 예시입니다.", example = "안녕하세요 저는 숱을 많이 친 허쉬컷이 하고 싶어요 근데 머리가 곱슬이라 매직도 같이 해야지 이쁘게 될것 같아요. 그리고 머리가 얇아서 그거 감안하고 해야할것 같습니다.")
        String hairDetail,
        @Schema(description = "HairServiceRecords 의 예시 JSON 배열 포맷입니다.", example = "[{\\"hairService\\": \\"PERM\\", \\"hairServiceTerm\\": \\"UNDER_ONE\\"}, {\\"hairService\\": \\"BLACK\\", \\"hairServiceTerm\\": \\"ABOVE_TWELVE\\"}]")
        List<ModelHairServiceRequest> hairServiceRecords,
        @Schema(description = "모델의 인스타그램 예시입니다.", example ="hizo0")
        String instagramId
) {}

 

코드적으로 커스텀 함수를 사용하지도 않아도 되고 클라이언트가 미리 이야기해둔 ENUM도 그대로 사용할 수 있고, @RequestPart의 value 값만 설정해주기만 하면 알아서 객체로 바인딩도 되고 사용하기에는 편리합니다.

 

다만 한가지 걸렸던 것은 두가지 헤더로 받는 것이 옳은가? 라는 부분 하나였는데요

이번 과정을 통해 무조건 CS적으로 옳은게 편한 방법은 아니구나~에 대해 알게 되었고 한 API 통신에서 두가지 헤더로 값을 받을 수 있다는 것에 대해 알 수 있었습니다!

 

또한 더 좋은 방식으로 풀어나가는 방법에 대해 생각해보다가 제가 고민했던 부분인 한가지 헤더를 사용하는 부분을 충족시키려면 MultiPartFile을 받는 API와 객체 데이터를 받는 API로 2가지로 나누는 방법이 가장 좋겠구나!에 대해 생각하게 되었고 추후 리팩토링때…. 같이 이야기 해보고 싶네요…

 

결론

MultiPartFile 을 받아야하는데 List<Dto>의 형태 또는 ENUM도 같이 받아야하는 경우에는 우리의 Case 2 방식으로 헤더를 2가지를 사용해서 받아올 수 있다~….

아웅 설명이 너무 어려운데요... 혹시나 MultiPartFile과 ENUM, List<ENUM>, List<객체>를 받아야 하는데 어려움이 있으시다면.. 언제나 편히 댓글을 달아주세요...

 

 

해당 문제를 같이 해결한 클라이언트 선생님의 아티클도 두고갑니다! 

https://velog.io/@binllionaire/File과-JSON-데이터를-multipartform-data로-한번에-보내는-방법


참고링크

https://ko.javascript.info/formdata

multipart/form-data에서 List 사용하는 방법 - 인프런

폼을 통해 받은 데이터를, 직접 만든 Model에 매핑하는 방법 - 인프런

[Toy Project] Spring Boot에서 Multipart 데이터와 다수의 RequestParam 함께 처리하기

[JS] formData에 파일, 객체 함께 넣기

Form 에 FormData 와 File 을 동시에 받아 Java 로 처리하기

multipart/form-data에서 List 사용하는 방법 - 인프런

Spring Multipart 및 파일업로드

댓글