VO를 작성하면서 자연스럽게 equals와 hashCode를 함께 구현했는데, 이에 대해 피드백을 받게 되었습니다.
생각해보니 Kotlin에는 Java와는 달리 data class라는 기능이 있어, VO를 구현할 때 자연스럽게 사용할 수 있다는 점에서 적합하다고 느꼈습니다. (물론 Java에도 record가 있지만, 당시에는 사용하지 않았습니다.)
하지만 한편으로는, 그렇다면 왜 모든 클래스를 data class로 작성하지 않는지에 대한 궁금증도 생겼습니다.
그래서 이번 기회에 Kotlin의 다양한 클래스 종류들을 정리하고, 각 클래스가 어떤 상황에서 적절한지 알아보고자 합니다.
일반 클래스는 특별한 키워드 없이 선언하는 가장 기본적인 클래스 형태입니다. 코틀린에서 class 키워드로 정의하며, 기본적으로 별도의 제한 없이 여러 인스턴스를 생성할 수 있는 청사진 역할을 합니다. 다만 코틀린의 클래스는 기본적으로 final(상속 불가)로 선언되며, 클래스를 상속 가능하게 하려면 open 키워드로 열어주어야 합니다. 일반 클래스는 내용에 따라 추상(abstract) 클래스로 선언하거나 인터페이스를 구현할 수도 있으며, 생성자나 초기화 블록(init) 등을 통해 복잡한 초기화 로직도 담을 수 있습니다.
복잡한 로직이나 상속 관계가 있는 객체: 일반 클래스는 복잡한 동작이나 관계를 모델링할 수 있는 유연성을 제공하며, 객체에 중요한 로직이 있거나 다른 클래스의 상속이 필요한 경우에 적합합니다. 예를 들어 서비스 클래스나 비즈니스 도메인 객체처럼 여러 메서드와 상태를 갖고 있고, 상황에 따라 하위 클래스로 확장될 수 있는 경우 일반 클래스를 사용하면 됩니다.
특정 상위 클래스를 상속해야 하는 경우: Kotlin 표준 라이브러리나 프레임워크의 클래스를 상속하여 커스터마이징해야 할 때는 일반 클래스를 open으로 열어 하위 클래스를 만들 수 있습니다. 인터페이스 구현 역시 일반 클래스에서 자유롭게 할 수 있습니다.
데이터 보관 이상의 역할을 하는 객체: 단순히 값만 담기보다 행위(메서드)가 중요한 객체라면 데이터 클래스보다 일반 클래스로 구현하는 편이 좋습니다. 예를 들어, 엔티티 객체가 데이터 외에 비즈니스 로직을 포함하거나 동등성 비교의 기준이 단순 값 비교 이상인 경우입니다.
설계의 유연성: 일반 클래스는 커스텀 메서드와 프로퍼티를 자유롭게 정의하고, 생성자도 여러 개(secondary constructor) 가질 수 있습니다. 따라서 필요한 모든 동작과 상태를 한 클래스에 담아낼 수 있습니다. 복잡한 비즈니스 로직, 캡슐화, 다형성 등 객체 지향 프로그래밍의 다양한 개념을 적용하기에 용이합니다.
상속 및 다형성 활용: 클래스에 open을 붙이면 다른 클래스가 이를 상속받아 확장할 수 있으므로, 상위 클래스-하위 클래스 구조를 만들 수 있습니다. 이때 하위 클래스에서 메서드를 override하여 다형성을 구현할 수 있습니다. 이러한 특징은 디자인 패턴 구현이나 프레임워크 개발 시 유용합니다.
명시적인 설계: 일반 클래스는 특별한 역할이 정해져 있지 않기 때문에, 개발자가 의도한 대로 클래스의 의미를 정의할 수 있습니다. 필요하다면 data나 sealed 같은 제한을 두지 않고도 자유롭게 클래스 구조를 설계할 수 있다는 점에서 기본 토대가 됩니다.
보일러플레이트 코드: 순수히 데이터를 담는 용도의 클래스라면, 일반 클래스는 equals/hashCode나 toString() 등을 자동으로 생성해주지 않으므로 필요한 경우 직접 구현해야 합니다. 예를 들어 자바처럼 값 동등성 비교를 하려면 equals와 hashCode를 오버라이드해야 하고, 객체 내용을 출력하려면 toString()을 작성해야 합니다. 이 때문에 단순 데이터 보관 용도로 일반 클래스를 쓰면 코드가 장황해질 수 있습니다.
상속 제한 (기본 final): 앞서 언급했듯이 코틀린의 클래스는 기본적으로 final이라서 그대로는 상속이 불가능합니다. 상속을 허용하려면 클래스 선언에 open을 명시적으로 붙여야 하는데, 이를 잊으면 하위 클래스를 만들 수 없고 컴파일 에러가 발생합니다. 따라서 상속을 염두에 둔다면 이러한 키워드를 빠뜨리지 않도록 유의해야 합니다.
특수 기능 부족: 일반 클래스는 데이터 클래스처럼 자동 생성 메서드가 있거나, sealed 클래스처럼 컴파일러의 exhaustiveness 체크를 받는 등의 특별한 지원이 없습니다. 필요에 따라 이러한 기능을 직접 구현하거나 다른 클래스 유형을 사용해야 합니다. 즉, 용도에 특화된 편의 기능은 없다는 점이 단점이라면 단점입니다.
데이터 클래스는 데이터 저장에 최적화된 클래스로, 클래스 정의 앞에 data 키워드를 붙여 선언합니다. 주 생성자에 나열된 프로퍼티들을 기반으로 코틀린 컴파일러가 자동으로 여러 메서드를 생성해주기 때문에, 보일러플레이트 코드를 크게 줄일 수 있습니다. 데이터 클래스는 주로 데이터를 담기 위한 그릇 객체로 사용되며, 객체를 출력하거나 비교하거나 복사하는 작업이 자주 필요한 경우 유용합니다. 예를 들어 data class User(val name: String, val age: Int)처럼 정의하면, 해당 클래스에는 컴파일러가 equals()/hashCode(), toString(), copy(), componentN() 함수들을 자동으로 만들어줍니다. 이를 통해 인스턴스 내용을 손쉽게 비교하고 출력하거나, 일부 속성만 바꿔 새 객체를 생성하거나, 구조 분해를 사용할 수 있게 됩니다.
값 객체 및 DTO: 복잡한 로직이 없이 순수 데이터만 담고 있는 객체에 적합합니다. 예를 들어 데이터베이스 엔티티의 값 객체나 API 요청/응답을 주고받는 DTO(Data Transfer Object)를 정의할 때 많이 사용됩니다. 이러한 경우 데이터 클래스는 불필요한 보일러플레이트 없이 필요한 데이터 속성만 표현할 수 있습니다.
값의 동등성 비교가 필요한 경우: 두 객체가 같은지 비교할 때 객체의 내용으로 판단해야 하는 상황에 유용합니다. 데이터 클래스는 모든 주 생성자 프로퍼티를 기준으로 equals/hashCode를 자동 생성하므로, 별도로 구현하지 않아도 내용이 같으면 동등한 객체로 취급됩니다. 예를 들어 두 사용자 객체의 이름과 나이가 같으면 같은 사용자로 간주하는 식의 로직을 쉽게 구현할 수 있습니다.
불변 객체 및 복사: 데이터 클래스는 주 생성자의 프로퍼티를 val로 선언하여 불변 객체로 활용하는 경우가 많습니다. 불변 객체는 멀티스레드 환경에서 안전하게 공유될 수 있고, copy() 메서드를 통해 일부 속성만 변경한 새로운 객체를 쉽게 만들 수 있어 상태 변화가 필요한 경우에도 원본을 보존할 수 있습니다. 이러한 특성 덕분에 함수형 프로그래밍 스타일이나 스레드 세이프티가 요구되는 상황에서 유용합니다.
보일러플레이트 감소: 데이터 클래스는 반복 코드 작성 없이 많은 기능을 제공합니다. equals()/hashCode() 메서드를 자동으로 구현해주어 객체 동등성 비교가 편리하며, toString()은 각 프로퍼티의 이름과 값을 포함한 문자열을 반환하여 디버깅에 유용합니다. 덕분에 개발자는 객체의 데이터에 집중할 수 있고 코드의 간결성과 가독성이 높아집니다.
편리한 데이터 처리 기능: copy() 메서드를 자동 생성하여 객체를 손쉽게 복제하고 일부 프로퍼티만 변경할 수 있습니다. 또한 주 생성자 프로퍼티에 대응하는 componentN() 함수들을 제공하므로, 데이터 클래스를 사용하면 구조 분해 선언을 통해 객체의 각 프로퍼티를 쉽게 분리해낼 수 있습니다. 예를 들어 val (name, age) = user 같은 구문으로 User 객체의 내용을 분해할 수 있습니다. 이러한 기능들은 불변 객체를 다루는 패턴이나 값 복사가 필요한 로직을 간결하게 만들어줍니다.
명확한 의도: 데이터 클래스는 "이 클래스는 주로 데이터 운반용"이라는 의도가 코드에 드러나기 때문에, 협업 시에도 해당 클래스에 비즈니스 로직을 넣지 않는 등 역할 구분이 명확해지는 장점이 있습니다. 표준 라이브러리의 Pair나 Triple보다도 의도를 잘 드러내는 이름있는 데이터 홀더를 만들 수 있어 코드 이해도가 높아집니다.
선언 요건: 데이터 클래스로 선언하려면 주 생성자에 최소 한 개 이상의 파라미터가 있어야 하고, 그 파라미터들은 반드시 val 또는 var로 정의된 프로퍼티여야 합니다. 이는 데이터가 전혀 없는 객체를 의미없게 생성하지 못하도록 하기 위함입니다. 만약 이러한 요건을 충족하지 못하면 컴파일 시 에러가 발생합니다.
상속 및 타입 제한: 데이터 클래스는 자체가 특정 조건을 가지므로 상속 관계에 제약이 있습니다. data 클래스는 abstract(추상 클래스), open(확장 가능 클래스), sealed, inner로 선언될 수 없으며, 다른 클래스의 상위 클래스가 될 수도 없습니다. 다시 말해 데이터 클래스는 항상 암묵적으로 final이며, 필요한 경우 인터페이스 구현은 가능하지만 일반 클래스처럼 계층 구조의 중간에서 동작할 수는 없습니다.
과도한 로직에는 부적합: 데이터 클래스는 이름 그대로 데이터를 저장하기 위한 용도에 초점이 맞춰져 있습니다. 따라서 복잡한 비즈니스 로직이나 상태 변화가 많은 객체를 표현하는 데는 적합하지 않을 수 있습니다. 예를 들어, 데이터 클래스에 너무 많은 메서드나 비즈니스 로직이 담기면 오히려 코드의 의도가 흐려질 수 있습니다. 이런 경우에는 차라리 일반 클래스로 구현하여 의도를 분명히 드러내는 것이 좋습니다.
동등성 기준의 한계: 자동 생성되는 equals는 오직 주 생성자에 선언된 프로퍼티로 객체를 비교합니다. 만약 클래스 몸체에 별도의 프로퍼티를 정의하여 추가 상태를 가진다면, 그 상태는 equals/hashCode 비교에 사용되지 않습니다. 이러한 동작은 의도된 것이지만, 개발자가 이를 모르고 있다면 예상치 못한 동등성 판단을 할 수 있으므로 주의가 필요합니다.
Sealed 클래스는 클래스 계층에 대한 상속을 제한하기 위한 클래스입니다. sealed 키워드를 붙여 선언하며, 동일 모듈 내에서 정의된 한정된 하위 클래스들만 이 상위 sealed 클래스를 상속할 수 있습니다. 즉, sealed 클래스를 상속할 수 있는 클래스의 종류를 컴파일 시에 모두 확정짓고, 그 외에는 새로운 하위 클래스를 추가할 수 없습니다. 이러한 특성 때문에 sealed 클래스의 하위 타입들은 컴파일 타임에 모두 알 수 있고, exhaustive한(빠짐없는) 처리가 가능하여 안전한 타입 계층을 구성하는데 쓰입니다. sealed 클래스 자체는 추상 클래스처럼 직접 인스턴스화할 수 없으며, 정의된 하위 클래스들을 통해서만 객체를 만들 수 있습니다.
상태가 제한된 경우: 나타날 수 있는 상태나 결과가 몇 가지로 한정적인 상황에 적합합니다. 예를 들어 결과 타입이 성공/실패 둘 중 하나로 한정되거나, 교통수단 타입이 자동차/버스/지하철로 정해져 있는 경우 등이 있습니다. Sealed 클래스를 사용하면 이러한 미리 정해진 종류의 하위 클래스들만 허용하므로, 다른 불필요한 값이 나타나지 않음을 컴파일러 수준에서 보장할 수 있습니다.
조건 분기 처리: sealed 클래스는 Kotlin의 when 표현식과 함께 자주 쓰입니다. sealed 클래스의 모든 하위 타입은 컴파일러가 알고 있기 때문에, when으로 이 타입을 분기 처리할 때 모든 경우를 처리했는지 검증할 수 있습니다. 하나의 하위 타입을 빠뜨리면 경고나 에러를 주어 개발자가 인지하게 해주므로, 실수로 인한 분기 누락을 방지할 수 있습니다. 복잡한 조건 처리나 상태 머신 구현에 sealed 클래스가 선호되는 이유입니다.
API의 확장 억제: 라이브러리나 프레임워크를 개발할 때, 클라이언트가 임의로 클래스를 상속해서 사용하지 못하도록 하고 싶다면 sealed 클래스를 사용할 수 있습니다. sealed 클래스로 공개 API의 상위 타입을 만들면 같은 모듈 내에서 정의된 하위 클래스 외에는 상속을 금지할 수 있어, 외부 개발자가 이를 확장함으로써 발생할 수 있는 오동작을 방지하고 API 사용을 통제할 수 있습니다. (자바 17의 sealed 키워드와 유사한 개념입니다.)
컴파일러의 Exhaustive 체크: sealed 클래스와 when을 함께 쓰면 모든 하위 타입을 처리했는지 컴파일러가 검사해줍니다. 덕분에 새로운 하위 클래스가 추가되어도 when 분기에서 빠뜨리면 곧바로 알려주므로 안정성이 높아집니다. 이는 흔히 발생하는 예외 케이스 누락을 방지하여 코드의 신뢰성을 향상시킵니다.
상속 범위 통제: sealed 클래스는 상속을 제한함으로써 의도하지 않은 확장을 막아줍니다. 모든 하위 클래스가 한 곳에 모여 있거나 정해진 패키지 내에 있으므로 코드의 흐름을 한 눈에 파악하기 쉽고, 새로운 하위 타입이 함부로 추가되지 않으므로 코드 관리와 유지보수에 유리합니다. 특히 앞서 언급한 라이브러리 개발 시 API의 사용 범위를 통제하는 장점이 여기에 해당합니다.
다양한 형태의 하위 클래스: sealed 클래스의 하위 클래스들은 서로 다른 형태를 가질 수 있습니다. 데이터가 필요하면 data 클래스로 하위를 만들 수도 있고, 일정한 하나의 상수적 존재라면 object 싱글턴 하위 클래스로 정의할 수도 있습니다. 또 일반 클래스 형태로 구체 구현 클래스를 넣을 수도 있습니다. 이처럼 각 경우별로 적합한 형태(클래스 or 객체)를 선택하여 하위 타입을 정의할 수 있어, 유연하면서도 타입 안정적인 계층 구조를 만들 수 있습니다. (반면 enum 클래스는 모든 상수가 동일한 구조를 갖습니다.)
하위 클래스의 범위 제한: sealed 클래스의 하위 클래스는 동일한 모듈 내, 그리고 보통은 동일 패키지에 정의되어야 하며(코틀린 1.5부터 같은 패키지 내 여러 파일에 정의 가능), 그 외 영역에서는 새로운 하위 클래스를 만들 수 없습니다. 이는 설계상 의도된 제한이지만, 만약 모듈을 나누거나 외부에서 확장해야 하는 경우에는 sealed 클래스를 사용할 수 없다는 제약이 됩니다.
직접 인스턴스화 불가: sealed 클래스는 자체가 추상 클래스와 같아서 sealed class A로는 인스턴스를 만들 수 없습니다. 반드시 정의된 하위 클래스(object 포함)의 형태로만 객체를 생성해서 사용해야 합니다. 추상 클래스와 유사하게 동작하므로, sealed 클래스 자체에 생성자가 있다면 그 생성자는 하위 클래스에서 super 호출할 때만 사용됩니다.
새 경우 추가의 어려움: 설계 당시 정해둔 하위 클래스의 집합이 변경되면, 즉 새로운 하위 타입을 추가하거나 기존 것을 변경해야 하면 원천 sealed 클래스 선언부를 수정해야 합니다. 또한 sealed 클래스를 사용한 모든 분기문(when)도 수정이 필요합니다. 컴파일러가 체크해주긴 하지만, 요구사항 변경 시 수정 범위가 넓어질 수 있다는 점은 enum과 sealed 구조의 공통 단점입니다.
간단한 상황에서는 오히려 복잡: 나타날 수 있는 값의 종류가 단순히 몇 가지 상수에 불과한 경우, sealed 클래스보다는 enum 클래스로 더 간결하게 표현할 수 있습니다. Sealed 클래스는 각 경우를 별도의 클래스로 정의해야 하므로 파일 하나에 여러 클래스를 작성하거나 코드를 길게 작성해야 할 수 있습니다. 이처럼 경우의 수가 많지 않고 각 경우가 데이터 차이만 있는 단순한 상황에서는 sealed 클래스가 과할 수 있습니다.
코틀린의 object 클래스는 특별한 키워드인 object를 사용하여 클래스 선언과 동시에 인스턴스를 생성하는 방식입니다. 이를 통해 전역에서 하나만 존재하는 싱글톤 객체(Singleton)를 간편하게 만들 수 있으며, 또는 익명 객체를 생성하여 일회용으로 활용할 수도 있습니다. object로 선언된 클래스는 이름을 가지는 경우 전역에서 접근 가능한 싱글톤이 되고, 이름이 없는 객체 표현식으로 사용되면 익명 내부 클래스처럼 일시적으로 객체를 만들어낼 수 있습니다. Object 선언은 주 생성자를 정의하지 않으며, 최초 사용 시 자동으로 초기화됩니다. (초기화는 thread-safe하게 한 번만 일어납니다.)
전역 싱글톤이 필요한 경우: 애플리케이션에서 단 하나만 존재해야 하는 객체를 만들 때 object 선언을 사용합니다. 예를 들어 설정 정보를 담는 객체나 공용 관리자 클래스(DB 커넥션 관리자 등)를 object로 정의하면, 어디서든 동일한 인스턴스를 가져다 쓸 수 있습니다. 코틀린의 object 싱글톤은 명시적인 생성자 호출 없이 처음 접근할 때 자동 생성되며, 스레드 안전하게 초기화됩니다.
동반 객체(Companion Object): 클래스 내부에 companion object로 선언된 object는 자바의 static 멤버처럼 클래스 수준의 함수와 프로퍼티를 제공하는 용도로 쓰입니다. 팩토리 메서드를 제공하거나 클래스와 연관된 유틸리티 함수를 넣어둘 때 companion object를 사용합니다. Companion object는 정의된 클래스의 정적인 컨텍스트처럼 동작하여, 클래스명으로 직접 해당 내부 요소들을 호출할 수 있습니다. (예: MyClass.create() 형태로 팩토리 호출)
익명 객체 생성 (일회용 객체): Java의 익명 내부 클래스를 대체하는 개념으로, 필요한 순간 즉석에서 객체를 만들어 사용할 때 object { ... } 객체 표현식을 사용합니다. 인터페이스 구현이나 이벤트 리스너, 콜백 등의 일회성 용도에 활용되며, 별도로 클래스를 선언하지 않고도 해당 인터페이스를 구현한 객체를 바로 만들 수 있어 편리합니다. 예를 들어 버튼 클릭 리스너를 설정할 때 object : OnClickListener { override fun onClick(...) { ... } }처럼 작성할 수 있습니다.
손쉬운 싱글톤 구현: object 선언을 사용하면 디자인 패턴으로 싱글톤을 구현하는 과정을 언어 차원에서 지원합니다. 별도의 클래스 변수나 synchronized 블럭 없이도 한 클래스의 단일 인스턴스만 존재함을 보장할 수 있고, 초기화도 lazy하게 (최초 사용 시) 이루어집니다. 코틀린 런타임이 이러한 싱글톤의 초기화를 보장하므로 멀티스레드 환경에서도 안전하게 사용할 수 있습니다.
전역 접근성: object로 만든 싱글톤은 클래스명 자체가 인스턴스 이름처럼 동작하므로 전역에서 간편하게 접근할 수 있습니다. 예를 들어 object Config { val port = 8080 }을 정의했다면 어디서든 Config.port로 값을 얻을 수 있습니다. 이러한 전역 접근 지점은 필요에 따라 어플리케이션 전역 상태나 공용 기능을 한 곳에 모아두는 역할을 합니다.
익명 클래스 대체: object 표현식을 사용하면 익명 내부 클래스를 만들 필요 없이 바로 객체를 생성하여 사용할 수 있습니다. 이로써 코드 양을 줄이고 가독성을 높일 수 있으며, 특정 인터페이스나 클래스를 잠깐 구현해야 하는 경우 새로운 클래스 파일을 만들지 않아도 되는 이점이 있습니다. Java로 작성하던 이벤트 핸들러를 Kotlin에서는 람다나 object 표현식으로 간결하게 대체할 수 있는 것이 좋은 예입니다.
static 대용 및 확장: Companion object를 사용하면 Java의 static 멤버처럼 함수와 변수를 사용할 수 있는데, 필요하면 이 동반 객체 자체에 인터페이스를 구현하거나 상속을 통해 확장할 수도 있습니다. 이는 단순히 정적 함수 모음 그치지 않고 객체지향적으로 동작을 공유하거나 대체할 수 있게 해주므로, 유연한 설계가 가능합니다.
다중 인스턴스 불가: object로 정의된 클래스는 항상 한 인스턴스만 존재합니다. 따라서 동일한 구조의 객체를 여러 개 만들어야 하는 경우에는 부적합합니다. 예를 들어 object Car로 차 객체를 정의하면 프로그램 전체에 한 대의 차만 존재할 수 있죠. 이러한 경우 일반 클래스나 데이터 클래스로 여러 인스턴스를 생성해야 합니다.
생성자 정의 불가: object 클래스에는 주 생성자나 보조 생성자를 정의할 수 없습니다. 객체가 애초에 한 번만 생성되므로 생성자를 호출할 일이 없기 때문입니다. 초기화 로직이 필요하다면 클래스 본문에 init 블록을 사용해야 합니다. 또한 object 객체는 생성 시점을 제어하기 어렵고 (처음 접근할 때 자동 생성되므로), 생성자 매개변수를 줄 수 없다는 제약이 있습니다.
로컬 범위 제한: object 선언은 로컬 영역(함수 내부)에서 사용할 수 없습니다. 최상위 레벨이나 클래스/객체의 내부에서만 선언할 수 있어, 특정 함수 안에서만 존재하는 싱글톤을 만들지는 못합니다. (다만, object 표현식은 식(expression)으로 간주되므로 함수 내부에서도 사용할 수 있습니다.)
전역 상태 관리 주의: 싱글톤 객체는 어플리케이션 전체에서 하나만 존재하며 상태를 공유하므로, 잘못 사용하면 전역 변수를 남용하는 것처럼 될 수 있습니다. 특히 var 변경 가능한 프로퍼티를 object에 두고 여기저기서 변경한다면 프로그램 상태를 추적하기 어려워집니다. 따라서 object를 사용할 때는 가능한 불변 상태로 설계하고, 필요한 경우 동기화나 상태 관리에 신경써야 합니다.
Enum 클래스는 한정된 상수 값의 집합을 표현하는 특수한 클래스입니다. enum class 키워드를 사용해 정의하며, 여러 개의 상수를 열거하여 그 외의 값은 가질 수 없도록 합니다. 대표적인 예로 요일을 나타내는 enum class Day { MON, TUE, WED, ... }를 들 수 있습니다. Enum 클래스의 각 열거 상수는 해당 클래스의 유일한 인스턴스이며, 보통 상수 이름은 모두 대문자로 표기합니다. 열거형을 사용하면 미리 정해진 값들만 취급하도록 강제할 수 있으므로, 잘못된 값이 사용되는 것을 컴파일러 단계에서 방지하는 타입 안정성의 장점이 있습니다. 코틀린의 enum 클래스는 Java의 enum과 유사하게 동작하면서도, 더 많은 기능을 제공합니다. 각 상수마다 생성자 파라미터를 지정하여 데이터를 가질 수 있고, 상수별로 별도의 메서드를 구현할 수도 있습니다. 또한 enum 클래스 자체가 프로퍼티나 메서드를 가질 수 있으며, 인터페이스를 구현할 수도 있습니다 (다만 클래스를 상속할 수는 없습니다). 이러한 특징으로 enum은 단순 상수 집합 이상의 역할도 수행할 수 있습니다.
정해진 옵션의 표현: 값의 범위가 한정적으로 정해진 경우 enum이 적합합니다. 예를 들어 앞서 말한 요일이나 월(MONTH), 방향(NORTH/SOUTH/EAST/WEST), 혹은 상태(State)가 몇 가지로 규정되는 경우에 사용할 수 있습니다. Enum 클래스로 정의하면 해당 값들이 한 눈에 모여 있고 그 외의 값은 코드상으로 표현할 방법이 없으므로, 코드의 의도가 명확해집니다.
상태 코드 대체: 기존에 정수나 문자열 상수로 정의하던 상태 코드들을 enum으로 대체하면 가독성과 안전성이 올라갑니다. 예를 들어 사용자 권한을 "ADMIN", "USER" 문자열로 구분하는 대신 enum class Role { ADMIN, USER }로 정의하면, 오타나 잘못된 값 입력을 컴파일러가 잡아줄 수 있습니다. 이처럼 열거형은 의미 있는 이름을 통해 코드 이해를 돕고 잘못된 값 사용을 예방합니다.
관련 상수 묶음 관리: 서로 관련 있는 상수들을 하나의 enum으로 묶어서 관리하면 코드 구조가 체계적입니다. 예를 들어 환경 설정 값을 나타내는 여러 상수들을 enum으로 정의해 두면 (DEV, STAGE, PROD 등), 이 값들을 전달하거나 비교할 때 문자열보다 의도가 분명하고, 새로운 값을 추가하기도 용이합니다.
강력한 타입 안정성: enum을 사용하면 정해진 값들만 사용하도록 컴파일러가 강제하므로, 존재하지 않는 값이나 잘못된 값을 쓰는 것을 원천 봉쇄할 수 있습니다. 이는 런타임 오류를 줄이고, 코드 작성 단계에서 오류를 쉽게 발견하도록 도와줍니다. (예를 들어 when으로 enum을 처리할 때 모든 상수를 처리하면 else 분기가 필요 없고, 상수 추가 시 컴파일러가 처리 누락을 경고해줍니다.)
표현력과 가독성: 열거 상수들은 그 자체로 의미있는 이름을 가지기 때문에, 코드에 상수값을 직접 써넣는 것보다 이해하기 쉽습니다. if (mode == Mode.SECURE) 같은 코드는 if (mode == 2)보다 의도가 명확합니다. 또한 enum 클래스 안에 각 상수별 설명을 위한 프로퍼티나 메서드를 정의할 수도 있어, 코드 자체에 메타정보를 담는 효과도 얻을 수 있습니다.
부가 기능 (메서드/프로퍼티) 제공: Kotlin의 enum 클래스는 자동으로 여러 유틸리티를 제공합니다. 예를 들어 모든 enum에는 그 상수들을 배열로 반환하는 values() 함수(또는 Kotlin 1.9부터 지원되는 entries 프로퍼티)가 제공되어 정의된 모든 상수를 쉽게 열거할 수 있습니다. 이름으로 상수를 찾는 valueOf(name: String) 메서드도 기본 제공됩니다. 또한 각 상수는 name (상수명)과 ordinal (정의된 순서, 0부터) 프로퍼티를 가지며, 이를 통해 상수의 정보를 얻을 수 있습니다. 이러한 표준 기능 덕분에 열거형을 사용할 때 일일이 상수 목록이나 이름 매핑을 관리할 필요가 없습니다.
상수별 고유 동작: 필요하다면 각 enum 상수마다 별도의 구현을 가질 수도 있습니다. Enum 클래스 내에 추상 메서드를 정의하고 각 상수에서 이를 구현하면 상수별로 서로 다른 동작을 수행하게 만들 수 있습니다. 이는 sealed 클래스보다는 제한적이지만, 간단한 상수별 행위 분기에는 충분히 활용될 수 있습니다. (예를 들어 상태에 따른 처리 로직을 enum의 각 상수가 구현하게 할 수 있습니다.)
상수 집합의 고정성: enum 클래스에 정의된 상수 외에는 다른 값을 가질 수 없습니다. 즉 새로운 상수를 런타임에 추가하거나 할 수 없으며, 값을 확장하려면 소스 코드를 수정해야 합니다. 애플리케이션 도메인의 요구가 바뀌어 새로운 종류의 값이 필요해지면 enum 정의를 변경하고 재배포해야 하므로, 확장에는 다소 경직된 면이 있습니다. (sealed 클래스와 유사한 단점입니다.)
클래스 상속 불가: enum 클래스는 내부적으로 kotlin.Enum을 상속하므로 다른 클래스를 상속할 수 없습니다. 인터페이스 구현은 가능하지만, 클래스 계층 구조의 일원이 될 수 없다는 제약이 있습니다. 따라서 경우에 따라 enum을 쓸 수 없는 상황이 생길 수 있는데, 특히 이미 다른 클래스를 상속해야 한다면 enum으로 표현하기 어렵습니다.
상수별 데이터 구조 제한: 모든 enum 상수는 같은 클래스 타입이므로 속성 구조가 동일합니다. 상수에 따라 가지고 있는 값의 종류가 달라야 한다면 enum으로 직접 표현하기 힘듭니다. 예를 들어 어떤 이벤트를 나타내는 값들이 있는데, 일부는 추가 데이터(메시지 등)가 필요하고 일부는 필요 없다면 sealed 클래스로 각각의 하위 클래스에 다른 프로퍼티를 두는 방법이 적합할 수 있습니다. Enum으로는 공통의 프로퍼티를 두고 필요 없는 곳은 무시하거나, enum 외부에서 별도로 맵핑하는 식으로 우회해야 하므로 불편합니다.
메모리 및 성능: 일반적으로 enum 상수의 수가 많지 않다면 문제되지 않지만, 상수가 매우 많은 열거형의 경우 주의가 필요합니다. 모든 enum 상수는 애플리케이션 시작 시 각각 인스턴스화되므로, 상수 개수가 많으면 그만큼 메모리를 사용합니다. 또한 values() 호출 시 모든 상수를 순회하는 비용 등이 있으므로, 아주 빈번한 연산에서는 성능을 고려해야 할 수 있습니다. 대다수 경우 이 비용은 미미하지만, 극단적인 경우라면 고려 대상입니다.
지금까지 코틀린의 주요 클래스 타입 다섯 가지에 대해 특징과 용도를 살펴보았습니다. 요약하면 다음과 같습니다:
일반 클래스: 가장 범용적인 클래스로, 특별한 기능 없이 자유롭게 설계 가능합니다. 복잡한 로직이나 상속 구조가 필요한 경우에 선택합니다. 기본적으로 final이라 상속하려면 open이 필요하며, 데이터 저장 용도로 쓰기엔 보일러플레이트가 많을 수 있습니다.
데이터 클래스: 데이터를 담는 용도에 특화된 클래스입니다. 값 객체나 DTO 등에 사용하며, 자동으로 생성되는 메서드들 덕분에 코드가 간결해집니다. 다만 상속할 수 없고 복잡한 행위가 어울리지 않으므로, 순수한 데이터 모델에만 사용합니다.
Sealed 클래스: 폐쇄된 클래스 계층을 만들 때 사용합니다. 정해진 여러 타입(하위 클래스) 중 하나의 형태를 갖는 객체를 표현할 때 적합하며, when과 함께 쓰면 컴파일러가 모든 경우의 처리를 강제하여 안전성을 높입니다. 확장이 제한되므로 외부 모듈에 공개하는 API 설계 등에 활용하지만, 경우의 수가 단순한 상황에서는 enum이 더 간결할 수 있습니다.
Object 클래스: 싱글톤 패턴을 언어 차원에서 지원하는 클래스입니다. 하나만 필요한 객체를 만들 때 사용하며, 전역적으로 접근 가능합니다. 또한 companion object로 정적 멤버 대용으로 쓰이거나, 익명 object 표현식으로 임시 객체를 생성하는 데 쓰입니다. 여러 인스턴스가 필요하면 부적합하고, 생성자를 가질 수 없으며, 전역 상태 관리에 주의해야 합니다.
Enum 클래스: 한정된 상수들의 집합을 표현하는 클래스입니다. 값의 종류가 고정되어 있고 명시적인 이름으로 다루고 싶을 때 사용합니다. 타입 안전하게 상수를 다룰 수 있고, 자동으로 유용한 메서드들을 제공합니다. 단, 동적으로 값을 확장할 수 없고 다른 클래스 상속이 불가능하므로, 유연성이 필요한 경우 sealed 클래스를 고려해야 합니다.
각 클래스 유형마다 이처럼 장단점이 상존하기 때문에, 설계하려는 대상의 성격에 맞게 선택하는 것이 중요합니다. 간단히 기억해두면, "데이터 중심이면 Data 클래스, 상수 집합이면 Enum, 변형 가능한 계층이면 Sealed, 오직 하나만 필요하면 Object, 그 외에는 기본 Class" 정도로 구분할 수 있겠습니다. 코틀린이 제공하는 이 다양한 도구들을 적재적소에 활용하여, 가독성 좋고 안전하며 효율적인 클래스 설계를 해보세요!