Effect-TS Schema로 외부 API 응답을 신뢰할 수 있게 만드는 법: 검증과 타입 변환을 한 파이프라인으로
외부 API를 믿어본 적 있으신가요? 저는 한번 크게 데인 후로 조금 의심 많은 사람이 됐습니다. 결제 API 응답에서 amount가 분명 number라고 문서에 써 있었는데, 어느 날 갑자기 "1000" — 따옴표 붙은 문자열로 날아왔을 때, 타입스크립트는 아무 말도 안 해줬습니다. 그도 그럴 것이, as PaymentResponse로 단언해버린 코드는 컴파일 타임에 완전히 안전한 척하거든요.
이 글은 그 경험에서 시작합니다. 외부에서 오는 데이터는 기본적으로 unknown으로 취급하고, 경계를 통과하는 순간 반드시 검증하고 변환해야 한다는 원칙, 그리고 Effect-TS의 Schema 모듈이 그 작업을 어떻게 정리해주는지를 보여드리려고 합니다.
특히 검증(validation), 타입 변환(transformation), 직렬화 규칙을 하나의 스키마 정의에서 전부 도출한다는 발상이 처음엔 낯설지만, 익숙해지면 "이걸 왜 지금까지 따로 관리했지?"라는 생각이 절로 듭니다.
왜 as 단언과 Zod만으로는 아쉬웠나
런타임 경계 문제
TypeScript의 타입은 컴파일 타임에만 존재합니다. fetch()로 받은 JSON이나 process.env에서 꺼낸 값은 런타임에서 완전히 unknown입니다. as SomeType으로 단언하면 컴파일러의 눈을 가릴 뿐, 실제 데이터가 달라도 코드는 조용히 오작동합니다.
Zod를 쓰면 이 문제는 해결됩니다. 그런데 한 가지 불편한 지점이 있었어요. 검증(런타임)과 타입(컴파일타임)은 연결되는데, 변환은 또 별도였습니다. 외부 API가 snake_case를 쓰면 매핑 레이어를 따로 만들어야 했고, "2024-01-01T00:00:00.000Z" 같은 날짜 문자열을 Date 객체로 바꾸는 코드는 검증 이후 어딘가에 또 흩어졌습니다.
Effect Schema의 발상 전환
Effect Schema는 Schema<Type, Encoded, Requirements>라는 세 축으로 이 문제를 정면 돌파합니다.
Encoded: 외부에서 들어오는 원시 형태 — JSON의 ISO 날짜 문자열,snake_case키, 숫자로 온 boolean 같은 것들Type: 애플리케이션 내부에서 쓰는 도메인 타입 —Date객체, 브랜드 타입,camelCase구조체Requirements: 스키마 실행에 필요한 Effect 컨텍스트 (대부분never라서 무시해도 됩니다)
단일 스키마 정의에서 TypeScript 타입, 런타임 검증 규칙, 직렬화/역직렬화 로직이 전부 자동 도출됩니다. 따로 관리하던 세 가지가 한 곳에 모이는 거죠.
설치와 첫 번째 스키마
@effect/schema 별도 패키지는 Effect 3.0(2024년 4월) 릴리스부터 메인 effect 패키지 안으로 통합됐습니다. 지금은 effect만 설치하면 Schema 모듈을 바로 쓸 수 있습니다.
# 순수 Schema 검증만 필요하다면 이거 하나면 충분합니다
npm install effect가장 기본적인 스키마를 하나 만들어보면:
import { Schema } from "effect"
const UserSchema = Schema.Struct({
id: Schema.String,
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/)),
createdAt: Schema.Date, // ISO 8601 문자열 → Date 객체로 자동 변환
age: Schema.Number.pipe(Schema.int(), Schema.positive()),
})
// 타입은 스키마에서 자동 도출 — 별도로 interface 선언 불필요
type User = Schema.Schema.Type<typeof UserSchema>
// => { id: string; email: string; createdAt: Date; age: number }Schema.Date가 흥미로운 지점입니다. 내부적으로는 new Date(input)를 통해 값을 만들어내기 때문에, 브라우저/Node 런타임이 파싱할 수 있는 형태 — 대표적으로 "2024-01-01T00:00:00.000Z" 같은 완전한 ISO 8601 문자열 — 을 넣어주는 게 안전합니다. "2024-01-01"처럼 시간 부분이 빠진 값도 대부분 파싱되긴 하지만 타임존 해석이 런타임마다 달라질 수 있어, 완전한 ISO 문자열을 권장합니다. 결과적으로 Encoded 타입은 string, Type 타입은 Date — 이 둘이 스키마 하나에 공존합니다.
실전 시나리오별 코드
시나리오 1: HttpClient로 외부 API 응답을 직접 검증
@effect/platform의 HttpClient와 Schema를 조합하면 HTTP 요청부터 타입 안전한 도메인 객체까지 단일 파이프라인으로 연결됩니다. 이 예제부터는 플랫폼 패키지와 런타임별 어댑터가 필요합니다.
# HttpClient 통합 예제를 실행하려면 플랫폼 패키지가 추가로 필요합니다
npm install @effect/platform @effect/platform-node
# Bun 환경이라면 @effect/platform-bun을 사용합니다HttpClient는 Effect 서비스(인터페이스)이기 때문에 네임스페이스에서 .get()을 직접 호출하는 게 아니라, HttpClientRequest로 요청을 만들고 서비스에서 실행하거나 Effect.gen 안에서 서비스를 꺼내 쓰는 방식이 정석입니다.
import { Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
const GithubUserSchema = Schema.Struct({
login: Schema.String,
id: Schema.Number,
created_at: Schema.Date, // ISO 8601 문자열 → Date 변환 포함
public_repos: Schema.Number,
})
// HttpClient 서비스를 yield*로 꺼내 사용하는 패턴
const fetchGithubUser = (username: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
`https://api.github.com/users/${username}`
)
return yield* HttpClientResponse.schemaBodyJson(GithubUserSchema)(response)
}).pipe(Effect.scoped)
// 실행 시점에는 FetchHttpClient.layer(플랫폼별 어댑터) 같은 레이어를 제공해야 합니다응답이 스키마 정의와 맞지 않으면 ParseError가 Effect의 에러 채널로 올라옵니다. 어느 필드에서 어떤 규칙이 실패했는지 트리 형태로 담겨 있어서, 디버깅할 때 "뭔가 파싱이 안 됐는데 어디서?"라는 막막함이 없어집니다.
시나리오 2: snake_case → camelCase + 타입 변환을 Schema.transform으로
서드파티 API가 snake_case 키를 쓸 때, 내부 도메인 모델에는 camelCase를 쓰고 싶은 경우가 많습니다. 보통은 매핑 함수를 따로 만들곤 하는데, Schema.transform으로 스키마 안에 선언적으로 집어넣을 수 있습니다.
import { Schema } from "effect"
// 결제 API 응답 예시: { amount_in_cents: 1000, currency_code: 'KRW' }
const PaymentApiResponseSchema = Schema.transform(
// Encoded 형태 (외부에서 들어오는 모습)
Schema.Struct({
amount_in_cents: Schema.Number,
currency_code: Schema.String,
}),
// Type 형태 (내부 도메인 모델)
Schema.Struct({
amountInCents: Schema.Number,
currency: Schema.String,
}),
{
decode: (raw) => ({
amountInCents: raw.amount_in_cents,
currency: raw.currency_code,
}),
encode: (domain) => ({
amount_in_cents: domain.amountInCents,
currency_code: domain.currency,
}),
}
)
type PaymentDomain = Schema.Schema.Type<typeof PaymentApiResponseSchema>
// => { amountInCents: number; currency: string }decode는 외부 → 내부 변환, encode는 내부 → 외부 직렬화입니다. 둘이 한 스키마에 있으니 나중에 응답 포맷이 바뀌어도 한 곳만 수정하면 됩니다.
시나리오 3: 브랜드 타입으로 UserId와 ProductId 혼동 방지
저도 처음엔 "브랜드 타입이 그게 그거 아니야?"라고 생각했습니다. 근데 userId를 productId 파라미터에 실수로 넘기는 버그를 컴파일러가 잡아주기 시작하면 생각이 바뀝니다.
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const Email = Schema.String.pipe(
Schema.pattern(/.+@.+/),
Schema.brand("Email")
)
// Schema.Class를 쓰면 plain object 대신 클래스 인스턴스로 디코딩됩니다
class User extends Schema.Class<User>("User")({
id: UserId,
email: Email,
createdAt: Schema.Date,
}) {}
// 동기적으로 디코딩 (실패 시 예외 발생)
const user = Schema.decodeUnknownSync(User)({
id: "u_123",
email: "foo@example.com",
createdAt: "2024-06-01T00:00:00.000Z",
})
// user.id 타입: string & Brand<'UserId'>
// user.email 타입: string & Brand<'Email'>
// user.createdAt 타입: Date (문자열이 Date 객체로 변환됨)이제 다른 함수에서 UserId만 받도록 시그니처를 좁혀두면, Email 값을 잘못 넘기는 순간 컴파일러가 잡아줍니다. 이때 파라미터 타입을 표현하는 두 가지 방법이 있는데, 관계를 짚어두면 헷갈리지 않습니다.
// 방법 1: Schema.Schema.Type<> 헬퍼로 타입을 꺼낸다 (가장 명확)
type UserIdType = Schema.Schema.Type<typeof UserId>
function getUserPosts(userId: UserIdType) { /* ... */ }
// 방법 2: 스키마의 phantom property 'Type'을 typeof로 참조한다 (축약형)
// UserId.Type은 런타임 값이 아니라 타입 정보를 담기 위한 phantom 필드입니다.
// 실제 실행 시에는 undefined이며 typeof를 통해 타입 레벨에서만 의미를 갖습니다.
function getUserPostsAlt(userId: typeof UserId.Type) { /* ... */ }
getUserPosts(user.email) // ❌ Email을 UserId 자리에 넣을 수 없음Schema.Class는 메서드 추가, 동등성 비교, 해싱 기능도 자동 제공합니다. 단순 타입 안전성 그 이상의 역할을 합니다.
시나리오 4: 환경변수 검증
process.env는 모두 string | undefined입니다. 서비스 시작 시점에 환경변수를 한 번에 검증해두면, 런타임 중간에 설정 누락으로 터지는 상황을 미리 막을 수 있습니다.
import { Schema } from "effect"
const EnvSchema = Schema.Struct({
DATABASE_URL: Schema.String.pipe(Schema.startsWith("postgres://")),
PORT: Schema.NumberFromString.pipe(Schema.int(), Schema.between(1, 65535)),
NODE_ENV: Schema.Literal("development", "production", "test"),
})
// 애플리케이션 부트스트랩 시점에 한 번만 실행
const config = Schema.decodeUnknownSync(EnvSchema)(process.env)
// PORT: string → number 변환이 스키마 안에서 완료됩니다Schema.NumberFromString처럼 문자열로 들어오는 값을 숫자로 바꾸는 내장 변환자들이 준비되어 있어서, 환경변수 특유의 "다 문자열인데 숫자로 쓰고 싶다" 문제를 깔끔하게 처리합니다.
에러 흐름이 달라진다
Effect Schema를 도입하면 에러 처리 방식도 바뀝니다. 여기서 중요한 건 스키마 검증이 별도 컴포넌트가 아니라, HttpClientResponse.schemaBodyJson 같은 헬퍼를 통해 HTTP 응답 처리 파이프라인의 한 단계로 실행된다는 점입니다.
ParseError는 단순한 "검증 실패"가 아니라, 어느 필드의 어떤 규칙이 왜 실패했는지를 트리 형태로 담고 있습니다. 이를 TreeFormatter.formatErrorSync(error)로 꺼내면 읽기 좋은 텍스트로 변환할 수 있고, 그대로 로그에 찍거나 클라이언트에 돌려보낼 수 있습니다.
트레이드오프: Effect Schema vs Zod
솔직히 말하면, 팀 모두가 Effect를 처음 보는 상황에서 도입하는 건 쉽지 않습니다. 저도 처음 Effect 코드를 봤을 때 "이게 TypeScript인가?" 싶었거든요. 아래 표가 판단에 도움이 되길 바랍니다.
| 항목 | Effect Schema | Zod |
|---|---|---|
| 단일 스키마로 검증 + 변환 | 네이티브 지원 (Transform) | .transform() 체이닝으로 지원 |
| 양방향 변환 (encode/decode) | 스키마 정의에 encode/decode 내장 | 기본은 단방향 변환, encode는 별도 처리 필요 |
| 구조화된 에러 | 필드별 트리 형태 ParseError |
ZodError의 issues 배열 |
| 브랜드 타입 | Brand 모듈과 연동, 브랜드 조합·인터섹션이 풍부 |
z.brand()로 지원, 조합은 상대적으로 단순 |
| HTTP 클라이언트 통합 | @effect/platform과 단일 파이프라인 |
수동 연결 필요 |
| 학습 곡선 | 가파름 (함수형 + Effect 에러 모델 동시 습득) | 낮음 (직관적 API) |
| 생태계 규모 | 성장 중이나 Zod보다 작음 | 매우 큼 |
| 번들 크기 | Effect 전체 도입 시 오버헤드 | 경량 |
| 점진적 도입 | Schema 모듈만 부분 도입 가능 | 독립적 사용 가능 |
실무에서 흔한 실수들
Type과 Encoded를 헷갈리는 것: 처음엔 Schema.Schema.Type<> 으로 꺼낸 타입이 항상 "내부 도메인 타입"이라는 걸 잊기 쉽습니다. Schema.Date를 정의했을 때 Type은 Date, Encoded는 string입니다. 이 둘을 혼동하면 타입이 맞는 것 같아 보이는데 런타임에서 이상한 일이 일어납니다.
decodeUnknownSync vs decodeUnknown: Sync 버전은 실패 시 예외를 던집니다. 경계 진입 시점(부트스트랩, 요청 진입 등)에선 Sync가 편하지만, Effect 파이프라인 안에서는 decodeUnknown으로 에러를 Effect 에러 채널로 받는 편이 자연스럽습니다.
Schema.transform에서 decode와 encode 방향 실수: decode가 Encoded → Type이고, encode가 Type → Encoded입니다. 방향을 반대로 쓰면 TypeScript가 잡아주긴 하는데, 처음엔 직관이 반대로 형성되는 경우가 많습니다.
어떤 상황에서 도입을 고려할 만한가
Effect 전체 생태계 없이 Schema 모듈만 부분 도입도 가능합니다. 다만 실제 사용자들이 공통적으로 말하는 건, Effect HttpClient 같은 나머지 생태계와 연결됐을 때 진가가 발휘된다는 점입니다.
Standard Schema 이니셔티브(Zod, Effect Schema, ArkType 등이 공유하는 ~standard 인터페이스)가 진행 중이지만, 표준이 커버하는 범위는 기본 타입 추출과 파싱 인터페이스 수준입니다. Schema.transform, 브랜드 타입 조합, 양방향 인코딩 같은 고급 기능은 표준화 범위 밖이라, 라이브러리를 옮길 때 이 부분은 여전히 직접 재작성이 필요합니다. 그래도 검증 함수 하나를 주고받는 계층에서는 상호운용성이 조금씩 열리고 있습니다.
마무리
결국 핵심은 경계(boundary)입니다. 내 코드가 통제하지 못하는 데이터 — 외부 API 응답, 환경변수, 사용자 입력 — 가 시스템 안으로 들어오는 그 순간에 검증과 변환을 완료하면, 이후 코드는 타입을 신뢰할 수 있습니다. Effect Schema는 그 경계에서 할 일을 선언적이고 구성 가능한 방식으로 표현하는 도구입니다.
시작점으로 제가 실제로 효과를 본 진입 방식이 있습니다. 이미 Zod로 검증 + 별도 매핑 함수 조합을 쓰고 있는 코드 중, 가장 지저분한 매핑 레이어 하나를 골라 Schema.transform 하나로 대체해보는 겁니다. 검증과 변환이 한 스키마 정의로 합쳐지는 순간 코드 라인이 절반 이하로 줄고, 응답 포맷이 바뀌었을 때 수정해야 할 지점이 정확히 한 군데로 수렴합니다. 이 경험을 한 번 하고 나면, 나머지 경계도 자연스럽게 옮기고 싶어집니다.