[Spring Boot] AWS S3 연동 & Presigned URL을 통한 조회

eunsil·2025년 3월 26일
0

Spring Boot

목록 보기
1/14

개요

브라우저에서 이미지 파일을 첨부하고, 조회하기 위한 기능을 구현하고자 합니다.
전체적인 흐름은 아래와 같습니다.

업로드

  • 클라이언트에서 전달한 파일을 서버에서 MultipartFile 타입으로 받고, Amazon S3에 업로드합니다.
  • 업로드 성공 후, DB에 S3 객체 Key를 저장하여 삭제와 Presigned URL 발급에 사용합니다.

조회

  • S3 버킷은 퍼블릭 액세스를 모두 차단하고 있으며, 지정된 IAM 사용자만이 접근 가능합니다.
  • 프론트에서 S3 이미지를 조회하기 위해, 서버를 통해 Presigned URL을 발급 받습니다.



목차

  1. S3 버킷 생성

  2. 버킷 정책

  3. IAM 액세스 키 발급

    → 여기까지는 방법이 아주 간단하기도 하고, 구글에 방법이 많이 나와있어 찾기 쉽습니다.

4. 개발 컴퓨터(로컬)에 AWS 프로파일 생성
5. S3Config 설정
6. Spring Boot에서 S3 이미지 파일 업로드/삭제/조회



개발 환경

  • Spring Boot 3

  • JDK 17

  • AWS S3

    • 버킷 정책

    • 퍼블릭 액세스 차단



🟢 AWS 프로파일 생성

1. AWS CLI 설치

🔗 AWS CLI 설치, 업데이트 및 제거


2. 설치 확인


3. 프로파일 설정

aws configure

IAM 생성 후 받은 Access Key와 Secret Access Key를 입력하고, S3의 리전을 입력합니다.


4. 영구 저장 (Windows 기준)

setx AWS_PROFILE "원하는 프로파일 명"

별도로 저장하지 않으면 컴퓨터 재부팅 시 내용이 삭제돼 매번 입력해야 합니다.
개발 서버에 시스템 환경 변수로 저장하는 것을 추천합니다.

작업한 CLI는 변경 사항이 적용되지 않으니, 꼭 새 CLI 창을 열어서 확인해야 합니다.
IntelliJ의 Terminal에서 새 Terminal을 여는 것도 적용되지 않아서, 윈도우의 CMD를 새로 열어서 확인했습니다.

위 명령 입력 후 윈도우의 시스템 환경 변수에 들어가면 아래와 같이 생성된 것을 확인할 수 있습니다.



❗이슈 - Failed: Profile file contained no credentials for profile ‘default’

AWS 프로파일 파일에 ‘default’ 라는 이름의 프로파일이 없다라는 예외입니다.
저의 경우, 런타임 시점에서 AWS 접근을 위한 객체를 생성할 때 AWS 프로파일을 찾을 수 없어 발생했습니다.

CLI에서 설정한 AWS Profile을 꼭 시스템 환경 변수로 저장해줘야 컴퓨터를 재부팅해도 변수가 유지됩니다.
시스템 환경 변수로 저장하지 않고 재부팅을 한 경우, 다시 key 설정을 해줘야 합니다.



🟢 의존성 & 환경 설정

◾ build.gradle

implementation 'software.amazon.awssdk:s3:2.20.90'

◾ application.yml

spring:
  cloud:
    aws:
      active: false

  profiles:
    active: dev #보안 정보가 담긴 yml 파일 명

◾ application-dev.yml

cloud:
  aws:
    active: true
    auth: profile
    s3:
      bucket: #버킷 이름
      region: #리전 이름
      profile: #프로파일 이름

인증 정보와 같은 민감한 정보는 절대 GitHub에 올리지 말고 따로 관리해야 합니다.
특히, 과금이 발생할 수 있는 AWS 서비스를 사용할 때는 더욱 조심해야 합니다.



🟢 S3 연동

◾ S3Config.java

  • S3에 파일을 업로드하기 위해 필요한 S3Client를 Bean으로 등록
  • 앞에서 만든 프로파일을 환경 변수로 가져와 인자로 전달
    • 이때, 프로파일을 찾지 못하면 예외 발생
@Configuration
public class S3Config {
    @Value("${cloud.aws.s3.region}")
    private String region;

    @Value("${cloud.aws.s3.profile:#{null}}")
    private String profile;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(ProfileCredentialsProvider.create(profile))
                .build();
    }
}

❗이슈 - Failed: Unable to load credentials

Failed: Unable to load credentials from any of the providers in the chain AwsCredentialsProviderChain ‥‥‥ (이하생략)

AWS 접근을 위해 사용자의 자격 증명을 담은 객체를 생성할 때, 자격 증명을 찾을 수 없어 발생하는 예외입니다.
저의 경우, S3 연동에 필요한 S3Client S3Presigner 객체를 Bean으로 등록할 때 발생했습니다.

@Value("${cloud.aws.s3.profile:#{null}}")
private String profile;

절대! 하드 코딩하지 않고 @Value를 사용해서 환경 변수로 가져옵니다.



🟢 S3 업로드 & 삭제 & 추출

현재 구현 중인 프로젝트의 사전 설명

  • 1개의 Task에 N개의 Attach가 저장되고, N개의 Attach들을 group id로 그룹핑합니다.
  • Attach는 img, png 파일 요청만 받고있으며, 서버를 통해 Amazon S3에 업로드 됩니다.
  • Task 조회 시, S3에 업로드 된 파일의 Presigned URL을 반환합니다.
    • S3 퍼블릭 액세스는 모두 차단된 상태
    • 서버에 AWS 자격 증명이 저장되어 있어 클라이언트는 서버에게 받은 Presigned URL을 통해서만 파일 조회가 가능합니다.


1. 파일 업로드

Front-end

TaskForm.ts

  • FormData 타입으로 서버에게 데이터 전달
const formData = new FormData();
fileArray.forEach((file) => {
  formData.append("files", file);
});

formData.append("content", content.value);
formData.append("year", splitDate[0]);
formData.append("month", splitDate[1]);
formData.append("day", splitDate[2]);
formData.append("createdBy", "1");
formData.append("groupId", crypto.randomUUID());

const response = await createTask(formData);

Back-end

TaskController.java

  • MultipartFile을 FormData 타입으로 받기 위해, @RequestParam 사용
  • TaskVO의 멤버변수에 해당되는 나머지 FormData 값들은 @ModelAttribute로 매핑
/* TaskController.java */

@PostMapping
public ApiResponse<String> create(@RequestParam("files") List<MultipartFile> files,
                                  @ModelAttribute TaskVO taskVO) {
    if (taskService.create(files, taskVO)) {
        return ResponseUtil.createSuccessResponse("Success Create Task");
    }
    return ResponseUtil.createErrorResponse(HttpStatus.NOT_FOUND, "Failed Create Task");
}

AttachServiceImpl.java

  • S3 업로드하기 전에, UUID를 이용하여 해당 파일의 고유 이름이 될 key 생성
  • 파일과 key를 함께 인자로 전달해 S3 업로드
  • 업로드 성공하면 DB 저장
    • DB에 S3 Key를 저장해 삭제Presigned URL 생성에 사용됩니다.
/* AttachServiceImpl.java */

@Transactional
@Override
public boolean save(List<MultipartFile> files, String groupId, Long createdBy) {
    for (MultipartFile file : files) {
        String key = "uploads/" + UUID.randomUUID() + "-" + file.getOriginalFilename();

        try {
            s3UploadServiceImpl.saveFile(file, key); // S3 업로드
            Attach attachEntity = new Attach(
                    key
                    , attach.getOriginalFilename()
                    , attach.getContentType()
                    , attach.getSize()
                    , groupId
                    , createdBy
            );
            attachRepository.save(attachEntity);
        } catch (Exception e) {
            logger.error(e.getMessage());
            return false;
        }
    }
    return true;
}

◾ S3UploadServiceImpl.java

  • S3에 업로드할 때는 File 타입(MultipartFile❌)이 필요하기 때문에 getInputStream() 사용
    • IOException 예외 처리 필요
/* S3UploadServiceImpl.java */

public boolean saveFile(MultipartFile file, String key) {
    try {
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .build();
        s3Client.putObject(putObjectRequest,
                software.amazon.awssdk.core.sync.RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
        return true;
    } catch (S3Exception | IOException e) {
        logger.warn(e.getMessage());
        return false;
    }
}



2. 파일 삭제

◾ S3UploadServiceImpl.java

  • S3에 업로드 된 파일의 고유 이름 key 사용

/* S3UploadServiceImpl.java */

public void deleteFile(String key) {
    try {
        DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .build();
        s3Client.deleteObject(deleteObjectRequest);
    } catch (S3Exception e) {
        logger.warn(e.getMessage());
    }
}



3. S3 이미지 추출 - Presigned URL 발급

◾ S3Config

  • Presigned URL 발급을 위해 필요한 S3Presigner 을 Bean으로 등록
/* S3Config.java */

@Value("${cloud.aws.s3.profile:#{null}}")
    private String profile;

@Bean
public S3Presigner s3Presigner() {
    return S3Presigner.builder()
            .region(Region.AP_NORTHEAST_2)
            .credentialsProvider(ProfileCredentialsProvider.create(profile))
            .build();
}

◾ S3UploadServiceImpl.java

  • key를 인자로 받고, Request 객체를 생성해 AWS에게 URL을 요청합니다.
  • Duration.ofMinutes() : URL 만료 시간
/* S3UploadServiceImpl.java */

public String generatePreSignedUrl(String key) {
    try {
        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .build();

        GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(10))
                .getObjectRequest(getObjectRequest)
                .build();

        return s3Presigner.presignGetObject(getObjectPresignRequest).url().toString();
    } catch (S3Exception e) {
        logger.error(e.getMessage());
  

◾ TaskServiceImpl.java

  • 1개의 Task에 저장된 N개의 Attach를 groupId로 한 번에 추출
  • N개의 Attach의 S3 key로 Presigned URL을 요청
  • 결과를 DTO에 담아 반환
/* TaskServiceImpl.java */

private List<AttachDTO> getImageDTOsForTask(Task task) {
    List<Attach> attaches = attachRepository.findByGroupId(task.getGroupId());
    
    return attaches.stream()
            .map(attach -> new AttachDTO(
                    attach.getId(),
                    attach.getOriginName(),
                    s3UploadService.generatePreSignedUrl(attach.getS3Key())
            ))
            .collect(Collectors.toList());
}

0개의 댓글