try-catch 없이 실패를 타입으로 선언하면 서비스 테스트가 달라집니다
NestJS 서비스를 짜다 보면 이런 순간이 꼭 옵니다. findUser를 호출하는 쪽에서 "이게 뭘 던질 수 있지?" 하고 시그니처를 열어보면 Promise<User> — 끝입니다. 실제로 어떤 예외가 날 수 있는지 알려면 구현 코드를 직접 읽어야 하고, 테스트에서 DB를 목킹하려면 jest.mock()과 씨름해야 합니다.
결제 로직에서 6가지 실패 케이스를 try-catch로 처리하다 보면 자연스럽게 이런 질문이 생깁니다. 실패 케이스가 타입 시그니처에 없으면, 처리되지 않은 에러는 런타임까지 숨어 있습니다. TypeScript가 강타입 언어라고 해도 throw는 타입 검사 대상이 아니기 때문입니다.
이 글은 Effect-TS v3의 3채널 타입 시스템이 그 문제를 어떻게 다르게 푸는지 살펴봅니다. Effect v3 API 기준이며, 2026년 6월 기준으로 v4 베타도 공개되어 있지만 핵심 개념은 그대로 이어집니다.
Effect 타입의 세 채널
Effect<A, E, R> 세 파라미터가 각각 무엇을 뜻하는지 먼저 잡고 가면 이후가 훨씬 편합니다.
| 채널 | 의미 | never일 때 |
|---|---|---|
| A (Success) | 성공 시 반환값 타입 | — |
| E (Error) | 예상 가능한 실패 타입 목록 | 도메인 실패 없음 |
| R (Requirements) | 실행에 필요한 서비스 목록 | 의존성 없음 |
// Promise — 뭘 던질지 시그니처로 알 수 없음
async function findUser(id: string): Promise<User> { ... }
// Effect — 실패와 의존성이 시그니처에 선언됨
const findUser = (id: string): Effect.Effect<User, UserNotFound | DbError, Database> => ...태그드 에러 패턴
Data.TaggedError로 에러를 선언하면 _tag 판별자 필드가 생겨서 catchTag로 특정 에러만 정확하게 잡을 수 있습니다.
import { Data, Effect } from "effect";
class UserNotFound extends Data.TaggedError("UserNotFound")<{
id: string;
}> {}
class DbError extends Data.TaggedError("DbError")<{
cause: unknown;
}> {}const program = Effect.gen(function* () {
const user = yield* findUser("123");
// E 채널: UserNotFound | DbError 자동 합성됨
return user;
}).pipe(
// UserNotFound만 로컬 처리 → E 채널에서 제거됨
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ id: e.id, name: "Guest" })
)
// DbError는 E 채널에 남아 있음 → 호출자가 처리해야 함
);
// 타입: Effect<User, DbError, Database>E 채널이 좁혀지는 흐름
catchTag를 체이닝할 때마다 E 채널에서 해당 에러 타입이 제거됩니다. 아래 다이어그램은 실제 타입 변화 과정을 보여줍니다.
provide(DatabaseLive)로 R = never가 되는 시점이 runPromise를 호출할 수 있는 컴파일 조건입니다. E = never는 그와 독립적으로, "이 Effect는 절대 reject하지 않는다"는 런타임 보증입니다.
Layer 기반 의존성 주입
Layer<ROut, E, RIn>은 서비스를 만드는 레시피입니다. Context.GenericTag로 서비스 타입의 주입 토큰을 만들고, Layer가 그 토큰에 구현체를 바인딩합니다.
NestJS의 @Inject('DATABASE')와 비교하면 이해가 쉽습니다. NestJS는 문자열 토큰과 타입을 따로 관리하지만, Context.GenericTag<DatabaseService>("Database")는 서비스 타입 DatabaseService와 런타임 식별자 "Database"를 하나의 태그에 묶어 타입 안전하게 사용합니다.
import { Context, Effect, Layer } from "effect";
interface DatabaseService {
query: (sql: string, params: unknown[]) => Effect.Effect<unknown[], DbError>;
}
// "Database"는 런타임 식별자, DatabaseService는 정적 타입
const Database = Context.GenericTag<DatabaseService>("Database");
const DatabaseLive = Layer.succeed(Database, {
query: (sql, params) =>
Effect.tryPromise({
try: () => pool.query(sql, params).then((r) => r.rows),
catch: (e) => new DbError({ cause: e }),
}),
});프로덕션 구현체와 테스트 구현체를 교체하는 것이 Effect.provide(layer)만 바꾸면 됩니다.
실전 적용
NestJS 서비스 레이어에 Effect 도입하기
NestJS에서 Effect를 도입할 때 가장 자연스러운 진입점은 서비스 메서드가 Effect를 반환하고, 글로벌 인터셉터가 runPromise를 담당하는 패턴입니다.
두 DI 시스템의 역할 분리를 짚어두면 혼란이 없습니다. NestJS 컨테이너는 UserService 인스턴스 생성을 담당하고, Database 의존성은 Effect의 Layer 시스템이 담당합니다. UserService는 생성자 파라미터가 없으므로 NestJS DI에 Database를 따로 등록할 필요가 없습니다. 인터셉터에서 Effect.provide(DatabaseLive)를 호출하는 시점에 비로소 Database가 연결됩니다.
// user.service.ts
import { Effect } from "effect";
// 실제 프로젝트에서는 Effect Schema나 Zod로 검증하는 것을 권장합니다
const parseUser = (row: unknown): User => {
const r = row as Record<string, unknown>;
return {
id: r["id"] as string,
name: r["name"] as string,
email: r["email"] as string,
};
};
@Injectable()
export class UserService {
findOne(id: string): Effect.Effect<User, UserNotFound | DbError, Database> {
return Effect.gen(function* () {
const db = yield* Database;
// db.query가 DbError를 E 채널에 갖고 있으므로 yield*로 자동 전파됩니다
const rows = yield* db.query(`SELECT * FROM users WHERE id = $1`, [id]);
if (rows.length === 0) {
return yield* new UserNotFound({ id });
}
return parseUser(rows[0]);
});
}
}// effect.interceptor.ts — 글로벌 인터셉터
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { Effect } from "effect";
import { Observable, from, of, switchMap } from "rxjs";
@Injectable()
export class EffectInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
switchMap((value) => {
if (Effect.isEffect(value)) {
return from(
Effect.runPromise(
value.pipe(Effect.provide(DatabaseLive))
)
);
}
return of(value);
})
);
}
}테스트 레이어 교체 — 실제 DB 없이
jest.mock()이나 jest.spyOn() 없이 Layer만 교체합니다.
여기서 Effect.runPromiseExit를 사용하는 이유도 짚어둡니다. runPromise는 E 채널에 에러가 남아 있으면 Promise를 reject하므로 테스트에서 expect(...).rejects로 잡아야 합니다. runPromiseExit는 성공과 실패 모두를 Exit<A, E> 값으로 반환하므로, 에러 타입을 직접 검사할 때 훨씬 편합니다.
// user.service.spec.ts
import { Effect, Exit, Cause, Layer } from "effect";
const TestDatabase = Layer.succeed(Database, {
query: (_sql, _params) =>
Effect.succeed([{ id: "123", name: "Alice", email: "alice@example.com" }]),
});
describe("UserService.findOne", () => {
it("유저가 존재하면 User를 반환한다", async () => {
const result = await Effect.runPromise(
userService.findOne("123").pipe(Effect.provide(TestDatabase))
);
expect(result.name).toBe("Alice");
});
it("유저가 없으면 UserNotFound 에러가 발생한다", async () => {
const EmptyDatabase = Layer.succeed(Database, {
query: () => Effect.succeed([]),
});
// runPromiseExit: 성공/실패 모두 Exit<A, E>로 반환 (reject 없음)
const exit = await Effect.runPromiseExit(
userService.findOne("999").pipe(Effect.provide(EmptyDatabase))
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit) && Cause.isFail(exit.cause)) {
expect(exit.cause.error).toBeInstanceOf(UserNotFound);
expect((exit.cause.error as UserNotFound).id).toBe("999");
}
});
});Hono 핸들러에서 에러 처리를 타입으로 닫기
// user.handler.ts (Hono)
import { Effect } from "effect";
app.get("/users/:id", async (c) => {
const handler = Effect.gen(function* () {
const user = yield* userService.findOne(c.req.param("id"));
return c.json(user);
}).pipe(
// E: UserNotFound | DbError → DbError
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed(c.json({ error: `User ${e.id} not found` }, 404))
),
// E: DbError → never
Effect.catchTag("DbError", (e) => {
console.error(e.cause);
return Effect.succeed(c.json({ error: "Internal server error" }, 500));
}),
// R: Database → never (runPromise 컴파일 조건 충족)
Effect.provide(DatabaseLive)
);
return Effect.runPromise(handler);
});AI 에이전트 파이프라인에서의 Retry 패턴
Effect의 Schedule 조합자는 에러 타입별로 재시도 정책을 다르게 줄 수 있습니다.
import { Effect, Schedule, Data } from "effect";
class RateLimitError extends Data.TaggedError("RateLimitError")<{}> {}
class ModelUnavailableError extends Data.TaggedError("ModelUnavailableError")<{}> {}
// RateLimitError에 한해서만 재시도하는 스케줄
const rateLimitSchedule = Schedule.exponential("1 second").pipe(
Schedule.intersect(Schedule.recurs(3)),
Schedule.whileInput(
(e: RateLimitError | ModelUnavailableError) => e._tag === "RateLimitError"
)
);
const callLLM = (prompt: string) =>
Effect.gen(function* () {
// ... gpt-4o 호출
}).pipe(
// RateLimit은 지수 백오프로 최대 3회 재시도
// ModelUnavailableError는 스케줄 조건 불충족 → 아래 catchTag로 전달
Effect.retry(rateLimitSchedule),
// ModelUnavailable이면 fallback 모델로
Effect.catchTag("ModelUnavailableError", () => callFallbackLLM(prompt))
);장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 타입 시그니처의 투명성 | E 채널이 가능한 실패를 모두 선언 — 구현 코드를 읽지 않아도 무엇이 실패할 수 있는지 시그니처만으로 알 수 있음 |
| exhaustive 에러 처리 | catchTag마다 E 채널이 좁혀지고 컴파일러가 남은 타입을 추적 — 처리 누락을 즉시 확인 가능 |
| 테스트 가능성 | Layer 교체만으로 모든 의존성 교체 가능 — 실제 DB·네트워크 없이 단위 테스트 |
| 에러 의미론 분리 | defect(버그) vs failure(도메인 오류) 명시적 구분 |
| 내장 도구 | Retry, Schedule, OpenTelemetry, Fiber — 별도 라이브러리 불필요 |
| 절차적 스타일 | Effect.gen + yield* 덕분에 async/await처럼 작성 |
단점
| 항목 | 내용 |
|---|---|
| 러닝 커브 | Effect<A, E, R>과 Layer 시스템 이해에 2~4주 소요 |
| 번들 크기 | 전체 Effect 런타임 포함 시 번들이 상당히 커짐 — 엣지 환경에서는 tree-shaking 전략 필요 |
| 기존 코드와의 마찰 | Promise 기반 코드와 혼용 시 Effect.tryPromise, Effect.runPromise 변환 지점 증가 |
| 과잉 설계 위험 | 단순 CRUD에 Effect를 적용하면 오히려 복잡해짐 |
| 공식 어댑터 없음 | NestJS·Hono 공식 지원 없어 통합 코드를 직접 작성해야 함 |
| 빠른 생태계 변화 | v3 → v4처럼 주요 변경이 빠르게 일어나 업그레이드 부담 있음 |
실무에서 흔한 실수
1. 모든 서비스에 Effect 도입하려는 욕심
단순 CRUD 엔드포인트에 Effect를 적용하면 코드 양만 늘어납니다. 실패 모드가 3가지 이상이거나, 의존성 교체가 자주 필요한 레이어에서 시작하는 것이 좋습니다. 결제, 외부 API 연동, AI 오케스트레이션이 대표적입니다.
2. Effect.runPromise를 서비스 내부에서 호출하기
서비스 메서드 안에서 runPromise를 호출하면 Effect 세계에서 탈출하게 됩니다. 에러 합성이 끊기고, 테스트에서 Layer 교체도 불가능해집니다. runPromise는 항상 최상단 경계(인터셉터, 핸들러 래퍼)에서만 호출하는 것이 좋습니다.
3. neverthrow와의 비교를 건너뛰기
팀이 함수형 프로그래밍에 익숙하지 않다면 neverthrow의 Result<T, E> 패턴을 먼저 경험해보는 것도 좋은 선택입니다. 학습 곡선이 완만하고 기존 코드와 통합이 쉽습니다. 단, DI·동시성·재시도는 직접 해결해야 하므로 복잡도가 올라가는 시점에 Effect 전환을 고려할 수 있습니다.
도구 선택 의사결정 흐름
마치며
시작하고 싶다면 이 순서를 권합니다.
1단계 — Tagged Error 하나 만들어보기
기존 서비스 한 메서드에서 throw new Error("not found")를 Data.TaggedError("UserNotFound")로 바꾸고, 반환 타입을 Effect<User, UserNotFound>로 선언해 봅니다.
2단계 — 테스트 Layer 교체 경험하기
jest.mock() 대신 Layer.succeed(Database, { query: () => Effect.succeed([...]) })로 교체하는 테스트를 한 개 작성해 봅니다. 같은 서비스 코드를 건드리지 않고 의존성만 바꾸는 경험이 Layer 시스템을 직관적으로 이해하는 가장 빠른 방법입니다.
3단계 — 인터셉터 경계 설정하기
NestJS라면 글로벌 인터셉터, Hono라면 미들웨어에서 Effect.runPromise를 호출하는 얇은 경계를 만들고, 서비스 레이어 전체가 Effect를 반환하도록 점진적으로 확장합니다.
Effect v4 베타가 공개된 지금 시점에서 v3 코드베이스를 새로 시작하는 게 망설여질 수 있습니다. v3 → v4 마이그레이션은 패키지 통합과 API 세부 변경이 주이며, Effect<A, E, R>, Layer, Tagged Error 같은 핵심 개념은 그대로 이어집니다. 개념을 먼저 잡으면 버전 전환 비용은 크지 않습니다.
참고 자료
- Effect 공식 문서 — Expected Errors
- Effect 공식 문서 — Yieldable Errors
- Effect 공식 문서 — Managing Layers
- Effect 3.0 릴리스 노트
- Effect v4 Beta 발표
- Effect vs neverthrow 공식 비교
- Building a backend with Effect-TS: 6 patterns from Inkpipe
- Effect with NestJS — Medium
- Intro To Effect, Part 2: Handling Errors
- No more Try/Catch: a better way to handle errors in TypeScript — DEV Community
- TypeScript Errors and Effect — davidmyno.rs
- Effect-TS in 2026: FP for TypeScript That Actually Makes Sense — DEV
- Why We Love FP but Don't Use Effect-TS — Harbor
- EffectPatterns GitHub — 커뮤니티 패턴 모음
- Try-Catch is a Lie: Why TypeScript Error Handling is Broken