Skip to content

rae-han(4주차): 4. 비동기 프로그래밍.md #13

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,391 @@
# 0. 개론

## 0.1. 제너레이터

명령형(Imperative) 코드로 이터레이터를 생성하는 수단이다.

**`yield`**를 통해 함수 실행을 일시 정지/재개하며, 리스트 단위의 지연 평가를 구현한다.

이는 "코드가 리스트이고, 리스트가 코드"라는 LISP적 사고와도 연결된다.

## 0.2. 이터레이터

객체지향(Object-Oriented) 패턴의 대표 구현체로, 컬렉션 데이터를 일반화된 방식으로 순회한다. → 어떻게 순회 할것인가가 아니라 무엇을 순회 할 것인가에 초점이 맞춰져 있다.

필요할 때마다 값을 평가(지연성)하여 유한/무한 시퀀스 모두 처리할 수 있다.

## 0.3. **리스트 프로세싱(LISP)**

함수형(Functional) 프로그래밍의 핵심으로, 리스트(데이터)를 함수 조합으로 처리한다.

## 0.4. 정리

**Generator, Iterator, LISP**는 각각 명령형, 객체지향, 함수형 패러다임의 반복과 데이터 처리 추상화를 대표한다.

이들은 서로 생성자이자 사용자로 협력하며, 고도의 추상화와 패러다임 간 조화를 이룬다.

# 1. 코드가 곧 데이터 - 로직이 담긴 리스트

LISP의 "코드가 리스트이고, 리스트가 코드"라는 사고는 코드와 데이터를 동일하게 리스트로 다루는 철학을 의미한다.

## 1.1. [for, i++, if, break] - 코드를 리스트로 생각하기

명령형 코드를 함수형 리스트 프로세싱 함수로 변환한다면 아래와 같이 생각할 수 있다.

- if를 filter로 대체
- 갑 변화 후 변수 할당을 map으로 대체
- break를 take로 대체
- 합산을 reduce로 대체

추가로 while 대신 range 함수를, 효과는 each로 다룬다는 생각도 해볼 수 있다.

참고로 take 함수는 다음과 같이 구현 가능하다.

```typescript
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;
}
}
```

이 함수 역시 체이닝을 위해 FxIterable 클래스에 메서드로 추가 가능하다.

```typescript
class FxIterable<A> {
constructor(private iterable: Iterable<A>) {}

[Symbol.iterator]() {
return this.iterable[Symbol.iterator]();
}

// ...

take(limit: number): FxIterable<A> {
return fx(take(limit, this)); // new FxIterable(take(limit, this));
}
}
```

## 1.2. 현대 언어에서 리스트 프로세싱

현대 프로그래밍 언어들은 지금까지 살펴본 리스트 프로세싱의 철학을 따르는 함수형 프로그래밍을 지원한다.

# 2. 하스켈로부터 배우기

## 2.1. 하스켈의 함수와 함수 시그니처

하스켈의 함수 시그니처와 타입스크립트의 함수 타입 표기는 모두 함수형 프로그래밍의 명확성을 높인다.

## 2.2. 언어 차원에서 지원하는 커링

커링은 여러 인자를 받는 함수를, 인자 하나만 받는 함수들의 연쇄(체인)로 변환하는 함수형 프로그래밍 기법이다. 즉 커링을 사용해 일부 인자만 적용한 새로운 함수를 만들 수 있다.

하스켈은 언어 차원에서 커링을 지원, 여러 인자를 받는 함수를 부분 적용으로 쉽게 분해 가능하다.

## 2.3. main 함수와 IO

하스켈 프로그램은 main 함수에서 시작하며, IO 타입으로 입출력 등 부수효과를 안전하게 관리한다.

IO 타입은 입출력 작업을 수행할 수 있도록 설계된 특별한 타입으로, IO 타입을 통해 순수 함수와 부수효과 함수를 명확히 구분한다.

하스켈의 ()는 반환값이 없음을 의미하는 Unit 타입의 유일한 값으로 타입스크립트의 void와 유사한 역할을 한다. 즉 이 함수가 별다른 값을 제공하지 않고 단순히 부수 효과(콘솔 출력)를 발생시키는 상황을 명확히 나타낸다.

## 2.4. head, map, filter, foldl 함수 시그니처

head, map, filter, foldl 등 대표 함수의 시그니처를 통해 제네릭과 고차 함수 개념을 간결하게 표현한다.

## 2.5. 함수 합성 - . 연산자와 $ 연산자

. 연산자는 함수 합성(오른쪽에서 왼쪽), $ 연산자는 함수 적용과 괄호 생략에 사용한다.

## 2.7. 파이프라인 스타일 &

& 연산자를 사용하면 함수 합성을 데이터 흐름 방향(왼→오른쪽)으로 표현할 수 있어 가독성을 높일 수 있다.

## 2.8. Either를 통한 에러처리

하스켈은 예외(try-catch) 대신 Either 타입(Left: 에러, Right: 성공)으로 명시적 에러 처리를 선호한다.

자바스크립트에서도 fp-ts나 Effect 같은 라이브러리에서 해당 기능을 지원한다.

## 2.9. 패턴 매칭

패턴 매칭으로 함수 결과를 분기 처리, 명확하고 안전한 코드 작성 가능하다.

Maybe 타입 등도 값의 유무를 안전하게 다루는 데 사용된다.

자바스크립트에서도 ts-pattern 같은 라이브러리로 지원하며 ECMAScript stage 1에 해당 스펙이 올라와있다.

# 3. 지연 평가 자세히 살펴보기

지연 평가를 지원하는 이터레이터는 중첩된 고차 함수(map, filter, take 등)에서 바깥 이터레이터의 next()가 호출될 때마다 내부 이터레이터의 next()가 순차적으로 실행되어, 효율적으로 필요한 값만 평가하고 반환한다.

# 4. **Generator:Iterator:LISP - 지연 평가와 안전한 합성**

## 4.1. find 함수 시그니처

**map, filter**는 연산을 지연(lazy)한 이터레이터를 만들어, 이후에도 리스트 프로세싱(체이닝)이 계속 이어질 수 있도록 하는 함수라면, **find**는 이렇게 지연된 이터레이터를 실제로 평가(consume)하여, 조건을 만족하는 첫 번째 결과를 즉시 만들어내는 함수이다.

즉 함수를 크게 분류하자면 아래와 같다.

- map, filter, take 등은 "이터레이터를 반환하는 함수"로, 연산을 계속 이어갈 수 있게 해준다.
- find, reduce, some, every 등은 "이터레이터를 평가(소비)하여 최종 결과를 만드는 함수"로, 리스트 프로세싱의 끝단에서 실제 값을 만들어 낸다.

## 4.2. 하스켈에서 find 함수와 안전한 합성

find는 조건을 만족하는 첫 요소를 반환하고, 없으면 undefined(하스켈의 Maybe 타입)를 반환해 안전한 합성을 지원한다. 하스켈은 안전한 합성을 위해 Maybe 타입(Just A | Nothing 타입)을, 타입스크립트는 undefined(A | undefined)를 사용한다.

조금 다른점은 하스켈은 fromMaybe, Maybe 타입 등으로 값이 없을 때 안전하게 기본값을 제공하거나 예외 없는 처리를 지원하며, 타입스크립트는 옵셔널 체이닝(?.), Nullish 병합(??), Non-null 단언(!) 등으로 안전한 값 접근과 에러 처리를 지원한다.

## 4.3. find 함수로 생각하는 지연 평가와 리스트 프로세싱

find 함수를 명령형 방식으로 구현하면 아래와 같다.

```typescript
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) break;
if (f(value)) return value;
}
return undefined;
}
```

이제 find 함수를 함수형 방식으로 구현해보자. 명령형으로 구현한 filter 함수와 find 함수를 비교해보자.

```typescript
function* filter<A>(
f: (a: A) => boolean,
iterable: Iterable<A>
): IterableIterator<A> {
const iterator = iterable[Symbol.iterator]();
while (true) {
const { value, done } = iterator.next();
if (done) break;
if (f(value)) yield value;
}
}
```

두 함수의 차이는 function 키워드의 \*와 yield, return 의 차이다.

- `filter`는 \*이 붙은 제너레이터로, `f(value)`가 참일 때 `yield`로 결과를 반환하며, 인자로 받은 이터레이터가 종료될 때까지 계속 순회할 수 있다.
- `find`는 일반 함수로, `f(value)`가 참일 때 `return`으로 결과를 반환하며 동시에 반복문과 함수를 종료한다.

find를 세 가지 방법으로 함수형으로 구현해 보자.

### 4.3.1. find 1

```typescript
function find<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined {
return filter(f, iterable).next().value;
// 아래와 같이 구현할 수도 있다.
// const [head] = filter(f, iterable);
// return head;
}
```

```typescript
const result = find((a) => a > 2, [1, 2, 3, 4]); // [const result: number | undefined]
console.log(result);
// 3

const isOdd = (a: number) => a % 2 === 1;

const result2 = find(isOdd, [2, 4, 6]); // [const result2: number | undefined]
console.log(result2);
// undefined
```

filter와 next() 활용한 코드이다. 주석처럼 구조분해 할당을 이용할 수도 있다.

### 4.3.2. find 2

```typescript
const head = <A>(iterable: Iterable<A>): A | undefined =>
iterable[Symbol.iterator]().next().value;
// 아래와 같이 구현할 수도 있다.
// const head = <A>([a]: Iterable<A>): A | undefined => a;

const find = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined =>
head(filter(f, iterable));
```

head 헬퍼 함수를 이용한 코드이다.

head 라는 함수를 별도 정의하여 모듈성을 높였다. 코드의 재사용을 높이고 함수의 역할을 분명하게 분리했다.

### 4.3.3. find 3

```typescript
const find = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined =>
fx(iterable).filter(f).to(head);
```

전에 만든 FxIterable 클래스를 활용해 체이닝 방식으로 구현한 코드이다.

메서드 체이닝 방식이라 읽기 쉽고, 확장성이 높.

위 3가지 방법을 통해 우리가 알수 있는 것은 find는 명령형 반복문, 함수형 filter+head 조합, 체이닝(FxIterable) 등 다양한 방식으로 구현 가능하며, 효율과 동작이 동일하다는 것이다. 각 패러다임은 완전히 대체 가능하며, 필요에 따라 자유롭게 조합할 수 있다.

## 4.4. 타입스크립트에서의 안전한 합성

하스켈에서 Maybe 타입을 통해 안전하게 예외 상황을 처리 가능하다. 타입스크립트에서도 아래와 같은 방식으로 처리 가능하다.

```typescript
const desserts = [
{ name: "Chocolate", price: 5000 },
{ name: "Latte", price: 3500 },
{ name: "Coffee", price: 3000 },
];

// (1) 옵셔널 체이닝 연산자(?.)를 통해 name 프로퍼티에 안전하게 접근
const dessert = find(({ price }) => price < 2000, desserts);
console.log(dessert?.name ?? "T^T");
// T^T

// (2) Non-null 단언 연산자(!)를 통해 항상 무조건 찾을 상황을 의도하고 있다고 언어와 소통
const dessert2 = find(({ price }) => price < Infinity, desserts)!;
console.log(dessert2.name);
// Chocolate
```

**1. 옵셔널 체이닝(?.)과 Nullish 병합(??)**

- find 함수가 조건을 만족하는 값을 찾지 못하면 undefined를 반환한다.
- 옵셔널 체이닝(?.)을 사용하면, 반환된 값이 undefined일 때도 런타임 에러 없이 안전하게 프로퍼티에 접근할 수 있다.
- Nullish 병합(??)을 함께 사용하면, 값이 undefined일 때 원하는 기본값(예: 'T^T')을 지정할 수 있다.
- 값이 없을 수 있음을 코드에 명확히 드러내며, 안전하게 대체값을 처리하는 함수형 스타일과 유사하다

**2. Non-null 단언 연산자(!)**

- Non-null 단언 연산자(!)는 "여기서는 값이 반드시 존재해야 한다"는 개발자의 의도를 타입스크립트에 명확히 전달한다.
- 만약 값이 실제로 undefined라면 런타임 에러가 발생하게 하여, 설계 상 절대 없어야 하는 상황을 강하게 표현한다.
- 이 방식은 기피해야 할 방식이 아닌 값이 없을 때 조용히 넘어가는 대신, 반드시 값이 있어야 하는 상황을 강하게 보장하고, 만약 에러가 난다면 설계나 데이터 흐름을 재점검해야 함을 의미한다.

다시 한번 정리하면 ?.와 ??를 사용해 값이 없을 때 안전하게 기본값을 제공하며, !를 사용해 값이 반드시 존재해야 함을 언어에 명확히 알리고, 설계 오류를 빠르게 감지할 수 있다.

즉 타입스크립트에서는 옵셔널 체이닝(?.)과 Nullish 병합(??)으로 값이 없을 때 안전하게 기본값을 제공하고, Non-null 단언 연산자(!)로 값의 존재를 강하게 보장함으로 안전한 합성과 명확한 에러 처리를 구현할 수 있다.

## 4.5. every 함수

every 함수는 주어진 함수 f가 모든 요소에 대해 true를 반환하면 최종 결과를 true로 그렇지 않으면 false를 반환하는 함수다.

함수 시그니처는 다음과 같다.

```typescript
function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {}
```

모든 함수를 조건 함수로 변환(map)후 boolean 값을 논리 AND(&&)로 누적(reduce) 하는 방법이 있다.

아래 방법은 대부분 언어에서 지원하는 && 연산자만 사용한 방법이라 언어나 자료구조에 종속되지 않는다.

```typescript
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]
}
```

## 4.6. some 함수

some 함수도 비슷한 방식으로 구현 가능하다.

some 함수는 주어진 함수 f가 하나라도 true를 반환하면 최종 결과로 true를, 모든 요소가 false를 반환하면 false를 반환해야 한다.

다음은 some 함수의 함수 시그니처이다.

```typescript
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {}
```

every 함수와 거의 같지만 차이는 변환(map) 후 boolean 값을 논리 OR(||)로 누적(reduce)하는 것이다.

```typescript
function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return fx(iterable)
.map(f)
.reduce((a, b) => a || b, false);
}
```

every 함수 some 함수 둘 다 어떻게 할 것인지(어떻게 반복하며 값을 추려나갈 것인지)보다 무엇을 할지를 담아내고 있다.

## 4.7. 지연 평가에 기반한 break 로직 끼워넣기

some, every 함수 결과를 만들기 위해 모든 요소를 순회할 필요는 없다. some은 true를 하나라도 만나면, every는 false를 하나라도 만나면 순회를 종료해 결과를 만들어도된다.

```typescript
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를 하나라도 만나면 이터레이터가 바로 종료된다. reduce 함수는 요소가 없으면 false, 있으면 true 반환한다.

```typescript
function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
return fx(iterable)
.map(f)
.filter((a) => !a)
.take(1)
.reduce((a, b) => a && b, true);
}
```

위 every 함수는 **`.filter(a => !a).take(1)`**로 false를 하나라도 만나면 이터레이터가 바로 종료된다. reduce는 요소가 없으면 true, 있으면 false 반환한다.

위와 같이 작성하면 지연 평가와 take(1) 덕분에 불필요한 순회를 하지 않고 명령형의 break와 동일한 효과를 함수형 리스트 프로세싱 합성만으로 구현 가능하다.

## 4.8. every와 some 함수의 공통 로직을 함수형으로 추상화 하기

every와 some은 구조가 거의 동일하므로, 공통 부분을 하나의 함수(accumulateWith)로 추상화할 수 있다.

다음 accumulateWith 함수는 map, filter, take, reduce의 체이닝 구조를 파라미터화하여, 다양한 논리(accumulator, 초기값, break 조건, 판별 함수)를 인자로 받아 재사용 가능하게 만든다.

```typescript
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
);
}
```

accumulateWith 같은 고차 함수를 활용하면 every와 some의 공통 로직을 함수형 패러다임답게 쉽게 추상화할 수 있어, 중복 없이 유지보수성과 확장성이 뛰어난 코드를 만들 수 있다.
Loading