sequence 알아보기

김장훈·2023년 3월 25일
0

fp-ts function 탐구

목록 보기
2/5

1. interface

  • Sequence 는 sequence, sequenceS, sequenceT 로 구분이 됩니다. 다만 여기선 제일 기본인 sequence 만 확인토록 하겠습니다.

1.1. signature

<T>(argu:(HKT<F, T>[]) => HKT<F, T[]>
  • array 를 받아서 single 을 return합니다.
  • HKT 는 higher-kinded type 을 뜻합니다. 이에 대해선 기회가 될때 따로 설명을 하겠습니다.
  • 여기서는 monodic type 등을 뜻한다고 이해하시면 됩니다.
Option<None, T> = HKT<OptionURI, T>
Either<E, T> = HKT<EitherURI, T>

1.2. function 상세

// fp-ts/Array.js
var sequence = function (F) {
    return function (ta) {
        return _reduce(ta, F.of((0, exports.zero)()), function (fas, fa) {
            return F.ap(F.map(fas, function (as) { return function (a) { return (0, function_1.pipe)(as, (0, exports.append)(a)); }; }), fa);
        });
    };
};
  • sequence 는 function 받아서 활용하고 function 을 return 하는 higher-order fuction 입니다.
  • sequence 가 받은 F 는 다음 블로그에서 추가 설명하겠습니다.
  • sequence 가 return 하는 함수는 무언가를 받아서 이를 reduce 에 활용하고 있습니다. ta 는 collection 이겠죠?

2. 어디에 쓰이는가?

  • sequence 는 모나드 등 wrapping 되어있는 객체들의 collection 을 쉽게 관리하기 위해 사용한다고 보시면 됩니다.
  • 특히 개별 결과물을 조합할 수 있어서 divide and conquer 를 적용하기에 적합한 방법이라 생각됩니다.

2.1. 사용하는 이유

  • Array<monad< T >> 를 Monad<T[]> 로 변환할때 쉽게 사용할 수 있습니다.
import * as O from 'fp-ts/Option

const arr = [1, 2, 3].map(O.of);
  • 위 arr 은 Option[] 입니다.이는 결과적으로 Array 이기 때문에 이를 pipe 등으로 다루려면 Array.map(fn) 등 Array 형태로 다뤄야합니다. 그러면 우리는 Array 의 개별 value(Option) 를 다시 다뤄야하는 형태가 됩니다.
  const res = pipe(
        [1, 2, 3].map(O.of),
        A.map((data) => {
          const res = pipe(
            data,
            O.map((data) => data)
          );
        })
      );
  • 위 code 의 경우 option 을 또 벗겨내야한다는 점 빼고(=귀찮다) 딱히 문제가 되는것은 잘 보이진 않습니다.
  • 위 code 에서 Option<number[]> 로 바꾸려면 아래처럼 되어야 할 것입니다.
      const res = O.some(
        pipe(
          [1, 2, 3].map(O.of),
          A.map((data) => {
            return pipe(
              data,
              O.getOrElse(() => 0)
            );
          })
        )
      );
  • 이렇게 하면 우리는 [1,2,3] 을 하나의 value 로서 사용할 수 있습니다. 그런데 하려는 목적에 비해 코드가 너무 장황합니다. 이런 경우 sequence 를 통해 간단히 바꿀 수 있습니다.
import { sequence } from 'fp-ts/Array';
  
const res = pipe(
    [1,2,3].map(O.of), 
    sequence(O.Applicative)
  )
  • 간단하게 Option<number[]> 를 만들었습니다.
  • 물론 Option[] 와 Option<number[]> 둘의 사용성의 차이점이 어떤것이 낫느냐 어떤 경우에 써야하느냐는 당장 지금 여기에서 풀어낼 순 없습니다. 다만 실제 사용 예시를 통해 sequence 를 이렇게 써볼 수 있지 않을까를 풀어보도록 하겠습니다.
  • O.Applicative 가 눈에 띄실텐데 이는 다음 blog 에서 추가 설명 드리도록 하겠습니다. 지금은 저렇게 사용하는구나 정도만 이해하시면 되겠습니다.

2.2. 예시

  • 우리가 monad 를 사용하는 실용적 case 를 생각해보도록 하겠습니다.
  • 일반적으로 우리가 작성하는 비즈니스 로직은 한개의 object 에 여러개의 필드로 구성되어있는 경우 일 겁니다.
  • 그리고 이 각각의 field 를 대상으로 monad를 활용한 비즈니스 로직을 적용하면 아래와 같은 모양이 될 수 있습니다.
    (설명을 단순하게 하기 위해서 array 로 표시했습니다)
const givenData = ['name', 18]
  
const afterData = [Either.right('name'),Eigher.right(18)]
  • 위 case 의 핵심은 비즈니스의 결과인 collection 이 사용하기 어려운 구조로 되어있다는 것 입니다.
# expected
Either<T>
  
# real
Array<Either>
  • 이렇게 될 경우 이후 로직에서는 위 data의 개별 요소들을 각각 처리 해줘야합니다. 즉 either 를 벗겨내는 작업이 무조건 필수로 들어가게 됩니다.
  • 조금 더 구체적인 case로 보도록 하겠습니다.

2.2.1. data validation: 하나의 data-set 을 개별로 validation 해야하는 경우

  • 시나리오는 아래와 같습니다.
    • 회원가입 API 에서 user data 를 받는다.
    • (구현목표) user data를 validation 이 필요로 하다.
    • ...(생략)
  • 일반적으로 생각하는 validation 은 아래와 같을것입니다.
  it('normal user validation example', () => {
    const validateUserData = (data: userDataType) => {
      if (!data.email.includes('@')) {
        return E.left({ reason: 'not proper email' });
      }

      if (data.age <= 20) {
        return E.left({ reason: 'not adult' });
      }

      if (!['male', 'female'].includes(data.gender)) {
        return E.left({ reason: 'not proper gender type' });
      }

      return E.right(data);
    };
                        
    const userData = {
      email: 'some',
      age: 18,
      gender: 'MM',
    };

    const result = pipe(
      userData,
      E.fromNullable({ reason: '' }),
      E.chain(validateUserData),
      E.matchW(
        (error) => error,
        (data) => data
      )
    );
    expect(result).toHaveProperty('reason');
  });
  • input 을 하나의 data 로 하여 이를 처리하는 function 을 직접 구현하였습니다. 해당 function 은 validate 한다는 한가지 일을 하지만 실제 구현부에선 각각의 field 를 확인하는 형태를 지닙니다.
  • 함수형 프로그래밍은 여러가지 순수함수를 조합하여 로직을 구현할 수 있으므로 유연하게 확장이 가능합니다. 그렇기에 이러한 측면에서 위 validateUserData 는 특정 목적(=user 의 validate)의 측면에선 잘 구현되었다 라고 볼 수 있으나 내부 로직들은 외부에서 재사용하기 어렵습니다. 특히 위 예시에서의 개별 validate 로직들은 global 하게 사용 할 수 있으므로 각각 개별로 사용할 수 있으면 좋을것 같습니다.
  • 그렇기에 우리는 이를 function 의 집합체로 구성 해보도록 하겠습니다.
  • 참고) 위 예시에서 map 이 아닌 chain 을 사용한 이유는 map 을 사용시 nested 한 결과가 나오기 때문 입니다(chain = flatmap)
     {
      _tag: 'Right',
      right: { _tag: 'Left', left: { reason: 'not adult' } }
    }
  1. fuctnion 분리하기
  • 이는 간단합니다. 위의 function 들을 각각으로 모두 구성하면 됩니다.
    const validateEmail = (email: string) => {
      if (!email.includes('@')) {
        return E.left({ reason: 'not proper email' });
      }
      return E.right({ email });
    };

    const validateAge = (age: number) => {
      if (age <= 20) {
        return E.left({ reason: 'not adult' });
      }
      return E.right({ age });
    };

    const validateGender = (gender: string) => {
      if (!['male', 'female'].includes(gender)) {
        return E.left({ reason: 'not proper gender type' });
      }
      return E.right({ gender });
    };
  1. sequence 를 활용하여 하나의 pipe 로 조합하기
  • 서로 의존성이 없는 개별의 function 들을 동작시키고 이 결과물을 하나로 합쳐야 하므로 이때 sequence 를 사용합니다. Object 로 결과물을 조합할 예정이므로 sequenseS 를 사용하겠습니다.
	pipe(
      { email: 'my@mail.com', age: 44, gender: 'male'},
      ({ email, age, gender }) =>
        sequenceS(E.Applicative)({
          email: validateEmail(email),
          age: validateAge(age),
          gender: validateGender(gender),
        }),
      console.log
    );  
  {
  _tag: 'Right',
	  right: {
		  email: { email: 'my@mail.com' },
		  age: { age: 44 },
		  gender: { gender: 'male' }
	  }
  }
// 잘못된 타입 넣었을 경우
   { _tag: 'Left', left: { reason: 'not proper gender type' } }
  
  • 기존의 1개의 validate 로직을 여러개로 나눈 후 이를 sequence 를 활용하여 한개의 data set 로 만들었습니다. 즉 개별 Either[] 의 형태가 Either<T[]> 로 합쳐진 것입니다(여기선 object)
  • 만약 sequence 를 사용하지 않는다면 아래와 같은 형태로 가야합니다.
    1. 개별 data validate
    2. validate 의 결과물이 either 이므로 이를 unpacking
    pipe(
      { email: 'my@mail.com', age: 44, gender: 'male'},
      E.fromNullable({}),
      E.map((data) => {
        return {
          email: pipe(
            validateEmail(data.email),
            E.getOrElseW((error) => {
              return { reason: error.reason };
            })
          ),
          age: pipe(
            validateAge(data.age),
            E.getOrElseW((error) => {
              return { reason: error.reason };
            })
          ),
          gender: pipe(
            validateGender(data.gender),
            E.getOrElseW((error) => {
              return { reason: error.reason };
            })
          ),
        };
      }),
      console.log
  • 뭔가, 뭔가 길어졌습니다. 이것 말고 다른 더 좋은 방법이 있으면 알려주세요! 저도 계속 공부중이라 이게 최선인지는 의문이긴 합니다.
  • 위 개별 data에 대한 pipe 가 pipe(function, unpacking) 형태이므로 이를 flow 등으로 하여 재사용가능하게 만들 수도 있을것 같습니다만 그렇게까지 할 필요가 있나 싶습니다.
  • 참고) 개인적으로 함수를 작은 단위로 재사용가능하게 만드는 것을 선호하나 걱정되는 부분은 너무 많은 함수가 존재하게 된다인 점이고 위에 처럼 flow 를 만들게 된다면 단순히 dry 를 하지 않기 위한 목적의 함수가 하나 나오는 것이라서 차후 관리하기가 더 어렵다 생각합니다.
  • 그렇기에 sequence 를 활용하게 된다면 최소한의 순수함수를 바탕으로 내가 원하는 형태로 손쉽게 조합할 수 있다는 점이 큰 이점이라 생각 합니다.
profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글