Skip to main content
Summary

데이터만으로는 부족하다

지금까지 우리는 변수에 값을 담고, 함수로 코드를 나누고, 조건문으로 흐름을 제어했다. 하지만 프로그램이 조금만 커져도 단순한 변수와 함수만으로는 부족해진다.

예를 들어 은행 계좌를 코드로 표현한다고 해 보자.

계좌에는 이런 데이터가 있다.

  • 예금주

  • 잔액

  • 계좌 상태

그리고 이런 행동도 필요하다.

  • 입금하기

  • 출금하기

  • 잔액 확인하기

이 데이터와 행동을 따로 흩어 놓으면 코드가 금방 복잡해진다. 잔액을 아무 곳에서나 바꿀 수 있으면 음수 잔액 같은 잘못된 상태가 생기기도 쉽다.

클래스는 이 문제를 해결하기 위한 도구다. 클래스는 서로 관련 있는 데이터와 행동을 하나의 단위로 묶는다.

class BankAccount

이렇게 선언한 BankAccount는 아직 실제 계좌가 아니다. 계좌를 만들기 위한 설계도에 가깝다. 이 설계도를 바탕으로 실제 값을 가진 객체를 만들 수 있다.

val account = BankAccount()

여기서 account는 BankAccount 클래스로 만든 객체다. 객체는 인스턴스라고도 부른다.

이 장에서 말하는 객체는 클래스로 만든 인스턴스를 뜻한다. 코틀린에는 object라는 별도의 키워드도 있지만, 그것은 뒤 장에서 다룬다.

클래스와 객체로 데이터와 행동 묶기

클래스와 인스턴스의 관계

클래스는 하나지만, 인스턴스는 여러 개 만들 수 있다.

class Student(
    val name: String,
    var grade: Int
)

val minji = Student("민지", 1)
val junho = Student("준호", 2)

println(minji.name)  // 민지
println(junho.name)  // 준호

Student는 학생을 표현하는 클래스다. minji와 junho는 각각 다른 인스턴스다.

두 객체는 같은 구조를 가지지만, 내부에 담긴 값은 다르다.

minji.grade = 2

println(minji.grade) // 2
println(junho.grade) // 2

여기서 name은 val로 선언했기 때문에 바꿀 수 없다.

// minji.name = "김민지" // 오류

반면 grade는 var로 선언했기 때문에 변경할 수 있다.

중요한 점이 하나 있다.

val student = Student("지민", 1)
student.grade = 2

student가 val이어도 grade는 바뀐다. val student는 변수 student가 다른 객체를 가리키도록 다시 대입할 수 없다는 뜻이다. 객체 내부의 var 프로퍼티까지 모두 불변이 되는 것은 아니다.

// student = Student("하린", 3) // 오류
student.grade = 3              // 가능

그래서 객체의 상태를 안전하게 관리하려면 어떤 프로퍼티를 val로 둘지, 어떤 프로퍼티를 var로 둘지 신중히 정해야 한다.

주 생성자로 객체 생성 규칙 만들기

코틀린 클래스는 주 생성자를 클래스 이름 옆에 바로 쓴다.

class User(
    val name: String,
    val age: Int
)

이 클래스는 name과 age를 받아서 User 객체를 만든다.

val user = User("서연", 20)

println(user.name) // 서연
println(user.age)  // 20

생성자 매개변수 앞에 val이나 var를 붙이면 그 값은 프로퍼티가 된다.

class User(
    val name: String,
    var age: Int
)
  • val name: 읽을 수 있지만 바꿀 수 없는 프로퍼티

  • var age: 읽고 바꿀 수 있는 프로퍼티

반대로 val이나 var를 붙이지 않으면 단순한 생성자 매개변수다. 객체가 만들어지는 과정에서만 사용할 수 있다.

class User(name: String) {
    val displayName: String = name.trim()
}

val user = User("  민수  ")
println(user.displayName) // 민수

여기서 name은 프로퍼티가 아니다. user.name으로 접근할 수 없다. 대신 name.trim()의 결과를 displayName 프로퍼티에 저장했다.

생성자에는 기본값도 줄 수 있다.

class Order(
    val number: String,
    val status: String = "READY"
)

val order1 = Order("A-001")
val order2 = Order("A-002", "PAID")

println(order1.status) // READY
println(order2.status) // PAID

기본값을 잘 사용하면 불필요한 생성자를 줄일 수 있다.

init 블록으로 생성 시점 검증하기

객체는 처음부터 올바른 상태로 만들어져야 한다. 이름이 비어 있거나 나이가 음수인 사용자를 허용하면, 그 객체를 사용하는 모든 코드가 불안해진다.

이럴 때 init 블록을 사용한다.

class User(
    val name: String,
    val age: Int
) {
    init {
        require(name.isNotBlank()) { "이름은 비어 있을 수 없습니다." }
        require(age >= 0) { "나이는 0 이상이어야 합니다." }
    }
}

init 블록은 객체가 만들어질 때 실행된다. 위 코드에서 잘못된 값으로 객체를 만들면 예외가 발생한다.

val user = User("", -1) // 예외 발생

require는 조건이 거짓이면 IllegalArgumentException을 던진다. 즉, 잘못된 인자를 넘겼다는 뜻을 코드로 표현한다.

init 블록은 검증뿐 아니라 초기화에도 사용할 수 있다.

class Member(rawName: String) {
    val name: String

    init {
        name = rawName.trim()
        require(name.isNotEmpty()) { "이름은 비어 있을 수 없습니다." }
    }
}

val member = Member("  유나  ")
println(member.name) // 유나

val 프로퍼티는 한 번만 값을 넣을 수 있다. 위 예제에서는 init 블록 안에서 name을 한 번 초기화했다.

중요한 기준은 간단하다.

객체가 만들어진 직후 반드시 만족해야 하는 규칙은 생성자와 init 블록에서 처리하자.

부 생성자로 다른 생성 경로 제공하기

주 생성자 외에 부 생성자를 만들 수도 있다. 부 생성자는 constructor 키워드로 선언한다.

class Rectangle(
    val width: Int,
    val height: Int
) {
    init {
        require(width > 0) { "너비는 0보다 커야 합니다." }
        require(height > 0) { "높이는 0보다 커야 합니다." }
    }

    constructor(size: Int) : this(size, size)
}

이제 직사각형과 정사각형을 모두 만들 수 있다.

val rectangle = Rectangle(10, 20)
val square = Rectangle(10)

println(rectangle.width) // 10
println(rectangle.height) // 20

println(square.width) // 10
println(square.height) // 10

부 생성자에서 : this(size, size)라고 쓴 부분을 보자. 주 생성자가 있는 클래스에서 부 생성자는 반드시 주 생성자를 호출해야 한다. 그래야 객체 생성 규칙이 한곳으로 모인다.

이 예제에서 Rectangle(10)을 호출해도 결국 Rectangle(10, 10)이 호출된다. 따라서 init 블록의 검증도 동일하게 적용된다.

다만 코틀린에서는 단순한 경우 부 생성자보다 기본값을 더 자주 쓴다.

class Product(
    val name: String,
    val price: Int = 0
)

val freeProduct = Product("샘플")
val paidProduct = Product("키보드", 50_000)

부 생성자는 생성 방식이 명확히 다르거나, 다른 형태의 입력을 받아 객체를 만들어야 할 때 사용하면 좋다. 단순히 값을 생략하고 싶을 뿐이라면 기본값이 더 간결하다.

프로퍼티는 필드보다 큰 개념이다

코틀린에서 프로퍼티는 단순한 변수가 아니다. 값을 읽는 동작과 쓰는 동작을 함께 가진다.

class Person(
    var name: String
)

name은 하나의 프로퍼티다. 밖에서는 이렇게 사용한다.

val person = Person("지호")

println(person.name)
person.name = "지훈"

겉으로는 변수처럼 보이지만, 내부적으로는 값을 읽을 때 getter가 사용되고 값을 쓸 때 setter가 사용된다.

val 프로퍼티에는 getter만 있다.

class Person(
    val name: String
)

var 프로퍼티에는 getter와 setter가 있다.

class Person(
    var name: String
)

직접 getter와 setter를 정의할 수도 있다.

class Product(
    val name: String,
    initialPrice: Int
) {
    var price: Int = initialPrice
        set(value) {
            require(value >= 0) { "가격은 0 이상이어야 합니다." }
            field = value
        }

    val label: String
        get() = "$name: ${price}원"
}

price의 setter는 새 값이 0 이상인지 검사한다.

val product = Product("마우스", 20_000)

product.price = 25_000
println(product.price) // 25000

// product.price = -1 // 예외 발생

label은 값을 따로 저장하지 않는다. 읽을 때마다 name과 price를 이용해 계산한다.

println(product.label) // 마우스: 25000원

이처럼 프로퍼티는 두 가지 방식으로 동작할 수 있다.

  • 값을 저장하는 프로퍼티

  • 읽을 때 계산하는 프로퍼티

둘 다 사용하는 쪽에서는 product.label처럼 같은 방식으로 접근한다. 이것이 프로퍼티의 장점이다.

backing field와 field 키워드

커스텀 getter나 setter 안에서 실제 저장 공간에 접근하려면 field를 사용한다.

class Counter {
    var count: Int = 0
        set(value) {
            require(value >= 0) { "count는 0 이상이어야 합니다." }
            field = value
        }
}

여기서 field는 count 값을 실제로 저장하는 내부 공간을 가리킨다. 이것을 backing field라고 한다.

setter 안에서 다음처럼 쓰면 안 된다.

class Counter {
    var count: Int = 0
        set(value) {
            // count = value // 잘못된 코드
            field = value
        }
}

count = value라고 쓰면 다시 setter를 호출한다. setter 안에서 setter를 다시 부르는 셈이므로 문제가 생긴다. 그래서 실제 값을 바꿀 때는 field = value를 사용한다.

반대로 계산만 하는 프로퍼티에는 backing field가 필요 없다.

class Cart(
    val itemCount: Int,
    val itemPrice: Int
) {
    val totalPrice: Int
        get() = itemCount * itemPrice
}

totalPrice는 따로 저장되지 않는다. 읽을 때마다 계산된다.

val cart = Cart(3, 10_000)
println(cart.totalPrice) // 30000

프로퍼티를 설계할 때는 먼저 질문해야 한다.

이 값은 저장해야 하는가, 아니면 다른 값으로부터 계산할 수 있는가?

계산할 수 있는 값이라면 굳이 상태로 저장하지 않는 편이 안전하다. 저장해야 할 상태가 줄어들수록 객체는 단순해진다.

접근 제한자로 공개 범위 정하기

객체의 모든 값을 외부에 공개하면 편해 보이지만 위험하다. 아무 코드나 내부 상태를 바꿀 수 있기 때문이다.

코틀린은 접근 제한자로 공개 범위를 조절한다.

접근 제한자의미
public어디서나 접근할 수 있다. 생략하면 기본값이다.
private같은 클래스 내부에서만 접근할 수 있다. 최상위 선언에서는 같은 파일 안에서만 접근할 수 있다.
protected같은 클래스와 하위 클래스에서 접근할 수 있다. 상속과 함께 쓰인다.
internal같은 모듈 안에서 접근할 수 있다.

가장 자주 쓰는 것은 public과 private이다.

class Wallet(
    val owner: String,
    initialMoney: Int
) {
    var money: Int = initialMoney
        private set

    fun add(amount: Int) {
        validateAmount(amount)
        money += amount
    }

    fun use(amount: Int): Boolean {
        validateAmount(amount)

        if (amount > money) {
            return false
        }

        money -= amount
        return true
    }

    private fun validateAmount(amount: Int) {
        require(amount > 0) { "금액은 0보다 커야 합니다." }
    }
}

이 클래스에서 money는 밖에서 읽을 수는 있지만 직접 바꿀 수는 없다.

val wallet = Wallet("민지", 10_000)

println(wallet.money) // 10000

wallet.add(5_000)
println(wallet.money) // 15000

// wallet.money = 0 // 오류

private set은 setter만 비공개로 만든다. getter는 여전히 공개다.

validateAmount는 클래스 내부에서만 사용하는 함수이므로 private으로 숨겼다. 밖에서 알 필요가 없는 구현 세부 사항이기 때문이다.

접근 제한자를 정할 때는 다음 기준을 사용하자.

  • 다른 코드가 반드시 알아야 하는 것만 public으로 둔다.

  • 내부 규칙과 보조 함수는 private으로 숨긴다.

  • 같은 모듈 안에서만 공유할 기능은 internal을 고려한다.

  • 상속을 통해 확장할 때만 protected를 고려한다.

공개 범위는 좁을수록 좋다. 나중에 바꾸기 쉽고, 잘못 사용할 가능성이 줄어든다.

상태 변경은 메서드로 관리한다

객체의 상태는 시간이 지나면서 바뀔 수 있다. 문제는 상태가 아무렇게나 바뀌면 객체가 쉽게 망가진다는 점이다.

은행 계좌 예제로 정리해 보자.

class BankAccount(
    val owner: String,
    initialBalance: Int = 0
) {
    var balance: Int = initialBalance
        private set

    init {
        require(owner.isNotBlank()) { "예금주는 비어 있을 수 없습니다." }
        require(initialBalance >= 0) { "초기 잔액은 0 이상이어야 합니다." }
    }

    fun deposit(amount: Int) {
        validateAmount(amount)
        balance += amount
    }

    fun withdraw(amount: Int): Boolean {
        validateAmount(amount)

        if (amount > balance) {
            return false
        }

        balance -= amount
        return true
    }

    private fun validateAmount(amount: Int) {
        require(amount > 0) { "금액은 0보다 커야 합니다." }
    }
}

이 클래스에는 몇 가지 중요한 설계가 들어 있다.

첫째, 예금주는 val이다.

val owner: String

계좌가 만들어진 뒤 예금주가 마음대로 바뀌면 이상하다. 그래서 변경할 수 없게 했다.

둘째, 잔액은 var이지만 setter는 private이다.

var balance: Int = initialBalance
    private set

잔액은 입금과 출금으로 바뀌어야 한다. 하지만 외부 코드가 직접 balance = -1000처럼 바꾸면 안 된다.

셋째, 상태 변경은 deposit과 withdraw에 모았다.

fun deposit(amount: Int) {
    validateAmount(amount)
    balance += amount
}

fun withdraw(amount: Int): Boolean {
    validateAmount(amount)

    if (amount > balance) {
        return false
    }

    balance -= amount
    return true
}

이렇게 하면 잔액이 바뀌는 규칙을 한곳에서 관리할 수 있다.

사용하는 코드는 단순해진다.

val account = BankAccount("민지", 10_000)

account.deposit(5_000)
println(account.balance) // 15000

val success = account.withdraw(7_000)
println(success)         // true
println(account.balance) // 8000

val failed = account.withdraw(20_000)
println(failed)          // false
println(account.balance) // 8000

객체는 자신의 상태를 스스로 지켜야 한다. 외부 코드가 내부 상태를 마음대로 조작하게 두지 말고, 의미 있는 메서드를 통해 바꾸게 해야 한다.

이것을 캡슐화라고 한다. 캡슐화는 어렵게 말하면 내부 구현을 숨기는 것이고, 쉽게 말하면 "아무나 함부로 못 바꾸게 하는 것"이다.

this로 현재 객체 가리키기

클래스 안에서 현재 객체를 가리킬 때 this를 사용한다.

class User(
    val name: String
) {
    fun hasSameName(name: String): Boolean {
        return this.name == name
    }
}

여기서 생성자 프로퍼티 name과 함수 매개변수 name의 이름이 같다.

  • this.name: 객체가 가진 프로퍼티

  • name: 함수로 전달된 매개변수

이름이 겹치지 않으면 this는 보통 생략한다.

class User(
    val name: String
) {
    fun introduce() {
        println("안녕하세요, 저는 ${name}입니다.")
    }
}

위 코드에서 name은 사실 this.name과 같다. 코틀린에서는 문맥상 분명하면 this를 생략하는 스타일을 자주 사용한다.

좋은 클래스는 규칙을 담고 있다

클래스는 단순히 여러 변수를 모아 놓는 상자가 아니다. 좋은 클래스는 그 데이터가 지켜야 할 규칙까지 함께 담는다.

나쁜 예를 보자.

class Account(
    var owner: String,
    var balance: Int
)

이 클래스는 너무 많은 것을 허용한다.

val account = Account("민지", 10_000)

account.owner = ""
account.balance = -100_000

문법적으로는 가능하지만, 의미적으로는 잘못된 상태다.

조금 더 나은 클래스는 잘못된 상태를 막는다.

class Account(
    val owner: String,
    initialBalance: Int
) {
    var balance: Int = initialBalance
        private set

    init {
        require(owner.isNotBlank())
        require(initialBalance >= 0)
    }

    fun deposit(amount: Int) {
        require(amount > 0)
        balance += amount
    }
}

이제 외부 코드는 잔액을 직접 망가뜨릴 수 없다. 반드시 deposit 같은 의미 있는 행동을 통해 상태를 바꿔야 한다.

클래스를 만들 때는 다음 순서로 생각하면 좋다.

  1. 이 객체가 가져야 할 데이터는 무엇인가?

  2. 그중 바뀌면 안 되는 값은 무엇인가?

  3. 바뀌어도 되는 값은 어떤 규칙으로 바뀌어야 하는가?

  4. 외부에 공개해야 할 행동은 무엇인가?

  5. 숨겨야 할 내부 구현은 무엇인가?

이 질문에 답하다 보면 클래스의 모양이 자연스럽게 잡힌다.

작은 연습: 할 일 객체 만들기

할 일을 표현하는 클래스를 만들어 보자.

요구 사항은 다음과 같다.

  • 제목은 비어 있을 수 없다.

  • 완료 여부는 처음에는 false다.

  • 완료 처리는 메서드로만 할 수 있다.

  • 완료된 할 일인지 읽을 수는 있어야 한다.

한 가지 구현은 다음과 같다.

class Todo(
    val title: String
) {
    var completed: Boolean = false
        private set

    init {
        require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다." }
    }

    fun complete() {
        completed = true
    }
}

사용해 보자.

val todo = Todo("코틀린 클래스 공부하기")

println(todo.title)     // 코틀린 클래스 공부하기
println(todo.completed) // false

todo.complete()
println(todo.completed) // true

// todo.completed = false // 오류

이 작은 예제에도 클래스 설계의 핵심이 들어 있다.

  • 생성자에서 필요한 값을 받는다.

  • init 블록에서 올바른 값인지 검사한다.

  • 외부에서 직접 바꾸면 안 되는 값은 private set으로 보호한다.

  • 상태 변경은 의미 있는 메서드로 제공한다.

이번 장에서 기억할 것

클래스는 현실의 개념을 코드로 표현하는 기본 단위다. 데이터와 행동을 하나로 묶으면 코드가 더 읽기 쉬워지고, 잘못된 상태를 막기 쉬워진다.

이번 장의 핵심은 다음과 같다.

  • 클래스는 객체를 만들기 위한 설계도다.

  • 객체 또는 인스턴스는 클래스로부터 만들어진 실제 값이다.

  • 주 생성자는 객체를 만들 때 필요한 값을 정의한다.

  • 부 생성자는 다른 생성 경로가 필요할 때 사용한다.

  • init 블록은 객체 생성 시점의 검증과 초기화에 사용한다.

  • 프로퍼티는 getter와 setter를 가질 수 있다.

  • field는 커스텀 접근자 안에서 backing field에 접근할 때 사용한다.

  • 접근 제한자는 객체의 내부 상태를 보호하기 위해 사용한다.

  • 상태 변경은 가능한 한 메서드로 모아 관리한다.

클래스를 잘 설계한다는 것은 단순히 문법을 아는 것이 아니다. 어떤 값을 공개하고, 어떤 값을 숨기고, 어떤 행동을 통해 상태를 바꿀지 결정하는 일이다.

다음 장에서는 코틀린이 제공하는 더 코틀린다운 객체 모델을 살펴본다. 데이터 클래스, 싱글턴 객체, 동반 객체처럼 코틀린에서 자주 쓰는 표현 방식이 등장한다.

책 목록

처음 배우는 코틀린: 문법에서 실전 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기