흐름 기반 프로그래밍

이전글: (함수형JS) 순수성, 불변성, 변경정책

앞서 설명한 순수성, 불변성, 변화 제어가 함수 조립에서 어떤 역할을 하는지 알아봤다. 이러한 특징을 이용해 함수를 더 자유롭게 조립하는 방법을 이번 글에서 살펴보겠다.

curry-banner
curry-banner

체이닝

아래와 같은 함수 체이닝 기법은 코드를 매우 간결하고 읽기 쉽게 만든다.

person().setName("Chris").setAge(60).toString()

사람 객체를 만들어 이름과 나이를 설정한 뒤 문자열 정보를 얻는 코드다. 체이닝의 핵심은 각 함수가 this를 반환하는 것이다. person() 함수는 이렇게 구현할 수 있다.

function person() {
  let _name = ""
  let _age = 0

  return {
    setName: function (name) {
      this._name = name
      return this
    },
    setAge: function (age) {
      this._age = age
      return this
    },
    toString: function () {
      return [_this.name, _this.age].join(",")
    },
  }
}

자바스크립트 유틸리티 라이브러리인 lodash, underscore 등을 사용하면 this를 반환하는 함수를 만들지 않고서도 체이닝을 사용할수 있다. chain()과 value()를 사용한 아래 코드를 보자.

var r = _.chain([2, 1, 3])
  .sort()
  .map(n => n * 2)
  .value() // [1, 3, 9]

배열 [2, 1, 3]을 chain() 함수에 전달하면 특별한 lodash 객체를 반환한다. 반환된 객체가 제공하는 메소드는 [2, 1, 3] 배열를 파라매터로 전달받아 실행되는 것이 일반 lodash 객체와 다른 점이다.

또한 이것은 value() 메소드가 호출되기 전까지는 실행되지 않고 기다리는 게으른(lazy) 동작이다. 아래 코드로 확인해 보자.

var r = _.chain([2, 1, 3]).sort().tap(console.log) // 아무것도 출력하지 않음

tap() 메소드는 체이닝 중간에 끼여들어 전달받은 함수 console.log()를 실행한다. 위 코드에서 아직 value() 메소드를 실행하지 않았기 때문에 터미널에는 아무것도 출력되지 않을 것이다. 단지 미래 행동을 미리 설정했다고 이해하면 되겠다.

이러한 체이닝을 직접 구현해 보자

lodash에서 제공하는 이러한 체이닝 기법을 직접 구현하면서 내부 동작을 이해해 보자. 아래 LazyChain 클래스 코드부터 시작해 보겠다.

class LazyChain {
  constructor(obj) {
    this._target = obj
    this._calls = []
  }

  invoke(methodName, ...args) {
    this._calls.push(target => target[methodName].apply(target, args))
    return this
  }
}

생성자 함수에서 _target_calls란 내부 변수를 설정했다. 생성자 함수에서 받은 데이터를 _target 변수에 저장하고, 체이닝으로 추가할 함수를 _calls 배열에 넣을 용도다. _target은 체이닝으로 연결된 메소드를 실행할 때 전달 인자로 사용될 것이다.

체이닝으로 연결할 함수는 invoke() 메소드로 추가할 수 있다. 타겟 객체를 받는 함수를 기존 _calls 배열에 추가하는데 이 함수는 methodName과 이름이 같은 메쏘드를 타겟객체에서 찾은 후 실행한다. 함수 호출뒤 체이닝을 위한 this를 반환한다.

아래 LazyChain 사용법을 보면 동작을 이해하는데 수월할 것이다.

new LazyChain([2, 1, 3]).invoke("concat", [6, 4, 5]).invoke("sort")._calls // [[Function], [Function]]

invoke 메소드로 배열 메소드인 sort와 concat을 체이닝에 추가했다. 이 함수는 아직 실행되지 않은 형태, 즉 래핑된 형태로 _calls 배열에 담겨 있을 것이다. 이렇게 함수를 실행하지 않고 감싸고만 있는 것을 성크(thunk)라고 한다.

그렇다면 이 성크를 실행할 메소드가 필요한데 LazyChain의 force() 메소드로 구현했다.

class LazyChain {
  force() {
    return this._calls.reduce((target, thunk) => thunk(target), this._target)
  }
}

성크 배열인 _calls를 리듀스로 돌려서 하나씩 실행한다.

new LazyChain([2, 1, 3]).invoke("concat", [6, 4, 5]).invoke("sort").force() // [1, 2, 3, 4, 5, 6]

이번에는 체이닝 중간에 끼여들수 있는, 그래서 중간 값을 확인할 용도로 사용할 tap 메소드를 만들어 보겠다.

class LazyChain {
  tap(fun) {
    this._calls.push(target => (fun(target), target))
    return this
  }
}

함수를 인자로 받아 이것을 감싼 성크를 _calls 배열에 추가한다. tab은 어떤 행동을 하고나서 그 결과가 아니라 타겟객체를 그대로 반환하는 것이 invoke()와 다른 점이다. 마지막으로 체이닝을 위해 this를 반환한다.

아래는 tap()을 사용한 코드다.

new LazyChain([2, 1, 3])
  .invoke("concat", [6, 4, 5])
  .tap(console.log) // [ 2, 1, 3, 6, 4, 5 ]
  .invoke("sort")
  .force() // [ 1, 2, 3, 4, 5, 6 ]

체이닝의 한계

LazyChain은 초기에 전달한 공통 객체 _target의 메소드만 연결할 수 있다는 한계를 가진다. 뿐만 아니라 LazyChaine은 초기 전달할 객체를 변이시킬 가능성도 가지고 있다.

let data = [2, 1, 3]
new LazyChain(data).invoke("splice", 2, 1).force()
console.log("data", data) // [2, 1] ->초기 데이터 변경됨

대부분의 배열 메소드는 기존 객체를 변이 시키지 않고 새로운 배열을 반환한다. splice()는 다르다. 기존 객체를 변경시키기 때문에 [2, 1, 3]에 splice 메소드 체인을 추가하면 기존 배열이 변경된다. 이것은 데이터의 불변성을 지키기 못한 것이고, 인자를 변이시키지 않는 순수 함수의 조건에도 어긋난다.

다음으로 순수함수와 불변성을 유지할수 있는 파이프라이닝 기법을 소개하겠다. 우리는 여전히 함수를 조립해야 하니깐 말이다.

파이프라이닝

LazyChain이 객체에 종속적인 메소드를 연결한 것이라면, 아래 pipeline은 데이터와 이를 가공할 함수를 인자로 받는다.

const pipeline = (seed, ...funs) =>
  funs.reduce((value, fun) => fun(value), seed)

LazyChain과 비교하면 매우 짧은 코드다. 초기 데이터는 seed로 들어오고 이것을 가지고 처리할 함수목록이 funs 배열로 들어올 것이다. 입력한 데이터를 리듀스로 돌리면서 함수를 실행해 나간다. 마치 LazyChain의 force()와 비슷한 동작이다.

pipeline() // undefined
pipeline(42) // 42
pipeline(42, n => -n) // -42

사용법도 간단하다. 타겟 객체를 첫번째 인자로 전달하고 나머지는 파이프라인을 구성할 함수들을 전달한다. 이렇게 만든 파이프라인에 타겟 객체를 흘려보내는 모습이 될것이다.

아래는 제곱함수 sqr을 연결한 것이다.

const sqr = n => n * n
pipeline(2, sqr, sqr) // 16

파이프라인끼리는 조합할 수 있다

파이프라인에 전달된 함수가 순수함수라면 파이프라인으로 만든 함수도 순수함수이다. 따라서 파이프라인으로 만든 함수를 다시 파이프라이닝할 수 있다. 아래 코드는 sqr 두 개를 연결한 doubleSqr 함수를 다시 sqr 함수로 연결한 코드다.

const doubleSqr = n => pipeline(n, sqr, sqr)
pipeline(2, doubleSqr, sqr) // 256

이처럼 함수의 순수성을 지킴으로 인해 함수 조립의 유연성을 극대화 할 수 있다.

인자가 두 개인 함수의 조합

파이프라이닝은 각 함수간에 데이터를 한 개만 전달할 수 있는 구조다. sqr이 인자를 하나만 받기 때문에 파이프라이닝으로 연결할 수 있었다. 만약 add 함수처럼 인자가 두 개인 함수는 어떻게 파이프라이닝으로 연결할 수 있을까?

커링은 다중인자를 받는 함수를 단일 인자를 받는 함수열로 변경하는 녀석이다. 커링을 이용해 add(a, b) 처럼 두 개 인자를 받는 함수를 b는 4로 고정하고 a 하나만 받는 함수로 변경할 수 있다.

const curry = (fun, arg2) => arg1 => fun(arg1, arg2)
const add = (a, b) => a + b
const add4 = curry(add, 4)

add4(6)은 add(6, 4)를 호출할 것이다. add() 함수의 인자를 1개로 고정시켰으니 이제 파이프라인으로 연결할 수 있게 되었다.

console.log(pipeline(2, doubleSqr, add4)) // 20

파이프라인의 한계

하지만 파이프라인도 한계가 있다. 함수 간에 전달되는 공통 객체의 모양이 같아야만 한다는 것이다. 파이프라인에 console.log처럼 터미널에 문자열만 출력하고 undefined 값을 반환하는 함수는 어떻게 연결할 수 있을까?

pipeline(2,
  sqr, // 4
  console.log, // 4 출력
  sqr)); // NaN

첫번째 sqr(2)가 실행되어 숫자 4가 console.log()로 전달될 것이다. 그리고 console.log(4)는 콘솔에 숫자 4만만 출력하고 undefeind를 sql 함수로 전달한다. 결국 sqr(undefined)는 숫자형 인자가 아니라서 NaN을 반환하게 된다.

물론 console.log를 래핑해 파이프라인으로 엮을 수 있겠지만 근본적인 해결 방법은 아니다.

pipeline(2,
  sqr, // 4
  n => (console.log(n), n), // 4 출력
  sqr)); // 16

함수의 인터페이스에 구애받지 않고 함수를 자유자재로 연결하는 방법에 대해 알아보자.

데이터 흐름과 제어 흐름

공통모양찾기

파이프라인을 대체할수 있는 actions()라는 함수를 만들었다. 함수 목록을 acts 배열로 받아서 결과를 반환하는 함수다.

const actions = (...acts) => seed =>
  acts.reduce(
    (stateObj, action) => {
      const result = action(stateObj.state),
        answers = [...stateObj.answers, result.answer]
      return { answers, state: result.state }
    },
    { answers: [], state: seed }
  )

코드가 좀 복잡해졌다. 혹시 'stateObj가 공통 객체아닌가?'라고 눈치챘다면 어느정도 이해한 셈이다. 차근차근 읽어보자.

우선 actions는 함수로 구성된 acts 배열을 받은 뒤, 초기값 seed를 인자로 받는 함수를 반환한다. 반환된 함수는 함수 목록을 리듀스로 돌려 실행하는데 이 때 공통객체인 stateObj 를 활용해 값을 계산한다.

stateObj는 answers와 state 두 개의 키로 구성된 객체다.

answers는 파이프라인으로 연결된 함수가 실행될 때마다 그 결과값을 저장하는 배열이다. 각 함수(action)가 계산한 결과(answer)를 저장하는 히스토리라고 이해하면 되겠다.

state는 함수 실행시마다 그 결과값으로 갱신되는 변수다. 리듀스가 종료되면 최종 결과 값이 저장될 것이다.

actions 함수는 리듀스 결과 값을 반환하는데 모든 함수의 결과값을 저장한 answers 배열과 최종 결과값을 저장한 state로 구성된 객체를 반환할 것이다.

actions로 함수를 연결하기 위해서는 기존의 sqr 함수를 조금 수정해야 한다. actions에서는 전달받은 함수가 state와 answer를 가지고 있다고 가정했기 때문이다.

const sqr = n => n * n
const msqr = n => ({ answer: sqr(n), state: sqr(n) })

이렇게 정의한 msqr은 actions의 인자로 전달해서 연결할 수 있다.

const doubleSqr = actions(msqr, msqr)
doubleSqr(2) // { values: [ 4, 16 ], state: 16 }

값을 리턴하지 않는 함수를 연결하기

actions를 만든 목적은 파이프라이닝과 달리 결과를 반환하지 않는 함수도 연결하기 위해서다. 터미널 로그만 찍고 값을 반환하지 않는 함수 log() 함수는 어떻게 기존 함수와 연결할 수 있을까?

const log = msg => console.log("mlog", msg)

sqr을 actions에 전달하기 위해 sqr을 msqr로 래핑했듯이 log도 mlog로 래핑해야 한다.

const mlog = msg => (log(msg), { state: msg })

메세지를 받아 로그를 찍고 객체를 반환한다. 이 객체는 state에 전달받은 msg 값을 그대로 사용한다. actions에서는 mlog가 반환한 객체의 state 키를 통해 다음 함수의 전달 인자로 사용할 것이다.

msqr과는 달리 mlog의 반환 객체는 answer가 없다. 즉 이 함수는 반환할 것이 없다는 의미다. actions에서는 mlog의 반환 객체에 answer 값이 없으므로 answers 배열에 undefined 값을 저장할 것이다.

아래는 mlog를 기존 함수들과 연결한 코드다.

const doubleSqrLog = actions(msqr, mlog, msqr)
console.log(doubleSqrLog(2))
//'mlog 4' 가 터미널에 출력된다
// { answers: [ 4, undefined, 16 ], state: 16 }

이처럼 actions를 통해 형태가 다른 함수를 서로 연결할 수 있다. 하지만 연결할 함수를 actions에 전달하기 위한 처리과정이 다소 번거롭다.

액션을 단순화하기 위한 lift() 함수

연결할 함수를 actions 전달용 함수로 변환하는 함수인 life()를 만들어 보자.

const lift = (answerFun, stateFun) => state => {
  const answer = answerFun(state)
  state = stateFun ? stateFun(state) : answer
  return { answer, state }
}

actions는 매번 함수 호출시 answers와 state를 갱신한다. 이것을 위해 actions로 연결할 함수는 answers와 state에 어떤 값이 저장될지 설정할수 있어야할 것이다. 그래서 answerFun과 stateFun 함수를 인자로 받았다. 그리고 state를 인자로 받는 함수를 반환한다.

answerFun으로 이 함수의 대답(answer)을 구하고 stateFun으로 다음 함수로 전달할 state 값을 구한다. 마지막으로 이 두 값으로 구성한 객체를 반환하는 것이 life의 역할이다.

life()를 사용하는 코드를 보면 수월하게 이해할수 있을 것이다.

const lsqr = lift(sqr)
const llog = lift(log, v => v)
const ladd6 = life(curry(add, 6))

lsqr는 answerFun에 sqr로 전달해서 만든 함수다. 반환된 함수는 state를 받는 함수를 반환할 것이다.

이제 남은것은 lift로 만든 함수를 actions로 연결하는 것이다.

const doubleSqrLog2 = actions(lsqr, llog, ladd6)
console.log(doubleSqrLog(2)) // { answers: [ 4, undefined, 10 ], state: 10 }
// 중간에 'mlog 4' 가 콘솔에 출력된다

이처럼 actions를 이용해서 리턴값이 다른 함수들을 연결할 수 있다.

정리

함수 체이닝을 직접 구현해 보면서 함수 연결 방법에 대해 이해할 수 있었다. 객체 종속적인 메소드의 한계를 극복하기 위해 파이프라이닝을 구현했고 리턴타입이 다른 함수들까지 연결하기 위한 actions() 함수를 구현해 봤다.

샘플코드: https://github.com/jeonghwan-kim/functionalstudy

이것으로 함수형 JS 연재를 마친다. 전체 글 목록은 아래를 참고.