[서버 개발 일지 #1] 공통 설정과 Flyway

KimGwangmin·2026년 10월 8일

1. 프로젝트 개요

A Game About Feeding A Black Hole을 레퍼런스로 한 인크리멘탈 게임

프로젝트 구성

서버는 계정·진행 데이터 저장·클라이언트 데이터 검증 등의 백엔드 역할을 맡는다.
6인 팀 개발로 진행한다.

2. 기술 스택 및 의존성

구분선택
언어 / 런타임Java 21 (Gradle Toolchain으로 고정)
프레임워크Spring Boot 4.1.1
빌드Gradle (Wrapper 사용)
DBMySQL 8.4
스키마 관리Flyway
테스트JUnit 5, Testcontainers
컨벤션EditorConfig, Checkstyle

build.gradle의 핵심 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-flyway'
    implementation 'org.flywaydb:flyway-mysql'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
    testImplementation 'org.testcontainers:testcontainers-mysql'
    // ... (starter-*-test 생략)
}
  • starter-data-jpa / starter-webmvc: JPA와 REST API(Spring MVC)
  • starter-flyway: Spring Boot가 앱 시작 시 Flyway를 자동 실행해 주는 연동 모듈
  • flyway-mysql: Flyway 10부터 DB별 지원이 별도 모듈로 분리되어, MySQL을 쓰려면 이 모듈이 따로 필요
  • mysql-connector-j: MySQL JDBC 드라이버
  • testcontainers-*: 테스트 때 진짜 MySQL을 Docker 컨테이너로 띄우기 위한 것(후술)

3. 로컬 개발 환경: Docker + .env

접속 정보: .env

# .env.example
DB_PORT=3306
DB_NAME=feedablackhole
DB_USERNAME=blackhole
DB_PASSWORD=change-me
DB_ROOT_PASSWORD=change-me-root
  • DB 이름·계정·비밀번호·포트
  • .env는 .gitignore에 포함하고, 값을 비운 .env.example만 공유

MySQL: Docker Compose

services:
  mysql:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?.env.example을 복사해 .env를 만드세요}
      MYSQL_DATABASE: ${DB_NAME:-feedablackhole}
      MYSQL_USER: ${DB_USERNAME:-blackhole}
      MYSQL_PASSWORD: ${DB_PASSWORD:?.env.example을 복사해 .env를 만드세요}
    ports:
      - "${DB_PORT:-3306}:3306"
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci
    healthcheck: ...
  • 문자셋을 utf8mb4로 명시
  • 비밀번호에는 기본값을 두지 않고 ${VAR:?메시지} 문법으로 .env가 없으면 안내 메시지와 함께 실패하도록 함

Spring Boot 설정

# application.properties
spring.config.import=optional:file:.env[.properties]

spring.datasource.url=${DB_URL:jdbc:mysql://localhost:${DB_PORT:3306}/${DB_NAME:feedablackhole}}
spring.datasource.username=${DB_USERNAME:blackhole}
spring.datasource.password=${DB_PASSWORD:}
  • spring.config.import=optional:file:.env[.properties]: 프로젝트 루트의 .env를 properties 형식으로 읽어옴
    • 파일이 없어도 에러 없음 (optional:), 이 경우 실제 환경 변수를 사용
    • 배포 환경에서는 .env 없이 환경변수 DB_URL, DB_USERNAME, DB_PASSWORD로 덮어씀
  • 비밀번호는 기본값을 두지 않는다. 실수로라도 취약한 기본 비밀번호가 운영에 올라가는 일이 없어야 하기 때문
    • 실행 프로필을 분리하고 로컬 프로필에서는 기본 비밀번호를 사용하는 방법도 있지만, 우선 프로필을 분리할 정도의 설정 차이가 없기 때문에 단일 설정으로 시작한다.

4. Flyway

부트캠프에서 학습한 방식

spring.jpa.hibernate.ddl-auto=update
  • 엔티티 클래스만 만들고 서버를 실행하면 Hibernate가 테이블을 알아서 만들어 준다.
  • SQL 없이 DDL을 관리할 수 있어 편리하다.

복습: ddl-auto 옵션 정리

값동작
none아무것도 안 함
validate엔티티와 스키마가 일치하는지 검사만 함 (다르면 시작 실패)
update엔티티에 맞춰 스키마를 추가 방향으로 변경 시도
create시작할 때 테이블을 지우고 새로 생성
create-dropcreate + 종료 시 삭제

update의 문제점

1. 스키마의 "변경 이력"이 어디에도 남지 않는다

  • 누군가 엔티티를 고치고 서버를 실행한 순간 스키마가 바뀐다.
  • 어떤 컬럼이 언제, 왜 추가되었는지 ALTER 등의 이력이 남지 않음
  • 엔티티 diff로 간접적으로만 추측 가능

2. update는 "추가"만 한다

  • 컬럼 이름을 바꾸면 새 컬럼이 생기고 기존 컬럼이 그대로 남는다. 또한 데이터 이전도 안된다.
  • 엔티티에서 필드를 지워도 DB 컬럼은 그대로 남는다.
  • 타입과 제약 조건을 바꿔도 기존 것이 바뀌지 않는 경우가 많다.
  • 이미 데이터가 있는 테이블에 NOT NULL 컬럼을 추가하면 실패하거나 의도와 다르게 동작할 수 있다.

3. 팀원마다 DB 상태가 달라진다

  • 2번 내용이 주 원인이다. update는 추가만 하고 있는 걸 바꾸거나 없애지 못한다.
  • A는 컬럼을 추가했다가 이름을 바꿨고, B가 오늘 처음 클론했다면, 엔티티는 같아도 로컬 DB의 실제 스키마가 달라질 수 있다.
  • DB에 남은 과거 흔적이 성공·실패의 원인이 될 수 있다.
  • create를 쓰면 이 문제는 어느 정도 해결되지만, 이러면 개발 데이터가 매번 날아간다.

4. 데이터 마이그레이션이 불가능하다

  • 다음 작업은 hibernate가 해줄 수 없다.
    • 기존 값을 새 컬럼으로 옮겨 담기
    • 초기 기준 데이터 넣기
  • 이런 작업은 결국 손으로 SQL을 실행해야 한다. 이 기록 역시 어디에도 남지 않는다.

5. 운영 단계에선 결국 별도로 스키마를 만들어야 한다

  • 운영 서비스에서 update 옵션을 사용하는 것은 의도치 않은 스키마 변경의 원인이 되어 위험하다.
  • 보통 validate나 none을 쓰는데, 그러면 운영 단계에선 결국 스키마 관리를 개발 단계와 다른 방식으로 해야 한다. 즉, 개발 단계에서 검증하지 않은 방식을 쓰는 셈이다.

6. DB 설계가 코드 리뷰에 보이지 않는다.

  • 협업에서 문제가 되는 부분이다.
  • 인덱스, 유니크 제약, 외래키, 컬럼 길이 등 DB 설계에 중요한 영역이 어노테이션에 숨거나 hibernate의 기본값에 맡지고, 생성된 DDL은 PR에서 볼 수 없다.

Flyway란

DB 스키마의 버전 관리 도구

  • 코드를 Git으로 관리하듯, 스키마 변경을 SQL 파일로 만들어 Git으로 관리한다.
  • 앱이 시작될 때, 아직 적용되지 않은 파일을 순서대로 자동 실행한다.

작동 방식

  1. src/main/resources/db/migration/ 아래에 V<번호>__<설명>.sql 파일을 둔다.
    db/migration/
    ├── V1__create_account.sql
    ├── V2__create_progress_tables.sql
    └── V3__add_xxx_column.sql
  2. 앱이 시작되면 Flyway가 DB에 있는 flyway_schema_history 테이블을 확인한다. 이 테이블에는 어떤 버전을, 언제, 어떤 체크섬으로 적용했는지가 기록되어 있다.
  3. 아직 적용되지 않은 버전만 번호 순서대로 실행하고, 결과를 flyway_schema_history에 기록한다.
  4. 이미 적용된 파일은 다시 실행하지 않는다.
  • 파일명 규칙(V + 번호 + __ + 설명)을 지켜야 인식된다.

ddl-auto=update와의 비교

ddl-auto=update의 문제Flyway의 경우
① 변경 이력이 없다모든 변경이 SQL 파일로 Git 이력에 남는다. 누가·언제·왜(커밋 메시지) 바꿨는지 추적 가능
② 추가만 되고 어긋난다컬럼 이름 변경·삭제·타입 변경도 ALTER로 명시적으로 작성한다
③ 팀원 DB가 제각각빈 DB에서 시작해도 V1부터 순서대로 적용하면 누구든 같은 스키마가 된다
④ 데이터 이전 불가같은 마이그레이션 파일에 UPDATE/INSERT로 데이터 이전·초기 데이터도 넣을 수 있다
⑤ 개발/운영 방식이 다르다개발·테스트·운영이 같은 마이그레이션 파일을 같은 순서로 적용한다. 배포 전에 이미 수없이 검증된 경로다
⑥ 설계가 리뷰에 안 보인다CREATE TABLE의 제약조건·인덱스·외래키가 PR diff에 그대로 보인다

작업 흐름

(예전) 엔티티 수정 → 서버 실행 → 끝 (DB가 알아서 바뀜)

(지금) 1. 새 마이그레이션 파일 추가  (예: V3__add_nickname_to_account.sql)
       2. 엔티티 수정
       3. 서버 실행 → Flyway가 V3 적용 → Hibernate가 일치 여부 검증
       4. SQL 파일과 엔티티를 같은 PR에 포함
  • JPA의 ddl-auto는 validate로 둔다.
  • 스키마는 Flyway가 관리하고, JPA는 엔티티와 일치하는지 검증만 한다.
  • 엔티티와 DDL과 동일한 구조를 갖도록 명시적으로 SQL을 작성해주어야 한다.
  • 작성한 SQL이 설계 문서이자 리뷰 대상으로 팀에 공유된다.

주의할 점

  • 버전 번호 충돌: 두 사람이 동시에 V3__...를 만들면 병합 시 충돌한다. PR 병합 시점에 번호를 확인하고 조정해야 한다.
  • MySQL의 DDL은 트랜잭션 롤백이 되지 않는다. 마이그레이션 중간에 실패하면 일부만 적용된 상태로 남을 수 있다. 한 파일에 너무 많은 변경을 넣지 않는 편이 안전하다.
  • 개발 단계에서 로컬 DB가 꼬인 경우 docker compose down -v로 볼륨째 지우고 V1부터 다시 적용하면 된다. 언제든 처음부터 재현 가능하다는 것 자체가 Flyway의 장점 중 하나이다.
  • SQL을 직접 쓰는 만큼 엔티티와의 일치는 validate와 테스트로 계속 확인해야 한다. 이제는 자동으로 코드에서 스키마를 만들어주지 않는다.

참고: validate가 잡아주는 것과 못 잡는 것

엔티티와 스키마의 일치를 validate에 맡긴다고 했지만, validate가 확인하는 범위는 생각보다 좁다.

기본 검증 범위

  • 테이블의 존재
  • 컬럼의 존재
  • 컬럼 타입의 종류 (String↔VARCHAR, long↔BIGINT 등)

반대로 다음은 기본 설정에서 검증하지 않는다.

  • 컬럼 길이 (VARCHAR(20)과 VARCHAR(255)를 구분하지 않는다.)
  • NOT NULL 여부
  • 유니크 제약, 인덱스
  • 외래키, PK, AUTO_INCREMENT, 기본값, CHECK 제약

즉 엔티티가 쓰는 테이블과 컬럼이 DB에 있고 타입이 맞는가만 보장한다.

옵션으로 켤 수 있는 것: 유니크 제약과 인덱스

  • Hibernate 7.3부터 유니크 제약과 인덱스도 검증할 수 있는 설정이 추가되었다. (기본값은 NONE)
  • 엔티티에 선언한 제약이 DB에 같은 이름으로 존재하는지, 컬럼 구성(순서 포함)이 같은지 확인한다.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.tooling.schema.unique_key_validation=ALL
spring.jpa.properties.hibernate.tooling.schema.index_validation=ALL
  • 값은 NONE, NAMED, ALL 중 하나이다.
    • NAMED는 UK(대문자)로 시작하는 이름을 검증에서 제외한다. 이름을 붙이지 않아 Hibernate가 자동 생성한 이름을 걸러 내려는 동작이다. 하지만 직접 UK_...로 이름을 붙인 제약도 조용히 빠질 수 있다.
    • 우리는 "엔티티의 제약 이름은 SQL과 같게 둔다"는 규칙을 강제하려고 ALL을 택했다.
  • 이 프로젝트에서는 @UniqueConstraint(name = "uk_...", columnNames = ...)로 SQL에 있는 제약을 엔티티에 옮겨 적고 있다. 이 옵션을 켜면 이 어노테이션이 SQL과 어긋나는 순간 서버가 시작되지 않는다.

실제로 확인한 결과

엔티티를 일부러 어긋나게 만들고 테스트를 돌려 보았다.

어긋나게 한 것결과
유니크 제약 이름실패 (Missing unique constraint named ...)
유니크 제약 컬럼 순서실패 (Unique-key mismatch)
컬럼 길이 (64 → 10)통과
nullable = false 추가 (DB는 NULL 허용)통과
외래키 이름통과

엔티티 요소별 검증 여부

엔티티에 쓰는 요소스키마와 일치 검증
테이블 이름 (@Table(name))O
컬럼 이름 (@Column(name))O
컬럼 타입 종류O
유니크 제약 (이름·컬럼 구성·순서)O (옵션 활성화 시)
인덱스 (@Index)O (옵션 활성화 시)
컬럼 길이 (length)X
NOT NULL (nullable, optional)X
외래키X
PK, AUTO_INCREMENTX
기본값, CHECK 제약X
@Enumerated의 허용 값 집합X (DB는 VARCHAR일 뿐이라 비교 대상이 아니다.)
  • 엔티티에 쓴 모든 요소를 검증할 수 있는 것은 아니다. 검증되지 않는 영역에서는 어긋나도 서버가 조용히 시작된다.
  • 이 경우 문제는 뒤늦게 드러난다. 길이가 어긋나면 긴 값을 저장할 때 DB 오류가 나고, 엔티티는 NOT NULL인데 DB가 NULL을 허용하면 데이터 품질 문제만 조용히 남는다.

엔티티를 작성할 때 신경 쓸 것

  • 스키마의 원본은 SQL이다. 엔티티의 @Column, @Table, @UniqueConstraint 등은 스키마를 만들지 않는다. SQL을 코드에서도 읽을 수 있게 옮겨 적은 문서에 가깝다.
  • 유니크 제약과 인덱스는 SQL과 이름·컬럼 구성(순서 포함)을 똑같이 쓴다. 어긋나면 validate가 잡아 주므로 안전하다.
    • 이름을 붙이지 않은 @Column(unique = true)는 자동 생성된 이름이 SQL의 이름과 맞지 않아 실패할 수 있다. @UniqueConstraint(name = ...)로 SQL과 같은 이름을 명시한다.
  • 컬럼 길이, NOT NULL, 외래키는 validate가 잡아주지 않으므로 직접 맞춘다. SQL을 바꾸면 엔티티도 같은 PR에서 같이 고치고, 리뷰에서도 두 파일을 나란히 확인한다.
  • 제약이 DB에서 실제로 동작하는지는 테스트로 확인한다. 중복 값을 저장하면 DB가 거부하는지 등을 Testcontainers의 실제 MySQL에서 검증한다.

협업 규칙

  • 스키마 변경은 V<번호>__<설명>.sql 파일 추가로만 한다.
  • 이미 병합된 파일은 수정하지 않는다. 바꿀 게 있으면 새 번호의 파일을 추가한다. (수정하면 체크섬 불일치로 시작 거부)
  • DB 스키마를 바꾸는 PR은 PR 템플릿에 적용 방법과 기존 환경에 미치는 영향을 적는다.

5. Testcontainers

기존 로컬 테스트 방법

  • H2 등 인메모리 DB: 실제 운영 DB(MySQL)와 문법·동작이 달라 테스트 통과가 실제 통과를 보장하지 못한다. Flyway SQL은 MySQL 문법으로 쓰기에 H2로는 검증할 수 없다.

  • 로컬 DB: 팀원마다 상태가 다르고, 이전 테스트 데이터가 남아 영향을 줄 수 있다.

Testcontainers

Docker 컨테이너로 실제 DB 같은 의존 서비스를 띄웠다가, 테스트가 끝나면 자동으로 정리해 주는 Java 라이브러리

  • 운영과 동일한 MySQL 8.4를 매 테스트마다 깨끗한 상태로 띄워준다.
  • 빈 DB에 V1부터 마이그레이션이 적용되므로 마이그레이션 파일 자체도 매번 검증된다.

동작 흐름

테스트 시작 → Docker로 mysql:8.4 컨테이너 기동(랜덤 포트)
           → Spring이 그 컨테이너 접속 정보를 DataSource에 자동 연결
           → Flyway가 V1부터 적용 → 테스트 실행
테스트 종료 → 컨테이너 자동 삭제
  • 포트가 랜덤이라 로컬에서 compose로 띄운 DB(3306)와 충돌하지 않는다.

6. 기타 협업 환경 설정

DB 외에 팀 작업을 위해 정한 것들이다.

  • Git 설정: .gitattributes로 줄바꿈을 LF로 통일 (Windows/Mac 팀원이 섞여 있어도 diff가 지저분해지지 않도록), .gitmessage 커밋 메시지 템플릿 (feat/fix/refactor/docs/test/chore)
  • 코드 컨벤션: .editorconfig + Checkstyle(maxWarnings = 0). 규칙 위반 시 빌드가 실패한다.
  • CI: GitHub Actions에서 PR마다 Checkstyle 검사 (전체 빌드·DB 테스트 CI는 후속 단계)
  • PR 템플릿: 목적 / 변경 내용 / 검증 / 클라이언트·DB·환경 설정 영향 / 관련 이슈
  • 브랜치 전략: git flow 전략 사용. main 아래에 dev 브랜치를 디폴트로 설정하고, feat/…, fix/…, chore/… 브랜치를 만들어 PR로 병합

0개의 댓글