안녕하세요, 힐링페이퍼에서 백엔드를 담당하고 있는 제이슨입니다!
사용자가 불편함 없이 빠르고 안정적으로 더 좋은 의료 서비스를 이용할 수 있도록 하는 역할을 하고 있습니다.

이번에 공유드릴 내용은 작년쯤 힐링페이퍼에 입사해서 가장 먼저 진행했던 Flyway 로 DB schema, seed 관리하기 입니다. Flyway 의 자세한 사용방법은 다루지 않지만, 실제로 적용하면서 필요한 현실적인 고민과 작업을 담았습니다.

DB schema 를 코드로 관리한다는 것

저는 힐링페이퍼에 오기전에 Ruby, Elixir 를 경험했었는데, 그 때는 DB schema 이력을 코드로 관리하던 것이 일상적이었습니다.

# https://hexdocs.pm/ecto_sql/Ecto.Migration.html#module-creating-your-first-migration

defmodule MyRepo.Migrations.AddWeatherTable do
  use Ecto.Migration

  def up do
    create table("weather") do
      add :city,    :string, size: 40
      add :temp_lo, :integer
      add :temp_hi, :integer
      add :prcp,    :float

      timestamps()
    end
  end

  def down do
    drop table("weather")
  end
end

DB schema 를 코드로 관리했을 때 얻을 수 있는 이점은 아래와 같습니다.

  • DB schema 의 변경 이력이 남는다

    DB schema 도 어플리케이션 코드처럼 이력이 관리되어야 할 대상입니다. 예를 들어 뒤늦게 DB schema 에서 어떤 문제가 발견되었다면, 이력이 남아있지 않을 때와 남아있을 때의 문제 해결 난이도는 많이 다를 것입니다.

  • DB schema 변경 작업이 더 안전해진다

    코드로 관리한다는 것은 프로그래밍을 할 수 있다는 것입니다. 그래서 단순히 이력을 남기는 것에 그치지 않고 DB migration 작업 자체를 코드로 할 수 있게 됩니다.

    # https://hexdocs.pm/ecto_sql/Ecto.Migration.html#module-mix-tasks
    
    # migrate
    $ mix ecto.migrate
    
    # rollback
    $ mix ecto.rollback --step 1
    

    사람이 직접 DB 에 작업하는 대신 코드로 실행하기 때문에 빼먹거나 실수할 여지가 줄어들고, 배포 시에 자동화도 가능합니다.

  • 더 많은 사람이 협업 하기에 수월해진다

    코드로 관리가 되기 때문에 서로 리뷰를 받을 수 있어 미리 문제를 파악하기 좋고, 실제 환경과 동일한 local 환경을 구축하기 쉬워져서 local 에서 개발하기가 훨씬 수월해집니다.

방안

위의 이점들을 힐링페이퍼에도 도입하기 위해 자료를 탐색해보았지만 Java 나 Spring 에는 아직 이 부분에 대한 좋은 방법을 찾을 수 없었고, 그밖에 사용해볼만한 것이 Flyway, Liquibase 두 가지였습니다.

각각의 장점은 다음과 같습니다.

  • Flyway

    • 사용하기 간단하다

      Liquibase 대비 계층이 적고 작업이 단순해서 사용하기 간단합니다.

    • 문서화가 잘 되어있다

      이 작업을 했을 때는 2019년 7월쯤으로 Liquibase 의 문서가 보기 좋게 정리되어있지 않았었습니다.
      (지금도 그렇게 친절하지는 않은 것 같지만..)
      Flyway 는 상대적으로 문서화가 잘 되어있어서 파악하기 쉬웠습니다.

  • Liquibase

    • 기능이 풍부하다

      예를 들면, rollback 은 Flyway 에서 무료로 사용할 수 없는 기능이지만 Liquibase 에서는 사용할 수 있습니다. multi-context 에 대한 대응도 잘되어있습니다.

당시에는 Java 를 처음 사용해보면서 진행하는 작업이었기 때문에, 문제의 복잡도를 줄이기 위해 둘 중 더 사용하기 간단하고 문서화가 잘 되어있는 쪽으로 선택했습니다. 1년이 지난 지금 선택하라면 Liquibase 로 할 것 같기는 합니다.

목표

이 작업을 하면서 아래와 같은 목표를 이루고자 하였습니다.

  • 현재 DB schema 를 코드로 옮긴다

    DB schema 를 코드로 관리하지 않는다면 이를 분석하기 위해서는 DB 에 직접 접속해서 찾아보는 수밖에 없습니다. 테이블간의 관계를 알아보기 위해 DB table 들을 왔다갔다 해야하고, Index 나 Foreign Key 등의 변경은 제대로 추적되지 않아 기능이 변경될 때 함께 고려되기 힘듭니다.

    기존 DB schema 를 전부 코드로 옮김으로써 현재 상태를 파악하기 쉽게하는 것을 목표로 하였습니다.

  • 앞으로 DB schema 변경을 코드로 관리한다

    새로운 작업을 할 때 DB schema 변경 또한 같이 고려되어 리뷰를 주고받을 수 있고, 누구든 궁금할 때 DB schema 변경 이력을 찾아볼 수 있도록 하는 것을 목표로 하였습니다.

  • 배포 시에 Flyway 를 이용해 DB migration 을 수행한다

    사람하면 문제가 생깁니다. DB migration 을 코드로 수행해서 사람의 실수가 개입할 여지를 최소화하는 것을 목표로 하였습니다. 조금 더 신경쓰면 배포 자동화에도 이용할 수 있습니다.

  • local 환경 작업을 위한 seed 를 코드로 관리한다

    local 환경 작업을 위해서는 seed 가 필수적입니다. 매 번 DB 를 reset 할 때마다 user 를 가입시키고 있을 수는 없으니까요.

    이 부분은 SQL 이 아닌 코드로 하면 굉장히 편해지지만 이미 서비스가 커져있는 상태에서 이러한 코드 기반을 만드는 작업 또한 간단하지는 않았기 때문에, Flyway 도입을 하면서 이를 이용해 seed 도 생성할 수 있도록 하는 것을 목표로 하였습니다.

이 중 '배포 시에 Flyway 를 이용해 DB migration 을 수행한다' 를 제외하고는 목표를 달성할 수 있었습니다. 이 부분은 조금씩 적용해나가는 중입니다.

작업

Flyway 설치 및 설정

macOS 에서는 brew 로 간단하게 설치할 수 있습니다.

$ brew install flyway

local 환경 기준으로 config 파일을 하나 만들어보겠습니다.

# ./src/main/resources-local/flyway_main.conf
# (여러 context 중 main context 에 해당하는 flyway config 파일)

flyway.url=jdbc:mysql://localhost:43306/
flyway.schemas=my_app_local
flyway.user=root
flyway.password=password
flyway.locations=filesystem:src/main/resources/db/migration/main

현재 DB schema 를 기준으로 migration 파일 생성

현재 DB schema 의 DDL 이 필요했습니다. 간단하게 mysqldump 를 이용해서 현재 DDL 을 받았습니다.

$ mysqldump -h <host> -u <user> --databases <space-sperated databases> --no-data > <output file name>

이렇게 받은 DDL 을 테이블 별로 분리하면서 의존성을 분석했습니다. Table 간에도 Foreign Key 나 논리적 연결을 통해 dependency 가 존재하기 때문에 실행의 순서가 중요합니다. 예를 들어 useruser_type 에 의존적이라면 user_types table 이 먼저 생성되어야 합니다. (user 생성user_type 생성user 에 user_type 관계 추가 순서로 할 수도 있지만 이렇게까지 하려면 히스토리 파악이 필요해서 너무 오래걸립니다.) 이렇게 migration 파일들의 순서를 정하기위해 DB table 간의 dependency 파악이 필요했습니다.

Domain 별로 분리한 DB table dependency 파악의 일부

더불어 저희 서비스에서는 여러 DB 를 사용하고 있어서, 이를 각각 컨트롤하기 위해 context 를 분리했습니다. 이렇게 하면 서로 다른 context 끼리는 version 순서나 겹침을 신경쓰지 않아도 됩니다. 각각의 context 는 Flyway config 파일을 별도로 생성해서 다룹니다.

3가지 context 로 나눈 DB schema SQL 들

dependency 파악 후에 migration 을 순서대로 만들었는데, 이 때 Flyway 의 규약에 맞는 version 을 붙여주어야 합니다.

flyway 에서 version 을 파일명으로 나타내는 방식은 아래와 같습니다.

version 과 description 사이에 underbar 가 두개!

참조: https://flywaydb.org/documentation/migrations#naming

나중에 새로운 DB schema migration SQL 파일을 만들 때 매번 버전 숫자 따져가며 작업하는 것은 귀찮기 때문에, timestamp 형식(%Y%m%d%H%M%S) 으로 version 을 붙이기로 하고 새로 만든 파일들도 그 형식을 따랐습니다.

작업한 파일들이 잘 작동하는지 확인해봅시다.

$ flyway -configFiles=./src/main/resources-loal/flyway_main.conf migrate

결과가 현재 DB 와 실제로 일치하는지도 확인해야합니다. Liquibase 에 diff 기능이 있어서 사용해서 확인해보았습니다.

참조: https://docs.liquibase.com/commands/community/diff.html

seed 파일 생성

Seed 를 SQL 로 관리하기 위해 달성되어야 할 조건은 다음과 같습니다.

  • migration 과 별도로 컨트롤 할 수 있어야 합니다.

    seed 는 production 환경이 아닌 곳에서만 사용되기 때문에 migration 이 실행될 때 같이 실행되거나 해서는 안됩니다.

  • 도중에 몇 번을 실행해도 괜찮아야 합니다.

    작업 도중에 새로운 seed 파일을 생성했다고 해서 데이터를 다 지우고 새로 seeding 을 해야하는 것은 번거롭습니다. seeding 을 실패했을 때도 이미 생성된 데이터와 관계없이 중간부터 쉽게 다시 시작될 수 있어야 합니다.

이를 위해 아래와 같이 작업했습니다.

  • 별도의 폴더에 seed 파일을 두었습니다.
  • INSERT IGNORE 을 사용해서 도중에 새로운 row 가 추가되어도 적용이 가능하게 했습니다.

    # R__001_Seed_country.sql
    
    INSERT IGNORE INTO country(code, name, is_public, is_supported) VALUES
      ("KR", "한국", TRUE, TRUE),
      ...;
    
  • Flyway 의 Repeatable Migration 으로 적용하여 중복 실행할 수 있게 했습니다.

migration 파일과 마찬가지로 확인해볼 수 있습니다.

# ./src/main/resources-local/flyway_seed.conf

flyway.url=jdbc:mysql://localhost:43306/
flyway.schemas=my_app_local
flyway.user=root
flyway.password=password
flyway.locations=filesystem:src/main/resources/db/seed
$ flyway -configFiles=./src/main/resources-loal/flyway_seed.conf migrate

Flyway 도입 이후의 작업 방식

도입을 했으니 이제 일하는 방식이 바뀌어야 합니다.

DB 변경 작업이 있을 때

기존에는 별도의 SQL 관련 작업이 없었으나, 이제는 테이블을 생성하거나 수정할 때 migration 파일을 만듭니다.

# migration 파일 생성
$ touch ./src/main/resources/db/migration/main/V20201012100000__Create_posts.sql

# migration 파일 작성
CREATE TABLE posts (
	...
)

# local DB migration
$ flyway -configFiles=./src/main/resources-local/flyway_main.conf migrate

이 때 migration 파일은 가능한 한 파일이 하나의 작업만 하도록 합니다. MySQL 의 경우에는 DDL Transaction 이 없고, 5.x 까지는 atomic 하지도 않기 때문에, 한 migration 파일에서 여러 작업을 동시에 진행한다면 도중에 실패하거나 중단했을 때 이도저도 할 수 없게 되어 매우 답답한 상황에 처할 수 있습니다.

CREATE TABLE users (
  ...
);

# 이 SQL 이 실패하면, 재시도 하더라도 위의 SQL 에서 이미 users 가 존재하기 때문에 또 실패합니다.
CREATE TABLE posts (
);

참고: Why you should care that your sql DDL is transactional

local 작업에 필요하면 마찬가지 방식으로 seed 파일도 추가합니다.

local 환경을 셋업할 때

local 에서 편리하게 작업을 하기 위해 DB 를 정리하고 DB 에 seed 를 추가합니다. 이렇게 하면 항상 깨끗한 환경에서 작업을 할 수 있습니다.

# local DB clean
$ flyway -configFiles=./src/main/resources-local/flyway_main.conf clean
# local DB migration
$ flyway -configFiles=./src/main/resources-local/flyway_main.conf migrate
# local DB seed
$ flyway -configFiles=./src/main/resources-local/flyway_seed.conf migrate

기타

CLI 를 선택한 이유

Flyway 는 CLI, API, Maven, Gradle 4가지 방법으로 컨트롤이 가능합니다. 이 중 CLI 를 제외한 다른 방법들은 다른 무언가에 dependency 가 있거나 설정이 불필요하게 복잡해서, dependency 도 없고 유연하게 사용할 수 있는 CLI 를 이용하기로 했습니다.

스크립트 작성

Flyway 의 명령을 직접 사용하려면 매우 귀찮습니다. 이를 간단하게 사용할 수 있도록 간단하게 Ruby 로 script 를 작성해서 사용하고 있습니다.

이렇게 하면 특정 환경에 따른 제어도 가능해서, prod 환경에서는 clean 을 할 수 없게 만든다든지 하는 것도 가능합니다.

# 현재 시간을 version 으로 하여 원하는 context 에 migration 파일 생성
$ db gen_migration main Add_view_count_to_post

# local DB clean
$ db clean main --env=local

# stage DB migration
$ db migration main --env=stage

# local DB seed (env 는 local 이 default)
$ db seed

# local DB clean + migration + seed
$ db reset

한계

Flyway 로 DB schema 를 관리했을 때 한계점도 존재합니다.

  • Rollback, Dry run 기능이 유료이다
    • 돈을 내면 해결할 수 있습니다. (하지만 비쌉니다)
  • Java 가 아닌 SQL 로 migration 을 작성해서 프로그래밍이 힘들다
    • 자주 사용하는 DB schema 작업 등을 함수로 만들어서 재사용하기 힘듭니다.
  • 표준이 아닌 구문을 사용하면 다른 RDBMS 에서는 작동 안될 수도 있다
    • 코드로 RDBMS 별 adapter 를 만들어서 여러 RDBMS 에서 공통적으로 동작하게 할 수 없습니다.
  • Seed 데이터에 암호화된 정보를 넣는 등의 작업이 힘들다
    • password hash 를 저장해서 로그인할 수 있도록 하려면 어떻게 해야할까요?

대안

Rails 등을 참조해 Kotlin 으로 만든 프로젝트가 존재합니다.

https://github.com/KenjiOhtsuka/harmonica

package your.migration

import com.improve_future.harmonica.core.AbstractMigration

/**
 * HolloWorld
 */
class M20180624011127699_HolloWorld : AbstractMigration() {
    override fun up() {
        createTable("table_name") {
            boolean("boolean_column", nullable = false, default = true)
            integer("integer_column", default = 1)
            decimal("decimal_column", 5, 2, default = 3)
        }

        // When you add index, use `addIndex`.
        createIndex("table_name", "column_name")
    }

    override fun down() {
        dropTable("table_name")
    }
}

사용 예제는 아래 repo 를 참조해주세요

https://github.com/nallwhy/spring_boot_rumbl

Reference:
https://flywaydb.org/documentation/
https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-database-initialization
https://www.popit.kr/나만-모르고-있던-flyway-db-마이그레이션-tool/
https://www.lesstif.com/pages/viewpage.action?pageId=17105261

Json
Back-end Developer
사용자가 빠르고 안정적으로 강남언니를 이용하실 수 있게 Back-end 를 담당하고 있습니다. 생각하는 개발자입니다.