관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 3장. 함수 정의와 호출 본문

책/Kotlin in Action

[Kotlin in Action] 3장. 함수 정의와 호출

참깨빵위에참깨빵 2023. 9. 30. 22:32
728x90
반응형
코틀린에서 컬렉션 만들기

 

2장에서 확인한 setOf()을 써서 집합을 만드는 걸 확인했다. 이번엔 숫자로 이뤄진 집합을 만든다.

 

val set = hashSetOf(1, 7, 53)

 

비슷한 방법으로 리스트, 맵도 만들 수 있다.

 

val list = arrayListOf(1, 2, 3)
val map = hashMapOf(1 to "one", 2 to "two", 3 to "three")

 

여기서 "to"는 언어가 제공하는 특별한 키워드가 아닌 일반 함수라는 것에 주의해야 한다. 여기서 만든 객체가 어떤 클래스에 속하는지 확인하려면 아래와 같이 할 수 있다.

 

fun main() {
    val set = setOf(1, 2, 3)
    val list = arrayListOf(1, 2, 3)
    val map = hashMapOf(1 to "one", 2 to "two", 3 to "three")

    println(set.javaClass)  // javaClass는 getClass()에 해당한다
    println(list.javaClass)
    println(map.javaClass)
}

// >> class java.util.LinkedHashSet
// >> class java.util.ArrayList
// >> class java.util.HashMap

 

이것은 코틀린이 자신만의 컬렉션 기능을 제공하지 않는다는 뜻이다. 자바 개발자가 기존 자바 컬렉션을 활용할 수 있다는 뜻이므로 자바 개발자들에게 좋은 소식이다.

코틀린이 자체 컬렉션을 제공하지 않는 이유는 표준 자바 컬렉션을 활용하면 자바 코드와 상호작용하기가 훨씬 쉽다. 자바에서 코틀린 함수를 호출하거나 코틀린에서 자바 함수를 호출할 때 자바, 코틀린 컬렉션을 서로 변환할 필요가 없다.

코틀린 컬렉션은 자바 컬렉션과 같은 클래스지만 자바보다 더 많은 기능을 쓸 수 있다. 예를 들어 리스트의 마지막 원소를 가져오거나 수로 이뤄진 컬렉션에서 최대값을 찾을 수 있다.

 

fun main() {
    val strings = listOf("first", "second", "third")
    println(strings.last())

    val numbers = setOf(1, 2, 3)
    println(numbers.max())
}

// >> third
// >> 3

 

4장 이후에서 람다에 대해 말할 때 컬렉션을 통해 할 수 있는 일을 더 보게 된다. 하지만 여기서도 똑같은 표준 자바 컬렉션을 사용한다.

 

함수를 호출하기 쉽게 만들기

 

여러 원소로 이뤄진 컬렉션을 만드는 방법을 배웠으니 간단하게 모든 원소를 찍어본다. 단순해 보이지만 원소를 찍는 과정에서 여러 중요한 개념을 마주할 수 있다.

자바 컬렉션에는 디폴트 toString() 구현이 들어 있다. 하지만 그 toString()의 출력은 고정돼 있고 내게 필요한 형식이 아닐 수 있다.

 

fun main() {
    val list = listOf(1, 2, 3)
    println(list)
}

// >> [1, 2, 3]

 

디폴트 구현과 다르게 (1; 2; 3)처럼 원소 사이를 세미콜론으로 구분하고 괄호로 리스트를 둘러싸려면 어떻게 해야 할까?

자바 프로젝트에 구아바나 아파치 커먼즈 같은 서드파티 프로젝트를 추가하거나 직접 로직을 구현해야 한다. 코틀린에는 이런 요구사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 포함돼 있다. 앞으로 직접 이런 함수를 구현해 본다.

 

아래의 joinToString()은 컬렉션의 원소를 stringBuilder의 뒤에 덧붙인다. 이 때 원소 사이에 구분자를 추가하고 stringBuilder의 맨 앞, 맨 뒤에 접두사와 접미사를 추가한다.

 

fun <T> joinToString(
    collection: Collection<T>,
    separator: String,
    prefix: String,
    postfix: String
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    
    return result.toString()
}

 

이 함수는 제네릭해서 어떤 타입의 값을 원소로 하는 컬렉션이든 처리할 수 있다. 제네릭 함수의 문법은 자바와 비슷하다. 이 함수가 의도대로 작동하는지 검증한다.

 

fun <T> joinToString(
    collection: Collection<T>,
    separator: String,
    prefix: String,
    postfix: String
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)

    return result.toString()
}

fun main() {
    val list = listOf(1, 2, 3)
    println(joinToString(list, "; ", "(", ")"))
}

// >> (1; 2; 3)

 

잘 작동하고 이 함수를 그대로 써도 좋을 것이다. 하지만 선언부를 좀 더 고민해야 한다. 어떻게 해야 이 함수를 호출하는 문장을 덜 번잡하게 만들 수 있는가? 함수를 호출할 때마다 매번 4개 인자를 전달하지 않을 수는 없을까?

 

이름 붙인 인자

 

해결할 첫 문제는 함수 호출부의 가독성이다. 예를 들어 아래와 같은 joinToString() 호출을 확인한다.

 

joinToString(collection, " ", " ", ".")

 

인자로 전달한 문자열이 어떤 역할을 하는지 구분 가능한가? 함수 시그니처를 보지 않고는 답하기 어렵다.

이런 문제는 Boolean 플래그 값을 전달해야 하는 경우 흔히 발생한다. 이를 해결하기 위해 일부 자바 코딩 스타일에선 Boolean 대신 enum 타입을 쓰라고 권장한다. 아니면 파라미터 이름을 주석에 넣으라고 요구하기도 한다.

코틀린에선 아래처럼 할 수 있다.

 

joinToString(
    collection = list,
    separator = "; ",
    prefix = "(",
    postfix = ")"
)

 

코틀린으로 작성한 함수 호출 시에는 함수에 전달하는 인자 중 일부(또는 전체)의 이름을 명시할 수 있다. 호출 시 인자 중 하나라도 이름을 명시하면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을 명시해야 한다.

자바로 작성한 코드를 호출할 때는 이름 붙인 인자를 쓸 수 없다. 따라서 안드로이드 프레임워크, JDK가 제공하는 함수 호출 시에도 마찬가지로 이름 붙인 인자를 쓸 수 없다.

 

디폴트 파라미터 값

 

자바에선 일부 클래스에서 오버로딩한 메서드가 너무 많아진다는 문제가 있다. java.lang.Thread의 8가지 생성자와 같이, 이런 오버로딩 메서드들은 하위 호환성을 유지하거나 API 사용자에게 편의를 더하는 등의 여러 이유로 만들어진다. 하지만 중복이란 결과는 같다.

코틀린에선 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드 중 상당수를 피할 수 있다. 디폴트 값을 사용해 joinToString()을 개선하면 아래와 같다.

 

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)

    return result.toString()
}

 

이제 함수 호출 시 모든 인자를 쓸 수도 있고 일부는 생략 가능하다.

 

fun main() {
    val list = listOf(1, 2, 3)
    println(joinToString(collection = list))
    println(joinToString(list, "; "))
    println(
        joinToString(
            collection = list,
            separator = "; ",
            prefix = "(",
            postfix = ")"
        )
    )
}

// >> 1, 2, 3
// >> 1; 2; 3
// >> (1; 2; 3)

 

함수의 디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라 선언하는 쪽에서 지정된다. 따라서 어떤 클래스 안에 정의된 함수의 디폴트 값을 바꾸고 그 클래스가 포함된 파일을 재컴파일하면 그 함수를 호출하는 코드 중 값을 지정하지 않은 모든 인자는 자동으로 바뀐 디폴트 값을 적용받는다.

 

정적인 유틸리티 클래스 없애기 : 최상위 함수와 프로퍼티

 

자바에선 모든 코드를 클래스 메서드로 작성해야 한다는 걸 알고 있다. 보통 이런 구조는 잘 작동하지만 실전에선 어느 한 클래스에 포함시키기 어려운 코드가 많이 생긴다. 일부 연산에선 비슷하게 중요한 역할을 하는 클래스가 둘 이상 있을 수 있다. 중요한 객체는 하나뿐이지만 그 연산을 객체의 인스턴스 API에 추가해서 API를 너무 크게 만들고 싶지 않은 경우도 있다. 그 결과 다양한 정적 메서드를 모아두는 역할만 담당하고 특별한 상태, 인스턴스 메서드는 없는 클래스가 생겨난다. JDK의 collections 클래스가 그 예시다.

코틀린에선 이런 무의미한 클래스가 필요없다. 대신 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다. 이런 함수들은 여전히 그 파일의 맨 앞에 정의된 패키지의 멤버 함수기 때문에, 다른 패키지에서 사용하려면 그 함수가 정의된 패키지를 임포트해야 한다. 하지만 임포트 시 유틸리티 클래스 이름이 추가로 들어갈 필요는 없다.

joinToString()을 strings 패키지에 직접 넣는다. strings 패키지를 만들고 join.kt라는 파일을 아래처럼 작성하라.

 

package strings

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)

    return result.toString()
}

 

JVM이 클래스에 들어있는 코드만을 실행할 수 있기 때문에 컴파일러가 이 파일을 컴파일할 때 새 클래스를 정의해준다.

코틀린 컴파일러가 만드는 클래스의 이름은 최상위 함수가 들어있던 코틀린 파일의 이름과 대응한다. 코틀린 파일의 모든 최상위 함수는 이 클래스의 정적 메서드가 된다.

 

최상위 프로퍼티

 

함수와 마찬가지로 프로퍼티도 파일의 최상위 수준에 놓을 수 있다. 어떤 데이터를 클래스 밖에 위치시켜야 하는 경우 유용할 수 있다. 예를 들어 어떤 연산을 수행한 횟수를 저장하는 var 프로퍼티를 만들 수 있다.

 

var opCount = 0

fun performOperation() {
    opCount++
}

fun requestOperationCount() {
    println("Operation이 ${opCount}번 수행됐어요")
}

 

이런 프로퍼티의 값은 정적 필드에 저장된다. 최상위 프로퍼티를 활용해 코드에 상수를 추가할 수도 있다.

기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 자바 코드에 노출된다. val은 게터, var은 게터세터가 모두 생긴다. 겉으론 상수인데 실제로 게터를 써야 한다면 자연스럽지 못하다. 더 자연스럽게 쓰려면 이 상수를 public static final 필드로 컴파일해야 한다. const 변경자를 추가하면 프로퍼티를 public static final 필드로 컴파일하게 만들 수 있다.

 

메서드를 다른 클래스에 추가 : 확장 함수와 확장 프로퍼티

 

기존 코드와 코틀린 코드를 자연스럽게 통합하는 건 코틀린의 핵심 목표 중 하나다. 완전히 코틀린으로만 이뤄진 프로젝트도 JDK나 안드로이드 프레임워크 또는 다른 서드파티 프레임워크 등의 자바 라이브러리를 기반으로 만들어진다. 또 코틀린을 기존 자바 프로젝트에 통합하는 경우, 코틀린으로 직접 변환할 수 없거나 변환하지 않은 기존 자바 코드를 처리할 수 있어야 한다. 확장 함수를 사용하면 이런 역할을 구현할 수 있다.

개념적으로 확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만, 그 클래스의 밖에 선언된 함수다. 어떤 문자열의 마지막 문자를 돌려주는 메서드를 join.kt에 추가한다.

 

package strings

fun String.lastChar(): Char = this.get(this.length - 1)

 

확장 함수를 만들려면 추가하려는 함수명 앞에 그 함수가 확장할 클래스명을 쓰면 된다. 클래스명을 수신 객체 타입이라고 하며, 확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체라고 부른다. 이 함수를 호출하는 구문은 다른 일반 클래스 멤버를 호출하는 구문과 똑같다.

 

fun main() {
    println("Kotlin".lastChar())
}

// >> n

 

이 예시에선 String이 수신 객체 타입이고 "Kotlin"이 수신 객체다. 어떻게 보면 이는 String 클래스에 새 메서드를 추가하는 것과 같다. String 클래스는 내가 작성하지 않았고 그 클래스의 코드를 갖고 있지도 않지만 내가 원하는 메서드를 String 클래스에 추가할 수 있다. String이 자바, 코틀린 중 뭘로 작성됐는지도 중요하지 않다. 자바 클래스로 컴파일한 클래스 파일이 있는 한 그 클래스에 원하는 대로 확장 함수를 추가할 수 있다.

일반 메서드 본문에서 this를 쓸 때와 마찬가지로 확장 함수 본문에도 this를 쓸 수 있다. 생략하는 것도 가능하다.

 

package strings

fun String.lastChar(): Char = get(length - 1)

 

확장 함수 내부에선 수신 객체와 메서드, 프로퍼티를 바로 사용할 수 있다. 하지만 확장 함수가 캡슐화를 깨지는 않는다. 클래스 안에 정의한 메서드와 달리 확장 함수 안에선 클래스 안에서만 쓸 수 있는 private, protected 멤버를 쓸 수 없다. 즉 public, default만 쓸 수 있다.

 

임포트와 확장 함수

 

확장 함수를 정의해도 자동으로 프로젝트 안의 모든 코드에서 그 함수를 쓸 수는 없다. 확장 함수를 쓰려면 그 함수를 다른 클래스나 함수처럼 임포트해야 한다. 확장 함수를 정의하자마자 어디서든 그 함수를 쓸 수 잇다면 한 클래스에 같은 이름의 확장 함수가 둘 이상 있어서 이름이 충돌할 수 있다. as 키워드를 쓰면 임포트한 클래스, 함수를 다른 이름으로 부를 수 있다.

 

import strings.lastChar as last

fun main() {
    println("Kotlin".last())
}

 

한 파일 안에서 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와 써야 하는 경우, 이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다. 일반적인 클래스, 함수면 전체 이름을 써도 되지만 코틀린 문법상 확장 함수는 반드시 짧은 이름을 써야 한다. 따라서 임포트할 때 이름을 바꾸는 게 확장 함수 이름 충돌을 해결하는 유일한 방법이다.

 

확장 함수로 유틸리티 함수 정의

 

이제 joinToString()의 최종 버전을 만든다.

 

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)

    return result.toString()
}

 

확장 함수는 단지 정적 메서드 호출에 대한 문법적 편의일 뿐이다. 그래서 클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다. 그래서 문자열의 컬렉션에 대해서만 호출 가능한 join()을 정의하려면 아래처럼 한다.

 

fun Collection<String>.join(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
) = joinToString(separator, prefix, postfix)

fun main() {
    println(listOf("one", "two", "three").join(" "))
}

// >> one two three

 

이 함수는 객체의 리스트에 대해 호출할 수는 없다. 확장 함수가 정적 메서드와 같은 특징을 가지므로 확장 함수를 하위 클래스에서 오버라이드할 수는 없다.

 

확장 함수는 오버라이드할 수 없다

 

View와 하위 클래스인 Button이 있는데, Button이 상위 클래스의 click()을 재정의하는 경우를 생각해 보라.

 

open class View {
    open fun click() = println("뷰가 클릭됐어요")
}

class Button: View() {
    override fun click() {
        println("버튼이 클릭됐어요")
    }
}

 

버튼이 뷰의 하위 타입이기 때문에 뷰 타입 변수를 선언해도 버튼 타입 변수를 그 변수에 대입 가능하다. click()을 버튼 클래스가 재정의했다면 실제론 버튼이 오버라이드한 click()이 호출된다.

 

open class View {
    open fun click() = println("뷰가 클릭됐어요")
}

class Button: View() {
    override fun click() {
        println("버튼이 클릭됐어요")
    }
}

fun main() {
    val view: View = Button()
    view.click()
}

// >> 버튼이 클릭됐어요

 

하지만 확장은 이렇게 작동하지 않는다. 확장 함수는 클래스의 일부가 아니고 클래스 밖에 선언된다. 이름, 파라미터가 완전히 같은 확장 함수를 기반 클래스, 하위 클래스에 대해 정의해도 실제로 확장 함수 호출 시 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수가 호출될지 결정되고, 그 변수에 저장된 객체의 동적 타입에 의해 확장 함수가 결정되지 않는다.

 

확장 프로퍼티

 

확장 프로퍼티를 쓰면 기존 클래스 객체에 대한 프로퍼티 형식 구문으로 쓸 수 있는 API를 추가할 수 있다. 프로퍼티라고 부리지만 상태를 저장할 적절한 방법이 없어서 실제로 확장 프로퍼티는 어떤 상태도 가질 수 없다.

앞의 lastChar()를 프로퍼티로 바꾸면 아래와 같다.

 

val String.lastChar: Char
    get() = get(length - 1)

 

확장 프로퍼티도 일반 프로퍼티와 같고 수신 객체 클래스가 추가됐을 뿐이다. backing field가 없어서 기본 게터 구현을 제공할 수 없기 때문에 최소한 게터는 꼭 정의해야 한다.

StringBuilder에 같은 프로퍼티를 정의한다면 마지막 문자는 변경 가능해서 var로 만들 수 있다.

 

var StringBuilder.lastChar: Char
    get() = get(length - 1)
    set(value: Char) {
        this.setCharAt(length - 1, value)
    }

 

사용법은 멤버 프로퍼티 사용법과 같다.

 

fun main() {
    val sb = StringBuilder("Kotlin?")
    println(sb.lastChar)
}

// >> ?

 

컬렉션 처리 : 가변 길이 인자, 중위 함수 호출, 라이브러리 지원

 

가변 인자 함수 : 인자 개수가 달라질 수 있는 함수 정의

 

가변 길이 인자는 메서드 호출 시 원하는 개수만큼 값을 인자로 넘기면 자바 컴파일러가 배열에 그 값들을 넣는 기능이다. 코틀린의 가변 길이 인자도 자바와 비슷하지만 문법이 다르다. 파라미터 앞에 vararg 키워드를 붙인다.

배열의 원소를 가변 길이 인자로 넘길 때도 코틀린, 자바 구문이 다르다. 코틀린에선 배열을 명시적으로 풀어서 배열의 각 원소가 인자로 전달되게 해야 한다. 스프레드 연산자가 그런 작업을 해주지만 실제론 전달하려는 배열 앞에 *를붙이면 된다.

 

fun main(args: Array<String>) {
    val list = listOf("args: ", *args)
    println(list)
}

 

값의 쌍 다루기 : 중위 호출, 구조 분해 선언

 

맵을 만들려면 mapOf()를 쓴다. 이 때 사용하는 to는 코틀린 키워드가 아니라 중위 호출(infix call)이라는 형식으로 to라는 일반 메서드를 호출한 것이다. 중위 호출 시에는 수신 객체와 유일한 메서드 인자 사이에 메서드명을 넣는다. 이 때 객체, 메서드명, 유일한 인자 사이에는 공백이 들어가야 한다. 아래의 두 호출은 동일하다.

 

1.to("one")
1 to "one"

 

인자가 하나뿐인 일반 메서드, 확장 함수에 중위 호출을 쓸 수 있다. 함수를 중위 호출에 사용하게 허용하려면 inifx 변경자를 함수 선언 앞에 추가해야 한다.

또한 Pair의 내용으로 두 변수를 즉시 초기화할 수 있다. 이 기능을 구조 분해 선언이라고 부른다.

 

val (number, name) = 1 to "one"

 

Pair 인스턴스 외 다른 객체에도 구조 분해를 적용할 수 있다. 루프에서도 구조 분해 선언을 사용할 수 있다.

joinToString()에서의 withIndex()를 구조 분해 선언과 조합하면 컬렉션 원소의 인덱스, 값을 따로 변수에 담을 수 있다.

 

to 함수는 확장 함수다. to를 쓰면 타입에 상관없이 임의의 순서쌍을 만들 수 있다. 이는 to의 수신 객체가 제네릭하다는 뜻이다. listOf()처럼 mapOf()에도 원하는 개수만큼 인자를 전달할 수 있다. 하지만 mapOf()는 각 인자가 키, 값으로 이뤄진 순서쌍이어야 한다.

 

로컬 함수와 확장

 

자바 코드 작성 시 DRY 원칙을 피하기는 쉽지 않다. 많은 경우 메서드 추출 리팩토링을 통해 긴 메서드를 나눠서 각 부분을 재활용할 수 있다. 하지만 이렇게 하면 클래스 안에 작은 메서드가 많아지고 각 메서드 사이 관계를 파악하기 힘들어서 코드 이해가 어려워질 수 있다.

코틀린에선 함수에서 추출한 함수를 원래 함수 내부에 중첩시킬 수 있다. 이렇게 하면 부가 비용 없이도 코드를 깔끔하게 만들 수 있다.

유저를 DB에 저장하는 함수가 있는데 DB에 유저 객체를 저장하기 전 각 필드를 검증하는 예제 코드를 확인한다.

 

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    if (user.name.isEmpty()) {
        throw IllegalArgumentException("이름은 빈 문자열일 수 없습니다 : ${user.name}")
    }
    if (user.address.isEmpty()) {
        throw IllegalArgumentException("주소는 빈 문자열일 수 없습니다 : ${user.address}")
    }
    // 유저를 DB에 저장
}

 

중복이 많지 않지만 클래스가 유저의 필드를 검증할 때 필요한 여러 경우를 하나씩 처리하는 메서드로 넘칠 수 있다. 검증 코드를 로컬 함수로 분리하면 중복을 없애면서 코드 구조를 깔끔하게 유지할 수 있다.

 

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    fun validate(user: User, value: String, fieldName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("user ${user.id}의 ${fieldName}이 빈 문자열입니다")
        }
    }
    validate(user, user.name, "Name") // 로컬 함수를 호출해서 각 필드 검증
    validate(user, user.address, "Address")
    // 유저를 DB에 저장
}

 

검증 로직 중복이 사라졌고 필요하면 User의 다른 필드에 대한 검증도 쉽게 추가할 수 있다. 하지만 User 객체를 로컬 함수에 하나하나 전달해야 한다.

그러나 로컬 함수는 자신이 속한 바깥 함수의 모든 파라미터, 변수를 사용할 수 있다. 이 성질을 활용해서 불필요한 User 파라미터를 없애본다.

 

fun saveUser(user: User) {
    fun validate(value: String, fieldName: String) {
        if (value.isEmpty()) {
            // 바깥 함수의 파라미터(user)에 직접 접근 가능
            throw IllegalArgumentException("user ${user.id}의 ${fieldName}이 빈 문자열입니다")
        }
    }
    
    validate(user.name, "Name")
    validate(user.address, "Address")
    // 유저를 DB에 저장
}

 

반응형
Comments