포스트

Liquibase로 데이터베이스 형상관리 도입기

DB 스키마 형상관리를 해보자.

Liquibase로 데이터베이스 형상관리 도입기

Liquibase로 멀티레포 DB 스키마 관리하기 — 도입 전 알아야 할 것들

멀티레포 환경에서 DB 컬럼 하나 추가하다가 Liquibase 도입을 고민하게 됐다. 처음 알아본 김에 오늘 배운 내용을 정리한다.

시작은 단순한 질문이었다

DB에 컬럼 하나 추가해야 하는 일이 생겼다. 평소처럼 dev → uat → prd 순서로 SQL을 직접 치려다 문득 생각했다.

“매번 환경마다 SQL 직접 치는 거, 좀 자동화할 수 없나?”

그렇게 Liquibase를 알아보기 시작했다. 그런데 막상 도입을 검토해보니 단순한 라이브러리 추가 그 이상이었다. 특히 우리 프로젝트가 15개 레포가 같은 DB를 공유하는 멀티레포 구조라 고려할 게 많았다.

Liquibase가 뭐 하는 도구인가

오해하기 쉬운 부분부터 짚고 가자. Liquibase는 DB 데이터를 조회하거나 시각화하는 도구가 아니다. DBeaver나 DataGrip 같은 GUI 클라이언트와는 결이 완전히 다르다.

용도 도구
DB 스키마 변경 자동화/버전관리 Liquibase, Flyway
DB 데이터 조회/편집 GUI DBeaver, DataGrip, SQL Developer
DB 모니터링 Grafana, OEM

Liquibase는 changelog 파일에 “이런 변경을 적용해라”고 정의해두면, 실행 시점에 DB의 DATABASECHANGELOG 테이블과 비교해서 아직 적용되지 않은 변경만 자동으로 실행해주는 도구다. 즉, “스키마 변경 자동화 + 버전관리” 가 본질이다.

GUI는 따로 없고 CLI 또는 Gradle/Maven 플러그인으로 동작한다. 적용 이력은 DB에 자동 생성되는 DATABASECHANGELOG 테이블을 직접 조회해서 확인한다.

멀티레포 환경 — 모든 레포에 Liquibase를 넣어야 하나?

우리 프로젝트는 16개의 레포 중 15개가 동일한 Oracle 스키마를 바라본다. 처음엔 “각 레포가 자기 도메인 테이블을 관리하면 되겠지” 싶었다. 그런데 알고 보니 그러면 안 되는 이유가 뚜렷했다.

1. 동시 실행 시 락 경합 Liquibase는 실행 중 DATABASECHANGELOGLOCK 테이블에 락을 건다. 15개 애플리케이션이 동시 부팅하면 한 놈이 락을 잡고 나머지는 대기하다 타임아웃이 난다. 롤링 배포면 더 꼬인다.

2. 변경 추적 불가 “users 테이블 status 컬럼 누가 추가했지?” 를 찾으려고 15개 레포의 changelog를 다 뒤지는 건 끔찍하다.

3. changeSet 충돌 서로 다른 레포에서 같은 컬럼을 두 번 추가하는 changeSet을 만들면, 먼저 도는 쪽이 성공하고 나중 쪽은 부팅 실패한다.

결론: DB 스키마 관리는 한 곳에서만 한다. Single Source of Truth 원칙이다.

어디에 둘 것인가

세 가지 옵션을 검토했다.

  • 옵션 A: project_db 같은 전용 마이그레이션 레포 신설 (Spring Boot 아님, Liquibase CLI/Gradle만)
  • 옵션 B: 기존 공통 레포(project_com, project_frw)에 통합하고 나머지는 spring.liquibase.enabled=false
  • 옵션 C: 도메인별로 분리 (테이블 경계가 명확할 때만)

지금 구조처럼 한 스키마를 여러 서비스가 공유하면 옵션 A가 가장 깔끔하다. 애플리케이션 코드와 스키마 관리가 분리되니 배포 순서 통제도 쉽다.

디렉토리 구조가 변경될 가능성을 감안한 선택

Liquibase는 changeSet을 id + author + 파일경로 조합으로 식별하기 때문에, 나중에 디렉토리 구조가 바뀌면 같은 changeSet을 새로운 것으로 오인해서 중복 실행 시도가 일어난다.

이걸 막는 보험이 logicalFilePath 속성이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    logicalFilePath="project_db/changelog/2026/20260423-01-add-status.xml">

    <changeSet id="20260423-01" author="duyan">
        <addColumn tableName="USERS">
            <column name="STATUS" type="VARCHAR2(20)"/>
        </addColumn>
        <rollback>
            <dropColumn tableName="USERS" columnName="STATUS"/>
        </rollback>
    </changeSet>
</databaseChangeLog>

logicalFilePath만 명시해두면 실제 파일이 ~/local/project_db/...에 있든 ~/git/project_db/...에 있든 Liquibase는 같은 changeSet으로 인식한다.

환경별 분리 — context로 prd만 빼는 건 위험하다

처음엔 “context에 dev, uat만 적으면 prd는 자동으로 빠지겠네?” 싶었다. 절반은 맞고 절반은 위험하다.

1
<changeSet id="..." context="dev,uat">  <!-- prd 실행 시 건너뜀 -->

이건 분명 동작한다. 그런데 함정이 있다. context 속성을 명시하지 않은 changeSet은 모든 환경에서 항상 적용된다. 즉, 누군가 깜빡하고 context 안 붙이면 그 changeSet은 prd에도 들어간다.

그래서 환경 자체를 분리할 거라면 context보다 환경별 설정 파일에서 enabled를 끄는 방식이 더 안전하다.

1
2
3
4
5
6
7
8
9
# application-dev.yml, application-uat.yml
spring:
  liquibase:
    enabled: true

# application-prd.yml
spring:
  liquibase:
    enabled: false

prd는 Liquibase가 아예 안 돌고, 대신 liquibase updateSql 명령으로 적용될 SQL을 미리 뽑아 DBA에게 전달한다. 승인 프로세스가 있는 조직에 딱 맞는 패턴이다.

1
./gradlew updateSql -Penv=prd > prd-migration-20260423.sql

이 SQL을 검토 → DBA 실행 → DBA가 prd DB에서 직접 적용. 우리 같은 환경에 잘 맞다.

application.properties와 liquibase.properties는 같은 게 아니다

처음에 헷갈렸던 부분이다. 정리하면:

상황 필요한 설정 파일
Spring Boot 앱이 부팅 시 자동 실행 application.propertiesspring.liquibase.*
독립 Liquibase 프로젝트 (CLI 실행) liquibase.properties 또는 build.gradle의 activities 블록
둘 다 사용 둘 다 필요

project_db처럼 Spring Boot 없는 독립 프로젝트라면 application.properties는 의미가 없다. 대신 Gradle 플러그인의 activities 블록에 환경별 설정을 다 넣을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
liquibase {
    activities {
        dev {
            changelogFile 'changelog/db.changelog-master.xml'
            url 'jdbc:oracle:thin:@dev-host/ORCL'
            username 'name'
            password 'pw'
        }
        uat {
            changelogFile 'changelog/db.changelog-master.xml'
            url System.getenv('UAT_DB_URL') ?: ''
            username System.getenv('UAT_DB_USER') ?: ''
            password System.getenv('UAT_DB_PASSWORD') ?: ''
        }
    }
    runList = project.hasProperty('env') ? project.env : 'dev'
}

실행은 ./gradlew update -Penv=dev 식으로. uat/prd 접속정보는 절대 코드에 박지 말고 환경변수로 주입한다.

이미 운영 중인 DB에 도입하기 — changelogSync를 정확히 이해하기

가장 헷갈렸던 부분이다. UAT DB에 이미 테이블이 다 있는 상태에서 Liquibase를 도입하려면 어떻게 해야 하나?

처음엔 막연히 “changelogSync 돌리고 update 하면 되겠지” 싶었는데, 정확히 알아보니 좀 다르다.

changelogSync가 실제로 하는 일

“changelog에 들어있는 모든 changeSet을 실제 SQL 실행 없이 DATABASECHANGELOG 테이블에 ‘적용 완료’로 기록”

핵심: 이건 선언이다. “이 changeSet들은 이미 DB에 반영되어 있다고 간주하라”고 Liquibase에게 알려주는 명령. 그래서 sync를 돌리는 시점에 changelog 내용이 현재 DB 상태와 일치해야 한다. 안 그러면 나중에 불일치가 생긴다.

두 가지 선택지

선택지 1 — 빈 baseline부터 시작 (추천)

기존 스키마는 changelog로 표현하지 않고, “오늘부터의 변경만 추적한다”는 선을 긋는다.

  1. 빈 changelog로 시작
  2. update 한 번 실행 → DATABASECHANGELOG/DATABASECHANGELOGLOCK 테이블만 생성, SQL 실행은 없음
  3. 이후 새 changeSet 추가 → update → 신규 변경만 적용

changelogSync도 필요 없다. 가장 단순하고 부작용이 적다.

선택지 2 — 기존 스키마를 baseline으로 떠서 포함

1
2
3
liquibase generateChangeLog  # 현재 스키마를 changelog로 추출
liquibase changelogSync       # 추출된 내용을 "이미 적용됨"으로 마킹
# 이후 새 changeSet 추가 → update

장점: 신규 환경 구축 시 changelog만으로 동일 스키마를 만들 수 있다. 단점: Oracle의 시퀀스/트리거/제약조건이 깔끔하게 안 뽑히는 경우가 많아 손봐야 한다.

1번이 압도적으로 편하다. 기존 스키마는 어차피 잘 돌고 있으니 굳이 changelog로 다시 표현할 이유가 없다.

최종 도입 전략 정리

오늘 알아본 내용을 우리 환경에 적용하면 이런 그림이 된다.

  1. 레포 구조: project_db 전용 레포
  2. changeSet 작성: id/author/logicalFilePath 명시, <rollback> 함께 정의
  3. dev: 자동 적용 (./gradlew update -Penv=dev)
  4. uat: 빈 baseline → 자동 적용 (./gradlew update -Penv=uat)
  5. prd: Liquibase 비활성화, updateSql로 SQL 추출 → DBA 승인 → 수동 적용

이제 컬럼 하나 추가할 때 SQL 직접 치고 환경별로 옮기는 일은 안 해도 된다. 변경은 Git에 남고, 누가 언제 왜 추가했는지 추적할 수 있다.

마치며

Liquibase는 한 줄로 요약하면 “DB 스키마에 Git을 도입하는 도구” 같다.

다만 도입할 때 단일 진실 공급원 원칙을 지키는 게 핵심이다. 멀티레포 환경에서 각 레포가 자기 마음대로 스키마를 건드리기 시작하면 락 경합과 충돌로 더 큰 지옥이 된다. 한 곳에서 일원화하고, 환경별로 적용 방식을 분리하는 게 정석이다.

다음 글에서는 실제로 project_db 레포를 만들고 첫 changeSet을 적용한 경험을 정리해보려고 한다.


참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.