최근 GCP의 GCS 파일 업로드를 구현하며, 다른 점을 정리해보고 싶다는 생각이 들어 AWS와 GCP 각각의 파일 업로드 방법 및 차이를 정리해보고자 한다.
AWS S3 Presigned Url 문서를 보면 Presigned Url의 정의를 알 수 있다.
문서를 읽어보았을 때에는 두 기능의 차이가 정확히 존재하지 않는 것으로 보여, GPT와 Claude에게 물어봤다.
첫 번째가 GPT, 두 번째가 Claude의 답변이다.
결론적으로 보자면, 동일하게 안전하게 임시 액세스를 제공한다는 관점의 기능이라는 것을 알 수 있었다.
이제 차이가 크게 없다는 것을 알았고, 왜 사용해야 하는가에 대하여 알아보려고 한다.
(Presigned와 Signed를 중복해서 적는 것이 불편하기에, 여기서는 Presigned로 통칭한다. / 이에 더해 아래에서의 스토리지는 GCS/S3를 말한다.)
Presigned를 사용하지 않을 때 업로드 방식
1. 클라이언트에서 사용자의 파일 입력
2. 클라이언트가 서버에게 파일 전송
3. 서버는 객체 스토리지에 파일 업로드
4. 객체 스토리지에서 업로드된 URL 정보를 서버에게 반환
5. 서버는 해당 URL와 필요한 다른 정보들을 함께 처리 후 응답
Presigned를 사용할 때 업로드 방식
1. 클라이언트에서 사용자의 파일 입력
2. 클라이언트는 서버에게 Presigned URL 발급 요청
3. 서버는 객체 스토리지에게 Presigned URL 발급 요청
4. 객체 스토리지는 Presigned URL을 반환
5. 서버는 Presigned URL을 클라이언트에게 반환
6. 클라이언트는 Presigned URL로 파일 업로드 진행
7. 업로드한 이후, 해당 경로 정보를 서버에게 전달
8. 서버는 관련한 나머지 비즈니스 로직 처리
위 두 방식에서 가장 큰 차이점은 파일이 서버에게 전달되는지에 대한 유무이다.
Presigned를 사용하지 않는다면, 서버에게 파일이 전달되는데, 서버는 용량이 큰 파일을 내부적으로 들고 있으면서 처리를 해야 한다. Spring Boot 기준으로, 10MB의 용량 제한을 기준으로 10MB 이상의 데이터는 메모리에 저장한 이후 처리를 하고 있는데, 이는 파일 업로드 요청이 많아진다면 직접적인 서버 부하로 이어지게 된다.
Presigned를 사용한다면, 직접적인 서버 부하를 최소화 및 파일 용량의 제한이 사라진다는 점이 장점이다.
Q. 클라이언트에서 바로 업로드하면 안 되나요?
A. 클라이언트에서 서버를 통해 Presigned URL을 발급받는 이유는 보안 때문이다. 즉, 인가된 사용자 또는 권한을 받은 사용자만 업로드할 수 있어야 한다는 점에서 Presigned URL을 통해 업로드해야 한다는 것이다.
Presigned URL을 사용하는 방법으로는 크게 두 가지가 있다.
업로드
파일을 Presigned URL로 Put 요청을 통해 업로드할 수 있다.
다운로드
기존에 스토리지에 존재하는 파일에 대해, 지정된 시간동안 해당 URL로 객체 접근 및 다운로드가 가능하다.
먼저, Signed URL을 발급받기 위해서는 버킷을 생성해야 한다. (프로젝트는 미리 만들어둔 상황이라고 가정한다.)
Cloud Storage 콘솔에 접근 이후, 좌측 상단의 만들기를 통해 생성할 수 있다.
버킷 관련된 설정은 위와 같이 진행했다.
생성된 버킷은 위와 같다.
데이터의 스토리지 클래스 선택
과거 사용할 때는 별 생각 없이 생성했던 부분이다. 최근가상 면접 사례로 배우는 대규모 시스템 설계 기초 2
로 스터디를 진행하며, S3도 접근 빈도 등에 따라 6개 내외의 클래스로 나뉘는데, GCS도 동일함을 알 수 있다.
생성한 버킷에 접근하여 Presigned URL을 발급할 수 있는 권한을 가진 서비스 계정을 생성해야 한다.
좌측 상단의 리스트르 클릭하여, IAM 및 관리자 -> 서비스 계정에 접속한다.
저장소 개체 생성자
, 저장소 관리자
, 저장소 개체 관리자
권한을 추가한 뒤 계정 생성을 완료하면 아래와 같이 서비스 계정이 생성되어 있는 것을 확인할 수 있다.
서비스 계정을 생성한 이후에는, 해당 권한을 가지고 있는 키를 생성해야 한다.
여기서 생성한 키는 json파일로, 개발할 때 권한 인증된 객체를 생성할 때 활용하게 된다.
접근 권한별 내용은 GCP IAM Docs에서 확인할 수 있다.
개발 과정은 GCP Docs에 있는 샘플 코드를 최대한 활용한다.
implementation group: 'com.google.cloud', name: 'google-cloud-storage', version: '2.40.1'
Spring Boot에서 GCS의 Signed URL을 발급받고자 한다면, Google Cloud Storage 의존성을 주입해야 한다. 다만, 위 이미지와 같이 Spring Boot 버전이 제한되어 있으므로 꼭 확인해야 한다.
이전에 발급받은 json 확장자의 키를 resources 하위에 위치시킨다.
@Configuration
public class GcsConfig {
@Value("${spring.cloud.gcp.credentials.location}")
private String location;
@Value("${spring.cloud.gcp.storage.project-id}")
private String projectId;
@Bean
public Storage storage() throws IOException {
ClassPathResource resource = new ClassPathResource(location);
InputStream inputStream = resource.getInputStream();
GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream);
return StorageOptions.newBuilder()
.setCredentials(credentials)
.setProjectId(projectId)
.build()
.getService();
}
}
@Service
@RequiredArgsConstructor
public class GcsService {
private final Storage storage;
@Value("${spring.cloud.gcp.storage.bucket}")
private String bucketName;
public String generateSignedURL(String objectName) {
BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucketName, objectName)).build();
Map<String, String> extensionHeaders = new HashMap<>();
String contentType = getContentType(objectName);
extensionHeaders.put("Content-Type", contentType);
URL url = storage.signUrl(blobInfo,
15,
TimeUnit.MINUTES,
Storage.SignUrlOption.httpMethod(HttpMethod.PUT),
Storage.SignUrlOption.withExtHeaders(extensionHeaders),
Storage.SignUrlOption.withV4Signature());
return url.toString();
}
private static String getContentType(String objectName) {
String contentType;
// 파일 이름에서 확장자 추출
int lastDotIndex = objectName.lastIndexOf('.');
if (lastDotIndex != -1) {
String extension = objectName.substring(lastDotIndex + 1).toLowerCase();
contentType = switch (extension) {
case "pdf" -> "application/pdf";
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
default -> "application/octet-stream"; // 기본값
};
} else {
contentType = "application/octet-stream"; // 확장자가 없는 경우 기본값
}
return contentType;
}
}
Content-Type
Service에서 GCP Docs와 다르게 Content-Type을 파일명을 받아서 구분하고 있다.
application/octet-stream
으로 Signed URL을 발급하면,SignatureDoesNotMatch
라는 오류가 발생했기 때문이다.
여기서는, 학습의 목적으로 진행했기에 오류가 발생한 것을 알리기 위해 위의 코드를 의도적으로 변경하지 않았다.
서버를 실행 후, API 요청을 보내면 아래와 같이, 발급할 때 사용하는 Signed URL을 받을 수 있다.
발급받은 URL로 Put 요청을 보내면 아래와 같이 성공으로 응답이 온다. GCS를 접근해보면, 정상적으로 잘 업로드되었음을 확인할 수 있다.
S3 자체에 대하여, 어떤 권한을 가지도록 할 것인지를 설정해야 한다.
위 이미지의 정책 생성기를 클릭하여 정책을 생성해야 한다.
나는 위와 같이 진행했으며, ARN에는 버캣 정책 편집이라는 페이지에 작성되어 있는 ARN을 복사하여 사용하면 된다.
Presigned URL은 결국 외부에서 업로드 및 다운로드를 진행하기에 CORS 오류가 발생하게 된다. CORS 정책을 적용하여 오류가 발생하지 않도록 처리해야 한다.
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
학습 목적이기에, 별도의 추가 설정 없이 와일드카드로 모든 접근을 허용했다.
버킷을 생성했다면, 업로드/다운로드 등의 권한을 가지고 있는 IAM을 생성해야 한다. S3와 관련된 역할만 필요하기에 AmazonS3FullAccess
권한만 설정해주었다.
기존에 사용 중이던 사용자가 있다면, 해당 사용자에 위 권한을 추가하여 활용하시면 된다.
사용자 생성 후, 해당 사용자 페이지로 접근하여 액세스 키를 발급받으면 된다.
액세스 키는 외부에 노출되면 안되므로, 꼭 Github에 올라가지 않도록 암호화 또는 .gitignore 설정을 해야 한다.
implementation group: 'io.awspring.cloud', name: 'spring-cloud-starter-aws', version: '2.4.4'
cloud:
aws:
s3:
bucket: {BUCKET_NAME}
region:
static: ap-northeast-2
credentials:
access-key: {ACCESS_KEY}
secret-key: {SECRET_KEY}
@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 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
.build();
}
}
@Service
@RequiredArgsConstructor
public class S3Service {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
public String generatePreSignedUrl(String fileName) {
// AWS Presigned URL 발급을 위한 요청 객체 생성
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucket, fileName)
.withMethod(HttpMethod.PUT) // 업로드이므로 PUT
.withExpiration(new Date(System.currentTimeMillis() + 10 * 60 * 1000)); // URL 유효기간 설정 (10분)
generatePresignedUrlRequest.addRequestParameter(
Headers.S3_CANNED_ACL, // ACL 설정
CannedAccessControlList.PublicRead.toString()); // 공개 읽기 권한 적용
// Presigned URL 발급
URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
return url.toString();
}
}
성공적으로 업로드된 것을 확인할 수 있다.
not allow ACLs, Access Denied
ACL 문제는 버켓-권한-객체 소유권 설정을 ACL 활성화됨으로 변경해야 한다.
Access Denied 문제는 버켓-권한-퍼블릭 액세스 차단(버킷 설정)을 변경해보면서 확인해야 한다.
개발한 코드는 Github Repository에서 확인할 수 있다.
더 고려해야 할 점
실제 서비스 개발 과정에서 사용할 경우, 아래와 같은 점을 추가로 고려해야 한다.
파일이름이 중복되어, 덮어쓰는 상황이 발생할 수 있다. GCS 기준,동일한 파일명일 경우 최신 파일로 덮어쓰게 되기에, 이러한 상황을 예방하기 위해서는 UUID를 생성하여 이를 파일명에 붙여야 한다. 즉, 파일 테이블에 원본 파일명은 별도로 관리해야 할 수 있다는 것이다. 이 문제는, 아래의 디렉토리 경로 설정 문제와 같이 고려애야 한다.
업로드하고자 하는 파일이 기능별로 구분되어야 할 경우, 업로드되는 경로를 파일이름 앞에 추가해야 한다. 이렇게 구분하지 않을 경우, 어떤 기능에 대해 업로드된 파일인지를 인지하기 어려워 더 많은 비효율을 야기할 것이다. 이 문제는 위의 파일 이름 중복 문제와 함께 고려해서 적용해야 한다.
후기
GCS 오류를 마주쳐서, 이를 해결하는 김에 S3도 같이 정리를 진행했다. 재사용할 수 있는 코드를 만들어놓을 수 있다는 점과 생각하고 있었던 내용을 정리해볼 수 있어서 좋은 기회였다.