Pipe 사용기 - 2, 모나드 적용

김장훈·2024년 1월 21일
0

(반년만에 두번째 글이 나와서 민망 ... )

지난 글 요약

  • pipe 를 활용하면 여러개의 함수를 조합할 수 있다!
  • 다만 이를 보기좋게 조합하기 위해선 단항함수가 강제된다. 이건 어쩔 수 없다.

Pipe 를 사용하는 것은 좋은데, 각종 예외 케이스는 어떻게 해야할까?

  • 정말 복잡한 서비스, 비즈니스 로직이 아닌 이상 일반적인 flow 은 한방향으로 흘러갈 것이다.

    A -> B -> C -> D

  • 그리고 이렇게 순서대로 흘러가는 비즈니스 로직에서 에러 핸들링을 하는 방법은 이전에 작성한 글 에 있다.
  • 그리고 이번에는 Either 를 async 에 사용해보고자 한다.

순수함수를 지향해야하지만 현실은 그렇지 않다.

  • 외부로부터의 영향(IO 등)을 최소로 받게 끔 코드를 작성해야하지만 이를 지키는 것은 쉽지 않다.
  • 그렇기에 우리는 이러한 부분 역시 대응 해야하고 이를 위해 TaskEither 와 같은 개념을 사용할 수 있다.
  • 나는 async + either = taskEither 이기에 굳이 taskEither 를 써야하나 생각했지만 차이점은 분명했다.

User 를 조회하는 로직으로 taskEither 를 사용해보자

  • data 는 미리 만들어 놓음

일반 User 로직

interface UserRecord {
    id: number;
    nickname: string;
    state: string;
    property?: Prisma.JsonValue;
    createdAt?: Date;
    updatedAt?: Date;
}

async function getUser(userId: number): Promise<UserRecord | null> {
  try {
    return await prismaClient.users.findFirstOrThrow({ where: { id: userId } });
  } catch (error) {
    return null;
  }
}

 const user = await getUser(1);

(IO 에서 에러 발생하기 위해 의도적으로 orThrow 사용)

  • 일반적인 await 을 활용한 DB 조회 로직이다. user 를 조회한 후 무언가 하려고 한다면 우리는 null 을 체크를 해야할 것 이다(또는 error catch 안하고 그대로 던질 수도 있을 것이다)
  • 이제 nickName 을 갖고 오려 한다면 코드는 아래처럼 될 것이다.
...

function getUserNickname(user: UserRecord) {
  return user.nickname;
}
...

const user = await getUser(userId);
  if (!user) {
    return null;
  }

  return getUserNickname(user);
  • if condition 이 들어오기 시작했다. 복잡도를 높이기 위해 의도적으로 IO 상황을 만들어보도록 하겠다. nickName 으로 user 를 조회해보자.
...

async function getUserByNickname(nickname: string): Promise<UserRecord | null> {
  try {
    return await prismaClient.users.findFirstOrThrow({ where: { nickname } });
  } catch (error) {
    return null;
  }
}

const user = await getUser(userId);
  if (!user) {
    return null;
  }

  const nickName = getUserNickname(user);
  const targetUser = await getUserByNickname(nickName);

  if (!targetUser) {
    return null;
  }

  return targetUser;

(억지로 만든 상황을 감안해야한다 ... )

  • if condition 이 추가 되었다. 단순히 위 로직만 보았을때, 우리는 저 로직을 사용하게 되면
  1. null 을 받을 수 있다.
  2. null 이 무엇때문에 나온것인지 모른다(id 에 해당하는 것이 없는 것인지, nickName 에 해당 되는게 없는 것인지 등)
  • 그렇게 나빠 보이는 코드는 아니며 이를 pipe 형태로 바꾸기 위해선 중간 중간 있는 await 을 처리해야한다. 이를 위해 taskEither 가 필요로 하다.

taskEither + pipe 를 사용

import * as E from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/function';
import * as TE from 'fp-ts/lib/TaskEither';


function getUserTE(userId: number) {
  return TE.tryCatch(
    () => {
      return prismaClient.users.findFirstOrThrow({ where: { id: userId } });
    },
    () => new Error(`no user data ${userId}`),
  );
}

function getUserByNicknameTE(nickname: string) {
  return TE.tryCatch(() => {
    return prismaClient.users.findFirstOrThrow({ where: { nickname } });
  }, E.toError);
}
...

const getUser = pipe(
    userId,
    getUserTE,
    TE.map(getUserNickname),
    TE.chain((nickName) => getUserByNicknameTE(nickName)),
  );

const userTask = await getUser();
const result = pipe(
    userTask,
    E.getOrElseW((error) => error),
  );
  • getUser 는 Task 를 return 한다. 바로 값을 가져올 수 없으므로 한번 데이터를 가져오고 난 후 다시 value 를 벗기는 코드가 추가 되었다.
  • 바로 값을 가져올 수 없는 이유는 TaskEither 는 promise 를 래핑한 either 이기 때문이다.
  • 이 부분에서 taskEither 와 async + either 의 차이가 발생한다.

async + either, taskEither 의 차이


function getUserTE(userId: number) {
  return TE.tryCatch(
    () => {
      return prismaClient.users.findFirstOrThrow({ where: { id: userId } });
    },
    () => new Error(`no user data ${userId}`),
  );
}

async function getUserE(userId: number): Promise<E.Either<Error, UserRecord>> {
  try {
    const user = await prismaClient.users.findFirstOrThrow({
      where: { id: userId },
    });
    return E.right(user);
  } catch (error) {
    return E.left(error);
  }
}

// const user: E.Either<Error, UserRecord>
const user = await getUserE(1);

// const userTE: E.Either<Error, UserRecord>
const userTE = await getUserTE(1)();
  • 처음에 위 두개는 같은것이라 생각했다. either 에 Promise 를 씌우면 taskEither 이기 때문이다.
  • 실제 사용하는 부분도 다르긴 하지만 제일 달라지는 것은 바로 pipe 에서 사용할때이다.
// Type 'Promise<Either<Error, UserRecord>>' is not assignable to type // 'Either<unknown, UserRecord>'
const user = await pipe(1, getUserE, E.map(getNickname));
  • 위에 처럼 사용하려고 하는 경우 getUserE 는 promise 를 return 한다. 이를 받아서 처리하는 형태의 함수가 아니라면 pipe 에선 async-either 를 사용하기 어렵다.
  • 즉 단일로 사용하는 경우는 같을 수 있으나 여러개의 promise 를 조합하여 무언가를 해야하는 경우엔 다소 많은 귀찮음이 발생할 수 있다.

결론

  • promise 에서 모나드를 사용하는 것 자체가 상당히 쉽지 않은 과제이긴 하다.
  • 순수함수를 지향하는 FP 에서 promise 를 섞어야하는 것 자체가 문제일 수 있기에 이를 분리하는것이 맞긴 하지만 현실은 그렇지 못하기에 ...
  • 또한 TE 로 핸들링 한 후 값을 별도로 가져와야하는 부분은 또 다른 귀찮음이긴 하다(이게 맞나 싶은)
  • 실제로 적용해봐야 좋고 나쁨이 명확해질듯 하다. 지금까지 봤을때는 Promise 를 핸들링 하기 위해선 필수로 사용해야하지만 사용하는 것 자체가 매우 어렵다 정도!
profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글