Presigned URL이란?
일반적으로 사진과 같은 미디어는 S3 버킷으로 업로드되고, 해당 사진의 엔드포인트(url)만 RDB에 저장된다. 이때 사진 파일은 굉장히 많은 트래픽을 소모하므로, 프론트엔드→백엔드→ S3로 전달하는 것보다 프론트엔드→S3로 전달하는 것이 더 효율적이다. 또한 이미지를 조회할 때도 S3→백엔드→프론트엔드보다는 S3→프론트엔드로 전달하는 게 서버의 과부화를 막을 수 있다.
여기서 문제는 AWS 리소스인 S3에 접근하기 위해서는 엑세스 키처럼 권한이 필요한데, 이 키를 프론트엔드가 직접 사용할 수는 없다는 것이다. 프론트엔드는 사용자가 접근 가능한 영역이므로, 프론트엔드에 키를 줄 경우 유출 문제가 생길 수 있다.
이 문제를 해결하기 위해서 사용하는 것이 바로 presigned url이다. presigned url은 서버에서 가지고 있는 권한을 사용해 클라이언트에게 임시적인 권한을 발급해주는 것을 의미한다. 서버는 presigned url을 사용해 특정 객체(이미지)를 조회하거나, 아니면 업로드할 권한을 클라이언트에게 임시적으로 부여할 수 있다.
S3 만들기
먼저 AWS에서 다음과 같은 버킷을 만들어준다. 버킷이 위치할 AWS 리전이나, 버킷 이름은 원하는 대로 설정해주면 된다. (참고로 S3 버킷 이름은 글로벌하게 사용되므로, 다른 사람들과 겹치면 생성 불가능하다. 보안상으로는 S3 버킷 접미사에 랜덤한 난수를 추가하는 것을 추천한다.)
그 후 객체 소유권은 “ACL 비활성화됨”으로, 퍼블릭 엑세스 차단 설정은 “모든 퍼블릭 엑세스 차단”으로 설정한다. 지금은 presigned url을 사용해서 권한이 필요한 클라이언트에게 임시 권한을 부여해줄 수 있으므로, 모두에게 퍼블릭 엑세스를 열어줄 필요가 없다.
생성 버튼을 누르면 아래와 같이 만들어져 있는 것을 볼 수 있다.
테스트를 위해서 샘플로 사진 하나를 올려보도록 하자.
올린 객체의 URL이 생기는데, 이 URL로 접속해봐도 Access Denied가 뜨고 이미지가 보이지 않는다. 그 이유는 해당 버킷에 대해서 퍼블릭 엑세스를 막아두었기 때문이다. 이 이미지를 보기 위해서는 GET 요청을 할 수 있는 presigned url을 발급 받아야 한다. 또한, 이 S3 버킷에 이미지를 올리고 싶을 때도 PUT 요청이 가능한 presigned url이 필요하다. (S3를 생성한 계정의 콘솔로 직접 업로드할 때는 괜찮지만, 외부에서 이 S3에 접근하려면 항상 권한을 따로 발급해줘야 한다.)
Access Key 발급하기
위에서 만든 S3에 SDK를 사용해서 접근하기 위해서는 적절한 자격 증명을 가지고 있어야 한다. IAM > 보안 자격 증명에서 엑세스 키를 만들어주면 된다. 이때 루트 권한으로 엑세스 키를 만들어주기 보다는 IAM Role 등을 생성해서 만드는 것이 권장되지만, 우선 여기서는 루트 권한으로 만들어주기로 한다.
build.gradle 설정하기
먼저 S3를 사용하기 위해서 필요한 종속성을 넣어줘야 한다. 나는 자바 17, Springboot 3.2.3 기준으로 아래 종속성을 포함시켜서 S3를 사용하였다.
dependencies {
//AWS S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}
application.yml 설정하기
이제 위에서 만든 S3 버킷에 대한 정보를 설정 파일에 담아주어야 한다. 위에서 만든 버킷 이름, 버킷이 위치한 리전, 그리고 엑세스 키와 시크릿 키 정보를 넣어준다. 이때 엑세스 키와 시크릿 키는 무슨 일이 있어도 github에 올라가서는 안되므로, application.yml을 .gitignore에 추가해줘야 한다. (*루트 권한으로 만든 엑세스 키를 실수로 깃허브에 올렸다면 AWS 자격 증명에서 바로 키를 비활성화하고 삭제해줘야 한다.)
cloud:
aws:
s3:
bucket: s3-sample-name # 버킷 이름
stack.auto: false
region.static: ap-northeast-2 # 버킷 리전
credentials:
access-key: # 엑세스 키
secret-key: # 시크릿 키
Springboot에서 S3 설정하기
아래의 config로 AmazonS3라는 클라이언트를 빈에 등록해줘야 한다. 이 클라이언트가 있어야 AWS 리소스와 연결하고 정보를 가져오는 등의 일을 할 수 있다.
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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.static}")
private String region;
@Bean
public AmazonS3 amazonS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
S3 조회/업로드 API 만들기
클라이언트에서 S3에 직접 이미지 업로드 및 이미지 조회 기능이 필요한 상황이므로, 우리는 GET URL 및 PUT URL을 만들어야 한다. 이때 각 API의 구조는 다음과 같다.
PUT용 presigned URL
- 클라이언트는 POST API Call을 통해서 특정한 이름을 가진 파일을 S3에게 업로드하고 싶다는 요청을 한다.
- 서버는 해당 파일이 S3에 저장될 경로를 지정해준다. 예를 들어서, 파일명이 cat.jpg이고, 이 파일을 S3에서 profile/[MemberID]/[UUID]이라는 디렉토리 안에 저장할 것이라면, 경로는 profile/[MemberID]/[UUID]/cat.jpg가 된다. 이 경로는 나중에 GET용 presigned url을 위해 필요하므로 DB에 저장해둔다.
- 서버는 해당 경로를 key로 하는 presigned url을 발급해준다.
- 클라이언트는 발급받은 presigned url에 PUT 요청을 해서 이미지를 업로드한다.
이때 파일명이나 경로의 디테일한 전략 등은 클라이언트랑 맞춰주면 된다. 클라이언트에서 애초에 파일명에 UUID 값을 붙여서 key를 발급받아도 되고, 서버에서 이 값을 붙여줘도 되고, 기타 등등 다 가능하다.
GET용 presigned URL
- 클라이언트는 GET API Call을 통해서 서버에게 특정 이미지를 조회하고 싶다는 요청을 한다.
- 서버는 DB에서 조회할 이미지 경로(key)을 가져온다.
- 서버는 조회할 이미지 이름과 bucket 이름, 엑세스 키 및 시크릿 키를 통해 presigned url을 받아 클라이언트에게 전송한다.
- 클라이언트는 발급 받은 임시 권한(presigned url)으로 이미지를 조회한다. (즉, <image> 태그의 src이 presigned url이 되면 된다.)
GetS3UrlDto
먼저, 클라이언트에게 presigned url을 전달해줄 DTO를 만들어보자. 이 DTO는 GET/PUT용 presigned url과, 그 url을 발급 받은 key를 모두 전달해주는 DTO이다.
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode
public class GetS3UrlDto {
private String preSignedUrl;
private String key;
@Builder
public GetS3UrlDto(String preSignedUrl, String key) {
this.preSignedUrl = preSignedUrl;
this.key = key;
}
}
S3Controller
이제 컨트롤러를 만들어보자. PUT용 API인 getPostS3Url은 유저가 전달해주는 filename으로 presigned url을 발급해준다. (메소드 이름을 getPostS3Url로 실수로 지었는데, 클라이언트 입장에서는 PUT용 presigned url이 발급되는 것이 맞다.) 그리고 GET용 API인 getGetS3Url는 조회하고 싶은 key를 전달받아서, 그 key(경로)에 있는 이미지를 조회할 수 있는 presigned url을 발급해준다.
사실, 이 GET용 API는 클라이언트에게 직접 호출되지는 않는다. 클라이언트가 보내는 요청은 (예를 들자면) “SNS 사진 포스팅을 조회할 수 있는 GET 요청”같은 것이고, 이 요청을 받으면 서버가 해당 사진의 key를 DB에서 조회해서, GET용 presigned url을 발급 받은 다음 클라이언트에게 전달해주는 것이다. 다면 여기서는 개발 테스트용으로 둘 다 API를 만들었다.
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/s3")
public class S3Controller {
private final S3Service s3Service;
@GetMapping(value = "/posturl")
public ResponseEntity<GetS3UrlDto> getPostS3Url(@AuthUser Member member, String filename) {
GetS3UrlDto getS3UrlDto = s3Service.getPostS3Url(member.getId(), filename);
return new ResponseEntity<>(getS3UrlDto, HttpStatusCode.valueOf(200));
}
@GetMapping(value = "/geturl")
public ResponseEntity<GetS3UrlDto> getGetS3Url(@AuthUser Member member, @RequestParam String key) {
GetS3UrlDto getS3UrlDto = s3Service.getGetS3Url(member.getId(), key);
return new ResponseEntity<>(getS3UrlDto, HttpStatusCode.valueOf(200));
}
}
S3Service
이제 서비스의 로직을 만들어보자. 여기서는 해당 presigned url이 어떤 HTTP 메소드를 허락하는지(GET/PUT), 어떤 key로 발급받을 것인지, 그리고 유효기간은 어떻게 되는지 등을 설정할 수 있다.
import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.Headers;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URL;
import java.util.Date;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3 amazonS3Client;
// 버킷 이름
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Transactional(readOnly = true)
public GetS3UrlDto getPostS3Url(Long memberId, String filename) {
// filename 설정하기(profile 경로 + 멤버ID + 랜덤 값)
String key = "profile/" + memberId + "/" + UUID.randomUUID() + "/" + filename;
// url 유효기간 설정하기(1시간)
Date expiration = getExpiration();
// presigned url 생성하기
GeneratePresignedUrlRequest generatePresignedUrlRequest =
getPostGeneratePresignedUrlRequest(key, expiration);
URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest);
// return
return GetS3UrlDto.builder()
.preSignedUrl(url.toExternalForm())
.key(key)
.build();
}
@Transactional(readOnly = true)
public GetS3UrlDto getGetS3Url(Long memberId, String key) {
// url 유효기간 설정하기(1시간)
Date expiration = getExpiration();
// presigned url 생성하기
GeneratePresignedUrlRequest generatePresignedUrlRequest =
getGetGeneratePresignedUrlRequest(key, expiration);
URL url = amazonS3Client.generatePresignedUrl(generatePresignedUrlRequest);
// return
return GetS3UrlDto.builder()
.preSignedUrl(url.toExternalForm())
.key(key)
.build();
}
/* post 용 URL 생성하는 메소드 */
private GeneratePresignedUrlRequest getPostGeneratePresignedUrlRequest(String fileName, Date expiration) {
GeneratePresignedUrlRequest generatePresignedUrlRequest
= new GeneratePresignedUrlRequest(bucket, fileName)
.withMethod(HttpMethod.PUT)
.withKey(fileName)
.withExpiration(expiration);
generatePresignedUrlRequest.addRequestParameter(
Headers.S3_CANNED_ACL,
CannedAccessControlList.PublicRead.toString());
return generatePresignedUrlRequest;
}
/* get 용 URL 생성하는 메소드 */
private GeneratePresignedUrlRequest getGetGeneratePresignedUrlRequest(String key, Date expiration) {
return new GeneratePresignedUrlRequest(bucket, key)
.withMethod(HttpMethod.GET)
.withExpiration(expiration);
}
private static Date getExpiration() {
Date expiration = new Date();
long expTimeMillis = expiration.getTime();
expTimeMillis += 1000 * 60; // 1시간으로 설정하기
expiration.setTime(expTimeMillis);
return expiration;
}
}
GET용 presigned url 사용하기
직접 API가 호출되는 PUT용 presigned url과 달리, GET용은 이렇게 다른 API (예를 들어, 프로필 조회)에서 service단의 로직만 활용된다. 아래는 프로필 조회 API call이 일어나면, DB에 저장된 프로필 사진의 key를 가지고 S3Service의 getGetS3Url 로직을 호출해 GET용 presigned url을 발급받고, 이를 DTO에 담아서 클라이언트에게 응답해주는 로직을 구현한 것이다.
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class ProfileService {
private final ProfileRepository profileRepository;
private final S3Service s3Service;
@Transactional(readOnly = true)
public GetProfileDto getProfile(Long memberId, String email) {
Profile profile = profileRepository.findProfileByMember_Id(memberId);
GetProfileDto getProfileDto = GetProfileDto.builder()
.pictureUrl(createPictureUrl(memberId, profile))
.pictureKey(profile.getPictureKey())
.build();
return getProfileDto;
}
/* S3에 사진이 있다면 presigned URL로 보내주기 */
private String createPictureUrl(Long memberId, Profile profile){
return s3Service.getGetS3Url(memberId,
profile.getPictureKey()).getPreSignedUrl();
}
}
더 자세한 코드를 보고 싶다면, 아래 경로에서 확인할 수 있다.