class User {
val name: String = "Jinnie"
var userInfo: String = ""
fun getUserInfo(): String {
userInfo = "She's name is $name"
return userInfo
}
}
fun main() {
val user = User()
user.getUserInfo()
}
Root cause를 확인해보기 위해 코틀린으로 작성된 위 테스트케이스를 자바로 변환해보자.
자바로 변환된 코드를 확인해보면, 파라미터, 반환 타입이 동일한 2개의 getUserInfo가 생성된 것을 확인할 수 있다.
public final class User {
@NotNull
private final String name = "Jinnie";
@NotNull
private String userInfo = "";
@NotNull
public final String getName() {
return this.name;
}
public final void setUserInfo(@NotNull String var1) {
this.userInfo = var1;
}
@NotNull
public final String getUserInfo() {
// $FF: Couldn't be decompiled
}
@NotNull
public final String getUserInfo() {
// $FF: Couldn't be decompiled
}
}
public final class MainKt {
public static final void main() {
User user = new User();
user.getUserInfo();
}
public static void main(String[] var0) {
main();
}
}
일단 이 문제를 이해하기 위해서는 먼저 코틀린의 프로퍼티에 대해 이해하고 있어야 한다.
자바에서의 프로퍼티와 코틀린의 프로퍼티는 개념 측면에서 서로 다르다. 자바에서의 프로퍼티는 객체가 갖는 상태와 같은 정적인 속성이며, 필드는 프로퍼티의 동적인 실제 값이 된다.
다음과 같은 클래스가 있다고 할 때,
public class User {
private String name;
}
name은 프로퍼티이고, 이것의 실제 값은 필드가 된다. name은 이름이라는 속성이 되는데, name이 전화번호가 되거나 나이가 되는 등의 변경은 불가능하다. 하지만 필드, 즉 name의 실제 값은 "Amy"에서 "James"로 변경할 수 있다. 그렇기 때문에 프로퍼티를 정적이라고 할 수 있으며, 필드를 동적이라고 할 수 있다.
자바에서는 객체의 무결성(데이터를 보호하기 위해 외부에서 직접적으로 접근하는 것을 막는 것)을 지키기 위해 클래스의 멤버 변수들을 private으로 선언하고, 클래스 외부에서 이 private한 멤버 변수들에 접근하기 위해 public한 접근자 getter와 setter를 생성한다.
public class User {
private String name;
public void setName(String name) {
this.name = name;
}
public void getName() {
return this.name;
}
}
public static final void main() {
User user = new User();
user.setName("Ace");
System.out.println(user.getName());
}
하지만 멤버 변수가 늘어나게 되면 그만큼 접근자의 수도 늘어나기 때문에 코드가 길어질 수 밖에 없다. 이러한 문제를 보완하기 위해 코틀린에서는 자바와는 다른 프로퍼티의 개념을 도입했다.
필드 + 접근자(getter & setter)
코틀린에서는 클래스의 멤버 변수를 프로퍼티라고 칭한다. 코틀린의 경우, 클래스에 멤버 변수를 선언하게 되면 접근자를 자동으로 생성해준다. 따라서 자바에서처럼 멤버 변수를 선언할 때마다 매번 접근자를 생성해줄 필요가 없다.
class Main {
var name: String = ""
/* These are automatically generated */
// fun setName(name: String) {
// this.name = name
// }
// fun getName(): String = this.name
}
fun main() {
val user = User()
user.name = "Jinnie"
println(user.name)
}
Result : Jinnie
위 코드에서 변수 name은 var 키워드가 붙었기 때문에 가변 변수이다. 따라서 setter getter가 모두 생성되었다. 만약 val일 경우, 불변 변수이기 때문에 setter를 통한 값 할당이 불가능해진다. 따라서 다음과 같이 getter만 생성된다.
class Main {
val name: String = "Hazel"
/* This is automatically generated */
// fun getName(): String = this.name
}
fun main() {
val user = User()
println(user.name)
}
Result : Hazel
테스트 코드의 경우 멤버 변수 userInfo에 대한 getter가 자동으로 생성됨에도 내부에서 임의적으로 getter를 작성했으며, 두 함수가 이름, 반환 타입 및 파라미터가 모두 동일하기 때문에 충돌이 나는 것이었다. 그냥 자동으로 생성되는 접근자를 사용하면 되는 것 아니냐는 의문을 제기할 수 있겠지만, 위 테스트 코드처럼 우리는 때때로 내용만 다른, 동일한 형태의 접근자를 따로 만들어야 하는 상황을 마주할 수 밖에 없다.
안드로이드를 예로 들자면, Activity 또는 Fragment에서 버튼을 클릭하면 특정한 값을 datastore를 통해 로컬에 저장하고자 할 때, 우리는 ViewModel에서 관련 비즈니스 로직을 정의할 수 있다.
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private val viewModel: ViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
binding.btn.setOnClickListener {
viewModel.setCurrentRound("1")
}
}
}
// ViewModel.kt
class ViewModel : ViewModel() {
private val dataStore: DataStoreModule = App.getInstance().getDataStore()
var currentRound: String = ""
get() {
viewModelScope.launch {
field = dataStore.currentRound.first()
}
return field
}
fun setCurrentRound(round: String) {
viewModelScope.launch {
dataStore.setCurrentRound(round)
}
}
}
ViewModel에는 currentRound라는 가변 변수가 존재하며, 해당 변수에는 커스텀 getter를 통해 datastore의 currentRound의 최신 값이 저장된다. 또한 외부 Activity 또는 Fragment에서 setCurrentRound
를 통해 datastore currentRound의 값을 업데이트한다.
커스텀 setter를 정의하지 않은 이유는 외부 Activity 또는 Fragment에서 ViewModel의 멤버변수인 currentRound의 값이 아닌, datastore에 저장된 값을 변경하고자 했기 때문에 setter를 통해 멤버변수 currentRound의 값을 직접 set 해줄 필요가 없기 때문이다.
하지만 이렇게 코드를 작성하게 되면 앞서 말했듯 멤버변수 currentRound의 변수명에 기반하여 setter가 자동으로 생성되는데, 이는 setCurrentRound
와 이름이 동일하여 충돌이 발생하게 된다.
이 문제를 우리는 어떻게 해결할 수 있을까?
함수의 형태가 동일해서 발생하는 문제이기 때문에, 단순히 함수명만 변경하는 것으로 해결할 수도 있다.
class User {
val name: String = "Jinnie"
var userInfo: String = ""
// getUserInfo -> getUserDetail
fun getUserDetail(): String {
userInfo = "She's name is $name"
return userInfo
}
}
fun main() {
val user = User()
user.getUserDetail()
}
Kotlin -> Java
public final class User {
@NotNull
private final String name = "Jinnie";
@NotNull
private String userInfo = "";
@NotNull
public final String getName() {
return this.name;
}
@NotNull
public final String getUserInfo() {
return this.userInfo;
}
public final void setUserInfo(@NotNull String var1) {
this.userInfo = var1;
}
@NotNull
public final String getUserDetail() {
this.userInfo = "She's name is " + this.name;
return this.userInfo;
}
}
public final class MainKt {
public static final void main() {
User user = new User();
user.getUserDetail();
}
public static void main(String[] var0) {
main();
}
}
하지만 우리는 함수명의 의미를 정확히 하고싶기에, 기존에 정의했던 함수명을 그대로 사용하고 싶을 수 있다. 따라서 우리는 코틀린에서 제공하는 다음 2개의 JVM annotation를 사용해서 충돌을 피할 수 있다.
코틀린은 컴파일 시 자바 바이트코드(.class)로 컴파일되며, 다른 자바 파일들과 상호호환 되게 되며 JVM에 의해 실행된다. 이러한 변환 과정에서 좀 더 정밀한 제어를 할 수 있도록 우리는 코틀린에서 제공하는 다양한 JVM Annotation들을 사용할 수 있다.
코틀린 코드에서 자바 바이트코드로 컴파일될 때, 자바에서 호출되는 코틀린 파일명, 프로퍼티, 접근자명을 rename 하기 위해 사용하는 annotation이다. 이 annotation을 지정할 경우, 해당 파일명/프로퍼티/접근자 명은 사용자가 지정한 이름으로 변경되어 컴파일된다.
class User {
val name: String = "Jinnie"
var userInfo: String = ""
@JvmName("getUserDetail")
fun getUserInfo(): String {
userInfo = "She's name is $name"
return userInfo
}
}
fun main() {
val user = User()
user.getUserInfo()
}
public final class User {
@NotNull
private final String name = "Jinnie";
@NotNull
private String userInfo = "";
@NotNull
public final String getName() {
return this.name;
}
public final void setUserInfo(@NotNull String var1) {
this.userInfo = var1;
}
@NotNull
public final String getUserInfo() {
return this.userInfo;
}
@JvmName(
name = "getUserDetail"
)
@NotNull
public final String getUserDetail() {
this.userInfo = "She's name is " + this.name;
return this.userInfo;
}
}
public final class MainKt {
public static final void main() {
User user = new User();
user.getUserDetail();
}
public static void main(String[] var0) {
main();
}
}
코틀린 코드에서 자바 바이트코드로 컴파일될 때, 특정 프로퍼티를 필드로서 정의는 하되 접근자는 생성하고 싶지 않을 때 사용하는 annotation이다.
class User {
val name: String = "Jinnie"
@JvmField
var userInfo: String = ""
@JvmName("getUserDetail")
fun getUserInfo(): String {
userInfo = "She's name is $name"
return userInfo
}
}
fun main() {
val user = User()
user.getUserInfo()
}
public final class User {
@NotNull
private final String name = "Jinnie";
@JvmField
@NotNull
public String userInfo = "";
@NotNull
public final String getName() {
return this.name;
}
@NotNull
public final String getUserInfo() {
this.userInfo = "She's name is " + this.name;
return this.userInfo;
}
}
public final class MainKt {
public static final void main() {
User user = new User();
user.getUserDetail();
}
public static void main(String[] var0) {
main();
}
}
class User(private var name: String) {
fun getName(): String = this.name
}
fun main() {
val user = User("Jinnie")
println("This is ${user.getName()}")
}
public final class User {
private String name;
@NotNull
public final String getName() {
return this.name;
}
public User(@NotNull String name) {
super();
this.name = name;
}
}
public final class MainKt {
public static final void main() {
User user = new User("Jinnie");
String var1 = "This is " + user.getName();
System.out.println(var1);
}
public static void main(String[] var0) {
main();
}
}
기존에 작성했던 getter만 확인 가능하며 접근자는 자동으로 생성되지 않는데, 멤버변수가 private 하기 때문에 외부에서 멤버변수에 직접 접근할 수 없고, 그에 따라 접근자를 호출할 수 없기 때문이다.
class User(private val name: String) {
// ...
}
fun main() {
val user = User("Jinnie")
println(user.name) // cannot access 'name' because it is private
}
💡 kotlin에서 멤버변수가 private 할 때는 애초에 외부에서 직접 접근할 수 없기 때문에 접근자가 자동으로 생성되지 않는다.