본문 바로가기
작디 작은 나만의 라이브러리/DDL-DSL

[주니어 개발자의 두 번째 라이브러리 개발기] 도메인 특화 언어(DSL)을 만들어 보자! DDL-DSL 개발기 -4. 마이그레이션 기능-

by 시니성 2025. 2. 15.

저번 편에서는 마이그레이션 기능 구현시 필요한 ALTER와 DROP기능 개발을 살펴 보았습니다.
이번 편에서는 DDL-DSL을 사용하는 프로젝트 첫 출시 전, 꼭 필요했던 마이그레이션 기능 개발에 대해 다루어 보겠습니다.

마이그레이션의 필요성

데이터베이스 스키마는 시간이 지남에 따라 변경되기 마련입니다.
새로운 기능이 추가되거나, 기존 기능이 수정되면서 테이블과 컬럼의 구조도 함께 변경되어야 하죠.
이런 변경사항을 관리하는 것은 매우 중요한 작업입니다.

특히 제 라이브러리가 사용된 프로젝트가 출시를 앞둠에 따라, 다음과 같은 요구사항들을 충족시켜야 했습니다.

  1. 스키마 변경 이력 추적
  2. 실행된 마이그레이션의 성공/실패 여부 확인
  3. 마이그레이션 버전 관리
  4. 반복 가능한 마이그레이션 지원

마이그레이션 시스템의 구현

마이그레이션 기능 개발시에는 flyway를 많이 참고하여 개발했습니다.

1. 메타데이터 테이블 설계

우선 마이그레이션 정보를 저장하기 위한 테이블을 다음과 같이 설계했습니다:

internal val migrationSchemaTableCreateDdl = TableDefinition.create("ddl_dsl_migration_schema") {
    val version = column("version", ColumnType.Integer) nullable false
    column("description", ColumnType.String(200)) nullable false
    column("installed_by", ColumnType.String(10)) nullable false
    column("type", ColumnType.DbEnum(
        enumQualifiedName = MigrationType::class.qualifiedName!!,
        simpleNamesOfEntries = MigrationType.entries.map { it.name }
    )) nullable false
    column("script", ColumnType.String()) nullable false
    column("is_success", ColumnType.DbBoolean) nullable false
    column("installed_at", ColumnType.TimeStamp) nullable false
    column("execution_time", ColumnType.BigInt) nullable false
    column("error_message", ColumnType.String(300)) nullable true
    column("checksum", ColumnType.String(64)) nullable false

    key {
        primaryKey(version)
    }
}

각 컬럼의 역할은 다음과 같습니다:

  • version: 마이그레이션 버전
  • description: 마이그레이션 설명
  • installed_by: 마이그레이션 실행자
  • type: 마이그레이션 타입 (VERSIONED, REPEATABLE)
  • script: 실행된 SQL 스크립트
  • is_success: 성공 여부
  • installed_at: 실행 시간
  • execution_time: 실행 소요 시간
  • error_message: 에러 메시지 (실패시)
  • checksum: SQL 스크립트의 체크섬

2. 마이그레이션 명령 정의

마이그레이션 명령은 다음과 같이 정의됩니다:

data class MigrationCommand(
    val version: Int,
    val description: String = "No description",
    val installedBy: String = "System",
    val type: MigrationType = MigrationType.VERSIONED,
    val script: MigrationCapable,
) : SqlConvertable {
    init {
        require(version > 0) { "Version must be greater than 0" }
    }

    override fun toSql(dialect: SqlDialect): String {
        return script.toSql(dialect)
    }
}

각 마이그레이션은 고유한 버전을 가지며, 버전은 오름차순으로 실행됩니다. 마이그레이션 타입은 다음과 같이 구분됩니다:

enum class MigrationType {
    VERSIONED,   // 한 번만 실행되는 마이그레이션
    REPEATABLE,  // 매번 실행되는 마이그레이션
}

3. SqlExecutor를 통한 유연한 실행 환경 지원

마이그레이션 실행을 위한 SqlExecutor 인터페이스를 SAM(Single Abstract Method) 인터페이스로 정의했습니다:

fun interface SqlExecutor {
    fun execute(sqlCommand: SqlConvertable)
}

이를 통해 다양한 ORM이나 데이터베이스 API와 함께 사용할 수 있습니다.
DDL-DSL은 실제 SQL실행에는 관심이 없는 라이브리 입니다.
각 환경에 맞게 SQL 실행이 가능한 방식으로 SqlExecutor를 람다로 구현하면 되죠.
이에 따라 다양한 ORM 및 DB Mapper들과 호환이 가능해졌습니다.

몇 가지 예시를 살펴볼까요?

Android(SQLite) 환경 - Ktorm 사용 시

actual fun migrate(database: Database) {
    database.useConnection { connection ->
        val sqlDialect = SqliteDialect()
        val migrationRepository = KtormMigrationRepository(database)
        Migrator(
            sqlExecutor = { sqlConvertable ->
                val sqls = sqlConvertable.toSql(sqlDialect).split(";")
                    .filter { it.isNotBlank() }
                connection.createStatement().use { conn ->
                    sqls.forEach { sql ->
                        conn.execute(sql)
                    }
                }
            },
            migrationRepository = migrationRepository,
            sqlDialect = sqlDialect,
            logger = logger
        )
            .addMigrations(MIGRATION_COMMANDS)
            .migrateAll()
    }
}

Spring 환경 - JPA 사용 시

@Component
class JpaMigrationExecutor(
    private val entityManager: EntityManager,
    private val logger: Logger
) {
    fun migrate() {
        val sqlDialect = MsSqlDialect()  // 또는 사용 중인 DB에 맞는 Dialect
        val migrationRepository = JpaMigrationRepository(entityManager)

        Migrator(
            sqlExecutor = { sqlConvertable ->
                val sqls = sqlConvertable.toSql(sqlDialect).split(";")
                    .filter { it.isNotBlank() }
                entityManager.transaction.begin()
                try {
                    sqls.forEach { sql ->
                        entityManager.createNativeQuery(sql).executeUpdate()
                    }
                    entityManager.transaction.commit()
                } catch (e: Exception) {
                    entityManager.transaction.rollback()
                    throw e
                }
            },
            migrationRepository = migrationRepository,
            sqlDialect = sqlDialect,
            logger = logger
        )
            .addMigrations(MIGRATION_COMMANDS)
            .migrateAll()
    }
}

MyBatis 사용 시

class MyBatisMigrationExecutor(
    private val sqlSession: SqlSession,
    private val logger: Logger
) {
    fun migrate() {
        val sqlDialect = PostgresDialect()  // 또는 사용 중인 DB에 맞는 Dialect
        val migrationRepository = MyBatisMigrationRepository(sqlSession)

        Migrator(
            sqlExecutor = { sqlConvertable ->
                val sqls = sqlConvertable.toSql(sqlDialect).split(";")
                    .filter { it.isNotBlank() }
                try {
                    sqlSession.connection.autoCommit = false
                    sqls.forEach { sql ->
                        sqlSession.update("migration.executeDDL", sql)
                    }
                    sqlSession.commit()
                } catch (e: Exception) {
                    sqlSession.rollback()
                    throw e
                }
            },
            migrationRepository = migrationRepository,
            sqlDialect = sqlDialect,
            logger = logger
        )
            .addMigrations(MIGRATION_COMMANDS)
            .migrateAll()
    }
}

4. 마이그레이션 실행기

마이그레이션의 핵심인 Migrator 클래스는 다음과 같은 기능을 제공합니다.

  1. 마이그레이션 스키마 테이블 자동 생성
  2. 체크섬을 통한 스크립트 변경 감지
  3. 버전 순서 검증
  4. 실행 결과 기록
  5. 반복 가능한 마이그레이션 지원
fun migrate(migrationCommand: MigrationCommand) {
    val existingMigration = migrationRepository.findByVersion(migrationCommand.version)
    if (existingMigration != null && existingMigration.isSuccess && 
        existingMigration.type.isNotRepeatable()) {
        if (existingMigration.checksum != 
            calculateChecksum(migrationCommand.script.toSql(sqlDialect))) {
            logger.error("Migration script is different.")
            throw MigrationException("Migration script is different.")
        }
        logger.info("Migration already exists.")
        return
    }

    val latestMigration = migrationRepository.findLatest()
    if ((latestMigration?.version ?: 1) > migrationCommand.version) {
        throw MigrationException("Migration version is lower than the latest.")
    }

    // ... 실행 및 결과 기록 로직
}

마이그레이션 사용 예시

실제 프로젝트에서는 다음과 같이 마이그레이션을 정의하고 실행할 수 있습니다.

val V1_ADD_ORDER_MIGRATION_TEST_1 = MigrationCommand(
    version = 1,
    description = "Add migration_test_1 column to order table",
    installedBy = "TESTER",
    type = MigrationType.VERSIONED,
    script = TableAlteration.create("ORDER_H") {
        addColumn(
            name = "migration_test_1", 
            type = ColumnType.String(255), 
            defaultValue = "test"
        )
    }
)

val MIGRATION_COMMANDS: List<MigrationCommand> = listOf(
    V1_ADD_ORDER_MIGRATION_TEST_1,
    V2_ADD_ORDER_MIGRATION_TEST_2,
    V3_DROP_ORDER_MIGRATION_TEST_2,
    V4_MODIFY_ORDER_MIGRATION_TEST_1,
    V5_DROP_ORDER_MIGRATION_TEST_1
)

actual fun migrate(database: Database) {
    database.useConnection { connection ->
        val sqlDialect = SqliteDialect()
        val migrationRepository = KtormMigrationRepository(database)
        Migrator(
            sqlExecutor = { sqlConvertable ->
                val sqls = sqlConvertable.toSql(sqlDialect).split(";")
                    .filter { it.isNotBlank() }
                connection.createStatement().use { conn ->
                    sqls.forEach { sql ->
                        conn.execute(sql)
                    }
                }
            },
            migrationRepository = migrationRepository,
            sqlDialect = sqlDialect,
            logger = logger
        )
            .addMigrations(MIGRATION_COMMANDS)
            .migrateAll()
    }
}

안전성 보장

마이그레이션 시스템은 다음과 같은 안전장치를 제공해 줍니다.

  1. 체크섬 검증: 이미 실행된 마이그레이션의 스크립트가 변경되었는지 감지
  2. 버전 순서 보장: 마이그레이션은 반드시 버전 순서대로 실행
  3. 트랜잭션 처리: 마이그레이션 실행 중 오류 발생 시 롤백
  4. 실행 이력 추적: 모든 마이그레이션의 실행 결과를 기록

결론

마이그레이션 기능의 추가로 DDL-DSL 라이브러리는 조금 더 실제 프로덕트에서도 쓸만해 졌네요 ㅎㅎ
이제 출시 후 데이터베이스 스키마 변경을 더욱 안전하고 체계적으로 관리할 수 있겠죠?

특히 다음과 같은 이점을 얻을 수 있었습니다.

  1. 스키마 변경 이력의 버전 관리
  2. 실행 결과의 추적 및 검증
  3. 다양한 실행 환경 지원
  4. 반복 가능한 마이그레이션을 통한 유연성

앞으로도 계속해서 동료분들의 피드백을 받아 더 나은 기능을 제공하도록 하겠지만 당분간은 버그 수정외의 기능추가는 없을 것 같습니다.
목표했던 기능까지 개발하고 나니 후련섭섭 하네용
그 동안 긴 글 읽어주셔서 감사합니다!

728x90