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>

세 개의 파라미터가 각각 무엇을 의미하는지가 핵심이다.

자리이름의미
ASuccess이 이펙트가 성공했을 때 돌려주는 값의 타입
EError / Failure이 이펙트가 예측한 실패의 타입(유니온 가능)
RRequirements (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로 조합한다. 서비스 간 의존성도 타입으로 잡힌다 — DbServiceConfig를 필요로 하면, 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 typesS.brand로 명목 타입을 만든다. UserIdOrderId를 동일 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만 줄짜리가 되고, 리뷰가 막히고, 팀은 화낸다.

단계적 도입

  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로 다시 써보자.

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개 항목 체크리스트

  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

현재 단락 (1/364)

다음 코드를 보자. TypeScript로 흔히 짜는 모양이다.

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