안녕하세요~ 이번에는 객체의 유효성 검사에 대해 알아보고, 코틀린에서는 구체적으로 언제, 어디에서 검증을 진행하는 것이 좋을지 알아보겠습니다.
객체 생성을 위한 클래스 선언
요구사항을 분석해보니, "Ball 객체가 필요하다"고 가정해보겠습니다.
모두 아시다시피 자바와 코틀린 모두 클래스를 정의할 수 있습니다. 그리고 이를 인스턴스화 함으로써, OOP를 실현할 수 있습니다.
따라서 객체를 만들기 위해, 먼저 클래스를 정의해야합니다.
(자바로 먼저 설명드린 뒤, 코틀린 코드로도 구현해보겠습니다~)
public class Ball {
private final String number;
public Ball(String number) {
this.number = number;
}
}
Ball이라는 클래스를 만들었습니다. 인스턴스마다 number라는 상태를 가질 수 있게 하였고,
이로서 number를 가지는 Ball이라는 객체를 찍어낼 수 있게 되었습니다.
객체 생성 후 유효성 검사하기, 그리고 문제점
하지만 위 상황에서, “숫자는 1~45만 가능하다.” 라는 요구사항이 추가된다면 어떻게 해야할까요?
public static void main(String[] args) {
Ball ball = new Ball(46);
int ballNumber = ball.getNumber();
if (ballNumber >= 1 && ballNumber <= 45) {
throw new IllegalArgumentException("숫자는 1부터 45까지 가능합니다");
}
}
이렇게 Ball을 생성한 뒤, number 값의 범위를 검증해야할까요?
검증 메서드를 분리하면 더 좋을 것 같네요.
public static void main(String[] args) {
Ball ball = new Ball(46);
int ballNumber = ball.getNumber();
validate(ballNumber);
}
private static void validate(int number) {
if (number < 1 || number > 45) {
throw new IllegalArgumentException("숫자는 1부터 45까지 가능합니다");
}
}
메서드를 분리하니, 더 보기 좋네요. 클래스로 분리하여 관리할 수도 있을 것 같아요.
하지만 이러한 방식은 몇 가지 문제점이 있습니다.
- 첫 번째는 도메인 규칙에 어긋나는 객체가 시스템에 돌아다닐 수 있다는 점입니다.
위 코드를 보면, 이미 1~45라는 규칙에 어긋나는 Ball 객체가 생성돼버렸습니다. Ball 객체는 이를 허용해준 것이구요.
하지만 요구사항이 점점 많아지고 시스템이 복잡해진다면 “해당 객체가 검증된 객체인지” 보장하기 어려운 순간이 분명히 생길 것입니다.
검증 전에 객체를 사용해버릴 수도 있고, 실수로 검증을 진행하지 않을 지도 모릅니다.
이처럼 보장되지 않은 객체를 사용하는 것은 서비스의 장애로 직결되겠죠! 🥲
이는 생성 시점에 개발자가 매번 수동으로 검증을 해주어야하기 때문에 발생하는 문제입니다.
- 두 번째는 검증 메서드 관리의 어려움입니다.
현재는 Ball 객체만 있지만, 실제 서비스를 개발할때는 정말 수많은 객체가 생겨납니다.
그럼 그 수많은 객체의 검증 메서드를 따로 관리하는 클래스를 만들어야할까요?
좋은 생각이긴하지만, 따로 관리한다고 해도 그 수많은 검증 메서드가 어떤 객체에 대한 검증 메서드인지 구분하기 어려워질 것입니다.
그렇다고 도메인마다 Validtion 클래스를 만들어야할지도 의문입니다. 클래스 수가 두 배로 증가하기 때문입니다.
이 외에도 파급되는 여러가지 문제가 있을 수 있습니다.
그렇다면 이를 어떻게 해결할 수 있을까요?
생성자에서 유효성 검사하기
결론부터 말씀드리면, 객체를 생성하는 시점에 검증을 수행하는 것입니다.
다시 말해, 검증에 실패하면 객체가 생성되지 않도록 하는 것이죠. 코드로 바로 보여드리겠습니다.
먼저 Ball 클래스를 수정해보겠습니다.
public class Ball {
private final int number;
public Ball(int number) {
validate(number);
this.number = number;
}
private void validate(int number) {
if (number < 1 || number > 45) {
throw new IllegalArgumentException("숫자는 1부터 45까지 가능합니다");
}
}
}
수정 사항은 다음과 같습니다.
- 검증 메서드를 Ball 클래스 내부에 구현하였습니다.
- 그리고 Ball의 생성자에서 검증을 진행한 뒤 number 필드에 값을 대입합니다.
이렇게 구현하면 이전과 어떤 변화가 있을까요?
public static void main(String[] args) {
Ball ball = new Ball(46);
}
규칙에 어긋나는 값(46)으로 Ball을 생성한다면, 예외가 발생합니다. 애초에 객체 생성 자체가 불가능해졌죠.
어떤 장점이 있을까요?
앞서 말씀드린 두 가지 문제가 해결됩니다.
생성자에서 number 값을 검증하고 검증에 실패하면 예외가 발생하기 때문에, 유효하지 않은 도메인 객체는 시스템에 존재할 수 없습니다.
이에 따라 검증되지 않은 객체를 사용할 가능성이 사전 차단되어, 더 안정적인 시스템 개발이 가능해집니다.
또한, 검증 메서드가 도메인 객체 내부에 위치하게 됩니다. 다시 말해, 검증을 해당 도메인 객체 스스로가 책임지게 됩니다.
Ball의 규칙인 “1~45까지 숫자이어야한다”가 Ball의 외부에 있는 것이 자연스러울까요? 아니면 내부에서 관리하는 것이 더 자연스러울까요?
내부에 위치시키면, Ball 클래스를 처음 보는 사람도 “아 이 도메인 객체는 이런 규칙을 갖고 있구나~” 하고 훨씬 쉽게 코드를 이해할 수 있을 겁니다. 반면에 검증을 외부 클래스로 따로 관리하게 되면 개발자가 Ball 클래스를 읽을 때 해당 도메인에 대해 얻을 수 있는 정보가 없습니다. 가독성 측면에서도 내부에 위치시키는 것이 더 나은 선택인거죠!
코드에 변경 사항이 생기더라도 마찬가지입니다. 만약 number의 범위가 1~50으로 규칙이 변경되었다고 가정해봅시다.
만약 외부에서 검증을 관리한다면, Ball의 규칙이 바뀌었음에도 Ball이 아닌 다른 클래스에서 해당 검증 메서드를 힘들게 찾아내고, 이를 손봐야합니다. 반면에 내부에 검증이 있다면 개발자는 곧바로 Ball 클래스로 들어가 코드를 변경하기만 하면 됩니다.
결론적으로 도메인 객체에 대한 검증은 해당 객체가 직접 맡는 것이 객체의 자율성을 지키는 올바른 전략이라고 할 수 있습니다.
Kotlin의 생성자 구현 방법
https://kotlinlang.org/docs/classes.html
Classes | Kotlin
kotlinlang.org
Classes in Kotlin are declared using the keyword class: A class in Kotlin has a primary constructor and possibly one or more secondary constructors. The primary constructor is declared in the class header, and it goes after the class name and optional type parameters.
코틀린도 자바와 같이 class 키워드로 클래스를 선언할 수 있습니다.
하지만 생성자를 구현하는 방법에서 차이가 있습니다.
위 처음 예시에서 자바로 만들었던 Ball 클래스를 코틀린에서 어떻게 만들 수 있을까요?
아래와 같이 생성자는 class의 header 부분에 만들 수 있습니다.
만약 파라미터에 val을 붙였다면, 이는 프로퍼티로서 this.number = number 이라는 값 대입까지 일어나게 됩니다.
class Ball(
private val number: Int
)
이를 자바 코드로 디컴파일하면
처음 자바로 만들었던 Ball 클래스와 똑같은 코드가 나옵니다!
코드 양이 확 줄어든 코틀린 대단하네요ㅎㅎ
The primary constructor initializes a class instance and its properties in the class header. The
class header can't contain any runnable code.
하지만 Kotlin docs에 나와있듯이, 클래스의 헤더 부분에 선언한 생성자는 주 생성자로서, 프로퍼티 초기화 외의 실행 가능 코드를 포함할 수 없습니다!
아니 그럼 number를 검증하는 로직을 생성자에 넣어주려 했는데, 어떻게 넣어줘야할까요?
Initializer block을 활용하기
이를 위해 코틀린에는 init 구문이 존재합니다.
https://kotlinlang.org/docs/classes.html#constructors
If you want to run some code during object creation, use initializer blocks
inside the class body. Initializer blocks are declared with the init keyword followed by curly braces.
객체 생성 시 어떤 코드를 실행시키고 싶다면, 클래스의 body에 initializer block을 사용하라고 하네요.
initializer block은 init 키워드와 중괄호를 이용해 구현할 수 있습니다.
바로 Ball의 검증 로직을 코틀린으로 구현해보겠습니다.
class Ball(
private val number: Int
) {
init {
if (number !in 1..45) {
throw IllegalArgumentException("번호는 1~45 사이여야 합니다.")
}
}
}
위와 같이 init block에 검증 로직을 구현하였습니다.
정말 객체 생성 시점에 검증을 수행하는지 확인하기 위해, 자바로 디컴파일 해보겠습니다.
자바로 바꾼 코드에서도 생성자 안에서 검증 로직을 수행하는 것을 확인할 수 있네요!
프로퍼티 선언과 초기화, 그리고 검증 시점 조정하기
그런데 자바 코드를 보면, this.number = number;가 실행된 후에 검증을 수행하고 있습니다.
하지만 number의 검증에 실패한다면 필드에 값을 대입할 필요가 없어지니, 대입보다 검증을 먼저 수행하고 싶은데
이럴 땐 어떻게 해야할까요?
아래 코드처럼 프로퍼티 선언과 초기화를 init 뒤로 미뤄줄 수 있습니다.
class Ball(
number: Int
) {
init {
if (number !in 1..45) {
throw IllegalArgumentException("번호는 1~45 사이여야 합니다.")
}
}
private val number = number
}
During the initialization of an instance, the initializer blocks are executed in the same order as they appear in the class body, interleaved with the property initializers:
init 블록은 property 초기화와 함께 class의 body 내에서 "순서대로" 실행된다고 합니다.
그래서 주 생성자에서 프로퍼티 선언을 분리하고, init 블록 (검증) 을 프로퍼티 선언 위로 올리면 의도한대로 검증 후 대입을 할 수 있는 것입니다.
자바 코드로 바꿔서 확인해보겠습니다.
검증 후 number 값 초기화를 실행하고 있음이 확인되네요!
또 다른 방법도 있습니다. 굳이 프로퍼티 선언을 init 뒤로 뺄 필요가 없습니다.
class Ball(
number: Int
) {
private val number: Int
init {
if (number !in 1..45) {
throw IllegalArgumentException("번호는 1~45 사이여야 합니다.")
}
this.number = number
}
}
위와 같이 선언만 위에서 해주고, 초기화만 검증 뒤에 수행하면 되죠!
val는 선언과 동시에 초기화할 필요 없이, 언젠가 한 번만 초기화해주면 되는 거니까요. 이게 더 가독성이 좋네요.
더 깔끔하게 만들어볼까요?
require 활용하기
코틀린은 require라는 유용한 함수를 제공하고 있습니다.
어떤 함수인지 알아보겠습니다.
https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/require.html
require
Throws an IllegalArgumentException if the value is false. Since Kotlin1.0 Samples import kotlin.test.* fun main() { //sampleStart fun getIndices(count: Int): List { require(count >= 0) { "Count must be non-negative, was $count" } // ... return List(count)
kotlinlang.org
Throws an IllegalArgumentException if the value is false.
value가 false일 때 IllegalArgumentException을 던진다고 합니다.
Ball 객체의 검증 로직은 1~45가 아닐 때 IllegalArgumentException을 던지니, 바로 활용해볼 수 있겠죠!
class Ball(
number: Int
) {
private val number: Int
init {
require(number in 1..45) { "number는 1~45 사이여야 합니다." }
this.number = number
}
}
3줄 짜리 if문이 한 줄로 축소되었네요. 깔끔합니다.
어떻게 저런 가독성 있는 표현으로 조건을 확인하고 IllegalArgumentException 까지 던질 수 있는걸까요?
이는 코틀린의 trailing lambda (후행 람다) 문법과 관련 있습니다.
궁금하신 분들은 require의 내부 구현과,
https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas
Higher-order functions and lambdas | Kotlin
kotlinlang.org
위 trailing lambda(후행 람다) 관련 짧은 docs를 같이 읽어보시면 좋을 것 같습니다.
trailing lambda에 대한 자세한 내용은 다음 블로그 주제로 적어보겠습니다!
읽어주셔서 감사합니다.
참고 자료
공식 문서
https://kotlinlang.org/docs/classes.html
https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/require.html
https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas
'Kotlin' 카테고리의 다른 글
코틀린의 후행 람다(trailing lambda) 활용 사례 알아보기 (1) | 2024.11.24 |
---|---|
Boxing과 Unboxing으로 인한 성능 저하를 해결하는 Kotlin (2) | 2024.11.19 |