Zod 스키마와 API 검증
🤔 왜 Zod인가? - 기술 선택의 배경
문제 상황: TypeScript만으로는 부족하다
TypeScript는 컴파일 타임에 타입 안전성을 보장하지만, 런타임에서 외부 API로부터 받는 데이터의 타입은 보장할 수 없습니다. 다음과 같은 상황에서 문제가 발생합니다.
// TypeScript 인터페이스 정의
interface ApiResponseData {
totalValue: number;
timestamp: string;
status: number;
}
// API 응답이 인터페이스와 다를 때 문제 발생
const fetchData = async (): Promise<ApiResponseData> => {
const response = await fetch('/api/data');
return response.json(); // 💥 타입 단언이지만 실제 검증은 없음
};
// 실제 런타임 데이터
const actualApiResponse = {
totalValue: null, // number 대신 null
timestamp: undefined, // string 대신 undefined
status: "active" // number 대신 string
};
// 결과: 컴파일은 성공하지만 런타임에 크래시
const data = await fetchData();
console.log(data.totalValue.toFixed(2)); // 💥 Cannot read property 'toFixed' of null
기술 선택지 비교 분석
런타임 타입 검증을 위한 여러 라이브러리를 비교 분석한 결과입니다.
| 라이브러리 | 장점 | 단점 | 선택 이유 |
|---|---|---|---|
| Zod | • TypeScript 타입 자동 추론 • 풍부한 변환 기능 • 체이닝 문법 • 활발한 커뮤니티 |
• 번들 크기 (2.7KB gzipped) • 학습 곡선 |
✅ 채택: 타입 추론의 강력함 |
| Yup | • 성숙한 라이브러리 • 폼 검증에 특화 |
• TypeScript 지원 부족 • 타입 추론 한계 |
❌ 타입 안전성 부족 |
| Joi | • 매우 강력한 검증 • 상세한 에러 메시지 |
• 브라우저 지원 부족 • 큰 번들 크기 |
❌ 클라이언트 부적합 |
| io-ts | • 함수형 프로그래밍 • 완벽한 타입 안전성 |
• 가파른 학습 곡선 • 복잡한 문법 |
❌ 팀 생산성 저하 |
Zod 선택의 결정적 이유
Zod의 가장 큰 장점은 스키마에서 TypeScript 타입을 자동으로 추론해준다는 점입니다.
// Zod의 타입 자동 추론 마법
const apiDataSchema = z.object({
totalValue: z.coerce.number(),
timestamp: z.string(),
status: z.number()
});
// TypeScript 타입이 자동으로 추론됨! 🎉
type ApiDataType = z.infer<typeof apiDataSchema>;
// 결과: { totalValue: number; timestamp: string; status: number; }
// 스키마 = 타입 = 런타임 검증이 모두 하나로 통합
const validateApiData = (data: unknown): ApiDataType => {
return apiDataSchema.parse(data); // 검증 + 타입 가드 + 변환
};
- Single Source of Truth: 스키마 하나로 타입 + 검증 + 문서화가 모두 가능합니다
- 개발자 경험: IDE 자동완성, 리팩토링 지원이 완벽합니다
- 점진적 도입: 기존 코드에 단계적으로 적용할 수 있습니다
- 성능: 프로덕션에서 검증 로직을 선택적으로 제거할 수 있습니다
🏗️ 스키마 설계 패턴과 실전 예시
1. 기본 스키마 패턴
환경에 따라 다른 검증 전략을 적용하여 개발 환경에서는 엄격하게, 운영 환경에서는 유연하게 검증할 수 있습니다.
// src/utilities/validation/schemas/apiData.zod.ts
// 🎯 패턴 1: 엄격한 검증 (개발 환경)
export const strictApiDataSchema = z.object({
id: z.number().int().positive(), // 양의 정수만
timestamp: z.string().datetime(), // ISO 8601 형식만
value: z.number().min(0), // 음수 불허
status: z.enum(['ACTIVE', 'INACTIVE', 'PENDING']), // 정확한 값만
score: z.number().finite(), // 무한대 값 불허
});
// 🎯 패턴 2: 관대한 검증 (운영 환경)
export const lenientApiDataSchema = z.object({
id: z.coerce.number(), // 문자열도 숫자로 변환
timestamp: z.union([ // 다양한 날짜 형식 허용
z.string(),
z.date(),
z.number().transform(n => new Date(n))
]),
value: z.coerce.number().nullable(), // null 허용
status: z.string().default('ACTIVE'), // 기본값 제공
score: z.coerce.number().default(0), // 기본값 제공
});
// 🎯 패턴 3: 환경별 스키마 선택
export const apiDataSchema = process.env.NODE_ENV === 'development'
? strictApiDataSchema
: lenientApiDataSchema;
2. 복잡한 데이터 구조 처리
실제 애플리케이션에서는 중첩된 객체와 배열이 포함된 복잡한 데이터 구조를 다뤄야 합니다.
// 중첩 객체와 배열이 포함된 복잡한 API 응답
export const complexDataSchema = z.object({
metadata: z.object({
timestamp: z.coerce.date(),
source: z.string(),
version: z.string().regex(/^\d+\.\d+\.\d+$/) // 시맨틱 버저닝
}),
items: z.array(
z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
quantity: z.number().min(0),
discount: z.number().min(0).max(100), // 0-100% 범위
category: z.enum(['ELECTRONICS', 'CLOTHING', 'BOOKS', 'OTHER']),
lastUpdated: z.coerce.date().optional(),
})
).min(1), // 최소 1개 아이템은 있어야 함
summary: z.object({
totalItems: z.number(),
totalValue: z.number(),
averagePrice: z.number(),
// 계산된 필드는 검증으로 일관성 체크
}).refine(data => data.totalValue >= 0, {
message: "총 가격은 음수일 수 없습니다"
})
});
3. 날짜/시간 처리의 복잡함
웹 애플리케이션에서 시간 데이터는 매우 중요하므로 엄격하게 검증해야 합니다.
// 다양한 형식의 날짜 입력을 허용하되 검증은 엄격하게
export const timeAwareDataSchema = z.object({
createdAt: z.custom<Date>((val) => {
// 다양한 형식의 날짜 입력 허용하되 검증은 엄격하게
const date = dayjs(val);
if (!date.isValid()) {
throw new Error(`Invalid date format: ${val}`);
}
// 비즈니스 규칙: 미래 데이터는 허용하지 않음
if (date.isAfter(dayjs())) {
throw new Error("Future dates are not allowed");
}
// 비즈니스 규칙: 너무 오래된 데이터도 의심스러움
if (date.isBefore(dayjs().subtract(1, 'year'))) {
throw new Error("Data older than 1 year is suspicious");
}
return true;
}).transform((val) => dayjs(val).toDate()),
// 시간 범위 데이터 처리
timeRange: z.object({
start: z.coerce.date(),
end: z.coerce.date()
}).refine(data => data.start < data.end, {
message: "시작 시간이 종료 시간보다 빨라야 합니다"
})
});
4. 에러 메시지 커스터마이징
사용자에게 친화적인 에러 메시지를 제공하여 문제 해결을 쉽게 만들 수 있습니다.
// 사용자 친화적인 에러 메시지
export const userFriendlySchema = z.object({
price: z.number({
required_error: "가격 정보가 필요합니다",
invalid_type_error: "가격은 숫자여야 합니다"
}).min(0, "가격은 0 이상이어야 합니다"),
productName: z.string({
required_error: "상품명이 필요합니다"
}).min(1, "상품명은 비어있을 수 없습니다")
.max(100, "상품명은 100자를 초과할 수 없습니다"),
location: z.object({
lat: z.number().min(-90).max(90, "위도는 -90도에서 90도 사이여야 합니다"),
lng: z.number().min(-180).max(180, "경도는 -180도에서 180도 사이여야 합니다")
})
});
🎭 고급 Zod 패턴과 트릭
1. 조건부 스키마 (Conditional Schema)
데이터 타입에 따라 다른 검증 규칙을 적용해야 하는 경우에 사용하는 패턴입니다.
// 제품 타입에 따라 다른 검증 규칙 적용
const baseProductSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['DIGITAL', 'PHYSICAL', 'SERVICE', 'SUBSCRIPTION']),
});
const productSchema = baseProductSchema.and(
z.discriminatedUnion('type', [
// 디지털 제품만의 특별한 필드
z.object({
type: z.literal('DIGITAL'),
fileSize: z.number().positive(),
downloadLimit: z.number().min(1).max(10), // 다운로드 제한
format: z.enum(['PDF', 'MP4', 'ZIP']),
}),
// 물리적 제품만의 특별한 필드
z.object({
type: z.literal('PHYSICAL'),
weight: z.number().positive(),
dimensions: z.object({ // 제품 크기
width: z.number().positive(),
height: z.number().positive(),
depth: z.number().positive(),
}),
shippingCost: z.number().min(0),
}),
// 서비스
z.object({
type: z.literal('SERVICE'),
duration: z.number().positive(), // 서비스 기간 (시간)
location: z.string(), // 서비스 제공 위치
}),
// 구독 서비스
z.object({
type: z.literal('SUBSCRIPTION'),
billingCycle: z.enum(['MONTHLY', 'YEARLY']),
features: z.array(z.string()).min(1), // 구독 특징들
})
])
);
2. 스키마 재사용과 확장
기본 스키마를 만들고 이를 확장하여 코드 중복을 줄이고 일관성을 유지할 수 있습니다.
// 기본 타임스탬프 스키마
const timestampedSchema = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// 기본 메타데이터 스키마
const metadataSchema = z.object({
id: z.string().uuid(),
version: z.number().int().positive(),
});
// 조합하여 새로운 스키마 생성
const dataWithMetadataSchema = timestampedSchema
.merge(metadataSchema)
.merge(z.object({
value: z.number(),
description: z.string(),
}));
// 특정 필드만 선택
const dataSummarySchema = dataWithMetadataSchema.pick({
id: true,
value: true,
updatedAt: true
});
// 특정 필드만 제외
const dataWithoutMetadataSchema = dataWithMetadataSchema.omit({
id: true,
version: true,
createdAt: true,
updatedAt: true
});
3. 성능 최적화 패턴
스키마 검증은 성능에 영향을 줄 수 있으므로 적절한 최적화가 필요합니다.
// 🐌 비효율적인 방법: 매번 새로운 스키마 생성
const createSchemaEveryTime = (strictMode: boolean) => {
return z.object({
value: strictMode ? z.number() : z.coerce.number(),
name: z.string()
});
};
// 🚀 효율적인 방법: 스키마 캐싱
const strictSchema = z.object({
value: z.number(),
name: z.string()
});
const lenientSchema = z.object({
value: z.coerce.number(),
name: z.string()
});
const getOptimizedSchema = (strictMode: boolean) => {
return strictMode ? strictSchema : lenientSchema;
};
// 🚀 더 효율적인 방법: Lazy 평가
const lazyComplexSchema = z.lazy(() =>
z.object({
children: z.array(lazyComplexSchema).optional(), // 재귀 참조
value: z.number()
})
);
4. 에러 핸들링과 복구 전략
검증 실패 시에도 애플리케이션이 계속 동작할 수 있도록 우아한 에러 처리 전략이 필요합니다.
// 검증 실패 시 우아한 처리
export const safeValidateWithFallback = <T>(
data: unknown,
schema: z.ZodSchema<T>,
fallback: T
): { success: true; data: T } | { success: false; data: T; errors: string[] } => {
try {
const validatedData = schema.parse(data);
return { success: true, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
// 상세한 에러 정보 추출
const errors = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
// 부분적 복구 시도
const partialData = attemptPartialRecovery(data, schema, fallback);
return {
success: false,
data: partialData,
errors
};
}
// 예상치 못한 에러는 fallback 반환
return {
success: false,
data: fallback,
errors: ['Unexpected validation error']
};
}
};
// 부분적 데이터 복구 시도
const attemptPartialRecovery = <T>(
data: any,
schema: z.ZodSchema<T>,
fallback: T
): T => {
// 스키마에서 필수 필드와 선택적 필드 구분
const result = { ...fallback };
if (typeof data === 'object' && data !== null) {
Object.keys(fallback).forEach(key => {
if (data[key] !== undefined && data[key] !== null) {
try {
// 개별 필드 검증 시도
const fieldSchema = (schema as any).shape[key];
if (fieldSchema) {
result[key] = fieldSchema.parse(data[key]);
}
} catch {
// 개별 필드 검증 실패해도 계속 진행
}
}
});
}
return result;
};
🔧 실제 프로젝트 적용 사례
케이스 1: 사용자 프로필 데이터 검증
사용자 프로필 API에서 받아오는 데이터에 문제가 많은 경우의 검증 사례입니다.
// src/utilities/validation/schemas/userProfile.zod.ts
// 실제 API 응답 예시 (문제가 많은 데이터)
const problematicApiResponse = {
"userId": "12345",
"email": "user@example.com",
"profileComplete": "true", // boolean이 아닌 문자열
"score": null, // null 값
"preferences": {
"theme": "invalid_theme", // 잘못된 enum 값
"notifications": "error_value" // 잘못된 값
}
};
// 강건한 검증 스키마
export const userProfileSchema = z.object({
userId: z.string().min(1, "사용자 ID는 필수입니다"),
email: z.string()
.email("올바른 이메일 형식이 아닙니다")
.transform(email => email.toLowerCase()),
profileComplete: z.coerce.boolean()
.transform(val => val ?? false), // null을 false로 변환
score: z.coerce.number()
.min(0, "점수는 음수일 수 없습니다")
.max(100, "점수는 100을 초과할 수 없습니다")
.nullable()
.transform(val => val ?? 0), // null을 0으로 변환
preferences: z.object({
theme: z.enum(['LIGHT', 'DARK', 'AUTO'])
.catch('LIGHT'), // 잘못된 값은 기본값으로 대체
notifications: z.coerce.boolean()
.catch(true) // 변환 실패 시 true로 대체
})
});
// 검증 함수
export const validateUserProfile = <T>(
data: T
): Promise<ValidationResult<T>> => {
return validateApiResponseInBackground(
data,
userProfileSchema,
"getUserProfile",
{
// 사용자 데이터는 민감하므로 엄격한 검증
enableStrictValidation: true
}
);
};
케이스 2: 주문 데이터 검증
전자상거래 시스템에서 주문 데이터를 검증하는 복잡한 케이스입니다.
// src/utilities/validation/schemas/orderData.zod.ts
// 주문 데이터는 정확성이 매우 중요함
export const orderDataSchema = z.object({
orderId: z.string().uuid("올바른 주문 ID 형식이 아닙니다"),
items: z.array(
z.object({
productId: z.string().min(1),
name: z.string().min(1, "상품명은 필수입니다"),
price: z.coerce.number()
.min(0, "가격은 음수일 수 없습니다")
.max(1000000, "가격이 비정상적으로 높습니다"),
quantity: z.coerce.number()
.int("수량은 정수여야 합니다")
.min(1, "수량은 1개 이상이어야 합니다")
.max(999, "수량이 너무 많습니다"),
category: z.string().default("기타")
})
).min(1, "주문에는 최소 1개의 상품이 있어야 합니다"),
shippingInfo: z.object({
address: z.string().min(5, "주소는 5자 이상이어야 합니다"),
zipCode: z.string()
.regex(/^\d{5}(-\d{4})?$/, "올바른 우편번호 형식이 아닙니다"),
recipientName: z.string().min(1, "수령인 이름은 필수입니다")
}),
payment: z.object({
method: z.enum(['CARD', 'BANK_TRANSFER', 'MOBILE']),
amount: z.coerce.number().min(0),
currency: z.literal('KRW').default('KRW')
}),
orderDate: z.coerce.date()
.refine(date => date <= new Date(), "주문 날짜는 미래일 수 없습니다")
}).refine(data => {
// 비즈니스 로직 검증: 총 주문 금액과 결제 금액이 일치해야 함
const totalItemPrice = data.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
return Math.abs(totalItemPrice - data.payment.amount) < 0.01;
}, {
message: "총 상품 금액과 결제 금액이 일치하지 않습니다"
});
케이스 3: 실시간 데이터 스트림 검증
실시간으로 들어오는 데이터 스트림에 대한 검증 시스템입니다.
// src/utilities/validation/schemas/realtimeData.zod.ts
// 실시간 데이터 배열 검증
export const realtimeDataStreamSchema = z.array(
z.object({
timestamp: z.coerce.date(),
metric: z.string().min(1),
value: z.coerce.number().finite(),
tags: z.record(z.string()).optional(),
source: z.string().min(1)
}).refine(data => {
// 데이터가 5분 이내의 것인지 확인
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return data.timestamp >= fiveMinutesAgo;
}, "데이터가 너무 오래되었습니다")
).transform(dataPoints => {
// 시간순 정렬
return dataPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}).refine(data => data.length > 0, {
message: "실시간 데이터가 비어있습니다"
});
// 실시간 데이터 검증 함수
export const validateRealtimeDataStream = <T>(
data: T
): Promise<ValidationResult<T>> => {
return validateApiResponseInBackground(
data,
realtimeDataStreamSchema,
"getRealtimeDataStream",
{
// 실시간 데이터는 빠른 처리가 중요
enableDateFiltering: true,
toleranceMs: 5 * 60 * 1000, // 5분 허용 오차
enableRealTimeFiltering: true
}
);
};
🎯 성능 최적화와 실무 팁
1. 스키마 컴파일 최적화
스키마를 매번 새로 생성하지 않고 캐싱하여 성능을 향상시킬 수 있습니다.
// ❌ 성능이 좋지 않은 방법
const createDynamicSchema = (options: ValidationOptions) => {
return z.object({
field1: options.strict ? z.number() : z.coerce.number(),
field2: options.includeField2 ? z.string() : z.string().optional(),
// 매번 새로운 객체 생성으로 메모리 낭비
});
};
// ✅ 성능 최적화된 방법
const schemaCache = new Map<string, z.ZodSchema>();
const getOptimizedSchema = (options: ValidationOptions) => {
const cacheKey = `${options.strict}-${options.includeField2}`;
if (!schemaCache.has(cacheKey)) {
const schema = z.object({
field1: options.strict ? z.number() : z.coerce.number(),
field2: options.includeField2 ? z.string() : z.string().optional(),
});
schemaCache.set(cacheKey, schema);
}
return schemaCache.get(cacheKey)!;
};
2. 부분 검증 전략
큰 객체의 경우 중요한 필드를 먼저 검증하고, 나머지는 나중에 검증하는 전략입니다.
// 큰 객체는 필수 필드만 먼저 검증
const quickValidationSchema = z.object({
id: z.string(),
timestamp: z.coerce.date(),
status: z.enum(['SUCCESS', 'ERROR']),
// 중요한 필드만 먼저 검증
});
const fullValidationSchema = quickValidationSchema.extend({
detailedData: z.object({
// 복잡한 중첩 객체
items: z.array(z.object({
// ... 많은 필드들
})),
analytics: z.object({
// ... 복잡한 계산 결과들
})
}).optional()
});
// 단계적 검증
export const validateApiData = async <T>(
data: T
): Promise<ValidationResult<T>> => {
// 1단계: 빠른 필수 검증
try {
quickValidationSchema.parse(data);
} catch (error) {
// 필수 검증 실패 시 즉시 로깅
return validateApiResponseInBackground(data, quickValidationSchema, "quickCheck");
}
// 2단계: 상세 검증 (백그라운드)
return validateApiResponseInBackground(data, fullValidationSchema, "fullCheck");
};
3. 에러 누적과 배치 처리
여러 API 응답을 한 번에 검증하여 효율성을 높일 수 있습니다.
// 여러 API 응답을 한 번에 검증
export const batchValidateApiResponses = async (
responses: Array<{ name: string; data: unknown; schema: z.ZodSchema }>
): Promise<ValidationResult[]> => {
const results = await Promise.allSettled(
responses.map(async ({ name, data, schema }) => {
try {
const validated = schema.parse(data);
return { success: true, name, data: validated };
} catch (error) {
return { success: false, name, data, error };
}
})
);
// 실패한 검증들을 배치로 로깅
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason);
if (failures.length > 0) {
// 한 번에 여러 에러를 로깅
batchLogger.addBatch(failures);
}
return results.map(result =>
result.status === 'fulfilled' ? result.value : { success: false, error: result.reason }
);
};
🐛 흔한 실수와 해결책
1. 타입 강제 변환의 함정
const dangerousSchema = z.object({
value: z.coerce.number() // 모든 것을 숫자로 변환하려 시도
});
dangerousSchema.parse({ value: "definitely not a number" }); // NaN 반환
const safeSchema = z.object({
value: z.string()
.regex(/^\d+(\.\d+)?$/, "숫자 형식이 아닙니다")
.transform(val => parseFloat(val))
.refine(val => !isNaN(val), "유효한 숫자가 아닙니다")
});
2. 성능 최적화 vs 검증 엄격성
환경별로 다른 검증 전략을 적용하여 개발 시에는 엄격하게, 운영 시에는 성능을 고려한 검증을 수행합니다.
// 환경별 검증 전략
const getValidationStrategy = () => {
const env = process.env.NODE_ENV;
switch (env) {
case 'development':
return {
schema: strictSchema, // 엄격한 검증
async: false, // 동기 검증 (즉시 에러 확인)
throwOnError: true // 에러 시 즉시 중단
};
case 'testing':
return {
schema: strictSchema,
async: true, // 비동기 검증
throwOnError: false, // 에러 로깅만
sampleRate: 1.0 // 100% 검증
};
case 'production':
return {
schema: lenientSchema, // 관대한 검증
async: true,
throwOnError: false,
sampleRate: 0.1 // 10%만 검증 (성능 고려)
};
}
};
3. 메모리 누수 방지
const memoryLeakSchema = z.array(
z.object({
largeData: z.string().transform(str => {
// 큰 객체를 계속 생성하여 메모리에 누적
return new Array(10000).fill(str);
})
})
);
const memoryEfficientSchema = z.array(
z.object({
data: z.string()
.max(1000, "데이터가 너무 큽니다")
.transform(str => str.slice(0, 100)) // 크기 제한
})
).max(1000, "배열이 너무 큽니다"); // 배열 크기 제한
🔮 고급 활용과 확장
1. 동적 스키마 생성
API 설정에 따라 동적으로 스키마를 생성하는 고급 패턴입니다.
// API 설정에 따라 동적으로 스키마 생성
const createDynamicSchema = (config: ApiConfig) => {
const baseSchema = z.object({
id: z.string(),
name: z.string(),
});
// 설정에 따라 필드 추가
let schema = baseSchema;
if (config.includeLocation) {
schema = schema.extend({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
});
}
if (config.includeRealTimeData) {
schema = schema.extend({
currentValue: z.number().min(0),
lastUpdated: z.coerce.date(),
});
}
if (config.includeMetadata) {
schema = schema.extend({
metadata: z.object({
version: z.string(),
source: z.string(),
tags: z.array(z.string()).optional(),
}),
});
}
return schema;
};
2. 스키마 버전 관리
API 버전별로 다른 스키마를 관리하고 자동으로 적절한 스키마를 선택하는 방법입니다.
// API 버전별 스키마 관리
const dataSchemaV1 = z.object({
value: z.number(),
timestamp: z.string(),
});
const dataSchemaV2 = z.object({
dataValue: z.number(), // 필드명 변경
timestamp: z.coerce.date(), // 타입 강화
metadata: z.object({ // 새 필드 추가
version: z.literal("2.0"),
source: z.string(),
}),
});
const dataSchemaV3 = dataSchemaV2.extend({
category: z.string(), // 새 필드
tags: z.array(z.string()), // 새 필드
});
// 버전 자동 감지 및 적절한 스키마 적용
const detectAndValidateData = (data: unknown) => {
// 버전 감지 로직
if (hasField(data, 'metadata.version')) {
const version = (data as any).metadata.version;
switch (version) {
case "3.0": return dataSchemaV3.parse(data);
case "2.0": return dataSchemaV2.parse(data);
}
}
// fallback to v1
return dataSchemaV1.parse(data);
};
특히 환경별 검증 전략과 성능 최적화 패턴을 적절히 활용한다면, 개발 생산성과 운영 안정성을 모두 확보할 수 있을 것입니다.
'je개발 회고' 카테고리의 다른 글
| [ 번들러 ] Monorepo 모노레포 번들러 비교 (0) | 2025.12.16 |
|---|---|
| [ 데이터 품질 관리 ] (4) Frontend : Batching (4) | 2025.08.19 |
| [ 데이터 품질 관리 ] (2) Frontend : 아키텍쳐와 설계 철학 (3) | 2025.08.18 |
| [ 데이터 품질 관리 ] (1) 기획 : 페이지 시나리오 (1) | 2025.08.18 |
| [ 데이터 품질 관리 ] (0) 프롤로그 : 탄생 배경 (2) | 2025.08.18 |