Kotlin 문법 배우기
코틀린에서 클래스 다루기
1. 클래스
클래스와 프로퍼티
우선 다음과 같은 자바의 클래스가 있다고 해보자.
public class Person {
private final String name; // 변경 불가능한 필드
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
// name은 Setter가 없음
}
위 클래스를 1차적으로 형태가 비슷하게 코틀린으로 변환해보도록 하겠다.
name
은 불변 필드이기 때문에 val
, age
는 가변 필드이기 때문에 var
로 선언해주었으며, 코틀린에서 생성자는 기본적으로 constructor(인자, ...)
로 작성한다.
class Person constructor(name: String, age: Int) {
val name = name
var age = age
}
그럼 위 코틀린 클래스를 2차적으로 더욱 코틀린스럽게 변경해보자. 우선 public constructor는 생략 가능하다. 그리고 코틀린에서는 생성자가 만들어줄 때 동시에 프로퍼티를 선언할 수 있다.
프로퍼티: 필드 + getter + setter
class Person(val name: String, var age: Int)
코틀린에서는 클래스에 필드만 만들어주면 getter, setter를 자동으로 만들어준다. 즉 필드만 만들어주면 자동으로 프로퍼티가 된다.
더 자세하게는 아래 표와 같다.
클래스 선언 | getter 생성 | setter 생성 | 유형 |
---|---|---|---|
class Person(name: String) | X | X | 생성자 매개변수 |
class Person(var name: String) | O | O | 속성 |
class Person(val name: String) | O | X | 속성 |
코틀린에서는 .필드
를 통해 getter와 setter를 바로 호출할 수 있다. 이 점은 코틀린에서 자바 클래스를 가져와 사용할 때도 동일하게 사용할 수 있다.
fun main() {
val person = Person("Paul", 30)
println(person.name) // 이는 자바의 person.getName()과 동일
person.age = 31 // 이는 자바의 person.setAge(31)과 동일
println(person.age)
}
class Person(val name: String, var age: Int)
생성자와 init
앞서 본 Person 클래스가 생성되는 시점에 age
값을 검증하는 로직을 추가해보자. 자바 코드였다면 다음과 같이 추가될 것이다.
public class Person {
private final String name;
private int age;
public Person(String name, int age) {
if (age < 0) { // 생성자에서 검증
throw new IllegalArgumenException("나이는 0 미만일 수 없습니다.");
}
this.name = name;
this.age = age;
}
// ...
}
그럼 코틀린에서는 생성시에 어떠한 로직을 처리하고자 할 땐 어떻게 할까? 바로 init 이라는 블록을 사용해 처리한다.
init 블록은 클래스가 초기화되는 시점에 한 번 호출되어, init 블록 안에 검증 로직을 추가하면 된다.
class Person(val name: String, var age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
}
최초로 태어난 아기는 나이가 0살일테니, 새로 태어날 때 사용할 생성자를 하나 더 추가해보자. 먼저 자바 코드로 작성하면 다음과 같다.
public class Person {
private final String name;
private int age;
public Person(String name, int age) {
if (age < 0) { // 생성자에서 검증
throw new IllegalArgumenException("나이는 0 미만일 수 없습니다.");
}
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 0);
}
// ...
}
코틀린에서는 추가 생성자를 어떻게 만들까? 가장 기본이 되는 생성자는 클래스 선언 시 만들어주지만, 추가되는 생성자는 클래스 body 부분 안에 constructor 키워드와 함께 만들어진다.
이처럼 더 추가되는 생성자를 부생성자라고 하며 부생성자는 있을 수도 있고, 없을 수도 있다.
class Person(val name: String, var age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
constructor(name: String): this(name, 0) // :this = 자바의 this 처럼 기본 생성자를 호출
}
또한 부생성자는 body를 가질 수 있다. 따라서 다음과 같은 형태도 가능하다.
이러한 형태의 클래스를 외부에서 부생성자로 생성하게 된다면 init 블록 → 부생성자 블록 순으로 호출된다.
class Person(val name: String, var age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
constructor(name: String): this(name, 0) {
println("첫 번째 부생성자")
}
}
하지만 실무에서는 부생성자를 사용하는 일보다 정적 팩토리 메서드를 주로 사용하기 때문에 부생성자라는 게 이런 거다 라고 알아두고 넘어가면 좋을 것 같다.
커스텀 getter, setter
Person 클래스에 성인인지 확인하는 기능을 추가해야 한다고 해보자. 자바에서는 다음과 같이 클래스 내부에 메서드를 추가하는 식으로 기능 추가를 할 것이다.
public class Person {
private final String name;
private int age;
public Person(String name, int age) {
if (age < 0) { // 생성자에서 검증
throw new IllegalArgumenException("나이는 0 미만일 수 없습니다.");
}
this.name = name;
this.age = age;
}
public boolean isAdult() {
return this.age >= 20;
}
// ...
}
코틀린에서도 물론 클래스 내부에 함수를 추가해 해당 기능을 추가할 수 있다.
class Person(val name: String, var age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
fun isAdult(): Boolean {
return this.age >= 20
}
}
하지만 다른 방법도 존재한다. 바로 커스텀 getter라는 것을 이용하는 방법인데, 마치 Person 클래스에 프로퍼티가 있는 것처럼 보여주는 방식이다.
class Person(val name: String, var age: Int) {
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
val isAdult: Boolean
get() = this.age >= 20
// 또는 중괄호를 사용해 작성할 수도 있음
val isAdult: Boolean
get() {
return this.age >= 20
}
}
함수를 추가하는 방법과 커스텀 getter를 사용하는 방법 모두 동일한 기능이며 표현 방법만 다른 것이다.
한 가지 팁으로는 외부에서 접근을 할 때 프로퍼티인 것처럼 접근하느냐, 함수인 것처럼 접근하느냐에 따라 커스텀 getter 또는 함수로 처리하면 좋다. 프로퍼티인 것처럼 접근한다는 의미는 접근하는 대상이 객체의 속성에 가까운지라고도 표현할 수 있을 것 같다.
그리고 커스텀 getter를 사용하면 자신을 변형할 수도 있다. 한번 Person 클래스에서 name을 get할 때 문자를 대문자로 바꿔보자.
class Person(name: String, // val name 이라고 선언하면 하나의 프로퍼티가 생성되어 getter 가 자동으로 생성되므로 val을 뺌
var age: Int
) {
val name = name
get() = field.uppercase() // 그냥 name.uppercase() 라고 name을 호출하면 getter가 호출되므로 해당 커스텀 get이 호출되고, 무한루프 발생!
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
val isAdult: Boolean
get() = this.age >= 20
}
get() = field.uppercase()
부분에서 field 대신 name을 그냥 부르게 되면 (get() = name.uppercase()
), 외부에서 .필드를 부르면 getter가 호출되듯 내부에서도 필드를 부르면 getter가 호출된다. 하지만 위 코드에서 getter는 커스텀 getter로 만들어놓은 get() = name.uppercase()
가 되므로 또 getter안에 name으로 getter를 호출하게 되는 셈이다. 즉 무한루프가 발생하게 되는 문제가 있다.
그래서 무한루프를 방지하기 위해서 field
라는 자기 자신을 가리키는 필드를 의미하는 예약어가 생겼고, 이를 backing field 라고 부른다. 하지만 실제로는 backing field를 잘 사용하지 않는다. 위와 같이 name 필드를 get할 때 대문자로 바꿔주는 요구사항은 다음과 같이 처리해줄 수도 있다.
class Person(val name: String, var age: Int
) {
val uppercaseName: String
get() = this.name.uppercase()
init {
if (age < 0) {
throw IllegalArgumentException("나이는 0 미만일 수 없습니다.")
}
}
val isAdult: Boolean
get() = this.age >= 20
}
이처럼 대문자로 변환해주는 uppercaseName
를 추가해 원하는 프로퍼티처럼 사용하도록 하여 요구사항을 처리할 수도 있다.
그래서 실무에서는 backing field를 사용하여 커스텀 getter를 만들 일이 잘 없기는 하다.
커스텀 getter와 비슷하게 커스텀 setter 또한 존재하나, setter 자체를 지양하고 setter를 사용하는 것보다 update 함수를 따로 만들어 사용하는 식의 방법이 더 안전하고 관리가 용이하기 때문에 잘 사용하지 않는다.
2. 중첩 클래스
사실 중첩 클래스 또한 실무에서 자주 사용하는 방법은 아니긴 하나, 계층 관게를 표현하거나 논리적인 구조를 표현할 때 간혹 사용되긴 한다.
중첩 클래스의 종류
자바에서는 크게 static을 사용한 중첩 클래스, static을 사용하지 않는 중첩 클래스 두 가지로 나뉘고, 여기서 static을 사용하지 않는 중첩 클래스에서는 내부 클래스, 지역 클래스, 익명 클래스 세 가지로 나뉠 수 있다.
- Static을 사용하는 중첩 클래스 : 밖의 클래스 직접 참조 불가
- Static을 사용하지 않는 중첩 클래스
- 내부 클래스 (Inner Class) : 밖의 클래스 직접 참조 가능
- 지역 클래스 (Local Class) : 메서드 내부에 클래스 정의
- 익명 클래스 (Anonymous Class) : 일회성 클래스
하지만 이펙티브 자바 같은 가이드를 살펴보면 내부 클래스는 숨겨진 밖의 클래스의 정보를 가지고 있어, 참조 해지를 하지 못하는 경우 메모리 누수가 생길 수 있고, 디버깅이 어렵다고 한다. 또한 내부 클래스의 직렬화 형태가 명확히 정의되어 있지 않아 직렬화에 제한이 생긴다는 문제도 제시되고 있다.
따라서 클래스 안에 클래스를 만들 때는 static 클래스를 사용하도록 권장하고 있는데, 코틀린에서는 이러한 가이드를 잘 따르고 있다.
먼저 권장되는 static 클래스를 중첩 클래스로 사용한 자바 클래스 예제를 보면 다음과 같다.
public class House {
private String address;
private LivingRoom livingRoom;
public House(String address) {
this.address = address;
this.livingRoom = new LivingRoom(10L);
}
public static class LivingRoom {
private long area;
public LivingRoom(long area) {
this.area = area;
}
}
}
위 클래스를 코틀린으로 변환해보겠다.
class House (
private val address: String,
private val livingRoom: LivingRoom = LivingRoom(10L)
) {
class LivingRoom(
private val area: Long
)
}
코틀린에는 static 키워드가 따로 존재하지 않는다. 따라서 권장되는 중첩 클래스를 생성할 때는 그냥 class로 생성하면 된다.
그렇다면 권장하지 않는, 밖의 클래스를 참조할 수 있는 내부 클래스는 어떻게 작성할까? 바로 다음과 같이 작성한다.
class House (
private val address: String,
private val livingRoom: LivingRoom = LivingRoom(10L)
) {
inner class LivingRoom( // 내부 클래스에 inner 키워드 추가
private val area: Long
) {
val address: String
get() = this@House.address // 외부 클래스의 값을 참조할 땐 this@외부클래스명.필드명 으로 가져옴
}
}
3. 다양한 클래스
Data Class
먼저 다음과 같은 자바의 Dto 클래스가 있다고 해보자.
public class PersonDto {
private final String name;
private final int age;
public PersonDto(String name, int age) {
this.name = name;
this.age = age;
}
}
DTO(Data Transfer Object)는 계층 간 (persistence 계층 <-> application 계층) 데이터를 전달하기 위한 객체이다. DTO에는 보통 데이터(필드), 생성자와 getter, equals, hashCode, toString 과 같은 구성 요소들이 있다. 보통 실무의 자바 코드에서는 lombok과 같은 라이브러리를 활용하여 위의 구성 요소들을 추가하지만, 클래스가 내용이 많아지고 클래스 생성 이후 추가적인 처리를 해줘야 한다는 단점이 있다.
코틀린에서는 DTO 클래스를 다음과 같이 만들 수 있다.
data class PersonDto(
private val name: String,
private val age: Int
)
코틀린에서 data class 로 생성하면 equals, hashCode, toString 메서드들을 자동으로 만들어준다. 또한 앞전의 포스팅에서 언급한 Named Argument를 활용하면 빌더처럼 사용할 수도 있게 된다.
Enum Class
우선 자바로 Enum Class 예제를 작성해보도록 하자.
public enum Grade {
S(100),
A(90),
B(80);
private final int score;
Grade(int score) {
this.score = score;
}
public int getScore() {
return score;
}
}
Enum 클래스의 특징으로는 추가적인 클래스를 상속받을 수 없으며, 인터페이스는 구현 가능하고, 각 코드가 싱글톤이라는 점이 있다.
그럼 위 Enum 클래스를 코틀린으로 변환해보겠다.
enum class Grade(
private val score: Int
) {
S(100),
A(90),
B(80)
;
}
Enum 클래스는 when 구문과 함께 가장 많이 사용하게 되는데, 먼저 아래와 같은 자바 코드를 살펴보자.
private static void handleGrade(Grade grade) {
if (grade == Grade.S) {
// 로직 처리
}
if (grade == Grade.A) {
// 로직 처리
}
if (grade == Grade.B) {
// 로직 처리
}
}
위 코드의 문제점은 만약 Grade Enum에 코드가 추가되면, 추가된 코드에 대해 처리해줘야 하지만 그런 부분에 대한 경고가 발생하지 않아 추적하기 어렵고, else 로직 처리에 대해서 Grade에 있는 코드에 대해 모두 처리해주었지만 안써도 써도 애매한 상황이 발생한다.
그럼 코틀린에서 when을 사용하여 위와 동일한 동작을 하는 코드를 작성해보도록 하겠다.
private fun handleGrade(grade: Grade) {
when (grade) {
Grade.S -> // TODO()
Grade.A -> // TODO()
Grade.B -> // TODO()
}
}
Enum 클래스는 컴파일 시점에 Enum 클래스 안에 어떤 코드들이 있는지 알수 있다. 그래서 when에서 Enum 인스턴스를 값으로 받게 되면 알아서 Enum에 있는 코드들을 파악해서 따로 else 로직을 작성해주지 않아도 되며, 만약 Enum 클래스에 새로운 코드가 추가되면 Warn 경고를 통해 안내해준다. 이 경고는 IDE 설정으로 Error 로 알려주도록 설정해줄 수도 있다.
Sealed Class, Sealed Interface
Sealed Class는 상속이 가능하도록 추상 클래스를 만들려 하는데, 외부에서는 해당 클래스를 상속받지 않도록 하고 싶을 때 사용하기 위한 클래스이다.
즉 상속이 가능하도록 계층 구조는 만들고 싶은데, 외부에서는 상속받지 못하게 하고자 하위 클래스를 봉인하는 개념이다.
Sealed Class는 컴파일 시 하위 클래스의 타입을 모두 기억하여 런타임 때 클래스 타입이 추가될 수 없다. 그리고 하위 클래스는 Sealed Class와 같은 패키지에 있어야 한다.
Enum 클래스와의 차이점으로는 클래스를 상속받을 수 있다는 점, 그리고 하위 클래스는 여러 인스턴스로 생성 가능하다는 점, 즉 싱글톤이 아니라는 점이다.
그럼 코틀린에서 Sealed Class 를 살펴보자.
sealed class Animal(
val species: String,
val legCount: Int
)
// 같은 파일도 같은 패키지로 판단
class Dog : Animal("강아지", 4) // 뒤 포스팅에서 상속에 대해 다루겠지만 상속, 구현을 할 때는 클래스명 띄어쓰기 : 띄어쓰기 상위 클래스
class Cat : Animal("고양이", 4)
class Parrot : Animal("앵무새", 2)
생성할 때는 class 앞에 sealed 키워드를 붙여서 생성하면 된다.
앞에서 설명한 것 처럼 Sealed Class는 컴파일 때 하위 클래스의 타입을 기억해서 런타임 때 하위 클래스 타입이 추가될 수 없다고 했다. 이는 Enum과 같은 특징으로, 위에서 Enum과 같이 when 구문과 같이 쓰일 때 용이하다.
private fun handleAnimal(animal: Animal) {
when(animal) {
is Dog -> // TODO()
is Cat -> // TODO()
is Parrot -> // TODO()
}
}
코틀린에서 is 타입
으로 자바의 instanceof
처럼 처리해줄 수 있는데, 이처럼 when을 사용하여 분기처리를 해주면 추후 Sealed Class의 하위 클래스가 추가되거나 제거될 때 Warn 경고로 알려준다.
실무에서도 추상화가 필요한 Entity 또는 DTO에 Sealed Class를 활용하기도 한다.