Kotlin 문법 복습 (2/4)

이전 편: [Kotlin] 1. 변수, 타입, 함수

다음 편: [Kotlin] 3. Null Safety와 컬렉션

Kotlin 문법 복습 시리즈 (2/4)

이전 편: 1 변수, 타입, 함수

다음 편: 3 Null Safety와 컬렉션

Java에서 간단한 DTO 하나 만드는 데 필드, 생성자, getter, setter, equals, hashCode, toString — 이걸 다 써야 했다. Lombok으로 줄여도 어노테이션이 덕지덕지 붙는다. Kotlin은 이 문제를 언어 차원에서 해결했다. 클래스 하나 선언하는 데 필요한 코드량이 극적으로 줄어든다.

이 문서에서는 Kotlin의 클래스, 객체, 상속 구조를 복습한다. Java와 비교했을 때 얼마나 달라지는지, 그리고 왜 그렇게 설계됐는지를 함께 짚는다.

클래스 기본

주 생성자(Primary Constructor)

Kotlin의 클래스 선언에서 가장 먼저 눈에 띄는 차이는 주 생성자가 클래스 이름 옆에 붙는다는 것이다.

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

이 한 줄이 Java의 아래 코드와 동일하다.

public class User {
    private final String name;
    private final int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}
  • 주 생성자 파라미터에 val을 붙이면 프로퍼티 선언 + 생성자 파라미터 + getter가 한 번에 된다.
  • var를 붙이면 getter + setter까지 생성된다.
  • valvar도 안 붙이면 생성자 파라미터일 뿐, 프로퍼티가 아니다.

왜 이렇게 했을까? Java에서 필드 선언 → 생성자 파라미터 → 대입 → getter 작성이라는 똑같은 정보를 4번 반복하는 게 낭비이기 때문이다. Kotlin은 이걸 한 곳에 모았다.


프로퍼티

Java는 필드와 getter/setter가 분리되어 있다. Kotlin은 프로퍼티라는 개념으로 통합한다.

class Post(
    val id: Long,           // 읽기 전용 (getter만)
    var title: String,      // 읽기/쓰기 (getter + setter)
    var viewCount: Int = 0  // 기본값 지정 가능
)

프로퍼티에 접근할 때 getter/setter 메서드를 호출하는 게 아니라, 필드처럼 직접 접근한다.

val post = Post(id = 1, title = "첫 글")
println(post.title)       // getter 호출 (내부적으로)
post.title = "수정된 제목"  // setter 호출 (내부적으로)
  • 겉보기에는 필드 접근이지만, 내부적으로는 getter/setter가 호출된다.
  • Java 코드에서 Kotlin 프로퍼티를 쓸 때는 getTitle(), setTitle()로 호출해야 한다.

커스텀 getter/setter가 필요하면 직접 정의할 수 있다.

class Post(private var _title: String) {
    var title: String
        get() = _title
        set(value) {
            require(value.isNotBlank()) { "제목은 비어있을 수 없습니다" }
            _title = value
        }
}
  • value는 setter의 기본 파라미터 이름이다.
  • require는 조건이 false면 IllegalArgumentException을 던진다.

init 블록과 부 생성자

주 생성자에서 복잡한 초기화 로직이 필요하면 init 블록을 쓴다.

class User(val name: String, val age: Int) {
    init {
        require(name.isNotBlank()) { "이름은 비어있을 수 없습니다" }
        require(age > 0) { "나이는 양수여야 합니다" }
    }
}
  • init 블록은 주 생성자의 일부로, 객체 생성 시 실행된다.
  • 여러 개 선언할 수 있고, 위에서 아래 순서로 실행된다.

부 생성자(secondary constructor)도 있지만, 실무에서는 거의 쓰지 않는다. 기본값 파라미터와 named argument로 대부분 해결되기 때문이다.

class User(val name: String, val age: Int = 0) {
    // 부 생성자 — 가능하지만 권장하지 않음
    constructor(name: String) : this(name, 0)
}

// 이렇게 하면 부 생성자가 필요 없다
val user = User(name = "관리자")  // age는 기본값 0
언제 부 생성자를 쓰나?

Java 프레임워크와의 호환성이 필요하거나, 팩토리 메서드 패턴에서 companion object로 처리하기 애매할 때 정도. 대부분의 경우 기본값 파라미터로 충분하다.


Data Class

Java에서 DTO나 VO를 만들 때마다 equals(), hashCode(), toString()을 구현하거나 Lombok의 @Data를 붙였다. Kotlin은 data 키워드 하나로 이 모든 걸 해결한다.

data class PostResponse(
    val id: Long,
    val title: String,
    val content: String,
    val viewCount: Int
)

이 선언 하나로 자동 생성되는 것들은 다음과 같다.

  • equals() — 주 생성자의 모든 프로퍼티를 기준으로 값 비교
  • hashCode() — 주 생성자의 모든 프로퍼티를 기준으로 해시 생성
  • toString()PostResponse(id=1, title=첫 글, content=..., viewCount=0) 형태
  • copy() — 일부 프로퍼티만 바꿔서 새 객체를 만드는 메서드

copy()는 특히 유용하다. 불변 객체의 일부만 수정할 때 쓴다.

val original = PostResponse(id = 1, title = "원본", content = "내용", viewCount = 0)
val modified = original.copy(title = "수정됨", viewCount = 100)
  • copy()는 named argument와 함께 쓰면 직관적이다.
  • 새 객체를 반환한다. 원본은 변경되지 않는다.
JPA 엔티티에는 data class를 쓰지 말 것

data classequals()/hashCode()는 모든 프로퍼티를 비교한다. JPA에서는 프록시 비교, lazy loading, 영속성 컨텍스트 관리 등에서 문제가 생긴다. 엔티티는 일반 class로, DTO는 data class로 — 이 구분을 지키자. 자세한 이유는 코프링 실전 시리즈에서 다룬다.


Enum Class

Java의 enum과 거의 동일하지만, Kotlin에서는 when과 함께 쓸 때 빛을 발한다.

enum class PostStatus {
    DRAFT, PUBLISHED, ARCHIVED
}

프로퍼티와 메서드를 가질 수 있다는 점도 Java와 같다.

enum class HttpStatus(val code: Int, val message: String) {
    OK(200, "성공"),
    NOT_FOUND(404, "찾을 수 없음"),
    INTERNAL_ERROR(500, "서버 에러");

    fun isSuccess() = code in 200..299
}
  • enum 상수에 프로퍼티를 전달하려면 괄호 안에 값을 넣는다.
  • 마지막 상수 뒤에 세미콜론을 찍고 메서드를 선언한다.

enum의 진짜 강점은 when과의 조합이다.

fun handleStatus(status: PostStatus) = when (status) {
    PostStatus.DRAFT -> "임시저장 상태입니다"
    PostStatus.PUBLISHED -> "발행된 글입니다"
    PostStatus.ARCHIVED -> "보관된 글입니다"
    // else가 필요 없다 — 모든 케이스를 다뤘으니까
}
  • enum의 모든 값을 when에서 처리하면 else가 필요 없다. 나중에 enum에 새 값을 추가하면 when을 빠뜨린 곳에서 컴파일 에러가 난다. 이게 안전한 이유다.

Object — 싱글톤

Java에서 싱글톤을 만들려면 private 생성자, static 인스턴스, thread-safe 초기화를 직접 구현해야 했다. Kotlin은 object 키워드 하나로 끝이다.

object BlogConfig {
    val maxTitleLength = 200
    val maxContentLength = 50_000
    val defaultPageSize = 10

    fun isValidTitle(title: String) = title.length <= maxTitleLength
}
  • object로 선언하면 클래스 정의와 동시에 싱글톤 인스턴스가 생성된다.
  • thread-safe하게 lazy 초기화된다.
  • 생성자를 호출할 수 없다. 인스턴스가 하나뿐이니까.

사용할 때는 클래스 이름을 직접 쓴다.

BlogConfig.maxTitleLength
BlogConfig.isValidTitle("제목")

Companion Object — static 대체

Java의 static 멤버는 Kotlin에 없다. 대신 companion object를 쓴다.

class Post(val title: String, val content: String) {
    companion object {
        const val MAX_TITLE_LENGTH = 200

        fun of(title: String, content: String): Post {
            require(title.length <= MAX_TITLE_LENGTH)
            return Post(title, content)
        }
    }
}
  • companion object는 클래스 안에 하나만 존재하는 싱글톤 객체다.
  • Java의 static 메서드, static final 상수 역할을 한다.

호출 방식은 Java의 static과 동일하다.

Post.MAX_TITLE_LENGTH
Post.of("제목", "내용")

static 대신 companion object를 만들었을까? Kotlin의 설계 철학은 "모든 것은 객체"다. companion object는 실제 객체이기 때문에 인터페이스를 구현하거나, 변수에 대입하거나, 확장 함수를 붙일 수 있다. Java의 static은 이런 게 불가능하다.

// companion object가 인터페이스를 구현하는 예
class Post(val title: String) {
    companion object : Comparator<Post> {
        override fun compare(a: Post, b: Post) =
            a.title.compareTo(b.title)
    }
}

상속

Kotlin의 클래스는 기본이 final이다. Java와 정반대다. 상속을 허용하려면 open 키워드를 명시해야 한다.

open class Animal(val name: String) {
    open fun sound(): String = "..."
}

class Dog(name: String) : Animal(name) {
    override fun sound(): String = "멍멍"
}
  • open — 클래스나 메서드에 붙여야 상속/오버라이드가 가능하다.
  • override — Java의 @Override와 같지만, 필수 키워드다. 안 쓰면 컴파일 에러.
  • 상속 문법은 :을 쓴다. extends 키워드가 없다.

왜 기본이 final일까? "상속은 캡슐화를 깨뜨린다"는 Effective Java의 가르침을 언어 차원에서 적용한 것이다. 상속을 의도한 클래스만 명시적으로 open을 붙이도록 해서, 의도치 않은 상속으로 인한 문제를 예방한다.

Spring과 open

Spring은 @Service, @Configuration 등에 CGLIB 프록시를 만들어야 해서 open이 필요하다. 매번 붙이기 귀찮으니까 kotlin-spring 플러그인이 자동으로 open을 추가해준다. 이건 코프링 가이드에서 다룬다.


인터페이스

Java의 인터페이스와 거의 동일하지만, 문법이 조금 다르다.

interface Publishable {
    val isPublished: Boolean

    fun publish()

    fun unpublish() {
        // 기본 구현 가능 (Java 8의 default method와 같음)
        println("발행 취소")
    }
}

class Post(
    val title: String,
    override var isPublished: Boolean = false
) : Publishable {

    override fun publish() {
        isPublished = true
    }
}
  • 인터페이스에 프로퍼티를 선언할 수 있다. 구현 클래스에서 override로 구현한다.
  • 기본 구현이 있는 메서드를 가질 수 있다.
  • 상속과 구현 모두 :을 쓴다. implements 키워드가 없다.

여러 인터페이스를 구현할 때는 쉼표로 구분한다.

class Post : Publishable, Serializable, Comparable<Post> {
    // ...
}

심화 분석

abstract class vs interface

Kotlin에서 추상 클래스와 인터페이스의 차이는 Java와 동일하다.

graph TD A[상태를 가져야 하나?] -->|Yes| B[abstract class] A -->|No| C[여러 개 구현이 필요한가?] C -->|Yes| D[interface] C -->|No| E[abstract class도 가능] style B fill:#e1f5fe style D fill:#e8f5e9 style E fill:#fff3e0
  • abstract class — 상태(필드)를 가질 수 있고, 생성자가 있다. 하나만 상속 가능.
  • interface — 상태를 가질 수 없고(프로퍼티 선언은 가능하지만 backing field 없음), 여러 개 구현 가능.

실무에서는 대부분 interface를 쓰고, 공통 상태가 꼭 필요할 때만 abstract class를 쓴다. Spring의 서비스 계층에서 인터페이스를 추출하는 것도 같은 원리다.

중첩 클래스와 내부 클래스

Java에서 중첩 클래스는 기본이 내부 클래스(inner class, 외부 참조를 가짐)다. Kotlin은 반대로 기본이 정적 중첩 클래스다.

class Outer {
    class Nested {
        // 외부 클래스의 멤버에 접근 불가 (Java의 static inner class)
    }

    inner class Inner {
        // inner 키워드를 붙여야 외부 클래스 참조를 가진다
    }
}
  • class Nested — Java의 static class Nested와 같다. 외부 참조 없음.
  • inner class Inner — Java의 class Inner와 같다. 외부 참조 있음.

Java는 static을 안 붙이면 암묵적으로 외부 참조를 가져서 메모리 누수의 원인이 되곤 했다. Kotlin은 기본을 안전한 쪽(정적 중첩)으로 뒤집어서 이 문제를 예방한다.


자주 하는 실수

data class의 equals 함정

data classequals()주 생성자의 프로퍼티만 비교한다. 본문에 선언한 프로퍼티는 비교 대상이 아니다.

data class Post(val title: String) {
    var viewCount: Int = 0   // equals에 포함되지 않음!
}

val a = Post("제목").apply { viewCount = 100 }
val b = Post("제목").apply { viewCount = 0 }
a == b  // true! viewCount가 달라도 title만 비교한다

equals()에 포함시키고 싶은 프로퍼티는 반드시 주 생성자에 넣어야 한다.

open 깜빡하고 상속 시도

class Animal(val name: String)

// 컴파일 에러! Animal은 final이다
class Dog(name: String) : Animal(name)

Java 습관으로 open을 안 붙이는 실수가 잦다. 에러 메시지가 This type is final, so it cannot be inherited from이면 open을 확인하자.

companion object와 Java 상호운용

Java에서 Kotlin의 companion object 멤버에 접근하면 Companion이 중간에 들어간다.

// Java에서 호출할 때
Post.Companion.of("제목", "내용");     // 기본
Post.of("제목", "내용");                // @JvmStatic 붙이면

Java에서 자연스럽게 쓰려면 @JvmStatic을 붙여야 한다. 이건 상호운용 가이드에서 자세히 다룬다.