파일을 업로드하고 다운로드 하는 것은 인터넷에서 우리가 하는 작업 중 아주 큰 비중을 차지합니다. 친구들과 찍은 사진을 업로드 하거나 필요한 프로그램을 다운 받는 일은 지금 이 순간에도 끊임없이 일어나고 있죠. 우리가 파일을 다운받기 위해서는 해당 파일이 인터넷 상의 오브젝트 스토리지에 저장되어 있어야 합니다.
이번 포스트에서는 오브젝트 스토리지에 대해 알아보고 실제로 프론트엔드, 백엔드 어플리케이션을 구성해 파일을 실제로 업/다운로드하는 실습까지 진행해보겠습니다.
구글 클라우드는 오브젝트 스토리지(객체 스토리지)를 다음과 같이 정의하고 있습니다.
객체 스토리지는 구조화되지 않은 데이터를 저장하기 위한 데이터 스토리지 아키텍처이며, 데이터를 여러 단위(객체)로 분할하고 이를 구조적으로 플랫 데이터 환경에 저장합니다. 각 객체에는 쉬운 액세스 및 검색을 위해 애플리케이션이 사용할 수 있는 데이터, 메타데이터 및 고유 식별자가 포함됩니다. 추가 문서
복잡해 보이는 내용이지만 간단히 정리하면 우리가 가지고 있는 다양한 형태(이미지, 영상, 문서 등)의 파일을 외부의 장소에 보관하고 언제 어디서든 접근할 수 있다는 것을 의미합니다. 이미 많이 사용되고 있는 구글 드라이브, 드롭박스, 네이버 마이박스 등도 오브젝트 스토리지의 예로 볼 수 있죠. 하지만 어플리케이션 개발자가 오브젝트 스토리지를 활용하기 위해서는 각 파일을 식별할 수 있는 메타 데이터나 HTTP(S) 방식으로 접근할 수 있는 프로토콜 등이 지원되어야 합니다. 이 때 일반적으로 많이 사용하는 것이 주요 CSP에서 제공하는 오브젝트 스토리지 서비스이며, 대표적인 상품은 아래와 같습니다.
위와 같은 서비스는 대용량 데이터를 안정적으로 딜리버리 할 뿐만 아니라 데이터가 외부로 유출되지 않도록 하는 다양한 보안 기술이 적용되어 있습니다. 이 뿐만 아니라 데이터가 유실되지 않도록 백업하는 기능도 갖추고 있죠. 다만, 용량과 트래픽에 따라 비용이 책정되기 때문에 서비스 규모, 파일 사용 방식 및 파일의 크기에 따라 큰 돈을 지출할 수 있습니다.
인터넷에서 사용하는 자원은 모두 어떤 서버에 물리적으로 저장된 것을 다운로드하여 사용하는 것입니다. 무선으로 사용하는 것에 익숙해져 있지만 사실 인터넷망은 케이블을 기반으로 컴퓨터가 연결되어 있는 거대한 망 위에 구축된 세계이죠. 전 세계를 가로지르는 해저 케이블을 한눈에 볼 수 있는 Submarine Cable Map 에 접속해보면 이렇게나 많은 케이블에 의해 전세계가 연결되어 있다는 것에 놀라게 됩니다.
한 가지 상황을 가정해 볼까요? 한국에서 미국에 호스팅 되어있는 서비스나 자원에 접근하기 위해서는 케이블을 따라서 요청과 응답을 주고받게 됩니다. 하지만 그 거리가 너무 멀기 때문에 자원이 로드되는 데에 지연(latency)가 생길 수 밖에 없습니다. 그렇다고 전세계 모든 지역에 서비스를 호스팅하는 것은 천문학적인 비용이 들 뿐만 아니라 관리하기에도 매우 까다로워집니다. 이 때 CDN을 활용하면 합리적인 비용으로 데이터를 딜리버리 하면서도 속도와 안정성을 향상시킬 수 있습니다.
CDN을 구성하는 일반적인 방법은 각 지역에 네트워크 에지를 두고 원본 데이터를 캐싱하도록 하는 것입니다. 굳이 먼 거리의 서버까지 가지 않아도 가장 가까운 네트워크 에지에서 사용자가 요청한 데이터를 받아올 수 있는 것이죠. 우리가 초고화질의 영상을 끊김이나 버퍼링 없이 스트리밍 방식으로 시청할 수 있게 된 것도 CDN이 적지 않은 역할을 하였습니다.
Cloudflare는 DDoS 방어 솔루션, CDN(Content Delivery Network), DNS 및 오브젝트 스토리지 등을 제공하는 클라우드 전문 기업입니다. 특히 CDN은 Cloudflare의 핵심 서비스라고 할 수 있는데요, OTT나 SNS와 같은 대용량 미디어 스트리밍 서비스가 전세계적으로 인기를 얻고 있는 만큼 더욱 그 중요성이 커지고 있습니다.
R2는 Cloudflare에서 제공하고 있는 오브젝트 스토리지 서비스입니다. 2024년 4월 2일 기준, 월 10GB의 스토리지 공간을 무료로 제공하고 있으며, AWS S3의 SDK를 그대로 활용할 수 있다는 것이 큰 장점으로 꼽힙니다. 그 뿐만 아니라 Cloudflare의 Global DNS를 통합하여 제공하고 있기 때문에 별도의 설정이 없이도 사용자와 가장 가까운 네트워크 엣지에서 데이터를 가져올 수 있습니다. 이어지는 실습에서는 Spring Boot, React, R2로 간단한 파일 업/다운로드 어플리케이션을 구성해보겠습니다.
실습은 아래 저장소의 Spring Boot, React 어플리케이션을 통해 진행됩니다. 저장소를 clone 하거나 fork 해주세요.
Cloudflare에 로그인 후 R2 대시보드에 진입합니다. 이어서 우측 상단 계정 ID 아래의 R2 API 토큰 관리 버튼을 누릅니다.
우측 상단의 API 토큰 생성 버튼을 누릅니다.
토큰의 이름은 식별할 수 있는 것으로 자유롭게 작성하고 권한은 관리자 읽기 및 쓰기를 선택합니다. TTL에 희망하는 토큰 활성 기간을 선택하고 API 토큰 생성 버튼을 누릅니다.
토큰 생성이 완료되면 클라이언트 사용에 필요한 계정 정보 및 엔드포인트가 표시됩니다. 이 중 실습에서는 표시된 S3 클라이언트 접속 정보와 엔드포인트가 활용되며, 페이지를 벗어나면 인증 정보는 재조회가 블가능하므로 안전한 곳에 값을 저장해놓습니다. AWS S3와의 대응관계는 다음과 같습니다.
대시보드의 R2 페이지로 돌아와 버킷 생성 버튼을 누릅니다.
버킷 이름을 적절하게 입력하고 위치는 자동으로 선택합니다. 이어서 버킷 생성 버튼을 누릅니다.
버킷 생성이 완료된 후 설정 탭에 접속하면 다음과 같이 정보를 확인할 수 있습니다. 위치 항목에 표시된 APAC는 S3 클라이언트에서 Region 값으로 활용됩니다.
클라우드타입에 로그인 후 우측 네비바의 ➕ 버튼을 눌러 새 프로젝트 창을 띄우고 프로젝트 이름과 표시 이름을 입력한 뒤 생성하기 버튼을 누릅니다.
build.gradle
파일은 다음과 같습니다. Cloudflare R2가 AWS SDK와 호환되므로 com.amazonaws:aws-java-sdk-s3:1.12.691
가 사용되었습니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'io.cloudtype'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.691'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
S3 클라이언트 관련 설정을 담당하는 S3Config.java
파일의 내용은 다음과 같습니다. AWS S3를 연동하는 설정과 거의 비슷하지만 AmazonS3ClientBuilder
에 withEndpointConfiguration()
메서드를 적용하여 엔드포인트를 R2로 변경해주어야 하는 부분에 유의해야 합니다.
package io.cloudtype.springfileupload.config;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
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("${aws.s3.endpointUrl}")
private String s3EndpointUrl;
@Value("${aws.s3.region}")
private String s3Region;
@Value("${aws.accessKey}")
private String awsAccessKey;
@Value("${aws.secretKey}")
private String awsSecretKey;
@Bean
public AmazonS3 s3Client() {
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(awsAccessKey, awsSecretKey);
return AmazonS3ClientBuilder
.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(s3EndpointUrl, s3Region))
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
}
FileUploadController.java
에는 R2와 통신하여 파일을 업/다운로드 할 수 있는 라우팅 규칙을 구성하였습니다.
package io.cloudtype.springfileupload.controller;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import io.cloudtype.springfileupload.service.FileUploadService;
import lombok.extern.log4j.Log4j2;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@Log4j2
@RequestMapping("/api")
public class FileUploadController {
private final FileUploadService fileUploadService;
public FileUploadController(FileUploadService fileUploadService) {
this.fileUploadService = fileUploadService;
}
@GetMapping
public String status() {
return "OK";
}
@PostMapping(path = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public String uploadFile(@RequestParam("file")MultipartFile file) throws IOException {
fileUploadService.uploadFile(file.getOriginalFilename(), file);
return "파일이 스토리지에 업로드 되었습니다.";
}
@GetMapping("/download/{fileName}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) throws AmazonS3Exception{
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new InputStreamResource(fileUploadService.getFile(fileName).getObjectContent()));
}
}
프로젝트 설정에 진입하여 변수-시크릿 항목에 다음과 같이 R2 토큰 정보를 입력 후 저장합니다.
클라우드타입의 프로젝트 페이지에서 ➕ 버튼을 누르고 Spring Boot를 선택한 후, 미리 fork 해놓은 r2-fileupload 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.
AWS_ACCESS_KEY
: 열쇠 아이콘 => aws-access-keyAWS_SECRET_KEY
: 열쇠 아이콘 => aws-secret-keyAWS_S3_BUCKET
: 생성한 R2 버킷이름AWS_S3_ENDPOINT_URL
: 열쇠 아이콘 => aws-s3-endpointAWS_S3_REGION
: APAC배포가 완료되면 Spring Boot 어플리케이션 페이지의 연결 탭에서 https://
로 시작되는 URL을 확인합니다. 추후 프론트엔드에서 API를 호출하는 주소로 사용됩니다.
➕ 버튼을 누르고 React 템플릿을 선택한 후, 미리 fork 해놓은 r2-fileupload 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.
REACT_APP_FILE_UPLOAD_ENDPOINT
: [Spring Boot URL]/api/uploadREACT_APP_FILE_DOWNLOAD_ENDPOINT
: [Spring Boot URL]/api/download배포가 완료되면 접속하기 버튼을 눌러 정상적으로 페이지가 로드되는지 확인합니다.
프론트엔드 페이지에 접속한 후 파일을 드래그 앤 드롭하여 업로드 영역으로 옮깁니다.
업로드 대상 파일이 하단에 표시되며, 업로드를 하거나 파일 선택 취소 작업을 할 수 있습니다. 업로드 버튼을 누릅니다.
정상적으로 파일이 업로드 되면 아래와 같은 알림창이 표시됩니다.
위에서 업로드 했던 파일을 다운로드 할 수 있는 버튼이 하단에 생성되었습니다. 다운로드 버튼을 누릅니다.
다운로드가 정상적으로 수행되면 브라우저의 다운로드 창에서 해당 파일을 확인할 수 있습니다.