[코틀린] 코틀린 기초

코틀린이란

코틀린이란 JVM 기반 언어이다. 자바와 동일하게 JVM이 구동 가능한 모든 플랫폼에서 코틀린으로 작성된 프로그램 실행이 가능하다. 자바와 코틀린은 컴파일 시 바이트코드(bytecode)(클래스 파일)로 번역되며 바이트코드는 CPU가 아닌 JVM 가상 머신에서 실행된다. 자바와 코틀린은 정적 타입 언어이므로 컴파일 시점에 타입 관련 안전성 검사가 수행된다.

코틀린은 자바와 통합할 수 있다. 한 프로젝트 내에서 자바와 코틀린을 혼합해 사용할 수 있으며 하나의 별도의 모듈로 빌드되어 배포될 수도 있다. 이는 스칼라와의 가장 큰 차이점이기도 하다. 스칼라의 경우 자바와 스칼라 각각 프로젝트를 구성한 후 빌드해야 한다. 코틀린을 사용하더라도 기존 자바 라이브러리를 그대로 사용할 수도 있다.

코틀린은 함수형 프로그래밍에 친화적이다. 함수형 프로그램의 특성과 기법을 적용할 수 있어 더 안전한 프로그램 작성이 가능하다. 예로 코틀린은 기존 제어 구조를 대체할 수 있는 함수형 추상화를 제공한다.


자바의 하위 호환성으로 인한 한계

자바의 경우 이전 버전의 자바로 컴파일된 프로그램이 새로운 버전의 실행 환경에서도 실행되어야 하는 하위 호환성(backward compatibility)으로 인해 버전이 업데이트되더라도 기능 제공에 한계가 있다.


자바 vs 코틀린

  • 불변 및 가변 참조: 자바와 코틀린 모두 불변 및 가변 객체의 참조가 가능하다.
  • 보일러플레이트 코드 생성: 코틀린에서는 델리게이션 패턴 적용, getter/setter 접근자 메서드 정의, 메서드 오버로딩, equals()/ hashCode(), toString() 메서드 정의 등의 기능을 자동으로 적용하여 코드양을 최소화할 수 있다. 자바에서는 Lombok 라이브러리가 제공하는 다양한 어노테이션을 사용하여 이러한 보일러플레이트 코드 생성이 가능하다.
  • 필드 및 프로퍼티 선언 및 접근: 자바에서는 클래스의 인스턴스 필드 멤버에 접근하기 위해 getter/setter 메서드를 별도로 정의하여 사용한다. 코틀린에서는 프로퍼티를 선언하면 코틀린 컴파일러가 비공개 필드와 getter 메서드를 자동으로 생성해 준다.
  • 널 처리: 자바에서는 모든 객체가 널 값이 될 수 있는 반면 코틀린에서는 널 값이 될 수도, 널 값이 되지 않게 만들 수도 있다. 즉, 코틀린에서는 널 참조를 허용할 수도, 금지할 수도 있다. 자바에서는 널 참조에 의한 NPE 발생을 항상 경계하며 코드를 작성해야 하지만(Optional은 이러한 한계 때문에 도입되었다) 코틀린에서는 여러 연산자들을 사용하여 NPE 런타임 예외 발생을 막을 수 있을 뿐만 아니라 코틀린 컴파일러는 널 값을 가질 수 없는 변수에 널 값이 할당될 수 있는 코드에 대해 컴파일 시간에 잠재적 에러를 발생시키는 등 널 처리를 보다 안전하게 수행할 수 있도록 해준다. 자바에서는 메서드 호출 시 널 값을 전달하는 경우 메서드 본문에서 널 검사 관련 처리를 해야하는 반면 코틀린에서는 함수 정의 시 파라미터를 널이 될 수 없는 변수로 지정하면 컴파일러가 함수 본문의 이전에 널 검사를 수행한다. 따라서 널을 함수에 전달하는 경우 메서드 본문의 코드가 실행되기 전에 널 검사를 수행할 수 있다. 이러한 방어적인 널 검사로 인해 코틀린에서는 예기치 않은 널 참조 예외가 발생하는 가장 가까운 시점을 파악하기 쉽다.
  • 메서드와 데이터 선언: 자바에서 메서드와 데이터는 클래스 내에 선언되어야 하지만, 코틀린에서는 최상위에(클래스 밖에) 함수와 데이터를 선언할 수 있다. 이 함수를 최상위 함수라고 한다. 즉, 코틀린에서 함수와 데이터는 그 자체로 독립적인 1급(first class) 기능이다.
  • 메서드(함수)의 파라미터 수정: 자바에서는 메서드의 파라미터를 수정할 수 있는 반면 코틀린에서는 함수의 파라미터를 수정할 수 없다. 즉, 코틀린에서 함수의 파라미터는 불변(immutable)이다링크.
  • 함수형 프로그래밍: 자바에서 메서드는 1급의 특성을 만족하지 않으므로 메서드 자체를 변수에 할당하거나 함수의 인자로 전달 및 함수의 반환값으로 반환할 수 없다. 반면 코틀린의 함수는 1급이다. 자바에서는 메서드 대신 람다 표현식(lambda expression)과 메서드 참조(method reference)를 사용하여 코드를 메서드의 인자로 전달함으로써 함수형 프로그래밍을 구현할 수 있다.
  • 정적 메서드: 자바에서는 모든 인스턴스들이 서로 공유하기 위한 정적 메서드를 클래스 내에서 정의해야 하지만, 코틀린에서는 클래스 밖에 최상위 함수를 정의할 수 있다. 서로 다른 인스턴스들이 서로 공유하여 사용하고자 하는 정적 함수를 최상위 함수로 정의하여 사용하면 된다.
  • 빈 객체와 값 객체: 자바에서는 가변(mutable) 상태의 자바빈(JavaBean)을 정의하기 위해 클래스에 인자가 없는 기본 생성자와 프로퍼티를 읽기 위한 getter, 가변 프로퍼티를 변경하기 위한 setter를 정의하여 사용한다. 자바 객체를 사용하는 기본 패턴은 getter와 setter를 통해 프로퍼티를 표현하는 가변 객체를 사용하는 것이다. 데이터를 표현하기 위해 사용하면서 변하지 않는 값을 가지는 데이터 구조인 값 객체(또는 값 타입)가 필요한 경우 자바빈이나 POJO 객체를 불변(immutable) 객체로 만들 수 있다. 코틀린에서는 가변 객체의 프로퍼티 정의 시 var 키워드를 사용하며 var 프로퍼티를 갖는 객체는 가변 객체가 된다. 불변 객체를 생성하기 위해서 클래스 정의 시 주 생성자에 var 대신 val 키워드를 사용하여 모든 프로퍼티를 정의하고 필요 시 해당 클래스를 데이터 클래스로 정의하면 된다.
  • 타입 추론: 자바 10 버전 이전에서는 변수 선언 시 데이터 타입을 반드시 명시해야 하며 그렇지 않을 경우 컴파일 에러가 발생하는 엄격한 타임 검사가 수행된다. 자바 10 버전 이후부터 타입 추론 기능이 추가되어 변수 선언 시 타입을 명시하지 않더라도 컴파일러가 타입을 추론한다. 코틀린은 기본적으로 타입 추론 기능을 제공한다.


코틀린 빌드

그래들(Gradle) 빌드 도구에서 제공하는 코틀린 플러그인은 그래들의 코틀린 빌드를 지원한다. 따라서 기존 자바 프로젝트를 빌드하는 그래들 설정에 코틀린 플러그인(빌드 플러그인)만 추가하면 코틀린 빌드가 가능하다.

코틀린 빌드 플러그인은 src/main/kotlinsrc/test/kotlin이라는 소스 루트(소스 셋)를 기존 구조에 추가하고 두 루트 경로의 하위 디렉터리에 있는 코틀린 소스 코드를 컴파일한다. 또한 자바 소스 트리인 src/main/java, src/test/java 디렉터리와 그 하위 디렉터리에 존재하는 코틀린 소스도 컴파일해 준다. 따라서 반드시 자바와 코틀린 코드를 별도의 소스 트리로 관리할 필요는 없다.


변수 선언

val 키워드로 선언한 변수는 값이 한 번 설정(할당)되고 나면 변경될 수 없다. 해당 변수는 읽기 전용(read-only) 변수이다. val 변수를 모든 프로퍼티로 갖는 객체는 불변 객체가 된다.

var 키워드로 선언한 변수는 값이 한 번 설정되고 나서도 변경될 수 있다. 해당 변수는 가변(mutable) 변수이다. var 변수를 프로퍼티로 갖는 객체는 가변 객체가 된다.


표현식, 조건문, 반복문


레이블

코틀린에서 모든 표현식은 레이블(label)로 표시가 가능하다. 레이블은 abc@, @abc와 같이 식별자 앞뒤에 @ 기호를 붙이는 형식이다. 표현식에 레이블을 지정하기 위해서는 표현식 앞뒤에 레이블을 추가하기만 하면 된다.

식별자@ 표현식
...
표현식@식별자


예를 들어, 반복문에 레이블을 지정하면 다음과 같다.

식별자@ for(i in 1..10) {
  ...
}


레이블에는 명시적(explicit) 레이블과 암시적(implicit) 레이블이 있다. 명시적 레이블은 식별자를 abc@와 같이 지정하는 것이다. 레이블의 식별자가 표현식이 전달되는 함수의 이름과 동일한 경우 식별자를 생략할 수 있으며 이러한 레이블을 암시적 레이블이라고 한다.

코틀린에서는 레이블을 반복문이나 함수에 지정하고 해당 블록 내의 return, break, continue 키워드에 지정함으로써 코드 실행 흐름을 보다 쉽게 제어할 수 있다.


if 표현식

if 키워드를 사용하여 여러 분기로 조건 구문/표현식을 정의할 수 있다.

if는 표현식일 경우 값을 반환한다. 따라서 if 조건문을 변수에 할당할 수 있다.

var 변수명 = if(조건) 구문/표현식


조건 ? 표현식1 : 표현식2과 같은 삼항 연산은 존재하지 않으며 if 표현식으로 대신한다.

var 변수명 = if(조건) 구문/표현식1 else 구문/표현식2


if 표현식에 else if 표현식을 사용할 수도 있다.

var 변수명 = if(조건1) 구문/표현식1 else if(조건2) 구문/표현식2 else 구문/표현식3


if 표현식의 분기는 블록이 될 수 있다. 블록은 구문/표현식으로 이루어져 있으며 마지막 표현식은 블록의 값이 된다. if 표현식을 조건에 따라 값을 반환하고 변수에 할당하기 위해 사용한다면 else 분기가 반드시 필요하다.

var 변수명 = if(조건) {
  구문/표현식1
  구문/표현식2
} else {
  구문/표현식3
  구문/표현식4
}


if가 구문일 경우 단순히 실행 흐름을 분기 처리하고 각각의 분기 블록 내에서 구문을 실행하는 역할을 한다. 이때 블록 내 정의한 값은 무시된다.


when 표현식

when 키워드를 사용하여 여러 분기로 조건 구문/표현식을 정의할 수 있다. whenswitch 구문과 비슷하다.

when(인자값) {
  비교대상값1 -> 구문/표현식1
  비교대상값2 -> 구문/표현식2
  else -> {
    구문/표현식3
  }
}


if와 마찬가지로 각 분기는 블록이 될 수 있으며 분기의 값은 블록의 마지막 구문/표현식이 반환하는 값이다.

when 블록 내에 정의하는 각각의 라인과 else는 인자값에 대한 비교 조건 분기이다. when 표현식에 전달된 인자값이 비교 대상값과 일치할 때까지 비교한 후 인자값이 비교 대상값과 일치하였을 때 해당 분기에 대한 구문/표현식이 실행된다. 모든 분기 조건을 만족하지 않는 경우 else 분기의 블록이 실행된다.

when은 구문 또는 표현식으로 사용될 수 있다. 구문으로 사용하는 경우 각각의 분기에 대한 구문/표현식이 반환하는 값은 무시된다. 표현식으로 사용되는 경우 조건을 만족하는 분기에 대한 구문/표현식이 반환하는 값이 when 표현식의 최종값이 된다.

when을 표현식으로 사용하는 경우 else 분기는 필수이지만 Boolean 타입, enum 클래스의 항목, sealed 클래스의 서브타입, 널이 될 수 있는 타입과 같이 컴파일 시 모든 가능한 경우의 수 및 종류가 결정되고 조건 구문 또는 표현식 실행 시 모든 경우에 대한 분기 처리가 가능하도록 when 블록을 정의한 경우 else 분기를 생략할 수도 있다. 반면 when 블록에 모든 경우에 대한 분기 처리가 되어 있지 않다면 else 분기는 필수이다.

여러 경우에 대해 하나의 분기로 처리하기 위해서는 해당 비교 대상값들을 ,로 결합하고 한 줄에 작성할 수 있다.

when(인자값) {
  비교대상값1, 비교대상값2 -> 구문/표현식1
  else -> {
    구문/표현식2
  }
}


비교 대상값 대신 상수, 표현식도 사용할 수 있다.

in 또는 !in을 사용하여 범위 또는 컬렉션에 값이 포함되어 있는지, 그렇지 않은지 확인도 가능하다.

when(변수명) {
  in 컬렉션 -> 구문/표현식
}


is 또는 !is를 사용하여 값이 특정 타입인지, 그렇지 않은지 확인도 가능하다. 스마트 타입 변환(캐스팅) 기능을 통해 추가적인 확인 없이 해당 타입의 메서드 및 속성에 접근할 수 있다.

when(변수명) {
  is 타입 -> 변수명.멤버
}


if, else if 연결을 대체할 수도 있다. when에 인자값이 제공되지 않으면 비교 대상값은 단순히 부울 표현식이며 조건이 참일 때 해당 분기의 구문/표현식이 실행된다.

when {
  부울표현식1 -> 구문/표현식1
  부울표현식2 -> 구문/표현식2
  else -> 구문/표현식3
}


when에 인자값을 전달하는 대신 괄호 안에서 변수를 정의 및 할당하고 변수가 사용되는 스코프 설정이 가능하다. 해당 변수 영역은 when의 본문으로 제한된다.

when(var 변수명 = 구문/표현식) {
  비교대상값 -> 변수명.멤버
}


점프 구문

반복문이나 메서드를 작성하다 보면 중첩(nested) 형태로 코드를 작성할 수 있다. 대부분 프로그래밍 언어에서는 중첩된 반복문이나 메서드(함수 내 반복문이 정의된 경우도 포함된다) 내에서 즉시 값을 반환하거나, 반복문(함수)을 종료, 그 다음 반복문(함수)을 실행하기 위해 return, break, continue와 같은 키워드를 제공한다. 이러한 키워드는 특정 조건에서 프로그램 실행의 흐름을 제어하기 위해 사용하며 이를 점프 구문(jump statement) 또는 점프 표현식(jump expression)이라고 한다. 코틀린도 마찬가지로 다음 세 가지 구조적 점프 구문이 존재한다.

  1. return: 값을 반환한다.
  2. break: 반복문을 종료한다.
  3. continue: 반복문을 계속 진행한다.


하지만 이러한 구문은 기본적으로 가장 가까운 블록(구문이 위치한 해당 블록)에 대해서만 그 기능을 수행한다. 내부 블록과 외부 블록으로 이루어진 중첩 형태에서 내부 블록에 존재하는 구문은 내부 블록에 대해서, 외부 블록에 존재하는 구문은 외부 블록에 대해서 동작한다. 따라서, 세 구문은 다시 다음과 같이 정의할 수 있다.

  1. return: 가장 가까운 함수(또는 익명 함수)가 값을 반환한다.
  2. break: 가장 가까운 반복문을 종료한다.
  3. continue: 가장 가까운 반복문의 다음 단계로 진행한다.


위 세 점프 구문은 자신이 위치한 가장 가까운 블록에 대해서만 적용되기 때문에 내부 블록에 존재하는 구문이 외부 블록을 대상으로도 동작하도록 하고 싶을 경우 외부 블록에도 그대로 구문을 작성해야 한다. 즉, 구문을 내부 블록과 외부 블록에 두 번 작성해야 한다. 코틀린에서는 레이블을 사용하여 이러한 코드를 개선할 수 있다.

위 세 가지 구문에 레이블을 지정할 수 있다. 레이블을 지정한 해당 구문을 한정된(qualifed)(또는 제한된) 구문이라고 표현한다.

중첩 반복문에서 외부 반복문에 레이블을 지정하고 내부 반복문의 블록 내의 break 구문에 레이블을 지정하면 내부 반복문 종료 시 외부 반복문도 종료된다. 이러한 코드는 “외부 반복문에 레이블을 지정하였고 내부 반복문의 break까지로 한정한다”고 표현한다.

loop@ for(...) {
  for(...) {
    if(조건) break@loop
  }
}


내부 반복문의 블록 내의 continue 구문에 레이블을 지정하면 내부 반복문을 계속 진행하는 것이 아니라 내부 반복문은 종료하며 외부 반복문을 계속 진행한다.

loop@ for(...) {
  for(...) {
    if(조건) continue@loop
  }
}


return 구문은 함수에서 결과를 반환하는데 사용된다. 함수 리터럴(람다 표현식과 익명 함수), 로컬 함수, 범위 함수, 객체 표현식을 사용하여 함수를 중첩시킬 수 있다. 중첩 함수 내에서 return에 레이블을 지정하면 외부 함수로부터 값을 반환할 수 있다. 예를 들어, 람다 표현식으로부터 값을 반환받고자 하는 경우 레이블을 사용하면 된다.

함수에 레이블을 적용하고 return으로 한정하기 위해서는 중첩 함수 정의가 필요하다. 함수 내 반복문을 정의하였고 반복문 내에서 함수를 정의하고, 범위 함수 내에서 return을 사용하여 범위 함수를 종료한 후 값을 반환하는 코드의 예는 다음과 같다.

fun 함수명() { // 외부 함수
  for(...) { // 반복문
    run { // 내부 함수
      if(조건) return // 로컬 반환이 아니다. 
      구문/표현식1
    }
  }
  구문/표현식2
}


이 경우 외부 함수의 로컬에서 반환하는 것이 아니지만 외부 함수에서 반환하는 것이 되어 내부 함수 및 외부 함수 모두 종료되며 코드 실행 흐름은 외부 함수를 호출한 호출자에게 곧바로 넘어가게 된다. 따라서 반복문 실행 도중 조건을 만족하게 되면 그 다음 반복문 실행이 종료되고 내부 함수와 외부 함수 모두 종료되므로 이후 반복문 내 내부 함수의 구문/표현식1과 외부 함수의 구문/표현식2는 실행되지 않고 함수 실행이 곧바로 종료된다.

동일한 코드에서 레이블을 사용하면 다음과 같다.

fun 함수명() { // 외부 함수
  for(...) {  // 반복문
    run loop@ { // 내부 함수
      if(조건) return@loop // 로컬 반환이다.
      구문/표현식1
    }
  }
  구문/표현식2
}


이 경우에는 레이블에 의해 내부 함수에서 반환하는 것이 되어 내부 함수만 종료되며 외부 함수는 종료되지 않는다. 따라서 코드 실행 흐름은 내부 함수를 호출한 호출자에게 넘어가게 된다. 반복문 실행 도중 조건을 만족하게 되면 해당 조건에서 반복문이 종료되지만 그 다음 반복문이 실행되므로 반복문 내 내부 함수의 구문/표현식1이 계속 실행되며 반복문이 모두 실행된 후 외부 함수의 구문/표현식2까지 실행된다.

람다식을 사용하는 경우의 예는 다음과 같다.

fun 함수명() { // 외부 함수
  리스트.forEach { // 내부 람다식
    if(조건) return // 로컬 반환이 아니다.
    구문/표현식1
  }
  구문/표현식2
}


위 코드는 마찬가지로 외부 함수에서 반환하는 것이다. 내부 람다 표현식에서 반환하기 위해서는 람다 표현식에 레이블을 지정하고 레이블을 return까지로 한정지어야 한다.

fun 함수명() { // 외부 함수
  리스트.forEach loop@ { // 내부 람다식
    if(조건) return@loop // 로컬 반환이다.
    구문/표현식1
  }
  구문/표현식2
}


이 경우에는 내부 람다 표현식에서만 반환하는 것이므로 코드 실행 흐름은 람다 표현식을 호출한 호출자(외부 함수 내 코드 호출부)에게 넘어가게 된다. 따라서 구문/표현식1이 계속 실행되며 반복문이 모두 실행된 후 외부 함수의 구문/표현식2까지 실행된다.

다음과 같이 람다식 대신 익명 함수를 사용할 수도 있다. 익명 함수의 return 구문은 익명 함수 자체에서 반환한다.

fun 함수명() { // 외부 함수
  리스트.forEach(fun(인자값) { // 내부 익명 함수
    if(조건) return@loop // 로컬 반환이다.
    구문/표현식1
  })
  구문/표현식2
}


표현식에 레이블을 지정하고 return으로 한정짓는 것은 반복문에서 continue를 사용하는 것과 실행 흐름 제어 방식이 유사하다. 반복문에서 break를 사용하는 것과 동일한 실행 흐름 제어를 중첩 함수의 내부 함수에서 하기 위해서는 중첩으로 람다 표현식을 하나 더 추가한 후 레이블을 설정하고 로컬이 아닌 반환이 되도록 하여 조건 만족 시 반복문이 종료되도록 하면 된다. 즉, 다음 코드는 동일하게 동작한다.

fun 함수명() {
  for(i in 리스트) {
    if(조건) break
    구문/표현식1
  }
  구문/표현식2
}

fun 함수명() {
  run loop@{
    리스트.forEach {
      if(조건) return@loop // 로컬 반환이 아니다.
      구문/표현식1
    }
  }
  구문/표현식2
}


클래스

클래스 정의 시 클래스 헤더에는 클래스 이름, 주 생성자, 상위 클래스, 인터페이스 정의가 가능하다. 상위 클래스 뒤에 괄호를 붙여서 상위 클래스 생성자를 호출할 수도 있다.

클래스 본문에는 프로퍼티와 생성자를 추가로 정의할 수 있고, 메서드와 동반 객체(companion object)를 정의할 수 있다.

클래스 이름 뒤에 파라미터를 선언함으로써 생성자를 선언한다. 파라미터를 val로 선언하면 해당 변수는 클래스의 프로퍼티가 된다.

생성자 정의 시 생성자 파라미터를 한 줄에 하나씩 배치하면 클래스의 가독성을 높일 수 있다.

생성자를 private로 선언하려면 다음과 같이 private constructor 키워드를 사용한다.

class 클래스명 private constructor(...) {...}


프로퍼티(속성) 및 변수

늦은 초기화

널 값을 허용하지 않는 타입의 프로퍼티는 생성자 내에서 초기화되어야 한다. 그러나 의존 객체의 생성자 주입이나 단위 테스트에서의 초기(setup) 구성 메서드 내 인스턴스화와 같이 프로퍼티를 생성자에서 초기화할 필요가 없는 경우가 있다. 이 경우 lateinit 수정자(modifier)를 사용하면 널이 될 수 없는 타입의 프로퍼티를 생성자 밖에 선언할 수 있으며 해당 프로퍼티는 생성자 내에서 초기화하지 않아도 된다.

lateinit 수정자를 프로퍼티에 적용하기 위해서는 다음과 같은 제약이 있다.

  • 주 생성자가 아닌 클래스 내 필드 프로퍼티에 사용할 수 있다.
  • 최상위 프로퍼티 및 로컬 변수에 사용할 수 있다.
  • var 키워드로 선언한 프로퍼티에 사용할 수 있다.
  • 프로퍼티에 대해 사용자화된 getter와 setter 정의가 없어야 한다.
  • 프로퍼티 또는 변수의 타입은 널이 될 수 없는 타입이고 원시 타입이 아니어야 한다.


lateinit 프로퍼티는 널이 될 수 없는 타입이므로 초기화 전에 프로퍼티에 접근 시 널 역참조 예외가 발생하지는 않지만 프로퍼티가 초기화되기 전에 접근하였다는 UninitializedPropertyAccessException 예외가 발생한다. 컴파일러는 lateinit 프로퍼티의 초기화 여부를 검사하지 않으므로 런타임 시 해당 예외가 발생 가능하다. 이 예외를 막기 위해 코드 작성 시 isInitialized 프로퍼티를 사용하여 해당 프로퍼티의 초기화 여부를 먼저 확인할 수 있다.


싱글턴 객체, 정적 멤버, 동반 객체, 최상위 함수

인스턴스가 아닌 클래스에 속한 정적 멤버(상수, 프로퍼티, 함수)를 선언하기 위해 object 키워드를 사용한다. object 선언은 싱글턴(singleton)을 정의한다. 싱글턴은 인스턴스가 단 하나뿐이며, 이 인스턴스에 접근할 수 있는 전역 영역(global scope)에 단 하나만 존재한다. 코틀린은 싱글턴 객체가 다른 클래스를 확장하거나 인터페이스를 구현하도록 허용한다. object 선언 시 클래스 영역의 멤버를 선언하여 싱글턴을 정의할 수 있다. object의 모든 멤버는 해당 객체의 이름과 동일한 이름의 클래스의 멤버가 된다. 이때 멤버에 @JvmStatic을 적용하면 정적 멤버로 컴파일된다.

정적 멤버와 비정적 멤버를 하나의 클래스 내에 정의하거나 팩토리 함수를 정의해야 하는 경우 클래스 내에서 정적인 멤버 선언만 companion object 선언 안에 위치시키면 된다. 이를 동반 객체(companion object)라고 한다.

동반 객체 내 멤버 선언은 해당 선언이 속한 클래스 파일 안에서 한 그룹으로 묶인다. 동반 객체의 멤버는 동반 객체가 속한 클래스의 인스턴스의 비공개 멤버에도 접근할 수 있다. 동반 객체도 object 선언을 통해 생성되는 싱글턴 객체와 마찬가지로 다른 클래스를 확장하거나 인터페이스를 구현할 수 있다.

서로 다른 인스턴스 간 공유할 정적 함수가 필요하다면 동반 객체를 사용하면 된다. 동반 객체는 비정적 클래스에 정적 멤버를 포함시키기 위해 사용한다. 동반 객체와 @JvmStatic 애너테이션을 사용하면 기존 자바의 클래스를 코틀린으로 바꿔도 정적 함수 호출 코드를 변경할 필요가 없다. 이를 코틀린 지원 기능을 사용하여 리팩토링하려면 최상위 함수를 사용하면 된다. 코틀린에서는 자바와 달리 정적 함수를 클래스에 정의하는 대신 최상위에 정의할 수 있다.

코틀린에서는 멤버(상수, 프로퍼티, 함수)를 클래스 밖에 선언할 수 있다. 하지만 JVM에서는 최상위 데이터나 함수를 표현할 방법이 없다. JVM은 최상위 함수가 아닌 정적 함수만 지원한다. 따라서 코틀린 컴파일러는 파일 내 최상위 멤버 선언이 존재하는 경우 정적 멤버가 정의되어 있는 클래스를 생성해 준다.

코틀린 컴파일러는 함수 정의가 들어있는 파일명으로부터 클래스를 만든다. 예를 들어, file-name.kt 내에 정의된 최상위 함수들은 File_NameKt라는 클래스 안의 정적 함수가 된다. 클래스 이름을 사용하여 자바에서 정적으로 클래스의 멤버를 참조 또는 호출할 수 있다. @file:JvmName 어노테이션을 파일 맨 앞에 추가해서 정적 클래스의 이름을 직접 지정할 수도 있다. @file:로 시작하는 어노테이션은 packageimport 문 보다도 더 앞에 써야 한다. @file:JvmName 어노테이션을 수동으로 추가하면 object 영역과 @JvmStatic 어노테이션을 없앨 수 있다.

최상위 함수는 import 패키지명.정적클래스명.함수명과 같은 방식으로 불러올 수 없다. 코틀린의 입장에서 이 함수들은 패키지 영역에 정의되어 있으며 패키지 내의 클래스에 정의되어 있지 않다. 컴파일러는 컴파일 시점에 코틀린 코드 안에서 클래스 내에 정의된 정적 함수로 컴파일할지에 대한 정보를 알 수는 없기 때문이다.


객체 표현식

객체 표현식(object expression)을 사용하여 익명 클래스(anonymous class)의 객체를 만들 수 있다. 이 경우 class 키워드를 통해 명시적으로 클래스를 선언하는 대신 object 키워드를 사용한다. 객체 표현식으로 생성한 클래스는 일회성이다. 클래스의 이름으로 클래스를 정의하는 것이 아닌 표현식으로 클래스를 정의하는 것이므로 객체 표현식이라고 한다. 객체 표현식으로 생성한 익명 클래스의 인스턴스를 익명 객체(anonymous object)라고 한다.

object {
  val 변수명1: 타입
  val 변수명2: 타입
  
  fun 함수명(): 반환타입 {
    ...
  }
}


sealed

sealed 클래스 및 인터페이스는 클래스 계층 구조의 상속을 제어하는 기능을 제공한다. 코틀린 컴파일러는 sealed 클래스의 모든 직접 하위 클래스(direct subclass)를 컴파일 시점에 알 수 있다. 직접적인 하위 클래스는 상위 클래스로부터 바로 상속하는 클래스를 의미한다. 반면 간접 하위 클래스(indirect subclass)는 상위 클래스로부터 한 단계 이상 아래에서 상속하는 클래스이다. 직접적인 하위 클래스 외에 다른 하위 클래스는 sealed 클래스가 정의된 모듈과 패키지 외부에 나타날 수 없다. sealed 인터페이스와 그 구현에도 동일한 로직이 적용된다. sealed 인터페이스가 있는 모듈이 컴파일되면 해당 인터페이스에 대한 구현을 만들 수 없다. sealed 클래스는 추상 클래스이므로 직접 인스턴스를 생성할 수는 없다. sealed 클래스를 사용하면 클래스 계층구조를 수정하고 새로운 하위 클래스를 만들지 못하도록 강제할 수 있다.

sealed 클래스는 클래스 계층 구조를 제한하거나 제어할 때 사용된다. 어떤 클래스가 특정 하위 클래스만 갖고 다른 하위 클래스는 없도록 하는 매우 엄격한 상속 계층 구조를 만들 수 있다.

sealed 클래스는 상속이 가능한 클래스이지만 이 클래스 자체를 상속할 수 있는 직접 하위 클래스는 같은 패키지 내에 정의되어야 한다. 최상위 수준일 수도 있고 다른 여러 명명된 클래스 명명된 인터페이스 또는 명명된 개체 안에 중첩될 수 있지만 로컬 또는 익명 객체가 될 수는 없다. 하위 클래스는 코틀린의 일반적인 상속 규칙과 호환되는 한 어떠한 가시성(visibility)도 가질 수 있다.

sealed 클래스의 사용 예로 에러와 관련된 타입을 정의하고 타입에 따른 특정 구현을 강제하는 것이 있다. 다음 코드는 발생 가능한 에러 타입을 위한 sealed 인터페이스를 정의하고 세부적인 에러에 대한 구현 클래스를 정의한 것이다.

// sealed 인터페이스
sealed interface Error

// sealed 클래스
sealed class IOError(): Error

// sealed 클래스를 상속하는 하위 클래스
class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

// sealed 인터페이스를 구현하는 싱글턴
object RuntimeError: Error


단순히 인터페이스나 추상 클래스로 정의한다면 라이브러리 클라이언트 코드를 사용하는 개발자는 해당 타입을 구현하거나 확장하는데 아무런 제한이 없다. 라이브러리는 외부에 선언된 에러 타입에 대해 알지 못하므로 라이브러리는 상위 에러 타입의 구현체 또는 하위 클래스에 대해서는 일관성 있게 처리할 수 없다. 그러나 sealed 키워드를 사용하여 에러 인터페이스 또는 클래스의 계층 구조를 작성하고 패키지 바깥에서의 하위 클래스 또는 구현체 정의를 제한하면 라이브러리 개발자는 의도된 대로 상위 에러 타입의 하위 클래스 또는 구현체가 무엇인지 명확히 알 수 있다. 이로 인해 라이브러리 개발자는 에러 타입에 대한 의도되고 명확한 처리를 할 수 있으며 클라이언트의 코드 구현에 영향을 받지 않는다.

sealed 클래스와 인터페이스를 when 표현식과 결합하여 가능한 모든 하위 클래스의 동작을 분류 및 정의하고 새로운 하위 클래스가 생성되지 않도록 강제할 수 있다. 이때 else 구문은 필요하지 않다. 컴파일러는 모든 하위 클래스가 when 표현식에서 처리되었는지 확인할 수 있으므로 안전한 코드 작성이 가능하다. 모든 하위 클래스를 대상으로 분기 처리를 하지 않으면 컴파일 오류가 발생한다. 열거 타입을 사용할 때에도 유사한 코드 작성이 가능하다.

sealed class SuperClass {
    object SubClassA: SuperClass()
    object SubClassB: SuperClass()
    object SubClassC: SuperClass()
}

...
fun handle(sub: SuperClass) {
  when (sub) {
    is SuperClass.SubClassA -> ...
    is SuperClass.SubClassB -> ...
    is SuperClass.SubClassC -> ...
  }
}


델리게이트

객체 지향 프로그래밍에서 델리게이션(delegatation, 위임) 패턴이란 객체 합성(composition)을 통해 클래스를 재사용하는 디자인 패턴이다. 구현 상속(implementation inheritance)을 통한 재사용을 대체할 수 있다. 기존 클래스를 확장하거나 변경해야 하지만 상속할 수 없는 경우에도 델리게이션 패턴을 사용하여 코드를 재사용할 수 있다. 델리게이션 패턴은 구현 상속의 좋은 대안이며 코틀린은 언어 수준에서 델리게이션 패턴 구현을 지원하여 관련된 보일러플레이트 코드를 제거하도록 도와준다.

델리게이션 패턴에서 요청을 처리하는데 다음 두 개의 객체가 관여한다.

  • 요청 수신 객체 (위임하는 객체): 요청을 수신하여 델리게이트에 위임한다.
  • 델리게이트 (delegate)(위임을 받는 객체): 위임 받은 요청을 처리한다. 델리게이트는 헬퍼(helper) 객체이다.


상속을 사용하여 클래스를 재사용하기 위해서는 상위 클래스를 직접 상속하고 상위 클래스에 정의된 함수를 호출하여 사용한다.

class SubClass: SuperClass {
  ...
}

SubClass().함수명()


델리게이션 패턴에서는 코드의 재사용을 위해 상속 대신 합성을 이용한다. 하위 클래스 타입의 멤버를 필드로 포함하고 해당 멤버들에게 메서드를 위임한다. 다음과 같이 하위 클래스의 인스턴스를 생성자의 인자로 전달 받은 후 멤버를 재정의한다.

// 델리게이트
class SubClass: SuperClass {
  override val 변수명: 타입
    get() = subClass.변수명
  
  override fun 함수명() {
    subClass.함수명()
  }
}

...

class MyClass(
  // 합성
  val subClass: Base
) {
  val 변수명: 타입
    get() = subClass.변수명
  
  fun 함수명() {
    // 위임
    subClass.함수명()
  }
}


위의 경우 SubClass에 멤버로 포함된 SuperClass 타입의 subclass는 요청을 위임 받아 처리하는 델리게이트이다. MyClass가 메서드의 구현을 델리게이트인 subclass에 위임한다.

코틀린은 by 키워드를 통해 위임 처리 기능을 제공하여 멤버 재정의와 관련된 보일러플레이트 코드를 자동으로 생성해준다. 단, 인터페이스에 대해서만 위임 처리가 가능하다. 구현하려는 상위 클래스명 뒤에 by 키워드를 추가한 후 그 뒤에 위임할 인스턴스 변수명을 작성한다. 컴파일러는 인터페이스의 모든 멤버를 생성하여 해당 인스턴스로 전달한다.

interface Base {
  fun 함수명()
}

class BaseImpl: Base {
  override fun 함수명() {
    ...
  }
}

class Derived(
  base: Base
): Base by base

...

val base = BaseImpl()
Derived(base).함수명()


Derivedby 절에 의해 컴파일러는 DerivedBase 인터페이스의 모든 멤버를 생성하여 base로 전달한다. 즉, DerivedBase 인터페이스에서 구현하려는 모든 멤버는 동일한 시그니처(프로퍼티의 경우 변수명과 타입, 메서드의 경우 메서드명, 파라미터, 반환값)로 Derived 멤버로 재정의된다.


함수

코틀린에서는 void가 아닌 Unit 키워드를 사용하여 반환값이 없는 함수를 정의한다. 함수 반환 타입이 Unit이면 return으로 값을 반환해도 되고 하지 않아도 된다. Unit은 값이 없는 것이 아니라 값이 없음을 표현하기 위해 항상 똑같은 싱글턴을 반환한다는 점이 다르다.

함수 본문에서 최상위 코드가 표현식 하나로 이뤄져 있는 경우 return을 쓰는 대신 등호 뒤에 반환할 값을 계산하는 표현식을 위치시키는 형태로 함수를 작성할 수도 있다. 이러한 함수를 단일식 함수(single-expression function)라고 한다.

fun 함수명(): 반환타입 {
  return 단일식
}

fun 함수명(): 반환타입 = 단일식


반환 타입을 생략하고 코틀린이 제공하는 타입 추론(type inference)이 등호 뒤의 식의 타입으로부터 함수의 반환 타입을 결정하게 할 수도 있다.

fun 함수명() = 단일식


인라인 함수

코틀린에서 함수는 1급 클래스(first-class)이다. 따라서 함수는 객체로 취급될 수 있으며 변수에 할당되거나 또다른 함수의 인자로 전달될 수 있다. 고차 함수(high-order function)를 사용할 경우 추가적인 메모리 할당과 가상 호출(virtual call)로 인해 런타임 오버헤드가 발생한다. 함수에 대한 변수를 캡처하는 클로저(closure)를 사용하는 경우 추가적인 메모리 할당이 발생한다.

람다 표현식과 같은 고차 함수를 사용하는 경우 컴파일된 자바 코드에는 Function으로 시작하는 객체의 인스턴스화 코드가 존재한다. 따라서 고차 함수를 선언할 때마다 특정 인스턴스가 생성되어 힙 메모리가 사용된다. 추가적으로 람다의 블록을 실행하기 위해 새 인스턴스의 invoke() 메서드가 호출되며 이러한 가상 호출로 인해 추가적인 오버헤드가 발생하게 된다.

코틀린은 함수를 호출한 결과를 변수에 할당하는 코드를 함수 블록으로 치환하는 인라인(inline) 함수 기능을 제공한다. 인라인 함수는 컴파일 타임에 함수 호출 코드를 함수 본문의 코드로 치환한다. 기본적으로 코틀린 컴파일러는 함수 자체와 함수에 인자로 전달된 람다에 대한 코드를 모두 인라인 처리한다. 이로 인해 추가적인 인스턴스 생성을 위한 메모리 할당과 가상 호출이 필요 없어지므로 함수 호출 오버헤드를 줄어든다. 함수 호출이 제거되면 함수 호출에 따른 스택 프레임 생성이 감소하고 메모리 사용이 감소하게 된다.

인라인 함수는 제네릭 타입 사용 시 발생할 수 있는 타입 소거(type erasure)로 인한 문제를 해결한다. 코틀린은 런타임에 제네릭 타입 정보를 소거하므로 제네릭 클래스의 인스턴스는 런타임에 해당 타입 파라미터를 유지하지 않는다. 반면 런타임에 타입 정보를 유지하는 것을 타입 구체화(type reification)라고 한다. 인라인 함수를 사용하면 코틀린 컴파일러는 인라인 함수에 대한 제네릭 타입 정보를 소거하는 대신 구체화할 수 있다. 제네릭 함수가 인자로 전달 받은 타입 으로 타입 정보를 조회하거나 타입 그대로 또다른 제네릭 함수의 인자로 전달하는 경우 타입 구체화가 필요하며 이 경우 인라인 함수를 사용하여 타입을 구체화하면 된다. 타입 매개변수에 reified 키워드를 사용하여 타입 파라미터를 구체화할 수 있다.

inline fun <reified T> myFunc(): {
  val type = T::class.java
}


inlinereified 키워드를 선언하지 않으면 위 함수는 컴파일되지 않는다.

인라인 함수는 컴파일 시점에 적용되므로 런타임에 동작하는 스프링 프레임워크의 AOP와 호환되지 않는다.


클로저

코틀린에서 함수는 1급 클래스(first-class)이므로 함수를 변수와 데이터 구조에 저장할 수 있으며 다른 고차 함수(higher-order function)에 인자로 전달하거나 다른 고차 함수에서 함수를 반환할 수도 있다.

내부 함수가 외부 함수에 선언된 로컬 변수에 접근하는 경우 내부 함수를 외부 함수 바깥에서 정의하도록 변경할 수는 없다. 로컬 변수의 스코프는 외부 함수 블록 내로 한정되어 있으며 외부 함수 바깥에서 이 로컬 변수에 접근할 수 없다.

fun outerFunc(): Unit {
  // 로컬 변수
  var value: Sequence<String> = ...
  
  // 로컬 변수는 외부 함수 블록 내에서만 접근 가눙하다.
  fun innerFunc(): Unit = value.forEach {
    ...
  }
}


일반적으로 외부 함수가 종료되면 모든 로컬 변수는 더 이상 필요하지 않으며 변수는 메모리 상에서 사라진다. 내부 함수가 위치에 관계 없이 외부 함수의 로컬 변수에 접근 가능하게 만들기 위해서는 클로저(closure) 개념이 필요하다. 클로저란 코드 실행이 해당 블록을 벗어난 이후에도 로컬 변수를 유지하는 영속적(persistent) 스코프를 의미한다. 클로저는 함수와 해당 함수가 생성된 환경(environment)을 결합한 특별한 종류의 객체이다. 여기서 환경이란 클로저가 생성될 때 스코프 내에 있던 모든 로컬 변수로 구성된다.

외부 함수의 로컬 변수를 사용하는 내부 함수를 정의한 후 외부 함수가 내부 함수를 반환하도록 하면 외부 함수가 종료된 후에도 로컬 변수를 지속할 수 있다. 내부 함수가 정의되는 시점에 내부 함수가 참조하는 외부 함수의 로컬 변수가 캡처 및 영속화되며 이 경우 내부 함수가 외부 함수의 로컬 변수를 클로즈 오버(close over)한다고 표현한다. 로컬 변수는 클로저 안에 있으며 클로즈 오버된다(closed over). 이때 내부 함수는 클로저이며 내부 함수가 참조하는 외부 함수의 모든 로컬 변수는 내부 함수와 연결되어 있다. 내부 함수에 대한 참조가 메모리에 지속되는 동안 로컬 변수도 메모리에 지속된다.

fun outerFunc(): () -> Unit {
  // 로컬 변수
  val value: Sequence<String> = ...

  // 로컬 변수는 외부 함수 블록 내에서만 접근 가눙하다.
  // 내부 함수를 선언한 후 변수에 할당한다.
  val innerFunc: () -> Unit = {
    value.forEach {
      ...
    }
  }
  
  // 외부 함수가 내부 함수를 반환하면 내부 함수는 클로저가 된다.
  return innerFunc
}

// 외부 함수를 호출하여 클로저를 생성한다. 로컬 변수를 캡처한 내부 함수가 반환된다.
val innerFuncVal = outerFunc()
// 내부 함수를 호출하여 
innerFuncVal()


로컬 변수는 외부 함수의 스코프에 속한다. 내부 함수의 스코프에는 외부 함수의 스코프에 대한 참조가 존재하며, 외부 함수를 호출 완료한 후에도 변수(innerFuncVal)에 저장된 내부 함수에 대한 참조를 사용하여 로컬 변수를 유지할 수 있다. 클로저는 외부 함수의 로컬 변수들을 캡처하여 외부 함수의 생명주기와 관계 없이 해당 변수들을 사용할 수 있게 한다. 클로저는 함수가 처음 정의되었을 때 스코프에 존재하던 로컬 변수를 포함하고 있으므로 완전히 다른 컨텍스트에서 함수를 호출하더라도 동일한 로컬 변수를 얻을 수 있다.

클로저인 내부 함수가 메모리에 존재하는 동안 클로저에 의해 캡처된 로컬 변수들도 메모리에 존재하게 된다. 클로저는 외부 함수의 로컬 변수들을 캡처하며 이 변수들은 클로저가 존재하는 한 메모리 상에 유지된다. 이는 클로저가 해당 변수들에 대한 참조를 유지하기 때문이다. 클로저가 활성 상태(메모리에 존재하고 참조 및 실행 가능한 상태)인 동안에는 캡처된 로컬 변수들이 가비지 컬렉션에 의해 회수되지 않으며 메모리에 유지된다.

클로저는 데이터를 안전하게 캡슐화할 수 있는 방법을 제공한다. 객체지향 프로그래밍에서 캡슐화는 주로 클래스의 멤버 변수와 메서드를 사용하여 데이터와 기능을 하나의 단위로 묶고, 이를 외부로부터 숨기는 것을 의미한다. 캡슐화를 통해 한 객체가 다른 객체의 데이터와 기능에 아무런 제약 없이 접근하고 변경하는 것을 막음으로써 객체 간 결합도와 의존성을 낮출 수 있다. 함수형 프로그래밍에서는 함수 내부에 정의된 로컬 변수와 클로저를 통해 데이터와 기능을 캡슐화할 수 있다. 로컬 변수에 데이터를, 내부 함수에는 데이터를 처리하는 기능을 정의하고 이를 클로저를 통해 캡슐화한다. 내부 로컬 변수에 접근하고 이를 사용하는 행위는 클로저에 의해 캡슐화되며 외부로부터 내부 변수에 대한 접근이 제한된다.

fun createCounter(): () -> Int {
  // 로컬 변수
  var count = 0

  val increment: () -> Int = {
    // 로컬 변수를 변경한다.
    count++
    // 변경한 로컬 변수를 반환한다.
    count
  }

  // 내부 함수를 반환한다.
  return increment
}

// 클로저를 생성하여 로컬 변수를 캡처 및 변경한다.
val counter = createCounter()
println(counter())
println(counter())


데이터 클래스

클래스 정의 시 data라는 키워드를 붙이면 해당 클래스를 데이터 클래스(data class)로 만들 수 있다. 데이터 클래스는 코틀린이 언어 수준에서 지원하는 값 타입(value type)이다. 원시값을 감싸는 새로운 타입이 필요하거나 데이터를 임시로 저장하고자 할 때 데이터 클래스 기반의 값 타입을 사용할 수 있다.

데이터 클래스를 정의한 후 컴파일하면 직접 정의하지 않은 equals(), hashCode(), toString(), copy() 메서드가 클래스에 자동으로 생성된다. 컴파일러가 데이터 클래스에 자동으로 추가해주는 네 가지 메서드 중 copy() 메서드를 사용하면 데이터 클래스 객체의 모든 프로퍼티 값을 그대로 복사한 새 객체를 생성한 후, 원하면 일부 프로퍼티의 값을 다른 값으로 변경할 수도 있다. 다만 코틀린 컴파일러는 자동으로 생성된 함수에 대해 주 생성자 내에 정의된 프로퍼티만 사용한다. 자동 생성된 구현에서 프로퍼티를 제외하려면, 클래스 본문 안에 선언해야 한다. copy() 메서드는 데이터 객체의 내부 상태에 직접 접근하도록 허용하는 공개 메서드이다. 데이터 클래스를 캡슐화하기 위해 멤버를 비공개로 만들 수는 없다.

데이터 클래스가 항상 불변인 것은 아니다. 데이터 클래스는 값 타입을 위해 사용되는 것이 주 목적이지만 데이터 클래스의 프로퍼티의 값을 변경하는 것은 허용된다. 주 생성자의 프로퍼티 값이 변경될 경우 자동으로 생성되는 equals()hashCode() 메서드의 반환값도 달라지며 객체의 값 동등성이 변경된다. 데이터 클래스를 불변으로 만들기 위해서는 모든 프로퍼티를 val로 선언하여 인스턴스화 된 후 프로퍼티의 값을 변경할 수 없도록 하면 된다. 인스턴스 생성 후 불변 조건을 유지해야 하는 클래스는 비공개 생성자와 정적 메서드를 사용하면 된다. 값 타입이 불변 조건을 유지해야 하거나 내부 표현을 캡슐화해야 한다면 데이터 클래스는 적합하지 않다.

자바 언어에서는 객체의 참조 동등성(reference equality) 비교를 위해 == 연산자나 Object 클래스의 equals() 메서드를 사용하여 객체의 메모리 주소값이 동일한지(참조가 같은지) 비교할 수 있다. 자바와 다르게 코틀린의 경우 == 연산자는 객체의 값 동등성(value equality)(또는 구조적 동등성(structural equality))을 비교하고, === 연산자는 객체의 참조 동등성을 비교한다. == 연산자는 데이터 클래스의 equals() 메서드를 호출하여 값에 대한 비교를 수행한다.

데이터 클래스는 주 생성자에 선언된 모든 프로퍼티들을 기반으로 equals()hashCode() 메서드를 자동으로 만들어준다. 같은 데이터 클래스에 속한 두 인스턴스는 주 생성자의 모든 프로퍼티 값이 동등할 때 서로 동등하다. 즉, 주 생성자의 모든 프로퍼티 값이 동등한 두 인스턴스에 대해 equals() 메서드의 결과값은 true이며, hashCode()의 결과값은 동등하다. 주의할 점은 주 생성자의 프로퍼티 값이 모두 동등하고 부 생성자의 프로퍼티 값이 동등하지 않더라도 두 인스턴스의 값 동등성을 true로 판별한다는 것이다.

데이터 클래스 정의 시 제약 사항은 다음과 같다. 이러한 제약 사항은 데이터 클래스에 자동 생성되는 보일러플레이트 코드의 일관성과 의도된 동작을 위함이다.

  • 주 생성자는 적어도 하나 이상의 파라미터를 가져야 한다.
  • 모든 주 생성자 파라미터는 val 또는 var로 정의되어야 한다.
  • abstract, open, sealed, inner 키워드로 선언할 수 없다.


첫 번째 제약 사항인 “주 생성자는 적어도 하나 이상의 파라미터를 가져야 한다.”에 따르면 자바와 다르게 데이터 클래스는 파라미터가 없는 기본 생성자를 정의할 수 없다. 대신 정의된 모든 주 생성자 파라미터를 초기화하거나 constructor 키워드를 사용하여 파라미타가 없는 부 생성자를 정의하면 생성자 호출 시 인자를 전달하지 않아도 된다.


확장

확장(extension)이란 클래스를 상속하거나 데코레이터(decorator) 패턴을 사용하지 않고도 기존 클래스에 새로운 기능을 추가하여 클래스를 확장할 수 있는 코틀린의 기능 중 하나이다. 확장을 사용하면 기존 클래스를 변경하지 않고 기존 클래스를 확장할 수 있어서 디자인 패턴의 개방/폐쇄 원칙(OCP)을 적용할 수 있다. 확장 기능을 사용한 정의 대상에는 함수와 속성이 있으며 각각 확장 함수(extension function), 확장 속성(extension property)이라고 한다.

확장은 실제로 확장 대상 클래스를 수정하지 않는다. 확장을 정의하는 경우 해당 클래스에 새로운 멤버(프로퍼티, 함수)가 새롭게 정의되는 것은 아니며 해당 타입의 변수에 점 표기법(.)을 사용하여 새 멤버를 호출할 수 있게 만들 뿐이다. 또한 확장 함수는 확장 대상 클래스의 캡슐화를 깰 수 없다. 확장 함수는 클래스 밖에 정의된 함수이므로 확장 대상 클래스에 속한 비공개 멤버에 접근할 수 없다.

확장을 사용하여 라이브러리의 클래스 또는 인터페이스의 함수를 새로 작성할 수 있으며 원래 클래스의 메서드인 것처럼 일반적인 방법으로 호출할 수 있다. 확장을 통해 정의한 함수를 확장 함수라고 한다. 기존 클래스의 속성을 새로 정의하는 것을 확장 속성이라고 한다. 확장 함수를 통해 클래스에 정의된 인스턴스 함수인 것처럼 객체의 함수를 호출할 수 있다.

확장의 활용 중 하나는 널 값을 가질 수 있는 변수에 대한 처리를 하는 것이다. 널 값을 가질 수 있는 변수나 메서드 호출 결과에 대해 확장을 사용함으로써 결과가 널일 경우(값이 존재하지 않는 경우) 특정 연산을 수행하도록 하여 널 참조 처리를 간편하게 수행할 수 있다.

함수 본문에서 함수에 파라미터로 정의한 객체 변수의 메서드를 호출하는 경우 코틀린의 확장을 이용해 파라미터 정의에서 제거할 수 있다. 즉, 다음과 같은 코드 변경이 가능하다.

fun 함수명(변수명: 객체타입) {
  변수명.함수()
}

fun 객체타입.확장함수명() {
  함수()
}


널 처리

코틀린은 널 참조 처리를 컴파일 시간에 강제한다. 즉, 컴파일 시 널 값을 가질 수 없는 변수에 널 값이 할당된 경우 오류(NPE)를 발생시킨다.

코틀린의 타입 시스템은 널 가능성을 지원한다. 자바가 널 가능성을 자바 8 버전부터 Optional이란 타입을 도입함으로써 라이브러리 형태로 제공한 것과 달리 코틀린의 경우 언어의 타입 시스템 자체가 타입의 널 가능성 표현을 지원한다.

널이 될 수 없는 타입을 non-nullable 타입이라고 하고, 널이 될 수 있는 타입을 nullable 타입이라고 한다. 변수의 타입이 nullable인 경우 해당 변수는 널 값을 가질 수 있으며, 변수의 타입이 non-nullable인 경우 해당 변수는 널 값을 가질 수 없다.

nullable 타입으로 변수 선언 시 타입에 ?를 추가한다.

var 변수명: 타입?

코틀린 타입 시스템에서 TT?의 하위 타입이다. 따라서 널이 될 수 없는 타입(T)의 값을 널이 될 수 있는 타입(T?)의 값으로 항상 교체 가능하다.

함수 정의 시 파라미터를 널이 될 수 없는 변수로 지정하면 컴파일러는 함수 본문 이전에 널 검사를 한다. 이런 방식으로 함수 호출자가 널을 몰래 함수에 전달해도 바로 그 사실을 알아낼 수 있다. 이런 방어적인 검사로 인해 코틀린은 예기치 않은 널이 발생하는 경우 가장 가까운 시점에 이를 알아낼 수 있다. 이런 특성은 널로 설정된 참조가 시간이나 공간적으로 멀리 떨어진 곳에서 발생할 수도 있는 자바와는 다르다.

널 값을 가질 수 없는 변수를 다룰 때는 NPE가 발생하지 않지만 널 값을 가질 수 있는 변수를 다룰 때는 NPE가 발생 가능하다. 널 값을 가질 수 있는 변수를 점(.) 연산자(역참조 연산자)를 사용하여 아무런 널 참조 처리 없이 참조하는 코드를 작성하고 해당 변수에 널 값이 할당된 경우 참조(널 역참조)시 NPE 예외가 발생할 것이며 이 경우 코드는 컴파일되지 않는다. 컴파일을 위해서는 해당 변수가 널인지 확인하는 코드가 추가로 필요하다.

안전 호출(safe call) 연산자인 ?.를 사용하면 널 값을 가질 수 있는 변수를 참조할 경우 NPE 예외가 발생하지 않게 해준다. 해당 변수가 널인지 확인하는 코드 필요 없이 연산 결과의 값을 널 값으로 평가한다. 이러한 평가를 쇼트 서킷(short-circuit) 평가라고 한다. 이 경우 연산 결과의 값을 담는 변수가 다시 널이 될 수 있으므로 추가적인 널 검사가 필요하다.

?. 연산자와 확장을 같이 사용할 수도 있다. ?.let는 널 값을 가질 수 있는 변수가 널이면 널로 평가하고, 널이 아니면 해당 값을 let 블록으로 전달한다. let 블록 내에서 널일 경우 추가적인 처리를 하면 된다. 이처럼 확장을 사용하여 널 참조 처리를 간편하게 수행할 수 있다.

엘비스(elvis) 연산자인 ?:를 사용하면 해당 변수가 널인 경우 대신 반환할 값을 지정할 수 있다. 즉, 해당 변수가 널일 경우 지정한 기본값을 반환한다.

널 값을 가질 수 있는 변수를 참조하여 해당 변수의 속성을 사용하고자 하는 경우 최종적으로 널 값이 반환될 수 있으므로 ?. 연산자와 ?: 연산자를 모두 사용하여 NPE 발생을 막고 널 값 대신 의도된 기본값을 반환할 수 있도록 코드를 작성하는 것이 좋다.

널 값을 가질 수 있는 변수에 대해 직접 널 값 검사 코드를 작성하는 경우 널 참조 처리가 강제되지 않기도 한다. 이는 코틀린의 스마트 타입 변환(smart type casting)이란 타입 시스템 때문이다. 널 값을 가질 수 있는 변수에 대해 if 문을 사용하여 변수가 널이 아닐 경우 실행할 코드에서는 해당 변수에 대해 널 참조 처리를 하지 않아도 되며 해당 변수의 타입은 non-nullable으로 변환된다. 이 스마트 타입 변환은 해당 변수(또는 변수를 속성으로 가지는 객체 변수)가 한 번 설정되고 나면 변경될 수 없는 val 변수일 경우에만 수행된다. var 변수의 경우 스마트 타입 변환은 수행되지 않으며 널 참조 처리가 필요하다.

스마트 타입 변환을 강제하는 방법도 있다. 널 아님 단언 연산자(not-null assertion operator)인 !!를 사용하면 변수가 널이 아닌 값으로 처리되도록 강제하고(해당 변수가 널이 아니면 값을 반환하고) 해당 변수가 널이라면 NPE 예외를 발생시킨다. !! 연산자는 개발자가 해당 변수에 널 값이 할당되지 않을 것임을 강제하는 것이다. 이 연산자는 if 문을 사용하여 변수가 널이 아닐 경우 실행할 코드에서 사용되어야 NPE가 발생되지 않지만 이 원칙이 지켜지지 않은 경우 문제가 되므로 권장되지 않는다.

널이 아닌 경우 실행(execute if not null) 블록은 변수가 널 값이 아닌 경우 실행할 코드를 정의하기 위해 사용한다.


널 처리 코드 작성

  • 널 값을 가질 수 있는 변수 선언
    var nullable변수: 타입?
    
  • 널 값을 가질 수 없는 변수 선언
    var non-nullable변수: 타입
    
  • 널 값을 가질 수 있는 변수 참조
    nullable변수.속성 // NPE 발생 가능
    
  • 널 값을 가질 수 있는 변수 참조 시 ?. 연산자 사용
    nullable변수?.속성 // NPE 발생하지 않음
    
  • 널 값을 가질 수 있는 변수 참조 시 ?., ?: 연산자 사용
    nullable변수?.속성 ?: 기본값 // NPE 발생하지 않음
    
  • 널이 아닌 경우 실행할 코드 정의
    nullable변수?..let { 코드 } // NPE 발생하지 않음
    


참고

Comments