Effect로 TypeScript 비즈니스 로직 짜기: 태그 에러, Layer DI, Stream·Schedule, 테스트

이 글의 핵심

Promise와 try/catch만으로는 어떤 함수가 어떤 에러를 내고 어떤 서비스에 의존하는지 타입에 드러나지 않습니다. Effect는 이를 타입 인자로 끌어올리지만, R이 점점 비대해지거나 Cause와 스택 트레이스를 읽기 어려워지는 등 학습·운영 비용도 따라옵니다. 실전 비즈니스 로직 스케치와 도입 체크리스트를 보고 팀에 들일지 판단할 수 있습니다.

Effect는 TypeScript에서 부수 효과, 실패, 실행에 필요한 의존성을 하나의 타입 Effect<A, E, R>로 표현하고, 이를 합성하고 실행하는 런타임을 함께 제공하는 라이브러리입니다. Promise가 성공 값만 타입에 드러내고 실패는 unknown으로 남긴다면, Effect는 성공 타입 A, 실패 타입 E, 요구 사항 R을 모두 시그니처에 올려 함수 경계의 계약을 명확히 합니다.

설치, Effect.promise와 Effect.tryPromise의 차이, @effect/platform으로 HTTP 서버 띄우기, React에서 Fiber를 취소하는 법처럼 처음 코드를 쓸 때 부딪히는 사용법은 Effect-TS로 Promise의 한계 넘기에서 다룹니다. 이 글은 그다음 단계인 도메인 로직을 어떻게 나누고 조립하는지에 초점을 둡니다. Effect는 버전에 따라 API 이름과 패키지 구성이 바뀌어 왔으므로, 예제는 Effect 3.x 기준이며 공식 문서와 함께 확인하세요.


Effect의 핵심 개념

Effect는 실행 결과가 아니라 프로그램 설명이다

async 함수는 호출하는 순간 작업이 시작됩니다. 반면 Effect 값은 “무엇을 할지”를 기술한 불변 데이터이고, Effect.runPromise 같은 실행 함수에 넘겨야 비로소 부수 효과가 일어납니다. 같은 Effect를 두 번 실행하면 작업도 두 번 일어나며, 만들어 두기만 하고 실행하지 않으면 아무 일도 일어나지 않습니다. 이 구분 덕분에 같은 프로그램 설명을 프로덕션 의존성과 테스트 의존성으로 각각 실행할 수 있습니다.

작은 Effect를 map, flatMap, Effect.gen으로 합쳐 큰 유스케이스를 만들고, 그 결과의 E는 일어날 수 있는 실패의 합집합, R은 필요한 서비스의 합집합이 됩니다. 실패가 겹치거나 병렬 작업에서 여러 실패가 동시에 나면 런타임은 이를 Cause라는 구조에 모두 보존합니다.

Effect<A, E, R> 읽는 법

매개변수의미
A성공 시 결과 값
E예상된 실패(보통 태그 에러의 유니온)
R실행에 필요한 서비스(요구 사항)

R은 “이 Effect를 실행하려면 무엇이 제공되어야 하는가”입니다. Effect.runPromise는 R이 never인 Effect만 받으므로, 필요한 서비스를 Effect.provide로 모두 채우지 않으면 컴파일 단계에서 실행이 거부됩니다. 의존성 누락이 런타임 오류가 아니라 타입 오류로 드러나는 것입니다.

pipe와 합성

pipe는 계산 단계를 왼쪽에서 오른쪽으로 나열합니다. map은 성공 값을 변환하고, flatMap(또는 andThen)은 성공 값으로 다음 Effect를 이어 붙입니다.

import { Effect, pipe } from "effect";

const parsePositive = (s: string): Effect.Effect<number, "not-a-number" | "non-positive"> =>
  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나 Effect.catchAll 안의 switch로 분기할 수 있습니다. 다만 catchTag 같은 태그 기반 도구는 _tag 필드가 있는 객체에만 동작하므로, 실제 도메인에서는 아래의 Data.TaggedError로 에러를 정의하는 편이 낫습니다.

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;
});
// example: Effect<number, "boom">

동기 코드처럼 보이지만 여전히 실행 전의 프로그램 설명이며, 각 yield*의 E와 R이 자동으로 합쳐져 전체 타입이 추론됩니다.


에러 처리

throw 대신 E로 모델링하는 이유

TypeScript는 함수가 어떤 예외를 던지는지 타입에 기록하지 않으므로, catch (e)의 e는 항상 unknown입니다. Effect는 예상된 실패를 E로 올려 시그니처에 드러내므로, 호출자는 어떤 실패를 처리해야 하는지 타입으로 알 수 있고, 처리한 실패는 E에서 빠집니다.

라이브러리 내부 예외나 프로그래밍 버그처럼 예상하지 못한 오류가 사라지지는 않습니다. Effect는 이런 것을 결함(defect)으로, E에 선언된 것을 실패(failure)로 구분해 Cause에 담습니다. 운영 관점에서 “사용자에게 안내할 비즈니스 실패”와 “알림을 보내야 할 버그”를 나누는 기준이 타입 수준에서 생기는 셈입니다.

태그 에러로 도메인 경계 지키기

Data.TaggedError로 만든 에러는 _tag 필드를 가지므로 catchTag로 특정 종류만 골라 처리할 수 있습니다.

import { Data, Effect, pipe } from "effect";

class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {}
class Unauthorized extends Data.TaggedError("Unauthorized") {}

declare const findUser: (id: string) => Effect.Effect<{ name: string }, NotFound | Unauthorized>;

const program = pipe(
  findUser("u1"),
  Effect.catchTag("NotFound", () => Effect.succeed({ name: "guest" }))
);
// program: Effect<{ name: string }, Unauthorized>

NotFound를 처리했으므로 program의 에러 타입에서 NotFound가 빠지고 Unauthorized만 남습니다. 모든 에러를 한 번에 삼키는 catchAll보다 의도가 분명하고, 나중에 findUser에 새 에러 종류가 추가되면 그 에러가 상위 타입에 나타나 처리 누락이 드러납니다.

재시도와 Schedule

외부 API 호출이나 일시적인 네트워크 오류에는 재시도 정책이 필요합니다. Effect에서는 지수 백오프, 최대 횟수, 지터를 Schedule로 조합해 Effect.retry에 넘깁니다. 구체적인 조합 코드와, retry가 에러 종류를 가리지 않고 재시도한다는 점이나 Effect.timeout이 에러 채널에 TimeoutException을 더한다는 점 같은 함정은 Effect-TS 글의 재시도·스케줄 절에 정리해 두었습니다.

설계 관점에서 기억할 것은 두 가지입니다. 첫째, 재시도는 에러 타입 설계와 짝을 이룹니다. NotFound처럼 다시 해도 결과가 같은 실패와 RateLimited처럼 기다리면 풀리는 실패를 태그로 나눠 두어야 retry의 while 조건으로 재시도 대상을 좁힐 수 있습니다. 둘째, Schedule은 일반 값이라 정책을 한 모듈에 모아 여러 유스케이스에서 재사용할 수 있고, 테스트에서는 TestClock으로 시간을 앞당겨 결정적으로 검증할 수 있습니다.

경계에서의 에러 변환

HTTP 클라이언트나 DB 드라이버처럼 바깥 계층의 에러는 Effect.mapError로 도메인 에러로 바꿔서 안으로 들여보냅니다. 바깥 라이브러리의 에러 타입이 도메인 코드 전체로 퍼지면, 라이브러리를 바꿀 때 도메인 코드까지 고쳐야 하고 상위에서 의미 있는 분기도 어렵습니다.


의존성 주입: Context와 Layer

Context.Tag로 서비스 인터페이스 정의

Context.Tag는 서비스의 식별자와 인터페이스 타입을 함께 정의합니다. 프로그램은 yield* Clock처럼 태그로 서비스를 꺼내 쓰고, 이 사용이 자동으로 R에 기록됩니다. 실제 구현은 Layer로 공급합니다.

import { Context, Effect, Layer, pipe } from "effect";

class Clock extends Context.Tag("Clock")<Clock, { readonly now: Effect.Effect<number> }>() {}

const ClockLive = Layer.succeed(Clock, {
  now: Effect.sync(() => Date.now()),
});

const program = Effect.gen(function* () {
  const clock = yield* Clock;
  return yield* clock.now;
});
// program: Effect<number, never, Clock>

const runnable = pipe(program, Effect.provide(ClockLive));
// runnable: Effect<number, never, never> → Effect.runPromise(runnable) 가능

같은 program에 테스트에서는 고정 시각을 돌려주는 Layer를 넣으면 됩니다. 프로그램 코드는 바뀌지 않습니다.

Layer 조합

서비스가 늘어나면 Layer를 조합해 의존성 그래프를 만듭니다. Layer.merge와 Layer.mergeAll은 독립적인 Layer를 나란히 합치고, Layer.provide는 한 Layer가 다른 Layer를 필요로 할 때 그 의존성을 채워 줍니다.

import { Layer } from "effect";

// Database, UserRepo, Logger는 앞의 Clock처럼 Context.Tag로 정의된 서비스
// UserRepoLive는 Database를 필요로 함: Layer<UserRepo, never, Database>
declare const DatabaseLive: Layer.Layer<Database>;
declare const UserRepoLive: Layer.Layer<UserRepo, never, Database>;
declare const LoggerLive: Layer.Layer<Logger>;

const AppLive = Layer.mergeAll(
  UserRepoLive.pipe(Layer.provide(DatabaseLive)),
  LoggerLive,
);
// AppLive: Layer<UserRepo | Logger>

Layer는 기본적으로 한 번의 provide 안에서 메모이제이션되므로, 여러 서비스가 같은 DatabaseLive에 의존해도 커넥션 풀은 하나만 만들어집니다. 기능 단위로 Layer를 모듈에 나눠 두고 앱 엔트리에서만 전체를 조립하는 구성이 일반적입니다.

Reader 패턴과의 차이

함수형 프로그래밍에 익숙하다면 R이 Reader 모나드의 환경과 비슷하다는 것을 알아챌 것입니다. 차이는 Effect가 비동기, 중단(interruption), Fiber, 자원 관리까지 같은 추상 안에서 다룬다는 점입니다. Layer는 자원을 획득하고 해제하는 수명 주기까지 가질 수 있어서, DB 연결을 여는 Layer는 프로그램이 끝날 때 연결을 닫는 것까지 책임집니다.


스트림과 동시성

Stream

Stream은 여러 값을 시간에 걸쳐 생산하고 소비하는 파이프라인입니다. 대량 데이터 처리, 메시지 소비, 파일이나 네트워크 청크 처리에 씁니다. 하나의 결과를 내는 Effect와 달리 중간 값, 종료, 에러를 함께 다루며, 소비자가 느리면 생산자가 기다리는 백프레셔가 기본으로 동작합니다. 설계할 때는 청크 크기(메모리 사용과 지연의 균형), 원소 하나가 실패했을 때 스트림 전체를 실패시킬지 건너뛸지, 상위 파이프라인에 종료를 어떻게 알릴지를 정해야 합니다.

Fiber와 구조적 동시성

Fiber는 Effect 런타임이 관리하는 경량 실행 단위입니다. Effect.all에 concurrency 옵션을 주면 내부적으로 Fiber를 만들어 병렬로 실행하고, Effect.fork로 직접 Fiber를 띄워 나중에 Fiber.join으로 결과를 받거나 Fiber.interrupt로 취소할 수도 있습니다.

import { Effect, Fiber, pipe } from "effect";

const parallel = pipe(
  Effect.all([Effect.succeed(1), Effect.succeed(2)], { concurrency: 2 }),
  Effect.map(([a, b]) => a + b)
);

const withFork = Effect.gen(function* () {
  const fiber = yield* Effect.fork(Effect.sleep("1 second").pipe(Effect.as(42)));
  // ... 다른 작업 ...
  return yield* Fiber.join(fiber);
});

Effect.fork로 띄운 Fiber는 부모 Fiber에 묶여 있어서, 부모가 끝나거나 취소되면 자식도 함께 중단됩니다. 이것이 구조적 동시성이며, Promise에서 흔한 “아무도 기다리지 않는 백그라운드 작업이 계속 돌다 에러를 삼키는” 문제를 막아 줍니다. Effect.all은 기본적으로 하나가 실패하면 나머지를 중단합니다. 장시간 작업, 사용자가 취소할 수 있는 요청, 서버 종료 시그널 처리에서 이 중단 전파가 특히 중요합니다.

동시 실행 수 제어

DB 커넥션이나 외부 API 한도를 넘지 않도록 동시 실행 수를 제한하려면 Effect.makeSemaphore로 만든 세마포어의 withPermits로 감싸거나, Effect.forEach의 concurrency 옵션을 씁니다. 생산자와 소비자의 속도가 다르면 Queue로 사이를 버퍼링합니다. 이런 도구들이 같은 런타임 안에서 중단과 자원 정리를 공유하므로, 취소된 작업이 세마포어 허가를 반환하지 않고 사라지는 식의 누수가 생기지 않습니다.

Schedule은 재시도 외에 Effect.repeat로 주기적인 폴링이나 반복 작업에도 씁니다. 시간 정책을 값으로 분리해 두면 테스트에서 시간만 조작해 동작을 검증할 수 있습니다.


테스트 전략

Layer로 구현 교체

가장 실용적인 방법은 프로덕션과 같은 프로그램을 그대로 두고 Layer만 테스트용으로 바꾸는 것입니다. DB, HTTP, 시계, 난수, UUID 생성기처럼 결과가 바깥 세계에 따라 달라지는 지점마다 태그를 두면 결정적인 테스트를 쓰기 쉽습니다.

import { Context, Effect, Layer } from "effect";

class Random extends Context.Tag("Random")<Random, { readonly next: Effect.Effect<number> }>() {}

const RandomFixed = Layer.succeed(Random, {
  next: Effect.succeed(0.42),
});

// Effect.provide(program, RandomFixed)로 실행하면 항상 같은 값을 받음

시간과 스케줄

재시도, 타임아웃, 디바운스처럼 시간에 의존하는 로직은 실제로 기다리면 테스트가 느리고 불안정해집니다. Effect의 TestClock은 가상 시간을 제공하므로 TestClock.adjust("10 seconds")로 시간을 앞당겨 재시도 횟수나 타임아웃 동작을 즉시 검증할 수 있습니다. 이는 프로그램이 내장 Clock 서비스를 통해 시간을 다룰 때만 동작하므로, 코드 안에서 Date.now()나 setTimeout을 직접 부르지 말고 Effect.sleep과 Clock을 써야 합니다.

테스트 러너 통합

Vitest를 쓴다면 공식 통합인 @effect/vitest의 it.effect로 Effect를 바로 테스트할 수 있고, 이 안에서는 TestClock이 기본 제공됩니다. 다른 러너에서는 Effect.runPromise로 실행하되, 실패 시 Cause.pretty로 전체 실패 원인을 출력하는 헬퍼를 두면 디버깅이 빨라집니다. 도입 초기에는 유스케이스마다 기대한 E가 실제로 나오는지를 먼저 검증하고, 그 뒤에 속성 기반 테스트로 입력 범위를 넓히는 순서가 효과적입니다.


실전 비즈니스 로직 스케치

아래는 주문 확정 유스케이스를 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>;
    readonly release: (sku: string, qty: number) => Effect.Effect<void>;
  }
>() {}

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> }
>() {}

/* 유스케이스 */
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).pipe(
      Effect.tapErrorTag("PaymentDeclined", () => inv.release(sku, qty))
    );
    yield* ord.save(orderId);
    return { orderId } as const;
  });
// Effect<{ orderId: string }, InsufficientStock | PaymentDeclined, Inventory | Payments | Orders>

/* 로컬 개발용 가짜 구현 */
const AppFake = Layer.mergeAll(
  Layer.succeed(Inventory, { reserve: () => Effect.void, release: () => Effect.void }),
  Layer.succeed(Payments, { charge: () => Effect.void }),
  Layer.succeed(Orders, { save: () => Effect.void }),
);

const runnable = pipe(placeOrder("o1", "SKU", 1, 1000), Effect.provide(AppFake));

유스케이스 본문은 재고나 결제의 구체 구현을 모르고 태그로 계약만 읽습니다. 실패 타입이 도메인 태그로 나뉘어 있어, 결제 거절 시 재고 예약을 되돌리는 보상 동작을 tapErrorTag로 그 실패에만 정확히 붙일 수 있습니다. 반환 타입 주석처럼 이 함수가 어떤 실패를 내고 어떤 서비스가 필요한지가 모두 추론되므로, 새 실패 종류를 추가하면 처리하지 않은 호출부가 타입 오류로 드러납니다. 테스트에서는 charge가 항상 PaymentDeclined를 내는 Layer를 넣어 release가 호출되는지 검증할 수 있습니다.

다만 release 호출 자체도 프로세스가 그 순간 죽으면 실행되지 않으므로, 실제 결제 흐름에서는 사가나 아웃박스 같은 영속적인 보상 메커니즘이 필요합니다. Effect는 그 단계들을 명시적으로 드러내 줄 뿐, 분산 트랜잭션 문제를 대신 해결하지는 않습니다.


Promise·async 코드와의 접점

대부분의 기존 코드는 async/await로 되어 있으므로, 한 번에 바꾸기보다 경계에서 Promise를 Effect로 감싸며 점진적으로 옮기는 것이 안전합니다.

Effect.tryPromise는 Promise의 거절을 catch 함수로 변환해 E에 넣습니다. Effect.promise는 거절을 결함으로 취급하므로, 실패할 수 있는 HTTP 호출이나 DB 쿼리에는 tryPromise를 쓰고 거절을 도메인 에러로 바꾸는 함수를 함께 둡니다. 콜백 스타일 API는 Effect.async로 감쌀 수 있습니다.

실행은 HTTP 핸들러나 CLI 진입점 같은 바깥 경계에서 Effect.runPromise 등으로 한 번만 합니다. 도메인 코드 깊은 곳에서 runPromise를 반복해서 부르면 그 지점에서 의존성 주입, 중단 전파, 에러 타입 추적이 모두 끊깁니다. Next.js 서버 컴포넌트처럼 이미 Promise를 기대하는 프레임워크와 섞을 때는 도메인 코어를 Effect로 두고 프레임워크와 맞닿는 어댑터만 Promise로 노출하면 경계가 분명해집니다.


스키마 검증과 경계

effect 패키지의 Schema(3.10부터 핵심 패키지에 포함되었고, 그 전에는 @effect/schema로 따로 설치했습니다)는 바깥에서 들어오는 불확실한 데이터를 다룰 때 Effect와 잘 맞습니다. HTTP 요청 본문, 환경 변수, 메시지 큐 페이로드를 검증 없이 도메인으로 들이면 타입은 맞는 척하지만 실제 값은 다를 수 있어 E 설계 자체가 무의미해집니다.

컨트롤러나 메시지 컨슈머 같은 경계에서 스키마로 디코딩을 끝내고, 도메인에는 검증된 타입만 넘깁니다. 검증 실패는 문자열 한 줄이 아니라 필드와 이유를 담은 구조로 E에 합쳐야 API 응답과 로그에서 쓸모가 있습니다. 같은 데이터를 여러 계층에서 반복 검증하면 비용도 들고 규칙이 서로 어긋날 위험도 생기므로, 한 번 검증된 값은 브랜드 타입 등으로 좁혀 신뢰 경계를 타입에 남깁니다.


자주 막히는 지점

요구 사항 R이 거대한 유니온이 되면 모듈 간 결합이 커집니다. 서비스를 도메인 단위로 나누고, 공통 인프라는 하위 Layer 안으로 감춰 상위 서비스의 R에 드러나지 않게 하고, 최종 조립은 앱 엔트리에서만 하는 것이 대응 방법입니다.

Cause는 실패 맥락을 모두 보존하지만, 익숙하지 않은 팀원에게는 로그가 장황하게 느껴질 수 있습니다. 어떤 태그를 사용자 메시지로 바꾸고 어떤 태그나 결함에 알림을 보낼지 표로 정해 두면 대응이 빨라지고, 로그에는 Cause 전체를 덤프하기보다 사람이 읽을 요약과 추적 ID를 남기는 편이 온콜에 유리합니다.

Effect는 추상화 계층이라 호출마다 약간의 오버헤드가 있습니다. 대부분의 서비스에서 병목은 I/O라서 문제가 되지 않지만, 아주 작은 순수 계산을 Effect로 감싸 수백만 번 반복하는 식의 코드는 피하고, 핫 패스라면 벤치마크로 확인합니다.

타임아웃을 여러 계층에 겹쳐 걸면 안쪽은 이미 중단되었는데 바깥 계층이 다른 에러로 덮어쓰는 혼란이 생기기 쉽습니다. 데드라인은 한 계층에서 정하고, 하위 작업은 Scope와 finalizer(Effect.acquireRelease, Effect.ensuring)로 중단 시 자원을 정리하도록 만듭니다.

팀 전체에 한꺼번에 도입하기보다, 외부 API 연동, 결제, 재시도처럼 실패와 시간이 섞이는 경계부터 시작하는 편이 학습 비용 대비 효과가 큽니다. 이런 곳은 원래 타임아웃, 재시도, 에러 분류 코드가 흩어져 있기 쉬워서 Effect로 옮겼을 때 차이가 바로 드러납니다. HTTP 클라이언트 래퍼, 재시도 정책, 로깅 같은 공유 라이브러리부터 Effect API를 노출하고, 애플리케이션 코드는 점진적으로 합류시키면 됩니다.

현상점검
테스트는 통과하는데 프로덕션에서만 실패테스트와 프로덕션 Layer의 차이, 프로덕션 Layer가 요구하는 환경 변수·시크릿 누락
메모리가 시간에 따라 증가끝나지 않는 Stream, 닫히지 않은 Scope, 계속 쌓이는 Fiber
재시도 폭주여러 계층에 걸친 중복 재시도, 업스트림 레이트 리밋과의 충돌
E가 unknown으로 퍼짐경계에서 스키마 검증 누락, Effect.promise나 catchAll 남용

같이 보면 좋은 글