Liquibase로 데이터베이스 형상관리 도입기
DB 스키마 형상관리를 해보자.
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.properties의 spring.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로 표현하지 않고, “오늘부터의 변경만 추적한다”는 선을 긋는다.
- 빈 changelog로 시작
update한 번 실행 →DATABASECHANGELOG/DATABASECHANGELOGLOCK테이블만 생성, SQL 실행은 없음- 이후 새 changeSet 추가 →
update→ 신규 변경만 적용
changelogSync도 필요 없다. 가장 단순하고 부작용이 적다.
선택지 2 — 기존 스키마를 baseline으로 떠서 포함
1
2
3
liquibase generateChangeLog # 현재 스키마를 changelog로 추출
liquibase changelogSync # 추출된 내용을 "이미 적용됨"으로 마킹
# 이후 새 changeSet 추가 → update
장점: 신규 환경 구축 시 changelog만으로 동일 스키마를 만들 수 있다. 단점: Oracle의 시퀀스/트리거/제약조건이 깔끔하게 안 뽑히는 경우가 많아 손봐야 한다.
1번이 압도적으로 편하다. 기존 스키마는 어차피 잘 돌고 있으니 굳이 changelog로 다시 표현할 이유가 없다.
최종 도입 전략 정리
오늘 알아본 내용을 우리 환경에 적용하면 이런 그림이 된다.
- 레포 구조:
project_db전용 레포 - changeSet 작성:
id/author/logicalFilePath명시,<rollback>함께 정의 - dev: 자동 적용 (
./gradlew update -Penv=dev) - uat: 빈 baseline → 자동 적용 (
./gradlew update -Penv=uat) - prd: Liquibase 비활성화,
updateSql로 SQL 추출 → DBA 승인 → 수동 적용
이제 컬럼 하나 추가할 때 SQL 직접 치고 환경별로 옮기는 일은 안 해도 된다. 변경은 Git에 남고, 누가 언제 왜 추가했는지 추적할 수 있다.
마치며
Liquibase는 한 줄로 요약하면 “DB 스키마에 Git을 도입하는 도구” 같다.
다만 도입할 때 단일 진실 공급원 원칙을 지키는 게 핵심이다. 멀티레포 환경에서 각 레포가 자기 마음대로 스키마를 건드리기 시작하면 락 경합과 충돌로 더 큰 지옥이 된다. 한 곳에서 일원화하고, 환경별로 적용 방식을 분리하는 게 정석이다.
다음 글에서는 실제로 project_db 레포를 만들고 첫 changeSet을 적용한 경험을 정리해보려고 한다.
참고 자료
