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

- Name
- Nostrss
- Github
- Github

명령형, 함수형 구현 방식의 비교하기
같은 기능을 명령형과 함수형으로 구현하는 방식의 차이를 비교해 보려고 한다. 함수형의 경우에는 그동안 공부한 fxjs2, lodash/fp, Ramda 세 가지 라이브러리를 사용해서 구현한 코드를 비교해 보자.
| 항목 | fxjs2 | lodash/fp | Ramda |
|---|---|---|---|
| 한줄 소개 | 유인동 님이 만든 한국산 FP 라이브러리. 이터러블/제너레이터 기반 | lodash의 함수형 모듈. auto-curried, data-last | 순수 함수형 유틸리티 라이브러리. auto-curried, data-last |
| 파이프라인 | go (data-first, 즉시 실행), pipe | pipe, flow | pipe, 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 루프로 해결한다. 바깥 루프가 줄을, 안쪽 루프가 각 줄의 별을 담당한다. result와 row라는 변수를 계속 변이(mutate)시키면서 최종 문자열을 조립한다.
특징:
- "어떻게(how)"를 기술한다 — 변수 초기화, 반복, 누적, 조건 분기까지 모든 단계를 직접 작성한다
- 변수 변이 —
result와row가 루프를 돌면서 계속 바뀐다 - 한눈에 흐름이 보인다 — 절차적이라 위에서 아래로 읽으면 동작이 보인다
- 재사용이 어렵다 — 별 대신 다른 문자를 쓰거나 줄 수를 바꾸려면 코드를 복사해서 고쳐야 한다
함수형 풀이 — 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.map과 R.range를 사용한다. 문법적으로 lodash/fp과 놀라울 정도로 비슷하다.
명령형 vs 함수형: 무엇이 다른가
| 관점 | 명령형 | 함수형 |
|---|---|---|
| 사고방식 | "어떻게(how)" — 단계별 절차를 기술 | "무엇을(what)" — 데이터 변환을 선언 |
| 변수 변이 | result += ..., row += ... | 없음 — 값이 파이프라인을 타고 흘러간다 |
| 데이터 흐름 | 변수에 누적하며 조립 | 입력 → 변환 → 변환 → ... → 출력 |
| 재사용성 | 코드 복사 후 수정 | 함수를 교체하거나 파이프라인을 확장 |
| 가독성 | 절차가 명시적이라 초보자에게 친숙 | 변환 단계가 선언적이라 익숙해지면 간결 |
| 디버깅 | 중간 변수에 breakpoint | 파이프라인 중간에 tap 삽입 |
명령형이 나쁜 것이 아니다. 단순한 로직에서는 명령형이 오히려 직관적이다. 하지만 변환 단계가 늘어나거나, 조합과 재사용이 필요한 순간 함수형의 장점이 드러난다.
FP 라이브러리 비교
| 항목 | fxjs2 | lodash/fp | Ramda |
|---|---|---|---|
| 파이프라인 | go (data-first, 즉시) | pipe (data-last) | pipe (data-last) |
| 함수 합성 | pipe도 별도 제공 | pipe, flow | pipe, compose |
| 지연 평가 | L.map, L.range 등 | 없음 (즉시 평가) | 없음 (즉시 평가) |
| 커링 | curry 수동 적용 | auto-curried | auto-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: 뭐가 다른가
솔직히, 별 그리기 수준에서는 거의 동일하다. 차이가 드러나는 지점은 다른 곳에 있다.
- Ramda는
R.lens,R.over,R.evolve같은 불변 데이터 조작 도구가 풍부하다 - lodash/fp는 일반 lodash 생태계와 호환되어 팀 도입이 쉽다
- Ramda는 FP에 더 충실한 API 설계를 추구한다
이전 시리즈에서 직접 만든 것들과의 대응
이 시리즈에서 바닐라 JS로 직접 구현했던 함수들이 각 라이브러리에서 어떻게 대응되는지 정리하면 이렇다.
| 직접 만든 것 | fxjs2 | lodash/fp | Ramda |
|---|---|---|---|
go(데이터, f1, f2) | _.go | 없음 (pipe 사용) | 없음 (pipe 사용) |
pipe(f1, f2)(데이터) | _.pipe | fp.pipe | R.pipe |
curry(fn) | _.curry | 자동 적용 | 자동 적용 |
L.range(start, end) | L.range | fp.range | R.range |
L.map(fn, iter) | L.map | fp.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의 원리를 이해하고 있다면, 라이브러리는 그 원리 위에 얹는 편의 도구일 뿐이다.
