
해당 포스트는 멀티패러다임 프로그래밍 (유인동 저)을 읽고 개인적으로 정리한 글입니다.
1장과 2장에서 다룬 개념들을 명령형 프로그래밍, 객체 지향 프로그래밍, 함수형 프로그래밍 관점에서 다시 정리해보면 다음과 같다.
- 제네레이터는 명령형 코드로 이터레이터 생성
yeild
지점에서 코드를 지연 평가하는 효과를 낸다.- 이는 '코드가 리스트이고 리스트가 코드'라는 LISP적 사고와 맞닿아 있다.
- 이터레이터는 반복자 패턴의 구현체
- 객체지향의 반복자 패턴을 가져와 명령형인 이터레이터를 만든다.
- 이터러블 이터레이터는 명령형, 객체지향적, 함수형으로 다룰 수 있음
next()
메서드를 실행하여 명령형처럼 다룰 수 있다.- 이터러블 이터레이터를 다루는 클래스를 만들어 객체지향적으로 만들 수 있다.
- 고차 함수를 통해 함수형으로 구현할 수 있다.
코드가 곧 데이터 - 로직이 담긴 리스트
[for, i++, if, break]
- 코드를 리스트로 생각하기
코드를 리스트로 바라보는 사고방식은 프로그래밍 패러다임을 확장하는 강력한 도구가 된다. 이 사고를 이용하면 함수형 프로그래밍에서 더 읽기 쉽고 유지보수하기 좋은 코드를 작성할 수 있다. 어떻게 작성하면 좋을지 예제들을 살펴보자.
명령형으로 작성한 n개의 홀수를 제곱하여 모두 더하는 함수
function sumOfSquaredOdds(limit: number, array: number[]): number {
let sum = 0;
for (const num of array) {
if (num % 2 === 1) {
sum += num * num; // 제곱해서 더하기
}
if (--limit === 0) break;
}
return sum;
}
// 사용 예시
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(sumOfSquaredOdds(3, numbers)); // 1² + 3² + 5² = 35
console.log(sumOfSquaredOdds(2, numbers)); // 1² + 3² = 10
이 함수의 동작을 분해하면 다음과 같다.
- 순회 (
for
) - 조건 검사 (
if (num % 2 === 1)
) - 값 변환 (
num * num
) - 누적 (
sum +=
) - 제어 (
if (--limit === 0) break
)
단계별 함수형 변환
1단계: 조건문을 filter
로 대체
function sumOfSquaredOdds(limit: number, array: number[]): number {
let sum = 0;
for (const num of filter(a => a % 2 === 1, array)) {
sum += num * num;
if (--limit === 0) break;
}
return sum;
}
이제 반복문 내부에서는 홀수인지 검사할 필요 없이 제곱과 합산에만 집중할 수 있다.
2단계: 값 변환을 map
으로 대체
function sumOfSquaredOdds(limit: number, array: number[]): number {
let sum = 0;
for (const num of map(a => a * a, filter(a => a % 2 === 1, array))) {
sum += num;
if (--limit === 0) break;
}
return sum;
}
리스트 자체가 "홀수만 제곱한 리스트"가 되어 반복문에서는 더하기만 하면 된다.
3단계: 횟수 제한을 take
로 대체
function* take<T>(limit: number, iterable: Iterable<T>): Generator<T> {
const iterator = iterable[Symbol.iterator]();
while (limit-- > 0) {
const { value, done } = iterator.next();
if (done) break;
yield value;
}
}
function sumOfSquaredOdds(limit: number, array: number[]): number {
let sum = 0;
for (const num of take(limit, map(a => a * a, filter(a => a % 2 === 1, array)))) {
sum += num;
}
return sum;
}
break
문조차 함수로 추상화했다. 시간 복잡도는 동일하지만 제어 구조가 선언적으로 바뀌었다.
4단계: 합산을 reduce
로 대체
function sumOfSquaredOdds(limit: number, array: number[]): number {
return reduce(
(a, b) => a + b,
0,
take(limit, map(a => a * a, filter(a => a % 2 === 1, array)))
);
}
5단계: 체이닝으로 가독성 향상
function sumOfSquaredOdds(limit: number, array: number[]): number {
return fx(array)
.filter(a => a % 2 === 1)
.map(a => a * a)
.take(limit)
.reduce((a, b) => a + b, 0);
}
지금까지 명령형 코드로 구현된 함수를 함수형 프로그래밍 방식으로 변경했다. 코드가 선언적으로 변했고, 가독성이 크게 향상됐다.
결과를 보면 코드의 각 부분을 리스트 프로세싱 함수들로 대체 했고, 이는 결국 중첩된 리스트를 만드는 것과 같다.
언어를 넘어 적용 가능한 개념, 패러다임
예제로 살펴본 자바스크립트, 타입스크립트 외에도 클로저, 코틀린, 스위프트, 스칼라, C#, 자바 등 다양한 언어에서도 유사한 매커니즘으로 구현할 수 있다. 현대 언어들은 함수형 패러다임을 적극적으로 적용하고 고도화하고 있다. 전통적인 객체지향 언어인 자바도 최근에는 풍부한 고차 함수 세트를 갖춘 멀티패러다임 언어로 발전했다.
현대 프로그래밍에서 패러다임의 경계가 흐려지고 있다. 명령형 프로그래밍, 객체지향 프로그래밍, 함수형 프로그래밍이 서로 완전히 대체 가능한 관계로 발전하고 있는 것이다.
Generator:Iterator:LISP - 지연 평가와 안전한 합성
이어서 find
, every
, some
과 같은 고차 함수를 리스트 프로세싱 함수의 조합만으로 구현하면서 Generator, Iterator, LISP가 서로 완전히 대체될 수 있다는 관점을 확장해보자.
find 구현
find는 이터러블을 순회하면서 참으로 평가되는 첫 번째 요소를 반환하고, 만족하는 요소가 없다면 undefined
를 반환한다.
type Find = <A>(f: (a: A) => boolean, iterable, Iterable<A>) => A | undefined;
우선 명령형 코드로 함수를 작성해보자.
function find<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined {
const iterator = iterable[Symbol.iterator]();
while (true) {
const { value, done } = iterator.next();
if (done) {
return undefined;
}
if (f(value)) {
return value;
}
}
}
이 코드의 작동 방식은 다음과 같다.
- 이터레이터 객체를 생성하여 이터러블을 순회할 준비를 한다.
- 무한 루프안에서 이터레이터의
next()
메서드를 호출한다. done
이true
인 경우 해당 값을 반환한다.f(value)
가 true인 경우 해당 값을 반환한다.- 루프가 끝날 때까지 만족하는 조건의 값을 찾지 못하면
undefined
를 반환한다.
Array.porototype.filter
처럼 지연 평가를 지원하지 않는 filter
함수는 배열의 모든 요소를 순회하여 참으로 평가되는 모든 요소를 담은 배열을 반환한다. 하지만 지연 평가를 지원하는 filter
를 사용한다면 원하는 만큼만 실행시킬 수 있다. 즉 지연 평가를 지원하는 filter
로 만들어진 이터레이터의 next()
를 한 번만 평가하면 이는 find
와 동일한 로직과 효율을 가지게 된다.
즉, find
는 filter
를 이용해 만들 수 있다.
const find = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
const [head] = filter(f, iterable);
return head;
}
코드의 모듈성을 높이기 위해 head를 찾는 부분을 별도의 함수로 분리해보자.
const head = <A>([a]: Iterable<A>): A | undefined => a;
const find = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
return head(filter(f, iterable));
}
마지막으로 2장에서 만들어 두었던 FxIterable
클래스를 이용하여 체이닝으로 변경해보자.
const find = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined =>
fx(iterable)
.filter(f)
.to(head);
세 가지 방식 모두 명령형 코드로 작성한 find
함수와 동일한 효율을 제공하면서도 코드가 간결해졌다.
우리는 find
와 같은 고차 함수를 명령형 접근 대신 리스트 프로세싱 함수들의 조합을 통해서도 동일하게 동작하고, 시간 복잡도도 같을 수 있도록 만들어 보았다. 각 패러다임으로 작성한 코드가 서로를 완전히 대체하고, 섞어서 사용할수도 있다. 멀티패러다임 언어를 사용하는 우리는 상황에 따라 가장 알맞은 패러다임을 선태하고 조합할 수 있다.
타입스크립트에서의 안전한 합성
타입스크립트에서 안전한 합성을 위해 예외상황을 처리하는 방법들을 살펴보자
// 1. 옵셔널널체이닝 연산자 (?.)
const desert = find({ price }) => price < 2000, deserts);
const price = desert?.price;
// 2. 단언 연산자자(!)
const desert2 = find({ price }) => price < Infinity, deserts)!;
const name = desert.name;
옵셔널 체이닝 연산자를 통해 프로퍼티에 안전하게 접근
객체가 존재하지 않을 경우 옵셔널 체이닝 연산자 혹은 병합 연산자를 사용하여 안전하게 값에 접근하고, 기본값을 제공하는 방법이 있다.
Non-null 단언 연산자를 통해 무조건 값이 있는 상황을 의도
만약 객체가 없는 상황이 있다면 이는 개발자가 의도한 상황이 아니기 때문에, 에러를 숨기지 않고 전파해야 한다고 하는 것이다. 에러를 무조건 숨기는 것이 정답이 아닐 때가 있다. 꼭꼭 숨은 에러는 그만큼 디버깅하기 힘들어지기 때문이다. 즉, 이 방식은 "이 로직에는 값이 반드시 존재하도록 설계 했다." 라는 개발자의 의도를 표현하는 것이다.
따라서 이 부분에서 에러가 발생한다면 !
를 없애는 것이 아닌, API가 잘못됐는지, DB가 잘못됐는지를 검사해야 한다.
every 함수
이번에는 주어진 함수 f
가 모든 요소에 대해 true
를 반환하면 최종 결과로 true
아니면 false
를 반환하는 함수를 만들어 보자.
아이디어는 다음과 같다. 리스트의 모든 요소를 boolean
값으로 변환한 뒤(map
활용) 이들을 &&
연산자로 모두 연결(reduce
활용) 하면 원하는 결과를 얻을 수 있을 것이다.
function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return fx(iterable)
.map(f)
.reduce((a, b) => a && b, true); // [a: boolean], [b: boolean]
}
주로 reduce
는 누적 함수로 두 값을 계산하거나, 병합하는 유형의 함수로 많이 쓰지만 여기서는 논리 연산자를 통해 누적시켰다. 이 방법은 모든 요소가 조건을 만족하는지 확인할 때 유용하다.
코드를 보고 이런 의문이들수도 있을 것이다. "왜 reduce
에서 한 번에 처리하지 않고 map
으로 나누는 거지?" 하지만 위 코드와 reduce
에서 한 번에 처리할 경우 시간 복잡도는 O(n)으로 동일하다.
표면적으로는 리스트가 map
을 모두 통과한 후 reduce
로 한번 더 순회 로보이지만. 실제로는 각 요소가 map
을 통과 후 reduce
로 소비되므로 시간 복잡도가 동일해진다.
Warning
이터레이터로 만든 함수가 아닌 일반 array에 내장된 함수를 쓰면 생각한대로 배열을 두 번 순회하는 것이 맞다.
some 함수
비슷한 아이디어로 some
함수도 구현할 수 있다. 논리 연산자로 ||
를 사용하면 된다.
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return fx(iterable)
.map(f)
.reduce((a, b) => a || b, false);
}
지연 평가에 기반한 break 로직 끼워 넣기
사실 every
와 some
함수 모두 결과를 만들기 위해 모든 요소를 순회할 필요가 없다. every
는 false
를 하나라도 만나면 순회를 종료하게 하고, some
은 true
를 하나라도 만나면 순회를 종료시키면 된다.
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return fx(iterable)
.map(f)
.filter(a => a)
.take(1)
.reduce((a, b) => a || b, false);
}
some
함수에 .filter(a ⇒ a).take(1)
부분을 추가하여 true
를 하나라도 만나면 더 이상 순회하지 않고 종료 하도록 했다.
함수의 공통 로직을 함수형으로 추상화하기
함수형 프로그래밍은 리스트, 코드, 함수를 값으로 다루므로 공통 로직을 분리하여 추상화하기 편리하다. every
와 some
함수는 비슷한 코드 구조를 가지고 있으므로 이를 추상화 해보자.
function accumulateWith<A>(
accumulator: (a: boolean, b: boolean) => boolean,
acc: boolean,
taking: (a: boolean) => boolean,
f: (a: A) => boolean,
iterable: Iterable<A>
): boolean {
return fx(iterable)
.map(f)
.filter(taking)
.take(1)
.reduce(accumulator, acc);
}
function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return accumulateWith((a, b) => a && b, true, a => !a, f, iterable);
}
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return accumulateWith((a, b) => a || b, false, a => a, f, iterable);
}
이미 함수를 전달하는 형태로 구성되어있기 때문에 간단한 수정으로 추상화 작업을 마칠 수 있다. 이처럼 함수형 프로그래밍은 리팩터링하기 좋고, 유지보수성이 뛰어나다.
concat으로 더하기
배열 메서드 concat
은 여러 배열을 하나로 결합하는데 사용된다. arr.concat(arr2)
는 배열 arr
과 arr2
를 결합한 새로운 배열을 즉시 반환하며 원본 배열은 수정되지 않는다. 그러나 모든 배열 요소를 즉시 평가하기 때문에 매우 큰 배열을 결합할 때 메모리 사용량이 증가할 수 있다.
제네레이터를 사용해 concat
을 구현하면 지연 평가를 통해 요소를 필요할 때마다 처리할 수 있어 메모리 효율성과 성능을 높일 수 있는 가능성을 제공한다.
제네레이터로 concat 구현하기
function* concat<T>(...iterables: Iterable<T>[]): IterableIterator<T> {
for (const iterable of iterables) yield* iterable;
}
const arr = [1, 2, 3, 4];
const iter = concat(arr, [5]);
console.log([...iter]);
// [1, 2, 3, 4, 5]
여기서 눈여겨 볼 점은 배열 전체를 한 번에 결합하는 대신 요소를 하나씩 순회하며 생성한다는 것이다.
배열 메서드 concat과 제네레이터 concat의 차이
const arr = [1, 2, 3, 4, 5];
// 배열 concat을 사용한 예제
const arr2 = arr.concat([6, 7, 8, 9, 10]);
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let acc = 0;
for (const a of take(7, arr2)) {
acc += a;
}
console.log(acc); // 28
// 제너레이터 concat을 사용한 예제
const iter = concat(arr, [6, 7, 8, 9, 10]);
console.log(iter); // concat {<suspended>} (아무 일도 일어나지 않음)
let acc2 = 0;
for (const a of take(7, iter)) {
acc2 += a;
}
console.log(acc2); // 28
배열의 concat
메서드를 사용할 때는 큰 크기의 배열이 복사될 경우 메모리 사용량이 증가한다. 반면 제네레이터 concat
은 값을 복사하지 않고 필요한 순간에만 값을 생성하여 효율적으로 작동한다.
배열의 목적이 acc
값을 구하는 데만 목적이 있음에도 arr2
라는 새로운 배열을 만들어야 한다. 그러나 제네레이터 concat
은 배열을 복사하지 않고 연산을 수행할 수 있다.
[!NOTE]
메모리 사용량이 아닌 처리 속도를 기준으로 본다면 배열 메서드
concat
을 사용하는 것이 훨씬 빠르다.
unshift 대신 concat을 사용하며 생각해보기
unshift
는 배열의 앞 부분에 새로운 요소를 추가하는 메서드로 원본 배열을 변경한다. 제네레이터 concat
을 사용하면 원본 배열을 변경하지 않고, 메모리도 효율적으로 사용하면서 작업을 처리할 수 있다.
unshift
메서드는 배열의 앞부분에 요소를 추가할 때 기존 모든 요소들의 인덱스를 하나씩 뒤로 이동시켜야 하기 때문에 배열의 크기가 클 수록 부하가 발생할 수 있다. 배열에 100개의 요소가 있다면, 앞에 요소를 추가할 때마다 100개의 인덱스를 옮겨야하기 때문이다.
const arr = ['2', '3', '4', '5'];
const iter = concat(['1'], arr);
console.log(arr); // ['2', '3', '4', '5']
let result2 = '';
for (const str of iter) {
result2 += str;
}
console.log(result2); // '12345'
마무리
코드:객체:함수 = Generator:Iterator:LISP = IP:OOP:FP라는 등식이 의미하는 것은 현대 프로그래밍에서 패러다임 간의 경계가 사라지고 있다는 것이다.
Generator는 명령형 구문으로 함수형 지연 평가를 구현하고, Iterator는 객체지향 패턴을 명령형 인터페이스로 제공하며, 이 모든 것이 LISP의 "코드가 곧 데이터"라는 철학 위에서 통합된다.
이런 사고방식을 갖추면
- 문제 해결이 특정 패러다임에 국한되지 않는다
- 코드의 재사용성과 합성 가능성이 높아진다
- 각 상황에 최적화된 접근법을 선택할 수 있다
- 언어와 도구에 덜 의존적인 개발자가 된다
결국 중요한 것은 특정 패러다임을 고수하는 것이 아니라, 문제의 본질을 파악하고 가장 적합한 도구를 조합하는 능력이다. 현대의 멀티패러다임 언어들은 이런 유연성을 최대한 활용할 수 있도록 설계되고 있다.