타입을 안전하게 만들기 위한 방식
우리가 아이디와 비밀번호로 로그인하는 간단한 로그인 시스템을 만든다고 해보자. 이런 로그인 시스템을 만들기 위해서 우리는 아이디나 비밀번호 값을 받기 위해 다음과 같이 LoginInfo 클래스를 선언하고 아이디(id)와 비밀번호(password)를 표현하기 위해 String 타입을 사용할 수 있다.
data class LoginInfo(
val id: String,
val password: String
)
하지만, 이렇게 아이디나 비밀번호를 String 타입 값으로 설정하는 것은 안전하지 않다. String은 이곳저곳에서 다 쓰이는 타입이기 때문에 언제든 실수가 일어날 수 있기 때문이다. 예를 들어 다음과 같이 id 자리에 password를 넣고 password 자리에 id를 넣는 실수를 할 수 있다.
val id = "dummyId"
val password = "dummyPassword"
val loginInfo = LoginInfo( // 문제가 있는 LoginInfo
password,
id
)
하지만, 이 문제는 컴파일 타임에 잡아지지 않는다. 각 인자로 올바른 타입의 값이 들어갔기 때문이다.
이를 방지하기 위해 우리는 특정 도메인(기능)을 위한 모델을 String, Boolean, Char 등의 간단한 타입 값으로 사용하기보다. 다음과 같이 감싸서 사용하면 된다.
data class LoginInfo(
val id: Id,
val password: Password
)
class Id(val value: String)
class Password(val value: String)
그러면 같은 코드를 작성했을 때 컴파일 타임에 오류가 발생하는 것을 볼 수 있다.
기존 방식의 문제
기존 방식은 완벽해보인다. 하지만, 기존 방식이 값을 클래스로 감싸고 있다는 점에 주목하자. 값이 클래스로 감싸진 다음 해당 클래스가 인스턴스화되면, JVM의 메모리 영역 중 힙 영역에 추가로 공간을 차지하게 된다. 즉, 간단한 값만 메모리에 저장하면 될 수도 있는 상황에서 힙 영역에 추가로 메모리가 사용되게 된다.
value class의 등장
이를 해결하기 위해 Kotlin 1.5에서는 inline class라고도 부르는 value class가 등장했다.
value class는 위에서 다룬 Id나 Password 같이 하나의 값을 감싸기 위한 클래스로, 코드를 만들 때는 타입 시스템을 안전하게 만들기 위해 사용되지만, 컴파일이 되면 감싸는 것이 해제되고 값만 사용된다. 이를 통해 추가적인 힙 영역 메모리를 사용하지 않는다. 위의 Id나 Password를 value class로 바꾸면 다음과 같아진다.
value class Id(val value: String)
value class Password(val value: String)
하지만 위와 같이 value class를 사용하면 다음과 같은 오류가 발생한다.
Value classes without @JvmInline annotation are not supported yet
번역: @JvmInline 어노테이션이 붙지 않은 value class는 아직 지원되지 않습니다.
이는 코틀린을 JVM 위에서 사용할 때 발생하는 현상으로, JVM을 사용하지 않으면 발생하지 않는다.
이를 해결하기 위해서는 value class 위에 @JvmInline 어노테이션만 붙여주면 된다.
@JvmInline
value class Id(val value: String)
@JvmInline
value class Password(val value: String)
내부 동작
내부 동작은 어떻게 일어나는지 확인해보자. 다음은 value class로 선언된 Password 클래스를 자바로 디컴파일한 클래스이다. Password 클래스는 일반 클래스와 마찬가지로 선언되지만, 내부에는 정적 함수인 constructor-impl이 만들어지며, 이 constructor-impl 함수를 String 값과 함께 호출하면 Password 객체가 반환되는 게 아니라 곧바로 String 값이 반환된다.
@JvmInline
@Metadata(
...
)
public final class Password {
@NotNull
private final String value;
@NotNull
public final String getValue() {
return this.value;
}
// $FF: synthetic method
private Password(String value) {
Intrinsics.checkNotNullParameter(value, "value");
super();
this.value = value;
}
// 중요 부분
@NotNull
public static String constructor_impl/* $FF was: constructor-impl*/(@NotNull String value) {
Intrinsics.checkNotNullParameter(value, "value");
return value;
}
...
}
또한 이 Password 객체를 사용하던 LoginInfo 또한 자바로 디컴파일 해보면, Password 객체를 사용하는 것이 아니라 String 객체를 사용하고 있음을 볼 수 있다.
public final class LoginInfo {
@NotNull
private final String id;
@NotNull
private final String password;
@NotNull
public final String getId_eEFUqEU/* $FF was: getId-eEFUqEU*/() {
return this.id;
}
@NotNull
public final String getPassword_eWH3160/* $FF was: getPassword-eWH3160*/() {
return this.password;
}
private LoginInfo(String id, String password) {
this.id = id;
this.password = password;
}
// $FF: synthetic method
public LoginInfo(String id, String password, DefaultConstructorMarker $constructor_marker) {
this(id, password);
}
...
}
이번에는 OuterClass.kt 파일에 쓰여진 다음 코틀린 코드를 디컴파일 해보자.
val id = Id("dummyId")
val password = Password("dummyPassword")
val loginInfo = LoginInfo( // 문제가 있는 LoginInfo
id,
password
)
그러면 위 코드는 다음과 같이 나온다.
public final class OuterClassKt {
@NotNull
private static final String id = Id.constructor-impl("dummyId");
@NotNull
private static final String password = Password.constructor-impl("dummyPassword");
@NotNull
private static final LoginInfo loginInfo;
...
static {
loginInfo = new LoginInfo(id, password, (DefaultConstructorMarker)null);
}
}
id는 Id 클래스의 constructor-impl 함수를 통해 만들어진 String 값이 되고, password 또한 같은 방식으로 String 타입의 값으로 설정되는 것을 볼 수 있다.