프롤로그 — 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가 다섯 번. 잘 동작한다 — 해피 패스에서는. 그런데 이 함수의 시그니처가 우리에게 말해주는 것은 단 두 가지뿐이다.
Promise를 돌려준다.- 성공하면
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 같은 모양으로 이펙트를 시퀀싱한다.
import { Effect } from "effect"
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 한 줄이 진짜 버그를 삼킨다.
도메인 에러 정의
import { Data } from "effect"
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는 다른 프레임워크의 컨테이너가 아니라 타입으로 표현되는 환경이다.
import { Context, Effect, Layer } from "effect"
// 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).
import { Schema as S } from "effect"
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도 자연스럽게 흡수한다.
import { Stream } from "effect"
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만 줄짜리가 되고, 리뷰가 막히고, 팀은 화낸다.
단계적 도입
- 가장자리부터 — 한 모듈/서비스에서 시작. 외부 경계는 Promise를 유지하고, 안쪽만 Effect.
- Schema부터 — DI/Stream 없이 Effect Schema만 도입해도 검증 코드가 깔끔해진다.
- Result 단계 거치기 — 먼저
Either/Result로 에러만 잡고, 익숙해지면 Effect로. - Layer 도입 — 핵심 서비스부터 Layer로 추출. 테스트 더블이 따라온다.
- 새 코드만 — 기존 코드는 그대로, 새 모듈은 Effect로.
팀 학습
- 처음 2주: Effect 타입 읽기만 익숙해진다.
- 그다음 2주:
Effect.gen+ 기본 연산. - 그다음: 에러 분류, Layer, Scope.
- 마지막: Stream, Fiber.
함수형 라이브러리를 처음 도입하면 사람들이 "이건 마법 같다"고 한다. Effect는 그 마법을 줄이고 읽기 쉬운 모양을 많이 갖췄지만, 그래도 패러다임 전환이다.
14장 · 짧은 미니 예제 — 결제 파이프라인
처음 본 결제 코드를 Effect로 다시 써보자.
import { Effect, Layer, Data, Context, Schema as S } from "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개 항목 체크리스트
- 도메인 에러를
Data.TaggedError로 정의했는가? - 함수 시그니처에 에러 유니온이 정직하게 드러나는가?
try/catch로 도메인 실패와 defect를 섞어 잡고 있지 않는가?- 서비스를
Context.Tag+Layer로 분리했는가? - 테스트용 Layer가 운영용과 분리되어 있는가?
- 자원 획득은
acquireRelease/scoped로 감싸는가? - 동시 처리에
Effect.all의 동시성 옵션을 설정했는가? Effect.race로 묶은 후 패배자가 정리되는지 확인했는가?- 외부 데이터는
Effect Schema로 한 번만 검증하는가? - Stream은
Stream.run전까지 게으르게 유지하는가? - Promise/콜백 경계에서
tryPromise/async로 매핑하는가? - 도입 전략이 단계적인가 — 한 번에 갈아엎지 않는가?
안티패턴 10가지
- 도메인 실패를
Error한 종류로 뭉뚱그리기. try/catch로 defect까지 삼키기 — 진짜 버그를 가린다.- DI 없이 모듈 톱레벨에서 클라이언트를 import 하기 — 테스트 막힘.
acquireRelease없이 파일/커넥션을 그냥 열기.Effect.all의 동시성 한도를 빼먹어 N×요청 폭주.Effect.race로 묶고 패배자 정리를 안 한다고 착각하기.- Stream을 게으름 없이 즉시 실행으로 쓰기.
- 큰 코드베이스를 한 PR에 다 갈아엎으려 하기.
- 라이브러리 저자가 사용자에게 Effect 의존을 강요.
- 200줄짜리 CLI에 Effect를 도입하고 학습 비용을 정당화하기.
다음 글 예고
다음 글 후보: Effect Schema 심층 — zod와의 차이, 변환, 브랜드 타입, Effect Stream으로 백프레셔 파이프라인 짜기, Effect Layer로 마이크로서비스 부트스트랩 — 환경·설정·로깅·트레이싱.
"타입이 더 많이 말하면, 리팩터는 덜 두렵다. Effect는 그 약속이다."
— Effect-TS 심층, 끝.
참고 / References
- Effect — Official site
- Effect Documentation
- Effect GitHub — Effect-TS/effect
- Effect Schema — Documentation
- Effect Stream — Documentation
- Effect — Why Effect
- fp-ts — Original library
- fp-ts GitHub — gcanti/fp-ts
- Effect 3.0 announcement
- Giulio Canti joins Effect — announcement
- ZIO — Scala effect system
- neverthrow — Result for TypeScript
- ts-belt — Functional helpers
- zod — schema validation
- TC39 Explicit Resource Management proposal
- Effect Days conference
- Bun runtime
- Vercel engineering blog
- Michael Arnaldi — Effect talks
- ReScript Result and effect community
- Haskell IO monad
- Rust ? operator and Result
- F# Computation Expressions
- Effect Schema vs zod — community discussion
- Structured concurrency — Wikipedia
현재 단락 (1/364)
다음 코드를 보자. TypeScript로 흔히 짜는 모양이다.