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

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

by 시니성 2024. 12. 1.
728x90

이번 편에서는 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. 테이블 정의 시스템

테이블 정의의 핵심은 TableDefinition 클래스입니다. 이 클래스는 빌더 패턴을 사용하여 직관적인 DSL을 제공합니다.
테이블 이름, 컬럼들, 기본 키, 인덱스 등 테이블의 모든 구성 요소를 관리합니다.

class TableDefinition private constructor(
    val tableName: String,
    val columns: List<ColumnDefinition<*, *, *>>,
    val primaryKeys: List<ColumnDefinition<*, *, *>>,
    val indexes: Map<String, List<ColumnDefinition<*, *, *>>>
)

이 설계를 통해 사용자는 테이블의 모든 측면을 명확하게 정의할 수 있으며, 컴파일 시점에 많은 오류를 잡아낼 수 있습니다.

2. 컬럼 타입 시스템

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

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

이러한 타입 시스템은 다음과 같은 이점을 제공합니다:

  • 컬럼 타입과 실제 데이터 타입 간의 타입 안전성 보장
  • 각 타입별로 필요한 설정 옵션 제공
  • 새로운 데이터베이스 타입 추가가 용이

3. SQL 방언 시스템

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

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"
    }
    // ... 기타 구현
}

실제 사용 예시

라이브러리의 실제 사용은 매우 직관적입니다. 예를 들어, 주문 테이블을 생성하는 경우:

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")
    }
}

이 정의는 선택된 데이터베이스 방언에 따라 적절한 SQL DDL로 변환됩니다:

// SQLite용 SQL 생성
val sqliteSql = orderTable.toSql(SqliteDialect())
// MS SQL용 SQL 생성
val mssqlSql = orderTable.toSql(MsSqlDialect())

타입 안전성과 검증

DDL-DSL은 컴파일 시점과 런타임에 여러 검증을 수행합니다:

  1. 컴파일 시점 검증
    • 컬럼 타입과 데이터 타입의 일치 여부
    • 필수 설정의 누락 여부
    • 메서드 체이닝의 유효성
  2. 런타임 검증
    • 중복된 컬럼 이름 검사
    • 자동 증가 컬럼의 유효성 검사
    • 기본 키 설정의 유효성 검사

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

이렇게 구현된 DDL-DSL 라이브러리는 타입 안전성, 사용 편의성, 그리고 멀티 데이터베이스 지원이라는 목표를 어느정도 달성할 수 있었습니다.
또, 실제 프로덕션 환경에서 개발자의 실수에 의한 문제 사항을 막고, 개발 및 유지 보수시 DDL 문을 작성하거나 수정할 때에 들어가는 시간과 노력의 품을 상당히 경감시켜줍니다.

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

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

728x90