From ddee2fef45c7f16753867145f6855a8725038945 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 14:59:31 +0900 Subject: [PATCH 01/38] =?UTF-8?q?feat(deploy):=20EC2=20=EB=B0=8F=20RDS=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?(Dockerfile,=20compose.prod.yml,=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 33 +++++ backend/src/main/resources/application.yml | 40 +++++- compose.prod.yml | 29 ++++ database/production/001_initial_schema.sql | 149 +++++++++++++++++++++ database/production/002_reference_data.sql | 31 +++++ docs/project/work.md | 29 ++++ frontend/Dockerfile | 44 ++++++ 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 compose.prod.yml create mode 100644 database/production/001_initial_schema.sql create mode 100644 database/production/002_reference_data.sql create mode 100644 frontend/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5aaa511 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +# ========================================== +# 1단계: 빌드 스테이지 (JDK 21) +# ========================================== +FROM eclipse-temurin:21-jdk-jammy AS builder +WORKDIR /app + +# Gradle 래퍼 및 빌드 스크립트 복사 +COPY gradlew . +COPY gradle gradle +COPY build.gradle settings.gradle ./ +RUN chmod +x ./gradlew + +# 소스코드 복사 및 bootJar 빌드 (테스트 제외하여 빠른 배포 빌드) +COPY src src +RUN ./gradlew bootJar -x test --no-daemon + +# ========================================== +# 2단계: 실행 스테이지 (초경량 JRE 21) +# ========================================== +FROM eclipse-temurin:21-jre-jammy AS runner +WORKDIR /app + +# 보안을 위한 비root 시스템 유저 생성 +RUN addgroup --system --gid 1001 spring && adduser --system --uid 1001 --ingroup spring spring +USER spring:spring + +# 빌더에서 생성된 실행 가능한 jar 파일만 추출 +COPY --from=builder --chown=spring:spring /app/build/libs/*.jar app.jar + +EXPOSE 8080 +ENV TZ=Asia/Seoul + +ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"] diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c9cdfb6..0cd323d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -45,6 +45,7 @@ spring: jpa: hibernate: ddl-auto: update + show-sql: true properties: hibernate: @@ -53,12 +54,12 @@ spring: --- # ========================================== -# [Profile: docker / prod] Docker Compose 및 운영 컨테이너 환경 +# [Profile: docker] 로컬 Docker Compose 환경 # ========================================== spring: config: activate: - on-profile: docker, prod + on-profile: docker datasource: url: jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul @@ -73,3 +74,38 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect + +--- +# ========================================== +# [Profile: prod] AWS EC2 + RDS 운영 컨테이너 환경 +# ========================================== +spring: + config: + activate: + on-profile: prod + + datasource: + url: ${SNOWTHING_PROD_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${SNOWTHING_DB_USERNAME} + password: ${SNOWTHING_DB_PASSWORD} + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.hibernate.SQL: info + org.hibernate.orm.jdbc.bind: info + com.ikae.snowthing: info diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..7f2a18a --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,29 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: snowthing-backend + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SNOWTHING_PROD_DB_URL=${SNOWTHING_PROD_DB_URL:?SNOWTHING_PROD_DB_URL must be set} + - SNOWTHING_DB_USERNAME=${SNOWTHING_DB_USERNAME:?SNOWTHING_DB_USERNAME must be set} + - SNOWTHING_DB_PASSWORD=${SNOWTHING_DB_PASSWORD:?SNOWTHING_DB_PASSWORD must be set} + - TZ=Asia/Seoul + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-} + container_name: snowthing-frontend + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 diff --git a/database/production/001_initial_schema.sql b/database/production/001_initial_schema.sql new file mode 100644 index 0000000..a472ec2 --- /dev/null +++ b/database/production/001_initial_schema.sql @@ -0,0 +1,149 @@ +-- ==================================================================== +-- Snowthing Production Initial Schema (MySQL 8.0) +-- Version: 001 +-- Description: 운영 RDS 최초 스키마 생성 스크립트 (비파괴 CREATE TABLE IF NOT EXISTS) +-- ==================================================================== + +-- 1. 크루 마스터 테이블 (1인 1크루 소속) +CREATE TABLE IF NOT EXISTS `crew` ( + `crew_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '크루 고유 식별자', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 노출용 UUID v7', + `name` VARCHAR(100) NOT NULL COMMENT '크루 이름', + `description` TEXT NULL COMMENT '크루 소개', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='크루 마스터'; + +-- 2. 회원 마스터 테이블 +CREATE TABLE IF NOT EXISTS `member` ( + `member_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'DB 내부 조인용 PK', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 API/URL 노출용 UUID v7', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '로그인 이메일 계정', + `password` VARCHAR(255) NULL COMMENT 'BCrypt 암호화 비밀번호 (OAuth2 대비 NULLABLE)', + `nickname` VARCHAR(50) NOT NULL UNIQUE COMMENT '활동 닉네임', + `profile_image_url` VARCHAR(500) NULL COMMENT '프로필 이미지 URL', + `bio` VARCHAR(255) NULL COMMENT '자기소개 한마디', + `departure_region` VARCHAR(100) NULL COMMENT '주 출발/거주 지역', + `crew_id` BIGINT NULL COMMENT '소속 크루 ID', + `crew_role` VARCHAR(20) NULL COMMENT '크루 내 권한 (OWNER, MANAGER, MEMBER)', + `role` VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER' COMMENT '전역 권한 (ROLE_USER, ROLE_ADMIN)', + `status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '계정 상태 (ACTIVE, SUSPENDED, WITHDRAWN)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '가입 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_member_crew` FOREIGN KEY (`crew_id`) REFERENCES `crew` (`crew_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원 마스터'; + +-- 3. 리조트 마스터 테이블 (Resort.java: length=100, unique=true) +CREATE TABLE IF NOT EXISTS `resort` ( + `resort_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '리조트 고유 식별자', + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '스키장 이름', + `region` VARCHAR(50) NOT NULL COMMENT '소재 지역' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='리조트 마스터'; + +-- 4. 회원-리조트 N:M 중계 테이블 +CREATE TABLE IF NOT EXISTS `member_resort` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '단일 대리키 PK', + `member_id` BIGINT NOT NULL COMMENT '회원 ID', + `resort_id` BIGINT NOT NULL COMMENT '리조트 ID', + `is_main` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '주 베이스 여부', + CONSTRAINT `fk_mr_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `fk_mr_resort` FOREIGN KEY (`resort_id`) REFERENCES `resort` (`resort_id`) ON DELETE CASCADE, + CONSTRAINT `uk_member_resort` UNIQUE (`member_id`, `resort_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원-리조트 N:M 중계'; + +-- 5. 라이딩 스타일 마스터 테이블 (RidingStyle.java: description, name UNIQUE) +CREATE TABLE IF NOT EXISTS `riding_style` ( + `style_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '스타일 고유 식별자', + `name` VARCHAR(50) NOT NULL UNIQUE COMMENT '라이딩 스타일명', + `description` VARCHAR(255) NULL COMMENT '스타일 상세 설명' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='라이딩 스타일 마스터'; + +-- 6. 회원-라이딩스타일 N:M 중계 테이블 +CREATE TABLE IF NOT EXISTS `member_riding_style` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '단일 대리키 PK', + `member_id` BIGINT NOT NULL COMMENT '회원 ID', + `style_id` BIGINT NOT NULL COMMENT '라이딩 스타일 ID', + CONSTRAINT `fk_mrs_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `fk_mrs_style` FOREIGN KEY (`style_id`) REFERENCES `riding_style` (`style_id`) ON DELETE CASCADE, + CONSTRAINT `uk_member_riding_style` UNIQUE (`member_id`, `style_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='회원-라이딩스타일 N:M 중계'; + +-- 7. 게시판 카테고리 테이블 +CREATE TABLE IF NOT EXISTS `post_category` ( + `category_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '카테고리 식별자', + `name` VARCHAR(50) NOT NULL COMMENT '카테고리명', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '카테고리 고유 코드' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시판 카테고리'; + +-- 8. 게시글 테이블 +CREATE TABLE IF NOT EXISTS `post` ( + `post_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '게시글 고유 식별자', + `public_id` VARCHAR(36) NOT NULL UNIQUE COMMENT '외부 API/URL 노출용 UUID v7', + `member_id` BIGINT NULL COMMENT '작성자 회원 ID (비회원 작성 시 NULL)', + `category_id` BIGINT NOT NULL COMMENT '카테고리 ID', + `title` VARCHAR(200) NOT NULL COMMENT '게시글 제목', + `content` LONGTEXT NOT NULL COMMENT '게시글 본문', + `writer_ip` VARCHAR(45) NOT NULL COMMENT '작성자 IP 주소', + `is_anonymous` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '익명 작성 여부', + `anonymous_password` VARCHAR(255) NULL COMMENT '비회원 익명 수정/삭제용 비밀번호 (BCrypt)', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '조회수', + `comment_count` INT NOT NULL DEFAULT 0 COMMENT '댓글 수 역정규화', + `like_count` INT NOT NULL DEFAULT 0 COMMENT '추천 수 역정규화', + `dislike_count` INT NOT NULL DEFAULT 0 COMMENT '비추천 수 역정규화', + `has_image` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '이미지 첨부 여부 역정규화 (목록 뱃지 아이콘 렌더링용)', + `status` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '게시글 상태 (NORMAL, DELETED, BLOCKED, HIDDEN, DRAFT)', + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_post_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, + CONSTRAINT `fk_post_category` FOREIGN KEY (`category_id`) REFERENCES `post_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글'; + +-- 9. 게시글 첨부 이미지 테이블 (PostImage.java: BaseTimeEntity 상속으로 updated_at 포함) +CREATE TABLE IF NOT EXISTS `post_image` ( + `image_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '이미지 고유 식별자', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `image_url` VARCHAR(500) NOT NULL COMMENT '이미지 파일 URL', + `sort_order` INT NOT NULL DEFAULT 1 COMMENT '표시 순서', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_pi_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 첨부 이미지'; + +-- 10. 게시글 추천/비추천 중계 테이블 +CREATE TABLE IF NOT EXISTS `post_reaction` ( + `reaction_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '반응 식별자', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `member_id` BIGINT NULL COMMENT '회원 ID (비회원 투표 시 NULL)', + `writer_ip` VARCHAR(45) NULL COMMENT '투표자 IP 주소', + `anonymous_voter_id` VARCHAR(36) NULL COMMENT '비회원 익명 투표자 식별 쿠키 ID', + `type` VARCHAR(10) NOT NULL COMMENT '반응 종류 (LIKE, DISLIKE)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '투표 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + CONSTRAINT `fk_pr_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, + CONSTRAINT `fk_pr_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, + CONSTRAINT `uk_post_member_type` UNIQUE (`post_id`, `member_id`, `type`), + CONSTRAINT `uk_post_anon_voter_type` UNIQUE (`post_id`, `anonymous_voter_id`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 추천/비추천'; + +-- 11. 댓글 및 계층형 대댓글 테이블 (Comment.java: @Version version 컬럼 포함) +CREATE TABLE IF NOT EXISTS `comment` ( + `comment_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 식별자', + `version` BIGINT NOT NULL DEFAULT 0 COMMENT '낙관적 락 동시성 제어용 버전', + `post_id` BIGINT NOT NULL COMMENT '게시글 ID', + `member_id` BIGINT NULL COMMENT '작성자 회원 ID (비회원 작성 시 NULL)', + `parent_id` BIGINT NULL COMMENT '부모 댓글 ID (대댓글 구현용)', + `content` VARCHAR(1000) NOT NULL COMMENT '댓글 내용', + `writer_ip` VARCHAR(45) NOT NULL COMMENT '작성자 IP 주소', + `is_anonymous` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '익명 작성 여부', + `anonymous_password` VARCHAR(255) NULL COMMENT '비회원 익명 수정/삭제용 비밀번호 (BCrypt)', + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + INDEX `idx_comment_post_parent_id` (`post_id`, `parent_id`, `comment_id`), + INDEX `idx_comment_parent_deleted_id` (`parent_id`, `is_deleted`, `comment_id`), + CONSTRAINT `fk_comment_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, + CONSTRAINT `fk_comment_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, + CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`comment_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='댓글 및 계층형 대댓글'; diff --git a/database/production/002_reference_data.sql b/database/production/002_reference_data.sql new file mode 100644 index 0000000..85da1eb --- /dev/null +++ b/database/production/002_reference_data.sql @@ -0,0 +1,31 @@ +-- ==================================================================== +-- Snowthing Production Reference Data (MySQL 8.0) +-- Version: 002 +-- Description: 운영 환경 필수 마스터 기준데이터 삽입 스크립트 (멱등성 보장 INSERT IGNORE) +-- ==================================================================== + +-- 1. 리조트 마스터 기준데이터 (6개) +INSERT IGNORE INTO `resort` (`name`, `region`) VALUES +('휘닉스파크', '강원 평창'), +('하이원리조트', '강원 정선'), +('모나용평', '강원 평창'), +('비발디파크', '강원 홍천'), +('웰리힐리파크', '강원 횡성'), +('지산리조트', '경기 이천'); + +-- 2. 라이딩 스타일 마스터 기준데이터 (6개) +INSERT IGNORE INTO `riding_style` (`name`, `description`) VALUES +('올라운드', '슬로프, 트릭, 파크 등을 가리지 않고 다양하게 즐기는 스타일'), +('라이딩 / 카빙', '슬로프 고속 라이딩 및 칼날 카빙'), +('그라운드 트릭', '평지 버터링, 알리, 스핀 트릭'), +('파크 / 기물 / 파이프', '킥커 점프, 레일, 하프파이프'), +('입문 / 초보', '기초 자세 및 B턴, J턴 습득 중'), +('관광 / 크루징', '풍경 감상 및 여유로운 라이딩'); + +-- 3. 게시판 카테고리 마스터 기준데이터 (5개) +INSERT IGNORE INTO `post_category` (`name`, `code`) VALUES +('자유게시판', 'FREE'), +('익명게시판', 'ANONYMOUS'), +('질문게시판', 'QNA'), +('장비VS', 'GEAR_VS'), +('맛집게시판', 'FOOD'); diff --git a/docs/project/work.md b/docs/project/work.md index 7356b37..63d9590 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,32 @@ +- **AWS EC2 + RDS 운영 배포 3단계 스키마/설정/Compose 준비 완료 (2026-09-11)**: + 1. **작업명**: 운영 비파괴 스키마(`001_initial_schema.sql`), 기준데이터(`002_reference_data.sql`), Spring Boot prod 프로필 분리, multi-stage Dockerfile 및 `compose.prod.yml` 작성 + 2. **현재 상태**: 로컬 준비 및 구문/컴파일 검증 완료 (EC2 전달 및 적용 대기) + 3. **완료된 항목**: + - `database/production/001_initial_schema.sql`: 엔티티 불일치 4대 결함(`comment.version`, `riding_style.description`, `post_image.updated_at`, `resort.name UNIQUE`) 교정 및 비파괴 스키마 작성 완료. + - `database/production/002_reference_data.sql`: 테스트 계정 및 더미 글을 배제하고 스키장(6), 라이딩스타일(6), 게시판 카테고리(5) 필수 마스터 데이터만 분리 작성 완료 (`INSERT IGNORE`). + - `application.yml`: `docker`와 `prod` 프로필 분리, `SNOWTHING_PROD_DB_URL` 외부 주입, RDS TLS 검증(`useSSL=true`), HikariCP 풀 최적화, 운영 로그 `INFO` 설정 완료. + - `backend/Dockerfile`: Eclipse Temurin 21 JRE 경량 Multi-stage 및 비root `spring` 유저 실행 도커파일 작성 완료. + - `frontend/Dockerfile`: Node 20 Alpine 기반 `npm ci` ➔ `npm run build` ➔ `npm run start` Multi-stage 도커파일 작성 완료 (`NEXT_PUBLIC_API_BASE_URL` ARG 주입). + - `compose.prod.yml`: MySQL/Redis 없이 `backend`, `frontend`만 정의, 호스트 포트는 루프백(`127.0.0.1:8080:8080`, `127.0.0.1:3000:3000`) 바인딩 완료. + 4. **검증 결과**: + - `gradlew spotlessCheck compileJava` 100% BUILD SUCCESSFUL 통과. + - `docker compose -f compose.prod.yml config --services` 실행 결과 `backend`, `frontend`만 정확히 출력 확인. + - `docker compose -f compose.prod.yml config --quiet` 문법 검증 통과 (exit code 0). + 5. **남은 항목**: + - Git push ➔ EC2에서 `git pull`로 파일 동기화. + - EC2에서 RDS로 `001_initial_schema.sql` 및 `002_reference_data.sql` 적용 (테이블 11개 및 기준데이터 생성). + - EC2 `/etc/snowthing/prod.env` 작성 및 `compose.prod.yml up -d --build` 수동 배포 가동. + +- **Sprint 04 댓글 벤치마크 학습정리 문서 보강 및 디렉터리 영문화 완료 (2026-09-09)**: + 1. **디렉터리 영문화**: `benchmark` 하위 한글 폴더를 영문 표준으로 변경 (`실행계획` ➔ `explain-plans`, `쿼리` ➔ `queries`). + 2. **경로 동기화**: `ADR-002-댓글아키텍처.md` 및 `댓글-벤치마크-결과.md` 내 실행계획 경로를 `explain-plans/`로 갱신. + 3. **재현가이드 ➔ README.md 개편 및 내용 보강 (no_ai 톤)**: + - `재현가이드.md`를 `README.md`로 전환하고 실무 개발자 톤으로 4대 핵심 영역(Seed 코드 위치, 실행/초기화 명령어, 데이터 분포 구조, 1K~1M 9대 시나리오 검증 결과 및 인덱스/불변식 요약) 보강 완료. + 4. **`docs/study/sprint04/댓글조회-벤치마크-학습정리.md` 심층 학습서 보강 완결**: + - 5대 학습 목표와 멘토의 실험 의도 및 실무 배경(1K 메모리 착시 vs 1M 운영 장애, estimated/actual rows/loops 해석, 재현 가능 시드, 읽기 이점 vs 쓰기 비용, 운영 DB 안전장치) 기술. + - 1K·10K·100K·1M 9대 시나리오 종합 레이턴시 비교표 및 1M Invisible 인덱스 검증 비교표(226ms vs 36ms, actual rows 200,001 ➔ 2,000) 수록. + - 4대 핵심 결론 및 MySQL InnoDB 물리 엔진 심층 분석(16KB Buffer Pool I/O, B-Tree 수직 Seek 및 수평 Scan 메커니즘, Hotspot 국소 격리, 커버링 인덱스 Clustered Random I/O 차단, `Using filesort` 메모리 정렬 vs 4컬럼 B-Tree 페이지 분할 트레이드오프) 반영. + - 7대 필수 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & 극복 방안) 기반 복합 인덱스 계층 조회 아키텍처 정리 완료. - **Sprint 03 댓글 도메인 메인 브랜치 최종 병합 완료 (2026-09-06)**: 1. **PR #17 (`feature/sprint03-comment` ➔ `main`) 병합 완결**: - Sprint 03 댓글 도메인(생성·조회·수정·삭제 및 하이브리드 프리뷰 아키텍처) 전체 작업물을 `main` 브랜치로 병합 완료 ([PR #17](https://github.com/devikae/snowthing/pull/17) `MERGED`). diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a97b987 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,44 @@ +# ========================================== +# 1단계: 빌드 스테이지 (Node.js 20 Alpine) +# ========================================== +FROM node:20-alpine AS builder +WORKDIR /app + +# 패키지 의존성 파일 복사 및 설치 (npm ci로 정확한 잠금 버전 설치) +COPY package.json package-lock.json ./ +RUN npm ci + +# 소스코드 전체 복사 +COPY . . + +# 빌드 타임에 실제 도메인 주소(HTTPS) 주입 (예: https://snowthing.shop) +ARG NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +# Next.js 프로덕션 빌드 실행 +RUN npm run build + +# ========================================== +# 2단계: 실행 스테이지 (초경량 런타임) +# ========================================== +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# 보안을 위한 비root 유저 생성 +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs + +# 프로덕션 실행에 필요한 결과물만 복사 +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/package-lock.json ./package-lock.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next + +USER nextjs + +EXPOSE 3000 + +# 프로덕션 서버 구동 (npm run start) +CMD ["npm", "run", "start"] From 4426b9cc899c09a6d705f59a6b2633b350c3c412 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 15:18:15 +0900 Subject: [PATCH 02/38] =?UTF-8?q?feat(deploy):=20AWS=20Advanced=20JDBC=20W?= =?UTF-8?q?rapper=20=EA=B8=B0=EB=B0=98=20RDS=20IAM=20=EB=AC=B4=EC=95=94?= =?UTF-8?q?=ED=98=B8=20=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 2 ++ backend/src/main/resources/application.yml | 11 +++++++---- compose.prod.yml | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/build.gradle b/backend/build.gradle index b48eea1..0f22b25 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -35,6 +35,8 @@ dependencies { // JPA & DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'software.amazon.jdbc:aws-advanced-jdbc-wrapper:2.3.7' + implementation 'software.amazon.awssdk:rds:2.25.70' // Security & Session Redis implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0cd323d..e7d74ac 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -77,7 +77,7 @@ spring: --- # ========================================== -# [Profile: prod] AWS EC2 + RDS 운영 컨테이너 환경 +# [Profile: prod] AWS EC2 + RDS 운영 컨테이너 환경 (IAM 무암호 인증) # ========================================== spring: config: @@ -86,15 +86,18 @@ spring: datasource: url: ${SNOWTHING_PROD_DB_URL} - driver-class-name: com.mysql.cj.jdbc.Driver - username: ${SNOWTHING_DB_USERNAME} - password: ${SNOWTHING_DB_PASSWORD} + driver-class-name: software.amazon.jdbc.Driver + username: ${SNOWTHING_DB_USERNAME:snowthing_app} + password: ${SNOWTHING_DB_PASSWORD:} hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 + data-source-properties: + wrapperPlugins: iam + iamExpiration: 870 jpa: hibernate: diff --git a/compose.prod.yml b/compose.prod.yml index 7f2a18a..ed589e8 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -10,8 +10,8 @@ services: environment: - SPRING_PROFILES_ACTIVE=prod - SNOWTHING_PROD_DB_URL=${SNOWTHING_PROD_DB_URL:?SNOWTHING_PROD_DB_URL must be set} - - SNOWTHING_DB_USERNAME=${SNOWTHING_DB_USERNAME:?SNOWTHING_DB_USERNAME must be set} - - SNOWTHING_DB_PASSWORD=${SNOWTHING_DB_PASSWORD:?SNOWTHING_DB_PASSWORD must be set} + - SNOWTHING_DB_USERNAME=${SNOWTHING_DB_USERNAME:-snowthing_app} + - SNOWTHING_DB_PASSWORD=${SNOWTHING_DB_PASSWORD:-} - TZ=Asia/Seoul frontend: From 9cedf545ebbc93d8f2bb7f6f17c3196c3e6820dc Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 15:24:29 +0900 Subject: [PATCH 03/38] =?UTF-8?q?fix(frontend):=20Dockerfile=20npm=20ci?= =?UTF-8?q?=EC=97=90=20--legacy-peer-deps=20=EC=98=B5=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(React=2019=20=ED=98=B8=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a97b987..816abc4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app # 패키지 의존성 파일 복사 및 설치 (npm ci로 정확한 잠금 버전 설치) COPY package.json package-lock.json ./ -RUN npm ci +RUN npm ci --legacy-peer-deps # 소스코드 전체 복사 COPY . . From 287569ed6f5780119e462b78716805ee21ac76e6 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 16:35:33 +0900 Subject: [PATCH 04/38] =?UTF-8?q?docs(work):=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=EB=B0=8F=20Nginx=20?= =?UTF-8?q?=EC=9B=B9=20=EC=A0=91=EC=86=8D=20=EC=99=84=EB=A3=8C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 63d9590..40ba7c2 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,21 +1,18 @@ -- **AWS EC2 + RDS 운영 배포 3단계 스키마/설정/Compose 준비 완료 (2026-09-11)**: - 1. **작업명**: 운영 비파괴 스키마(`001_initial_schema.sql`), 기준데이터(`002_reference_data.sql`), Spring Boot prod 프로필 분리, multi-stage Dockerfile 및 `compose.prod.yml` 작성 - 2. **현재 상태**: 로컬 준비 및 구문/컴파일 검증 완료 (EC2 전달 및 적용 대기) +- **AWS EC2 + RDS 운영 배포 3단계 수동 배포 및 웹 접속 완결 (2026-09-11)**: + 1. **작업명**: EC2 배포 브랜치(`deploy/aws-ec2`) 동기화, RDS 비파괴 스키마/기준데이터 적용, 호스트 Nginx 리버스 프록시 연동 및 실제 웹 접속 검증 + 2. **현재 상태**: 3단계 100% 완료 (EC2 공인 IP `43.202.157.3` 웹 화면 및 백엔드 API 정상 서빙 중) 3. **완료된 항목**: - - `database/production/001_initial_schema.sql`: 엔티티 불일치 4대 결함(`comment.version`, `riding_style.description`, `post_image.updated_at`, `resort.name UNIQUE`) 교정 및 비파괴 스키마 작성 완료. - - `database/production/002_reference_data.sql`: 테스트 계정 및 더미 글을 배제하고 스키장(6), 라이딩스타일(6), 게시판 카테고리(5) 필수 마스터 데이터만 분리 작성 완료 (`INSERT IGNORE`). - - `application.yml`: `docker`와 `prod` 프로필 분리, `SNOWTHING_PROD_DB_URL` 외부 주입, RDS TLS 검증(`useSSL=true`), HikariCP 풀 최적화, 운영 로그 `INFO` 설정 완료. - - `backend/Dockerfile`: Eclipse Temurin 21 JRE 경량 Multi-stage 및 비root `spring` 유저 실행 도커파일 작성 완료. - - `frontend/Dockerfile`: Node 20 Alpine 기반 `npm ci` ➔ `npm run build` ➔ `npm run start` Multi-stage 도커파일 작성 완료 (`NEXT_PUBLIC_API_BASE_URL` ARG 주입). - - `compose.prod.yml`: MySQL/Redis 없이 `backend`, `frontend`만 정의, 호스트 포트는 루프백(`127.0.0.1:8080:8080`, `127.0.0.1:3000:3000`) 바인딩 완료. - 4. **검증 결과**: - - `gradlew spotlessCheck compileJava` 100% BUILD SUCCESSFUL 통과. - - `docker compose -f compose.prod.yml config --services` 실행 결과 `backend`, `frontend`만 정확히 출력 확인. - - `docker compose -f compose.prod.yml config --quiet` 문법 검증 통과 (exit code 0). - 5. **남은 항목**: - - Git push ➔ EC2에서 `git pull`로 파일 동기화. - - EC2에서 RDS로 `001_initial_schema.sql` 및 `002_reference_data.sql` 적용 (테이블 11개 및 기준데이터 생성). - - EC2 `/etc/snowthing/prod.env` 작성 및 `compose.prod.yml up -d --build` 수동 배포 가동. + - `deploy/aws-ec2` 브랜치 생성 및 GitHub 원격 푸시 완료 (`ddee2fe`, `4426b9c`, `9cedf54`). + - RDS MySQL에 `001_initial_schema.sql` (테이블 11개) 및 `002_reference_data.sql` (스키장 6개, 카테고리 5개 등) 무결성 생성 완료. + - EC2 환경변수 `/etc/snowthing/prod.env` 작성 및 `chmod 600`, 소유권 `ubuntu:ubuntu` 격리 완료. + - AWS Advanced JDBC Wrapper 기반 IAM 무암호 DB 인증으로 Spring Boot 백엔드 RDS 연결 완결. + - Next.js 16 (`--legacy-peer-deps`) 및 Spring Boot 도커 컨테이너 빌드 & `Up` 구동 성공. + - 호스트 Nginx 설치 및 `/` (Next.js 3000), `/api/` (Spring Boot 8080) 리버스 프록시 라우팅 구성 완료. + - `curl -i http://127.0.0.1:8080/api/v1/master/resorts` 200 OK 및 브라우저(`http://43.202.157.3`) 접속 실측 완료. + 4. **남은 항목**: + - 4단계: 비공개 AWS S3 버킷 생성 및 EC2 IAM Role 연동 (이미지 업로드/조회 API 구현). + - 5단계: 도메인 연결 및 Cloudflare Free (Full strict) HTTPS 암호화. + - 6단계: GitHub Actions 기반 자동 배포 파이프라인 구축. - **Sprint 04 댓글 벤치마크 학습정리 문서 보강 및 디렉터리 영문화 완료 (2026-09-09)**: 1. **디렉터리 영문화**: `benchmark` 하위 한글 폴더를 영문 표준으로 변경 (`실행계획` ➔ `explain-plans`, `쿼리` ➔ `queries`). From f068c158d238bc1e15d4b0faa6c7e5d8d91eebae Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 16:59:58 +0900 Subject: [PATCH 05/38] =?UTF-8?q?feat(image):=20AWS=20S3=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B9=84=EA=B3=B5=EA=B0=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 4 +- .../image/controller/ImageController.java | 67 ++++++++ .../image/dto/ImageDownloadResponse.java | 12 ++ .../domain/image/dto/ImageUploadResponse.java | 3 + .../domain/image/service/ImageService.java | 126 +++++++++++++++ .../snowthing/global/error/ErrorCode.java | 5 + .../global/exception/BusinessException.java | 16 ++ .../exception/GlobalExceptionHandler.java | 6 + .../snowthing/global/infra/s3/S3Config.java | 24 +++ .../domain/image/ImageServiceTest.java | 145 ++++++++++++++++++ docs/project/work.md | 22 ++- 11 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java diff --git a/backend/build.gradle b/backend/build.gradle index 0f22b25..5c742d3 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -31,12 +31,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-jackson' implementation 'com.fasterxml.jackson.core:jackson-databind' - // JPA & DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + + // AWS Advanced JDBC Wrapper & RDS/S3 SDK implementation 'software.amazon.jdbc:aws-advanced-jdbc-wrapper:2.3.7' implementation 'software.amazon.awssdk:rds:2.25.70' + implementation 'software.amazon.awssdk:s3:2.25.70' // Security & Session Redis implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java b/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java new file mode 100644 index 0000000..5549e51 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/controller/ImageController.java @@ -0,0 +1,67 @@ +package com.ikae.snowthing.domain.image.controller; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.domain.image.service.ImageService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/images") +@RequiredArgsConstructor +public class ImageController { + + private final ImageService imageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + ImageUploadResponse response = imageService.uploadImage(file); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/**") + public ResponseEntity getImage( + HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + String path = request.getRequestURI(); + String prefix = "/api/v1/images/"; + if (!path.startsWith(prefix)) { + return ResponseEntity.badRequest().build(); + } + + String imageKey = path.substring(prefix.length()); + ImageDownloadResponse download = imageService.getImage(imageKey); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(download.contentType())); + headers.setCacheControl("public, max-age=86400"); + + return new ResponseEntity<>(download.data(), headers, HttpStatus.OK); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java new file mode 100644 index 0000000..c888f9f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageDownloadResponse.java @@ -0,0 +1,12 @@ +package com.ikae.snowthing.domain.image.dto; + +public record ImageDownloadResponse(byte[] data, String contentType) { + public ImageDownloadResponse { + data = data != null ? data.clone() : new byte[0]; + } + + @Override + public byte[] data() { + return data.clone(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java new file mode 100644 index 0000000..d110119 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/dto/ImageUploadResponse.java @@ -0,0 +1,3 @@ +package com.ikae.snowthing.domain.image.dto; + +public record ImageUploadResponse(String imageUrl, String imageKey) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java b/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java new file mode 100644 index 0000000..273a0b3 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/image/service/ImageService.java @@ -0,0 +1,126 @@ +package com.ikae.snowthing.domain.image.service; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageService { + + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024L; // 5MB + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); + private static final Set ALLOWED_MIME_TYPES = + Set.of("image/jpeg", "image/png", "image/webp"); + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket:snowthing-media-00001}") + private String bucketName; + + public ImageUploadResponse uploadImage(MultipartFile file) { + validateFile(file); + + String originalFilename = file.getOriginalFilename(); + String extension = extractExtension(originalFilename); + String imageKey = "images/" + UUID.randomUUID() + "." + extension; + + try { + PutObjectRequest putRequest = + PutObjectRequest.builder() + .bucket(bucketName) + .key(imageKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject( + putRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + String imageUrl = "/api/v1/images/" + imageKey; + log.info("S3 이미지 업로드 성공: bucket={}, key={}", bucketName, imageKey); + return new ImageUploadResponse(imageUrl, imageKey); + } catch (IOException | S3Exception e) { + log.error( + "S3 이미지 업로드 실패: bucket={}, key={}, error={}", + bucketName, + imageKey, + e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + public ImageDownloadResponse getImage(String imageKey) { + if (imageKey == null || imageKey.isBlank() || imageKey.contains("..")) { + throw new BusinessException(ErrorCode.INVALID_INPUT); + } + + try { + GetObjectRequest getRequest = + GetObjectRequest.builder().bucket(bucketName).key(imageKey).build(); + + ResponseInputStream s3Object = s3Client.getObject(getRequest); + byte[] bytes = s3Object.readAllBytes(); + String contentType = s3Object.response().contentType(); + + return new ImageDownloadResponse(bytes, contentType); + } catch (NoSuchKeyException e) { + log.warn("S3 이미지를 찾을 수 없음: bucket={}, key={}", bucketName, imageKey); + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } catch (IOException | S3Exception e) { + log.error( + "S3 이미지 조회 실패: bucket={}, key={}, error={}", + bucketName, + imageKey, + e.getMessage()); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + String extension = extractExtension(file.getOriginalFilename()); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType.toLowerCase())) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + } + + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 6ea254d..d1a1c6b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -32,6 +32,11 @@ public enum ErrorCode { HttpStatus.BAD_REQUEST, "COMMENT_005", "댓글 페이지 크기는 1 이상 50 이하이어야 합니다."), COMMENT_UPDATE_CONFLICT( HttpStatus.CONFLICT, "COMMENT_006", "다른 요청에서 댓글을 먼저 수정했습니다. 최신 댓글을 다시 확인해 주세요."), + INVALID_FILE_TYPE( + HttpStatus.BAD_REQUEST, "FILE_001", "지원하지 않는 파일 형식입니다. JPG, PNG, WEBP 이미지만 업로드 가능합니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE_002", "파일 크기는 최대 5MB까지 업로드 가능합니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_003", "존재하지 않는 파일입니다."), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "파일 업로드에 실패했습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java b/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java new file mode 100644 index 0000000..4807305 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/BusinessException.java @@ -0,0 +1,16 @@ +package com.ikae.snowthing.global.exception; + +import com.ikae.snowthing.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index e114580..637cc79 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -20,6 +20,12 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getErrorCode().getStatus()) + .body(ErrorResponse.from(e.getErrorCode())); + } + @ExceptionHandler(CustomAuthException.class) public ResponseEntity handleCustomAuthException(CustomAuthException e) { ErrorCode errorCode = diff --git a/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java b/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java new file mode 100644 index 0000000..2d8a9f0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/infra/s3/S3Config.java @@ -0,0 +1,24 @@ +package com.ikae.snowthing.global.infra.s3; + +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.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.region.static:ap-northeast-2}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java new file mode 100644 index 0000000..7109f0d --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/image/ImageServiceTest.java @@ -0,0 +1,145 @@ +package com.ikae.snowthing.domain.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import com.ikae.snowthing.domain.image.dto.ImageDownloadResponse; +import com.ikae.snowthing.domain.image.dto.ImageUploadResponse; +import com.ikae.snowthing.domain.image.service.ImageService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.BusinessException; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +@ExtendWith(MockitoExtension.class) +class ImageServiceTest { + + @Mock private S3Client s3Client; + + private ImageService imageService; + + @BeforeEach + void setUp() { + imageService = new ImageService(s3Client); + ReflectionTestUtils.setField(imageService, "bucketName", "snowthing-media-00001"); + } + + @Test + @DisplayName("정상 이미지 업로드 시 S3 putObject 호출 후 ImageUploadResponse 반환") + void uploadImage_success() { + MockMultipartFile file = + new MockMultipartFile( + "file", "test.png", "image/png", "test-image-content".getBytes()); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + ImageUploadResponse response = imageService.uploadImage(file); + + assertThat(response.imageUrl()).startsWith("/api/v1/images/images/"); + assertThat(response.imageUrl()).endsWith(".png"); + assertThat(response.imageKey()).startsWith("images/"); + assertThat(response.imageKey()).endsWith(".png"); + + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("5MB 초과 파일 업로드 시 FILE_SIZE_EXCEEDED 예외 발생") + void uploadImage_exceedsMaxSize() { + byte[] largeBytes = new byte[5 * 1024 * 1024 + 1]; + MockMultipartFile file = + new MockMultipartFile("file", "large.png", "image/png", largeBytes); + + assertThatThrownBy(() -> imageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FILE_SIZE_EXCEEDED); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("지원하지 않는 확장자 파일 업로드 시 INVALID_FILE_TYPE 예외 발생") + void uploadImage_invalidExtension() { + MockMultipartFile file = + new MockMultipartFile( + "file", "danger.exe", "application/octet-stream", "malicious".getBytes()); + + assertThatThrownBy(() -> imageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_FILE_TYPE); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("정상 이미지 조회 시 ResponseInputStream에서 바이트 배열과 contentType 반환") + void getImage_success() { + String key = "images/sample.png"; + byte[] expectedBytes = "image-bytes".getBytes(); + GetObjectResponse response = GetObjectResponse.builder().contentType("image/png").build(); + ResponseInputStream responseInputStream = + new ResponseInputStream<>( + response, + AbortableInputStream.create(new ByteArrayInputStream(expectedBytes))); + + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(responseInputStream); + + ImageDownloadResponse download = imageService.getImage(key); + + assertThat(download.data()).isEqualTo(expectedBytes); + assertThat(download.contentType()).isEqualTo("image/png"); + } + + @Test + @DisplayName("경로 탐색(..) 등 악의적인 key 요청 시 INVALID_INPUT 예외 발생") + void getImage_pathTraversal_throwsException() { + assertThatThrownBy(() -> imageService.getImage("../../../etc/passwd")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + + verifyNoInteractions(s3Client); + } + + @Test + @DisplayName("존재하지 않는 파일 키 조회 시 FILE_NOT_FOUND 예외 발생") + void getImage_notFound_throwsException() { + when(s3Client.getObject(any(GetObjectRequest.class))) + .thenThrow( + NoSuchKeyException.builder() + .message("The specified key does not exist.") + .build()); + + assertThatThrownBy(() -> imageService.getImage("images/nonexistent.png")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.FILE_NOT_FOUND); + } +} diff --git a/docs/project/work.md b/docs/project/work.md index 40ba7c2..34f7d18 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,21 @@ +- **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 API 구현 및 단위 테스트 검증 완결 (2026-09-11)**: + 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, 권한 검증 및 단위 테스트 수트 작성 + 2. **현재 상태**: 백엔드 코드 작성, Spotless 포맷팅 및 단위 테스트 100% 통과 완료 (EC2 배포 및 실측 대기 중) + 3. **완료된 항목**: + - `software.amazon.awssdk:s3:2.25.70` AWS Java SDK v2 의존성 추가. + - `S3Config.java`: `DefaultCredentialsProvider` 기반으로 EC2 IAM Role 임시 자격증명 자동 주입 아키텍처 수립 (정적 액세스 키 배제). + - `ErrorCode.java`: `INVALID_FILE_TYPE(FILE_001)`, `FILE_SIZE_EXCEEDED(FILE_002)`, `FILE_NOT_FOUND(FILE_003)`, `FILE_UPLOAD_FAILED(FILE_004)` 비즈니스 에러 코드 정의. + - `BusinessException.java` & `GlobalExceptionHandler.java`: 문자열 리터럴 예외 금지 규칙 준수 기반 표준 비즈니스 예외 핸들러 구축. + - `ImageUploadResponse.java`, `ImageDownloadResponse.java`: 방어적 복사(`byte[].clone()`)를 통한 DTO 불변성 완벽 보장. + - `ImageService.java`: 5MB 파일 크기 제한, 확장자/MIME 화이트리스트(`jpg`, `jpeg`, `png`, `webp`) 검증, 경로 탐색(`..`) 방어, S3 `putObject`/`getObject` 로직 구현. + - `ImageController.java`: `POST /api/v1/images` (인증 필수 업로드), `GET /api/v1/images/**` (인가 필수 조회) 엔드포인트 구현 (비인증/외부 접근 시 401/403 차단). + - `ImageServiceTest.java`: Mockito 기반 격리 단위 테스트 6종 작성 및 100% 통과 (정상 업로드, 5MB 초과 차단, 비허용 확장자 차단, 정상 다운로드, 경로 탐색 차단, 존재하지 않는 파일 404 차단). + - `spotlessApply` 및 `spotlessCheck` 서식 검증 100% 통과. + 4. **남은 항목**: + - 4단계 실측: `deploy/aws-ec2` 커밋/푸시 ➔ EC2 `git pull` ➔ 백엔드 컨테이너 리빌드 ➔ S3 업로드/인가 다운로드 실측. + - 5단계: 도메인 연결 및 Cloudflare Free (Full strict) HTTPS 암호화. + - 6단계: GitHub Actions 기반 자동 배포 파이프라인 구축. + - **AWS EC2 + RDS 운영 배포 3단계 수동 배포 및 웹 접속 완결 (2026-09-11)**: 1. **작업명**: EC2 배포 브랜치(`deploy/aws-ec2`) 동기화, RDS 비파괴 스키마/기준데이터 적용, 호스트 Nginx 리버스 프록시 연동 및 실제 웹 접속 검증 2. **현재 상태**: 3단계 100% 완료 (EC2 공인 IP `43.202.157.3` 웹 화면 및 백엔드 API 정상 서빙 중) @@ -9,10 +27,6 @@ - Next.js 16 (`--legacy-peer-deps`) 및 Spring Boot 도커 컨테이너 빌드 & `Up` 구동 성공. - 호스트 Nginx 설치 및 `/` (Next.js 3000), `/api/` (Spring Boot 8080) 리버스 프록시 라우팅 구성 완료. - `curl -i http://127.0.0.1:8080/api/v1/master/resorts` 200 OK 및 브라우저(`http://43.202.157.3`) 접속 실측 완료. - 4. **남은 항목**: - - 4단계: 비공개 AWS S3 버킷 생성 및 EC2 IAM Role 연동 (이미지 업로드/조회 API 구현). - - 5단계: 도메인 연결 및 Cloudflare Free (Full strict) HTTPS 암호화. - - 6단계: GitHub Actions 기반 자동 배포 파이프라인 구축. - **Sprint 04 댓글 벤치마크 학습정리 문서 보강 및 디렉터리 영문화 완료 (2026-09-09)**: 1. **디렉터리 영문화**: `benchmark` 하위 한글 폴더를 영문 표준으로 변경 (`실행계획` ➔ `explain-plans`, `쿼리` ➔ `queries`). From dd9ea51409d1a7360f1224884220a2b984b8a6c4 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 17:13:56 +0900 Subject: [PATCH 06/38] =?UTF-8?q?docs:=20S3=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1=20100%=20=EC=99=84=EB=A3=8C=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 34f7d18..6b3badc 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,18 +1,22 @@ -- **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 API 구현 및 단위 테스트 검증 완결 (2026-09-11)**: - 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, 권한 검증 및 단위 테스트 수트 작성 - 2. **현재 상태**: 백엔드 코드 작성, Spotless 포맷팅 및 단위 테스트 100% 통과 완료 (EC2 배포 및 실측 대기 중) +- **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 연동 및 프로덕션 실측 100% 완결 (2026-09-11)**: + 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, EC2 IAM Role 정책(`snowthing-s3-policy`) 연동 및 인가 실측 + 2. **현재 상태**: 4단계 100% 완료 (EC2 ➔ S3 이미지 업로드 및 로그인 세션 기반 인가 다운로드 실측 성공) 3. **완료된 항목**: - `software.amazon.awssdk:s3:2.25.70` AWS Java SDK v2 의존성 추가. - `S3Config.java`: `DefaultCredentialsProvider` 기반으로 EC2 IAM Role 임시 자격증명 자동 주입 아키텍처 수립 (정적 액세스 키 배제). + - EC2 IAM 역할(`snowthing-rds-connect-policy`)에 인라인 정책 `snowthing-s3-policy` (`s3:PutObject`, `s3:GetObject` on `arn:aws:s3:::snowthing-media-00001/*`) 부여 완료. - `ErrorCode.java`: `INVALID_FILE_TYPE(FILE_001)`, `FILE_SIZE_EXCEEDED(FILE_002)`, `FILE_NOT_FOUND(FILE_003)`, `FILE_UPLOAD_FAILED(FILE_004)` 비즈니스 에러 코드 정의. - `BusinessException.java` & `GlobalExceptionHandler.java`: 문자열 리터럴 예외 금지 규칙 준수 기반 표준 비즈니스 예외 핸들러 구축. - `ImageUploadResponse.java`, `ImageDownloadResponse.java`: 방어적 복사(`byte[].clone()`)를 통한 DTO 불변성 완벽 보장. - `ImageService.java`: 5MB 파일 크기 제한, 확장자/MIME 화이트리스트(`jpg`, `jpeg`, `png`, `webp`) 검증, 경로 탐색(`..`) 방어, S3 `putObject`/`getObject` 로직 구현. - `ImageController.java`: `POST /api/v1/images` (인증 필수 업로드), `GET /api/v1/images/**` (인가 필수 조회) 엔드포인트 구현 (비인증/외부 접근 시 401/403 차단). - `ImageServiceTest.java`: Mockito 기반 격리 단위 테스트 6종 작성 및 100% 통과 (정상 업로드, 5MB 초과 차단, 비허용 확장자 차단, 정상 다운로드, 경로 탐색 차단, 존재하지 않는 파일 404 차단). - - `spotlessApply` 및 `spotlessCheck` 서식 검증 100% 통과. + - 프로덕션 실측 완료: + * 비인가 다운로드 시도 시 Spring Security에 의한 401 Unauthorized 차단 검증 완료. + * `POST /api/v1/members` 및 `POST /api/v1/auth/login` 인증 세션 획득 성공 (RDS MySQL 연동 확인). + * `POST /api/v1/images` 호출 시 S3 버킷(`snowthing-media-00001`)에 고유 UUID 객체 업로드 성공 (`201 Created`). + * `GET /api/v1/images/**` 호출 시 S3 객체 스트리밍을 통해 HTTP 200 OK 및 원본 데이터 수신 실측 성공. 4. **남은 항목**: - - 4단계 실측: `deploy/aws-ec2` 커밋/푸시 ➔ EC2 `git pull` ➔ 백엔드 컨테이너 리빌드 ➔ S3 업로드/인가 다운로드 실측. - 5단계: 도메인 연결 및 Cloudflare Free (Full strict) HTTPS 암호화. - 6단계: GitHub Actions 기반 자동 배포 파이프라인 구축. From 0d3c1ab25bdfd7075b71edeac544a0aaec168047 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 17:53:52 +0900 Subject: [PATCH 07/38] =?UTF-8?q?ci:=20GitHub=20Actions=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20EC2=20=EC=9E=90=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8(CI/CD)=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20=EB=B0=8F=20DTO=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 125 ++++++++++++++++++ .../domain/auth/dto/MemberLoginRequest.java | 10 +- docs/project/work.md | 16 +++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..96d365b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,125 @@ +name: CI/CD Deploy Pipeline + +on: + push: + branches: [ "main", "deploy/aws-ec2" ] + workflow_dispatch: + +jobs: + ci-backend: + name: Backend CI (Spotless & Test) + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: snowthing_test + MYSQL_USER: snowuser + MYSQL_PASSWORD: snowthing_pass_2026! + MYSQL_ROOT_PASSWORD: snowthing_root_2026! + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + env: + SNOWTHING_DB_USERNAME: snowuser + SNOWTHING_DB_PASSWORD: snowthing_pass_2026! + SNOWTHING_TEST_DB_URL: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + SNOWTHING_TEST_DB_USERNAME: snowuser + SNOWTHING_TEST_DB_PASSWORD: snowthing_pass_2026! + + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Spotless Linter Check + run: ./gradlew spotlessCheck + + - name: Build and Test with Gradle + run: ./gradlew test build -x spotlessCheck + + ci-frontend: + name: Frontend CI (Build Verification) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Build Next.js + run: npm run build + + deploy: + name: Deploy to AWS EC2 + needs: [ci-backend, ci-frontend] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' + + steps: + - name: Deploy to EC2 via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + timeout: 10m + script_stop: true + script: | + set -e + echo "=== [1/4] 최신 배포 브랜치 동기화 (${{ github.ref_name }}) ===" + cd ~/snowthing + git fetch origin ${{ github.ref_name }} + git reset --hard origin/${{ github.ref_name }} + + echo "=== [2/4] 도커 컨테이너 증분 빌드 및 롤링 배포 ===" + docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build + + echo "=== [3/4] 불필요한 이전 댕글링(dangling) 이미지 정리 ===" + docker image prune -f + + echo "=== [4/4] 헬스 체크 검증 (최대 60초 대기) ===" + for i in $(seq 1 12); do + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/api/v1/master/resorts || true) + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "✅ 백엔드 헬스 체크 성공 (HTTP 200 OK)" + exit 0 + fi + echo "⏳ 백엔드 초기화 대기 중... ($i/12, HTTP $HTTP_STATUS)" + sleep 5 + done + echo "❌ 백엔드 헬스 체크 실패! 백엔드 로그 확인:" + docker logs --tail 30 snowthing-backend + exit 1 diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java index 545819d..f11e29b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java @@ -16,12 +16,16 @@ public class MemberLoginRequest { @NotBlank(message = "비밀번호는 필수 입력값입니다.") private String password; - private boolean rememberMe = false; // 로그인 상태 유지 (30일 지속 세션 쿠키) + private Boolean rememberMe = Boolean.FALSE; // 로그인 상태 유지 (30일 지속 세션 쿠키) @Builder - public MemberLoginRequest(String email, String password, boolean rememberMe) { + public MemberLoginRequest(String email, String password, Boolean rememberMe) { this.email = email; this.password = password; - this.rememberMe = rememberMe; + this.rememberMe = rememberMe != null ? rememberMe : Boolean.FALSE; + } + + public boolean isRememberMe() { + return Boolean.TRUE.equals(this.rememberMe); } } diff --git a/docs/project/work.md b/docs/project/work.md index 6b3badc..3307391 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,19 @@ +- **AWS EC2 + RDS + S3 운영 배포 6단계 GitHub Actions CI/CD 자동 배포 파이프라인 구축 및 Elastic IP 확립 (2026-09-11)**: + 1. **작업명**: 영구 고정 탄력적 IP(`13.124.57.166`) 연결, GitHub Secrets 등록, `.github/workflows/deploy.yml` 자동 배포 파이프라인 구축 및 DTO 역직렬화 방어 + 2. **현재 상태**: 6단계 파이프라인 코드 작성 및 푸시 대기 (GitHub Actions 트리거 및 실측 검증 단계) + 3. **완료된 항목**: + - AWS 탄력적 IP(Elastic IP) 할당 및 EC2(`i-014492690412b656e`) 영구 연결 완결 (`13.124.57.166`). + - GitHub 저장소 Secrets 등록: `EC2_HOST` (`13.124.57.166`), `EC2_USER` (`ubuntu`), `EC2_SSH_KEY` (PEM 개인키 전체). + - EC2 2GB Linux Swap 메모리 활성화 가이드 (OOM-Killer 원천 방어). + - `MemberLoginRequest.java`: Jackson 역직렬화 시 `null`을 원시 `boolean`에 매핑하지 못하던 결함을 래퍼 클래스 `Boolean` 및 방어 메서드(`isRememberMe()`)로 개선. + - `.github/workflows/deploy.yml` 신설: + * `ci-backend`: JDK 21 + Spotless 서식 검사 + MySQL 8.0 테스트 컨테이너 기반 단위/통합 테스트 + * `ci-frontend`: Node.js 20 + Next.js 16 Turbopack 프로덕션 빌드 사전 검증 + * `deploy`: `appleboy/ssh-action@v1.0.3` 기반 무중단 배포 및 60초 헬스체크 루프(`http://127.0.0.1:8080/api/v1/master/resorts`) 연동 + 4. **남은 항목**: + - 6단계 실측: `deploy/aws-ec2` 푸시 ➔ GitHub Actions 탭에서 CI 통과 및 EC2 자동 배포 성공 여부 실측. + - 5단계: 추후 도메인 준비 완료 시 Cloudflare Free (Full strict) HTTPS 암호화. + - **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 연동 및 프로덕션 실측 100% 완결 (2026-09-11)**: 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, EC2 IAM Role 정책(`snowthing-s3-policy`) 연동 및 인가 실측 2. **현재 상태**: 4단계 100% 완료 (EC2 ➔ S3 이미지 업로드 및 로그인 세션 기반 인가 다운로드 실측 성공) From f630f342b3293ab3184f182893da0b4052c42a68 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 18:09:40 +0900 Subject: [PATCH 08/38] =?UTF-8?q?docs(project):=20CI/CD=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20=EB=82=B4=EC=97=AD=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 3307391..36ff7fe 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,18 +1,18 @@ -- **AWS EC2 + RDS + S3 운영 배포 6단계 GitHub Actions CI/CD 자동 배포 파이프라인 구축 및 Elastic IP 확립 (2026-09-11)**: - 1. **작업명**: 영구 고정 탄력적 IP(`13.124.57.166`) 연결, GitHub Secrets 등록, `.github/workflows/deploy.yml` 자동 배포 파이프라인 구축 및 DTO 역직렬화 방어 - 2. **현재 상태**: 6단계 파이프라인 코드 작성 및 푸시 대기 (GitHub Actions 트리거 및 실측 검증 단계) +- **AWS EC2 + RDS + S3 운영 배포 6단계 GitHub Actions CI/CD 파이프라인 구축 및 검증 (2026-09-11)**: + 1. **작업명**: 탄력적 IP(`13.124.57.166`) 연결, GitHub Secrets 등록, `.github/workflows/deploy.yml` 배포 파이프라인 구축 및 헬스체크 검증 + 2. **현재 상태**: 완료 (GitHub Actions 워크플로 정상 동작 및 EC2 컨테이너 자동 재배포/헬스체크 200 OK 확인) 3. **완료된 항목**: - - AWS 탄력적 IP(Elastic IP) 할당 및 EC2(`i-014492690412b656e`) 영구 연결 완결 (`13.124.57.166`). - - GitHub 저장소 Secrets 등록: `EC2_HOST` (`13.124.57.166`), `EC2_USER` (`ubuntu`), `EC2_SSH_KEY` (PEM 개인키 전체). - - EC2 2GB Linux Swap 메모리 활성화 가이드 (OOM-Killer 원천 방어). + - AWS 탄력적 IP(Elastic IP) 할당 및 EC2(`i-014492690412b656e`) 연결 (`13.124.57.166`). + - GitHub 저장소 Secrets 등록: `EC2_HOST` (`13.124.57.166`), `EC2_USER` (`ubuntu`), `EC2_SSH_KEY` (PEM 개인키). + - EC2 보안 그룹(Security Group) 포트 22번 인바운드 `0.0.0.0/0` 개방 (GitHub Actions 러너 SSH 접속 허용). + - EC2 2GB Linux Swap 메모리 활성화 가이드 (OOM 방어). - `MemberLoginRequest.java`: Jackson 역직렬화 시 `null`을 원시 `boolean`에 매핑하지 못하던 결함을 래퍼 클래스 `Boolean` 및 방어 메서드(`isRememberMe()`)로 개선. - - `.github/workflows/deploy.yml` 신설: - * `ci-backend`: JDK 21 + Spotless 서식 검사 + MySQL 8.0 테스트 컨테이너 기반 단위/통합 테스트 - * `ci-frontend`: Node.js 20 + Next.js 16 Turbopack 프로덕션 빌드 사전 검증 - * `deploy`: `appleboy/ssh-action@v1.0.3` 기반 무중단 배포 및 60초 헬스체크 루프(`http://127.0.0.1:8080/api/v1/master/resorts`) 연동 + - `.github/workflows/deploy.yml` 구축 및 동작 검증: + * `Backend CI (Spotless & Test)`: 통과 (MySQL 8.0 테스트 컨테이너 기반) + * `Frontend CI (Build Verification)`: 통과 (Next.js 16 Turbopack 프로덕션 빌드) + * `Deploy to AWS EC2`: 통과 (`appleboy/ssh-action` SSH 접속 ➔ Git reset ➔ Docker Compose 증분 빌드 ➔ 댕글링 이미지 정리 ➔ 헬스체크 루프 HTTP 200 OK 판정) 4. **남은 항목**: - - 6단계 실측: `deploy/aws-ec2` 푸시 ➔ GitHub Actions 탭에서 CI 통과 및 EC2 자동 배포 성공 여부 실측. - - 5단계: 추후 도메인 준비 완료 시 Cloudflare Free (Full strict) HTTPS 암호화. + - 5단계: 도메인 준비 시 Cloudflare Free (Full strict) HTTPS 암호화. - **AWS EC2 + RDS + S3 운영 배포 4단계 비공개 S3 이미지 연동 및 프로덕션 실측 100% 완결 (2026-09-11)**: 1. **작업명**: AWS S3 SDK 연동, 비공개 버킷(`snowthing-media-00001`) 기반 이미지 업로드/다운로드 API 개발, EC2 IAM Role 정책(`snowthing-s3-policy`) 연동 및 인가 실측 From 03b1819e84856b51a7366557a337b7803bd460e3 Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 11 Sep 2026 20:57:08 +0900 Subject: [PATCH 09/38] =?UTF-8?q?ci:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=84=A0=EB=B3=84=EC=A0=81=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{deploy.yml => deploy-backend.yml} | 50 ++++------- .github/workflows/deploy-frontend.yml | 82 +++++++++++++++++++ docs/project/work.md | 15 ++++ 3 files changed, 114 insertions(+), 33 deletions(-) rename .github/workflows/{deploy.yml => deploy-backend.yml} (77%) create mode 100644 .github/workflows/deploy-frontend.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-backend.yml similarity index 77% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-backend.yml index 96d365b..d5bb12e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-backend.yml @@ -1,10 +1,19 @@ -name: CI/CD Deploy Pipeline +name: Deploy Backend on: push: branches: [ "main", "deploy/aws-ec2" ] + paths: + - 'backend/**' + - 'database/**' + - 'compose.prod.yml' + - '.github/workflows/deploy-backend.yml' workflow_dispatch: +concurrency: + group: ec2-production-deployment + cancel-in-progress: false + jobs: ci-backend: name: Backend CI (Spotless & Test) @@ -57,39 +66,14 @@ jobs: - name: Build and Test with Gradle run: ./gradlew test build -x spotlessCheck - ci-frontend: - name: Frontend CI (Build Verification) - runs-on: ubuntu-latest - - defaults: - run: - working-directory: ./frontend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install dependencies - run: npm ci --legacy-peer-deps - - - name: Build Next.js - run: npm run build - - deploy: - name: Deploy to AWS EC2 - needs: [ci-backend, ci-frontend] + deploy-backend: + name: Deploy Backend to EC2 + needs: [ci-backend] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' steps: - - name: Deploy to EC2 via SSH + - name: Deploy Backend via SSH uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.EC2_HOST }} @@ -104,13 +88,13 @@ jobs: git fetch origin ${{ github.ref_name }} git reset --hard origin/${{ github.ref_name }} - echo "=== [2/4] 도커 컨테이너 증분 빌드 및 롤링 배포 ===" - docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build + echo "=== [2/4] 백엔드 컨테이너 단독 증분 빌드 및 롤링 배포 ===" + docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build backend echo "=== [3/4] 불필요한 이전 댕글링(dangling) 이미지 정리 ===" docker image prune -f - echo "=== [4/4] 헬스 체크 검증 (최대 60초 대기) ===" + echo "=== [4/4] 백엔드 헬스 체크 검증 (최대 60초 대기) ===" for i in $(seq 1 12); do HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/api/v1/master/resorts || true) if [ "$HTTP_STATUS" -eq 200 ]; then diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml new file mode 100644 index 0000000..2aaeb3c --- /dev/null +++ b/.github/workflows/deploy-frontend.yml @@ -0,0 +1,82 @@ +name: Deploy Frontend + +on: + push: + branches: [ "main", "deploy/aws-ec2" ] + paths: + - 'frontend/**' + - 'compose.prod.yml' + - '.github/workflows/deploy-frontend.yml' + workflow_dispatch: + +concurrency: + group: ec2-production-deployment + cancel-in-progress: false + +jobs: + ci-frontend: + name: Frontend CI (Build Verification) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Build Next.js + run: npm run build + + deploy-frontend: + name: Deploy Frontend to EC2 + needs: [ci-frontend] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' + + steps: + - name: Deploy Frontend via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + timeout: 10m + script_stop: true + script: | + set -e + echo "=== [1/4] 최신 배포 브랜치 동기화 (${{ github.ref_name }}) ===" + cd ~/snowthing + git fetch origin ${{ github.ref_name }} + git reset --hard origin/${{ github.ref_name }} + + echo "=== [2/4] 프론트엔드 컨테이너 단독 증분 빌드 및 롤링 배포 ===" + docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build frontend + + echo "=== [3/4] 불필요한 이전 댕글링(dangling) 이미지 정리 ===" + docker image prune -f + + echo "=== [4/4] 프론트엔드 헬스 체크 검증 (최대 60초 대기) ===" + for i in $(seq 1 12); do + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000 || true) + if [ "$HTTP_STATUS" -eq 200 ] || [ "$HTTP_STATUS" -eq 304 ]; then + echo "✅ 프론트엔드 헬스 체크 성공 (HTTP $HTTP_STATUS)" + exit 0 + fi + echo "⏳ 프론트엔드 초기화 대기 중... ($i/12, HTTP $HTTP_STATUS)" + sleep 5 + done + echo "❌ 프론트엔드 헬스 체크 실패! 프론트엔드 로그 확인:" + docker logs --tail 30 snowthing-frontend + exit 1 diff --git a/docs/project/work.md b/docs/project/work.md index 36ff7fe..d974673 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,18 @@ +- **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: + 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 + 2. **현재 상태**: 완료 (워크플로 파일 분리 생성 및 커밋/푸시 완료) + 3. **완료된 항목**: + - 기존 단일 `.github/workflows/deploy.yml` 제거. + - `.github/workflows/deploy-backend.yml` 신설: + * `backend/**`, `database/**`, `compose.prod.yml` 변경 시에만 트리거. + * 백엔드 CI(Spotless + MySQL 8.0 테스트) 통과 후 EC2에서 `docker compose up -d --build backend` 단독 증분 빌드 및 API 헬스체크(200 OK) 수행. + - `.github/workflows/deploy-frontend.yml` 신설: + * `frontend/**`, `compose.prod.yml` 변경 시에만 트리거. + * 프론트엔드 CI(Next.js 16 프로덕션 빌드) 통과 후 EC2에서 `docker compose up -d --build frontend` 단독 증분 빌드 및 웹 헬스체크(200/304 OK) 수행. + - 두 워크플로 모두 `concurrency: group: ec2-production-deployment, cancel-in-progress: false` 적용하여 동시 커밋 시 EC2 배포 명령 충돌 방지 및 큐잉 직렬화 보장. + 4. **남은 항목**: + - 5단계: 도메인 준비 시 Cloudflare Free (Full strict) HTTPS 암호화. + - **AWS EC2 + RDS + S3 운영 배포 6단계 GitHub Actions CI/CD 파이프라인 구축 및 검증 (2026-09-11)**: 1. **작업명**: 탄력적 IP(`13.124.57.166`) 연결, GitHub Secrets 등록, `.github/workflows/deploy.yml` 배포 파이프라인 구축 및 헬스체크 검증 2. **현재 상태**: 완료 (GitHub Actions 워크플로 정상 동작 및 EC2 컨테이너 자동 재배포/헬스체크 200 OK 확인) From 0399445ffda5136d91154d19fdad599cb211e982 Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 17:27:55 +0900 Subject: [PATCH 10/38] =?UTF-8?q?ci:=20OIDC=EC=99=80=20SSM=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B0=B0=ED=8F=AC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-backend.yml | 85 +++++++++++---------------- .github/workflows/deploy-frontend.yml | 79 +++++++++++-------------- docs/project/work.md | 9 +++ 3 files changed, 79 insertions(+), 94 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index d5bb12e..69493c0 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -3,22 +3,21 @@ name: Deploy Backend on: push: branches: [ "main", "deploy/aws-ec2" ] - paths: - - 'backend/**' - - 'database/**' - - 'compose.prod.yml' - - '.github/workflows/deploy-backend.yml' + paths: [ 'backend/**', 'database/**', 'compose.prod.yml', '.github/workflows/deploy-backend.yml' ] workflow_dispatch: concurrency: group: ec2-production-deployment cancel-in-progress: false +permissions: + contents: read + id-token: write + jobs: ci-backend: name: Backend CI (Spotless & Test) runs-on: ubuntu-latest - services: mysql: image: mysql:8.0 @@ -27,42 +26,33 @@ jobs: MYSQL_USER: snowuser MYSQL_PASSWORD: snowthing_pass_2026! MYSQL_ROOT_PASSWORD: snowthing_root_2026! - ports: - - 3306:3306 + ports: [ '3306:3306' ] options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 - env: SNOWTHING_DB_USERNAME: snowuser SNOWTHING_DB_PASSWORD: snowthing_pass_2026! SNOWTHING_TEST_DB_URL: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul SNOWTHING_TEST_DB_USERNAME: snowuser SNOWTHING_TEST_DB_PASSWORD: snowthing_pass_2026! - defaults: run: working-directory: ./backend - steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Run Spotless Linter Check run: ./gradlew spotlessCheck - - name: Build and Test with Gradle run: ./gradlew test build -x spotlessCheck @@ -71,39 +61,34 @@ jobs: needs: [ci-backend] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' - steps: - - name: Deploy Backend via SSH - uses: appleboy/ssh-action@v1.0.3 + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v4 with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - timeout: 10m - script_stop: true - script: | - set -e - echo "=== [1/4] 최신 배포 브랜치 동기화 (${{ github.ref_name }}) ===" - cd ~/snowthing - git fetch origin ${{ github.ref_name }} - git reset --hard origin/${{ github.ref_name }} - - echo "=== [2/4] 백엔드 컨테이너 단독 증분 빌드 및 롤링 배포 ===" - docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build backend - - echo "=== [3/4] 불필요한 이전 댕글링(dangling) 이미지 정리 ===" - docker image prune -f - - echo "=== [4/4] 백엔드 헬스 체크 검증 (최대 60초 대기) ===" - for i in $(seq 1 12); do - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/api/v1/master/resorts || true) - if [ "$HTTP_STATUS" -eq 200 ]; then - echo "✅ 백엔드 헬스 체크 성공 (HTTP 200 OK)" - exit 0 - fi - echo "⏳ 백엔드 초기화 대기 중... ($i/12, HTTP $HTTP_STATUS)" - sleep 5 - done - echo "❌ 백엔드 헬스 체크 실패! 백엔드 로그 확인:" - docker logs --tail 30 snowthing-backend - exit 1 + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Deploy Backend via SSM + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", + "echo \"=== [2/4] 백엔드 이미지 빌드 및 컨테이너 배포 ===\"", + "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build backend", + "echo \"=== [3/4] 사용하지 않는 이미지 정리 ===\"", "docker image prune -f", + "echo \"=== [4/4] 백엔드 헬스체크 ===\"", + "for i in $(seq 1 12); do HTTP_STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:8080/api/v1/master/resorts || true); if [ \"$HTTP_STATUS\" = \"200\" ]; then echo \"백엔드 헬스체크 성공 (HTTP 200)\"; exit 0; fi; echo \"백엔드 시작 대기 중 ($i/12, HTTP $HTTP_STATUS)\"; sleep 5; done; echo \"백엔드 헬스체크 실패\"; docker logs --tail 30 snowthing-backend; exit 1" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Deploy backend from ${GITHUB_REPOSITORY}@${GITHUB_SHA}" --query 'Command.CommandId' --output text) + echo "SSM command ID: $COMMAND_ID" + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 2aaeb3c..d4da9a0 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -3,39 +3,35 @@ name: Deploy Frontend on: push: branches: [ "main", "deploy/aws-ec2" ] - paths: - - 'frontend/**' - - 'compose.prod.yml' - - '.github/workflows/deploy-frontend.yml' + paths: [ 'frontend/**', 'compose.prod.yml', '.github/workflows/deploy-frontend.yml' ] workflow_dispatch: concurrency: group: ec2-production-deployment cancel-in-progress: false +permissions: + contents: read + id-token: write + jobs: ci-frontend: name: Frontend CI (Build Verification) runs-on: ubuntu-latest - defaults: run: working-directory: ./frontend - steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Node.js 20 uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Build Next.js run: npm run build @@ -44,39 +40,34 @@ jobs: needs: [ci-frontend] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' - steps: - - name: Deploy Frontend via SSH - uses: appleboy/ssh-action@v1.0.3 + - name: Configure AWS credentials with OIDC + uses: aws-actions/configure-aws-credentials@v4 with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - timeout: 10m - script_stop: true - script: | - set -e - echo "=== [1/4] 최신 배포 브랜치 동기화 (${{ github.ref_name }}) ===" - cd ~/snowthing - git fetch origin ${{ github.ref_name }} - git reset --hard origin/${{ github.ref_name }} - - echo "=== [2/4] 프론트엔드 컨테이너 단독 증분 빌드 및 롤링 배포 ===" - docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build frontend - - echo "=== [3/4] 불필요한 이전 댕글링(dangling) 이미지 정리 ===" - docker image prune -f - - echo "=== [4/4] 프론트엔드 헬스 체크 검증 (최대 60초 대기) ===" - for i in $(seq 1 12); do - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000 || true) - if [ "$HTTP_STATUS" -eq 200 ] || [ "$HTTP_STATUS" -eq 304 ]; then - echo "✅ 프론트엔드 헬스 체크 성공 (HTTP $HTTP_STATUS)" - exit 0 - fi - echo "⏳ 프론트엔드 초기화 대기 중... ($i/12, HTTP $HTTP_STATUS)" - sleep 5 - done - echo "❌ 프론트엔드 헬스 체크 실패! 프론트엔드 로그 확인:" - docker logs --tail 30 snowthing-frontend - exit 1 + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Deploy Frontend via SSM + env: + INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} + DEPLOY_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", + "echo \"=== [2/4] 프론트엔드 이미지 빌드 및 컨테이너 배포 ===\"", + "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build frontend", + "echo \"=== [3/4] 사용하지 않는 이미지 정리 ===\"", "docker image prune -f", + "echo \"=== [4/4] 프론트엔드 헬스체크 ===\"", + "for i in $(seq 1 12); do HTTP_STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:3000 || true); if [ \"$HTTP_STATUS\" = \"200\" ] || [ \"$HTTP_STATUS\" = \"304\" ]; then echo \"프론트엔드 헬스체크 성공 (HTTP $HTTP_STATUS)\"; exit 0; fi; echo \"프론트엔드 시작 대기 중 ($i/12, HTTP $HTTP_STATUS)\"; sleep 5; done; echo \"프론트엔드 헬스체크 실패\"; docker logs --tail 30 snowthing-frontend; exit 1" + ]}') + COMMAND_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name AWS-RunShellScript --timeout-seconds 900 --parameters "$PARAMETERS" --comment "Deploy frontend from ${GITHUB_REPOSITORY}@${GITHUB_SHA}" --query 'Command.CommandId' --output text) + echo "SSM command ID: $COMMAND_ID" + STATUS=Pending + for _ in $(seq 1 180); do + STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending) + case "$STATUS" in Success|Cancelled|TimedOut|Failed|Cancelling) break ;; esac + sleep 5 + done + aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query '{Status:Status,StandardOutput:StandardOutputContent,StandardError:StandardErrorContent}' + test "$STATUS" = Success diff --git a/docs/project/work.md b/docs/project/work.md index d974673..115225d 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,12 @@ +- **GitHub Actions 배포 인증을 OIDC + SSM으로 전환 (2026-09-13)**: + - 상태: 진행 중 + - EC2 인스턴스 역할에 `AmazonSSMManagedInstanceCore`를 연결하고 SSM Agent의 제어 채널 연결을 확인했습니다. + - GitHub OIDC 공급자와 배포 전용 IAM 역할을 만들고, `main`과 `deploy/aws-ec2` 브랜치만 역할을 맡을 수 있도록 신뢰 정책을 제한했습니다. + - Repository variables `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID`를 등록했습니다. + - 백엔드와 프론트엔드 워크플로에서 PEM 기반 SSH 배포를 제거하고, OIDC 임시 자격증명으로 SSM Run Command를 호출하도록 변경했습니다. + - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. + - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. + - **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 2. **현재 상태**: 완료 (워크플로 파일 분리 생성 및 커밋/푸시 완료) From 58ab0bdd16a35823b2ddc83618684b301c527b4f Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:06:16 +0900 Subject: [PATCH 11/38] =?UTF-8?q?ci:=20GitHub=20OIDC=20=ED=81=B4=EB=A0=88?= =?UTF-8?q?=EC=9E=84=20=EC=A7=84=EB=8B=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-backend.yml | 8 ++++++++ docs/project/work.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 69493c0..a4a11fe 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -62,6 +62,14 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' steps: + - name: Diagnose GitHub OIDC claims + run: | + set -euo pipefail + TOKEN_RESPONSE=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com") + TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.value') + PAYLOAD=$(printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | awk '{ l=length($0)%4; if (l==2) printf "%s==", $0; else if (l==3) printf "%s=", $0; else printf "%s", $0 }' | base64 -d 2>/dev/null) + printf '%s' "$PAYLOAD" | jq '{sub, aud, repository, ref, event_name}' + - name: Configure AWS credentials with OIDC uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/docs/project/work.md b/docs/project/work.md index 115225d..7e29c6c 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -6,6 +6,7 @@ - 백엔드와 프론트엔드 워크플로에서 PEM 기반 SSH 배포를 제거하고, OIDC 임시 자격증명으로 SSM Run Command를 호출하도록 변경했습니다. - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. + - 진단 중: AWS 역할 수임 거부 원인을 확인하기 위해 OIDC 토큰 원문을 노출하지 않고 `sub`, `aud`, `repository`, `ref` 클레임만 출력하는 일회성 진단 단계를 추가했습니다. - **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 From 64f4fdb47d348c647dcdbed320a1be61bfc76ea3 Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:18:14 +0900 Subject: [PATCH 12/38] =?UTF-8?q?ci:=20SSM=20root=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20Git=20=EC=95=88=EC=A0=84=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=84=B0=EB=A6=AC=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-backend.yml | 2 +- .github/workflows/deploy-frontend.yml | 2 +- docs/project/work.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index a4a11fe..0ae9888 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -82,7 +82,7 @@ jobs: run: | set -euo pipefail PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ - "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", "git config --global --add safe.directory /home/ubuntu/snowthing", "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", "echo \"=== [2/4] 백엔드 이미지 빌드 및 컨테이너 배포 ===\"", "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build backend", diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index d4da9a0..322c555 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -53,7 +53,7 @@ jobs: run: | set -euo pipefail PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ - "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", "git config --global --add safe.directory /home/ubuntu/snowthing", "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", "echo \"=== [2/4] 프론트엔드 이미지 빌드 및 컨테이너 배포 ===\"", "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build frontend", diff --git a/docs/project/work.md b/docs/project/work.md index 7e29c6c..93da922 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -7,6 +7,7 @@ - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. - 진단 중: AWS 역할 수임 거부 원인을 확인하기 위해 OIDC 토큰 원문을 노출하지 않고 `sub`, `aud`, `repository`, `ref` 클레임만 출력하는 일회성 진단 단계를 추가했습니다. + - OIDC 및 SSM 명령 전송은 성공했으나 root로 실행되는 SSM의 Git 안전 디렉터리 검사에서 중단되었습니다. 배포 명령에 `/home/ubuntu/snowthing`을 `safe.directory`로 등록하는 보완을 추가합니다. - **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 From 0d1d312a9909467a64c3821ddfa238b95299f365 Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:21:15 +0900 Subject: [PATCH 13/38] =?UTF-8?q?ci:=20SSM=20Git=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=97=90=20=EC=95=88=EC=A0=84=20=EB=94=94=EB=A0=89=ED=84=B0?= =?UTF-8?q?=EB=A6=AC=20=EC=98=B5=EC=85=98=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-backend.yml | 4 ++-- .github/workflows/deploy-frontend.yml | 4 ++-- docs/project/work.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 0ae9888..b6a82bf 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -82,8 +82,8 @@ jobs: run: | set -euo pipefail PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ - "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", "git config --global --add safe.directory /home/ubuntu/snowthing", - "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", "echo \"=== [2/4] 백엔드 이미지 빌드 및 컨테이너 배포 ===\"", "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build backend", "echo \"=== [3/4] 사용하지 않는 이미지 정리 ===\"", "docker image prune -f", diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 322c555..5a2988f 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -53,8 +53,8 @@ jobs: run: | set -euo pipefail PARAMETERS=$(jq -n --arg branch "$DEPLOY_BRANCH" '{commands: [ - "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", "git config --global --add safe.directory /home/ubuntu/snowthing", - "git fetch origin \"" + $branch + "\"", "git reset --hard \"origin/" + $branch + "\"", + "set -e", "echo \"=== [1/4] 배포 브랜치 동기화 ===\"", "cd /home/ubuntu/snowthing", + "git -c safe.directory=/home/ubuntu/snowthing fetch origin \"" + $branch + "\"", "git -c safe.directory=/home/ubuntu/snowthing reset --hard \"origin/" + $branch + "\"", "echo \"=== [2/4] 프론트엔드 이미지 빌드 및 컨테이너 배포 ===\"", "docker compose --env-file /etc/snowthing/prod.env -f compose.prod.yml up -d --build frontend", "echo \"=== [3/4] 사용하지 않는 이미지 정리 ===\"", "docker image prune -f", diff --git a/docs/project/work.md b/docs/project/work.md index 93da922..6371083 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -7,7 +7,7 @@ - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. - 진단 중: AWS 역할 수임 거부 원인을 확인하기 위해 OIDC 토큰 원문을 노출하지 않고 `sub`, `aud`, `repository`, `ref` 클레임만 출력하는 일회성 진단 단계를 추가했습니다. - - OIDC 및 SSM 명령 전송은 성공했으나 root로 실행되는 SSM의 Git 안전 디렉터리 검사에서 중단되었습니다. 배포 명령에 `/home/ubuntu/snowthing`을 `safe.directory`로 등록하는 보완을 추가합니다. + - OIDC 및 SSM 명령 전송은 성공했습니다. root로 실행되는 SSM에서 Git 안전 디렉터리를 전역 설정하려 했으나 `$HOME`이 없어 실패했으므로, 전역 설정 없이 각 Git 명령에 `-c safe.directory=/home/ubuntu/snowthing`을 전달하도록 보완했습니다. - **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: 1. **작업명**: `.github/workflows/deploy.yml` 단일 워크플로를 `deploy-backend.yml`과 `deploy-frontend.yml`로 분리하여 경로 기반 선별적 배포 및 동시성 큐잉 적용 From af56beef85b8d36dea519d59d2e0a980f4969f6e Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:24:35 +0900 Subject: [PATCH 14/38] =?UTF-8?q?ci:=20OIDC=20=EC=9D=BC=ED=9A=8C=EC=84=B1?= =?UTF-8?q?=20=EC=A7=84=EB=8B=A8=20=EB=8B=A8=EA=B3=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-backend.yml | 8 -------- docs/project/work.md | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index b6a82bf..53a2d08 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -62,14 +62,6 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/deploy/aws-ec2' steps: - - name: Diagnose GitHub OIDC claims - run: | - set -euo pipefail - TOKEN_RESPONSE=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com") - TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.value') - PAYLOAD=$(printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | awk '{ l=length($0)%4; if (l==2) printf "%s==", $0; else if (l==3) printf "%s=", $0; else printf "%s", $0 }' | base64 -d 2>/dev/null) - printf '%s' "$PAYLOAD" | jq '{sub, aud, repository, ref, event_name}' - - name: Configure AWS credentials with OIDC uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/docs/project/work.md b/docs/project/work.md index 6371083..0b4e44b 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -6,7 +6,7 @@ - 백엔드와 프론트엔드 워크플로에서 PEM 기반 SSH 배포를 제거하고, OIDC 임시 자격증명으로 SSM Run Command를 호출하도록 변경했습니다. - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. - - 진단 중: AWS 역할 수임 거부 원인을 확인하기 위해 OIDC 토큰 원문을 노출하지 않고 `sub`, `aud`, `repository`, `ref` 클레임만 출력하는 일회성 진단 단계를 추가했습니다. + - 진단 결과 GitHub OIDC의 실제 `sub`에는 저장소·소유자 식별자가 포함되어 기존 조건과 불일치했습니다. 신뢰 정책을 실제 클레임에 맞춰 수정한 뒤 역할 수임이 성공했습니다. 진단 단계는 확인 후 제거했습니다. - OIDC 및 SSM 명령 전송은 성공했습니다. root로 실행되는 SSM에서 Git 안전 디렉터리를 전역 설정하려 했으나 `$HOME`이 없어 실패했으므로, 전역 설정 없이 각 Git 명령에 `-c safe.directory=/home/ubuntu/snowthing`을 전달하도록 보완했습니다. - **AWS EC2 + RDS + S3 운영 배포 6단계 모노레포 선별적 배포(Selective CI/CD) 파이프라인 분리 구축 (2026-09-11)**: From c001dec2960d7555695adb79bb72e40c70171eea Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:28:44 +0900 Subject: [PATCH 15/38] =?UTF-8?q?docs:=20OIDC=EC=99=80=20SSM=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/project/work.md b/docs/project/work.md index 0b4e44b..7520d7c 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -5,7 +5,9 @@ - Repository variables `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID`를 등록했습니다. - 백엔드와 프론트엔드 워크플로에서 PEM 기반 SSH 배포를 제거하고, OIDC 임시 자격증명으로 SSM Run Command를 호출하도록 변경했습니다. - SSM 명령은 최대 15분 동안 완료 상태를 확인하고, 원격 명령의 최종 상태가 `Success`일 때만 Actions 작업을 성공 처리합니다. - - 남은 작업: 워크플로 실제 실행 검증, 성공 후 기존 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY` Secrets 삭제, UI 디자인 커밋 배포. + - 검증 완료: 백엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757010101)), 프론트엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757130034)). + - 기존 SSH 배포용 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY`와 중복 등록된 `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID` Secrets를 삭제하고 Repository variables만 유지했습니다. + - 남은 작업: UI 디자인 커밋을 이 브랜치에 반영하고 새 SSM 배포로 공개 화면을 검증합니다. - 진단 결과 GitHub OIDC의 실제 `sub`에는 저장소·소유자 식별자가 포함되어 기존 조건과 불일치했습니다. 신뢰 정책을 실제 클레임에 맞춰 수정한 뒤 역할 수임이 성공했습니다. 진단 단계는 확인 후 제거했습니다. - OIDC 및 SSM 명령 전송은 성공했습니다. root로 실행되는 SSM에서 Git 안전 디렉터리를 전역 설정하려 했으나 `$HOME`이 없어 실패했으므로, 전역 설정 없이 각 Git 명령에 `-c safe.directory=/home/ubuntu/snowthing`을 전달하도록 보완했습니다. From 173f2033e7607f18373b8c0a147e5e6dbc4a0e0c Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:50:23 +0900 Subject: [PATCH 16/38] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20HTTPS=20origi?= =?UTF-8?q?n=20CORS=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ikae/snowthing/global/config/SecurityConfig.java | 6 +++++- docs/project/work.md | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index 88388c7..d7a46c0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -55,7 +55,11 @@ public AuthenticationManager authenticationManager( @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedOrigins( + List.of( + "http://localhost:3000", + "https://snowthing.org", + "https://www.snowthing.org")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); diff --git a/docs/project/work.md b/docs/project/work.md index 7520d7c..7e29280 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -8,6 +8,7 @@ - 검증 완료: 백엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757010101)), 프론트엔드 CI와 SSM 배포 성공([Actions 실행](https://github.com/devikae/snowthing/actions/runs/34757130034)). - 기존 SSH 배포용 `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY`와 중복 등록된 `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `EC2_INSTANCE_ID` Secrets를 삭제하고 Repository variables만 유지했습니다. - 남은 작업: UI 디자인 커밋을 이 브랜치에 반영하고 새 SSM 배포로 공개 화면을 검증합니다. + - 운영 CSRF 확인 중 API 주소를 `https://snowthing.org`로 교체했으며, 백엔드 CORS가 localhost만 허용해 `403 Invalid CORS request`를 반환하는 문제를 확인했습니다. 운영 apex/www HTTPS origin을 허용하도록 수정합니다. - 진단 결과 GitHub OIDC의 실제 `sub`에는 저장소·소유자 식별자가 포함되어 기존 조건과 불일치했습니다. 신뢰 정책을 실제 클레임에 맞춰 수정한 뒤 역할 수임이 성공했습니다. 진단 단계는 확인 후 제거했습니다. - OIDC 및 SSM 명령 전송은 성공했습니다. root로 실행되는 SSM에서 Git 안전 디렉터리를 전역 설정하려 했으나 `$HOME`이 없어 실패했으므로, 전역 설정 없이 각 Git 명령에 `-c safe.directory=/home/ubuntu/snowthing`을 전달하도록 보완했습니다. From 16ee934393054a4a0c6a032ec34fc26dffc557f7 Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 21:56:57 +0900 Subject: [PATCH 17/38] =?UTF-8?q?docs:=20=EC=9A=B4=EC=98=81=20CSRF=20CORS?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/project/work.md b/docs/project/work.md index 7e29280..7b98387 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1277,3 +1277,9 @@ - `CommentControllerTest`와 `spotlessCheck`는 통과했습니다. - `CommentCreateTest` 16건은 `SNOWTHING_TEST_DB_URL` 미설정 시 실행을 차단하는 기존 MySQL 강제 설정 때문에 Spring Context 생성 전에 실패했습니다. 경계값 변경으로 인한 테스트 assertion 실패는 아닙니다. >>>>>>> origin/feature/sprint03-comment + +- **운영 CSRF/CORS 검증 및 수정 (2026-09-13)**: + - 프론트엔드 운영 API 주소가 이전 EC2 IP의 HTTP 주소로 남아 있어 `https://snowthing.org`로 교체하고 프론트엔드를 재배포했습니다. + - 운영 도메인에서 CSRF 발급 요청은 200이었지만 로그인 요청이 `403 Invalid CORS request`로 차단되었습니다. 원인은 백엔드 CORS 허용 origin이 `http://localhost:3000`만 포함하고 있었기 때문입니다. + - `https://snowthing.org`, `https://www.snowthing.org`를 허용 origin에 추가하고 백엔드를 OIDC+SSM으로 재배포했습니다. + - 재검증 결과 운영 CSRF 발급은 200, 동일 세션의 로그인 요청은 CORS 차단이 아닌 정상적인 `401 AUTH_001 INVALID_CREDENTIALS`를 반환했습니다. 즉 브라우저와 백엔드 사이의 CORS/CSRF 진입 문제는 해결되었습니다. From 12bfe1ba6ee80c08ae7439c05371b6565f7286b2 Mon Sep 17 00:00:00 2001 From: ikae Date: Sun, 13 Sep 2026 16:34:50 +0900 Subject: [PATCH 18/38] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20UI=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/post-compose-detail-redesign.md | 26 ++ frontend/app/components/SiteChrome.tsx | 221 +++++++------ frontend/app/globals.css | 241 ++++++++++++-- frontend/app/layout.tsx | 7 +- frontend/app/page.tsx | 250 ++++++-------- frontend/app/posts/[publicId]/page.tsx | 121 +++---- frontend/app/posts/anonymous/page.tsx | 121 +------ frontend/app/posts/create/page.tsx | 183 +++++------ frontend/app/posts/page.tsx | 361 ++++++++------------- 9 files changed, 728 insertions(+), 803 deletions(-) create mode 100644 docs/specs/post-compose-detail-redesign.md diff --git a/docs/specs/post-compose-detail-redesign.md b/docs/specs/post-compose-detail-redesign.md new file mode 100644 index 0000000..53a80d3 --- /dev/null +++ b/docs/specs/post-compose-detail-redesign.md @@ -0,0 +1,26 @@ +# 게시글 작성·상세 화면 재설계 + +## 목표 + +두 번째 Stitch 시안의 Modern Alpine Soft UI를 게시글 작성과 상세 화면에 적용한다. API와 권한 정책은 변경하지 않고 사용자가 실제로 작성, 조회, 반응, 댓글·답글, 수정·삭제할 수 있는 영역을 우선 완성한다. + +## 확정 범위 + +- 글쓰기 제목 옆 `REAL ANONYMOUS` 배지와 제목 아래 개인정보 안내 문구는 표시하지 않는다. +- 글쓰기의 카테고리, 익명 비밀번호, 제목, Toast UI 본문, 이미지 URL, 취소·등록 기능을 유지한다. +- 상세의 조회, 작성자, 수정·삭제 권한, 이미지, 추천·비추천, 댓글·답글과 댓글 수정·삭제·더보기 기능을 유지한다. +- 시안의 리조트 태그, 설질, 카풀 등 서버 데이터가 없는 부분은 실제 기능으로 오해되지 않는 정적인 보조 카드로만 둔다. +- 모바일에서는 기능 영역을 먼저 배치하고 보조 카드는 아래로 이동한다. + +## 구현 원칙 + +기존 요청 함수와 상태는 보존하고 렌더링 구조와 공통 CSS만 재구성한다. API가 지원하지 않는 정렬·신고·파일 직접 업로드·임시저장은 동작하는 컨트롤로 만들지 않는다. + +## 검증 + +- 익명 및 일반 게시글 작성 후 상세 페이지 이동 +- 실제 상세 데이터와 이미지 표시 +- 추천·비추천 요청 +- 댓글·답글 작성과 수정·삭제·더보기 +- 모바일 및 데스크톱 반응형 화면 +- 프로덕션 빌드와 변경 파일 ESLint diff --git a/frontend/app/components/SiteChrome.tsx b/frontend/app/components/SiteChrome.tsx index 36781d9..17e2c6c 100644 --- a/frontend/app/components/SiteChrome.tsx +++ b/frontend/app/components/SiteChrome.tsx @@ -1,11 +1,12 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { csrfFetch } from "../lib/csrfFetch"; import { API_ENDPOINTS } from "../lib/api"; -export type ActiveNav = "home" | "posts" | "resort" | "profile" | "login" | "signup"; +export type ActiveNav = "home" | "posts" | "free" | "anonymous" | "gear" | "resort" | "profile" | "login" | "signup"; interface MemberUser { publicId: string; @@ -14,36 +15,42 @@ interface MemberUser { profileImageUrl: string | null; } -const navItems: { href: string; label: string; key: ActiveNav }[] = [ - { href: "/resort", label: "Resorts", key: "resort" }, - { href: "/posts", label: "Community", key: "posts" }, - { href: "/profile", label: "Rider Card", key: "profile" }, -]; +interface NavItem { + href: string; + label: string; + key: string; +} -const categories = [ - { href: "/posts", label: "전체 게시판", icon: "forum", key: "all" }, - { href: "/posts?category=FREE", label: "자유 게시판", icon: "terrain", key: "free" }, - { href: "/posts?category=ANONYMOUS", label: "익명 게시판", icon: "visibility_off", key: "anonymous" }, - { href: "/posts?category=QNA", label: "장비 Q&A", icon: "help", key: "qna" }, - { href: "/posts?category=FOOD", label: "리조트 맛집", icon: "restaurant", key: "food" }, +const navItems: NavItem[] = [ + { href: "/posts", label: "전체글", key: "posts" }, + { href: "/posts?sort=popular", label: "실시간 베스트", key: "best" }, + { href: "/posts?category=FREE", label: "자유게시판", key: "free" }, + { href: "/posts?category=ANONYMOUS", label: "익명게시판", key: "anonymous" }, + { href: "/resort", label: "실시간 설질/웹캠", key: "resort" }, + { href: "/posts?category=QNA", label: "장비·테크닉", key: "gear" }, + { href: "/#carpool", label: "카풀/동행", key: "carpool" }, + { href: "/#market", label: "중고장터", key: "market" }, ]; +const mobileItems = [ + { href: "/", label: "홈", icon: "home", key: "home" }, + { href: "/posts?sort=popular", label: "베스트", icon: "local_fire_department", key: "best" }, + { href: "/resort", label: "실시간 설질", icon: "ac_unit", key: "resort" }, + { href: "/#carpool", label: "카풀", icon: "directions_car", key: "carpool" }, + { href: "/posts?category=ANONYMOUS", label: "익명", icon: "forum", key: "anonymous" }, +] as const; + export function TopNav({ active = "home" }: { active?: ActiveNav }) { + const router = useRouter(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); useEffect(() => { - (async () => { + void (async () => { try { - const res = await fetch(API_ENDPOINTS.members.me, { - credentials: "include", - }); - if (res.ok) { - const data = await res.json(); - setUser(data); - } else { - setUser(null); - } + const response = await fetch(API_ENDPOINTS.members.me, { credentials: "include" }); + setUser(response.ok ? await response.json() : null); } catch { setUser(null); } finally { @@ -52,13 +59,15 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { })(); }, []); + const handleSearch = (event: FormEvent) => { + event.preventDefault(); + const keyword = search.trim(); + if (keyword) router.push(`/posts?keyword=${encodeURIComponent(keyword)}`); + }; + const handleLogout = async () => { try { - await csrfFetch(API_ENDPOINTS.auth.logout, { - method: "POST", - }); - } catch { - // ignore + await csrfFetch(API_ENDPOINTS.auth.logout, { method: "POST" }); } finally { setUser(null); window.location.href = "/"; @@ -66,107 +75,95 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { }; return ( -
-
-
- - SnowThing - - + <> +
+
+
+ 공지 + 24/25 시즌방 인원 구인 및 리조트 실시간 슬로프 제보 게시판 이용 수칙 안내 +
+
+ 강원권 야간 정설 완료 + 실시간 웹캠 센터 + 출석체크 +
-
- {!loading && ( - <> - {user ? ( -
- - {user.nickname}님 - - -
- ) : ( +
+ +
+
+
+ + + SnowThing + + +
+ +
+
+ search + setSearch(event.target.value)} placeholder="슬로프, 장비, 카풀 검색" aria-label="통합 검색" /> +
+ + edit글쓰기 + + + {!loading && (user ? ( +
+ + {user.profileImageUrl ? : {user.nickname.slice(0, 1)}} + + +
+ ) : ( + 로그인 + ))} +
-
-
- ); -} + -export function SideCategories({ active = "all" }: { active?: string }) { - return ( - + ); } +export function SideCategories({ active = "all" }: { active?: string }) { + return