[AWS] S3로 이미지 업로드&조회 하기(Presigned URL)

LYM·2026년 8월 5일

AWS

목록 보기
1/2
post-thumbnail

이미지 업로드 기능을 구현하다 보면 가장 먼저 떠오르는 방식은 클라이언트 → 서버 → S3 순으로 이미지를 전달하는 방식이다.
그런데 이렇게 구현하면 서버가 이미지 바이너리를 그대로 받아서 S3로 다시 전달해야 하기 때문에, 서버 리소스와 트래픽을 불필요하게 잡아먹는다.

이미지 개수가 많아지거나 용량이 커지면 이 부담은 더 커질 텐데, 이걸 어떻게 줄일 수 있을까 고민하다가 Presigned URL 방식을 알게 되었다.

정리하면서 구현해보자!

Presigned URL이란?

Presigned URL은 S3 버킷에 대한 접근 권한을 임시로 부여한 URL이다.
서버가 AWS 자격증명을 이용해 "이 URL로는 몇 분 동안만 특정 파일에 대해 업로드(혹은 조회)가 가능하다"는 서명된 URL을 발급해주면, 클라이언트는 그 URL을 이용해서 S3에 직접 요청을 보낼 수 있다.

즉, 이미지 바이너리 자체는 서버를 거치지 않고 클라이언트 ↔ S3 사이에서 오간다. 서버는 URL 발급만 담당하는 것이다.

업로드 흐름

  1. 클라이언트가 서버에 "이 파일을 업로드하고 싶다"고 요청 (파일명, 타입 등 전달)
  2. 서버는 S3에 대한 업로드용 Presigned URL을 생성해서 클라이언트에게 응답
  3. 클라이언트는 응답받은 URL로 S3에 직접 PUT 요청을 보내서 이미지 업로드
  4. 업로드가 끝나면 클라이언트는 서버에 완료를 알리거나, 파일 key를 DB에 저장 요청

조회 흐름

  1. 클라이언트가 서버에 이미지 조회를 요청 (게시글 id 등만 전달 — key를 클라이언트가 직접 들고 있게 하지 않는다)
  2. 서버는 전달받은 id로 DB를 조회해서 해당 게시글에 저장된 이미지 key를 가져옴
  3. 조회한 key를 기반으로 조회용 Presigned URL을 생성해서 응답
  4. 클라이언트는 응답받은 URL로 이미지를 바로 렌더링

key는 결국 S3 객체에 접근할 수 있는 정보이기 때문에, 클라이언트가 직접 들고 있다가 그대로 요청에 실어 보내는 구조는 위험하다고 생각했다. id만 넘기고 key는 서버(DB)에서만 관리하는 쪽으로 설계했다.

버킷을 퍼블릭으로 열어두지 않고도 이미지를 보여줄 수 있다는 점, 그리고 URL에 만료 시간이 있어서 무한정 노출되지 않는다는 점이 이 방식의 장점인 것 같다.

사전 준비 (S3, IAM)

구현에 들어가기 전에, AWS 콘솔에서 미리 해둬야 하는 것들이 있다.

1. S3 버킷 생성

이미지를 저장할 버킷을 하나 만든다. 이번 구현은 버킷을 퍼블릭으로 열지 않고 presigned URL로만 접근하는 방식이라, 퍼블릭 액세스 차단은 그대로 켜둔 상태로 진행했다.

2. CORS 설정

클라이언트(브라우저)에서 presigned URL로 S3에 직접 PUT/GET 요청을 보내야 하기 때문에, 버킷 권한 → CORS 설정에서 내 프론트엔드 도메인을 허용해줘야 한다.

3. IAM 사용자 및 정책

서버가 presigned URL을 발급하려면 S3에 접근할 수 있는 AWS 자격증명(액세스키, 시크릿키)이 필요하다. 이걸 위해 IAM에서 별도 사용자를 만들고, 아래처럼 필요한 버킷에 대해 딱 필요한 권한만 부여했다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::my-bucket-name/*"
    }
  ]
}

루트 계정 자격증명을 그대로 쓰지 않고, 이 IAM 사용자의 액세스키/시크릿키를 발급받아서 application.yml에 등록했다.

구현

1. 의존성 추가

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

AWS SDK for Java v2 기준으로 작성했다.

2. application.yml 설정

cloud:
  aws:
    credentials:
      access-key: ${AWS_ACCESS_KEY}
      secret-key: ${AWS_SECRET_KEY}
    region: ap-northeast-2
    s3:
      bucket: my-bucket-name

access key, secret key는 당연히 환경변수나 별도 설정 파일로 분리해서 git에 올라가지 않도록 했다.

3. S3Config

@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}")
    private String region;

    @Bean
    public S3Presigner s3Presigner() {
        AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);

        return S3Presigner.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(credentials))
                .build();
    }
}

Presigned URL을 생성할 때는 S3Client가 아니라 S3Presigner를 사용한다.

4. Presigned URL 발급 서비스

업로드용, 조회용을 각각 만들었다.

@Service
@RequiredArgsConstructor
public class S3PresignedUrlService {

    private final S3Presigner s3Presigner;

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

    // 업로드용 Presigned URL 생성
    public String generateUploadUrl(String key, String contentType) {
        PutObjectRequest objectRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType(contentType)
                .build();

        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(5))
                .putObjectRequest(objectRequest)
                .build();

        PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
        return presignedRequest.url().toString();
    }

    // 조회용 Presigned URL 생성
    public String generateGetUrl(String key) {
        GetObjectRequest objectRequest = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

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

        PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
        return presignedRequest.url().toString();
    }
}

업로드는 짧게(5분), 조회는 조금 더 길게(10분) 만료 시간을 잡았다. 이 값은 서비스 특성에 맞게 조절하면 될 것 같다.

5. Controller

@RestController
@RequestMapping("/api/images")
@RequiredArgsConstructor
public class ImageController {

    private final S3PresignedUrlService s3PresignedUrlService;

    @GetMapping("/upload-url")
    public ResponseEntity<PresignedUrlResponse> getUploadUrl(
            @RequestParam String fileName,
            @RequestParam String contentType
    ) {
        String key = "images/" + UUID.randomUUID() + "_" + fileName;
        String uploadUrl = s3PresignedUrlService.generateUploadUrl(key, contentType);

        return ResponseEntity.ok(new PresignedUrlResponse(uploadUrl, key));
    }

    @GetMapping("/view-url")
    public ResponseEntity<String> getViewUrl(@RequestParam String key) {
        String viewUrl = s3PresignedUrlService.generateGetUrl(key);
        return ResponseEntity.ok(viewUrl);
    }
}

key는 파일명이 겹치지 않도록 UUID를 붙여서 생성했다.

profile
열정 열정 열정

0개의 댓글