- Published on
명령형 vs 함수형, 이번엔 구구단이다 — fxjs2 · lodash/fp · Ramda
- Authors

- Name
- Nostrss
- Github
- Github

별 그리기 다음은 구구단이다
이전 글에서 별 그리기를 명령형, fxjs2, lodash/fp, Ramda 네 가지 스타일로 풀어봤다. 별 그리기는 단일 범위를 순회하면서 문자열을 쌓는 비교적 단순한 구조였다.
이번에는 한 단계 올려보자. 구구단이다. 2단부터 9단까지, 각 단 안에서 1부터 9까지 곱셈을 출력해야 한다. 바깥 루프와 안쪽 루프, 즉 중첩 반복이 필요하다. 이 중첩 구조에서 명령형과 함수형의 차이가 더 선명하게 드러난다.
문제 정의
2단부터 9단까지, 각 단은 2x1=2부터 2x9=18 형식으로 출력하고, 단과 단 사이는 빈 줄로 구분한다.
2x1=2
2x2=4
...
2x9=18
3x1=3
3x2=6
...
9x9=81
공통 준비: 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'
const join = (sep: string) => _.reduce((a: string, b: string) => `${a}${sep}${b}`)
const joinFp = (sep: string) => (arr: string[]) => arr.reduce((a, b) => `${a}${sep}${b}`)
const joinR = (sep: string) => (arr: string[]) => arr.reduce((a, b) => `${a}${sep}${b}`)
명령형 풀이
console.log('===== [명령형] 구구단 =====')
const rows: string[] = []
for (let a = 2; a <= 9; a++) {
const cols: string[] = []
for (let b = 1; b <= 9; b++) {
cols.push(`${a}x${b}=${a * b}`)
}
rows.push(cols.join('\n'))
}
console.log(rows.join('\n\n'))
별 그리기와 마찬가지로 이중 for 루프다. 바깥 루프가 단(29)을, 안쪽 루프가 각 단의 곱셈(19)을 담당한다.
흐름을 풀어보면 이렇다.
a = 2일 때 안쪽 루프가b를 1부터 9까지 돌면서"2x1=2","2x2=4", ...,"2x9=18"을cols에push한다.- 안쪽 루프가 끝나면
cols.join('\n')으로 한 단을 하나의 문자열로 합친다 —"2x1=2\n2x2=4\n...\n2x9=18". - 이 문자열을
rows에push한다. a = 3, 4, ..., 9에 대해 같은 과정을 반복한다.- 마지막에
rows.join('\n\n')으로 단과 단 사이에 빈 줄을 넣어서 전체 구구단을 완성한다.
별 그리기 때와 다른 점이 있다면, 이번에는 rows와 cols라는 두 개의 배열을 변이시킨다는 것이다. 안쪽 루프가 cols에 push하고, 바깥 루프가 rows에 push한다. 중첩이 깊어질수록 관리해야 할 변수가 늘어나는 게 명령형의 특징이다.
별 그리기 때와 비교:
- 별 그리기: 변수 2개 (
result,row), 문자열 누적 - 구구단: 변수 2개 (
rows,cols), 배열 누적 후join
구조는 비슷한데, 중첩이 한 단계 더 깊어지면서 "바깥 변수가 안쪽 루프에서 쓰이는" 패턴이 생긴다. a라는 바깥 루프 변수가 안쪽 루프의 템플릿 리터럴에서 참조되는 부분이다. 명령형에서는 자연스럽지만, 이 의존 관계가 코드 어디에도 명시적으로 드러나지 않는다.
함수형 풀이 — fxjs2
console.log('\n===== [fxjs2] 구구단 =====')
_.go(
L.range(2, 10),
L.map((a: number) =>
_.go(
L.range(1, 10),
L.map((b: number) => `${a}x${b}=${a * b}`),
join('\n')
)
),
join('\n\n'),
console.log
)
별 그리기에서는 go 안에 L.map이 일렬로 나열됐다. 구구단에서는 go 안에 또 다른 go가 중첩된다.
데이터 흐름을 따라가보자:
L.range(2, 10) → [2, 3, 4, ..., 9]
L.map((a) =>
_.go(
L.range(1, 10), → [1, 2, 3, ..., 9]
L.map((b) => `${a}x${b}=...`) → ["2x1=2", "2x2=4", ...]
join('\n') → "2x1=2\n2x2=4\n..."
)
) → ["2x1=2\n...", "3x1=3\n...", ...]
join('\n\n') → 전체 구구단 문자열
바깥 go가 29를 순회하고, 각 숫자 9를 순회하면서 곱셈 문자열을 만든다. 안쪽 a에 대해 안쪽 go가 1go의 결과는 한 단의 문자열이고, 바깥 join('\n\n')이 단과 단 사이에 빈 줄을 넣어준다.
명령형의 이중 루프와 비교하면, 바깥 루프 → 바깥 go, **안쪽 루프 → 안쪽 go**로 대응된다. 차이는 변수 변이 없이 데이터가 파이프라인을 타고 흘러간다는 것이다.
함수형 풀이 — lodash/fp
console.log('\n===== [lodash/fp] 구구단 =====')
const guguFp = fp.pipe(
fp.map((a: number) =>
fp.pipe(
fp.map((b: number) => `${a}x${b}=${a * b}`),
joinFp('\n')
)(fp.range(1, 10))
),
joinFp('\n\n')
)
console.log(guguFp(fp.range(2, 10)))
lodash/fp도 중첩 구조다. **pipe 안에 또 다른 pipe**가 들어간다.
fxjs2와 비교했을 때 눈에 띄는 차이가 있다.
// fxjs2: go에 데이터를 먼저 넣는다
_.go(
L.range(1, 10), // 데이터가 첫 번째
L.map((b) => ...),
join('\n')
)
// lodash/fp: pipe로 함수를 합성한 뒤 데이터를 넣는다
fp.pipe(
fp.map((b) => ...),
joinFp('\n')
)(fp.range(1, 10)) // 데이터가 마지막
이전에 정리했던 go와 pipe의 차이가 여기서 다시 드러난다. go는 데이터를 먼저 받아서 즉시 실행하고, pipe는 함수를 합성한 뒤 데이터를 나중에 받는다. 중첩이 깊어지면 이 차이가 가독성에 영향을 준다. fxjs2의 go는 위에서 아래로 읽히는 반면, lodash/fp의 pipe는 함수 정의 끝에 (데이터)가 붙는 형태라 시선이 분산될 수 있다.
함수형 풀이 — Ramda
console.log('\n===== [Ramda] 구구단 =====')
const guguR = R.pipe(
R.map((a: number) =>
R.pipe(
R.map((b: number) => `${a}x${b}=${a * b}`),
joinR('\n')
)(R.range(1, 10))
),
joinR('\n\n')
)
console.log(guguR(R.range(2, 10)))
별 그리기 때와 마찬가지로 Ramda는 lodash/fp과 거의 동일한 구조다. R.pipe 안에 R.pipe가 중첩되고, R.map과 R.range를 사용한다. fp.을 R.로, joinFp를 joinR로 바꾸면 끝이다.
별 그리기 vs 구구단: 복잡도가 올라가면 뭐가 달라지나
| 관점 | 별 그리기 | 구구단 |
|---|---|---|
| 중첩 깊이 | 단일 범위 순회 | 이중 범위 순회 (단 × 곱셈) |
| 명령형 변수 | result, row (2개) | rows, cols (2개) + 바깥 변수 참조 |
| fxjs2 구조 | go 안에 L.map 나열 | go 안에 go 중첩 |
| lodash/fp 구조 | pipe 안에 fp.map 나열 | pipe 안에 pipe 중첩 |
| Ramda 구조 | pipe 안에 R.map 나열 | pipe 안에 pipe 중첩 |
| 함수형의 이점 | 별 그리기 수준에서는 크지 않음 | 중첩이 깊어질수록 선언적 구조가 빛남 |
별 그리기에서는 명령형과 함수형의 차이가 "스타일 취향" 정도였다면, 구구단처럼 중첩이 생기면 차이가 더 명확해진다.
명령형에서는 바깥 루프 변수 a가 안쪽 루프에서 암묵적으로 참조된다. 코드가 더 복잡해지면 "이 변수가 어디서 왔지?"를 추적하기 어려워진다.
함수형에서는 (a: number) => ... 클로저가 그 의존 관계를 명시적으로 드러낸다. 각 파이프라인이 독립적인 변환 단위이기 때문에, 안쪽 파이프라인만 떼어내서 테스트하거나 재사용할 수 있다.
fxjs2의 go가 빛나는 순간
세 라이브러리의 구구단 코드를 나란히 놓고 보면, fxjs2의 go가 중첩 구조에서 가독성 면에서 유리하다는 걸 느낄 수 있다.
// fxjs2 — 위에서 아래로 자연스럽게 읽힌다
_.go(
L.range(1, 10),
L.map((b: number) => `${a}x${b}=${a * b}`),
join('\n')
)
// lodash/fp — 함수 정의 끝에 데이터가 붙는다
fp.pipe(
fp.map((b: number) => `${a}x${b}=${a * b}`),
joinFp('\n')
)(fp.range(1, 10))
go는 데이터 → 변환1 → 변환2 → ... 순서로 읽힌다. pipe는 변환1 → 변환2 → ... 를 먼저 정의하고 마지막에 데이터를 넣는다. 단일 파이프라인에서는 큰 차이가 없지만, 중첩이 깊어질수록 go의 위에서-아래로 흐르는 구조가 읽기 편하다.
물론 이건 취향의 영역이기도 하다. pipe를 선호하는 사람은 "함수를 먼저 정의하고 데이터를 나중에 넣는 게 더 함수형답다"고 말할 수 있다. go와 pipe의 관계에서 정리했듯이, 둘은 본질적으로 같은 것이다. go는 즉시 실행, pipe는 함수를 반환할 뿐이다.
정리
별 그리기에 이어 구구단을 네 가지 스타일로 풀어봤다.
- 명령형: 이중
for루프로rows와cols배열을 변이시킨다. 직관적이지만, 바깥 변수가 안쪽 루프에서 암묵적으로 참조되는 구조가 생긴다. - fxjs2:
go안에go를 중첩한다. 지연 평가(L.)와 data-first 구조 덕분에 위에서 아래로 자연스럽게 읽힌다. - lodash/fp:
pipe안에pipe를 중첩한다. 함수를 합성한 뒤 데이터를 넣는 data-last 방식. - Ramda: lodash/fp과 거의 동일한 구조.
R.pipe안에R.pipe를 중첩한다.
별 그리기에서는 "명령형도 충분히 간결한데?"라는 생각이 들 수 있었다. 하지만 구구단처럼 중첩이 한 단계 올라가면, 함수형의 선언적 구조와 명시적 의존 관계가 장점으로 드러나기 시작한다. 다음에는 더 복잡한 문제를 풀어보면서 이 차이를 계속 느껴보자.
