[2022 하계 모각코] Spring boot React S3를 활용한 이미지 업로드

Kyunghwan Ko·2022년 8월 15일
3

22년도 하계 모각코

목록 보기
9/13

S3

S3(Simple, Storage, Service)란

S3는 AWS(Amazon Web Service)에서 제공하는 인터넷 스토리지 서비스입니다.
S3(Simple Storage Service) 를 뜻합니다.

EC2에 이미지를 직접적으로 업로드하게 되면 상당한 비용이 부과되기 때문에 이를 방지하기 위해 S3에 이미지를 직접 업로드하고 해당 업로도된 경로인 URL를 EC2에 저장해서 접근하는 방식으로 사용해보도록 하겠습니다.
(S3는 비교적 저렴한 비용과 쉬운 사용법으로 많이 사용됩니다.)

S3 버킷 생성

검색상자에서 S3를 검색한 다음 버킷 만들기 클릭


만약, 실무에서 사용할 경우에는 모든 엑세스 차단 혹은 ACL을 이용하여 액세스 차단해주는 것이 보안에 좋습니다.

보안상 기본 암호화도 활성화를 하는게 좋지만 기본 생성 후 테스트를 위해 비활성화 해둡니다.

서버측 암호화를 위한 참고자료
https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/serv-side-encryption.html

고급설정에서도 객체잠금은 비활성화로 유지한 채
하단의 버킷만들기 클릭해서 버킷을 생성해줍니다.

파일 업로드

생성된 버킷에 이미지를 업로드해보고 정상적으로 볼 수 있는지 확인해보기 위해 이미지를 테스트삼아 업로드해보겠습니다.

생성된 버킷을 클릭해서 우측상단에 있는 업로드 버튼을 클릭합니다

업로드 -> 파일/폴더 추가 -> 파일선택 -> 업로드


업로드된 이미지를 확인하기 위해

이름 클릭 -> 객체 URL클릭

아래와 같은 Access Denied 에러가 발생하는 것을 볼 수 있습니다.

This XML file does not appear to have any style information associated with it. The document tree is shown below.

<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
<RequestId>K4008EFYKX474Y4A</RequestId>
<HostId>yW7a5dfYCovyBhtlr+vE8tX3GC5cCyF69hBhsT7Ls8DdCOEGs/gnXhttJZcgMOUiHKNbNZcfghtX0GcX2kVKYg==</HostId>
</Error>

퍼블릭 엑세스 차단 수정

권한 -> 퍼블릭 엑세스 차단 -> 편집 -> 체크 모두 해제 -> 변경사항 저장


여기서 현재 선택되어 있는 객체탭 말고 권한탭을 선택해줍니다.

퍼블릭 엑세스 차단(버킷 설정)에서 편집버튼을 클릭해줍니다


위 사진과 같이 모든 체크를 해제해준 뒤 우측하단의 변경사항 저장을 클릭해줍니다.

나온 팝업창에 설정 변경을 확인한다는 의미로 확인입력해준 뒤 우측하단의 확인 버튼 클릭

버킷 정책 편집

권한 -> 버킷정책 -> 편집 -> 버킷정책 -> 버킷ARN복사 후 정책 생성기 클릭

앞서 설명드린 것처럼 권한 탭 누른 상태에서 퍼블릭 엑세스 차단(버킷 설정)에서 스크롤 내리면

이와 같이 버킷 정책을 볼 수 있는 영역이 나오고 여기서 우측의 편집 버튼을 눌러줍니다.

빨간 네모 1번에서 글자 부분 말고 왼쪽에 종이 두장 겹쳐져 있는 것 같은 버튼을 누르면 자동으로 해당 텍스트가 복사됩니다.
이후 빨간 네모 2번을 클릭해줍니다.

버킷 정책 생성(1)

입력 필드입력 값
Select Type of PolicyS3 Bucket Policy
Principal*
Actions* (All Actions를 체크)
Amazon Resources Name(ARN){복사한ARN}/*


복사한 ARN을 입력해주고 뒤에 /* 을 붙여줍니다.

위 사항들을 다 입력하고 나면 Add Statement 버튼을 클릭한 후

입력한 내용이 맞는지 확인한 다음 Generate Policy을 클릭하면

위와 같이 팝업창이 뜨는데 json부분을 드래그해서 복사해둡니다.
복사한 후 이전 브라우저 창의 버킷 정책편집하는 영역에 붙여넣어줍니다.

이후 우측 하단의 변경사항 저장 버튼을 클릭해줍니다.

만약 알수없는 오류가 발생한다면 다시 Generate Policy해서 새로 팝업된 json을 다시 복사후 붙여넣어 보시기 바랍니다.

업로드한 이미지 재확인

상단의 서비스 검색기능등을 통해서 S3 로 가서 아까 권한 설정 변경한 버킷을 클릭해서 보면 아래와 같이 퍼블릭 액세스 가능이라고 문구가 떠 있을 것입니다.



업로드한 이미지 이름을 클릭해서 객체URL을 클릭해보면 업로드한 이미지가 정상적으로 잘 보이는 것을 확인할 수 있습니다.

IAM 계정 Access Key, Secret Key 발급받기

S3에 접근하기 위해서는 IAM 사용자에게 S3 접근 권한을 줘야합니다.
IAM 서비스 > 사용자 > 사용자 추가로 이동해서 사용자 이름을 입력하고 엑세스키 유형을 선택해줍니다. 권한은 기존 정책 > AmazonS3FullAccess를 선택해 S3 권한이 있는 사용자를 추가합니다.

IAM 계정이 없다면 아래의 사이트를 참고해서 IAM계정을 만들어서 액세스 키를 발급받습니다.
AWS계정 및 액세스키 발급받기




※ 액세스 키 발급받는 사이트에서도 언지하고 있지만 해당 IAM 액세스키를 발급받고 애플리케이션에 입력후 깃에 푸시할 때 꼭 .gitignore파일로 정보를 입력한 파일이 깃에 업로드 되지 않도록 잘 유지해야합니다.

Spring Boot에서 S3 버킷 연동

보안 정보 관리

깃에 올릴 때 민감한 정보를 제외하고 push하기 위해서 별도의 파일을 만들어 민감정보를 저장한 후 .gitignore파일에 추가해서 깃에 올라가지 않고 안전하게 로컬에서 민감정보를 유지할 수 있게 해보겠습니다.

애플리케이션 동작할때 S3에 이미지를 업로드하고 접근하기 위해선 앞서 발급한 액세스 키, 시크릿 키를 입력해줘야 합니다.

application-credentails.yml파일을 생성한 이후 아래와 같이 정보를 입력해줍니다.
(application-credentails.yml은 application.yml과 동일한 경로에 위치합니다)

# src/main/resources/application-credentials.yml
cloud:
  aws:
    credentials:
      access-key: {발급받은 액세스 키}
      secret-key: {발급받은 시크릿 키}

이후 application-aws.yml 파일에 아래 내용중 cloud 에 해당하는 내용을 추가해줍니다.

# src/main/resources/application-aws.yml
spring:
	datasource: ~{RDS 정보}~
    
cloud:
  aws:
    s3:
      bucket: {버킷 이름}
    region:
      static: ap-northeast-2
    stack:
      auto: false

그리고 위 설정을 application.yml에 적용시켜 주겠습니다.

# src/main/resources/application.yml
spring:
  profiles:
    include:
      - aws
      - credentials
servlet:
  multipart:
    enabled: true
    max-file-size: 20MB
    max-request-size: 20MB

이후 마지막으로 새로 생성한 application-credentials.yml 파일을 깃에 푸시하지 않기 위해
.gitignore파일을 수정합니다.

...

application-aws.yml
application-credentials.yml

...

절대경로 나 참조경로 없이 파일이름만 적어도 나중에 깃에서 푸시할때 정상적으로 무시되고 푸시되는 것을 확인할 수 있습니다.

git add .
git status

위 명령어를 진행했을 때 .gitignore에 추가한 파일이 stage에 올라가있다면,

git rm -r --cached . 
git add . 

위 명령어를 통해 cache를 비우고 다시 add -> status로 .gitignore가 잘 적용됬는지 확인해보시기 바랍니다

코드 구현

dependency

Maven Repository 사이트에서 각자 프로젝트 빌드 라이브러리에 맞게 dependency를 추가합니다.

dependencies{
	...
    implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.6.RELEASE'
}

폴더 구조

├─api
	...
│  	   UserApiController.java
├─config
	...
│  	   S3Config.java
├─domain
	...
│      User.java
│
├─repository
	...
│      UserRepository.java
│
└─service
        S3Uploader.java

위에 존재하는 파일들을 통해 S3업로드 기능을 구현해보겠습니다.

config

package team_project.beer_community.config;


import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
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();
    }
}

S3Uploader.java - 파일 업로드 클래스 생성

파일을 올릴 클래스를 생성합니다. 업로드할때 파일이 로컬에 없으면

Unable to calculate MD5 hash: [파일명] (No such file or directory)

위와 같은 에러가 발생하기 때문에,
convert로 입력받은 파일을 로컬에 저장하고 upload로 S3 버킷에 업로드하게 됩니다.

package team_project.beer_community.service;

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

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import team_project.beer_community.domain.User;
import team_project.beer_community.repository.UserRepository;



@Service
@Slf4j // log찍기 위함
@RequiredArgsConstructor
public class S3Uploader {

    private final AmazonS3Client amazonS3Client;
    private final UserRepository userRepository;

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

    @Transactional
    public FileUploadResponse uploadFiles(Long userId, MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("Error: MultipartFile -> File로 전환이 실패했습니다."));
        return upload(userId, uploadFile, dirName);
    }


    @Transactional
    public FileUploadResponse upload(Long userId, File uploadFile, String filePath) {
        String fileName = filePath + "/" + userId + uploadFile.getName(); // S3에 저장된 파일 이름
        String uploadImageUrl = putS3(uploadFile, fileName); // S3로 업로드
        log.info("uploadImageUrl = " + uploadImageUrl);
        removeNewFile(uploadFile);

        //사용자의 프로필을 등록하는 것이기때문에, User 도메인에 setImageUrl을 해주는 코드.
        //이 부분은 그냥 업로드만 필요하다면 필요없는 부분이다.
        User user = userRepository.findById(userId).orElseThrow(NullPointerException::new);
        user.setImageUrl(uploadImageUrl); // dirtyChecking으로 변경사항 DB반영

        //FileUploadResponse DTO로 반환해준다.
        return new FileUploadResponse(fileName, uploadImageUrl);
    }

    // S3로 업로드
    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(
                CannedAccessControlList.PublicRead));
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    // 로컬에 저장된 이미지 지우기
    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    // 로컬에 파일 업로드 하기
    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile =  new File(System.getProperty("user.dir") + "/" + file.getOriginalFilename());
        if(convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }


}

DB에 변경사항이 발생하는 함수인 upload(){ user.setImageUrl(uploadImageUrl); }와 이를 호출한 uploadFile() 함수에는 @Transactional 어노테이션을 사용해서 Dirty Checking을 통해 영속성 context와 스냅샷의 차이를 DB에 반영하는 update 쿼리가 나가도록 구현합니다.

어떤 @Transactional을 사용할까? javax vs spring

UserController.java

@RestController
@RequiredArgsConstructor
public class UserController{

    private final S3Uploader s3Uploader;
    ...
    @PostMapping("/api/user/{user_id}/imageUrl")
    public ResponseEntity<?> uploadProfilePhoto(
            @PathVariable("user_id") Long userId,
            @RequestParam("profilePhoto") MultipartFile multipartFile) throws IOException {
        //S3 Bucket 내부에 "/profile"
        System.out.println("UserApiController.uploadProfilePhoto");
        System.out.println("userId = " + userId); // 1
        System.out.println("multipartFile = " + multipartFile); // org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@606b1f72
        try{
            FileUploadResponse profile = s3Uploader.uploadFiles(userId, multipartFile, "profile");
            System.out.println("profile = " + profile);
            return ResponseEntity.ok(profile);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
        }
    }
}

PostMan에서 API 테스트

현재 id 1인 사용자가 가입되어있을 때,

위와 같이 별도의 Headers 설정없이 POST / form-data 형식으로 해서 Key값을 profilePhoto, Value를 업로드할 파일을 선택해서 Send하면 200 OK 응답과 함께 S3로 업로드된 파일경로가 나올 것입니다.

그리고 그 경로가 text형태로 DB에 잘 저장된 것을 확인할 수 있습니다.

에러

로컬환경에선 잘 진행되다가 배포환경에서 권한문제(Permission Denied)가 발생할 경우 아래의 블로그를 참고해보시기 바랍니다!
https://percyfrank.github.io/springboot/S301/

참조

Springboot-AWS-S3로-파일-저장소-연동하기

Front-End code(사용자 프로필이미지 업로드)

profile
부족한 부분을 인지하는 것부터가 배움의 시작이다.

0개의 댓글