Monad 를 활용한 예외 상황 대응

김장훈·2023년 2월 5일
0

저 역시 FP 형태는 아직 미숙하여 FP 형태의 code 들이 다소 이상할 수 있습니다. 피드백 주시면 반영하겠습니다. 😀

이전 code 를 다시 봐보자.

const main = (userId) => {
  try{
   	const user = getUser(userId)
    const parsedUser = parseUser(user)
    const transformUser = transformUser(parseUser)
  }catch(error){
    console.log(error.message)
  }
} 
  • 우리가 원하는 것은 다음과 같다.
  1. parseUser 에서 예외 상황이 발생하면 sendAlarm 을 보내고 싶다.
    1.1. sendAlarm 은 사용처에 따라서 선택적일 수 있다.
  2. parseUser 에서 예외 상황이 발생했어도 transformUser 를 그대로 사용하고 싶다.
    2.1. 다른 경우엔 그대로 예외 상황을 처리하고 싶다.

monad 를 다시 봐보자.

  • 예외 상황을 처리해야하므로 우리는 Either(Try) 를 사용해야한다.
  • 기존의 함수가 정상 / 예외 상황 을 return 하는 것과 달리 Either 를 사용하게 된다면 모든 함수는 Either<error, value> 를 return 하게 될 것이다. 따라서 우리는 예외 상황에 대한 최상위 레이어에서의 try/catch block 이 필요가 없다.
import * as Either from 'fp-ts/Either';
import { pipe } from 'fp-ts/lib/function';
type UserType = {
  userId: Number;
  name: String;
};

type ParseUserType = UserType & {
  age: Number;
};

const getUser = (userId: number) => {
  try {
    const user = { userId, name: 'name' };
    return Either.right(user);
  } catch (error) {
    return Either.left(error);
  }
};

const parseUser = (user: UserType) => {
  try {
    const parseUser = { userId: user.userId, name: user.name, age: 20 };
    return Either.right(parseUser);
  } catch (error) {
    return Either.left(error);
  }
};

const transformUser = (parsedUser: ParseUserType) => {
  try {
    const transformedUser = { user: 'transformed', userId: parsedUser.userId };
    return Either.right(transformedUser);
  } catch (error: any) {
    return Either.left(error);
  }
};
  • 그리고 이를 pipe 로 표현하면 아래와 같다(사용한 라이브러리는 fp-ts 이다)
export const main = (userId: number) => {
  return pipe(
    userId,
    getUser,
    Either.chain(parseUser),
    Either.chain(transformUser),
    Either.getOrElseW(() => 'error occur')
  );
};
  • chain 은 map 을 좀 더 편히 쓰는 방법이라 생각하면 된다.
  • 기존의 try-catch block 을 사용한 형태랑 특별히 달라진게 없는 것 같다. 그렇다면 이전의 가정했던 상황들을 적용했을때 어떻게 되는지 확인해보자

1. 예외 상황 처리 없던 함수의 변경


// error handling 전
export const someFunc = (user: { user: string; userId: Number }) => {
  return Either.right(user);
};

// error handling 추가 후
export const someFunc = (user: { user: string; userId: Number }) => {
  if (user.userId == 0) {
    return Either.left(new Error('not proper user id'));
  }
  return Either.right(user);
};


describe('example about monad', () => {
  it('case 1, add error handling', () => {
    pipe(
      1234,
      getUser,
      Either.chain(parseUser),
      Either.chain(transformUser),
      Either.chain(someFunc),
      Either.getOrElseW(() => 'error occur'),
      console.log
    );
  });
});
  • either 를 사용하므로서 모든 function 의 return 타입은 either 로 통일 되었고 either 의 메서드 들은 Left 상황에 대해서 개별 대응이 이미 되어있다(=left 값을 다른 function 으로 전파하지 않는다)
  • 따라서 에러 핸들링이 추가 됬다고 해서 사용처를 모두 변경할 필요는 없다.

2. 상황별 에러 대응

2.1. parseUser 에러 발생시 sendAlarm 을 보내고 싶다.

export const parseUser = (user: UserType) => {
  try {
    if (user.userId == 1) {
      return E.left(new Error('blocked user id'));
    }

    const parseUser = { userId: user.userId, name: user.name, age: 20 };
    return E.right(parseUser);
  } catch (error: any) {
    return E.left(error);
  }
};

describe('example about monad', () => {
  it('case 2.1, after parsedUser, send alarm, terminate logic', () => {
    pipe(
      1, // error 발생 하기 위한 block user Id
      getUser,
      Either.chain(parseUser),
      Either.match(
        (error: any) => {
          console.log(`blocked user signed, ${error}`);
          return Either.left(error);
        },
        (data) => {
          return Either.right(data);
        }
      ),
      Either.chain(transformUser),
      Either.getOrElseW((error) => `error occur ${error}`),
      console.log
    );
  });
});
  console.log
    blocked user signed, Error: blocked user id

  console.log
    error occur Error: blocked user id
  • pipe line 중간에 특정 상황에 대한 대응 code 를 추가 하였다. 이에 따라서 parseUser 의 결과물에 따라 사용처에서 원하는 행동을 추가할 수 있다.

2.2. parseUser 에러 발생시 sendAlarm 을 보내고 이후 로직역시 그대로 작동시키고 싶다.


describe('example about monad', () => {
  it('case 2.1, after parsedUser, send alarm, terminate logic', () => {
    pipe(
      1, // error 발생 하기 위한 block user Id
      getUser,
      Either.chain(parseUser),
      Either.match(
        (error: any) => {
          console.log(`blocked user signed, ${error}`);
		  return Either.right({ userId: 1, name: 'default', age: 20 }); // left 가 아니라 정상 처리를 위한 right 를 넣었다.
        },
        (data) => {
          return Either.right(data);
        }
      ),
      Either.chain(transformUser),
      Either.getOrElseW((error) => `error occur ${error}`),
      console.log
    );
  });
});
  console.log
    blocked user signed, Error: blocked user id

  console.log
    { user: 'transformed', userId: 1 }
  • 2.1 과 다르게 이번에는 예외 상황시 right 를 return 함으로써 정상 처리하는 형태로 변경 하였다.
  • 이처럼 우리는 기존의 function 의 변경 없이 의도한 바를 사용처에서 원하는 대로 변경 할 수 있다.
    • sendAlarm 부분(console log) 역시 필요하면 제거할 수 있다.

정리

FP 의 예외 상황 핸들링은 If(error) 를 반복하는 형태이며 이를 자동화(?) 했다고 할 수 있다.

const res = someFunction()
if(typeof res == error){
  return ...
}
return ...
  • 사실상 위와 같은 형태가 모든 부분에서 사용되는 것인데 위의 code 들을 그대로 사용하는 것이 아닌 모나드의 특성을 활용하므로서 boilerplate 를 줄일 수 있었다.

모나드가 좋으니까 무조건 쓰자? X

  • 위 코드들은 특정 상황을 가정한 것이므로 모든 case 를 상정하고 있진 않다. 이럴땐 이렇게 가능하다 정도의 케이스만을 보여준다.

모나드를 사용하면 참조 투명성을 준수 할 수 있다.

  • 모나드를 사용하는 가장 큰 이유라고 생각한다. function 이 여러개의 type 을 return 하게 되면 사용처에서는 이를 확인 & 처리 하는 형태의 code 가 들어갈 수 밖에 없다.
  • 따라서 해당 function 사용하는 쪽에서는 비슷한 모양의 boilerplate 가 많이 나올 수 밖에 없으며 변경점이 생길 경우 수정해야하는 곳들도 상당히 많아지게 된다.
  • 또한 정상 & 예외 case에 대한 일관된 처리 방식을 최소한의 code 를 통해서 구현이 가능하다.

FP의 핵심은 부수효과의 제거와 참조 투명성을 준수하는 것

  • 명시적 입력과 명시적 출력의 보장에 따른 사용처에서의 혼돈 또는 추가 상황 대응에 대한 고려를 할 필요 없게 만들어 주는 것.

예외 상황 처리는 정말 어렵다

  • 이번 블로깅을 하면서 예외 상황 처리에 대한 여러가지 레퍼런스 등을 찾아 봤는데 역시 아직도 어려운 부분이다.

그외 읽으면 좋은 글

profile
읽기 좋은 code란 무엇인가 고민하는 백엔드 개발자 입니다.

0개의 댓글