Effective Kotlin, 좋았던 부분들.
동기
요 며칠 간 마르친 모스칼라의 저서 <이펙티브 코틀린> 을 집중적으로 읽었습니다. 언어에 대한 이해도를 높이고 싶었기 때문입니다. 언어에 대한 이해도가 높아지면 보다 가독성이 높고 간결한 코드를 작성할 수 있게 되니까요.
물론 정리를 하지 않은 것은 아닙니다. 책을 읽으면서 옵시디언에 별도로 정리를 해뒀는데, 사실 다시 보게 되지는 않더라고요. 그래서 이번 기회에, 정말 중요하다 싶었던 내용들에 대해서 다시 한 번 정리하기 위해 포스팅합니다.
어떤 책이었나?
개인적으로는 좋은 내용들이 굉장히 많았습니다. '이런 것도 된다고?' 싶을 정도로 신기한 부분도 있었고, 평소에 꽤 당연하게 여기는 부분들에도 깊은 수준의 이해를 위한 설명이 포함되어 있기도 했습니다.
다만, 코틀린을 처음 접하는 이들에게 추천할만한 서적은 아닌 것 같습니다. 조금 더 쉬운 단계의 책을 먼저 읽으시거나, 무작정 코틀린을 사용해보는 시간을 가진 뒤에 읽으면 좋을 것 같아요.
즉, 애초에 해당 서적으로 코틀린을 학습하기 보다는, 내가 모르고 사용했던 부분이나 잘못 사용했던 부분들을 교정하는 데에 사용하면 좋겠습니다.
그래서, 좋았던 부분들.
좋은 내용들은 너무나도 많았고, 그 중에서도 특히 좋게 느껴졌던 부분들만 한 번 정리해보려 합니다.
변수 타입이 명확하지 않은 경우 확실하게 지정하라
코틀린의 경우, 다음과 같은 문법으로 코드를 작성할 수 있습니다.
private fun getUserInfo() = flow {
...
}
별도의 retrun 키워드 없이 간편하게 작성할 수 있어 코드가 조금 더 간결해지는 측면이 있습니다. 다만, 이와 같이 작성할 경우 코드의 리턴 타입이 명시되지 않아, 메서드를 빠르게 파악할 수는 없습니다.
이러한 경우, 다음과 같이 작성할 수 있습니다.
private fun getUserInfo() : Flow<User> = flow {
...
}
이와 같이 작성하면, 해당 메서드를 활용하는 부분에서의 코드를 작성할 때, 메서드의 반환 타입만 빠르게 파악하고 원래 작성하던 메서드를 이어 작성할 수 있게 되겠지요. 즉, 생산성의 향상으로 이어질 수 있습니다.
일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라
이 부분은 두 번 이상 읽고나서야 이해가 되었습니다. '일반적인'이라고 번역되어 있는데, 아마 원서에는 'General' 이라는 단어가 있을 것이라고 생각합니다. 우리말의 `일반적인` 과 대응 관계에 있는 말로는 '특수한', '특별한' 등이 있을텐데, 이 경우의 'General' 은 '일반적인' 을 의미한다기 보다 '사용 빈도가 높은' 을 의미하는 것으로 보이며, 그렇게 생각하고 읽어보니 글이 한 눈에 들어왔습니다.
자주 사용되는 프로퍼티 패턴은 새로운 Custom Getter 를 정의하지 말고, Property Delegate 를 통해서 구현하라는 내용입니다.
아주 간단한 예를 들어보자면, 다음과 같습니다.
interface StringDecoratorDelegate {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String)
}
class StringDecorator() : StringDecoratorDelegate {
private val stringBuilder = StringBuilder()
private var string = ""
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
decorate()
return stringBuilder.toString()
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
string = value
}
private fun decorate() {
stringBuilder.run {
append("{[***$string***]}")
}
}
}
간단히 확장 함수나 별도의 메서드를 구현하면 가능할만한 구현이기도 하고, 내부의 코드 조차 비효율의 극치이지만, 설명을 돕기 위해 '복잡하고 과정이 많은' 예시를 작성한 것이라 이해 해주시면 됩니다.
프로퍼티 위임을 처리하기 위해서는 위임을 위한 인터페이스와, 해당 인터페이스의 구현체이자 위임의 주체인 클래스가 필요합니다.
코드를 길게 작성해둬서 복잡해보이지만, 단순히 얻어 온 문자열을 StringBuilder 에 가공하여 넣고 그대로 돌려주는 것이 전부입니다. 실제로 프로퍼티 위임을 사용해야 하는 경우는 생성이 잦지만 정적 메서드로 구현하기에는 충분히 많은 코드가 필요할 때 정도로 이해하시면 되겠습니다.
fun main() {
string = "안녕하세요. 깨달음의 비탈입니다."
println(string)
}
// Console
{[***안녕하세요. 깨달음의 비탈입니다.***]}
이러한 과정을 별도의 정적 메서드나 확장 함수 등으로 작성한다면, 생각보다 더 큰 가독성의 저하를 불러 올 수 있을 겁니다. 정적 메서드로 작성하는 경우, 코드가 길어지면 자연스럽게 함수화를 고려하게 될텐데, 그 때에는 의미없는 정적 메서드만 추가로 생성될 수 있습니다. 확장 함수로 작성하는 쪽도 물론 같습니다.
또한, 이를 특정 기능을 수행하는 클래스 내에 작성해둔다면 그 피해가 더 클 것입니다. 안그래도 코드가 많을텐데 말이죠.
그렇다고 Custom Getter 로 작성하자니, 생성에 관련된 코드가 특정 클래스 상단에 길게 뿌려져 있어 더욱 복잡한 코드가 작성될 것이 뻔합니다. 이러한 경우가 잦지 않다면 큰 문제가 되지 않을 수도 있지만, 자주 사용되는 경우면 당연히 곤란할 것입니다. 이럴 때 프로퍼티 위임을 통해 패키지를 분리할 수 있고, 사용되는 곳마다 간결하게 사용할 수 있으니 가독성에도 큰 도움이 되겠지요.
외부 API 를 Wrap 해서 사용하라
추상화에 관한 내용인데, 추상화가 잘 된 코드는 유지보수에 정말 용이합니다. 추상화를 하지 않으면 똑같은 코드를 여러 번 수행하게 됩니다. 즉, 함수 및 메서드는 가장 단순한 추상화 도구입니다. 클래스, 인터페이스 등 모든 것이 추상화의 도구로 활용될 수 있고요.
외부 API 를 인터페이스나 클래스로 분리 설계 및 재구성하여 사용하게 되면, 유지보수 용이성이 극대화됩니다. 대부분의 아키텍처나 구조들은 추상화에 집중하여 설계되어 있습니다.
아래는 이전에 진행했던 프로젝트 코드의 일부분입니다. MVI 설계를 위해 이벤트를 정의한 코드인데요.
sealed class MainEvent {
object TrackCadence : MainEvent()
object AssignCadence : MainEvent()
object PlayFavoriteList : MainEvent()
object StartRunning : MainEvent()
object StopRunning : MainEvent()
data class SetAssignedCadence(val cadence: Int) : MainEvent()
}
이와 같이 Event 를 따로 정의해두고, 이에 대한 행동은 별도로 구현합니다.
private fun reduceState(state: MainState, event: MainEvent): MainState {
return when (event) {
is MainEvent.TrackCadence -> {
state.copy(loadingMusicType = TRACKING_CADENCE)
}
...
}
}
만약 TrackCadence 에 대한 코드가 변경되더라도, 해당 Event 를 수령하는 부분에서는 수정할 것이 없고 TrackCadence 가 발생했을 때 실행시켜야 하는 코드만 변경해주면 그만입니다. 즉, 변경이 발생했을 때 수정해주어야 하는 사이트를 최소화하는 것이 목표가 되어야 합니다.
여기서 외부 API란, Third-Party-Libaray 를 의미할 수 있습니다. 언제든 내용이나 사용법이 변경될 수 있으므로, 이러한 버전 업데이트에 유연하게 대처하기 위해 이를 인터페이스나 클래스로 한 단계 더 추상화하여 사용하는 것이죠. 만약 라이브러리에 변경이 있다 하더라도, (그리고 그 변경이 과도하게 큰 변경이 아니라면) 추상화되지 않은 부분만 수정하여, 이를 호출하는 소스에는 변경을 만들지 않을 수 있습니다.
다만, 대부분의 Third-Party-Library 는 추상화가 이미 잘 되어 있긴 합니다. 그렇다면 이를 굳이 래핑할 이유가 있을까요? 네. 당연히 있습니다.
예를 들면, 다음과 같은 상황이 발생할 수 있겠지요.
override suspend fun fetchMusicImage(url: String): Flow<ResponseState<ResponseBody>> {
return flow {
responseHandler.handle {
apiService.fetchMusicImage(url)
}.onEach { result ->
when (result) {
is MurunResponse.OnSuccess -> emit(ResponseState.Success(result.data))
is MurunResponse.OnError -> emit(ResponseState.Error(ErrorResponse(message = "이미지 파일 오류입니다.").toDataModel()))
}
}.catch {
emit(ResponseState.Error(ErrorResponse(message = "네트워크 연결 상태를 확인해 주세요.").toDataModel()))
}.collect()
}
}
이미지를 획득하는 메서드입니다. 해당 메서드는 레포지토리 구현체에 존재하고요. fetchMusicImage() 메서드는 직접 API 를 호출하고, 얻어 온 결과를 다음 레이어에 Flow<T> 의 형태로 전달합니다. apiService 객체를 통해 네트워크 통신을 수행합니다.
이 경우, 만약 네트워크 통신 라이브러리를 다른 라이브러리로 교체하면 어떻게 될까요? 아마 코드를 다 뜯어 고쳐야 될 겁니다. 해당 메서드가 작성된 레포지토리 구현체뿐만 아니라, 다른 모든 레포지토리 구현체에서 말입니다. 애초에 apiService 라는 변수 자체가 변경될테니까요. 그럼 이를 어떻게 해결하면 좋을까요? 정답은 당연히 추상화입니다.
class NetworkManager @Inject constructor(private val apiService: ApiService) {
fun fetchMusicImage(): Flow<ResponseState<ResponseBody>> {
...
}
}
이와 같이 작성하면, 모든 레포지토리에서 apiService 를 변경할 필요가 없어집니다. 단 한 곳, NetworkManager 의 apiService 만 변경해주면 됩니다. 내부의 메서드를 수정해야하지만요.
또한, 이는 사실 Retrofit 의 고질적 문제이기도 한데, 미리 인터페이스에 작성한 메서드를 기반으로 런타임에 통신 메서드를 추가 생성하여 캐싱하고 있는 형태이다 보니 구현의 자유도가 떨어지긴 합니다. 다만, DynamicUrl 을 사용하면 더욱 고수준의 추상화를 이룰 수 있겠지요.
최근 느끼는 건데, 정말 대부분의 유지보수 문제는 추상화로 해결할 수 있는 것 같습니다. 온 세상이 추상화로 이루어져 있으니까요..!
둘 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라
마지막으로, 제가 가장 감명(?)깊었던 부분입니다.
사실 여러 개의 항목을 처리하는 경우에는 List<T> 와 Array<T> 를 쓸테지요. 항목의 수가 불분명한 경우에는 전자, 분명한 경우에는 후자로요.
다만, 갖가지 익스텐션으로 컬렉션을 보다 유연하게 처리할 수 있는 코틀린에서는 이를 Sequence<T> 로 처리하는 것이 낫습니다.
map { }, filter { } 등의 컬렉션 익스텐션은 정말 자주 사용되는데요.
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
for (element in this) if (predicate(element)) destination.add(element)
return destination
}
두 익스텐션 외에도 자주 사용되는 익스텐션의 내부 구현을 보면 이들은 대부분 for 문을 통해 순회하며 코드를 실행합니다. List<T> 로 보통 대부분 반환하게 되는데, 이는 한 컬렉션에 대해 여러 익스텐션을 사용할 경우 for 문을 여러 번 처리하는 것임을 나타냅니다.
Sequence<T> 는 하나의 Sequence<T> 에 추가된 모든 처리를 한 번에 수행합니다. 익스텐션들은 데코레이터 패턴으로 꾸며져 있는 새로운 Sequence<T> 를 반환하며, 반환된 이를 그대로 활용하여 계산합니다. 이를 LazyEvaluation 이라 칭하는데, 변환된 바이트 코드를 보면 확실하게 이해할 수 있습니다.
fun main() {
listOf(1, 2, 3).map { it * 3 }.filter { it > 5 }
sequenceOf(1, 2, 3).map { it * 3 }.filter { it > 5 }
}
각 라인을 바이트 코드로 변환하면 다음과 같습니다.
List<T>
public static final void main() {
Iterable $this$filter$iv = (Iterable)CollectionsKt.listOf(new Integer[]{1, 2, 3});
int $i$f$filter = false;
Collection destination$iv$iv = (Collection)(new ArrayList(CollectionsKt.collectionSizeOrDefault($this$filter$iv, 10)));
int $i$f$filterTo = false;
Iterator var5 = $this$filter$iv.iterator();
Object element$iv$iv;
int it;
boolean var8;
while(var5.hasNext()) {
element$iv$iv = var5.next();
it = ((Number)element$iv$iv).intValue();
var8 = false;
Integer var10 = it * 3;
destination$iv$iv.add(var10);
}
$this$filter$iv = (Iterable)((List)destination$iv$iv);
$i$f$filter = false;
destination$iv$iv = (Collection)(new ArrayList());
$i$f$filterTo = false;
var5 = $this$filter$iv.iterator();
while(var5.hasNext()) {
element$iv$iv = var5.next();
it = ((Number)element$iv$iv).intValue();
var8 = false;
if (it > 5) {
destination$iv$iv.add(element$iv$iv);
}
}
List var10000 = (List)destination$iv$iv;
}
일단 각 메서드를 실행할 때마다 while 문을 사용합니다. 각 while 문에는 Iterator 를 사용하고 있고요. 익스텐션에 정의된 코드가 inline 됩니다.
진행 과정을 살펴보면, 일단 최초로 다음과 같은 소스가 실행됩니다. 하나의 컬렉션을 생성하는 것입니다.
Collection destination$iv$iv = (Collection)(new ArrayList(CollectionsKt.collectionSizeOrDefault($this$filter$iv, 10)));
이후, 각 익스텐션을 실행할 때마다 while 문을 포함한 코드가 추가 되는데, 이 때, 새로운 컬렉션을 추가로 생성하여 기존의 컬렉션에 포함된 요소를 옮겨 담습니다.
$this$filter$iv = (Iterable)((List)destination$iv$iv);
$i$f$filter = false;
destination$iv$iv = (Collection)(new ArrayList());
$i$f$filterTo = false;
var5 = $this$filter$iv.iterator();
대놓고 비효율적이다라고 치부할 수는 없겠지만, 이후에 서술할 Sequence<T> 의 처리에 비하면 충분히 비효율적인 것은 맞습니다.
Sequence<T>
public static final void main() {
SequencesKt.toList(SequencesKt.filter(SequencesKt.map(SequencesKt.sequenceOf(new Integer[]{1, 2, 3}), (Function1)null.INSTANCE), (Function1)null.INSTANCE));
}
일단 바이트 코드 길이부터 확연히 차이가 납니다. 코드도 몹시 간단명료한데, 계산되는 값들을 Sequence<T> 객체에 담아 바로 전달하는 형태로 이루어져있습니다. 당연히 추가적인 컬렉션 생성이 없으니 간결합니다.
다만 모든 부분에 이렇게까지 적용할 것은 없어 보이고, 두 번 이상 컬렉션 익스텐션을 사용하는 경우에 적용하면 좋을 것 같습니다. 보통 map { }, filter { } 를 함께 사용하는 경우가 해당될 수 있겠습니다.
시간 비교
내친 김에 걸리는 시간까지 비교해보려고 합니다. 먼저, List<T> 입니다. 1,000,000 개의 Int 에 map { }, filter { } 메서드를 수행해봅니다.
fun main() {
println(System.currentTimeMillis())
List(1_000_000) { it }.map { it * 3 }.filter { it > 5 }
println(System.currentTimeMillis())
}
// Console
1701016516179
1701016516232
0.053 초가 걸렸네요. 다음은 Sequence<T> 입니다.
fun main() {
println(System.currentTimeMillis())
generateSequence(0) { it }.takeWhile { it <= 1000000 }.map { it * 3 }.filter { it > 5 }
println(System.currentTimeMillis())
}
// Console
1701016577623
1701016577632
0.009 초 걸립니다. 약 80% 이상의 개선율을 보입니다.
단순히 Int 를 처리해서 그렇지, 여러 프로퍼티를 가진 다양한 데이터 타입에 대해 이러한 연산을 수행하면 더 큰 성능의 개선을 경험할 수 있겠습니다. 서버의 경우 특히 그렇겠지요.
사실 서적으로 공부하는 것을 그렇게 좋아하지는 않습니다. 원하는 내용을 찾는 데에 시간이 조금 걸려서 말입니다. 다만, 파악하고자 하는 분야나 지식의 지엽적인 이해도를 높이기 위해서는 좋은 것 같습니다. 이번 경우도 그랬었습니다. 어떤 특정 지식을 얻고 싶었다기 보다는, 언어를 바라보는 전체적인 시야를 얻고 싶었거든요. 비슷한 경우가 생기면 종종 서적으로 공부해보아도 좋을 것 같습니다.