From 556c90e3ca45d8d2a10a140a90f0c00f9e2c4391 Mon Sep 17 00:00:00 2001 From: Woomin-Wang Date: Tue, 21 Apr 2026 19:35:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20Github=20Actions=20CI/CD=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/.DS_Store | Bin 0 -> 6148 bytes .github/workflows/cd.yaml | 83 +++++++++++++++ .github/workflows/ci.yaml | 106 +++++++++++++++++++ Dockerfile | 8 +- build.gradle | 2 + docker-compose.yml => docker-compose-dev.yml | 2 +- docker-compose-prod.yml | 70 ++++++++++++ docker/fluent-bit/fluent-bit.conf | 9 +- 8 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 .github/.DS_Store create mode 100644 .github/workflows/cd.yaml create mode 100644 .github/workflows/ci.yaml rename docker-compose.yml => docker-compose-dev.yml (97%) create mode 100644 docker-compose-prod.yml diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..72d939e6b6003bab974d1ace0831149ce595bf1a GIT binary patch literal 6148 zcmeHK&2G~`5S~p#>$H@*X<7;uC{iTEq0mrJMa6;O7NG)CswR{aT7sQyf>p;G*-p|@ zD&-7E9)JhnjyQ7U1>l4PXHGl-63qUX{&GWzB9FBDjpuJYpXFT}0Ep45UINeoK*PZ? z-jCBE!u@<4keIT>5s9c#0|yv%z+Z3BjRF4dCh(1A;K4fVdVg8Pa+rl) zzBL#)F8&aL+MyX%$8V~Pw^q}!{UDqDNMb$lW4(P^pEjVa6`rt8AuxlMZ!~^B2Bz&&PbAn?iXp4_ZMVug25Y%hU*r~~LrZ9hvOd1GH9b2snZA;p z-kwZv%}!@gzdWrTUE&&9n1!M#ezpu!MdpmGQocBQR4BcH;~KI_9_ zZ;AeaV&Y6$AF<5Jdfnr9J;xm88BU8ntb2B~&@?Ny zTQ3E4BPiHUX<1M13tghsM?0qCW)ovlI|fnimPe^8>g8)Sp9ZB}`pN@yqxh@(kYDkb z<18}Y4s7PGtolg*LO>e}ZjIsMZKOWm(tm>b4-gmdDmb; zz#2S~SF130(WAUl$wr;GmAU>+lAFoh36=9w(IF!hJX}(trjTUb(Jw+Ty3D1CL?*YW zTpC<}Imkl^8t@EW!YgPH(m@|{EtL<~d>{AmXG{*cDOF{iLAQEVMJ z#qg5*`DcU>)cFSkx1|;46m}(|2ZhK~M43v|F9wn6XiuxSoWia|nGQs(jCIt?ME#)< zu{zq*A{FYNu8|+n&W3} w8pmTCTzFhpqDa9>9mhi9NAWlgA?QnU1u>_vD-kUy(T9MbA(|ov{wf1M0eVZ~1poj5 literal 0 HcmV?d00001 diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 0000000..6828354 --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,83 @@ +name: CD + +on: + workflow_run: + workflows: ['CI'] + types: [completed] + +jobs: + # ============================================ + # 서버 배포 + # ============================================ + deploy: + name: Deploy to Wisoft Server + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} + + steps: + - name: SSH 키 설정 + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.RASPI_SSH_PRIVATE_KEY }} + + - name: Known Hosts 등록 + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + run: | + mkdir -p ~/.ssh + ssh-keyscan -p $JUMP_PORT -H $JUMP_HOST >> ~/.ssh/known_hosts + + - name: 서버에 배포 + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + JUMP_USER: ${{ secrets.RASPI_USER }} + TARGET_HOST: ${{ secrets.RASPI_TARGET_HOST }} + TARGET_USER: ${{ secrets.RASPI_TARGET_USER }} + DEPLOY_PATH: ${{ secrets.RASPI_DEPLOY_PATH_JAVA }} + GHCR_PAT: ${{ secrets.GHCR_PAT }} + GITHUB_ACTOR: ${{ github.actor }} + run: | + ssh -o StrictHostKeyChecking=no -o ProxyJump=$JUMP_USER@$JUMP_HOST:$JUMP_PORT $TARGET_USER@$TARGET_HOST /bin/bash << ENDSSH + set -e + + echo "=== 배포 디렉토리 이동 ===" + cd $DEPLOY_PATH + + echo "=== GHCR 로그인 ===" + echo "$GHCR_PAT" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + + echo "=== 최신 이미지 Pull ===" + docker compose -f docker/docker-compose.prod.yml --env-file .env pull app + + echo "=== 앱 컨테이너 재시작 ===" + docker compose -f docker/docker-compose.prod.yml --env-file .env up -d app + + echo "=== Health Check (최대 60초) ===" + for i in \$(seq 1 12); do + if curl -sf http://localhost:7300/health > /dev/null; then + echo "Health check 통과" + exit 0 + fi + echo "재시도 \$i/12" + sleep 5 + done + + echo "Health check 실패" + docker compose -f docker/docker-compose.prod.yml logs --tail=50 app + exit 1 + ENDSSH + + - name: 오래된 Docker 이미지 정리 + if: success() + env: + JUMP_PORT: ${{ secrets.RASPI_SSH_PORT }} + JUMP_HOST: ${{ secrets.RASPI_HOST }} + JUMP_USER: ${{ secrets.RASPI_USER }} + TARGET_HOST: ${{ secrets.RASPI_TARGET_HOST }} + TARGET_USER: ${{ secrets.RASPI_TARGET_USER }} + run: | + ssh -o StrictHostKeyChecking=no -o ProxyJump=$JUMP_USER@$JUMP_HOST:$JUMP_PORT $TARGET_USER@$TARGET_HOST /bin/bash << ENDSSH + docker image prune -af --filter "until=72h" || true + ENDSSH diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1d89e85 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,106 @@ +name: CI + +on: + push: + branches: + - "*/**" + - main + pull_request: + branches: + - "*/**" + - main + +jobs: + # ============================================ + # Job 1: 테스트 + # ============================================ + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: interview_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 21 설정 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Gradle 캐싱 + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 테스트 실행 + env: + SPRING_PROFILES_ACTIVE: test + run: ./gradlew test --no-daemon + + # ============================================ + # Job 2: Docker 이미지 빌드 + GHCR 푸시 + # ============================================ + build: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + permissions: + contents: read + packages: write + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v4 + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: GHCR 로그인 + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 이미지 메타데이터 설정 + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Docker 이미지 빌드 & 푸시 + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 2e94725..b1b9c19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,12 +25,16 @@ FROM mcr.microsoft.com/playwright/java:v1.49.0-noble WORKDIR /app -RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/build/libs/*.jar app.jar # 포트 문서화 EXPOSE 7300 +# 컨테이너 레벨 헬스체크 (Docker daemon의 status 표시용) +HEALTHCHECK --interval=24h --timeout=10s --start-period=40s --retries=3 \ + CMD curl -sf http://localhost:7300/health || exit 1 + # 실행 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 93476eb..0e0afc5 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,8 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.26.12' runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose-dev.yml similarity index 97% rename from docker-compose.yml rename to docker-compose-dev.yml index a2cc874..48276ec 100644 --- a/docker-compose.yml +++ b/docker-compose-dev.yml @@ -31,7 +31,7 @@ services: app: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.prev container_name: interview-api ports: - "7300:7300" diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..c7a5bdc --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,70 @@ +services: + fluent-bit: + image: fluent/fluent-bit:3.2 + container_name: interview-fluent-bit + volumes: + - ./fluentbit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro + - ./fluentbit/parsers.conf:/fluent-bit/etc/parsers.conf:ro + ports: + - "24224:24224" + environment: + - ELASTIC_USERNAME=${ELASTIC_USERNAME} + - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} + networks: + - interview-network + - logging-network + restart: unless-stopped + + postgres: + image: postgres:17 + container_name: interview-postgres + ports: + - "${POSTGRES_PORT}:5432" + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - interview-network + restart: unless-stopped + + app: + image: ghcr.io/wisoft-prepair/backend-java:latest + container_name: interview-api + ports: + - "7300:7300" + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=prod + - TZ=Asia/Seoul + depends_on: + postgres: + condition: service_healthy + fluent-bit: + condition: service_started + logging: + driver: fluentd + options: + fluentd-address: localhost:24224 + tag: interview-service + fluentd-async: "true" + networks: + - interview-network + restart: unless-stopped + +volumes: + postgres-data: + +networks: + interview-network: + driver: bridge + logging-network: + external: true \ No newline at end of file diff --git a/docker/fluent-bit/fluent-bit.conf b/docker/fluent-bit/fluent-bit.conf index 0b78219..d38ea45 100644 --- a/docker/fluent-bit/fluent-bit.conf +++ b/docker/fluent-bit/fluent-bit.conf @@ -1,5 +1,6 @@ [SERVICE] Flush 1 + Daemon Off Log_Level info Parsers_File parsers.conf @@ -15,6 +16,12 @@ Parser json Reserve_Data On +[FILTER] + Name modify + Match * + Add source fluent-bit + Add host ${HOSTNAME} + [OUTPUT] Name es Match * @@ -24,4 +31,4 @@ HTTP_Passwd ${ELASTIC_PASSWORD} Index interview-service Retry_Limit 5 - Suppress_Type_Name On + Suppress_Type_Name On \ No newline at end of file From f31714cd4e38742fdf48e23b0da5d6844dbd75e1 Mon Sep 17 00:00:00 2001 From: Woomin-Wang Date: Tue, 21 Apr 2026 19:37:56 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prompt/InterviewPromptBuilderTest.java | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java diff --git a/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java b/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java deleted file mode 100644 index 8d6ffa8..0000000 --- a/src/test/java/io/wisoft/prepair/prepair_api/prompt/InterviewPromptBuilderTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.wisoft.prepair.prepair_api.prompt; - -import io.wisoft.prepair.prepair_api.entity.InterviewQuestion; -import io.wisoft.prepair.prepair_api.entity.enums.QuestionType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("면접 프롬프트 생성 테스트") -class InterviewPromptBuilderTest { - - private PromptBuilder promptBuilder; - - @BeforeEach - void setUp() { - promptBuilder = new PromptBuilder(); - } - - @Test - @DisplayName("직무 정보를 기반으로 프롬프트를 생성한다.") - void 직무_정보_프롬프트_생성() { - // Given - String job = "백엔드 개발자"; - List previousQuestions = List.of(); - - // When - String prompt = promptBuilder.buildDailyQuestionPrompt(job, previousQuestions); - - // Then - assertThat(prompt).contains("백엔드 개발자"); - } - - @Test - @DisplayName("이전 질문이 있으면 프롬프트에 포함된다.") - void 이전_질문_포함() { - // Given - String job = "백엔드 개발자"; - - InterviewQuestion prev = new InterviewQuestion( - UUID.randomUUID(), - "Spring이란?", - QuestionType.TEXT, - "Spring", - null - ); - - List previousQuestions = List.of(prev); - - // When - String prompt = promptBuilder.buildDailyQuestionPrompt(job, previousQuestions); - - // Then - assertThat(prompt).contains("이전에 받은 질문들"); - assertThat(prompt).contains("Spring이란?"); - } -} From be9c0feb0a27d1545192e514043bd89cfb9ce549 Mon Sep 17 00:00:00 2001 From: Woomin-Wang Date: Tue, 21 Apr 2026 19:41:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20JPA=20=EC=84=A4=EC=A0=95=20update?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c685eb2..28a6f06 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -7,7 +7,7 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: true open-in-view: false properties: From 8d9fc70114329fb65e7ce3d8f04920f595d89c10 Mon Sep 17 00:00:00 2001 From: Woomin-Wang Date: Tue, 21 Apr 2026 19:43:48 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20JPA=20=EC=84=A4=EC=A0=95=20update?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cb8819e..2710d2c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -7,6 +7,6 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: update open-in-view: false From aaad96653ddfbb13732c4953adeddd6f25565d3b Mon Sep 17 00:00:00 2001 From: Woomin-Wang Date: Tue, 21 Apr 2026 19:48:07 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20CI=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=EC=9A=A9=20application-tes?= =?UTF-8?q?t.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/test/resources/application-test.yml | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/test/resources/application-test.yml diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..13f2644 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,46 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/interview_test + username: test + password: test + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + + mail: + host: smtp.gmail.com + port: 587 + username: test@test.com + password: test + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +external: + member-service: + url: http://localhost:3000 + api-key: test-api-key + + openai: + api-key: test-openai-key + api-url: https://api.openai.com/v1/chat/completions + model: gpt-4o-mini + whisper-url: https://api.openai.com/v1/audio/transcriptions + whisper-model: whisper-1 + +cloud: + aws: + s3: + endpoint: http://localhost:9000 + access-key: test-access-key + secret-key: test-secret-key + region: us-east-1 + bucket: prepair + presigned-url-expiration: 3600