Skip to content

필사 모드: Effect-TS 심층 — TypeScript에서 이펙트 시스템·DI·리소스·동시성을 한 라이브러리로 푸는 법

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

프롤로그 — 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로 흔히 짜는 모양이다.

작성 글자: 0원문 글자: 16,302작성 단락: 0/364