From 8b8419f3f2bdf78b489b07f011a50cfcfb9382c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=84=B1=EC=A3=BC=20Hong=20Seong=20Ju?= <86274319+ghdtjdwn@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:26:08 +0900 Subject: [PATCH 1/2] ci: add hermetic backend test gate --- .github/workflows/ci.yml | 54 ++++++++++++ build.gradle | 3 +- .../2026-backend-ci-test-environment.md | 84 +++++++++++++++++++ .../chat/service/MacroWebhookService.java | 2 + .../unithon/UnithonApplicationTests.java | 2 + src/test/resources/application-test.yml | 33 ++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/troubleshooting/2026-backend-ci-test-environment.md create mode 100644 src/test/resources/application-test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9baf9624 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: Backend CI + +on: + pull_request: + branches: + - master + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: backend-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Java 21 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-provider: basic + validate-wrappers: true + + - name: Build and run test suite + run: ./gradlew build --no-daemon --stacktrace + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backend-test-reports-${{ github.run_id }} + path: | + build/reports/tests/test + build/test-results/test + if-no-files-found: warn + retention-days: 7 diff --git a/build.gradle b/build.gradle index 3dc8ccb7..cf761c02 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,7 @@ dependencies { // === Test === testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // === GCP === @@ -71,4 +72,4 @@ dependencies { tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() -} \ No newline at end of file +} diff --git a/docs/troubleshooting/2026-backend-ci-test-environment.md b/docs/troubleshooting/2026-backend-ci-test-environment.md new file mode 100644 index 00000000..61f2c16f --- /dev/null +++ b/docs/troubleshooting/2026-backend-ci-test-environment.md @@ -0,0 +1,84 @@ +# Backend 테스트가 로컬 MySQL에 의존한 문제 + +- 발생일: 2026-07-15 +- 범위: `UNITHON24/Backend` 전체 Gradle 테스트와 pull request 검증 +- 상태: 로컬 해결, CI 검증 대기 +- 영향: Macro 주문 토큰 회귀 테스트는 단독 실행할 수 있었지만, 저장소 전체 테스트를 원격에서 + 지속적으로 검증하는 gate가 없었다. + +## 기대와 실제 + +Macro 주문 전달에 installation token을 추가한 뒤 `./gradlew test --no-daemon`이 개발자 장비와 +GitHub Actions에서 별도 비밀이나 외부 서비스 없이 반복 실행되기를 기대했다. + +최종 감사에서 저장소에 GitHub Actions workflow가 없음을 확인했다. 전체 테스트를 로컬에서 +실행하자 `MacroWebhookServiceTest`는 통과했지만 `UnithonApplicationTests.contextLoads()`가 +`localhost:3306` MySQL 연결을 시도해 실패했다. test profile로 DB를 격리한 다음에는 토큰 +변경 때 추가한 두 번째 생성자 때문에 Spring이 `MacroWebhookService`의 주입 생성자를 고르지 +못하는 wiring 오류도 이어서 드러났다. + +## 재현과 증거 + +```sh +./gradlew test --no-daemon +``` + +- 결과: 2개 테스트 중 context smoke test 1개 실패 +- 예외 흐름: `JDBCConnectionException` → MySQL `CommunicationsException` → + `java.net.ConnectException` +- DB 격리 뒤 예외: `MacroWebhookService`의 `NoSuchMethodException`과 "No default constructor" +- `application.yml`: MySQL, Google STT/TTS와 외부 자격증명을 운영 기본값으로 사용 +- 기존 workflow: 없음 + +따라서 원인은 Spring context smoke test가 운영 인프라 설정을 그대로 상속한 점과, 생성자가 +둘인 service에서 운영 주입 생성자를 명시하지 않은 점이었다. 토큰 단위 테스트는 service를 +직접 생성하므로 두 문제를 모두 드러내지 못했다. + +## 검토한 대안 + +- GitHub Actions에서 MySQL service container와 가짜 cloud 설정 사용: 실제 DB 엔진과 가장 + 가깝지만 context smoke test마다 컨테이너 시간과 실패 지점이 늘어난다. +- Testcontainers로 MySQL 실행: 운영 일치도와 격리는 좋지만 현재 두 개뿐인 테스트에 Docker + 의존성과 초기화 비용을 추가한다. +- context smoke test 제거: 빌드 성공만 확인하고 Spring wiring 회귀를 놓치므로 제외했다. +- H2의 MySQL compatibility mode를 쓰는 명시적 test profile: 빠르고 비밀이 없으며 현재 + entity, schema와 seed 초기화를 함께 검증할 수 있어 선택했다. + +## 해결 + +1. test runtime에 H2를 추가했다. +2. `application-test.yml`에서 in-memory H2를 MySQL mode로 실행하고 STT/TTS를 비활성화했다. +3. context smoke test에 `test` profile을 명시해 운영 MySQL과 cloud credential을 요구하지 + 않게 했다. +4. 생성자가 둘인 `MacroWebhookService`의 운영 생성자를 Spring 주입 대상으로 명시했다. +5. Java 21, Gradle wrapper validation, 전체 build·테스트와 실패 report 업로드를 포함한 + Backend CI를 + pull request와 `master` push에 추가했다. +6. 외부 action은 현재 release의 전체 commit SHA로 고정하고 `contents: read`만 허용했다. + +## 검증 + +로컬에서 다음 결과를 확인했다. + +- `./gradlew clean test --no-daemon`: 2개 테스트, 실패·skip 없이 성공 +- `./gradlew test --no-daemon --rerun-tasks`: cache를 쓰지 않은 재실행 성공 +- `./gradlew clean build --no-daemon --stacktrace`: packaging을 포함한 전체 build 성공 +- workflow와 test profile YAML parse 성공 + +pull request와 병합 뒤 `master` Backend CI, secret scan 결과를 확인한 뒤 상태를 확정한다. + +## 회귀 방지와 남은 위험 + +모든 pull request와 `master` push는 같은 Gradle suite를 실행한다. 실패한 test report는 7일간 +artifact로 남겨 원인을 확인할 수 있다. + +H2 compatibility mode는 MySQL의 collation, transaction, index와 SQL dialect를 완전히 +재현하지 않는다. 데이터 계층이 커지면 Testcontainers 기반 MySQL integration test를 별도 +job으로 추가한다. Google STT/TTS와 실제 자격증명 연동도 이 smoke test의 검증 범위가 아니다. + +## 인터뷰에서 설명할 질문 + +- 단위 테스트는 통과했는데 왜 저장소 전체 gate는 실패했는가? +- H2 test profile과 MySQL service container 사이에서 무엇을 기준으로 선택했는가? +- cloud client를 test profile에서 끄는 것이 어떤 회귀를 잡고 놓치는가? +- 테스트가 늘어날 때 Testcontainers로 전환할 기준은 무엇인가? diff --git a/src/main/java/com/example/unithon/domain/chat/service/MacroWebhookService.java b/src/main/java/com/example/unithon/domain/chat/service/MacroWebhookService.java index 6743253b..24a50e14 100644 --- a/src/main/java/com/example/unithon/domain/chat/service/MacroWebhookService.java +++ b/src/main/java/com/example/unithon/domain/chat/service/MacroWebhookService.java @@ -2,6 +2,7 @@ import com.example.unithon.domain.chat.dto.MacroOrderData; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; @@ -16,6 +17,7 @@ public class MacroWebhookService { private final String macroWebhookToken; private final RestTemplate restTemplate; + @Autowired public MacroWebhookService( @Value("${macro.webhook.url:http://localhost:9999/api/orders}") String macroWebhookUrl, @Value("${macro.webhook.token:}") String macroWebhookToken, diff --git a/src/test/java/com/example/unithon/UnithonApplicationTests.java b/src/test/java/com/example/unithon/UnithonApplicationTests.java index b6d2f08c..424814b7 100644 --- a/src/test/java/com/example/unithon/UnithonApplicationTests.java +++ b/src/test/java/com/example/unithon/UnithonApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class UnithonApplicationTests { @Test diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..b6932707 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,33 @@ +spring: + datasource: + url: jdbc:h2:mem:unithon;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + sql: + init: + mode: always + +google: + cloud: + credentials: + encoded-key: test-only + +gemini: + api: + key: test-only + +feature: + stt: false + tts: false + +logging: + file: + name: build/test-logs/application.log From 5eac83fbc0ead54e5e272866d6c37d24afb90eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=84=B1=EC=A3=BC=20Hong=20Seong=20Ju?= <86274319+ghdtjdwn@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:28:42 +0900 Subject: [PATCH 2/2] docs: record backend pull request CI --- docs/troubleshooting/2026-backend-ci-test-environment.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting/2026-backend-ci-test-environment.md b/docs/troubleshooting/2026-backend-ci-test-environment.md index 61f2c16f..e73e82df 100644 --- a/docs/troubleshooting/2026-backend-ci-test-environment.md +++ b/docs/troubleshooting/2026-backend-ci-test-environment.md @@ -2,7 +2,7 @@ - 발생일: 2026-07-15 - 범위: `UNITHON24/Backend` 전체 Gradle 테스트와 pull request 검증 -- 상태: 로컬 해결, CI 검증 대기 +- 상태: pull request CI 검증 완료, `master` 병합 대기 - 영향: Macro 주문 토큰 회귀 테스트는 단독 실행할 수 있었지만, 저장소 전체 테스트를 원격에서 지속적으로 검증하는 gate가 없었다. @@ -64,8 +64,10 @@ GitHub Actions에서 별도 비밀이나 외부 서비스 없이 반복 실행 - `./gradlew test --no-daemon --rerun-tasks`: cache를 쓰지 않은 재실행 성공 - `./gradlew clean build --no-daemon --stacktrace`: packaging을 포함한 전체 build 성공 - workflow와 test profile YAML parse 성공 +- PR #3 Backend CI run `29404516754`: Java 21 설정, wrapper 검증과 전체 build 성공 +- staged 변경과 commit의 로컬 gitleaks 검사: 노출 0건 -pull request와 병합 뒤 `master` Backend CI, secret scan 결과를 확인한 뒤 상태를 확정한다. +병합 뒤 `master` Backend CI 결과를 확인한 뒤 상태를 확정한다. ## 회귀 방지와 남은 위험