Simple Storage Service(S3)

  • 온라인 오브젝트 스토리지 서비스
  • 데이터를 오브젝트 형태로 저장하는 서비스
  • 데이터 조작에 Http / Https를 통한 API가 사용됨
  • 객체 : S3에 저장되는 모든 데이터
  • 메타데이터 : name-value 쌍으로 이루어진 최종 수정일, 파일 타입 등의 데이터
  • 버킷에 존재하는 모든 객체는 단 하나의 key를 가지며, 이 key를 통해 식별이 가능함

Bucket & Object

  • Bucket : 객체를 저장하고 관리하는 역할
  • Object : 데이터와 메타데이터를 구성하고 있는 저장 단위
    출처

S3 Region

  • S3가 생성한 버킷을 저장할 위치
  • region을 어디에 지정하느냐에 따라 비용과 시간이 달라짐

S3 Bucket 구성

  • Amazon S3에서 생성되는 최상위 디렉토리이며, 객체의 컨테이너임
  • S3상의 모든 객체는 버킷에 포함됨
  • 버킷의 이름은 S3 내에서 유일해야 함 -> 전 세계 어디에서도 중복된 Bucket이 존재할 수 없음
  • 버킷 안에 버킷을 둘 수 없으며, 버킷 소유권 또한 이전할 수 없음
  • 버킷 주소 : https://bucketname.s3.Region.amazonaws.com 형태

S3 객체 구성

  • Key : 파일의 이름
    • 버킷 내 객체의 고유한 식별자
    • 버킷 내 객체는 정확히 하나의 키를 가짐
    • 버킷 + 키 + 버전 ID로 각 객체를 고유하게 식별
  • Value : 파일의 데이터
    • 폴더 개념처럼 경로로 url을 구성함(/~/~/~.png 와 같은 형식으로 구성된다는 의미) -> 폴더 이름에 '/'를 포함할 수 없음
  • Version Id : 파일의 버전 아이디
    • 같은 파일이지만 다른 버전으로 올릴 수 있게 돕는 인식표
    • 이전 버전으로 돌아가려면 Version Id 값만 변경해 주면 됨
  • MetaData : 파일의 정보를 담은 데이터
    • 최종 수정일, 파일 타입, 파일 소유자, 사이즈 등
  • ACL : 파일의 접근, 수정 등의 권한을 담은 데이터
  • Torrents : 토렌트 공유를 위한 데이터
  • CORS : 지역을 무시하고 다른 버켓에서 다른 버켓의 파일을 접근할 수 있도록 하는 기능

Bucket Policy 설정

  • 버킷을 사용할 권한을 가진 여러 명의 사용자 별로 각각의 행위에 대한 권한 범위를 설정할 수 있는 것
  • 버킷 안의 파일 하나하나에 대한 권한 설정은 불가능함
  • JSON 형식으로 이루어짐
            {
      "Id": "Policy1718072787670",
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "Stmt1718072786495",
          "Action": "s3:*",
          "Effect": "Allow",
          "Resource": "arn:aws:s3:::chukahaeyo/*",
          "Principal": "*"
        }
      ]
    }

만약 다음과 같은 에러가 뜬다면

퍼블릭 액세스 차단을 모두 비활성 해 주면 된다. 이 문제를 해결하기 위하여 IAM에 사용자를 추가하고, 역할 부여도 하는 식으로 많이 헤맸었다 ㅠㅠ IAM 사용자에 s3의 모든 권한을 추가하였는데도 정책이 추가되지 않았고, 퍼블릭 액세스 차단 설정과 충돌한다는 에러 메세지를 보고 혹시나 하는 마음에 모든 퍼블릭 액세스 차단을 '비활성'화 하였더니 해결되었다.

내가 만든 정책은 '모든 사용자가 접근 가능하도록 함'이라는 액세스 수준이었고, 초반에 버킷을 생성하였을 때의 버킷 정책은 '퍼블릭 액세스 차단'을 해 두었기 때문에 두개가 충돌나는 문제였다.

아래의 사진처럼 퍼블릭 액세스 차단을 비활성 하니 정책이 잘 추가되었다.

파일 업로드

버킷을 생성하고, 정책을 모두 설정해 주었으면 파일을 업로드 해 주면 된다.

이제 이 버킷이 컴퓨터로 따지면 '폴더'가 되는 것이다. 버킷에서의 주소값을 이용하여 사진을 불러 다운받아 사용할 수 있는 것이다.


객체 URL 복사 버튼을 누르면 다음과 같은 URL이 보이는데, 이걸 인터넷으로 접속하면 내가 업로드 한 사진이 제대로 뜨는 것을 알 수 있다.
https://chukahaeyo-bucket.s3.ap-northeast-2.amazonaws.com/chicken_icon.png

IAM에서 Key 발급

이제 spring을 통해서 S3에 접근해 사진을 사용할 것이다. 그러려면 key가 있어야 하므로, 'IAM > 사용자'에 들어가 사용자 생성을 해 준다.

이후 이름을 입력하고, 직접 정책 연결을 눌러 S3의 모든 권한을 부여해준다.

그 다음 AmazonS3FullAccess로 모든 권한을 부여해준다.

마지막으로 사용자 생성을 눌러준 후 다시 사용자에 들어가면 내가 만든 사용자가 뜬 것을 확인할 수 있다. 나는 팀 명이 '축하해요'이므로 사용자의 이름도 'chukahaeyo'로 설정해 주었다.

이 때 발급받은 key(AWS IAM 사용자 액세스 키 & 비밀 액세스 키)는 창을 닫으면 볼 수 없으므로 바로 어딘가에 복사해 두는 것이 좋다.

Config가 잘 연결 되었는지 test

jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<body>
<h1>Chicken Icon</h1>
<img src="https://chukahaeyo-bucket.s3.amazonaws.com/chicken_icon.png" alt="Chicken Icon" />
</body>
</html>

Config

package com.choikang.chukahaeyo.s3;

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.accessKey}")
    private String accessKey;

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

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

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

Controller

package com.choikang.chukahaeyo.s3;

import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class S3Controller {
    @GetMapping("/s3")
    public String s3Test(){
        return "/s3/s3Test";
    }
}

이렇게 코드를 작성한 후 localhost~/s3로 접속하면 치킨 사진이 나와야 한다.
아래 실행 결과를 보면 s3에 잘 접속되어 사진을 불러오는 것을 알 수 있다.

처음에는 프로젝트를 배포하면 프로젝트에 필요한 모든 이미지들을 전부 S3 버킷에 올리고, 거기서 불러와서 사용해야 하는 줄 알았다. 현재는 저장된 경로에서 이미지를 불러오고, 이 경로는 로컬에서 불러와진다고 생각하였기 때문이다. 하지만, Intellij 파일 자체에 이미지들을 넣어놓고 이 이미지 자체를 배포해버리면 굳이 S3에 저장할 필요가 없다. 대신, 배포 후 사용자가 이미지를 업로드 하는 것을 S3에 저장해야 하는 것이다.

이제 사용자가 이미지를 업로드하면, 이것을 S3에 저장하고, 저장된 URL을 DB에 넣는 로직을 작성할 것이다.

이미지 업로드 구현

  1. 프론트에서 업로드 된 파일의 이름을 받아온다
  2. 받아온 이름을 S3에 업로드 한다
  3. 업로드 한 곳의 링크를 DB에 저장한다

1. 프론트에서 업로드 된 파일의 이름 받아오기
우선 S3만 따로 기능을 빼서 test하는 것이므로 html에 버튼을 만들어 준다.

s3Test.jsp

<form action="upload" method="post" enctype="multipart/form-data">
    Select image to upload:
    <input type="file" name="file" id="file">
    <input type="submit" value="Upload Image" name="submit">
</form>

이후 파일이 업로드되면 파일 명이 들어가고, 이 파일 명이 백엔드로 전송되어지는 과정을 확인하기 위하여 controller를 작성해 주었다.

S3Controller

    @PostMapping("/upload")
    public void fileUpload(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) {
        System.out.println("업로드된 파일 이름: " + file.getOriginalFilename());
    }

하지만, 다음과 같은 에러가 발생하였다.

org.springframework.web.servlet.DispatcherServlet - Failed to complete request: org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.lang.IllegalStateException: 어떤 multi-part 설정도 제공되지 않았기 때문에, part들을 처리할 수 없습니다.

multi-part 설정을 처음에는 property에 파일 크기만 설정해 주면 된다고 생각하고, 그렇게 하였지만 여전히 같은 에러가 발생하였다. mvn repostiory에서 검색해보니 의존성을 따로 추가해 주는 것도 아닌 것 같았다. Bean을 추가 해 주면 될 것 같아 MvcConfig에서 Bean을 추가 해 줌과 동시에 파일의 최대 용량을 지정해 주었더니 실행이 되었고, 서버로 정보가 넘어오는 것을 볼 수 있었다.

이제 이 파일을 S3에 업로드 해 주면 된다.

단, 이 때 버킷명이 유출되면 요금 폭탄을 맞을 수 있으므로 버킷명도 환경 변수에 넣어 주어야 한다. 현재 S3 에는 버킷에 업로드가 실제로 되지 않고 업로드 시도를 하기만 해도 요금이 부과되는 버그가 있기 때문이다. 그래서 상대방의 버킷명을 알기만 해도 요금이 많이 나오도록 공격 할 수 있기 때문에, 버킷명을 숨겨 주어야 한다.

2. S3에 업로드하기
S3Controller

@PostMapping("/upload")
    public void fileUpload(@RequestParam("file") MultipartFile file) {
        System.out.println("업로드된 파일 이름: " + file.getOriginalFilename());

        if (file.isEmpty()) {
            throw new CustomException(ErrorCode.VALIDATION_REQUEST_MISSING_EXCEPTION, "업로드 할 파일이 선택되지 않았습니다.");
        }

        try {
            String fileUrl = s3Service.saveFile(file);
            System.out.println("컨트롤러 fileUrl : " + fileUrl);

            if(s3Service == null){
                System.out.println("S3에서 받아온 주소 값이 null입니다.");
            }
            
            System.out.println(fileUrl);
        }catch(CustomException e){
           throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "S3 bucket에 사진을 저장하는 것을 실패했습니다.");
        }
    }

S3Service

@Service
@RequiredArgsConstructor
public class S3Service {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;
    private final AmazonS3 amazonS3;

    // 파일 유효성 검사
    private String getFileExtension(String fileName) {
        if (fileName.length() == 0) {
            throw new CustomException(ErrorCode.NOT_FOUND_IMAGE_EXCEPTION, ErrorCode.NOT_FOUND_IMAGE_EXCEPTION.getMessage());
        }
        ArrayList<String> fileValidate = new ArrayList<>();
        fileValidate.add(".jpg");
        fileValidate.add(".JPG");
        fileValidate.add(".jpeg");
        fileValidate.add(".JPEG");
        fileValidate.add(".png");
        fileValidate.add(".PNG");
        fileValidate.add(".webp");
        fileValidate.add(".WebP");
        fileValidate.add(".heif");
        fileValidate.add(".HEIF");
        fileValidate.add(".heic");
        fileValidate.add(".HEIC");
        fileValidate.add(".svg");
        fileValidate.add(".SVG");
        String idxFileName = fileName.substring(fileName.lastIndexOf("."));
        System.out.println("idxFileName : " + idxFileName);
        if (!fileValidate.contains(idxFileName)) {
            throw new CustomException(ErrorCode.VALIDATION_IMAGE_REQUEST_FAILED, ErrorCode.VALIDATION_IMAGE_REQUEST_FAILED.getMessage());
        }
        return fileName.substring(fileName.lastIndexOf("."));
    }

    //파일을 S3 bucket에 업로드
    public String saveFile(MultipartFile file){
        System.out.println("컨트롤러에서 받아온 file명 : " + file);
        String fileName = createFileName(file.getOriginalFilename());
        System.out.println("서버에서 생성한 파일 이름 : " + fileName);
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(file.getSize());
        metadata.setContentType(file.getContentType());

        try{
            amazonS3.putObject(bucket, fileName, file.getInputStream(), metadata);
        } catch(SdkClientException e){
            System.out.println("AWS SDK 클라이언트에서 문제 발생");
            throw new CustomException(ErrorCode.NOT_FOUND_IMAGE_EXCEPTION, "AWS SDK 클라이언트에서 문제가 발생하였습니다.");
        } catch (IOException e){
            System.out.println("파일 업로드 중 문제 발생");
            throw new CustomException(ErrorCode.NOT_FOUND_IMAGE_EXCEPTION, "AWS에서 파일 업로드 중 문제가 발생하였습니다.");
        }
        System.out.println("파일 업로드 성공");
        System.out.println("업로드한 파일 이름 : " + fileName);

        return amazonS3.getUrl(bucket, fileName).toString(); //S3에 저장된 URL을 갖고 오는 로직
    }

    //파일 이름 중복 방지를 위한 파일명 생성
    private String createFileName(String fileName) {
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
    }
}

이 과정도 매우 험난했다 ... 처음에 Controller에서 Service가 호출되지 않는 문제가 발생했었다. Service의 빈이 제대로 주입되지 않는 문제였었다. Service 어노테이션도 붙였는데 말이다. 알고보니 Controller에서 @Autowired를 해 주지 않아서 발생한 문제였다.. 오랜 시간을 투자했는데, 정말 의외의 곳에서 에러를 찾아서 허무했다...

Service에 접근이 가능했지만, 파일을 저장할 수 없는 문제가 발생하였다. print를 찍어 보았는데, 분명 컨트롤러에서는 "pizza.png"와 같은 String값으로 넘겼지만 서버에서 출력 될 때는 이상한 문자열로 출력이 되는 문제였다. 나는 객체를 String으로 변환하기 위하여 toString() 문자열을 썼었는데, 이렇게 되면 원본 url이 출력이 되는 것이 아닌 것이다. file.getOriginalFilename()을 써야 원본 url을 받아올 수 있다. 이후 바꾸어 출력을 해 주니 제대로 받아와짐을 알 수 있었다.


여기서 파일을 선택하고 Upload Image 버튼을 누르면 서버에는 다음과 같이 출력이 된다.

fileUrl을 누르면 의도한 사진이 잘 뜨는 것을 알 수 있다.
https://chukahaeyo-bucket.s3.ap-northeast-2.amazonaws.com/45c065b8-d869-463d-832b-31809be0cab2.png

또한, bucket에도 제대로 올라가 있음을 알 수 있다.

3. S3의 링크를 DB에 저장하기
현재 시점까지의 템플릿이다.

여기서 사진만 S3 bucket에 업로드되고, 이름, 날짜, 문구, 이모티콘은 DB에 저장이 되어야 한다. 현재 DB에 업로드되는 부분은 현재 프론트에서 테스트를 진행하며 수정중이고, 사진 업로드만 먼저 구현이 가능한 상황이었다. 그래서 나는 '장바구니 담기, 결제하기' 버튼을 누르면 다음 두 가지의 동작이 이루어지도록 로직을 작성했었다.

  1. 파일을 S3 bucket에 업로드
  2. 나머지 값 DB에 저장

우선 1번 작업부터 수행하던 중, 문득 '결합도가 높다'는 생각이 들었다. 왜냐하면 사진을 저장하려는 각 jsp마다 ajax문을 써 주어야 하고, 컨트롤러를 다시 호출해 주어야 하기 때문인데다가 컨트롤러도 DB 저장용과 S3 저장용 두 개를 호출해야 하기 때문이다.

하나의 컨트롤러를 만들어 프론트에서 받아온 필수 항목들을 전부 받아오고, 이 안에서 S3 service를 호출하여 S3에 저장하는 로직을 따로 호출하고 DB에 저장하는 로직도 따로 호출해야 하나의 컨트롤러만 사용하고, 결합도가 낮아진다고 판단하였다.

업로드한 이미지 삭제

다음 과정대로 진행하면 된다.
현재 로직 : saveFile을 수행하면 프론트에 S3에 저장된 파일명을 넘겨줌(서버에서 생성한 중복을 방지하기 위한 파일명) -> 이 파일명이 html에 저장되어 있음. 이 값을 삭제 요청을 할 때 String값으로 넘겨줌
1. 받아온 파일명을 이용하여 삭제 요청을 보냄

S3 Service

    public void deleteFile(String fileName){
        System.out.println("받아온 삭제할 파일명 : " + fileName);
        amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
    }

S3 Controller

 @PostMapping("/delete")
    public ResponseEntity<String> fileDelete(String fileName) {
        if (fileName.isEmpty()) {
            throw new CustomException(ErrorCode.VALIDATION_REQUEST_PARAMETER_MISSING_EXCEPTION, ErrorCode.VALIDATION_REQUEST_PARAMETER_MISSING_EXCEPTION.getMessage());
        }
        try {
            s3Service.deleteFile(fileName);
        } catch (CustomException e) {
            throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "S3 bucket에서 사진을 삭제하는 것을 실패했습니다.");
        }
        return new ResponseEntity<>(SuccessCode.DELETE_SUCCESS.getMessage(), SuccessCode.DELETE_SUCCESS.getHttpStatus());
    }

S3 Test

@Log4j
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {config.MvcConfig.class})
@WebAppConfiguration
@Slf4j
public class S3Test {
    @Autowired
    S3Service s3Service;

    @Test
    public void cancelTest(){
        s3Service.deleteFile("d26e29fe-fb0b-4807-bb1d-2ea664aa0094.png");
    }
}

결제 취소 매개변수를 찾기 위하여 우선 AmazonS3 Interface에 들어가 취소 부분을 보았다.

여기에서 var1과 var2값이 무엇인지를 몰라 AmazonS3Client에 들어가 보았더니 다음과 같이 쓰여져 있었다.

여기서의 bucketName은 s3에서 설정해 준 bucket의 이름 값을 넣으면 되고, 이는 환경 변수로 설정을 한 후 전역변수로 선언해 두었다. String key값은 Bucket에 들어가 보이는 key값인데, 이는 파일명과 동일하므로 파일명을 넘겨주면 된다.

이후 S3 버킷에 들어가 확인해 보면 삭제가 제대로 수행된 것을 알 수 있다.

profile
컴퓨터가 이해하는 코드는 바보도 작성할 수 있다. 사람이 이해하도록 작성하는 프로그래머가 진정한 실력자다. -마틴 파울러

0개의 댓글