[Spring Boot] Signed URL + Google Cloud Storage

이홍준·2025년 3월 3일
1

Spring Boot

목록 보기
13/13

개요

첨부파일들을 관리하는데 모든 역할을 서버가 담당한다면, 서버의 부하가 엄청 크게 발생할것이라고 봅니다.

그래서 Signed URL 방식을 통해 보안성을 유지하며 서버에 대한 부하를 줄이고자 합니다.

해당글은 Google Cloud Platform을 기준으로 작성되었음을 알려드립니다.

Workflow

대략적인 순서는 이렇습니다

  1. Client → API : URL 요청
  2. API → Storage : URL 생성 요청
  3. Storage → API: URL 반환
  4. API → Client: URL 반환
  5. Client → Storage: Upload or Download 직접 수행

고려사항

  • 유효시간이 너무 길면 위험하기때문에 5~10분정도로 설정
  • URL이 로그에 남거나 캐싱되지 않도록 주의
  • 요청을 특정 사용자나 IP로 제한

방법

  1. GCP 접속

  2. IAM 및 관리자 접속

    • 권한관련 설정을 위해 IAM 및 관리자에 접속합니다.

  3. 서비스 계정 생정

    • 서비스 계정 탭으로 이동합니다.
    • 서비스 계정 만들기를 클릭합니다.
    • 세부정보를 입력해줍니다.
    • Google Cloud Storage 관련 권한을 넣어줍니다. (저장소 관리자)

  4. 서비스계정 키 만들기

    • 생성된 서비스 계정을 클릭합니다.
    • 키 탭을 누릅니다.
    • 새 키 만들기를 클릭해줍니다.
    • JSON으로 만듭니다.
    • 키 생성된후 화면

  5. Cloud Storage에서 버킷을 생성해줍니다.






API 실습

  1. appilication.yml 설정

    spring:
      application:
        name: file-api
      cloud:
        gcp:
            credentials:
              location: classpath:gcs-key.json # gcs-key.json 파일 경로
            project-id: [프로젝트 명]
            bucket: gcs-test-bucket123 // 버킷 이름 
    
    server:
      port: 8888
    
  2. build.gradle 작성

    plugins {
        id 'java'
        id 'org.springframework.boot' version '3.4.3'
        id 'io.spring.dependency-management' version '1.1.7'
    }
    
    group = 'com.example'
    version = '0.0.1-SNAPSHOT'
    
    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(17)
        }
    }
    
    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }
    
    repositories {
        mavenCentral()
    }
    
    ext {
        set('springCloudGcpVersion', "6.0.0")
        set('springCloudVersion', "2024.0.0")
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'com.google.cloud:spring-cloud-gcp-starter-storage'
        annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    }
    
    dependencyManagement {
        imports {
            mavenBom "com.google.cloud:spring-cloud-gcp-dependencies:${springCloudGcpVersion}"
            mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
        }
    }
    
    tasks.named('test') {
        useJUnitPlatform()
    }
    
  3. gcs-key.json 파일 예시

    {
      "type": "service_account",
      "project_id": "",
      "private_key_id": "",
      "private_key": "-----BEGIN PRIVATE KEY-----\n",
      "client_email": "",
      "client_id": "",
      "auth_uri": "https://accounts.google.com/o/oauth2/auth",
      "token_uri": "https://oauth2.googleapis.com/token",
      "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
      "client_x509_cert_url": "",
      "universe_domain": "googleapis.com
    }
    

  1. GcpProperties.java - properties 받을 클래스 정의

    package com.example.fileapi.config;
    
    import lombok.Getter;
    import lombok.Setter;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.context.annotation.Configuration;
    
    @Getter
    @Setter
    @Configuration
    @ConfigurationProperties(prefix = "spring.cloud.gcp")  // 👈 YAML의 해당 부분 매핑
    public class GcpProperties {
        private Credentials credentials;
        private String projectId;
        private String bucket;
    
        @Getter
        @Setter
        public static class Credentials {
            private String location;
        }
    }
    
  2. GcpConfig.java - Cloud Storage 설정 클래스

    import com.google.auth.oauth2.GoogleCredentials;
    import com.google.cloud.storage.Storage;
    import com.google.cloud.storage.StorageOptions;
    import lombok.RequiredArgsConstructor;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.util.ResourceUtils;
    
    import java.io.IOException;
    import java.io.InputStream;
    
    @Configuration
    @RequiredArgsConstructor
    public class GcpConfig {
        private final GcpProperties gcpProperties;
        @Bean
        public Storage googleCloudStorage() throws IOException {
            InputStream keyFile = ResourceUtils.getURL(gcpProperties.getCredentials().getLocation())
                    .openStream(); // keyfile 
            return StorageOptions.newBuilder()
                    .setProjectId(gcpProperties.getProjectId())
                    .setCredentials(GoogleCredentials.fromStream(keyFile))
                    .build()
                    .getService();
        }
    }
  3. Controller

    package com.example.fileapi.controller;
    
    import com.example.fileapi.dto.SignedUrlRequest;
    import com.example.fileapi.service.SignedUrlService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;
    
    @RestController
    @RequestMapping("/api/v1/files")
    @RequiredArgsConstructor
    public class FileController {
    
        private final SignedUrlService signedUrlService;
    
        @PostMapping("/signed-url/upload")
        public ResponseEntity<?> generateUpLoadSignedUrl(@RequestBody SignedUrlRequest signedUrlRequest) {
            return ResponseEntity.ok(signedUrlService.generateUploadSignedUrl( signedUrlRequest));
        }
        @PostMapping("/signed-url/download")
        public ResponseEntity<?> generateDownLoadSignedUrl(@RequestBody SignedUrlRequest signedUrlRequest) {
            return ResponseEntity.ok(signedUrlService.generateDownloadSignedUrl( signedUrlRequest));
        }
    
    }
    
  4. SignedUrlRequest.java - 서명된 URL을 받을 Request DTO

    public record SignedUrlRequest(String filePath) {
    }
  5. SignedUrlService.java - 실제 동작하는 서비스

    package com.example.fileapi.service;
    
    import com.example.fileapi.config.GcpProperties;
    import com.example.fileapi.dto.SignedUrlRequest;
    import com.google.cloud.storage.BlobInfo;
    import com.google.cloud.storage.HttpMethod;
    import com.google.cloud.storage.Storage;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    
    import java.net.URL;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;
    
    @Service
    @RequiredArgsConstructor
    public class SignedUrlService {
    
        private final Storage storage;
        private final GcpProperties gcpProperties;
    
        /**
         * 📌 업로드용 Signed URL 생성 (PUT 요청)
         * @param signedUrlRequest 파일 경로
         * @return Signed URL (5분간 유효)
         */
        public String generateUploadSignedUrl(SignedUrlRequest signedUrlRequest) {
            try {
                BlobInfo blobInfo = BlobInfo.newBuilder(gcpProperties.getBucket(), signedUrlRequest.filePath()).build();
                URL signedUrl = storage.signUrl(
                        blobInfo,
                        5,
                        TimeUnit.MINUTES,
                        Storage.SignUrlOption.httpMethod(HttpMethod.PUT), // upload는 PUT or POST
                        Storage.SignUrlOption.withV4Signature() // V4 서명 방식 사용,
                );
                return signedUrl.toString();
            } catch (Exception e) {
                throw new RuntimeException("서명된 URL 생성 실패", e);
            }
        }
    
        /**
         * 📌 다운로드용 Signed URL 생성 (GET 요청)
         * @param signedUrlRequest 파일 경로
         * @return Signed URL (5분간 유효, 브라우저에서 다운로드 가능)
         */
        public String generateDownloadSignedUrl(SignedUrlRequest signedUrlRequest) {
            try {
                BlobInfo blobInfo = BlobInfo.newBuilder(gcpProperties.getBucket(), signedUrlRequest.filePath()).build();
                URL signedUrl = storage.signUrl(
                        blobInfo,
                        5,
                        TimeUnit.MINUTES,
                        Storage.SignUrlOption.httpMethod(HttpMethod.GET), // download는 GET
                        Storage.SignUrlOption.withV4Signature(), // V4 서명 방식 사용,
                        Storage.SignUrlOption.withQueryParams(Map.of("response-content-disposition", "attachment")) // 브라우저 접속시 다운로드
                );
                return signedUrl.toString();
            } catch (Exception e) {
                throw new RuntimeException("서명된 URL 생성 실패", e);
            }
        }
    
    }
    

업로드 실습

  1. API 요청-업로드용 URL

  2. 업로드

  3. 업로드 확인

다운로드 실습

  1. 다운로드 URL 요청

  2. 다운로드

  3. 브라우저에서 URL 입력


Reference

profile
I'm not only a web developer.

1개의 댓글

comment-user-thumbnail
2025년 9월 24일

글 올려주세요 기다리는중...

답글 달기