[기능 구현] AWS S3 이미지 업로드 및 삭제

SexyWoong·2023년 11월 16일
0

spring

목록 보기
8/11

회원 프로필 수정기능 구현에서 프로필 사진을 수정하기 위해서 기존의 이미지를 S3에서 삭제하고 새로운 이미지를 S3에 업로드 하는 기능을 구현해야했다.
이 과정에서 공부한 내용을 정리한다.

multipart/form-data

이미지를 업로드 하기 위해서는 클라이언트에서 서버로 요청을 보낼때 헤더의 contentType은 multipart/form-data 이다.
이 방식은 이진 파일(예: 이미지, 오디오, 비디오)및 대용량 데이터를 전송하는데 적합하다.

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: [총 길이]

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="text_field"

text value
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file_field"; filename="example.txt"
Content-Type: text/plain

[파일 내용]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

요청 형식은 위와 같다.

헤더의 Content-Type에 booundary는 ----WebKitFormBoundary7MA4YWxkTrZu0gw로 되어있다.
이는 body를 나누는 기준이다.

구분된 각 body에는 Content-Disposition헤더로 시작하고 form-data 형식의 데이터와 해당 데이터의 name을 포함한다.

파일 업로드의 경우에는 filename과 Content-Type도 포함한다.

마지막 경계 문자열은 ------WebKitFormBoundary7MA4YWxkTrZu0gW--와 같이 마지막에 --로 끝난다.

Image Upload

  1. AWS에서 Bucket을 생성해준다.
  2. build.gradle에 의존성 추가
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

AWS와 통합하는데 사용되는 스프링 부트 스타터 패키지이다.

  1. yaml파일 설정 - github에 업로드 금지
cloud:
  aws:
    s3:
      bucket: 
    credentials:
      access-key: 
      secret-key: 
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false
  1. S3Configuration 작성
@Configuration
public class S3Configuration {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);

        return (AmazonS3Client) AmazonS3ClientBuilder
                .standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
                .build();
    }

}
@ThreadSafe
public class AmazonS3Client extends AmazonWebServiceClient implements AmazonS3 {

를 보면 AmazonS3Client는 AmazonS3를 구현하는 구현체이다. 따라서 AmazonS3Client를 Bean으로 등록해두면 AWSS3Uploader.class에서 AmazonS3에 DI가 된다.

  1. AWSS3Uploader.class
@Slf4j
@Component
@RequiredArgsConstructor
public class AWSS3Uploader implements ImageUploader {

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    private static final String DEFAULT_DIRECTORY = "images/";

    @Override
    public List<String> upload(String directory, List<MultipartFile> multipartFiles) {
        if (multipartFiles.isEmpty()) {
            return List.of();
        }

        List<String> uploadedImageUrl = new ArrayList<>();
        multipartFiles.forEach(
            multipartFile -> {
                String originalFilename = multipartFile.getOriginalFilename(); //파일의 이름

                ObjectMetadata objectMetadata = new ObjectMetadata(); //ObjectMetadata는 파일의 메타 정보
                objectMetadata.setContentLength(multipartFile.getSize());
                objectMetadata.setContentType(multipartFile.getContentType());

                try {
                    amazonS3.putObject(bucket, DEFAULT_DIRECTORY + directory + originalFilename, multipartFile.getInputStream(), objectMetadata);
                } catch (IOException e) {
                    log.warn("[Warning] Image Upload to S3 has some exception", e);
                    throw new AmazonServiceException("Some Error occurred during Upload Image to S3 Server", e);
                }
                uploadedImageUrl.add(convertURL(amazonS3.getUrl(bucket, originalFilename).toString(), directory));
            }
        );
        return uploadedImageUrl;
    }

    // 원하는 부분을 찾아 새 경로를 추가
    public static String convertURL(String url, String directory) {

        String searchPattern = ".com/";
        int index = url.indexOf(searchPattern) + searchPattern.length();
        String newPart = DEFAULT_DIRECTORY + directory;
        String modifiedURL = url.substring(0, index) + newPart + url.substring(index);

        return modifiedURL;
    }

    @Override
    public void delete(String directory, List<String> imageUrls) {
        String splitStr = ".com/";

        imageUrls.stream()
            .map(imageUrl -> imageUrl.substring(imageUrl.lastIndexOf(splitStr) + splitStr.length()))
            .map(fileName -> new DeleteObjectRequest(bucket, fileName))
            .forEach(amazonS3::deleteObject);
    }

}

프로필 이미지 수정 시 추가되는 이미지는 하나이다. 하지만 다이어리 기능에서 이미지를 여러개 추가해야하기 때문에 List<MultipartFile> multipartFiles와 같이 parameter를 List로 받았다.

upload메서드의 경우 List를 순회하면서 S3에 이미지를 upload하고 Url을 uploadedImageUrl에 담아 리스트를 반환한다.

delete메서드의 경우 imageUrls 리스트를 순회하면서 해당 주소의 이미지들을 삭제한다.

Code

MemberController.java

@SneakyThrows
    @PatchMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<ApiResponse<Void>> editMemberProfile(
        @RequestPart(value = "images", required = false) List<MultipartFile> profileImage,
        @RequestPart(value = "texts") @Valid MemberProfileEditRequest request,
        @LoginUser SessionUser sessionUser) {

        log.debug("profileImage null check : {}", profileImage == null);

        memberService.updateMemberProfile(profileImage, request.toServiceRequest(), sessionUser.memberId());

        return ApiResponse.ok(
            linkTo(methodOn(MemberController.class).editMemberProfile(profileImage, request, sessionUser)).withSelfRel(),
            linkTo(MemberController.class.getMethod("getMemberProfile", SessionUser.class)).withRel("get member profile")
        );
    }

profileImage와 변경할 회원의 정보인 MemberProfileEditRequest를 파라미터로써 받는다.

MemberService.java

@Transactional
    public void updateMemberProfile(List<MultipartFile> profileImages, MemberProfileEditServiceRequest serviceRequest, Long memberId) {
        Member findMember = validateMemberId(memberId);
        String oldProfileImageUrl = findMember.getImageUrl();

        String profileImageUrl = updateProfileImage(profileImages, oldProfileImageUrl);

        updateMemberProfile(profileImageUrl, serviceRequest, findMember);
        eventPublisher.publishEvent(new MemberupdatedEvent(findMember));
    }

    private String updateProfileImage(List<MultipartFile> profileImages, String oldProfileImageUrl) {
        if (profileImages != null) {
            imageUploader.delete("member/", List.of(oldProfileImageUrl));
            checkCountOfImage(profileImages);
            return uploadImage(profileImages);
        }
        return oldProfileImageUrl;
    }

    private String uploadImage(List<MultipartFile> profileImage) {
        return imageUploader.upload("member/", profileImage).get(0);
    }

    private void checkCountOfImage(List<MultipartFile> multipartFileList) {
        if (multipartFileList.size() > 1) {
            throw new IllegalArgumentException("프로필 사진 수정시 이미지는 하나만 필요합니다.");
        }
    }

    private Member validateMemberId(Long memberId) {
        return memberRepository.findById(memberId)
            .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 id 입니다."));
    }

    private void updateMemberProfile(String profileImageUrl, MemberProfileEditServiceRequest serviceRequest, Member findMember) {
        findMember.updateProfile(
            profileImageUrl,
            serviceRequest.nickname(),
            serviceRequest.birthday(),
            serviceRequest.mbti(),
            serviceRequest.calendarColor());
    }

회원 프로필 수정 시 이미지도 수정을 한다면 이전의 이미지를 S3에서 삭제해준다. 그렇지 않으면 S3에 파일들이 계속 쌓인다.

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글