TypeScript 백엔드에서 에러 정보를 타입에 담는 법: Effect-TS 실전 가이드
try { ... } catch (e: unknown) { ... } 패턴을 쓸 때마다 catch 블록에서 e를 꺼내려면 타입 단언(as Error)이 필요하고, 어떤 에러가 날 수 있는지 함수 시그니처만으로는 알 수 없습니다. 코드는 컴파일을 통과하는데 런타임에 터지는 상황, TypeScript 백엔드를 운영하다 보면 반드시 만납니다.
NestJS 커스텀 필터와 Sentry 조합으로 버틸 수 있지만, 팀이 커지고 코드베이스가 복잡해질수록 "이 함수가 어떤 에러를 던질 수 있는가"를 파악하기 위해 구현 코드를 직접 읽어야 하는 시간이 늘어납니다. 에러를 타입 시스템 바깥으로 밀어내면, 컴파일러가 도움을 줄 수 없습니다.
이 문제에 접근하는 방법은 스펙트럼이 있습니다. 기존 코드베이스에 부분 도입이 가능한 가벼운 방법으로는 neverthrow가 있습니다. Result<T, E> 타입으로 에러를 값으로 다루는 방식인데, 러닝 커브가 낮고 기존 async/await 코드와 섞어 쓰기 쉽습니다. Effect-TS는 반대쪽 극단에 있습니다. 에러 처리뿐 아니라 의존성 주입, 동시성, 재시도, 스트림까지 하나의 패러다임으로 통합하는 풀스택 런타임입니다.
이 글은 Effect-TS의 핵심 개념인 타입 안전 에러 처리, Context와 Layer를 통한 의존성 주입, 그리고 이를 실제 백엔드 서비스에 적용하는 방법을 설명합니다. v3.x 기준 코드이며, TypeScript 백엔드를 이미 운영 중인 분들을 대상으로 합니다.
Effect<A, E, R> — 세 개의 타입 파라미터
Effect-TS의 모든 것은 이 타입에서 출발합니다.
Effect<A, E, R>
// ↑ ↑ ↑
// │ │ └── Requirements: 실행에 필요한 서비스(의존성) 목록
// │ └───── Error: 발생 가능한 예상 에러 타입의 union
// └──────── Success: 성공 시 반환값 타입기존 Promise<User>는 내부에서 무슨 에러가 날 수 있는지 아무것도 알려주지 않습니다.
// 기존 방식 — 에러 정보가 타입에 없음
async function fetchUser(id: string): Promise<User> { ... }
// Effect 방식 — 에러와 의존성이 타입에 드러남
const fetchUser = (id: string): Effect.Effect<User, NotFoundError | DatabaseError, DatabaseService> => ...두 슬롯이 강제하는 것은 성격이 다릅니다.
- R 슬롯:
never가 될 때까지 모든 의존성을 제공하지 않으면Effect.runPromise를 호출할 수 없습니다. 컴파일 타임 하드 제약입니다. - E 슬롯: 에러가 남아 있어도 컴파일은 통과하고 실행도 됩니다. 단, 처리되지 않은 에러는 런타임에 unhandled failure가 되어 Promise rejection으로 전파됩니다. E 슬롯의 가치는 컴파일 강제보다 설계 압력(design pressure) 에 있습니다—어떤 에러가 날 수 있는지를 타입으로 문서화하고,
catchTag로 각각을 명시적으로 처리하도록 유도하는 구조입니다.
Expected Error와 Defect — 타입에 찍히는 에러, 찍히지 않는 에러
"모든 에러가 타입에 담긴다"는 말은 부정확합니다. Effect는 에러를 두 종류로 구분합니다.
Expected Error: 비즈니스 로직에서 예상하는 실패입니다. Effect.fail로 생성하며 E 슬롯에 기록됩니다. catchTag로 처리 가능합니다.
Defect: 예기치 않은 런타임 오류입니다. Effect.die로 명시하거나, 외부 라이브러리가 던진 예외가 잡히지 않을 때 발생합니다. E 슬롯에 기록되지 않으며, Effect.sandbox나 Effect.catchAllCause로만 잡을 수 있습니다.
// Expected Error — E 슬롯에 기록, catchTag로 처리 가능
const findUser = (id: string) =>
id === "" ? Effect.fail(new NotFoundError({ id })) : Effect.succeed({ id, name: "Alice" })
// 타입: Effect<{ id: string; name: string }, NotFoundError, never>
// Defect — E 슬롯에 기록되지 않음
const badDivide = (a: number, b: number) =>
b === 0
? Effect.die("0으로 나눌 수 없음")
: Effect.succeed(a / b)
// 타입: Effect<number, never, never> ← 에러가 E에 보이지 않음!
// 외부 라이브러리 throw를 Expected Error로 변환하려면 tryPromise 사용
const queryDb = (sql: string) =>
Effect.tryPromise({
try: () => pool.query(sql),
catch: (err) => new DatabaseError({ message: String(err) }), // E 슬롯에 기록됨
})외부 라이브러리를 Effect.tryPromise 없이 그냥 호출하면 그 throw는 Defect가 됩니다. Expected Error로 타입에 담으려면 항상 Effect.tryPromise나 Effect.try로 감싸야 합니다.
Typed Errors — 에러를 값으로 정의하기
Effect는 에러를 Tagged Error 클래스로 정의해서 타입 추론의 대상으로 만듭니다.
import { Data, Effect } from "effect"
class NotFoundError extends Data.TaggedError("NotFoundError")<{
id: string
}> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{
message: string
cause?: unknown
}> {}Data.TaggedError는 _tag 필드를 자동으로 붙여서, catchTag로 분기할 때 타입 좁히기(narrowing)가 완벽하게 동작합니다.
const findUser = (id: string) =>
Effect.gen(function* () {
const db = yield* DatabaseService
const row = yield* db.query(`SELECT * FROM users WHERE id = $1`, [id])
if (!row) {
return yield* Effect.fail(new NotFoundError({ id }))
}
return row as User
})
// 반환 타입: Effect<User, NotFoundError | DatabaseError, DatabaseService>Effect.gen 안에서 yield*는 await처럼 동작하면서, 실패 가능성을 E 슬롯에 자동으로 누적합니다. 에러를 처리하는 쪽은 이렇게 씁니다.
const result = yield* findUser("123").pipe(
Effect.catchTag("NotFoundError", (err) =>
Effect.succeed({ id: err.id, name: "Unknown" })
),
Effect.catchTag("DatabaseError", (err) => {
console.error(`DB 에러: ${err.message}`)
return Effect.fail(new ServiceUnavailableError())
})
)catchTag로 각 에러 타입을 처리할 때마다 E 슬롯에서 해당 타입이 빠집니다. 모든 에러를 catchTag로 처리하면 E가 never가 되어 런타임 unhandled failure 위험이 없어집니다.
Context & Layer — 타입으로 검증되는 의존성 주입
NestJS의 @Injectable()과 DI 컨테이너에 익숙하다면 Layer 개념이 낯설지 않습니다. 차이점은 의존성 관계가 런타임 리플렉션이 아니라 타입 레벨에서 검증된다는 점입니다.
서비스는 Effect.Service를 상속해 정의합니다 (v3에서 Context.Service가 아닌 Effect.Service입니다).
import { Effect, Layer } from "effect"
class DatabaseService extends Effect.Service<DatabaseService>()(
"DatabaseService",
{
effect: Effect.gen(function* () {
// 기본 스텁 — 실제 앱에서는 PgDatabaseLayer로 반드시 교체
return {
query: (_sql: string, _params: unknown[]): Effect.Effect<unknown[], DatabaseError> =>
Effect.die("DatabaseService not provided"),
}
}),
}
) {}실제 구현은 Layer.effect로 분리합니다. 의존성 주입은 서비스 클래스 정의가 아닌, Layer 안에서 yield*로 처리합니다.
// PostgreSQL 구현 레이어
const PgDatabaseLayer = Layer.effect(
DatabaseService,
Effect.gen(function* () {
const config = yield* AppConfig // 이 Layer가 AppConfig에 의존함을 타입으로 선언
const pool = new Pool({ connectionString: config.databaseUrl })
return {
query: (sql: string, params: unknown[]) =>
Effect.tryPromise({
try: () => pool.query(sql, params),
catch: (err) => new DatabaseError({ message: String(err) }),
}),
}
})
)
// 테스트용 인메모리 레이어
const InMemoryDatabaseLayer = Layer.succeed(DatabaseService, {
query: (_sql, _params) => Effect.succeed([{ id: "1", name: "test" }]),
})레이어를 조합하면 의존성 그래프가 타입으로 표현됩니다.
const AppLayer = Layer.mergeAll(
PgDatabaseLayer, // 내부에서 AppConfig를 yield*로 참조
HttpClientLayer,
LoggerLayer,
AppConfigLayer // PgDatabaseLayer가 필요로 하는 의존성
)
Effect.runPromise(
mainProgram.pipe(Effect.provide(AppLayer))
)테스트할 때는 InMemoryDatabaseLayer만 교체하면 됩니다. DI 컨테이너 설정도, Jest mock 설정도 필요 없습니다.
Effect.gen — generator를 async/await처럼
function*와 yield* 문법이 낯설 수 있지만, 패턴은 async/await와 거의 같습니다.
// async/await 버전
async function createOrder(data: OrderData) {
const user = await getUser(data.userId)
const stock = await checkStock(data.itemId)
if (stock < data.quantity) throw new InsufficientStockError()
return await saveOrder({ user, ...data })
}
// Effect.gen 버전
const createOrder = (data: OrderData) =>
Effect.gen(function* () {
const user = yield* getUser(data.userId)
const stock = yield* checkStock(data.itemId)
if (stock < data.quantity) {
return yield* Effect.fail(new InsufficientStockError({ available: stock }))
}
return yield* saveOrder({ user, ...data })
})yield* 하나하나가 await처럼 동작하고, 실패는 예외를 던지는 대신 E 슬롯에 타입을 추가합니다.
실전 적용
시나리오 1 — HTTP 핸들러에서 에러 계층 나누기
@effect/platform의 HttpRouter를 사용하는 패턴입니다. 핸들러는 Effect이지만, HttpRouter가 매 요청마다 해당 Effect를 재실행하면서 HttpServerRequest를 컨텍스트에 주입합니다. NestJS 컨트롤러 메서드가 매 요청마다 호출되는 것과 같은 방식입니다.
import {
HttpRouter, HttpServer, HttpServerRequest,
HttpServerResponse, HttpMiddleware
} from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Schema, Layer } from "effect"
import { createServer } from "node:http"
const CreateUserBody = Schema.Struct({
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
name: Schema.String.pipe(Schema.minLength(1)),
})
const createUserHandler = Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(
Effect.flatMap(Schema.decodeUnknown(CreateUserBody)),
// JSON 파싱 오류와 스키마 검증 오류를 함께 400으로 처리
// 두 케이스의 로깅 전략을 달리해야 한다면 mapError 이전에 분리할 것
Effect.mapError((err) => new ValidationError({ message: String(err) }))
)
const userService = yield* UserService
const user = yield* userService.create(body)
return HttpServerResponse.json(user, { status: 201 })
}).pipe(
Effect.catchTag("ValidationError", (err) =>
HttpServerResponse.json({ error: err.message }, { status: 400 })
),
Effect.catchTag("DuplicateEmailError", (_err) =>
HttpServerResponse.json({ error: "이미 사용 중인 이메일입니다." }, { status: 409 })
),
Effect.catchTag("DatabaseError", (err) =>
Effect.gen(function* () {
const logger = yield* Logger
yield* logger.error("DB 에러 발생", { cause: err.message })
return HttpServerResponse.json({ error: "서버 오류" }, { status: 500 })
})
)
)
// 라우터에 등록하고 서버 실행
const router = HttpRouter.empty.pipe(
HttpRouter.post("/users", createUserHandler)
)
const app = router.pipe(HttpServer.serve(HttpMiddleware.logger))
const ServerLayer = NodeHttpServer.layer(() => createServer(), { port: 3000 })
NodeRuntime.runMain(
Layer.launch(Layer.provide(app, Layer.merge(ServerLayer, AppLayer)))
)각 catchTag를 적용할 때마다 E 슬롯에서 해당 타입이 제거됩니다. 처리되지 않은 에러 타입이 남아 있으면 어떤 에러가 아직 처리되지 않았는지 타입 정보로 즉시 파악할 수 있습니다.
시나리오 2 — 외부 API 클라이언트를 Layer로 감싸기
외부 결제 API처럼 실패 가능성이 있는 클라이언트를 래핑하는 패턴입니다. 의존성(HttpClient)은 서비스 클래스 정의가 아닌, Live Layer 안에서 yield*로 주입합니다.
import { Effect, Layer, Schedule, Data } from "effect"
import { HttpClient } from "@effect/platform"
class PaymentGatewayError extends Data.TaggedError("PaymentGatewayError")<{
statusCode: number
}> {}
class PaymentService extends Effect.Service<PaymentService>()(
"PaymentService",
{
effect: Effect.gen(function* () {
return {
charge: (_amount: number, _currency: string): Effect.Effect<ChargeResult, PaymentGatewayError> =>
Effect.die("PaymentService.Live가 제공되지 않았습니다"),
}
}),
}
) {
static readonly Live = Layer.effect(
PaymentService,
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient // Layer 레벨에서 주입
return {
charge: (amount: number, currency: string) =>
httpClient.post("https://api.payment.example/charge").pipe(
Effect.flatMap((res) =>
res.status >= 400
? Effect.fail(new PaymentGatewayError({ statusCode: res.status }))
: res.json // res.json은 Effect이므로 flatMap 체인에서 자연스럽게 처리됨
),
// 지수 백오프 재시도 — 선언적으로 표현
Effect.retry(
Schedule.exponential("100 millis").pipe(
Schedule.intersect(Schedule.recurs(3))
)
)
),
}
})
)
}Schedule.exponential로 지수 백오프 재시도를 단 두 줄에 선언적으로 표현합니다. 직접 구현하면 setTimeout, 루프, 카운터 변수가 얽히는 코드가 됩니다.
시나리오 3 — 레이어로 환경 분리하기
import { Either } from "effect"
const TestLayer = Layer.mergeAll(
InMemoryDatabaseLayer,
MockPaymentServiceLayer,
ConsoleLoggerLayer
)
const ProductionLayer = Layer.mergeAll(
PgDatabaseLayer,
PaymentService.Live,
StructuredLoggerLayer
)
// 테스트
describe("createOrder", () => {
it("재고 부족 시 InsufficientStockError를 반환한다", async () => {
const result = await Effect.runPromise(
createOrder({ itemId: "item-1", quantity: 999 }).pipe(
Effect.provide(TestLayer),
Effect.either
)
)
// Either.isLeft로 타입 좁히기 — as any 없이 타입 안전하게 검증
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left._tag).toBe("InsufficientStockError")
}
})
})Effect.provide(TestLayer) 한 줄로 전체 의존성을 교체합니다. Effect를 쓰는 목적이 타입 안전성인 만큼, 테스트 코드에서도 Either.isLeft로 타입 좁히기를 통해 as any 없이 에러를 검증합니다.
시나리오 4 — 병렬 실행과 구조화된 동시성
const getDashboardData = (userId: string) =>
Effect.gen(function* () {
const [orders, notifications, profile] = yield* Effect.all(
[
orderService.getRecent(userId),
notificationService.getUnread(userId),
userService.getProfile(userId),
],
{ concurrency: 3 }
)
return { orders, notifications, profile }
})Effect.all은 배열 안 Effect를 병렬로 실행하고, 하나라도 실패하면 나머지를 자동으로 취소합니다. Promise.all은 하나가 reject되어도 나머지 Promise가 백그라운드에서 계속 실행됩니다. Effect의 파이버(Fiber) 기반 동시성은 하나가 실패하면 나머지를 즉시 취소하고 자원을 해제합니다. 이 차이가 메모리 누수와 예기치 않은 부작용의 원인을 구조적으로 차단합니다.
장단점
장점
| 항목 | 내용 |
|---|---|
| 에러 타입 추적 | 발생 가능한 예상 에러가 E 슬롯에 명시됨. 어떤 에러가 처리되지 않았는지 타입으로 파악 가능 |
| 의존성 명시화 | 함수가 무엇에 의존하는지 R 슬롯으로 드러남. 의존성 누락은 컴파일 에러 |
| 테스트 용이성 | 레이어 교체만으로 모킹 완료. DI 컨테이너 설정 불필요 |
| 구조화된 동시성 | 파이버 기반으로 취소·자원 해제 보장 |
| 선언적 재시도·스케줄 | Schedule로 복잡한 재시도 로직을 간결하게 표현 |
| 내장 관찰가능성 | @effect/opentelemetry로 스팬 자동 계측 |
| 단일 생태계 | 스트림, 캐싱, Schema, SQL 클라이언트가 하나의 패러다임 |
단점
| 항목 | 내용 |
|---|---|
| 전염성(Viral) | Effect를 반환하는 함수를 쓰면 호출자도 Effect를 써야 함. 기존 코드베이스와의 경계 관리가 복잡 |
| 학습 곡선 | Fiber, Layer, Cause, Defect, Schedule, Scope를 한꺼번에 익혀야 함. 팀 생산성 회복에 3~6주 |
| 생태계 미성숙 | Effect-native 라이브러리가 적어 외부 라이브러리 연동 시 interop 코드 필요 |
| 번들 크기 | 기능이 방대한 만큼 번들이 큼. 서버사이드에 적합 |
| 코드 가독성 | function*, yield* 문법이 팀 컨벤션 합의를 필요로 함 |
실무에서 흔한 실수
1. 기존 코드베이스에 부분 도입 시도
Express나 NestJS 컨트롤러 일부에만 Effect를 삽입하면, 경계 지점에서 Effect.runPromise를 반복적으로 호출하게 됩니다. 이렇게 되면 구조화된 에러 추적이 경계에서 끊기고, async/await 코드와 Effect 코드를 번갈아 읽는 인지 부하가 생깁니다. 기존 코드베이스에 조금씩 삽입하는 전략은 대부분 interop 비용으로 돌아옵니다. 부분 도입을 원한다면 neverthrow가 현실적인 선택입니다.
2. 에러 타입을 너무 넓게 정의하기
DB 연결 실패, 쿼리 타임아웃, 유니크 제약 위반을 전부 DatabaseError 하나로 뭉개면 E 슬롯의 정보가 무의미해집니다. 구분 가능한 Tagged Error로 나눠야 catchTag가 의미 있게 동작합니다.
3. 비즈니스 로직 내부에서 Effect.runPromise 호출
runPromise는 애플리케이션 진입점에서만 호출해야 합니다. 내부에서 호출하면 취소 보장이 깨집니다.
어떤 상황에서 도입할 것인가
Effect는 "전부 아니면 전무(all-or-nothing)"에 가까운 프레임워크입니다. 기존 코드베이스에 조금씩 녹여 넣는 것보다, 새 프로젝트에서 처음부터 일관되게 쌓을 때 ROI가 가장 높습니다.
마치며
Effect-TS가 다루는 문제는 두 가지입니다. 첫째, 에러가 타입 바깥에 있어서 컴파일러가 어떤 에러가 날 수 있는지 알 수 없는 문제. 둘째, 의존성이 암묵적이어서 함수를 읽어봐야 무엇이 필요한지 알 수 있는 문제. 이 두 가지를 Effect<A, E, R>이라는 하나의 타입으로 끌어안습니다.
다만 한 가지는 분명히 해두는 것이 좋습니다. "모든 에러가 타입에 담긴다"는 Expected Error에만 해당하고, E 슬롯에 에러가 남아 있어도 컴파일은 통과합니다. 그리고 기존 코드베이스에 조금씩 삽입하는 전략은 대부분 interop 비용으로 돌아옵니다. 이 두 가지를 이해하고 도입하는 것과 과장된 기대를 가지고 도입하는 것은 팀의 만족도에 큰 차이를 만듭니다.
지금 시작해본다면 이 순서가 현실적입니다.
- 공식 문서의 "Why Effect?" 챕터 읽기 — 철학을 먼저 이해하면 API 설계 의도가 납득됩니다.
- 새 CLI 도구나 스크립트에서 먼저 실험 — 기존 코드베이스와 분리된 환경에서
Effect.gen과Data.TaggedError의 감각을 익힙니다. 이 단계는 학습이지 프로덕션 통합이 아닙니다. - 새 마이크로서비스에서
@effect/platform과 함께 전체 도입 — HTTP 핸들러부터 DB 레이어까지 Effect로 일관되게 쌓으면,E슬롯이 자동으로 채워지는 경험이 왜 이 설계를 선택했는지 답해줍니다.
catch (e: unknown) 없이 에러를 타입으로 추적하는 코드베이스는 충분히 구현 가능합니다. Effect-TS는 그 중에서 가장 완성도 높은 방법 중 하나입니다.
참고 자료
- Effect 공식 문서 — Why Effect?
- Effect 공식 문서 — Expected Errors
- Effect 공식 문서 — Managing Layers
- Effect 공식 문서 — Fibers
- Effect 공식 문서 — Introduction to Effect Schema
- Effect GitHub Repository
- Effect-TS in 2026: Functional Programming for TypeScript That Actually Makes Sense
- Building a backend with Effect-TS: 6 patterns from Inkpipe
- Why We Love Functional Programming but Don't Use Effect-TS
- Effect vs fp-ts 공식 비교
- Error Handling in TypeScript: Neverthrow, Try-Catch, and EffectTS 비교
- EffectPatterns — 커뮤니티 주도 패턴 모음