이번에 프로젝트를 마무리하고 AWS를 통해 배포하면서 S3를 같이 사용해보게 되었는데 솔직히 시작할때는 S3 Bucket 생성과 보안 같은 설정 말고는 별거 있겠나 했는데 역시 사용해봐야 한다는 것을 느낄 수 있었다...

 

환경 먼저 정리.

Backend - Spring Boot 3

FrontEnd - React

 

S3 Bucket 생성과 연동에 대해서는 따로 정리했다. 여기서는 기본 설정 클래스까지 모두 처리했다고 가정하고 정리.

https://myyoun.tistory.com/231

 

SpringBoot & React AWS 배포 테스트 1)S3 Bucket 로컬 테스트

정리 목적이번에 AWS에 배포 테스트를 하며 UI가 변경된 부분도 좀 있었고 이전에는 도메인 구매 후 접근 테스트만 해본 반면 이번에는 여러 환경을 설정하고 처리했기 때문에 이전 정리 내용을

myyoun.tistory.com

 

 

S3에 파일 저장과 삭제

S3에 파일을 저장하는 것은 여러 블로그에 나와있기도 하고 어렵지 않게 처리할 수 있었다.

//serviceImpl
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;

@Service
@RequiredArgsConstructor
public class TestServiceImpl implements TestService {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    private final AmazonS3Client amazonS3Client;
    
    //save file
    public String imageInsert(MultipartFile image) {
        StringBuffer sb = new StringBuffer();
        String saveName = sb.append(new SimpleDateFormat("yyyyMMddHHmmss")
                        .format(System.currentTimeMillis()))
                .append(UUID.randomUUID().toString())
                .append(
                    image
                      .getOriginalFilename()
                      .substring(
                        image.getOriginalFilename().lastIndexOf(".")
                      )
                )
                .toString();
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(image.getSize());
        objectMetadata.setContentType(image.getContentType());
        
        try{
            amazonS3.putObject(
                new PutObjectRequest(
                    bucket
                    , saveName
                    , image.getInputStream()
                    , objectMetadata
                )
                .withCannedAcl(CannedAccessControlList.PublicRead)
            );
        }catch (Exception e) {
            e.printStackTrace();
        }
        
        //아래 코드를 통해 저장한 파일의 url을 바로 받아볼 수 있다.
        log.info("TestServiceImpl.imageInsert :: imageUrl : {}", 
                    amazonS3Client.getUrl(bucket, saveName))
        );
        
        return saveName;
    }
    
    //delete file
    public void deleteImage(String imageName) {
        amazonS3.deleteObject(
            new DeleteObjectRequest(bucket, imageName)
        );
    }
}

 

대부분의 S3 연동에 대한 포스팅은 이정도 코드로 마무리가 되었다.

 

조회도 궁금한데.........

 

그래서 좀 여러 방면으로 찾아보기도 하고 ChatGPT의 도움도 받았다.

 

찾아본 결과 이미지를 출력하는 방법은 총 3가지가 있었다.

 

1. amazonS3Client.getUrl()을 통해 받은 url을 통해 출력

2. PresignedUrl()을 통한 출력

3. Backend 서버를 Proxy 서버로서의 역할을 하도록 해 다운로드 받은 뒤 클라이언트에게 반환해 출력.

 

 

각 방법의 처리 방법

가장 먼저 url을 전달하는 방법이다.

저장 후 getUrl()을 통해 받은 url을 데이터베이스에 저장해뒀다가 반환한다.

하지만 이 방법은 아주 큰 문제가 있다.

이 Url을 통한 접근은 사실상 S3 Bucket 객체에 직접 접근하는 것과 같다고 한다.

그래서 S3 Bucket이 Public으로 설정되어있는 경우에만 접근이 가능해진다.

솔직히 그런 경우가 있을지는 모르겠지만 Bucket을 Public으로 설정하고 누구나 접근할 수 있도록 하는 경우가 있고 그 접근에 대해 보안상 문제가 발생하지 않을만한 서비스라거나 하면 괜찮겠지만 아무래도 문제가 발생할 여지가 너무 많은 방법이다.

간단하게 설명하면 이미지만 출력하는 수준이 아닌 S3 Bucket 객체에 직접 접근할 수 있는 URL을 클라이언트에게 제공한다는 의미가 된다.

그래서 이 방법은 사실상 사용할 수 없는 방법이지 않나 라는 생각을 했다.

 

 

두번째 방법.

PresignedURL을 사용하는 방법이다.

PresignedUrl은 서버에서 설정한 일정 기간동안 접근할 수 있는 임시 URL 같은 개념이다.

서버에서 만약 1분을 설정했다면 1분 이후에는 해당 URL이 만료되어 접근이 불가능해진다. 그렇기 때문에 Bucket 객체 URL을 직접 전달하는 것 보다 보안이 강화된다.

PresignedURL 역시 Bucket 객체에 직접 접근한다는 개념이긴 하지만 유효기간동안만 유효하기 때문에 좀 더 좋다 라는 느낌이다.

그리고 PresignedURL은 AWS 자격 증명으로 서명되기 때문에 접근은 가능하지만 무단으로 생성하거나 변경을 할 수는 없다고 한다.

 

그럼 이 PresignedURL의 생성 방법과 구조를 정리한다.

//serviceImpl
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;

@Override
public URL getSignedUrl(String imageName) {
    Date expiration = new Date();
    long expirationTime = expiration.getTime();
    expirationTime += 1000 * 60 * 1 //1 minute
    expiration.setTime(expirationTime);
    
    GeneratePresignedUrlRequest generatePresignedUrlRequest = 
        new GeneratePresignedUrlRequest(bucket, imageName)
            .withMethod(HttpMethod.GET)
            .withExpiration(expiration);
    
    return amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
}

 

클라이언트에서는 이 URL을 img src에 넣어주면 정상적으로 출력된다.

 

그럼 이때 생성되는 URL의 구조는 아래와 같다.

 

https://버킷명.s3.리전명.amazonaws.com/파일명.확장자명
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=YOUR_ACCESS_KEY%2F20240717%2Fap-northeast-2%2Fs3%2Faws4_request
&X-Amz-Date=20240717T123456Z
&X-Amz-Expires=3600
&X-Amz-SignedHeaders=host
&X-Amz-Signature=SIGNATURE

 

쿼리 매개변수를 정리하자면 아래와 같다.

  • X-Amz-Algorithm
    • 서명에 사용된 알고리즘을 나타낸다.
  • X-Amz-Credential
    • 요청을 인증하기 위해 사용된 AWS 자격 증명을 나타낸다.
    • accessKey  date  region  s3  aws4_reqeust 구조
  • X-Amz-Date
    • 요청이 생성된 날짜와 시간을 나타낸다.
    • YYYYMMDDTHHmmssZ 구조
  • X-Amz-Expires
    • PresignedURL의 유효기간을 초 단위로 나타낸다.
    • 위 예시에서는 1분으로 설정했기 때문에 3600으로 나온다.
  • X-Amz-SignedHeaders
    • 서명에 포함된 헤더 목록을 나타낸다.
  • X-Amz-Signature
    • 요청의 서명을 나타낸다.
    • 이 서명은 AWS Secret Access Key를 사용해 생성한다.

 

이렇게 보면 여기에도 꽤 많은 정보가 포함된다.

생성, 변경이 안된다고 하더라도 굳이 이런 정보를 노출시켜야 할까 싶은 정보들이다.

 

개인적으로는 불필요한 정보가 클라이언트에 넘어가는 것도, 노출되는 것도 좋지 않다고 생각하기 때문에 이 방법도 적합하지 않다고 생각했다.

내가 배포를 뭘로 하고 있고 어떤 환경인지 굳이 알려줄 필요도 그러고 싶지도 않고 보안상으로 좋은 방법도 아니라고 생각하기 때문이다.

 

 

그래서 마지막 방법을 통해 처리하도록 했다.

Backend 서버를 Proxy 서버 역할을 하도록 해 S3로 부터 다운로드 받은 파일을 반환한다.

그럼 React에서는 blob으로 받고 처리할 수 있게 된다.

//controller
@GetMapping("/display/{imageName}")
public ResponseEntity<InputStreamResource> getDisplay(@PathVariable(name = "imageName") String imageName) {
    return testService.getImageDisplay(imageName);
}

//serviceImpl
import org.springframework.core.io.InputStreamResource;
import com.amazonaws.services.s3.model.S3Object;
import com.amazonaws.services.s3.AmazonS3;

public TestServiceImpl implements TestService {
    private final AmazonS3 amazonS3;
    
    @Value("${cloud.aws.s3.bucket}")  
    private String bucket;
    
    @Override
    public ResponseEntity<InputStreamResource> getImageDisplay(String imageName) {
        S3Object s3Object = amazonS3.getObject(bucket, imageName);
        InputStreamResource resource = new InputStreamResource(
                                            s3Object.getObjectContent()
                                        );
        
        return ResponseEntity.status(HttpStatus.OK)
        .header(
            HttpHeaders.CONTENT_DISPOSITON, "attachment; filename=\"" + imageName + "\""
        )
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .contentLength(s3Object.getObjectMetadata().getContentLength())
        .body(resource);
    }
}
//React
const getImageDisplay = async () => {
    await axiosInstance.get(`main/display/${imageName}`, {
        responseType: 'blob',
    }
        .then(res => {
            const url = window.URL.createObjectURL(res.data);
            setImgSrc(url);
        })
}

 

이렇게 처리하면?

blob:http://localhost:8080/파일명

 

이렇게 출력된다!!

 

이 방법으로 수행하게 되면 서버를 통해서만 이미지를 응답 받을 수 있기 때문에 보안이 이전 두 방법보다 강화된다.

그리고 S3 Bucket 객체에 대한 URL 노출이 되지 않고 접근 제어도 수월하게 처리할 수 있게 된다.

 

하지만 단점으로 S3에서 직접 처리되는 것이 아닌 Backend 서버를 거치기 때문에 많은 요청이 집중되게 되면 서버 부하가 증가할 수 있다는 단점도 있다. 서버 부하가 증가한다는 것은 비용 증가로 연결될 수 있기 때문에 여러 요구사항과 환경을 고려해 선택해야 한다.

 

 

다시 방법에 대해 간결하게 정리.

 

1. URL 직접 전달

S3 Bucket 객체에 직접 접근하는 방법이기 때문에 좋은 방법이 아니다. S3가 public하게 설정되어 있지 않다면 접근할 수 없지만 public 하게 설정하는 경우 누구나 접근할 수 있기 때문에 보안 문제가 발생할 수 있다. getUrl()로 얻은 URL로 접근할때는 인증 과정이 생략되기 때문에 문제가 발생할 여지가 많다.

 

2. PresignedURL

서버에서 유효기간이 존재하는 URL을 생성해 반환하는 방법이다.

설정한 유효기간 동안만 접근할 수 있기 때문에 객체 URL을 직접 전달하는 것 보다 보안적인 이점이 존재하지만 직접 접근한다는 것은 동일하다.

접근만 할 수 있고 생성이나 변경은 불가능하지만 여러 정보가 같이 담겨 반환되긴 한다.

서버를 통해 간접적인 접근 방식이 아니기 때문에 접근 로그를 세밀히 관리하기가 어렵고 파일 접근에 대한 추가적인 처리를 수행하기가 어려울 수 있다.

 

3. 서버를 Proxy 서버로 사용해 접근

서버에서만 접근하고 파일을 반환하기 때문에 보안적인 이점이 위 방법들 보다 크다.

URL이 일체 노출되지 않기 때문에 S3를 사용한다는 것 조차 노출시키지 않을 수 있다.

또한 서버를 거쳐야 하기 때문에 세밀하게 제어할 수 있고 추가적인 처리를 수행하는데도 큰 문제가 없다.

다만, 서버를 꼭 거쳐야 하는 만큼 성능 저하가 발생할 수 있고 그로인한 비용이 증가될 수 있다.

무조건적으로 좋은 방법은 아니고 환경에 따라 PresignedURL과 혼합해 사용하는 방법을 고려하는 것도 좋은 방법이다.

+ Recent posts