102. (Java to Kotlin) 2. 클래스

2. 클래스

원제 : Java to Kotlin Classes

자바에서 클래스는 코드를 조작하는 기본 단위이다.

자바의 클래스를 코틀린의 클래스로 변환해보고, 어떤 차이점이 있는지 알아보자.

2.1. 간단한 값 타입

원제 : A Simple Value Type

우리는 먼저 아래 EmailAddress라는 자바 클래스를 코틀린으로 변경하려고 한다.

EmailAddress는 이메일 주소의 local part와 domain을 나누어 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class EmailAddress {
// 각 값은 불변이므로 final로 선언되었다.
private final String localPart;
private final String domain;

// 문자열을 파싱하는 정적 팩토리 메서드로 생성자를 호출한다.
public static EmailAddress parse(String value) {
var atIndex = value.lastIndexOf('@');
if (atIndex < 1 || atIndex == value.length() - 1)
throw new IllegalArgumentException(
"EmailAddress must be two parts separated by @"
);
return new EmailAddress(
value.substring(0, atIndex),
value.substring(atIndex + 1)
);
}

// 필드 값은 생성자에서 초기화된다.
public EmailAddress(String localPart, String domain) {
this.localPart = localPart;
this.domain = domain;
}

// JavaBean 규약에 따른 Getter
public String getLocalPart() {
return localPart;
}

// JavaBean 규약에 따른 Getter
public String getDomain() {
return domain;
}

// 동일성, 동등성 보장을 위해 equals를 오버라이딩한다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EmailAddress that = (EmailAddress) o;
return localPart.equals(that.localPart) &&
domain.equals(that.domain);
}

// 동일성, 동등성 보장을 위해 hashCode를 오버라이딩한다.
@Override
public int hashCode() {
return Objects.hash(localPart, domain);
}

// 표준 이메일 포맷인 local part + symbol + domain 으로 객체의 값을 표현한다.
@Override
public String toString() { // <6>
return localPart + "@" + domain;
}
}

EmailAddress는 local part와 domain을 감싸기만하고 별도의 연산은 없는 간단한 코드이다.

두 개의 문자열을 입력받아, 하나의 포맷을 출력해주는 것치곤 많은 코드가 필요하며, 필드가 추가될때마다 오버라이딩 비용도 계속해서 투입되어야 한다.

이제 이 클래스를 코틀린으로 변환해보자.

IDE의 기능으로 변환하면 아래와 같은 형태로 변환된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class EmailAddress(
val localPart: String,
val domain: String,
) {
override fun equals(o: Any?): Boolean {
if (this === o) return true
if (o == null || javaClass != o.javaClass) return false
val that = o as EmailAddress
return localPart == that.localPart && domain == that.domain
}

override fun hashCode(): Int {
return Objects.hash(localPart, domain)
}

override fun toString(): String {
return "$localPart@$domain"
}

companion object {
@JvmStatic
fun parse(value: String): EmailAddress {
val atIndex = value.lastIndexOf('@')
require(!(atIndex < 1 || atIndex == value.length - 1)) {
"EmailAddress must be two parts separated by @"
}
return EmailAddress(
value.substring(0, atIndex),
value.substring(atIndex + 1)
)
}
}
}

코틀린의 클래스는 하나의 주 생성자와 복수 개의 부 생성자를 구현할 수 있다.

1
2
3
4
class EmailAddress(
val localPart: String,
val domain: String,
)

주 생성자는 위 코드처럼 클래스 이름 뒤에 괄호로 둘러쌓아서 표현한다.

파라미터 앞에 붙은 val은 파라미터를 클래스의 프로퍼티로 취급하게 해준다.

참고 domain 프로퍼티 끝에도 콤마(,)가 있는 것을 볼 수 있다.
오타처럼 보이는 이것은 사실 Trailing Commas 라고 불리는 코틀린의 권장사항이다.
코틀린 1.4부터 지원되며 가독성 및 VCS에서의 diff 비교를 간소화해준다.
kotlinlang.org - Coding conventions

위 한 줄의 코드는 아래 자바 코드를 전부 대체해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final String localPart; 
private final String domain;

// 필드 값은 생성자에서 초기화된다.
public EmailAddress(String localPart, String domain) {
this.localPart = localPart;
this.domain = domain;
}

// JavaBean 규약에 따른 Getter
public String getLocalPart() {
return localPart;
}

// JavaBean 규약에 따른 Getter
public String getDomain() {
return domain;
}

그 다음으로 눈여겨볼 코드는 아래 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
companion object {
@JvmStatic
fun parse(value: String): EmailAddress {
val atIndex = value.lastIndexOf('@')
require(!(atIndex < 1 || atIndex == value.length - 1)) {
"EmailAddress must be two parts separated by @"
}
return EmailAddress(
value.substring(0, atIndex),
value.substring(atIndex + 1)
)
}
}

오히려 더 복잡해졌다는 느낌을 주는데, 코틀린은 static 키워드가 없기때문이다.

정적 메서드 영역은 companion object라는 동반 객체 블럭 안에 위치시킴으로서 동일하게 구현할 수가 있다.

@JvmStatic 어노테이션도 추가된 것을 볼 수 있는데, 이는 자바에서 해당 메서드(여기서는 parse())를 호출할 때

마치 자바의 정적 메서드를 호출하듯이 쓸 수 있게 해준다.

실제로 아래와 같이 호출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class EmailAddressTests {

@Test
public void parsing() {
assertEquals(
new EmailAddress("fred", "example.com"),
EmailAddress.parse("fred@example.com")
);
}

@Test
public void parsingFailures() {
assertThrows(
IllegalArgumentException.class,
() -> EmailAddress.parse("@"
);
assertThrows(
IllegalArgumentException.class,
() -> EmailAddress.parse("fred@")
);
assertThrows(
IllegalArgumentException.class,
() -> EmailAddress.parse("@example.com")
);
assertThrows(
IllegalArgumentException.class,
() -> EmailAddress.parse("fred_at_example.com")
);
}

@Test
public void parsingWithAtInLocalPart() {
assertEquals(
new EmailAddress("\"b@t\"", "example.com"),
EmailAddress.parse("\"b@t\"@example.com")
);
}
}

그 다음으로 JavaBean의 Getter에 대해서도 생각해보자.

코틀린의 클래스는 프로퍼티에 대해 자동으로 getter를 생성해준다.

따라서 프로퍼티 domain에 대해서는 자바에서 아래와 같이 호출할 수 있다.

1
2
3
4
5
6
public class Marketing {

public static boolean isHotmailAddress(EmailAddress address) {
return address.getDomain().equalsIgnoreCase("hotmail.com");
}
}

이후 클래스 앞에 data 키워드를 붙여서 데이터 클래스로 선언하면 그 자체로 값 타입으로 처리되어 최종적으로 아래와 같이 코드가 줄어든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
data class EmailAddress(
val localPart: String,
val domain: String
) {

override fun toString(): String { // <1>
return "$localPart@$domain"
}

companion object {
@JvmStatic
fun parse(value: String): EmailAddress {
val atIndex = value.lastIndexOf('@')
require(!(atIndex < 1 || atIndex == value.length - 1)) {
"EmailAddress must be two parts separated by @"
}
return EmailAddress(
value.substring(0, atIndex),
value.substring(atIndex + 1)
)
}
}
}

2.2. 데이터 클래스의 한계

원제 : The Limitations of Data Classes

2.1에서 변환한 데이터 클래스는 캡슐화를 제공하지 않는다는 단점이 있다.

데이터 클래스에 대해 컴파일러가 equals(), hashCode(), toString() 메서드를 생성해주는ㄴ데,

추가로 copy() 메서드도 생성해준다.

예제를 통해 살펴보도록 하자.

먼저 아래와 같이 EmailAddress 객체를 하나 만든다.

1
2
3
val postmasterEmail = customerEmail.copy(
localPart = "postmaster",
)

특정 상태만 바꾼채로 객체를 복제할 수 있다는 건 유용한 것은 분명하다.

다만, copy() 메서드 자체가 내부 상태를 직접 접근하도록 허용하는 것이므로 불변 조건을 꺠뜨리게 된다.

좀 더 깊은 이해를 위해 Money 클래스 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class Money {
private final BigDecimal amount;
private final Currency currency;

// 생성자가 비공개임을 확인하자.
private Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}

// 생성자가 비공개이므로, 정적 메서드 of() 를 이용해 객체를 획득해야한다.
public static Money of(BigDecimal amount, Currency currency) {
return new Money(
amount.setScale(currency.getDefaultFractionDigits()),
currency);
}

public static Money of(String amountStr, Currency currency) {
return Money.of(new BigDecimal(amountStr), currency);
}

public static Money of(int amount, Currency currency) {
return Money.of(new BigDecimal(amount), currency);
}

public static Money zero(Currency userCurrency) {
return Money.of(ZERO, userCurrency);
}

// JavaBean 규약에 따라 amount와 currency 프로퍼티가 노출되어있다.
public BigDecimal getAmount() {
return amount;
}

// JavaBean 규약에 따라 amount와 currency 프로퍼티가 노출되어있다.
public Currency getCurrency() {
return currency;
}

// 각 객체의 의미론 원칙에 의거하여 구현되어 있다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.equals(money.amount) &&
currency.equals(money.currency);
}

// 각 객체의 의미론 원칙에 의거하여 구현되어 있다.
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}

// toString()은 프로퍼티를 노출하되, 사용자가 볼 수 있는 표현을 반환한다.
@Override
public String toString() {
return amount.toString() + " " + currency.getCurrencyCode();
}

// 두 통화 값을 더할 수 있는 연산을 제공한다.
public Money add(Money that) {
if (!this.currency.equals(that.currency)) {
throw new IllegalArgumentException(
"cannot add Money values of different currencies");
}

return new Money(this.amount.add(that.amount), this.currency);
}
}

이제 이걸 코틀린으로 변환해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Money
private constructor(
val amount: BigDecimal,
val currency: Currency
) {
override fun equals(o: Any?): Boolean {
if (this === o) return true
if (o == null || javaClass != o.javaClass) return false
val money = o as Money
return amount == money.amount && currency == money.currency
}

override fun hashCode(): Int {
return Objects.hash(amount, currency)
}

override fun toString(): String {
return amount.toString() + " " + currency.currencyCode
}

fun add(that: Money): Money {
require(currency == that.currency) {
"cannot add Money values of different currencies"
}
return Money(amount.add(that.amount), currency)
}

companion object {
@JvmStatic
fun of(amount: BigDecimal, currency: Currency): Money {
return Money(
amount.setScale(currency.defaultFractionDigits),
currency
)
}

@JvmStatic
fun of(amountStr: String?, currency: Currency): Money {
return of(BigDecimal(amountStr), currency)
}

@JvmStatic
fun of(amount: Int, currency: Currency): Money {
return of(BigDecimal(amount), currency)
}

@JvmStatic
fun zero(userCurrency: Currency): Money {
return of(BigDecimal.ZERO, userCurrency)
}
}
}

비공개 생성자가 있으므로, 코틀린도 주생성자를 private으로 처리하였다.

추가로 of() 메서드는 @JvmStatic 어노테이션으로 자바에서의 동일 호출을 보장한다.

이제 다시 데이터 클래스로 변환해보자.

1
2
3
4
5
6
7
data class Money
private constructor(
val amount: BigDecimal,
val currency: Currency
) {
// ...
}

data 키워드를 선언하면 아래와 같은 경고 메시지가 출력 된다.

1
Private data class constuctor is exposed via the generated 'copy' method.

컴파일러가 데이터 클래스로 바꾸면 비공개 생성자로 작성하더라도 생성자가 노출된다고 경고하는 것을 확인할 수 있다.

이처럼 데이터 클래스에 있는 copy() 메서드는 항상 public이기 때문에 불변 조건을 위배하는 Money를 생성할 수 있게 된다.