본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 30. · 21 Views
TypeScript 에러 처리 패턴 완벽 가이드
TypeScript에서 안전하고 효율적인 에러 처리 방법을 배워봅니다. try-catch부터 커스텀 에러, Result 타입까지 실무에서 바로 활용할 수 있는 8가지 핵심 패턴을 다룹니다.
목차
- 기본 try-catch 패턴 - 에러의 타입을 안전하게 검사하기
- 커스텀 에러 클래스 - 에러를 구분하여 처리하기
- Result 타입 패턴 - 예외 대신 값으로 에러 반환하기
- 에러 바운더리 패턴 - 전역 에러 처리 계층 만들기
- Optional 체이닝과 Nullish 병합 - null/undefined 안전하게 다루기
- 타입 좁히기로 에러 처리 - 유니온 타입 활용하기
- 비동기 에러 처리 - Promise와 async/await 마스터하기
- Zod를 활용한 런타임 검증 - 타입 안전성을 런타임까지 확장하기
1. 기본 try-catch 패턴 - 에러의 타입을 안전하게 검사하기
시작하며
여러분이 API를 호출하거나 파일을 읽을 때 갑자기 앱이 멈춰버린 경험이 있나요? 에러 메시지조차 제대로 표시되지 않아서 사용자는 무슨 일이 일어났는지 알 수 없는 상황 말입니다.
이런 문제는 에러를 제대로 처리하지 않았을 때 발생합니다. JavaScript와 달리 TypeScript는 타입 시스템을 제공하지만, catch 블록에서 받는 에러는 기본적으로 unknown 타입입니다.
이것이 제대로 처리되지 않으면 런타임 에러로 이어집니다. 바로 이럴 때 필요한 것이 타입 안전한 try-catch 패턴입니다.
이 패턴을 사용하면 에러를 안전하게 검사하고, 사용자에게 의미 있는 메시지를 제공할 수 있습니다.
개요
간단히 말해서, 이 패턴은 catch 블록에서 받는 에러를 타입 가드를 통해 안전하게 검사하는 방법입니다. TypeScript 4.0 이후부터 catch 절의 에러는 any 대신 unknown 타입으로 처리됩니다.
이는 더 안전한 코드를 작성하도록 유도하지만, 동시에 에러의 속성에 접근하기 전에 타입을 확인해야 한다는 의미입니다. 실무에서 API 호출이나 데이터베이스 작업 같은 경우에 매우 유용합니다.
기존에는 catch(error: any)로 받아서 error.message를 바로 사용했다면, 이제는 에러가 실제로 Error 인스턴스인지 먼저 확인해야 합니다. 이 패턴의 핵심 특징은 타입 가드를 사용한 안전한 검사, 예상치 못한 에러 타입 처리, 그리고 명확한 에러 메시지 제공입니다.
이러한 특징들이 프로덕션 환경에서 디버깅을 훨씬 쉽게 만들어줍니다.
코드 예제
// 타입 가드 함수로 Error 객체인지 확인
function isError(error: unknown): error is Error {
return error instanceof Error;
}
async function fetchUserData(userId: string) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
// unknown 타입의 에러를 안전하게 처리
if (isError(error)) {
console.error('에러 발생:', error.message);
throw new Error(`사용자 데이터를 불러올 수 없습니다: ${error.message}`);
}
// Error 객체가 아닌 경우
console.error('알 수 없는 에러:', error);
throw new Error('사용자 데이터를 불러올 수 없습니다');
}
}
설명
이것이 하는 일: 비동기 함수에서 발생할 수 있는 다양한 에러를 타입 안전하게 처리하고, 사용자에게 명확한 피드백을 제공합니다. 첫 번째로, isError 타입 가드 함수는 unknown 타입의 값이 실제로 Error 인스턴스인지 확인합니다.
error is Error라는 반환 타입은 TypeScript에게 이 함수가 true를 반환하면 해당 변수가 Error 타입이라고 알려줍니다. 이렇게 하면 조건문 내부에서 error.message에 안전하게 접근할 수 있습니다.
그 다음으로, try 블록에서 API 호출을 수행하고 HTTP 상태 코드를 확인합니다. response.ok가 false면 명시적으로 Error를 throw하여 catch 블록에서 처리하도록 합니다.
이는 네트워크 에러와 HTTP 에러를 일관된 방식으로 처리할 수 있게 해줍니다. catch 블록에서는 먼저 타입 가드로 에러를 검사합니다.
Error 객체라면 message 속성에 접근하여 상세한 에러 정보를 로깅하고, 사용자 친화적인 메시지와 함께 새로운 Error를 throw합니다. Error 객체가 아닌 경우(예: 문자열이나 숫자가 throw된 경우)에도 안전하게 처리합니다.
마지막으로, 최종적으로 새로운 Error를 throw하여 호출하는 쪽에서도 에러를 처리할 수 있도록 합니다. 이렇게 하면 에러가 발생한 원인을 추적하면서도, 사용자에게는 이해하기 쉬운 메시지를 제공할 수 있습니다.
여러분이 이 코드를 사용하면 런타임 에러 없이 안전한 에러 처리, 명확한 에러 메시지 제공, 그리고 디버깅이 쉬운 코드를 작성할 수 있습니다. 특히 프로덕션 환경에서 예상치 못한 에러 타입으로 인한 크래시를 방지할 수 있습니다.
실전 팁
💡 타입 가드 함수를 유틸리티 파일에 모아두면 프로젝트 전체에서 재사용할 수 있어 효율적입니다
💡 console.error 대신 로깅 라이브러리를 사용하면 프로덕션 환경에서 에러를 추적하고 모니터링하기 쉽습니다
💡 에러를 다시 throw할 때는 원본 에러 정보를 포함시켜 디버깅 시 전체 컨텍스트를 파악할 수 있도록 하세요
💡 API 호출 실패 시 재시도 로직을 추가하면 일시적인 네트워크 문제로 인한 실패를 줄일 수 있습니다
💡 에러 처리 로직이 복잡해지면 별도의 에러 핸들러 함수로 분리하여 코드 가독성을 높이세요
2. 커스텀 에러 클래스 - 에러를 구분하여 처리하기
시작하며
여러분이 대규모 애플리케이션을 개발할 때 모든 에러가 단순히 "Error"로만 표시되어 어떤 종류의 에러인지 구분하기 어려웠던 경험이 있나요? 네트워크 에러인지, 인증 에러인지, 데이터 검증 에러인지 알 수 없어서 적절한 대응을 하지 못하는 상황 말입니다.
이런 문제는 에러를 세밀하게 분류하지 않았을 때 발생합니다. 기본 Error 클래스만 사용하면 에러 메시지만으로 에러의 종류를 판단해야 하는데, 이는 매우 불안정하고 유지보수하기 어렵습니다.
특히 다국어를 지원하는 앱에서는 메시지로 에러를 구분하는 것이 거의 불가능합니다. 바로 이럴 때 필요한 것이 커스텀 에러 클래스입니다.
에러 타입별로 클래스를 만들면 instanceof로 에러를 구분하고, 각 에러 타입에 맞는 처리 로직을 실행할 수 있습니다.
개요
간단히 말해서, 이 패턴은 Error 클래스를 상속받아 특정 도메인이나 상황에 맞는 커스텀 에러 클래스를 만드는 방법입니다. 커스텀 에러 클래스를 사용하면 에러의 종류를 타입 레벨에서 구분할 수 있고, 각 에러 타입에 필요한 추가 정보(예: HTTP 상태 코드, 재시도 가능 여부)를 포함시킬 수 있습니다.
실무에서 API 에러 처리, 비즈니스 로직 검증, 권한 검증 같은 경우에 매우 유용합니다. 기존에는 에러 메시지나 커스텀 프로퍼티로 에러를 구분했다면, 이제는 에러의 클래스 자체로 구분할 수 있습니다.
이 패턴의 핵심 특징은 타입 안전한 에러 구분, 에러별 메타데이터 포함 가능, 그리고 명확한 에러 계층 구조입니다. 이러한 특징들이 복잡한 애플리케이션에서 에러 처리 로직을 체계적으로 관리할 수 있게 해줍니다.
코드 예제
// 인증 관련 에러
class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
Object.setPrototypeOf(this, AuthenticationError.prototype);
}
}
// API 호출 에러 (HTTP 상태 코드 포함)
class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public endpoint: string
) {
super(message);
this.name = 'ApiError';
Object.setPrototypeOf(this, ApiError.prototype);
}
}
// 검증 에러
class ValidationError extends Error {
constructor(
message: string,
public field: string
) {
super(message);
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
// 에러 타입별 처리
function handleError(error: unknown): string {
if (error instanceof AuthenticationError) {
return '로그인이 필요합니다. 다시 로그인해주세요.';
}
if (error instanceof ApiError) {
return `서버 오류 (${error.statusCode}): ${error.message}`;
}
if (error instanceof ValidationError) {
return `입력 오류 (${error.field}): ${error.message}`;
}
return '알 수 없는 오류가 발생했습니다.';
}
설명
이것이 하는 일: 서로 다른 종류의 에러를 명확하게 구분하고, 각 에러 타입에 맞는 처리 로직을 실행하여 사용자에게 적절한 피드백을 제공합니다. 첫 번째로, 각 커스텀 에러 클래스는 Error를 상속받고 생성자에서 super(message)를 호출합니다.
this.name을 설정하면 스택 트레이스에서 에러 종류를 쉽게 파악할 수 있습니다. Object.setPrototypeOf는 TypeScript의 트랜스파일 과정에서 발생할 수 있는 프로토타입 체인 문제를 해결합니다.
그 다음으로, ApiError와 ValidationError는 에러 처리에 필요한 추가 정보를 프로퍼티로 포함합니다. ApiError는 HTTP 상태 코드와 엔드포인트 정보를, ValidationError는 잘못된 필드 이름을 저장합니다.
이 정보들은 에러 로깅, UI 표시, 재시도 로직 결정 등에 활용됩니다. handleError 함수에서는 instanceof 연산자로 에러의 타입을 체크합니다.
TypeScript는 각 if 블록 내에서 에러의 타입을 자동으로 좁혀주므로, 해당 에러 클래스의 프로퍼티에 안전하게 접근할 수 있습니다. 예를 들어 ApiError 블록 안에서는 error.statusCode에 접근할 수 있습니다.
마지막으로, 각 에러 타입에 맞는 사용자 친화적인 메시지를 반환합니다. 인증 에러는 로그인 유도 메시지를, API 에러는 상태 코드를 포함한 상세 정보를, 검증 에러는 어떤 필드에 문제가 있는지 알려줍니다.
여러분이 이 코드를 사용하면 에러 처리 로직이 명확하고 유지보수하기 쉬워집니다. 새로운 에러 타입을 추가할 때도 기존 코드를 수정하지 않고 확장할 수 있으며, 타입 시스템의 도움을 받아 컴파일 타임에 많은 오류를 잡을 수 있습니다.
실전 팁
💡 Object.setPrototypeOf는 성능상 약간의 오버헤드가 있지만, instanceof가 올바르게 동작하려면 필수입니다
💡 에러 클래스에 isRetryable 같은 플래그를 추가하면 자동 재시도 로직을 구현하기 쉽습니다
💡 에러 클래스를 별도의 파일(errors.ts)로 분리하면 프로젝트 전체에서 일관된 에러 처리가 가능합니다
💡 프로덕션 환경에서는 에러의 stack 프로퍼티를 로깅하여 정확한 발생 위치를 추적하세요
💡 너무 많은 에러 클래스를 만들기보다는 의미 있는 분류 기준으로 적절한 수준을 유지하는 것이 중요합니다
3. Result 타입 패턴 - 예외 대신 값으로 에러 반환하기
시작하며
여러분이 여러 단계의 비동기 작업을 연속으로 수행할 때 try-catch 블록이 중첩되어 코드가 복잡해지고 읽기 어려워진 경험이 있나요? 각 단계에서 에러가 발생할 수 있는데, 어디서 에러를 catch하고 어떻게 전파해야 할지 혼란스러운 상황 말입니다.
이런 문제는 예외(exception)를 사용한 에러 처리 방식의 한계입니다. 예외는 제어 흐름을 방해하고, 함수 시그니처만 봐서는 에러가 발생할 수 있는지 알 수 없습니다.
또한 여러 함수를 조합할 때 에러 처리 로직이 흩어져서 관리하기 어렵습니다. 바로 이럴 때 필요한 것이 Result 타입 패턴입니다.
성공과 실패를 명시적인 반환 값으로 표현하면 에러를 예측 가능한 방식으로 처리할 수 있고, 함수형 프로그래밍 기법을 활용해 우아하게 에러를 다룰 수 있습니다.
개요
간단히 말해서, 이 패턴은 함수가 예외를 throw하는 대신 Success 또는 Failure를 나타내는 Result 객체를 반환하는 방법입니다. Result 타입은 Rust나 Haskell 같은 언어에서 널리 사용되는 패턴으로, TypeScript에서도 유니온 타입과 타입 가드를 활용해 구현할 수 있습니다.
함수 시그니처를 보면 에러가 발생할 수 있다는 것을 바로 알 수 있고, 호출하는 쪽에서 반드시 에러 케이스를 처리하도록 강제할 수 있습니다. 실무에서 복잡한 비즈니스 로직, 다단계 데이터 처리, 파이프라인 구성 같은 경우에 매우 유용합니다.
기존에는 try-catch로 예외를 잡아서 처리했다면, 이제는 함수가 반환하는 Result 객체를 검사하여 성공/실패에 따라 다른 로직을 실행합니다. 이 패턴의 핵심 특징은 명시적인 에러 처리, 타입 안전한 성공/실패 구분, 그리고 함수 조합이 쉽다는 점입니다.
이러한 특징들이 복잡한 비즈니스 로직을 안전하고 예측 가능하게 만들어줍니다.
코드 예제
// Result 타입 정의
type Success<T> = { success: true; data: T };
type Failure<E> = { success: false; error: E };
type Result<T, E = Error> = Success<T> | Failure<E>;
// 헬퍼 함수
const success = <T>(data: T): Success<T> => ({ success: true, data });
const failure = <E>(error: E): Failure<E> => ({ success: false, error });
// 사용 예시: 사용자 데이터 검증
function validateEmail(email: string): Result<string, string> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(email)) {
return success(email);
}
return failure('유효하지 않은 이메일 형식입니다');
}
// 사용자 등록 함수
function registerUser(email: string, password: string): Result<{ id: string; email: string }, string> {
const emailResult = validateEmail(email);
// 이메일 검증 실패 시 early return
if (!emailResult.success) {
return failure(emailResult.error);
}
// 비밀번호 길이 검증
if (password.length < 8) {
return failure('비밀번호는 8자 이상이어야 합니다');
}
// 성공 케이스
return success({ id: crypto.randomUUID(), email: emailResult.data });
}
// Result 사용
const result = registerUser('test@example.com', 'password123');
if (result.success) {
console.log('가입 성공:', result.data.email);
} else {
console.error('가입 실패:', result.error);
}
설명
이것이 하는 일: 함수의 성공과 실패를 타입 안전하게 표현하고, 에러 처리를 강제하여 런타임 예외를 줄입니다. 첫 번째로, Result 타입은 Success와 Failure의 유니온 타입으로 정의됩니다.
Success는 success: true와 함께 성공 시 반환할 데이터를 담고, Failure는 success: false와 함께 에러 정보를 담습니다. 제네릭 타입 T와 E를 사용하여 성공 데이터와 에러 타입을 유연하게 지정할 수 있습니다.
그 다음으로, success와 failure 헬퍼 함수는 Result 객체를 쉽게 생성할 수 있게 해줍니다. 이 함수들은 타입 추론을 통해 적절한 타입의 Result 객체를 반환하므로, 사용하는 쪽에서 타입을 명시적으로 작성할 필요가 없습니다.
validateEmail 함수는 이메일 형식을 검증하고 Result를 반환합니다. 정규식 테스트를 통과하면 검증된 이메일을 success로 감싸서 반환하고, 실패하면 에러 메시지와 함께 failure를 반환합니다.
함수 시그니처를 보면 이 함수가 에러를 발생시킬 수 있다는 것을 바로 알 수 있습니다. registerUser 함수에서는 Result를 활용한 에러 처리를 보여줍니다.
emailResult의 success 프로퍼티를 체크하면 TypeScript가 타입을 자동으로 좁혀주므로, success가 false일 때는 error에, true일 때는 data에 안전하게 접근할 수 있습니다. 각 검증 단계에서 실패하면 즉시 failure를 반환하고, 모든 검증을 통과하면 success를 반환합니다.
여러분이 이 코드를 사용하면 에러 처리를 깜빡하는 실수를 방지할 수 있고, 타입 시스템의 도움으로 안전한 코드를 작성할 수 있습니다. try-catch보다 명시적이고 예측 가능한 에러 처리가 가능하며, 함수를 조합할 때도 일관된 방식으로 에러를 전파할 수 있습니다.
실전 팁
💡 Result 타입에 map, flatMap 같은 유틸리티 메서드를 추가하면 함수형 프로그래밍 스타일로 에러 처리를 체이닝할 수 있습니다
💡 에러 타입을 유니온 타입으로 정의하면 여러 종류의 에러를 타입 안전하게 구분할 수 있습니다 (예: Result<T, 'NotFound' | 'Unauthorized'>)
💡 Result 타입은 비동기 함수에서도 동일하게 사용 가능합니다 (Promise<Result<T, E>> 형태)
💡 Result 타입을 사용하면 에러 로깅을 한 곳에서 중앙 집중식으로 처리하기 쉽습니다
💡 라이브러리(neverthrow, ts-results 등)를 사용하면 Result 패턴을 더 풍부한 기능과 함께 활용할 수 있습니다
4. 에러 바운더리 패턴 - 전역 에러 처리 계층 만들기
시작하며
여러분이 애플리케이션의 여러 부분에서 발생하는 에러를 각각 처리하다 보니 중복 코드가 많아지고, 일관되지 않은 에러 메시지가 표시되어 사용자 경험이 떨어진 적이 있나요? 각 함수마다 try-catch를 추가하고 로깅하고 알림을 보내는 코드를 반복해서 작성하는 상황 말입니다.
이런 문제는 에러 처리 로직이 애플리케이션 전체에 흩어져 있을 때 발생합니다. 에러 처리를 일관되게 관리하지 않으면 어떤 에러는 로깅되지만 어떤 에러는 무시되고, 사용자에게 보여지는 메시지도 제각각입니다.
유지보수가 어려워지고 버그를 놓치기 쉽습니다. 바로 이럴 때 필요한 것이 에러 바운더리 패턴입니다.
상위 레벨에서 에러를 포착하고 처리하는 계층을 만들면 에러 처리 로직을 중앙화하고, 일관된 방식으로 에러를 처리할 수 있습니다.
개요
간단히 말해서, 이 패턴은 애플리케이션의 특정 계층에서 하위에서 발생한 모든 에러를 포착하여 통합적으로 처리하는 방법입니다. 에러 바운더리는 React에서 시작된 개념이지만, 비슷한 아이디어를 백엔드나 순수 TypeScript 애플리케이션에도 적용할 수 있습니다.
고차 함수(Higher-Order Function)나 데코레이터를 사용해 에러 처리 로직을 재사용 가능한 형태로 분리합니다. 실무에서 API 라우터, 비즈니스 로직 계층, 데이터 접근 계층 같은 경우에 매우 유용합니다.
기존에는 각 함수마다 에러 처리 코드를 작성했다면, 이제는 에러 바운더리로 함수를 감싸서 자동으로 에러를 처리합니다. 이 패턴의 핵심 특징은 에러 처리 로직의 재사용, 관심사의 분리, 그리고 일관된 에러 응답입니다.
이러한 특징들이 코드 중복을 줄이고 유지보수를 쉽게 만들어줍니다.
코드 예제
// 에러 처리 옵션
interface ErrorBoundaryOptions {
onError?: (error: Error) => void;
fallback?: <T>(error: Error) => T;
retryCount?: number;
}
// 에러 바운더리 고차 함수
function withErrorBoundary<TArgs extends any[], TReturn>(
fn: (...args: TArgs) => Promise<TReturn>,
options: ErrorBoundaryOptions = {}
): (...args: TArgs) => Promise<TReturn> {
return async (...args: TArgs): Promise<TReturn> => {
let lastError: Error | undefined;
const retries = options.retryCount || 0;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(...args);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// 마지막 시도가 아니면 재시도
if (attempt < retries) {
console.log(`재시도 중... (${attempt + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
continue;
}
// 에러 콜백 실행
options.onError?.(lastError);
// fallback이 있으면 사용
if (options.fallback) {
return options.fallback(lastError);
}
throw lastError;
}
}
throw lastError!;
};
}
// 사용 예시
const fetchWithErrorBoundary = withErrorBoundary(
async (url: string) => {
const response = await fetch(url);
return response.json();
},
{
retryCount: 3,
onError: (error) => {
console.error('API 호출 실패:', error);
// 여기서 로깅 서비스에 전송
},
fallback: () => ({ data: [], error: true })
}
);
설명
이것이 하는 일: 함수를 에러 처리 로직으로 감싸서 자동으로 에러를 포착하고, 로깅하고, 재시도하고, fallback 값을 반환하는 등의 작업을 수행합니다. 첫 번째로, withErrorBoundary는 제네릭을 사용해 어떤 함수든 감쌀 수 있는 고차 함수입니다.
TArgs extends any[]는 원본 함수의 매개변수 타입을, TReturn은 반환 타입을 캡처합니다. 이렇게 하면 타입 안전성을 유지하면서 에러 처리 기능을 추가할 수 있습니다.
그 다음으로, ErrorBoundaryOptions를 통해 에러 처리 동작을 커스터마이즈할 수 있습니다. onError 콜백으로 에러 로깅이나 알림을 추가하고, fallback으로 에러 발생 시 기본값을 제공하고, retryCount로 자동 재시도 횟수를 지정합니다.
이러한 옵션들은 상황에 맞게 조합하여 사용할 수 있습니다. 반환된 래퍼 함수는 for 루프로 재시도 로직을 구현합니다.
각 시도에서 원본 함수를 실행하고, 성공하면 즉시 결과를 반환합니다. 실패하면 에러를 캡처하고, 마지막 시도가 아니면 지수 백오프(exponential backoff) 방식으로 대기 시간을 늘려가며 재시도합니다.
setTimeout으로 다음 시도 전에 지연을 추가하여 네트워크나 서버가 복구될 시간을 줍니다. 마지막으로, 모든 재시도가 실패하면 onError 콜백을 실행하여 에러를 기록하고, fallback이 제공되었다면 그 값을 반환합니다.
fallback이 없으면 에러를 다시 throw하여 상위 호출자가 처리하도록 합니다. 여러분이 이 코드를 사용하면 반복적인 에러 처리 코드를 제거하고, 애플리케이션 전체에서 일관된 에러 처리를 보장할 수 있습니다.
새로운 함수를 추가할 때도 withErrorBoundary로 감싸기만 하면 자동으로 에러 처리, 로깅, 재시도 기능이 적용됩니다.
실전 팁
💡 여러 종류의 에러 바운더리를 만들어 계층별로 다르게 적용하면 더욱 세밀한 제어가 가능합니다 (예: apiErrorBoundary, dbErrorBoundary)
💡 재시도 로직에서 에러 타입을 확인하여 재시도 가능한 에러(네트워크 타임아웃)만 재시도하도록 개선할 수 있습니다
💡 TypeScript 데코레이터를 사용하면 클래스 메서드에 에러 바운더리를 더 우아하게 적용할 수 있습니다
💡 에러 바운더리에서 Sentry나 LogRocket 같은 모니터링 서비스와 통합하면 프로덕션 에러를 실시간으로 추적할 수 있습니다
💡 개발 환경과 프로덕션 환경에서 다른 에러 처리 전략을 사용하도록 환경 변수로 분기할 수 있습니다
5. Optional 체이닝과 Nullish 병합 - null/undefined 안전하게 다루기
시작하며
여러분이 중첩된 객체의 속성에 접근할 때 "Cannot read property of undefined" 에러로 앱이 크래시된 경험이 있나요? API에서 받은 데이터나 사용자 입력에서 특정 필드가 없을 수 있는데, 그것을 체크하지 않아서 발생하는 상황 말입니다.
이런 문제는 JavaScript의 동적 특성과 null/undefined 처리의 어려움 때문에 발생합니다. 전통적으로는 각 단계마다 if 문이나 삼항 연산자로 체크해야 했는데, 이는 코드를 장황하고 읽기 어렵게 만듭니다.
특히 깊게 중첩된 객체에서는 체크 로직이 실제 비즈니스 로직보다 더 많아지기도 합니다. 바로 이럴 때 필요한 것이 Optional 체이닝(?.)과 Nullish 병합(??) 연산자입니다.
이 두 기능을 사용하면 null/undefined를 우아하게 처리하고, 기본값을 설정하고, 안전하게 중첩된 속성에 접근할 수 있습니다.
개요
간단히 말해서, Optional 체이닝은 객체의 속성이 존재하지 않을 때 에러 대신 undefined를 반환하고, Nullish 병합은 null/undefined일 때만 기본값을 제공하는 연산자입니다. Optional 체이닝(?.)은 왼쪽 피연산자가 null이나 undefined면 즉시 undefined를 반환하고, 그렇지 않으면 오른쪽 속성에 접근합니다.
Nullish 병합(??)은 왼쪽 값이 null이나 undefined일 때만 오른쪽 값을 사용하며, 0이나 빈 문자열 같은 falsy 값과는 구분됩니다. 실무에서 API 응답 처리, 사용자 설정 값 읽기, 중첩된 객체 탐색 같은 경우에 매우 유용합니다.
기존에는 obj && obj.prop && obj.prop.nested 같은 긴 체크를 했다면, 이제는 obj?.prop?.nested로 간결하게 표현할 수 있습니다. 이 패턴의 핵심 특징은 간결한 문법, 안전한 속성 접근, 그리고 명확한 기본값 설정입니다.
이러한 특징들이 코드를 더 읽기 쉽고 유지보수하기 쉽게 만들어줍니다.
코드 예제
// API 응답 타입
interface UserResponse {
user?: {
profile?: {
name?: string;
email?: string;
preferences?: {
theme?: 'light' | 'dark';
notifications?: boolean;
};
};
statistics?: {
loginCount?: number;
lastLogin?: string;
};
};
}
// Optional 체이닝과 Nullish 병합 사용
function getUserInfo(response: UserResponse) {
// 안전한 중첩 속성 접근
const userName = response.user?.profile?.name ?? '익명 사용자';
const userEmail = response.user?.profile?.email ?? 'email@example.com';
// 0이나 false도 유효한 값으로 처리
const loginCount = response.user?.statistics?.loginCount ?? 0;
const notificationsEnabled = response.user?.profile?.preferences?.notifications ?? true;
// 테마 설정 (없으면 기본값)
const theme = response.user?.profile?.preferences?.theme ?? 'light';
// Optional 체이닝으로 함수 호출도 안전하게
const lastLoginDate = response.user?.statistics?.lastLogin;
const formattedDate = lastLoginDate ? new Date(lastLoginDate).toLocaleDateString() : '없음';
return {
userName,
userEmail,
loginCount,
notificationsEnabled,
theme,
lastLogin: formattedDate
};
}
// 사용 예시
const apiResponse: UserResponse = { user: { profile: { name: '홍길동' } } };
const userInfo = getUserInfo(apiResponse);
console.log(userInfo.userName); // '홍길동'
console.log(userInfo.loginCount); // 0 (기본값)
설명
이것이 하는 일: 중첩된 객체 구조에서 null이나 undefined로 인한 런타임 에러를 방지하고, 합리적인 기본값을 제공하여 안정적인 코드를 작성합니다. 첫 번째로, Optional 체이닝(?.)은 체인의 각 단계에서 값이 존재하는지 자동으로 확인합니다.
response.user?.profile?.name은 user가 undefined면 더 이상 진행하지 않고 undefined를 반환합니다. 이는 여러 번의 if 체크를 한 줄로 줄여주며, 코드의 의도를 명확하게 전달합니다.
그 다음으로, Nullish 병합(??)은 왼쪽 값이 정확히 null이나 undefined일 때만 오른쪽 기본값을 사용합니다. 이는 || 연산자와 중요한 차이가 있습니다.
예를 들어 loginCount ?? 0은 loginCount가 0일 때 0을 반환하지만, loginCount || 0은 0을 falsy로 간주하여 오른쪽 0을 반환합니다.
이 차이는 0, false, 빈 문자열이 유효한 값인 경우에 중요합니다. 두 연산자를 조합하면 매우 강력합니다.
response.user?.profile?.name ?? '익명 사용자'는 name 속성에 안전하게 접근하고, 존재하지 않으면 기본값을 제공합니다.
이 패턴은 API 응답 처리에서 매우 일반적입니다. 마지막으로, getUserInfo 함수는 불완전한 데이터를 받아도 항상 완전한 userInfo 객체를 반환합니다.
이렇게 하면 호출하는 쪽에서는 데이터의 존재 여부를 걱정하지 않고 안전하게 사용할 수 있습니다. 타입 시스템도 반환 타입에서 optional을 제거하여 더 안전한 코드를 작성할 수 있게 도와줍니다.
여러분이 이 코드를 사용하면 null/undefined 체크 코드가 대폭 줄어들고, 코드의 가독성이 크게 향상됩니다. 실수로 체크를 빠뜨려서 발생하는 런타임 에러도 방지할 수 있으며, 기본값 처리 로직이 명확해집니다.
실전 팁
💡 Optional 체이닝은 배열 접근(arr?.[0])과 함수 호출(fn?.())에도 사용할 수 있어 매우 versatile합니다
💡 Nullish 병합을 사용할 때는 0, false, 빈 문자열이 유효한 값인지 항상 고려하세요
💡 타입 정의에서 optional(?)을 남용하지 말고, 필수 필드는 명확히 required로 표시하면 더 안전합니다
💡 Optional 체이닝 후 타입 단언(!)은 피하세요 - 체이닝의 안전성을 무효화시킵니다
💡 복잡한 기본값 로직이 필요하면 ?? 대신 함수를 사용하는 것이 더 명확할 수 있습니다
6. 타입 좁히기로 에러 처리 - 유니온 타입 활용하기
시작하며
여러분이 API에서 성공 응답과 에러 응답을 모두 받을 수 있는 상황에서, 타입 단언(as)을 남발하거나 any를 사용해서 타입 안전성을 잃어버린 경험이 있나요? 성공인지 에러인지 구분하는 로직에서 타입 체크가 제대로 되지 않아 런타임 에러가 발생하는 상황 말입니다.
이런 문제는 유니온 타입을 제대로 활용하지 못해서 발생합니다. TypeScript는 강력한 타입 좁히기(Type Narrowing) 기능을 제공하지만, 판별 유니온(Discriminated Union)을 올바르게 설계하지 않으면 그 이점을 누릴 수 없습니다.
각 분기에서 타입이 자동으로 좁혀지지 않으면 매번 타입 체크를 해야 하고 코드가 지저분해집니다. 바로 이럴 때 필요한 것이 판별 유니온과 타입 좁히기입니다.
공통 속성(discriminant)을 사용해 타입을 구분하면 TypeScript가 자동으로 타입을 좁혀주어 안전하고 깔끔한 에러 처리가 가능합니다.
개요
간단히 말해서, 이 패턴은 유니온 타입의 각 멤버에 공통 리터럴 타입 속성을 추가하여 TypeScript가 타입을 자동으로 구분할 수 있게 하는 방법입니다. 판별 유니온(Discriminated Union)은 태그된 유니온(Tagged Union)이라고도 불리며, 각 타입이 고유한 리터럴 값을 가진 공통 속성을 갖습니다.
이 속성을 체크하면 TypeScript가 해당 분기에서 정확한 타입을 추론합니다. 실무에서 API 응답, 상태 관리, 이벤트 처리 같은 경우에 매우 유용합니다.
기존에는 타입 단언이나 타입 가드 함수로 수동으로 타입을 좁혔다면, 이제는 단순한 if 문만으로도 TypeScript가 자동으로 타입을 좁혀줍니다. 이 패턴의 핵심 특징은 자동 타입 추론, 컴파일 타임 안전성, 그리고 명시적인 상태 구분입니다.
이러한 특징들이 타입 안전한 에러 처리를 쉽고 자연스럽게 만들어줍니다.
코드 예제
// 판별 유니온으로 API 응답 타입 정의
type SuccessResponse<T> = {
status: 'success';
data: T;
timestamp: number;
};
type ErrorResponse = {
status: 'error';
error: {
code: string;
message: string;
};
timestamp: number;
};
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// 사용 예시: 사용자 데이터 페칭
async function fetchUser(userId: string): Promise<ApiResponse<{ name: string; email: string }>> {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!response.ok) {
return {
status: 'error',
error: {
code: `HTTP_${response.status}`,
message: data.message || '서버 오류가 발생했습니다'
},
timestamp: Date.now()
};
}
return {
status: 'success',
data,
timestamp: Date.now()
};
} catch (error) {
return {
status: 'error',
error: {
code: 'NETWORK_ERROR',
message: '네트워크 연결을 확인해주세요'
},
timestamp: Date.now()
};
}
}
// 타입 좁히기를 활용한 응답 처리
async function displayUser(userId: string) {
const response = await fetchUser(userId);
// status를 체크하면 TypeScript가 자동으로 타입을 좁혀줌
if (response.status === 'success') {
// 여기서 response는 SuccessResponse 타입
console.log('사용자 이름:', response.data.name);
console.log('이메일:', response.data.email);
return response.data;
} else {
// 여기서 response는 ErrorResponse 타입
console.error(`에러 [${response.error.code}]:`, response.error.message);
throw new Error(response.error.message);
}
}
설명
이것이 하는 일: 성공과 실패 상태를 명확히 구분된 타입으로 표현하고, 조건문만으로도 자동 타입 추론이 되도록 합니다. 첫 번째로, SuccessResponse와 ErrorResponse는 모두 status 속성을 갖지만 서로 다른 리터럴 값('success' vs 'error')을 사용합니다.
이 status가 판별자(discriminant) 역할을 합니다. TypeScript는 status 값을 체크하면 어떤 타입인지 확실히 알 수 있으므로, 해당 분기에서 적절한 속성에 접근할 수 있게 해줍니다.
그 다음으로, ApiResponse는 제네릭 타입 T를 사용하여 성공 시 반환할 데이터 타입을 유연하게 지정할 수 있습니다. 이렇게 하면 여러 종류의 API 엔드포인트에서 동일한 응답 패턴을 재사용할 수 있습니다.
timestamp 같은 공통 메타데이터도 양쪽 타입에 포함시킬 수 있습니다. fetchUser 함수에서는 try-catch로 네트워크 에러를 잡고, HTTP 상태 코드를 확인하여 적절한 응답 객체를 반환합니다.
중요한 점은 모든 반환 경로에서 ApiResponse 타입을 만족하는 객체를 반환한다는 것입니다. 예외를 throw하지 않고 에러 정보를 담은 객체를 반환하므로, 호출하는 쪽에서 에러 처리를 강제할 수 있습니다.
displayUser 함수에서 타입 좁히기의 마법이 일어납니다. if (response.status === 'success') 조건문 내부에서 TypeScript는 response가 SuccessResponse 타입이라고 추론합니다.
따라서 response.data.name에 안전하게 접근할 수 있고, 타입 에러도 발생하지 않습니다. else 블록에서는 자동으로 ErrorResponse로 좁혀져서 response.error에 접근할 수 있습니다.
여러분이 이 코드를 사용하면 타입 단언이나 any 없이도 완벽히 타입 안전한 에러 처리가 가능합니다. IDE의 자동완성도 정확하게 작동하고, 잘못된 속성에 접근하려고 하면 컴파일 타임에 에러를 잡아줍니다.
코드를 읽는 사람도 각 분기에서 어떤 데이터를 사용할 수 있는지 명확히 알 수 있습니다.
실전 팁
💡 판별 속성은 리터럴 타입(문자열, 숫자, boolean)을 사용해야 TypeScript가 타입을 좁힐 수 있습니다
💡 여러 종류의 에러를 구분해야 한다면 ErrorResponse를 다시 판별 유니온으로 세분화할 수 있습니다
💡 switch 문도 타입 좁히기를 지원하므로 3개 이상의 상태를 다룰 때 유용합니다
💡 exhaustiveness 체크를 위해 default 케이스에서 never 타입을 사용하면 모든 케이스를 처리했는지 확인할 수 있습니다
💡 판별 유니온은 Redux의 액션, React의 useReducer 같은 상태 관리 패턴과 완벽하게 어울립니다
7. 비동기 에러 처리 - Promise와 async/await 마스터하기
시작하며
여러분이 여러 개의 비동기 작업을 동시에 실행할 때 하나가 실패하면 나머지도 모두 중단되거나, 반대로 실패한 작업이 무시되어 데이터 불일치가 발생한 경험이 있나요? Promise.all을 사용했는데 하나만 실패해도 전체가 실패하거나, 각 작업의 성공/실패를 개별적으로 추적하기 어려운 상황 말입니다.
이런 문제는 비동기 에러 처리의 복잡성을 제대로 이해하지 못해서 발생합니다. Promise는 여러 가지 조합 방법(all, allSettled, race, any)을 제공하는데, 각각의 에러 처리 방식이 다릅니다.
잘못된 방법을 선택하면 원하는 동작을 얻을 수 없고, 일부 에러가 누락될 수 있습니다. 바로 이럴 때 필요한 것이 Promise 조합 메서드의 올바른 사용과 체계적인 비동기 에러 처리입니다.
각 상황에 맞는 적절한 Promise 메서드를 선택하고 에러를 처리하면 안정적인 비동기 코드를 작성할 수 있습니다.
개요
간단히 말해서, 이 패턴은 Promise.all, Promise.allSettled, Promise.race 등을 상황에 맞게 사용하고 각각의 에러 처리 특성을 이해하는 방법입니다. Promise.all은 모든 Promise가 성공해야 성공하고 하나라도 실패하면 즉시 reject됩니다.
Promise.allSettled는 모든 Promise의 완료를 기다리고 각각의 성공/실패 상태를 반환합니다. Promise.race는 가장 먼저 완료된 Promise의 결과를 반환합니다.
실무에서 병렬 API 호출, 타임아웃 구현, 캐시와 네트워크 동시 요청 같은 경우에 매우 유용합니다. 기존에는 Promise.all만 사용해서 하나의 실패가 전체를 실패시켰다면, 이제는 Promise.allSettled로 모든 결과를 받고 개별적으로 처리할 수 있습니다.
이 패턴의 핵심 특징은 적재적소의 Promise 조합, 세밀한 에러 제어, 그리고 예측 가능한 실패 처리입니다. 이러한 특징들이 복잡한 비동기 플로우를 안전하게 관리할 수 있게 해줍니다.
코드 예제
// 개별 API 호출 함수들
async function fetchUserProfile(userId: string) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('프로필 조회 실패');
return response.json();
}
async function fetchUserPosts(userId: string) {
const response = await fetch(`/api/users/${userId}/posts`);
if (!response.ok) throw new Error('게시물 조회 실패');
return response.json();
}
async function fetchUserFollowers(userId: string) {
const response = await fetch(`/api/users/${userId}/followers`);
if (!response.ok) throw new Error('팔로워 조회 실패');
return response.json();
}
// Promise.allSettled로 모든 요청 처리
async function loadUserDashboard(userId: string) {
const results = await Promise.allSettled([
fetchUserProfile(userId),
fetchUserPosts(userId),
fetchUserFollowers(userId)
]);
// 각 결과를 개별적으로 처리
const profile = results[0].status === 'fulfilled'
? results[0].value
: { name: '알 수 없음', error: true };
const posts = results[1].status === 'fulfilled'
? results[1].value
: [];
const followers = results[2].status === 'fulfilled'
? results[2].value
: [];
// 실패한 요청 로깅
results.forEach((result, index) => {
if (result.status === 'rejected') {
const names = ['프로필', '게시물', '팔로워'];
console.error(`${names[index]} 로딩 실패:`, result.reason);
}
});
return { profile, posts, followers };
}
// 타임아웃을 포함한 안전한 fetch
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('요청 시간 초과')), timeoutMs);
});
return Promise.race([promise, timeoutPromise]);
}
// 사용 예시
async function getUserData(userId: string) {
try {
const profile = await fetchWithTimeout(
fetchUserProfile(userId),
5000 // 5초 타임아웃
);
return profile;
} catch (error) {
if (error instanceof Error && error.message === '요청 시간 초과') {
console.error('서버 응답이 너무 느립니다');
}
throw error;
}
}
설명
이것이 하는 일: 여러 비동기 작업을 병렬로 실행하고, 일부가 실패해도 나머지 결과를 활용할 수 있도록 하며, 타임아웃 같은 고급 에러 처리 기능을 제공합니다. 첫 번째로, loadUserDashboard 함수는 Promise.allSettled를 사용하여 세 개의 API 호출을 동시에 시작합니다.
Promise.all과 달리 하나가 실패해도 다른 작업을 중단하지 않고 모두 완료될 때까지 기다립니다. 이는 사용자 경험을 크게 개선합니다.
예를 들어 팔로워 정보를 가져오지 못해도 프로필과 게시물은 보여줄 수 있습니다. 그 다음으로, allSettled가 반환하는 배열의 각 요소는 { status: 'fulfilled', value: T } 또는 { status: 'rejected', reason: any } 형태입니다.
status를 체크하면 TypeScript가 자동으로 타입을 좁혀주므로, fulfilled 분기에서는 value에, rejected 분기에서는 reason에 안전하게 접근할 수 있습니다. 각 결과를 개별적으로 처리하여 실패한 요청에는 기본값을 제공합니다.
forEach 루프에서는 실패한 요청만 필터링하여 에러 로깅을 수행합니다. 이렇게 하면 어떤 API가 실패했는지 추적할 수 있고, 프로덕션 환경에서 모니터링 서비스로 전송할 수 있습니다.
일부 실패가 있어도 대시보드는 정상적으로 로딩되므로 사용자는 가능한 정보를 볼 수 있습니다. fetchWithTimeout 함수는 Promise.race를 활용한 타임아웃 패턴입니다.
실제 API 호출과 타임아웃 Promise를 경쟁시켜, 먼저 완료되는 쪽의 결과를 반환합니다. API 호출이 너무 오래 걸리면 타임아웃 Promise가 먼저 reject되어 전체 Promise가 실패합니다.
이는 응답이 느린 서버 때문에 앱이 무한정 대기하는 것을 방지합니다. 여러분이 이 코드를 사용하면 네트워크가 불안정한 환경에서도 안정적으로 동작하는 애플리케이션을 만들 수 있습니다.
일부 API가 실패해도 사용자는 가능한 정보를 볼 수 있고, 타임아웃으로 무한 대기를 방지할 수 있습니다. 각 에러의 원인을 정확히 추적하여 문제를 빠르게 파악할 수 있습니다.
실전 팁
💡 Promise.all은 모든 요청이 필수적일 때만 사용하고, 부분 실패를 허용한다면 Promise.allSettled를 사용하세요
💡 타임아웃 값은 네트워크 상황을 고려해 설정하되, 너무 짧으면 정상 요청도 실패할 수 있습니다
💡 AbortController를 사용하면 타임아웃 시 실제로 네트워크 요청을 취소할 수 있어 리소스를 절약합니다
💡 재시도 로직과 타임아웃을 조합하면 일시적인 네트워크 문제에 강한 시스템을 만들 수 있습니다
💡 병렬 요청 수를 제한하는 로직(concurrency control)을 추가하면 서버 부하를 줄일 수 있습니다
8. Zod를 활용한 런타임 검증 - 타입 안전성을 런타임까지 확장하기
시작하며
여러분이 외부 API나 사용자 입력을 받을 때 TypeScript 타입만으로는 안전하지 않다는 것을 깨달은 적이 있나요? 컴파일 타임에는 문제가 없었는데 런타임에 예상치 못한 데이터 형식이 들어와서 앱이 크래시되는 상황 말입니다.
이런 문제는 TypeScript의 타입 시스템이 컴파일 타임에만 존재한다는 한계 때문에 발생합니다. 런타임에는 타입 정보가 사라지므로, API 응답이나 사용자 입력이 실제로 예상한 형태인지 검증할 수 없습니다.
JSON.parse로 파싱한 데이터를 그대로 사용하면 타입 에러가 발생하거나 잘못된 데이터로 비즈니스 로직이 망가질 수 있습니다. 바로 이럴 때 필요한 것이 런타임 스키마 검증 라이브러리인 Zod입니다.
Zod를 사용하면 타입 정의와 런타임 검증을 동시에 할 수 있고, 검증 실패 시 명확한 에러 메시지를 제공받을 수 있습니다.
개요
간단히 말해서, Zod는 스키마를 정의하면 자동으로 TypeScript 타입을 생성하고 런타임에 데이터를 검증하는 라이브러리입니다. Zod 스키마는 데이터의 구조와 제약사항을 선언적으로 정의합니다.
z.object, z.string, z.number 같은 빌더로 스키마를 만들고, parse() 메서드로 데이터를 검증합니다. 검증에 실패하면 어떤 필드가 어떻게 잘못되었는지 상세한 에러 정보를 제공합니다.
실무에서 API 응답 검증, 폼 입력 검증, 환경 변수 검증 같은 경우에 매우 유용합니다. 기존에는 TypeScript 타입을 따로 정의하고 런타임 검증 로직을 수동으로 작성했다면, 이제는 Zod 스키마 하나로 두 가지를 모두 얻을 수 있습니다.
이 패턴의 핵심 특징은 타입과 검증의 단일 소스, 명확한 에러 메시지, 그리고 타입 추론입니다. 이러한 특징들이 외부 데이터를 다룰 때 완벽한 타입 안전성을 제공합니다.
코드 예제
import { z } from 'zod';
// Zod 스키마 정의 (타입과 검증을 동시에)
const UserSchema = z.object({
id: z.string().uuid('유효한 UUID 형식이 아닙니다'),
email: z.string().email('유효한 이메일 주소가 아닙니다'),
age: z.number().int().min(18, '18세 이상만 가입할 수 있습니다'),
name: z.string().min(2, '이름은 2자 이상이어야 합니다').max(50),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime().optional(),
preferences: z.object({
newsletter: z.boolean().default(false),
theme: z.enum(['light', 'dark']).default('light')
}).optional()
});
// 자동으로 TypeScript 타입 생성
type User = z.infer<typeof UserSchema>;
// API 응답 검증
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const rawData = await response.json();
try {
// 런타임 검증 수행
const validatedUser = UserSchema.parse(rawData);
return validatedUser;
} catch (error) {
if (error instanceof z.ZodError) {
// 상세한 검증 에러 처리
console.error('데이터 검증 실패:');
error.errors.forEach(err => {
console.error(`- ${err.path.join('.')}: ${err.message}`);
});
throw new Error('서버에서 잘못된 데이터를 받았습니다');
}
throw error;
}
}
// 안전한 검증 (에러 던지지 않음)
function validateUserInput(data: unknown): { success: true; data: User } | { success: false; errors: string[] } {
const result = UserSchema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
const errors = result.error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
return { success: false, errors };
}
// 부분 검증 (필드 일부만)
const UpdateUserSchema = UserSchema.partial();
// 사용 예시
const inputData = {
id: 'not-a-uuid',
email: 'invalid-email',
age: 15,
name: 'A',
role: 'superadmin'
};
const validation = validateUserInput(inputData);
if (!validation.success) {
console.error('검증 실패:', validation.errors);
// 출력:
// - id: 유효한 UUID 형식이 아닙니다
// - email: 유효한 이메일 주소가 아닙니다
// - age: 18세 이상만 가입할 수 있습니다
// - name: 이름은 2자 이상이어야 합니다
// - role: Invalid enum value...
}
설명
이것이 하는 일: 런타임에 데이터 구조와 값의 유효성을 검증하고, 실패 시 어떤 필드가 잘못되었는지 정확히 알려줍니다. 첫 번째로, UserSchema는 User 객체의 모든 필드와 제약사항을 선언적으로 정의합니다.
z.string().uuid()는 문자열이면서 UUID 형식이어야 한다는 의미이고, z.number().int().min(18)은 정수이면서 18 이상이어야 한다는 의미입니다. 각 제약사항에 커스텀 에러 메시지를 추가하여 사용자 친화적인 피드백을 제공할 수 있습니다.
그 다음으로, z.infer<typeof UserSchema>는 스키마로부터 자동으로 TypeScript 타입을 생성합니다. 이는 스키마와 타입이 항상 동기화되도록 보장합니다.
스키마를 수정하면 타입도 자동으로 업데이트되므로, 중복 정의나 불일치 문제가 발생하지 않습니다. fetchUser 함수에서는 parse() 메서드로 API 응답을 검증합니다.
데이터가 스키마를 만족하면 검증된 User 객체를 반환하고, 그렇지 않으면 ZodError를 throw합니다. ZodError의 errors 배열에는 각 검증 실패에 대한 상세 정보(경로, 메시지, 타입)가 담겨 있어 디버깅이 쉽습니다.
validateUserInput 함수는 safeParse()를 사용하여 에러를 던지지 않고 Result 형태로 반환합니다. 이는 폼 검증처럼 에러 자체가 정상적인 플로우인 경우에 유용합니다.
success 플래그로 성공/실패를 구분하고, 실패 시 모든 에러 메시지를 배열로 받아서 UI에 표시할 수 있습니다. 마지막으로, partial()이나 pick(), omit() 같은 유틸리티로 기존 스키마를 변형할 수 있습니다.
UpdateUserSchema는 UserSchema의 모든 필드를 optional로 만들어, PATCH 요청처럼 일부 필드만 업데이트할 때 사용할 수 있습니다. 여러분이 이 코드를 사용하면 런타임에 잘못된 데이터로 인한 버그를 완전히 방지할 수 있습니다.
API 명세가 변경되었거나 클라이언트가 잘못된 데이터를 보내도 즉시 감지하고 적절히 처리할 수 있습니다. 타입과 검증 로직을 한 곳에서 관리하므로 유지보수도 훨씬 쉬워집니다.
실전 팁
💡 Zod 스키마를 별도의 파일(schemas.ts)로 분리하면 프론트엔드와 백엔드에서 동일한 검증 로직을 공유할 수 있습니다
💡 transform() 메서드를 사용하면 검증과 동시에 데이터 변환(예: 문자열 날짜를 Date 객체로)을 수행할 수 있습니다
💡 환경 변수 검증에 Zod를 사용하면 앱 시작 시 설정 오류를 조기에 발견할 수 있습니다
💡 superRefine()으로 복잡한 커스텀 검증 로직(예: 두 필드 간의 관계)을 추가할 수 있습니다
💡 Zod와 React Hook Form을 조합하면 타입 안전한 폼 검증을 쉽게 구현할 수 있습니다
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.
에러 처리와 폴백 완벽 가이드
AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.
외부 API 연동 에이전트 완벽 가이드
Lambda를 활용한 외부 API 연동 에이전트 구현부터 에러 처리, 타임아웃 관리, 테스트까지 실무에 바로 적용 가능한 완벽 가이드입니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.