Ch 02. Java 개발자를 위한 코틀린 필수 사항

About

코드 한 줄 한 줄은 모두 중요하다. 기본 연산자가 장황하면 전체 애플리케이션을 간결하게 만들 수 없다. 명령형 스타일이든, 함수형이든, 객체 지향적이든, 표현식(expressions)과 명령문(statements)들이 블록을 만든다. 코틀린은 절차, 노이즈, 산만한 과정을 코드 전체에서 제거하고, 따라서 우리가 만든 프로그램은 짧고, 정확하게 표현하고, 관리하기 쉬워진다.

코틀린은 세미콜론, 타입 정의, 클래스 등 다른 언어에서 필수적인 것들을 몇 가지 선택사항으로 만들었다. 코틀린에서 개발자는 변수를 만들 때 mutable인지 immutable인지를 결정해야 한다. 문자열 표현식과 멀티라인 스트링을 사용할 때 스트링 탬플릿을 제공해서 고통을 줄여준다. 그리고 mutable 변수의 사용을 줄이기 위해서 명령문보다는 표현식을 선호한다.

2-1. 더 적은 타이핑

세미콜론은 생략해도 된다

모든 표현식이나 명령문을 세미콜론으로 끝낼 필요가 없다.

6 * 2

변수 타입 지정은 생략해도 된다

코틀린은 정적 타입 언어이지만, 그게 꼭 변수 타입을 지정해야 한다는 뜻은 아니다. 정적 타입이란, 변수의 타입이 컴파일 시점에 검증되고 정확해져야 한다는 의미이다.

코틀린은 컨텍스트에 기반한 스마트 타입 추론 기능을 갖고 있다. 타입을 명시하지 않고 변수를 정의할 수 있다.

// essence/typeinference.kts
val greet = "hello"
println(greet)
println(greet::class)
println(greet.javaClass)
  • ::class - 변수에 의해 참조되고 있는 객체의 코틀린 클래스를 확인한다.

  • .javaClass - Java 클래스를 확인한다.

코틀린과 Java의 클래스가 서로 다른 경우는 매우 드물다. 코틀린 컴파일러에 친숙한 클래스들만 차이를 보여줄 것이다.

hello
class kotlin.String
class java.lang.String

코틀린의 타입 추론은 극단적이지 않아서, 타입이 명확하다면 타입을 생략할 수 있다.

  • 함수나 메소드를 정의할 때, 리턴타입은 명시하지 않아도 되지만 파라미터 타입은 명시해야 한다.

  • 일반적으로 API의 리턴 타입을 명시하는 이유는 라이브러리 내부에서 사용하기 위해서가 아니라 라이브러리를 사용하는 외부 유저에게 보여주기 위해서다.

클래스와 함수는 생략 가능하다

Java와는 다르게 코틀린은 명령문이나 표현식이 메소드에 속할 필요가 없고, 메소드는 클래스에 속할 필요가 없다. 적어도 우리가 만드는 소스코드 안에서는 말이다.

코드가 컴파일되거나, 스크립트로 실행될 때 코틀린은 JVM에서 실행하기 위해 필수적인 필요한 wrapper 클래스와 메소드를 생성한다.

// essence/standalone.kts
fun nofluff() {
    println("nofluff called...")
    throw RuntimeException("oops")
}
println("not in a function, calling nofluff")
try {
    nofluff()
} catch (ex: Exception) {
    val stackTrace = ex.getStackTrace()
    println(stackTract[0])
    println(stackTract[1])
}

실행 결과는 다음과 같다.

not in a function, calling nofluff
nofluff called...
Standalone.nofluff(standalone.kts:4)
Standalone.<init>(standalone.kts:10)

코틀린은 조용히 nofluff() 함수를 Standalone 이라는 동기화된 클래스의 메소드 안으로 넣었다. 클래스의 이름은 파일 이름으로 추론된다. 그리고 출력 결과에 나온 <init>을 봤을 때 단독적으로 동작하는 코드는 클래스의 생성자 안으로 들어갔다.

작은 코드를 작성할 때 파일 안에 코드를 바로 작성하면 스크립트로 동작된다. 클래스와 메소드를 만들기 위한 관례적인 코드가 필요가 없다. 물론 큰 애플리케이션을 작업할 때는 클래스와 메소드를 만들어야 할 것이다.

간단한 코드는 간단하게 만들고, 복잡한 코드는 더 정밀하고 구조적이어야 한다.

try-catch는 선택사항이다

Java에서는 예외가 unchecked와 checked exception으로 나뉜다.

Java 컴파일러에서는 명시적 예외 (Checked Exception)을 확실하게 처리(catch)하거나 전달(throw)해야 한다.

코틀린은 checked든 unchecked든 상관 없이 어떤 예외든 처리하도록 강제하지 않는다. 만약 try-catch 문이 없는 함수를 호출했을 때 예외가 발생하면 자동으로 해당 함수를 호출한 함수 또는 호출한 코드로 전달된다. 호출한 코드나 함수에서 예외를 핸들링하지 않는다면 프로그램은 종료된다.

예를 들면, Java의 Thread 클래스에 속한 sleep() 메소드는 명시적 예외를 전달한다. 컴파일러는 해당 예외를 반드시 처리하도록 강제한다. 즉, Checked exception이다. 그 결과 sleep() 메소드를 부르는 모든 호출을 try문으로 감싸야 하고, sleep() 메소드가 전달할 수 있는 InterruptedException을 어떻게 처리해야 할지 고민하느라 시간을 써야 한다. 코틀린에서는 그럴 필요가 없다.

// essence/nocatch.kts
println("Lemme take a nap")
Thread.sleep(1000)
println("ah that feels good")

위의 코드에는 try-catch문이 없다. 그러나 실행시키면 첫 줄이 출력되고 1초 뒤 두 번째 줄이 출력된다.

예외처리를 위해서는 방어적 프로그래밍 하는 것이 좋다. 코틀린은 try-catch 문을 강요하지 않기 때문에, Java처럼 컴파일러의 체크를 통과하기 위해 불필요한 빈 catch 블록을 만들 필요가 없다. 개발자가 직접 다루지 않은 예외는 자동으로 호출한 코드로 전파된다는 것을 기억하자.

2-2. 현명한 경고

어떤 코드가 문법적으로는 문제가 없어도 잠재적인 문제가 있을 수 있다. 컴파일 시점에 빠른 경고(early warning)을 받을 수 있다면 개발자들이 사전에 대처할 수 있을 것이다.

코틀린 컴파일러는 코드 안의 다양한 잠재적 문제들을 찾아낸다.

예를 함수나 메소드에 사용되지 않는 파라미터가 있다면 컴파일러가 경고를 줄 것이다. 아래의 스크립트에서 compute()에 전달된 파라미터는 사용되지 않는다.

// essence/unused.kts
fun compute(n: Int) = 0
println(compute(4))
0
unused.kts:1:13: warning: parameter 'n' is never used
fun compute(n: Int) = 0
            ^

저자가 이전에 집필한 책 <애자일 프랙티스>(인사이트, 2007)에서 애자일 개발에서 '경고(warning)를 오류처럼 다루는 것이 올바른 소프트웨어 개발 습관이다.'라고 강조했다.

코틀린에서는 -Werror 옵션으로 경고를 오류처럼 다룰 수 있다. 커맨드 라인으로 해당 옵션을 사용해서 실행시켜보자.

$ kotlinc-jvm -Werror -script unused.kts

이 옵션은 빌드와 실행을 실패시킨다.

error: warnings found and -Werror specified
unused.kts:1:13: warning: parameter 'n' is never used
fun compute(n: Int) = 0
            ^

2-3. var 보다는 val

immutable 변수(상수 또는 값)를 정의하기 위해서 다음처럼 val을 사용한다.

val pi: Double = 3.14

현재 컨텍스트에서 변수의 타입이 명확하므로 타입은 생략할 수 있다.

val pi = 3.14

val은 Java의 final과 비슷하다. val로 정의한 변수의 값을 바꾸거나 재할당하려는 시도를 하면 컴파일 오류가 발생한다.

val pi = 3.14
pi = 3.14 // 오류: val에는 재할당이 불가능하다.

반대로 var로 정의된 변수는 mutable하다.

var score = 10 // var score: Int = 10도 가능하다.
println(score) // 10
score = 11
println(score) // 11

명령형 프로그래밍에서 변수 값의 변경은 피할 수 없는 일이다. 그러나 함수형 프로그래밍에선 변수의 값을 변경하는 것이 터부시된다. 일반적으로 immutable 변수를 사용하는 것이 선호된다. 왜 immutable이 mutable보다 좋은지 다음 예제를 통해서 확인해보자.

// essence/mutate.kts
var factor = 2
fun doubleIt(n: Int) = n * factor
factor = 0
println(doubleIt(2))

추측으로 맞췄을지 모르지만 위 코드의 출력은 0이다. 코딩할 때 결과를 추측한다는 건 전혀 즐겁지 않다.

  • mutability는 코드를 추론하기 어렵게 만든다.

  • mutable한 코드는 오류가 발생할 가능성이 더 높다.

  • mutable한 변수가 있는 코드는 병렬화하기가 더 어렵다.

var 대신 val을 사용하려 노력해야 한다. 코틀린은 다른 경우에서도 val과 immutable을 기본으로 사용한다.

코틀린의 val은 Java의 final과 비슷하다. Java와는 다르게 코틀린은 var을 사용하여 mutable 변수를 나타낸다. 그래서 Java에서 누락된 final을 찾는 것보다 코틀린의 var을 찾는 것이 더 쉽다! 결과적으로 코틀린에서는 변경가능성때문에 나올 수 있는 잠재적 오류를 찾기가 쉽다.

물론 val도 주의해서 사용해야 한다. val은 변수나 참조(reference)만 상수(constant)로 만든다. 객체를 상수로 만드는 것은 불가능하다. 그래서 val은 참조에 대한 immutability만 보장해주고, 객체의 변화는 방지할 수 없다. 예를 들어 String은 immutable이지만, StringBuilder는 mutable이다. val이나 var 어떤 것을 사용하든지 String 객체는 변화로부터 안전하다. 하지만 StringBuilder의 객체는 안전하지 않다.

val message = StringBuilder("hello")
// message = StringBuilder("another") // 이렇게 하면 오류가 난다.
message.append("there")

StringBuilder 의 객체 변수는 immutable하지만, 참조하는 객체는 그 변수를 사용하여 변경할 수 있는 것을 알 수 있다.

가능하면 var 보다는 val을 사용하자.

2-4. 향상된 동일성 체크

  • Java의 equals() 메소드 또는 코틀린의 == 연산자는 값을 비교한다. 구조상의 동일성(structural equality)라고 부른다.

  • Java의 == 연산자 또는 코틀린의 === 연산자는 참조 대상을 비교한다. 참조상의 동일성(referential equality)이라고 부른다. 참조상의 동일성은 참조를 비교하고, 두 비교대상이 같은 객체를 참조할 경우 true를 반환한다.

코틀린의 ==는 Java의 equals() 메소드보다 뛰어나다. 만약 Java에서 str1.equals(str2); 라는 코드를 실행시켰을 때 str1null이라면 NullPointerException을 리턴 받지만, 코틀린의 == 연산자는 다른 결과를 준다.

// essence/equality.kts
println("hi" == "hi") // true
println("hi" == "Hi") // false
println(null = "hi") // false
println("hi" == null) // false
println(null == null) // true

만약 위의 비교가 Java의 equals()로 실행되었다면 런타임에서 NullPointerException 이 나왔을 것이다. 그러나 코틀린은 null을 안전하게 다룬다.

  • 만약 두 개의 값이 동일하면 true를, 그게 아니면 false를 리턴한다.

  • 둘 중 하나가 null이고 하나는 null이 아니라면 false를 리턴한다.

  • 둘 다 null이라면 결과는 true다.

게다가 코틀린은 거기에 추가적으로 다른 정보도 준다.

true
false
false
false
true
equality.kts:3:9: warning: condition 'null == "hi"' is always 'false'
println(null == "hi")
       ^
equality.kts:4:9: warning: condition '"hi" == null' is always 'false'
println("hi" == null)
       ^
equality.kts:5:9: warning: condition 'null == null' is always 'true'
println(null == null)
       ^

코드의 실행 결과는 위에 나온 것처럼 ==의 연산결과를 알려주며, 동시에 훌륭한 경고를 덧붙인다. 만약 결과가 항상 같은 값을 보여줄 것 같다면 경고를 통해 쓸모없는 컨디션 체크를 하는 코드를 수정하라고 제안한다.

코틀린에서 == 연산자를 사용할 때, null 체크를 먼저 하고, equals() 메소드를 실행한다.

2-5. 문자열 템플릿

표현식의 결과를 문자열에 포함하고 싶을 때가 있는데, + 연산자를 이용해 문자열을 만들면 코드는 장황해지고, 유지보수는 어려워진다. 문자열 템플릿을 이용하면 이를 해결할 수 있다.

  • 큰따옴표 문자열 안에서 $ 심볼을 변수 앞에 붙여주면 어떤 변수든 문자열 안에 들어간다. 변수 하나보다 더 복잡한 명령문이라면 ${}로 감싸서 사용할 수 있다.

  • $ 심볼 뒤에 변수 이름이나 표현식이 없으면 $ 심볼은 문자로 취급되며, 역슬래시(\)를 이용해 $를 문자로 취급할 수도 있다.

// essence/stringtemplate.kts
val price = 12.25
val texRate = 0.08
val output = "The amount $price after tax comes to $${price * (1 + taxRate)}"
val disclaimer = "The amount is in US$, that's right in \$only"
println(output)
println(disclaimer)

출력된 코드이다.

The amount 12.25 after tax comes to $13.23
The amount is in US$, that's right in $only

앞서 말했던 var보다 val을 사용하라는 경고는 여기서도 적용된다. 이전에 사용했던 예제를 약간 변형해 문자열 템플릿을 사용해보도록 수정한다.

// essence/mutateconfusion.kts
var factor = 2
fun doubleIt(n: Int) = n * factor
var message = "The factor is $factor"
factor = 0
println(doubleIt(2))
println(message)

이전과 같이, 코드를 실행시키지 않고 결과를 생각해보자.

0
The factor is 2

함수 doubleIt()에 있는 변수 factor는 스코프 밖에서 바인딩된다. 즉, 렉시컬 스코프이다.

factor의 값은 함수가 호출된 시점에서 사용된다. 반면에 문자열 템플릿은 출력될 때가 아니고 message가 만들어질 때 사용되었다.

이런 종류의 차이들이 인지부하를 증가시키고 코드를 유지보수하기 어렵게 만들며 오류를 만드는 경향도 생긴다.

다시 강조하지만, 가능한 한 var보다는 val을 사용하라.

2-6. RAW 문자열

이스케이프 문자를 사용하면 코드가 지저분해진다. 코틀린에서는 이스케이프 문자를 사용하는 대신 시작과 끝에서 큰따옴표 세 개를 이용해 raw 문자열을 사용할 수 있다. 이스케이프 문자 없이도 아무 문자나 표현할 수 있다. 그리고 raw 문자열을 사용하면 여러 줄(멀티라인) 문자열도 만들 수 있다.

이스케이프는 이제 안녕

줄바꿈이나 큰 따옴표 같은 문자들은 이스케이프 문자인 백슬래시(\) 없이는 입력할 수 없다. 간단한 문장도 읽기 어려워지는 셈이다.

val escaped = "The kid asked, \"How's it going, $name?\""

코틀린에서는 이스케이프 문자를 사용하는 대신 raw 문자열을 사용한다.

val raw = """The kid asked, "How's it going, $name?""""
  • 이스케이프할 필요가 없는, 작고, 단순하고, 간단한 문자열이라면 이스케이프 문자가 필요한 문자열을 사용하고,

  • 복잡하거나 여러 줄을 써야 할 경우에는 raw 문자열을 사용하는 게 좋다.

멀티라인 문자열

RAW 문자열을 이용해 멀티라인 스트링을 만들 수 있다.

// essence/memo.kts
val name = "Eve"

val memo = """Dear $name, a quick reminder about the
party we have scheduled next Tuesday at
the 'Low Ceremony Cafe' at Noon. | Please plan to..."""

println(memo)

만약 멀티라인 문자열이 함수나 if문 안에 있으면 어떻게 될까?

// essence/nestedmemo.kts
fun createMemoFor(name: String): String {
    if (name == "Eve") {
        val memo = """Dear $name, a quick reminder about the
            party we have scheduled next Tuesday at
            the 'Low Ceremony Cafe' at Noon. | Please plan to..."""
        return memo
    }
    
    return ""
}

println(createMemo("Eve"))

출력 결과는 다음과 같다.

Dear Eve, a quick reminder about the
        party we have scheduled next Tuesday at
        the 'Low Ceremony Cafe' at Noon. | Please plan to...

결과 문자열은 띄어쓰기가 포함되어 버린다. 하지만 들여쓰기를 제거하려면 다음과 같이 하면 된다.

// essence/nestedmemofix.kts
fun createMemoFor(name: String): String {
    if (name == "Eve") {
        val memo = """Dear $name, a quick reminder about the
            |party we have scheduled next Tuesday at
            |the 'Low Ceremony Cafe' at Noon. | Please plan to..."""
        return memo.trimMargin()
    }
    
    return ""
}

println(createMemo("Eve"))
  • 둘째 줄부터 모든 줄마다 수직선(|)을 넣었다.

  • 문자열에서 마진을 없애기 위해 확장함수인 trimMargin() 메소드를 사용했다.

아규먼트 없이 trimMargin() 메소드를 사용하면 수직선(|) 문자가 나올 때까지 공백을 제거한다. 시작점에 있는 수직선(|) 문자가 아닐 경우엔 아무런 영향도 주지 않는다.

Dear Eve, a quick reminder about the
party we have scheduled next Tuesday at
the 'Low Ceremony Cafe' at Noon. | Please plan to...

수직선 문자를 문장 내용으로 사용하고 싶어서 시작 구분점으로 사용하고 싶지 않다면, 수직선 문자 대신 원하는 문자를 선택할 수 있다.

val memo = """Dear $name, a quick reminder about the
~party we have scheduled next Tuesday at
~the 'Low Ceremony Cafe' at Noon. | Please plan to..."""
return memo.trimMargin("~")

trimMargin() 메소드를 부를 때 시작 구분점 문자(~)를 넘겨주면 된다.

2-7. 표현식은 많이, 명령문은 적게

Java, C#, JavaScript 같은 언어들은 표현식(expression)보다는 명령문(statement)을 더 많이 가지고 있다.

  • if문, for문, try문 등등...

반면, Ruby, F#, Groovy, Haskell 등 다른 많은 언어들은 명령문보다는 표현식을 많이 가지고 있다.

코틀린이 무엇을 선호하는지 말하기 전에 표현식과 명령문 중 어떤 것이 더 좋은가를 논의해보자.

명령문(statement)은 아무것도 리턴하지 않을 뿐 아니라, 부작용(side effect)도 가지고 있다. 부작용이란, 상태가 변하고, 변수를 변하게 하고, 파일을 작성하고, 데이터베이스를 업데이트하고, 리모트 웹 서버에 데이터를 전송하고, 하드 드라이브를 오염시키는 것 등이 포함된다.

명령문보다는 표현식(expression)이 좋다. 표현식은 결과를 리턴해주고, 어떤 상태도 변화시키지 않는다.

차이를 알아보기 위해 Java나 C# 스타일로 코틀린 코드를 작성해보자.

fun canVote(name: String, age: Int): String {
    var status: String
    if (age > 17) {
        status = "yes, please vote"
    } else {
        status = "nope, please come back"
    }
    return "$name, $status"
}
println(canVote("Eve", 12))

canVote() 메소드는 if문을 명령문 형태로 사용한다. 명령문은 아무런 리턴값을 주지 않기 때문에, canVote() 메소드에서 쓸만한 결과를 얻기 위한 유일한 방법은 mutable 변수(status)를 만들고, 메소드 안에서 해당 변수를 수정하는 것 뿐이다.

하지만 코틀린에서 if는 표현식이다. 우리는 이후의 처리를 위해 if를 호출하고, 그 결과를 사용할 수 있다.

val status = if (age > 17) "yes, please vote" else "nope, please come back"
return "$name, $status"
  • 해당 변수를 더 이상 변화시킬 필요가 없으므로 val가 아닌 var을 사용할 수 있었다.

  • status에 들어갈 값이 if 표현식에 의해 정해지므로 status에 타입추론을 사용할 수 있었다.

변화된 코드는 덜 지저분하고, 오류도 덜 발생한다.

코틀린은 try-catch도 표현식으로 취급한다. 예외가 발생하지 않으면 try식의 마지막 부분이 결과가 되고, 예외가 발생하면 catch식의 마지막 부분이 결과가 된다.

fun tryExpr(blowup: Boolean): Int {
    return try {
        if (blowup) {
            throw RuntimeException("fail")
        }
        2
    } catch (ex: Exception) {
        4
    } finally {
        // ...
    }
}
println(tryExpr(false)) // 2
println(tryExpr(true)) // 4

이 코드에서 놀라운 점이 존재한다. Java에서는 할당을 표현식으로 취급하지만, 코틀린은 그렇지 않다.

만약 변수 a, b, cvar을 이용해 1, 2, 3 같은 정수로 정의되었다면 아래의 코드는 실행되지 않았을 것이다.

a = b = c // 오류

위 식이 표현식으로 취급되지 않는 이유는 코틀린이 델리게이션을 통해 변수를 get하거나 set하도록 허용하기 때문이다. 이는 뒷부분에서 다시 다룰 것이다. 만약 대입연산자 =이 표현식으로 다뤄졌다면 할당 체이닝은 예상할 수 없고, 복잡한 행동을 하면서 혼란을 주고 오류를 불러올 것이다.

정리

코틀린은 가장 기초적인 프로그래밍에서 사용되는 관용적인 코드 대부분을 없앴다. 같은 작업을 한다면 다른 언어들보다 코틀린을 사용할 때 더 적은 코드를 타이핑하는 것으로 완료할 수 있다.

  • 세미콜론은 선택사항이다.

  • 변수 선언을 할 때는 타입 추론을 사용한다.

  • 모든 것을 함수나 클래스에 넣을 필요가 없고 예외처리를 강요하지도 않는다.

이런 점들이 프로그래밍을 쉽게 해준다. 또한,

  • 코틀린은 오류로부터 보호해주기 위해서 현명한 경고를 해준다.

  • 변수의 immutability와 mutability를 미리 선택하기 때문에 프로그램의 안정성도 올라간다.

  • 비교(equality check)를 할 때 null 오류가 발생하지 않는다.

  • 문자열 템플릿과 멀티라인 문자열은 문자열을 표현식으로 만들어야 하는 수고를 덜어준다.

  • 코틀린은 Java, C#, JavaScript 같은 언어들에 비해서 명령식(statement)보다 표현식(expression)을 제공해준다.

Last updated