“코드는 동작하는 것만으로는 부족하다. 어떻게 동작하는지 이해해야 한다” - 함수형 프로그래밍 심화 학습에서 얻은 두 번째 깨달음

첫 번째 글에서 함수형 프로그래밍의 기본 원리를 다뤘다면, 이번에는 더 깊이 들어가 보자. every, some, find, take 같은 조건부 연산들과 실제 지연 평가가 어떻게 작동하는지 직접 관찰하며 얻은 인사이트들을 공유하고 싶다.

조건부 연산의 세 가지 얼굴: every, some, find

배열 메서드로 익숙한 every, some, find를 직접 구현해보니 흥미로운 패턴들이 보였다.

every: 모든 것이 참이어야 한다

 
function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
	for (const a of iterable) {
		if (!f(a)) return false; // 하나라도 거짓이면 즉시 종료
	}
return true;
}
 

이 간단한 구현에서 핵심은 **단축 평가(Short-circuit evaluation)**다. 하나라도 조건을 만족하지 않으면 즉시 false를 반환한다.

하지만 함수형 체이닝으로도 표현할 수 있다:

function everyWithChain<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
	return fx(iterable)
	.map(f) // 각 값을 불린으로 변환
	.reduce((acc, curr) => acc && curr, true); // AND 연산으로 축약
}

some: 하나라도 참이면 된다

function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
	for (const a of iterable) {
		if (f(a)) return true; // 하나라도 참이면 즉시 종료
	}
	return false;
}

every와 정반대 로직이다. 하나라도 조건을 만족하면 즉시 true를 반환한다.

최적화된 버전의 아이디어

더 흥미로운 것은 체이닝을 활용한 최적화 버전이었다.

function someUpgrade<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
	return fx(iterable)
		.map(f)
		.filter(a => a) // 참인 값만 통과
		.take(1) // 첫 번째 참 값만 가져오기
		.reduce((acc, curr) => acc || curr, false);
}

take(1)이 핵심이다! 첫 번째 true 값을 찾으면 즉시 중단한다. 함수형 방식으로 단축 평가를 구현한 것이다.

find의 네 가지 구현

find 함수 하나를 네 가지 다른 방식으로 구현해보면서 함수형 프로그래밍의 다양성을 경험했다.

1. 직접적인 이터레이터 조작

function find<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined {
	const iterator = iterable[Symbol.iterator]();
	while (true) {
		const { done, value } = iterator.next();
		if (done) break;
		if (f(value)) return value;
	}
	return undefined;
}

가장 원시적이지만 가장 직접적인 방법이다. 이터레이터 프로토콜을 직접 다루면서 JavaScript의 내부 동작을 이해할 수 있다.

2. filter 조합 - 함수 재사용의 힘

function findWithFilter<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined {
	return filter(f, iterable)[Symbol.iterator]().next().value;
}

이미 구현된 filter 함수를 재사용한다. 코드 재사용성의 좋은 예시다.

3. head + filter 조합 - 작은 함수들의 조합

const head = <A>(iterable: Iterable<A>): A | undefined => {
	return iterable[Symbol.iterator]().next().value;
};
 
const headWithFilter = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
	return head(filter(f, iterable));
};

더 작은 단위로 분해했다. head라는 재사용 가능한 유틸리티가 생겼다.

4. 체이닝

const chainWithFilter = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
	return fx(iterable).filter(f).to(head);
};

메서드 체이닝으로 가장 읽기 쉽게 표현했다. to() 메서드로 다른 타입으로의 변환으로 처리할수 있다.

take 함수: 간단해 보이지만 강력한 도구

export function* take<A>(limit: number, iterable: Iterable<A>): IterableIterator<A> {
	const iterator = iterable[Symbol.iterator]();
	while(true) {
		const { value, done } = iterator.next();
		if (done) break;
		yield value;
		if (--limit === 0) break; // 제한에 도달하면 중단
	}
}

take 함수는 단순해 보이지만 무한 시퀀스를 다룰 때 없어서는 안 될 도구다. 특히 단축 평가와 결합될 때 그 진가를 발휘한다.

지연 평가: 실제로 언제 계산될까?

각 함수에 console.log를 넣어서 실제 실행 순서를 관찰해보았다.

 
function* filter<A>(f: (a: A) => boolean, iterable: Iterable<A>): IterableIterator<A> {
	const iterator = iterable[Symbol.iterator]();
	while (true) {
		console.log('filter'); // 실행 추적
		const { done, value } = iterator.next();
		if (done) break;
		if (f(value)) yield value;
	}
}
 
function* map<A, B>(f: (a: A) => B, iterable: Iterable<A>): IterableIterator<B> {
	const iterator = iterable[Symbol.iterator]();
	while (true) {
		console.log('map'); // 실행 추적
		const { done, value } = iterator.next();
		if (done) break;
		yield f(value);
	}
}
 
function* take<A>(limit: number, iterable: Iterable<A>): IterableIterator<A> {
	const iterator = iterable[Symbol.iterator]();
	while (true) {
		console.log('take', limit); // 실행 추적
		const { done, value } = iterator.next();
		if (done) break;
		yield value;
	}
}

실행 결과는 아래와 같다.

take 1
map
filter
result 1
take 1
map
filter
filter
result 9
take 1
map
filter
filter
result 25

지연 평가의 실제 동작 원리

이 결과에서 알 수 있는 것들:

  1. 값이 필요할 때마다 전체 체인이 실행된다: for...of가 하나의 값을 요청할 때마다 take → map → filter 순서로 모든 함수가 호출된다.
  2. 필터링으로 인한 재실행: 짝수가 나오면 filter에서 걸러지므로, 홀수가 나올 때까지 filter가 반복 호출된다.
  3. 지연 평가: 필요한 만큼만 계산하고, 그 즉시 결과를 반환한다.

예제: 홀수 제곱합 계산기

이론을 실전에 적용해보자. 명령형에서 함수형으로의 변환 과정을 보여주는 좋은 예시다:

AS-IS: 명령형 스타일

 
function sumOfSquaresOfOddNumbers(limit: number, list: number[]): number {
	let acc = 0;
	for (const num of list) {
		if (num % 2 === 1) { // 홀수인지 확인
			const b = num * num; // 제곱 계산
			acc += b; // 누적 합산
			if (--limit === 0) break; // 최대 limit개까지만 처리
		}
	}
	return acc;
}

TO-BE: 함수형 스타일

const sumOfSquaresOfOddNumbers = (limit: number, list: number[]): number =>
	fx(list)
	.filter(a => a % 2 === 1) // 홀수만 필터링
	.map(a => a * a) // 제곱으로 변환
	.take(limit) // 최대 limit개 선택
	.reduce((acc, a) => acc + a, 0); // 합산

두 방식의 차이점

명령형 스타일의 특징:

  • 반복문과 조건문으로 직접 흐름 제어
  • 변수 상태 변경 (acc, limit)
  • 알고리즘의 세부사항이 드러남

함수형 스타일의 특징:

  • 각 연산이 독립적이고 조합 가능
  • 원본 데이터 불변성 유지
  • 무엇을 할지에 집중 (선언적)
  • 새로운 연산 추가 및 테스트 용이

concat으로 배우는 이터러블 조합

두 이터러블을 합치는 concat 함수에서도 흥미로운 인사이트를 얻었다.

export function* concat<A>(
iterable1: Iterable<A>,iterable2: Iterable<A>
): IterableIterator<A> {
	for (const iterable of [iterable1, iterable2]) yield* iterable;
}

yield*로 인해 다른 이터러블의 모든 값을 위임하여 yield한다.

배열 concat vs 제네레이터 concat

// 배열 방식 - 즉시 평가
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = arr1.concat(arr2); // [1, 2, 3, 4, 5, 6] - 즉시 생성
 
// 제네레이터 방식 - 지연 평가
const iter = concat(arr1, arr2); // 이터레이터만 생성
 
// 실제 값은 필요할 때 생성됨

메모리 효율성의 극적인 차이:

  • 배열: 모든 데이터를 즉시 메모리에 올림
  • 제네레이터: 필요할 때만 값을 생성

무한 시퀀스나 대용량 데이터를 다룰 때 제네레이터의 진가가 발휘된다.

깨달은 것

1. 단축 평가는 성능의 핵심이다

every, some, find 모두 조건을 만족하면 즉시 중단한다. 이는 성능 최적화의 핵심 원리다.

2. 같은 기능, 다양한 구현

하나의 목표를 여러 방식으로 구현해보면서 각 접근법의 장단점을 이해할 수 있다.

3. 함수 조합의 표현력

작은 함수들(head, filter, map)을 조합하여 복잡한 로직을 만들 수 있다.

4. 지연 평가의 진짜 의미

“필요할 때 계산한다”는 것이 실제로 어떻게 동작하는지 직접 관찰하며 이해했다.

5. 선언적 vs 명령적의 실용적 차이

명령형은 어떻게 할지에 집중하고, 함수형은 무엇을 할지에 집중한다.

타입 시스템과 함수형의 만남

TypeScript의 제네릭 시스템이 함수형 프로그래밍과 만날 때 진가를 발휘한다:

function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean
function find<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined

컴파일 타임에 타입 안전성을 보장하면서도 제네릭으로 재사용성을 확보한다.

마무리

함수형 프로그래밍은 단순히 문법을 배우는 것이 아니라 사고방식의 전환이다.

  • 상태 변경 대신 → 데이터 변환
  • 명령형 제어 대신 → 선언적 조합
  • 즉시 계산 대신 → 지연 평가

각 함수가 작고 단순하지만, 조합하면 놀라운 표현력을 가진다. 그리고 TypeScript의 타입 시스템이 이 모든 것을 안전하게 뒷받침해준다.

코드는 동작하는 것만으로는 부족하다. 어떻게 동작하는지, 왜 그렇게 설계했는지 이해할 때 진정한 학습이 일어난다.