From 82b86163bd33f038746b9381e751707539d15fc9 Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 16:52:30 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20AWS->GCP=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 18 +- .github/workflows/deploy.yml | 71 ++--- Dockerfile | 5 + build.gradle | 6 +- docker-compose.yaml | 18 +- .../asset/service/AssetsServiceImpl.java | 4 +- .../conversation/service/ChatServiceImpl.java | 4 +- .../service/ConversationQueryServiceImpl.java | 4 +- .../domain/note/service/NoteServiceImpl.java | 4 +- .../storage/service/StorageService.java | 4 +- .../domain/user/service/UserService.java | 4 +- .../com/proovy/global/config/GcsConfig.java | 39 +++ .../com/proovy/global/config/S3Config.java | 43 --- .../global/infra/gcs/GcsHealthController.java | 90 ++++++ .../proovy/global/infra/gcs/GcsService.java | 68 +++++ .../global/infra/gcs/GcsServiceImpl.java | 181 ++++++++++++ .../global/infra/s3/S3HealthController.java | 93 ------ .../com/proovy/global/infra/s3/S3Service.java | 68 ----- .../proovy/global/infra/s3/S3ServiceImpl.java | 274 ------------------ .../infra/thumbnail/ThumbnailService.java | 6 +- src/main/resources/application.yaml | 18 +- .../storage/service/StorageServiceTest.java | 4 +- 22 files changed, 446 insertions(+), 580 deletions(-) create mode 100644 Dockerfile create mode 100644 src/main/java/com/proovy/global/config/GcsConfig.java delete mode 100644 src/main/java/com/proovy/global/config/S3Config.java create mode 100644 src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java create mode 100644 src/main/java/com/proovy/global/infra/gcs/GcsService.java create mode 100644 src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java delete mode 100644 src/main/java/com/proovy/global/infra/s3/S3HealthController.java delete mode 100644 src/main/java/com/proovy/global/infra/s3/S3Service.java delete mode 100644 src/main/java/com/proovy/global/infra/s3/S3ServiceImpl.java diff --git a/.env.example b/.env.example index f79f148..cfc65ad 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,11 @@ # =============================== -# 공통 AWS 설정 +# GCS (Google Cloud Storage) # =============================== -AWS_REGION=ap-northeast-2 - -# =============================== -# AWS S3 (PDF 저장) -# =============================== -# IAM 사용자의 S3 전용 Access Key -AWS_S3_ACCESS_KEY= -AWS_S3_SECRET_KEY= - -# S3 버킷 이름 -S3_BUCKET= +GCS_PROJECT_ID= +# 서비스 계정 JSON 키 전체를 한 줄 문자열로 입력 (로컬 개발용) +# Cloud Run에서는 서비스 계정을 직접 연결하므로 불필요 +GCS_CREDENTIALS_JSON= +GCS_BUCKET= # =============================== # Database (PostgreSQL) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e159444..7a834c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy Backend (SSM + S3) +name: Deploy to Cloud Run on: workflow_run: @@ -36,55 +36,32 @@ jobs: - name: Build (bootJar, skip tests) run: ./gradlew clean bootJar -x test - - name: Find bootJar (exclude plain) - run: | - ls -al build/libs - JAR_PATH="$(ls -1 build/libs/*.jar | grep -v plain | head -n 1)" - if [ -z "$JAR_PATH" ]; then - echo "ERROR: No boot jar found in build/libs" - exit 1 - fi - echo "JAR_PATH=$JAR_PATH" >> $GITHUB_ENV - echo "JAR_NAME=$(basename "$JAR_PATH")" >> $GITHUB_ENV - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} + credentials_json: ${{ secrets.GCP_SA_KEY }} - - name: Upload artifact to S3 - run: | - SHA="${{ github.event.workflow_run.head_sha }}" - KEY="${{ secrets.DEPLOY_KEY_PREFIX }}/${SHA}/${{ env.JAR_NAME }}" - echo "S3_KEY=$KEY" >> $GITHUB_ENV - aws s3 cp "${{ env.JAR_PATH }}" "s3://${{ secrets.DEPLOY_BUCKET }}/$KEY" + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 - - name: Deploy on EC2 via SSM + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev --quiet + + - name: Build and push Docker image run: | - DEPLOY_PATH="${{ secrets.DEPLOY_PATH }}" - SERVICE_NAME="${{ secrets.SERVICE_NAME }}" - BUCKET="${{ secrets.DEPLOY_BUCKET }}" - KEY="${{ env.S3_KEY }}" SHA="${{ github.event.workflow_run.head_sha }}" + IMAGE="${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.AR_REPO }}/${{ secrets.SERVICE_NAME }}:${SHA}" + echo "IMAGE=$IMAGE" >> $GITHUB_ENV + docker build -t "$IMAGE" . + docker push "$IMAGE" - # Flyway runs automatically on Spring Boot startup. - # Do not run `java -jar app.jar migrate` in this pipeline. - - aws ssm send-command \ - --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \ - --comment "Deploy backend ${SHA:0:7}" \ - --document-name "AWS-RunShellScript" \ - --parameters commands="[ - \"set -e\", - \"echo Deploying $KEY\", - \"sudo mkdir -p ${DEPLOY_PATH}\", - \"sudo chown -R ec2-user:ec2-user ${DEPLOY_PATH}\", - \"sudo aws s3 cp s3://${BUCKET}/${KEY} ${DEPLOY_PATH}/app.jar\", - \"sudo chmod 644 ${DEPLOY_PATH}/app.jar\", - \"sudo systemctl daemon-reload\", - \"sudo systemctl restart ${SERVICE_NAME}\", - \"sudo systemctl is-active ${SERVICE_NAME}\" - ]" \ - --output text + - name: Deploy to Cloud Run + run: | + gcloud run deploy ${{ secrets.SERVICE_NAME }} \ + --image "${{ env.IMAGE }}" \ + --region ${{ secrets.GCP_REGION }} \ + --platform managed \ + --allow-unauthenticated \ + --min-instances 1 \ + --set-env-vars "SPRING_PROFILES_ACTIVE=prod" \ + --project ${{ secrets.GCP_PROJECT_ID }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57e791f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index d711892..90521a9 100644 --- a/build.gradle +++ b/build.gradle @@ -50,9 +50,9 @@ dependencies { // Swagger (Springdoc OpenAPI) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15' - // AWS S3 SDK - implementation platform('software.amazon.awssdk:bom:2.20.26') - implementation 'software.amazon.awssdk:s3' + // Google Cloud Storage SDK + implementation platform('com.google.cloud:libraries-bom:26.34.0') + implementation 'com.google.cloud:google-cloud-storage' // PDF/이미지 처리 (썸네일 생성용) implementation 'org.apache.pdfbox:pdfbox:3.0.6' diff --git a/docker-compose.yaml b/docker-compose.yaml index f635e7e..fd01864 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,18 +1,14 @@ version: '3.8' services: - localstack: - image: localstack/localstack:latest - container_name: proovy-localstack + # GCS 로컬 에뮬레이터 (fake-gcs-server) + # 접속: http://localhost:4443/storage/v1/ + fake-gcs: + image: fsouza/fake-gcs-server:latest + container_name: proovy-fake-gcs ports: - - "4566:4566" - environment: - - SERVICES=s3 - - DEBUG=1 - - TMPDIR=/var/lib/localstack/tmp - volumes: - - "./localstack-data:/var/lib/localstack" - - "/var/run/docker.sock:/var/run/docker.sock" + - "4443:4443" + command: -scheme http -port 4443 -public-host localhost:4443 postgres: image: pgvector/pgvector:pg15 diff --git a/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java b/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java index 128a01b..f2b8f2e 100644 --- a/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java +++ b/src/main/java/com/proovy/domain/asset/service/AssetsServiceImpl.java @@ -13,7 +13,7 @@ import com.proovy.domain.user.entity.PlanType; import com.proovy.domain.user.repository.UserPlanRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.infra.thumbnail.ThumbnailService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; @@ -40,7 +40,7 @@ public class AssetsServiceImpl implements AssetsService { private final AssetRepository assetRepository; private final NoteRepository noteRepository; - private final S3Service s3Service; + private final GcsService s3Service; private final UserPlanRepository userPlanRepository; private final ThumbnailService thumbnailService; diff --git a/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java b/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java index e4fe714..c2c918f 100644 --- a/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java +++ b/src/main/java/com/proovy/domain/conversation/service/ChatServiceImpl.java @@ -26,7 +26,7 @@ import com.proovy.domain.user.entity.User; import com.proovy.domain.user.repository.UserRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -60,7 +60,7 @@ public class ChatServiceImpl implements ChatService { private final UserRepository userRepository; private final AssetRepository assetRepository; private final NoteRepository noteRepository; - private final S3Service s3Service; + private final GcsService s3Service; private final WebClient webClient; private final ObjectMapper objectMapper; private final CreditUseService creditUseService; diff --git a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java index 4a6a64d..e536041 100644 --- a/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java +++ b/src/main/java/com/proovy/domain/conversation/service/ConversationQueryServiceImpl.java @@ -13,7 +13,7 @@ import com.proovy.domain.note.entity.Note; import com.proovy.domain.note.repository.NoteRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +36,7 @@ public class ConversationQueryServiceImpl implements ConversationQueryService { private final ChatMessageRepository chatMessageRepository; private final AssetRepository assetRepository; private final NoteRepository noteRepository; - private final S3Service s3Service; + private final GcsService s3Service; private static final int PRESIGNED_URL_DURATION_MINUTES = 15; private static final Set ALLOWED_CANVAS_MIME_TYPES = Set.of("image/png", "image/jpeg", "image/webp"); diff --git a/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java b/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java index 8bab907..764ab51 100644 --- a/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java +++ b/src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java @@ -31,7 +31,7 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import java.time.LocalDateTime; import java.time.ZoneId; @@ -52,7 +52,7 @@ public class NoteServiceImpl implements NoteService { private final MessageToolRepository messageToolRepository; private final AssetRepository assetRepository; private final com.proovy.domain.user.repository.UserPlanRepository userPlanRepository; - private final S3Service s3Service; + private final GcsService s3Service; private final EmbeddingJobPublisher embeddingJobPublisher; private final GeminiClient geminiClient; diff --git a/src/main/java/com/proovy/domain/storage/service/StorageService.java b/src/main/java/com/proovy/domain/storage/service/StorageService.java index 4e8da59..c44008f 100644 --- a/src/main/java/com/proovy/domain/storage/service/StorageService.java +++ b/src/main/java/com/proovy/domain/storage/service/StorageService.java @@ -14,7 +14,7 @@ import com.proovy.domain.user.repository.UserPlanRepository; import com.proovy.domain.user.repository.UserRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +36,7 @@ public class StorageService { private final UserRepository userRepository; private final NoteRepository noteRepository; private final UserPlanRepository userPlanRepository; - private final S3Service s3Service; + private final GcsService s3Service; /** * 자산 일괄 삭제 diff --git a/src/main/java/com/proovy/domain/user/service/UserService.java b/src/main/java/com/proovy/domain/user/service/UserService.java index dfab0c7..4494e41 100644 --- a/src/main/java/com/proovy/domain/user/service/UserService.java +++ b/src/main/java/com/proovy/domain/user/service/UserService.java @@ -20,7 +20,7 @@ import com.proovy.domain.user.repository.UserPlanRepository; import com.proovy.domain.user.repository.UserRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -55,7 +55,7 @@ public class UserService { private final MessageAttachmentRepository messageAttachmentRepository; private final ChatMessageRepository chatMessageRepository; private final ChatSessionRepository chatSessionRepository; - private final S3Service s3Service; + private final GcsService s3Service; private final RefreshTokenRepository refreshTokenRepository; private final AccessTokenBlacklistService accessTokenBlacklistService; diff --git a/src/main/java/com/proovy/global/config/GcsConfig.java b/src/main/java/com/proovy/global/config/GcsConfig.java new file mode 100644 index 0000000..ff29dd4 --- /dev/null +++ b/src/main/java/com/proovy/global/config/GcsConfig.java @@ -0,0 +1,39 @@ +package com.proovy.global.config; + +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Configuration +public class GcsConfig { + + @Value("${gcs.project-id}") + private String projectId; + + @Value("${gcs.credentials-json:}") + private String credentialsJson; + + @Bean + public Storage gcsStorage() throws IOException { + StorageOptions.Builder builder = StorageOptions.newBuilder() + .setProjectId(projectId); + + if (credentialsJson != null && !credentialsJson.isBlank()) { + ServiceAccountCredentials credentials = ServiceAccountCredentials + .fromStream(new ByteArrayInputStream( + credentialsJson.getBytes(StandardCharsets.UTF_8))); + builder.setCredentials(credentials); + } + // credentialsJson 미설정 시 ADC(Application Default Credentials) 사용 + // Cloud Run에서는 서비스 계정이 자동 적용됨 + + return builder.build().getService(); + } +} diff --git a/src/main/java/com/proovy/global/config/S3Config.java b/src/main/java/com/proovy/global/config/S3Config.java deleted file mode 100644 index 99ef0c4..0000000 --- a/src/main/java/com/proovy/global/config/S3Config.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.proovy.global.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; - -@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(destroyMethod = "close") - public S3Client s3Client() { - AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); - - return S3Client.builder() - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } - - @Bean(destroyMethod = "close") - public S3Presigner s3Presigner() { - AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); - - return S3Presigner.builder() - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } -} diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java b/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java new file mode 100644 index 0000000..7652542 --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java @@ -0,0 +1,90 @@ +package com.proovy.global.infra.gcs; + +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import com.proovy.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +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.RestController; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "GCS Health Check", description = "GCS 연결 상태 확인 API (개발용)") +@Slf4j +@RestController +@RequestMapping("/api/health") +@RequiredArgsConstructor +public class GcsHealthController { + + private final Storage storage; + + @Value("${gcs.bucket}") + private String bucketName; + + @Operation( + summary = "GCS 연결 테스트", + description = "Google Cloud Storage 버킷 연결 상태를 확인합니다. 개발 및 테스트 용도로 사용됩니다." + ) + @GetMapping("/gcs") + public ResponseEntity>> checkGcsConnection() { + Map result = new HashMap<>(); + + try { + Bucket bucket = storage.get(bucketName); + + if (bucket == null) { + result.put("status", "ERROR"); + result.put("bucketName", bucketName); + result.put("accessible", false); + result.put("errorMessage", "버킷을 찾을 수 없습니다."); + + log.error("[GCS Health Check] 버킷 없음 - Bucket: {}", bucketName); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( + ApiResponse.success("GCS 연결 테스트 실패", result) + ); + } + + result.put("status", "OK"); + result.put("bucketName", bucketName); + result.put("accessible", true); + result.put("message", "GCS 버킷에 정상적으로 연결되었습니다."); + + log.info("[GCS Health Check] 성공 - Bucket: {}", bucketName); + return ResponseEntity.ok(ApiResponse.success("GCS 연결 테스트 성공", result)); + + } catch (StorageException e) { + result.put("status", "ERROR"); + result.put("bucketName", bucketName); + result.put("accessible", false); + result.put("errorCode", e.getCode()); + result.put("errorMessage", e.getMessage()); + + log.error("[GCS Health Check] 실패 - Bucket: {}, Code: {}, Error: {}", + bucketName, e.getCode(), e.getMessage()); + + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( + ApiResponse.success("GCS 연결 테스트 실패", result) + ); + } catch (Exception e) { + result.put("status", "ERROR"); + result.put("bucketName", bucketName); + result.put("accessible", false); + result.put("errorMessage", e.getMessage()); + + log.error("[GCS Health Check] 예외 발생 - Bucket: {}, Error: {}", bucketName, e.getMessage()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponse.success("GCS 연결 테스트 중 오류 발생", result) + ); + } + } +} diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsService.java b/src/main/java/com/proovy/global/infra/gcs/GcsService.java new file mode 100644 index 0000000..4edeceb --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsService.java @@ -0,0 +1,68 @@ +package com.proovy.global.infra.gcs; + +import java.io.InputStream; +import java.util.List; + +public interface GcsService { + + /** + * GCS에서 파일 삭제 + * @param gcsKey GCS 저장 경로 + */ + void deleteFile(String gcsKey); + + /** + * GCS에서 여러 파일 일괄 삭제 + * @param gcsKeys GCS 저장 경로 목록 + */ + void deleteFiles(List gcsKeys); + + /** + * 파일 업로드 + * @param gcsKey GCS 저장 경로 + * @param inputStream 파일 스트림 + * @param contentLength 파일 크기 + * @param contentType 파일 타입 + * @return 업로드된 파일 URL + */ + String uploadFile(String gcsKey, InputStream inputStream, long contentLength, String contentType); + + /** + * 파일 URL 생성 + * @param gcsKey GCS 저장 경로 + * @return GCS 파일 URL + */ + String getFileUrl(String gcsKey); + + /** + * 썸네일 URL 생성 + * @param thumbnailGcsKey 썸네일 GCS 저장 경로 + * @return 썸네일 URL (없으면 null) + */ + String getThumbnailUrl(String thumbnailGcsKey); + + /** + * 파일 존재 여부 확인 + * @param gcsKey GCS 저장 경로 + * @return 존재 여부 + */ + boolean doesFileExist(String gcsKey); + + /** + * 파일 업로드용 Signed URL 생성 (PUT) + * @param gcsKey GCS 저장 경로 + * @param contentType 파일 타입 + * @param durationMinutes URL 유효 시간 (분) + * @return Signed URL + */ + String generatePresignedUploadUrl(String gcsKey, String contentType, int durationMinutes); + + /** + * 파일 다운로드용 Signed URL 생성 (GET) + * @param gcsKey GCS 저장 경로 + * @param fileName 다운로드 시 파일명 + * @param durationMinutes URL 유효 시간 (분) + * @return Signed URL + */ + String generatePresignedDownloadUrl(String gcsKey, String fileName, int durationMinutes); +} diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java new file mode 100644 index 0000000..2816832 --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -0,0 +1,181 @@ +package com.proovy.global.infra.gcs; + +import com.google.cloud.storage.*; +import com.proovy.global.exception.BusinessException; +import com.proovy.global.response.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GcsServiceImpl implements GcsService { + + private final Storage storage; + + @Value("${gcs.bucket}") + private String bucketName; + + @Override + public void deleteFile(String gcsKey) { + if (gcsKey == null || gcsKey.isBlank()) return; + + try { + storage.delete(BlobId.of(bucketName, gcsKey)); + log.info("[GCS] 파일 삭제 성공: {}", gcsKey); + } catch (StorageException e) { + log.error("[GCS] 파일 삭제 실패: {}, code={}, message={}", gcsKey, e.getCode(), e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @Override + public void deleteFiles(List gcsKeys) { + if (gcsKeys == null || gcsKeys.isEmpty()) { + log.warn("[GCS] 삭제할 파일 목록이 비어있습니다."); + return; + } + + List blobIds = gcsKeys.stream() + .filter(key -> key != null && !key.isBlank()) + .distinct() + .map(key -> BlobId.of(bucketName, key)) + .collect(Collectors.toList()); + + if (blobIds.isEmpty()) { + log.warn("[GCS] 유효한 삭제 대상 key가 없습니다."); + return; + } + + try { + List results = storage.delete(blobIds); + long failCount = results.stream().filter(r -> !r).count(); + + if (failCount > 0) { + log.error("[GCS] {} 개 파일 삭제 실패", failCount); + throw new BusinessException(ErrorCode.COMMON500); + } + + log.info("[GCS] 파일 일괄 삭제 성공: {} 개", results.size()); + } catch (StorageException e) { + log.error("[GCS] 파일 일괄 삭제 실패: {}", e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @Override + public String uploadFile(String gcsKey, InputStream inputStream, long contentLength, String contentType) { + if (gcsKey == null || gcsKey.isBlank()) { + throw new BusinessException(ErrorCode.COMMON400); + } + + try { + BlobInfo blobInfo = BlobInfo.newBuilder(bucketName, gcsKey) + .setContentType(contentType) + .build(); + + storage.createFrom(blobInfo, inputStream); + log.info("[GCS] 파일 업로드 성공: {}", gcsKey); + + return getFileUrl(gcsKey); + } catch (IOException | StorageException e) { + log.error("[GCS] 파일 업로드 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @Override + public String getFileUrl(String gcsKey) { + String encodedKey = URLEncoder.encode(gcsKey, StandardCharsets.UTF_8) + .replace("+", "%20"); + return String.format("https://storage.googleapis.com/%s/%s", bucketName, encodedKey); + } + + @Override + public String getThumbnailUrl(String thumbnailGcsKey) { + if (thumbnailGcsKey == null || thumbnailGcsKey.isBlank()) return null; + return getFileUrl(thumbnailGcsKey); + } + + @Override + public boolean doesFileExist(String gcsKey) { + if (gcsKey == null || gcsKey.isBlank()) return false; + + try { + Blob blob = storage.get(BlobId.of(bucketName, gcsKey)); + return blob != null && blob.exists(); + } catch (StorageException e) { + log.error("[GCS] 파일 존재 확인 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @Override + public String generatePresignedUploadUrl(String gcsKey, String contentType, int durationMinutes) { + if (gcsKey == null || gcsKey.isBlank()) { + throw new BusinessException(ErrorCode.COMMON400); + } + + try { + BlobInfo blobInfo = BlobInfo.newBuilder(bucketName, gcsKey) + .setContentType(contentType) + .build(); + + URL signedUrl = storage.signUrl( + blobInfo, + durationMinutes, + TimeUnit.MINUTES, + Storage.SignUrlOption.httpMethod(HttpMethod.PUT), + Storage.SignUrlOption.withContentType(), + Storage.SignUrlOption.withV4Signature() + ); + + log.info("[GCS] Upload Signed URL 생성 성공: {}", gcsKey); + return signedUrl.toString(); + } catch (StorageException e) { + log.error("[GCS] Upload Signed URL 생성 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @Override + public String generatePresignedDownloadUrl(String gcsKey, String fileName, int durationMinutes) { + if (gcsKey == null || gcsKey.isBlank()) { + throw new BusinessException(ErrorCode.COMMON400); + } + + try { + String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8) + .replace("+", "%20"); + String contentDisposition = "attachment; filename=\"" + encodedFileName + "\""; + + BlobInfo blobInfo = BlobInfo.newBuilder(bucketName, gcsKey) + .setContentDisposition(contentDisposition) + .build(); + + URL signedUrl = storage.signUrl( + blobInfo, + durationMinutes, + TimeUnit.MINUTES, + Storage.SignUrlOption.withV4Signature() + ); + + log.debug("[GCS] Download Signed URL 생성 성공: {}", gcsKey); + return signedUrl.toString(); + } catch (StorageException e) { + log.error("[GCS] Download Signed URL 생성 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } +} diff --git a/src/main/java/com/proovy/global/infra/s3/S3HealthController.java b/src/main/java/com/proovy/global/infra/s3/S3HealthController.java deleted file mode 100644 index 233eada..0000000 --- a/src/main/java/com/proovy/global/infra/s3/S3HealthController.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.proovy.global.infra.s3; - -import com.proovy.global.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -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.RestController; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.HeadBucketRequest; -import software.amazon.awssdk.services.s3.model.S3Exception; - -import java.util.HashMap; -import java.util.Map; - -@Tag(name = "S3 Health Check", description = "S3 연결 상태 확인 API (개발용)") -@Slf4j -@RestController -@RequestMapping("/api/health") -@RequiredArgsConstructor -public class S3HealthController { - - private final S3Client s3Client; - - @Value("${aws.s3.bucket}") - private String bucketName; - - @Operation( - summary = "S3 연결 테스트", - description = "AWS S3 버킷 연결 상태를 확인합니다. 개발 및 테스트 용도로 사용됩니다." - ) - @GetMapping("/s3") - public ResponseEntity>> checkS3Connection() { - Map result = new HashMap<>(); - - try { - // S3 버킷 존재 여부 및 접근 권한 확인 - HeadBucketRequest headBucketRequest = HeadBucketRequest.builder() - .bucket(bucketName) - .build(); - - s3Client.headBucket(headBucketRequest); - - result.put("status", "OK"); - result.put("bucketName", bucketName); - result.put("message", "S3 버킷에 정상적으로 연결되었습니다."); - result.put("accessible", true); - - log.info("[S3 Health Check] 성공 - Bucket: {}", bucketName); - - return ResponseEntity.ok( - ApiResponse.success("S3 연결 테스트 성공", result) - ); - - } catch (S3Exception e) { - result.put("status", "ERROR"); - result.put("bucketName", bucketName); - result.put("accessible", false); - - if (e.awsErrorDetails() != null) { - result.put("errorCode", e.awsErrorDetails().errorCode()); - result.put("errorMessage", e.awsErrorDetails().errorMessage()); - } else { - result.put("errorMessage", e.getMessage()); - } - - log.error("[S3 Health Check] 실패 - Bucket: {}, Error: {}", - bucketName, e.awsErrorDetails() != null ? e.awsErrorDetails().errorMessage() : e.getMessage()); - - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( - ApiResponse.success("S3 연결 테스트 실패", result) - ); - - } catch (Exception e) { - result.put("status", "ERROR"); - result.put("bucketName", bucketName); - result.put("accessible", false); - result.put("errorMessage", e.getMessage()); - - log.error("[S3 Health Check] 예외 발생 - Bucket: {}, Error: {}", - bucketName, e.getMessage()); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - ApiResponse.success("S3 연결 테스트 중 오류 발생", result) - ); - } - } -} diff --git a/src/main/java/com/proovy/global/infra/s3/S3Service.java b/src/main/java/com/proovy/global/infra/s3/S3Service.java deleted file mode 100644 index 60d5118..0000000 --- a/src/main/java/com/proovy/global/infra/s3/S3Service.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.proovy.global.infra.s3; - -import java.io.InputStream; -import java.util.List; - -public interface S3Service { - - /** - * S3에서 파일 삭제 - * @param s3Key S3 저장 경로 - */ - void deleteFile(String s3Key); - - /** - * S3에서 여러 파일 일괄 삭제 - * @param s3Keys S3 저장 경로 목록 - */ - void deleteFiles(List s3Keys); - - /** - * 파일 업로드 - * @param s3Key S3 저장 경로 - * @param inputStream 파일 스트림 - * @param contentLength 파일 크기 - * @param contentType 파일 타입 - * @return 업로드된 파일 URL - */ - String uploadFile(String s3Key, InputStream inputStream, long contentLength, String contentType); - - /** - * 파일 URL 생성 - * @param s3Key S3 저장 경로 - * @return S3 파일 URL - */ - String getFileUrl(String s3Key); - - /** - * 썸네일 URL 생성 - * @param thumbnailS3Key 썸네일 S3 저장 경로 - * @return 썸네일 URL (없으면 null) - */ - String getThumbnailUrl(String thumbnailS3Key); - - /** - * 파일 존재 여부 확인 - * @param s3Key S3 저장 경로 - * @return 존재 여부 - */ - boolean doesFileExist(String s3Key); - - /** - * 파일 업로드용 Presigned URL 생성 - * @param s3Key S3 저장 경로 - * @param contentType 파일 타입 - * @param durationMinutes URL 유효 시간 (분) - * @return Presigned URL - */ - String generatePresignedUploadUrl(String s3Key, String contentType, int durationMinutes); - - /** - * 파일 다운로드용 Presigned URL 생성 - * @param s3Key S3 저장 경로 - * @param fileName 다운로드 시 파일명 - * @param durationMinutes URL 유효 시간 (분) - * @return Presigned URL - */ - String generatePresignedDownloadUrl(String s3Key, String fileName, int durationMinutes); -} diff --git a/src/main/java/com/proovy/global/infra/s3/S3ServiceImpl.java b/src/main/java/com/proovy/global/infra/s3/S3ServiceImpl.java deleted file mode 100644 index bc574f8..0000000 --- a/src/main/java/com/proovy/global/infra/s3/S3ServiceImpl.java +++ /dev/null @@ -1,274 +0,0 @@ -package com.proovy.global.infra.s3; - -import com.proovy.global.exception.BusinessException; -import com.proovy.global.response.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.*; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -import java.time.Duration; - -import java.io.InputStream; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class S3ServiceImpl implements S3Service { - - private final S3Client s3Client; - private final S3Presigner s3Presigner; - - @Value("${aws.s3.bucket}") - private String bucketName; - - @Value("${aws.region}") - private String region; - - @Override - public void deleteFile(String s3Key) { - if (s3Key == null || s3Key.isBlank()) return; - - try { - DeleteObjectRequest request = DeleteObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - s3Client.deleteObject(request); - log.info("[S3] 파일 삭제 성공: {}", s3Key); - - } catch (S3Exception e) { - log.error("[S3] 파일 삭제 실패: {}, code={}, message={}", - s3Key, - e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "unknown", - e.getMessage(), - e - ); - throw new BusinessException(ErrorCode.COMMON500); - } - } - - @Override - public void deleteFiles(List s3Keys) { - if (s3Keys == null || s3Keys.isEmpty()) { - log.warn("[S3] 삭제할 파일 목록이 비어있습니다."); - return; - } - - // S3 DeleteObjects는 최대 1000개까지 지원 - List objectIdentifiers = s3Keys.stream() - .filter(key -> key != null && !key.isBlank()) - .distinct() - .map(key -> ObjectIdentifier.builder().key(key).build()) - .collect(Collectors.toList()); - - if (objectIdentifiers.isEmpty()) { - log.warn("[S3] 유효한 삭제 대상 key가 없습니다."); - return; - } - - try { - Delete delete = Delete.builder() - .objects(objectIdentifiers) - .build(); - - DeleteObjectsRequest request = DeleteObjectsRequest.builder() - .bucket(bucketName) - .delete(delete) - .build(); - - DeleteObjectsResponse response = s3Client.deleteObjects(request); - - if (response.hasDeleted()) { - log.info("[S3] 파일 일괄 삭제 성공: {} 개", response.deleted().size()); - } - - // 부분 실패 가능 -> errors 있으면 에러 처리 - if (response.hasErrors() && !response.errors().isEmpty()) { - response.errors().forEach(error -> - log.error("[S3] 파일 삭제 실패 - Key: {}, Code: {}, Message: {}", - error.key(), error.code(), error.message()) - ); - throw new BusinessException(ErrorCode.COMMON500); - } - - } catch (S3Exception e) { - log.error("[S3] 파일 일괄 삭제 실패: {}", e.getMessage(), e); - throw new BusinessException(ErrorCode.COMMON500); - } - } - - /** - * 파일 업로드 - */ - @Override - public String uploadFile(String s3Key, InputStream inputStream, long contentLength, String contentType) { - if (s3Key == null || s3Key.isBlank()) { - throw new BusinessException(ErrorCode.COMMON400); - } - - try { - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType(contentType) - .contentLength(contentLength) - .build(); - - s3Client.putObject(request, RequestBody.fromInputStream(inputStream, contentLength)); - log.info("[S3] 파일 업로드 성공: {}", s3Key); - - return getFileUrl(s3Key); - - } catch (S3Exception e) { - log.error("[S3] 파일 업로드 실패: {}, code={}, message={}", - s3Key, - e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "unknown", - e.getMessage(), - e - ); - throw new BusinessException(ErrorCode.COMMON500); - } - } - - /** - * 파일 URL 생성 - */ - @Override - public String getFileUrl(String s3Key) { - String encodedKey = URLEncoder.encode(s3Key, StandardCharsets.UTF_8) - .replace("+", "%20"); - return String.format("https://%s.s3.%s.amazonaws.com/%s", - bucketName, - region, - encodedKey); - } - - /** - * 썸네일 URL 생성 - */ - @Override - public String getThumbnailUrl(String thumbnailS3Key) { - if (thumbnailS3Key == null || thumbnailS3Key.isBlank()) { - return null; - } - return getFileUrl(thumbnailS3Key); - } - - /** - * 파일 존재 여부 확인 - */ - @Override - public boolean doesFileExist(String s3Key) { - if (s3Key == null || s3Key.isBlank()) return false; - - try { - HeadObjectRequest request = HeadObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); - - s3Client.headObject(request); - return true; - - } catch (S3Exception e) { - if (e.statusCode() == 404) return false; - - log.error("[S3] 파일 존재 확인 실패: {}", e.getMessage(), e); - throw new BusinessException(ErrorCode.COMMON500); - } - } - - /** - * 파일 업로드용 Presigned URL 생성 - */ - @Override - public String generatePresignedUploadUrl(String s3Key, String contentType, int durationMinutes) { - if (s3Key == null || s3Key.isBlank()) { - throw new BusinessException(ErrorCode.COMMON400); - } - - try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType(contentType) - .build(); - - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(durationMinutes)) - .putObjectRequest(putObjectRequest) - .build(); - - PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); - String presignedUrl = presignedRequest.url().toString(); - - log.info("[S3] Presigned URL 생성 성공: {}", s3Key); - return presignedUrl; - - } catch (S3Exception e) { - log.error("[S3] Presigned URL 생성 실패: {}, code={}, message={}", - s3Key, - e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "unknown", - e.getMessage(), - e - ); - throw new BusinessException(ErrorCode.COMMON500); - } - } - - /** - * 파일 다운로드용 Presigned URL 생성 - */ - @Override - public String generatePresignedDownloadUrl(String s3Key, String fileName, int durationMinutes) { - if (s3Key == null || s3Key.isBlank()) { - throw new BusinessException(ErrorCode.COMMON400); - } - - try { - String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8) - .replace("+", "%20"); - String contentDisposition = "attachment; filename=\"" + encodedFileName + "\""; - - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .responseContentDisposition(contentDisposition) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(durationMinutes)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - String presignedUrl = presignedRequest.url().toString(); - - log.debug("[S3] 다운로드 Presigned URL 생성 성공: {}", s3Key); - return presignedUrl; - - } catch (S3Exception e) { - log.error("[S3] 다운로드 Presigned URL 생성 실패: {}, code={}, message={}", - s3Key, - e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "unknown", - e.getMessage(), - e - ); - throw new BusinessException(ErrorCode.COMMON500); - } - } -} diff --git a/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java b/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java index 7aa1fc3..f6ff7ac 100644 --- a/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java +++ b/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java @@ -3,7 +3,7 @@ import com.proovy.domain.asset.entity.Asset; import com.proovy.domain.asset.repository.AssetRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,11 +39,11 @@ @RequiredArgsConstructor public class ThumbnailService { - private final S3Service s3Service; + private final GcsService s3Service; private final AssetRepository assetRepository; private final ApplicationContext applicationContext; - @Value("${aws.s3.bucket}") + @Value("${gcs.bucket}") private String bucketName; private static final int THUMBNAIL_WIDTH = 400; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dab443e..be7f413 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -57,19 +57,13 @@ server: forward-headers-strategy: framework # =============================== -# AWS 설정 +# GCS 설정 # =============================== -cloud: - aws: - credentials: - access-key: ${AWS_S3_ACCESS_KEY} - secret-key: ${AWS_S3_SECRET_KEY} - region: - static: ${AWS_REGION:ap-northeast-2} - s3: - bucket: ${AWS_S3_BUCKET:proovy-assets-dev} - stack: - auto: false # CloudFormation 스택 생성 비활성화 +gcs: + project-id: ${GCS_PROJECT_ID} + bucket: ${GCS_BUCKET:proovy-assets-dev} + # 서비스 계정 JSON 키 (문자열 전체). 미설정 시 ADC 사용 (Cloud Run 권장) + credentials-json: ${GCS_CREDENTIALS_JSON:} # =============================== # OAuth 설정 diff --git a/src/test/java/com/proovy/domain/storage/service/StorageServiceTest.java b/src/test/java/com/proovy/domain/storage/service/StorageServiceTest.java index 8f7a7fa..fa93f47 100644 --- a/src/test/java/com/proovy/domain/storage/service/StorageServiceTest.java +++ b/src/test/java/com/proovy/domain/storage/service/StorageServiceTest.java @@ -11,7 +11,7 @@ import com.proovy.domain.user.repository.UserPlanRepository; import com.proovy.domain.user.repository.UserRepository; import com.proovy.global.exception.BusinessException; -import com.proovy.global.infra.s3.S3Service; +import com.proovy.global.infra.gcs.GcsService; import com.proovy.global.response.ErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -52,7 +52,7 @@ class StorageServiceTest { private UserPlanRepository userPlanRepository; @Mock - private S3Service s3Service; + private GcsService s3Service; private User testUser; private Note testNote; From 8ae595405b1f5492e279bffb5e1b2cbd5f643829 Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:40:40 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20Dockerfile=20jar=20=EB=B3=B5?= =?UTF-8?q?=EC=88=98=20=EB=A7=A4=EC=B9=AD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- build.gradle | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 57e791f..7d36eee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM eclipse-temurin:21-jre-alpine WORKDIR /app -COPY build/libs/*.jar app.jar +COPY build/libs/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 90521a9..e112274 100644 --- a/build.gradle +++ b/build.gradle @@ -79,3 +79,11 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.named('bootJar') { + archiveFileName = 'app.jar' +} + +tasks.named('jar') { + enabled = false +} From b7d897e52987e80949e8193399a0aa7a5088ef2f Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:43:11 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20Cloud=20Run=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=8B=9C=20=ED=95=84=EC=88=98=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EB=88=84=EB=9D=BD=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7a834c8..3216264 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -64,4 +64,5 @@ jobs: --allow-unauthenticated \ --min-instances 1 \ --set-env-vars "SPRING_PROFILES_ACTIVE=prod" \ + --set-secrets "DB_HOST=db-host:latest,DB_PORT=db-port:latest,DB_NAME=db-name:latest,DB_USERNAME=db-username:latest,DB_PASSWORD=db-password:latest,REDIS_HOST=redis-host:latest,REDIS_PORT=redis-port:latest,JWT_SECRET=jwt-secret:latest,KAKAO_CLIENT_ID=kakao-client-id:latest,KAKAO_CLIENT_SECRET=kakao-client-secret:latest,NAVER_CLIENT_ID=naver-client-id:latest,NAVER_CLIENT_SECRET=naver-client-secret:latest,GOOGLE_CLIENT_ID=google-client-id:latest,GOOGLE_CLIENT_SECRET=google-client-secret:latest,GCS_PROJECT_ID=gcs-project-id:latest,GCS_BUCKET=gcs-bucket:latest,GCS_CREDENTIALS_JSON=gcs-credentials-json:latest,INTERNAL_API_TOKEN=internal-api-token:latest,OPENROUTER_API_KEY=openrouter-api-key:latest" \ --project ${{ secrets.GCP_PROJECT_ID }} From f3724eb0042c369a61e03779f43dbef186fc49cb Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:52:52 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=BB=AC=20GCS=20?= =?UTF-8?q?=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20endpoint=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/proovy/global/config/GcsConfig.java | 7 +++++++ .../java/com/proovy/global/infra/gcs/GcsServiceImpl.java | 8 +++++++- src/main/resources/application.yaml | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/proovy/global/config/GcsConfig.java b/src/main/java/com/proovy/global/config/GcsConfig.java index ff29dd4..b2342e9 100644 --- a/src/main/java/com/proovy/global/config/GcsConfig.java +++ b/src/main/java/com/proovy/global/config/GcsConfig.java @@ -20,6 +20,9 @@ public class GcsConfig { @Value("${gcs.credentials-json:}") private String credentialsJson; + @Value("${gcs.endpoint:}") + private String endpoint; + @Bean public Storage gcsStorage() throws IOException { StorageOptions.Builder builder = StorageOptions.newBuilder() @@ -34,6 +37,10 @@ public Storage gcsStorage() throws IOException { // credentialsJson 미설정 시 ADC(Application Default Credentials) 사용 // Cloud Run에서는 서비스 계정이 자동 적용됨 + if (endpoint != null && !endpoint.isBlank()) { + builder.setHost(endpoint); + } + return builder.build().getService(); } } diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java index 2816832..4579ce6 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -27,6 +27,9 @@ public class GcsServiceImpl implements GcsService { @Value("${gcs.bucket}") private String bucketName; + @Value("${gcs.endpoint:}") + private String endpoint; + @Override public void deleteFile(String gcsKey) { if (gcsKey == null || gcsKey.isBlank()) return; @@ -99,7 +102,10 @@ public String uploadFile(String gcsKey, InputStream inputStream, long contentLen public String getFileUrl(String gcsKey) { String encodedKey = URLEncoder.encode(gcsKey, StandardCharsets.UTF_8) .replace("+", "%20"); - return String.format("https://storage.googleapis.com/%s/%s", bucketName, encodedKey); + String baseUrl = (endpoint != null && !endpoint.isBlank()) + ? endpoint + : "https://storage.googleapis.com"; + return String.format("%s/%s/%s", baseUrl, bucketName, encodedKey); } @Override diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index be7f413..90ae58f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -64,6 +64,8 @@ gcs: bucket: ${GCS_BUCKET:proovy-assets-dev} # 서비스 계정 JSON 키 (문자열 전체). 미설정 시 ADC 사용 (Cloud Run 권장) credentials-json: ${GCS_CREDENTIALS_JSON:} + # 로컬 GCS 에뮬레이터 endpoint. 미설정 시 실제 GCS 사용 + endpoint: ${GCS_ENDPOINT:} # =============================== # OAuth 설정 From 6cd94451d8f07a93b64b5897d59623f992133942 Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:54:34 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20GCS=20=ED=97=AC=EC=8A=A4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=8B=A4=ED=8C=A8=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20success=20=EB=9E=98=ED=8D=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/infra/gcs/GcsHealthController.java | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java b/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java index 7652542..e2d88e6 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java @@ -49,7 +49,7 @@ public ResponseEntity>> checkGcsConnection() { log.error("[GCS Health Check] 버킷 없음 - Bucket: {}", bucketName); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( - ApiResponse.success("GCS 연결 테스트 실패", result) + ApiResponse.failure("GCS503", "GCS 연결 테스트 실패: 버킷을 찾을 수 없습니다.") ); } @@ -62,28 +62,17 @@ public ResponseEntity>> checkGcsConnection() { return ResponseEntity.ok(ApiResponse.success("GCS 연결 테스트 성공", result)); } catch (StorageException e) { - result.put("status", "ERROR"); - result.put("bucketName", bucketName); - result.put("accessible", false); - result.put("errorCode", e.getCode()); - result.put("errorMessage", e.getMessage()); - log.error("[GCS Health Check] 실패 - Bucket: {}, Code: {}, Error: {}", bucketName, e.getCode(), e.getMessage()); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( - ApiResponse.success("GCS 연결 테스트 실패", result) + ApiResponse.failure("GCS503", "GCS 연결 테스트 실패: " + e.getMessage()) ); } catch (Exception e) { - result.put("status", "ERROR"); - result.put("bucketName", bucketName); - result.put("accessible", false); - result.put("errorMessage", e.getMessage()); - log.error("[GCS Health Check] 예외 발생 - Bucket: {}, Error: {}", bucketName, e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - ApiResponse.success("GCS 연결 테스트 중 오류 발생", result) + ApiResponse.failure("GCS500", "GCS 연결 테스트 중 오류 발생: " + e.getMessage()) ); } } From 83f56f53201d22f38703f31d8a7ebece609b8e1e Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:55:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20GCS=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=A1=B4=EC=9E=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/proovy/global/infra/gcs/GcsServiceImpl.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java index 4579ce6..31b48bb 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -63,11 +63,10 @@ public void deleteFiles(List gcsKeys) { try { List results = storage.delete(blobIds); - long failCount = results.stream().filter(r -> !r).count(); + long missingCount = results.stream().filter(Boolean.FALSE::equals).count(); - if (failCount > 0) { - log.error("[GCS] {} 개 파일 삭제 실패", failCount); - throw new BusinessException(ErrorCode.COMMON500); + if (missingCount > 0) { + log.warn("[GCS] {} 개 파일은 이미 존재하지 않아 삭제를 건너뛰었습니다.", missingCount); } log.info("[GCS] 파일 일괄 삭제 성공: {} 개", results.size()); From f20db10e2257aad93f4657dc1915f0004a0c997c Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:58:13 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20GCS=20Signed=20URL=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20IllegalStateException=20?= =?UTF-8?q?=EB=AF=B8=EC=B2=98=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java index 31b48bb..2dad6d0 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -148,7 +148,7 @@ public String generatePresignedUploadUrl(String gcsKey, String contentType, int log.info("[GCS] Upload Signed URL 생성 성공: {}", gcsKey); return signedUrl.toString(); - } catch (StorageException e) { + } catch (IllegalStateException | StorageException e) { log.error("[GCS] Upload Signed URL 생성 실패: {}, message={}", gcsKey, e.getMessage(), e); throw new BusinessException(ErrorCode.COMMON500); } @@ -178,7 +178,7 @@ public String generatePresignedDownloadUrl(String gcsKey, String fileName, int d log.debug("[GCS] Download Signed URL 생성 성공: {}", gcsKey); return signedUrl.toString(); - } catch (StorageException e) { + } catch (IllegalStateException | StorageException e) { log.error("[GCS] Download Signed URL 생성 실패: {}, message={}", gcsKey, e.getMessage(), e); throw new BusinessException(ErrorCode.COMMON500); } From b600f13eb374e3288dc98a6d2233e5d406fc6087 Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 22:58:52 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20GCS=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20Signed=20URL=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20fileName=20null=20=EA=B2=80=EC=A6=9D=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java index 2dad6d0..21079e8 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -159,6 +159,9 @@ public String generatePresignedDownloadUrl(String gcsKey, String fileName, int d if (gcsKey == null || gcsKey.isBlank()) { throw new BusinessException(ErrorCode.COMMON400); } + if (fileName == null || fileName.isBlank()) { + throw new BusinessException(ErrorCode.COMMON400); + } try { String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8) From 5a2761ab57a9ca5dcf2bda7497819d10284c82b8 Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Thu, 16 Apr 2026 23:01:04 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20ThumbnailService=EA=B0=80=20p?= =?UTF-8?q?rivate=20GCS=20=EB=B2=84=ED=82=B7=EC=97=90=EC=84=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=9D=BD=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proovy/global/infra/gcs/GcsService.java | 7 +++ .../global/infra/gcs/GcsServiceImpl.java | 18 +++++++ .../infra/thumbnail/ThumbnailService.java | 53 +++++-------------- 3 files changed, 37 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsService.java b/src/main/java/com/proovy/global/infra/gcs/GcsService.java index 4edeceb..f73c0d8 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsService.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsService.java @@ -65,4 +65,11 @@ public interface GcsService { * @return Signed URL */ String generatePresignedDownloadUrl(String gcsKey, String fileName, int durationMinutes); + + /** + * GCS에서 파일 바이트 직접 읽기 (서버 내부용, 버킷 공개 여부 무관) + * @param gcsKey GCS 저장 경로 + * @return 파일 바이트 배열 + */ + byte[] readFileBytes(String gcsKey); } diff --git a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java index 21079e8..4d62342 100644 --- a/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -154,6 +154,24 @@ public String generatePresignedUploadUrl(String gcsKey, String contentType, int } } + @Override + public byte[] readFileBytes(String gcsKey) { + if (gcsKey == null || gcsKey.isBlank()) { + throw new BusinessException(ErrorCode.COMMON400); + } + + try { + byte[] bytes = storage.readAllBytes(bucketName, gcsKey); + if (bytes == null) { + throw new BusinessException(ErrorCode.COMMON500); + } + return bytes; + } catch (StorageException e) { + log.error("[GCS] 파일 읽기 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + @Override public String generatePresignedDownloadUrl(String gcsKey, String fileName, int durationMinutes) { if (gcsKey == null || gcsKey.isBlank()) { diff --git a/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java b/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java index f6ff7ac..32e1e8a 100644 --- a/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java +++ b/src/main/java/com/proovy/global/infra/thumbnail/ThumbnailService.java @@ -27,11 +27,6 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; import java.util.Iterator; @Slf4j @@ -52,10 +47,6 @@ public class ThumbnailService { private static final int PDF_DPI = 150; private static final long MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB 제한 - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .build(); - /** * 셀프 프록시 주입 (트랜잭션 AOP 적용용) */ @@ -76,13 +67,10 @@ public String generateThumbnailSync(String s3Key, String mimeType) { return null; } - // 1. 원본 파일 다운로드 URL 생성 - String fileUrl = s3Service.getFileUrl(s3Key); - - // 2. 파일 다운로드 - byte[] fileBytes = downloadFile(fileUrl); + // 1. 원본 파일 직접 읽기 (private 버킷 지원) + byte[] fileBytes = readFileWithSizeCheck(s3Key); - // 3. 썸네일 생성 + // 2. 썸네일 생성 byte[] thumbnailBytes = createImageThumbnail(fileBytes); // 4. 썸네일 S3 키 생성 @@ -115,13 +103,10 @@ public void generateThumbnailAsync(Long assetId, String s3Key, String mimeType) log.info("[Thumbnail] 썸네일 생성 시작 - assetId: {}, s3Key: {}, mimeType: {}", assetId, s3Key, mimeType); - // 1. 원본 파일 다운로드 URL 생성 - String fileUrl = s3Service.getFileUrl(s3Key); - - // 2. 파일 다운로드 - byte[] fileBytes = downloadFile(fileUrl); + // 1. 원본 파일 직접 읽기 (private 버킷 지원) + byte[] fileBytes = readFileWithSizeCheck(s3Key); - // 3. 썸네일 생성 + // 2. 썸네일 생성 byte[] thumbnailBytes; if (mimeType.equals("application/pdf")) { thumbnailBytes = createPdfThumbnail(fileBytes); @@ -255,32 +240,18 @@ private byte[] resizeAndConvertToJpeg(BufferedImage originalImage) throws Except } /** - * 파일 다운로드 (HTTP) - 크기 체크 포함 + * GCS에서 파일 직접 읽기 - 크기 체크 포함 (private 버킷 지원) */ - private byte[] downloadFile(String fileUrl) throws Exception { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(fileUrl)) - .GET() - .timeout(Duration.ofSeconds(30)) - .build(); - - HttpResponse response = httpClient.send(request, - HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - throw new RuntimeException("파일 다운로드 실패: HTTP " + response.statusCode()); - } - - byte[] body = response.body(); + private byte[] readFileWithSizeCheck(String gcsKey) { + byte[] bytes = s3Service.readFileBytes(gcsKey); - // 파일 크기 체크 (OOM 방지) - if (body.length > MAX_FILE_SIZE_BYTES) { + if (bytes.length > MAX_FILE_SIZE_BYTES) { throw new IllegalArgumentException( String.format("파일 크기가 너무 큽니다: %d bytes (최대 %d bytes)", - body.length, MAX_FILE_SIZE_BYTES)); + bytes.length, MAX_FILE_SIZE_BYTES)); } - return body; + return bytes; } /** From dc03fbe510c8e7fafa23f8d068a41d74928f4cfe Mon Sep 17 00:00:00 2001 From: gaeunee2 Date: Fri, 8 May 2026 19:58:16 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20dockerfile=20=EB=B9=84?= =?UTF-8?q?=EB=A3=A8=ED=8A=B8=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 +++- Dockerfile | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3216264..1112094 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + id-token: write concurrency: group: backend-deploy-${{ github.event.workflow_run.head_branch }} @@ -39,7 +40,8 @@ jobs: - name: Authenticate to GCP uses: google-github-actions/auth@v2 with: - credentials_json: ${{ secrets.GCP_SA_KEY }} + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOYER_SA }} - name: Set up Cloud SDK uses: google-github-actions/setup-gcloud@v2 diff --git a/Dockerfile b/Dockerfile index 7d36eee..f7f0a2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM eclipse-temurin:21-jre-alpine WORKDIR /app -COPY build/libs/app.jar app.jar +RUN addgroup -S app && adduser -S -G app app +COPY --chown=app:app build/libs/app.jar app.jar EXPOSE 8080 +USER app ENTRYPOINT ["java", "-jar", "app.jar"]