[Spring Boot & AWS] AWS S3 bucket을 활용한 이미지 업로드

박진우·2023년 9월 25일
3
post-thumbnail

시작하기에 앞서...

프로젝트를 진행하면서 부가적인 회원 서비스를 깔끔하게 마무리짓기 위해 프로필 사진 업로드를 진행하고자 하였습니다.
로컬 환경의 상대 경로를 활용하는 것은 이전에 시도해보아서 익숙했지만, 현재 EC2로 배포된 상태에서 활용하기엔 AWS S3를 활용하는 것이 좋겠다는 생각이 들어 시도해보았습니다!
따라서, 이번 포스팅은 해당 작업을 진행했던 과정을 기록하고자 합니다.


1. AWS S3 버킷 생성

S3를 활용하기 위해선 먼저 버킷을 생성해야 합니다.

  • AWS 버킷 생성버킷의 이름을 작성해주고, 나머지는 기본값으로 설정합니다.
  • 액세스 차단 설정모든 퍼블릭 액세스 차단을 풀어주시고, 하단 체크박스 확인 후 체크버튼을 눌러주세요.
    여기까지 진행되셨다면 생성 버튼 눌러주시고 끝!

2. 버킷 정책 설정

이제 버킷의 사용을 위해 정책을 설정해주어야 합니다.

  • 버킷 정책 편집버킷 정책의 '편집'에 진입한 후, 정책 생성기로 넘어갑니다.
    버킷 ARN은 필요하니 복사해주세요!
  • 버킷 정책 생성기 사용Principal 항목에는 전체를 의미하는 *를 입력해주세요!
    이후, Actions 항목에서 DeleteObject, GetObject, PutObject 항목을 선택해주시면 완료입니다!
    이제 Add Statement 버튼을 눌러주세요.이제 Generate Policy버튼을 눌러 정책을 생성해주세요!
  • 생성된 버킷 정책 적용버킷 정책을 바로 적용해주시면 됩니다!
    여기서 주의할 점은, "Resource"항목에서 반드시 버킷 이름 뒤에 /* 문자를 붙여주셔야 해요!!! 빼먹으시면 안됩니다🥹

3. IAM 사용자 생성

이제 IAM 사용자를 생성할 차례입니다.
여기서 잠깐, "왜" IAM 사용자를 생성해서 사용해야 할까요?
IAM의 존재 이유에 대해 알고가실게요🫣

Identity and Access Management (IAM)

  • AWS 리소스에 대한 액세스 권한을 제어할 수 있는 계정
  • IAM 계정 : 리소스 액세스 여부가 지정되어 권한을 제어받는 계정

위와 같은 맥락으로, IAM 계정은 AWS 서비스에 모든 권한이 있는 루트 계정을 사용하는 것을 지양하고, 액세스 권한이 제한된 IAM 계정을 생성 및 사용함으로써 안전하게 서비스를 사용할 수 있도록 하는 데에 의의가 있습니다!

이제 본격적으로 IAM 사용자 생성을 시작하도록 하겠습니다!

  • IAM 사용자 생성사용자 생성 버튼을 누른 후, 사용자 이름에 원하는 IAM 사용자 이름을 입력하세요!
    권한 정책에서 S3 액세스 권한을 부여하기 위해 AmazonS3FullAccess를 선택해주세요!
    모든 설정을 마치셨다면 그대로 생성하시면 됩니다 😎

4. 액세스 키 만들기

IAM 사용자에 대한 액세스 키를 생성해야 합니다!
액세스 키 만들기를 눌러주세요.

  • 사용 사례 선택
    해당 선택사항들은 누르게 되면 대안에 대한 설명을 띄워줍니다.
    어떤 선택지를 선택하고 넘어가든 상관없으니 원하는 선택지를 확인 후 '다음'을 눌러주세요!
    (저는 AWS 컴퓨팅 서비스에서 실행되는 애플리케이션을 선택했습니다😆)
    이후 설명 태그를 선택하게 되는데 선택 사항이므로 희망하시는 분에 한해 작성해주세요!
    이제 액세스 키 만들기 클릭!
  • 액세스 키 확인
    이제 생성된 액세스 키를 확인하실 수 있습니다.
    한번 확인한 액세스 키와 비밀 액세스 키는 다시 확인할 수 없으니 반드시 .csv 파일을 다운로드 받는 것을 권장합니다!🙏

이제 모든 과정이 끝났습니다!
Spring Boot 환경에서 직접 사용해보도록 해요🤗


5. AWS S3 Bucket에 이미지 업로드

  • build.gradle 추가

  • application.yml 설정
    yml파일에 S3 버킷의 이름을 적어주시고,
    앞서 생성했던 IAM의 accessKey와 secretKey를 입력해주세요!
    리전은 생성된 S3 버킷 정보를 보시면 알 수 있습니다🧐

  • S3Config.class
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {

    @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 awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

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

}

AWS S3에 엑세스하기위해 필요한 값들을 Configuration class를 통해 작성 및 활용합니다.

  • S3Service.class
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.PutObjectRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import yjhb.meeti.global.error.ErrorCode;
import yjhb.meeti.global.error.exception.BusinessException;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

    private final AmazonS3Client amazonS3Client;

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

    // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드
    public String upload(MultipartFile multipartFile, String dirName) throws IOException {

        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new BusinessException(ErrorCode.FILE_CONVERT_FAIL));

        return upload(uploadFile, dirName);
    }

    private String upload(File uploadFile, String dirName){

        String fileName = dirName + "/" + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);

        removeNewFile(uploadFile); // convert() 과정에서 로컬에 생성된 파일 삭제

        return uploadImageUrl;
    }

    private String putS3(File uploadFile, String fileName){

        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile) // PublicRead 권한으로 upload
        );

        return amazonS3Client.getUrl(bucket, fileName).toString(); // File의 URL return
    }

    private void removeNewFile(File targetFile){

        String name = targetFile.getName();

        // convert() 과정에서 로컬에 생성된 파일을 삭제
        if (targetFile.delete()){
            log.info(name + "파일 삭제 완료");
        } else {
            log.info(name + "파일 삭제 실패");
        }
    }

    public Optional<File> convert(MultipartFile multipartFile) throws IOException{

        // 기존 파일 이름으로 새로운 File 객체 생성
        // 해당 객체는 프로그램이 실행되는 로컬 디렉토리(루트 디렉토리)에 위치하게 됨
        File convertFile = new File(multipartFile.getOriginalFilename());

        if (convertFile.createNewFile()){ // 해당 경로에 파일이 없을 경우, 새 파일 생성

            try (FileOutputStream fos = new FileOutputStream(convertFile)) {

                // multipartFile의 내용을 byte로 가져와서 write
                fos.write(multipartFile.getBytes());
            }
            return Optional.of(convertFile);
        }

        // 새파일이 성공적으로 생성되지 않았다면, 비어있는 Optional 객체를 반환
        return Optional.empty();
    }
}
  • upload(상단) : 업로드할 multipartFile을 convert() 작업 후 전달
  • upload(하단) : 디렉터리 이름과 함께 파일 이름을 생성하고, putS3() 메서드를 통해 S3 버킷에 이미지를 업로드한 후 로컬에 생성된 파일은 제거하는 작업 수행
  • putS3 : 파라미터로 전달받은 파일을 경로에 맞게 S3 버킷에 업로드하는 작업 수행
  • removeNewFile : 로컬 프로젝트에 생긴 파일을 곧바로 지우는 작업 수행
  • convert : 전달받은 multipartFile을 byte로 가져와서 변환하고, 해당 값을 Optional로 전달하는 작업 수행

저는 유저의 정보를 update하면서 프로필 사진을 업로드 하는 것을 목표로 하고 있었으므로 UpdateController 안에서 구현하였습니다.

  • UpdateController.class
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import yjhb.meeti.domain.user.entity.User;
import yjhb.meeti.dto.user.UpdateDTO;
import yjhb.meeti.service.user.UpdateService;
import yjhb.meeti.global.jwt.service.TokenManager;
import yjhb.meeti.global.resolver.memberinfo.UserInfo;
import yjhb.meeti.global.resolver.memberinfo.UserInfoDto;
import yjhb.meeti.global.util.AuthorizationHeaderUtils;
import yjhb.meeti.service.user.UserService;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@Tag(name = "Update", description = "유저 정보 update API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/meeti/user")
public class UpdateController {

    private final UpdateService updateService;
    private final UserService userService;
    private final TokenManager tokenManager;

    @Tag(name = "Update User")
    @PostMapping(value = "/update/{userId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<Boolean> updateUser(@RequestPart("username")  String username,
                                                @PathVariable("userId") Long userId,
                                                @RequestPart(value = "image")  MultipartFile image) throws IOException {

        User findUser = userService.findUserByUserId(userId);

        updateService.updateUser(findUser.getId(), username, image);

        return ResponseEntity.ok(true);
    }
}
  • UpdateService.class
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import yjhb.meeti.domain.user.entity.User;
import yjhb.meeti.service.file.S3Service;
import yjhb.meeti.service.user.UserService;

import java.io.IOException;

@Service
@Transactional
@RequiredArgsConstructor
public class UpdateService {

    private final UserService userService;
    private final S3Service s3Service;

    public Long updateUser(Long userId, String username, MultipartFile image) throws IOException {
        User findUser = userService.findUserByUserId(userId);

        if (!image.isEmpty()){

            String profileImages = s3Service.upload(image, "profileImages");
            findUser.update(username, profileImages);
        }else findUser.update(username, null);

        return findUser.getId();
    }
}

6. Postman Test

  • Postman을 사용해 로컬에서 테스트 진행

  • S3 Bucket 확인
    잘 동작하는 모습을 확인할 수 있습니다🙌


글을 마치며..

과금으로 인해 두렵기만 했던 AWS의 서비스들을 프로젝트 진행을 통해 하나둘씩 사용해보고 있습니다.
시도해보는 서비스들이 정상 작동할 때 마다 이전에 있었던 두려움들은 자신감으로 변했고, 개발에 대한 즐거움과 흥미로움을 배로 증가시켜주는 것 같습니다.
가끔 어려운 취업의 벽에 막혀 힘이 들 때도 있지만 아직 풋내기인 저의 실력에 대해 냉정히 객관화를 하고 하루하루 발전하고자 노력하고 있으며, 이러한 모습을 기록하니 때론 즐거움에 웃음짓게 됩니다.🥹
이번 프로젝트를 성공적으로 마무리짓고 다른 경험도 많이 해보고 싶습니다.😎

profile
꾸준히 한 발씩 발전해나가는 모습을 기록합니다.

3개의 댓글

comment-user-thumbnail
2023년 10월 9일

잘 봤습니다
stream에 관한 글도 업로드해주세요

2개의 답글