throws 대신 타입으로 에러를 추적하고, Layer로 의존성을 주입하는 Effect-TS 백엔드 설계
TypeScript 백엔드를 짜다 보면 어느 순간 이런 상황을 마주하게 됩니다. async function getUser(id: string): Promise<User> 라는 함수 시그니처를 보는데, 이 함수가 네트워크 오류를 던지는지, DB 연결 오류를 던지는지, 아니면 유저를 못 찾았을 때 null을 반환하는지 예외를 던지는지 — 함수 본문을 직접 열어보기 전까지 알 방법이 없습니다. JSDoc에 @throws를 적어두는 방법도 있지만, 이건 어디까지나 "선의의 약속"일 뿐 컴파일러는 아무것도 검증해주지 않습니다.
의존성 문제도 만만치 않습니다. UserService가 내부적으로 DatabaseConnection, CacheService, EmailClient에 의존하는데, 이 관계를 명시하지 않으면 테스트할 때마다 모킹 지옥에 빠지고, 어딘가 초기화가 빠진 채로 런타임에 터지는 Cannot read properties of undefined 에러를 만나게 됩니다. 저도 처음엔 이런 문제를 "팀 내 컨벤션"으로 해결하려 했는데, 팀이 조금만 커지면 컨벤션은 금방 무너진다는 걸 배웠습니다.
Effect-TS는 이 두 가지 문제를 타입 시스템 자체로 해결합니다. 에러는 함수 시그니처에 리터럴하게 기록되고, 의존성은 컴파일러가 충족 여부를 검증합니다. 이 글에서는 Effect<A, E, R> 타입의 구조부터 Layer를 이용한 의존성 주입까지, 실제 백엔드 코드에 바로 적용할 수 있는 흐름으로 살펴봅니다.
핵심 개념
Effect<A, E, R> — 세 글자가 바꾸는 함수 시그니처
Effect의 모든 계산은 Effect<A, E, R> 이라는 하나의 타입으로 표현됩니다.
- A (성공 값 타입): 성공했을 때 반환하는 값의 타입
- E (에러 타입): 실패할 수 있는 에러 타입 (유니온으로 여러 개 가능)
- R (요구사항 타입): 이 계산을 실행하려면 주입되어야 하는 의존성 타입
// 기존 방식: 에러 정보가 타입에서 완전히 사라짐
async function fetchUser(id: string): Promise<User> {
// DB 오류? 네트워크 오류? 유저 없음? 아무것도 알 수 없음
}
// Effect: 발생 가능한 모든 에러와 필요한 의존성이 시그니처에 드러남
const fetchUser = (id: string): Effect.Effect<User, NotFoundError | DbError, Database> => ...솔직히 처음 이 타입을 봤을 때 "이걸 다 써야 해?" 싶었습니다. 그런데 한 달쯤 쓰다 보니 함수 시그니처만 보고 "이 함수는 Database가 있어야 하고, NotFoundError나 DbError가 날 수 있구나"를 즉시 파악할 수 있다는 게 얼마나 강력한지 체감하게 됩니다.
E나 R이 never이면 각각 "에러 없음" 또는 "의존성 없음"을 의미합니다. 특히 R = never가 되는 순간이 중요합니다. Effect.runPromise의 타입 시그니처는 Effect<A, E, never>만 받도록 되어 있어서, 아직 주입되지 않은 의존성이 남아 있으면 실행 코드 자체를 작성할 수 없습니다. TypeScript가 이를 컴파일 타임에 검사한다는 점이 핵심입니다.
타입 안전한 에러 설계 — Data.TaggedError
기존 Error 클래스를 상속하는 방식으로는 catch 블록에서 instanceof로 분기해야 하고, 이마저도 타입 시스템이 강제하지 않습니다. Effect는 Data.TaggedError로 에러에 고유한 태그를 붙여 패턴 매칭처럼 처리할 수 있게 합니다.
import { Data, Effect } from "effect"
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
userId: string
}> {}
class DatabaseConnectionError extends Data.TaggedError("DatabaseConnectionError")<{
cause: unknown
}> {}
// 이 함수가 어떤 에러를 낼 수 있는지 시그니처만 봐도 알 수 있음
const getUser = (id: string): Effect.Effect<
User,
UserNotFoundError | DatabaseConnectionError,
Database
> =>
Effect.flatMap(Database, (db) =>
db.findUser(id).pipe(
Effect.catchTag("DatabaseConnectionError", () =>
Effect.fail(new UserNotFoundError({ userId: id }))
)
)
)catchTag를 쓰면 _tag 필드를 기준으로 TypeScript가 에러 타입을 자동으로 좁혀줍니다. 처리한 에러는 E 유니온에서 제거되어 나머지 에러만 남습니다.
Layer — 의존성 주입의 새로운 문법
Layer는 "어떤 서비스를 어떻게 만드는지에 대한 레시피"입니다. 서비스(인터페이스)와 레이어(구현체)를 분리하면, 테스트 시에는 레이어만 교체하면 됩니다 — Jest 모킹도 필요 없이.
import { Context, Effect, Layer } from "effect"
import { Pool } from "pg"
// DatabaseConnectionError는 위 섹션에서 정의한 클래스를 재사용
class Database extends Context.Service<Database>()("Database", {
effect: Effect.succeed({
query: (_sql: string) => Effect.succeed([] as unknown[])
})
}) {}
// 프로덕션 구현체 레이어 — 레이어 초기화 시 커넥션 풀을 생성하고 재사용
const DatabaseLive = Layer.effect(
Database,
Effect.sync(() => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
return {
query: (sql: string) =>
Effect.tryPromise({
try: () => pool.query(sql).then(r => r.rows as unknown[]),
catch: (cause) => new DatabaseConnectionError({ cause })
})
}
})
)
// 테스트 구현체 레이어 — 진짜 DB 없이 교체
const DatabaseTest = Layer.succeed(Database, {
query: (_sql: string) => Effect.succeed([{ id: "1", name: "Test" }])
})Layer 조합 API 한눈에 보기
| API | 용도 |
|---|---|
Layer.provide(target, dep) |
target의 의존성을 dep으로 충족시킴 |
Layer.merge(layerA, layerB) |
서로 독립적인 두 레이어를 병렬 합산 |
Layer.provideMerge(layerA, layerB) |
layerB를 layerA에 주입하고 두 레이어를 합산하여 반환 |
Layer.succeed(service, impl) |
즉시 값으로 레이어 생성 (테스트에 유용) |
Layer.effect(service, effect) |
Effect로 레이어 생성 (비동기 초기화 가능) |
레이어는 조합할 수 있습니다. Layer.provide로 의존성을 연결하면 같은 레이어를 두 곳에서 요구해도 인스턴스는 한 번만 생성됩니다(자동 싱글턴). 순환 의존성이 있으면 컴파일 타임에 잡아줍니다.
실전 적용
Repository 패턴을 Layer로 구현하기
서비스 → 레포지토리 → 데이터베이스 계층을 Layer로 연결하면, 각 레이어는 자신이 필요한 것만 선언하고 어떻게 조달하는지는 신경 쓰지 않아도 됩니다.
import { Context, Effect, Layer, Data } from "effect"
// 도메인 에러 정의
// DatabaseConnectionError는 "타입 안전한 에러 설계" 섹션에서 정의한 클래스를 사용
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
userId: string
}> {}
interface User {
id: string
name: string
email: string
}
// Repository 서비스 계약 — 기본 구현은 인터페이스 명세를 위한 플레이스홀더
class UserRepository extends Context.Service<UserRepository>()(
"UserRepository",
{
effect: Effect.succeed({
findById: (_id: string): Effect.Effect<User | null, DatabaseConnectionError> =>
Effect.succeed(null)
})
}
) {}
// 프로덕션 구현체 — Database Layer에 의존
const UserRepositoryLive = Layer.effect(
UserRepository,
Effect.map(Database, (db) => ({
findById: (id: string) =>
db.query(`SELECT * FROM users WHERE id = $1`).pipe(
Effect.map((rows) => (rows.length > 0 ? (rows[0] as User) : null))
)
}))
)
// 비즈니스 로직 — 시그니처가 에러와 의존성을 모두 명시
const getUserOrFail = (
id: string
): Effect.Effect<User, UserNotFoundError | DatabaseConnectionError, UserRepository> =>
Effect.flatMap(UserRepository, (repo) =>
repo.findById(id).pipe(
Effect.flatMap((user) =>
user
? Effect.succeed(user)
: Effect.fail(new UserNotFoundError({ userId: id }))
)
)
)요청 흐름을 시각화하면 다음과 같습니다. 에러 타입이 시그니처에 명시되어 있어, 호출부에서 처리하지 않으면 컴파일이 되지 않는다는 점이 핵심입니다.
테스트에서 Layer 교체하기
이게 Effect의 가장 실용적인 장점 중 하나입니다. 실제 DB 없이, 모킹 라이브러리 없이, 레이어만 바꿔서 테스트할 수 있습니다.
import { Effect, Layer } from "effect"
import { describe, it, expect } from "vitest"
// 테스트용 레이어 — 고정된 데이터 반환
const UserRepositoryTest = Layer.succeed(UserRepository, {
findById: (id: string) =>
id === "existing-user"
? Effect.succeed({ id, name: "테스트 유저", email: "test@example.com" } as User)
: Effect.succeed(null)
})
describe("getUserOrFail", () => {
it("존재하는 유저를 반환한다", async () => {
const result = await Effect.runPromise(
getUserOrFail("existing-user").pipe(
Effect.provide(UserRepositoryTest)
)
)
expect(result.name).toBe("테스트 유저")
})
it("없는 유저면 UserNotFoundError를 반환한다", async () => {
const result = await Effect.runPromise(
getUserOrFail("ghost").pipe(
Effect.provide(UserRepositoryTest),
Effect.flip // 에러를 성공 채널로 뒤집어서 검증
)
)
expect(result._tag).toBe("UserNotFoundError")
expect(result.userId).toBe("ghost")
})
})Effect.flip은 에러 채널과 성공 채널을 뒤집어주는 유틸리티입니다. 에러가 나야 하는 케이스를 테스트할 때 try-catch 없이 깔끔하게 검증할 수 있습니다.
레이어를 조합해서 앱 진입점 만들기
UserRepositoryLive는 Database에 의존하므로, Layer.provide로 의존성을 명시적으로 연결합니다. Layer.merge는 서로 독립적인 레이어를 합산할 뿐 의존성을 자동 해결하지 않으므로 주의해야 합니다.
import { Effect, Layer } from "effect"
// UserRepositoryLive가 DatabaseLive를 의존 → Layer.provide로 연결
const AppLayer = Layer.provide(UserRepositoryLive, DatabaseLive)
const getUserProgram = (id: string) =>
Effect.gen(function* () {
const user = yield* getUserOrFail(id)
yield* Effect.log(`유저 조회 성공: ${user.name}`)
return user
})
// 진입점에서 에러를 타입별로 최종 처리한 뒤 실행
const main = getUserProgram("some-id").pipe(
Effect.catchTag("UserNotFoundError", (err) =>
Effect.logWarning(`존재하지 않는 유저: ${err.userId}`)
),
Effect.catchTag("DatabaseConnectionError", () =>
Effect.logError("데이터베이스 연결 실패")
),
Effect.provide(AppLayer)
)
Effect.runPromise(main)에러 처리에서 자주 밟는 함정 하나를 짚고 넘어가겠습니다. Effect.catchAll로 Effect.void를 반환하면 이후 코드에서 값을 활용할 수 없게 됩니다.
// 주의: catchAll로 Effect.void를 반환하면 user 타입이 User | void가 됨
const bad = Effect.gen(function* () {
const user = yield* getUserOrFail("123").pipe(
Effect.catchAll(() => Effect.void) // user가 void가 되어버림
)
console.log(user.name) // 타입 오류: void에는 name이 없음
})
// 에러를 타입별로 처리하거나, 진입점에서 일괄 처리
const good = getUserOrFail("123").pipe(
Effect.catchTag("UserNotFoundError", (err) =>
Effect.logWarning(`유저 없음: ${err.userId}`).pipe(
Effect.andThen(Effect.fail(err)) // 에러를 재전파
)
)
)구조적 동시성 — 파이버와 재시도
Effect는 파이버(fiber) 기반으로 동작합니다. 각 Effect는 경량 가상 스레드처럼 동작하며, 취소(cancellation)와 리소스 해제가 구조적으로 보장됩니다.
import { Effect, Schedule } from "effect"
// ServiceUnavailableError도 Data.TaggedError로 정의되어 있다고 가정
const resilientFetch = getUserOrFail("123").pipe(
// 100ms 지수 백오프로 최대 3회 재시도
Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.take(3))),
// 5초 타임아웃
Effect.timeout("5 seconds"),
// 타임아웃 시 커스텀 에러로 변환
Effect.catchTag("TimeoutException", () => Effect.fail(new ServiceUnavailableError()))
)async/await로 이걸 구현하려면 직접 타이머를 관리하고, 취소 시 AbortController를 연결하고, 재시도 횟수를 추적하는 코드를 작성해야 합니다. Effect에서는 이 모든 게 파이프라인 선언 한 줄로 해결됩니다.
OpenTelemetry 트레이싱 — 한 줄로 계측
Effect의 또 다른 장점은 옵저버빌리티가 내장되어 있다는 점입니다. 별도의 계측 코드 없이 Effect.withSpan만 추가하면 분산 트레이싱이 됩니다.
import { Effect } from "effect"
const getUserWithTracing = (id: string) =>
getUserOrFail(id).pipe(
Effect.withSpan("user.getById", {
attributes: { "user.id": id }
})
)
// 중첩 스팬도 자연스럽게 부모-자식 계층 구조를 이룸
// getOrder, fulfillOrder는 동일한 패턴의 다른 도메인 Effect 함수라고 가정
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const order = yield* getOrder(orderId) // 예시: 주문 조회 Effect
const user = yield* getUserWithTracing(order.userId)
return yield* fulfillOrder(order, user) // 예시: 주문 처리 Effect
}).pipe(Effect.withSpan("order.process"))getOrder → getUserWithTracing → fulfillOrder가 자동으로 부모-자식 스팬 관계를 형성합니다. Jaeger나 Datadog 같은 도구에서 바로 확인할 수 있습니다.
장단점 분석
실무에서 느낀 장단점
| 항목 | 내용 |
|---|---|
| 컴파일 타임 에러 완전성 | 처리하지 않은 에러 경로를 TypeScript 컴파일러가 강제 — 런타임 서프라이즈가 줄어듦 |
| 테스트 용이성 | Layer 교체만으로 실제 DB·외부 API를 교체, 별도 모킹 라이브러리 불필요 |
| 의존성 그래프 자동 검증 | R 타입에 미충족 의존성이 있으면 Effect.runPromise 호출 자체가 컴파일 오류 |
| 구조적 동시성 | 재시도·타임아웃·회로 차단기를 선언적으로 표현, 누락된 취소 처리 걱정 없음 |
| 레이어 싱글턴 | 동일 Layer를 여러 곳에 제공해도 인스턴스는 한 번만 생성됨 |
| 내장 옵저버빌리티 | OpenTelemetry, 구조화 로깅, 메트릭을 추가 설정 없이 사용 |
| 항목 | 내용 |
|---|---|
| 높은 학습 곡선 | 함수형 패러다임에 익숙하지 않은 팀은 온보딩에 시간이 걸림 |
| 보일러플레이트 | 단순한 CRUD에도 Service/Layer 정의가 추가되어 초반엔 과하게 느껴짐 |
| 채용 풀 | Effect 숙련 개발자가 일반 async/await 개발자보다 드문 편 |
| 생태계 종속 | Effect Schema, Stream 등을 쓰기 시작하면 Effect 생태계에 깊게 묶임 |
| 번들 크기 | 기능을 많이 쓸수록 번들이 커짐 — 클라이언트 사이드보다 서버 사이드에 적합 |
실무에서 자주 저지르는 실수
1. Effect.runPromise를 너무 자주 호출하기
// 나쁜 패턴 — Effect 계산 중간에 런타임을 깨뜨림
const bad = async () => {
const user = await Effect.runPromise(getUser("123"))
const order = await Effect.runPromise(getOrder(user.id))
}
// 좋은 패턴 — 끝까지 Effect 체인으로 연결하고 진입점에서 한 번만 실행
const good = Effect.gen(function* () {
const user = yield* getUser("123")
const order = yield* getOrder(user.id)
return order
})2. 에러를 너무 일찍 삼키기
// 나쁜 패턴 — catchAll로 모든 에러를 null로 바꿔버림
const bad = getUser("123").pipe(
Effect.catchAll(() => Effect.succeed(null))
)
// 좋은 패턴 — 에러 타입별로 적절히 처리
const good = getUser("123").pipe(
Effect.catchTag("UserNotFoundError", () => Effect.succeed(defaultUser)),
Effect.catchTag("DatabaseConnectionError", (err) =>
Effect.logError("DB 연결 실패").pipe(
Effect.andThen(Effect.fail(new ServiceUnavailableError()))
)
)
)3. Layer를 요청마다 생성하기
Layer는 앱 시작 시 한 번 생성하고 전체에서 공유해야 합니다. 요청마다 Layer를 새로 만들면 DB 커넥션 풀 등의 리소스가 누적됩니다.
Effect가 잘 맞는 상황과 신중하게 고려해야 할 상황
| 잘 맞는 경우 | 신중하게 고려할 경우 |
|---|---|
| 복잡한 에러 처리가 필요한 도메인 서비스 | 간단한 CRUD API만 있는 소규모 프로젝트 |
| 외부 API, DB, LLM 호출이 많은 백엔드 | 팀 전체가 함수형 패러다임에 익숙하지 않은 경우 |
| 테스트 커버리지를 높여야 하는 핵심 비즈니스 로직 | 빠른 프로토타이핑이 우선인 초기 단계 |
| AI 에이전트·오케스트레이션 파이프라인 | 번들 크기가 중요한 클라이언트 사이드 코드 |
이론적으로는 매력적이지만 팀 현실과 맞지 않아 채택을 보류한 사례도 있습니다. Effect를 도입하기 전에 팀의 함수형 프로그래밍 친숙도와 학습 투자 여력을 먼저 점검해보시면 좋습니다.
마치며
Effect-TS는 "에러와 의존성을 타입에 명시"라는 하나의 원칙 위에 세워진 프레임워크입니다. Promise<T>에서 숨어있던 에러가 Effect<T, E, R>의 E에 드러나고, 런타임에 터지던 의존성 문제가 컴파일 타임에 잡힙니다. 많은 fp-ts 사용자들이 Effect로 이동하는 추세이며, Vercel·Polar 같은 기업들이 프로덕션에 도입하면서 생태계도 빠르게 성숙하고 있습니다.
지금 시작해보시려면 이 세 단계가 좋은 출발점입니다.
neverthrow로 워밍업: Effect가 부담스럽다면Result타입만 제공하는neverthrow로 타입 안전한 에러 처리에 먼저 익숙해지세요.- 기존 코드 일부를 Effect로 감싸기:
Effect.tryPromise로 기존fetch나 DB 호출을 Effect로 변환하는 것부터 시작해보세요. 전체를 바꾸지 않아도 됩니다. - Layer 하나 만들어보기: 테스트하기 까다로웠던 외부 의존성 하나를
Context.Service+Layer로 분리하고, 테스트용 Layer를 만들어보세요. 기존 모킹 코드가 얼마나 줄어드는지 직접 확인해보시면 됩니다.