logo
Nostrss
Published on

명령형 vs 함수형, 별 그리기로 비교해보자 — fxjs2 · lodash/fp · Ramda

Authors
명령형 vs 함수형

명령형, 함수형 구현 방식의 비교하기

같은 기능을 명령형과 함수형으로 구현하는 방식의 차이를 비교해 보려고 한다. 함수형의 경우에는 그동안 공부한 fxjs2, lodash/fp, Ramda 세 가지 라이브러리를 사용해서 구현한 코드를 비교해 보자.

항목fxjs2lodash/fpRamda
한줄 소개유인동 님이 만든 한국산 FP 라이브러리. 이터러블/제너레이터 기반lodash의 함수형 모듈. auto-curried, data-last순수 함수형 유틸리티 라이브러리. auto-curried, data-last
파이프라인go (data-first, 즉시 실행), pipepipe, flowpipe, compose
지연 평가L.map, L.filter, L.range 등 네이티브 지원없음 (즉시 평가)없음 (즉시 평가)
커링curry를 수동 적용모든 함수 auto-curried모든 함수 auto-curried
이터러블 지원네이티브 지원 (제너레이터 기반)배열 중심배열 중심
비동기 지원go, pipe, map, reduce 등에서 Promise 네이티브 지원없음 (별도 처리 필요)pipeWith(andThen) 등으로 가능하나 제한적
러닝 커브이터러블/제너레이터 개념 이해 필요. 중간lodash 경험 있으면 낮음FP 개념(lens, transducer 등) 이해 필요. 높음
특징지연 평가 + 이터러블 프로토콜이 최대 강점lodash 생태계와 호환, 팀 도입이 쉬움FP에 충실한 API (lens, evolve 등)

문제 정의

1줄부터 5줄까지, 각 줄에 *을 줄 번호만큼 출력한다.

*
**
***
****
*****

단순하지만, 명령형과 함수형의 사고방식 차이를 드러내기에 충분하다.

공통 준비: join 함수

세 라이브러리 모두 배열을 문자열로 합치는 join이 필요하다. 이전에 reduce로 직접 만들었던 join과 같은 역할이다.

import * as _ from 'fxjs2'
import * as L from 'fxjs2/Lazy'
import fp from 'lodash/fp.js'
import * as R from 'ramda'

// fxjs2용 — reduce 기반, 이터러블에서 동작
const join = (sep: string) => _.reduce((a: string, b: string) => `${a}${sep}${b}`)

// lodash/fp용
const joinFp = (sep: string) => (arr: string[]) => arr.reduce((a, b) => `${a}${sep}${b}`)

// Ramda용
const joinR = (sep: string) => (arr: string[]) => arr.reduce((a, b) => `${a}${sep}${b}`)

fxjs2의 join만 조금 다르다. _.reduce는 이터러블 프로토콜을 따르기 때문에 배열이 아닌 제너레이터 결과에도 바로 동작한다. lodash/fp과 Ramda 버전은 배열의 reduce를 사용한다.

명령형 풀이

console.log('===== [명령형] 별그리기 =====')
let result = ''
for (let i = 1; i <= 5; i++) {
  let row = ''
  for (let j = 0; j < i; j++) {
    row += '*'
  }
  result += (i === 1 ? '' : '\n') + row
}
console.log(result)

이중 for 루프로 해결한다. 바깥 루프가 줄을, 안쪽 루프가 각 줄의 별을 담당한다. resultrow라는 변수를 계속 변이(mutate)시키면서 최종 문자열을 조립한다.

특징:

  • "어떻게(how)"를 기술한다 — 변수 초기화, 반복, 누적, 조건 분기까지 모든 단계를 직접 작성한다
  • 변수 변이resultrow가 루프를 돌면서 계속 바뀐다
  • 한눈에 흐름이 보인다 — 절차적이라 위에서 아래로 읽으면 동작이 보인다
  • 재사용이 어렵다 — 별 대신 다른 문자를 쓰거나 줄 수를 바꾸려면 코드를 복사해서 고쳐야 한다

함수형 풀이 — fxjs2

console.log('\n===== [fxjs2] 별그리기 =====')
_.go(
  L.range(1, 6),
  L.map(L.range),
  L.map(L.map((_: unknown) => '*')),
  L.map(join('')),
  join('\n'),
  console.log
)

이전에 직접 만들었던 go 함수를 기억하는가? _.go가 바로 그것이다. 첫 번째 인자를 시작값으로 받아서 나머지 함수들을 순서대로 적용한다.

데이터 흐름을 따라가보자:

L.range(1, 6)[1, 2, 3, 4, 5]         (지연)
L.map(L.range)[[0], [0,1], [0,1,2], ...]  (지연)
L.map(L.map(_ => '*'))[['*'], ['*','*'], ...]      (지연)
L.map(join(''))['*', '**', '***', ...]      (지연)
join('\n')'*\n**\n***\n****\n*****'     (여기서 평가)

핵심은 L. 접두사가 붙은 함수들이 지연 평가를 한다는 점이다. L.map과 L.filter를 직접 구현했을 때 배웠던 그 제너레이터 기반 지연 평가다. 마지막 join('\n')이 결과를 소비할 때 비로소 전체 파이프라인이 실행된다.

함수형 풀이 — lodash/fp

console.log('\n===== [lodash/fp] 별그리기 =====')
const starFp = fp.pipe(
  fp.map((n: number) => fp.range(0, n)),
  fp.map(fp.map(() => '*')),
  fp.map(joinFp('')),
  joinFp('\n')
)
console.log(starFp(fp.range(1, 6)))

lodash/fp는 일반 lodash와 다르다. 모든 함수가 auto-curried이고 data-last 방식이다. 직접 만든 pipe와 같은 구조다 — 함수를 합성해서 새 함수를 만들고, 데이터는 나중에 넣는다.

fxjs2의 go와 비교하면:

  • go데이터를 먼저 받고 함수를 순서대로 적용한다 (즉시 실행)
  • pipe함수를 먼저 합성해서 새 함수를 만든다 (데이터는 나중에)
// fxjs2: 데이터가 첫 번째
_.go(데이터, f1, f2, f3)

// lodash/fp: 함수 합성 후 데이터 투입
const fn = fp.pipe(f1, f2, f3)
fn(데이터)

함수형 풀이 — Ramda

console.log('\n===== [Ramda] 별그리기 =====')
const starR = R.pipe(
  R.map((n: number) => R.range(0, n)),
  R.map(R.map(() => '*')),
  R.map(joinR('')),
  joinR('\n')
)
console.log(starR(R.range(1, 6)))

Ramda는 lodash/fp과 거의 동일한 구조다. R.pipe로 함수를 합성하고, R.mapR.range를 사용한다. 문법적으로 lodash/fp과 놀라울 정도로 비슷하다.

명령형 vs 함수형: 무엇이 다른가

관점명령형함수형
사고방식"어떻게(how)" — 단계별 절차를 기술"무엇을(what)" — 데이터 변환을 선언
변수 변이result += ..., row += ...없음 — 값이 파이프라인을 타고 흘러간다
데이터 흐름변수에 누적하며 조립입력 → 변환 → 변환 → ... → 출력
재사용성코드 복사 후 수정함수를 교체하거나 파이프라인을 확장
가독성절차가 명시적이라 초보자에게 친숙변환 단계가 선언적이라 익숙해지면 간결
디버깅중간 변수에 breakpoint파이프라인 중간에 tap 삽입

명령형이 나쁜 것이 아니다. 단순한 로직에서는 명령형이 오히려 직관적이다. 하지만 변환 단계가 늘어나거나, 조합과 재사용이 필요한 순간 함수형의 장점이 드러난다.

FP 라이브러리 비교

항목fxjs2lodash/fpRamda
파이프라인go (data-first, 즉시)pipe (data-last)pipe (data-last)
함수 합성pipe도 별도 제공pipe, flowpipe, compose
지연 평가L.map, L.range없음 (즉시 평가)없음 (즉시 평가)
커링curry 수동 적용auto-curriedauto-curried
이터러블 지원네이티브 지원배열 중심배열 중심
번들 크기작음tree-shaking 가능tree-shaking 가능
TypeScript 지원제한적@types/lodash 별도@types/ramda 별도

fxjs2의 차별점: 지연 평가와 이터러블

fxjs2가 가장 눈에 띄는 지점은 지연 평가다. L.range, L.map 등이 제너레이터 기반으로 동작하기 때문에, 중간 배열을 만들지 않고 파이프라인 끝에서 한 번에 평가한다. 직접 L.map을 구현했을 때 경험한 그 방식이다.

lodash/fp과 Ramda는 각 map 호출마다 새 배열을 즉시 생성한다. 별 그리기처럼 작은 데이터에서는 차이가 없지만, 대량 데이터를 다룰 때는 지연 평가가 유리할 수 있다.

lodash/fp vs Ramda: 뭐가 다른가

솔직히, 별 그리기 수준에서는 거의 동일하다. 차이가 드러나는 지점은 다른 곳에 있다.

  • RamdaR.lens, R.over, R.evolve 같은 불변 데이터 조작 도구가 풍부하다
  • lodash/fp는 일반 lodash 생태계와 호환되어 팀 도입이 쉽다
  • Ramda는 FP에 더 충실한 API 설계를 추구한다

이전 시리즈에서 직접 만든 것들과의 대응

이 시리즈에서 바닐라 JS로 직접 구현했던 함수들이 각 라이브러리에서 어떻게 대응되는지 정리하면 이렇다.

직접 만든 것fxjs2lodash/fpRamda
go(데이터, f1, f2)_.go없음 (pipe 사용)없음 (pipe 사용)
pipe(f1, f2)(데이터)_.pipefp.pipeR.pipe
curry(fn)_.curry자동 적용자동 적용
L.range(start, end)L.rangefp.rangeR.range
L.map(fn, iter)L.mapfp.map (즉시)R.map (즉시)
reduce 기반 join_.reduce 활용직접 구현직접 구현

go는 fxjs2에만 있는 독특한 함수다. lodash/fp과 Ramda에서는 pipe로 함수를 합성한 뒤 데이터를 따로 넣는 방식을 취한다. go와 pipe의 관계는 이전에 정리한 바 있다go는 즉시 실행, pipe는 함수를 반환한다는 차이뿐이다.

정리

같은 별 그리기를 네 가지 방식으로 풀어봤다.

  • 명령형: 절차를 직접 기술한다. 단순하고 직관적이지만, 변환이 복잡해지면 변수 관리가 힘들어진다.
  • fxjs2: go로 데이터를 먼저 넣고 변환을 나열한다. 지연 평가(L.)로 중간 배열 없이 효율적으로 처리한다.
  • lodash/fp: pipe로 함수를 합성한다. auto-curried, data-last. lodash 생태계와 호환된다.
  • Ramda: lodash/fp과 구조는 비슷하지만, FP에 더 충실한 API를 제공한다.

어떤 라이브러리를 쓸지보다 중요한 것은 함수형 사고방식 자체다. "데이터를 어떻게 변환할 것인가"를 선언적으로 표현하는 습관이 붙으면, 어떤 라이브러리를 쓰든 코드의 구조가 비슷해진다. 이전 시리즈에서 직접 만들어본 go, pipe, map, reduce의 원리를 이해하고 있다면, 라이브러리는 그 원리 위에 얹는 편의 도구일 뿐이다.