[개발 서적] 쏙쏙 들어오는 함수형 코딩(1)

6 분 소요

🔗 들어가며

안드로이드 개발을 진행하면서 kotlin을 주언어로 사용하게 되었다. kotlin은 객체지향 프로그래밍 언어이면서 동시에 함수형 프로그래밍 언어이기도 하다.

내가 함수형 프로그래밍을 자세히 알고 있는지에 대해 스스로 생각해봤을 때 추상적인 개념만 두리뭉실하게 알고 있다는 것을 깨닫게 되었다. kotlin 언어를 조금 더 잘 활용하기 위해, kotlin이 주는 이점을 최대로 누리기 위해 이번에는 함수형 코딩에 대해 딥-다이브🌊를 하는 시간을 가져보았다.

내가 읽은 책은 쏙쏙 들어오는 함수형 코딩(에릭 노먼드 지음 / 김은민 옮김)이라는 책이다. 두께가 상당해 읽는 데 꽤 오랜 시간이 걸릴줄 알았으나 내용이 너무 쉽게 구성되어 있어 빠르게 읽을 수 있었다. 쉬운 내용이었지만 함수형 코딩을 명확히 이해하는 데 너무나 완벽한 서적이었다.


🔗 함수형 프로그래밍에 들어가기 앞서…

우선 함수형 프로그래밍의 사전적 정의부터 살펴보자.


함수형 프로그래밍 정의

  1. 수학 함수를 사용하고 부수효과를 피하는 것이 특징인 프로그래밍 패러다임
  2. 부수 효과 없이 순수 함수만 사용하는 프로그래밍 스타일

그렇다면 위에서 계속 언급하는 부수효과순수 함수란 무엇일까?

1️⃣ 부수효과

  • 함수가 리턴값 이외에 하는 모든 일
  • ex) 메일 보내기, 전역 상태 수정

2️⃣ 순수함수

  • 부수효과 없이 결과값이 인자에만 의존하는 함수

하지만 실용적인 측면에서 위와 같은 정의는 다음의 문제를 가지고 있다.

3️⃣ 실용적인 측면에서의 함수형 프로그래밍 정의 되짚어보기

  1. 부수 효과는 필요하다.
    • 이메일을 보내는 프로그램인데 이메일을 안 보낸다면??
  2. 함수형 프로그래밍은 부수 효과를 잘 다룰 수 있다.
    • 정의에서는 순수한 함수만을 사용하라는 것처럼 나와 있지만, 실제 함수형 프로그래밍에서는 순수하지 않은 함수도 많이 사용된다. 또한, 이러한 함수형 프로그래밍에서는 이러한 순수하지 않은 함수를 잘 다룰 수 있는 도구가 존재한다.


✏️ 내가 이해한 함수형 프로그래밍의 정의

  • 부수효과가 존재하는 순수하지 않은 함수들을 잘 활용할 수 있게 하는 프로그래밍 방식


🔗 액션 / 계산 / 데이터 구분이 핵심!

함수형 프로그래밍의 시작은 코드를 액션 / 계산 / 데이터로 구분하는 것부터 시작한다. 우선 정의부터 짚고 넘어가자!


1. 액션 / 계산 / 데이터 정의

  • 액션 : 호출하는 횟수, 호출하는 시점이 중요한 함수
  • 계산 : 호출하는 횟수나 시점은 중요하지 않는다. 실행 전까지 어떻게 동작할지 알 수 없다.
    • 호출하는 횟수나 시점이 중요하지 않기 때문에 동일한 입력값에 대해 항상 동일한 결과가 나온다.
  • 데이터 : 이벤트에 대한 사실, 정적인 존재

✏️ 일반적으로 사용하는 데 제약이 많은 데이터 -> 계산 -> 액션순으로 구현한다.


2. 액션의 다양한 형태

액션을 생각한다면 단순히 API 호출만을 떠올리는 경우가 많을 것이다. 하지만 실제 액션은 다양한 형태로 존재한다. 그 예를 조금 더 살펴보자.

  • 메서드 호출
  • 생성자 호출
    • 부르는 시점에 서로 다른 값으로 초기화가 이루어진다.
  • 변수 / 속성 / 배열 참조
    • 부르는 시점에 따라 값이 달라진다.
  • 값 할당
    • 변경 가능한 공유되는 변수의 값을 변경한다면 다른 코드에 영향을 줄 수 있으므로 액션이다.
  • 속성 삭제
    • 이 또한 다른 코드에 영향을 줄 수 있으므로 액션이다.

✏️ 액션인지 아닌지 헷갈릴 때는 액션의 정의를 다시 한 번 생각해보자! 코드가 호출 시점이나 횟수에 의존한다면 그 코드는 액션이다!


3. 액션은 코드 전체로 퍼진다.

그렇다면 액션을 부르는 함수는 액션일까? 맞다. 액션은 코드 전체로 퍼진다.

액션을 사용하는 것은 어려운 일이다. 액션에서 계산을 분리해 액션의 크기를 줄이는 것이 좋다. 내부에 계산과 데이터만 있고 가장 바깥쪽에 액션이 있는 구조가 가장 이상적이다.

함수형 프로그래머는 가능한 액션을 쓰지 않고, 계산을 활용한다. 그 이유는 계산은 외부에 영향을 주지 않기 때문에 쉽게 테스트가 가능하며, 재사용 할 수 있기 때문이다.


🔗 액션에서 계산 빼내기

코드에서 액션을 아예 제거할 수 없다. 위에서 설명 했듯이 꼭 필요한 액션라면 적게, 작게 만드는 것이 좋다. 따라서 액션 안에 있는 계산을 따로 분리해 테스트가 쉽도록 구성해야 한다. 그렇다면 어떻게 액션에서 계산을 빼낼 수 있을까? 다음의 코드를 먼저 살펴보자.

var sum = 0 // 전역변수

fun calc(amount: Int): Int {
  sum += amount
  return sum
}

함수에는 입출력이 존재한다. 그렇다면 한 번 입출력을 나열해보자.

  • 입력: amount(파라미터)
  • 출력: sum(리턴값)

하지만 위의 값들 말고 암묵적 입출력이 더 존재한다. 그렇다면 암묵적 입출력 또한 찾아보자.

  sum += amount
  • 전역 변수 sum을 읽는 것암묵적 입력에 해당한다.
  • 전역 변수 sum을 변경하는 것암묵적 출력에 해당한다.


1. 액션을 계산으로: 암묵적 입출력 없애기

이러한 암묵적 입출력이 존재하면 해당 함수는 액션이 된다. 따라서 암묵적 입출력을 없애기 위해 다음의 원칙을 적용하면 된다.

  • 암묵적 입력은 인자로 변경하자.
  • 암묵적 출력은 리턴값으로 변경하자.

즉, 모든 입력은 인자로 모든 출력은 리턴값으로 만든다! 여기서 말하는 인자와 리턴값은 모두 불변값이다. 만약 인자와 리턴값이 바뀔 수 있다면 이 또한 암묵적 입출력이 된다.


2. 계산 추출 단계

  • 추출해 재사용하고자 하는 계산 파악해 별도의 함수로 빼낸다.
  • 위에서 빼낸 함수에서 암묵적 입출력을 파악한다.
  • 암묵적 입력은 인자로, 암묵적 출력은 리턴값으로 변경한다.
// 위 코드 수정
var sum = 0 // 전역변수

fun calc(amount: Int): Int {
  sum = add(sum, amount)
  return sum
}

// 재활용할 계산 만들기
fun add(base: Int, newNum: Int): Int {
  return base + newNum
}


🔗 copy-on-write: 불변 데이터 만들기

다른 곳에서 사용되고 있는 데이터를 바꾸게 된다면 예상하지 못한 결과를 만들어 낼 수 있다. 따라서 변경 가능한 데이터의 불변성을 유지하는 것이 프로그램의 안정성에 좋다. 이를 액션과 계산 관련해서 살펴보자!


1. 불변 데이터와 계산

1️⃣ 변경 가능한 데이터를 읽는 것은 액션이다?

변경 가능한 데이터를 읽는 경우를 생각해보자! 변경이 가능하다는 말은 언제 읽는지에 따라 다른 값을 읽을 수 있다라는 말이다 .

2️⃣ 쓰기와 변경 가능한 데이터

쓰기는 결국 데이터를 변경하는 작업이다. 결국 변경 가능한 데이터는 쓰기가 가능해진다. 따라서 쓰기를 없애는 것은 변경 가능한 데이터를 불변 데이터로 만든다.

3️⃣ 불변 데이터를 읽는 것은 계산이다!

불변 데이터는 읽는 시점 및 횟수와 상관없이 항상 동일한 값을 가지고 있다. 따라서 불변 데이터를 읽는 것은 계산이다!


2. copy-on-write?

그렇다면 변경 가능한 데이터 구조를 다룰 떄 불변성을 어떻게 유지할 수 있을까? 이를 해결하기 위한 것이 바로 copy-on-write 원칙이다.

말 그대로 copy(복사)하고 복사한 객체에 write(쓰기)를 진행하는 것이다. 그렇다면 이러한 원칙이 왜 필요한 것일까? 데이터의 불변성을 유지하고자 하는 목적은 데이터 쓰기를 할 때 다른 코드에 영향을 주는 것을 방지하기 위해서이다. 다음의 copy-on-write 코드를 확인해보자.

(다음은 javaScript 코드이다. javaScript는 기본적으로 변경 가능한 데이터 구조를 제공하기 때문에 데이터 불변성을 위해 copy-on-write 원칙을 적용할 수 있다.

✏️ 여기에서 진행하는 copy-on-write 과정은 기본적으로 변경 가능한 데이터 구조를 제공하는 javaScript 관점이다. 불변 데이터 구조를 제공하는 언어에서는 불변 데이터를 최대한 활용하자!


function add_book(book_list, book) {
  var new_book_list = book_list.slice(); // 1. 얕은 복사
  new_book_list.push(elem) // 2. 복사본 바꾸기
  return new_book_list // 3. 복사본 리턴
}

원래라면 기존 book_list에 새로운 book을 추가하기 때문에 다른 곳에서 공유할 수도 있는 book_list가 변경되면서 예상치 못한 문제가 발생할 수도 있다.

하지만 위의 코드를 본다면 데이터를 읽기만 하고 수정하지 않았다. 결과적으로 쓰기를 읽기만으로 변경한 것이다! 따라서 공유하는 데이터를 바꿔 발생하는 문제를 없앨 수 있는 것이다.

✏️ 핵심은 값을 읽는 책임과 쓰는 책임을 분리하는 것이다.


3. copy, 비용 많이 들지 않을까?

최신 프로그래밍 언어의 런타임과 garbage collector는 불필요한 메모리를 효율적으로 잘 처리한다. 따라서 신경을 쓰지 않고 복사본을 만들 수 있다.

또한, 얕은 복사를 진행한다면 데이터의 최상위 단계만 복사된다. 따라서 같은 메모리를 가리키는 참조에 대한 복사본을 만드는 구조적 공유가 일어나기 때문에 실질적으로 복사되는 양은 그리 많지 않다.


🔗 방어적 복사: 변경 가능한 데이터 잘 활용하기

위에서는 변경 가능한 데이터 구조에 쓰기를 진행할 때 안전하게 할 수 있는 copy-on-write에 대해 다뤄보았다. 하지만 만약 라이브러리나 레거시 코드가 많아 copy-on-write를 적용할 수 없는 경우라면 어떻게 해야 할까?

결국에는 변경 가능한 데이터를 주고 받아야 하는 상황이 생기게 된다. 이러한 상황에서 불변 데이터를 만들어 주고 받을 수 있을까? 이때 활용할 수 있는 방법이 방어적 복사이다.


방어적 복사?

방어적 복사는 들어오고 나가는 데이터의 복사본을 만드는 방식이다.

다음의 경우를 각각 생각해보자.

1️⃣ 안전지대 밖(레거시, 라이브러리..)에서 안전지대(우리가 짠 코드)로 데이터가 들어오는 경우

  • 해당 데이터의 변경 가능성에 대해서는 알 수 없다. 따라서 깊은 복사를 통해 데이터의 모든 계층을 복사함으로써 외부 영향을 받지 않도록 한다.

2️⃣ 안전지대(우리가 짠 코드)에서 안전지대 밖(레거시, 라이브러리..)로 데이터가 나가는 경우

  • 데이터가 나가는 경우에는 많은 복사를 하지 않아도 된다. 깊은 복사는 얕은 복사보다 더 많은 비용을 필요로 하기 때문에 이 경우에는 얕은 복사를 활용한다.


🔗 마무리하며

읽은 내용을 되짚어가며 정리를 하니 읽을 때는 몰랐던 사실을 깨달을 수 있었다. 안드로이드 개발을 진행하면서 liveData를 mutable과 immutable 두 가지로 나눠 관리하는 이유에 대해서도 명확히 이해할 수 있었다. 당시에는 단순히 읽기와 쓰기를 분리하는 것으로만 생각했는데 액션과 계산 관점에서 바라보니 이렇게 책임을 분리하는 것이 무슨 이점을 가져오는지에 대해 확실히 이해하고 넘어갈 수 있었다.

다음 게시글에서는 계층형 설계에 대해 깊게 다뤄보고자 한다. 함수형 코딩을 이해하며 kotlin이라는 언어에 대해 한 발짝 가까워진 듯한 느낌이 들어 뿌듯하다. 이번 기회에 kotlin과 더욱 친숙해져봐야겠다😌.

댓글남기기