프론트엔드 진영에서는 폼 유효성 검증을 위해 Zod 라는 라이브러리를 주로 사용하는 것으로 알고 있다.
이제 Android/Kotlin Multiplatform 환경에서도 비슷한 경험을 할 수 있게 되었다.
Zod에서 영감을 받은 ZodKmp가 등장했기 때문이다!
이 글에서는 ZodKmp가 무엇인지, 왜 필요한지, 그리고 어떻게 사용하는지 알아보고자 한다.

Zod는 TypeScript를 위한 스키마 검증 라이브러리로, 정적 타입 추론을 지원한다.Star가 무려 40K
여기서 스키마(Schema)는 "데이터의 구조와 규칙을 정의해둔 명세"를 뜻한다.
데이터의 구조를 정의하면 TypeScript 타입이 자동으로 추론되고, 런타임에 실제 데이터가 올바른 형태인지 검증할 수 있게 해준다.
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().positive()
});
type User = z.infer<typeof UserSchema>; // 타입 자동 추론
위에 예시 코드처럼 스키마 하나로 타입 정의와 검증 로직을 동시에 해결할 수 있다는 게 핵심이다.
직접 검증 코드를 작성하는 방식과 비교해보면 그 장점이 명확하다.
// 타입 정의
data class User(
val name: String,
val email: String,
val age: Int
)
// 검증 함수 (타입과 별도로 작성해야 함)
fun validateUser(data: Map<String, Any?>): Result<User> {
val name = data["name"] as? String
?: return Result.failure(Exception("이름은 문자열이어야 합니다"))
if (name.length < 2) {
return Result.failure(Exception("이름은 최소 2자 이상이어야 합니다"))
}
val email = data["email"] as? String
?: return Result.failure(Exception("이메일은 문자열이어야 합니다"))
if (!email.contains("@")) {
return Result.failure(Exception("올바른 이메일 형식이 아닙니다"))
}
val age = (data["age"] as? Number)?.toInt()
?: return Result.failure(Exception("나이는 숫자여야 합니다"))
if (age <= 0) {
return Result.failure(Exception("나이는 양수여야 합니다"))
}
return Result.success(User(name, email, age))
}
val userSchema = Zod.objectSchema<User>({
string("name", Zod.string()
.min(2, "이름은 최소 2자 이상이어야 합니다"))
string("email", Zod.string()
.email("올바른 이메일 형식이 아닙니다"))
number("age", Zod.number()
.positive("나이는 양수여야 합니다"))
}) { map ->
User(
name = map["name"] as String,
email = map["email"] as String,
age = (map["age"] as Number).toInt()
)
}
val result = userSchema.safeParse(userData)
타입과 검증이 하나로 통합되고, 코드가 선언적이며, 에러 메시지도 검증 규칙과 함께 선언할 수 있다.
스키마 하나로 검증 규칙을 관리하고, 그로부터 타입까지 자동으로 추론된다. 검증 로직과 타입 정의를 별도로 관리할 필요가 없어 유지보수가 쉽고, 스키마가 변경되면 타입도 자동으로 변경되어 일관성이 보장된다.
// 이 스키마 하나로
// 검증 규칙 정의 (.min(), .email())
// 타입 안정성 확보 (User 타입)
// 에러 메시지 관리 (메서드의 메시지 파라미터) 가능
val userSchema = Zod.objectSchema<User>({
string("name", Zod.string()
.min(2, "이름은 최소 2자 이상이어야 합니다") // ← 여기!
)
string("email", Zod.string()
.email("올바른 이메일 형식이 아닙니다") // ← 여기!
)
}) { map -> User(...) }
이메일, URL 같은 자주 쓰는 포맷은 정규식 작성 없이 바로 검증이 가능하다.(내장된 정규식 사용)
/**
* Schema for validating strings
*/
@JvmInline
value class ZodString private constructor(private val validations: List<(String) -> ZodError?>) : ZodSchema<String> {
companion object {
fun schema(): ZodString = ZodString(emptyList())
}
// ...
fun email(message: String = "Invalid email format"): ZodString {
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$")
val validation: (String) -> ZodError? = { value ->
if (!emailRegex.matches(value)) ZodError(message) else null
}
return ZodString(validations + validation)
}
fun url(message: String = "Invalid URL format"): ZodString {
val urlRegex = Regex("^(https?://)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([/\\w \\.-]*)*/?$")
val validation: (String) -> ZodError? = { value ->
if (!urlRegex.matches(value)) ZodError(message) else null
}
return ZodString(validations + validation)
}
// ...
}
그외 특수 포맷의 경우 .regex()를 통해 커스터마이징이 가능하다.
// UUID 검증 예시
val uuidSchema = Zod.string().regex(
Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"),
"유효한 UUID가 아닙니다"
)
중첩 객체, 배열, 유니온 타입을 쉽게 처리할 수 있다. 직접 검증 코드를 작성하면 복잡해지는 구조도 선언적으로 간단하게 정의 가능하다.
// 중첩 객체
val userWithAddressSchema = Zod.objectSchema<UserWithAddress>({
string("name", Zod.string().min(2))
field("address", addressSchema) // 다른 스키마 재사용
}) { map -> UserWithAddress(...) }
// 배열
val tagsSchema = Zod.array(Zod.string().min(2))
.min(1)
.max(5)
// 유니온 타입
val stringOrNumber = Zod.union(
Zod.string(),
Zod.number()
)
유니온 타입은 "여러 타입 중 하나" 를 의미한다.
"이 값은 문자열일 수도 있고, 숫자일 수도 있다"처럼 여러 가능성을 허용하는 타입
검증 실패 시 어떤 필드가 어떤 규칙을 위반했는지 명확하게 알려준다.
직접 작성한 검증 코드에서는 에러를 하나하나 수집하고 관리해야 하지만, ZodKmp는 모든 검증 에러를 수집하여 리스트로 제공한다.
// 스키마 정의
val userSchema = Zod.objectSchema<User>({
string("name", Zod.string().min(2, "이름은 최소 2자 이상이어야 합니다"))
string("email", Zod.string().email("올바른 이메일 형식이 아닙니다"))
number("age", Zod.number().min(0.0, "나이는 0 이상이어야 합니다"))
}) { map ->
User(
name = map["name"] as String,
email = map["email"] as String,
age = (map["age"] as Number).toInt()
)
}
// 검증
val result = userSchema.safeParse(invalidData)
// 검증 결과
when (result) {
is ZodResult.Success -> {
println("검증 성공: ${result.data}")
}
is ZodResult.Failure -> {
result.error.errors.forEach { error ->
println("검증 실패: $error")
// "email: 올바른 이메일 형식이 아닙니다"
// "age: 0 이상이어야 합니다"
}
}
}
스키마를 조합하고 확장할 수 있어 중복 코드를 줄일 수 있다. 공통 검증 규칙을 스키마로 정의해두면 여러 곳에서 재사용 가능하다.
// 기본 스키마 정의
val emailSchema = Zod.string().email()
val positiveNumberSchema = Zod.number().positive()
// 재사용
val userSchema = Zod.objectSchema<User>({
string("email", emailSchema) // 재사용
number("age", positiveNumberSchema) // 재사용
}) { map -> User(...) }
val adminSchema = Zod.objectSchema<Admin>({
string("email", emailSchema) // 같은 스키마 재사용
string("role", Zod.literal("admin"))
}) { map -> Admin(...) }
한국어로 발음하면 좀 애매한데, 보통 "조드" 또는 영어 발음 그대로 "졷"이라고 부르는 것 같다. 개인적으로는 어감이 별로라고 생각하지만, 이름보다 중요한 건 기능이니까...
ZodKmp을 적용한 예제는 해당 레포지토리에서 확인할 수 있다.
Soil Form과 비교를 위해 작성한 예제로, 아직 ZodKmp의 장점을 제대로 활용하지 못한 버전이라, 이후 제대로 적용한 뒤에, 분석 글을 작성할 예정이다.
ZodKMP 라이브러리에 대해 소개하고, 어떻게 사용하면 되는지 알아보았다.
다만, 아직 개발된 지 얼마 안 된 응애 라이브러리이기에, Zod에서 지원하는 추가적인 기능들(ex. 비동기 데이터 검증(API 응답 검증을 포함한 모든 런타임 데이터 검증), 특수 포맷 내장 정규식)은 아직 지원하지 않는다.
// Zod (TypeScript)
const schema = z.string().refine(async (val) => {
const exists = await checkDatabase(val)
return !exists
}, "Username already taken")
// ZodKmp - 비동기 refine 불가
// Zod (TypeScript)
z.string().uuid()
z.string().ip()
z.string().datetime()
z.string().jwt()
// ZodKmp - 직접 작성 필요
Zod.string().regex(
Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"),
"Must be valid UUID"
)
앞으로 위와 같은 유용한 기능들이 많이 많이 추가되길 기대해본다.
기존에 소개했던 Soil이라는 라이브러리 내에 Soil Form이라는 React Hook Form에서 영감을 받아 만들어진 폼 관리 라이브러리가 존재한다.
현재 Soil Form의 예제 코드에서는 직접 검증 코드를 작성하여 사용하지만, ZodKmp와 연계한다면 프론트엔드 진영에서 자주 사용하는 React Hook Form + Zod 조합처럼 강력한 시너지를 낼 수 있을 것으로 보인다.
Soil Form 예제 코드를 보면 아래와 같이 자체 검증 로직이 Soil Form에 내장되어 있는 것을 확인할 수 있다.
@Composable
private fun Form<FormData>.Email(
content: @Composable (FormField<String>) -> Unit
) {
Field(
selector = { it.email },
updater = { copy(email = it) },
validator = FieldValidator {
notBlank { "must be not blank" }
email { "must be valid email address" }
},
render = content
)
}
@Composable
private fun Form<FormData>.MobileNumber(
content: @Composable (FormField<String>) -> Unit
) {
Field(
selector = { it.mobileNumber },
updater = { copy(mobileNumber = it) },
validator = FieldValidator {
notBlank { "must be not blank" }
phoneNumber { "must be valid phone number" }
},
render = content
)
}
// Basic custom validation rule for email addresses
private fun StringRuleBuilder.email(message: () -> String) {
val pattern = Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\$")
extend(StringRule({ pattern.matches(this) }, message))
}
// Basic custom validation rule for phone numbers
private fun StringRuleBuilder.phoneNumber(message: () -> String) {
val pattern = Regex("^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$")
extend(StringRule({ pattern.matches(this) }, message))
}
package soil.form
import soil.form.core.ValidationResult
import soil.form.core.ValidationRuleBuilder
import soil.form.core.rules
import soil.form.core.validate
/**
* A type alias for field validation functions.
*
* A field validator is a function that takes a field value and returns a [FieldError].
* If validation passes, it should return [noFieldError]. If validation fails,
* it should return a [FieldError] containing the validation error messages.
*
* Usage:
* ```kotlin
* val emailValidator: FieldValidator<String> = { email ->
* if (email.contains("@")) noFieldError
* else FieldError(listOf("Must be a valid email"))
* }
* ```
*
* @param V The type of the value being validated.
*/
typealias FieldValidator<V> = (V) -> FieldError
/**
* Creates a field validator using a validation rule builder.
*
* This function provides a convenient DSL for building field validators using
* predefined validation rules. The builder allows you to chain multiple validation
* rules together.
*
* Usage:
* ```kotlin
* val nameValidator = FieldValidator<String> {
* notBlank { "Name is required" }
* minLength(2) { "Name must be at least 2 characters" }
* maxLength(50) { "Name must not exceed 50 characters" }
* }
*
* val emailValidator = FieldValidator<String> {
* notBlank { "Email is required" }
* match("^[^@]+@[^@]+\\.[^@]+$") { "Must be a valid email address" }
* }
* ```
*
* @param V The type of the value being validated.
* @param block A lambda that builds the validation rules using [ValidationRuleBuilder].
* @return A [FieldValidator] that applies all the specified validation rules.
*/
inline fun <V> FieldValidator(
noinline block: ValidationRuleBuilder<V>.() -> Unit
): FieldValidator<V> = { value ->
when (val result = validate(value, rules(block))) {
is ValidationResult.Valid -> noFieldError
is ValidationResult.Invalid -> FieldError(result.messages)
}
}
이처럼 Soil Form은 기본적인 검증 기능을 내장하고 있어, 간단한 폼 검증의 경우 단독으로 사용해도 충분할 것으로 보인다.
Lambda with recevier 를 활용한 Kotlin DSL 스타일로 검증 규칙을 선언적으로 정의할 수 있다. 다만, 이메일이나 전화번호 같은 포맷 검증을 위한 정규식은 ZodKmp와 다르게 직접 작성해줘야 한다.
다만, 중첩된 객체 검증, 복잡한 조건부 로직, 여러 필드 간 의존성 검증 등이 필요한 경우에는 ZodKmp와 연계하면 더욱 강력한 검증 시스템을 구축할 수 있을 듯 하다.
React Hook Form도 마찬가지로 자체 검증 기능을 제공하지만, Zod와 함께 사용하면 검증 규칙을 컴포넌트 밖으로 분리하여 관리할 수 있고, 복잡한 다중 필드 검증도 선언적으로 처리할 수 있다. React Hook Form은 폼 상태 관리에, Zod는 데이터 검증에 집중하는 방식으로 관심사가 명확히 분리된다.
reference)
https://github.com/piashcse/ZodKmp
https://zod.dev/
https://www.linkedin.com/posts/piashcse_github-piashcsezodkmp-zodkmp-is-a-kotlin-activity-7380623595347247105-GlPG?utm_source=share&utm_medium=member_desktop&rcm=ACoAADeYy2YBzSJPCJqjzoaiEve7gxLPUaiMBqA
https://docs.soil-kt.com/guide/form/hello-form.html
https://jaeyeong951.medium.com/kotlin-lambda-with-receiver-5c2cccd8265a