아이템 1 - 가변성을 제한하라
- var보다는 val을 사용하고, mutable 프로퍼티보다는 immutable 프로퍼티를 사용하는 것이 좋습니다.
- 변경이 필요할 경우에는, 처음부터 읽고 쓸 수 있는 요소를 사용하기 보단 data class의 copy를 활용하는 것이 좋습니다.
- 변경 가능 지점은 최소화하여 불필요한 곳에서 변경이 일어나는 것을 막는 것이 좋습니다.
코틀린에서 읽고 쓸 수 있는 프로퍼티(read-write property) var을 사용하거나, mutable 객체를 사용하면 상태를 가질 수 있습니다.
요소가 상태를 갖게 되면, 해당 요소의 동작은 사용 방법 뿐 아니라 그 이력에도 의존하게 됩니다.
상태를 갖는 요소는 시간의 변화에 따라 바뀌는 요소를 표현할 수는 있지만, 관리하기 어렵습니다.
- 프로그램을 관리하고 디버깅하기 힘듬
- 상태 변화가 일어날 수 있는 부분들을 모두 추적해야하기 때문에 이후 수정할 때 영향 범위 파악 시 어려움
- 가변성(mutability)이 있으면, 코드 실행 추론이 어려움
- 현재의 값이 이후 시점의 값과 동일할 것으로 볼 수 없기 때문에 추론이 어려움
- 멀티스레드 프로그램일 때 동기화가 필요함
- 공유 객체라면, 여러 곳에서 동시에 접근함으로써 올바른 값으로 업데이트가 되지 않을 수 있음(예: 코루틴으로 가변적인 공유 자원 접근)
- 상태 변경에 따라 변경될 수 있는 다른 부분에 알려 함께 변경되도록 해야 함
- mutableList를 정렬하는 로직이 있을 때, 해당 리스트에 원소가 추가되면 재정렬이 필요함
코틀린의 가변성 제한
immutable 객체 사용 장점
- 한 번 정의된 상태가 유지되므로, 코드 이해가 쉬움(예측이 쉬움)
- 공유객체로 사용하여도, 상태를 변경할 수 없어 병렬 처리가 가능함
- 한 번 초기화된 이후 재할당이 안되므로 참조가 변경되지 않아 쉽게 캐시 가능
- 값을 외부에 넘길 때 상태 변경을 우려한 방어적 복사본을 만들 필요가 없음
- immutable 객체는 set 또는 map의 key로 사용이 가능함
- set와 map은 내부적으로 해시 테이블을 사용하는데 처음 요소를 넣을 때의 값을 기반으로 버킷을 결정함(요소가 변경되면, 해시 테이블 내부에서 찾을 수 없음)
읽기 전용 프로퍼티(val)과 읽고 쓰기 전용 프로퍼티(var)
읽기 전용 프로퍼티는 완전히 변경 불가능한 것은 아닙니다. 재할당이 불가능할 뿐입니다.
- mutable 객체를 담고 있는 읽기 전용 프로퍼티는 내부적으로 변할 수 있습니다.
- var 프로퍼티를 활용한 사용자 정의 게터를 가진 읽기 전용 프로퍼티는 값이 변경될 수 있습니다.
참고) 스마트 캐스트(smart cast)
코틀린에서는 컴파일러가 변수가 변경되지 않는다고 보장할 수 있을 때 스마트 캐스트가 작동합니다.
읽기 전용 프로퍼티를 사용하더라도, 사용자 정의 게터(custom getter)를 사용할 경우 스마트 캐스트가 동작하지 않습니다.
가변 컬렉션과 읽기 전용 컬렉션 구분
기본적으로 읽기 전용 컬렉션은 변경을 위한 메소드를 가지고 있지 않습니다. 위 예시처럼, 가변 컬렉션은 읽기 전용 컬렉션을 상속 받고 변경을 위한 메소드를 추가로 가지고 있습니다. 읽기 전용 컬렉션이라는 의미가 내부 값을 변경할 수 없다는 의미는 아니지만, 읽기 전용 인터페이스가 내부 값 변경을 지원하지 않아 변경할 수 없습니다.
코틀린은 내부적으로 immutable하지 않은 컬렉션을 외부적으로 immutable하게 보이게 만들어서 플랫폼 고유의 컬렉션을 사용해도 안정적입니다.
읽기 전용 컬렉션을 mutable 컬렉션으로 사용이 필요할 경우, 다운 캐스팅이 아닌, 복제(copy)를 통해 새로운 mutable 컬렉션을 만들어 사용해야 합니다.
- Iterable<T>.map과 같은 함수는 ArrayList를 새로 생성하여 List로 리턴합니다.
- ArrayList는 java.util.ArrayList를 의미하기 때문에 변경 가능한 리스트이지만, 최종적으로 반환하는 타입이 List이기 때문에 읽기 전용으로 반환합니다.
data class의 copy
immutable 객체는 변경할 수 없어 일부 값을 수정한 새로운 객체를 만들 메소드가 필요할 수 있습니다. data class를 사용하면, 기본적으로 copy 메소드를 제공하여 이를 편리하게 할 수 있습니다.
val mutable collection VS var immutable collection
변경할 수 있는 리스트를 만들 때는 아래와 같이 두 가지 방식으로 구현할 수 있습니다. 두 경우 모두 컬렉션에 변경을 일으킬 수 있지만, 변경 가능 지점이 상이합니다.
- 읽기 전용 프로퍼티로 mutable 컬렉션
- 변경 가능 지점: 구체적인 리스트 구현 내부에 존재
- 위 사진에서 볼 수 있듯이, add()의 구현이 리스트 구현 내부에 존재하여 멀티스레드 처리가 이루어질 때 내부적으로 적절한 동기화가 일어나는지 확인이 어려움
- 변경이 일어나도, 읽기 전용 프로퍼티가 바라보는 참조는 변경되지 않음
- 변경 가능 지점: 구체적인 리스트 구현 내부에 존재
- 읽고 쓸 수 있는 프로퍼티로 immutable 컬렉션
- 변경 가능 지점: 프로퍼티 자체가 변경 가능 지점
- 프로퍼티 자체가 변경 지점이므로, 동기화가 필요할 경우 개발자가 직접 처리를 할 수 있음
- 변경이 일어날 경우, 읽고 쓸 수 있는 프로퍼티가 바라보는 참조가 변경됨
- 변경 가능 지점: 프로퍼티 자체가 변경 가능 지점
참고) var list = mutableListOf(1, 2, 3)처럼 변경 가능 지점을 프로퍼티와 컬렉션 모두에 여는 것은 최악의 방법입니다.
상태를 변경할 수 있는 불필요한 방법은 만들지 않도록 해야 합니다.
변경 가능 지점 노출하지 않기
mutable 객체를 외부에 노출할 때에는 아래 방법들을 활용하여 잘못된 위치에서 수정이 발생하지 않도록 해야 합니다.
- 방어적 복제(defensive copying)을 통한 새로운 객체로 반환
- 읽기 전용 슈퍼타입으로 업캐스트하여 가변성을 제한한 뒤 반환
아이템 2 - 변수의 스코프를 최소화하라
스코프란 요소를 볼 수 있는 영역으로, 잘못된 영역에서 사용되지 않도록 변수와 프로퍼티의 스코프를 최소화하는 것이 좋습니다.
- 변수의 스코프 범위가 너무 넓으면, 의도치 않게 변수가 잘못 사용될 수 있습니다.
변수는 읽기 전용 여부와 상관없이 변수 정의할 때 초기화하는 것이 좋으며, 여러 프로퍼티를 한 번에 설정해야 할 경우 구조분해 선언(destructuring declaration)을 활용하면 좋습니다.
캡처링
변수의 스코프를 최소화하지 않으면 캡처 문제가 발생할 수 있습니다.
위 예시를 보면, 정상적으로 10개의 소수를 출력합니다.
하지만, prime이라는 변수의 스코프를 좀 더 확장해 재사용하도록 변경하면, 아래에서 볼 수 있듯이 정상적인 결과가 반환되지 않습니다.
위와 같은 결과가 나온 이유는 prime이라는 변수가 캡쳐되었기 때문입니다.
- 위 예시에서는 시퀀스 빌더를 활용해 filter()가 지연 연산을 수행하여 변경된 prime 값을 이용하게 되었습니다.
캡처란?
closure 밖의 변수를 closure 안에서 사용하게 되면, 그 변수가 capture되었다고 합니다.
- 자바에서는 final 값에 대해서만 람다에서 캡처될 수 있으나, 코틀린은 var로 선언된 변수를 람다에서 사용할 경우, 내부적으로 Ref라는 final 클래스로 래핑하여 mutable한 변수도 캡처될 수 있습니다.
위와 같은 문제를 방지하기 위해서라도, 변수의 스코프는 좁게 만들어서 사용하는 것이 좋습니다.
sequence란?
코틀린의 시퀀스는 List나 Set과 같은 컬렉션과 비슷한 개념이지만, 필요할 때마다 값을 하나씩 계산하는 지연(lazy) 처리를 합니다.
sequence의 특징
- 요구되는 연산을 최소한으로 수행합니다.
- 무한정이 될 수 있습니다.
- 메모리 사용에 효율적입니다.
sequence 작동 방식
위 sequence 함수는 DSL(Domain-Specific Language)로 구현되어, 파라미터로 수신 객체 지정 람다 함수를 받습니다.
이 람다 함수는 내부적으로 수신 객체인 SequenceScope<T>를 가지고 있고, SequenceScope는 yield함수를 가집니다.
위 코드를 실행하면, 아래 그림처럼 각 숫자가 미리 생성되는 대신, 필요할 때마다 생성됩니다.
iterable과 sequence 동작 방식 차이
val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words.filter { println("filter: $it"); it.length > 3 }
.map { println("length: ${it.length}"); it.length }
.take(4)
println("Lengths of first 4 words longer than 3 chars:")
println(lengthsList)
- iterable에서는 filter와 map 함수가 코드에 나와있는 순서대로 동작합니다.
- 마지막 print문이 filter, map 출력문 이후에 나온 것을 볼 수 있습니다.
- filter를 모두 수행한 뒤 남은 항목들에 대해 모두 map 함수를 수행합니다.
val words = "The quick brown fox jumps over the lazy dog".split(" ")
//convert the List to a Sequence
val wordsSequence = words.asSequence()
val lengthsSequence = wordsSequence.filter { println("filter: $it"); it.length > 3 }
.map { println("length: ${it.length}"); it.length }
.take(4)
println("Lengths of first 4 words longer than 3 chars")
// terminal operation: obtaining the result as a List
println(lengthsSequence.toList())
- sequence는 결과를 만들 때 filter와 map을 호출합니다.
- 따라서, 마지막 출력문이 먼저 나오고 그 뒤에 filter와 map 출력문이 나옵니다.
- iterable과 다르게 sequence는 filter를 다 돌기 전에 filter된 항목이 있을 때마다 map 함수를 각각 호출하고, 대상건이 4개가 된 순간 더 이상 작업을 하지 않습니다.
아이템 3 - 최대한 플랫폼 타입을 사용하지 말라
플랫폼 타입
코틀린은 null-safety 메커니즘이 있지만, null-safety 메커니즘이 없는 프로그래밍 언어에서 넘어온 타입들을 다루기 위해 사용하는 타입입니다. 예를 들어, Java의 모든 참조는 null이 될 가능성이 있어 코틀린의 엄격한 null-safety 메커니즘에 만족시키기 어렵습니다.
이에, platform type이라는 타입을 만들어 null-check를 조금 완화하여 넘어온 언어(예: Java) 수준의 null 안정성을 제공합니다.
플랫폼 타입은 String!과 같이 타입 이름 뒤에 ! 기호를 붙여 표기하여, null이 될 수도 있고, null이 안될 수도 있습니다.
플랫폼 타입을 다룰 때에는 항상 null이 아님이 보장된다면, null이 불가한 타입으로 선언해서 사용할 수 있습니다. 하지만, 현재는 null을 리턴하지 않아도 미래에는 변경될 수 있기 때문에 최대한 플랫폼 타입 사용은 지양해야 합니다.
- 코틀린 로직에서 다루는 자바 로직을 직접 조작할 수 있다면, 가능한 @Nullable, @NotNull과 같은 null 허용 여부를 알려주는 어노테이션을 함께 사용하는 것을 권장합니다.
아이템 4 - inferred 타입으로 리턴하지 말라
코틀린의 타입 추론은 알려진 코틀린의 특징입니다. 위처럼, 값을 할당할 때 따로 타입을 지정해주지 않는다면 정확하게 할당한 값의 타입으로 지정됩니다.
리턴 타입은 중요한 정보이기 때문에, 숨기지 않는 것이 좋고 외부 API를 만들 때는 반드시 타입을 지정하여 잘못된 타입으로 추론하지 않는 것이 중요합니다.
타입이 지정되어 있는데, 해당 타입을 특별한 이유와 확실한 확인 없이 제거하는 것은 위험합니다 !
아이템 5 - 예외를 활용해 코드에 제한을 걸어라
확실하게 어떤 형태로 동작해야 하는 코드가 있다면, 예외를 활용해 제한을 걸어주는 게 좋습니다.
require 블록
fun factorial(n: Int): Int {
// require 함수는 조건을 만족하지 않으면 IllegalArgumentException을 발생시킵니다.
// require는 argument에 대해 제한을 걸 때 사용
require(n >= 0) { "n must be non-negative" } // 예외 발생 시 출력할 메시지 지정 가능
if (n == 0) return 1
return n * factorial(n - 1)
}
check 블록
fun next(): String {
// check 함수는 조건을 만족하지 않으면 IllegalStateException을 발생시킵니다.
// check는 함수 내부에서의 특정 조건을 만족할 때만 함수를 사용하고자 할 때 사용
check(hasNext()) { "No more elements" }
// ...
return "next"
}
- check 블록은 require와 비슷하지만, 지정된 예측을 만족하지 못할 때 IllegalStateException을 던집니다.
- 일반적으로, require 블록 뒤에 배치합니다.
assert 블록
fun MutableList<Int>.add(num: Int = 1): List<Int> {
val length = this.size
require(length >= num) { "size is not enough" }
this.add(num)
// assert 함수는 조건을 만족하지 않으면 AssertionError을 발생시킵니다.
assert(this.size == length + 1) { "size is not $num" }
return this
}
- 어떤 것이 true인지 확인할 수 있으며, 실행 시 테스트모드로 동작(-ea JVM 옵션 활성화 시)시킬 때만 작동합니다.
- 단위 테스트로도 assert 테스트를 진행하지만, 테스트하는 특정 상황이 아닌 모든 상황에 대해 테스트가 가능합니다.
- 물론, assert 블록을 사용해도 단위 테스트는 진행해야 합니다.
제한을 걸 때 장점
- 제한을 걸면, 코드를 보았을 때 한 눈에 제한되는 지점을 확인할 수 있습니다.
- 문제가 있을 경우, 비정상적인 동작을 하지 않고 예외를 던져 개발자가 확인할 수 있도록 합니다.
- null 가능성에 대해 require와 check 블록으로 확인 한 뒤에 로직을 진행하면, non-null 타입으로 스마트 캐스팅이 작동합니다.
- requireNotNull, checkNotNull이라는 특수한 함수도 사용할 수 있습니다.
응용 - return/throw와 run 함수 조합
fun runTest(str: String?) {
val result = str ?: run {
println("str is null")
return
}
}
- null 일 때에, 여러 가지 처리가 필요하다면, return/throw와 run 함수를 조합해서 활용할 수도 있습니다.
아이템 6 - 사용자 정의 오류보다는 표준 오류를 사용하라
직접 오류를 정의하는 것보다는 최대한 표준 라이브러리의 오류를 사용하는 것이 좋습니다.
- 표준 라이브러리의 오류는 많은 개발자가 알고 있으므로, 해당 오류를 재사용하는 것이 서로 이해하기 좋습니다.
일반적으로 사용되는 예외
예외 | 설명 |
IllegalArgumentException, IllegalStateException | require, check를 사용해 throw할 수 있는 예외 |
IndexOutOfBoundsException | 인덱스 파라미터의 값이 범위를 벗어났다는 것을 나타냄 |
ConcurrentModificationException | 동시 수정을 금지했는데 발생한 경우 |
UnsupportedOperationException | 사용자가 사용하고자 하는 메소드가 현 객체에서 사용할 수 없는 경우 |
NoSuchElementException | 사용자가 사용하려는 요소가 존재하지 않는 경우 발생 |
아이템 7 - 결과 부족이 발생할 경우 null과 Failure를 사용하라
충분히 예측할 수 있는 범위의 오류는 null과 Failure로 처리하고, 예측하기 어려운 예외적인 범위의 오류에 대해 예외를 throw하는 것이 좋습니다.
함수가 원하는 결과를 만들어 낼 수 없을 때 충분히 예측할 수 있는 범위라면, null 또는 실패를 나타내는 sealed 클래스(Result)를 리턴합니다.
- 추가적인 정보를 전달해야 한다면, Result 클래스를 리턴하고 그 외에는 null을 사용하는 것이 일반적입니다.
예외는 정보 전달하는 방법으로 사용해서는 안되며, 실제 예외적인 상황이 발생했을 때 사용하는 것이 좋습니다.
- 코틀린의 예외는 unchecked 예외이기 때문에, 사용자가 예외를 처리하지 않을 수 있어 제대로 예외 전파를 추적하기 어려울 수 있습니다.
아이템 8 - 적절하게 null을 처리하라
null은 값이 부족하다는 것을 나타냅니다. 함수가 null을 리턴하는 것은 함수에 따라 여러 의미를 가질 수 있습니다. null을 사용할 때에는 최대한 명확한 의미를 갖도록 해야 합니다.
방어적 프로그래밍과 공격적 프로그래밍
모든 가능성을 올바른 방식으로 처리하는 것(예: null일 때는 출력하지 않음)은 방어적 프로그래밍이라고 부릅니다. 방어적 프로그래밍은 운영 환경에서 발생할 수 있는 많은 상황으로부터 프로그램을 방어하여 안정성을 높이는 방안입니다.
하지만, 모든 상황에 대해 안전하게 처리하는 것은 불가능하여 공격적 프로그래밍이라는 방법도 사용합니다. 공격적 프로그래밍은 예상하지 못한 상황이 발생했을 때 이러한 문제를 개발자에게 알려서 수정하게 만드는 것입니다. 공격적 프로그래밍의 예시는 require, check, assert가 있습니다.
방어적 프로그래밍과 공격적 프로그래밍을 적절하게 사용할 수 있어야 합니다.
not-null assertion(!!)
!!은 타입은 nullable이지만, null이 나오지 않는다는 것이 거의 확실한 상황에서 사용됩니다. 하지만, 현재는 확실하다고 해서 미래에도 확실한 것은 아니기 때문에 주의해야 합니다. 특히, !! 연산자에 의한 오류가 발생했을 때에는 제네릭 NPE를 반환하기 때문에 예외와 관련된 정보를 별로 제공해주지 않습니다. 따라서, null일 때 예외가 필요하다면, 명시적으로 예외를 발생시키는 것이 좋습니다.
의미 없는 nullability 피하기
nullability는 처리가 필요하기 때문에, 추가 비용이 발생합니다. 따라서, 필요한 상황이 아니라면 최대한 nullability 자체를 피하는 것이 좋습니다. null은 의미를 가질 수 있기 때문에 의미가 없을 때는 null을 사용하지 않는 것이 좋습니다.
- 빈 컬렉션을 나타내고자 할 때 null을 리턴하지 않습니다.
- 빈 컬렉션과 null의 의미는 완전히 다릅니다. 의미에 맞게 사용해야 합니다.
- 빈 컬렉션은 요소가 부족하다는 의미라면, null은 아예 컬렉션 자체가 없다는 것입니다.
- enum 사용 시, null을 리턴할 수 있게 하기 보다는 None과 같은 의미없는 값을 정의하여 활용합니다.
- enum이 null을 반환할 수 있다면, 별도의 처리가 필요하기 때문에 필요한 경우에만 사용할 수 있도록 None과 같은 의미없는 값을 추가로 정의하는 것이 낫습니다.
아이템 9 - use를 사용하여 리소스를 닫아라
기본적으로 AutoCloseable을 상속받는 리소스들은 최종적으로 리소스에 대한 참조가 없어질 때 GC가 청소를 진행합니다. GC에 의한 청소는 굉장히 느리고, 그동안 리소스를 유지하는 비용이 많이 듭니다.
이 경우, 위와 같이 명시적으로 close 메소드를 호출해주는 것이 좋으며, 이때 use 함수를 활용할 수 있습니다.
- 다만, 명시적으로 close를 하려면 try ~ catch ~ finally를 활용하여 finally 구문에서 정리를 하게 됩니다.
- 하지만, finally에서 오류가 발생하면 예외가 따로 처리되지 않기때문에 use 함수를 활용하는 것이 좋습니다.
아이템 10 - 단위 테스트를 만들어라
단위 테스트는 개발자가 만들고 있는 요소가 제대로 동작하는지 빠르게 피드백해주므로 개발하는 동안에 큰 도움이 됩니다.
단위 테스트
장점
- 테스트가 잘 된 요소는 신뢰할 수 있습니다.
- 테스트가 꼼꼼하게 짜여있다면, 리팩토링하는 것이 두렵지 않습니다.
- 수동으로 실행시켜 테스트하는 것보다, 단위 테스트로 확인하는 것이 작은 단위로 확인할 수 있고, 더 빠릅니다.
단점
- 단위 테스트를 만드는 데 시간이 걸립니다.
- 테스트를 짤 수 있도록 코드를 조정해야 합니다.
- 올바르지 않은 단위 테스트는 오히려 이후에 테스트 수정으로 인해 큰 시간이 소요될 수 있습니다.
스터디 준비
궁금한 점
- 아이템 1 - 가변성을 제한하라
- P10에서 완전히 변경할 필요가 없다면, final 프로퍼티를 사용하라고 하는데 코틀린은 final 예약어가 없습니다. 어떤것을 의미한 것일까요?
- 읽기 전용 컬렉션을 가진 val 생성 또는 사용자 정의 게터를 가진 val을 의미한 것일까요?
- P17에 나온 것처럼 delegates를 사용해서 변경 추적하는 케이스를 사용해보신 분이 계실까요?
- P10에서 완전히 변경할 필요가 없다면, final 프로퍼티를 사용하라고 하는데 코틀린은 final 예약어가 없습니다. 어떤것을 의미한 것일까요?
- 아이템 2 - 변수의 스코프를 최소화하라
- P25 람다 캡처 관련 내용이 정확하게 이해가 가지 않습니다. sequence가 지연연산한다는 것은 이해하고 있는데, 지연연산 시점이 왜 마지막 prime 값이 만들어진 이후인지 잘 모르겠습니다. (디버깅했을 때, yield 하고 다시 prime 값 넣고 filter쪽으로 넘어가는걸로 봄)
- 아이템 5 - 예외를 활용해 코드에 제한을 걸어라
- assert를 비즈니스 로직에 적용하여 -ea 옵션을 활성화하는 경우가 있는지 궁금합니다.
- 테스트 환경에 대해서만 항상 키거나 QA 기간에만 키는 등 실제 사용 사례가 궁금합니다.
- assert를 비즈니스 로직에 적용하여 -ea 옵션을 활성화하는 경우가 있는지 궁금합니다.
- 아이템 8 - 적절하게 null을 처리하라
- 10개의 데이터 중에 1개의 데이터가 문제가 있다고 했을 때, 10개를 모두 다 안보내고 예외를 발생하는게 올바른 방법일까요?
- 어떤 상황일 때 방어적 프로그래밍이 맞고, 어떤 상황일 때 공격적 프로그래밍이 맞을지 궁금합니다.
- 팀에 그라운드룰로 !! 사용하지 않는 팀이 있으신지 궁금합니다.
- 10개의 데이터 중에 1개의 데이터가 문제가 있다고 했을 때, 10개를 모두 다 안보내고 예외를 발생하는게 올바른 방법일까요?
고찰
- 아이템 4 - inferred 타입으로 리턴하지 말라
- 간혹, 로직을 구현하다가 줄이 너무 길어져버리면 리턴 타입을 제거한 경우가 종종 있었는데 혹 해당 타입 제거로 인해 추론되는 타입이 달라질 수 있다는 것을 알게되어 좀 더 조심해야겠다는 생각이 들었습니다.
- 아이템 10 - 단위 테스트를 만들어라
- 좀 더 테스트를 잘 짤 수 있는 구조로 구현할 수 있도록 로직을 작은 단위로 쪼개야겠다는 생각이 들었습니다.
참고 자료
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Kotlin Coroutine] 고급 코루틴 구조 및 패턴 이해 (0) | 2024.06.27 |
---|---|
[Effective Kotlin] 2장 가독성 (0) | 2024.06.27 |
[Kotlin Coroutine] 코루틴 컨텍스트와 디스패처의 이해 (0) | 2024.06.19 |
[Kotlin Coroutine] 코루틴 빌더와 비동기 패턴의 이해 (0) | 2024.06.16 |
[Kotlin Coroutine] 코루틴 기본 이해 & JVM에서의 async (0) | 2024.06.08 |