AWS SDK2 Presigned url 적용하기

김채원·2025년 6월 23일
post-thumbnail

1. 개요

저번에 이어 채팅 기능 고도화 작업을 하고있었다
중고거래 채팅이니만큼 사진을 보내는 기능은 꼭 필요하다고 생각되어
사진 보내기 기능을 추가하기로 했다

여기서 문제가 또 또 또 발생했는데
필자는 TextWebSocketHandler를 구현한 순수 웹소켓이 아닌
@MessageMapping 어노테이션과 messasingTemplate을 활용해
채팅 기능을 만들었기 때문에
바이너리 파일을 받기 위해선 64base 인코딩을 해줘야했다

하지만 지금 우리팀 서비스내에서 S3를 통해 이미지를 저장하고 있었기 때문에
인코딩된 문자열 대신 S3에 저장된 이미지의 url을 받아오고싶었다

그래서 처음 생각한 방법
도큐먼트에 메시지 타입을 받는 필드를 추가해서
문자열로 해당 파일을 받은 다음 S3에 저장 후
url을 dto로 반환해주려고 했다

하지만 이렇게되면 들어오는 입력값이 맞는지
추가적인 검증이 필요해지고

해당 검증 방식이 너무나 복잡해질거같아
이렇게 하는게 아닌가 ..? 라는 생각이 들었다

최종적으로 결정된 방법
presigned url을 사용하는 것이었다

2. presigned url

presigned url이란
AWS S3에서 파일을 일시적으로 안전하게 접근할 수 있도록 만들어주는 임시 URL

원리는 S3에 권한이 없는 사람도 접근할 수 있는 url을 하나 만들어둔다
이후 사진 등록을 요청받으면 해당 url에
요청 받은 사진을 올릴 수 있도록 해주는 것이다

아래 사진처럼 특이한 점은
클라이언트가 서명된 url을 통해 직접 사진을 올릴 수 있다는 점이다

구현 방법을 이해하는데 시간이 너무 오래걸려서
혹시 이 글을 보는 사람들은 조금이나마 쉽게 이해가 갈 수 있도록
풀어서 작성해보면

  1. 사용자가 원하는 사진을 보낸다
  2. 프론트엔드에서 해당 사진을 받아 S3에 저장할 수 있도록 파일이름과 형식들을 분리해 벡엔드로 보내준다
  3. 백엔드는 해당 파일이름과 형식을 활용해 S3에 사진을 저장할 수 있는 임시 url을 만들어준다
    이 후 해당 url을 프론트엔드에 보내준다
  4. 프론트엔드는 해당 url을 통해 S3에 사진을 올리고
    S3에 업로드된 사진의 주소를 사용자에게 보내준다

이런 흐름이라고 생각하면 된다

3. 구현

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

가장 먼저 의존성을 추가해준다

sdk1버전이 25년 말부터 더이상 추가적인 업데이트나 수정이 없다고 해서
sdk2 버전을 사용했다

sdk1과 sdk2 차이점

차이점이나 마이그레이션 하는 법 등은
공식문서에 나와있다

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

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

다음으로 기존에 있던 S3 컨피그에
presignedUrl을 사용하기 위해 빈에 등록하는 코드를 추가해줬다

controller

@RestController
@RequiredArgsConstructor
public class PresignedUrlController {

	private final PresignedUrlService presignedUrlService;

	@PostMapping("/chat/image")
	public ResponseEntity<CommonResponse<PresignedUrlResponseDto>> getPresignedUrl(
		@RequestBody PresignedUrlRequestDto requestDto
	) {
		return CommonResponse.of(SuccessCode.CREATED, presignedUrlService.createPresignedUrl(requestDto));
	}
}

다음은 컨트롤러 코드이다
만약 클라이언트에서 사진을 보낸다면
프론트는 해당 경로로 presigned url 생성을 요청한다

service

@Service
@RequiredArgsConstructor
public class PresignedUrlService {

	private final S3Presigner s3Presigner;
	private final S3Uploader s3Uploader;

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

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

	public PresignedUrlResponseDto createPresignedUrl(PresignedUrlRequestDto requestDto) {

		// 파일 확장자 추출
		s3Uploader.getExtension(requestDto.getFilename());

		// 업로드될 S3 객체의 Key 경로 생성
		String objectKey = "chat/" + requestDto.getChatRoomId() + "/" + requestDto.getFilename();

		// 업로드 객체 생성
		PutObjectRequest objectRequest = PutObjectRequest.builder()
			.bucket(bucket)
			.key(objectKey)
			.contentType(requestDto.getContentType())
			.build();

		// Presigned Url 생성을 위한 요청 객체 생성
		PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
			.signatureDuration(Duration.ofMinutes(5)) //시간은 우선 5분으로 지정
			.putObjectRequest(objectRequest)
			.build();

		// Presigned URL 생성
		URL uploadUrl = s3Presigner.presignPutObject(presignRequest).url();

		// 업로드 완료 후 접근 가능한 정적 파일 URL 생성
		String fileUrl = "https://" + bucket + ".s3." + region + ".amazonaws.com/" + objectKey;

		return new PresignedUrlResponseDto(uploadUrl.toString(), fileUrl);
	}
}

url을 생성하는 코드이다

사용자가 올린 파일에서 이름과 파일 타입을 추출해
S3에 저장할 경로를 만들어준다

이때 해당 url을 이용할 수 있는 시간을 정해주는데
cloudFront를 구현할게 아니라면 시간을 넉넉하게 잡아주는것이 좋아보인다

    async function sendImageMessage() {
        const imageFile = document.getElementById("imageInput").files[0];
        if (!imageFile) {
            alert("이미지를 선택하세요!");
            return;
        }

        const presignRes = await fetch("/chat/image", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Authorization": `Bearer ${token}`
            },
            body: JSON.stringify({
                chatRoomId,
                filename: imageFile.name,
                contentType: imageFile.type
            })
        });

        if (!presignRes.ok) {
            alert("Presigned URL 발급 실패");
            return;
        }

        const resJson = await presignRes.json();
        const { uploadUrl, fileUrl } = resJson.data;

        const uploadRes = await fetch(uploadUrl, {
            method: "PUT",
            headers: {
                "Content-Type": imageFile.type
            },
            body: imageFile
        });

        if (!uploadRes.ok) {
            alert("S3 업로드 실패");
            return;
        }

프론트에도 이미지를 넣어주는 코드를 만들어뒀는데
자바스크립트는 잘 알지 못해서 지피티의 도움을 받아 수정했다

이미지가 들어오면 만들어둔 post api를 호출해 경로를 만든 후
s3에 이미지를 저장한다

4. 테스트

오늘도 귀여운 커비를 채팅으로 보내보자

콘솔을 살펴보면 값이 잘 들어가고 전달되는것을 볼 수 있다



마찬가지로 S3와 포스트맨을 통한 테스트에도 값이 잘 저장되어 있는걸 확인할 수 있다

5. 트러블슈팅

테스트를 해보는 중

위와 같은 오류가 발생했다

이유는 S3에 CORS 설정을 해주지 않았기 때문이다

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "GET",
            "POST"
        ],
        "AllowedOrigins": [
            "http://localhost:8080"
        ],
        "ExposeHeaders": [
            "ETag"
        ],
        "MaxAgeSeconds": 3000
    }
]

S3에 들어가서 권한 설정을 해주었다

URL 생성을 위한 POST와 사진을 저장하기 위한 PUT
조회까지 고려해서 GET만 넣어주었다

삭제는 유효일자를 설정해서 스케줄러로 직접 구현해볼 생각이라 제외시켰다

이후 다시 테스트를 해보면 정상적으로 동작하는걸 확인할 수 있다

6. 마무리

이번에 presigned url을 공부하면서 프론트와 백엔드의 역할에 대해 생각해보는 계기가 됐다. 사실 전까지 프론트엔드가 하는 일은 단순하게 뷰를 반환해주는 일이라고 생각했다. 근데 정말 정말 많은 일들을 하고 있구나 ,, 라는 생각이 들었다. 협업에 대한 이해도를 높일 수 있어 좋은 경험이었다.

출처

presigned url 이미지

참고 블로그

profile
김채원 판교간다

0개의 댓글