Tag Archives: java

Kotlin 알아보기

kotlin-new-logo

Kotlin은 JVM과 Java Script 환경에서 돌아가는 IntelliJ, PHPStorm, PyCharm, WebStorm 등을 개발한 JetBrains사가 개발한 정적 타입 언어입니다. Kotlin은 Java의 기존의 수많은 API들을 모두 사용할 수 있으며 Java 코드를 Kotlin으로 변경하는 기능도 제공하고 있으며 이를 통해서 Android 어플리케이션 역시 개발을 할 수 있습니다. Kotlin 프로젝트 사이트에 방문해 보면 Android 뿐만 아니라 Spring Framework를 적용한 예시도 볼 수 있으며 Maven과 Gradle과 같은 빌드툴 역시 사용할 수 있으므로 기존의 Java 개발자들이 완벽하게 옮겨탈 수 있는 새롭지만 새롭지 않은 그런 언어가 될 수 있을것 같습니다. 아마도 iOS의 Swift도 같은 IDE환경에서 개발가능하고 기존의 Objective-C 로 만들어진 API를 모두 사용할 수 있다는점에서 같은 포지션을 갖는 언어가 되지 않을까 생각이 됩니다.

어떤면에서 Kotlin은 새로운 언어인가?

Kotlin은 Java에 비해서 더 안전한 타입 형태와 간결한 문법으로 개발이 가능합니다. 또한 Java와 호환 가능하며 Java만큼 빠른 컴파일 속도를 가집니다. 또한 이미 많은 개발자들이 사용하고 있는 Scala보다 심플한 언어입니다. JVM이 돌아가는 환경이면 어떤 언어도 개발가능하며 Java Script와 Android 어플리케이션 역시 개발 가능합니다. IntelliJ IDE를 사용하면 완벽한 개발 환경을 구축할 수 있으며 Eclipse 용 플러그인 역시 제공하므로 필요할 경우 이를 사용할 수 있습니다.

class HelloWorld {
  public static void main(String ... args) {
    System.out.println("Hello World!");
  }
}

위와 같은 Java 코드가 있다면 Kotlin으로는 다음과 같이 표현할 수 있습니다.

fun main(vararg args: String) {
  println("Hello World!");
}

fun main(args: Array<String>) = println("Hello World!")

위에서 보여지는 vararg는 Variable number of arguments의 약자로 배열형태의 인자를 뜻합니다.

Functions

Kotlin에서 함수 표현은 다음과 같은 표기법을 사용할 수 있습니다.

fun sum(a: Int, b: Int): Int {
  return a + b
}

fun sum(a: Int, b: Int) = a + b

fun printSum(a: Int, b:Int): Unit {
  println(a + b)
}

첫번째 표기법은 Java 개발자들에게 익숙할 표기법입니다. 조금 다른게 있다면 인자의 표기법이 변수명/콜론/타입 순서로 표기되고 있다는 점입니다. 마지막에 함수의 반환값으로 콜론 뒤에 Int가 표기되어있는것도 볼 수 있군요. Int형 a와 b를 더해서 Int형 반환값을 리턴한다는것을 알 수 있습니다.

하지만 두번째는 조금 특이합니다. Single Expression Function 이라고 불리는 표기법인데 반환 타입이 생략되어있으며 이 식은 결과값으로 a + b의 결과값을 반환하게 됩니다. return을 명시하지 않아도 자동으로 결과를 반환한다고 생각하시면 될것 같습니다.

그렇다면 마지막 세번째는 고민을 해봐야 할 것 같습니다. 코드의 내부 구현은 println을 한번 호출했을 뿐 어떤 결과를 반환한다거나 하는것을 생각하기 어렵습니다. 이런 경우에 println은 Unit을 리턴합니다. 이런 부분은 Scala와 비슷한 부분이 있어 보입니다. Kotlin은 모든 함수 구현이 반환값을 가지며 return을 명시적으로 써줄수도 생략할 수 도 있습니다. 반환이 없어 보이는 코드의 경우에는 Unit을 리턴하며 이 Unit은 Java의 void와 같다고 생각하시면 됩니다. 사실은 반환값이 없는경우를 Unit을 리턴한다고 생각하시면 될 것 같습니다.

Variables

// Immutable
val a: Int = 1
val b = 1
val c: Int
c = 1

// Mutable
var x = 5
x += 1

변수의 경우 iOS의 Swift의 그것을 많이 닮아있습니다. val과 var 모두 로컬 변수를 선언하는 방법이지만 val의 경우 Assign Once 또는 Read Only로 언급이 됩니다. 즉 변하지 않는 상수를 선언하는 방법이라고 보시면 됩니다. var의 경우에는 이미 알고 계시는 일반적인 값이 변하는 변수로 생각하시면 됩니다. 이렇게 나누어진 의미는 좀 더 변수들에 대한 타입의 안정성을 가지기 위함인데요. 어떤 값이 나중에라도 변경되지 않길 원한다면 val로 선언을 하시면 됩니다. 먼 이후에 누군가가 이 값을 수정하려고 할 경우 컴파일러가 오류를 뱉을 것이므로 프로그램의 안정성을 향상 시킬 수 있습니다.

Strings

val x = "World"
println("Hello $x!")

println("3 + 4 = ${3 + 4}")

println("""1. Uno
2. "Dos"
3. Tres""")

문자열(String)에 대한 처리는 기존의 Java에 비해 월등히 좋아졌습니다. 단순히 $x를 사용함으로 써 변수값을 이어붙일 수 있으며 ${…}를 사용하여 좀 더 복잡한 처리도 가능합니다. 마지막으로 “””를 사용함으로써 문자열 Escape 처리 없이 줄바꿈을 포함한 어떠한 다른 문자도 포함이 가능합니다. 위의 경우 Dos 앞뒤의 따옴표 역시 정상적으로 출력됩니다.

Conditions

fun max(a: Int, b: Int): Int {
  if (a > b) {
    return a
  } else {
    return b
  }
}

fun max(a: Int, b: Int) = if (a > b) a else b

첫번째 예시는 전통적인 Java의 문법을 보여주는 예시입니다. 두개의 값을 받아서 if와 중괄호를 사용하여 분기하여 더 큰값을 반환하는 예시입니다. 하지만 두번째와 같이 더 간단하게 표기가 가능합니다.  위에서 언급했던것 처럼 반환 타입을 명시하지 않아도 return을 직접 명시하지 않아도 결과값을 반환할 수 있습니다.

Null

val x: String = null // error
val x: String? = null // ok

val x:Int? = 3
x + 4 // error
if (x != null) x + 4 // ok

val x: Int? = null
val y: Int = x ?: 0
x?.rangeTo(3) // null
y.rangeTo(3) // 0..3

이부분은 소름돋을 정도로 Swift와 너무 비슷해서 놀랐습니다. Kotlin이 null을 어떻게 처리하는가 보면 우선 기본적으로 변수에 null을 대입하는것은 불가능합니다. 하지만 변수의 타입을 지정할 때 물음표를 뒤에 붙임으로써 null이 들어갈 수 있는 변수임을 선언할 수 있습니다. 그렇기에 첫번째 예시인 String타입의 x에는 null을 넣을 수 없지만 String? 타입인 x에는 null을 넣는것이 가능합니다.

두번째 예시를 보면 어떻게 Kotlin이 NullPointException을 제거하려 노력했는지를 볼 수 있습니다. null이 들어갈 수 있는 정수형 변수 x에 3을 넣습니다. 그리고 뒤에 4를 더하려 시도하면 에러가 발생합니다. 이유인즉 컴파일러 입장에서 null일 수 있는 변수로 부터 값을 읽어서 4를 더하는 행위를 하려 했고 이는 위험하다고 판단한것인데요. 그 다음줄에서 null 체크를 하는 코드를 넣으면 문제 없이 컴파일 되는것을 볼 수 있습니다. Kotlin은 이런식으로 null의 잘못된 사용을 회피하려고 하고 있습니다.

마지막으로 Safe Call이라고 부르는 코드를 한번 보겠습니다. null 대입이 가능한 Int? 형 x를 선언하고 null을 넣었습니다. 그리고 null을 넣을 수 없는 Int형 변수 y를 선언하고 x가 null일 경우 0을 넣는다는 코드를 수행합니다. 결과적으로 y에는 0이 대입됩니다. (x ?: 0는 x ? x : 0의 축약형으로 보시면 됩니다)

x?.rangeTo(3)을 수행하려고 할 때 x의 뒤에 ?를 붙임으로 써 x가 null일 수 있음을 알려줍니다. x가 null일 경우 뒤의 함수는 실행되지 않고 null을 반환합니다. 이는 다음과 같은 체인형 함수를 실행할 때 유리합니다. 다음중에 하나라도 결과값이 null일 경우 더이상의 수행이 중단되고 null이 반환됩니다.

bob?.department?.head?.name

마지막으로 0이 대입된 y의 경우 문제없이 y.rangeTo(3)가 실행됩니다.

Types

fun getStringLength(obj: Any): Int? {
  if (obj is String) // 여기서 obj가 자동으로 String으로 캐스팅됩니다.
    return obj.length
  return null // 여기는 여전히 obj의 타입은 Any입니다.
}

if (obj !is String) { return ... }
return obj.length

if (obj is String && obj.length > 0) { ... }

여기서 처음으로  Any가 등장 했습니다. Any는 Scala에서도 등장하는 타입을 특정짓지 않은 타입을 뜻합니다. iOS의 id와도 동일한 개념입니다. 하지만 이 Any는 Java 개발자들은 상상도 하기 힘든게 가능한데 마치 PHP에서나 볼법한 방식으로 동작을 합니다.

var x: Any = "hello"
println(x) // hello가 출력

x = 3
println(x) // 3이 출력

이런식으로 Any형 변수에는 어떤 값이던지 대입이 가능합니다. 다시 본론으로 돌아가서 정말 재미있는것이 if (obj is String) 이 코드가 참이라면 그 이하의 블록에서는 obj의 타입이 String으로 캐스팅되어 동작합니다. 즉 따로 캐스팅 하는 코드 없이 length를 호출가능합니다. 뭐 이런걸 자동화 했나 싶었다가 곰곰히 생각해보니 정말 좋은 기능인것 같습니다.

두번째 예시는 컴파일러가 얼마나 간섭이 심한지(?) 볼 수 있는 코드입니다. obj가 String이 아니라면 특정 블록을 실행하고(물론 거기서는 반환을 한다는 전제 조건이 있습니다) 거기에 들어가지 않는 경우 자동으로 obj는 String이 됩니다. 논리적으로 틀린건 아니라는게 더 신기하네요.

마지막 예시는 심지어 같은 조건문 안에서도(AND조건문은 왼쪽부터 순차적으로 비교되죠) 왼쪽에서 String타입이 참이라는 결론이 나오면 두번째 조건부터 아예 String으로 캐스팅 되어 사용됩니다.

Loops

val collection: List<String> = ...
for (element in collection)
  println(element)

var i = 0
while (i < collection.size())
  println(collection[i++])

collection.forEach { e -> println(e) }
collection.forEach { println(it) }

첫번째 예시는 문법상에 in을 쓴다는 차이점이 있을 뿐 기존의 Java에서 사용할 수 있는 문법과 동일합니다. collection의 원소가 차례로 element에 할당되며 반복문을 순환하겠지요. 두번째도 흔하게 볼 수 있는 while을 사용한 반복문을 순환하는 예시입니다.

마지막으로 Kotlin에서 사용 가능한 forEach에 대해서 알아보겠습니다. 첫번째 예시는 마치 Scala에서 볼법한 형태의 문법입니다. 순환되는 각각의 원소들이 e에 할당되고 수행할 코드가 화살표 뒤에 나옵니다. 두번째는 이 e를 따로 지정하지 않을 경우 기본적으로 it에 할당되는것을 보여주는 코드입니다.

이전에 말했던것처럼 Kotlin은 암묵적으로 return이 생략되는데요. 이 forEach의 경우에는 무엇이 반환될까요? forEach의 경우 Scala와 동일하게 내부 구현과 상관없이 Unit이 반환됩니다.

Switches

when (obj) {
  1          -> ...
  3..5       -> ...
  is String  -> ...
  !is String -> ...
  else       -> ...
}

기존의 switch문은 when으로 변경되었습니다. 여기서도 Scala의 냄새가 물씬 나는데요. 코드를 보시면 아시겠지만 굉장히 유연해졌습니다. 3..5와 같이 범위지정도 가능하고 is문법도 사용가능합니다. 이 코드를 보면 1이거나, 3에서 5사이의 값이거나, 문자열이거나, 문자열이 아니거나, 이도저도 아닌경우로 분류되겠네요.

이부분에서 Scala스러운점은 바로 다음과 같이 when절이 결과값을 반환할 수 있다는 점입니다.

val hasPrefix = when(x) {
  is String -> x.startsWith("prefix")
  else -> false
}

여기서 알아두셔야 하는 부분은 두가지 정도가 있는데요. 먼저 hasPrefix에는 문자열 값이 들어갈수도 불리언 값이 들어갈수도 있다는 점과 is String과 같이 is를 이용한 타입체크를 할 경우 바로 Smart Casting이라고 하는 자동 캐스팅이 적용된다는 점입니다. 결과적으로 is String 조건에 맞아서 진입한 조건절인 경우 별도의 타입 체크나 캐스팅 코드가 필요없습니다.

Membership

Java를 사용하면서 항상 이런거 있었으면 좋겠다는 생각을 많이 했었는데요. 배열의 경우 값이 존재하는지 여부를 체크하기가 좀 번거로웠는데 다음과 같은 기능을 제공합니다.

if (x in 1..10) ...
if (element in collection) ...

첫번째 코드는 x가 1과 10 사이의 값인지 여부를 손쉽게 확인할 수 있겠네요. 두번째는 element가 collection에 포함되어있는지 여부를 확인할 수 있습니다.

Infix functions

Infix 표기법은 조금 용어가 생소할 수는 있는데 반대가 Dot 표기법이라고 생각하시면 쉽습니다. Infix는 코드의 중간중간에 삽입되어야 하는 점(.)이 생략되는 표기법입니다. 이것 역시 Scala의 그것을 차용한 모양입니다.

collection.contains(element)
collection contains element

collection.forEach({ it + 1 })
collection.forEach { it + 1 }

names
  .filter { it.startsWith("A") }
  .sortedBy { it }
  .map { it.toUpperCase() }
  .forEach { println(it) }

위의 모든 표기법은 완벽하게 사용 가능한 방법들입니다. 하지만 첫번째 예시와 같이 인자마저도 Infix로 괄호가 생략가능한건 하나의 인자값만을 받는 함수일 경우에 한해서 가능합니다. 두개 이상의 인자값을 받는 함수의 경우 생략이 불가능합니다.

확인해본 결과 Kotlin 1.0 릴리즈에서 바뀐것 같은데 Infix 함수는 클래스의 맴버 함수(Member Function)이거나 기존의 클래스를 확장하는 확장 함수(Extension Function)인 경우에만 Infix라는 키워드를 통해서 선언이 가능합니다. 이렇게 선언된 경우에만 Infix 표기법으로 호출하는것이 가능합니다.

class Checker {
    infix fun checkInt(a: Int) { ... }
}

infix fun Int.shl(x: Int): Int { ... }

fun main(vararg args: String) {
  checker checkInt 54
  2 shl 3
}


첫번째는 Class에 선언된 Infix 표기법으로 사용가능한 checkInt 함수를 선언한 예시이고 두번째는 Int에 shl이라는 함수를 확장하면서 Infix 표기법으로 사용 가능하도록 선언한 경우입니다. 이 Extension 표기법의 경우 Swift의 그것과 동일한 기능을 합니다.

Classes

class Empty

class Person(val name: String)
val person = Person("Dennis")

class User(val email: String) {
  val username = email.toLowerCase()
  init {
    println("Creating user with email $email")
    println("UserName can be read at here: $username")
  }
}

우선 첫번째로 Kotlin의 클래스는 멤버함수도 변수도 갖지 않는 그냥 빈 클래스를 선언하는 것이 가능합니다. 심지어 중괄호도 필요 없습니다. 두번째로 Primary Constructor라고 불리는 클래스의 헤더에 생성자로 곧바로 선언하는 방식입니다. 위와 같이 클래스는 선언하는 줄에 생성자를 곧바로 선언하게 되면 멤버 변수 역시 알아서 생성이 됩니다. 위의 코드는 아래와 동일한 코드를 생성합니다.

class Person constructor(firstName: String) {}

이러한 Primary 생성자 형태로 클래스의 헤더에 원하는 인자값만을 선택하면 클래스의 초기화를 위한 어떤 코드도 필요가 없습니다. 하지만 세번째 예제와 같이 init을 사용한 초기화 코드 블록을 지정할 수 있습니다.

이 부분에서 흥미로운점은 (흥미롭다기 보다는 오버한다는 느낌이 좀 드는군요;;) 생략된 생성자 코드와 상관없이 곧장 email을 가져다 쓰는것이 가능합니다. 위의 코드중에 생략된 코드를 굳이 추측해서 Java스럽게 써보자면 다음과 같은 모습이 될 것 같습니다.

class User {
  val email
  val username

  User(val email: String) {
    this.email = email
    this.username = email.toLowerCase()

    println("Creating user with email $this.email")
    println("UserName can be read at here: $this.username")
  }
}

이제보니 이렇게 생성자를 헤더에 표기하고 기본 코드가 생략되는것은 Scala스러운 부분이었군요.

Companion Objects

Kotlin에서는 Java나 C#과 다르게 정적 메소드 선언을 할 수 없습니다. (문서엔 이렇게 써있긴 한데 @JvmStatic를 이용해서 JVM이 지원하는 Static Method의 선언이 가능합니다, 그렇지만 대부분의 경우에는 Package Level 함수를 사용하여 문제를 해결할것을 권장하고 있습니다.)  하지만 클래스를 초기화 하지 않고 내부의 코드에 접근해야 한다거나, 가령 팩토리 메소드같은 구현이 필요할 경우 Companion Objects라는것을 사용해 볼 수 있습니다. 이것을사용하면 클래스를 인스턴스화하지 않고도 Java의 Static Method와 동일한 문법으로 멤버 함수를 호출하는것이 가능합니다.

class Foo(...) {
  companion object {
    fun create(...) { return Foo(...) }
  }
}

Foo.Companion.create(...)
Foo.create(...)

코드를 보면 클래스 선언중에 companion object라는 블록을 만들어 그 안에 함수를 선언한 것을 볼 수 있습니다. 그리고 Foo.Companion을 통해서 해당 컴패니언 객체에 접근할 수 있으며 Companion을 생략하는것도 가능합니다.

Package Functions

패키지 함수라는 것은 또 Scala에서 가져온 방식인 모양입니다. 또는 그 사용 방법이 Node.JS와도 흡사합니다. 모든 파일에 정의된 클래스나 함수의 경우 모두 특정 패키지에 속하게 됩니다. 만약 정의되지 않았다면 디폴트 패키지에 포함되게 됩니다. 이러한 패키지는 다른 파일에서 Import되어 사용되어질 수 있으며 다음과 같은 형태로 사용 가능합니다.

package Foo
fun create(...) { return ... }

import Foo
Foo.create(...)

테스트 해보니 같은 패키지내에 있는 클래스들끼리는 import 문 없이도 곧장 정적 메소드처럼 호출해서 사용 가능하군요.

Lists

val names = listOf("Woody", "Buzz")
names[0]            // Woody
names[1] = "Jessie" // 에러!

val names = mutableListOf("Woody", "Buzz")
names[0]            // Woody
names[1] = "Jessie" // 문제 없음
names[0]            // Jessie

이부분은 특별한 설명할것 없이 수정 불가능한, 수정 가능한 배열을 만드는 과정을 보여주고 있습니다. 두번째의 경우 수정 가능한 배열임에도 val로 선언된것이 왜 문제 없는지는 다들 잘 아시겠지요? 하지만 Swift에서는 let (Kotlin의 val과 동일)으로 선언하면 Immutable 배열로 선언이 되는데 그 부분이 다르긴 하네요.

Maps

val m = mapOf("k" to "v", ...)
m["k"]        // "v"
m["k"] = "v!" // 에러 발생

val m = mutableMapOf("k" to "v", ...)
m["k"]        // "v"
m["k"] = "v!" // 문제 없음
m["k"]        // "v!"

for ((k, v) in m)
  ...

이 부분은 List와 별반 다를게 없는 부분인것 같습니다. 마지막에 for 부분을 보면 Key/Value를 손쉽게 매핑할 수 있다는 점은 편리해 보입니다.

Extension Functions

fun String.exclaim() = "$this!"
println("Hello World".exclaim())
// Hello World!

위에서 잠깐 언급했던 확장 함수입니다. 이미 존재하는 클래스에 멤버 함수를 추가할 수 있습니다. 위의 경우에는 String 클래스에 exclaim이라는 함수를 추가하여 사용하는 예시를 보여주고 있습니다. $this 접근까지 가능하다는것을 알 수 있으며 String과 동일한 Visibility를 가지는것을 알 수 있습니다.

Data Classes

이부분은 Scala의 Case 클래스와 굉장히 많은 부분 닮아 있는 클래스입니다. Java 프로젝트를 만들다 보면 수많은 Getter/Setter를 갖는 모델 클래스들을 많이 만들게 되는데 이런 멋진 기능을 이용하여 단 한줄로 바꾸는것이 가능합니다.

data class User(val email: String, val password: String)
val user = User("someone@example.com", "hunter2")
user.email // "someone@example.com"

data 지시자를 붙이고 클래스 헤더에 생성자를 선언하는것만으로 Public 멤버 변수가 알아서 선언이 됩니다. 심지어 toString() 까지 이쁘게 알아서 구현이 되어 위에서 선언한 user를 출력해 보면 다음과 같이 출력이 됩니다.

User(email=someone@example.com, password=hunter2)

위의 코드에서는 email과 password가 val로 선언이 되었기 때문에 한번 생성된 이후에 값의 변경이 불가능합니다. 하지만 var로 선언할 경우 자유롭게 값 변경이 가능해 집니다.

Default Values, Named Arguments

Kotlin에서는 함수에서 기본 값의 지정이 가능합니다.

fun f(x: Int = 0) = x * x
f()  // 0
f(1) // 1

x의 디폴트값으로 0이 정의되어있기 때문에 인자값으로 아무것도 넘기지 않을 경우 0 * 0이 수행되는것을 확인할 수 있습니다. 이것과 맞물려 변수 이름을 지정하여 값을 넘기는 것도 가능합니다.

fun f(x: Int = 2, y: Int) = x * y
f(y=2) // 4

x에는 기본값이 지정되어있고 y에 원하는 값을 넘겨본 결과입니다.

Visibility Modifiers

클래스, 객체, 인터페이스, 생성자, 함수, 프로퍼티(Setter 포함)들은 가시성 선언의 영향을 받습니다. 이는 Java와 매우 비슷하며 Public, Protected, Private, Internal이 있으며 아무것도 선언되지 않을 경우 기본적으로 Public으로 설정이 됩니다.

// 파일이름: example.kt
package foo
private fun foo() {}    // example.kt 파일 안에서만 접근 가능
public var bar: Int = 5 // 이 프로퍼티를 읽는것은 어디서든지 접근 가능하지만
  private set           // Setter는 이 파일 안에서만 접근 가능
internal val baz = 6    // 같은 모듈 안에서만 접근 가능

Control Flow

do { ... } while ( ... )

while (true) {
  if (false) break
  if (false) continue
  if (false) return
}

outer@ for (x in xs) {
  for (y in ys) {
    if (x == y) break@outer
  }
}

우선 do-while 문법이라던가 while 문법의 경우 Java와 동일해서 특별히 설명이 필요없을것 같습니다. break, continue, return 문법 역시 그대로 사용 가능합니다. 특이한것이 있다면 Label을 사용할 수 있다는 점인데요. 보시면 x와 y가 같을 경우 outer@까지 break로 빠져나간다는것을 의미합니다. 중첩된 반복문의 경우 원하는 지점까지 한번에 빠져나가고 싶을때가 있을텐데요 이런 경우에 요긴하게 사용할 수 있을것 같습니다.

Interfaces

interface Foo {
  fun foo()
  fun bar() { // Default Body }
}

class Bar : Foo {
  override fun foo() { ... }
}

Kotlin의 인터페이스는 Java 8의 인터페이스와 비슷합니다. Java 8에서 default 선언으로 가능하던 내부 코드를 가질 수 있으며 추상 멤버 함수 역시 가질 수 있습니다. 이 인터페이스를 구현(Implements) 하는 클래스는 추상 멤버 함수들을 반드시 구현해야 하고요.

Generics

Java와 완벽하게 호환하기 위해서일까요? 제네릭 역시 그대로 사용 가능합니다.

class Box<T>(t: T)
val box = Box(1) // box: Box<Int>

Box를 초기화 할 때 Int형 1을 넣어서 초기화 하였으므로 box는 자연스럽게 Box<Int>로 초기화가 됩니다.

Enums

enum class Direction {
  NORTH, SOUTH, EAST, WEST
}

enum class Color(val rgb: Int) {
  RED(0xFF0000)
  GREEN(0x00FF00)
  BLUE(0x0000FF)
}

이 부분은 Java와 크게 다른 부분이 없는 것 같습니다. 일반적인 enum의 선언도 가능하고 생성자를 갖는 enum 역시 선언이 가능합니다.

Type Casting

if (x is String)
  x.toUpperCase() // Smart Cast

val y = x as String // Unsafe Explicit Cast

val z = x as? String // Safe Explicit Cast

스마트 캐스팅은 위에서 언급을 했었으니 패스하겠습니다. 두번째는 명시적인 캐스팅인데 캐스팅이 불가능한 상황일 경우 예외가 발생할 수 있으니 안전하지 않은 캐스팅입니다. 마지막은 as? 키워드를 사용함으로써 Nullable 캐스팅을 하게 됩니다. 캐스팅이 불가능할 경우 z에는 null이 들어가게 되며 예외가 발생하지는 않습니다.

Exceptions

try {
  throw Exception("...")
}
catch (e: Exception) { ... }
catch (e: ...) { ... }
finally { ... }

예외 처리를 하는 부분은 Java와 동일합니다. 중첩 catch를 동일하게 적용가능합니다. 여기서 흥미로운점은 Scala와 마찬가지로 try-catch 구문이 결과값을 가진다는 것인데요 다음과 같은 활용이 가능합니다.

val a: Int? = try { parseInt(input) } catch (e: NumberFormatException) { null }

Closures

fun sum(xs: List<Int>): Int {
  var result = 0
  xs.forEach { result += it }
  return result
}

람다 표현식이나 익명 함수는 모두 자신의 클로저에 접근할 수 있습니다. 위의 예시에서는 forEach 람다식에서 바깥의 스코프에 있는 result에 접근하는것을 볼 수 있습니다. Java와 다르게 클로저 내에서 캡쳐된 변수의 값을 변경하는것도 가능합니다.

참고 : https://youtu.be/GIFD1AcNv-Q

 

Spring에서 GCM CCS (XMPP) 서버 구현하기

spring_title

이번에 Spring 프로젝트를 진행하면서 GCM 서버를 CCS 방식으로 구현하여 보았습니다. CCS 방식은 구글 서버와 지속적인 커넥션을 유지하며 비동기 양방향 통신을 하는 XMPP방식의 엔드포인트를 의미합니다. XMPP의 비동기적인 특성에 의해 적은 리소스로 더 많은 메시지를 발송할 수 있으며 양방향 통신을 지원하기 때문에 서버에서 디바이스로 메시지를 보내는것뿐 아니라 반대로 디바이스에서 서버로 응답 메시지를 되돌려 보낼 수도 있습니다. 이 때에 디바이스는 메시지 수신을 위해 연결했던 커넥션을 재활용하므로 배터리를 아낄 수 있습니다. 이렇게 좋은 장점들이 있지만 저는 우선 단방향 통신이면 충분한것 같습니다.

1. gradle에 필요한 라이브러리 추가하기

프로젝트가 gradle 프로젝트일경우 다음과 같이 smack 라이브러리를 dependencies 에 추가합니다. gradle 프로젝트가 아닐경우 적당히 라이브러리를 다운받아 프로젝트에 추가하시면 됩니다.

dependencies {
  ...
  compile "org.igniterealtime.smack:smack-core:4.0.4"
  compile "org.igniterealtime.smack:smack-tcp:4.0.4"
}

2. smack 클라이언트 클래스 작성

smack을 이용하여 구글 서버와 CCS 방식으로 통신하기 위해 클라이언트 클래스의 작성이 필요합니다. 깊게 생각할 것 없이 구글에서 제공하는 코드를 사용합니다.

public class SmackCcsClient {

    private static final Logger logger = Logger.getLogger("SmackCcsClient");

    private static final String GCM_SERVER = "gcm.googleapis.com";
    private static final int GCM_PORT = 5235;

    private static final String GCM_ELEMENT_NAME = "gcm";
    private static final String GCM_NAMESPACE = "google:mobile:data";

    static {

        ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
                new PacketExtensionProvider() {
                    @Override
                    public PacketExtension parseExtension(XmlPullParser parser) throws
                            Exception {
                        String json = parser.nextText();
                        return new GcmPacketExtension(json);
                    }
                });
    }

    private XMPPConnection connection;

    /**
     * Indicates whether the connection is in draining state, which means that it
     * will not accept any new downstream messages.
     */
    protected volatile boolean connectionDraining = false;

    /**
     * Sends a downstream message to GCM.
     *
     * @return true if the message has been successfully sent.
     */
    public boolean sendDownstreamMessage(String jsonRequest) throws
            NotConnectedException {
        if (!connectionDraining) {
            send(jsonRequest);
            return true;
        }
        logger.info("Dropping downstream message since the connection is draining");
        return false;
    }

    /**
     * Returns a random message id to uniquely identify a message.
     *
     * <p>Note: This is generated by a pseudo random number generator for
     * illustration purpose, and is not guaranteed to be unique.
     */
    public String nextMessageId() {
        return "m-" + UUID.randomUUID().toString();
    }

    /**
     * Sends a packet with contents provided.
     */
    protected void send(String jsonRequest) throws NotConnectedException {
        Packet request = new GcmPacketExtension(jsonRequest).toPacket();
        connection.sendPacket(request);
    }

    /**
     * Handles an upstream data message from a device application.
     *
     * <p>This sample echo server sends an echo message back to the device.
     * Subclasses should override this method to properly process upstream messages.
     */
    protected void handleUpstreamMessage(Map<String, Object> jsonObject) {
        // PackageName of the application that sent this message.
        String category = (String) jsonObject.get("category");
        String from = (String) jsonObject.get("from");
        @SuppressWarnings("unchecked")
        Map<String, String> payload = (Map<String, String>) jsonObject.get("data");
        payload.put("ECHO", "Application: " + category);

        // Send an ECHO response back
        String echo = createJsonMessage(from, nextMessageId(), payload,
                "echo:CollapseKey", null, false);

        try {
            sendDownstreamMessage(echo);
        } catch (NotConnectedException e) {
            logger.log(Level.WARNING, "Not connected anymore, echo message is not sent", e);
        }
    }

    /**
     * Handles an ACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle ACKs.
     */
    protected void handleAckReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleAckReceipt() from: " + from + ", messageId: " + messageId);
    }

    /**
     * Handles a NACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle NACKs.
     */
    protected void handleNackReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleNackReceipt() from: " + from + ", messageId: " + messageId);
    }

    protected void handleControlMessage(Map<String, Object> jsonObject) {
        logger.log(Level.INFO, "handleControlMessage(): " + jsonObject);
        String controlType = (String) jsonObject.get("control_type");
        if ("CONNECTION_DRAINING".equals(controlType)) {
            connectionDraining = true;
        } else {
            logger.log(Level.INFO, "Unrecognized control type: %s. This could happen if new features are " + "added to the CCS protocol.",
            controlType);
        }
    }

    /**
     * Creates a JSON encoded GCM message.
     *
     * @param to RegistrationId of the target device (Required).
     * @param messageId Unique messageId for which CCS will send an
     *         "ack/nack" (Required).
     * @param payload Message content intended for the application. (Optional).
     * @param collapseKey GCM collapse_key parameter (Optional).
     * @param timeToLive GCM time_to_live parameter (Optional).
     * @param delayWhileIdle GCM delay_while_idle parameter (Optional).
     * @return JSON encoded GCM message.
     */
    public static String createJsonMessage(String to, String messageId,
                                           Map<String, String> payload, String collapseKey, Long timeToLive,
                                           Boolean delayWhileIdle) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("to", to);
        if (collapseKey != null) {
            message.put("collapse_key", collapseKey);
        }
        if (timeToLive != null) {
            message.put("time_to_live", timeToLive);
        }
        if (delayWhileIdle != null && delayWhileIdle) {
            message.put("delay_while_idle", true);
        }
        message.put("message_id", messageId);
        message.put("data", payload);
        return JSONValue.toJSONString(message);
    }

    /**
     * Creates a JSON encoded ACK message for an upstream message received
     * from an application.
     *
     * @param to RegistrationId of the device who sent the upstream message.
     * @param messageId messageId of the upstream message to be acknowledged to CCS.
     * @return JSON encoded ack.
     */
    protected static String createJsonAck(String to, String messageId) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("message_type", "ack");
        message.put("to", to);
        message.put("message_id", messageId);
        return JSONValue.toJSONString(message);
    }

    /**
     * Connects to GCM Cloud Connection Server using the supplied credentials.
     *
     * @param senderId Your GCM project number
     * @param apiKey API Key of your project
     */
    public void connect(long senderId, String apiKey)
            throws XMPPException, IOException, SmackException {
        ConnectionConfiguration config =
                new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
        config.setSecurityMode(SecurityMode.enabled);
        config.setReconnectionAllowed(true);
        config.setRosterLoadedAtLogin(false);
        config.setSendPresence(false);
        config.setSocketFactory(SSLSocketFactory.getDefault());
        config.setDebuggerEnabled(false);

        connection = new XMPPTCPConnection(config);
        connection.connect();

        connection.addConnectionListener(new LoggingConnectionListener());

        // Handle incoming packets
        connection.addPacketListener(new PacketListener() {

            @Override
            public void processPacket(Packet packet) {
                logger.log(Level.INFO, "Received: " + packet.toXML());
                Message incomingMessage = (Message) packet;
                GcmPacketExtension gcmPacket =
                        (GcmPacketExtension) incomingMessage.
                                getExtension(GCM_NAMESPACE);
                String json = gcmPacket.getJson();
                try {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> jsonObject =
                            (Map<String, Object>) JSONValue.parseWithException(json);

                    // present for "ack"/"nack", null otherwise
                    Object messageType = jsonObject.get("message_type");

                    if (messageType == null) {
                        // Normal upstream data message
                        handleUpstreamMessage(jsonObject);

                        // Send ACK to CCS
                        String messageId = (String) jsonObject.get("message_id");
                        String from = (String) jsonObject.get("from");
                        String ack = createJsonAck(from, messageId);
                        send(ack);
                    } else if ("ack".equals(messageType.toString())) {
                        // Process Ack
                        handleAckReceipt(jsonObject);
                    } else if ("nack".equals(messageType.toString())) {
                        // Process Nack
                        handleNackReceipt(jsonObject);
                    } else if ("control".equals(messageType.toString())) {
                        // Process control message
                        handleControlMessage(jsonObject);
                    } else {
                        logger.log(Level.WARNING,
                                "Unrecognized message type (%s)",
                                messageType.toString());
                    }
                } catch (ParseException e) {
                    logger.log(Level.SEVERE, "Error parsing JSON " + json, e);
                } catch (Exception e) {
                    logger.log(Level.SEVERE, "Failed to process packet", e);
                }
            }
        }, new PacketTypeFilter(Message.class));

        // Log all outgoing packets
        connection.addPacketInterceptor(new PacketInterceptor() {
            @Override
            public void interceptPacket(Packet packet) {
                logger.log(Level.INFO, "Sent: {0}", packet.toXML());
            }
        }, new PacketTypeFilter(Message.class));

        connection.login(senderId + "@gcm.googleapis.com", apiKey);
    }

    /**
     * XMPP Packet Extension for GCM Cloud Connection Server.
     */
    private static final class GcmPacketExtension extends DefaultPacketExtension {

        private final String json;

        public GcmPacketExtension(String json) {
            super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
            this.json = json;
        }

        public String getJson() {
            return json;
        }

        @Override
        public String toXML() {
            return String.format("<%s xmlns=\"%s\">%s</%s>",
                    GCM_ELEMENT_NAME, GCM_NAMESPACE,
                    StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
        }

        public Packet toPacket() {
            Message message = new Message();
            message.addExtension(this);
            return message;
        }
    }

    private static final class LoggingConnectionListener
            implements ConnectionListener {

        @Override
        public void connected(XMPPConnection xmppConnection) {
            logger.info("Connected.");
        }

        @Override
        public void authenticated(XMPPConnection xmppConnection) {
            logger.info("Authenticated.");
        }

        @Override
        public void reconnectionSuccessful() {
            logger.info("Reconnecting..");
        }

        @Override
        public void reconnectionFailed(Exception e) {
            logger.log(Level.INFO, "Reconnection failed.. ", e);
        }

        @Override
        public void reconnectingIn(int seconds) {
            logger.log(Level.INFO, "Reconnecting in %d secs", seconds);
        }

        @Override
        public void connectionClosedOnError(Exception e) {
            logger.info("Connection closed on error.");
        }

        @Override
        public void connectionClosed() {
            logger.info("Connection closed.");
        }
    }
}

3. Spring Sender 컴포넌트 작성

위에서 작성한 SmackCssClient는 한번 초기화 되면 구글 서버와 연결을 맺고 커넥션을 지속합니다. 이 하나의 커넥션으로 다수의 메시지를 보낼 수 있으며 외부의 요인에 의해 연결이 끊어질 경우 자동으로 다시 재연결을 하게 됩니다. 그러므로 Spring에서 컴포넌트로 등록하여 이 객체(빈)의 관리를 컨테이너에 맞기고 생성 직후 커넥션을 맺고 비동기 매소드인 send를 통해 딜레이 없이 메시지를 발송할 수 있도록 하였습니다. (@Async가 사용가능하도록 구현되어있어야 합니다)

페이로드를 만드는 부분에서 키로 msg를 사용하는데 그것은 현재의 프로젝트에 맞춰 적절히 구현하시면 됩니다.

@Component
public class GcmCcsSender {

    private static final Logger LOG = LoggerFactory.getLogger(GcmCcsSender.class);

    private static final long senderId = {SENDER_ID};
    private static final String password = "{PASSWORD}";
    private SmackCcsClient mCssClient = new SmackCcsClient();

    @PostConstruct
    public void init() {
        try {
            mCssClient.connect(senderId, password);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }

    @Async
    public void send(String registrationId, String message) {
        try {
            Map<String, String> payload = new HashMap<String, String>();
            payload.put("msg", message);

            String jsonMessage = mCssClient.createJsonMessage(registrationId, mCssClient.nextMessageId(), payload, "", 10000L, true);
            mCssClient.sendDownstreamMessage(jsonMessage);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }
}

 4. 메시지 발송하기

다음은 메시지 발송을 위한 단순한 예시입니다. 적당한 방법으로 서버에서 수집한 사용자들의 registrationId를 이용하여 메시지를 발송하시면 됩니다.

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private GcmCcsSender mGcmCcsSender;

    @RequestMapping(value = "/{registrationId}", method = RequestMethod.GET)
    public void monsters(@PathVariable("registrationId") String registrationId) {
        String message = "TEST MESSAGE";

        mGcmCcsSender.send(registrationId, message);
    }
}

참고 : http://developer.android.com/google/gcm/ccs.html