프롤로그 — Promise만으로는 답이 안 되는 순간
다음 코드를 보자. TypeScript로 흔히 짜는 모양이다.
async function chargeCustomer(userId: string, amount: number): Promise<Receipt> {
const user = await db.getUser(userId)
const card = await stripe.getDefaultCard(user.stripeId)
const charge = await stripe.charge(card, amount)
await db.saveReceipt(charge)
await mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
}
여섯 줄에 `await`가 다섯 번. 잘 동작한다 — 해피 패스에서는. 그런데 이 함수의 **시그니처**가 우리에게 말해주는 것은 단 두 가지뿐이다.
1. `Promise`를 돌려준다.
2. 성공하면 `Receipt`다.
말하지 **않는** 것:
- 어떤 에러가 나는지 — `Error` 하나로 뭉뚱그려진다.
- 어떤 의존성이 필요한지 — `db`, `stripe`, `mailer`가 클로저로 빨려 들어가 있다.
- 중간에 죽으면 청구는 됐는데 영수증만 안 보낸 상태가 될 수 있는지.
- 취소가 어떻게 전파되는지 (`AbortSignal`은 어디에?).
- 동시에 여러 단계를 병렬화하면 어떤 일이 일어나는지.
리팩터링이 두려운 이유가 여기 있다. 시그니처가 너무 적게 말한다. **이펙트 시스템(effect system)**은 이 말 부족을 타입으로 메우는 접근이다. Haskell의 `IO`, Scala의 `ZIO`, F#의 컴퓨테이션 익스프레션, Rust의 `Result` + 명시적 자원관리 — 이들 모두 같은 가족이다.
TypeScript 진영에서 그 자리를 차지한 라이브러리가 **Effect-TS**다. 2026년 기준 안정화된 3.x, fp-ts의 저자 Giulio Canti가 합류해 fp-ts를 maintenance mode로 보낸 그 라이브러리. 이 글은 Effect-TS를 **해부**한다 — `Effect<R, E, A>`, 연산, 에러, DI, 리소스, 스키마, 스트림, 파이버까지. 그리고 솔직히 **언제 쓰면 안 되는지**도 본다.
1장 · Effect 타입 — 컨텍스트·에러·값을 한꺼번에 추적한다
Effect-TS의 모든 것은 단 하나의 타입에서 출발한다.
type Effect<A, E, R>
세 개의 파라미터가 각각 무엇을 의미하는지가 핵심이다.
| 자리 | 이름 | 의미 |
| --- | --- | --- |
| `A` | Success | 이 이펙트가 성공했을 때 돌려주는 값의 타입 |
| `E` | Error / Failure | 이 이펙트가 **예측한** 실패의 타입(유니온 가능) |
| `R` | Requirements (Context) | 실행하려면 환경에 있어야 하는 서비스 |
읽는 법: "Effect는 `R`을 환경으로 받아 실행되며, `E`로 실패하거나 `A`로 성공한다." Promise와 비교하면 차이가 또렷하다.
// Promise: 성공 타입만 보인다
function foo(): Promise<Receipt>
// Effect: 환경·실패·성공이 시그니처에 다 있다
function foo(): Effect.Effect<Receipt, StripeError | DbError, DbService | StripeService | Mailer>
이게 별것 아닌 것 같지만, **리팩터 안전성**의 차원이 다르다. 어떤 함수가 새로운 에러를 던지기 시작하면 타입이 깨지고, 어떤 함수가 새 의존성을 필요로 하기 시작하면 호출 위치에서 컴파일러가 손가락질을 한다. Promise는 이 둘을 침묵 속에 흘려보낸다.
> **이펙트 추적의 핵심**: "이 함수가 무엇을 **할 수 있는지**"가 타입에 새겨진다. 호출자는 시그니처만 보고 "여기서 무엇이 깨질 수 있는가, 무엇이 필요한가"를 안다.
`Effect<A, E, R>`은 **묘사(description)**다. 즉시 실행되는 게 아니라, "이걸 이 환경에서 실행하면 이런 일이 일어난다"는 레시피다. `Effect.runPromise` 같은 인터프리터로 끝에서 한 번만 실행한다. 이 지연(lazy) 모델 덕에 동시성·재시도·취소를 안전하게 합성할 수 있다.
2장 · 기본 연산 — map, flatMap, zip, race, all
Effect는 모나드다. 즉 두 가지 연산이 출발점이다.
- `Effect.succeed(a)` — `A`를 그대로 감싸는 이펙트.
- `Effect.flatMap(eff, a => effB)` — `A`가 나오면 그것으로 다음 이펙트를 만든다.
이것만으로도 모든 시퀀싱이 표현된다. 하지만 매번 `flatMap`을 적는 건 피곤하다. Effect-TS는 **`Effect.gen`** 으로 제너레이터 기반의 코루틴 표기를 준다 — 사실상 `async/await` 같은 모양으로 이펙트를 시퀀싱한다.
const program = Effect.gen(function* () {
const user = yield* getUser(userId)
const card = yield* getDefaultCard(user.stripeId)
const charge = yield* chargeCard(card, amount)
yield* saveReceipt(charge)
yield* sendReceipt(user.email, charge)
return toReceipt(charge)
})
`yield*`가 `await`처럼 보이지만, 타입 시스템 안에서는 훨씬 풍부한 정보를 추적한다. 각 `yield*` 결과에서 환경 `R`과 에러 `E`가 합쳐지며, 마지막 시그니처는 모든 의존성과 가능한 모든 실패를 **자동으로** 합쳐 보여준다.
주요 연산 빠르게
- `Effect.map(eff, fn)` — 성공 값을 변환.
- `Effect.flatMap(eff, fn)` — 성공 값을 다음 이펙트로.
- `Effect.zip(a, b)` — 둘 다 성공해야 성공. 결과는 튜플.
- `Effect.all([a, b, c])` — 여러 개 합성. 옵션으로 동시성, fail-fast/모두 수집.
- `Effect.race(a, b)` — 먼저 끝나는 쪽 채택. 나머지는 **자동 취소**.
- `Effect.either(eff)` — 실패도 값으로 받아온다 (`Either` 변환).
- `Effect.catchAll(eff, e => recover)` — 실패 복구.
- `Effect.timeout(eff, "5 seconds")` — 시간 초과.
- `Effect.retry(eff, schedule)` — 재시도(스케줄로 백오프 표현).
`Effect.all`은 동시성 옵션이 강력하다.
Effect.all([fetchA, fetchB, fetchC], { concurrency: "unbounded" })
Effect.all(tasks, { concurrency: 8, mode: "either" })
`mode: "either"`는 일부가 실패해도 다른 결과를 끝까지 거둔다. Promise.allSettled를 닮았지만, 타입은 더 정확하고, 동시성 한도가 라이브러리 차원에서 강제된다.
3장 · 에러 — failures와 defects는 다른 종류다
이펙트 시스템에서 가장 비싸게 배우는 교훈: **모든 실패가 같은 종류는 아니다.**
Effect는 두 가지를 명확히 구분한다.
| 범주 | 무엇 | 타입에서 | 예시 |
| --- | --- | --- | --- |
| **Failure** | 예측한 실패 | `E`에 나타남 | `UserNotFound`, `PaymentDeclined`, `RateLimited` |
| **Defect** | 예상 못한 버그 | `E`에 안 나타남 | `null` 역참조, 무한 루프, 다운된 시스템 |
도메인 실패는 타입에 적어두고 복구한다. 버그는 잡지 않고 위로 던져 보낸다 — 잡으면 진짜 버그가 숨는다. 이 구분이 명확하지 않으면 `try/catch` 한 줄이 진짜 버그를 삼킨다.
도메인 에러 정의
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string
}> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
reason: string
code: number
}> {}
`Data.TaggedError`는 판별 가능한 유니온(discriminated union)을 위한 빌더다. 각 에러에 `_tag`가 박혀서 `switch`로 좁히기가 깔끔하다.
const recovered = program.pipe(
Effect.catchTags({
UserNotFound: (e) => Effect.succeed(guestReceipt),
PaymentDeclined: (e) => logAndNotify(e),
}),
)
여기서 압권은: `catchTags`로 일부만 처리하면 **남은 에러는 시그니처에 그대로 남는다.** 모든 케이스를 처리하지 않은 채 함수를 끝내면 시그니처가 그것을 드러낸다. Promise의 `try/catch`에는 이런 보증이 없다.
Promise vs Effect — 같은 일
Promise:
async function charge() {
try {
const u = await getUser(id)
return await stripe.charge(u, amt)
} catch (e) {
// e의 타입은 unknown. 어떤 에러? 모른다.
throw e
}
}
Effect:
const charge = Effect.gen(function* () {
const u = yield* getUser(id) // E: UserNotFound
return yield* stripeCharge(u, amt) // E: StripeError
})
// 추론된 시그니처: Effect<Receipt, UserNotFound | StripeError, R>
복구할 때 어떤 에러를 다루고 있는지 컴파일러가 정확히 알려준다. 새 에러가 추가되면 `catchAll`/`catchTags`가 안 잡힌 케이스를 비추거나, 시그니처가 변한다.
4장 · 의존성 주입 — Context.Tag와 Layer
`Effect<A, E, R>`의 `R`이 진짜 가치를 발휘하는 자리. Effect-TS의 DI는 다른 프레임워크의 컨테이너가 아니라 **타입으로 표현되는 환경**이다.
// 1. 서비스의 인터페이스를 Tag로 선언
class DbService extends Context.Tag("DbService")<
DbService,
{
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (r: Charge) => Effect.Effect<void, DbError>
}
>() {}
// 2. 사용 — yield* Tag 로 꺼낸다
const fetchUser = (id: string) =>
Effect.gen(function* () {
const db = yield* DbService
return yield* db.getUser(id)
})
// 추론: Effect<User, UserNotFound, DbService>
`DbService`를 호출하는 곳마다 `R`에 자동으로 `DbService`가 추가된다. 컴파일러가 "이 이펙트를 실행하려면 환경에 DbService가 필요하다"를 기록한다.
Layer — 환경 만들기
`Layer`는 서비스의 구현을 묶어 환경으로 빚는다.
const DbLive = Layer.succeed(DbService, {
getUser: (id) => /* 실제 구현 */,
saveReceipt: (c) => /* 실제 구현 */,
})
const DbTest = Layer.succeed(DbService, {
getUser: (id) => Effect.succeed(testUser),
saveReceipt: () => Effect.void,
})
const program = fetchUser("u1").pipe(Effect.provide(DbLive))
// ▲
// 테스트에선 DbTest를 끼우면 끝
여러 Layer는 `Layer.merge`/`Layer.provide`로 조합한다. 서비스 간 의존성도 타입으로 잡힌다 — `DbService`가 `Config`를 필요로 하면, `Config`를 안 끼운 Layer는 컴파일 에러다.
이건 **DI 컨테이너가 아니다.** 런타임 매직이 없다. 그저 함수 인자와 클로저의 변형일 뿐인데, 타입으로 환경을 추적하므로 컨테이너의 진단 가치를 얻으면서 동적 디스패치 비용은 없다.
5장 · 리소스 관리 — Scope, acquireRelease, using
자원이 새는 코드의 패턴은 익숙하다. 파일을 열고, 가운데서 예외가 나고, 닫는 코드가 안 도는 것.
// Promise — 까딱하면 누수
async function processFile() {
const f = await openFile(path)
const data = await parse(f) // 실패하면?
await closeFile(f) // 도달 못함
}
`finally`로 막을 수 있지만, 여러 자원이 얽히고 동시성이 끼면 손이 모자란다. Effect는 **Scope**라는 추상으로 이 문제를 풀었다.
acquireRelease
const file = Effect.acquireRelease(
openFile(path), // 획득
(f) => Effect.promise(() => f.close()) // 해제 (반드시 실행)
)
const program = Effect.gen(function* () {
const f = yield* Effect.scoped(file) // scope 안에서 라이프 사이클 관리
return yield* parse(f)
})
`Effect.scoped`는 블록을 만든다 — 블록을 벗어나면 **성공·실패·취소 어느 경로로든** 해제가 보장된다. 여러 자원을 같은 scope에 등록하면 **역순으로** 안전하게 풀린다.
using — TC39 명시적 자원 관리와의 연결
ECMAScript의 `using` 선언(Stage 3)은 같은 아이디어를 언어 레벨로 끌어왔다. Effect는 이미 그 의미론을 라이브러리 안에서 일관되게 구현해왔다 — `using`이 깔리면 둘 다 자연스럽게 통합된다.
리소스 안전성은 **분산 시스템에서 누수의 1순위 원인**이다. Effect가 이 자리에서 빛난다.
6장 · Effect Schema — 런타임과 컴파일타임을 한 정의로
타입스크립트의 한계: 컴파일 시점의 안전성이 런타임 데이터(JSON, env, query params)까지 미치지 못한다. zod가 이 자리를 차지해왔고, 2026년에는 또 다른 강자가 있다 — **Effect Schema** (`effect/Schema`).
const User = S.Struct({
id: S.String,
email: S.String.pipe(S.pattern(/^[^@]+@[^@]+$/)),
age: S.Number.pipe(S.between(0, 150)),
role: S.Literal("admin", "user", "guest"),
})
type User = S.Schema.Type<typeof User> // 타입 추론
const decoded = S.decodeUnknownEither(User)(jsonBlob)
// ▲ Either<User, ParseError>
zod 대비 차별점:
- **양방향(Bidirectional)** — encode와 decode가 정의로부터 동시에 나온다. Date 같은 비-JSON 타입을 자연스럽게 다룬다.
- **이펙트 통합** — `S.decode(schema)(data)`가 `Effect<A, ParseError>`로 떨어진다. 검증을 다른 이펙트와 그대로 합성한다.
- **변환(Transformations)** — `S.transform`으로 "문자열을 Date로", "트림+소문자" 같은 변환을 스키마 자체에 박아둔다.
- **Branded types** — `S.brand`로 명목 타입을 만든다. `UserId`와 `OrderId`를 동일 string에서 구분.
변환 한 예
const ISODate = S.Date.pipe(S.transform(
S.String,
{ decode: (s) => new Date(s), encode: (d) => d.toISOString() }
))
API 경계에서 한 번만 데이터를 검증/변환하고, 내부에서는 정확한 타입으로만 다루는 코드를 짜기가 쉬워진다.
7장 · Effect Stream — push·pull을 한 모델로
대량 데이터, 백프레셔, 무한 스트림 — Promise/AsyncIterator로는 다루기 까다로운 자리다. Effect Stream은 **pull 기반**으로 설계됐지만 push도 자연스럽게 흡수한다.
const numbers = Stream.range(1, 1_000_000)
const program = numbers.pipe(
Stream.map((n) => n * 2),
Stream.filter((n) => n % 3 === 0),
Stream.take(100),
Stream.run(Sink.collectAll),
)
Stream은 게으르다. 끝의 `Stream.run`이 흐름을 시작한다. 중간 변환은 backpressure로 자연스레 조절된다 — 다운스트림이 빠지지 못하면 업스트림이 멈춘다.
흔한 패턴
- `Stream.fromAsyncIterable` — AsyncIterator를 그대로 흡수.
- `Stream.fromQueue` — push 소스(웹훅, 이벤트)에서 받아 처리.
- `Stream.throttle` — 주기적 제어.
- `Stream.broadcast` — 한 소스를 N개 consumer에.
- `Stream.fromSchedule` — 시간 기반 트리거.
Node Streams의 백프레셔 모델이 까다로운 이유는 누가 멈출지 명시적이지 않아서다. Effect Stream은 그 결정을 **타입과 의미론**으로 라이브러리에 내장했다.
8장 · Fiber와 구조적 동시성
Effect의 동시성 기본 단위는 **Fiber**다. 가볍고, 협력적(co-operative)이며, 취소가 전파된다. Promise/Worker가 아니라 — 그린 스레드에 가깝다.
const a = Effect.delay(longFetch(...), "100 millis")
const b = longCompute(...)
// 동시 실행, 둘 다 끝나면 합친다
const both = Effect.all([a, b], { concurrency: 2 })
// 먼저 끝나는 쪽 사용 — 나머지 fiber는 자동 cancel
const fastest = Effect.race(a, b)
구조적 동시성이 의미하는 것
핵심: **부모의 수명을 자식이 못 넘는다.** `race`로 a가 이기면 b는 즉시 인터럽트된다. acquireRelease로 잡은 자원은 인터럽트 시점에 풀린다. 부모 fiber가 죽으면 자식 fiber도 다 죽는다. "어디선가 백그라운드 작업이 살아 남아서 자원을 점유하는" 시나리오가 안 일어난다.
취소 전파
const stoppable = Effect.gen(function* () {
yield* Effect.log("작업 시작")
yield* Effect.sleep("10 seconds")
yield* Effect.log("완료 — 이 줄은 절대 안 보일 것")
})
const fiber = yield* Effect.fork(stoppable)
yield* Effect.sleep("1 second")
yield* Fiber.interrupt(fiber) // 즉시 취소, 자원 정리 보장
Promise에서는 취소가 일등 시민이 아니다 — `AbortSignal`로 부분 흉내를 내지만 라이브러리마다 들쭉날쭉하다. Effect에서는 모든 이펙트가 인터럽트 가능하고, scope가 정리를 보장한다.
9장 · 인터옵 — Promise·Callback·AbortSignal과 살기
현실의 코드는 Promise·콜백·라이브러리 API와 섞인다. Effect는 그 경계를 깔끔하게 다룬다.
Promise → Effect
const fetchUser = Effect.tryPromise({
try: () => fetch(`/api/user/${id}`).then(r => r.json()),
catch: (e) => new FetchError({ cause: e }),
})
`Effect.promise`는 절대 실패 안 하는 promise용, `Effect.tryPromise`는 실패를 도메인 에러로 매핑한다.
Callback → Effect
const readFile = (path: string) =>
Effect.async<Buffer, NodeJS.ErrnoException>((resume) => {
fs.readFile(path, (err, data) => {
if (err) resume(Effect.fail(err))
else resume(Effect.succeed(data))
})
})
`Effect.async`는 1회성 콜백을, `Effect.asyncEffect`는 정리(cleanup)도 필요한 콜백을 다룬다.
AbortSignal 다리
const withAbort = (signal: AbortSignal) =>
Effect.async<Data, FetchError>((resume) => {
const c = new AbortController()
signal.addEventListener("abort", () => c.abort())
fetch(url, { signal: c.signal })
.then(r => resume(Effect.succeed(r)))
.catch(e => resume(Effect.fail(new FetchError({ cause: e }))))
})
Effect → Promise는 `Effect.runPromise` 한 줄로 끝난다. 즉, **Effect는 인터옵에서 절대 격리된 섬이 아니다.** 가장자리는 Promise/콜백 세계와 다리를 놓고, 안쪽은 Effect로만 유지한다.
10장 · fp-ts에서 Effect-TS로 — 합병의 의미
Effect-TS의 역사는 TypeScript 함수형 진영의 작은 사건이다.
- **2017~** fp-ts (Giulio Canti) — Haskell/PureScript의 타입 클래스를 TypeScript로 옮긴 라이브러리. ADT, 모나드, 렌즈까지. 학구적이지만 사랑받았다.
- **2020~** Effect-TS (Michael Arnaldi 등) — ZIO의 영향을 강하게 받은 새 접근. 단일 `Effect` 타입 중심, 실제 production 지향.
- **2023** Giulio Canti가 Effect-TS 팀에 합류. fp-ts는 사실상 **maintenance mode**로. Effect-TS가 사실상의 후계자로 자리매김.
- **2024** Effect 3.0. API 안정화. 패키지 구조 정리(`effect` 단일 패키지로 통합).
- **2026** 3.x 안정. Schema·Stream·Match·Worker·CLI 등 동심원 패키지 성숙.
fp-ts가 못 쓰게 된 건 아니다 — 동작한다. 하지만 새 코드라면 Effect-TS가 분명한 선택지다. 같은 사람들이 그쪽에서 일하고, 도구·문서·도입 사례·생태계가 그쪽에 모인다.
실제 도입 사례
- **Bun** — 일부 도구체인 내부에 Effect를 채택 (관찰 가능: 공개 코드의 사용).
- **Effect Studio**, **Disney Streaming**, **Vercel**의 일부 팀에서 사용 보고(컨퍼런스 토크 기준).
- 다수 OSS — **Schema**를 zod 대안으로 쓰는 라이브러리, Effect 위에 빌드된 백엔드 프레임워크.
규모 있는 TypeScript 백엔드에서 "관리 가능한 복잡도"가 중요해지면, Effect는 자주 등장한다.
11장 · 비교 — Promise·Result·ZIO·Rust Result
Effect만이 답은 아니다. 같은 자리에 다른 도구들이 있다.
Plain Promise
- **장점**: 표준, 모두가 안다, 작은 코드에 무리 없다.
- **단점**: 에러 타입이 `unknown`, 환경/취소가 일등 시민이 아님.
- **언제**: 단일 함수·단일 모듈·짧은 스크립트·라이브러리 가장자리.
Result/Either (neverthrow, ts-belt, fp-ts Either)
- **장점**: 에러 타입을 잡는다, 가벼움.
- **단점**: 동시성·취소·DI는 별도 도구 필요.
- **언제**: 에러만 명시적으로 추적하고 싶을 때. Effect 도입 전 단계로도.
ZIO (Scala)
- **장점**: Effect-TS의 영적 형제. 더 오래된 생태계.
- **언어 차이**: Scala에서만 작동. TypeScript에서는 Effect가 사실상 ZIO.
Rust Result + lifetimes
- **장점**: 에러·자원·소유권을 컴파일러가 통째로 강제.
- **언어 차이**: 다른 패러다임. JS 인프라와는 거리.
F# 컴퓨테이션 익스프레션 / Haskell IO
- **장점**: 더 깊은 타입 시스템, 일관된 이펙트 추적.
- **언어 차이**: 같은 자리. Effect-TS는 그 정신을 TS에 옮긴 것.
한 줄 요약
| 사용 시나리오 | 추천 |
| --- | --- |
| 짧은 스크립트, 단일 모듈 | Plain Promise |
| 에러를 좀 더 잡고 싶음 | Result 라이브러리 |
| 큰 백엔드, 리팩터 안전성·DI·동시성 일관성 | **Effect-TS** |
| Scala 생태계 | ZIO |
| 시스템 프로그래밍 | Rust Result + lifetimes |
12장 · 언제 Effect가 옳고 언제 과한가
Effect는 **무료가 아니다.** 학습 곡선, 런타임 오버헤드, 팀 동의 — 모두 비용이다.
Effect가 옳은 경우
- 백엔드가 **중대형** 이상이고 라이프타임이 **수 년** 단위.
- **리팩터 안전성**이 비즈니스 가치다 (결제, 자료 무결성, 인앱 구매).
- **테스트 가능성**이 중요 — DI/Layer가 단위 테스트를 단순화한다.
- 자원 관리(파일, 커넥션, 락)가 복잡하다.
- **동시성·취소**가 자주 등장한다.
- 팀이 함수형/이펙트 패러다임에 익숙하거나 학습 의지가 있다.
과한 경우 (Don't)
- **CLI 한 개**, **간단한 스크립트**, **프로토타입**.
- 팀이 다 신참이고, 도메인이 안정되지 않은 작은 스타트업.
- React 컴포넌트 내부에서만 쓰는 **UI 로직** (UI 가장자리에는 적합하지만 일관 도입은 부담).
- 라이브러리 저자 — 사용자에게 Effect 의존성을 강요하기 어렵다.
> **신호**: "코드가 점점 try/catch와 클로저 의존성으로 누더기가 되고, 리팩터 한 번에 일주일이 사라진다" — Effect의 자리다. "200줄짜리 스크립트인데 분당 한 번 도는 cron이다" — 그냥 async/await로 됐다.
13장 · 도입 전략 — 한 번에 갈아엎지 마라
Effect 도입의 가장 흔한 실수: **모든 코드를 한 번에 옮기려 든다.** 그러면 PR이 1만 줄짜리가 되고, 리뷰가 막히고, 팀은 화낸다.
단계적 도입
1. **가장자리부터** — 한 모듈/서비스에서 시작. 외부 경계는 Promise를 유지하고, 안쪽만 Effect.
2. **Schema부터** — DI/Stream 없이 Effect Schema만 도입해도 검증 코드가 깔끔해진다.
3. **Result 단계 거치기** — 먼저 `Either`/`Result`로 에러만 잡고, 익숙해지면 Effect로.
4. **Layer 도입** — 핵심 서비스부터 Layer로 추출. 테스트 더블이 따라온다.
5. **새 코드만** — 기존 코드는 그대로, 새 모듈은 Effect로.
팀 학습
- 처음 2주: **Effect 타입 읽기**만 익숙해진다.
- 그다음 2주: `Effect.gen` + 기본 연산.
- 그다음: 에러 분류, Layer, Scope.
- 마지막: Stream, Fiber.
함수형 라이브러리를 처음 도입하면 사람들이 "이건 마법 같다"고 한다. Effect는 그 마법을 줄이고 **읽기 쉬운 모양**을 많이 갖췄지만, 그래도 패러다임 전환이다.
14장 · 짧은 미니 예제 — 결제 파이프라인
처음 본 결제 코드를 Effect로 다시 써보자.
class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{ code: number }> {}
class DbService extends Context.Tag("DbService")<DbService, {
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
readonly saveReceipt: (c: Charge) => Effect.Effect<void>
}>() {}
class StripeService extends Context.Tag("StripeService")<StripeService, {
readonly getDefaultCard: (id: string) => Effect.Effect<Card>
readonly charge: (card: Card, amt: number) => Effect.Effect<Charge, PaymentDeclined>
}>() {}
class MailerService extends Context.Tag("MailerService")<MailerService, {
readonly sendReceipt: (to: string, c: Charge) => Effect.Effect<void>
}>() {}
const chargeCustomer = (userId: string, amount: number) =>
Effect.gen(function* () {
const db = yield* DbService
const stripe = yield* StripeService
const mailer = yield* MailerService
const user = yield* db.getUser(userId)
const card = yield* stripe.getDefaultCard(user.stripeId)
const charge = yield* stripe.charge(card, amount)
yield* db.saveReceipt(charge)
yield* mailer.sendReceipt(user.email, charge)
return toReceipt(charge)
})
// 추론된 시그니처:
// Effect<Receipt,
// UserNotFound | PaymentDeclined,
// DbService | StripeService | MailerService>
읽는 사람은 시그니처만 봐도 안다:
- 무엇을 돌려주는지: `Receipt`.
- 무엇이 실패할 수 있는지: `UserNotFound`, `PaymentDeclined` — 그것이 전부.
- 무엇이 필요한지: `DbService`, `StripeService`, `MailerService`.
테스트는 세 서비스의 Mock Layer를 끼우면 된다. 새 에러가 생기면 컴파일러가 비춘다. 새 의존성이 생기면 시그니처가 변한다. 이게 이펙트 추적의 가치다.
에필로그 — 이펙트는 타입을 더 말하게 만든다
Effect-TS의 한 줄 요약: **타입이 더 많이 말한다.** 함수가 무엇을 돌려주는지뿐 아니라, 무엇이 실패할 수 있는지, 무엇이 필요한지, 어떻게 취소되는지가 시그니처 안에 새겨진다.
`async/await`가 콜백을 대체한 것처럼, Effect는 `async/await`를 대체하려는 게 아니다 — 그 **위에** 더 풍부한 의미론을 얹는다. 시간이 지나면 `using` 같은 ECMAScript 표준이 그 의미론의 일부를 언어 레벨로 가져온다. Effect는 그 길의 앞쪽을 라이브러리로 빨리 보여주는 존재다.
큰 백엔드의 5년 차에 "이 함수가 뭘 던지지, 뭘 필요로 하지"를 알기 위해 코드를 처음부터 다 읽어야 한다면 — 거기가 Effect의 자리다. 작은 스크립트라면 — 굳이.
12개 항목 체크리스트
1. 도메인 에러를 `Data.TaggedError`로 정의했는가?
2. 함수 시그니처에 에러 유니온이 정직하게 드러나는가?
3. `try/catch`로 도메인 실패와 defect를 섞어 잡고 있지 않는가?
4. 서비스를 `Context.Tag` + `Layer`로 분리했는가?
5. 테스트용 Layer가 운영용과 분리되어 있는가?
6. 자원 획득은 `acquireRelease`/`scoped`로 감싸는가?
7. 동시 처리에 `Effect.all`의 동시성 옵션을 설정했는가?
8. `Effect.race`로 묶은 후 패배자가 정리되는지 확인했는가?
9. 외부 데이터는 `Effect Schema`로 한 번만 검증하는가?
10. Stream은 `Stream.run` 전까지 게으르게 유지하는가?
11. Promise/콜백 경계에서 `tryPromise`/`async`로 매핑하는가?
12. 도입 전략이 단계적인가 — 한 번에 갈아엎지 않는가?
안티패턴 10가지
1. 도메인 실패를 `Error` 한 종류로 뭉뚱그리기.
2. `try/catch`로 defect까지 삼키기 — 진짜 버그를 가린다.
3. DI 없이 모듈 톱레벨에서 클라이언트를 import 하기 — 테스트 막힘.
4. `acquireRelease` 없이 파일/커넥션을 그냥 열기.
5. `Effect.all`의 동시성 한도를 빼먹어 N×요청 폭주.
6. `Effect.race`로 묶고 패배자 정리를 안 한다고 착각하기.
7. Stream을 게으름 없이 즉시 실행으로 쓰기.
8. 큰 코드베이스를 한 PR에 다 갈아엎으려 하기.
9. 라이브러리 저자가 사용자에게 Effect 의존을 강요.
10. 200줄짜리 CLI에 Effect를 도입하고 학습 비용을 정당화하기.
다음 글 예고
다음 글 후보: **Effect Schema 심층 — zod와의 차이, 변환, 브랜드 타입**, **Effect Stream으로 백프레셔 파이프라인 짜기**, **Effect Layer로 마이크로서비스 부트스트랩 — 환경·설정·로깅·트레이싱**.
> "타입이 더 많이 말하면, 리팩터는 덜 두렵다. Effect는 그 약속이다."
— Effect-TS 심층, 끝.
참고 / References
- [Effect — Official site](https://effect.website/)
- [Effect Documentation](https://effect.website/docs)
- [Effect GitHub — Effect-TS/effect](https://github.com/Effect-TS/effect)
- [Effect Schema — Documentation](https://effect.website/docs/schema/introduction)
- [Effect Stream — Documentation](https://effect.website/docs/stream/introduction)
- [Effect — Why Effect](https://effect.website/docs/why-effect)
- [fp-ts — Original library](https://gcanti.github.io/fp-ts/)
- [fp-ts GitHub — gcanti/fp-ts](https://github.com/gcanti/fp-ts)
- [Effect 3.0 announcement](https://effect.website/blog/releases/effect/3.0)
- [Giulio Canti joins Effect — announcement](https://dev.to/effect/joining-the-effect-team-1cnk)
- [ZIO — Scala effect system](https://zio.dev/)
- [neverthrow — Result for TypeScript](https://github.com/supermacro/neverthrow)
- [ts-belt — Functional helpers](https://mobily.github.io/ts-belt/)
- [zod — schema validation](https://zod.dev/)
- [TC39 Explicit Resource Management proposal](https://github.com/tc39/proposal-explicit-resource-management)
- [Effect Days conference](https://effect.website/events)
- [Bun runtime](https://bun.sh/)
- [Vercel engineering blog](https://vercel.com/blog)
- [Michael Arnaldi — Effect talks](https://www.youtube.com/results?search_query=michael+arnaldi+effect)
- [ReScript Result and effect community](https://rescript-lang.org/)
- [Haskell IO monad](https://wiki.haskell.org/IO_inside)
- [Rust ? operator and Result](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html)
- [F# Computation Expressions](https://learn.microsoft.com/dotnet/fsharp/language-reference/computation-expressions)
- [Effect Schema vs zod — community discussion](https://effect.website/docs/schema/introduction)
- [Structured concurrency — Wikipedia](https://en.wikipedia.org/wiki/Structured_concurrency)
현재 단락 (1/364)
다음 코드를 보자. TypeScript로 흔히 짜는 모양이다.