Codeit/스프린트 과제

[sprint8] S3 관련 코드 + S3Client와 Presigned Url

leejunkim 2025. 8. 25. 15:48

먼저 주요 요소들 설명

  •  S3Client
    • AWS SDK에 포함된 클래스로 S3에 파일 업로드, 다운로드 등의 명령을 내리는 역할을 하는 서버용 도구다. 이 객체를 사용하려면 어떤 AWS계정의 자격증명(Credentials)을 쓰고 어느 리전에 접속할지 등 설정을 해주어야 한다. 
  • S3Presigner
    • 클라이언트(예: 웹 브라우저)가 S3에 직접 안전하게 접근할 수 있도록 임시 허가증(URL)을 발급해주는 보안용 도구다.
    • 퍼블릭 URL에 response-content-disposition을 붙이는 건 익명 요청에서 허용되지 않기 때문에, 다운로드 시점에 프리사인드 URL을 만들어 저장 상자를 띄워야 한
    • Presigned Url: 제한된 시간 동안 특정 S3 버킷의 객체에 접근할 수 있는 임시 URL
    • S3Presigner 객체: Presigned Url 생성기
      • 비공개(private) S3 버킷에 있는 파일에 접근하려면 인증이 필요한데, S3Presigner는 주어진 AWS 자격증명을 사용해서 "이 URL을 가진 사람은 특정 시간 동안 특정 파일에 접근해도 좋다"는 허가를 담아 URL에 디지털 서명을 해준다.

S3BinaryContentStorage

@ConditionalOnProperty(
    prefix = "discodeit.storage",
    name = "type",
    havingValue = "s3"
)
@Component
public class S3BinaryContentStorage implements BinaryContentStorage {
  
  private final String bucket;
  private final S3Client s3Client;
  private final String presignedUrlExpiration;

  public S3BinaryContentStorage(
      @Value("${discodeit.storage.s3.bucket}") String bucket,
      @Value("${discodeit.storage.s3.presigned-url-expiration}") String presignedUrlExpiration
  ) {
    this.bucket = bucket;
    this.presignedUrlExpiration = presignedUrlExpiration;

    // AWS SDK's builder automatically finds credentials and region from the environment
    this.s3Client = S3Client.builder().build();
  }


  @Override
  public UUID put(UUID binaryContentId, byte[] bytes) {

    try {
      String contentType = "application/octet-stream";

      PutObjectRequest putReq = PutObjectRequest.builder()
          .bucket(bucket)
          .key(binaryContentId.toString())
          .contentType(contentType)
          .build();

      s3Client.putObject(putReq,
          RequestBody.fromBytes(bytes)
      );
    } catch (Exception e) {
      throw new RuntimeException("S3 업로드 실패", e);
    }

    return binaryContentId;
  }

  @Override
  public InputStream get(UUID binaryContentId) {
    String key = binaryContentId.toString();

    GetObjectRequest getReq = GetObjectRequest.builder()
        .bucket(bucket)
        .key(key)
        .build();

    return s3Client.getObject(getReq);
  }

  @Override
  public ResponseEntity<?> download(BinaryContentDto metaData) {
    String key = metaData.id().toString();
    String presignedUrl = generatePresignedUrl(key, metaData.contentType());

    return ResponseEntity.status(HttpStatus.FOUND) // Or HttpStatus.SEE_OTHER (303)
        .location(URI.create(presignedUrl))
        .build();
//    return ResponseEntity.status(302).location(URI.create(presignedUrl)).build();
  }

  public S3Client getS3Client() {
    return S3Client.builder().build();
  }

  public String generatePresignedUrl(String key, String contentType) {
    S3Presigner presigner = S3Presigner
        .builder()
        .s3Client(s3Client)
        .build();

    // download -> GET
    GetObjectRequest getReq = GetObjectRequest.builder()
        .bucket(bucket)
        // tells the presigned URL to instruct S3 to serve the file with the correct Content-Type
        .responseContentType(contentType)
        .key(key)
        .build();

    GetObjectPresignRequest preReq = GetObjectPresignRequest.builder()
        .getObjectRequest(getReq)
        .signatureDuration(Duration.ofMinutes(Integer.parseInt(presignedUrlExpiration))) // duration
        .build();

    return presigner.presignGetObject(preReq).url().toString();
  }

}
  • application.yml파일의 discodeit.storage.type=s3 이여야지만 이 클래스가 빈으로 등록된다

컨테이너가 시작될 때:

  • Docker Compose가 .env와 docker-compose.yml 파일을 읽어서 컨테이너의 실행 환경(OS)에 환경 변수를 설정해준다.
  • 그 후에 컨테이너 안에서 실행되는 AWS SDK와 Spring 애플리케이션이 각자 필요한 환경 변수를 컨테이너의 실행 환경으로부터 읽어온다.

더 자세한 과정 설:

  • 컨테이너가 시작될때:
    • AWS SDKS3Client.builder().build()를 통해 초기화될 때, 컨테이너의 환경 변수에서 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION 같은 표준 이름을 찾아 자동으로 클라이언트를 설정한다.
      • 내부적으로 알아서 자격증명(credential: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY )과 리전(region: AWS_REGION )을 찾아준다
      • 그래서 원래 이 값들을 @Value를 통해 가져오고 드 요소들로 S3Client를 build해야 하는데, 그럴 필요가 없어졌다. 
    • Spring 애플리케이션은 application.yml을 읽는다. 위의 AWS SDK가 자동으로 받아와주지 않는 변수들은 직접 받아줘야 한다.
      • S3BinaryContentStorage의 bucket 필드처럼 AWS SDK가 아닌 내 애플리케이션 로직에 필요한 값들은 여전히 application.yml과 @Value를 통해 주입해야 한다

여기서 AWS SDK란?

AWS SDK for Java (도구 상자 전체)

  • AWS의 모든 서비스를 다룰 수 있는 도구들의 모음이다.
  • S3Client (S3 렌치): S3와 관련된 작업(파일 업로드, 다운로드 등)을 할 때 사용하는 전용 도구고, AWS SDK for Java라는 거대한 라이브러리의 일부다. S3 서비스와 통신하는 역할을 맡은 핵심 클래스라고 기억하면 된다.
  • S3 전용 도구 말고도 다른 기술의 전용 도구도 많이 존재한다 (DynamoDblient, SqsClient, 등)

SDK 빌더(Builder)

  • AWS SDK for Java의 핵심 디자인이며, S3Client뿐만 아니라 거의 모든 AWS 서비스 클라이언트에서 동일하게 사용할 수 있다. AWS SDK for Java (버전 2)를 개발한 팀은 모든 서비스 클라이언트를 생성하고 설정하는 방식을 통일하기로 결정했는데, 그 표준 방식이 바로 빌더 패턴(Builder Pattern)이다.
    • 즉, 하나의 사용법만 배우면 수십 개의 다른 AWS 서비스를 아주 쉽게 사용할 수 있다. S3Client를 만드는 방법을 배웠다면, 이미 다른 클라이언트들도 만들 수 있다는 것이다!!
    • // DynamoDB 전용 클라이언트를 생성
      DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build();
      
      // SQS 전용 클라이언트를 생성
      SqsClient sqsClient = SqsClient.builder().build();
  • 굉장히 똑똑한 기본값과 자동 탐지 기능을 내장한다
  • S3Client.builder().build() 라는 '시작' 버튼을 누르면, SDK는 내부적으로 다음과 같은 일들을 자동으로 수행한다.
    1. 자격 증명 찾기: DefaultCredentialsProvider를 실행해 환경 변수(AWS_ACCESS_KEY_ID), AWS 자격 증명 파일(~/.aws/credentials), IAM 역할 등에서 알아서 자격 증명을 찾아온다.
    2. 리전 찾기: DefaultRegionProviderChain을 실행해 환경 변수(AWS_REGION), AWS 설정 파일(~/.aws/config), EC2 메타데이터 등에서 알아서 리전 정보를 찾아온다.
    3. HTTP 클라이언트 설정: AWS와 통신하기 위한 최적화된 기본 HTTP 클라이언트를 설정한다.
    4. 재시도 로직 설정: 일시적인 네트워크 오류가 발생했을 때 자동으로 재시도하는 정책을 설정한다.
  • 참고로 위에 있는 것들은 S3Client뿐만 아니라 AWS SDK for Java의 모든 서비스 클라이언트에 공통으로 적용되는 핵심 로직이다.

compose.yml

services:
  # looks for dockerfile to get/build image
  app:
    build: .
    ports:
      - '8081:80'
    environment:
      JAVA_OPTS: -Xms256m -Xmx512m
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
      SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
      SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}

      # AWS
      AWS_CREDENTIALS_ACCESS_KEY: ${AWS_S3_ACCESS_KEY}
      AWS_CREDENTIALS_SECRET_KEY: ${AWS_S3_SECRET_KEY}
      AWS_REGION: ${AWS_S3_REGION}
      AWS_S3_BUCKET: ${AWS_S3_BUCKET}
      AWS_S3_PRESIGNED_URL_EXPIRATION: ${AWS_S3_PRESIGNED_URL_EXPIRATION}

    env_file: [ .env ]
    depends_on:
      - db
    volumes:
      - app-storage:/app/.discodeit/storage

  db:
    # don't look for dockerfile, instead pull this image
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    env_file: [ .env ]
    volumes:
      # MOUNTS the volume
      # It connects our 'db-data' volume to the '/var/lib/postgresql/data' folder inside the container.
      - db-data:/var/lib/postgresql/data
      - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro

volumes:
  db-data: { }
  app-storage: { }

 

volumes:
      - app-storage:/app/.discodeit/storage

 

*만약에 위에 volumes 설정을 아예 하지 않았다면 애플리케이션이 생성하는 파일은 컨테이너 내부 시스템에 그대로 저장된다! 근데 도커 컨테이너는 기본적으로 휘발성이기 때문에, volumes 설정이 없다면 도커를 삭제하고 다시 만들면 이전 컨테이너 내부에 저장되었던 모든 파일은 영구적으로 삭제된다!*

 

S3BinaryContentStorage와 LocalBinaryContentStorage

application.yaml 파일 일부:

discodeit:
  storage:
    #    type: local
    type: ${STORAGE_TYPE:local}  # local | s3 (기본값: local)
  • application.yml파일에서 discodeit.storage.type=local로 설정해주면:
    • volumes 설정을 안했으면, 이미지/문서 등 실제 파일은 컨테이너 안의 /app/.discodeit/storage 경로로 저장이 된다.
    • 하지만 volumes: app-storage:/app/.discodeit/storage 으로 정해주었기 때문에 컨테이너의  /app/.discodeit/storage에는 더이상 저장이 안되고 app-storage라는 호스트의 도커 볼륨에만 저장이 된다.
@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local")
@Component
public class LocalBinaryContentStorage implements BinaryContentStorage {
  • 여기서 application.yml파일에서 discodeit.storage.type=s3로 설정해주면:
    • 파일들은 바로 그냥 S3로 올려진다. docker compose 파일에 의해서 app-storage 도커 볼륨은 만들어지지만 전혀 사용되지 않는다. 반면 db-data는 여전히 사용된다.
    • LocalBinaryContentStorage는 아예 무시된다.
@ConditionalOnProperty(
    prefix = "discodeit.storage.type",
    havingValue = "s3"
)
public class S3BinaryContentStorage implements BinaryContentStorage {