logo
Nostrss
Published on

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

Authors
명령형 vs 함수형 구구단

별 그리기 다음은 구구단이다

이전 글에서 별 그리기를 명령형, 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)을 담당한다.

흐름을 풀어보면 이렇다.

  1. a = 2일 때 안쪽 루프가 b를 1부터 9까지 돌면서 "2x1=2", "2x2=4", ..., "2x9=18"colspush한다.
  2. 안쪽 루프가 끝나면 cols.join('\n')으로 한 단을 하나의 문자열로 합친다 — "2x1=2\n2x2=4\n...\n2x9=18".
  3. 이 문자열을 rowspush한다.
  4. a = 3, 4, ..., 9에 대해 같은 과정을 반복한다.
  5. 마지막에 rows.join('\n\n')으로 단과 단 사이에 빈 줄을 넣어서 전체 구구단을 완성한다.

별 그리기 때와 다른 점이 있다면, 이번에는 rowscols라는 두 개의 배열을 변이시킨다는 것이다. 안쪽 루프가 colspush하고, 바깥 루프가 rowspush한다. 중첩이 깊어질수록 관리해야 할 변수가 늘어나는 게 명령형의 특징이다.

별 그리기 때와 비교:

  • 별 그리기: 변수 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를 순회하고, 각 숫자 a에 대해 안쪽 go가 19를 순회하면서 곱셈 문자열을 만든다. 안쪽 go의 결과는 한 단의 문자열이고, 바깥 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.mapR.range를 사용한다. fp.R.로, joinFpjoinR로 바꾸면 끝이다.

별 그리기 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 루프로 rowscols 배열을 변이시킨다. 직관적이지만, 바깥 변수가 안쪽 루프에서 암묵적으로 참조되는 구조가 생긴다.
  • fxjs2: go 안에 go를 중첩한다. 지연 평가(L.)와 data-first 구조 덕분에 위에서 아래로 자연스럽게 읽힌다.
  • lodash/fp: pipe 안에 pipe를 중첩한다. 함수를 합성한 뒤 데이터를 넣는 data-last 방식.
  • Ramda: lodash/fp과 거의 동일한 구조. R.pipe 안에 R.pipe를 중첩한다.

별 그리기에서는 "명령형도 충분히 간결한데?"라는 생각이 들 수 있었다. 하지만 구구단처럼 중첩이 한 단계 올라가면, 함수형의 선언적 구조명시적 의존 관계가 장점으로 드러나기 시작한다. 다음에는 더 복잡한 문제를 풀어보면서 이 차이를 계속 느껴보자.