본문 바로가기
language/Kotilin In Action

2주차 (4~5장)

by abstract.jiin 2025. 9. 1.

4장 클래스. 객체, 인터페이스

4.1 클래스

자바와 달리 코틀린 선언은 기본적으로 final이며 public이다. 게다가 내포 클래스는 기본적으로는 내부 클래스가 아니다. 즉, 코틀린 내포 클래스에는 외부 클래스에 대한 암시적 참조가 없다

자바에서는 A클래스 안에 B클래스를 정의하면 B클래스는 자동으로 A클래스의 내부 클래스가 되어 A클래스 안의 객체를 참조할 수 있음. 코틀린에서는 이 경우 기본적으로 중첩 클래스가 된다. 내부 클래스로 만들고 싶으면 inner 를 붙여 클래스를 선언해야 함

4.1.1 코틀린 인터페이스

코틀린 인터페이스 인터페이스를 정의하고 구현하는 방법

  • 추상 메서드 뿐 아니라 구현이 있는 메서드도 정의할 수 있다.
  • 인터페이스에는 아무런 상태도 들어갈 수 없다.

4.1.2 open, final, abstract 변경자

  • 코틀린의 기본 원칙: 모든 것이 final
// 기본적으로 상속 불가능
class Button// final class (상속 못함)// 상속 가능하게 만들려면 open
open class RichButton {
    fun disable() {}// final 메서드 (오버라이드 못함)
    open fun animate() {}// open 메서드 (오버라이드 가능)
    override fun click() {}// 오버라이드한 메서드는 기본적으로 open
}

// 상속받기
class ThemedButton : RichButton() {
    override fun animate() {// 가능 (open이니까)
        println("테마 애니메이션")
    }
// override fun disable() {}        // 불가능 (final이니까)
}

  • abstract 클래스
abstract class Animated {
    abstract val animationSpeed: Double// 반드시 구현해야 함
    val keyframes: Int = 20// 일반 프로퍼티
    open val frames: Int = 60// 오버라이드 가능

    abstract fun animate()// 반드시 구현해야 함
    open fun stopAnimating() {}// 오버라이드 가능
    fun animateTwice() {}// final (오버라이드 불가)
}

final ( 오버라이드 불가 - 기본값 )

open ( 오버라이드 가능 )

abstract ( 반드시 오버라이드 )

override ( 오버라이드 중 )

4.1.3 가시성 변경자

  • 코틀린의 가시성 (기본: public)
class User {
    public val name: String = "홍길동"// public (기본값)
    internal val id: String = "user123"// 같은 모듈에서만
    protected val password: String = "****"// 상속받은 클래스에서만
    private val secret: String = "비밀"// 이 클래스에서만
}

public 모든 곳

internal 같은 모듈

protected 하위 클래스만

private 같은 클래스만

4.1.4 내부 클래스(Inner Class)와 내포된 클래스 (중첩 클래스 Nested Class)

  • 코틀린의 기본: 내포 클래스 (바깥 클래스 참조 안 함)
class Outer {
    private val outerProperty = "바깥 클래스"

// 내포 클래스 (기본) - 바깥 클래스 참조 없음
    class Nested {
        fun doSomething() {
// println(outerProperty)  // 접근 불가
            println("내포 클래스 실행")
        }
    }

// 내부 클래스 - 바깥 클래스 참조 있음
    inner class Inner {
        fun doSomething() {
            println(outerProperty)// 접근 가능
            println("내부 클래스 실행")
        }
    }
}

// 사용
val nested = Outer.Nested()// 바깥 인스턴스 없이 생성
val outer = Outer()
val inner = outer.Inner()// 바깥 인스턴스 필요
               java              /   kotlin

내포 클래스 static class A / class A

내부 클래스 class A / inner class A

4.1.5 봉인된 클래스 (sealed class)

  • when에서 else 필수
interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

fun eval(e: Expr): Int = when (e) {
    is Num -> e.value
    is Sum -> eval(e.left) + eval(e.right)
    else -> throw IllegalArgumentException("알 수 없는 식")// 반드시 필요
}

  • 해결: sealed class
sealed class Expr// 봉인된 클래스
class Num(val value: Int) : Expr()
class Sum(val left: Expr, val right: Expr) : Expr()

fun eval(e: Expr): Int = when (e) {
    is Num -> e.value
    is Sum -> eval(e.left) + eval(e.right)
// else 불필요! 컴파일러가 모든 경우를 확인 (다른 클래스가 추가될 가능성이 없음 - sealed 라서)
}

장점 : 새로운 클래스 추가하면 컴파일 에러 발생

제한된 상속으로 안전한 when 사용

4.2 프로퍼티

프로퍼티 의 2가지 유형

  • 값을 저장하는 프로퍼티
  • 커스텀 접근자에서 매번 값을 계산하는 프로퍼티

4.2.1 클래스 초기화: 주 생성자와 초기화 블록

주 생성자 : 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드

  • 생성자 파라미터를 지정
  • 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의

constructor 주 생성자나 부 생성자 정의를 시작할 때

init 초기화 블록의 시작

초기화블록 : 클래스의 객체가 만들어질 때(인스턴스화될 때) 실행될 초기화 코드

4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화

constructor 키워드로 시작

여러 개 선언 가능

4.2.3 인터페이스에 선언된 프로퍼티 구현

함수대신 프로퍼티를 사용하는 경우

  • 예외 없음
  • 빠른 계산
  • 일관된 결과
class Rectangle(val width: Int, val height: Int) {
    // 프로퍼티 - 3가지 조건 모두 만족
    val area: Int
        get() = width * height      // 예외 없음, 빠름, 일관됨
    
    val isSquare: Boolean
        get() = width == height     // 예외 없음, 빠름, 일관됨
    
    // 함수 - 매번 다른 결과 가능
    fun generateId(): String {
        return UUID.randomUUID().toString()  // 매번 다름
    }
    
    // 함수 - 예외 가능성
    fun divide(): Double {
        return width.toDouble() / height  // height가 0이면 문제
    }
}

4.2.4 게터와 세터에서 뒷받침하는 필드에 접근

프로퍼티에 값을 저장하면서 동시에 추가 작업도 하려면 field를 사용해야 함

  • field = 실제 값이 저장되는 곳
  • setter 안에서 field = value = 값 저장하기
  • setter 안에서 다른 코드 = 추가 로직 실행하기
var myProperty: String = "초기값"
    set(value) {
        // 여기서 로직 실행 (로그, 검증, 변환 등)
        println("값이 바뀝니다")
        
        // 여기서 실제 값 저장
        field = value
    }

예시

class Person {
    var name: String = "홍길동"
        set(value) {
            println("이름이 바뀝니다: $value")
            name = value  //이렇게 하면 setter가 자기 자신을 계속 호출! -> 무한 루프
        }
}
class BankAccount {
    var balance: Double = 0.0
        set(value) {
            if (value >= 0) {
                println("잔액이 ${field}에서 ${value}로 변경됩니다")
                field = value  // 실제 저장
            } else {
                println("음수 잔액은 불가능합니다")
                // field는 변경하지 않음
            }
        }
}

4.2.5 접근자의 가시성 변경

접근자의 가시성은 기본적으로는 프로퍼티의 가시성과 같다. 하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가해서 접근자의 가시성을 변경할 수 있다.

접근자의 가시성 : 누가 이 프로퍼티를 읽거나 쓸 수 있는가?

class Example {
    var property: String = "값"
        // ↓ 이 부분이 접근자 가시성!
        private set  // 읽기는 public, 쓰기는 private (읽기:허용/쓰기:제한)
}

예시

class BankAccount {
    // 잔액은 누구나 "볼 수" 있지만, "직접 바꿀 수는" 없음
    var balance: Double = 0.0
        private set
    
    // 입금/출금은 은행 시스템을 통해서만 가능
    fun deposit(amount: Double) {
        balance += amount
    }
    
    fun withdraw(amount: Double) {
        if (balance >= amount) {
            balance -= amount
        }
    }
}

프로퍼티 관련 고급 기능 (나중에 다룰 내용)

1. lateinit - 나중에 초기화하기

언제 쓰나요? 클래스 생성 시점에는 값을 모르지만, 사용하기 전에 반드시 초기화될 프로퍼티

class MainActivity {
    lateinit var binding: ActivityMainBinding// 나중에 초기화

    fun onCreate() {
        binding = ActivityMainBinding.inflate(layoutInflater)
// 이제 binding 사용 가능!
    }

    fun someMethod() {
        binding.textView.text = "안녕하세요"// 안전하게 사용
    }
}

특징:

  • var에만 사용 가능 (val 불가)
  • null이 될 수 없는 타입에만 사용
  • 초기화 전에 접근하면 예외 발생

2. lazy - 지연 초기화 (위임 프로퍼티의 일종)

언제 쓰나요? 처음 사용할 때까지 초기화를 미루고 싶을 때

class DataManager {
    val expensiveData: String by lazy {
        println("무거운 작업 시작...")
        Thread.sleep(1000)// 시간이 오래 걸리는 작업
        "계산 완료된 데이터"
    }
}

val manager = DataManager()
// 여기서는 아직 expensiveData 초기화 안 됨

println(manager.expensiveData)// 여기서 처음 초기화 실행
println(manager.expensiveData)// 이미 초기화된 값 재사용

특징:

  • val에만 사용 가능
  • 한 번만 초기화됨
  • 스레드 안전함

3. @JvmField - 자바 호환성

언제 쓰나요? 자바에서 코틀린 프로퍼티를 필드처럼 직접 접근하게 하고 싶을 때

class Config {
    @JvmField
    val API_URL = "<https://api.example.com>"// 자바에서 직접 접근 가능

    val normalProperty = "일반 프로퍼티"// 자바에서 getter로 접근
}

자바에서 사용:

// 자바 코드
Config config = new Config();
String url = config.API_URL;// 직접 접근 (getter 없이)
String normal = config.getNormalProperty();// getter 사용

4. const - 컴파일 타임

언제 쓰나요? 컴파일 시점에 값이 결정되는 상수

class Constants {
    companion object {
        const val MAX_COUNT = 100// 컴파일 타임 상수
        const val APP_NAME = "MyApp"// 컴파일 타임 상수

        val runtimeValue = System.currentTimeMillis()// 런타임에 결정
    }
}

// 어노테이션에서 사용 가능
@RequestMapping(Constants.APP_NAME)// const 값만 가능
class Controller

4.3 컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임

4.3.1 모든 클래스가 정의해야 하는 메서드

코틀린(자바도 마찬가지)에서 모든 클래스는 이 3가지 메서드를 가져야 함

  1. toString() - 객체를 문자열로 표현
  2. equals() - 객체가 같은지 비교
  3. hashCode() - 객체의 해시값 계산

data class는 이 3가지를 자동으로 만들어줌

1. toString() - 문자열 표현

기본 toString의 문제점

class Customer(val name: String, val postalCode: Int)

val customer = Customer("Alice", 342562)
println(customer)// Customer@5e9f23b4 <- 이상한 주소값만 나옴!

toString 오버라이드하기

class Customer(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Customer(name=$name, postalCode=$postalCode)"
    }
}

val customer = Customer("Alice", 342562)
println(customer)// Customer(name=Alice, postalCode=342562) <- 훨씬 유용!

언제 사용하나요?

  • 디버깅할 때 객체 내용 확인
  • 로그에 객체 정보 출력
  • 테스트에서 결과 확인

2. equals() - 객체 동등성 비교

기본 equals의 문제점

class Customer(val name: String, val postalCode: Int)

val customer1 = Customer("Alice", 342562)
val customer2 = Customer("Alice", 342562)// 내용이 똑같음

println(customer1 == customer2)// false <- 참조(주소)를 비교하기 때문

equals 오버라이드하기

class Customer(val name: String, val postalCode: Int) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true// 같은 객체면 true
        if (other !is Customer) return false// 타입이 다르면 false

// 내용물이 같은지 비교
        return name == other.name && postalCode == other.postalCode
    }

    override fun toString(): String {
        return "Customer(name=$name, postalCode=$postalCode)"
    }
}

val customer1 = Customer("Alice", 342562)
val customer2 = Customer("Alice", 342562)
println(customer1 == customer2)// true <- 이제 내용으로 비교!

코틀린 === vs == 차이

val customer1 = Customer("Alice", 342562)
val customer2 = Customer("Alice", 342562)
val customer3 = customer1

println(customer1 === customer2)// false (참조 비교 - 다른 객체)
println(customer1 == customer2)// true (내용 비교 - 같은 내용)
println(customer1 === customer3)// true (참조 비교 - 같은 객체)
  • === : 참조 동일성 (같은 객체인가?) ⇒ !==
  • == : 내용 동등성 (내용이 같은가?) ⇒ 논리 반전 !=

override 변경자가 필수여서 실수로 override fun equals (other: Any?) 대신 override fun equals (other: Customer) 를 작성할 수는 없다. 따라서 equals를 오버라이드하고 나면 프로퍼티의 값이 모두 같은 두 고객 객체는 동등하리라 예상할 수 있다.

3. hashCode() - 해시값 계산

equals를 오버라이드하면 반드시 hashCode도 오버라이드

class Customer(val name: String, val postalCode: Int) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Customer) return false
        return name == other.name && postalCode == other.postalCode
    }

    override fun hashCode(): Int {
        return name.hashCode() * 31 + postalCode
    }

    override fun toString(): String {
        return "Customer(name=$name, postalCode=$postalCode)"
    }
}

4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메서드를 자동으로 생성

  • 자동 생성: toString, equals, hashCode, copy 자동 생성
data class Customer(val name: String, val postalCode: Int)

// 자동으로 생성되는 메서드들:
// - toString()
// - equals()
// - hashCode()
// - copy()
// - componentN() (구조분해용)
  • 주 생성자 기반: 주 생성자의 프로퍼티만 equals/hashCode에 포함, 주 생성자 밖의 프로퍼티는 무시됨
data class Person(val name: String, val age: Int) {
    var nickname: String = ""  // 이건 equals/hashCode 계산에서 제외됨!
}

fun main() {
    val person1 = Person("Alice", 25)
    person1.nickname = "앨리스"
    
    val person2 = Person("Alice", 25)
    person2.nickname = "엘리"
    
    println(person1 == person2)  // true! nickname은 비교 안 함
}
  • 불변성 권장: val 사용으로 안전한 컨테이너 사용과 스레드 안전성 확보
data class Customer(var name: String, val postalCode: Int)

fun main() {
    val customers = hashMapOf<Customer, String>()
    
    val customer = Customer("Alice", 12345)
    customers[customer] = "VIP 고객"  // 해시맵에 저장
    
    customer.name = "앨리스"  // 키 객체 변경!
    
    println(customers[customer])  // null! 키가 바뀌어서 찾을 수 없음
    println(customers.size)       // 1 (데이터는 있지만 접근 불가)
}
data class Customer(val name: String, val postalCode: Int)  // 불변!

fun main() {
    val customers = hashMapOf<Customer, String>()
    
    val customer = Customer("Alice", 12345)
    customers[customer] = "VIP 고객"
    
    // customer.name = "앨리스"  // 컴파일 에러, 변경 불가
    
    println(customers[customer])  // "VIP 고객" - 안전하게 접근 가능
}
  • copy() 편의성: 불변 객체의 일부 속성만 변경한 새 객체 쉽게 생성
  • data class GameScore(val player: String, val score: Int) // 불변 객체라서 여러 스레드에서 안전하게 사용 가능 fun shareScoreAcrossThreads() { val score = GameScore("Player1", 1000) // 스레드 1 thread { val newScore = score.copy(score = score.score + 100) // 새 객체 생성 println("Thread 1: $newScore") } // 스레드 2 thread { val newScore = score.copy(score = score.score + 200) // 새 객체 생성 println("Thread 2: $newScore") } // 원본 score는 절대 변경되지 않아서 안전함 }
  • 자바 Record와의 비교
// 코틀린 data class
data class Person(val name: String, val age: Int) {
    var nickname: String = ""  // 추가 프로퍼티 가능
}

// 자바 Record (더 제한적)
// record Person(String name, int age) {
//     // 추가 필드 정의 불가
//     // 상속 불가
//     // 모든 필드 자동으로 private final
// }

호환성을 위해 코틀린 data class에 @JvmRecord 어노테이션을 추가해 레코드를 선언 가능. 이런 경우 데이터 클래스도 레코드와 똑같은 제약 사항을 지켜야 함

  • 클래스 위임(by)의 핵심 장점
  1. 코드 간소화
  2. 안전성: 구현 상속의 취약성 해결
  3. 유연성: 필요한 메서드만 오버라이드
  4. 명확성: 의존 관계가 명확함

위임을 사용하면 내가 직접 통제하는 부분과 다른 객체에게 맡기는 부분이 명확해짐.

  • 데코레이터 패턴
class DelegatingCollection<T> : Collection<T> {
    private val innerList = arrayListOf<T>()

    override val size: Int
        get() = innerList.size

    override fun isEmpty(): Boolean = innerList.isEmpty()

    override fun contains(element: T): Boolean = innerList.contains(element)

    override fun iterator(): Iterator<T> = innerList.iterator()

    override fun containsAll(elements: Collection<T>): Boolean =
        innerList.containsAll(elements)
}
  • 클래스 위임 by 키워드
class DelegatingCollection<T>(
    private val innerList: Collection<T> = mutableListOf()
) : Collection<T> by innerList
// 끝. 모든 메서드가 자동으로 innerList에 위임
  • 사용 예시 CountingSet : 기존 Set에 카운팅 기능 추가
class CountingSet<T>(
    private val innerSet: MutableCollection<T> = hashSetOf()
) : MutableCollection<T> by innerSet {

    var objectsAdded = 0
        private set

// 원하는 메서드만 오버라이드
    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        objectsAdded += elements.size
        return innerSet.addAll(elements)
    }

// 나머지 메서드들(size, contains, iterator 등)은 자동 위임
}
    //위임 없었으면 있었을 메서드들 ..
    override val size: Int get() = innerSet.size
    override fun isEmpty(): Boolean = innerSet.isEmpty()
    override fun contains(element: T): Boolean = innerSet.contains(element)
    override fun containsAll(elements: Collection<T>): Boolean = innerSet.containsAll(elements)
    override fun iterator(): MutableIterator<T> = innerSet.iterator()
    override fun remove(element: T): Boolean = innerSet.remove(element)
    override fun removeAll(elements: Collection<T>): Boolean = innerSet.removeAll(elements)
    override fun retainAll(elements: Collection<T>): Boolean = innerSet.retainAll(elements)
    override fun clear() = innerSet.clear()
  • 사용 예시
fun main() {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 1, 2))// Set이므로 중복 제거됨

    println("추가 시도: ${cset.objectsAdded}번")// 3번
    println("실제 크기: ${cset.size}")// 2개 (중복 제거)
    println("효율성: ${cset.size.toDouble() / cset.objectsAdded}")// 0.67
}

  • CountingSet<T>() ← 카운팅 기능 필요할 때
  • MutableCollection<T> ← 일반적인 컬렉션으로 사용할 때
  • MutableSet<T> ← Set의 특성 (중복 제거)만 필요할 때

4.4 object 키워드: 클래스 선언과 인스턴스 생성을 한꺼번에 하기

4.4.1 객체 선언object : 싱글턴을 쉽게 만들기

자바에서 싱글턴 패턴 : 생성자 private, 정적인 필드에 그 클래스의 유일한 객체를 저장하는 패턴

코틀린에서 객체 선언 : object 키워드로 시작, 객체 선언에서는 생성자를 (주생성자와 부생성자 모두) 쓸 수 없다. → 예시 Comparator 인스턴스

object CaseInsensitiveFileComparator : Comparator<File> {
    override fun compare(file1: File, file2: File): Int {
        return file1.path.compareTo(file2.path, ignoreCase = true)
    }
}

fun main() {
    val files = listOf(File("/Z"), File("/a"))
    println(files.sortedWith(CaseInsensitiveFileComparator))
    // [/a, /Z] - 대소문자 구분 없이 정렬됨
}

4.4.2 동반 객체companion object : 팩토리 메서드와 정적 멤버가 들어갈 장소

코틀린에는 정적인 멤버가 없다, static이 없다 → 동반 객체 사용 (static 대신)

  1. 최상위 함수
    1. 클래스 밖에 있는 최상위 함수는 비공개 멤버(private class 속 member)를 사용할 수 없음
  2. object 선언(싱글턴)
  3. companion object(클래스 내부 정보 접근 필요할 때) = 클래스 이름으로 바로 호출할 수 있는 특별한 함수들
    • private 생성자 접근 필요할 때
    • 팩토리 메서드 패턴 구현할 때
    • 캐싱, 유효성 검사 등 객체 생성 제어가 필요할 때
    • 클래스와 밀접한 관련이 있는 유틸리티 함수들

동반 객체가 자신에 대응하는 클래스에 속한다는 점을 염두에 두는 것이 중요하다. 따라서 해당 클래스의 인스턴스는 동반 객체의 멤버에 접근할 수 없다. 이것이 자바의 정적 멤버와 코틀린 동반 객체 멤버가 다른 점이다. → 누가 무엇을 하는지 정확하게 하기 위함

자바에서는 인스턴스로도 static 메서드 호출 가능

// 자바
class MyClass {
    public static void staticMethod() {
        System.out.println("static 메서드");
    }
}

// 자바에서는 둘 다 가능!
MyClass.staticMethod();           // 클래스명으로 호출
MyClass obj = new MyClass();
obj.staticMethod();               // 인스턴스로도 호출 가능 (경고는 뜨지만 동작함)

코틀린에서는 인스턴스로 companion object 호출 불가능

class MyClass {
    companion object {
        fun companionMethod() {
            println("companion 메서드")
        }
    }
}

// 코틀린에서는 하나만 가능!
MyClass.companionMethod()         // 클래스명으로 호출

val obj = MyClass()
// obj.companionMethod()          // 인스턴스로 호출 불가능 - 컴파일 에러

부 생성자 방식 (생성자가 여럿있어서 헷갈림) → companion object 팩토리 메서스 (명확함)

class User private constructor(val nickname: String) {  // 생성자 잠금
    companion object {
        // 이메일로 회원가입한 사용자
        fun newSubscribingUser(email: String) = 
            User(email.substringBefore('@'))
        
        // SNS로 가입한 사용자  
        fun newSocialUser(accountId: Int) = 
            User(getNameFromSocialNetwork(accountId))
    }
}

// 사용할 때 의도가 명확함
val emailUser = User.newSubscribingUser("bob@gmail.com")    // 이메일 가입자
val socialUser = User.newSocialUser(12345)                  // SNS 가입자

팩토리 메서드의 장점들

  • (명확) 목적에 맞는 이름
  • (효율) 같은 객체 재사용, 생성할 필요가 없는 객체를 생성하지 않을 수도 있음
  • (유연) 조건에 따라 다른 타입 반환

4.4.3 동반 객체를 일반 객체처럼 사용 - 3가지 추가 기능

  • 이름 붙이기: companion object 이름 { } 동반 객체가 특별한 역할을 할 때 (Factory, Builder 등)
class Person(val name: String) {
    companion object Loader {  // "Loader"라는 이름
        fun fromJSON(json: String): Person {
            return Person("Dmitry")
        }
    }
}

// 사용법 - 둘 다 가능
val person1 = Person.fromJSON("""{"name": "Dmitry"}""")        // 클래스명으로
val person2 = Person.Loader.fromJSON("""{"name": "Brent"}""")  // 이름으로
  • 인터페이스 구현: companion object : 인터페이스<T> { } 팩토리 패턴이나 전략 패턴 구현할 때
interface JSONFactory<T> { // 인터페이스 정의
    fun fromJSON(jsonText: String): T
}

class Person(val name: String) {
    companion object : JSONFactory<Person> {  // 인터페이스 구현
        override fun fromJSON(jsonText: String): Person {
            return Person("파싱된 이름")
        }
    }
}

fun <T> loadFromJSON(factory: JSONFactory<T>): T {
    return factory.fromJSON("""{"name": "테스트"}""")
}

// Person **클래스 자체를 팩토리로 전달**
val person1 = loadFromJSON(Person)  // Person.Companion이 JSONFactory 역할
val person2 = loadFromJSON(Person.Companion) // 명시적인 방식

// 1. Person을 loadFromJSON에 전달
// 2. Person.Companion이 JSONFactory<Person> 역할을 함
// 3. factory.fromJSON(json) 호출
// 4. Person.Companion.fromJSON(json) 실행
// 5. Person 객체 반환

왜 Person 클래스 자체를 전달할 수 있을까?

Person.companion object가 JSONFactory<Person>를 구현함

코틀린에서 Person이라고 쓰면 실제로는 Person.Companion을 의미

Person.Companion은 JSONFactory<Person> 타입이므로 전달 가능

  • 확장 함수: fun 클래스.Companion.새함수() { } 관심사를 분리하면서도 static처럼 사용하고 싶을 때
// 모델 클래스 (비즈니스 로직)
class User(val id: Int, val name: String, val email: String) {
    companion object  // 비어있는 동반 객체
}

// 데이터베이스 모듈에서 확장
fun User.Companion.findById(id: Int): User? {
    // 데이터베이스에서 사용자 찾기
    return User(id, "찾은사용자", "user@example.com")
}

fun User.Companion.findByEmail(email: String): User? {
    // 이메일로 사용자 찾기
    return User(1, "이메일사용자", email)
}

// API 모듈에서 확장
fun User.Companion.fromApiResponse(json: String): User {
    // API 응답에서 사용자 생성
    return User(99, "API사용자", "api@example.com")
}

// 사용 - 마치 static 메서드처럼!
val user1 = User.findById(123)
val user2 = User.findByEmail("test@gmail.com")  
val user3 = User.fromApiResponse("""{"id":1,"name":"홍길동"}""")

4.4.4 객체 식 object expression : 익명 내부 클래스를 다른 방식으로 작성

객체 식 = object expression = 이름 없는 일회용 객체

쓰면 좋은 경우

  • 일회용 구현이 필요할 때
  • 간단한 콜백 함수들
  • 테스트 코드에서 mock 객체
  • 여러 메서드를 구현해야 하는 인터페이스
// 패턴
val 객체 = object : 인터페이스1, 인터페이스2 { // 인터페이스는 없어도 됨
    override fun 메서드1() { /* 구현 */ }
    override fun 메서드2() { /* 구현 */ }
}

로컬 변수 접근 가능 (자바와의 차이점 비교)

자바에서는 final 변수만 접근 가능

// 자바 코드
public Button createButton() {
    int clickCount = 0;  // final이 아닌 변수
    
    return new Button(new MouseListener() {
        @Override
        public void onEnter() {
            System.out.println("들어왔어요");
        }
        
        @Override
        public void onClick() {
            clickCount++;  // 컴파일 에러! 
            // "Variable used in lambda expression should be final or effectively final"
            System.out.println("클릭 횟수: " + clickCount);
        }
    });
}

자바에서 해결하려면

// 자바 - 읽기만 가능
public Button createButton() {
    final int initialCount = 0;  // final 변수
    
    return new Button(new MouseListener() {
        @Override
        public void onClick() {
            System.out.println("초기값: " + initialCount);  // 읽기만 가능
            // initialCount++;  // final이라 변경 불가능
        }
    });
}

우회 방법 배열이나, AtomicInteger // 원자적 정수 사용

// 자바 - 배열이나 객체로 우회
public Button createButton() {
    final int[] clickCount = {0};  // 배열 사용 (우회 방법)
    
    return new Button(new MouseListener() {
        @Override
        public void onClick() {
            clickCount[0]++;  // 배열 내용 변경 (우회적으로 가능)
            System.out.println("클릭 횟수: " + clickCount[0]);
        }
    });
}

코틀린에서는 일반 변수도 접근/변경 가능함

fun createButton(): Button {
    var clickCount = 0  // 일반 var 변수
    var userName = "guest"  // 이것도 변경 가능
    
    return Button(object : MouseListener {
        override fun onClick() {
            clickCount++        // 자유롭게 변경
            userName = "user$clickCount"  // 이것도 변경
            println("$userName이 $clickCount번 클릭함")
        }
        
        override fun onEnter() {
            println("$userName이 마우스를 올렸어요")
        }
    })
}

4.5 부가 비용 없이 타입 안전성 추가: 인라인 클래스 inline class

  • 1단계: 문제가 있는 코드
fun addExpense(expense: Int) {
// 단위가 뭔지 모호함
// 비용을 미국 달러의 센트 단위로 저장
}

// 사용 - 실수하기 쉬움!
addExpense(200)// 이게 센트인지, 엔인지, 원인지 모름
  • 2단계: 일반 클래스로 해결 (안전하지만 느림)
class UsdCent(val amount: Int)

fun addExpense(expense: UsdCent) {
// 타입 안전!
// 이제 확실히 센트 단위
}

// 사용
addExpense(UsdCent(147))// 명확함, 하지만 객체 생성 비용 발생

문제: 함수 호출할 때마다 새 객체 생성 → 메모리 낭비

  • 3단계: 인라인 클래스로 완벽 해결 (안전하고 빠름)

@JvmInline 붙이고 value 키워드 사용

실행 시점에 UsdCent의 인스턴스는 감싸진 프로퍼티로 대체, 불필요하게 객체를 생성하는 비용을 줄일 수 있게 됨

@JvmInline
value class UsdCent(val amount: Int)

fun addExpense(expense: UsdCent) {
// 컴파일 시점: UsdCent 타입으로 안전성 보장
// 실행 시점: Int로 변환되어 성능 최적화
}

// 사용
addExpense(UsdCent(147))// 타입 안전 + 성능 좋음!

  • 인라인 클래스의 마법 : 객체 생성 없이 값만.

컴파일 전 (개발 시)

@JvmInline
value class UsdCent(val amount: Int)

fun process(money: UsdCent) {
    println("금액: ${money.amount}")
}

val cost = UsdCent(500)
process(cost)

컴파일 후 (실행 시) - 실제로는 이렇게 변환됨

// 컴파일러가 자동으로 이렇게 최적화
fun process(money: Int) {// UsdCent → Int로 변환
    println("금액: $money")
}

val cost = 500// 객체 생성 없이 Int 값만
process(cost)

5장. 람다를 사용한 프로그래밍

람다 = 이름 없는 함수 = 코드 블록을 값처럼 다루는 것

5.1 람다식과 멤버 참조

5.1.1 람다 소개: 코드 블록을 값으로 다루기 / 5.1.2 람다와 컬렉션

  • 함수를 값처럼 사용
  • 코드 블록을 직접 전달
  • 간결한 문법 - it 사용
// 파라미터가 하나일 때 'it' 사용 가능
val names = listOf("alice", "bob", "charlie")

// 방법 1: 명시적 파라미터
names.forEach { name -> println(name.uppercase()) }

// 방법 2: it 사용 (더 간단)
names.forEach { println(it.uppercase()) }

5.1.3 람다식의문법

{ 파라미터 -> 실행코드 }
  • 람다를 변수에 저장하기
val sum = { x: Int, y: Int -> x + y }
println(sum(1, 2))// 3// 직접 호출도 가능 (하지만 잘 안 씀)
{ println(42) }()// 42// 더 나은 방법: run 사용
run { println(42) }// 42

  • run 함수의 유용한 활용
val myFavoriteNumber = run {
    println("생각 중...")
    println("더 많은 작업 중...")
    42// 마지막 값이 반환됨
}
println(myFavoriteNumber)// 42
// 이렇게 람다를 바로 실행하는 건 이상함
{ println("안녕!") }()  //읽기 어려움

// run을 사용하면 깔끔
run { println("안녕!") }  //명확함
val result = run {
    // 여러 줄의 복잡한 로직
    // 임시 변수들
    // 계산이나 설정
    최종결과값  // 마지막 값이 반환됨
}
  • 람다 문법 단순화 과정
//1단계: 기본 형태
people.maxByOrNull({ p: Person -> p.age })
//2단계: 마지막 인자가 람다면 괄호 밖으로 (괄호와 람다 사이에 공백)
people.maxByOrNull() { p: Person -> p.age } 
//3단계: 람다가 유일한 인자면 빈 괄호 제거
people.maxByOrNull { p: Person -> p.age }
//4단계: 컴파일러가 타입 추론 가능하면 타입 생략, people이 List<Person>이므로 람다 파라미터도 Person임을 알 수 있음
people.maxByOrNull { p -> p.age } 
//5단계: 파라미터가 하나면 it 사용 (it = 자동 생성된 파라미터 이름)
people.maxByOrNull { it.age }
//6단계:멤버 참조 (Member Reference) (::는 멤버 참조 연산자)
people.maxByOrNull(Person::age)

5.1.4 현재 영역에 있는 변수 접근

변수 캡처(Variable Capture)

람다가 자신 주변의 변수들을 "잡아서" 사용할 수 있는 기능

fun printProblemCounts(responses: Collection<String>) {
    var clientErrors = 0    // 람다에서 접근할 변수들
    var serverErrors = 0
    
    responses.forEach {
        when {
            it.startsWith("4") -> clientErrors++     // 변수 변경!
            it.startsWith("5") -> serverErrors++     // 변수 변경!
        }
    }
    
    println("클라이언트 에러: $clientErrors, 서버 에러: $serverErrors")
}

val responses = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
printProblemCounts(responses)
// 클라이언트 에러: 1, 서버 에러: 1

특징

  • 람다 주변의 모든 변수 접근 가능
  • var 변수도 자유롭게 변경 가능 (자바와 다름)
  • 함수 종료 후에도 캡처된 변수 사용 가능
  • 비동기 처리 시 타이밍 주의

스코프별 변수 접근

val globalVar = "전역 변수"

class MyClass {
    val classVar = "클래스 변수"
    
    fun outerFunction(paramVar: String) {
        val outerVar = "외부 함수 변수"
        
        fun innerFunction() {
            val innerVar = "내부 함수 변수"
            
            listOf(1, 2, 3).forEach { 
                // 람다에서 모든 변수 접근 가능
                println("$globalVar")     // 전역
                println("$classVar")      // 클래스
                println("$paramVar")      // 파라미터
                println("$outerVar")      // 외부 함수
                println("$innerVar")      // 내부 함수
                println("현재 숫자: $it") // 람다 파라미터
            }
        }
        
        innerFunction()
    }
}

5.1.5 멤버 참조

멤버 참조 = 기존 함수나 프로퍼티를 람다 대신 사용하는 방법

클래스이름::멤버이름

언제 쓸까?

  • 멤버 참조가 좋은 경우

기존 함수/프로퍼티를 그대로 사용할 때

단순한 위임이나 전달일 때

코드 중복을 피하고 싶을 때

  • 람다가 나은 경우

추가 로직이 필요할 때

여러 멤버를 조합해야 할 때

조건문이나 복잡한 연산이 있을 때

// 멤버 참조가 좋음
people.map(Person::age)

// 람다가 나음
people.map { "${it.name} (${it.age}세)" }
people.filter { it.age >= 18 && it.name.startsWith("A") }

문법 정리

ClassName::memberName      // 멤버 참조
::topLevelFunction         // 최상위 함수 참조
::ClassName               // 생성자 참조

5.1.6 값과 엮인 호출 가능 참조

값이 엮인 호출 가능 참조

= 특정 객체 인스턴스에 "묶인" 참조

= 그 객체에만 특화된 함수

객체::멤버이름
  • 람다 방식 (더 길고 명시적)
val alice = User("Alice", "alice@gmail.com")

// 람다로 감싸기
val sendToAlice1 = { message: String -> alice.sendNotification(message) }
val checkAliceEmail1 = { alice.isValidEmail() }

// 사용
sendToAlice1("안녕하세요")
checkAliceEmail1()

  • 값과 엮인 참조 방식 (더 간결)
val alice = User("Alice", "alice@gmail.com")

// 값과 엮인 참조
val sendToAlice2 = alice::sendNotification
val checkAliceEmail2 = alice::isValidEmail

// 사용 (완전 동일)
sendToAlice2("안녕하세요")
checkAliceEmail2()

같은 객체에 반복 작업할 때

이벤트 핸들러로 쓸 때

5.2 자바의 함수형 인터페이스 사용: 단일 추상 메서드

자바의 함수형 인터페이스는 코틀린 람다로 자연스럽게 사용 가능

SAM(Single Abstract Method) 변환이 자동으로 일어나서 편리함

필요시 명시적 SAM 생성자 사용으로 더 정확한 제어 가능

  • 자바의 함수형 인터페이스 = 추상 메서드가 하나만 있는 인터페이스
// 자바 OnClickListener 인터페이스
public interface OnClickListener {
    void onClick(View v);// 추상 메서드 하나만!
}

public class Button {
    public void setOnClickListener(OnClickListener listener) { ... }
}

  • 코틀린에서 사용하기 : 메서드가 하나만 있으면 람다로 바꿔줄 수 있다
// 코틀린에서는 람다로 간단하게!
button.setOnClickListener { view ->
    println("버튼이 클릭되었습니다!")
}

// 자바에서는 복잡했을 것...
// button.setOnClickListener(new OnClickListener() {
//     @Override
//     public void onClick(View v) {
//         System.out.println("버튼이 클릭되었습니다!");
//     }
// });

5.2.1 람다를 자바 메서드의 파라미터로 전달

  • 다양한 자바 인터페이스 예시
    // 자바 코드 (라이브러리)
    void runInBackground(Runnable task);
    
    // 코틀린에서 사용
    runInBackground {
        println("백그라운드에서 실행!")
    }
    
    // 실제 일:// 파일 다운로드
    runInBackground {
        downloadFile("<https://example.com/file.zip>")
    }
    
    // 데이터 처리
    runInBackground {
        processLargeData()
    }
    
    
    2. Callable (결과를 반환하는 작업)
    // 코틀린에서 사용
    val future = submitTask {
        "계산 완료!"
    }
    
    // 실제 일:
    val result = submitTask {
        val sum = (1..1000).sum()
        "합계: $sum"
    }
    
    println(future.get())// 결과 받기
    
    
    3. 안드로이드의 다양한 리스너들4. 파일 처리
    // 코틀린에서 사용
    processFile("data.txt") { content ->
        val lines = content.split("\\n")
        println("파일에 ${lines.size}줄이 있습니다")
    }
    
    // 실제 활용
    processFile("users.csv") { csvContent ->
        val users = parseCsv(csvContent)
        saveToDatabase(users)
    }
    
  • // 자바 파일 유틸리티 void processFile(String filename, FileProcessor processor); interface FileProcessor { void process(String content); }
  • // 클릭 리스너 button.setOnClickListener { Toast.makeText(context, "클릭됨!", Toast.LENGTH_SHORT).show() } imageView.setOnLongClickListener { view -> showContextMenu(view) true// 이벤트 처리 완료 } // 텍스트 변경 리스너 editText.addTextChangedListener { text -> if (text.length > 10) { showWarning("너무 길어요!") } }
  • // 자바 코드 Future<String> submitTask(Callable<String> task);
  • 1. Runnable
  • 변수를 사용하지 않는 람다 (효율적)
// 이런 람다들은 하나의 객체만 만들어서 재사용
button1.setOnClickListener { println("안녕!") }
button2.setOnClickListener { println("안녕!") }
button3.setOnClickListener { println("안녕!") }

// 메모리에 Runnable 객체 하나만 존재
  • 변수를 캡처하는 람다 (매번 새로 생성)
fun setupButtons() {
    val userName = "홍길동" // 이 변수를 람다에서 사용하면

    button1.setOnClickListener {
        println("$userName님 안녕하세요!")
    }

    button2.setOnClickListener {
        println("$userName님 반갑습니다!")
    }

// 각각 새로운 객체 생성됨 (userName을 저장해야 하므로)
}

5.2.2 SAM 변환: 람다를 함수형 인터페이스로 명시적 변환

  • SAM 생성자 = 컴파일러가 자동으로 만들어주는 변환 함수
  • 함수에서 반환 할 때
  • 여러 곳에 재 사용 할 때
  • 변수 할당 하는데 타입 추론 안될 때
//자동 변환 예시들
// 1. 버튼 클릭 (자동)
saveButton.setOnClickListener { saveData() }    //  자동
deleteButton.setOnClickListener { deleteData() } //  자동

// 2. 리스트에 추가 (자동)
val clickListeners = mutableListOf<OnClickListener>()
clickListeners.add { println("첫 번째") }  //  자동
clickListeners.add { println("두 번째") }  //  자동

// 3. 변수 할당 (타입 명시하면 자동)
val myListener: OnClickListener = { println("자동!") }  //  자동

//명시적 변환 예시들
// 1. 함수에서 반환 (명시적 필요)
fun createSaveListener() = OnClickListener { saveData() }      //  명시적
fun createDeleteListener() = OnClickListener { deleteData() }  //  명시적

// 2. 여러 곳에서 재사용할 리스너 (명시적 필요)
val sharedListener = OnClickListener { view ->  //  명시적
    when (view.id) {
        R.id.button1 -> println("버튼 1")
        R.id.button2 -> println("버튼 2")
    }
}

button1.setOnClickListener(sharedListener)
button2.setOnClickListener(sharedListener)

// 3. 변수 할당 (타입 추론 안될 때)
val genericListener = OnClickListener { println("제네릭!") }  //  명시적

5.3 코틀린에서 SAM 인터페이스 정의: fun interface

SAM = Single Abstract Method (추상 메서드가 하나만 있는 인터페이스)

  • 함수타입보다 의미가 명확해서 사용
// 일반 함수 타입 - 의미가 모호
fun processNumbers(numbers: List<Int>, filter: (Int) -> Boolean) { }

// fun interface - 의미가 명확
fun interface NumberFilter {
    fun isValid(number: Int): Boolean
}

fun processNumbers(numbers: List<Int>, filter: NumberFilter) { }
  • 자바와 호환성이 좋음
    • 코틀린 함수 타입: 내부적으로 Unit 객체를 반환 fun interface: 자바의 일반적인 void 메서드처럼 컴파일
//코틀린 함수 타입 사용 시 (불편함)
kotlinfun consumeHelloFunctional(consumer: (String) -> Unit) {
    consumer("Hello")
}
//java// 자바에서 사용 - Unit.INSTANCE 명시 필요
MainKt.consumeHelloFunctional(s -> {
    System.out.println(s.toUpperCase());
    return Unit.INSTANCE;  // 이걸 써야 함
});
//fun interface 사용 시 (편함)
kotlinfun interface StringConsumer {
    fun consume(s: String)
}

fun consumeHello(consumer: StringConsumer) {
    consumer.consume("Hello")
}
//java// 자바에서 사용 - 간단함
MainKt.consumeHello(s -> System.out.println(s.toUpperCase()));  // 깔끔함

5.4 수신 객체 지정 람다: with, apply, also

수신 객체 지정 람다 : 특정 객체에 집중 → 그 객체의 메서드들을 편리하게 사용하는 방법

문제 상황: 반복적인 객체 이름

// 매번 result를 반복해야 함
fun alphabet(): String {
    val result = StringBuilder()
    result.append("A")
    result.append("B")
    result.append("C")
    result.append("\\nNow I know the alphabet!")
    return result.toString()
}

5.4.1 with 함수

  • 기본 사용법
// with로 개선
fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {// stringBuilder에 집중!
        append("A")// stringBuilder.append() 생략
        append("B")// this 생략 가능
        append("C")
        append("\\nNow I know the alphabet!")
        toString()// 마지막 값이 반환됨
    }
}
  • 더 간단하게
fun alphabet() = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)// StringBuilder의 메서드
    }
    append("\\nNow I know the alphabet!")
    toString()// 결과 반환
}

  • 예시
// 설정 객체 다루기
data class Config(var host: String = "", var port: Int = 0, var timeout: Int = 0)

fun setupConfig() = with(Config()) {
    host = "localhost"// Config의 속성들
    port = 8080
    timeout = 30
    this// Config 객체 반환하려면 명시적으로 this
}

5.4.2 apply 함수

  • with vs apply 차이점
// with: 람다 결과를 반환
val result1 = with(StringBuilder()) {
    append("Hello")
    toString()// String 반환
}

// apply: 객체 자체를 반환
val result2 = StringBuilder().apply {
    append("Hello")
// StringBuilder 객체 자체가 반환됨
}.toString()

5.4.3 객체에 추가 작업 수행: also

  • apply vs also 차이점
// apply: 수신 객체로 접근 (this)
val list1 = mutableListOf<String>().apply {
    add("Apple")// this.add()와 같음
    add("Banana")
}

// also: 파라미터로 접근 (it)
val list2 = mutableListOf<String>().also { list ->
    list.add("Apple")// 명시적으로 list 사용
    list.add("Banana")
}
  • with: "이 객체로 작업해서 결과 줘" → 변수를 간단하게 쓸 때
  • apply: "이 객체 설정하고 객체 자체 줘" → builder 처럼 씀
  • also: "이 객체에 추가 작업도 하고 객체 줘", "그대로 하되, 중간중간 이것도 해줘” → 로깅 추가

'language > Kotilin In Action' 카테고리의 다른 글

6주차 (13장)  (2) 2025.09.01
5주차(11~12장)  (1) 2025.09.01
4주차(9~10장)  (1) 2025.09.01
3주차 (6~8장)  (1) 2025.09.01
1주차 (1~3장)  (0) 2025.09.01