Web/Kotlin & Spring

[Kotlin & Spring] - 코틀린 문법 고급

Hyunseo😊 2023. 6. 25. 12:10

코틀린에서의 컬렉션

  • 코틀린 컬렉션
  • 코틀린 표준 라이브러리는 기본 컬렉션 타입인 List, Set, Map을 제공합니다.
  • 컬렉션은 두 가지 종류로 나뉩니다.
    • 불변 컬렉션(immutable): 읽기 전용 컬렉션
    • 가변 컬렉션(Mutable): 삽입, 수정, 삭제와 같은 쓰기 작업이 가능한 컬렉션
  • 컬렉션 계층 다이어그램

  • 중요하다 싶은 코드와 내용만 아래에 정리해 보도록 하겠습니다.
    // mutable
    val mutableCurrencyList = mutableListOf<String>().apply {
        add("달러")
        add("유로")
        add("원")
    }
  • apply함수를 사용하면 위와같이 가독성이 좋아집니다.
val numberMap = mapOf("one" to 1, "two" to 2)
  • to라는 중위 함수로 키-밸류 구조를 전달합니다.
val numberList: List<Int> = buildList { // buildMap MutableMap
add(1)
add(2)
add(3) }
  • 컬렉션 빌더를 사용하여 컬렉션을 생성할 수 있습니다. 
  • build내부에서는 Mutable이여서 add를 사용할 수 있지만, 반환시에는 Immutable즉 불변입니다.
  • 그리고 특정 구현체를 사용하고 싶은 경우 구현체의 생성자를 사용하면 됩니다.
  • 코틀린의 컬렉션은 Iterable의 구현체이므로 순차적 반복이 가능합니다.
currency.forEach {
  println(it)
}

val lowerList = listOf("a", "b", "c", "d")
val upperList = mutableListOf<String>()
for (lowerCase in lowerList) {
  upperList.add(lowerCase.uppercase())
}
println(upperList)
// [A, B, C, D]


val upperList = lowerList.map { it.uppercase() }
println(upperList)
// [A, B, C, D]

val filteredList = mutableListOf<String>()
for (upperCase in upperList) {
  if (upperCase == "A" || upperCase == "C") {
    filteredList.add(upperCase)
  } 
}
println(filteredList)
// [A, C]


val filteredList = upperList.filter { it == "A" || it == "C" }
println(filteredList)
// [A, C]
  • 위와같이 코틀린 표준 라이브러리에서는 컬렉션 사용시 자주 사용되는 패턴인 forEach, map, filter와 같은 유용한 인라인 함수를 제공합니다.
val filteredList = upperList
        .asSequence()
        .filter { it == "A" || it == "C" }
println(filteredList)
// kotlin.sequences.FilteringSequence@7f560810

val filteredList = upperList
        .asSequence()
        .filter { it == "A" || it == "C" }
        .toList()
println(filteredList)
// [A, C]
  • 코틀린에서는 sequence를 사용해 자바8의 스트림과 같이 Lazy하게 동작시킬 수 있습니다.
  • 시퀀스 API도 최종 연산자를 사용해야 중간 연산자가 동작합니다.
더보기

일반적으로 인라인 함수는 각각 함수가 동작할때마다 조건에 맞는 컬렉션을 사용합니다

그리고 시퀀스 API는 각각의 함수가 동작할때 시퀀스를 생성하고 최종 연산자를 호출할 때 1개의 컬렉션을 생성합니다. 이를 실제로 벤치마크상으로 측정하면 일반적으론 인라인 함수가 빠르기 때문에 인라인 함수를 쓰고 대량의 데이터를 다룰때는 시퀀스 API를 사용하길 추천합니다.

 

코틀린에서의 제네릭

제네릭에 대해 알아보기 전에 LSP(리스코브 치환의 원칙: The Liskov Substitution Principle)에 대해 알아보겠습니다.

The Liskov Substitution Principle

더보기

Subtypes must be substitutable for their base types

 

서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.

LSP는 상속의 기본적인 메커니즘을 표현하고 있습니다. LSP는 사용자의 관점에서 기능에 영향을 미치지 않고 서브 클래스를 부모 클래스로 대체할 수 있어야 한다는 것입니다.

  의미
공변성(convariant) T'가 T의 서브타입이면, C<T'>는 C<? extends T>의 서브타입입니다. = T<out T> <- kotlin
반공변셩(contravariant) T'가 T의 서브타입이면, C<T>는 C<? super T'>의 서브타입입니다. = T<in T'>
무공변성(invariant) C와 C<T'>은 아무런 관계가 없다.

상속이 있으면 다형성도 같이 가져갈 수 있습니다. 하지만 다형성의 이점을 얻기 위해서는 하위 클래스와 상위 클래스의 클라이언트 간의 규악을 향상 지켜야 합니다. LSP의 원칙에 따라서 구현을 하게 된다면 OCP는 자연스럽게 따라오게 됩니다. LSP는원칙을 준수하는 상속구조를 제공함으로써 이를 바탕으로한 OCP원칙을 통해 확장하는 부분에 다형성을 제공하여 변화에 열려있는 프로그램을 만들 수 있게 되는 것입니다!

 

간단한 예를 들겠습니다.

변성 

Integer는 Number를 상속받아 만들어진 객체입니다. 그래서 Integer는 Number의 하위타입이라고 할 수 있어 아래와 같은 코딩이 가능합니다.

public void test() {
	List<Number> list;
    list.add(Integer.valueOf(1));
}

하지만 List<Integer>는 List<Number>의 하이타입이 될 수 없습니다. 이러한 상황에서 Java나 Kotlin에서는 type parameter에 타입 경계를 명시하여 Sub-Type, Super-Type을 가능하게 합니다. 그걸 변성이라고 합니다.

 

interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun tamingHamster(cage: Cage<out Hamster>) {
    println("길들이기 : ${cage.get().name}")
}

fun main() {

    val animal = object : Cage<Animal> {
        override fun get(): Animal {
            return Animal()
        }
    }

    val hamster = object : Cage<Hamster> {
        override fun get(): Hamster {
            return Hamster("Hamster")
        }
    }

    val goldenHamster = object : Cage<GoldenHamster> {
        override fun get(): GoldenHamster {
            return GoldenHamster("Leo")
        }
    }
//    tamingHamster(animal)
    tamingHamster(hamster)
    tamingHamster(goldenHamster)

}

tamingHamster 함수는 Hamster의 서브타입만을 받기 때문에 animal변수는 들어갈 수 없습니다.

반공변성 

이는 그냥 공변성의 반대 개념으로 자기 자신과 부모 객체만을 허용합니다.

interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun ancestorOfHamster(cage: Cage<in Hamster>) {
    println("ancestor : ${cage.get()::javaClass.name}")
}

fun main() {

    val animal = object : Cage<Animal> {
        override fun get(): Animal {
            return Animal()
        }
    }

    val hamster = object : Cage<Hamster> {
        override fun get(): Hamster {
            return Hamster("Hamster")
        }
    }

    val goldenHamster = object : Cage<GoldenHamster> {
        override fun get(): GoldenHamster {
            return GoldenHamster("Leo")
        }
    }
    ancestorOfHamster(animal)
    ancestorOfHamster(hamster)
//    ancestorOfHamster(goldenHamster)

}

ancestorOfHamster에서 햄스터의 조상을 찾는 함수를 구현하여 햄스터를 포함한 그 조상들만 허용하도록 했습니다.

 

무공변성 

Java, Kotlin의 Generic은 기본적으로 무공변성으로 아무런 설정이 없는 기본 Generic을 말합니다. in, out을 안써주면 코드에서 Cage<Animal>, Cage<Hamster>, Gage<GoldenHamster>는 서로 각각 연관이 없는 객체로서 무공변성의 적절한 예시입니다.

 

코틀린에서의 지연초기화

  • 지연초기화는 대상에 대한 초기화를 미뤘다가 실제 사용시점에 초기화 하는 방법을 말합니다.
  • 지연초기화는 많은 상황에서 쓰이고 있는데
    • 웹페이지에서 특정 스크롤에 도달했을때 컨텐츠를 보여주는 무한 스크롤
    • 싱글톤 패턴의 지연초기화
      • 싱글톤 패턴의 초기화 방법에는 DCL(Double Check Locking), Enum싱글톤, Eager, Lazy초기화 방법이 있다.
    • JPA의 엔티티 LazyLoading기능
    • 이외에도 지연초기화는 많은 상황에서 사용된다.
class HelloBot {
    var greeting: String? = null
    fun sayHello() = println(greeting)
}
fun getHello() = " "
fun main() {
    val helloBot = HelloBot()
    //...
    helloBot.greeting = getHello()
    helloBot.sayHello()
}
  • 다음과 같이 변수를 선언한 시점에 초기화를 하지 않다가 특정 시점 이후에 초기화가 필요할 때 쓴다
  • 하지만 이렇게 구현하면 나중에 값을 수정할 수 있게 var로 선언하여 가변으로 만들어야 합니다.
  • 코틀린의 데이터클래스에서도 var로 선언하는 것은 몇 가지 위험성을 가지고 있으므로 될 수 있으면 모두 불변으로 유지하는 것이 좋습니다.
  • 위처럼 그냥 코틀린에서 제공하는 by lazy를 사용하면 불변성을 유지하면서 지연초기화가 가능합니다.
  • 이를 사용하면 사용 시점에 단 1회만 초기화 로직이 동작하게 됩니다.
  • 또한 이는 기본적으로 멀티-스레드 환경에서도 안전하게 동작하도록 설계되었습니다.
  • 만약 LazyThreadSafetyMode.NONE 모드로 변경한 뒤 멀티-스레드 환경에서 실행하면 Race-condition문제가 발생하게 됩니다.
  • 또한 멀티-스레드 환경이여도 동기화가 필요하지 않다면 PUBLICATION 모드를 사용합니다.

 

class `7_LateExample` {
    @Autowired
    lateinit var service: TestService
    lateinit var subject: TestTarget
    @SetUp
    fun setup() {
        subject = TestTarget()
    }
    @Test
    fun test() {
        subject.doSomething()
    }
}
  • 가변 프로퍼티에 대한 지연초기화가 필요한 때도 있습니다.
  • 예를 들어 위와같이 테스트 코드를 작성할 때 특정 애노테이션에 초기화 코드를 작성해야 하는 경우가 있게 됩니다.
  • lateinit을 사용할 때에는 무조건 가변 변수여야 합니다.
  • 그리고 무조건 lateinit을 이용한 경우엔 항상 non-null입니다.
class `7_LateInit` {
    lateinit var text: String
    fun printText() {
        println(text)
text = " " }
}
fun main() {
    val test = `7_LateInit`()
    test.printText()
}
// UninitializedPropertyAccessException
  • 위와같이 초기화 전에 사용하게 되면 이에 해당하는 Exception을 띄우게 됩니다.
  • 이는 특정 DI와 같이 외부에서 초기화를 해주는 경우를 염두해두고 만든 기능이기 때문에 초기화 전에 사용하더라도 컴파일 오류가 발생하지 않습니다.
  • 그래서 초기화 여부를 파악하고 사용하려면 isInitialized프로퍼티를 사용해야 합니다.
class `7_LateInit` {
    lateinit var text: String
    fun printText() {
        if (this::text.isInitialized) {
println(" ")
            println(text)
        } else {
text = " "
            println(text)
        }
} }
fun main() {
    val test = `7_LateInit`()
    test.printText()
}
  • 그리고 this::text.isIntialized를 반환하는 새 메소드를 만들어서 외부에서 사용하게 하는 것이 맞습니다.

코틀린 스코프 함수

  • 코틀린의 표준 라이브러리에는 객체의 컨텍스트 내에서 코드 블록을 실행하기 위해서만 존재하는 몇가지 함수가 포함되어 있는데 이를 스코프 함수라고 합니다.
  • 스코프 함수의 코드 블록 내부에서는 변수명을 사용하지 않고도 객체에 접근할 수 있는데 그 이유는 수신자 객체에 접근할 수 있기 때문입니다.
  • 수신자 객체는 람다식 내부에서 사용할 수 있는 객체에 대한 참조입니다.
  • 스코프 함수를 사용하면 수신자 객체에 대한 참조로 this또는 it입니다.

  • 코틀린은 총 5가지의 유용한 스코프 함수를 제공하며 본질적으로 유사한 기능을 제공하는데 각각 어떤 차이점이 있는지 보겠습니다.

let


fun main() {
    val str: String? = null
    str?.let {
        println(it) // 아무것도 출력되지 않음
    }
}
  • let은 함수의 결과를 반환합니다. (let함수 내부의 마지막 코드가 결과로 반환됩니다.)
fun main() {
    val str: String? = "안녕"
    val result = str?.let {
		println(it)
		1234 // let 함수 마지막 코드가 결과로 반환
	}
	println(result) // 1234
}
  • let을 쓸 때 호출이 중첩되면 코드가 복잡해지므로 if를 사용 하는 경우가 좋은 때가 있습니다.

fun main() {
    val str: String? = "안녕"

    var result = str?.let {
        println(it)

        val abc: String? = "abc"

        abc?.let {

            val def: String? = "def"
            def?.let {
                println("abcdef가 null이 아님")
            }
        }
        1234
    }
    println(result)
}
  • if문으로 수정후

fun main() {
    val str: String? = "안녕"

    var result = str?.let {
        println(it)

        val abc: String? = "abc"
        val def: String? = "def"

        if (!abc.isNullOrEmpty() && !def.isNullOrEmpty()) {
            println("abcdef가 null이 아님")
        }
        1234
    }
    println(result)
}

run


  • run은 수신 객체의 프로퍼티를 구성하거나 새로운 결과를 반환하고 싶을때 사용합니다.
class DatabaseClient {
    var url: String? = null
    var username: String? = null
    var password: String? = null

    // DB에 접속하고 Boolean결과를 반환
    fun connect(): Boolean {
        println("DB 접속 중 ...")
        Thread.sleep(1000)
        println("DB 접속 완료")
        return true
    }
}

fun main() {
    val connected = DatabaseClient().run {
        url = "localhost:3306"
        username = "mysql"
        password = "1234"
        connect()
    }

    println(connected)
}
  • 여기서 let을 사용할 순 있으나 it을 사용해야 하기 때문에 불편합니다.

with


  • with는 결과 반환없이 내부에서 수신 객체를 이용해 다른 함수를 호출하고 싶을 때 사용합니다.
class DatabaseClient {
    var url: String? = null
    var username: String? = null
    var password: String? = null

    // DB에 접속하고 Boolean결과를 반환
    fun connect(): Boolean {
        println("DB 접속 중 ...")
        Thread.sleep(1000)
        println("DB 접속 완료")
        return true
    }
}

fun main() {
    val connected = with(DatabaseClient()) {
        url = "localhost:3306"
        username = "mysql"
        password = "1234"
        connect()
    }

    println(connected)
}

위와같이 run으로 작성되었었던 코드를 with로 바꾼 것을 볼 수 있습니다. 이는 확장함수를 사용하지 않는 다는 점에서 run과 다릅니다. 그리고 결괏값도 run과 동일하게 동작합니다.

 

apply


  • apply는 수신 객체의 프로퍼티를 구성하고 수신 객체를 그대로 결과로 반환하고 싶을 때 사용합니다.
  • 앞서 소개한 let, run, with는 함수의 결과가 반환 타입으로 변환되는데 반해서 apply는 수신 객체 그대로 반환된다는 특징이 있습니다.
val client: DatabaseClient = DatabaseClient().apply {
    url = "localhost:3306"
    username = "mysql"
    password = "1234"
connect() }

 

also


  • also는 부수 작업을 수행하고 전달받은 수신 객체를 그대로 결과로 반환하고 싶을 때 사용합니다.
fun main() {
  User(name = "tony", password = "1234").also {
    it.validate()
  }
}

 

더보기

this는 키워드입니다. 키워드는 사전에 정의된 예약어이기 때문에 다른 의미로 사용할 수 없지만 it는 특정 용도에서만 작동하는 소프트 키워드이기 때문에 다른 용도로 사용할 수 있습니다.

 

val this: String? = null // 컴파일 오류

val it: String? = null // 작동

  • ㅇ또한 중첩 함수내에서 외부 함수에 대한 접근을 하려면 it는 자기 자신의 참조이기 때문에 불가능합니다.

 

코틀린에서의 고급 예외처리방법

  • Java7에서도 try-with-resource구문을 사용하면 자동으로 리소스를 close처리 해줍니다.
  • 정확히는 Closable 또는 상위 개념인 Autoclosable인터페이스의 구현체에 대해서 자동으로 close하는 것을 말합니다.
  • Wrtier를 상속받은 printWriter는 close 메서드를 재정의 하는 것을 찾아보면 볼 수 있을겁니다.
  • 코틀린에서는 try-catch를 통한 예외처리외에도 함수형 스타일의 Reuslt 패턴을 구현한 runCatching을 제공합니다.
    • Result패턴이란 함수가 성공하면 캠슐화된 결과를 반환하거나 예외가 발생하면 지정한 작업을 수행하는 패턴을 말합니다.
val result2 = runCatching { getStr() }
        .getOrElse {
			println(it.message)
			"기본값"
         }
    println(result2)
  • 실제 runCatching 내부는 아래와 같이 생겼습니다. 
public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}
  • 실제로 Result 내부 동반 객체에서 성공 상태와 실패 상태에 따라 Result를 다르게 생성하고 있는 것을 볼 수 있을겁니다.
public value class Result<out T> internal constructor(
    internal val value: Any?
) : Serializable {
    public val isSuccess: Boolean get() = value !is Failure
    public val isFailure: Boolean get() = value is Failure
... }
  • Result의 선언부
  • isSuccess는 성공적인 결과를 나타내는 경우 true를 반환합니다.
  • isFailure는 실패한 결과를 나타내는 경우 true를 반환합니다.
  • Result의 다양한 기능들도 존재합니다.
    • getOrNull: 실패인 경우 null을 반환
    • exceptionOrNull: 성공인 경우 null을 반환하고 실패한 경우 Throwable을 반환
    • getOrDefault: 성공시엔 성공 값을 반환하고 실패시엔 지정한 기본값을 반환
    • getOrElse: 실패시 수신자 객체로 Throwable을 전달받고 let, run과 같이 함수의 결과를 반환
    • getOrThrow: 성공시엔 값을 반환하고 실패시엔 예외를 발생시킵니다.
    • map: 성공인 경우 원하는 값으로 변경할 수 있습니다.
    • mapCatching: map처럼 성공인 경우 원하는 값으로 변경할 수 있다. 예외가 발생하면 재처리 가능
    • recover: map은 성공시에 원하는 값으로 변경하지만 recover는 실패시에 원하는 값으로 변경할 수 있다.
    • recoverCatching: recoverCatching내에서 예외가 발생할 경우 재처리 가능