개발 낙서장

[TIL] 내일배움캠프 76일차 - File과 JSON 한번에 보내기 본문

카테고리 없음

[TIL] 내일배움캠프 76일차 - File과 JSON 한번에 보내기

권승준 2024. 4. 16. 23:04

오늘의 학습 키워드📚

문제 상황

글을 업로드할 때 사진과 같이 업로드할 수 있는데 현재 메소드를 두 개로 나눠놓은 상태라
글을 먼저 업로드 -> 업로드 된 ID 값을 바탕으로 사진을 업로드 -> 성공!
이런 흐름인데 사진이 업로드에 실패하더라도 글은 이미 업로드돼있는 문제가 발생했다.

communityposts가 글을 업로드하는 API이고 multipart-files가 S3에 파일을 업로드하는 API이다.

사진 업로드에 실패했으니 에러 메세지와 함께 글 업로드도 되지 않아야 정상이지만 글만 업로드되고 사진은 업로드되지 않았다.

해결 방법

원인은 Content-type에 있었다. 기존에는 글 업로드만 먼저 구현했기에 application/json으로 content type을 설정했고 @RequestBody 어노테이션을 통해 Json으로 객체를 담아 요청했다.

하지만 파일은 multipart/form-data 헤더로 설정해야 하기에 기존의 방법과 일치하지 않아 우선 API를 나눠두었던 것이다.
나는 multipart/form-data 헤더로 설정하면 오로지 파일만 보낼 수 있다고 생각했는데 String 같은 Value라던가 Json도 같이 보낼 수 있다고 한다.

포스트맨에서도 위처럼 form-data이지만 Json으로 요청을 보낼 수 있다.

    const handleSubmit = async (event) => {
        event.preventDefault();

        try {
            const response = await createCommunityPost({
                title: title,
                content: content,
                categoryName: selectedCategory,
            });

            const communityPostId = response.data.communityPostId;

            if (communityPostId && selectedImage) {
                await uploadImage(communityPostId, selectedImage);
            }

            handleBackClick();
        } catch (error) {
            console.error('Error creating post:', error);
        }
    };

먼저 기존에 프론트에서 요청을 보냈던 방법이다. Json 형식으로 createCommunityPost 메소드를 통해 API를 호출했고 호출에 성공하여 해당 ID를 받았다면 그 ID를 바탕으로 이미지를 업로드하는 방법이었다.
따라서 먼저 글이 업로드가 되더라도 이미지 업로드에 실패할 수 있었던 것이다.

const handleSubmit = async (event) => {
    event.preventDefault();

    try {
        const response = await createCommunityPost(
            {
                title: title,
                content: content,
                categoryName: selectedCategory,
            },
            selectedImage
        );

        handleBackClick();
    } catch (error) {
        console.error('Error creating post:', error);
    }
};

위처럼 요청을 Json과 이미지를 같이 보낸다.

export const createCommunityPost = async (communityPost, image) => {
    const formData = new FormData();
    await formData.append('communityPost', new Blob([JSON.stringify(communityPost)], {type: 'application/json'}));

    if (image) {
        formData.append('image', image);
    }

    try {
        const response = await apiClient.post(startUrl + `/communityposts`, formData, {
            headers: {
                'Content-Type': 'multipart/form-data',
            },
        });
        return response;
    } catch (error) {
        console.error('Error creating post:', error);
        throw error;
    }
};

데이터들을 FormData에 묶어서 같이 요청을 보내는데 헤더에 Content-Type을 multipart/form-data로 설정해 보낸다.

다만 글에 관한 값들은 Json으로 묶어서 보내는데 multipart/form-data로 전송되는 데이터는 문자열 혹은 이진 데이터 타입만 가능하다. 따라서 Json 문자열을 이진화해 서버에서 application/json 형태로 받을 수 있게 하는 Blob 객체로 보낸다.

    @PostMapping
    public ResponseEntity<CommunityPostResponse> saveCommunityPosts(
        @RequestPart(value = "communityPost") @Valid CommunityPostRequest communityPostRequest,
        @RequestPart(value = "image", required = false) MultipartFile image,
        @AuthenticationPrincipal UserDetailsImpl userDetails) {
        CommunityPostResponse communityPostResponse = communityPostsService.saveCommunityPosts(
            communityPostRequest,
            image,
            userDetails.getUser());

        return new ResponseEntity<>(communityPostResponse, HttpStatus.CREATED);
    }

Controller 부분이다. 프론트에서 값을 Json 형태로 보냈으므로 RequestPart를 통해 객체를 수신할 수 있다.

    public CommunityPostResponse saveCommunityPosts(CommunityPostRequest communityPostRequest,
        MultipartFile image, User user) {

        CommunityCategory communityCategory = communityCategoryRepository.findByNameEquals(
                communityPostRequest.getCategoryName())
            .orElseThrow();

        if (!communityCategory.getStatus()) {
            throw new NoSuchElementException();
        }

        CommunityPost communityPost = communityPostRepository.save(new CommunityPost(
                communityPostRequest.getTitle(),
                communityCategory,
                communityPostRequest.getContent(),
                user
            )
        );

        String imageUrl = null;

        if (image != null && !image.isEmpty()) {
            try {
                imageUrl = imageService.createImage(image, communityPost);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

        return new CommunityPostResponse(communityPost, user.getUserName(), imageUrl);
    }

서비스 메소드에서는 @Transactional로 묶어 이미지 업로드에 실패하면 롤백되도록 했다.

구현 화면

아까와 같은 이미지를 사용해 업로드를 시도하니 역시 업로드 실패가 발생했다.

실제 DB에도 글이 저장되지 않았다.

업로드 가능한 사진으로 바꿔 요청을 보내고 디버그를 찍어보니 필요한 값과 이미지 파일이 제대로 넘어온 것을 알 수 있다.

이미지와 같이 업로드에 성공한 모습이다.


오늘의 회고💬

중간 발표 이후 어떻게 프로젝트를 진행해야 할지 회의를 많이 했다. 과연 다 구현할 수 있을까 걱정도 되긴 하지만 지금까지 잘 해왔으니 앞으로도 잘할 수 있을 거라 생각한다.

 

내일의 계획📜

SQS에 대한 공부를 좀 더 해야 할 것 같다. Redis로 동시성 제어를 하는 방식에서 DB의 낙관적 락이나 SQS로 하는게 어떻겠냐는 피드백을 받아서 기술 스택을 늘리고자 SQS를 활용하는 방식으로 진행하기로 결정했다.

Comments