내가 Effect를 좋아하는 이유

생성일
Jul 19, 2023 07:29 AM
언어
분야
우리는 TypeScript를 사용하고 있고, 아주 멋진 숫자를 얻기 위해서(...) 이런 모양의 함수를 호출하려고 한다.
declare function getNumber(): Promise<number>;
흠. 타입을 살펴보니, 이 함수를 호출하면 Promise를 통해, 비동기적으로 number 타입의 값을 얻을 수 있을 것이다.
const result = await getNumber();
Promise가 문제없이 해결되기를 바라면서 await을 붙였다. 하지만 잠재적으로 문제가 발생할 경우를 고려하여 await 을 사용하는 구문을 try ~ catch로 감싸기로 했다.
try { const result = await getNumber(); } catch (err /* $ExpectType unknown */) { }
catch 블록 내에서 err의 타입은 unknown 이다. 발생할 수 있는 에러의 타입을 알 수 있을까?
getNumber() 함수의 타입을 보고 어떤 오류가 발생할 수 있는지 알 수 있으면 좋겠지만, getNumber() 함수는 Result나 Either 같은 타입으로 결과를 제공하지 않는다.
TypeScript에 throws 같은 구문이 도입되었다면 타입을 보고 어떤 오류가 발생할 수 있는지 알 수 있고, 나아가 catch 블록 내에서 타입 힌트를 제공 받을 수 있었을지도 모르지만... 우선 errError의 인스턴스인지부터 확인해 보자.
try { const result = await getNumber(); } catch (err) { if (err instanceof Error) { console.error(err); } }
하지만 JavaScript는 Error 가 아닌, 일반 JavaScript 객체나 원시 값도 throw 키워드로 던질 수 있기 때문에...?
try { const result = await getNumber(); } catch (err) { if (err instanceof Error) { console.error(err); } throw err; }
이미 조금 지친 것 같지만, 알고보니 getNumber() 함수 내에서 NumberRepository 라는 객체를 필요로 하고 있었고, 이 함수를 호출하려면 미리 initNumberRepository() 함수를 호출해야 한다는 사실을 뒤늦게 깨달았다.
initNumberRepository(); try { const result = await getNumber(); } catch (err) { if (err instanceof Error) { console.error(err); } throw err; }
이제 우리는 확실하게 지쳤으니까 initNumberRepository() 함수가 예외를 발생시킬 여지가 있네 없네는 넘어가자.
처음에 Promise<number> 하나만 보고 써내려간 코드가 어느새 이렇게 되어 버렸다.
TypeScript를 통해 상당수의 타입 불일치와 관련된 문제를 미리 예방할 수 있지만, 호출하는 함수/로직이 어떤 오류가 발생하는지, 어떤 맥락적인 객체나 코드를 필요로 하는지 알 수 없다.
임시 방편으로는 위의 코드처럼 어딘가 허술한 try ~ catch 구문을 작성하는 것과 호출하는 함수/로직이 작성된 부분을 옆 창에 띄워두고 수시로 확인하는 것이 있겠지만, 어느 쪽도 만족스럽지 않다.

Introducing Effect

비약과 과장이 난무한 예시지만, Effect는 이러한 문제를 해결하는 데 초점을 맞췄다. Effect는 Scala의 ZIO 에서 영감을 받고, TypeScript의 포팅으로 시작된 라이브러리이다.
Effect를 사용하면 어떠한 로직, Effect를 처리하는 데에 필요한 환경과 발생할 수 있는 오류, 그리고 실행 결과에 대한 값을 오직 타입만 보고 유추할 수 있다.
Effect.Effect<R, E, A>
Effect.Effect는 세 개의 타입 인자를 받는데, 각각 다음을 의미한다:
  • R - Requirements: 해당 Effect를 실행하는데 필요한 컨텍스트(의존성)을 의미한다. 해당 인자가 never인 경우, 별도의 컨텍스트를 필요로 하지 않는다.
  • E - Error: 해당 Effect가 발생시킬 수 있는 오류들을 의미한다. 해당 인자가 never인 경우, 절대로 실패하지 않는다. 즉, 오류가 발생하지 않는다.
  • A - Value: 해당 Effect가 성공적으로 처리될 경우 반환되는 값의 타입을 의미한다. 위의 두 인자와는 다르게, 해당 인자가 never인 경우, 해당 Effect는 영원히 실행(또는 실패할 때 까지 실행)됨을 의미한다. 해당 인자가 void인 경우, 함수의 리턴 타입과 비슷하게 유의미한 값에 대한 정보가 없다는 것을 의미힌다.
만약 아까의 getNumber() 함수가 Effect를 반환했다면 어땠을까?
declare function getNumber(): Effect.Effect< NumberRepository, NoLuck, number >;
우리는 Effect.Effect<NumberRepository, never, number> 라는 타입을 보고 다음과 같이 이해할 수 있다.
  • 이 Effect는 NumberRepository 라는 환경을 필요로 한다. 이 Effect를 처리하려면 해당 컨텍스트를 주입해야 한다는 정보를 얻을 수 있다.
  • 이 Effect는 실패하면 NoLuck 이라는 에러가 발생한다. (아마 운이 안좋으면 발생할 것이다)
  • 이 Effect가 성공하면 number 타입의 값을 얻을 수 있다.
getNumber() 를 통해 반환받은 Effect를 실행하기 위해서 필요한 환경, 발생할 수 있는 오류, 그리고 성공 시 얻을 수 있는 결과값에 대한 정보를 타입만 보고 유추할 수 있다.
덕분에 에러를 핸들링 하는 것이 쉽고 정확해진다. NoLuck 오류가 발생했을 때, 런타임을 터트리는 대신 -1 값을 반환하고자 한다면 이렇게 할 수 있다.
const result = pipe( getNumber(), Effect.catchTags({ // NoLuck 키에 대한 타입 힌트가 제공된다. NoLuck: (error /* $ExpectType NoLuck */) => Effect.succeed(-1), }), )
NumberRepository 를 필요로 한다는 것도 알게 되었으니, 해당 컨텍스트를 주입하려면 Effect.provideContext 를 사용한다.
// $ExpectType Effect.Effect<never, never, number> const result = pipe( getNumber(), Effect.catchTags({ NoLuck: () => Effect.succeed(-1), }), Effect.provideContext(NumberRepositoryLive), )
Effect.Effect<R, E, A> 이 자체로만으로는 아무 일도 일어나지 않는다. 어떤 Effect를 실제로 실행해서 성공 또는 실패에 대한 결과를 얻으려면 Effect를 실행해야 한다.
// $ExpectType number const result = pipe( getNumber(), Effect.catchTags({ NoLuck: () => Effect.succeed(-1), }), Effect.provideContext(NumberRepositoryLive), Effect.runSync, )
Effect.runSync를 사용하여 해당 Effect를 동기적으로 처리한 뒤 결과를 반환한다.
처리하는 Effect가 동기(runSync)인지 비동기(runPromise)인지, 실행 결과를 구체적으로 다룰 것인지(Exit)에 따라서 실행 함수를 고를 수 있다. (참고: Running Effects - 공식 문서)

Effect의 좋은 점

이제 Effect의 좋은 점에 대해 몇가지만 더 세부적으로 짚어보자.

Just TypeScript

TypeScript의 부족한 점을 채우기 위해 좀 더 강타입인 매력적인 언어를 배워서 상호 운용에 도움을 얻을 수도 있지만, 러닝 커브가 가파르거나, 별도의 컴파일이 필요하다는 점이 있다.
pnpm install effect
Effect는 오로지 TypeScript이다. 따라서 작업 중인 TypeScript 프로젝트에 간단하게 설치해서 부분적으로 사용해볼 수 있다.

의존성 관리

Effect.provideContext를 통해 필요한 환경, 컨텍스트를 제공하는 코드를 확인했다. 이 부분을 좀 더 자세히 살펴보자.
export interface NumberRepository { readonly get(): Effect.Effect<never, NoLuck, number>; } export const NumberRepository = Context.Tag<NumberRepository>();
NumberRepository 에 대한 인터페이스를 생성하고, Context.Tag를 사용하여 해당 서비스에 대한 태그를 생성했다. NumberRepository를 사용하면 작성중인 Effect의 Requirements에 NumberRepository가 자동으로 추가된다.
// $ExpectType Effect.Effect<NumberRepository, NoLuck, number> const program = pipe( NumberRepository, Effect.flatMap(numberRepo => numberRepo.get()) )
아직 NumberRepository를 구체적으로 구현하지는 않았지만 Effect를 작성했다. 이 Effect를 실행할 때 어떤 컨텍스트를 제공하는가에 따라 program 의 결과가 달라질 수 있다.
const program = pipe( NumberRepository, Effect.flatMap(numberRepo => numberRepo.get()) ) const result = pipe( program, Effect.provideService( NumberRepository, NumberRepository.of({ get: () => Effect.succeed(42) }) ) Effect.runSync, ) console.log(result); // 42
유연하게 의존성을 주입할 수 있기 때문에, Effect에 대한 테스트 코드를 작성하는 것도 어렵지 않다.

Effect.gen

Effect에 대한 유입 절단 부분을 하나 고르라고 하면 pipe() 를 사용한 함수형 프로그래밍 스타일의 표현 방식이라고 생각한다.
(면책 사항: 나는 함수형에 대해 깊이 이해하고 있지 않다. '함수형' 또는 '함수형 프로그래밍'이라는 표현이 거슬린다면 너그러운 양해를 구한다.)
공식 문서에 따르면, pipe() 를 사용하여 파이프라인을 구성하면, 값을 처리하거나 조작하는 순서를 지정할 수 있어서 쉽게 데이터를 변환하거나 조작할 수 있다고 한다. (참고: Building Pipelines - 공식 문서)
Effect에 대한 개념을 아주 조금 익혀본 상태에서 실제 로직을 Effect로 구현하려고 하면 막막한 구석이 생기는데, Effect.gen API는 기존의 절차적 멘탈 모델을 그대로 사용하여 Effect.Effect<R, E, A> 를 다룰 수 있도록 도와준다.
// $ExpectType Effect.Effect<NumberRepository, NoLuck, number> Effect.gen(function* (_) { const numberRepository = yield* _(NumberRepository); const result = yield* numberRepository.get(); return result; })
제너레이터 문법이 익숙하지 않다면 어쩔 수 없지만, async/await 구문과 유사한 방식으로 Effect를 다룰 수 있다는 점이 좋았다.
다만 Effect의 맛을 좀 더 본 뒤에는 Effect.gen 보다는 어떻게 하면 다른 API를 다룰 수 있을지를 고민하게 되는 것 같다. (괜히 잘 작동하는데 다시 뜯어서 새로 작성한다거나...)
그 밖에도, Effect에는 일괄 처리나 동시성 제어, 스케쥴링, 리소스 관리, 로거 등의 다양한 기능이 있지만 아직 거기까지는 다뤄보지 못하여 이번 글에는 담지 못했다.
베타 버전이라는 리스크만 감내할 수 있다면, 시간을 들여서 Effect를 학습하고 다뤄보는 것을 추천하고 싶다. 특히 타입에 에러에 대한 정보가 나온다는 점이 최고다. 왜냐하면 우리가 작성하는 로직, 요청하는 API는 항상 오류가 날 가능성이 존재하니까!
그리고 공식 문서가 내용이 알차게, 특히 초심자 기준으로 채워지고 있어서, Effect에 대헤 더 알아보고 싶다면 공식 문서를 살펴보는 것을 추천한다.
리소스