ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flyway를 이용해 데이터베이스 테이블 변경 사항 관리하기
    Today I Learned 2023. 5. 5. 22:04

     
    이전에 프로젝트를 진행하는 과정에서 쉽지 않았던 것들 중 하나로 Entity를 변경한 후 서버를 실행시켰을 때 데이터베이스에 해당 Entity의 변동사항들을 일일이 적용시켜줘야 했었던 것이 있었다.
     
    예를 들어 다음과 같은 Entity가 있다고 치면,

    @Entity
    public class Semester {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
    
        private Boolean finalized;
        
        // constructors
        // ...
    }

     
    H2 로컬에는 이런 식으로 테이블이 생성되었다.

     
    구현을 진행하는 과정에서 Entity의 이름을 변경하거나, column 이름을 변경하면 당연히 생성되어 있는 테이블에 변경사항이 자동적으로 반영되겠거니 생각했었지만 그렇지 않았다.
     
    예를 들어 해당 Entity가 다음과 같이 변경되었다고 하면,

    @Entity
    public class Semester {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
    
        // Boolean 타입의 column이 String 타입으로 변경되었고, 이름이 바뀌었다.
        private String status;
        
        // constructors
        // ...
    }

     
    H2에는 다음과 같이 반영되었다.

     
    FINALIZED column이 STATUS column으로 변경되는 것이 아니라, 기존의 column은 그대로 유지된 채 STATUS column이 생성되는 식이었다.
     
    이러한 식으로 변경사항이 반영되는 이유는 일단은 application.properties에 정의해두었던 JPA의 구현체인 Hibernate에 부여한 데이터베이스 초기화 전략으로 인한 것으로 확인했었다.

    spring.jpa.hibernate.ddl-auto=update

     
    아직까지는 해당 문제에 대해 H2 Console에서 쿼리문을 직접 날려 사용하지 않게 된 column이나 테이블들을 직접 수정하거나 삭제하는 식으로 대응해오고 있었다.

    ALTER TABLE semester
    DROP COLUMN finalized;

     
     
    최근 다른 프로젝트를 진행하면서도 Entity를 변경한 뒤에 EntityManager를 직접 이용하는 테스트를 진행하거나 로컬 환경에서 서버를 실행시키는 과정에서 비슷한 문제를 다시 한 번 겪게 되었다. 이런 Entity와 데이터베이스의 상태 변화를 편리하게 관리할 수 있도록 도와주는 도구가 있을지 찾아보던 와중 Flyway에 대해 알게 되었고, 프로젝트에 적용해보면서 어떻게 사용할 수 있을지 알아보기로 했다.
     
     
     

    Flyway란?

     

    Homepage - Flyway

    Version control for your database. Robust schema evolution across all your environments. With ease, pleasure, and plain SQL.

    flywaydb.org

    Flyway는 데이터베이스의 변경 사항을 추적해 형상을 관리하기 위한 도구이다. 쉽게 말하자면 데이터베이스의 변경 사항을 Git처럼 관리할 수 있게 해주는 도구라고 할 수 있겠다.
     
     
     

    Flyway 사용을 위한 환경 세팅

    기존에 정의되어 있는 Spring Boot 프로젝트의 build.gradle에 다음의 내용들을 추가했다.

    plugins {
        id 'java'
        id 'org.springframework.boot' version '2.7.11'
        id 'io.spring.dependency-management' version '1.0.15.RELEASE'
        
        // plugins 안에 다음을 추가한다.
        id 'org.flywaydb.flyway' version '9.8.1'
    }
    
    group = 
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '11'
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
            
        developmentOnly 'org.springframework.boot:spring-boot-devtools'
    
        runtimeOnly 'com.h2database:h2'
        runtimeOnly 'org.postgresql:postgresql'
    
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        
        // dependencies 안에 다음을 추가한다.
        implementation 'org.flywaydb:flyway-core'
    }
    
    tasks.named('test') {
        useJUnitPlatform()
    }

     
     
    그리고 application.properties에는 다음의 설정을 추가했다.

    spring.flyway.enabled=true
    spring.flyway.baseline-version=1
    spring.flyway.baselineOnMigrate=true

     
     
     

    마이그레이션 파일 생성하기

    Flyway는 SQL문을 기반으로 데이터베이스의 형상을 관리한다. 테이블의 스키마를 정의하거나, 스키마 구조가 변경되었을 때 이 변경 사항을 create, alter, drop 등의 DDL문으로 SQL문을 작성한 뒤, 작성한 SQL문을 지정한 디렉터리에 파일 형태로 저장하는 방식을 따른다.
     
    프로젝트에 다음과 같이 디렉터리를 생성하자. 해당 디렉터리는 Flyway에서 마이그레이션 히스토리를 추적하는 기본 경로이다.

    src/main/resources/db/migration

     
    여기에 형상 관리 파일을 생성하면 테이블 스키마의 변동 사항이 추적되는 방식이다. 이때 각 형상 관리 파일의 이름은 정해진 네이밍 컨벤션을 따라 지어야 한다.

    - Prefix: 어떤 마이그레이션 사항인지 나타낸다. 'V'는 새로운 변경사항을 적용하는, 'U'는 변경사항을 이전으로 되돌리는, 'R'은 쿼리를 통해 모의 데이터를 삽입하는 마이그레이션임을 나타낸다.
    - Version: 점(.)이나 언더바 한 개(_)를 적절히 사용해 편의에 맞게 버전을 구분한다.
    - Separator: 반드시 언더바 두 개(__)를 입력해야 한다.
    - Description: 마이그레이션의 구체적인 설명을 기술한다. 맨 앞글자가 대문자로 시작하는 Snake_case 형식으로 작성한다.

     
     
    마이그레이션 히스토리는 이런 식으로 관리되게 된다.

    resources
    ┗ db
    ㅤ┗ migration
    ㅤㅤ┣ V1__Init.sql
    ㅤㅤ┣ V2.1__Drop_enrollment.sql
    ㅤㅤ┣ V2.2__Drop_student_and_lecture_association.sql
    ㅤㅤ┣ V3__Lorem_ipsum.sql
    ㅤㅤ┣ ...

     
     

    최초 마이그레이션 파일 생성하기

    * 본 예시는 데이터베이스 테이블에 삽입되어 있는 Tuple이 없는 상황을 가정했습니다.
     
    최초로 마이그레이션 파일을 작성하는 경우, 마이그레이션 파일을 작성하는 시점의 데이터베이스 테이블의 스키마를 바탕으로 테이블을 생성하는 DDL 쿼리문을 작성해 주자. (처음에는 Entity 필드에 맞추는 것이 아니라, 생성되어 있는 데이터베이스 테이블의 스키마에 맞춰야 함을 유의하자! 테이블이 이미 생성되어 있었고, Entity 필드를 변경하는 과정에 있었을 경우 Entity와 테이블의 스키마가 일치하지 않을 것인데, 우선은 현재 생성되어 있는 테이블 스키마에 맞추는 것이 먼저이다.)
     
     
    다음의 Entity들과 테이블 구조를 살펴보자. 편의상 column에 해당되는 필드 값만 명시했다.
     
    간단한 수강 등록 서비스를 만든다고 가정해보자. 아주 작은 기초 단계부터 시작해보자. 학생, 강의, 등록이라는 Entity가 있고, 이에 맞게 테이블이 형성되어 있다고 보자. 학생은 말 그대로 학생, 강의는 학생이 수강할 수 있는 강의, 등록은 학생이 어떤 강의를 수강하는지에 대한 정보를 알 수 있는 Entity이다.
     

    @Entity
    public class Lecture {
        @Id
        @GeneratedValue
        private Long id;
        
        private String name;
    }
    @Entity
    public class Student {
        @Id
        @GeneratedValue
        private Long id;
        
        private String name;
    }
    @Entity
    public class Enrollment {
        @Id
        @GeneratedValue
        private Long id;
    
        private Long lectureId;
    
        private Long studentId;
    }

     
    테이블 구조는 위의 Entity에 맞게 생성되어 있다.

     
     
    마이그레이션 파일을 생성해보자. 형상 이력의 가장 처음을 생각해보면 아무것도 없는 상태일 것이고, 맨 처음에 이루어져야 할 변화는 지금 생성되어 있는 상태의 테이블 스키마에 맞추어 테이블들을 생성하는 것일 것이다.
     
    따라서 다음과 같이 테이블을 생성하는 쿼리문을 작성한다.

    # V1__Init.sql
    
    DROP TABLE IF EXISTS student;
    DROP TABLE IF EXISTS lecture;
    DROP TABLE IF EXISTS enrollment;
    
    CREATE TABLE student(
        id BIGINT NOT NULL AUTO_INCREMENT,
        name CHARACTER VARYING(255) DEFAULT NULL,
        PRIMARY KEY (id)
    );
    CREATE TABLE lecture(
        id BIGINT NOT NULL AUTO_INCREMENT,
        name CHARACTER VARYING(255) DEFAULT NULL,
        PRIMARY KEY (id)
    );
    CREATE TABLE enrollment(
        id BIGINT NOT NULL AUTO_INCREMENT,
        student_id BIGINT DEFAULT NULL,
        lecture_id BIGINT DEFAULT NULL,
        PRIMARY KEY (id)
    );

     
    테이블 생성 쿼리문은 해당 테이블의 속성과 동일하게 작성해줘야 하는데, 테이블의 속성은 H2 Console의 경우 다음의 명령어를 쿼리해 확인할 수 있다. 확인된 속성에 맞게 작성해주자. 만약 해당 Entity가 id 자동 생성 전략을 취하고 있을 경우, id column에는 생성 전략과 관련된 속성도 같이 부여되어야 한다.

    SHOW COLUMNS FROM <테이블명>;

     
     
    마이그레이션 파일을 작성한 뒤, 서버를 구동시키고 H2 Console을 확인하면 'flyway_schema_history' 테이블이 생성된 것을 확인할 수 있다.

     
    방금 작성한 'V1__init.sql'은 BASELINE의 역할을 수행한다. 말 그대로 기준선으로, Flyway가 해당 시점의 테이블 스키마를 기준으로 데이터베이스 테이블의 형상 변경 내역을 추적하게 된다.
     
     
     

    Entity의 변경 사항을 마이그레이션 파일에 작성하기

    기능을 확장시키면서 다음과 같은 상황을 추가로 고려해야 하게 되었다고 가정해보자.

    - 강의는 사실 특정 '과목'에서 파생된 것이다. 예를 들어 '창조적사고와표현'이라는 과목이 있다고 한다면, 해당 과목을 9:00~12:00에 수강할 수 있는 강의가 있을 수 있고, 13:00~16:00에 수강할 수 있는 강의도 있을 수 있다.

     
    요구사항을 고려한 결과, Entity 구조를 다음을 고려하여 수정하기로 결정했다.

    강의 Entity에 이름이 있었으니까 강의 Entity를 그냥 과목 Entity로 이름만 바꿔도 될 것 같다. 대신 새로 생성할 강의 Entity에는 이제 어떤 과목에 속하는지 알 수 있어야 하니까 과목 Entity의 id를 부여해야 할 것 같다.

     
     
    결정한 사항을 바탕으로 Entity가 수정되었다.

    // Lecture Entity가 Course Entity로 이름이 바뀌었다.
    @Entity
    // public class Lecture {
    public class Course {
        @Id
        @GeneratedValue
        private Long id;
        
        private String name;
    }
    // 새로운 Lecture Entity가 정의되었다.
    @Entity
    public class Lecture {
        @Id
        @GeneratedValue
        private Long id;
        
        private Long courseId;
    }

     
    이제 Entity가 변경되었으니 테이블 스키마의 형상을 변경된 Entity에 맞게끔 업데이트하는 마이그레이션 파일을 작성해보자. 다음과 같이 쿼리문을 작성했다.

    # V2.1__Modify_lecture_to_course.sql
    
    ALTER TABLE lecture
    RENAME TO course;
    # V2.2__Create_lecture_table.sql
    
    DROP TABLE IF EXISTS lecture;
    
    CREATE TABLE lecture(
        id BIGINT NOT NULL AUTO_INCREMENT,
        course_id BIGINT DEFAULT NULL,
        PRIMARY KEY (id)
    );

     
     
    마이그레이션 파일을 작성한 뒤, 서버를 구동시키고 H2 Console을 확인해보면 테이블 스키마가 변경된 것을 확인할 수 있다.

     
     
    'flyway_schema_history' 테이블에는 정의한 마이그레이션 버전이 삽입되었다.

     
     
     

    Flyway 적용 회고

    사실 Flyway라는 도구가 있다는 것을 처음 알았을 때 든 생각은 '오 이거 사용하면 Entity 객체의 정보를 바꾸고 서버를 실행하기만 하면 테이블이 알아서 루삥뽕 바뀌어 있는 마법의 가루약 같은 도구인가??' 싶었다. 하지만 Flyway를 학습해보면서 적용해본 결과 Flyway는 그런 자동화된 마법 가루약 같은 도구가 아니었다. Flyway를 쓰면서 바뀐 것은, 단지 테이블 스키마를 Entity와 맞추기 위한 쿼리문 작성을 기존에는 Entity를 변경하고 난 뒤 직접 DB에 접속해서 하던 것에서 별도의 히스토리를 두고 거기에 작성하는 것으로 바뀐 게 전부였다.
     
    그래서 Entity와 테이블 스키마를 맞추기 위한 쿼리문 자체는 여전히 직접 작성해야 한다. 도입 이전에 비해 막 눈에 띄게 편리해지고 그렇지는 않았다. 하지만 생각지 못했던 곳에서 발견된 이점이 있었다. 소스코드를 Git을 이용해 변경 사항을 관리할 수 있듯, 이제는 테이블 스키마도 마이그레이션 히스토리를 통해 변경 사항을 관리할 수 있게 되었다는 것이다. 지금이야 작은 프로젝트를 만드는 것이니까 테이블을 한 눈에 관리할 수 있는 수준이겠지만 복잡하고 규모가 큰 서비스의 서버 애플리케이션이라면? 변경 이력을 알 수 없다면, 스키마를 변경했을 때 오류가 발생했을 경우 어떤 설정을 잘못 바꿔서 오류가 발생한 것인지 파악하기 쉽지 않을 것이다.
     
    기대했던 성과만큼은 아니었지만, 테이블 스키마도 소스코드와 마찬가지로 형상 관리의 영역으로 들여올 수 있다는 것을 알게 되었다는 점에서 꽤 괜찮았던 기술 도입이었다는 생각이 든다.
     
     
     

    어려웠던 점

    마이그레이션 스크립트에 작성한 쿼리문이 올바르지 않을 경우, 해당 버전으로의 마이그레이션이 실패하면서 예외가 발생해 서버가 실행되지 않는다. 이 상황에서 마이그레이션 스크립트를 다시 올바르게 바꾸어도 데이터베이스의 flyway_schema_history 테이블에는 이미 실패한 마이그레이션 내역이 Tuple로 삽입되었기 때문에, 수정한 마이그레이션 스크립트와 이전 마이그레이션 내역 Tuple이 불일치해 또다시 예외가 발생하면서 서버가 실행되지 않는다.
     
    아직까지는 이럴 경우 데이터베이스에 직접 접속해 flyway_schema_history에서 해당 실패했던 Tuple을 직접 지운 뒤 서버를 다시 실행시키는 식으로 문제를 해결하고 있는데, 문제를 더 나은 방법으로 해결할 수 있을지 찾아봐야 할 것 같다.
     
     
     

    추가로 해 볼 수 있을 것들

    - 실행 환경 별로 이력을 관리할 수 있을지 알아보기
    - Repetable Migration을 이용해 모의 데이터를 삽입하는 방식에 대해 찾아보기
     
     
     

    References

    - https://spring.io/guides/gs/accessing-data-mysql/
    - https://flywaydb.org/
    - https://ecsimsw.tistory.com/entry/Flyway%EB%A1%9C-DB-Migration
    - ChatGPT
     
     
     
     

    댓글

Designed by Tistory.