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..1112094 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: @@ -7,6 +7,7 @@ on: permissions: contents: read + id-token: write concurrency: group: backend-deploy-${{ github.event.workflow_run.head_branch }} @@ -36,55 +37,34 @@ 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 }} + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} + service_account: ${{ secrets.GCP_DEPLOYER_SA }} - - 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" \ + --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 }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7f0a2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +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"] diff --git a/build.gradle b/build.gradle index d711892..e112274 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' @@ -79,3 +79,11 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.named('bootJar') { + archiveFileName = 'app.jar' +} + +tasks.named('jar') { + enabled = false +} 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..b2342e9 --- /dev/null +++ b/src/main/java/com/proovy/global/config/GcsConfig.java @@ -0,0 +1,46 @@ +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; + + @Value("${gcs.endpoint:}") + private String endpoint; + + @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에서는 서비스 계정이 자동 적용됨 + + if (endpoint != null && !endpoint.isBlank()) { + builder.setHost(endpoint); + } + + 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..e2d88e6 --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsHealthController.java @@ -0,0 +1,79 @@ +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.failure("GCS503", "GCS 연결 테스트 실패: 버킷을 찾을 수 없습니다.") + ); + } + + 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) { + log.error("[GCS Health Check] 실패 - Bucket: {}, Code: {}, Error: {}", + bucketName, e.getCode(), e.getMessage()); + + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body( + ApiResponse.failure("GCS503", "GCS 연결 테스트 실패: " + e.getMessage()) + ); + } catch (Exception e) { + log.error("[GCS Health Check] 예외 발생 - Bucket: {}, Error: {}", bucketName, e.getMessage()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ApiResponse.failure("GCS500", "GCS 연결 테스트 중 오류 발생: " + e.getMessage()) + ); + } + } +} 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..f73c0d8 --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsService.java @@ -0,0 +1,75 @@ +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); + + /** + * 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 new file mode 100644 index 0000000..4d62342 --- /dev/null +++ b/src/main/java/com/proovy/global/infra/gcs/GcsServiceImpl.java @@ -0,0 +1,207 @@ +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; + + @Value("${gcs.endpoint:}") + private String endpoint; + + @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 missingCount = results.stream().filter(Boolean.FALSE::equals).count(); + + if (missingCount > 0) { + log.warn("[GCS] {} 개 파일은 이미 존재하지 않아 삭제를 건너뛰었습니다.", missingCount); + } + + 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"); + String baseUrl = (endpoint != null && !endpoint.isBlank()) + ? endpoint + : "https://storage.googleapis.com"; + return String.format("%s/%s/%s", baseUrl, 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 (IllegalStateException | StorageException e) { + log.error("[GCS] Upload Signed URL 생성 실패: {}, message={}", gcsKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.COMMON500); + } + } + + @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()) { + throw new BusinessException(ErrorCode.COMMON400); + } + if (fileName == null || fileName.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 (IllegalStateException | 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..32e1e8a 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; @@ -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 @@ -39,11 +34,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; @@ -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; } /** diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dab443e..90ae58f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -57,19 +57,15 @@ 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:} + # 로컬 GCS 에뮬레이터 endpoint. 미설정 시 실제 GCS 사용 + endpoint: ${GCS_ENDPOINT:} # =============================== # 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;