Effect-TS 개론
2026.08.19
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.decodeUnknown이ParseError를,Effect.timeout이TimeoutException을 자동으로E에 합친다.- 새 단계를 추가하면 컴파일러가 알아서
Eunion 을 키운다.- 어딘가에서
Effect.catchTag("ScrapeError", ...)로 처리하면 그 시점부터E에서ScrapeError가 빠진다. → 에러가 처리됐는지 안 됐는지가 타입으로 추적된다.R = Browser | Db도 같은 식.Layer로 의존성을 주입하면R이never로 줄고,R이never인 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.gen 과 pipe 의 역할 분리
둘은 다른 일을 한다. 헷갈리기 쉬우니 분리해서 기억.
| 역할 | 대응하는 익숙한 것 | |
|---|---|---|
Effect.gen (제너레이터) | 순차적 비즈니스 로직 | async/await (단, await → yield*) |
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단계
각 단계에서 멈춰도 그 시점까지의 가치는 확보된다.
- Schema 만 zod 대체 —
effect의Schema만 써보기. 사고방식은 같고 API 모양만 다름. "Effect 글쓰기 스타일"(pipe, Schema 합성)에 익숙해지는 게 목표. - Effect 를 Promise 자리에 — 작은 함수 하나를
async→Effect.gen으로.Effect.runPromise로 기존 Promise 코드와 경계에서 호환. 점진적 도입 가능. - Layer 로 DI — 의존성(DB·HTTP 클라이언트)을
Context.Tag+Layer로 분리. 여기서부터 진짜 Effect 스러워진다. NestJS DI 경험이 오히려 직관에 도움. - 운영 관심사 —
retry,timeout,Schedule,Fiber(동시성),@effect/opentelemetry(관측). OpenTelemetry 통합이 사실상 공짜로 따라온다.