Functional Thinking in Kotlin

Functional Thinking in Kotlin

Channel Talk

  • Frontend

현재 블로그 내용은 채널코퍼레이션 Web 팀 내부 테크톡 중 Kotlin과 함수형 프로그래밍에 관련한 발표를 바탕으로 재구성하였습니다.


Functional Thinking in Kotlin

우리가 프론트엔드를 개발할 때 사용하는 JavaScript는 대표적인 함수형 프로그래밍 언어입니다. 이외에도 함수형 언어는 많습니다. 모던 언어중에는 가장 사용처가 많다고 할 수 있는 Kotlin 또한 함수형 패러다임을 차용하고 있는데요, 이번 발표는 Kotlin의 함수형 방법론과 언어 디자인에 대해 살펴본 후, JavaScript와는 어떤 차이가 있는지 알아보면서 함수형 프로그래밍이라는 큰 틀에서 더욱 이해할 수 있는 계기가 되었으면 좋겠습니다.

Kotlin의 특징

일단 Kotlin의 특징에 대해 먼저 살펴보도록 하겠습니다. 코틀린은 공식적으로 멀티 패러다임 언어로 디자인 되었다고 설명하고 있는데요, 객체 지향 프로그래밍과 함수형 프로그래밍의 두 가지 특징을 모두 가지고 있다는 것입니다.

온전히 하나의 패러다임으로만 만들어진 언어에 다른 패러다임의 개념을 첨가하는 것은 '도전'으로 평가됩니다. 예를 들어, JS에서 Class 키워드를 ES6에서 추가했고, Java에서 lambda 개념을 8 버전에 도입했습니다. 이런 시도들은 장황한 코드를 개발자들이 익숙한 다른 형식으로 간결하게 표현할 수 있어 호평을 받는데요, 내부를 뜯어보면 사실 JS의 Class는 Function으로 구현되어있고, Java의 lambda는 FunctionalInterface로 선언된 Interface를 상속하는 익명 클래스의 구현으로 만들어지게 됩니다. 이러한 점에서 기존의 언어에서는 다른 패러다임을 도입할때 약간의 이질적인 면을 느낄 수 있었습니다.

하지만 Kotlin은 OOP와 FP 패러다임을 언어 설계 시점부터 같이 어우러질 수 있게 디자인하여 마치 잘 설계된 건물을 보는 느낌을 얻을 수 있습니다.

JS와는 달리 강타입 언어로 설계된 부분과, 내부 데이터가 바뀌면 해당 collection의 참조도 변경되어야 하는 Immutable 자료구조도 빌트인으로 제공하고 있어 편리한 개발 환경을 권장한다는 부분도 긍정적입니다.

Method chaining pattern

코틀린이 멀티 패러다임으로 디자인되었다는 내용을 설명하기 위해서, 먼저 OOP와 FP에서의 method chaining pattern 차이를 보겠습니다. OOP에서 메소드를 체이닝할 경우는 보통 builder 패턴이거나 어떤 인스턴스의 메소드를 연속으로 사용할 경우입니다. 이 과정에서 인스턴스 내부의 데이터가 변경될 수 있어서 일반적으로 Mutable하다고 여겨집니다.

반면, FP에서는 어떠한 데이터를 바탕으로 여러가지 연산을 적용하여 새로운 데이터를 얻어내기 위한 용도로 쓰는 경우가 많습니다. 이 경우에는 기존의 데이터에는 영향을 끼치지 않는다는 면에서 Immutable이라고 볼 수 있습니다. (splice 등 일부 메소드의 경우에는 그렇지 않을 수 있습니다.)

추가적으로 FP에서 체이닝을 사용할때 Lazy evaluation이라는 개념이 있는데, 짧게 알아보고 넘어가도록 하겠습니다.

Lazy evaluation

저희가 채널톡 개발에 자주 사용하는 lodash의 경우에도 Lazy evaluation을 지원하고 있습니다. 이를 이용한 코드와 그렇지 않은 코드를 작성해본 것인데요.

위에서는 filter 실행이 99번 일어나는데 아래에서는 9번만 실행하면 되는 것을 볼 수 있습니다.

Lazy evaluation의 가장 큰 장점은, 사람이 읽는 위-아래 순서대로 연산의 단계가 작성되어있지만, 실제 호출은 아래에서부터 일어나서 알아서 최적화된다는 점입니다. 이러한 Lazy evaluation는 어떠한 함수 참조를 이용해야 하는지를 리턴 객체에 같이 기록해놓아야 하기 때문에 함수가 1급 시민인 함수형 프로그래밍에 적합하다고 볼 수 있습니다.

Kotlin은?

하지만 Kotlin에서는 with, apply 등 scope function이 잘 마련되어있고, setter를 연속해서 사용할 필요가 없는 data class도 존재하여서 OOP에서 봤었던 형태의 mutable chaining pattern을 굳이 쓸 필요가 없습니다. 따라서 함수형 프로그래밍에서 사용했던 immutable chaining pattern을 많이 사용하게 됩니다.

우측은 Kotlin에 기본 제공되는 list collection은 기본적으로 immutable 하다는 것과, Lazy evaluation을 제공하고 있다는 점을 보여주는 예제입니다.

Kotlin 문법 훑어보기

Variable

이제 Kotlin의 문법에 대해 간단히 알아보겠습니다.

Kotlin의 변수는 TypeScript와 모양이 비슷하지만 조금 다른 점이 있습니다. 일단 TypeScript의 모든 기본 제공 타입은 대문자로 시작합니다. Java에서 보았던 int, long, float 등 primitive type을 사용할 수 없고, boxing된 형태인 Int, Long, Float 와 같은 Class를 사용한다고 보시면 되겠습니다.

Kotlin에서 Boxing, Unboxing은 자동으로 되기 때문에 딱히 신경써줄 필요는 없습니다.

변경 가능한 변수는 var로 선언하고, 변경 불가능한 변수는 val로 선언합니다. variable과 value의 약자로, 직관적으로 볼 수 있습니다. ? 를 타입 뒤에 붙이면 nullable한 형식을 나타내는 것입니다.

Collections

위에서 Kotlin에서 immutable collection을 제공하는 예를 잠깐 보여드렸는데요, mutable collection도 존재를 하고 있습니다. 다만 mutable~~~ 형식으로 작성을 해줘야 하기에 명시적으로 값이 바뀔 수 있다는 것을 알려주고 있지요.

각 collection을 만들어주는 literal은 존재하지 않고, listOf, mutableListOf 등 메소드를 사용해야 합니다. Collection은 아니긴 하지만, Array 또한 arrayOf 라는 메소드를 사용해야 하는 점이 참고 사항입니다. (개인적으로는 불편하게 느껴졌던 부분입니다.)

여기에서 immutable collection과 mutable collection 간에 이게 immutable한 메소드인지 mutable한 메소드인지를 명시적으로 보여주는 부분이 있는데요, MutableList에서는 sort, reverse 와 같이 동사원형을 사용하는 반면 List는 sorted, reversed 등 과거분사형을 사용하여 목적 collection에 직접 적용하는게 아니라는 점을 맥락으로도 알려주고 있죠. 좋은 디테일이라고 생각합니다.

Lambda

Lambda 경험은 TypeScript와 크게 다른 점은 없습니다. 단, 함수로 선언된 것을 이름으로 바로 변수에 넣을 순 없고, ::함수이름 형태로 현재 namespace에 존재하는 함수 참조라는 것을 표현해주어야 합니다.

무명 함수의 인자 또한 타입을 반드시 지정해주어야 하지만, Lambda 변수에 타입을 지정해주었을 경우에는 생략 가능합니다.

TypeScript보다 특별한 장점이라고 볼 수 있는 점은 콜백을 받는 함수가 Overloading이 가능하다는건데요, TypeScript는 런타임 시점에는 반드시 JavaScript로 변환되어야 하기 때문에 타입 정보가 날아간 상태로 실행이 되므로 인자의 타입에 따라서 다른 함수 참조를 이용하게 하는 것이 불가능합니다. (typeof 연산을 사용하여 분기하면 되나, TypeScript 자체적인 변환이 아니고 프로그래머가 따로 구현을 해야 하기 때문에 제외합니다.)

Kotlin은 강타입 언어이면서 함수형 패러다임을 도입했기 때문에, Lambda의 인자의 타입에 따라서도 Overloading을 시킬 수 있습니다. 단, 함수 사용처에서 Lambda의 타입을 반드시 알아야 하기에, 위에서 말했던 무명 함수의 인자 타입 생략은 이 경우에 불가능합니다.

Lambda를 맨 마지막 인자로 받는 함수의 특이성에 대해 보겠습니다. Lambda 형태가 전부 중괄호 내에 들어가니만큼, 굳이 함수의 소괄호 내에 들어갈 필요 없이 바깥에 위치시킬 수 있습니다. 함수의 인자가 Lambda 1개일 경우에는 소괄호를 완전히 생략할 수 있으며(runTwice 함수의 형태), 그렇지 않더라도 맨 마지막의 인자만 Lambda 형식일 경우에는 소괄호 바깥에 위치시킬 수 있습니다. 자주 사용하는 if, while 등 제어문과 비슷한 형태가 됩니다.

여기서 calcAndPrint의 Lambda 인자의 구현 형태를 보면, return과 같은 키워드를 사용하지 않고 있는 것을 볼 수 있습니다. 위에서는 한줄로 되어있었고, 인자도 함께 적어줬었기에 -> 표기로 인해 직관적으로 보였을 것 같은데요, 모든 Lambda는 기본적으론 맨 마지막줄을 return시켜주며 return 키워드는 사용할 수 없습니다. (Lambda block을 naming하여 return하는 방법도 있긴 합니다.)

calcAndPrint 함수에서 x와 y 인자에 default value가 주어졌을때를 가정해보겠습니다. 이때는 x, y 인자를 소괄호 내에 기입해주지 않더라도 calcAndPrint { ... } 와 같이 사용할 수 있습니다. (formatter에는 default value를 넣어줘도 되고, 넣어주지 않아도 됩니다. )

fun runEverything( task1: () -> Unit = { println("task1") }, task2: () -> Unit = { println("task2") } ) { task1(); task2(); } runEverything({ println("custom task1") }) runEverything { println("custom task2") }

다만 위와 같이 2개 이상의 인자가 Lambda를 받으면서 default value를 가지고 있을 경우도 있는데, 이 경우에는 소괄호 내에 넣는 것과 밖에 위치할 경우의 동작이 다른 것에 유의해야 합니다.

Scope function

다음으로, Scope function에 대해 알아보겠습니다.

해당하는 값을 this나 it으로 bind하는 함수셋입니다. 여기서 with는 소괄호 내에 this로 bind하고 싶은 값을 넣어주는 형태이고 run, let, apply, also의 경우에는 인스턴스의 메소드로 정의되어 있습니다.

with, run, apply는 this로 bind하며, let, also는 it으로 bind합니다. this는 JS에서는 생략이 불가능하지만 Java, Kotlin에서는 생략이 가능하여 해당 인스턴스의 메소드 / 속성을 편리하게 사용할 수 있습니다.

run, let의 경우에는 맨 마지막 expression을 return하고, apply, also는 bind된 this를 다시 return으로 돌려는 형태입니다.

여기서 with가 어떻게 구현되었나 알아보겠습니다. 제어문이라고 생각할 수 있겠지만 메소드로 구현이 되어있는데요, receiver와 lambda인 block으로 2개의 인자를 받고 있으며, 마지막 인자가 lambda이기에 소괄호 밖에 빼서 사용하는 형태입니다.

contract 블록은 무시해도 되고, return만 보면 receiver.block() 라고 되어있는데요. 사실 block은 receiver 내부에 존재하는 메소드가 아니지만 제너릭으로 참조된 receiver의 타입을 앞에 붙여주고 있는 형식으로 되어있는데요, 이렇게 특정한 메소드를 해당 스코프 내에서 class의 메소드처럼 사용하는 것을 확장 함수라고 합니다.

여기서 block은 엄연히 receiver의 메소드로써 사용되기에 당연하게 this가 receiver로 bind되는 것입니다.

이러한 편의 기능을 사실 JavaScript에서도 사용할 수 있었는데요, with 문이 존재하긴 하지만 사용하지 않는 것을 권고하고 있습니다. 해당 변수가 존재하지 않으면 오류를 띄우지 않고 global의 필드처럼 사용하는 JS 특성상 문제 발생 소지가 더 있었기 때문입니다. 만약 JS도 강타입 언어여서 IDE 차원에서 사전에 이러한 문제를 막거나, 프로그래머가 오류를 미리 알 수 있었다면 with가 strict mode에서 금지되는 일은 없었지 않을까요? 만약 global 대신 this를 생략 가능했으면 어땠을까요?

Kotlin/JS

Kotlin은 본래 Java Bytecode로 컴파일하고 있지만, Kotlin/JS 라는 이름으로 JS로 트랜스파일링하는 툴을 제공하고 있는데요,

Kotlin으로 웹 프론트엔드를 개발할 수 있는 환경을 제공해주고 있기에 방금 전에 말했던 만약 JS가 강타입 언어였다면, 코틀린이랑 더 비슷했다면 어땠을까? 를 공상이 아닌 실제로 만들어줍니다.

Kotlin으로 React를 사용하여 개발하는 것을 잠깐 살펴보도록 하겠습니다. Class Component와 Functional Component를 전부 지원하는데, 슬라이드와 같이 간결한 형태로 작성 가능합니다.

다만 제가 직접 만들어봤을때는 Webpack 설정에 약간 애로사항이 있어서 실제 사용은 어려웠습니다. 원래 Webpack 설정은 약타입인데 Kotlin에서 타입을 끼워맞추다보니 실제로 되는 설정이지만 Kotlin 타입 에러가 발생한다던가 하는 문제가 있었습니다. (여러가지 찾아봤지만 기존처럼 json 설정은 지원하지 않는듯 했습니다.)


채널톡 프론트엔드팀은 더 나은 기술적 고민을 위해 2~3주에 한번씩 자원을 받아 본인이 배워보았던 기술에 대해 공유해보는 테크톡 프로그램을 진행하고 있어요.

최고의 채팅경험을 만들어 나가는 채널톡 웹팀!

함께 하고 싶다면, 지금 바로 지원해주세요 🙌

We Make a Future Classic Product