비즈니스 로직의 실패 경로를 타입으로 추적하기
프로덕션 코드에서 catch (e: unknown) 블록 안을 들여다보면 반복되는 패턴이 있습니다. e의 타입을 알 수 없으니 any로 캐스팅하고, 로그만 찍고 넘어갑니다. TypeScript를 쓰는데도 에러 경로에서만큼은 동적 언어처럼 동작하는 것입니다.
문제는 비즈니스 도메인이 복잡해질수록 "어떤 에러가 발생할 수 있는가"가 함수 시그니처 어디에도 드러나지 않는다는 점입니다. UserService.createUser()가 ValidationError를 던지는지, DuplicateEmailError를 던지는지, DatabaseError를 던지는지 — 주석을 뒤지거나 소스를 열어봐야만 알 수 있습니다. 팀이 커지고 코드베이스가 늘어날수록 이 숨겨진 지식은 버그가 됩니다.
Railway-Oriented Programming(ROP) 은 이 문제를 발상 자체를 뒤집어 해결합니다. 에러를 예외로 던지는 대신, 에러를 반환 타입에 담아서 흘려보냅니다. 그리고 Effect-TS는 그 개념을 TypeScript 생태계에서 가장 실용적으로 구현한 라이브러리입니다. 이 글에서는 ROP의 핵심 원리부터, Effect-TS로 복잡한 주문 처리 로직을 타입 안전하게 구성하는 과정까지 살펴보겠습니다.
Railway-Oriented Programming이란?
F# 커뮤니티의 Scott Wlaschin이 체계화한 이 패턴은 이름 그대로 "두 선로를 가진 철도"를 상상하면 이해가 빠릅니다.
값은 성공 선로(happy path) 를 따라 단계별로 변환되고, 어느 단계에서든 실패가 발생하면 에러 선로로 전환되어 이후 모든 단계를 건너뜁니다. 핵심은 에러가 예외(throw)로 탈출하지 않고 반환값으로 흘러간다는 것입니다.
// 기존 방식: 에러가 숨어있다
async function processOrder(userId: string): Promise<Order> {
// 이 함수가 무엇을 throw할 수 있는지 타입에서 알 수 없다
}
// ROP 방식: 실패 경로가 타입에 드러난다
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
function processOrder(userId: string): Result<Order, UserNotFound | InsufficientStock>
// 함수 시그니처만 봐도 "무엇이 잘못될 수 있는가"가 보인다★ Insight ─────────────────────────────────────
- ROP는 에러를 타입 시스템의 일급 시민으로 만듭니다.
throw는 타입 시스템 밖으로 탈출하지만,Result반환은 컴파일러가 추적할 수 있습니다. - "실패도 데이터다"라는 발상이 핵심입니다. 에러를 예외로 처리하면 제어 흐름이 숨지만, 값으로 처리하면 합성(composition)이 가능해집니다.
─────────────────────────────────────────────────
단순 Result<T, E>만으로는 부족한 이유
neverthrow 같은 라이브러리의 Result<T, E>로도 에러를 타입에 담는 것은 가능합니다. 그런데 실무의 비즈니스 로직에는 세 가지 문제가 더 있습니다.
첫째, 비동기 체이닝이 어렵습니다. 각 단계가 Promise<Result<T, E>>를 반환하면 성공값을 꺼내기 위해 await 후 .ok 분기를 반복해야 합니다. 둘째, 의존성 추적이 없습니다. 이 함수를 실행하려면 DatabaseService가 필요하다는 정보가 타입에 남지 않습니다. 셋째, 구조화된 동시성이 없습니다. 병렬 실행 중 하나가 실패했을 때 나머지 리소스를 안전하게 정리하는 것이 수동 작업이 됩니다.
Effect-TS는 이 세 문제를 하나의 타입으로 통합해 해결합니다.
Effect-TS의 세 가지 타입 파라미터
Effect-TS(패키지명: effect)의 핵심 타입은 Result<T, E>를 한 단계 더 확장합니다.
Effect<Success, Error, Requirements>
// ↑성공값 ↑에러 합집합 ↑실행에 필요한 서비스세 번째 파라미터 Requirements가 독특합니다. 이 Effect를 실행하려면 어떤 서비스가 주입되어야 하는지도 타입에 포함됩니다. DI 컨테이너 역할까지 타입이 담당하는 셈입니다.
import { Effect, Context, Layer, Data } from "effect"
// 에러 타입 정의 (아래 섹션에서 자세히 다룹니다)
class UserNotFound extends Data.TaggedError("UserNotFound")<{ userId: string }> {}
// 서비스 인터페이스를 Tag로 정의
class UserRepo extends Context.Tag("UserRepo")<
UserRepo,
{ findById: (id: string) => Promise<User | null> }
>() {}
// 세 파라미터 모두 활용하는 Effect
const findUser = (userId: string): Effect.Effect<User, UserNotFound, UserRepo> =>
Effect.gen(function* () {
const repo = yield* UserRepo // Requirements에서 꺼내기
const raw = yield* Effect.promise(() => repo.findById(userId))
if (!raw) return yield* Effect.fail(new UserNotFound({ userId }))
return raw
})
// Layer로 실제 구현 제공
const UserRepoLive = Layer.succeed(UserRepo, {
findById: (id) => db.users.findUnique({ where: { id } })
})
// provide()로 Requirements를 충족해야 실행 가능
const program = findUser("u-1").pipe(Effect.provide(UserRepoLive))
// program 타입: Effect<User, UserNotFound, never>
// ↑ Requirements가 never → 실행 가능
await Effect.runPromise(program)async/await을 쓸 때는 어떤 서비스에 의존하는지, 어떤 에러를 던질 수 있는지 — 둘 다 타입에서 사라집니다. Effect는 그 정보를 모두 타입에 포함시킵니다.
★ Insight ─────────────────────────────────────
Requirements가never가 아닌 Effect는runPromise에 넘길 수 없습니다. 의존성을provide()로 충족하지 않으면 컴파일 에러가 납니다. 이 강제성이 "서비스 누락"을 런타임이 아닌 빌드 시점에 잡아냅니다.Context.Tag로 정의한 서비스는 테스트에서Layer.succeed(UserRepo, mockImpl)로 간단히 교체됩니다. 별도의 DI 프레임워크 없이 타입 시스템이 의존성 그래프를 추적합니다.─────────────────────────────────────────────────
Tagged Error — 에러의 신원증명
ROP에서 에러 선로 분기가 제대로 동작하려면 각 에러를 런타임에서도 식별할 수 있어야 합니다. Effect-TS는 Data.TaggedError로 이를 해결합니다.
import { Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string
}> {}
class InsufficientStock extends Data.TaggedError("InsufficientStock")<{
itemId: string
requested: number
available: number
}> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
reason: string
}> {}_tag 필드가 자동으로 부여되어, 컴파일타임 분기(catchTag)와 런타임 분기(switch)가 동일한 기준으로 동작합니다. 에러 타입 정의 하나가 분류·처리·직렬화의 단일 진실 원천(single source of truth)이 됩니다.
미처리 케이스는 에러 채널 타입에 그대로 남습니다. catchAll에 exhaustive check를 추가하면 이 누락을 컴파일 단계에서 잡을 수 있습니다(아래 섹션에서 코드로 확인합니다).
Effect.gen — async/await처럼 읽히는 Effect 코드
처음 Effect를 접하면 파이프 체이닝이 낯설게 느껴질 수 있습니다. Effect.gen의 제너레이터 문법을 쓰면 async/await과 거의 동일하게 읽히면서도 모든 에러가 타입으로 추적됩니다.
아래 코드는 각 함수의 타입 시그니처를 먼저 보여주고, 이를 조합하는 예시입니다.
import { Effect } from "effect"
// ── 타입 시그니처 (각 도메인 모듈에서 실제 구현) ──────────────────────
// findUser: (id: string) => Effect.Effect<User, UserNotFound>
// loadCart: (id: string) => Effect.Effect<Cart, CartNotFound>
// reserveStock: (cart: Cart) => Effect.Effect<Reservation, InsufficientStock>
// chargeUser: (user: User, reservation: Reservation) => Effect.Effect<Payment, PaymentDeclined>
// createOrder: (user: User, reservation: Reservation, payment: Payment) => Effect.Effect<Order, never>
// ── 조합 ──────────────────────────────────────────────────────────────
const processOrder = (userId: string, cartId: string) =>
Effect.gen(function* () {
// yield*로 Effect를 실행하고 성공값을 꺼냄
// 실패하면 즉시 에러 선로로 전환 — 이후 코드는 실행되지 않음
const user = yield* findUser(userId)
const cart = yield* loadCart(cartId)
const reserved = yield* reserveStock(cart)
const payment = yield* chargeUser(user, reserved)
return yield* createOrder(user, reserved, payment)
})
// 반환 타입 (TypeScript가 자동 추론):
// Effect<Order, UserNotFound | CartNotFound | InsufficientStock | PaymentDeclined, never>
// ↑ 발생 가능한 모든 에러가 타입에 자동으로 합산됩니다★ Insight ─────────────────────────────────────
yield*로 Effect를 실행하면 성공값만 꺼내오고, 에러는 자동으로 에러 채널로 흘러갑니다.async/await에서await가Promise를 unwrap하는 것과 같은 원리입니다.- 각 함수의 에러 타입이 자동으로 합산(
union)되므로, 스텝을 추가하거나 제거하면 에러 타입도 자동으로 갱신됩니다. 수동으로 유니온을 관리할 필요가 없습니다.─────────────────────────────────────────────────
catchTag로 에러 선로 분기 처리하기
processOrder가 반환하는 에러 타입에는 네 가지가 있습니다. HTTP 핸들러 레이어에서 이를 단계적으로 처리합니다.
import { Effect } from "effect"
// 도메인 레이어: 일부 에러만 처리하고 나머지는 타입에 남긴다
const processOrderWithApiErrors = (userId: string, cartId: string) =>
processOrder(userId, cartId).pipe(
// UserNotFound를 처리 → 에러 타입에서 제거됨
Effect.catchTag("UserNotFound", (e) =>
Effect.fail(new ApiError({ status: 404, message: `사용자 ${e.userId}를 찾을 수 없습니다` }))
),
Effect.catchTag("PaymentDeclined", (e) =>
Effect.fail(new ApiError({ status: 402, message: e.reason }))
)
// 반환 타입: Effect<Order, ApiError | InsufficientStock | CartNotFound, never>
// 아직 처리되지 않은 에러가 타입에 그대로 남아있다
)// HTTP 핸들러 레이어: catchAll + exhaustive check로 모든 에러를 처리
const handler = processOrderWithApiErrors(userId, cartId).pipe(
Effect.catchAll((error) => {
switch (error._tag) {
case "ApiError":
return Effect.succeed({ status: error.status, body: error.message })
case "InsufficientStock":
return Effect.succeed({ status: 409, body: "재고가 부족합니다" })
case "CartNotFound":
return Effect.succeed({ status: 404, body: "장바구니를 찾을 수 없습니다" })
default: {
// 이 줄에서 컴파일 에러가 나면 처리하지 않은 케이스가 있다는 증거
const _exhaustive: never = error
return _exhaustive
}
}
})
)
// 반환 타입: Effect<{ status: number; body: string }, never, never>
// Error 채널이 never → 모든 에러가 처리되었다는 컴파일타임 보증_exhaustive: never = error 할당은 TypeScript 구문만으로 exhaustive check를 강제합니다. switch에 케이스를 빠뜨리면 error가 never가 아닌 타입으로 남아 컴파일 에러가 납니다.
Schema와 결합한 입력 검증
Effect는 자체 Schema 모듈을 내장하고 있어, 런타임 검증 실패가 Effect 에러 채널로 흘러들어 동일한 파이프라인에서 처리됩니다.
import { Schema, Effect } from "effect"
const CreateOrderSchema = Schema.Struct({
userId: Schema.String,
cartId: Schema.String,
items: Schema.Array(
Schema.Struct({
id: Schema.String,
qty: Schema.Number.pipe(Schema.positive()) // Schema.Positive는 없음. 이렇게 써야 한다
})
)
})
type CreateOrderDTO = Schema.Schema.Type<typeof CreateOrderSchema>
// raw 입력을 검증하고 Effect로 반환
const parseBody = (raw: unknown) =>
Schema.decodeUnknown(CreateOrderSchema)(raw)
// 반환: Effect<CreateOrderDTO, ParseError, never>
// HTTP 핸들러에서 자연스럽게 연결
const handleRequest = (rawBody: unknown) =>
Effect.gen(function* () {
const dto = yield* parseBody(rawBody) // ParseError
const order = yield* processOrder(dto.userId, dto.cartId) // 도메인 에러들
return order
}).pipe(
Effect.catchTag("ParseError", () =>
Effect.fail(new ApiError({ status: 400, message: "잘못된 요청 형식입니다" }))
)
// 이후 도메인 에러 처리 체인 연결...
)Effect 실행하기
파이프라인을 구성하는 것과 실행하는 것은 별개입니다. 구성된 Effect는 설명서일 뿐, 실제 실행은 Effect.runPromise (또는 동기 실행이 필요한 경우 Effect.runSync)를 통해 이루어집니다.
// 파이프라인 구성 — 아직 아무것도 실행되지 않는다
const program = handleRequest(req.body).pipe(
Effect.provide(UserRepoLive)
)
// 실행 — 여기서 비로소 비동기 작업이 시작된다
const result = await Effect.runPromise(program)
// ↑ Error 채널이 never여야 컴파일 통과
// 에러가 남아있으면 런타임에 예외로 전환됨
// 에러를 직접 다루고 싶다면 runPromiseExit 사용
const exit = await Effect.runPromiseExit(program)
// Exit<결과값, 에러> — 성공/실패 양쪽을 명시적으로 처리 가능Effect.runPromise는 에러 채널에 never가 아닌 타입이 남아있어도 컴파일 에러를 내지는 않습니다. 대신 처리되지 않은 에러는 런타임 예외로 변환됩니다. 앞서 _exhaustive: never = error 패턴이 중요한 이유가 여기 있습니다 — catchAll에서 완전히 처리되었는지를 빌드 시점에 보장해야 런타임 놀라움이 없습니다.
레거시 Promise 코드 점진적 통합
기존 코드베이스를 한 번에 전환하기는 어렵습니다. Effect.tryPromise를 사용하면 기존 async 함수를 Effect로 래핑하면서 경계를 점진적으로 넓혀나갈 수 있습니다.
// 기존 레거시 코드 — 변경 없음
async function legacyFindUser(id: string): Promise<User | null> {
return db.users.findById(id)
}
// Effect 경계에서 래핑
const findUser = (userId: string): Effect.Effect<User, UserNotFound> =>
Effect.tryPromise({
try: async () => {
const user = await legacyFindUser(userId)
if (!user) throw new Error("not found")
return user
},
catch: () => new UserNotFound({ userId })
})
// 이제 findUser는 Effect 파이프라인에서 사용할 수 있다전체를 한 번에 전환하려 하면 팀 내 저항도 크고 위험도 높습니다. Promise → Effect 경계를 점진적으로 넓혀가는 방식이 현실적입니다. 도메인에서 가장 에러 종류가 많은 모듈 하나를 골라 시작하면 효과가 빠르게 느껴집니다.
장단점 분석
언제 Effect-TS가 빛을 발하나
| 장점 | 설명 |
|---|---|
| 컴파일타임 에러 완전성 | catchAll + exhaustive check로 처리하지 않은 에러 케이스를 빌드 단계에서 발견합니다. |
| 실패 경로가 공개 API | 함수 시그니처만 보면 "이 함수가 무엇이 잘못될 수 있는지" 알 수 있습니다. 주석이 필요 없습니다. |
| 구조화된 동시성 내장 | Effect.all, Effect.race, 파이버 기반 취소가 기본 제공됩니다. 리소스 정리 누락 문제가 타입 레벨에서 해결됩니다. |
| 단일 프레임워크 통합 | DI 컨테이너, 로거, OpenTelemetry 추적이 Effect 하나로 연결됩니다. |
| Effect v4에서 번들 크기 축소 | v3에서 약 70kB이던 번들이 v4 베타에서 약 20kB로 감소했고, 패키지 구조도 단순화됩니다. |
주의해야 할 지점들
| 단점 | 설명 |
|---|---|
| 학습 곡선이 가파릅니다 | Generator 문법, Tag 기반 DI, Layer 합성, 파이버 모델은 Express/Next.js에 익숙한 팀에게 낯선 패러다임입니다. |
| 과잉 엔지니어링 위험 | 단순 CRUD API에 Effect를 도입하면 코드량이 오히려 늘어납니다. Harbor 팀이 이 이유로 Effect를 채택하지 않은 사례를 공개하기도 했습니다. |
| 생태계 호환 비용 | 대부분의 Node.js 라이브러리는 Promise 기반입니다. Effect.tryPromise 래핑 레이어가 필요하고, 경계 설계를 신중히 해야 합니다. |
| v3 → v4 마이그레이션 | Schema API 변경, 패키지 통합 등 Breaking Change가 상당합니다. 프로덕션 도입 전 마이그레이션 가이드 확인이 필수입니다. |
도구 선택 기준
| 상황 | 추천 |
|---|---|
| ROP 개념 학습, 소규모 유틸리티 | neverthrow 또는 ts-railway |
| 중~대규모 백엔드, 복잡한 도메인 로직 | effect |
레거시 fp-ts 코드베이스 유지보수 |
fp-ts 유지 + 신규 모듈은 effect로 시작 |
| AI 파이프라인, 구조화된 동시성 필요 | effect (내장 OpenTelemetry 트레이싱 포함) |
실무에서 흔히 하는 실수
에러를 너무 일찍 처리하는 것입니다. 도메인 레이어에서 모든 에러를 Error 하나로 통합해버리면, 상위 레이어에서 에러 종류에 따른 세밀한 처리가 불가능해집니다.
// 피해야 할 패턴: 도메인 레이어에서 너무 일찍 뭉개기
const badPattern = findUser(id).pipe(
Effect.catchAll(() => Effect.fail(new Error("something went wrong")))
// 상위 레이어는 이제 에러 종류를 알 수 없다 — UserNotFound인지 DbError인지 구분 불가
)
// 권장 패턴: 에러 타입을 유지한 채 HTTP 레이어로 전달
const goodPattern = (id: string) =>
findUser(id).pipe(
// 별도의 catchAll 없이 UserNotFound 타입을 그대로 유지
Effect.map((user) => ({ ...user, processedAt: Date.now() }))
// HTTP 핸들러에서 catchTag("UserNotFound", e => ...) 로 최종 변환
)
// 반환 타입: Effect<{ ...User; processedAt: number }, UserNotFound, never>
// 에러 정보가 살아있으므로 호출자가 원하는 방식으로 처리할 수 있다에러는 HTTP 레이어 직전까지 최대한 구체적인 타입을 유지하는 것이 좋습니다.
마치며
Effect-TS로 에러 흐름을 다루는 핵심을 요약하면 이렇습니다.
try/catch는 에러를 타입 시스템 밖으로 내보내지만, Effect는 에러를 타입 시스템 안에서 데이터로 흘려보냅니다. 그 결과 "이 함수가 무엇이 잘못될 수 있는가"가 함수 시그니처에 명시되고, 처리하지 않은 에러 케이스는 컴파일 단계에서 발견됩니다. 복잡한 비즈니스 도메인에서는 이 차이가 크게 느껴집니다.
Effect가 모든 프로젝트에 맞는 답은 아닙니다. 단순한 CRUD API에는 과잉입니다. 하지만 여러 에러 종류가 교차하는 결제, 재고, 사용자 인증 같은 도메인이라면 — 그리고 팀이 학습 비용을 감당할 의지가 있다면 — 도입을 진지하게 검토할 만합니다.
지금 시작해보고 싶다면 이 세 단계를 권장합니다.
neverthrow로 ROP 감 잡기 — 가볍게Result<T, E>패턴을 경험하고, "에러를 값으로 다루는 것"이 어떤 느낌인지 확인합니다.- Effect 튜토리얼의 에러 처리 섹션부터 — 공식 문서의 Expected Errors 파트가 잘 정리되어 있습니다.
Data.TaggedError와catchTag를 작은 예제로 직접 타이핑해봅니다. - 레거시 코드 한 곳에
Effect.tryPromise적용 — 기존 Promise 함수 하나를 Effect로 래핑해보면서 타입 추론이 어떻게 바뀌는지 체감합니다.
★ Insight ─────────────────────────────────────
- Effect v4 베타(2026년 4월)는 번들 크기를 70kB에서 약 20kB로 줄이고,
@effect/platform등을 메인 패키지로 통합했습니다. 프로덕션 도입을 검토 중이라면 v4 마이그레이션 가이드를 먼저 확인하는 것이 좋습니다. fp-ts작성자(Giulio Canti)가 Effect 조직에 합류하면서 Effect가 사실상fp-ts의 후계자가 되었습니다. 새 프로젝트라면 Effect를 선택하는 것이 장기적으로 유리합니다.─────────────────────────────────────────────────
참고 자료
- Effect 공식 문서 — 소개
- Effect 공식 문서 — Expected Errors
- Effect 공식 문서 — Yieldable Errors
- Effect v4 Beta 릴리즈 노트
- Effect v4 Beta — InfoQ 분석
- Effect vs neverthrow 공식 비교
- Effect vs fp-ts 공식 비교
- Railway Oriented Programming — F# for fun and profit (원전)
- Railway Oriented Programming in TypeScript — DEV Community
- Why We Love FP but Don't Use Effect-TS — Harbor 팀 반론
- neverthrow vs Effect vs oxide.ts 2026 비교 — PkgPulse
- Effect-TS GitHub Repository
- EffectPatterns — 커뮤니티 패턴 모음