Effect-TS 개론

2026.08.19

Parse, don't validate 에서 Effect 생태계까지

TL;DR

Promise<T> 는 성공 채널만 타입에 있고 실패 채널이 없다. Effect 는 성공·실패·의존성을 전부 타입에 새긴다. 그 사상의 뿌리는 "검증(validate)이 아니라 파싱(parse)하라"에 있다.

zod 로 충분하다고 느끼고 있었는데, Parse, don't validate 계열의 글을 읽고 Effect-TS 생태계를 알게 되면서 사고방식 자체가 확장됐다. zod 도 사실 이 사상의 산물이라는 걸 뒤늦게 이해했다. 이 노트는 그 궤적과 실전 감각을 정리한 것.


1. 사상의 궤적

King 의 원칙 → fp-ts 학술적 구현 → zod 실용적 축약 → Effect 전방위 확장

① Haskell 진영의 원전 (2019)

Alexis King, Parse, don't validate. 핵심: 검증은 결과를 버리고, 파싱은 결과를 타입에 새긴다.

validate: T → boolean        // 정보 손실 (검사했다는 사실이 타입에 안 남음)
parse:    Raw → Parsed<T>    // 정보를 타입으로 승격

파싱한 순간 그 사실이 타입에 새겨지므로, 함수 시그니처만 보면 "이 값은 이미 검증됐다"가 컴파일러에 의해 보증된다. if (user.email) 같은 방어적 검사를 반복할 필요가 없어진다.

shotgun parsing King 이 지적한 안티패턴. 검증이 여기저기 흩어져 있고 그중 어느 것도 타입에 기억되지 않는 상태. 서로 다른 세 파일에서 같은 걸 검사하는 세 번째 방어적 if 를 추가하고 있다면, 파싱했어야 할 곳에서 검증한 것이다.

② fp-ts 시대 (2017~)

Giulio Canti 의 fp-ts / io-ts 가 TS 진영에 Either, Option, Task 같은 ADT 를 본격 도입. io-ts 의 Codec 은 사실상 "파서이자 타입 정의". 다만 pipe, chain, Kleisli 같은 학습 곡선 때문에 대중화엔 한계.

③ zod 의 실용주의 (2020~)

Colin McDonnell 의 zod 는 fp-ts/io-ts 의 사고방식을 FP 용어 없이 풀어낸 것.

  • safeParse 가 반환하는 { success, data } | { success, error } = 사실상 Either 의 변장
  • .brand() = King 이 말한 phantom type 그대로

zod 가 폭발적으로 퍼진 이유: parse, don't validate 의 정수만 가져오고 FP 개념은 숨겼기 때문.

④ Effect-TS 의 야망 (2023~)

fp-ts 핵심 기여자들이 fp-ts 를 흡수하며 만든 것. Scala 의 ZIO 에서 영감. 파싱뿐 아니라 비동기·에러·의존성 주입·동시성·리소스 관리·옵저버빌리티까지 전부 타입 시스템에 올리는 걸 목표로 한다.

생태계 규모 (2026-08 기준, GitHub Effect-TS/effect):

  • ⭐ 약 14.3k, 릴리스 1만 건 이상
  • @effect/sql, @effect/rpc, @effect/opentelemetry, @effect/cluster, @effect/workflow, @effect/ai 등 생태계 자체를 지향

2. zod 로 "충분한" 영역과 "부족한" 영역

대부분의 프론트엔드 시나리오에서는 zod 가 정답이다. 하지만 경계가 있다.

zod 가 잘 다루는 것zod 가 잘 못 다루는 것
외부 입력(API 응답, form, URL params) 파싱파싱 이후 비즈니스 로직 에러를 타입에 새기기
스키마 → 타입 추론 (z.infer)비동기 작업의 실패 모드를 타입으로 추적
브랜딩으로 "검증된 값" 타입 분리의존성(서비스·로거·DB)을 타입에 새기기
재시도·타임아웃·동시성을 합성 가능하게 표현

우측 열이 필요해지는 순간 Effect 가 등장한다.


3. Effect 의 핵심: 세 채널을 가진 함수

가장 중요한 단일 개념은 Effect<A, E, R>.

  • A — 성공 시 반환 타입
  • E — 실패 시 에러 타입 (열거된 union)
  • R — 필요한 의존성 타입
// Promise: 에러가 타입에 없음. 무엇이 throw되는지 시그니처만 봐선 모름
async function fetchUser(id: string): Promise<User>

// Effect: 에러도, 의존성도 시그니처에 노출
const fetchUser: (id: string) =>
  Effect<User, NotFoundError | NetworkError, HttpClient>

"Promise 는 약속이 되지 못한다" Promise<Reel> 은 "Reel 을 줄 수도 있고, 무언가가 throw 될 수도 있다"인데 그 "무언가"가 타입에 없다. catch 가 unknown 인 게 그 증거. 이는 King 이 말한 "검증기의 실패 모드는 예외이고, 예외는 타입 시스템에 보이지 않는다" 와 정확히 같은 문제다. Promise 는 성공 채널만 타입에 있고 실패 채널이 없다. Effect 의 E 채널이 이걸 메운다.


4. 실전 예제 - 스크래퍼 파이프라인

"Instagram 트렌딩 릴 크롤링 → 파싱 → DB 적재 → 실패 시 재시도" 파이프라인.

4-1. zod 로 충분한 케이스 (단순 검증)

import { z } from "zod";

const InstagramReelSchema = z.object({
  id: z.string(),
  shortcode: z.string(),
  viewCount: z.number().int().nonnegative(),
  ownerUsername: z.string(),
  takenAt: z.coerce.date(),
});
type InstagramReel = z.infer<typeof InstagramReelSchema>;

// 이 정도면 Effect 를 끌어올 이유가 없다. zod 충분.
async function parseReelFromRaw(raw: unknown): Promise<InstagramReel> {
  return InstagramReelSchema.parse(raw);
}

4-2. Effect 가 빛나는 케이스 (합성된 파이프라인)

import { Effect, Schedule, Schema, Context, Duration } from "effect";

// ── 1. 스키마 (zod 대체, 같은 사고방식)
const Reel = Schema.Struct({
  id: Schema.String,
  shortcode: Schema.String,
  viewCount: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
  ownerUsername: Schema.String,
  takenAt: Schema.Date,
});
type Reel = Schema.Schema.Type<typeof Reel>;

// ── 2. 에러를 *타입으로* 정의 (zod 엔 없는 부분)
class ScrapeError extends Schema.TaggedError<ScrapeError>()(
  "ScrapeError", { reason: Schema.String }
) {}
class DbError extends Schema.TaggedError<DbError>()(
  "DbError", { code: Schema.String }
) {}

// ── 3. 의존성을 *타입으로* 정의 (NestJS DI 를 타입 레벨에서)
class Browser extends Context.Tag("Browser")<
  Browser, { scrape: (url: string) => Effect.Effect<unknown, ScrapeError> }
>() {}
class Db extends Context.Tag("Db")<
  Db, { insert: (reel: Reel) => Effect.Effect<void, DbError> }
>() {}

// ── 4. 파이프라인 자체 — 시그니처가 모든 걸 말해줌
const collectReel = (url: string) =>
  Effect.gen(function* () {          // ← 순차적 비즈니스 로직 (async/await 자리)
    const browser = yield* Browser;
    const db = yield* Db;

    const raw = yield* browser.scrape(url);
    const reel = yield* Schema.decodeUnknown(Reel)(raw); // ParseError 자동 합성
    yield* db.insert(reel);
    return reel;
  }).pipe(                           // ← Effect 값에 횡단 관심사를 선언적으로 덧붙임
    Effect.retry({
      schedule: Schedule.exponential(Duration.seconds(1)),
      times: 3,
    }),
    Effect.timeout(Duration.seconds(30)),
  );

추론되는 시그니처:

Effect<Reel, ScrapeError | ParseError | DbError | TimeoutException, Browser | Db>

자동 합성이 핵심

  • Schema.decodeUnknownParseError 를, Effect.timeoutTimeoutException 을 자동으로 E 에 합친다.
  • 새 단계를 추가하면 컴파일러가 알아서 E union 을 키운다.
  • 어딘가에서 Effect.catchTag("ScrapeError", ...) 로 처리하면 그 시점부터 E 에서 ScrapeError 가 빠진다. → 에러가 처리됐는지 안 됐는지가 타입으로 추적된다.
  • R = Browser | Db 도 같은 식. Layer 로 의존성을 주입하면 Rnever 로 줄고, Rnever 인 Effect 만 실제로 실행 가능. "필요한 의존성이 다 채워졌다"가 타입으로 강제된다.

4-3. 같은 코드의 Promise/zod 버전 (비교)

async function collectReel(url: string): Promise<Reel> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const raw = await Promise.race([
        browser.scrape(url),
        new Promise<never>((_, rej) =>
          setTimeout(() => rej(new Error("timeout")), 30_000)
        ),
      ]);
      const reel = InstagramReelSchema.parse(raw);
      await db.insert(reel);
      return reel;
    } catch (err) {
      if (attempt === 2) throw err;     // err 의 타입은 unknown
      await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
  throw new Error("unreachable");
}

동작은 같지만:

  • 타입 시그니처 Promise<Reel>거짓말을 한다. 실제로는 5가지 이유로 실패 가능한데 타입엔 없다.
  • 재시도/타임아웃 로직이 비즈니스 로직과 섞여 있다.
  • browser, db 가 외부 변수로 캡처돼 테스트가 까다롭다.

5. Effect.genpipe 의 역할 분리

둘은 다른 일을 한다. 헷갈리기 쉬우니 분리해서 기억.

역할대응하는 익숙한 것
Effect.gen (제너레이터)순차적 비즈니스 로직async/await (단, awaityield*)
pipe (메서드)Effect 값에 변환·횡단 관심사 합성RxJS pipe, lodash _.flow

왜 async 가 아니라 제너레이터일까? async 는 항상 Promise 를 반환하도록 되어있으므로 Effect 를 못 끼워넣는다. 제너레이터는 런타임을 직접 통제할 수 있어서 Effect 가 자체 인터프리터로 실행한다.

핵심 이점: 비즈니스 로직과 운영 로직(retry, timeout, 로깅, DI)이 섞이지 않는다. Promise 버전에서 for 문과 setTimeout 이 비즈니스 로직 안에 들어왔던 것과 대조적.


6. 도입 판단과 순서

솔직한 결론 대부분 zod 로 충분하고, 도입한다면 신중하게.

Effect 가 가치를 발휘하는 시나리오

  • 백엔드 / 데이터 파이프라인 / 워커처럼 합성이 핵심인 코드
  • 에러 종류가 많고 각 에러에 다른 핸들링이 필요한 경우
  • 동시성·재시도·큐·백프레셔 같은 런타임 관심사가 비즈니스 로직만큼 중요한 경우
  • 팀이 함수형 사고방식에 익숙하거나 학습 의지가 있는 경우

Effect 가 과한 시나리오 (대부분의 프론트엔드)

  • React + TanStack Query + zod 로 잘 굴러가는 경우
  • 팀원이 1~2명이라 학습 곡선을 감당할 여유가 없을 때
  • Promise/async-await 가 충분히 표현력 있는 도메인

NestJS 와의 충돌 NestJS 는 클래스 데코레이터 기반 DI, Effect 는 타입 레벨 Context 기반 DI. 둘을 섞으면 어느 쪽도 깔끔하지 않다. Effect 를 진지하게 도입한다면 NestJS 를 빼고 @effect/platform + @effect/sql 로 가는 게 일관적. 큰 결정이므로 사이드 프로젝트에서 먼저 감을 잡을 것.

점진적 도입 4단계

각 단계에서 멈춰도 그 시점까지의 가치는 확보된다.

  1. Schema 만 zod 대체effectSchema 만 써보기. 사고방식은 같고 API 모양만 다름. "Effect 글쓰기 스타일"(pipe, Schema 합성)에 익숙해지는 게 목표.
  2. Effect 를 Promise 자리에 — 작은 함수 하나를 asyncEffect.gen 으로. Effect.runPromise 로 기존 Promise 코드와 경계에서 호환. 점진적 도입 가능.
  3. Layer 로 DI — 의존성(DB·HTTP 클라이언트)을 Context.Tag + Layer 로 분리. 여기서부터 진짜 Effect 스러워진다. NestJS DI 경험이 오히려 직관에 도움.
  4. 운영 관심사retry, timeout, Schedule, Fiber(동시성), @effect/opentelemetry(관측). OpenTelemetry 통합이 사실상 공짜로 따라온다.

7. 더 읽을거리