“코드는 동작하는 것만으로는 부족하다. 어떻게 동작하는지 이해해야 한다” - 함수형 프로그래밍 심화 학습에서 얻은 두 번째 깨달음
첫 번째 글에서 함수형 프로그래밍의 기본 원리를 다뤘다면, 이번에는 더 깊이 들어가 보자. 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
지연 평가의 실제 동작 원리
이 결과에서 알 수 있는 것들:
- 값이 필요할 때마다 전체 체인이 실행된다:
for...of
가 하나의 값을 요청할 때마다take → map → filter
순서로 모든 함수가 호출된다. - 필터링으로 인한 재실행: 짝수가 나오면
filter
에서 걸러지므로, 홀수가 나올 때까지filter
가 반복 호출된다. - 지연 평가: 필요한 만큼만 계산하고, 그 즉시 결과를 반환한다.
예제: 홀수 제곱합 계산기
이론을 실전에 적용해보자. 명령형에서 함수형으로의 변환 과정을 보여주는 좋은 예시다:
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의 타입 시스템이 이 모든 것을 안전하게 뒷받침해준다.
코드는 동작하는 것만으로는 부족하다. 어떻게 동작하는지, 왜 그렇게 설계했는지 이해할 때 진정한 학습이 일어난다.