프로그래밍/Kotlin

Java Class to Kotlin Class

시뻘건 튼튼발자 2025. 6. 4. 18:13
반응형
자바에서 코틀린으로 포팅하면서 고려해야할 리팰토링 중 [클래스]부문에 대해서 설명한다.
백문이 불여일견이라고 직접 실습을 통해 차근차근 설명하겠다.

 

 

아래 Java class [EmailAddress] 클래스를 Kotlin class로 포팅해보자.

public class EmailAddress {
    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;
    }

    public String getLocalPart() {
        return localPart;
    }

    public String getDomain() {
        return domain;
    }

    @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);
    }

    @Override
    public int hashCode() {
        return Objects.hash(localPart, domain);
    }

    @Override
    public String toString() {
        return localPart + "@" + domain;
    }
}

 

 

코틀린 클래스는 주 생성자 안에서 프로퍼티를 선언하기 때문에 자바보다 간결하다. 파라미터 앞에 'val'가 붙어 있으면 프로퍼티로 간주한다. 정적 상태와 메서드를 포함시키기 위해 사용하는 동반 객체를 살펴보자. 동반 객체와 @JvmStatic 어노테이션을 사용하면 클래스를 코틀린으로 바꿔도 이런 정적 메서드 호출 코드를 변경할 필요가 없다. 코틀린에서는 최상위 상태와 함수를 이런 클래스 영역의 멤버로 두는 것을 선호하는 경우가 있는데 이 부분에 대해서는 다음에 자세히 살펴보겠다. 지금은 "이렇게 포팅하면 되는구나"정도로만 알고 넘어가자.

domain 프로퍼티를 선언하면 코틀린 컴파일러가 비공개 domain필드와 getDomain() 접근자 메서드를 생성해준다.

이를 바탕으로 코틀린 클래스로 나타내면 아래와 같은 코드가 완성된다.

 

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)
            )
        }
    }
}

 

 

이제 데이터 클래스(data class)를 알아보겠다. 1차적으로 포팅한 위 코틀린 클래스를 보면 본문에 equals() 메서드와 hashCode(), toString()과 같은 메서드를 볼 수 있다. 코틀린에서는 data class를 사용하면 equals 메서드와 hashCode, toString클래스를 대신 생성해준다. 따라서 아래와 같이 2차적으로 개선할 수 있다. (parse() 메서드는 한 층 더 개선할 수 있지만 로직 관점에서의 개선은 다음 포스팅에서 살펴본다.)

 

data class EmailAddress(
    val localPart: String,
    val domain: String
) {

    override fun toString(): String { // toString() customize
        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)
            )
        }
    }
}

 

위 예제와 같이 data class를 필요한 경우에 잘 활용하면 좋다. 그치만 data class에도 한계점이 있다. 그 점은 캡슐화다.

앞에서 data class를 사용하면 equals 메서드와 hashCode, toString클래스를 대신 생성해준다고 했다. 사실은 컴파일러가 데이터 클래스 객체의 모든 프로퍼티 값을 그대로 복사한 새로운 객체를 생성하되, 원하면 일부를 다른 값으로 대치할 수 있는 copy() 메서드도 생성한다. 예를 들어 아래와 같이 localPartpostmaster로 바꾸고 domain은 같은 객체를 만들 수 있다.

val postmasterEmail = customerEmail.copy(localPart = "postmaster")

이런 기능이 편리 할 수 있겠지만, 클래스가 내부 표현을 추상화하거나 프로퍼티 사이에 어떤 불변 조건을 유지해야하는 경우에도 copy() 메서드로부터 내부 상태에 직접 접근하도록 허용하기 때문에 불변 조건을 깰 수 있다는 이슈가있다.

 

마지막으로 아래 Money 클래스를 포팅해보자.

public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    private Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    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);
    }


    public BigDecimal getAmount() {
        return amount;
    }

    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);
    }

    @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);
    }
}

 

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)
        }
    }
}
반응형