diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c663c3ae..36bdafa9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -23,25 +23,9 @@ jobs: id: vars run: | if [ "${{ github.ref_name }}" == "main" ]; then - echo "APP_SECRET=APPLICATION" >> $GITHUB_OUTPUT echo "DOCKER_TAG=latest" >> $GITHUB_OUTPUT - echo "COMPOSE_FILE=docker-compose.prod.yml" >> $GITHUB_OUTPUT else - echo "APP_SECRET=APPLICATION_STAGING" >> $GITHUB_OUTPUT echo "DOCKER_TAG=staging" >> $GITHUB_OUTPUT - echo "COMPOSE_FILE=docker-compose.staging.yml" >> $GITHUB_OUTPUT - fi - - - name: Remove existing application.yml - run: rm -f src/main/resources/application.yml - - - name: Make application.yml - run: | - mkdir -p src/main/resources - if [ "${{ github.ref_name }}" == "main" ]; then - echo "${{ secrets.APPLICATION }}" > src/main/resources/application.yml - else - echo "${{ secrets.APPLICATION_STAGING }}" > src/main/resources/application.yml fi - name: Build with Gradle @@ -55,39 +39,36 @@ jobs: docker build -f Dockerfile -t ${{ secrets.DOCKER_REPO }}:${{ steps.vars.outputs.DOCKER_TAG }} . docker push ${{ secrets.DOCKER_REPO }}:${{ steps.vars.outputs.DOCKER_TAG }} - - name: Deploy_EC2 + - name: Copy files to server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST }} + username: ubuntu + key: ${{ secrets.KEY }} + source: "docker-compose.yml,init-db.sql,nginx/,scripts/" + target: /home/ubuntu/cockple + + - name: Deploy uses: appleboy/ssh-action@master - id: deploy with: host: ${{ secrets.HOST }} username: ubuntu key: ${{ secrets.KEY }} + envs: >- + DB_PASSWORD,GCS_BUCKET, + KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY, + JWT_SECRET_KEY script: | - cd /home/ubuntu/home/monitor - echo "=== 배포 전 상태 ===" - sudo docker ps - sudo docker image prune -f - sudo docker pull ${{ secrets.DOCKER_REPO }}:${{ steps.vars.outputs.DOCKER_TAG }} - - if [ "${{ github.ref_name }}" == "main" ]; then - sudo docker stop cockple-app || true - sudo docker rm -f cockple-app || true - if ! sudo docker ps | grep -q cockple-redis; then - echo "Redis(prod)가 죽었음, 재시작 중..." - sudo docker compose -f docker-compose.prod.yml up -d redis - sleep 10 - fi - sudo docker compose -f docker-compose.prod.yml up -d cockple-app - else - sudo docker stop cockple-app-staging || true - sudo docker rm -f cockple-app-staging || true - if ! sudo docker ps | grep -q cockple-redis-staging; then - echo "Redis(staging)가 죽었음, 재시작 중..." - sudo docker compose -f docker-compose.staging.yml up -d redis-staging - sleep 10 - fi - sudo docker compose -f docker-compose.staging.yml up -d cockple-app-staging - fi - - echo "=== 배포 후 상태 ===" - sudo docker ps \ No newline at end of file + chmod +x /home/ubuntu/cockple/scripts/deploy.sh + bash /home/ubuntu/cockple/scripts/deploy.sh \ + ${{ secrets.DOCKER_REPO }} \ + ${{ github.ref_name }} + env: + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + GCS_BUCKET: ${{ secrets.GCS_BUCKET }} + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} + KAKAO_REDIRECT_URI_PROD: ${{ secrets.KAKAO_REDIRECT_URI_PROD }} + KAKAO_REDIRECT_URI_STAGING: ${{ secrets.KAKAO_REDIRECT_URI_STAGING }} + KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }} + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} diff --git a/.gitignore b/.gitignore index 21ca485b..7b042e06 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ bin/ ### IntelliJ IDEA ### .idea +src/main/generated/ *.iws *.iml *.ipr @@ -40,4 +41,10 @@ src/main/resources/application-dev.yml .env -application-dev.yml \ No newline at end of file +application-dev.yml + +### Terraform ### +terraform/.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup +terraform/terraform.tfvars \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 99a04c76..36c0d42a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -FROM eclipse-temurin:17-jdk-jammy +FROM eclipse-temurin:17-jre-jammy COPY build/libs/cockple.demo-0.0.1-SNAPSHOT.jar app.jar -CMD ["java", "-Dspring.profiles.active=dev", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index d007bfd6..57828e98 100644 --- a/build.gradle +++ b/build.gradle @@ -72,8 +72,8 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' - // s3 - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // gcs + implementation 'com.google.cloud:google-cloud-storage:2.40.1' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..605c9893 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,114 @@ +name: cockple + +services: + mysql: + image: mysql:8.0 + container_name: cockple-mysql + restart: always + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --innodb-buffer-pool-size=256M + volumes: + - mysql-data:/var/lib/mysql + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + mem_limit: 512m + memswap_limit: 768m + + redis: + image: redis:7-alpine + container_name: cockple-redis + restart: always + ports: + - "6379:6379" + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory + - 200mb + - --maxmemory-policy + - allkeys-lru + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + mem_limit: 256m + memswap_limit: 384m + + cockple-app: + container_name: cockple-app + image: kanghana1/cockple:latest + restart: always + environment: + JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m" + SPRING_PROFILES_ACTIVE: prod + DB_PASSWORD: ${DB_PASSWORD} + GCS_BUCKET: ${GCS_BUCKET} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_PROD} + KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + mem_limit: 1200m + memswap_limit: 1536m + + cockple-app-staging: + container_name: cockple-app-staging + image: kanghana1/cockple:staging + restart: always + environment: + JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m" + SPRING_PROFILES_ACTIVE: staging + DB_PASSWORD: ${DB_PASSWORD} + GCS_BUCKET: ${GCS_BUCKET} + KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} + KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_STAGING} + KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + mem_limit: 1024m + memswap_limit: 1280m + + nginx: + image: nginx:stable-alpine + container_name: cockple-nginx + restart: always + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + mem_limit: 64m + memswap_limit: 128m + +volumes: + mysql-data: + redis-data: + +networks: + default: + name: cockple_network diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 00000000..a50a51c5 --- /dev/null +++ b/init-db.sql @@ -0,0 +1,2 @@ +CREATE DATABASE IF NOT EXISTS cockple CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS cockple_staging CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/nginx/conf.d/prod.conf b/nginx/conf.d/prod.conf new file mode 100644 index 00000000..314b3e38 --- /dev/null +++ b/nginx/conf.d/prod.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name cockple.store; + + location / { + proxy_pass http://cockple-app:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } +} diff --git a/nginx/conf.d/staging.conf b/nginx/conf.d/staging.conf new file mode 100644 index 00000000..2a222c17 --- /dev/null +++ b/nginx/conf.d/staging.conf @@ -0,0 +1,12 @@ +server { + listen 80; + server_name staging.cockple.store; + + location / { + proxy_pass http://cockple-app-staging:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..2ce913f6 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,25 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 30M; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..32fa0040 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +DOCKER_REPO=$1 +BRANCH=$2 + +cd /home/ubuntu/cockple + +if [ "$BRANCH" == "main" ]; then + SERVICE="cockple-app" + TAG="latest" +else + SERVICE="cockple-app-staging" + TAG="staging" +fi + +cat > .env << EOF +DB_PASSWORD=${DB_PASSWORD} +GCS_BUCKET=${GCS_BUCKET} +KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID} +KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET} +KAKAO_REDIRECT_URI_PROD=${KAKAO_REDIRECT_URI_PROD} +KAKAO_REDIRECT_URI_STAGING=${KAKAO_REDIRECT_URI_STAGING} +KAKAO_ADMIN_KEY=${KAKAO_ADMIN_KEY} +JWT_SECRET_KEY=${JWT_SECRET_KEY} +EOF + +echo "=== 배포 전 상태 ===" +sudo docker ps + +sudo docker compose up -d mysql redis nginx +sudo docker image prune -f +sudo docker pull $DOCKER_REPO:$TAG + +sudo docker stop $SERVICE || true +sudo docker rm -f $SERVICE || true + +sudo docker compose up -d $SERVICE + +echo "=== 배포 후 상태 ===" +sudo docker ps + +echo "=== 헬스체크 ===" +for container in cockple-mysql cockple-redis $SERVICE; do + for i in $(seq 1 12); do + STATUS=$(sudo docker inspect --format='{{.State.Status}}' $container 2>/dev/null) + HEALTH=$(sudo docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $container 2>/dev/null) + + if [ "$STATUS" != "running" ]; then + echo "FAIL: $container 상태 이상 (status=$STATUS)" + sudo docker logs --tail 20 $container + exit 1 + fi + + if [ "$HEALTH" == "healthy" ] || [ "$HEALTH" == "none" ]; then + echo "OK: $container (status=$STATUS, health=$HEALTH)" + break + fi + + if [ $i -eq 12 ]; then + echo "FAIL: $container 헬스체크 타임아웃 (health=$HEALTH)" + sudo docker logs --tail 20 $container + exit 1 + fi + + echo "대기 중: $container ($i/12, health=$HEALTH)..." + sleep 5 + done +done + +echo "=== 배포 성공 ===" diff --git a/scripts/tunnel.sh b/scripts/tunnel.sh new file mode 100644 index 00000000..4786d106 --- /dev/null +++ b/scripts/tunnel.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# 사용법: bash scripts/tunnel.sh [GCP_IP] +# 예시: bash scripts/tunnel.sh 34.64.xxx.xxx + +GCP_IP=${1} + +if [ -z "$GCP_IP" ]; then + read -p "GCP IP 입력: " GCP_IP +fi + +echo "터널링 시작: $GCP_IP" +echo " MySQL -> localhost:3306 -> cockple-mysql:3306" +echo " Redis -> localhost:6379 -> cockple-redis:6379" +echo "종료: Ctrl+C" + +ssh -N \ + -L 3307:localhost:3306 \ + -L 6380:localhost:6379 \ + -i ~/.ssh/cockple_gcp \ + ubuntu@$GCP_IP diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java index 24b3eb4a..0c013009 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java @@ -1,7 +1,6 @@ package umc.cockple.demo.domain.chat.service; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.google.cloud.storage.Blob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.InputStreamResource; @@ -66,9 +65,9 @@ public ResponseEntity downloadFile(Long fileId, String token) { //채팅 파일 조회 ChatMessageFile chatFile = findChatFileOrThrow(fileId); - //S3에서 파일 객체 직접 가져오기 - S3Object s3Object = imageService.downloadFile(chatFile.getFileKey()); - ResponseEntity responseEntity = createDownloadResponseEntity(chatFile, s3Object); + //GCS에서 파일 객체 직접 가져오기 + Blob blob = imageService.downloadFile(chatFile.getFileKey()); + ResponseEntity responseEntity = createDownloadResponseEntity(chatFile, blob); log.info("파일 다운로드 완료 - fileName: {}", chatFile.getOriginalFileName()); return responseEntity; @@ -96,12 +95,11 @@ private void validateToken(Long fileId, String tokenValue) { downloadTokenRepository.delete(token); } - private ResponseEntity createDownloadResponseEntity(ChatMessageFile chatFile, S3Object s3Object) { - //S3 객체에서 직접 메타데이터를 가져오기 - long contentLength = s3Object.getObjectMetadata().getContentLength(); - String contentType = s3Object.getObjectMetadata().getContentType(); - S3ObjectInputStream inputStream = s3Object.getObjectContent(); - Resource resource = new InputStreamResource(inputStream); + private ResponseEntity createDownloadResponseEntity(ChatMessageFile chatFile, Blob blob) { + //GCS 객체에서 직접 메타데이터를 가져오기 + long contentLength = blob.getSize(); + String contentType = blob.getContentType(); + Resource resource = new InputStreamResource(new java.io.ByteArrayInputStream(blob.getContent())); //헤더 생성 ContentDisposition contentDisposition = ContentDisposition.builder("attachment") diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java index cd49e377..e7d778d0 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java @@ -1,7 +1,6 @@ package umc.cockple.demo.domain.chat.service; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.S3ObjectInputStream; +import com.google.cloud.storage.Blob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.InputStreamResource; @@ -66,9 +65,9 @@ public ResponseEntity downloadImage(Long imageId, String token) { //채팅 파일 조회 ChatMessageImg chatImage = findChatImageOrThrow(imageId); - //S3에서 파일 객체 직접 가져오기 - S3Object s3Object = imageService.downloadFile(chatImage.getImgKey()); - ResponseEntity responseEntity = createDownloadResponseEntity(chatImage, s3Object); + //GCS에서 파일 객체 직접 가져오기 + Blob blob = imageService.downloadFile(chatImage.getImgKey()); + ResponseEntity responseEntity = createDownloadResponseEntity(chatImage, blob); log.info("이미지 다운로드 완료 - imageName: {}", chatImage.getOriginalFileName()); return responseEntity; @@ -96,12 +95,11 @@ private void validateToken(Long ImageId, String tokenValue) { downloadTokenRepository.delete(token); } - private ResponseEntity createDownloadResponseEntity(ChatMessageImg chatMessageImg, S3Object s3Object) { - //S3 객체에서 직접 메타데이터를 가져오기 - long contentLength = s3Object.getObjectMetadata().getContentLength(); - String contentType = s3Object.getObjectMetadata().getContentType(); - S3ObjectInputStream inputStream = s3Object.getObjectContent(); - Resource resource = new InputStreamResource(inputStream); + private ResponseEntity createDownloadResponseEntity(ChatMessageImg chatMessageImg, Blob blob) { + //GCS 객체에서 직접 메타데이터를 가져오기 + long contentLength = blob.getSize(); + String contentType = blob.getContentType(); + Resource resource = new InputStreamResource(new java.io.ByteArrayInputStream(blob.getContent())); //헤더 생성 ContentDisposition contentDisposition = ContentDisposition.builder("attachment") diff --git a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java b/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java index e2ed69f0..aef8c61c 100644 --- a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java +++ b/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java @@ -26,7 +26,7 @@ public class ImgController { private final ImageService imageService; @PostMapping(value = "/s3/upload/img", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "이미지 업로드", description = "S3에 이미지를 업로드하고 이미지 URL과 imgKey를 반환합니다.") + @Operation(summary = "이미지 업로드", description = "GCS에 이미지를 업로드하고 이미지 URL과 imgKey를 반환합니다.") public BaseResponse imgUpload(@RequestPart("image") MultipartFile image, @RequestParam("domainType") DomainType domainType) { @@ -35,7 +35,7 @@ public BaseResponse imgUpload(@RequestPart("image") Mul @PostMapping(value = "/s3/upload/imgs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "이미지 여러장 업로드", description = "S3에 이미지 여러장을 업로드하고 이미지 URL과 imgKey를 반환합니다.") + @Operation(summary = "이미지 여러장 업로드", description = "GCS에 이미지 여러장을 업로드하고 이미지 URL과 imgKey를 반환합니다.") public BaseResponse> imgUpload(@RequestPart("image") List images, @RequestParam("domainType") DomainType domainType) { @@ -43,7 +43,7 @@ public BaseResponse> imgUpload(@RequestPart("image } @PostMapping(value = "/s3/upload/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "파일 업로드", description = "S3에 파일을 업로드하고 파일정보를 반환합니다.") + @Operation(summary = "파일 업로드", description = "GCS에 파일을 업로드하고 파일정보를 반환합니다.") public BaseResponse fileUpload(@RequestPart("file") MultipartFile file, @RequestParam("domainType") DomainType domainType) { diff --git a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java b/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java index bef5bbdc..ff74504c 100644 --- a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java +++ b/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java @@ -1,10 +1,6 @@ package umc.cockple.demo.domain.image.service; -import com.amazonaws.AmazonServiceException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.S3Object; +import com.google.cloud.storage.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -27,11 +23,10 @@ @Slf4j public class ImageService { - @Value("${cloud.aws.s3.bucket}") + @Value("${gcs.bucket}") private String bucket; - private final AmazonS3 amazonS3; - + private final Storage storage; public ImageUploadDTO.Response uploadImage(MultipartFile image, DomainType domainType) { if (image == null || image.isEmpty()) { @@ -41,8 +36,8 @@ public ImageUploadDTO.Response uploadImage(MultipartFile image, DomainType domai log.info("[이미지 업로드 시작]"); String originalFileName = image.getOriginalFilename(); - String key = getFileKey(image, domainType); // 예: contest-images/uuid.jpg - String imgUrl = uploadToS3(image, key, false); + String key = getFileKey(image, domainType); + String imgUrl = uploadToGcs(image, key); log.info("[이미지 업로드 완료]"); return ImageUploadDTO.Response.builder() @@ -63,7 +58,7 @@ public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainTy String originalFileName = file.getOriginalFilename(); String key = getFileKey(file, domainType); - String fileUrl = uploadToS3(file, key, false); + String fileUrl = uploadToGcs(file, key); log.info("[파일 업로드 완료]"); return FileUploadDTO.Response.builder() @@ -75,14 +70,9 @@ public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainTy .build(); } - /** - * 다중 이미지 업로드 - * @param images MultipartFile 이미지 리스트 - * @return 업로드된 이미지 URL 리스트 - */ public List uploadImages(List images, DomainType domainType) { if (images == null || images.isEmpty()) { - return List.of(); // 빈 리스트 반환 + return List.of(); } return images.stream() @@ -92,31 +82,27 @@ public List uploadImages(List images, Do public void delete(String imgKey) { try { - amazonS3.deleteObject(bucket, imgKey); - log.info("[S3 삭제 성공] {}", imgKey); + storage.delete(BlobId.of(bucket, imgKey)); + log.info("[GCS 삭제 성공] {}", imgKey); } catch (Exception e) { - log.error("[S3 삭제 실패] {}", e.getMessage()); + log.error("[GCS 삭제 실패] {}", e.getMessage()); throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION); } } - private String uploadToS3(MultipartFile file, String key, boolean useMetadata) { + private String uploadToGcs(MultipartFile file, String key) { try { - if (useMetadata) { - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(file.getSize()); - metadata.setContentType(file.getContentType()); - amazonS3.putObject(new PutObjectRequest(bucket, key, file.getInputStream(), metadata)); - } else { - amazonS3.putObject(new PutObjectRequest(bucket, key, file.getInputStream(), null)); - } - return amazonS3.getUrl(bucket, key).toString(); - } catch (AmazonServiceException e) { - log.error("[S3 업로드 실패 - AWS 예외] {}", e.getMessage()); - throw new S3Exception(S3ErrorCode.FILE_UPLOAD_AMAZON_EXCEPTION); + BlobInfo blobInfo = BlobInfo.newBuilder(bucket, key) + .setContentType(file.getContentType()) + .build(); + storage.create(blobInfo, file.getBytes()); + return String.format("https://storage.googleapis.com/%s/%s", bucket, key); } catch (IOException e) { - log.error("[S3 업로드 실패 - IO 예외] {}", e.getMessage()); + log.error("[GCS 업로드 실패 - IO 예외] {}", e.getMessage()); throw new S3Exception(S3ErrorCode.FILE_UPLOAD_IO_EXCEPTION); + } catch (StorageException e) { + log.error("[GCS 업로드 실패 - Storage 예외] {}", e.getMessage()); + throw new S3Exception(S3ErrorCode.FILE_UPLOAD_AMAZON_EXCEPTION); } } @@ -125,20 +111,22 @@ public String getFileKey(MultipartFile file, DomainType domainType) { return null; } - // 원본 파일명에서 확장자 추출 String originalFilename = file.getOriginalFilename(); String extension = StringUtils.getFilenameExtension(originalFilename); - // UUID 기반 유니크 키 생성 String uuid = UUID.randomUUID().toString(); return domainType.getDirectory() + "/" + uuid + "." + extension; } public String getUrlFromKey(String key) { - return amazonS3.getUrl(bucket, key).toString(); + return String.format("https://storage.googleapis.com/%s/%s", bucket, key); } - public S3Object downloadFile(String fileKey) { - return amazonS3.getObject(bucket, fileKey); + public Blob downloadFile(String fileKey) { + Blob blob = storage.get(BlobId.of(bucket, fileKey)); + if (blob == null) { + throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION); + } + return blob; } } \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/global/config/GcsConfig.java b/src/main/java/umc/cockple/demo/global/config/GcsConfig.java new file mode 100644 index 00000000..9fb20578 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/config/GcsConfig.java @@ -0,0 +1,15 @@ +package umc.cockple.demo.global.config; + +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GcsConfig { + + @Bean + public Storage storage() { + return StorageOptions.getDefaultInstance().getService(); + } +} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/global/config/RedisConfig.java b/src/main/java/umc/cockple/demo/global/config/RedisConfig.java index 1efe480a..d8de2b9e 100644 --- a/src/main/java/umc/cockple/demo/global/config/RedisConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/RedisConfig.java @@ -32,9 +32,13 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int redisPort; + @Value("${spring.data.redis.database:0}") + private int redisDatabase; + @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisHost, redisPort); + configuration.setDatabase(redisDatabase); return new LettuceConnectionFactory(configuration); } diff --git a/src/main/java/umc/cockple/demo/global/config/S3Config.java b/src/main/java/umc/cockple/demo/global/config/S3Config.java deleted file mode 100644 index e0574df9..00000000 --- a/src/main/java/umc/cockple/demo/global/config/S3Config.java +++ /dev/null @@ -1,40 +0,0 @@ -package umc.cockple.demo.global.config; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; - -@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 - @Primary - public BasicAWSCredentials awsCredentialsProvider(){ - BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey); - return basicAWSCredentials; - } - - @Bean - public AmazonS3 amazonS3Client() { - BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); - return AmazonS3ClientBuilder.standard() - .withRegion(region) - .enablePathStyleAccess() - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java index 6aae4a97..52718bf0 100644 --- a/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/SecurityConfig.java @@ -69,7 +69,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:5173", "https://cockple.store", "https://cockple-fe.vercel.app")); // 배포 시에는 도메인 지정 권장 + config.setAllowedOrigins(List.of("http://localhost:5173", "https://cockple.store", "https://staging.cockple.store", "https://cockple-fe.vercel.app")); // 배포 시에는 도메인 지정 권장 config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..d83c9adf --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3307/cockple?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + + data: + redis: + host: localhost + port: 6380 + database: 0 + + jpa: + hibernate: + ddl-auto: update diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..6a100ef4 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:mysql://cockple-mysql:3306/cockple?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + + jpa: + hibernate: + ddl-auto: validate + + data: + redis: + host: cockple-redis + database: 0 diff --git a/src/main/resources/application-staging.yml b/src/main/resources/application-staging.yml new file mode 100644 index 00000000..42edab0c --- /dev/null +++ b/src/main/resources/application-staging.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:mysql://cockple-mysql:3306/cockple_staging?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul + + jpa: + hibernate: + ddl-auto: update + + data: + redis: + host: cockple-redis + database: 1 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb6831ad..dc17074b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,26 +10,23 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} + username: root + password: ${DB_PASSWORD} sql: init: mode: never jpa: + show_sql: false hibernate: ddl-auto: update - show_sql: false properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: false use_sql_comments: false default_batch_fetch_size: 1000 - show_sql: false - servlet: multipart: @@ -38,8 +35,8 @@ spring: data: redis: - host: localhost port: 6379 + database: 0 lettuce: pool: max-active: 8 @@ -52,21 +49,12 @@ spring: write-dates-as-timestamps: false deserialization: fail-on-unknown-properties: false + cache: type: redis -cloud: - aws: - s3: - bucket: ${S3_BUCKET} - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - region: - static: ap-northeast-2 - auto: false - stack: - auto: false +gcs: + bucket: ${GCS_BUCKET} kakao: client-id: ${KAKAO_CLIENT_ID} @@ -85,4 +73,3 @@ jwt: logging: level: org.hibernate.SQL: WARN - diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index ab4deefc..171e20b3 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -17,17 +17,8 @@ spring: cache: type: redis -cloud: - aws: - s3: - bucket: test-bucket - credentials: - access-key: test-access-key - secret-key: test-secret-key - region: - static: ap-northeast-2 - stack: - auto: false +gcs: + bucket: test-bucket kakao: client-id: test-client-id diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index fe01691f..0507865a 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -20,14 +20,5 @@ spring: format_sql: true use_sql_comments: true -cloud: - aws: - s3: - bucket: test-bucket - credentials: - access-key: test-access-key - secret-key: test-secret-key - region: - static: ap-northeast-2 - stack: - auto: false \ No newline at end of file +gcs: + bucket: test-bucket \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..f5fc3276 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "4.52.5" + constraints = "~> 4.0" + hashes = [ + "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", + "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", + "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", + "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", + "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", + "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", + "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", + "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", + "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", + "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", + "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", + "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", + "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", + "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "5.45.2" + constraints = "~> 5.0" + hashes = [ + "h1:k8taQAdfHrv2F/AiGV5BZBZfI+1uaq8g6O8dWzjx42c=", + "zh:0d09c8f20b556305192cdbe0efa6d333ceebba963a8ba91f9f1714b5a20c4b7a", + "zh:117143fc91be407874568df416b938a6896f94cb873f26bba279cedab646a804", + "zh:16ccf77d18dd2c5ef9c0625f9cf546ebdf3213c0a452f432204c69feed55081e", + "zh:3e555cf22a570a4bd247964671f421ed7517970cd9765ceb46f335edc2c6f392", + "zh:688bd5b05a75124da7ae6e885b2b92bd29f4261808b2b78bd5f51f525c1052ca", + "zh:6db3ef37a05010d82900bfffb3261c59a0c247e0692049cb3eb8c2ef16c9d7bf", + "zh:70316fde75f6a15d72749f66d994ccbdde5f5ed4311b6d06b99850f698c9bbf9", + "zh:84b8e583771a4f2bd514e519d98ed7fd28dce5efe0634e973170e1cfb5556fb4", + "zh:9d4b8ef0a9b6677935c604d94495042e68ff5489932cfd1ec41052e094a279d3", + "zh:a2089dd9bd825c107b148dd12d6b286f71aa37dfd4ca9c35157f2dcba7bc19d8", + "zh:f03d795c0fd9721e59839255ee7ba7414173017dc530b4ce566daf3802a0d6dd", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/terraform/compute.tf b/terraform/compute.tf new file mode 100644 index 00000000..8910e2cf --- /dev/null +++ b/terraform/compute.tf @@ -0,0 +1,50 @@ +resource "google_compute_address" "prod" { + name = "cockple-prod-ip" + region = "asia-northeast3" +} + +resource "google_compute_instance" "prod" { + name = "cockple-prod" + machine_type = "e2-medium" # 4GB RAM + zone = "asia-northeast3-b" + tags = ["cockple-prod"] + allow_stopping_for_update = true + + boot_disk { + initialize_params { + image = "ubuntu-os-cloud/ubuntu-2204-lts" + size = 30 + } + } + + network_interface { + subnetwork = google_compute_subnetwork.prod.id + access_config { + nat_ip = google_compute_address.prod.address + } + } + + metadata = { + ssh-keys = "ubuntu:${var.ssh_public_key}" + } + + service_account { + email = google_service_account.cockple_app.email + scopes = ["cloud-platform"] + } + + metadata_startup_script = <<-EOF + #!/bin/bash + apt-get update -y + apt-get install -y ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + systemctl enable docker + systemctl start docker + usermod -aG docker ubuntu + EOF +} diff --git a/terraform/dns.tf b/terraform/dns.tf new file mode 100644 index 00000000..55143ad9 --- /dev/null +++ b/terraform/dns.tf @@ -0,0 +1,23 @@ +resource "cloudflare_record" "prod" { + zone_id = var.cloudflare_zone_id + name = "@" + content = google_compute_address.prod.address + type = "A" + proxied = true +} + +resource "cloudflare_record" "ssh" { + zone_id = var.cloudflare_zone_id + name = "ssh" + content = google_compute_address.prod.address + type = "A" + proxied = false +} + +resource "cloudflare_record" "staging" { + zone_id = var.cloudflare_zone_id + name = "staging" + content = google_compute_address.prod.address + type = "A" + proxied = true +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..9fa928de --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + } +} + +provider "google" { + project = var.gcp_project_id + region = var.gcp_region +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} \ No newline at end of file diff --git a/terraform/network.tf b/terraform/network.tf new file mode 100644 index 00000000..2eeadd24 --- /dev/null +++ b/terraform/network.tf @@ -0,0 +1,55 @@ +resource "google_compute_network" "cockple_vpc" { + name = "cockple-vpc" + auto_create_subnetworks = false +} + +resource "google_compute_subnetwork" "prod" { + name = "cockple-subnet-prod" + ip_cidr_range = "10.0.1.0/24" + region = "asia-northeast3" + network = google_compute_network.cockple_vpc.id +} + +# Cloudflare IP 대역에서만 80 포트 허용 (origin IP 보호) +resource "google_compute_firewall" "allow_http_cloudflare" { + name = "cockple-allow-http-cloudflare" + network = google_compute_network.cockple_vpc.name + + allow { + protocol = "tcp" + ports = ["80"] + } + + source_ranges = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + ] + + target_tags = ["cockple-prod"] +} + +resource "google_compute_firewall" "allow_ssh" { + name = "cockple-allow-ssh" + network = google_compute_network.cockple_vpc.name + + allow { + protocol = "tcp" + ports = ["22"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["cockple-prod"] +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 00000000..717c5af4 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "prod_ip" { + description = "서버 공인 IP (prod + staging 공용)" + value = google_compute_address.prod.address +} + +output "gcs_bucket_name" { + description = "GCS 버킷 이름" + value = google_storage_bucket.cockple_assets.name +} + +output "app_service_account_email" { + description = "앱 서비스 계정 이메일 (GCS 인증에 사용)" + value = google_service_account.cockple_app.email +} \ No newline at end of file diff --git a/terraform/storage.tf b/terraform/storage.tf new file mode 100644 index 00000000..78ee5250 --- /dev/null +++ b/terraform/storage.tf @@ -0,0 +1,32 @@ +resource "google_project_service" "storage" { + service = "storage.googleapis.com" + disable_on_destroy = false +} + +# 앱 인스턴스용 서비스 계정 (GCS 접근) +resource "google_service_account" "cockple_app" { + account_id = "cockple-app" + display_name = "Cockple App Service Account" +} + +resource "google_storage_bucket" "cockple_assets" { + name = "cockple-assets-${var.gcp_project_id}" + location = "ASIA-NORTHEAST3" + + uniform_bucket_level_access = true + + cors { + origin = ["https://cockple.store", "https://staging.cockple.store"] + method = ["GET", "PUT", "POST", "DELETE"] + response_header = ["Content-Type"] + max_age_seconds = 3600 + } + + depends_on = [google_project_service.storage] +} + +resource "google_storage_bucket_iam_member" "app_storage_admin" { + bucket = google_storage_bucket.cockple_assets.name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.cockple_app.email}" +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..27b8c522 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,26 @@ +variable "gcp_project_id" { + description = "GCP 프로젝트 ID" + type = string +} + +variable "gcp_region" { + description = "GCP 기본 리전 (prod)" + type = string + default = "asia-northeast3" +} + +variable "cloudflare_api_token" { + description = "Cloudflare API 토큰" + type = string + sensitive = true +} + +variable "cloudflare_zone_id" { + description = "cockple.store Cloudflare Zone ID" + type = string +} + +variable "ssh_public_key" { + description = "인스턴스 접속용 SSH 공개키" + type = string +}