함수형 자바스크립트 | 루이스 아텐시오 - ChoDragon9/posts GitHub Wiki

요약

  • 함수형 프로그래밍은 자료구조를 새로 만들어 어떤 요건을 충족시키는 게 아니라, 배열 등의 흔한 자료구조를 이용해 다수의 굵게 나뉜 고차 연산을 적용합니다.
    • 기능이 기술된 함수를 인수로 받는 다.
    • 수동 루프를 함수로 대체한다.(map, filter 등)

서론

코드가 복잡해지지 않게 하려면 어떻게 해야 할까요? 복잡성을 다스리는 비결은, 코드베이스의 크기가 커질수록 복잡성이 증가하지 않도록 붙잡아 두는 것입니다. 바로 여기서 함수형 프로그래밍이 요긴하게 쓰이지요.

자료구조는 적게, 일은 더 많이

어플리케이션의 제어 흐름

프로그램이 정답에 이르기까지 거치는 경로를 제어흐름이라고 합니다.

명령형 프로그램은 작업 수행에 필요한 전 단계를 노출하여 흐름이나 경로를 아주 자세히 서술합니다. 보통 작업을 수행하는 단계는 루프와 분기문, 구문마다 값이 바뀌는 변수들로 빼곡히 들어찬다.

let loop = optC()
while (loop) {
  const condition = optA()
  if (condition) {
    optB1()
  } else {
    optB2()
  }
  loop = optC()
}
optD()

반면, 선언적 프로그래밍, 특히 함수형 프로그래밍은 독립적인 블랙박스 연산들이 단순하게, 즉 최소한의 제어 구조를 통해 연결되어 추상화 수준이 높습니다. 실제로 함수형 프로그램은 데이터제어 흐름 자체를 고수준 컴포넌트 사이의 단순한 연결로 취급합니다.

덕분에 다음과 같이 코드가 짧아집니다.

optA().optB().optC().optD()

메서드 체이닝

메서드 체이닝은 여러 메서드를 단일 구문으로 호출하는 OOP 패턴입니다. 메서드가 모두 동일한 객체에 속해 있으며 메서드 흘리기라고도 합니다.

// OOP
'Functional Programming'.substring(0, 10).toLowerCase() + ' is fun'

// FP
concat(toLowerCase(substring('Functional Programming', 1, 10))), ' is fun')

이렇게 함수 코드를 안쪽에서 바깥쪽으로 작성하면 메서드 체이닝 방식만큼 매끄럽지 못합니다. 로직을 파악하려면 가장 안쪽에 감싼 함수부터 한 꺼풀씩 벗겨내야 하고 가독성도 현저히 떨어지지요.

함수 체이닝

객체지향 프로그램은 주로 상속을 통해 코드를 재사용합니다. 순수 객체지향 언어에서, 특히 언어 자체의 자료구조를 구현한 코드를 보면 이런 패턴이 자주 눈에 띕니다. 가령 자바에는 List 인터페이스를 용도에 맞게 달리 구현한 ArrayList, LinkedList, DoublyLinkedList, CopyOnWriteArrayList 등이 있습니다. 이들은 모두 한 부도에서 출발하여 나름대로 특수한 기능을 덧붙인 클래스입니다.

함수형 프로그래밍은 접근 방법이 다릅니다. 자료구조를 새로 만들어 어떤 요건을 충족시키는 게 아니라, 배열 등의 흔한 자료구조를 이용해 다수의 굵게 나뉜 고계 연산을 적용합니다. 이러한 고계 연산으로 다음과 같은 일을 합니다.

  • 작업을 수행하기 위해 무슨 일을 해야 하는지 기술된 함수를 인수로 받습니다.
  • 임시 변수의 값을 계속 바꾸면서 부수효과를 일으키는 기존 수동 루프를 대체합니다. 그 결과 관리할 코드가 줄고 에러가 날 만한 코드 역시 줄어듭니다.

복잡성을 줄이는 디자인 패턴

함수형 프로그래밍이 수치에 관한 학술적 문제만을 다루는 패러다임이라서 실세계에서 맞닥뜨리는 실패 가능성에 대해선 거의 관심이 없다고 오해하는 사람들이 있습니다. 하지만 최근 수년 동안, 외려 함수형 프로그래밍이 다른 프로그래밍보다 에러를 더 깔끔하게 잘 처리한다는 사실이 발혀졌습니다.

프로그램 실행 중 예외가 나거나 네트워크 연결이 끊기는 등 예기치 않는 사고로 인해 데이터가 null, undefined로 세팅되면 갖가지 골치 아픈 문제가 생깁니다. 그래서 언제 발생할지 모를 이슈를 대비해 null체크문을 넣는 라 코드는 어쩔 수 없이 점점 복잡해집니다.

그 결과 훨씬 더 복잡하게 꼬인 코드만 양산됩니다. 애플리케이션 덩치가 커지고 복잡도가 커질수록 확장하기도 어렵고 알 수 없는 수수께끼 코드로 가득해진다.

함수 매핑이 가능한 단순 자료형을 생성하는 함수자(Functor)라는 개념을 소개합니다. 다양한 방식으로 에러를 처리하는 로직이 들어 있는 모나드(Monad)라는 자료형에 함수자를 적용합니다.

모나드는 함수형 프로그래밍에서 가장 난해한 개념 중 하나로, 범주론이라는 수학 분야에서 비롯된 결과물입니다.

try-catch 에러 처리

명령형 자바스크립트 코드에서 예외를 붙잡아 던지는 방법은 단점이 많고 함수형 설계와도 잘맞지 않습니다. 예외를 던지는 함수의 특징은 다음과 같습니다.

  • 다른 함수형 장치처럼 합성이나 체이닝을 할 수 없습니다.
  • 예외를 던지는 행위는 함수 호출에서 빠져나갈 구멍을 찾는 것이므로 단일한, 예측 가능한 값을 지향하는 참조 투명성 원리에 위배됩니다.
  • 예기치 않게 스택이 풀리면 함수 호출 범위를 벗어나 전체 시스템에 영향을 미치는 부수 효과를 일으킵니다.

null 체크라는 고질병

뜻밖의 함수 호출이 실패하는 것보다, 차라리 null을 돌려받으면 적어도 함수를 한군데로 흘러가게 할 수는 있습니다. 하지만 나아질 건 조금도 없습니다. 함수가 null을 반환하면 이 함수를 부른 호출자는 성가신 null 체크를 해야 하는 부담을 떠안습니다.

const getCountry = (student) => {
  const school = student.getSchool()
  if (school !== null) {
    const addr = school.getAddress()
    if (addr !== null) {
      const country = addr.getCountry()
      return country
    }
    return null 
  }
  throw new Error('국가 조회 중 에러 발생!')
}

이 함수는 기껏해야 객체 속성을 얻는 기능이 전부라서 더 간단히 구현해야 합니다. try-catch나 null 체크 코드로 감싸는 건 겁쟁이나 하는 짓입니다. 판박이 코드를 안 쓰고도 에러를 멋지게 처리할 방법은 없을까요?

더 나은 방안: 함수자

함수형 에러 처리는 철저히 다른 방법으로 접근해 소프트웨어 시스템의 난관을 해결합니다. 기본 아이디어는 비슷합니다. 잠재적으로 위험한 코드 주위에 안전망(말하자면 컨테이너)을 설치하는 것입니다.

함수형 프로그램에서는 위함한 코드를 감싼다는 개념은 그대로 가져가되 try-catch 블록은 제거할 수 있습니다. 이것이 명령형과 가장 큰 차이점입니다. 함수형 자료형을 사용하여 불순함과의 분리를 일급 시민으로 만드는 것이지요.

불안전한 값을 감쌈

값을 컨테이너화하는 행위는 함수형 프로그래밍의 기본 디자인 패턴입니다. 값을 안정적으로 다루고 불변성을 지키기 위해 직접 접근을 차단하는 것입니다. 이렇게 감싼 값에 접근하는 유일한 방법은 연산을 컨테이너에 매핑하는 것입니다.

Wrapper라는 단순 자료형을 만들어 개념을 좀 더 구체적으로 알아봅시다. 형식은 단순하지만 그 바탕에 깔려 있는 원리는 실로 강력하고, 기초가 되는 내용이니 꼭 이해 해야 합니다.

class Wrapper {
  // 어떤 단일 값을 저장하는 단순한 형식입니다.
  constructor (value) {
    this._value = value
  }
  // map :: (A -> B) -> A -> B
  // 주어진 함수를 매핑합니다.
  map (f) {
    return f(this.value)
  }
  toString () {
    return `Wrapper(${this._value})`
  }
}

// wrap :: A -> Wrapper(A)
const wrap = (val) => new Wrapper(val)

요점은 에러가 날지 모를 값을 래퍼 객체로 감싼다는 것입니다. 어떤 값이 컨테이너 속으로 들어가면 절대로 값을 직접 조회/변경할 수 없습니다.

올바른 값을 넣어 확인해봅시다.

const wrappedValue = wrap('Get Functional')
wrappedValue.map(R.identity) // => 'Get Functional'
wrappedValue.map(console.log)
wrappedValue.map(R.toUpper) // => 'GET FUNCTIONAL'

이 단순한 아이디어 덕분에, 컨테이너 안에 넣어 보호된 값을 얻고 싶은 코드는 무조건 Wrapper.map을 통해서만 컨테이너 내부에 손을 뻗칠 수 있는 구조로 만들 수 있습니다.

다음은 map을 변형한 fmap 함수입니다.

// fmap :: (A -> B) -> Wrapper[A] -> Wrapper[B]
// 변환된 값을 호출부에 반환하기 전에 컨테이너로 감쌉니다.
fmap (f) {
  return new Wrapper(f(this._value))
}

fmap은 주어진 함수를 콘텍스트로 감싼 값에 적용하는 방법이 구현된 함수입니다. 먼저 컨테이너를 열고 그 안에 보관된 값에 주어진 함수를 적용한 다음, 그 결과를 동일한 형식의 새 컨테이너에 넣고 닫는 것으로 마무리하지요. 이런 함수를 함수자라고 합니다.

함수자의 세계로

함수자는 값을 래퍼 안으로 승급(lifting)한 다음 수정하고 다시 래퍼에 넣을 목적을 염두에 둔 함수 매핑이 가능한 자료구조입니다. fmap 을 일반적으로 정의하면 다음과 같습니다.

fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B)

fmap 함수는 함수(A -> B)와 함수자(감싼 콘텍스트) Wrapper(A)를 받아 새로운 함수자 Wrapper(B)를 반환합니다.