Effect-TS로 Promise의 한계 넘기: 에러를 타입으로, Context 의존성 주입, Fiber 동시성
이 글의 핵심
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/platform-node
npm install --save-dev @effect/vitest
TypeScript 5.4+ 권장 (tsconfig.json의 strict: true, moduleResolution: "bundler").
예전 자료에는 @effect/schema를 따로 설치하라고 나오는데, Effect 3.10부터 Schema가 핵심 패키지 effect에 합쳐져 import { Schema } from "effect"로 쓰고 @effect/schema는 deprecated되었습니다. 두 패키지를 섞어 설치하면 서로 다른 버전의 effect 타입이 들어와 “타입이 같아 보이는데 호환되지 않는다”는 에러가 나기 쉽습니다. 마찬가지로 effect와 @effect/* 패키지들은 버전을 함께 올려야 하므로, 하나만 업데이트했을 때 이상한 타입 에러가 나면 npm ls effect로 중복 설치 여부부터 확인하세요. strict: true가 필요한 이유는 Effect의 에러·의존성 추적이 never와 유니언 타입 추론에 의존하기 때문에, strict가 꺼져 있으면 타입이 any로 무너져 이 글에서 말하는 장점이 대부분 사라지기 때문입니다.
핵심 타입: 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: 요구 의존성(환경)
가장 먼저 익혀야 할 차이는 Effect는 값이지 실행이 아니다라는 점입니다. fetch()를 호출하면 그 순간 요청이 나가는 Promise와 달리, Effect.succeed나 Effect.gen은 “이런 일을 할 것”이라는 설명을 만들 뿐이고 Effect.runPromise/runSync/runFork를 호출해야 실행됩니다. 그래서 같은 Effect를 두 번 실행하면 작업도 두 번 일어나고, 재시도(retry)가 “처음부터 다시 실행”이라는 의미를 가질 수 있습니다. 반대로 Effect를 만들어 놓고 실행하지 않으면 아무 일도 일어나지 않아, 처음에는 “로그가 안 찍힌다”는 이유로 헤매기 쉽습니다. runSync는 비동기 작업(sleep, tryPromise)이 들어간 Effect에 쓰면 예외를 던지므로, 동기 작업만 있을 때만 씁니다.
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>
Effect.fail(예상된 실패)과 Effect.promise 안에서 던져진 예외는 성격이 다릅니다. Effect.promise는 “이 Promise는 실패하지 않는다”고 선언하는 것이라, response.json()이 잘못된 JSON 때문에 reject되면 그 실패는 에러 채널 E가 아니라 결함(defect)으로 처리되어 catchTag로 잡히지 않고 프로그램을 중단시킵니다. 실패할 수 있는 Promise는 위의 fetch처럼 Effect.tryPromise로 감싸 에러 타입을 부여해야 하므로, 이 예제의 .json() 부분도 실무에서는 tryPromise로 바꾸는 것이 맞습니다. 또 as Promise<User>는 타입 단언일 뿐 검증이 아니어서, 서버 응답 모양이 바뀌면 타입과 실제 값이 어긋납니다. 아래 Schema로 디코딩하는 방식이 이 구멍을 막아 줍니다.
호출 측은 타입 체크를 통해 모든 에러를 빠짐없이 처리하도록 강제됩니다.
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로 손쉽게 교체합니다.
R에 서비스가 남아 있는 Effect를 Effect.runPromise에 넘기면 Type 'Http' is not assignable to type 'never' 같은 타입 에러가 나는데, 이것이 “주입을 잊을 수 없다”는 말의 실체입니다. Layer.succeed는 이미 만들어진 구현 객체를 그대로 넣는 가장 단순한 형태이고, 구현이 다른 서비스(Config, Logger)에 의존하거나 연결 풀처럼 생성·정리가 필요하면 Layer.effect나 Layer.scoped로 만든 뒤 Layer.provide로 의존 Layer를 연결합니다. 같은 Layer를 여러 곳에서 Effect.provide하면 그때마다 새로 생성되므로, DB 풀처럼 하나만 있어야 하는 서비스는 애플리케이션 진입점에서 한 번 provide하거나 ManagedRuntime으로 런타임을 만들어 재사용하는 구조가 필요합니다.
동시성: 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급 시민으로 제공합니다.
여기서 “취소”는 Effect 런타임이 관리하는 작업에만 닿는다는 점을 알아 둬야 합니다. Effect.sleep이나 Effect로 만든 루프는 즉시 멈추지만, Effect.tryPromise로 감싼 fetch는 Fiber가 interrupt되어도 요청 자체는 계속 진행됩니다. 요청까지 끊으려면 tryPromise({ try: (signal) => fetch(url, { signal }), ... })처럼 Effect가 넘겨주는 AbortSignal을 받아 전달해야 합니다. Effect.all의 기본 동시성은 순차 실행이라, { concurrency: "unbounded" }나 숫자를 주지 않으면 배열의 Effect가 하나씩 차례로 실행된다는 점도 Promise.all에 익숙한 사람이 가장 많이 놓치는 부분입니다. 외부 API를 수백 건 호출할 때는 "unbounded" 대신 concurrency: 10처럼 상한을 거는 것이 안전합니다.
재시도·스케줄
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초 타임아웃을 한 블록으로 조합합니다.
이 조합에는 두 가지 함정이 있습니다. 첫째, Effect.retry(policy)는 에러 종류를 가리지 않고 재시도하므로, NotFoundError처럼 다시 해도 결과가 같은 에러까지 다섯 번 반복합니다. Effect.retry({ schedule: policy, while: (e) => e._tag === "NetworkError" })처럼 재시도할 에러를 좁히는 것이 좋습니다. 둘째, 스케줄 조합자의 의미를 정확히 확인해야 합니다. “지수 백오프이면서 최대 5회”처럼 두 조건을 모두 만족할 때만 계속하려면 Schedule.intersect가 의도를 가장 직접 표현하고, 단순히 횟수만 제한하려면 Effect.retry({ schedule, times: 5 }) 옵션을 쓰는 방법도 있습니다. 조합 결과가 헷갈리면 작은 테스트에서 TestClock으로 시간을 돌려 실제 재시도 간격과 횟수를 확인해 보는 것이 가장 확실합니다. 또 Effect.timeout은 시간이 지나면 에러 채널에 TimeoutException을 추가하므로, 호출하는 쪽의 에러 처리에도 이 태그를 포함해야 합니다.
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 = await Effect.runPromise(Stream.runCollect(events))
// Chunk(6, 8, 10) — 배열이 필요하면 Chunk.toReadonlyArray(result)
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만 바꿔서 테스트가 성립합니다.
이 테스트가 의미를 가지려면 fetchUser가 앞의 “의존성 주입” 절처럼 전역 fetch가 아니라 Http 서비스를 통해 요청해야 합니다. “에러를 타입으로” 절의 fetchUser는 fetch를 직접 부르므로 R이 never이고, HttpTest를 provide해도 아무 효과 없이 실제 네트워크로 요청이 나갑니다. 또 HttpNotFound는 get이 Effect.fail(new NotFoundError(...))을 돌려주는 Layer로 따로 정의했다고 가정한 것입니다. @effect/vitest의 it.effect는 테스트 안에서 TestClock을 기본 제공하므로, Effect.sleep이나 재시도 간격이 들어간 코드를 실제 시간을 기다리지 않고 TestClock.adjust("10 seconds")로 빠르게 검증할 수 있다는 점도 큰 장점입니다.
React 통합
import { useEffect, useState } from "react"
import { Effect, Fiber } from "effect"
function useUser(id: string) {
const [state, setState] = useState<
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: NetworkError | NotFoundError }
>({ status: "loading" })
useEffect(() => {
const fiber = Effect.runFork(
fetchUser(id).pipe(
Effect.provide(HttpLive),
Effect.match({
onSuccess: (data) => setState({ status: "success", data }),
onFailure: (error) => setState({ status: "error", error }),
}),
),
)
// interrupt는 비동기로 끝날 수 있으므로 runSync 대신 runFork
return () => { Effect.runFork(Fiber.interrupt(fiber)) }
}, [id])
return state
}
언마운트 시 자동 취소까지 구조적 동시성으로 해결됩니다.
정리 함수에서 Effect.runSync(Fiber.interrupt(fiber))를 쓰면, Fiber가 비동기 작업 중일 때 interrupt 완료를 동기적으로 기다릴 수 없어 AsyncFiberException이 던져질 수 있습니다. 그래서 위 코드는 interrupt를 runFork로 실행만 걸어 두는 형태로 바꿨습니다. id가 바뀌면 이전 요청의 Fiber가 interrupt된 뒤 새 요청이 시작되므로, useEffect에서 흔히 생기는 “이전 요청 응답이 늦게 와서 새 화면을 덮어쓰는” 경쟁 상태도 함께 막힙니다. 다만 앞에서 말했듯 fetch까지 실제로 끊으려면 tryPromise에 signal을 연결해야 합니다.
언제 쓰지 말아야 하나
- 소규모 스크립트: 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 익히기
유스케이스를 서비스 태그로 나누고 Layer를 조립하는 설계 쪽(주문 확정 예제, R이 비대해질 때의 대응, Cause 로그 운영 규칙)은 Effect로 TypeScript 비즈니스 로직 짜기에서 이어서 다룹니다.
트러블슈팅
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 통합처럼 “버그가 돈으로 직결되는” 레이어에서 먼저 도입해보세요. 도입 여부를 판단할 때는 “팀원 모두가 Effect 코드를 리뷰할 수 있는가”를 기준으로 삼는 것이 좋습니다. 한두 명만 이해하는 Effect 코드는 그 사람이 떠나는 순간 가장 손대기 어려운 코드가 되기 때문입니다.
자주 묻는 질문 (FAQ)
Q. 실행 중에 Service not found 오류가 나는 이유는 무엇인가요?
A. 프로그램이 요구하는 서비스에 대한 Layer를 Effect.provide로 넣어 주지 않았을 때 발생합니다. Effect는 필요한 의존성을 타입 R로 추적하므로 보통 컴파일 단계에서 걸러지지만, 캐스팅이나 타입 우회가 섞이면 런타임까지 넘어올 수 있습니다. 어떤 서비스가 빠졌는지 오류 메시지의 이름으로 확인하고, CI에서 tsc --noEmit을 돌려 의존성 누락을 미리 막는 것이 좋습니다.