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

[신입 개발자의 두 번째 라이브러리 개발기] 도메인 특화 언어(DSL)을 만들어 보자! DDL-DSL 개발기 -1. 구현-

by 시니성 2024. 12. 1.

DDL-DSL 라이브러리 구현

핵심 개념

우리가 일반적으로 데이터베이스 테이블을 생성할 때는 SQL DDL을 직접 작성합니다. 예를 들어, 사용자 테이블을 만들기 위해서는 다음과 같은 SQL을 작성해야 합니다:

CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

이러한 SQL 작성 방식은 여러 가지 문제를 가지고 있습니다. 특히 여러 데이터베이스를 지원해야 할 때, 각 데이터베이스마다 다른 SQL 문을 작성하고 관리해야 합니다. DDL-DSL 라이브러리는 이 문제를 해결하기 위해 Kotlin DSL을 사용하여 다음과 같은 방식으로 테이블을 정의할 수 있게 해줍니다:

TableDefinition.create("users") {
    val id = column("id", ColumnType.Integer) autoIncrement true
    column("name", ColumnType.String()) nullable false
    column("email", ColumnType.String()) {
        nullable(false)
        unique(true)
    }
    column("created_at", ColumnType.TimeStamp) {
        nullable(false)
        default(DateTimeType.Now)
    }
}

추상화 과정과 주요 구성 요소

1. SqlConvertable - 기본 추상화

가장 기본이 되는 추상화는 SQL로 변환 가능한 모든 요소들의 공통 계약을 정의하는 SqlConvertable 인터페이스입니다:

fun interface SqlConvertable {
    fun toSql(dialect: SqlDialect): String
}

이 인터페이스는 테이블 정의, 칼럼 타입 등 SQL로 변환되어야 하는 모든 컴포넌트들의 기본이 됩니다.

2. 컬럼 타입 시스템

데이터베이스의 각 컬럼은 특정 데이터 타입을 가집니다. DDL-DSL은 이를 타입 안전한 방식으로 표현하기 위해 sealed 클래스 계층구조를 사용합니다:

sealed class ColumnType<DataType, ConfigType : ConfigMarker> : SqlConvertable {
    data object Integer : ColumnType<Int, Nothing>()
    class String(limit: Int = 0) : ColumnType<kotlin.String, String.Config>()
    data object TimeStamp : ColumnType<DateTimeType, Nothing>()
    // ... 기타 타입들
}

이 추상화는 다음과 같은 고민들을 반영합니다:

  • 각 데이터베이스 타입은 고유한 Kotlin 데이터 타입(DataType)과 매핑
  • 특정 타입들은 추가 설정이 필요(ConfigType)
  • 모든 타입은 SQL로 변환 가능(SqlConvertable)

3. 컬럼 정의 시스템

ColumnDefinition 클래스는 실제 데이터베이스 칼럼의 모든 특성을 포함하도록 설계되었습니다:

class ColumnDefinition<Def : ColumnType<DataType, ConfigType>, DataType, ConfigType : ConfigMarker>(
    val name: String,
    private val type: Def,
    private var isNullable: Boolean = true,
    private var isUnique: Boolean = false,
    internal var isAutoIncrement: Boolean = false,
    private var defaultValue: DataType? = null,
)

이 클래스는 다음 특성들을 관리합니다:

  • 칼럼 이름
  • 데이터 타입
  • NULL 허용 여부
  • 유니크 제약조건
  • 자동 증가 여부
  • 기본값

4. SQL 방언 시스템

서로 다른 데이터베이스는 같은 개념을 다르게 표현합니다. 예를 들어, 자동 증가 기본 키는 SQLite에서는 INTEGER PRIMARY KEY AUTOINCREMENT로, MS SQL에서는 INT IDENTITY(1,1) PRIMARY KEY로 표현됩니다. 이러한 차이를 처리하기 위해 방언 시스템을 도입했습니다:

interface SqlDialect {
    val columnType: ColumnTypeCreator
    val tableConfig: TableConfigCreator
    val columnConfig: ColumnConfigCreator
}

SQLite 구현의 예:

class SqliteDialect : SqlDialect {
    override val columnType = object : ColumnTypeCreator {
        override fun integer() = "INTEGER"
        override fun string(config: ColumnType.String.Config) = "TEXT"
    }
    // ... 기타 구현
}

추상화를 통해 얻은 이점

1. 타입 안전성

DDL-DSL은 제네릭을 활용하여 컴파일 타임에 타입 안전성을 보장합니다:

// 데이터 타입과 기본값의 타입 안전성
column("age", ColumnType.Integer, defaultValue = 20) // OK
column("age", ColumnType.Integer, defaultValue = "20") // 컴파일 에러 - Int 타입이 필요한데 String이 들어감

// 설정 타입의 안전성
column("name", ColumnType.String()) config ColumnType.String.Config(limit = 100) // OK
column("name", ColumnType.String()) config ColumnType.Double.Config() // 컴파일 에러 - 잘못된 Config 타입

이러한 타입 시스템은 다음을 보장합니다:

  • 각 컬럼 타입에 맞는 올바른 데이터 타입만 기본값으로 설정 가능
  • 각 컬럼 타입에 맞는 올바른 설정(Config) 타입만 사용 가능
  • 제네릭을 통한 컴파일 타임 타입 체크

2. 설정의 유연성

각 칼럼 타입에 맞는 설정을 타입 안전하게 적용할 수 있습니다:

column("price", ColumnType.Decimal()) {
    config(ColumnType.Decimal.Config(precision = 10, scale = 2))
    nullable(false)
}

3. 데이터베이스 독립성

동일한 테이블 정의를 여러 데이터베이스에서 사용할 수 있습니다:

val tableDef = TableDefinition.create("users") { ... }
tableDef.toSql(SqliteDialect())  // SQLite용 SQL
tableDef.toSql(MsSqlDialect())   // MS SQL용 SQL

실제 사용 예시

주문 테이블을 생성하는 실제 예시를 통해 라이브러리의 사용성을 확인할 수 있습니다:

val orderTable = TableDefinition.create("orders") {
    val id = column("id", ColumnType.Integer) autoIncrement true
    val userId = column("user_id", ColumnType.Integer) nullable false
    column("total_amount", ColumnType.Decimal()) {
        config(ColumnType.Decimal.Config(precision = 10, scale = 2))
        nullable(false)
    }
    column("status", ColumnType.String()) default "PENDING"
    column("created_at", ColumnType.TimeStamp) default DateTimeType.Now

    key {
        index(userId, indexName = "idx_orders_user_id")
    }
}

타입 안전성과 검증

DDL-DSL은 다양한 수준의 검증을 수행합니다:

  • 컬럼 타입과 데이터 타입의 일치 여부
  • 필수 설정의 누락 여부
  • 중복된 컬럼 이름 검사
  • 자동 증가 컬럼의 유효성 검사
  • 기본 키 설정의 유효성 검사

이러한 검증들은 데이터베이스 스키마의 무결성을 보장하는 데 도움을 줍니다.

결론

이렇게 구현된 DDL-DSL 라이브러리는 타입 안전성, 사용 편의성, 그리고 멀티 데이터베이스 지원이라는 목표를 달성했습니다. 실제 프로덕션 환경에서 개발자의 실수를 방지하고, DDL 작성과 수정에 드는 시간과 노력을 크게 줄여주었습니다.

다음 편에서는 앞으로의 업데이트 방향을 살짝 조명하는 것으로 제작기 포스팅을 마치려 합니다.
현재는 create문만 지원하지만 차후에는 alter등의 ddl도 지원해야 하고, 나아가 migration을 지원할 예정이거든요

그럼 오늘도 부족하고 긴 글 읽어주셔서 정말 감사합니다!

728x90