abonglog logoabonglog

함수형 자바스크립트 모나드 알아보기 의 썸네일

함수형 자바스크립트 모나드 알아보기

함수형 자바스크립트

프로필 이미지
yonghyeun

글을 들어가며

모나드에 대해 공부하며 얻은 지식을 적는 행위에 대한 걱정이 우선 크다. 모나드와 관련된 여러 속설 중 이런 말이 있다.

  • "모나드를 이해한 순간 당신은 모나드를 설명 할 수 없다."
  • "모나드를 처음 이해한 개발자가 하는 일은, 인터넷 속 수 많은 모나드에 대한 잘못된 글들에 하나를 추가하는 일이다."

그래서, 내가 적은 이 글이 인터넷 속을 떠다니는 여러 잘못된 글 들 중 하나일까 걱정이지만, 우선 잼미니와의 교차검증을 통해 적도록 한다.

만약 모나드에 대해 검색하다 이 글을 본 사람이 있다면 나의 글보다 훨씬 엄밀하고 잘 정리된 게시글들의 링크를 서문에 적어두도록 한다.

정말 훌륭한 글들이다. 나는 50% 정도밖에 이해하지 못해 계속 읽어볼 예정이다.

이전 내용 리캡

이전 게시글인 함수형 자바스크립트의 펑터와 적용형 펑터 에서 이야기 했던 내용을 한 번 더 정리하자

Functor란

Functor
type Mapper<T, R> = (value: T) => R;
 
type Functor<T> = {
  value: T;
  map: <R>(mapper: Mapper<T, R>) => Functor<R>;
};

Functor<T> 란 순수 함수 형태의 Mapper<T,R> 를 받는 map 메소드를 가지고 있는 데이터 타입 T 를 데이터를 가진 타입 클래스이다.

우리가 이미 잘 사용하고 있던 Functor 의 가장 대표적인 예시가 Array 다.

대표적인 Functor Array
const parseIntToString: Mapper<number, string> = (value) => value.toString();
 
const array = [1];
log(array.map(parseIntToString)); // [ '1' ]

Array<T> 형태의 데이터에 T -> R 로 변환 시키는 mapper 함수를 map 에 적용하면 Array<R> 형태가 반환되기 때문이다.

Functor 는 데이터가 여러 컨텍스트를 가질 때 의미를 가지며 여러 컨텍스트를 다루기 위해 컨텍스트 별 Functor 들이 존재한다.

이번 설명에서 사용할 FunctorMaybe<T> 펑터이다.

Maybe Functor 를 사용한 예제는 Maybe Functor 사용 예시 를 살펴보도록 하자. 물론 이번 챕터에서도 Maybe Functor 를 이용할 것이다.

Functor 의 map 메소드의 한계

Maybe Functor 구현
class Just<A> {
  constructor(public value: A) {}
 
  map<B>(f: (value: A) => B): Maybe<B> {
    return Maybe.of(f(this.value));
  }
}
 
class Nothing {
  map<B>(f: (value: never) => B): Nothing {
    return this;
  }
}
 
type Maybe<A> = Just<A> | Nothing;
 
namespace Maybe {
  export function of<A>(value: A | null | undefined): Maybe<A> {
    if (value == null) {
      return new Nothing();
    }
    return new Just(value);
  }
}

다음과 같이 T 타입이나 Nullish 한 값을 가질 수 있는 컨텍스트를 가진 데이터를 담는 Maybe<T> 펑터를 구현해주었다.

Maybe<T> 펑터는 연산 과정 속에서 발생 할 수 있는 컨텍스트에 따른 예기치못한 버그를 컨트롤 하기 위해 사용된다.

number , null 컨텍스트를 가지는 값을 반환하는 함수
const someFunction = (value: number): number | null => {
  return Math.random() > 0.5 ? value + 1 : null;
};

이처럼 number , null 을 조건부적으로 반환하는 어떤 함수가 존재한다고 가정해보자.

실제 프로그래밍 세계에선 다양한 컨텍스트를 가지는 함수들이 많다. 예를 들어 API 요청에선 데이터가 날아올 수도, 에러가 날아올 수도 있고, 데이터의 조회 결과가 존재 할 수도 없을 수도 있는등 말이다.

이 때 다양한 컨텍스트를 가지는 함수인 someFunction 을 5번 연속 수행해야 한다고 가정해보자

명령형 코드에선 다음과 같이 구현해줘야 할 것이다.

명령형 코드로 분기 처리
const main = () => {
  const a = someFunction(1);
  if (a === null){  
    return;
  }
  const b = someFunction(a);
  if (b === null){  
    return;
  }
  ...
};

하지만 Maybe 펑터를 이용 해보자

someFunction 자체가 두 개의 타입인 number | null 을 반환하니 반환값의 타입은 Maybe<number> 로 볼 수 있을 것이다.

someFunction 에 Maybe Functor 적용
const someFunction = (value: number): Maybe<number> => {
 return Maybe.of(Math.random() > 0.5 ? value + 1 : null);
};

이후 5 번의 메소드 호출을 위해 someFunction(5).map(someFunction).map(someFunction)... 을 해주면 될 것 같지만 그렇지 않다.

someFunction 자체는 인수를 number 만을 받는데 someFunction() 이 처음 호출 된 후 다음 체인에서 호출되는 map 에 들어가는 인수는 Maybe<number> 이기 때문이다.

두 번의 체인에서 추론되는 타입두 번의 체인에서 추론되는 타입

Maybe<number>number 일 수도, null 일 수도 있기에 세 번째부턴 체인 연결이 불가능하다.

만약 map 메소드를 이용하여 체이닝 하고 싶다면, 인수를 number 를 받는 함수, Maybe<number> 를 받는 함수, Maybe<Maybe<number>> 를 받는 함수 .. 이렇게 계속 만들어줘야 할 것이다.

그리고 그 안에서 계속 분기처리를 해줘야 할텐데, 이는 선언적 형태의 코드라 볼 수 없을 것이다.

정리해보자, 현재 Functor.map 실행시 체이닝이 불가능했던 이유가 Functor<T>.map(Mapper<K,R>) 의 반환값이 Functor<R> 이기 때문이다.

우리는 체이닝을 통한 함수 합성으로 가독성 높고 선언적인 코드를 작성하고 싶다.

모나드

모나드는 모나드 법칙을 지키는 Functor 를 의미한다. 모나드 법칙에 대한 증명을 보기 전 먼저 인터페이스를 살펴보면 다음과 같다.

Monad의 인터페이스
interface Functor<A> {
  map<B>(f: Mapper<A, B>): Functor<B>;
}
 
interface Monad<A> extends Functor<A> {
  flatMap<B>(f: Mapper<A, Monad<B>>): Monad<B>;
  unit<A>(a: A): Monad<A>;
}

모나드는 Functor 의 하위 개념으로 다른 FunctorMonad 를 반환하는 mapper 를 받는 경우 사용하는 flatMap 메소드가 존재한다.

현재 구현된 Maybe Functor 를 모나드 형태로 구현하기 위해 flatMap , unit 메소드를 구현해주자

Monad를 만족하도록 Functor 업그레이드
class Just<A> implements Monad<A> {
  ...
  unit<A>(a: A): Just<A> {
    return new Just(a);
  }
  map<B>(f: (value: A) => B): Maybe<B> {
    return Maybe.of(f(this.value));
  }
  flatMap<B>(f: Mapper<A, Monad<B>>): Monad<B> {
    return f(this.value);
  }
}
 
class Nothing implements Monad<never> {
  unit<A>(): Nothing {
    return this;
  }
  map<B>(f: (value: never) => B): Nothing {
    return this;
  }
  flatMap<B>(f: Mapper<never, Monad<B>>): Monad<B> {
    return this;
  }
}

flatMap 메소드는 map 메소드와 다르게 Functor 로 반환값을 한 번 더 감싸는 것이 아니라 Mapper 의 반환값 자체를 바로 반환한다는 차이점이 있다.

이렇게 하게 되면 다음처럼 사용 하는 것이 가능하다.

모나드를 이용한 선언적 코드 구성
const someFunction = (value: number): Maybe<number> =>
  Maybe.of(Math.random() > 0.5 ? value + 1 : null);
 
const main = () => {
  const result = someFunction(5)
    .flatMap(someFunction)
    .flatMap(someFunction)
    .flatMap(someFunction)
    .flatMap(someFunction);
 
  console.log(result); // 모두 성공하면 Just { value : 10} 또는 Nothing
};
 
main();

flatMap 을 통해 Mapper<A,Monnad<unknow>> 형태의 메소드의 반환값을 한 번 더 Monad 로 감싸주지 않았기 때문에 여전히 한 겹의 Monad<..> 형태를 유지할 수 있게 해준다.

이를 통해 체이닝과 같은 함수 합성을 가능하게 해준다 .

모나드에 관련된 글을 찾다가 너무 감명깊게 읽은 게시글의 한 부분을 인용하여 정리하면 다음과 같다.

Functor 를 데이터를 추상화한 이데아로 바라볼 때 map 함수는 구체타입을 이데아로 연결해 주는 함수이며 flatMap 은 이데아를 계속 거닐게 해주는 함수입니다.
모나드와 함수형 아키텍쳐

unit 메소드는 굳이 설명하지 않는다. 기존에 구현한 Maybeof 메소드와 역할이 같다.

일반적인 값을 of 란 메소드를 통해 다른 Functor 로 추상화 시키는 작업을 리프팅 시킨다고 한다.

우리는 이미 자바스크립트 속에서 모나드들을 사용했다.

앞서 설명했던 Array<T> 뿐 아니라 Promise 자체도 모나드이다. 메소드들이 map, flatMap 이라 칭해져있진 않지만 구현을 보면 그렇다.

  1. Promise<T>Promsie<fullfiled<T>> , Promise<rejected> 라는 컨텍스트를 가진다.
  2. Promise<T>.then(Mapper<T,K>) 를 통해 Promise<K> 를 반환 받을 수 있다.
  3. Promise<T>.then(Mapper<T,K>) 의 반환값은 Promise<K> 이지만 Promise<T>.then(Mapper<T,K>).then(Mapper<K,R>) 형태처럼, Promise 안에 있는 값을 꺼내어 체이닝 하는 것이 가능하다.

이렇게 Promise 자체도 모나드로, Future Monad 라고 한다.

모나드 법칙

모나드 법칙은 이전 게시글에서 기술한 적용형 함자의 법칙과 동일하다. 해당 내용은 이전 게시글에 작성했으니 또다시 기술하진 않겠다.

함수간의 결합 법칙이 성립 되느냐 마느냐를 정의한다.

회고

머리속으론 대충 이해했지만, 실사용 코드에서 사용해보지 않았기에 모나드를 어떻게 적용해야 할지에 대한 감이 잘 오지 않는다.

추후 블로그를 리팩토링 할 때 사용 할 라이브러리를 고민하던 중 모나드 개념이 존재하는 라이브러리인 ramda 라이브러리를 사용해보며 모나드를 사용해봐야겠다.


해당 게시글에 사용된 전체 코드
type Mapper<T, R> = (value: T) => R;
 
interface Functor<A> {
  map<B>(f: Mapper<A, B>): Functor<B>;
}
 
interface Monad<A> extends Functor<A> {
  flatMap<B>(f: Mapper<A, Monad<B>>): Monad<B>;
  unit<A>(a: A): Monad<A>;
}
 
class Just<A> implements Monad<A> {
  constructor(public value: A) {}
 
  unit<A>(a: A): Just<A> {
    return new Just(a);
  }
  map<B>(f: (value: A) => B): Maybe<B> {
    return Maybe.of(f(this.value));
  }
  flatMap<B>(f: Mapper<A, Monad<B>>): Monad<B> {
    return f(this.value);
  }
}
 
class Nothing implements Monad<never> {
  unit<A>(): Nothing {
    return this;
  }
 
  map<B>(f: (value: never) => B): Nothing {
    return this;
  }
  flatMap<B>(f: Mapper<never, Monad<B>>): Monad<B> {
    return this;
  }
}
 
type Maybe<A> = Just<A> | Nothing;
 
namespace Maybe {
  export function of<A>(value: A | null | undefined): Maybe<A> {
    if (value == null) {
      return new Nothing();
    }
    return new Just(value);
  }
}
 
const someFunction = (value: number): Maybe<number> =>
  Maybe.of(Math.random() > 0.5 ? value + 1 : null);
 
const main = () => {
  const result = someFunction(5)
    .flatMap(someFunction)
    .flatMap(someFunction)
    .flatMap(someFunction)
    .flatMap(someFunction);
 
  console.log(result); // 모두 성공하면 Just { value : 10} 또는 Nothing
};
 
main();