Effect-TS 완벽 가이드 — 타입 안전 함수형 TypeScript, Promise의 진화
이 글의 핵심
Effect-TS는 Promise·async/await의 한계(에러가 any·의존성 주입 어려움·취소 불가·동시성 원시 primitive 부재)를 해결하는 함수형 TypeScript 런타임입니다. ZIO/Scala의 아이디어를 TS에 이식해 에러·환경·성공값을 타입으로 표현하고, Fiber 기반 구조적 동시성을 제공합니다. 이 글은 핵심 개념·패턴·실전 Node/React 예제를 정리합니다.
이 글의 핵심
Effect-TS는 TypeScript의 Promise/async-await가 가진 구조적 한계를 해결하기 위한 함수형 런타임 라이브러리입니다.
// async/await: 에러 타입은 사라짐, 의존성은 암시적
async function fetchUser(id: string): Promise<User> {
const data = await fetch(`/api/users/${id}`).then(r => r.json())
return User.parse(data)
}
// 이 함수가 던질 수 있는 에러? ZodError? NetworkError? any 다.
// 어떤 외부 의존성? fetch 전역. 테스트 시 mocking 필요.
// Effect
const fetchUser = (id: string) => Effect.gen(function* () {
const http = yield* Http
const data = yield* http.get(`/api/users/${id}`)
return yield* Schema.decodeUnknown(User)(data)
})
// 타입: Effect<User, HttpError | ParseError, Http>
// 성공값 User, 에러는 둘 중 하나, 의존성 Http 서비스
에러·의존성·성공값 3중 타입 추적이 핵심이며 이로써 수많은 런타임 버그가 컴파일 타임에 잡힙니다.
왜 Effect인가: Promise의 구조적 문제
| 문제 | Promise/async-await | Effect |
|---|---|---|
| 에러 타입 | any (catch에서) | 명시적 Union |
| 의존성 주입 | 전역/DI 프레임워크 | Context로 타입에 표현 |
| 취소 | AbortSignal 수동 전파 | 자동 전파 + Interruptible |
| 재시도 | try/catch + loop | Effect.retry(Schedule...) |
| 타임아웃 | Promise.race 수동 | Effect.timeout("3 seconds") |
| 동시성 primitive | Promise.all 수준 | Fiber·Queue·Semaphore·Latch |
| 테스트 | mocking 복잡 | Layer 교체로 순수 테스트 |
대규모 프로젝트에서 이 차이는 장애 발생률과 디버깅 시간에 직결됩니다.
설치
npm install effect @effect/platform @effect/schema
npm install --save-dev @effect/vitest
TypeScript 5.4+ 권장 (tsconfig.json의 strict: true, moduleResolution: "bundler").
핵심 타입: Effect<A, E, R>
import { Effect } from "effect"
const success: Effect.Effect<number, never, never> = Effect.succeed(42)
const failure: Effect.Effect<never, Error, never> = Effect.fail(new Error("oops"))
const both: Effect.Effect<number, Error, never> =
Math.random() > 0.5 ? Effect.succeed(1) : Effect.fail(new Error())
A: 성공 시 값 타입E: 에러 타입(Union 가능)R: 요구 의존성(환경)
Generator 문법: async/await처럼 읽기
const program = Effect.gen(function* () {
const x = yield* Effect.succeed(1)
const y = yield* Effect.succeed(2)
return x + y
})
const result = Effect.runSync(program) // 3
yield*가 await 역할을 하며 타입은 완벽하게 추론됩니다.
에러를 타입으로
import { Data, Effect, Schema } from "effect"
class NotFoundError extends Data.TaggedError("NotFoundError")<{
readonly id: string
}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly cause: unknown
}> {}
const fetchUser = (id: string) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise({
try: () => fetch(`/api/users/${id}`),
catch: (cause) => new NetworkError({ cause }),
})
if (response.status === 404) {
return yield* Effect.fail(new NotFoundError({ id }))
}
return yield* Effect.promise(() => response.json() as Promise<User>)
})
// fetchUser의 타입
// Effect<User, NotFoundError | NetworkError, never>
호출 측은 타입 체크를 통해 모든 에러를 빠짐없이 처리하도록 강제됩니다.
const handled = fetchUser("abc").pipe(
Effect.catchTag("NotFoundError", ({ id }) =>
Effect.succeed({ id, name: "Unknown" })
),
Effect.catchTag("NetworkError", () =>
Effect.succeed({ id: "offline", name: "Offline" })
),
)
// handled: Effect<User, never, never>
의존성 주입: Context & Layer
import { Context, Effect, Layer } from "effect"
// 서비스 인터페이스
class Http extends Context.Tag("Http")<Http, {
readonly get: (url: string) => Effect.Effect<unknown, NetworkError>
}>() {}
// 실제 구현
const HttpLive = Layer.succeed(Http, {
get: (url) =>
Effect.tryPromise({
try: () => fetch(url).then((r) => r.json()),
catch: (cause) => new NetworkError({ cause }),
}),
})
// 테스트 구현
const HttpTest = Layer.succeed(Http, {
get: (url) => Effect.succeed({ mocked: true, url }),
})
// 사용
const program = Effect.gen(function* () {
const http = yield* Http
return yield* http.get("/api/users/1")
})
// program: Effect<unknown, NetworkError, Http>
// 실행 시 Layer 주입
Effect.runPromise(program.pipe(Effect.provide(HttpLive)))
타입에 R = Http 가 남아 있어 주입을 잊을 수 없고, 테스트에서는 HttpTest로 손쉽게 교체합니다.
동시성: Fiber
import { Effect, Fiber } from "effect"
const slow = Effect.sleep("2 seconds").pipe(Effect.as("slow"))
const fast = Effect.sleep("500 millis").pipe(Effect.as("fast"))
// 동시에 실행, 둘 다 기다림
const both = Effect.all([slow, fast], { concurrency: "unbounded" })
// 먼저 끝난 쪽 채택, 나머지 자동 취소
const race = Effect.race(slow, fast)
// 수동 Fiber 제어
const supervised = Effect.gen(function* () {
const f = yield* Effect.fork(slow)
yield* Effect.sleep("1 second")
yield* Fiber.interrupt(f)
})
취소가 자동 전파됩니다 — Effect.timeout·race·scope 종료 시 내부의 모든 자식 Fiber가 정리됩니다. Promise로는 구현이 복잡한 구조적 동시성을 1급 시민으로 제공합니다.
재시도·스케줄
import { Effect, Schedule, Duration } from "effect"
const policy = Schedule.exponential(Duration.millis(100)).pipe(
Schedule.compose(Schedule.recurs(5)),
Schedule.jittered,
)
const robust = fetchUser("abc").pipe(
Effect.retry(policy),
Effect.timeout("10 seconds"),
)
지수 백오프 + jitter + 최대 5회 + 전체 10초 타임아웃을 한 블록으로 조합합니다.
Schema: zod의 함수형 대안
import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.String.pipe(Schema.uuid()),
email: Schema.String.pipe(Schema.pattern(/^.+@.+$/)),
age: Schema.Number.pipe(Schema.int(), Schema.positive()),
})
type User = Schema.Schema.Type<typeof User>
const parse = Schema.decodeUnknown(User)
// parse(data): Effect<User, ParseError>
결과가 Effect이므로 검증 에러를 타입으로 추적할 수 있고, 다른 Effect 흐름과 자연스럽게 조합됩니다.
HTTP 서버: @effect/platform
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { createServer } from "node:http"
const router = HttpRouter.empty.pipe(
HttpRouter.get(
"/users/:id",
Effect.gen(function* () {
const params = yield* HttpRouter.params
const user = yield* fetchUser(params.id)
return yield* HttpServerResponse.json(user)
}).pipe(
Effect.catchTag("NotFoundError", () =>
HttpServerResponse.empty({ status: 404 })
),
),
),
)
const app = HttpServer.serve(router).pipe(
HttpServer.withLogAddress,
Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3000 })),
Layer.provide(HttpLive),
)
NodeRuntime.runMain(Layer.launch(app))
- 라우팅·요청 파싱·응답 생성 모두 Effect 기반
- 에러가 누락되면 컴파일 에러
- 서비스 교체가 Layer로 일관
큐·스케줄·백그라운드 작업
import { Effect, Queue, Schedule } from "effect"
const worker = Effect.gen(function* () {
const queue = yield* Queue.bounded<Job>(100)
const producer = Effect.gen(function* () {
while (true) {
yield* Queue.offer(queue, { id: crypto.randomUUID() })
yield* Effect.sleep("1 second")
}
})
const consumer = Effect.gen(function* () {
while (true) {
const job = yield* Queue.take(queue)
yield* processJob(job)
}
})
yield* Effect.all([producer, consumer], { concurrency: 2 })
})
구조적 동시성 덕분에 shutdown 시 모든 워커가 자동 정리됩니다.
스트림
import { Stream, Effect, Schedule } from "effect"
const events = Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
Stream.mapEffect((n) =>
Effect.sleep("100 millis").pipe(Effect.as(n * 2))
),
Stream.filter((n) => n > 4),
Stream.take(3),
)
const result = Effect.runPromise(Stream.runCollect(events))
// [6, 8, 10]
RxJS와 유사하지만 타입 추론이 강력하고 백프레셔·취소가 1급으로 지원됩니다.
테스트: Layer 교체로 깔끔하게
import { describe, it, expect } from "@effect/vitest"
import { Effect, Layer } from "effect"
describe("fetchUser", () => {
it.effect("returns user on success", () =>
Effect.gen(function* () {
const user = yield* fetchUser("abc")
expect(user.name).toBe("Alice")
}).pipe(Effect.provide(HttpTest)),
)
it.effect("returns NotFoundError on 404", () =>
Effect.gen(function* () {
const result = yield* Effect.exit(fetchUser("missing"))
expect(result._tag).toBe("Failure")
}).pipe(Effect.provide(HttpNotFound)),
)
})
mock·spy·vi.mock 없이 Layer만 바꿔서 테스트가 성립합니다.
React 통합
import { useEffect, useState } from "react"
import { Effect, Runtime } from "effect"
const runtime = Runtime.defaultRuntime
function useUser(id: string) {
const [state, setState] = useState<
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: NetworkError | NotFoundError }
>({ status: "loading" })
useEffect(() => {
const fiber = runtime.pipe(
Runtime.runFork(
fetchUser(id).pipe(
Effect.provide(HttpLive),
Effect.match({
onSuccess: (data) => setState({ status: "success", data }),
onFailure: (error) => setState({ status: "error", error }),
}),
),
),
)
return () => Effect.runSync(Fiber.interrupt(fiber))
}, [id])
return state
}
언마운트 시 자동 취소까지 구조적 동시성으로 해결됩니다.
언제 쓰지 말아야 하나
- 소규모 스크립트: Effect의 설치·학습 비용이 과함
- CPU 집약 계산: 순수 함수로 충분, Effect 불필요
- Cold start가 중요한 serverless: 번들 크기·초기화 지연에 민감하면 다른 선택지 고려
- 팀이 함수형 경험이 없고 단기 프로젝트: 학습 곡선이 일정 지연을 초래할 수 있음
중장기 제품 개발, 결제·도메인 로직·외부 API 통합 같이 견고함이 최우선인 레이어에서 가치가 극대화됩니다.
점진적 도입 전략
- 경계 Effect: 외부 API 호출·큐 처리 등 IO 경계에서 시작
- 공유 의존성은 Context: DB·HTTP·Config를 Context로 정의
- 기존 Promise와 interop:
Effect.promise,Effect.runPromise - 에러 타입 선언: 도메인 에러 클래스를
Data.TaggedError로 정의 - CI에 타입 체크:
tsc --noEmit으로 의존성 누락 차단 - 팀 온보딩: 2일짜리 페어 프로그래밍으로 Generator 문법·Layer 익히기
트러블슈팅
Service not found
런타임에 Layer 주입을 잊은 경우. Effect.provide 호출 여부·누락된 서비스 이름 확인.
타입이 unknown으로 좁혀지지 않음
Schema decoding 결과는 반드시 yield*로 풀어야 타입이 성공 타입으로 좁혀짐.
번들 크기가 큼
Effect는 tree-shakable이지만 platform 패키지를 통째로 import하면 큼. import { HttpServer } from "@effect/platform/HttpServer" 형태로 서브패스 import.
디버깅 스택 트레이스가 이상함
Generator 내부 에러는 기본적으로 원인이 가려짐. Effect.tapErrorCause로 Cause.pretty(cause) 출력 활성화.
체크리스트
- TypeScript strict + moduleResolution bundler
- 도메인 에러를
Data.TaggedError로 표준화 - 외부 서비스는
Context.Tag로 추상화, Layer로 구현 - 재시도·타임아웃·동시성 제어는 Effect 조합자로 선언
- 테스트는
@effect/vitest+ Layer 치환 - 프로덕션 로깅은
Effect.log*계열로 통일 - Promise 경계에서
Effect.runPromise/Effect.runFork
마무리
Effect-TS는 “TypeScript로 함수형 프로그래밍을 할 때 진짜 쓸 만한 런타임”을 드디어 제공합니다. 초기 학습 비용이 있지만, 에러·의존성·동시성이 타입에 드러나기 시작하면 기존 Promise 코드의 취약성이 눈에 보입니다. 결제·주문·외부 API 통합처럼 “버그가 돈으로 직결되는” 레이어에서 먼저 도입해보세요. Discord·Shopify·여러 핀테크 스타트업이 이미 프로덕션에서 검증했으며, TypeScript 함수형 진영의 중심은 이제 Effect입니다.
관련 글
- TypeScript 완벽 가이드
- Async/Await 완벽 가이드
- RxJS 완벽 가이드
- 함수형 TypeScript 패턴