Devlery
Blog/Effect

Effect: TypeScript에 견고함을 더하는 함수형 프레임워크

에러 처리, 동시성, 의존성 관리를 타입 레벨에서 해결하는 TypeScript 프레임워크 Effect의 핵심 개념과 기본 사용법을 알아봅니다.

TypeScript로 백엔드를 개발하다 보면 비슷한 패턴의 코드를 반복해서 작성하게 된다. try-catch로 에러를 감싸고, 에러 타입을 좁히고, 리소스를 정리하고, 동시 요청의 취소를 처리하고... 하나하나는 별것 아닌데, 이런 보일러플레이트가 쌓이면 비즈니스 로직이 잘 보이지 않게 된다.

Effect는 이런 문제를 타입 시스템 레벨에서 해결하려는 프레임워크다.

Effect가 뭔가요?

Effect는 Scala의 ZIO에서 영감을 받은 TypeScript 프레임워크다. 핵심 아이디어는 사이드 이펙트를 값으로 다루는 것 이다.

보통 함수가 실행되면 바로 결과가 나온다. 하지만 Effect에서는 "무엇을 할 것인지"를 먼저 설명 하고, 나중에 실행 한다. 이 분리 덕분에 에러 처리, 재시도, 타임아웃, 동시성 같은 복잡한 동작을 조합하기 쉬워진다.

설치

npm install effect

의존성 없이 이 패키지 하나로 시작할 수 있다. 최근 출시된 v4에서는 번들 크기가 대폭 줄어들어, 최소 프로그램 기준 약 20KB 수준이다.

타입으로 표현하는 에러

Effect의 가장 실용적인 기능은 에러를 타입으로 추적하는 것 이다. 일반적인 TypeScript 함수는 어떤 에러가 발생할 수 있는지 타입에 드러나지 않는다.

// 일반 TypeScript — 어떤 에러가 날지 타입에서 알 수 없음
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

Effect를 사용하면 성공 타입뿐 아니라 에러 타입도 함수 시그니처에 명시된다.

import { Effect } from 'effect'

class NetworkError {
  readonly _tag = 'NetworkError'
  constructor(readonly message: string) {}
}

class NotFoundError {
  readonly _tag = 'NotFoundError'
  constructor(readonly userId: string) {}
}

// Effect<User, NetworkError | NotFoundError>
// → 성공하면 User, 실패하면 NetworkError 또는 NotFoundError
const getUser = (id: string) =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
    catch: () => new NetworkError('API 호출 실패'),
  })

함수의 타입만 보고도 어떤 에러가 발생할 수 있는지 알 수 있다. 호출하는 쪽에서 에러를 처리하지 않으면 컴파일 에러가 나기 때문에, 에러를 실수로 무시하는 일이 줄어든다.

Effect 조합하기

Effect의 강점은 여러 작업을 조합할 때 드러난다.

import { Effect, pipe } from 'effect'

const program = pipe(
  getUser('123'),
  Effect.flatMap((user) => getPostsByUser(user.id)),
  Effect.map((posts) => posts.filter((p) => p.published)),
  Effect.catchTag('NotFoundError', () =>
    Effect.succeed([]) // NotFound면 빈 배열 반환
  ),
)

pipe를 사용해서 작업을 순서대로 연결하고, 중간에 에러 처리를 끼워넣을 수 있다. catchTag는 특정 에러 타입만 골라서 처리한다. 처리된 에러는 타입에서 제거되기 때문에, 남은 에러가 무엇인지 항상 추적할 수 있다.

실행하기

Effect는 설명과 실행이 분리되어 있다. 최종적으로 Effect.runPromise로 실행하면 된다.

// Effect를 실제로 실행
const result = await Effect.runPromise(program)

재시도와 타임아웃

실무에서 흔히 필요한 재시도와 타임아웃도 간결하게 표현할 수 있다.

import { Effect, Schedule } from 'effect'

const robustProgram = pipe(
  getUser('123'),
  // 최대 3번 재시도, 지수 백오프
  Effect.retry(Schedule.exponential('100 millis').pipe(
    Schedule.compose(Schedule.recurs(3)),
  )),
  // 5초 타임아웃
  Effect.timeout('5 seconds'),
)

try-catch와 setTimeout, 카운터 변수를 조합해서 직접 구현해야 했던 로직이 선언적인 코드 몇 줄로 정리된다.

동시성 제어

여러 작업을 동시에 실행하되 동시 실행 수를 제한하고 싶을 때도 유용하다.

const userIds = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']

const program = Effect.forEach(
  userIds,
  (id) => getUser(id),
  { concurrency: 3 } // 최대 3개씩 동시 실행
)

Promise.all은 전부 동시에 실행하거나 순차 실행만 가능하다. Effect는 동시성 수를 세밀하게 조절할 수 있고, 하나가 실패하면 나머지를 자동으로 취소하는 structured concurrency를 지원한다.

진입 장벽이 있나요?

솔직히 있다. 함수형 프로그래밍 개념(모나드, pipe 등)에 익숙하지 않다면 처음에 낯설 수 있다. Effect<Success, Error, Requirements> 같은 제네릭 3개짜리 타입도 처음에는 읽기 어렵다.

하지만 꼭 모든 기능을 한 번에 도입할 필요는 없다. 에러 추적이 필요한 핵심 비즈니스 로직에만 부분적으로 적용하는 것도 좋은 시작이다.

마무리

Effect는 "TypeScript로 프로덕션 수준의 견고한 애플리케이션을 만들려면 어떤 도구가 필요한가"라는 질문에 대한 하나의 답이다. 에러 추적, 재시도, 타임아웃, 동시성 제어, 리소스 관리까지 하나의 일관된 모델로 해결한다.

모든 프로젝트에 필요한 도구는 아니다. 하지만 에러 처리가 복잡한 백엔드 서비스나, 신뢰성이 중요한 시스템을 TypeScript로 구축하고 있다면 한 번쯤 살펴볼 가치가 있다.