TypeScript Zod transform pipe 완벽 가이드 — API 응답 변환 7가지 실전 패턴

목차

const mySchema = z.string().transform((val) => val.length);

type MySchemaIn = z.input<typeof mySchema>;
// => string

type MySchemaOut = z.output<typeof mySchema>;
// => number

입력은 string인데 출력은 number다. TypeScript Zod transform pipe 패턴의 핵심이 이 코드 한 조각에 들어 있다. API에서 받은 문자열 날짜를 Date 객체로, 숫자 문자열을 number로, JSON 문자열을 파싱된 객체로 — 스키마 선언 단계에서 데이터 형태를 바꿔버리는 것이다. 수동으로 response.data.createdAt = new Date(response.data.createdAt) 같은 후처리 코드를 흩뿌리지 않아도 된다.

문제는 이 패턴이 Zod v3에서 v4로 넘어가면서 꽤 많이 바뀌었다는 점이다. v3에서 잘 돌던 .pipe() 체이닝이 v4에서 타입 에러를 뿜는 경우가 있고, 양방향 변환이 필요하면 codec이라는 새로운 개념까지 등장했다. 이 글에서는 .transform() 기본 동작부터 .pipe() 체이닝, v4 엄격화 대응, 비동기 변환, codec까지 순서대로 다룬다.

TypeScript Zod transform 기본 동작 원리

.transform()은 Zod 스키마의 파싱 결과를 다른 형태로 변환하는 단방향 변환 메서드다. Zod GitHub 리포지토리에 따르면 Zod 최신 버전은 v4.3.6(2026-01-22 릴리스)이며, .transform() API는 입력(z.input)과 출력(z.output) 타입이 달라질 수 있는 구조로 설계되어 있다.

핵심 개념은 파싱과 변환이 하나의 파이프라인으로 묶인다는 것이다. 일반적인 Zod 스키마에서 z.inputz.output은 동일하다. z.string()이면 입력도 string, 출력도 string이다. 하지만 .transform()을 붙이는 순간 이 대칭이 깨진다.

const mySchema = z.string().transform((val) => val.length);

type MySchemaIn = z.input<typeof mySchema>;
// => string

type MySchemaOut = z.output<typeof mySchema>;
// => number

z.input<typeof mySchema>string이고 z.output<typeof mySchema>number다. 이 타입 분리 덕분에 함수 시그니처에서 입력 타입(z.input)과 출력 타입(z.output)을 독립적으로 선언할 수 있다.

z.input.parse() 호출 시 넣어야 하는 데이터의 타입이고, z.output.parse()가 반환하는 데이터의 타입이다. .transform()이 없으면 둘은 같지만, 변환이 있으면 달라진다.

transform 함수의 반환 타입 추론

.transform() 콜백의 반환 타입이 곧 z.output이 된다. TypeScript가 이 반환 타입을 자동 추론하므로 별도의 제네릭 파라미터를 지정할 필요가 없다. val.length를 반환하면 number, new Date(val)을 반환하면 Date, JSON.parse(val)을 반환하면 any가 된다.

JSON.parse()의 반환 타입이 any라는 점이 문제다. .transform((val) => JSON.parse(val))의 출력 타입은 any가 되어 타입 안전성이 사라진다. 이때 필요한 것이 .pipe()다.

.pipe()로 변환 결과를 검증하는 TypeScript Zod transform pipe 체이닝

.pipe() 메서드는 Zod v3.20 릴리스에서 최초 도입되었다. 모든 스키마에서 사용 가능하며, 여러 스키마를 validation pipeline으로 체이닝한다. 반환값은 ZodPipeline 인스턴스다. 이 릴리스에는 브레이킹 API 변경은 없었으나 TypeScript 4.4 이하 지원이 종료되었다.

.pipe()의 핵심 용도는 .transform() 결과를 다시 한번 검증하는 것이다. .transform()만으로는 변환 후 결과가 원하는 조건을 충족하는지 확인할 수 없다. .pipe()를 붙이면 변환 결과를 다른 스키마에 통과시킬 수 있다.

z.string()
  .transform(val => val.length)
  .pipe(z.number().min(5))

이 코드의 동작 흐름은 세 단계다.

1단계: 입력값이 string인지 검증한다. 2단계: .transform()이 문자열 길이(number)를 반환한다. 3단계: .pipe()가 그 숫자를 z.number().min(5)로 다시 검증한다. 길이가 5 미만이면 파싱 에러가 발생한다.

JSON 문자열 파싱 패턴

.pipe()가 가장 많이 쓰이는 패턴이 JSON 문자열을 파싱하고 그 결과를 특정 스키마로 검증하는 것이다. API 응답이 중첩 JSON 문자열로 오는 경우가 있다 — response.body가 문자열이고 그 안에 실제 JSON 객체가 들어있는 형태다.

pipe를 써야 하는 순간 판별법
`.transform()` 결과의 타입이 `any`거나 `unknown`이면 `.pipe()`로 후속 스키마를 붙여야 한다. `JSON.parse()`, `parseInt()`, 외부 라이브러리 반환값 등이 대표적이다.
이 패턴의 장점은 변환과 검증이 분리된다는 것이다. `.transform()` 안에서 `if` 문으로 검증 로직을 넣는 것보다 `.pipe()`로 별도 스키마를 체이닝하는 편이 재사용성과 가독성 면에서 낫다. 스키마 자체가 문서 역할을 하기 때문이다.

Zod v4에서 pipe가 엄격해진 이유

Zod v3에서 v4로 올라가면서 .pipe() 동작이 눈에 띄게 바뀌었다. Zod v4 pipe 관련 이슈에 따르면, v4에서 .pipe()는 v3보다 엄격해졌다. 파이프 대상 스키마의 입력 타입이 소스 스키마의 출력 타입과 반드시 일치해야 한다.

이 변경의 배경은 v3의 타입 불건전성(unsoundness)이다. v3에서는 discriminated union 등이 transform 후 값에 의존할 때 런타임 에러를 유발하는 경우가 있었다. 타입 체커는 통과하지만 런타임에서 터지는 — 가장 위험한 종류의 버그다. v4는 이 문제를 컴파일 타임에 잡기 위해 의도적으로 엄격화한 것이다.

// v3에서 동작하지만 v4에서 실패하는 패턴
const stringToJSON = z.string().transform(str => JSON.parse(str));
const objectSchema = z.object({ name: z.string() });
stringToJSON.pipe(objectSchema).parse('{"name": "test"}');

위 코드가 v3에서는 문제없이 동작한다. JSON.parse()의 반환 타입이 any이고, v3의 .pipe()any를 받아들였기 때문이다. 하지만 v4에서는 any{ name: string } 사이의 타입 불일치로 에러가 발생한다.

타입 불건전성이 실제로 문제가 되는 경우

v3에서 타입이 맞는 것처럼 보이지만 런타임에서 실패하는 시나리오를 구체적으로 보면, discriminated union에서 .transform() 후 값의 구조가 union 분기 조건과 맞지 않을 때 발생한다. TypeScript 컴파일러는 .transform()의 반환 타입만 보고 통과시키지만, 실제 런타임 값은 그 타입을 만족하지 않을 수 있다.

v3에서 v4 마이그레이션 시 주의
`.pipe()` 체이닝을 사용하는 모든 스키마를 점검해야 한다. 특히 `.transform()`에서 `JSON.parse()`, `parseInt()`, `String()` 같은 타입을 변경하는 함수를 쓰고 있다면 v4에서 타입 에러가 날 가능성이 높다.
이 엄격화는 Zod의 철학과 일치한다. “런타임에 터지느니 컴파일 타임에 잡자”는 방향이다. 다만 기존 v3 코드를 v4로 마이그레이션할 때 당장 빌드가 깨질 수 있으므로, 우회 방법을 알아야 한다.

v4 TypeScript Zod transform pipe 타입 제한 우회 방법

Zod v4에서 .pipe() 타입 제한을 우회하는 방법은 세 가지가 있다. 각각 트레이드오프가 다르다.

방법 1: z.preprocess() 사용

z.preprocess()z.pipe(z.transform(fn), schema)의 편의 구문이다. .transform().pipe()를 하나로 합친 것으로, 타입 추론 체인이 단순해진다. 변환 함수가 unknown을 입력받는 형태라서 .pipe()의 입력 타입 매칭 문제를 우회한다.

이 방법의 장점은 기존 코드를 가장 적게 수정해도 된다는 점이다. 단점은 .preprocess() 단계에서 입력 타입 검증이 느슨해진다는 것이다. .preprocess()의 콜백은 unknown을 받기 때문에, 변환 전 입력이 실제로 원하는 타입인지 직접 확인해야 한다.

방법 2: 인라인 파이프라인 빌드

파이프라인을 변수에 저장하지 않고 인라인으로 빌드하면 TypeScript가 중간 타입을 더 정확하게 추론한다. v4의 타입 체커가 체이닝 문맥에서 타입을 좁히는 방식이 개선되었기 때문이다.

실무에서는 스키마를 재사용하지 않는 일회성 파이프라인이라면 이 방법이 가장 깔끔하다. 스키마를 변수로 빼서 여러 곳에서 쓰는 경우에는 첫 번째 방법이 낫다.

방법 3: ZodSchema<any, any>로 캐스팅

타입 안전성을 포기하고 as ZodSchema<any, any>로 캐스팅하는 방법이다. 비권장이지만 레거시 코드를 빠르게 마이그레이션할 때 임시 방편으로 쓸 수 있다.

캐스팅 우회는 임시 방편
`ZodSchema` 캐스팅은 `.pipe()`의 엄격화가 제공하는 타입 안전성을 완전히 무력화한다. 마이그레이션 초기에 빌드를 통과시키기 위한 용도로만 사용하고, 이후 방법 1이나 2로 전환해야 한다.
| 방법 | 타입 안전성 | 코드 변경량 | 적합한 상황 | |——|———–|———–|————| | z.preprocess() | 중간 | 적음 | v3 코드 빠른 마이그레이션 | | 인라인 파이프라인 | 높음 | 중간 | 새 코드 작성 시 | | ZodSchema 캐스팅 | 낮음 | 최소 | 레거시 임시 대응 |

세 가지 방법 중 어떤 것을 선택할지는 프로젝트의 상황에 따라 다르다. 새로 작성하는 코드라면 방법 2(인라인 파이프라인)로 타입 추론을 최대한 활용하는 것이 맞고, 기존 v3 코드를 점진적으로 마이그레이션하는 중이라면 방법 1(z.preprocess())이 현실적이다. 방법 3은 마감이 급한 날에만 쓰는 선택지다.

비동기 transform과 parseAsync 패턴

.transform() 콜백은 비동기 함수도 받을 수 있다. DB 조회, 외부 API 호출, 파일 읽기 등 비동기 작업을 스키마 파이프라인에 넣는 패턴이다. 다만 반드시 .parseAsync() 또는 .safeParseAsync()를 사용해야 한다. 동기 .parse()를 호출하면 Zod가 에러를 던진다.

const idToUser = z.string().transform(async (id) => {
  return db.getUserById(id);
});
const user = await idToUser.parseAsync("abc123");

이 코드에서 idToUser 스키마는 문자열 ID를 받아서 DB에서 사용자 객체를 조회한 뒤 반환한다. .parseAsync()를 쓰면 Zod가 내부적으로 await를 걸어준다.

transform 내부 에러 처리

.transform() 함수 내에서 검증 실패를 보고할 때는 throw 대신 ctx 파라미터의 ctx.issues에 push해야 한다. throw를 쓰면 Zod의 에러 수집 파이프라인을 우회하게 되어 .safeParseAsync()error.issues 배열에 해당 에러가 포함되지 않는다.

이 패턴은 API 응답 데이터를 변환하면서 동시에 비즈니스 규칙을 검증할 때 유용하다. 예를 들어 사용자 ID로 DB 조회를 하되, 해당 사용자가 비활성화 상태면 커스텀 이슈를 추가하는 식이다.

비동기 transform 사용 시 성능 고려
`.transform()` 안에서 DB 조회나 API 호출을 넣으면 스키마 파싱 자체가 I/O 바운드가 된다. 대량 데이터를 배열 스키마로 파싱할 때 각 요소마다 DB 호출이 발생할 수 있다. 배치 처리가 가능한 구조인지 먼저 확인해야 한다.
비동기 transform은 표현력이 높지만 남용하면 스키마의 역할이 과도하게 확장된다. 스키마는 데이터 형태 검증과 간단한 변환에 집중하고, 복잡한 비즈니스 로직은 서비스 레이어에 두는 것이 유지보수 면에서 유리하다. 프로덕션 환경에서 transform 성능 벤치마크 공식 자료는 현재 공식 문서에도 명시되어 있지 않다.

Zod v4 codec — 양방향 변환의 등장

Zod v4.1.0에서 codec이 도입되었다. Zod v4.1.0 릴리스 노트에 따르면, codec은 양방향 변환(encode/decode)을 캡슐화하며 내부적으로 pipe의 서브클래스로 구현된다.

.transform()이 단방향 변환이라면, codec은 정방향(decode)과 역방향(encode) 두 경로를 모두 정의한다. 네트워크 경계에서 JSON과 JavaScript 표현 간 변환에 특히 유용하다. API에서 ISO 문자열로 받은 날짜를 Date 객체로 변환하고, 다시 API에 보낼 때 ISO 문자열로 되돌리는 패턴이 대표적이다.

const stringToDate = z.codec(
  z.iso.datetime(),  // input schema
  z.date(),          // output schema
  {
    decode: (isoString) => new Date(isoString),
    encode: (date) => date.toISOString(),
  }
);

이 코드에서 decode는 ISO 문자열을 Date로 변환하고, encodeDate를 ISO 문자열로 되돌린다. 양방향이 하나의 스키마에 캡슐화되어 있어서, 같은 필드에 대해 변환 함수를 두 곳에 따로 정의할 필요가 없다.

.decode()와 .parse()의 차이

.decode()는 강타입 입력을 기대하고, .parse()unknown을 받는다. codec은 safeDecode, safeEncode, decodeAsync, encodeAsync 등 비동기·안전 변형 메서드를 제공한다.

이 차이는 사용 시점에 따라 결정된다. 외부 입력(API 응답, 사용자 입력)을 처리할 때는 .parse()unknown부터 검증하고, 내부 코드에서 이미 타입이 확인된 데이터를 변환할 때는 .decode()를 쓴다.

transform과 codec의 관계

중요한 제약이 있다. .transform()은 단방향 변환이므로 스키마에 .transform()이 존재하면 z.encode() 호출 시 런타임 에러가 발생한다. 역방향 변환 경로가 정의되어 있지 않기 때문이다. 양방향 변환이 필요한 필드는 처음부터 codec으로 정의해야 한다.

transform 관계 정리:

.transform()  →  단방향 (decode만)
                  ↓ z.encode() 호출 시 에러

.codec()      →  양방향 (decode + encode)
                  ↓ z.encode() / z.decode() 모두 정상

.pipe()       →  검증 체이닝 (codec의 부모 클래스)
codec은 pipe의 서브클래스
codec이 내부적으로 pipe의 서브클래스라는 설계는 의미가 있다. pipe가 “스키마 A의 출력을 스키마 B의 입력으로 연결”하는 범용 메커니즘이고, codec은 거기에 역방향 경로를 추가한 특수화다.
## API 응답 데이터 변환 실전 패턴

.transform(), .pipe(), codec 개념을 조합한 API 응답 데이터 변환 패턴을 사례별로 정리한다.

날짜 문자열 변환

API 응답에서 날짜는 거의 항상 ISO 8601 문자열로 온다. 프론트엔드에서는 Date 객체로 다루는 편이 훨씬 편하다. v4.1.0 이상이라면 codec을 쓰는 것이 정석이다. 양방향 변환이 필요 없는 읽기 전용 데이터라면 .transform()으로 충분하다.

숫자 문자열 변환

쿼리 파라미터나 CSV 파싱 결과에서 숫자가 문자열로 들어오는 경우가 빈번하다. z.string().transform(Number).pipe(z.number().int().positive())처럼 체이닝하면 변환과 검증을 한 줄로 처리할 수 있다.

중첩 JSON 문자열 파싱

일부 API는 응답 필드 중 일부를 JSON 문자열로 내보낸다. 메타데이터 필드가 대표적이다. 이때 v4에서는 앞서 설명한 .pipe() 엄격화 때문에 z.preprocess()를 쓰거나 인라인 파이프라인으로 빌드해야 한다.

변환 패턴사용 메서드v4 호환
날짜 문자열 → Date (양방향)z.codec()
날짜 문자열 → Date (읽기 전용).transform()
숫자 문자열 → number.transform().pipe()
JSON 문자열 → 객체z.preprocess() 또는 인라인
비동기 ID → 엔티티.transform(async).parseAsync()

스키마 조합 시 주의할 점

.transform()이 붙은 스키마를 z.object() 안에 필드로 넣으면, 해당 객체 스키마의 z.inputz.output이 달라진다. 이 타입 분리는 API 요청/응답 타입을 따로 정의할 때 유리하다. 요청 타입은 z.input으로, 응답 처리 후 타입은 z.output으로 쓰면 된다.

다만 z.inferz.output의 별칭이라는 점을 기억해야 한다. 대부분의 코드에서 z.infer<typeof schema>.parse() 반환값의 타입을 가리킨다. 입력 타입이 필요하면 반드시 z.input<typeof schema>를 명시적으로 써야 한다.

coverage gap 및 TypeScript Zod transform pipe 다음 단계

Zod v4 마이그레이션 과정에서 .pipe() 엄격화는 빈번하게 빌드를 깨뜨리는 변경사항이다. 특히 JSON.parse() 같은 any 반환 함수를 .transform()에서 쓰고 .pipe()로 연결하는 패턴이 많은 프로젝트에서는 마이그레이션 시 일괄 점검이 필요하다. Zod v3 v4 차이점을 정리해두면 전환 비용을 줄일 수 있다.

한 가지 주의할 점은, 한국어로 된 Zod transform/pipe 공식 가이드가 없다는 것이다. zod.dev 공식 API 레퍼런스도 현재 직접 인용이 어려운 상태다. OpenAI Structured Outputs에서 Zod를 연동할 때 transform/pipe 사용 시 주의사항에 대한 공식 문서도 부재한다. 이런 부분은 공식 문서가 보강될 때까지 GitHub 이슈와 릴리스 노트를 직접 추적하는 수밖에 없다.

codec 양방향 변환은 아직 도입 초기지만, API 응답/요청 스키마를 하나로 통합하려는 방향에서 확장 가능성이 크다. Zod codec encode decode 패턴을 미리 익혀두면 스키마 설계가 한 단계 깔끔해진다. 그리고 Zod 스키마 검증을 TypeScript 프로젝트 전반에 적용하려면, Zod preprocess vs transform의 차이를 정확히 이해하고 상황에 맞는 메서드를 선택하는 것이 출발점이다.

TypeScript Zod transform pipe는 스키마 선언 단계에서 데이터 형태를 확정짓는 패턴이다. 후처리 코드를 흩뿌리지 않아도 된다는 것, v4에서 타입 건전성을 위해 엄격해졌다는 것, 양방향이 필요하면 codec이 있다는 것 — 이 세 가지가 핵심이다.

관련 글

이 글 공유하기