코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 다양한 중첩 클래스를 다루는 방법이고 최태현님의 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide) 강의에 소금을 조금 친 내용이다
Kotlin은 간결하고 표현력 있는 코드를 작성할 수 있도록 다양한 클래스 타입을 제공한다. 특히 데이터 전달 객체, 상태 관리, 제한된 계층 구조 등 특정 목적에 최적화된 클래스들을 통해 개발 효율성과 코드 안전성을 높일 수 있다
Data Class (데이터 클래스)
- 데이터를 저장하고 전달하는 목적으로 설계된 클래스이다. Java의 DTO(Data Transfer Object)나 Record와 유사하지만 더 간결하다
// Java
public class PersonDto {
private final String name;
private final int age;
public PersonDto(String name, int age) {
this.name = name;
this.age = age;
}
// equals(), hashCode(), toString(), getter는 수동 또는 IDE/Lombok으로 생성 가능
}
// Java 16+ Concise Record
java record PersonDto(String name, int age) {
}
// Kotlin
data class PersonDto(
val name: String,
val age: Int
)
- 자동 생성 메서드: equals(), hashCode(), componentN(), copy() 메서드를 컴파일러가 자동으로 생성해준다 (Java의 record와 유사)
- Getter / Setter: val (immutable) 필드에는 getter가, var (mutable) 필드에는 getter / setter가 자동으로 생성된다
- Named Arguments (이름 있는 인자) 활용: Java의 빌더 패턴과 유사한 효과를 제공하여 객체 생성 시 가동석을 높이고 선택적 매개변수 처리를 간편하게 한다
전통적인 Builder Pattern VS Data Class + Named Arguments
Builder Pattern은 객체 생성의 가독성, 선택적 매개변수 처리, 매개변수 순서 무관 등의 이점을 제공한다. Lombok @Builder 어노테이션을 활용하면 보일러플레이트 코드를 줄일 수 있다
// Java
@Builder
class User {
private final String name;
private final int age;
// ...
}
User.builder()
.name("Hyeok")
.age(100)
.build();
- Kotlin의 Data Class의 Named Arguments를 결합하면 이러한 Builder Pattern의 모든 장점을 언어 차원에서 제공하여 더 안전하고 효율적인 객체 생성이 가능하다
// Kotlin Data class With Default Arguments & Named Arguments
data class User(
val name: String,
val age: Int = 0,
val email: String = "",
val phone: String = "",
val address: String = "",
)
// 사용 예시 (Builder Pattern과 동일한 가독성 및 유연성)
val user = User(
name = "Hyeok",
age = 100,
email = "hyeok@email.com", // 순서 무관, 일부 필드만 설정 가능
)
val user2 = user.copy(age = 101) // copy 메서드로 일부 값만 변경
// 구조 분해 선언 (Destructuring Declaration)
val (name, age, email, phone, address) = user // componentN() 메서드 활용
Named Arguments의 장점
- 보일러 플레이트 코드 제거: Builder 클래스 생성이 필요 없다
- 가독성: 어떤 값이 어떤 필드에 할당되는지 명확하다
- 선택적 매개변수 / 순서 무관: 기본 인자 값과 함께 사용하여 유연한 객체 생성이 가능하다
- 컴파일 타임 안전성: 잘못된 매개변수나 누락된 필수 매개변수를 컴파일 시점에 즉시 발견 가능하다
- 언어 내장 기능: Lombok과 같은 외부 도구 의존성 없이 Kotlin 언어 자체에서 제공한다. 이는 더 높은 안정성, 투명성, 효율성, 일관성을 보장한다
Enum Class (열거형 클래스)
- Java에서는 Enum 값에 따른 분치 처리는 if-else 체인을 사용해야 하며, 새로운 Enum 값이 추가될 경우 컴파일러가 누락을 감지하지 못하는 문제가 있다
// Java
public enum Country {
KOREA("KO"),
AMERICA("US");
// ...
private static void handleCountry(Country country) {
if (country == Country.KOREA) {
/* ... */
}
if (country == Country.AMERICA) {
/* ... */
}
// 새로운 Enum 값 추가 시 컴파일 경고가 없고 else 처리가 애매하다
}
}
- Kotlin의 when 표현식은 enum과 함께 사용될 때 완전성 검사(Exhaustive Check) 기능을 제공하여 이러한 문제를 해결한다
enum class Country(val code: String) { // Enum에 프로퍼티와 생성자 가능
KOREA("KO"),
AMERICA("US"),
JAPAN("JP") // 새로운 값 추가
}
fun handleCountry(country: Country) {
when(country) {
Country.KOREA -> println("Korea 로직")
Country.AMERICA -> println("America 로직")
// Country.JAPAN 케이스가 누락되면 컴파일 에러 발생 ("when expression must be exhaustive")
}
// 'else'가 필요 없으면, 컴파일러가 모든 케이스를 확인하여 타입 안전성 보장
// Enum에서 추상 메서드 정의 가능
abstract fun signal(): String
}
- 완전성 검사: when 표현식에서 모든 enum 값을 처리하지 않으면 컴파일 에러를 발생시켜 누락을 방지한다
- 가독성: if-else 체인보다 when 표현식이 직관적이고 가독성이 좋다
- 싱글톤: 각 Enum 인스턴스 싱글톤이다
- 인터페이스 구현: Enum 클래스는 인터페이스를 구현할 수 있지만, 다른 클래스를 상속받을 수 없다
- 프로퍼티 / 메서드: Enum 상수마다 고유한 프로퍼티(code)나 메서드를 가질 수 있다
Sealed Class (봉인된 클래스) / Sealed Interface (봉인된 인터페이스)
- 상속 계층 구조를 정의하지만, 이를 선언된 파일 또는 모듈 내에서만 제한하여 외부에서의 무분별한 상속을 막는 데 사용된다. enum의 확장 기능 버전으로 불 수 있다
탄생 배경
- 특정 추상 클래스나 인터페이스를 정의하면서도 그 하위 타입들을 제한된 범위 내에서만 허용하여 라이브러리 가용자가 임의로 확장하는 것을 방지하고자 할 때 유용하다. 컴파일러에게 “이 계층 구조에 속할 수 있는 모든 하위 타입은 여기 명시된 것뿐이다”라고 알려주는 것이다
특징
- 제한된 상속: sealed 키워드가 붙은 클래스 / 인터페이스는 같은 파일 또는 같은 컴파일 모듈 내에서만 하위 클래스 / 인터페이스를 정의할 수 있다
- 완전한 분기 처리(when): enum과 마찬가지로 sealed 클래스의 하위 타입에 대한 when 표현식으로 분기 처리할 때, 모든 하위 타입을 처리하지 않으면 컴파일 에러가 발생한다(완정성 검사)
- 변경 감지: 새로운 하위 클래스가 추가되거나 제거될 경우, 이를 사용하는 모든 when 표현식에서 즉시 컴파일 에러를 통해 알려주므로 누락된 로직을 방지할 수 있다
- enum의 안전성 + class의 유연성: enum처럼 정해진 상수 집합을 다루는 것처럼 안전하게 분기 처리하면서도 각 하위 클래스는 독립적인 상태와 동작을 가질 수 있는 class의 유연성을 제공한다
실용적 활용 (API 응답 추상화 예시)
- 네트워크 통신 결과나 UI 등을 표현할 때 sealed class를 활용하면 타입 안전하고 예측 가능한 코드를 작성할 수 있다
sealed class ApiResponse<T> // T는 응답 데이터의 타입 파라미터
data class Success<T>(val data: T): ApiResponse<T>() // 응답 성공
data class Error<T>(val code: Int, val message: String): ApiResponse<T>() // 에러 응답
object Loading: ApiResponse<Nothing>() // 로딩 상태 (데이터 없음)
// 싱글톤 객체 (인스턴스가 하나만 존재)
// 타입 안전한 분기 처리
fun handleResponse(response: ApiResponse<User>) = when (response) {
is Success -> showUser(response.data) // User 데이터 처리
is Error -> showError(response.message) / 에러 메시지 처리
is Loading -> showLoading() // 로딩 UI 표시
}
fun showUser(user: User) {
/* 사용자 정보 UI 표시 */
}
fun showError(message: String) {
/* 에러 메시지 UI 표시 */
}
fun showLoading() {
/* 로딩 인디케이터 표시 */
}
일반 클래스(class)와 프로퍼티, 생성자
기본 클래스 구조 및 프로퍼티
Class Person (
val name: String, // 읽기 전용 프로퍼티 (자동으로 getter 생성)
var age: Int, // 읽기쓰기 기능 프로퍼티 (자동으로 getter, setter 생성(
) {
// 초기화 블록: 주 생성자가 호출될 때 실행되는 로직 (유효성 검사 등)
init {
if (age <= 0) {
throw IllegalArgumentException("나이는 ${age}일 수 없습니다")
}
println("초기화 블록")
}
// 보조 생성자: 주 생성자를 반드시 위임 (this() 사용)
constructor(name: String): this(name, 1) {
println("첫 번째 보조 생성자")
}
constructor()" this("임꺽정") { // 다른 보조 생성자 위임 가능
println("두 번째 보조 생성자")
}
// 커스텀 getter (get())
val upperCaseName: String
get() = this.name.uppercase()
// 커스텀 getter를 사용한 함수 대체 (isAdult)
val isAdult(): Boolean {
return this.age >= 20
}
val isAdultProperty: Boolean
get() = this.age >= 20 // 함수와 동일한 로직을 프로퍼티로 구현
}
fun main() {
val person1 = Person("Hyeok", 5) // 초기화 블록
println("이름 ${person1.name}, 나이: ${person1.age}") // name은 val, age는 var
person1.age = 10 // var 프로퍼티는 값 변경 가능
println("변경 후 나이: ${person1.age}") // 변경 후 나이: 10
person2 = Person("홍길동") // 보조 생성자 사용
// 초기화 블록
// 첫 번째 보조 생성자
println("이름: ${person2.name}, 나이: ${person2.age}") // 이름: 홍길동, 나이: 1
person3 = Person() // 다른 보조 생성자 사용
// 초기화 블록
// 첫 번째 보조 생성자
// 두 번째 보조 생성자
println("이름: ${person3.name}, 나이: ${person3.age}") // 이름: 임꺽정, 나이: 1
println("성인 여부: ${person1.isAdult()}") // 성인 여부: false
println("성인 여부 (프로퍼티): ${person1.isAdultProperty}") // 성인 여부 (프로퍼티): false
println("대문자 이름: ${person1.uppercase}") // 대문자 이름 HYEOK
}
프로퍼티와 커스텀 접근자 (Getter / Setter)
class Person(
name: String = "Hyeok" // 기본 인자 값
var age: Int = 1
) {
// name 프로프티에 커스텀 getter 정의
// filed는 backing filed (실제 값을 저장하는 숨겨진 변수)를 참조
val name = name
get() = feild.uppercase() // name에 접근할 때마다 대문자로 변환하여 반환
init {
/* ... 유효성 검사 등 ... */
}
fun isAdult(): Boolean {
return this.age >= 20
}
// 계산된 프로퍼티 (backing field 없음, getter에서 값을 계산하여 반환)
fun isAdultComputed: Boolean
get() = this.age >= 20
}
fun main() {
val person = Person("hyeok", 5)
println(person.name) // "HYEOK" (커스텀 getter 적용)
person.age = 10
println(person.isAdultComputed) // false
}
주요 특징
- 주 생성자: 클래스 헤더에 선언되며, 클래스의 필수적인 프로퍼티를 정의 한다. val 또는 var 키워드를 사용하여 프로퍼티로 선언할 수 있다
- 보조 생성자: constructor 키워드를 사용하여 정의하며, 항상 주 생성자에 위임( this(…) )해야 한다
- init 블록: 주 생성자가 호출할 때 실행되는 초기화 로직을 포함한다. 주로 프로퍼티의 유효성 검사 등에 사용된다
- 프로퍼티: val (read-only)또는 var(mutable)로 선언하며, Kotlin이 자동으로 getter / setter를 생성한다
- 커스텀 접근자: get() 및 set() 키워드를 사용하여 프로퍼티의 동작을 정의할 수 있다. filed 식별자는 실제 값을 저장하는 백킹 필드(backing field)를 참조한다
- 계산된 프로퍼티: get()만 있는 프로퍼티로 실제 필드를 가지지 않고 접근 시마다 값을 계산하여 반환한다. 함수와 유사하지만 필드처럼 접근할 수 있어 가독성을 높인다
출처 – 인프런 강의 중 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)