Effect 완벽 가이드 — TypeScript 함수형 프로그래밍·에러·DI·스트림·동시성
이 글의 핵심
Effect는 TypeScript에서 부수 효과·실패·의존성을 타입으로 명시하고, Layer로 DI하며, Stream·Fiber·Schedule로 동시성을 다루는 함수형 런타임입니다. 이 글은 핵심 개념부터 실전 유스케이스·테스트까지 한 흐름으로 연결합니다.
이 글의 핵심
Effect는 TypeScript 생태계에서 부수 효과(side effect), 실패, 실행에 필요한 의존성(environment) 을 하나의 타입 Effect<A, E, R>로 표현하고, 이를 합성(composition)·해석(interpretation) 하기 위한 런타임과 라이브러리 묶음입니다. Promise가 “성공 또는 거부” 중심이라면, Effect는 성공 타입 A, 실패 타입 E, 요구 사항 R 을 동시에 드러내어 경계에서의 계약을 명확히 합니다.
이 글에서는 다음을 실무 관점에서 연결합니다.
- 핵심 개념:
Effect의 의미, 불변 프로그램 설명(immutable program description)과 인터프리터 - 에러 처리: 단순 예외가 아닌 원인(cause)·태그 에러·복구·매핑
- 의존성 주입:
Context.Tag,Layer, 프로덕션/테스트 조합 - 스트림·동시성:
Stream,Fiber,Schedule, 자원 경쟁 제어 - 테스트: Layer 기반 목, 결정적 시간·스케줄, 경계 테스트
- 실전 비즈니스 로직: 주문/결제에 가까운 유스케이스 스케치
참고: Effect 생태계는 버전에 따라 API 이름·패키지 구성이 달라질 수 있습니다. 운영 전 공식 문서와 프로젝트의
effect패키지 버전을 함께 확인하시기 바랍니다.
1. Effect의 핵심 개념
1-1. Effect는 “값”이 아니라 “프로그램 설명”이다
일반적인 async 함수는 호출 즉시 작업이 시작되는 경우가 많습니다. 반면 Effect 스타일에서는 Effect 값 자체는 실행 설명서에 가깝고, Effect.runPromise 같은 실행 함수를 통해서만 실제 부수 효과가 일어납니다(코드베이스·설정에 따라 실행 엔트리는 달라질 수 있음). 이 구분은 테스트에서 동일한 설명을 다른 환경으로 해석하기 쉽게 만듭니다.
- 합성 우선: 작은
Effect를map/flatMap/Effect.gen으로 합쳐 큰 유스케이스를 만듭니다. - 타입으로 계약:
E가 곧 실패 가능 집합의 상한,R이 필요한 서비스 집합입니다. - 디버깅: 실패가 누적되면
Cause로 연쇄 실패를 보존할 수 있습니다.
1-2. 삼중 제네릭 Effect<A, E, R> 읽는 법
| 매개변수 | 의미 |
|---|---|
A | 성공 시 결과(값) |
E | 실패 시 도메인/시스템 오류(보통 유니온) |
R | 실행에 필요한 요구 사항(서비스·설정 등) |
R은 “이 Effect를 실행하려면 런타임에 무엇이 제공되어야 하는가?”입니다. never에 가까우면 추가 의존성 없음을 뜻하는 패턴으로 자주 등장합니다.
1-3. pipe와 제어 흐름
Effect 코드는 pipe로 데이터가 아니라 “계산 단계”를 왼쪽에서 오른쪽으로 읽기 쉽게 배열합니다. map은 성공 값 변환, flatMap(또는 andThen)은 다음 Effect로 이어 붙이기에 해당합니다.
import { Effect, pipe } from "effect";
const parsePositive = (s: string): Effect.Effect<number, "not-a-number" | "non-positive", never> =>
pipe(
Effect.sync(() => Number(s)),
Effect.flatMap((n) =>
Number.isNaN(n)
? Effect.fail("not-a-number" as const)
: n > 0
? Effect.succeed(n)
: Effect.fail("non-positive" as const)
)
);
위 예시는 실패 타입을 문자열 리터럴 유니온으로 좁혀 두어, 호출부에서 Effect.match나 catchTag(태그 기반이 아닌 경우에는 매핑)로 분기하기 쉽게 만든 형태입니다. 실제 도메인에서는 Data.TaggedError 또는 스키마 기반 에러로 승격하는 편이 안전합니다.
1-4. Effect.gen과 가독성
복잡한 분기가 많을 때 Effect.gen은 가독성을 크게 개선합니다. yield*는 “여기서 Effect를 풀어 값을 얻는다”는 의미로 읽으면 됩니다.
import { Effect } from "effect";
const example = Effect.gen(function* () {
const a = yield* Effect.succeed(1);
const b = yield* Effect.fail("boom" as const); // 이후는 실행되지 않음
return a + b;
});
생성기 기반 코드는 동기 코드와 유사한 형태로 오류 전파를 표현하지만, 실제로는 여전히 지연된 계산입니다.
2. Effect 타입과 에러 처리
2-1. 예외(throw) 대신 E로 모델링하는 이유
throw는 타입 시스템이 실패 경로를 충분히 기록하지 못하는 경우가 많습니다. Effect에서는 실패를 E로 올려 함수 시그니처에 실패 가능성을 명시합니다. 이는 API 경계에서 특히 유용합니다.
다만 라이브러리 예외·런타임 오류는 완전히 사라지지 않으므로, Effect는 내부적으로 결함(defect) 과 실패(failure) 를 구분하는 Cause 모델을 제공합니다. 운영 관점에서는 “복구 가능한 비즈니스 실패”와 “프로그램 버그/환경 결함”을 분리하는 데 도움이 됩니다.
2-2. 태그 에러(Tagged Error)로 도메인 경계 지키기
태그 에러는 switch/match에서 분기 안정성을 높입니다. Effect 생태계에서는 Data.TaggedError 사용 패턴이 흔합니다.
import { Data, Effect, pipe } from "effect";
class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {}
class Unauthorized extends Data.TaggedError("Unauthorized") {}
type UserError = NotFound | Unauthorized;
declare const findUser: (id: string) => Effect.Effect<{ name: string }, UserError, never>;
const program = pipe(
findUser("u1"),
Effect.catchTag("NotFound", () => Effect.succeed({ name: "guest" })),
Effect.catchTag("Unauthorized", () => Effect.fail(new Unauthorized()))
);
catchTag는 특정 태그만 선별 복구할 때 유용합니다. “모든 에러를 한 번에 삼키는 catch”보다 의도가 드러나고, 리팩터링 시 누락을 줄입니다.
2-3. retry와 Schedule
외부 API 호출·일시적 네트워크 오류는 재시도 정책이 핵심입니다. Effect에서는 Schedule로 지수 백오프, 최대 시도 횟수, 지터(jitter) 등을 조합합니다.
import { Effect, Schedule } from "effect";
declare const callRemote: Effect.Effect<string, "rate-limited" | "timeout", never>;
const policy = pipe(
Schedule.exponential("100 millis"),
Schedule.intersect(Schedule.recurs(5))
);
const resilient = Effect.retry(callRemote, policy);
Schedule을 Effect의 일급 값으로 다루는 점은 테스트에서 시간을 가속하거나 결정적으로 재현하려 할 때 강점으로 이어집니다.
2-4. 에러 매핑과 경계
레이어 경계(예: HTTP 핸들러 → 도메인 서비스)에서는 Effect.mapError로 외부 오류 표현을 내부 도메인 오류로 변환합니다. 이때 너무 일반적인 Error 로만 흘리면 상위에서 의미 있는 처리가 어려워집니다.
3. 의존성 주입: Context와 Layer
3-1. Context.Tag로 서비스 인터페이스 정의
DI의 핵심은 “구현을 숨기고 필요한 능력(capability) 만 노출”하는 것입니다. Effect에서는 Context.Tag로 서비스 키를 만들고, 값은 Layer로 공급합니다.
import { Context, Effect, Layer, pipe } from "effect";
class Clock extends Context.Tag("Clock")<Clock, { readonly now: Effect.Effect<number> }>() {}
const liveClock = Layer.succeed(Clock, {
now: Effect.sync(() => Date.now()),
});
const program = Effect.gen(function* () {
const clock = yield* Clock;
return yield* clock.now;
});
// 실행 시 Layer 제공
const runnable = pipe(program, Effect.provide(liveClock));
이 패턴의 이점은 프로덕션 Layer와 테스트 Layer를 동일한 program에 주입할 수 있다는 점입니다.
3-2. Layer 조합: merge, provide
서비스가 늘어나면 Layer.merge·Layer.provide로 그래프를 구성합니다. 의존성 방향이 명확할수록 순환 의존을 줄이고, 모듈 경계가 선명해집니다.
import { Layer } from "effect";
declare const DatabaseLive: Layer.Layer<unknown, never, never>;
declare const LoggerLive: Layer.Layer<unknown, never, never>;
const AppLive = Layer.merge(DatabaseLive, LoggerLive);
실무에서는 기능 단위로 Layer를 파일/모듈로 분리하고, 앱 엔트리에서만 전체를 합치는 구성이 흔합니다.
3-3. Reader 모나드와의 비교
함수형 독자들에게 R 요구 사항은 Reader 스타일과 닮았습니다. 차이는 Effect가 비동기·중단·Fiber·자원 관리까지 동일한 추상 아래에 둔다는 점입니다. 즉 DI가 “테스트 편의”를 넘어 실행 모델 전체와 맞물립니다.
4. 스트림과 동시성
4-1. Stream: 이벤트·배치·백프레셔
Stream은 다중 요소를 시간에 걸쳐 생산·소비하는 파이프라인입니다. 대량 데이터 처리, 메시지 소비, 파일/네트워크 청크 처리에 적합합니다. 단일 결과에 강한 Effect와 달리 종료·에러·중간 이벤트를 함께 모델링합니다.
실무에서는 다음을 의식합니다.
- 청크 크기: 메모리 사용과 지연의 트레이드오프
- 에러 정책: 스트림 전체 실패 vs 스킵·데드레터
- 종료 신호: 상위 파이프라인과의 계약
4-2. Fiber: 구조적 동시성
Effect의 Fiber는 경량 작업 단위로, 여러 Effect를 동시에 진행하고 조인/인터럽트할 수 있습니다. HTTP 호출을 병렬로 모으거나, 워커 풀 패턴을 구성할 때 활용됩니다.
import { Effect, pipe } from "effect";
const parallel = pipe(
Effect.all([Effect.succeed(1), Effect.succeed(2)], { concurrency: 2 }),
Effect.map(([a, b]) => a + b)
);
인터럽트 가능성은 장시간 실행 작업·취소 가능한 UI 요청·서버 종료 시그널 처리에서 중요합니다.
4-3. Semaphore·Queue로 자원 경쟁 제어
동시 요청이 DB 커넥션·외부 API 한도를 넘지 않게 하려면 세마포어로 동시 실행 수를 제한합니다. Queue는 생산자-소비자 패턴으로 백로그를 안정화합니다. Effect는 이런 프리미티브를 동일한 실행 모델 안에서 다루도록 설계되어, Promise만으로 흩어지기 쉬운 경쟁 상태·누수를 줄이는 데 도움이 됩니다.
4-4. Schedule의 재사용
앞선 재시도뿐 아니라, 주기적 폴링, 윈도우 기반 레이트 리밋, 지연 큐에도 Schedule 패턴이 재등장합니다. “시간 관련 정책을 값으로 분리”하면 단위 테스트에서 시간만 바꿔 동작을 검증하기 쉬워집니다.
5. 테스트 전략
5-1. Layer로 목 구현 교체
가장 실용적인 접근은 프로덕션과 동일한 program을 유지하고, Layer만 테스트용으로 바꾸는 것입니다. DB·HTTP·시계·랜덤·UUID 생성기까지 경계에 Tag를 두면 결정적 테스트가 쉬워집니다.
import { Context, Effect, Layer, pipe } from "effect";
class Random extends Context.Tag("Random")<Random, { readonly next: Effect.Effect<number> }>() {}
const RandomDeterministic = Layer.succeed(Random, {
next: Effect.sync(() => 0.42),
});
// pipe(program, Effect.provide(RandomDeterministic)) 로 고정
5-2. 시간·스케줄 테스트
시간에 의존하는 로직은 TestClock 또는 프로젝트에서 제공하는 시간 추상화로 가상 시간을 전진시키는 방식이 일반적입니다. 재시도·타임아웃·디바운스는 실제 setTimeout을 기다리지 않고 검증하는 것이 CI 안정성에 유리합니다.
5-3. 속성 기반·스냅샷보다 먼저 할 일
Effect 도입 초기에는 유스케이스 단위로 E가 기대대로 수집되는지를 우선 검증합니다. 이후에 속성 테스트로 입력 공간을 넓히고, 로그/이벤트가 안정적이면 스냅샷을 고려합니다.
5-4. 테스트 러너 통합
Vitest/Jest에서는 Effect.runPromise로 프로그램을 실행하고, 실패 시 Cause를 출력하도록 헬퍼를 두면 디버깅이 빨라집니다. 프로젝트에 @effect/vitest류 공식 통합이 있다면 Assertion과 Cause 포맷을 표준화할 수 있습니다.
6. 실전 비즈니스 로직 구현(스케치)
아래는 “주문 확정” 유스케이스를 Effect 스타일로 쪼개는 교육용 스케치입니다. 실제 코드베이스에서는 트랜잭션 경계·아웃박스·멱등 키·관측 로그를 추가해야 합니다.
import { Data, Effect, Layer, Context, pipe } from "effect";
/* ---------- 도메인 에러 ---------- */
class InsufficientStock extends Data.TaggedError("InsufficientStock")<{ sku: string }> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{ reason: string }> {}
/* ---------- 포트(능력) ---------- */
class Inventory extends Context.Tag("Inventory")<
Inventory,
{ readonly reserve: (sku: string, qty: number) => Effect.Effect<void, InsufficientStock> }
>() {}
class Payments extends Context.Tag("Payments")<
Payments,
{ readonly charge: (amount: number) => Effect.Effect<void, PaymentDeclined> }
>() {}
class Orders extends Context.Tag("Orders")<
Orders,
{ readonly save: (id: string) => Effect.Effect<void, never> }
>() {}
/* ---------- 유스케이스 ---------- */
const placeOrder = (orderId: string, sku: string, qty: number, amount: number) =>
Effect.gen(function* () {
const inv = yield* Inventory;
const pay = yield* Payments;
const ord = yield* Orders;
yield* inv.reserve(sku, qty);
yield* pay.charge(amount);
yield* ord.save(orderId);
return { orderId } as const;
});
/* ---------- 가짜 구현(로컬 개발) ---------- */
const InventoryFake = Layer.succeed(Inventory, {
reserve: () => Effect.void,
});
const PaymentsFake = Layer.succeed(Payments, {
charge: () => Effect.void,
});
const OrdersFake = Layer.succeed(Orders, {
save: () => Effect.void,
});
const AppFake = Layer.merge(Layer.merge(InventoryFake, PaymentsFake), OrdersFake);
const runnable = pipe(placeOrder("o1", "SKU", 1, 1000), Effect.provide(AppFake));
이 스케치가 보여주는 포인트는 다음과 같습니다.
- 유스케이스 본문은 구체 저장소 구현을 모릅니다.
yield* Inventory만으로 계약을 읽습니다. - 실패 타입이 도메인 태그로 분리되어, 상위에서 보상 트랜잭션(예: 결제 실패 시 재고 예약 취소)을 설계하기 쉽습니다.
- 프로덕션 Layer는 DB·PG·HTTP 클라이언트로 바꾸고, 테스트는 의도적 실패 Layer로 바꿔 시나리오를 재현합니다.
실무에서는 reserve와 charge 사이 장애에 대비해 사가(saga)·아웃박스·멱등 키를 추가합니다. Effect는 이런 단계를 Effect 체인으로 명시하므로, 어디서 실패했는지를 Cause와 로그로 추적하기 좋습니다.
7. Promise·async 코드와의 접점
기존 코드베이스는 대부분 async/await와 Promise로 쌓여 있습니다. Effect로 한 번에 갈아엎기보다, 경계에서 Promise를 Effect로 감싸 점진적으로 옮기는 전략이 안전합니다.
Effect.tryPromise: Promise가 거절(reject)할 때를E로 매핑합니다. 매핑을 생략하면 알 수 없는 실패가 남기 쉬우므로, HTTP 클라이언트·ORM 예외를 도메인 오류로 바꾸는 함수를 함께 둡니다.Effect.async: 콜백 스타일 API를 Effect로 옮길 때 사용합니다. 레거시 라이브러리와의 접점에서 유용합니다.- 실행: 최종적으로 HTTP 핸들러·CLI 진입점에서
Effect.runPromise등으로 한 번만 실행합니다. 깊은 곳에서 반복적으로runPromise를 호출하면 중첩 실행과 테스트 불가능성이 커집니다.
또한 React 서버 컴포넌트·경계처럼 이미 Promise 기반인 프레임워크와 섞일 때는, “Effect는 도메인 코어에 두고, 어댑터 레이어만 Promise로 노출”하는 식의 헥사고날 경계를 유지하면 프론트엔드·백엔드 모두에서 혼란이 줄어듭니다.
8. 검증(Schema)과 Effect 경계
@effect/schema(또는 팀 표준 검증 라이브러리)는 외부 세계의 불확실한 데이터를 다룰 때 Effect와 잘 맞습니다. HTTP 요청 본문, 환경 변수, 메시지 큐 페이로드는 런타임 검증 없이 도메인으로 들어가면 E 설계가 무너집니다.
권장 패턴은 다음과 같습니다.
- 경계에서 파싱: 컨트롤러·컨슈머에서 스키마 디코딩을 마친 뒤, 내부 도메인 타입만
Effect로 전달합니다. - 실패를
E에 합치기: 검증 실패를 문자열 한 줄이 아니라 필드·코드가 있는 구조로 올리면, API 응답·로그 품질이 좋아집니다. - 중복 검증 줄이기: 동일 페이로드를 여러 레이어에서 반복 검증하면 비용과 불일치 위험이 생깁니다. “한 번 검증된 값”은 타입으로 좁혀 신뢰 경계를 명시합니다.
9. 자주 막히는 지점과 대응
9-1. R이 점점 비대해짐
요구 사항 R이 거대한 유니온이 되면 모듈 결합이 커집니다. 대응으로는 서비스를 도메인 단위로 Tag 분리, 공통 인프라는 하위 Layer로 흡수, 앱 엔트리에서만 최종 Layer를 조립하는 패턴을 권장합니다.
9-2. 스택 트레이스와 Cause
Effect는 실패 시 Cause로 맥락을 보존하지만, 팀원이 익숙하지 않으면 “예외 한 번 던지면 끝”보다 로그가 길게 느껴질 수 있습니다. 운영 규칙으로 어떤 태그는 사용자 메시지로, 어떤 태그는 알림으로 매핑하는 표를 두면 대응이 빨라집니다.
9-3. 성능 오해
Effect는 추상 계층이므로 오버헤드에 대한 질문이 나옵니다. 대부분의 병목은 여전히 I/O입니다. 다만 극단적으로 작은 순수 루프를 Effect로 감싸 반복 호출하는 식의 사용은 피하고, 핫 패스는 벤치마크로 검증합니다.
9-4. 학습 비용
팀 전체에 강제로 확산시키기보다, 공유 라이브러리(HTTP 클라이언트 래퍼, 재시도 정책, 로깅)부터 Effect API를 노출하고, 애플리케이션 코드는 점진적으로 합류시키는 방식이 갈등이 적습니다.
10. 도입 시 체크리스트
- 에러를 문자열로만 쓰지 않기: 가능한 한 태그·스키마로 구조화합니다.
- 서비스 경계마다
R문서화: “이 모듈을 실행하려면 어떤 Layer가 필요한가?”를 README나 타입 별칭으로 남깁니다. - 인터럽트·타임아웃: 외부 호출에는 일관된 타임아웃 정책을 둡니다.
- 관측:
Effect실행 경로에 스팬/로그 상관관계를 붙일 수 있도록 추상화합니다. - 팀 합의:
genvspipe스타일, 에러 네이밍, Layer 파일 구조를 코드 리뷰 가이드에 올립니다.
11. 정리
Effect는 TypeScript에서 함수형 효과 시스템을 실용적으로 쓰기 위한 도구입니다. Effect<A, E, R>는 성공·실패·의존성을 동시에 드러내고, Layer는 DI를 값으로, Stream·Fiber·Schedule은 동시성·시간·재시도를 값으로 다루게 합니다. 처음부터 전 도메인을 옮기기보다, 외부 연동·결제·재시도처럼 오류와 시간이 섞인 경계부터 도입하고 Layer로 고정해 나가면 학습 비용 대비 효용이 큽니다.
배포 전에는 git add → git commit → git push 후 npm run deploy를 실행하는 것을 권장합니다(이 저장소 배포 규칙).
부록: 용어 대응
| 영어 | 한국어 설명 |
|---|---|
| Effect | 효과: 실행 시 부수 효과·실패·요구 사항을 포함할 수 있는 계산 |
| Layer | 의존성 그래프의 한 층; 구현을 조립해 제공 |
| Fiber | 경량 스레드에 가까운 동시 실행 단위 |
| Schedule | 재시도·반복·지연 등 시간 정책을 값으로 표현 |
| Cause | 실패의 전체 맥락(연쇄, 결함 포함) |