Notice
Recent Posts
Recent Comments
Link
반응형
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 함수형프로그래밍
- Sort
- 자바
- 이펙티브자바
- ElasticSearch
- effectivejava
- 카카오
- 스프링부트
- 알고리즘정렬
- 스프링핵심원리
- java
- k8s
- 이펙티브 자바
- 스프링
- 스프링 핵심원리
- 알고리즘
- springboot
- 김영한
- 티스토리챌린지
- 엘라스틱서치
- JavaScript
- Effective Java 3
- Kotlin
- kubernetes
- 자바스크립트
- 클린아키텍처
- 예제로 배우는 스프링 입문
- Spring
- Effective Java
- 오블완
Archives
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin DSL 만들기 | 타입 안전한 빌더 패턴 완벽 가이드 본문
반응형
이 글을 읽으면: 설정 파일, HTML, SQL 쿼리를 타입 안전하고 읽기 쉬운 Kotlin 코드로 작성하는 방법을 배울 수 있습니다. @DslMarker와 수신 객체 지정 람다로 직관적인 DSL을 만드는 실전 패턴을 완벽하게 마스터하세요.
📌 목차
- 들어가며 - DSL이 뭐길래?
- DSL 기초 - 수신 객체 지정 람다
- 타입 안전성 - @DslMarker
- HTML DSL 만들기
- 설정 DSL - Config Builder
- SQL DSL - 쿼리 빌더
- 실전 DSL 패턴
- 마무리 - 다음 편 예고
들어가며 - DSL이 뭐길래?

DSL (Domain Specific Language)
"특정 도메인에 특화된 언어"
일반 프로그래밍 언어 (Java, Kotlin):
- 모든 것을 할 수 있음
- 범용적
DSL:
- 특정 분야만 잘함
- 전문적
우리가 이미 쓰는 DSL들
-- SQL DSL
SELECT name, age
FROM users
WHERE age > 20
ORDER BY name
<!-- HTML DSL -->
<div class="container">
<h1>제목</h1>
<p>내용</p>
</div>
# CSS DSL
.container {
display: flex;
margin: 20px;
}
Kotlin으로 DSL을 만들면?
문제: JSON 설정 파일
{
"database": {
"host": "localhost",
"port": 5432,
"username": "admin",
"password": "secret"
},
"cache": {
"enabled": true,
"ttl": 3600
}
}
단점
- 타입 안전성 없음 (오타 발견 못 함)
- IDE 자동완성 없음
- 컴파일 타임 검증 불가
Kotlin DSL로 해결
config {
database {
host = "localhost"
port = 5432
username = "admin"
password = "secret"
}
cache {
enabled = true
ttl = 3600
}
}
장점
- ✅ 타입 안전 (컴파일 타임 검증)
- ✅ IDE 자동완성
- ✅ 리팩토링 가능
- ✅ 읽기 쉬움
실제 사용 사례
Gradle Kotlin DSL
plugins {
kotlin("jvm") version "1.9.20"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.junit.jupiter:junit-jupiter")
}
Ktor (웹 프레임워크)
routing {
get("/users") {
call.respond(users)
}
post("/users") {
val user = call.receive<User>()
call.respond(HttpStatusCode.Created, user)
}
}
DSL 기초 - 수신 객체 지정 람다
일반 람다 vs 수신 객체 지정 람다
일반 람다
// 일반 람다
val regularLambda: (StringBuilder) -> Unit = { sb ->
sb.append("Hello")
sb.append(" World")
}
// 사용
val builder = StringBuilder()
regularLambda(builder)
println(builder.toString()) // Hello World
수신 객체 지정 람다
// 수신 객체 지정 람다
val receiverLambda: StringBuilder.() -> Unit = {
append("Hello") // this.append("Hello")
append(" World") // this 생략 가능!
}
// 사용
val builder = StringBuilder()
builder.receiverLambda()
println(builder.toString()) // Hello World
차이점
일반 람다:
- 파라미터로 받음
- sb.append() 형태
수신 객체 지정 람다:
- 내부에서 바로 사용
- append() 형태 (this 생략)
- 마치 클래스 내부처럼!
apply, with, run 다시 보기
이것들이 바로 DSL 기초!
// apply - 수신 객체 지정 람다 사용
val user = User().apply {
name = "규철" // this.name = "규철"
age = 30 // this.age = 30
}
// with
val greeting = with(user) {
"안녕하세요, $name님!" // user.name
}
// run
val result = user.run {
println("이름: $name")
println("나이: $age")
"완료"
}
간단한 DSL 만들기
1단계: 클래스 정의
class Pizza {
var size: String = "M"
val toppings = mutableListOf<String>()
fun topping(name: String) {
toppings.add(name)
}
}
2단계: 빌더 함수
fun pizza(init: Pizza.() -> Unit): Pizza {
val pizza = Pizza()
pizza.init() // 수신 객체 지정 람다 실행
return pizza
}
3단계: 사용!
fun main() {
val myPizza = pizza {
size = "L"
topping("페퍼로니")
topping("치즈")
topping("올리브")
}
println("크기: ${myPizza.size}")
println("토핑: ${myPizza.toppings}")
}
// 출력:
// 크기: L
// 토핑: [페퍼로니, 치즈, 올리브]
타입 안전성 - @DslMarker
문제 상황
중첩된 DSL에서 발생하는 혼란
class HTML {
fun body(init: Body.() -> Unit) { }
}
class Body {
fun div(init: Div.() -> Unit) { }
}
class Div {
fun span(text: String) { }
}
fun html(init: HTML.() -> Unit): HTML { }
// 사용
html {
body {
div {
span("안녕")
div { // ❌ 문제: 외부 div를 또 호출 가능!
span("세계")
}
}
}
}
무엇이 문제일까?
암묵적 수신자:
- 내부에서 외부 스코프 접근 가능
- div 안에서 body, html 모두 접근 가능
- 의도하지 않은 중첩 가능
결과:
- 혼란스러운 구조
- 의도와 다른 코드
@DslMarker로 해결
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML {
fun body(init: Body.() -> Unit) { }
}
@HtmlDsl
class Body {
fun div(init: Div.() -> Unit) { }
}
@HtmlDsl
class Div {
fun span(text: String) { }
}
// 이제 사용하면
html {
body {
div {
span("안녕")
// div { } // ❌ 컴파일 에러!
// body { } // ❌ 컴파일 에러!
}
}
}
@DslMarker의 효과
1. 같은 @DslMarker 어노테이션이 붙은 클래스끼리
2. 암묵적 수신자 접근 차단
3. 명시적으로만 접근 가능
결과:
- 타입 안전성 증가
- 의도하지 않은 코드 방지
HTML DSL 만들기
기본 구조 설계
@DslMarker
annotation class HtmlDsl
// 기본 Element
@HtmlDsl
abstract class Element(val name: String) {
private val children = mutableListOf<Element>()
private val attributes = mutableMapOf<String, String>()
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
fun attribute(name: String, value: String) {
attributes[name] = value
}
override fun toString(): String {
return buildString {
append("<$name")
if (attributes.isNotEmpty()) {
attributes.forEach { (k, v) ->
append(" $k=\"$v\"")
}
}
append(">")
children.forEach { append(it.toString()) }
append("</$name>")
}
}
}
// Text Node
class TextElement(val text: String) : Element("") {
override fun toString() = text
}
HTML 태그들
@HtmlDsl
class HTML : Element("html") {
fun head(init: Head.() -> Unit) = initTag(Head(), init)
fun body(init: Body.() -> Unit) = initTag(Body(), init)
}
@HtmlDsl
class Head : Element("head") {
fun title(text: String) = initTag(Title(), { +text })
}
@HtmlDsl
class Title : Element("title")
@HtmlDsl
class Body : Element("body") {
fun h1(init: H1.() -> Unit) = initTag(H1(), init)
fun h2(init: H2.() -> Unit) = initTag(H2(), init)
fun p(init: P.() -> Unit) = initTag(P(), init)
fun div(cssClass: String? = null, init: Div.() -> Unit): Div {
val div = initTag(Div(), init)
cssClass?.let { div.attribute("class", it) }
return div
}
fun a(href: String, init: A.() -> Unit): A {
val a = initTag(A(), init)
a.attribute("href", href)
return a
}
}
@HtmlDsl
class H1 : Element("h1") {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
@HtmlDsl
class H2 : Element("h2") {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
@HtmlDsl
class P : Element("p") {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
@HtmlDsl
class Div : Element("div") {
fun p(init: P.() -> Unit) = initTag(P(), init)
fun span(text: String) = initTag(Span(), { +text })
}
@HtmlDsl
class Span : Element("span") {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
@HtmlDsl
class A : Element("a") {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
빌더 함수
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
사용 예제
fun main() {
val page = html {
head {
title("Kotlin DSL")
}
body {
h1 { +"Kotlin DSL 만들기" }
div(cssClass = "container") {
p { +"DSL은 Domain Specific Language입니다." }
p { +"Kotlin으로 쉽게 만들 수 있습니다." }
}
h2 { +"링크" }
a(href = "https://kotlinlang.org") {
+"Kotlin 공식 사이트"
}
}
}
println(page.toString())
}
// 출력:
//
//
//
//
Kotlin DSL 만들기
//
//
DSL은 Domain Specific Language입니다.
//Kotlin으로 쉽게 만들 수 있습니다.
////
링크
// Kotlin 공식 사이트
//
//
설정 DSL - Config Builder
설정 클래스 설계
@DslMarker
annotation class ConfigDsl
@ConfigDsl
class AppConfig {
var appName: String = "MyApp"
var version: String = "1.0.0"
private var _database: DatabaseConfig? = null
private var _server: ServerConfig? = null
private var _cache: CacheConfig? = null
val database: DatabaseConfig
get() = _database ?: throw IllegalStateException("Database not configured")
val server: ServerConfig
get() = _server ?: throw IllegalStateException("Server not configured")
val cache: CacheConfig
get() = _cache ?: throw IllegalStateException("Cache not configured")
fun database(init: DatabaseConfig.() -> Unit) {
_database = DatabaseConfig().apply(init)
}
fun server(init: ServerConfig.() -> Unit) {
_server = ServerConfig().apply(init)
}
fun cache(init: CacheConfig.() -> Unit) {
_cache = CacheConfig().apply(init)
}
}
@ConfigDsl
class DatabaseConfig {
var host: String = "localhost"
var port: Int = 5432
var username: String = ""
var password: String = ""
var database: String = "mydb"
var maxConnections: Int = 10
fun validate() {
require(username.isNotBlank()) { "Database username is required" }
require(password.isNotBlank()) { "Database password is required" }
}
}
@ConfigDsl
class ServerConfig {
var host: String = "0.0.0.0"
var port: Int = 8080
var ssl: Boolean = false
var maxThreads: Int = 100
}
@ConfigDsl
class CacheConfig {
var enabled: Boolean = true
var ttl: Long = 3600 // seconds
var maxSize: Int = 1000
}
빌더 함수
fun appConfig(init: AppConfig.() -> Unit): AppConfig {
val config = AppConfig()
config.init()
config.database.validate()
return config
}
사용 예제
fun main() {
val config = appConfig {
appName = "쇼핑몰"
version = "2.0.0"
database {
host = "db.example.com"
port = 5432
username = "admin"
password = "secret123"
database = "shopping"
maxConnections = 50
}
server {
host = "0.0.0.0"
port = 8080
ssl = true
maxThreads = 200
}
cache {
enabled = true
ttl = 7200 // 2시간
maxSize = 5000
}
}
// 사용
println("앱 이름: ${config.appName}")
println("DB 호스트: ${config.database.host}")
println("서버 포트: ${config.server.port}")
println("캐시 TTL: ${config.cache.ttl}초")
}
환경별 설정
// 개발 환경
val devConfig = appConfig {
appName = "쇼핑몰 (개발)"
database {
host = "localhost"
username = "dev"
password = "dev123"
}
server {
port = 8080
ssl = false
}
}
// 운영 환경
val prodConfig = appConfig {
appName = "쇼핑몰"
database {
host = "prod-db.example.com"
username = System.getenv("DB_USER")
password = System.getenv("DB_PASSWORD")
maxConnections = 100
}
server {
port = 443
ssl = true
maxThreads = 500
}
cache {
ttl = 3600
maxSize = 10000
}
}
SQL DSL - 쿼리 빌더
기본 구조
@DslMarker
annotation class SqlDsl
@SqlDsl
class SelectQuery {
private val columns = mutableListOf<String>()
private var tableName: String = ""
private val conditions = mutableListOf<String>()
private val orderByColumns = mutableListOf<String>()
private var limitValue: Int? = null
fun select(vararg cols: String) {
columns.addAll(cols)
}
fun from(table: String) {
tableName = table
}
fun where(condition: String) {
conditions.add(condition)
}
fun orderBy(vararg cols: String) {
orderByColumns.addAll(cols)
}
fun limit(n: Int) {
limitValue = n
}
fun build(): String {
return buildString {
append("SELECT ")
append(if (columns.isEmpty()) "*" else columns.joinToString(", "))
append(" FROM $tableName")
if (conditions.isNotEmpty()) {
append(" WHERE ")
append(conditions.joinToString(" AND "))
}
if (orderByColumns.isNotEmpty()) {
append(" ORDER BY ")
append(orderByColumns.joinToString(", "))
}
limitValue?.let { append(" LIMIT $it") }
}
}
}
fun query(init: SelectQuery.() -> Unit): String {
val query = SelectQuery()
query.init()
return query.build()
}
사용 예제
fun main() {
// 단순 조회
val query1 = query {
select("name", "age")
from("users")
}
println(query1)
// SELECT name, age FROM users
// 조건 추가
val query2 = query {
select("*")
from("users")
where("age > 20")
where("city = 'Seoul'")
}
println(query2)
// SELECT * FROM users WHERE age > 20 AND city = 'Seoul'
// 정렬 및 제한
val query3 = query {
select("name", "email", "created_at")
from("users")
where("status = 'active'")
orderBy("created_at DESC")
limit(10)
}
println(query3)
// SELECT name, email, created_at FROM users
// WHERE status = 'active' ORDER BY created_at DESC LIMIT 10
}
타입 안전한 쿼리 빌더
@SqlDsl
class TypeSafeQuery<T> {
private val columns = mutableListOf<String>()
private var tableName: String = ""
private val conditions = mutableListOf<String>()
fun select(vararg cols: String) {
columns.addAll(cols)
}
fun from(table: String) {
tableName = table
}
infix fun String.eq(value: Any) {
conditions.add("$this = '${value}'")
}
infix fun String.gt(value: Any) {
conditions.add("$this > $value")
}
infix fun String.lt(value: Any) {
conditions.add("$this < $value")
}
fun build() = buildString {
append("SELECT ")
append(if (columns.isEmpty()) "*" else columns.joinToString(", "))
append(" FROM $tableName")
if (conditions.isNotEmpty()) {
append(" WHERE ")
append(conditions.joinToString(" AND "))
}
}
}
fun <T> typeSafeQuery(init: TypeSafeQuery<T>.() -> Unit): String {
val query = TypeSafeQuery<T>()
query.init()
return query.build()
}
// 사용
fun main() {
val query = typeSafeQuery<User> {
select("name", "age", "email")
from("users")
"age" gt 20
"city" eq "Seoul"
}
println(query)
// SELECT name, age, email FROM users WHERE age > 20 AND city = 'Seoul'
}
실전 패턴
패턴 1: 테스트 DSL
@DslMarker
annotation class TestDsl
@TestDsl
class TestSuite(val name: String) {
private val tests = mutableListOf<TestCase>()
fun test(name: String, block: () -> Unit) {
tests.add(TestCase(name, block))
}
fun run() {
println("=== $name ===")
var passed = 0
var failed = 0
tests.forEach { testCase ->
try {
testCase.block()
println("✓ ${testCase.name}")
passed++
} catch (e: AssertionError) {
println("✗ ${testCase.name}: ${e.message}")
failed++
}
}
println("\n통과: $passed, 실패: $failed")
}
}
data class TestCase(val name: String, val block: () -> Unit)
fun describe(name: String, init: TestSuite.() -> Unit) {
val suite = TestSuite(name)
suite.init()
suite.run()
}
fun assertEquals(expected: Any?, actual: Any?) {
if (expected != actual) {
throw AssertionError("Expected: $expected, Actual: $actual")
}
}
// 사용
fun main() {
describe("계산기 테스트") {
test("1 + 1 = 2") {
assertEquals(2, 1 + 1)
}
test("2 * 3 = 6") {
assertEquals(6, 2 * 3)
}
test("10 / 2 = 5") {
assertEquals(5, 10 / 2)
}
test("실패하는 테스트") {
assertEquals(10, 2 + 2)
}
}
}
// 출력:
// === 계산기 테스트 ===
// ✓ 1 + 1 = 2
// ✓ 2 * 3 = 6
// ✓ 10 / 2 = 5
// ✗ 실패하는 테스트: Expected: 10, Actual: 4
//
// 통과: 3, 실패: 1
패턴 2: JSON DSL
@DslMarker
annotation class JsonDsl
@JsonDsl
sealed class JsonValue {
data class JsonString(val value: String) : JsonValue()
data class JsonNumber(val value: Number) : JsonValue()
data class JsonBoolean(val value: Boolean) : JsonValue()
data class JsonArray(val items: List<JsonValue>) : JsonValue()
data class JsonObject(val properties: Map<String, JsonValue>) : JsonValue()
object JsonNull : JsonValue()
}
@JsonDsl
class JsonObjectBuilder {
private val properties = mutableMapOf<String, JsonValue>()
infix fun String.to(value: String) {
properties[this] = JsonValue.JsonString(value)
}
infix fun String.to(value: Number) {
properties[this] = JsonValue.JsonNumber(value)
}
infix fun String.to(value: Boolean) {
properties[this] = JsonValue.JsonBoolean(value)
}
infix fun String.to(value: JsonValue) {
properties[this] = value
}
fun obj(name: String, init: JsonObjectBuilder.() -> Unit) {
properties[name] = JsonValue.JsonObject(
JsonObjectBuilder().apply(init).properties
)
}
fun array(name: String, vararg items: Any) {
properties[name] = JsonValue.JsonArray(
items.map {
when (it) {
is String -> JsonValue.JsonString(it)
is Number -> JsonValue.JsonNumber(it)
is Boolean -> JsonValue.JsonBoolean(it)
else -> JsonValue.JsonNull
}
}
)
}
fun build(): JsonValue.JsonObject {
return JsonValue.JsonObject(properties)
}
}
fun json(init: JsonObjectBuilder.() -> Unit): String {
val builder = JsonObjectBuilder()
builder.init()
return formatJson(builder.build())
}
fun formatJson(value: JsonValue, indent: Int = 0): String {
val spaces = " ".repeat(indent)
return when (value) {
is JsonValue.JsonString -> "\"${value.value}\""
is JsonValue.JsonNumber -> value.value.toString()
is JsonValue.JsonBoolean -> value.value.toString()
is JsonValue.JsonNull -> "null"
is JsonValue.JsonArray -> {
val items = value.items.joinToString(", ") { formatJson(it, indent + 1) }
"[$items]"
}
is JsonValue.JsonObject -> {
val props = value.properties.entries.joinToString(",\n") { (k, v) ->
"$spaces \"$k\": ${formatJson(v, indent + 1)}"
}
"{\n$props\n$spaces}"
}
}
}
// 사용
fun main() {
val result = json {
"name" to "규철"
"age" to 30
"isActive" to true
obj("address") {
"city" to "서울"
"zipCode" to "12345"
}
array("hobbies", "테니스", "코딩", "독서")
}
println(result)
}
// 출력:
// {
// "name": "규철",
// "age": 30,
// "isActive": true,
// "address": {
// "city": "서울",
// "zipCode": "12345"
// },
// "hobbies": ["테니스", "코딩", "독서"]
// }
마무리 - 다음 편 예고
오늘 배운 것 ✅
- DSL 개념 - Domain Specific Language
- 수신 객체 지정 람다 - T.() -> Unit
- @DslMarker - 타입 안전성 보장
- HTML DSL - 타입 안전한 마크업
- 설정 DSL - Config Builder
- SQL DSL - 쿼리 빌더
다음 편에서 배울 것 📚
19편: Kotlin 성능 최적화 | inline, crossinline, noinline 완벽 가이드
- inline이 뭐길래?
- 언제 inline을 쓸까?
- crossinline vs noinline
- reified와 inline
- 성능 측정과 비교
- 실전 최적화 패턴
핵심 정리
DSL 만들기 3단계
1. 클래스 설계
- 도메인 모델링
- 프로퍼티/메서드 정의
2. 수신 객체 지정 람다
- T.() -> Unit 활용
- 자연스러운 중첩 구조
3. @DslMarker 적용
- 타입 안전성 보장
- 의도하지 않은 호출 방지
DSL의 장점
측면 일반 코드 DSL
| 가독성 | 보통 | 매우 높음 |
| 타입 안전성 | 없음 (JSON 등) | 있음 |
| IDE 지원 | 제한적 | 완벽 |
| 리팩토링 | 어려움 | 쉬움 |
💬 댓글로 알려주세요!
- DSL을 만들어본 경험이 있나요?
- 어떤 도메인에 DSL을 적용하고 싶으신가요?
- 이 글이 도움이 되셨나요?
반응형
'개발 > java basic' 카테고리의 다른 글
| Kotlin 테스트 코드 완벽 가이드 | JUnit5 + MockK로 안전한 코드 만들기 (0) | 2026.01.04 |
|---|---|
| Spring Boot와 Kotlin | 실전 백엔드 개발 완벽 가이드 (0) | 2026.01.02 |
| Kotlin 코루틴 심화 | Flow, Channel로 데이터 스트림 다루기 (0) | 2026.01.01 |
| Kotlin 코루틴 기초 | launch, async, suspend로 비동기 정복하기 (0) | 2025.12.29 |
| Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기 (0) | 2025.12.28 |
Comments
