Kotlin DSL 만들기 실전 가이드: 빌더 패턴 완전 정복
Gradle 빌드 스크립트에서 dependencies { } 블록 안에 라이브러리를 선언하고, Ktor에서 routing { get("/") { } }으로 라우팅을 정의하고, Jetpack Compose에서 Column { Text("Hello") }로 UI를 그립니다. 이 세 가지의 공통점이 무엇일까요? 전부 Kotlin DSL로 작성된다는 점입니다.
DSL(Domain-Specific Language)은 특정 영역의 문제를 해결하기 위해 설계된 미니 언어입니다. SQL이 데이터베이스 질의를 위한 DSL이고, 정규표현식이 문자열 패턴 매칭을 위한 DSL인 것처럼, Kotlin에서도 여러분만의 DSL을 만들 수 있습니다. 다른 언어에서도 DSL을 만들 수 있지만, Kotlin은 Lambda with Receiver, Extension Function, 연산자 오버로딩, infix 함수 같은 언어 기능 덕분에 유독 자연스럽고 타입 안전한 DSL을 설계할 수 있어 주목받고 있습니다.
이번 글에서는 DSL의 핵심 빌딩 블록부터 시작해서, 실무에서 바로 복사해 쓸 수 있는 DSL 설계 패턴까지 단계별로 살펴보겠습니다. 코드를 따라 치다 보면 어느새 여러분만의 DSL을 설계할 수 있게 될 겁니다.
Lambda with Receiver — DSL의 심장부
Kotlin DSL을 이해하려면 가장 먼저 수신 객체가 있는 람다(Lambda with Receiver)를 확실히 알아야 합니다. 과장을 좀 보태면, 이 개념 하나가 DSL 설계 능력의 80%를 좌우한다고 할 수 있습니다. 이미 apply나 buildString을 써 보셨다면 사실 이미 Lambda with Receiver를 사용하고 계신 겁니다.
일반 람다와 뭐가 다른가요?
먼저 일반 람다를 하나 봅시다.
// 일반 람다: 파라미터로 값을 명시적으로 전달
val greet: (String) -> String = { name ->
"안녕하세요, ${name}님!"
}
println(greet("홍길동")) // 안녕하세요, 홍길동님!
이제 같은 동작을 Lambda with Receiver로 바꿔 봅시다.
// Lambda with Receiver: 수신 객체의 멤버에 직접 접근
val greet: String.() -> String = {
"안녕하세요, ${this}님! (${length}글자)"
}
println("홍길동".greet()) // 안녕하세요, 홍길동님! (3글자)
차이가 보이시나요? 타입 선언에서 (String) -> String 대신 String.() -> String을 쓰면, 람다 내부에서 수신 객체(this)의 모든 멤버에 this. 없이 직접 접근할 수 있습니다. 위 예시에서 length는 사실 this.length인데, this를 생략해도 컴파일러가 알아서 추론하는 것이죠.

표준 라이브러리에서의 활용
이 원리는 이미 Kotlin 표준 라이브러리 곳곳에서 쓰이고 있습니다. 대표적인 예가 buildString입니다.
val html = buildString {
// this = StringBuilder 인스턴스
append("<html>") // this.append() — this 생략 가능
appendLine()
append(" <body>")
appendLine()
append(" Hello, Kotlin DSL!")
appendLine()
append(" </body>")
appendLine()
append("</html>")
}
println(html)
buildString의 실제 시그니처를 보면 원리가 명확해집니다.
// Kotlin 표준 라이브러리 정의
inline fun buildString(
builderAction: StringBuilder.() -> Unit
): String {
val sb = StringBuilder()
sb.builderAction() // Lambda with Receiver 호출
return sb.toString()
}
StringBuilder.() -> Unit이 핵심입니다. StringBuilder가 수신 객체(receiver)가 되고, 람다 내부에서 append, appendLine 등 StringBuilder의 멤버 함수를 자유롭게 호출할 수 있습니다. 이 패턴을 여러분의 클래스에 적용하면? 그것이 바로 DSL 빌더 함수가 됩니다.
apply와의 관계
자주 쓰는 apply 스코프 함수도 같은 원리입니다.
// apply의 정의
inline fun <T> T.apply(block: T.() -> Unit): T {
this.block()
return this
}
// 사용 예
val config = ServerConfig().apply {
// this = ServerConfig 인스턴스
host = "localhost" // this.host = "localhost"
port = 8080 // this.port = 8080
}
apply를 한 단계 더 감싸서 진입 함수를 만들면, 사용자에게 더 깔끔한 인터페이스를 제공할 수 있습니다. 이것이 다음 섹션에서 만들 DSL 빌더 패턴의 출발점입니다.
첫 번째 DSL 만들기 — 설정 객체 빌더
실무에서 가장 흔하고 가장 유용한 DSL 패턴은 설정 객체(Configuration) 빌더입니다. 데이터베이스 연결 설정, HTTP 클라이언트 구성, 앱 초기화 옵션 등 어디에나 등장하는 패턴이죠. 여기서는 데이터베이스 설정 DSL을 만들어 보겠습니다.
전통적인 방식의 한계
// 방법 1: 생성자에 모든 파라미터 나열
val config = DatabaseConfig(
host = "db.example.com",
port = 5432,
database = "myapp",
username = "admin",
password = "secret",
maxPoolSize = 20,
connectionTimeout = 30_000L,
idleTimeout = 600_000L,
sslEnabled = true,
sslMode = "verify-full"
)
// 방법 2: 프로퍼티 하나씩 설정
val config = DatabaseConfig()
config.host = "db.example.com"
config.port = 5432
config.database = "myapp"
// ... 같은 패턴이 10줄 넘게 반복
생성자 방식은 파라미터가 10개를 넘으면 가독성이 급격히 떨어지고, 프로퍼티 방식은 config.이 끊임없이 반복됩니다. 무엇보다 두 방식 모두 “이 설정이 어떤 논리적 그룹에 속하는지” 한눈에 파악하기 어렵습니다. 인증 정보와 커넥션 풀 설정이 같은 레벨에 나열되어 있으니까요.
DSL 방식으로 바꾸면
val config = databaseConfig {
host = "db.example.com"
port = 5432
database = "myapp"
credentials {
username = "admin"
password = "secret"
}
pool {
maxSize = 20
connectionTimeout = 30.seconds
idleTimeout = 10.minutes
}
ssl {
enabled = true
mode = SslMode.VERIFY_FULL
}
}
관련 설정이 논리적으로 그룹화되어 있고, 들여쓰기 자체가 구조를 설명합니다. 코드 리뷰할 때도 “아, SSL 설정은 여기 블록만 보면 되는구나” 하고 바로 파악할 수 있죠.

구현 코드
이 DSL의 구현은 세 단계로 나뉩니다. 빌더 클래스 → 빌드 메서드 → 진입 함수, 이 순서로 만들어 봅시다.
// ── 1단계: 불변 결과 모델 (사용자가 실제로 받는 객체) ──
data class DatabaseConfig(
val host: String,
val port: Int,
val database: String,
val credentials: Credentials,
val pool: PoolConfig,
val ssl: SslConfig
)
data class Credentials(val username: String, val password: String)
data class PoolConfig(val maxSize: Int, val connectionTimeout: Long, val idleTimeout: Long)
data class SslConfig(val enabled: Boolean, val mode: SslMode)
enum class SslMode { DISABLE, REQUIRE, VERIFY_CA, VERIFY_FULL }
// ── 2단계: 빌더 클래스들 (DSL 블록 내부에서 동작) ──
class CredentialsBuilder {
var username: String = ""
var password: String = ""
fun build() = Credentials(username, password)
}
class PoolBuilder {
var maxSize: Int = 10
var connectionTimeout: Long = 30_000L
var idleTimeout: Long = 600_000L
fun build() = PoolConfig(maxSize, connectionTimeout, idleTimeout)
}
class SslBuilder {
var enabled: Boolean = false
var mode: SslMode = SslMode.DISABLE
fun build() = SslConfig(enabled, mode)
}
class DatabaseConfigBuilder {
var host: String = "localhost"
var port: Int = 5432
var database: String = ""
private val credentialsBuilder = CredentialsBuilder()
private val poolBuilder = PoolBuilder()
private val sslBuilder = SslBuilder()
fun credentials(block: CredentialsBuilder.() -> Unit) {
credentialsBuilder.apply(block)
}
fun pool(block: PoolBuilder.() -> Unit) {
poolBuilder.apply(block)
}
fun ssl(block: SslBuilder.() -> Unit) {
sslBuilder.apply(block)
}
fun build(): DatabaseConfig = DatabaseConfig(
host = host,
port = port,
database = database,
credentials = credentialsBuilder.build(),
pool = poolBuilder.build(),
ssl = sslBuilder.build()
)
}
// ── 3단계: 진입 함수 (사용자가 호출하는 유일한 함수) ──
fun databaseConfig(block: DatabaseConfigBuilder.() -> Unit): DatabaseConfig {
return DatabaseConfigBuilder().apply(block).build()
}
이 패턴의 핵심 원칙 세 가지를 정리하겠습니다.
- 빌더(Mutable)와 결과(Immutable) 분리: 빌더 클래스는
var로 값을 받되,build()에서 불변data class를 생성합니다. DSL 블록이 끝나면 결과 객체를 수정할 수 없으므로 스레드 안전성까지 확보됩니다. - 진입 함수는 딱 하나:
databaseConfig { }가 DSL의 유일한 진입점입니다. 사용자는DatabaseConfigBuilder를 직접 생성할 필요가 없고, 그래서도 안 됩니다. - 중첩 빌더 = 중첩 블록:
credentials { },pool { },ssl { }각각이 자기만의 빌더를 가지고, 상위 빌더에서fun credentials(block: CredentialsBuilder.() -> Unit)으로 위임합니다. 이 패턴은 원하는 만큼 깊이 중첩할 수 있습니다.
@DslMarker로 스코프 오염 방지하기
중첩 DSL을 만들다 보면 한 가지 골치 아픈 문제를 만나게 됩니다. 바로 스코프 오염(scope pollution)입니다. 내부 블록에서 외부 블록의 멤버에 의도치 않게 접근할 수 있는 문제인데, 이것은 DSL의 안전성을 심각하게 해칩니다.
문제 상황
앞서 만든 데이터베이스 설정 DSL에서 이런 코드가 컴파일될 수 있습니다.
val config = databaseConfig {
host = "db.example.com"
port = 5432
credentials {
username = "admin"
password = "secret"
// 문제! credentials 블록 안에서 host에 접근 가능
host = "hacked.example.com" // 외부 빌더의 프로퍼티!
// 더 심각한 문제: 중첩 블록을 다시 호출할 수도 있음
ssl { // 외부 빌더의 함수!
enabled = false
}
}
}
이 코드가 왜 컴파일되냐면, Kotlin의 Lambda with Receiver는 기본적으로 외부 수신 객체에 암묵적으로 접근할 수 있기 때문입니다. credentials { } 블록의 수신 객체는 CredentialsBuilder지만, 외부의 DatabaseConfigBuilder도 여전히 스코프에 잡혀 있습니다. 단순한 설정 DSL에서는 큰 문제가 아닐 수 있지만, 복잡한 DSL에서는 예측 불가능한 버그로 이어집니다.

@DslMarker로 해결하기
Kotlin은 이 문제를 해결하기 위해 @DslMarker 메타 어노테이션을 제공합니다. 사용법은 놀라울 정도로 간단합니다.
// 1. 커스텀 마커 어노테이션 선언
@DslMarker
annotation class DbConfigDsl
// 2. 모든 빌더 클래스에 마커 부착
@DbConfigDsl
class DatabaseConfigBuilder { /* ... */ }
@DbConfigDsl
class CredentialsBuilder { /* ... */ }
@DbConfigDsl
class PoolBuilder { /* ... */ }
@DbConfigDsl
class SslBuilder { /* ... */ }
이렇게 하면 같은 @DbConfigDsl 마커가 붙은 빌더들 사이에서 가장 가까운 수신 객체만 암묵적으로 접근 가능하게 됩니다.
val config = databaseConfig {
host = "db.example.com" // OK — DatabaseConfigBuilder의 멤버
credentials {
username = "admin" // OK — CredentialsBuilder의 멤버
// host = "hacked" // 컴파일 에러!
// 'fun credentials(...)' can't be called in this context by
// implicit receiver. Use the explicit receiver if necessary.
// 정말 필요하다면 명시적 수신 객체로 접근 (의도적임을 표시)
[email protected] = "override.example.com" // OK
}
}
@DslMarker가 외부 수신 객체 접근을 완전히 금지하는 것은 아닙니다. this@databaseConfig처럼 명시적으로 지정하면 접근할 수 있습니다. 다만 실수로 접근하는 것을 방지하고, 의도적 접근은 코드에 명확히 드러나게 해 줍니다. 이것이 “타입 안전한 DSL”에서 “안전한”이 의미하는 핵심 중 하나입니다.
@DslMarker 적용 권장 시점
- 빌더 클래스가 2개 이상 중첩될 때는 반드시 적용하세요.
- 빌더가 하나뿐인 단순 DSL에서는 굳이 필요 없습니다.
- 라이브러리/프레임워크로 배포하는 DSL이라면 무조건 적용하는 것이 좋습니다. 사용자의 실수를 컴파일 타임에 잡아 줄 수 있으니까요.
infix와 연산자 오버로딩으로 표현력 높이기
기본 빌더 패턴만으로도 충분히 깔끔한 DSL을 만들 수 있지만, Kotlin의 infix 함수와 연산자 오버로딩을 활용하면 더욱 자연어에 가까운 표현이 가능합니다.
infix 함수로 키-값 쌍 만들기
HTTP 헤더처럼 키-값 쌍을 다루는 DSL에서 infix 함수가 빛을 발합니다.
// infix 없이
headers {
add("Content-Type", "application/json")
add("Authorization", "Bearer token123")
}
// infix 적용
headers {
"Content-Type" to "application/json"
"Authorization" to "Bearer token123"
}
표준 라이브러리의 to가 대표적인 infix 함수입니다. 필요하다면 도메인에 맞는 이름으로 커스텀 infix 함수를 만들 수도 있습니다.
class HeadersBuilder {
private val headers = mutableMapOf<String, String>()
// 커스텀 infix 함수
infix fun String.means(value: String) {
headers[this] = value
}
fun build(): Map<String, String> = headers.toMap()
}
// 사용
headers {
"Accept" means "text/html"
"Cache-Control" means "no-cache"
}
다만 infix 함수를 남발하면 오히려 코드를 읽기 어려워질 수 있습니다. “이 표현이 정말 가독성을 높이는가?” 자문하고, 그렇지 않다면 일반 함수 호출을 유지하세요.
연산자 오버로딩으로 컬렉션 DSL 만들기
컬렉션에 항목을 추가하는 DSL에서 unaryPlus 연산자를 활용하면 깔끔한 문법을 만들 수 있습니다.
class TagListBuilder {
private val tags = mutableListOf<String>()
// + 연산자 오버로딩
operator fun String.unaryPlus() {
tags.add(this)
}
fun build(): List<String> = tags.toList()
}
fun tags(block: TagListBuilder.() -> Unit): List<String> {
return TagListBuilder().apply(block).build()
}
// 사용
val myTags = tags {
+"kotlin"
+"dsl"
+"builder-pattern"
+"type-safe"
}
Gradle의 dependencies 블록에서 implementation("...") 대신 옛날에 쓰이던 +"...:library:1.0" 문법도 바로 이 unaryPlus 연산자 오버로딩이었습니다.

실전에서 바로 쓰는 DSL 패턴 모음
지금까지 배운 기법들을 실무 시나리오에 적용해 봅시다. 여기 소개하는 패턴들은 복사해서 프로젝트에 바로 붙여 넣고, 도메인에 맞게 수정하면 됩니다.
패턴 1: 테스트 픽스처 DSL
테스트 코드에서 복잡한 객체를 생성할 때 DSL이 특히 유용합니다. 테스트의 의도가 코드에 바로 드러나니까요.
// DSL 사용
val user = testUser {
name = "홍길동"
email = "[email protected]"
role = Role.ADMIN
address {
city = "서울"
district = "강남구"
zipCode = "06000"
}
permissions {
+Permission.READ
+Permission.WRITE
+Permission.DELETE
}
}
// 구현
class TestUserBuilder {
var name: String = "테스트유저"
var email: String = "[email protected]"
var role: Role = Role.USER
private val addressBuilder = AddressBuilder()
private val permissionsBuilder = PermissionListBuilder()
fun address(block: AddressBuilder.() -> Unit) {
addressBuilder.apply(block)
}
fun permissions(block: PermissionListBuilder.() -> Unit) {
permissionsBuilder.apply(block)
}
fun build() = User(
name = name,
email = email,
role = role,
address = addressBuilder.build(),
permissions = permissionsBuilder.build()
)
}
fun testUser(block: TestUserBuilder.() -> Unit): User {
return TestUserBuilder().apply(block).build()
}
이 패턴의 장점은 테스트마다 필요한 필드만 지정하면 나머지는 합리적인 기본값이 채워진다는 것입니다. 10개 필드 중 2개만 바꿔서 테스트하고 싶다면, 그 2개만 DSL 블록에 적으면 됩니다.
패턴 2: 유효성 검증 DSL
폼 데이터나 API 요청 검증 로직을 DSL로 만들면 선언적으로 규칙을 나열할 수 있습니다.
// DSL 사용
val userValidator = validator<UserRequest> {
field(UserRequest::name) {
notBlank("이름은 필수입니다")
maxLength(50, "이름은 50자 이하여야 합니다")
}
field(UserRequest::email) {
notBlank("이메일은 필수입니다")
matches(
Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"),
"올바른 이메일 형식이 아닙니다"
)
}
field(UserRequest::age) {
range(1..150, "나이는 1~150 사이여야 합니다")
}
}
// 실행
val result = userValidator.validate(request)
if (result.hasErrors()) {
result.errors.forEach { println(it.message) }
}
// 구현 핵심부
class ValidatorBuilder<T> {
private val fieldValidators = mutableListOf<FieldValidator<T, *>>()
fun <V> field(
property: KProperty1<T, V>,
block: FieldValidatorBuilder<V>.() -> Unit
) {
val builder = FieldValidatorBuilder<V>(property.name)
builder.apply(block)
fieldValidators.add(builder.build(property))
}
fun build(): Validator<T> = Validator(fieldValidators)
}
class FieldValidatorBuilder<V>(private val fieldName: String) {
private val rules = mutableListOf<ValidationRule<V>>()
fun notBlank(message: String = "$fieldName 은(는) 필수입니다") {
rules.add { value ->
if (value?.toString().isNullOrBlank()) message else null
}
}
fun maxLength(max: Int, message: String = "$fieldName 은(는) ${max}자 이하") {
rules.add { value ->
if (value?.toString()?.length?.let { it > max } == true) message else null
}
}
fun matches(regex: Regex, message: String = "$fieldName 형식이 올바르지 않습니다") {
rules.add { value ->
if (value?.toString()?.matches(regex) != true) message else null
}
}
fun range(range: IntRange, message: String = "$fieldName 은(는) 범위 초과") {
rules.add { value ->
val intVal = (value as? Number)?.toInt()
if (intVal == null || intVal !in range) message else null
}
}
// ...
}
inline fun <reified T> validator(
block: ValidatorBuilder<T>.() -> Unit
): Validator<T> {
return ValidatorBuilder<T>().apply(block).build()
}
이 패턴의 핵심은 검증 규칙이 선언적이라는 것입니다. “어떻게 검증하는가”의 로직은 빌더 내부에 감추고, 사용자는 “무엇을 검증하는가”만 나열합니다.
패턴 3: 간단한 라우팅 DSL
웹 프레임워크의 라우팅처럼 URL 패턴과 핸들러를 매핑하는 DSL도 자주 쓰이는 패턴입니다.
// DSL 사용
val router = router {
get("/users") {
// 사용자 목록 반환
respond(userService.findAll())
}
get("/users/{id}") {
val id = pathParam("id")
respond(userService.findById(id))
}
post("/users") {
val body = receiveBody<CreateUserRequest>()
respond(userService.create(body), status = 201)
}
group("/admin") {
get("/stats") {
respond(adminService.getStats())
}
delete("/cache") {
adminService.clearCache()
respond("Cache cleared")
}
}
}
이 패턴에서 group 블록은 경로 접두사(prefix)를 공유하는 라우트를 묶어 줍니다. group("/admin") 안의 get("/stats")는 실제로 /admin/stats에 매핑됩니다. Ktor의 route 블록이나 Spring의 함수형 라우팅 DSL이 바로 이 원리입니다.
DSL 설계 시 꼭 알아야 할 주의사항
DSL은 강력한 도구이지만, 잘못 쓰면 오히려 코드를 더 복잡하게 만들 수 있습니다. 실무에서 DSL을 설계하며 배운 교훈들을 정리했습니다.
DSL이 빛나는 상황
- 설정이 복잡하고 구조적일 때: 프로퍼티가 10개 이상이고, 논리적 그룹이 존재하며, 기본값이 필요한 경우에 DSL은 생성자보다 훨씬 가독성이 좋습니다.
- 트리 구조를 코드로 표현할 때: HTML, JSON, UI 레이아웃, 메뉴 구조처럼 계층적 데이터를 Kotlin 코드로 직접 작성해야 할 때 DSL이 자연스럽습니다.
- 같은 패턴이 여러 곳에서 반복될 때: 테스트 픽스처, 마이그레이션 스크립트, 라우팅 설정 등 동일한 구조가 수십 번 쓰인다면 DSL로 보일러플레이트를 제거하는 것이 합리적입니다.
- 비개발자도 읽어야 할 때: DSL이 충분히 자연어에 가까우면 기획자나 QA 담당자도 코드를 읽고 의도를 파악할 수 있습니다.
DSL이 과한 상황
- 단순한 객체 생성: 프로퍼티가 3~4개인
data class는 생성자 호출이 더 명확합니다. DSL을 만드는 비용 자체가 더 클 수 있습니다. - 한두 곳에서만 쓰이는 코드: DSL의 가치는 반복 사용에서 나옵니다. 딱 한 번 쓸 코드를 위해 빌더 클래스를 만드는 것은 낭비입니다.
- 팀이 패턴에 익숙하지 않을 때: DSL의 학습 곡선을 과소평가하지 마세요. Lambda with Receiver와
@DslMarker를 팀원 모두가 이해하지 못하면, 유지보수가 어려운 “마법 같은 코드”가 될 수 있습니다. - IDE 지원이 부족한 환경: DSL의 자동완성과 타입 검사는 IntelliJ IDEA 같은 강력한 IDE가 있어야 제대로 동작합니다. 단순 텍스트 에디터 환경이라면 DSL의 장점이 크게 줄어듭니다.
설계 팁 정리
- API 먼저 설계하세요: 구현보다 “사용자가 어떤 코드를 작성하게 되는가”를 먼저 작성합니다. 이상적인 DSL 사용 코드를 먼저 적고, 그것이 컴파일되도록 빌더를 역방향으로 구현하는 것이 가장 좋은 접근입니다.
- 불변 결과를 반환하세요: 빌더는 가변(var)이지만,
build()의 결과는 반드시 불변(val, data class)이어야 합니다. 이것이 DSL 블록 밖에서의 안전성을 보장합니다. - 기본값을 잘 설정하세요: 좋은 DSL은 아무것도 지정하지 않아도 합리적으로 동작합니다. 사용자가 필요한 최소한의 것만 오버라이드하면 되도록 설계하세요.
- 중첩이 3단계를 넘으면 재고하세요: DSL이 너무 깊이 중첩되면 가독성이 오히려 떨어집니다. 3단계가 넘어간다면 별도의 DSL로 분리하는 것을 고려하세요.
직접 만들어 보세요 — DSL 설계 연습 과제
글을 읽는 것만으로는 체득하기 어렵습니다. 아래 연습 과제를 하나 골라서 직접 만들어 보시길 추천합니다.
- 초급: 이메일 메시지를 구성하는 DSL을 만드세요.
email { from = "..."; to = "..."; subject = "..."; body { paragraph("...") } }같은 형태로 사용할 수 있도록요. - 중급: 간단한 JSON 빌더 DSL을 만드세요.
json { "name" to "홍길동"; "age" to 30; "address" obj { "city" to "서울" }; "tags" array { +"kotlin"; +"dsl" } }같은 형태입니다. - 고급: 상태 머신(State Machine) DSL을 만드세요. 상태, 이벤트, 전이(transition), 진입/이탈 액션을 선언적으로 정의할 수 있도록 설계해 보세요.
처음에는 “사용자가 쓸 코드”를 먼저 작성하고, 그 코드가 컴파일되고 동작하도록 빌더를 하나씩 채워 나가면 됩니다. 어렵게 느껴질 수 있지만, 핵심은 결국 Lambda with Receiver 하나입니다. 수신 객체를 지정하고, 그 안에서 멤버에 자유롭게 접근하게 해 주는 것. 이 본질을 잊지 않으면 어떤 도메인이든 깔끔한 DSL을 설계할 수 있습니다.
Gradle, Ktor, Compose가 특별한 마법을 쓰는 게 아니었습니다. 여러분이 오늘 배운 바로 그 기법들로 만들어진 것일 뿐입니다. 이제 여러분 차례입니다.
이미지는 Leonardo AI 로 생성되었습니다.
이미지는 Claude AI 로 생성되었습니다.


