From 958bdd5f88af11058f557432ea3b615e52e9dca5 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 13 Nov 2025 17:19:20 +0900 Subject: [PATCH 01/39] =?UTF-8?q?kubernetes=20yaml=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.yaml | 32 ++++++++++++++++++++++++++------ mysql.yaml | 2 +- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app.yaml b/app.yaml index a39088e..e690823 100644 --- a/app.yaml +++ b/app.yaml @@ -19,22 +19,42 @@ spec: spec: containers: - name: springboot-app - image: my-spring-app:latest + image: springboot-app:latest ports: - containerPort: 8080 envFrom: - secretRef: name: db-secret - --- apiVersion: v1 kind: Service metadata: - name: springboot-app -spec: - selector: + labels: app: springboot-app +spec: ports: - - port: 8080 + - port: 80 + protocol: TCP targetPort: 8080 + selector: + app: springboot-app + sessionAffinity: None type: ClusterIP +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: springboot-ingress + annotations: + kubernetes.io/ingress.class: traefik +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: springboot-app + port: + number: 80 diff --git a/mysql.yaml b/mysql.yaml index 8e9f918..b9ed1ff 100644 --- a/mysql.yaml +++ b/mysql.yaml @@ -31,7 +31,7 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 5Gi + storage: 1Gi --- apiVersion: v1 kind: Service From 23121bbea7465dcf5b402dffedfc601788e9bc3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:08:35 +0900 Subject: [PATCH 02/39] Add CI workflow for develop branch --- .github/workflows/develop.yml | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/develop.yml diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..1ccb143 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,53 @@ +name: develop CI + +on: + pull_request: + branches: + - develop + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + # 코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v3 + + # JDK 21 설정 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 21 + + # Gradle wrapper 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle 캐시 + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper/ + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # application.yml + - name: Overwrite application.yml with secret + run: | + mkdir -p src/main/resources + cat < src/main/resources/application.yml + ${{ secrets.APPLICATION_YML }} + EOF + + # 빌드 & 테스트 + - name: Build and Test + run: ./gradlew clean build + + # 테스트 결과 출력 + - name: Show build artifacts + run: ls -l build/libs From 6f0a5477a8add2dc5a5cb709128f9dc81bc0585a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:16:42 +0900 Subject: [PATCH 03/39] Rename develop CI to pull request CI --- .github/workflows/develop.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 1ccb143..93a21fa 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,8 +1,9 @@ -name: develop CI +name: pull request CI on: pull_request: branches: + - main - develop jobs: From 3daa34510458801aab31958115ccca7b4f095d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:23:52 +0900 Subject: [PATCH 04/39] Modify CI workflow configuration file name --- .github/workflows/{develop.yml => CI.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{develop.yml => CI.yml} (100%) diff --git a/.github/workflows/develop.yml b/.github/workflows/CI.yml similarity index 100% rename from .github/workflows/develop.yml rename to .github/workflows/CI.yml From 83956195d02929e25144af46e3e7d7fc01b68dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:51:39 +0900 Subject: [PATCH 05/39] Add CD workflow for Docker and K3s deployment --- .github/workflows/CD.yml | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/CD.yml diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml new file mode 100644 index 0000000..43f4aab --- /dev/null +++ b/.github/workflows/CD.yml @@ -0,0 +1,73 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # DockerHub 로그인 + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + # Docker 이미지 빌드 & 푸시 + - name: Build and Push Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . + docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + + # EC2 SSH → k3s에 Secret 생성 & Deployment 업데이트 + - name: Deploy to K3s via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd /home/ubuntu/skill-boost # k8s YAML 저장 경로 + + # -- db-secret 생성 -- + cat < Date: Thu, 20 Nov 2025 19:48:45 +0900 Subject: [PATCH 06/39] Add Dockerfile for Gradle build and deployment --- .github/workflows/Dockerfile | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/Dockerfile diff --git a/.github/workflows/Dockerfile b/.github/workflows/Dockerfile new file mode 100644 index 0000000..556342b --- /dev/null +++ b/.github/workflows/Dockerfile @@ -0,0 +1,25 @@ +FROM gradle:8.8-jdk21 AS builder + +WORKDIR /app + +# Gradle 캐시 활용 +COPY build.gradle settings.gradle gradlew ./ +COPY gradle gradle +RUN ./gradlew dependencies || true + +# 소스 코드 복사 +COPY . . + +# 빌드 +RUN chmod +x gradlew +RUN ./gradlew clean build -x test + +FROM amazoncorretto:21 + +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] From 5f303f300b18aeb1f0c0f3d2593f0d4b2895e978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:50:51 +0900 Subject: [PATCH 07/39] Delete .github/workflows/Dockerfile --- .github/workflows/Dockerfile | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/Dockerfile diff --git a/.github/workflows/Dockerfile b/.github/workflows/Dockerfile deleted file mode 100644 index 556342b..0000000 --- a/.github/workflows/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM gradle:8.8-jdk21 AS builder - -WORKDIR /app - -# Gradle 캐시 활용 -COPY build.gradle settings.gradle gradlew ./ -COPY gradle gradle -RUN ./gradlew dependencies || true - -# 소스 코드 복사 -COPY . . - -# 빌드 -RUN chmod +x gradlew -RUN ./gradlew clean build -x test - -FROM amazoncorretto:21 - -WORKDIR /app - -COPY --from=builder /app/build/libs/*.jar app.jar - -EXPOSE 8080 - -ENTRYPOINT ["java", "-jar", "app.jar"] From 29b3aea7b109c980eb26f3151efaa385b8fadf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:51:21 +0900 Subject: [PATCH 08/39] Add Dockerfile for Gradle build and Java application --- Dockerfile | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..556342b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM gradle:8.8-jdk21 AS builder + +WORKDIR /app + +# Gradle 캐시 활용 +COPY build.gradle settings.gradle gradlew ./ +COPY gradle gradle +RUN ./gradlew dependencies || true + +# 소스 코드 복사 +COPY . . + +# 빌드 +RUN chmod +x gradlew +RUN ./gradlew clean build -x test + +FROM amazoncorretto:21 + +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] From 0ef4fcf17aa7821b130c24f781c1c454bc6988b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:01:34 +0900 Subject: [PATCH 09/39] Rename deployment and service to skill-boost-app --- app.yaml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app.yaml b/app.yaml index e690823..646e81d 100644 --- a/app.yaml +++ b/app.yaml @@ -1,12 +1,12 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: springboot-app + name: skill-boost-app spec: replicas: 2 selector: matchLabels: - app: springboot-app + app: skill-boost-app strategy: type: RollingUpdate rollingUpdate: @@ -15,36 +15,42 @@ spec: template: metadata: labels: - app: springboot-app + app: skill-boost-app spec: containers: - - name: springboot-app - image: springboot-app:latest + - name: skill-boost-app + image: skill-boost-app:latest + imagePullPolicy: Always ports: - containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: "prod" envFrom: - secretRef: name: db-secret + - secretRef: + name: app-secret --- apiVersion: v1 kind: Service metadata: labels: - app: springboot-app + app: skill-boost-app spec: ports: - port: 80 protocol: TCP targetPort: 8080 selector: - app: springboot-app + app: skill-boost-app sessionAffinity: None type: ClusterIP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: springboot-ingress + name: skill-boost-app-ingress annotations: kubernetes.io/ingress.class: traefik spec: @@ -55,6 +61,6 @@ spec: pathType: Prefix backend: service: - name: springboot-app + name: skill-boost-app port: number: 80 From f5283dcf66740ce07b171b9df089697bc9bace11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:03:33 +0900 Subject: [PATCH 10/39] Fix Docker password secret and enhance deployment process Updated Docker login password secret and improved deployment steps. --- .github/workflows/CD.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 43f4aab..c568560 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -19,7 +19,7 @@ jobs: uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + password: ${{ secrets.DOCKER_PASSWORD }} # Docker 이미지 빌드 & 푸시 - name: Build and Push Docker image @@ -35,7 +35,7 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd /home/ubuntu/skill-boost # k8s YAML 저장 경로 + cd /home/ubuntu/skill-boost # -- db-secret 생성 -- cat < Date: Thu, 20 Nov 2025 21:11:22 +0900 Subject: [PATCH 11/39] Implement dynamic IP management for GitHub Actions Add steps to manage GitHub Actions IP in AWS security group --- .github/workflows/CD.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index c568560..012799f 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -27,6 +27,20 @@ jobs: docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + # Github Actions IP 가져오기 + - name: Get Github Actions IP + id: ip + uses: haythem/public-ip@v1.2 + + # AWS 보안 그룹에 동적으로 IP 추가 + - name: Add Github Actions IP to Security group + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ap-northeast-2 + # EC2 SSH → k3s에 Secret 생성 & Deployment 업데이트 - name: Deploy to K3s via SSH uses: appleboy/ssh-action@v0.1.7 @@ -75,3 +89,12 @@ jobs: # -- 배포 결과 확인 -- kubectl get pods -l app=skill-boost-app echo "✅ Deployment successful!" + + # AWS 보안 그룹에서 IP 제거 + - name: Remove Github Actions IP from security group + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ap-northeast-2 From d1cdc54ea2f9f551f94fe762ffefda4d57b9388f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:46:51 +0900 Subject: [PATCH 12/39] Add conditional execution for IP removal step --- .github/workflows/CD.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 012799f..96a0fd8 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -92,6 +92,7 @@ jobs: # AWS 보안 그룹에서 IP 제거 - name: Remove Github Actions IP from security group + if: always() run: | aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 env: From f2e5278681c5c2c0b9186d0cce4862c30c8d1f63 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 20 Nov 2025 21:56:24 +0900 Subject: [PATCH 13/39] add application yml file --- src/main/resources/application-local.yml | 47 +++++++++++++++++++++++ src/main/resources/application-prod.yml | 28 ++++++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 3 ++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..dada28b --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,47 @@ +# 서버 포트 설정 +server: + port: 8080 + +# Spring Boot 애플리케이션 기본 설정 +spring: + application: + name: skill-boost + + # JPA 설정 (테이블 자동 생성을 위해 ddl-auto: update 추가) + jpa: + hibernate: + ddl-auto: update + # (선택사항) 실행되는 SQL을 로그로 보려면 주석 해제 + # show-sql: true + + # GitHub OAuth2 로그인 설정 + security: + oauth2: + client: + registration: + github: + client-id: Ov23liXAPa0etQe0EisI + client-secret: ${GITHUB_CLIENT_SECRET} # .env 파일에서 읽어옴 + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +# SpringDoc (Swagger) 설정 +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + path: /swagger-ui.html + +# JWT 토큰 설정 +jwt: + secret-key: ${JWT_SECRET_KEY} # .env 파일에서 읽어옴 + expiration-ms: 86400000 # 토큰 만료 시간 (24시간) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..2da9ab5 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,28 @@ +application: + name: skill-boost + +jpa: + hibernate: + ddl-auto: none + +security: + oauth2: + client: + registration: + github: + client-id: Ov23liXAPa0etQe0EisI + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: ${JWT_SECRET_KEY} + expiration-ms: 86400000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 666da9c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=skill-boost diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ef46c2a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: local \ No newline at end of file From 1dff82935d8c0c8d8fbd6c3dc0c228500de6c3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:02:15 +0900 Subject: [PATCH 14/39] remove cd command Removed unnecessary directory change in deployment script. --- .github/workflows/CD.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 96a0fd8..bf29332 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -49,8 +49,6 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd /home/ubuntu/skill-boost - # -- db-secret 생성 -- cat < Date: Thu, 20 Nov 2025 22:09:27 +0900 Subject: [PATCH 15/39] CD comment out --- .github/workflows/CD.yml | 42 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index bf29332..1c40f18 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -27,19 +27,19 @@ jobs: docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} - # Github Actions IP 가져오기 - - name: Get Github Actions IP - id: ip - uses: haythem/public-ip@v1.2 - - # AWS 보안 그룹에 동적으로 IP 추가 - - name: Add Github Actions IP to Security group - run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-northeast-2 +# # Github Actions IP 가져오기 +# - name: Get Github Actions IP +# id: ip +# uses: haythem/public-ip@v1.2 +# +# # AWS 보안 그룹에 동적으로 IP 추가 +# - name: Add Github Actions IP to Security group +# run: | +# aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 +# env: +# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} +# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# AWS_DEFAULT_REGION: ap-northeast-2 # EC2 SSH → k3s에 Secret 생성 & Deployment 업데이트 - name: Deploy to K3s via SSH @@ -89,11 +89,11 @@ jobs: echo "✅ Deployment successful!" # AWS 보안 그룹에서 IP 제거 - - name: Remove Github Actions IP from security group - if: always() - run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-northeast-2 +# - name: Remove Github Actions IP from security group +# if: always() +# run: | +# aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 +# env: +# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} +# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# AWS_DEFAULT_REGION: ap-northeast-2 From 747cceea9c0145a90d7ccfcf3bd77011b02b358e Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 20 Nov 2025 22:15:56 +0900 Subject: [PATCH 16/39] add test controller --- .../java/com/example/skillboost/TestController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/example/skillboost/TestController.java diff --git a/src/main/java/com/example/skillboost/TestController.java b/src/main/java/com/example/skillboost/TestController.java new file mode 100644 index 0000000..d8e061c --- /dev/null +++ b/src/main/java/com/example/skillboost/TestController.java @@ -0,0 +1,12 @@ +package com.example.skillboost; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + @GetMapping("/") + public String hello() { + return "Hello, World"; + } +} From 082b9d6373a4e6647d0de54f794aaa75a83a85b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:21:31 +0900 Subject: [PATCH 17/39] Add workflow_dispatch trigger to CD workflow --- .github/workflows/CD.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 1c40f18..6778f51 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -1,6 +1,7 @@ name: CD on: + workflow_dispatch: push: branches: - main From 20803c67bdd107efc8c3713213ee97c259266e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=98=81=EB=B9=88?= <90145556+byb0823@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:31:05 +0900 Subject: [PATCH 18/39] Update Docker image name in app.yaml --- .github/workflows/CD.yml | 1 + app.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 6778f51..1881660 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -78,6 +78,7 @@ jobs: EOF # -- Deployment 이미지 업데이트 (자동으로 롤링 업데이트 시작) -- + kubectl apply -f app.yaml kubectl set image deployment/skill-boost-app \ skill-boost-app=${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} \ --record diff --git a/app.yaml b/app.yaml index 646e81d..137516a 100644 --- a/app.yaml +++ b/app.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: skill-boost-app - image: skill-boost-app:latest + image: skill-boost:latest imagePullPolicy: Always ports: - containerPort: 8080 From ce16e0e790f4d738ca5feb3878f18463edba2e0a Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 20 Nov 2025 23:35:48 +0900 Subject: [PATCH 19/39] modify k3s yaml file --- .github/workflows/CD.yml | 18 +++++++++++++----- app.yaml => k8s/app.yaml | 0 mysql.yaml => k8s/mysql.yaml | 0 3 files changed, 13 insertions(+), 5 deletions(-) rename app.yaml => k8s/app.yaml (100%) rename mysql.yaml => k8s/mysql.yaml (100%) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 1881660..291f775 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -28,6 +28,16 @@ jobs: docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + # EC2로 yaml 파일 복사 + - name: Copy k8s manifests to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "k8s/*.yaml" + target: "/home/${{ secrets.EC2_USER }}/k8s-manifests" + # # Github Actions IP 가져오기 # - name: Get Github Actions IP # id: ip @@ -77,11 +87,9 @@ jobs: GITHUB_CLIENT_SECRET: "${{ secrets.GITHUB_CLIENT_SECRET }}" EOF - # -- Deployment 이미지 업데이트 (자동으로 롤링 업데이트 시작) -- - kubectl apply -f app.yaml - kubectl set image deployment/skill-boost-app \ - skill-boost-app=${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} \ - --record + # -- 매니페스트 파일 적용 -- + cd /home/${{ secrets.EC2_USER }}/k8s-manifests/k8s + kubectl apply -f . # -- 롤링 업데이트 완료 대기 -- kubectl rollout status deployment/skill-boost-app --timeout=5m diff --git a/app.yaml b/k8s/app.yaml similarity index 100% rename from app.yaml rename to k8s/app.yaml diff --git a/mysql.yaml b/k8s/mysql.yaml similarity index 100% rename from mysql.yaml rename to k8s/mysql.yaml From 4fd2ddb658a87a576af18c403975b738a26e109b Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 20 Nov 2025 23:46:24 +0900 Subject: [PATCH 20/39] modify app.yaml --- k8s/app.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/k8s/app.yaml b/k8s/app.yaml index 137516a..cd75421 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: skill-boost-app + namespace: default spec: replicas: 2 selector: @@ -35,6 +36,8 @@ spec: apiVersion: v1 kind: Service metadata: + name: skill-boost-service + namespace: default labels: app: skill-boost-app spec: @@ -51,9 +54,9 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: skill-boost-app-ingress - annotations: - kubernetes.io/ingress.class: traefik + namespace: default spec: + ingressClassName: traefik rules: - http: paths: @@ -61,6 +64,6 @@ spec: pathType: Prefix backend: service: - name: skill-boost-app + name: skill-boost-service port: number: 80 From ee5fa2aaa63e32ad0728012350b06f07d88707bc Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 00:17:25 +0900 Subject: [PATCH 21/39] modify app.yaml --- .github/workflows/CD.yml | 16 ++++++++++++++++ k8s/app.yaml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 291f775..55033a4 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -38,6 +38,22 @@ jobs: source: "k8s/*.yaml" target: "/home/${{ secrets.EC2_USER }}/k8s-manifests" + # SSH 접속 후 app.yaml의 username 치환 + - name: Replace in app.yaml on EC2 + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # k8s 매니페스트 디렉토리 + MANIFEST_DIR="/home/${{ secrets.EC2_USER }}/k8s-manifests/k8s" + + # USERNAME을 GitHub Secret 값으로 치환 + sed -i "s|USERNAME|${{ secrets.DOCKER_USERNAME }}|g" "$MANIFEST_DIR/app.yaml" + + echo "✅ app.yaml의 USERNAME 치환 완료" + # # Github Actions IP 가져오기 # - name: Get Github Actions IP # id: ip diff --git a/k8s/app.yaml b/k8s/app.yaml index cd75421..b01e66c 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: skill-boost-app - image: skill-boost:latest + image: USERNAME/skill-boost:latest imagePullPolicy: Always ports: - containerPort: 8080 From e8c92b6f8f49c98b8536219613e968444e0f720e Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 00:56:37 +0900 Subject: [PATCH 22/39] modify mysql.yaml --- .github/workflows/CD.yml | 8 +++++--- k8s/mysql.yaml | 1 + src/main/resources/application-prod.yml | 8 +++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 55033a4..4c68aa1 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -85,9 +85,10 @@ jobs: namespace: default type: Opaque stringData: - DB_URL: "${{ secrets.DB_URL }}" - DB_USERNAME: "${{ secrets.DB_USERNAME }}" - DB_PASSWORD: "${{ secrets.DB_PASSWORD }}" + MYSQL_ROOT_PASSWORD: "${{ secrets.DB_PASSWORD }}" + MYSQL_DATABASE: "${{ secrets.DB }}" + MYSQL_USER: "${{ secrets.DB_USERNAME }}" + MYSQL_PASSWORD: "${{ secrets.DB_PASSWORD }}" EOF # -- app-secret 생성 -- @@ -99,6 +100,7 @@ jobs: namespace: default type: Opaque stringData: + MYSQL_URL: "${{ secrets.MYSQL_URL }}" JWT_SECRET_KEY: "${{ secrets.JWT_SECRET_KEY }}" GITHUB_CLIENT_SECRET: "${{ secrets.GITHUB_CLIENT_SECRET }}" EOF diff --git a/k8s/mysql.yaml b/k8s/mysql.yaml index b9ed1ff..6bd13ec 100644 --- a/k8s/mysql.yaml +++ b/k8s/mysql.yaml @@ -37,6 +37,7 @@ apiVersion: v1 kind: Service metadata: name: mysql + namespace: default spec: selector: app: mysql diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2da9ab5..7a0f91a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,6 +1,12 @@ application: name: skill-boost +spring: + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USER} + password: ${MYSQL_PASSWORD} + jpa: hibernate: ddl-auto: none @@ -10,7 +16,7 @@ security: client: registration: github: - client-id: Ov23liXAPa0etQe0EisI + client-id: ${GITHUB_CLIENT_ID} client-secret: ${GITHUB_CLIENT_SECRET} scope: - read:user From 73b44a5aa07c714608c96d89beaa51a7dca258c6 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 01:05:48 +0900 Subject: [PATCH 23/39] modify cd --- .github/workflows/CD.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 4c68aa1..c6638b6 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -76,6 +76,9 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | + # 기존 db-secret 삭제 + kubectl delete secret db-secret + # -- db-secret 생성 -- cat < Date: Fri, 21 Nov 2025 01:19:32 +0900 Subject: [PATCH 24/39] modify CD.yaml --- .github/workflows/CD.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index c6638b6..5e7c202 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -25,8 +25,8 @@ jobs: # Docker 이미지 빌드 & 푸시 - name: Build and Push Docker image run: | - docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . - docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:latest . + docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:latest # EC2로 yaml 파일 복사 - name: Copy k8s manifests to EC2 From 0071494659274e1f2ec3c9896975d6099c5048a9 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 02:02:28 +0900 Subject: [PATCH 25/39] modify CD.yaml --- .github/workflows/CD.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 5e7c202..0d6340b 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -121,6 +121,9 @@ jobs: # -- 배포 결과 확인 -- kubectl get pods -l app=skill-boost-app echo "✅ Deployment successful!" + + # -- 사용하지 않는 이미지 삭제 -- + sudo k3s ctr images prune -f # AWS 보안 그룹에서 IP 제거 # - name: Remove Github Actions IP from security group From 85d8b0d48262a1faa19203cb7b154f57cfdc3b87 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 02:13:53 +0900 Subject: [PATCH 26/39] modify hello controller --- src/main/java/com/example/skillboost/TestController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/skillboost/TestController.java b/src/main/java/com/example/skillboost/TestController.java index d8e061c..cf0db2b 100644 --- a/src/main/java/com/example/skillboost/TestController.java +++ b/src/main/java/com/example/skillboost/TestController.java @@ -7,6 +7,6 @@ public class TestController { @GetMapping("/") public String hello() { - return "Hello, World"; + return "Hello"; } } From 8067a1879d6e40c80f80e035863a665c84cde10a Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 02:20:05 +0900 Subject: [PATCH 27/39] modify CD.yml --- .github/workflows/CD.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 0d6340b..952526f 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -123,7 +123,7 @@ jobs: echo "✅ Deployment successful!" # -- 사용하지 않는 이미지 삭제 -- - sudo k3s ctr images prune -f + sudo k3s ctr images prune --all # AWS 보안 그룹에서 IP 제거 # - name: Remove Github Actions IP from security group From d9dda047410bbcd965d59d564d3de4b9b166656e Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 02:29:51 +0900 Subject: [PATCH 28/39] modify image name --- .github/workflows/CD.yml | 12 ++++++------ k8s/app.yaml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 952526f..811403b 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -25,8 +25,8 @@ jobs: # Docker 이미지 빌드 & 푸시 - name: Build and Push Docker image run: | - docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:latest . - docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:latest + docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . + docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} # EC2로 yaml 파일 복사 - name: Copy k8s manifests to EC2 @@ -38,8 +38,8 @@ jobs: source: "k8s/*.yaml" target: "/home/${{ secrets.EC2_USER }}/k8s-manifests" - # SSH 접속 후 app.yaml의 username 치환 - - name: Replace in app.yaml on EC2 + # SSH 접속 후 app.yaml의 IMAGE 치환 + - name: Replace IMAGE in app.yaml on EC2 uses: appleboy/ssh-action@v0.1.7 with: host: ${{ secrets.EC2_HOST }} @@ -49,8 +49,8 @@ jobs: # k8s 매니페스트 디렉토리 MANIFEST_DIR="/home/${{ secrets.EC2_USER }}/k8s-manifests/k8s" - # USERNAME을 GitHub Secret 값으로 치환 - sed -i "s|USERNAME|${{ secrets.DOCKER_USERNAME }}|g" "$MANIFEST_DIR/app.yaml" + # IMAGE를 GitHub Secret 값으로 치환 + sed -i "s|IMAGE|${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }}|g" "$MANIFEST_DIR/app.yaml" echo "✅ app.yaml의 USERNAME 치환 완료" diff --git a/k8s/app.yaml b/k8s/app.yaml index b01e66c..f28a1aa 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: skill-boost-app - image: USERNAME/skill-boost:latest + image: IMAGE imagePullPolicy: Always ports: - containerPort: 8080 From c9633ce78e3593ebe110e900f9f8bf948bf991d1 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 02:34:02 +0900 Subject: [PATCH 29/39] change test controller return value --- .github/workflows/CD.yml | 1 - src/main/java/com/example/skillboost/TestController.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 811403b..2e5c737 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -1,7 +1,6 @@ name: CD on: - workflow_dispatch: push: branches: - main diff --git a/src/main/java/com/example/skillboost/TestController.java b/src/main/java/com/example/skillboost/TestController.java index cf0db2b..7f8f67b 100644 --- a/src/main/java/com/example/skillboost/TestController.java +++ b/src/main/java/com/example/skillboost/TestController.java @@ -7,6 +7,6 @@ public class TestController { @GetMapping("/") public String hello() { - return "Hello"; + return "Hello World!"; } } From 4f7eebd6bf43fb0969732135541dbf912385cd43 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 15:08:38 +0900 Subject: [PATCH 30/39] =?UTF-8?q?application=20yml=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + src/main/resources/application-prod.yml | 46 ++++++++++++------------- src/main/resources/application-test.yml | 32 +++++++++++++++++ src/main/resources/application.yml | 4 +-- 4 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 src/main/resources/application-test.yml diff --git a/build.gradle b/build.gradle index 74c055c..710349c 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7a0f91a..f7044b2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,33 +1,33 @@ -application: - name: skill-boost - spring: + application: + name: skill-boost + datasource: url: ${MYSQL_URL} username: ${MYSQL_USER} password: ${MYSQL_PASSWORD} -jpa: - hibernate: - ddl-auto: none + jpa: + hibernate: + ddl-auto: none -security: - oauth2: - client: - registration: - github: - client-id: ${GITHUB_CLIENT_ID} - client-secret: ${GITHUB_CLIENT_SECRET} - scope: - - read:user - - user:email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - provider: - github: - authorization-uri: https://github.com/login/oauth/authorize - token-uri: https://github.com/login/oauth/access_token - user-info-uri: https://api.github.com/user - user-name-attribute: id + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id jwt: secret-key: ${JWT_SECRET_KEY} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..105917c --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,32 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL + driver-class-name: + username: sa + password: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + + security: + oauth2: + client: + registration: + github: + client-id: test + client-secret: test + scope: + - read:user + - user:email + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: test-secret + expiration-ms: 100000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef46c2a..c22d980 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: - profiles: - active: local \ No newline at end of file + application: + name: skill-boost \ No newline at end of file From 188a6866c64a8fac888710de47a0d931d7d079b7 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 21 Nov 2025 15:09:08 +0900 Subject: [PATCH 31/39] =?UTF-8?q?CI/CD=20yml=ED=8C=8C=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CD.yml | 2 +- .github/workflows/CI.yml | 57 ++++++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 2e5c737..64ac456 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -12,7 +12,7 @@ jobs: steps: # 코드 체크아웃 - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 # DockerHub 로그인 - name: Login to DockerHub diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 93a21fa..6670844 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -13,42 +13,47 @@ jobs: steps: # 코드 체크아웃 - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 # JDK 21 설정 - name: Set up JDK 21 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 21 + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + # 도커 컴포즈 + - name: Start docker-compose + run: | + docker-compose up -d + + # MySQL이 ready 될 때까지 대기 (최대 30초) + timeout 30 bash -c 'until docker-compose exec -T mysql mysqladmin ping -h localhost --silent; do sleep 1; done' + + echo "MySQL is ready!" # Gradle wrapper 실행 권한 부여 - name: Grant execute permission for gradlew run: chmod +x gradlew - # Gradle 캐시 - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper/ - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - # application.yml - - name: Overwrite application.yml with secret - run: | - mkdir -p src/main/resources - cat < src/main/resources/application.yml - ${{ secrets.APPLICATION_YML }} - EOF - # 빌드 & 테스트 - name: Build and Test run: ./gradlew clean build + env: + SPRING_PROFILES_ACTIVE: test + + # 테스트 결과 업로드 + - name: Upload Test Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + build/reports/tests/test/ + build/test-results/test/ - # 테스트 결과 출력 - - name: Show build artifacts - run: ls -l build/libs + # 도커 컴포즈 종료 + - name: Docker Compose Down + if: always() + run: docker-compose down -v \ No newline at end of file From 46dd6887f7b10b9e443d26eb98343685917c4e98 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Thu, 27 Nov 2025 22:36:32 +0900 Subject: [PATCH 32/39] feat: complete coding test backend implementation --- .gitignore | 5 +- build.gradle | 18 +- .../skillboost/SkillBoostApplication.java | 3 +- .../controller/CodingTestController.java | 54 ++ .../controller/SubmissionController.java | 39 + .../codingtest/domain/CodingProblem.java | 34 + .../codingtest/domain/CodingSubmission.java | 41 + .../codingtest/domain/CodingTestCase.java | 30 + .../codingtest/domain/Difficulty.java | 7 + .../codingtest/dto/ProblemDetailDto.java | 23 + .../codingtest/dto/ProblemSummaryDto.java | 13 + .../codingtest/dto/SubmissionRequestDto.java | 20 + .../codingtest/dto/SubmissionResultDto.java | 22 + .../init/CodingTestDataInitializer.java | 777 ++++++++++++++++++ .../codingtest/judge/GeminiJudge.java | 148 ++++ .../codingtest/judge/JudgeClient.java | 185 +++++ .../codingtest/judge/JudgeResult.java | 55 ++ .../repository/CodingProblemRepository.java | 16 + .../CodingSubmissionRepository.java | 7 + .../repository/CodingTestCaseRepository.java | 15 + .../codingtest/service/CodingTestService.java | 8 + .../codingtest/service/GradingService.java | 29 + src/main/resources/application.yml | 26 +- 23 files changed, 1570 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java create mode 100644 src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java create mode 100644 src/main/java/com/example/skillboost/codingtest/service/GradingService.java diff --git a/.gitignore b/.gitignore index e9574a0..ba43b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ .env -*secret.yaml \ No newline at end of file +*secret.yaml + +### Secret Config ### +src/main/resources/application-secret.yml diff --git a/build.gradle b/build.gradle index 710349c..0a4f3e4 100644 --- a/build.gradle +++ b/build.gradle @@ -25,14 +25,28 @@ repositories { } dependencies { + // 기본 Spring Web + JPA + Validation implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // ⭐ AI 요청에 필요한 JSON 처리기 (Jackson) + implementation 'com.fasterxml.jackson.core:jackson-databind' + + // 내장 DB + implementation 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + // lombok compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // 개발 환경 developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' + + // 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/example/skillboost/SkillBoostApplication.java b/src/main/java/com/example/skillboost/SkillBoostApplication.java index 63875ab..d65240e 100644 --- a/src/main/java/com/example/skillboost/SkillBoostApplication.java +++ b/src/main/java/com/example/skillboost/SkillBoostApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; + @SpringBootApplication public class SkillBoostApplication { @@ -10,4 +11,4 @@ public static void main(String[] args) { SpringApplication.run(SkillBoostApplication.class, args); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java b/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java new file mode 100644 index 0000000..fdaaa0d --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java @@ -0,0 +1,54 @@ +package com.example.skillboost.codingtest.controller; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Random; + +@RestController +@RequestMapping("/api/coding/problems") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class CodingTestController { + + private final CodingProblemRepository problemRepository; + + @GetMapping("/random") + public ResponseEntity getRandomProblem(@RequestParam(required = false) String difficulty) { + List problems; + + // 1. 프론트에서 난이도를 선택했는지 확인 + if (difficulty != null && !difficulty.isEmpty()) { + try { + // "EASY" -> Difficulty.EASY 변환 + Difficulty diff = Difficulty.valueOf(difficulty.toUpperCase()); + // 해당 난이도 문제들만 DB에서 가져옴 (예: 5개) + problems = problemRepository.findAllByDifficulty(diff); + } catch (IllegalArgumentException e) { + // 이상한 난이도가 오면 그냥 전체 문제 가져옴 + problems = problemRepository.findAll(); + } + } else { + // 난이도 선택 안 했으면 전체 문제(15개) 가져옴 + problems = problemRepository.findAll(); + } + + // 2. 문제가 하나도 없으면 404 에러 + if (problems.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // 3. 목록 중에서 랜덤으로 하나 뽑기 (핵심 로직) + Random random = new Random(); + int randomIndex = random.nextInt(problems.size()); // 0 ~ (개수-1) 사이 랜덤 숫자 + CodingProblem randomProblem = problems.get(randomIndex); + + // 4. 뽑힌 문제 반환 + return ResponseEntity.ok(randomProblem); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java new file mode 100644 index 0000000..df2ee8c --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -0,0 +1,39 @@ +package com.example.skillboost.codingtest.controller; + +import com.example.skillboost.codingtest.dto.SubmissionRequestDto; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.example.skillboost.codingtest.service.GradingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/coding") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class SubmissionController { + + private final GradingService gradingService; + + @PostMapping("/submissions") + public ResponseEntity submitCode(@RequestBody SubmissionRequestDto request) { + + log.info("채점 요청 도착: problemId={}, language={}", + request.getProblemId(), request.getLanguage()); + + if (request.getCode() == null || request.getCode().isEmpty()) { + return ResponseEntity.badRequest().body( + SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("코드가 비어 있습니다.") + .build() + ); + } + + SubmissionResultDto result = gradingService.grade(request); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java new file mode 100644 index 0000000..a5a7bce --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java @@ -0,0 +1,34 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingProblem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @Column(columnDefinition = "TEXT") // 긴 문제 설명 저장용 + private String description; + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; + + // 예: "array,implementation" + private String tags; + + @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) + private List testCases = new ArrayList<>(); +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java new file mode 100644 index 0000000..a64dff4 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java @@ -0,0 +1,41 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + private CodingProblem problem; + + // ★ [추가] 누가 풀었는지 저장해야 합니다! + private Long userId; + + private String language; + + @Column(columnDefinition = "TEXT") + private String sourceCode; + + private String verdict; + private int passedCount; + private int totalCount; + + private LocalDateTime createdAt; + + @PrePersist + public void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java new file mode 100644 index 0000000..e81bb41 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java @@ -0,0 +1,30 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingTestCase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ★ 여기 필드 이름이 CodingProblem의 mappedBy("problem") 와 같아야 함 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + private CodingProblem problem; + + @Column(columnDefinition = "TEXT") + private String inputData; + + @Column(columnDefinition = "TEXT") + private String expectedOutput; + + private boolean sample; // 예제용 테스트케이스인지 여부 +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java b/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java new file mode 100644 index 0000000..9110aeb --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java @@ -0,0 +1,7 @@ +package com.example.skillboost.codingtest.domain; + +public enum Difficulty { + EASY, + MEDIUM, + HARD +} diff --git a/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java b/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java new file mode 100644 index 0000000..d00ecb9 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java @@ -0,0 +1,23 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.Builder; +import lombok.Data; +import java.util.List; + +@Data +@Builder +public class ProblemDetailDto { + private Long id; + private String title; + private String description; + private String difficulty; + private String tags; + private List samples; + + @Data + @Builder + public static class SampleCase { + private String inputData; + private String expectedOutput; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java b/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java new file mode 100644 index 0000000..6445c1e --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java @@ -0,0 +1,13 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ProblemSummaryDto { + private Long id; + private String title; + private String difficulty; + private String tags; +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java new file mode 100644 index 0000000..c2c6149 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java @@ -0,0 +1,20 @@ +package com.example.skillboost.codingtest.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SubmissionRequestDto { + + private Long problemId; + + // 프론트에서 보내는 JSON 키: "sourceCode" + @JsonProperty("sourceCode") + private String code; + + private String language; + + private Long userId; +} diff --git a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java new file mode 100644 index 0000000..b1f67bf --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java @@ -0,0 +1,22 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionResultDto { + private Long submissionId; + private String status; // "AC"(정답), "WA"(오답) + private Integer score; // 0 ~ 100점 + private Integer passedCount; // (AI 추정치) + private Integer totalCount; + private String message; // "정답입니다!" 같은 간단 메시지 + + // ★ [추가] AI 선생님의 상세 피드백 + private String aiFeedback; +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java new file mode 100644 index 0000000..b20d889 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java @@ -0,0 +1,777 @@ +package com.example.skillboost.codingtest.init; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CodingTestDataInitializer implements CommandLineRunner { + + private final CodingProblemRepository problemRepository; + + @Override + public void run(String... args) { + // EASY (5문제) + createExamSupervisorProblem(); // 시험 감독 + createZoacDistancingProblem(); // ZOAC 거리두기 + createDjmaxRankingProblem(); // DJMAX 랭킹 + createMinHeapProblem(); // 최소 힙 + createTriangleProblem(); // 삼각형 분류 + + // MEDIUM (5문제) + createSnakeGameProblem(); // Dummy (뱀 게임) + createDiceSimulationProblem(); // 주사위 굴리기 + createTargetDistanceProblem(); // 목표지점 거리 + createDfsBfsProblem(); // DFS와 BFS + createTripPlanningProblem(); // 여행 가자 (New) + + // HARD (5문제) + createMarbleEscapeProblem(); // 구슬 탈출 + createSharkCopyMagicProblem(); // 마법사 상어와 복제 + createSimilarWordsProblem(); // 비슷한 단어 + createJewelThiefProblem(); // 보석 도둑 + createMarsExplorationProblem(); // 화성 탐사 (New) + } + + // ========================= + // EASY 문제들 + // ========================= + + // 1. 시험 감독 + private void createExamSupervisorProblem() { + if (problemRepository.existsByTitle("시험 감독")) { + return; + } + + String description = """ + [문제] + + 총 N개의 시험장이 있고, 각각의 시험장마다 응시자들이 있다. i번 시험장에 있는 응시자의 수는 Ai명이다. + + 감독관은 총감독관과 부감독관으로 두 종류가 있다. + 총감독관은 한 시험장에서 감시할 수 있는 응시자의 수가 B명이고, + 부감독관은 한 시험장에서 감시할 수 있는 응시자의 수가 C명이다. + + 각각의 시험장에 총감독관은 오직 1명만 있어야 하고, + 부감독관은 여러 명 있어도 된다. + + 각 시험장마다 응시생들을 모두 감시해야 한다. + 이때, 필요한 감독관 수의 최솟값을 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 시험장의 개수 N(1 ≤ N ≤ 1,000,000)이 주어진다. + 둘째 줄에는 각 시험장에 있는 응시자의 수 Ai (1 ≤ Ai ≤ 1,000,000)가 주어진다. + 셋째 줄에는 B와 C가 주어진다. (1 ≤ B, C ≤ 1,000,000) + + + [출력] + + 각 시험장마다 응시생을 모두 감독하기 위해 필요한 감독관의 최소 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("시험 감독") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,greedy") + .build(); + + problemRepository.save(problem); + } + + // 2. ZOAC 거리두기 + private void createZoacDistancingProblem() { + if (problemRepository.existsByTitle("ZOAC 거리두기")) { + return; + } + + String description = """ + [문제] + + 2021년 12월, 네 번째로 개최된 ZOAC의 오프닝을 맡은 성우는 + 오프라인 대회를 대비하여 강의실을 예약하려고 한다. + + 강의실에서 대회를 치르려면 거리두기 수칙을 지켜야 한다! + + 한 명씩 앉을 수 있는 테이블이 행마다 W개씩 H행에 걸쳐 있을 때, + 모든 참가자는 세로로 N칸 또는 가로로 M칸 이상 비우고 앉아야 한다. + 즉, 다른 모든 참가자와 세로줄 번호의 차가 N보다 크거나 + 가로줄 번호의 차가 M보다 큰 곳에만 앉을 수 있다. + + 논문과 과제에 시달리는 성우를 위해 + 강의실이 거리두기 수칙을 지키면서 + 최대 몇 명을 수용할 수 있는지 구해보자. + + + [입력] + + H, W, N, M이 공백으로 구분되어 주어진다. + (0 < H, W, N, M ≤ 50,000) + + + [출력] + + 강의실이 수용할 수 있는 최대 인원 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("ZOAC 거리두기") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,implementation") + .build(); + + problemRepository.save(problem); + } + + // 3. DJMAX 랭킹 + private void createDjmaxRankingProblem() { + if (problemRepository.existsByTitle("DJMAX 랭킹")) { + return; + } + + String description = """ + [문제] + + 태수가 즐겨하는 디제이맥스 게임은 각각의 노래마다 랭킹 리스트가 있다. + 이것은 매번 게임할 때마다 얻는 점수가 비오름차순으로 저장되어 있는 것이다. + + 이 랭킹 리스트의 등수는 보통 위에서부터 몇 번째 있는 점수인지로 결정한다. + 하지만, 같은 점수가 있을 때는 그러한 점수의 등수 중에 가장 작은 등수가 된다. + + 예를 들어 랭킹 리스트가 100, 90, 90, 80일 때 각각의 등수는 1, 2, 2, 4등이 된다. + + 랭킹 리스트에 올라 갈 수 있는 점수의 개수 P가 주어진다. + 그리고 리스트에 있는 점수 N개가 비오름차순으로 주어지고, + 태수의 새로운 점수가 주어진다. + 이때, 태수의 새로운 점수가 랭킹 리스트에서 몇 등 하는지 구하는 프로그램을 작성하시오. + 만약 점수가 랭킹 리스트에 올라갈 수 없을 정도로 낮다면 -1을 출력한다. + + 만약, 랭킹 리스트가 꽉 차있을 때, + 새 점수가 이전 점수보다 더 좋을 때만 점수가 바뀐다. + + + [입력] + + 첫째 줄에 N, 태수의 새로운 점수, 그리고 P가 주어진다. + P는 10보다 크거나 같고, 50보다 작거나 같은 정수, + N은 0보다 크거나 같고, P보다 작거나 같은 정수이다. + 그리고 모든 점수는 2,000,000,000보다 작거나 같은 자연수 또는 0이다. + + 둘째 줄에는 현재 랭킹 리스트에 있는 점수가 비오름차순으로 주어진다. + 둘째 줄은 N이 0보다 큰 경우에만 주어진다. + + + [출력] + + 첫째 줄에 태수의 점수가 랭킹 리스트에서 차지하는 등수를 출력한다. + 랭킹 리스트에 올라갈 수 없으면 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("DJMAX 랭킹") + .difficulty(Difficulty.EASY) + .description(description) + .tags("implementation,sorting") + .build(); + + problemRepository.save(problem); + } + + // 4. 최소 힙 + private void createMinHeapProblem() { + if (problemRepository.existsByTitle("최소 힙")) { + return; + } + + String description = """ + [문제] + + 널리 잘 알려진 자료구조 중 최소 힙이 있다. + 최소 힙을 이용하여 다음과 같은 연산을 지원하는 프로그램을 작성하시오. + + 1. 배열에 자연수 x를 넣는다. + 2. 배열에서 가장 작은 값을 출력하고, 그 값을 배열에서 제거한다. + + 프로그램은 처음에 비어있는 배열에서 시작하게 된다. + + + [입력] + + 첫째 줄에 연산의 개수 N(1 ≤ N ≤ 100,000)이 주어진다. + 다음 N개의 줄에는 연산에 대한 정보를 나타내는 정수 x가 주어진다. + 만약 x가 자연수라면 배열에 x라는 값을 넣는(추가하는) 연산이고, + x가 0이라면 배열에서 가장 작은 값을 출력하고 그 값을 배열에서 제거하는 경우이다. + x는 2^31보다 작은 자연수 또는 0이고, 음의 정수는 입력으로 주어지지 않는다. + + + [출력] + + 입력에서 0이 주어진 횟수만큼 답을 출력한다. + 만약 배열이 비어 있는 경우인데 가장 작은 값을 출력하라고 한 경우에는 0을 출력하면 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("최소 힙") + .difficulty(Difficulty.EASY) + .description(description) + .tags("datastructure,heap") + .build(); + + problemRepository.save(problem); + } + + // 5. 삼각형 분류 + private void createTriangleProblem() { + if (problemRepository.existsByTitle("삼각형 분류")) { + return; + } + + String description = """ + [문제] + + 삼각형의 세 변의 길이가 주어질 때 변의 길이에 따라 다음과 같이 정의한다. + Equilateral : 세 변의 길이가 모두 같은 경우 + Isosceles : 두 변의 길이만 같은 경우 + Scalene : 세 변의 길이가 모두 다른 경우 + + 단 주어진 세 변의 길이가 삼각형의 조건을 만족하지 못하는 경우에는 "Invalid" 를 출력한다. + 예를 들어 6, 3, 2가 이 경우에 해당한다. + 가장 긴 변의 길이보다 나머지 두 변의 길이의 합이 길지 않으면 삼각형의 조건을 만족하지 못한다. + + 세 변의 길이가 주어질 때 위 정의에 따른 결과를 출력하시오. + + + [입력] + + 각 줄에는 1,000을 넘지 않는 양의 정수 3개가 입력된다. + 마지막 줄은 0 0 0이며 이 줄은 계산하지 않는다. + + + [출력] + + 각 입력에 대해 Equilateral, Isosceles, Scalene, Invalid 중 하나를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("삼각형 분류") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,implementation,geometry") + .build(); + + problemRepository.save(problem); + } + + // ========================= + // MEDIUM 문제들 + // ========================= + + // 6. Dummy (뱀 게임) + private void createSnakeGameProblem() { + if (problemRepository.existsByTitle("Dummy (뱀 게임)")) { + return; + } + + String description = """ + [문제] + + 'Dummy' 라는 도스게임이 있다. 이 게임에는 뱀이 나와서 기어다니는데, + 사과를 먹으면 뱀 길이가 늘어난다. + 뱀이 이리저리 기어다니다가 벽 또는 자기자신의 몸과 부딪히면 게임이 끝난다. + + 게임은 NxN 정사각 보드 위에서 진행되고, 몇몇 칸에는 사과가 놓여져 있다. + 보드의 상하좌우 끝에는 벽이 있다. + 게임이 시작할 때 뱀은 맨 위 맨 좌측에 위치하고 뱀의 길이는 1이다. + 뱀은 처음에 오른쪽을 향한다. + + 뱀은 매 초마다 이동을 하는데 다음과 같은 규칙을 따른다. + + 1. 먼저 뱀은 몸길이를 늘려 머리를 다음 칸에 위치시킨다. + 2. 만약 벽이나 자기자신의 몸과 부딪히면 게임이 끝난다. + 3. 만약 이동한 칸에 사과가 있다면, 그 칸에 있던 사과가 없어지고 꼬리는 움직이지 않는다. + 4. 만약 이동한 칸에 사과가 없다면, 몸길이를 줄여서 꼬리가 위치한 칸을 비워준다. 즉, 몸길이는 변하지 않는다. + + 사과의 위치와 뱀의 이동경로가 주어질 때 + 이 게임이 몇 초에 끝나는지 계산하라. + + + [입력] + + 첫째 줄에 보드의 크기 N이 주어진다. (2 ≤ N ≤ 100) + 다음 줄에 사과의 개수 K가 주어진다. (0 ≤ K ≤ 100) + + 다음 K개의 줄에는 사과의 위치가 주어진다. + 첫 번째 정수는 행, 두 번째 정수는 열 위치를 의미한다. + 사과의 위치는 모두 다르며, 맨 위 맨 좌측 (1행 1열)에는 사과가 없다. + + 다음 줄에는 뱀의 방향 변환 횟수 L이 주어진다. (1 ≤ L ≤ 100) + + 다음 L개의 줄에는 뱀의 방향 변환 정보가 주어진다. + 정수 X와 문자 C로 이루어져 있으며, + 게임 시작 시간으로부터 X초가 끝난 뒤에 + 왼쪽(C가 'L') 또는 오른쪽(C가 'D')으로 90도 방향을 회전시킨다는 뜻이다. + X는 10,000 이하의 양의 정수이며, 방향 전환 정보는 X가 증가하는 순으로 주어진다. + + + [출력] + + 첫째 줄에 게임이 몇 초에 끝나는지 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("Dummy (뱀 게임)") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("simulation,implementation,queue") + .build(); + + problemRepository.save(problem); + } + + // 7. 주사위 굴리기 + private void createDiceSimulationProblem() { + if (problemRepository.existsByTitle("주사위 굴리기")) { + return; + } + + String description = """ + [문제] + + 크기가 N×M인 지도가 존재한다. 지도의 오른쪽은 동쪽, 위쪽은 북쪽이다. + 이 지도의 위에 주사위가 하나 놓여져 있으며, 주사위의 전개도는 아래와 같다. + 지도의 좌표는 (r, c)로 나타내며, r는 북쪽으로부터 떨어진 칸의 개수, + c는 서쪽으로부터 떨어진 칸의 개수이다. + + 2 + 4 1 3 + 5 + 6 + + 주사위는 지도 위에 윗 면이 1이고, 동쪽을 바라보는 방향이 3인 상태로 놓여져 있으며, + 놓여져 있는 곳의 좌표는 (x, y)이다. + 가장 처음에 주사위에는 모든 면에 0이 적혀져 있다. + + 지도의 각 칸에는 정수가 하나씩 쓰여져 있다. + 주사위를 굴렸을 때, 이동한 칸에 쓰여 있는 수가 0이면, + 주사위의 바닥면에 쓰여 있는 수가 칸에 복사된다. + 0이 아닌 경우에는 칸에 쓰여 있는 수가 주사위의 바닥면으로 복사되며, + 칸에 쓰여 있는 수는 0이 된다. + + 주사위를 놓은 곳의 좌표와 이동시키는 명령이 주어졌을 때, + 주사위가 이동했을 때마다 상단에 쓰여 있는 값을 구하는 프로그램을 작성하시오. + + 주사위는 지도의 바깥으로 이동시킬 수 없다. + 만약 바깥으로 이동시키려고 하는 경우에는 해당 명령을 무시해야 하며, + 출력도 하면 안 된다. + + + [입력] + + 첫째 줄에 지도의 세로 크기 N, 가로 크기 M (1 ≤ N, M ≤ 20), + 주사위를 놓은 곳의 좌표 x, y(0 ≤ x ≤ N-1, 0 ≤ y ≤ M-1), + 그리고 명령의 개수 K (1 ≤ K ≤ 1,000)가 주어진다. + + 둘째 줄부터 N개의 줄에 지도에 쓰여 있는 수가 북쪽부터 남쪽으로, + 각 줄은 서쪽부터 동쪽 순서대로 주어진다. + 주사위를 놓은 칸에 쓰여 있는 수는 항상 0이다. + 지도의 각 칸에 쓰여 있는 수는 10 미만의 자연수 또는 0이다. + + 마지막 줄에는 이동하는 명령이 순서대로 주어진다. + 동쪽은 1, 서쪽은 2, 북쪽은 3, 남쪽은 4로 주어진다. + + + [출력] + + 이동할 때마다 주사위의 윗 면에 쓰여 있는 수를 출력한다. + 만약 바깥으로 이동시키려고 하는 경우에는 해당 명령을 무시해야 하며, + 출력도 하면 안 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("주사위 굴리기") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("simulation,implementation") + .build(); + + problemRepository.save(problem); + } + + // 8. 목표지점 거리 + private void createTargetDistanceProblem() { + if (problemRepository.existsByTitle("목표지점 거리")) { + return; + } + + String description = """ + [문제] + + 지도가 주어지면 모든 지점에 대해서 목표지점까지의 거리를 구하여라. + 문제를 쉽게 만들기 위해 오직 가로와 세로로만 움직일 수 있다고 하자. + + [입력] + + 지도의 크기 n과 m이 주어진다. n은 세로의 크기, m은 가로의 크기다.(2 ≤ n ≤ 1000, 2 ≤ m ≤ 1000) + 다음 n개의 줄에 m개의 숫자가 주어진다. 0은 갈 수 없는 땅이고 1은 갈 수 있는 땅, 2는 목표지점이다. 입력에서 2는 단 한개이다. + + [출력] + + 각 지점에서 목표지점까지의 거리를 출력한다. + 원래 갈 수 없는 땅인 위치는 0을 출력하고, 원래 갈 수 있는 땅인 부분 중에서 도달할 수 없는 위치는 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("목표지점 거리") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("bfs,graph") + .build(); + + problemRepository.save(problem); + } + + // 9. DFS와 BFS + private void createDfsBfsProblem() { + if (problemRepository.existsByTitle("DFS와 BFS")) { + return; + } + + String description = """ + [문제] + + 그래프를 DFS로 탐색한 결과와 BFS로 탐색한 결과를 출력하는 프로그램을 작성하시오. + 단, 방문할 수 있는 정점이 여러 개인 경우에는 정점 번호가 작은 것을 먼저 방문하고, + 더 이상 방문할 수 있는 점이 없는 경우 종료한다. + 정점 번호는 1번부터 N번까지이다. + + + [입력] + + 첫째 줄에 정점의 개수 N(1 ≤ N ≤ 1,000), 간선의 개수 M(1 ≤ M ≤ 10,000), 탐색을 시작할 정점의 번호 V가 주어진다. + 다음 M개의 줄에는 간선이 연결하는 두 정점의 번호가 주어진다. + 어떤 두 정점 사이에 여러 개의 간선이 있을 수 있다. 입력으로 주어지는 간선은 양방향이다. + + + [출력] + + 첫째 줄에 DFS를 수행한 결과를, 그 다음 줄에는 BFS를 수행한 결과를 출력한다. + V부터 방문된 점을 순서대로 출력하면 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("DFS와 BFS") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("graph,dfs,bfs") + .build(); + + problemRepository.save(problem); + } + + // 10. 여행 가자 (New) + private void createTripPlanningProblem() { + if (problemRepository.existsByTitle("여행 가자")) { + return; + } + + String description = """ + [문제] + + 동혁이는 친구들과 함께 여행을 가려고 한다. 한국에는 도시가 N개 있고 임의의 두 도시 사이에 길이 있을 수도, 없을 수도 있다. + 동혁이의 여행 일정이 주어졌을 때, 이 여행 경로가 가능한 것인지 알아보자. 물론 중간에 다른 도시를 경유해서 여행을 할 수도 있다. + 예를 들어 도시가 5개 있고, A-B, B-C, A-D, B-D, E-A의 길이 있고, 동혁이의 여행 계획이 E C B C D 라면 E-A-B-C-B-C-B-D라는 여행경로를 통해 목적을 달성할 수 있다. + + 도시들의 개수와 도시들 간의 연결 여부가 주어져 있고, 동혁이의 여행 계획에 속한 도시들이 순서대로 주어졌을 때 가능한지 여부를 판별하는 프로그램을 작성하시오. + 같은 도시를 여러 번 방문하는 것도 가능하다. + + + [입력] + + 첫 줄에 도시의 수 N이 주어진다. N은 200이하이다. 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. M은 1000이하이다. + 다음 N개의 줄에는 N개의 정수가 주어진다. i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. + 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. A와 B가 연결되었으면 B와 A도 연결되어 있다. + 마지막 줄에는 여행 계획이 주어진다. 도시의 번호는 1부터 N까지 차례대로 매겨져 있다. + + + [출력] + + 첫 줄에 가능하면 YES 불가능하면 NO를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("여행 가자") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("graph,union_find,bfs") + .build(); + + problemRepository.save(problem); + } + + // ========================= + // HARD 문제들 + // ========================= + + // 11. 구슬 탈출 + private void createMarbleEscapeProblem() { + if (problemRepository.existsByTitle("구슬 탈출")) { + return; + } + + String description = """ + [문제] + + 스타트링크에서 판매하는 어린이용 장난감 중에서 가장 인기가 많은 제품은 구슬 탈출이다. + 구슬 탈출은 직사각형 보드에 빨간 구슬과 파란 구슬을 하나씩 넣은 다음, + 빨간 구슬을 구멍을 통해 빼내는 게임이다. + + 보드의 세로 크기는 N, 가로 크기는 M이고, 편의상 1×1 크기의 칸으로 나누어져 있다. + 가장 바깥 행과 열은 모두 막혀져 있고, 보드에는 구멍이 하나 있다. + 빨간 구슬과 파란 구슬의 크기는 보드에서 1×1 크기의 칸을 가득 채우는 사이즈이고, + 각각 하나씩 들어가 있다. + + 이때, 구슬을 손으로 건드릴 수는 없고, 중력을 이용해서 이리 저리 굴려야 한다. + 왼쪽으로 기울이기, 오른쪽으로 기울이기, 위쪽으로 기울이기, + 아래쪽으로 기울이기와 같은 네 가지 동작이 가능하다. + + 각각의 동작에서 공은 동시에 움직인다. + 빨간 구슬이 구멍에 빠지면 성공이지만, 파란 구슬이 구멍에 빠지면 실패이다. + 빨간 구슬과 파란 구슬이 동시에 구멍에 빠져도 실패이다. + 빨간 구슬과 파란 구슬은 동시에 같은 칸에 있을 수 없다. + 또, 빨간 구슬과 파란 구슬의 크기는 한 칸을 모두 차지한다. + 기울이는 동작을 그만하는 것은 더 이상 구슬이 움직이지 않을 때까지이다. + + 보드의 상태가 주어졌을 때, + 최소 몇 번 만에 빨간 구슬을 구멍을 통해 빼낼 수 있는지 구하는 프로그램을 작성하시오. + + + [입력] + + 첫 번째 줄에는 보드의 세로, 가로 크기를 의미하는 두 정수 N, M (3 ≤ N, M ≤ 10)이 주어진다. + 다음 N개의 줄에 보드의 모양을 나타내는 길이 M의 문자열이 주어진다. + 이 문자열은 '.', '#', 'O', 'R', 'B' 로 이루어져 있다. + '.'은 빈 칸을 의미하고, '#'은 공이 이동할 수 없는 장애물 또는 벽을 의미하며, + 'O'는 구멍의 위치를 의미한다. + 'R'은 빨간 구슬의 위치, 'B'는 파란 구슬의 위치이다. + + 입력되는 모든 보드의 가장자리에는 모두 '#'이 있다. + 구멍의 개수는 한 개이며, 빨간 구슬과 파란 구슬은 항상 1개가 주어진다. + + + [출력] + + 최소 몇 번 만에 빨간 구슬을 구멍을 통해 빼낼 수 있는지 출력한다. + 만약, 10번 이하로 움직여서 빨간 구슬을 구멍을 통해 빼낼 수 없으면 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("구슬 탈출") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,bfs,implementation") + .build(); + + problemRepository.save(problem); + } + + // 12. 마법사 상어와 복제 + private void createSharkCopyMagicProblem() { + if (problemRepository.existsByTitle("마법사 상어와 복제")) { + return; + } + + String description = """ + [문제] + + 마법사 상어는 파이어볼, 토네이도, 파이어스톰, 물복사버그, 비바라기, 블리자드 마법을 할 수 있다. + 오늘은 기존에 배운 물복사버그 마법의 상위 마법인 복제를 배웠고, + 4 × 4 크기의 격자에서 연습하려고 한다. + (r, c)는 격자의 r행 c열을 의미한다. + 격자의 가장 왼쪽 윗 칸은 (1, 1)이고, 가장 오른쪽 아랫 칸은 (4, 4)이다. + + 격자에는 물고기 M마리가 있다. + 각 물고기는 격자의 칸 하나에 들어가 있으며, 이동 방향을 가지고 있다. + 이동 방향은 8가지 방향(상하좌우, 대각선) 중 하나이다. + 마법사 상어도 연습을 위해 격자에 들어가있다. + 상어도 격자의 한 칸에 들어가있다. + 둘 이상의 물고기가 같은 칸에 있을 수도 있으며, + 마법사 상어와 물고기가 같은 칸에 있을 수도 있다. + + 상어의 마법 연습 한 번은 다음과 같은 작업이 순차적으로 이루어진다. + + 1. 상어가 모든 물고기에게 복제 마법을 시전한다. + 복제 마법은 시간이 조금 걸리기 때문에, 아래 5번에서 물고기가 복제되어 나타난다. + + 2. 모든 물고기가 한 칸 이동한다. + 상어가 있는 칸, 물고기의 냄새가 있는 칸, 격자의 범위를 벗어나는 칸으로는 이동할 수 없다. + 각 물고기는 자신이 가지고 있는 이동 방향이 이동할 수 있는 칸을 향할 때까지 + 방향을 45도 반시계 회전시킨다. + 이동할 수 있는 칸이 없으면 이동하지 않는다. + + 3. 상어가 연속해서 3칸 이동한다. + 상어는 상하좌우로 인접한 칸으로 이동할 수 있다. + 이동 중 격자를 벗어나면 그 방법은 불가능하다. + 이동 중 물고기가 있는 칸에 도착하면, 그 칸의 모든 물고기는 제거되고 냄새를 남긴다. + 가능한 이동 방법 중 제거되는 물고기가 가장 많은 방법을 선택하며, + 동일하다면 사전순으로 가장 앞서는 방법을 선택한다. + + 4. 두 번 전 연습에서 생긴 물고기의 냄새가 격자에서 사라진다. + + 5. 1에서 사용된 복제 마법이 완료되어 복제된 물고기가 생성된다. + + + [입력] + + 첫째 줄에 물고기의 수 M, 연습 횟수 S가 주어진다. + 다음 M개의 줄에는 물고기의 정보 (fx, fy, d)가 주어지며, + d는 1~8 방향을 의미한다. (←, ↖, ↑, ↗, →, ↘, ↓, ↙) + + 마지막 줄에는 상어의 위치 (sx, sy)가 주어진다. + + 격자 위에 있는 물고기의 수가 항상 1,000,000 이하인 입력만 주어진다. + + + [출력] + + S번의 연습을 마친 후 격자에 있는 물고기의 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("마법사 상어와 복제") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,backtracking,implementation") + .build(); + + problemRepository.save(problem); + } + + // 13. 비슷한 단어 + private void createSimilarWordsProblem() { + if (problemRepository.existsByTitle("비슷한 단어")) { + return; + } + + String description = """ + [문제] + + N개의 영단어들이 주어졌을 때, 가장 비슷한 두 단어를 구해내는 프로그램을 작성하시오. + + 두 단어의 비슷한 정도는 두 단어의 접두사의 길이로 측정한다. + 접두사란 두 단어의 앞부분에서 공통적으로 나타나는 부분문자열을 말한다. + 즉, 두 단어의 앞에서부터 M개의 글자들이 같으면서 M이 최대인 경우를 구하는 것이다. + "AHEHHEH", "AHAHEH"의 접두사는 "AH"가 되고, "AB", "CD"의 접두사는 ""(길이가 0)이 된다. + + 접두사의 길이가 최대인 경우가 여러 개일 때에는 입력되는 순서대로 제일 앞쪽에 있는 단어를 답으로 한다. + 즉, 답으로 S라는 문자열과 T라는 문자열을 출력한다고 했을 때, + 우선 S가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력하고, + 그런 경우도 여러 개 있을 때에는 그 중에서 T가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력한다. + + + [입력] + + 첫째 줄에 N(2 ≤ N ≤ 20,000)이 주어진다. + 다음 N개의 줄에 알파벳 소문자로만 이루어진 길이 100자 이하의 서로 다른 영단어가 주어진다. + + + [출력] + + 첫째 줄에 S를, 둘째 줄에 T를 출력한다. + 단, 이 두 단어는 서로 달라야 한다. 즉, 가장 비슷한 두 단어를 구할 때 같은 단어는 제외하는 것이다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("비슷한 단어") + .difficulty(Difficulty.HARD) + .description(description) + .tags("string,sorting") + .build(); + + problemRepository.save(problem); + } + + // 14. 보석 도둑 + private void createJewelThiefProblem() { + if (problemRepository.existsByTitle("보석 도둑")) { + return; + } + + String description = """ + [문제] + + 세계적인 도둑 상덕이는 보석점을 털기로 결심했다. + 상덕이가 털 보석점에는 보석이 총 N개 있다. 각 보석은 무게 Mi와 가격 Vi를 가지고 있다. + 상덕이는 가방을 K개 가지고 있고, 각 가방에 담을 수 있는 최대 무게는 Ci이다. + 가방에는 최대 한 개의 보석만 넣을 수 있다. + 상덕이가 훔칠 수 있는 보석의 최대 가격을 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N과 K가 주어진다. (1 ≤ N, K ≤ 300,000) + 다음 N개 줄에는 각 보석의 정보 Mi와 Vi가 주어진다. (0 ≤ Mi, Vi ≤ 1,000,000) + 다음 K개 줄에는 가방에 담을 수 있는 최대 무게 Ci가 주어진다. (1 ≤ Ci ≤ 100,000,000) + 모든 숫자는 양의 정수이다. + + + [출력] + + 첫째 줄에 상덕이가 훔칠 수 있는 보석 가격의 합의 최댓값을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("보석 도둑") + .difficulty(Difficulty.HARD) + .description(description) + .tags("greedy,sorting,priority_queue") + .build(); + + problemRepository.save(problem); + } + + // 15. 화성 탐사 (New) + private void createMarsExplorationProblem() { + if (problemRepository.existsByTitle("화성 탐사")) { + return; + } + + String description = """ + [문제] + + NASA에서는 화성 탐사를 위해 화성에 무선 조종 로봇을 보냈다. 실제 화성의 모습은 굉장히 복잡하지만, + 로봇의 메모리가 얼마 안 되기 때문에 지형을 N×M 배열로 단순화 하여 생각하기로 한다. + 지형의 고저차의 특성상, 로봇은 움직일 때 배열에서 왼쪽, 오른쪽, 아래쪽으로 이동할 수 있지만, 위쪽으로는 이동할 수 없다. + 또한 한 번 탐사한 지역(배열에서 하나의 칸)은 탐사하지 않기로 한다. + + 각각의 지역은 탐사 가치가 있는데, 로봇을 배열의 왼쪽 위 (1, 1)에서 출발시켜 오른쪽 아래 (N, M)으로 보내려고 한다. + 이때, 위의 조건을 만족하면서, 탐사한 지역들의 가치의 합이 최대가 되도록 하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N, M(1≤N, M≤1,000)이 주어진다. 다음 N개의 줄에는 M개의 수로 배열이 주어진다. + 배열의 각 수는 절댓값이 100을 넘지 않는 정수이다. 이 값은 그 지역의 가치를 나타낸다. + + + [출력] + + 첫째 줄에 최대 가치의 합을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("화성 탐사") + .difficulty(Difficulty.HARD) + .description(description) + .tags("dp") + .build(); + + problemRepository.save(problem); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java new file mode 100644 index 0000000..bec9308 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java @@ -0,0 +1,148 @@ +package com.example.skillboost.codingtest.judge; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiJudge { + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.model}") + private String model; + + private final ObjectMapper objectMapper; + + public SubmissionResultDto grade(CodingProblem problem, String userCode, String language) { + + String prompt = createPrompt(problem, userCode, language); + String apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/" + + model + ":generateContent?key=" + apiKey; + + try { + RestTemplate restTemplate = new RestTemplate(); + + Map body = Map.of( + "contents", List.of( + Map.of("parts", List.of( + Map.of("text", prompt) + )) + ) + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.exchange(apiUrl, HttpMethod.POST, entity, String.class); + + return parseResponse(response.getBody(), problem.getTestCases().size()); + + } catch (Exception e) { + log.error("AI 채점 실패", e); + return SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("AI 서버와 연결할 수 없습니다.") + .aiFeedback("일시적인 오류입니다. 잠시 후 다시 시도해주세요.") + .build(); + } + } + + private SubmissionResultDto parseResponse(String jsonResponse, int totalTestCases) { + try { + JsonNode root = objectMapper.readTree(jsonResponse); + + String rawText = null; + JsonNode cand = root.path("candidates").get(0); + + if (cand.has("output_text")) { + rawText = cand.path("output_text").asText(); + } + + if (rawText == null || rawText.isEmpty()) { + JsonNode parts = cand.path("content").path("parts"); + if (parts.isArray() && parts.size() > 0) { + rawText = parts.get(0).path("text").asText(); + } + } + + if (rawText == null || rawText.isEmpty()) { + throw new RuntimeException("AI 응답 파싱 실패"); + } + + rawText = rawText.replace("```json", "") + .replace("```", "") + .trim(); + + JsonNode resultNode = objectMapper.readTree(rawText); + + String status = resultNode.path("status").asText("WA"); + int score = resultNode.path("score").asInt(0); + String feedback = resultNode.path("feedback").asText("피드백 없음"); + + int passedCount = (score == 100) + ? totalTestCases + : (int) Math.round(totalTestCases * (score / 100.0)); + + return SubmissionResultDto.builder() + .status(status) + .score(score) + .passedCount(passedCount) + .totalCount(totalTestCases) + .message(score == 100 ? "정답입니다! 🎉" : "오답입니다.") + .aiFeedback(feedback) + .build(); + + } catch (Exception e) { + log.error("AI 응답 파싱 실패", e); + return SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("채점 오류") + .aiFeedback("AI 응답 분석 실패") + .build(); + } + } + + private String createPrompt(CodingProblem problem, String userCode, String language) { + return """ + You are a strict Algorithm Coding Test Judge. + + [PROBLEM TITLE]: %s + [PROBLEM DESCRIPTION]: %s + + [USER CODE - %s]: + %s + + Return ONLY pure JSON (no extra text): + + { + "status": "AC" or "WA", + "score": 0~100, + "feedback": "한국어 피드백" + } + """.formatted( + problem.getTitle(), + problem.getDescription(), + language, + userCode + ); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java b/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java new file mode 100644 index 0000000..3e4bd72 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java @@ -0,0 +1,185 @@ +package com.example.skillboost.codingtest.judge; + +import org.springframework.stereotype.Component; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Component +public class JudgeClient { + + private static final String TEMP_DIR = System.getProperty("java.io.tmpdir"); + private static final int TIMEOUT_SECONDS = 2; // 시간 제한 + + /** + * CodingTestService에서 호출하는 메서드 + * 소스코드, 언어, 입력값을 받아 실행 결과를 반환 + */ + public JudgeResult execute(String sourceCode, String language, String input) { + String uniqueId = UUID.randomUUID().toString(); + File sourceFile = createSourceFile(language, sourceCode, uniqueId); + + if (sourceFile == null) { + return JudgeResult.runtimeError("Internal Error: 파일 생성 실패"); + } + + try { + // 1. 컴파일 (Java, C++ 만) + if (language.equalsIgnoreCase("java") || language.equalsIgnoreCase("cpp")) { + String compileError = compileCode(language, sourceFile); + if (compileError != null) { + return JudgeResult.compileError(compileError); + } + } + + // 2. 실행 + return runCode(language, sourceFile, input); + + } catch (Exception e) { + return JudgeResult.runtimeError(e.getMessage()); + } finally { + cleanup(sourceFile); + } + } + + // --- 내부 헬퍼 메서드 --- + + private File createSourceFile(String language, String code, String uniqueId) { + try { + String fileName; + // 언어별 파일 확장자 및 클래스명 처리 + if (language.equalsIgnoreCase("java")) { + fileName = "Main.java"; // Java는 Main 클래스 강제 + } else if (language.equalsIgnoreCase("cpp")) { + fileName = uniqueId + ".cpp"; + } else { // python + fileName = uniqueId + ".py"; + } + + // 폴더 분리 (동시 실행 충돌 방지) + Path dirPath = Path.of(TEMP_DIR, "judge_" + uniqueId); + Files.createDirectories(dirPath); + + File file = dirPath.resolve(fileName).toFile(); + try (FileWriter writer = new FileWriter(file)) { + writer.write(code); + } + return file; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private String compileCode(String language, File sourceFile) { + ProcessBuilder pb; + if (language.equalsIgnoreCase("java")) { + // javac -encoding UTF-8 Main.java + pb = new ProcessBuilder("javac", "-encoding", "UTF-8", sourceFile.getAbsolutePath()); + } else { + // g++ -o output source.cpp + String outputPath = sourceFile.getParent() + File.separator + "output"; + // Windows인 경우 .exe 붙임 + if (System.getProperty("os.name").toLowerCase().contains("win")) { + outputPath += ".exe"; + } + pb = new ProcessBuilder("g++", "-o", outputPath, sourceFile.getAbsolutePath()); + } + + pb.directory(sourceFile.getParentFile()); + pb.redirectErrorStream(true); + + try { + Process process = pb.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroy(); + return "Time Limit Exceeded during Compilation"; + } + if (process.exitValue() != 0) { + return readProcessOutput(process.getInputStream()); + } + return null; // 컴파일 성공 + } catch (Exception e) { + return e.getMessage(); + } + } + + private JudgeResult runCode(String language, File sourceFile, String input) { + ProcessBuilder pb; + long startTime = System.currentTimeMillis(); + + try { + if (language.equalsIgnoreCase("java")) { + pb = new ProcessBuilder("java", "-cp", ".", "Main"); + } else if (language.equalsIgnoreCase("python")) { + pb = new ProcessBuilder("python", sourceFile.getName()); // python3 라면 "python3" + } else { // cpp + String cmd = System.getProperty("os.name").toLowerCase().contains("win") ? "output.exe" : "./output"; + pb = new ProcessBuilder(cmd); + } + + pb.directory(sourceFile.getParentFile()); + Process process = pb.start(); + + // 입력값 주입 + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()))) { + writer.write(input); + writer.flush(); + } + + // 실행 대기 + boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroy(); + return JudgeResult.builder().statusId(5).message("Time Limit Exceeded").build(); + } + + // 결과 읽기 + String output = readProcessOutput(process.getInputStream()); + String error = readProcessOutput(process.getErrorStream()); + double duration = (System.currentTimeMillis() - startTime) / 1000.0; + + if (process.exitValue() != 0) { + return JudgeResult.runtimeError(error.isEmpty() ? "Runtime Error" : error); + } + + // 로컬 실행 성공 (정답 여부는 Service에서 판단하므로 여기선 성공 상태 리턴) + // JudgeResult.accepted()는 statusId=3을 반환하여 Service가 정답 비교를 진행하게 함 + return JudgeResult.accepted(output, duration); + + } catch (Exception e) { + return JudgeResult.runtimeError(e.getMessage()); + } + } + + private String readProcessOutput(InputStream inputStream) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + } + return sb.toString().trim(); + } + + private void cleanup(File sourceFile) { + try { + if (sourceFile == null) return; + File dir = sourceFile.getParentFile(); + if (dir != null && dir.exists()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) f.delete(); + } + dir.delete(); + } + } catch (Exception e) { + // ignore + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java b/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java new file mode 100644 index 0000000..c09f6cf --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java @@ -0,0 +1,55 @@ +package com.example.skillboost.codingtest.judge; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JudgeResult { + // Judge0 표준 상태 코드 (3: Accepted, 4: Wrong Answer, 5: Time Limit, 6: Compilation Error, 11: Runtime Error) + private int statusId; + + private String stdout; // 표준 출력 결과 + private String stderr; // 에러 메시지 + private String message; // 설명 + private double time; // 실행 시간 + private long memory; // 메모리 사용량 + + public static JudgeResult accepted(String output, double time) { + return JudgeResult.builder() + .statusId(3) // Accepted + .stdout(output) + .time(time) + .message("Accepted") + .build(); + } + + public static JudgeResult wrongAnswer(String output, double time) { + return JudgeResult.builder() + .statusId(4) // Wrong Answer + .stdout(output) + .time(time) + .message("Wrong Answer") + .build(); + } + + public static JudgeResult compileError(String errorMessage) { + return JudgeResult.builder() + .statusId(6) // Compilation Error + .stderr(errorMessage) + .message("Compilation Error") + .build(); + } + + public static JudgeResult runtimeError(String errorMessage) { + return JudgeResult.builder() + .statusId(11) // Runtime Error + .stderr(errorMessage) + .message("Runtime Error") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java new file mode 100644 index 0000000..fe01138 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java @@ -0,0 +1,16 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; // ★ 이 import가 꼭 있어야 합니다 +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CodingProblemRepository extends JpaRepository { + + // 제목으로 문제 찾기 (중복 데이터 생성 방지용) + boolean existsByTitle(String title); + + // ★ [핵심] 이 줄이 없어서 에러가 난 것입니다. 추가해주세요! + List findAllByDifficulty(Difficulty difficulty); +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java new file mode 100644 index 0000000..03d5c06 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java @@ -0,0 +1,7 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingSubmission; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CodingSubmissionRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java new file mode 100644 index 0000000..8ce37b1 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java @@ -0,0 +1,15 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.CodingTestCase; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CodingTestCaseRepository extends JpaRepository { + + List findByProblem(CodingProblem problem); + + // 또는 problemId로 바로 찾고 싶으면 + List findByProblem_Id(Long problemId); +} diff --git a/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java new file mode 100644 index 0000000..d5390d8 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java @@ -0,0 +1,8 @@ +package com.example.skillboost.codingtest.service; + +import org.springframework.stereotype.Service; + +@Service +public class CodingTestService { + // TODO: 현재 사용하지 않음. 나중에 제출 기록 저장 기능 추가할 때 구현. +} diff --git a/src/main/java/com/example/skillboost/codingtest/service/GradingService.java b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java new file mode 100644 index 0000000..149ddfc --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java @@ -0,0 +1,29 @@ +package com.example.skillboost.codingtest.service; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.dto.SubmissionRequestDto; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.example.skillboost.codingtest.judge.GeminiJudge; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GradingService { + + private final CodingProblemRepository problemRepository; + private final GeminiJudge judge; + + public SubmissionResultDto grade(SubmissionRequestDto request) { + + CodingProblem problem = problemRepository.findById(request.getProblemId()) + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); + + // DB에 제출 저장 같은 건 나중에 하고, + // 일단 AI 채점만 연결 + return judge.grade(problem, request.getCode(), request.getLanguage()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c22d980..49f4f6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,27 @@ spring: + config: + import: optional:classpath:application-secret.yml application: - name: skill-boost \ No newline at end of file + name: skill-boost + + datasource: + url: jdbc:mysql://localhost:3306/mydatabase?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: myuser + password: secret + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show-sql: true + +server: + port: 8080 + +gemini: + api: + key: AIzaSyCQNb1WilrVsCKdZUhfIjk0VdFvHklJyeQ + model: gemini-2.5-flash \ No newline at end of file From a38e274015422f4b5f3248812d6662bd5a0dfa79 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Thu, 27 Nov 2025 23:03:14 +0900 Subject: [PATCH 33/39] fix: coding test issue --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 49f4f6d..61dcf5a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: AIzaSyCQNb1WilrVsCKdZUhfIjk0VdFvHklJyeQ + key: ## model: gemini-2.5-flash \ No newline at end of file From 111096b45e2d0e9f6847f8f26526279639094104 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 27 Nov 2025 23:10:49 +0900 Subject: [PATCH 34/39] =?UTF-8?q?application-test.yml=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 105917c..66363b4 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,9 +1,9 @@ spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL - driver-class-name: + driver-class-name: org.h2.Driver username: sa - password: org.h2.Driver + password: jpa: hibernate: From b25a4c843316e63396ef360a301004b1632598c2 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Thu, 27 Nov 2025 23:10:59 +0900 Subject: [PATCH 35/39] =?UTF-8?q?CI.yml=20=ED=8C=8C=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CI.yml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6670844..fd1d4e0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,6 +1,7 @@ name: pull request CI on: + workflow_dispatch: pull_request: branches: - main @@ -23,16 +24,6 @@ jobs: java-version: '21' cache: 'gradle' - # 도커 컴포즈 - - name: Start docker-compose - run: | - docker-compose up -d - - # MySQL이 ready 될 때까지 대기 (최대 30초) - timeout 30 bash -c 'until docker-compose exec -T mysql mysqladmin ping -h localhost --silent; do sleep 1; done' - - echo "MySQL is ready!" - # Gradle wrapper 실행 권한 부여 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -52,8 +43,3 @@ jobs: path: | build/reports/tests/test/ build/test-results/test/ - - # 도커 컴포즈 종료 - - name: Docker Compose Down - if: always() - run: docker-compose down -v \ No newline at end of file From 65bba8abacae05ce6291e48ffa4055bb5acce95b Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 11:22:24 +0900 Subject: [PATCH 36/39] fix: add docker-compose installation step for CI --- .github/workflows/CI.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6670844..131493f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,6 +23,13 @@ jobs: java-version: '21' cache: 'gradle' + # docker-compose(v1) 설치 스텝 + - name: Install docker-compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" \ + -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + # 도커 컴포즈 - name: Start docker-compose run: | From aa5dfe79c314ef4a88897a9a1461297b89049722 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 28 Nov 2025 12:59:16 +0900 Subject: [PATCH 37/39] =?UTF-8?q?setting:=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + compose.yaml | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 710349c..bcf0702 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' diff --git a/compose.yaml b/compose.yaml index 4d2047e..49ee895 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,4 +7,9 @@ services: - 'MYSQL_ROOT_PASSWORD=verysecret' - 'MYSQL_USER=myuser' ports: - - '3306' + - '33006:3306' + + redis: + image: 'redis:latest' + ports: + - '6379:6379' \ No newline at end of file From a47e779f6b8562801d888c15d311b7c0161beee0 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 28 Nov 2025 13:11:52 +0900 Subject: [PATCH 38/39] =?UTF-8?q?setting:=20redis=20=EB=A7=A4=EB=8B=88?= =?UTF-8?q?=ED=8E=98=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k8s/app.yaml | 4 +++ k8s/redis.yaml | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 k8s/redis.yaml diff --git a/k8s/app.yaml b/k8s/app.yaml index f28a1aa..5dc63b5 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -27,6 +27,10 @@ spec: env: - name: SPRING_PROFILES_ACTIVE value: "prod" + - name: SPRING_REDIS_HOST + value: "redis.redis.svc.cluster.local" + - name: SPRING_REDIS_PORT + value: "6379" envFrom: - secretRef: name: db-secret diff --git a/k8s/redis.yaml b/k8s/redis.yaml new file mode 100644 index 0000000..d9096d6 --- /dev/null +++ b/k8s/redis.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: redis +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-config + namespace: redis +data: + redis.conf: | + bind 0.0.0.0 + port 6379 + protected-mode yes + appendonly yes + appendfsync everysec + maxmemory 512mb + maxmemory-policy volatile-lru +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-data + namespace: redis +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: local-path +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: redis +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7.2 + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + ports: + - name: redis + containerPort: 6379 + volumeMounts: + - name: redis-config + mountPath: /usr/local/etc/redis + - name: redis-data + mountPath: /data + volumes: + - name: redis-config + configMap: + name: redis-config + items: + - key: redis.conf + path: redis.conf + - name: redis-data + persistentVolumeClaim: + claimName: redis-data +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: redis +spec: + type: ClusterIP + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP From b8ef268f18158b8faed045d12135efd287d5d29f Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 16:21:59 +0900 Subject: [PATCH 39/39] feat: add temporary AI code review using Gemini --- .../controller/CodeReviewController.java | 25 +++ .../codereview/dto/CodeReviewRequest.java | 31 +++ .../codereview/dto/CodeReviewResponse.java | 34 +++ .../llm/GeminiCodeReviewClient.java | 193 ++++++++++++++++++ .../codereview/service/CodeReviewService.java | 9 + .../service/CodeReviewServiceImpl.java | 27 +++ src/main/resources/application.yml | 4 +- 7 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java create mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java create mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java create mode 100644 src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java create mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java create mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java new file mode 100644 index 0000000..d1dc99f --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java @@ -0,0 +1,25 @@ +package com.example.skillboost.codereview.controller; + +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import com.example.skillboost.codereview.service.CodeReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +@CrossOrigin(origins = "http://localhost:3000") +@RestController +@RequestMapping("/api/review") +@RequiredArgsConstructor +public class CodeReviewController { + + private final CodeReviewService codeReviewService; + + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public CodeReviewResponse review(@RequestBody CodeReviewRequest request) { + return codeReviewService.review(request); + } +} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java new file mode 100644 index 0000000..60eb4ac --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java @@ -0,0 +1,31 @@ +package com.example.skillboost.codereview.dto; + +public class CodeReviewRequest { + + private String code; + private String comment; + + public CodeReviewRequest() { + } + + public CodeReviewRequest(String code, String comment) { + this.code = code; + this.comment = comment; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java new file mode 100644 index 0000000..5440b19 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java @@ -0,0 +1,34 @@ +package com.example.skillboost.codereview.dto; + +import java.util.ArrayList; +import java.util.List; + +public class CodeReviewResponse { + + private String review; + private List questions = new ArrayList<>(); + + public CodeReviewResponse() { + } + + public CodeReviewResponse(String review, List questions) { + this.review = review; + this.questions = questions; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } + + public List getQuestions() { + return questions; + } + + public void setQuestions(List questions) { + this.questions = questions; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java new file mode 100644 index 0000000..af2b4a3 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java @@ -0,0 +1,193 @@ +package com.example.skillboost.codereview.client; + +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +@Component +public class GeminiCodeReviewClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String apiKey; + private final String model; + + public GeminiCodeReviewClient( + @Value("${gemini.api.key}") String apiKey, + @Value("${gemini.model}") String model + ) { + this.apiKey = apiKey; + this.model = model; + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + public CodeReviewResponse requestReview(String code, String comment) { + try { + String url = "https://generativelanguage.googleapis.com/v1beta/models/" + + model + ":generateContent?key=" + apiKey; + + String prompt = buildPrompt(code, comment); + + Map textPart = new HashMap<>(); + textPart.put("text", prompt); + + Map content = new HashMap<>(); + content.put("parts", Collections.singletonList(textPart)); + + Map requestBody = new HashMap<>(); + requestBody.put("contents", Collections.singletonList(content)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + String body = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful() || body == null) { + CodeReviewResponse fallback = new CodeReviewResponse(); + fallback.setReview("AI 코드 리뷰 요청에 실패했습니다. 상태코드: " + response.getStatusCode()); + fallback.setQuestions(Collections.emptyList()); + return fallback; + } + + return parseGeminiResponse(body); + + } catch (Exception e) { + CodeReviewResponse fallback = new CodeReviewResponse(); + fallback.setReview("AI 코드 리뷰 중 오류가 발생했습니다: " + e.getMessage()); + fallback.setQuestions(Collections.emptyList()); + return fallback; + } + } + + /** + * 리뷰 + 질문을 "간결하고 핵심적"이고 "□ 포맷", "1. 2. 질문 구조"로 내보내도록 만드는 프롬프트 + */ + private String buildPrompt(String code, String comment) { + String userRequirement = (comment != null && !comment.trim().isEmpty()) + ? comment.trim() + : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; + + return """ + 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. + 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. + + 🔒 출력 형식 규칙 + - JSON만 출력 (바깥에 설명 절대 금지) + - 마크다운 금지(**, ```, # 등) + - review 항목은: + - 모든 줄을 '□ ' 로 시작 + - 한 줄은 핵심 한 문장만 + - 항목 사이에는 빈 줄(\\n\\n)을 넣기 + - questions 항목은: + - 배열 형태 + - 각 질문은 한 문장 + - 번호(1. 2.)는 넣지 말 것 (번호는 프론트에서 자동 생성됨) + + JSON 예시: + + { + "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", + "questions": [ + "이 코드의 시간 복잡도는 왜 O(N)인가요?", + "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" + ] + } + + 사용자가 요청한 요구사항: + %s + + 리뷰할 코드: + %s + """.formatted(userRequirement, code); + } + + /** + * Gemini 응답(JSON 스트링)을 CodeReviewResponse로 변환 + */ + private CodeReviewResponse parseGeminiResponse(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + + JsonNode candidates = root.path("candidates"); + if (!candidates.isArray() || candidates.isEmpty()) { + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview("AI 응답이 비어 있습니다."); + resp.setQuestions(Collections.emptyList()); + return resp; + } + + JsonNode textNode = candidates.get(0) + .path("content") + .path("parts") + .get(0) + .path("text"); + + String rawText = textNode.asText(""); + if (rawText.isEmpty()) { + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview("AI 응답 텍스트를 찾지 못했습니다."); + resp.setQuestions(Collections.emptyList()); + return resp; + } + + // ```json ... ``` 형태 제거 + String cleaned = stripCodeFence(rawText); + + // JSON 파싱 + try { + JsonNode json = objectMapper.readTree(cleaned); + + String review = json.path("review").asText(""); + if (review.isEmpty()) review = cleaned; + + List questions = new ArrayList<>(); + JsonNode qNode = json.path("questions"); + if (qNode.isArray()) { + for (JsonNode q : qNode) questions.add(q.asText()); + } + + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview(review); + resp.setQuestions(questions); + return resp; + + } catch (Exception e) { + // JSON 파싱 실패 시 그대로 리뷰로 전달 + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview(cleaned); + resp.setQuestions(Collections.emptyList()); + return resp; + } + } + + /** + * ```json + * {...} + * ``` + * 같은 코드블럭 제거 + */ + private String stripCodeFence(String text) { + if (text == null) return ""; + String trimmed = text.trim(); + + if (!trimmed.startsWith("```")) return trimmed; + + int firstNewline = trimmed.indexOf('\n'); + int lastFence = trimmed.lastIndexOf("```"); + + if (firstNewline != -1 && lastFence != -1 && lastFence > firstNewline) { + return trimmed.substring(firstNewline + 1, lastFence).trim(); + } + + return trimmed; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java new file mode 100644 index 0000000..c8eb152 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java @@ -0,0 +1,9 @@ +package com.example.skillboost.codereview.service; + +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; + +public interface CodeReviewService { + + CodeReviewResponse review(CodeReviewRequest request); +} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java new file mode 100644 index 0000000..6635aed --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java @@ -0,0 +1,27 @@ +package com.example.skillboost.codereview.service; + +import com.example.skillboost.codereview.client.GeminiCodeReviewClient; +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class CodeReviewServiceImpl implements CodeReviewService { + + private final GeminiCodeReviewClient geminiCodeReviewClient; + + @Override + public CodeReviewResponse review(CodeReviewRequest request) { + if (request == null || !StringUtils.hasText(request.getCode())) { + throw new IllegalArgumentException("코드가 비어 있습니다."); + } + + String code = request.getCode(); + String comment = request.getComment(); + + return geminiCodeReviewClient.requestReview(code, comment); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 61dcf5a..35e1022 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: ## - model: gemini-2.5-flash \ No newline at end of file + key: # + model: gemini-2.5-flash