Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand All @@ -71,4 +72,4 @@ dependencies {
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
}
86 changes: 86 additions & 0 deletions docs/troubleshooting/2026-backend-ci-test-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Backend 테스트가 로컬 MySQL에 의존한 문제

- 발생일: 2026-07-15
- 범위: `UNITHON24/Backend` 전체 Gradle 테스트와 pull request 검증
- 상태: pull request CI 검증 완료, `master` 병합 대기
- 영향: 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 성공
- PR #3 Backend CI run `29404516754`: Java 21 설정, wrapper 검증과 전체 build 성공
- staged 변경과 commit의 로컬 gitleaks 검사: 노출 0건

병합 뒤 `master` Backend CI 결과를 확인한 뒤 상태를 확정한다.

## 회귀 방지와 남은 위험

모든 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로 전환할 기준은 무엇인가?
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -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