주니어 개발자들의 배포 날 밤, 가장 많이 터지는 대참사가 무엇일까요? 바로 "자바 코드는 최신으로 배포했는데, 운영 DB에 컬럼(Column) 추가하는 걸 깜빡해서 서버가 죽는 현상"입니다. JPA hibernate.ddl-auto=update 기능이 있긴 하지만, 실무 운영 서버에서 이 옵션을 켜는 것은 "내 손으로 DB를 날려먹고 사직서를 쓰겠다"는 선언과 같습니다(절대 사용 금지). 결국 개발자가 수동으로 ALTER TABLE 쿼리를 짜서 DBA에게 넘겨주거나 직접 날려야 하는데, 이 과정에서 사람이 실수하거나 쿼리를 누락하면 걷잡을 수 없는 재앙이 벌어집니다.
"소스코드는 GIT으로 1.0, 1.1 버전을 깔끔하게 관리하면서, 왜 데이터베이스 스키마(DDL)는 원시인처럼 구두로 관리하는가? DB 스키마도 코드로 만들어서 GIT에 올리고, 서버가 켜질 때 알아서 버전을 맞춰주면 안 될까?"
이 위대한 철학을 실현한 도구가 바로 Flyway(플라이웨이)입니다.
본 글에서는 "DB 배포 사고 0%를 위한 마이그레이션 툴!" Spring Boot와 Flyway 연동 아키텍처에 대하여 다뤄보겠습니다.
1. Flyway의 핵심: 규칙적인 파일명과 히스토리 테이블
Flyway를 도입하는 순간, 여러분은 더 이상 DB 툴(DBeaver)을 열고 테이블을 만들지 않습니다. 오직 .sql 파일만 작성합니다.
스프링 부트 프로젝트의 src/main/resources/db/migration 폴더에 아주 엄격한 이름 규칙(Naming Convention)을 지켜서 파일을 만듭니다.
V1__init.sql: (V + 숫자 + 언더바 2개 + 설명) 최초 테이블 생성 DDLV2__add_email_to_user.sql: User 테이블에 email 컬럼을 추가하는 DDLV3__insert_admin_data.sql: 관리자 기본 데이터 삽입 DML
이제 스프링 부트 서버를 켜면(Run), Flyway가 DB에 flyway_schema_history라는 자기만의 비밀 테이블을 몰래 만듭니다. 그리고 "음, 저번에 V1과 V2까지는 내가 실행했었지. 어? 이번에 V3 파일이 새로 생겼네? V3 파일만 실행하고 히스토리에 기록해야지!"라고 아주 똑똑하게 밀린 숙제(Migration)를 처리합니다.
2. Spring Boot에서 Flyway 1분 세팅하기
설정은 너무나도 쉽습니다. 의존성 한 줄만 추가하면 스프링 부트가 알아서 Flyway를 낚아챕니다.
// build.gradl
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql' // MySQL 쓰면 이것도 추가 (Boot 3.x 기준)
-- resources/db/migration/V1__create_users_table.sql
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
이 상태에서 서버를 켜면, 콘솔에 Successfully applied 1 migration to schema라는 로그가 뜨며 실제 DB에 users 테이블이 생성됩니다. 앞으로 application.yml의 ddl-auto 속성은 무조건 validate(엔티티와 DB가 똑같이 생겼는지만 검사하고 안 고쳐줌)로 고정해 두고 안심하고 꿀잠을 자면 됩니다.
3. Flyway의 절대 금기: 체크섬(Checksum)의 저주
Flyway를 쓸 때 초보들이 가장 많이 저지르는 치명적인 실수가 있습니다. 바로 '이미 배포가 끝난 과거의 파일(예: V1)의 내용을 얍삽하게 수정하는 행위'입니다.
개발자 철수가 "아, V1 파일에 username 길이를 50에서 100으로 바꾸고 싶은데... 귀찮은데 V1 파일을 그냥 슥 고치자!"라고 파일 내용을 변경한 뒤 서버를 켭니다. 그러면 서버 기동이 실패하며 새빨간 에러가 터집니다!
Flyway는 SQL 파일을 실행할 때 파일 내용의 고유한 지문(Checksum)을 히스토리 테이블에 기록해 둡니다. 서버가 뜰 때마다 "내가 옛날에 실행했던 V1 파일의 지문과, 지금 폴더에 있는 V1 파일의 지문이 다르네? 누군가 몰래 과거를 조작했어! 위험해! 서버 구동 중지!"라며 막아버리는 것입니다. 한 번 배포된 V1 파일은 무덤까지 가져가야 하는 '불변(Immutable)'의 영역입니다. 수정하고 싶다면 무조건 새로운 V2__alter_username.sql 파일을 만들어서 ALTER TABLE 쿼리를 짜야만 합니다!
4. 마무리 및 주요 발전 방향
지금까지 수동 쿼리 실행의 공포에서 벗어나, DB 스키마 변경 내역을 자바 코드와 함께 GIT으로 완벽하게 형상 관리하고, CI/CD 파이프라인에서 무인 배포를 가능하게 만들어주는 기적의 툴, Flyway의 원리와 절대 금기 사항(Checksum)에 대해 상세히 다루어 보았습니다. 이제 여러분의 배포 날 밤은 평화로울 것입니다.
서버 코드도, DB 스키마도 완벽합니다. 이제 마지막 관문은 '테스트(Test)'입니다. 에서 배운 단위 테스트(Unit Test)는 가짜 객체(Mock)를 써서 아주 빠르지만, 실제 DB에 쿼리가 잘 먹히는지는 검증하지 못합니다. 그래서 통합 테스트(Integration Test)를 짜는데, 내 노트북 환경(윈도우 + 로컬 MySQL)에서는 테스트가 성공했는데, 젠킨스 서버(리눅스 + 텅 빈 DB)에서는 테스트가 다 깨져버립니다! "테스트 코드를 돌릴 때마다, 그 테스트 전용으로 완벽하게 격리된 '진짜 MySQL과 진짜 Redis'를 1회용으로 일회성으로 기동한 후 안전하게 정리할 수는 없을까?" 본 설명에서는 "테스트 환경 불일치의 저주를 완벽하게 끊어버리는 궁극의 통합 테스트 무기!" Docker와 연동하여 1회용 컨테이너를 띄우는 Testcontainers 완벽 가이드에 대해 아주 심도 있게 살펴보겠습니다.
"분명히 내 컴퓨터에선 테스트가 100% 통과했는데, 젠킨스(CI)에 올리기만 하면 빌드가 실패해요!" 개발팀에서 하루가 멀다고 들리는 비명입니다. 에서 배운 단위 테스트(Unit Test)는 순수한 자바 코드만 검증하므로 환경을 타지 않지만, 실제 쿼리가 날아가는지, Redis에 값이 잘 꽂히는지 확인하는 통합 테스트(Integration Test, @SpringBootTest)는 외부 인프라(MySQL, Redis)의 상태에 극도로 의존합니다. 철수의 노트북엔 MySQL 8.0이 있고 영희의 노트북엔 MySQL 5.7이 깔려있으며, 젠킨스 서버에는 아예 DB가 안 깔려있다면? 이 '테스트 환경 불일치'를 해결하기 위해 과거에는 H2 인메모리 DB로 흉내를 내봤지만, H2는 실제 MySQL 전용 함수나 락(Lock) 기능을 똑같이 재현하지 못해 반쪽짜리 테스트에 불과했습니다. "자바 테스트 코드를 딱 실행하는 순간, 백그라운드에서 진짜 MySQL 도커(Docker) 컨테이너를 하나 띄워서 거기에 테스트를 다 한 다음, 쿨하게 컨테이너를 죽여버리면 완벽하지 않을까?!" 이 천재적인 발상을 현실로 만든 프레임워크가 바로 Testcontainers(테스트컨테이너)입니다.
본 글에서는 "글로벌 IT 기업 테스트 표준!" Testcontainers를 활용한 무결점 통합 테스트 환경 구축에 대하여 다뤄보겠습니다.
1. Testcontainers의 철학: 진짜 인프라를 쓰되 1회용으로!
로컬 PC나 젠킨스 서버에 반드시 Docker(도커)가 깔려있어야만 작동합니다. 스프링이 뜨기 전에 자바 코드가 도커를 조종합니다.
// 1. 의존성 추가 (build.gradle)
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
// 2. 통합 테스트 코드 작성
@SpringBootTest
@Testcontainers // JUnit에게 "지금부터 내가 지시하는 도커 컨테이너들을 띄워줘!" 라고 명령
class UserRepositoryIntegrationTest {
// 이 어노테이션을 붙이면, 테스트 시작 전 진짜 MySQL 8.0 컨테이너가 짠! 하고 뜹니다.
@Container
static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0.32")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
// 도커가 랜덤한 포트(예: 32847)로 DB를 띄우기 때문에, 스프링의 yml 설정(url)을 강제로 덮어씌워 줍니다.
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("진짜 MySQL 환경에서 유저 저장 테스트를 수행한다")
void testSaveUser() {
// given
User user = new User("철수");
// when
User savedUser = userRepository.save(user);
// then
assertThat(savedUser.getId()).isNotNull();
}
}
2. 실무 꿀팁: 컨테이너 부팅 시간(지연) 단축하기
Testcontainers는 완벽하지만 치명적인 단점이 하나 있습니다. 엄청나게 느립니다! 테스트 클래스(A_Test.java, B_Test.java...)가 실행될 때마다 매번 10초씩 걸려서 무거운 MySQL 컨테이너를 내렸다가 새로 띄운다면, 테스트 100개 돌리는 데 30분이 걸릴 것입니다. CI 파이프라인에서 이러면 뺨 맞습니다.
이 문제를 해결하기 위해 실무에서는 '싱글톤(Singleton) 컨테이너' 패턴을 사용합니다. 클래스마다 @Container를 달지 않고, 모든 통합 테스트 클래스가 상속받는 거대한 부모 클래스(AbstractIntegrationTest)를 하나 만듭니다. 그 부모 클래스의 static 블록에서 컨테이너를 딱 한 번만 띄워놓고(Start), 자식 클래스 수십 개가 그 하나의 컨테이너를 재사용하도록 만듭니다. 이렇게 하면 첫 부팅 10초만 감수하고 나머지 테스트는 번개처럼 빠르게 돌아갑니다. (물론 테스트 간의 데이터 침범을 막기 위해 매 테스트 직후 @AfterEach에서 TRUNCATE TABLE 쿼리로 데이터를 싹 청소해 주는 것은 필수입니다!)
3. 마무리 및 주요 발전 방향
지금까지 어떤 PC나 CI 서버 환경이든 상관없이 100% 동일한 '진짜 DB/Redis' 환경을 보장하고, 테스트 종료 후 흔적도 없이 파괴되는 안정적인 격리 테스트 환경의 표준인 Testcontainers의 마법과 싱글톤 최적화 비법에 대해 상세히 다루어 보았습니다. 이제 더 이상 "내 컴퓨터에선 되는데요?"라는 변명은 통하지 않습니다.
우리는 완벽하게 동작하는 통합 테스트 코드까지 갖추었습니다. 그런데, 이 코드가 진짜 '좋은 코드'일까요? 동작은 잘하지만 변수 이름이 엉망진창이고, 한 메서드가 500줄이 넘어가며, 치명적인 보안 취약점(SQL 인젝션 등)이 숨어있다면 어떨까요? 시니어 개발자가 밤새 코드 리뷰(PR)를 해주지 않아도,
"기계가 내 코드를 쓱 훑어보고 '여기 코드 스멜(악취)이 나요! 여기 버그 덩어리에요!' 라며 회초리를 때려주고 등급을 매겨주는 AI 같은 시스템"은 없을까요? 다음 설명에서는 CI/CD 파이프라인에 이식하는 정적 코드 분석의 절대 권력, SonarQube(소나큐브) 구축과 실무 품질 게이트(Quality Gate) 연동 가이드에 대해 살펴보겠습니다.
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] WebRTC 화상 회의 P2P 시그널링 서버 (0) | 2026.10.22 |
|---|---|
| [GraphQL] 그래프QL REST API 오버패칭 (0) | 2026.10.22 |
| [Spring Boot] 낙관적 락Optimistic vs 비관적 락Pessimistic JPA 동시성 제어 (0) | 2026.10.21 |
| [Spring Boot] Spring Cloud Gateway (0) | 2026.10.21 |
| [Spring Boot] Spring 클라우드 게이트웨이(Cloud Gateway) (0) | 2026.10.20 |