함수형 사고하기

2022-08-19

해당 글은 쏙쏙 들어오는 함수형 코딩 책 내용을 기반으로 작성된 내용입니다.


언젠가부터 함수형 프로그래밍 얘기가 여기 저기에서 들려왔다. 함수형 프로그래밍이라하면 대부분 부수효과가 없는 순수함수, 일급 객체, 불변성 지향 그리고 고차 함수 등의 특징을 말한다. 그러나 필자가 봤었던 대부분의 자료들은 어플리케이션 전반이 아니라 단편적인 예시만 알려주고 있어서, 실제로 이 코드가 서비스에서 어떻게 작동하는지, 얼마나 큰 장점이 있는지 알기 힘들었다.

하지만 '쏙쏙 들어오는 함수형 코딩' 책은 이커머스 어플리케이션을 배경으로 자바스크립트를 어떻게 함수형으로 작성할 수 있는지 알려줘서 좋았다. 사실 원서의 제목은 'Grokking Simplicity: Taming Complex Software with Functional Thinking'로 함수형 프로그래밍보다는 함수형 사고에 더 집중하고 있다. 한글 책 제목만 보면 함수형 프로그래밍에 대해서만 얘기하는 것 같은데 조금 아쉽긴 하다.

책은 1, 2부로 나뉘며 1부에서는 함수형 프로그래밍을 위해 코드를 분류하고, 의미있는 계층으로 코드를 구성하는 법을 알려준다. 2부는 일급 함수를 중심으로 복잡한 계산을 만드는 법, 비동기 작업으로 인해 생길 수 있는 버그를 제거하는 법 등을 알려준다.

이번 글에서는 1부 초중반부 내용인 함수형 프로그래밍을 하기 위해 어떻게 사고해야하는지를 정리하고자 한다.

함수형 프로그래밍 정의의 문제점

책에서는 함수형 프로그래밍을 위키피디아의 내용을 요약해서 '부수 효과가 없는 순수 함수'로 정의하고 있지만, 이는 실제 프로그래밍을 할 때 지키기 어렵다고 설명하고 있다. 왜냐하면 모든 서비스 개발에는 부수 효과가 필연적이기 때문이다.

부수 효과란 함수에서 리턴 값을 주는 것 외에 하는 행동을 말한다. 따라서 그 함수에서 반환하는 것 외에 발생하는 모든 행위가 부수 효과라는 뜻이다.

// 합을 리턴하는 것 외에 b를 console로 찍는 행위
let b = 0
const sum = a => {
  console.log(b)
  return a + b
}

// 유저의 이름을 서버에 요청을 보내 업데이트하지만 리턴 값이 없는 onSubmit 함수
const onSubmit = () => {
  updateUserName({ name: 'howdy-mj' })
}

그렇다. 대부분의 서비스를 만들기 위해서는 서버에 요청을 보내는 부수 효과가 꼭 필요하다.

그렇다면 어떻게 함수형 프로그래밍을 따르며 코딩을 할 수 있을까? 부수 효과와 순수하지 않은 함수를 잘 다루어야 한다.

그러기 위해서는 코드를 분류할 줄 알아야 한다.

액션, 계산, 데이터

함수형 프로그래머는 직감적으로 코드를 액션, 계산 그리고 데이터 이렇게 세 분류로 나눈다.

액션

액션은 호출하는 시점과 횟수에 의존한다. 언제 실행되는지, 얼마나 실행되는지에 영향을 받는다. 따라서 호출할 때 조심해야 한다.

액션은 다양한 형태로 나타난다.

// 함수 호출
window.alert('howdy-mj')

// 메서드 호출
console.log('howdy-mj')

// 생성자 함수
new Date() // 호출하는 시점마다 값이 다르다

// 표현식
user.nickName // user가 공유되고 변경 가능한 경우, 읽는 시점에 따라 값이 다를 수 있음
cartItem[0] // 변경 가능한 배열일 경우, 읽는 시점에 따라 값이 다를 수 있음

이 외에도, 앞에서 나온 유저의 정보를 업데이트하는 등의 서버 요청도 모두 액션에 포함된다.

만약 함수 안에서 액션을 호출하고 있다면, 함수 전체가 액션이 된다.

function figurePayout(affiliate) {
  const owed = affiliate.sales * affiliate.commission
  if (owed > 100) {
    // 100달러 이상일 경우만 송금
    sendPayout(affiliate.bank_code, owed)
  }
}

function affiliatePayout(affiliates) {
  for (let i = 0; i < affiliate.length; i++) {
    figurePayout(affiliates[i])
  }
}

function main(affiliates) {
  affiliatePayout(affiliates)
}

코드만 봤을 때 API를 호출하는건 sendPayout() 밖에 없어서 액션이 figurePayout() 하나 인 것처럼 보인다.

하지만 액션은 호출 시점이나 횟수에 의존한다. 따라서 sendPayout()을 호출하고 있는 figurePayout() 역시 액션이다. 같은 논리로 affiliatePayout() 함수도 액션이고, 이를 호출하는 main()도 액션이 되는 것을 피할 수 없다.

이처럼 액션은 코드 전체로 퍼지기 때문에 사용할 때 조심해야 한다.

그러면 과연 어떻게 사용해야 할까?

  1. 가능한 액션을 적게 사용한다. 액션 대신 계산을 사용할 수 있는지 생각해야 한다.
  2. 액션은 가능한 작게 만든다. 액션에서 액션과 관련 없는 코드는 모두 제거한다.
  3. 액션이 외부 세계와 상호작용하는 것을 제한할 수 있다. 내부에 계산과 데이터만 있고, 가장 바깥쪽에 액션이 있는 구조가 이상적이다. (ex. 어니언 아키텍처)
  4. 액션이 호출 시점에 의존하는 것을 제한한다.

지금 바로 다 이해하기는 어려울 수 있다. 우선 다음으로 넘어가자.

계산

계산은 입력 값을 계산하여 출력한다. 그래서 언제 호출해도 항상 같은 값을 주며, 호출하는 횟수도 중요하지 않다.

const sum = (a, b) => {
  return a, b
}

const getStringLength = str => {
  return str.length
}

계산은 언제, 어디서 실행해도 반환 값이 같으며 외부에 영향을 주지 않는다. 따라서 테스트하기 쉽고, 다른 것과 조합하여 더 큰 계산(ex. 일급 계산)을 만들 수 있다. 몇 번을 불러도 안전하다.

하지만 계산과 액션은 실행하기 전에 어떤 일이 발생했는지 알 수 없다. 코드를 읽으면 알 수 있지만, 입력 값으로 실행되어야 결과를 알 수 있다.

데이터

데이터도 계산과 마찬가지로 호출하는 횟수가 중요하지 않다. 하지만 계산과 달리 실행할 수 없다.

const numList = [1, 2, 3, 4, 5]

const name = {
  firstName: 'Kim',
  lastName: 'MJ',
}

액션이나 계산과는 달리 데이터는 정적이고 보이는 그대로이기 때문에 알아보기 쉽고, 비교하기도 쉽다. 그리고 데이터 자체로 의미가 있기 때문에, 여러 가지 방식으로 해석할 수 있다.

분류의 장점

이렇게 분류하는게 어떤 장점이 있을까?

액션은 실행 시점과 횟수에 의존하기 때문에 코드 전체에 영향을 주지 않도록 격리 시켜야 한다. 반면, 계산과 데이터는 실행 시점이나 횟수에 의존하지 않아서 프로그래머가 제어하기 쉬운 코드가 된다. 따라서 에러가 났을 때도 쉽게 찾아낼 수 있다.

그리고 이러한 분류는 불변성을 지켰을 때 더 큰 장점으로 다가온다.

불변성

함수형 프로그래밍 언어는 일반적으로 불변형 데이터 구조를 지원하지만, 지원하지 않는 언어도 존재한다. 자바스크립트 역시 지원하지 않아, 불변 데이터 구조를 만들기 위해서는 카피-온-라이트, 방어적 복사, 이 두 가지 원칙을 지켜야 한다.

동작에 대한 분류

불변 데이터를 만드는 원칙에 대해 알아보기 전에, 동작에 대한 분류를 먼저 알아보자.

동작(기능)에 대한 분류는 읽기(read) 또는 쓰기(write) 또는 둘 다 하는 것으로 분류할 수 있다.

읽기쓰기
  • 데이터의 정보를 가져온다
  • 데이터를 바꾸지 않는다
  • 데이터를 바꾼다

그렇다면 왜 함수형 프로그래밍에서 불변성을 지켜야하는 걸까?

불변 데이터 구조를 읽는 것은 액션이 아니라 계산이다. 다시 한 번 액션과 계산의 차이점을 생각해보자.

액션은 변경 가능한 데이터를 읽는 것이며, 읽는 시점에 따라 데이터가 변경될 수 있다. 쓰기 동작은 데이터를 바꾸기 때문에 데이터를 변경 가능한 구조로 만든다. 만약 쓰기 동작을 모두 제거했다면, 데이터는 생성 이후 바뀌지 않는다. 따라서 불변 데이터다.

아래 장바구니 예시를 통해 불변 데이터 구조를 만드는 방법을 알아보자.

카피-온-라이트(copy-on-write)

카피-온-라이트는 이름에 나타나는 그대로 이전의 값을 복사하고 새로 쓰는 것을 뜻한다. 카피-온-라이트는 1) 복사본 만들기, 2) 복사본 변경하기 그리고 3) 복사본 리턴하기 이렇게 세 단계로 되어 있다.

// 카피-온-라이트를 지키지 않은 방식
const cart = []

const addCartItem = item => {
  cart.push(item)
}

// 카피-온-라이트를 지킨 방식
const cart = []

const addCartItem = (cart, item) => {
  const newCart = cart.slice() // 1. 복사본 만들기
  newCart.push(item) // 2. 복사본 바꾸기
  return newCart // 3. 복사본 리턴하기
}

기존 장바구니를 복사한 장바구니의 정보를 반환했다. 따라서 이는 읽기 동작이다.

이번에는 장바구니의 아이템을 제품명으로 삭제한다고 가정해보자.

// 카피-온-라이트를 지키지 않은 방식
const removeCartItemByName = (cart, name) => {
  let idx = null
  for (let i = 0; i < cart.length; i++) {
    if (cart[i].name === name) {
      idx = i
    }
  }
  if (idx !== null) {
    cart.splice(idx, 1) // 기존 장바구니를 변경하는 쓰기 동작
  }
}

// 카피-온-라이트를 지킨 방식
const removeCartItemByName = (cart, name) => {
  const newCart = cart.slice() // 1. 복사본 만들기
  let idx = null
  for (let i = 0; i < newCart.length; i++) {
    if (newCart[i].name === name) {
      idx = i
    }
  }
  if (idx !== null) {
    newCart.splice(idx, 1) // 2. 복사본 변경하기
  }
  return newCart // 3. 복사본 리턴하기
}

카피-온-라이트 방식을 사용하면 기존 값을 수정하지 않고, 새로운 값으로 리턴하여 불변성을 지킬 수 있다. 따라서 기존의 쓰기 동작이 읽기 동작으로 변경되었다.

만약 읽기와 쓰기를 동시에 하는 메서드는 어떻게 해야할까? 자바스크립트에서 Array.shift() 메서드가 그렇다.

const a = [1, 2, 3, 4]
const b = a.shift()
console.log(b) // output: 1 => 값을 리턴한다
console.log(a) // output: [2, 3, 4] => 값이 바뀌었다

이를 읽기, 쓰기 함수로 각각 분리하는 카피-온-라이트 방법을 알아보자.

const getFirstElement = arr => {
  return arr[0]
}

const dropFirstElement = arr => {
  const newArr = arr.slice()
  newArr.shift()
  return newArr
}

const originArr = [1, 2, 3, 4]

const a = dropFirstElement(originArr) // output: [2, 3, 4]
const b = getFirstElement(originArr) // output: 1

이 외, 객체에 대한 카피-온-라이트 방식은 아래와 같다.

const updateNickname = (user, newNickname) => {
  const userCopy = Object.assign({}, user)
  userCopy.nickname = newNickname
  return userCopy
}

일반적으로 불변 데이터 구조는 변경 가능한 데이터 구조보다 메모리를 더 많이 쓰고 느리다. 하지만 언제든 최적화가 가능하니, 불변 데이터 구조를 사용해보고 느린 부분이 있다면 그때 최적화하면 된다.

또한, 카피-온-라이트는 얕은 복사(shallow copy)이기 때문에, 배열을 복사해도 참조에 대한 복사본이기 때문에 생각보다 많이 복사하지 않는다. 얕은 복사는 중첩 데이터에서 최상위 데이터 구조만 복사하는걸 뜻하며, 중첩된 데이터 구조에서 안쪽 데이터가 같은 데이터를 참조하는 것을 구조적 공유(structural sharing)라고 한다.


방어적 복사(defensive-copy)

방어적 복사는 보관하려고 하는 데이터의 복사본을 만드는 것이다. 카피-온-라이트는 데이터를 바꾸기 전에 복사한다. 이는 무엇이 바뀌는지 알기 때문에 무엇을 복사해야 할지 예상할 수 있다. 하지만 외부에서 들어오는 데이터는 어떤 건지 예측할 수 없기 때문에, 내부에 있는 데이터가 바뀌는 것을 완벽히 막아주는 원칙이 필요하다. 이러한 원칙을 방어적 복사라 한다.

외부의 데이터를 깊은 복사

쏙쏙 들어오는 함수형 코딩, p150

O는 복사본, C는 복사본을 말한다.

외부의 바뀔 수도 있는 신뢰할 수 없는 코드가 안전지대(불변성이 지켜지는 곳)로 들어올 경우, 들어오기 전 깊은 복사본을 만들고 변경 가능한 원본은 버린다. 신뢰할 수 있는 코드만 복사본을 쓰기 때문에 데이터는 바뀌지 않는다. 이러한 방법으로 들어오는 데이터를 보호할 수 있다.

외부로 내보내는 데이터를 깊은 복사

쏙쏙 들어오는 함수형 코딩, p150

반대로, 안전지대에서 나가는 데이터도 바뀌면 안된다. 안전지대 밖으로 나가는 데이터는 신뢰할 수 없는 코드가 값을 변경할 수 있어서 변경하지 못하도록 해야한다. 따라서 나가는 데이터도 깊은 복사본을 만들어 내보낸다. 이렇게 하면 나가는 데이터를 보호할 수 있다.

방어적 복사는 깊은 복사(deep copy)를 한다. 위의 그림처럼, 방어적 복사는 데이터가 안전한 코드에서 나갈 때 복사, 안전한 코드로 데이터가 들어올 때 복사해야 하는 규칙이 있다.

사실 대부분의 웹 API는 암묵적으로 방어적 복사를 한다. JSON 데이터가 API에 요청으로 들어왔을 때, 클라이언트는 데이터를 인터넷을 통해 API로 보내려고 직렬화한다. 이때 JSON 데이터는 깊은 복사본이다. 서비스가 잘동작한다면 JSON으로 응답하는데, 이 역시도 깊은 복사본이다. 서비스에 들어오고 나갈 때 데이터를 복사한 것이다.

자바스크립트의 표준 라이브러리로 깊은 복사를 만들기에는 복잡하다. 보통 Lodash의 cloneDeep() 메서드를 사용한다.


카피-온-라이트와 방어적 복사 비교

카피-온-라이트방어적 복사
통제할 수 있는 데이터를 바꿀 때 사용신뢰할 수 없는 코드와 데이터를 주고 받을 때 사용
안전지대 어디서나 사용 가능 (카피-온-라이트가 불변성을 가진 안전지대를 만든다)안전지대의 경계에서 데이터가 오고 갈 때 사용
얕은 복사깊은 복사
  • 바꿀 데이터의 얕은 복사 생성
  • 복사본 변경
  • 복사본 리턴
  • 안전지대로 들어오는 데이터의 깊은 복사 생성
  • 안전지대에서 나가는 데이터의 깊은 복사 생성

소결론

이 책을 읽기 전까지, '함수형 프로그래밍은 순수 함수로 만들어지며, 이러한 함수들을 조합해서 재사용이 쉽다' 정도로만 생각하고 있었다.

그러나 그 전에 코드를 이해하고, 바라보는 시각을 바꿔야 한다는 생각을 들게 해줬다. 사실 읽었을 때는 이해했다는 느낌이 들지만, 막상 코드로 옮겨보면 생각보다 쉽지 않다. 현재 회사에서 진행 중인 프로젝트를 함수형으로 짜고 있는데, 계층을 나누는 것 자체가 굉장히 어렵다.

함수형으로 코드를 작성하는 것에 관심이 있다면, 해당 책을 읽고 기존의 프로젝트를 리팩토링하는 방식으로 해봐도 좋을 것 같다.

본 글은 책의 단편적인 내용만을 가져왔지만, 책에는 실제 어플리케이션에서 작성될 법한 프론트 코드를 기반으로 예제를 작성했으며, 계층에 대한 그래프도 같이 있어서 이해하기 훨씬 쉽다. 꼭 한 번 읽어보는 것을 추천한다.

다음 글에서 1부의 후반부 내용인 계층형 설계에 대해 정리해보려 한다.