검증을 통과한 파일이 문제를 일으켰다
4편까지 구현한 검증은 파일의 크기, 확장자, 개수, 중복 여부를 확인했습니다.
File 객체의 name과 size 속성만 보면 되니 동기적이고 빠릅니다.
이 검증만으로 충분하다고 생각했습니다.
극단적인 페이지 비율
그런데 AI 파이프라인에서 문제가 생겼습니다.
특정 PDF가 전처리 단계에서 글자가 누락되거나 레이아웃이 깨지는 현상이 발생한 것입니다.
원인을 추적해보니, 해당 PDF는 웹 페이지를 캡처한 것이라 하나의 페이지가 비상식적으로 길었습니다.
- 일반적인 A4: 가로:세로 비율 약 0.7
- 문제의 PDF: 가로:세로 비율 약 0.1 (세로가 가로의 10배)
왜 프론트엔드에서 걸러야 했나
이 프로젝트의 AI 파이프라인은 PDF를 이미지로 변환한 뒤 분석합니다.
본래라면 서버 쪽에서 window-slicing을 적용해야 하지만, 당시 AI 파이프라인에 리소스를 투입할 수 없는 상황이었습니다.
window-slicing 없이 극단적인 비율의 페이지가 그대로 들어가면, AI 모델의 인식률이 떨어지고 전처리 결과도 불안정해집니다.
Info
Window-slicing은 큰 이미지를 일정 크기의 창(window)으로 잘라 순차적으로
분석하는 기법입니다. 문서 이미지처럼 해상도가 높거나 비율이 극단적인 입력을
모델이 처리할 수 있는 크기로 분할하여, 정보 손실 없이 안정적인 인식률을 유지할
수 있습니다.
그래서 AI 팀에서 프론트엔드에 우선 비율 검증을 요청했습니다.
서버에서 거부할 수도 있지만, 50MB짜리 PDF를 업로드하고 나서야 “비율이 안 맞습니다”라고 알려주는 것은 좋은 UX가 아닙니다.
업로드 전에, 클라이언트에서 파일 콘텐츠를 분석해서 걸러야 했습니다.
1. 메타데이터 검증과 콘텐츠 검증은 다른 레이어다
동기 검증과 비동기 검증의 차이
4편까지의 검증과 이번에 필요한 검증은 본질적으로 다릅니다.
| 구분 | 메타데이터 검증 (4편) | 콘텐츠 검증 (이 글) |
|---|---|---|
| 검증 대상 | File.name, File.size | 파일 바이너리 내용 |
| 실행 방식 | 동기 | 비동기 (파일 파싱 필요) |
| 소요 시간 | 즉시 (< 1ms) | PDF 페이지 수에 비례 (수백ms~수초) |
| 라이브러리 | 불필요 | pdf-lib (PDF), Image API (이미지) |
PDF라면 페이지 구조를 해석해야 하고, 이미지라면 디코딩해서 해상도를 알아내야 합니다.
addFiles 흐름의 변화
이 차이 때문에 4편의 addFiles 흐름에 콘텐츠 검증을 그냥 끼워 넣을 수 없었습니다.
별도의 비동기 레이어로 설계해야 했습니다.
// 4편까지: 메타데이터 검증만 있을 때 (동기)
addFiles(fileList) {
if (파일 수 초과) return;
for (file of fileList) {
if (크기 초과 || 확장자 불일치 || 중복) continue;
enqueue(file); // → QUEUED → 업로드 시작
}
}
// 5편: 콘텐츠 검증이 추가되면 (비동기)
addFiles(fileList) {
if (파일 수 초과) return;
for (file of fileList) {
if (크기 초과 || 확장자 불일치 || 중복) continue;
validFiles.push(file);
}
// 메타데이터 통과한 파일들만 비동기 검증
const results = await Promise.all(validFiles.map(콘텐츠_검증));
const passed = results.filter(통과한_것만);
enqueue(passed); // → QUEUED → 업로드 시작
}
Note
검증이 비동기라는 것은 UI에도 영향을 줍니다. 메타데이터 검증은 파일 선택 즉시
결과가 나오지만, 콘텐츠 검증은 수백ms에서 수초까지 걸릴 수 있습니다.
100페이지짜리 PDF의 모든 페이지 비율을 확인하는 동안 사용자는 기다려야 합니다.
2. 구체적 요구사항
| 항목 | 조건 |
|---|---|
| 대상 파일 | PDF, 이미지 (jpg, png, webp 등) |
| 비율 제한 | 0.4 ≤ (width / height) ≤ 5.0 |
| 검증 시점 | 업로드 전 (클라이언트) |
| 검증 범위 | PDF: 모든 페이지, 이미지: 전체 |
이 임계값은 AI 파이프라인 팀과 협의하여 정한 기준입니다.
비율 0.4 미만이면 세로로 극단적으로 긴 문서(영수증, 모바일 스크린샷 연결, 웹 페이지 캡처), 비율 5.0 초과면 가로로 극단적으로 긴 문서(스프레드시트 캡처, 파노라마)에 해당하며, 이 범위 밖에서 window-slicing 결과가 불안정해지는 것을 확인한 뒤 설정했습니다.
PDF는 페이지별로 비율이 다를 수 있으므로, 모든 페이지를 확인해야 합니다.
한 페이지라도 기준을 벗어나면 어떤 페이지가 문제인지 사용자에게 알려야 합니다.
3. 첫 번째 시도. 함수 주입
검증 함수를 통째로 주입받는 설계
가장 먼저 떠올린 설계는 검증 함수를 통째로 주입받는 것이었습니다.
// 첫 번째 설계: 검증 함수를 외부에서 주입
interface FileManagerOptions {
// ... 기존 옵션 (3편)
contentValidator?: (file: File) => Promise<ValidationResult>;
}
훅이 파일 포맷에 대해 아무것도 모르게 되니, OCP(Open/Closed Principle)에도 맞고 어떤 검증이든 넣을 수 있습니다.
설계 원칙으로 보면 깔끔합니다.
기본 검증이 누락되는 문제
하지만 실제 사용처에 적용해보니 문제가 드러났습니다.
프로젝트의 대부분의 사용처는 동일한 기본 검증(비율 0.4~5.0, 크기 50MB)을 필요로 합니다.
거기에 “이미지는 1MB 이하만 허용” 같은 추가 조건을 하나 더 넣고 싶을 뿐입니다.
그런데 함수 주입 방식에서는, 커스텀 함수를 전달하면 기본 검증이 통째로 날아갑니다.
// 사용처에서 "이미지 1MB 제한"만 추가하고 싶은데...
contentValidator: async file => {
// 기본 비율 검증을 직접 다시 구현해야 한다
if (isPdf(file)) {
const result = await validatePdfAspectRatio(file);
if (!result.valid) return result;
}
if (isImage(file)) {
const result = await validateImageAspectRatio(file);
if (!result.valid) return result;
}
// 여기서부터가 실제 추가하고 싶은 로직
if (isImage(file) && file.size > 1_000_000) {
return { valid: false, reason: '이미지는 1MB 이하만 허용됩니다.' };
}
return { valid: true };
};
Warning
“추가만 하고 싶다”는 가장 흔한 니즈를 지원하지 못하는 구조였습니다. 사용처마다
기본 검증을 복사해 넣어야 하고, 기본 검증 기준이 바뀌면 모든 사용처를 찾아
수정해야 합니다. 1편에서 patchFile이 5곳에 흩어져 있던 것과 같은 패턴이
반복될 위험이 있었습니다.
4. 최종 설계. 설정 기반으로
기본 검증은 훅이 내장하고, 사용처는 임계값 오버라이드와 추가 검증만 config로 전달하는 방식으로 바꿨습니다.
interface ContentValidationConfig {
/** 파일 크기 제한 (bytes). 기본: 50MB. false면 스킵 */
maxFileSize?: number | false;
/** 비율 제한. 기본: { min: 0.4, max: 5.0 }. false면 스킵 */
aspectRatio?: { min: number; max: number } | false;
/** 기본 검증 이후 실행할 추가 validator */
extraValidator?: (file: File) => Promise<ValidationResult>;
}
interface FileManagerOptions {
// ... 기존 옵션 (3편)
/**
* 콘텐츠 검증 설정.
* - 생략(undefined): 기본 검증 전부 적용
* - false: 모든 콘텐츠 검증 스킵
* - 객체: 임계값 오버라이드 + 추가 validator
*/
contentValidation?: ContentValidationConfig | false;
}
함수 주입에서 실패했던 요구사항을 다시 해결해보겠습니다.
// "이미지 1MB 제한"만 추가하고 싶을 때
contentValidation: {
extraValidator: async (file) => {
if (isImage(file) && file.size > 1_000_000) {
return { valid: false, reason: '이미지는 1MB 이하만 허용됩니다.' };
}
return { valid: true };
},
}
// → 기본 비율 검증(0.4~5.0)은 자동으로 유지됨
extraValidator는 기본 검증을 통과한 후에만 실행됩니다.
기본 검증이 누락될 여지가 없습니다.
4-1. 네 가지 사용 전략
이 설계가 커버하는 사용 패턴을 정리하면 네 가지입니다.
// 1. 기본: 옵션 생략. 비율 0.4~5.0 + 크기 50MB
useFileStaging({ confirmDir: '/uploads/documents' });
// 2. 커스텀 임계값: 더 엄격한 기준
useFileStaging({
confirmDir: '/uploads/strict',
contentValidation: {
aspectRatio: { min: 0.5, max: 3.0 },
maxFileSize: 10_000_000, // 10MB
},
});
// 3. 추가 validator: 기본 + 커스텀 로직
useFileStaging({
confirmDir: '/uploads/special',
contentValidation: {
extraValidator: async file => {
/* ... */
},
},
});
// 4. 검증 스킵: 콘텐츠 검증 없이 바로 업로드
useFileStaging({
confirmDir: '/uploads/raw',
contentValidation: false,
});
3편에서 설계한 FileManagerOptions에 contentValidation 필드 하나가 추가된 것뿐입니다.
기존 옵션(maxFiles, accept, concurrency 등)과 같은 패턴, “설정을 선언하면 동작이 바뀐다”를 따릅니다.
4-2. 함수 주입 대신 설정 기반을 선택한 이유
사용처가 늘어날수록 누락 확률은 높아지고, 그 실수의 결과는 비율이 깨진 파일이 AI 파이프라인에 들어가는 것입니다.
설정 기반은 유연성에 약간의 제약을 두는 대신, 기본 검증을 덮어쓰는 게 아니라 조절하게 합니다.
false로 명시적으로 꺼야 기본 검증이 빠지니, “몰라서 빠트리는” 상황이 발생하지 않습니다.
5. 검증 레이어의 분리
설계에서 한 가지 더 신경 쓴 점은, useFileStaging 훅이 파일 포맷에 대한 지식을 갖지 않는 것입니다.
useFileStaging
→ contentValidation config를 받음
→ buildContentValidator()로 config를 실행 가능한 함수로 변환
→ createAspectRatioValidator(): 파일 타입별 분기
→ PDF면 pdf-lib로 페이지 비율 확인
→ 이미지면 Image API로 크기 확인
훅은 contentValidation config만 알고, 실제로 PDF를 어떻게 파싱하는지, 이미지 크기를 어떻게 읽는지는 모릅니다.
buildContentValidator가 config를 받아 하나의 (file: File) => Promise<ValidationResult> 함수로 합성하고, 훅은 그 함수를 addFiles 안에서 호출하기만 합니다.
이 분리 덕분에 몇 가지 이점이 생깁니다.
- 번들 최적화. PDF 비율 검증에 사용하는
pdf-lib는 PDF를 선택했을 때만 dynamic import됩니다. 이미지만 업로드하는 사용처에서는 pdf-lib 코드가 로드되지 않습니다. - 포맷 확장. 새로운 파일 포맷의 검증이 필요해지면(예: CSV 컬럼 수, JSON 스키마),
extraValidator로 추가하면 됩니다. 훅 코드를 수정할 필요가 없습니다. - 테스트 용이성. 훅을 테스트할 때 실제 PDF 파일을 준비할 필요 없이,
contentValidation에 mock validator를 주입하면 됩니다.
▶buildContentValidator + createAspectRatioValidator 구현 코드
// ── createAspectRatioValidator ─────────────────────────
// config의 임계값으로 파일 타입별 비율 검증 함수를 생성한다.
const IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/bmp',
'image/gif',
'image/webp',
'image/tiff',
]);
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const DEFAULT_ASPECT_RATIO = { max: 5.0, min: 0.4 };
export function createAspectRatioValidator(
config: Pick<ContentValidationConfig, 'maxFileSize' | 'aspectRatio'> = {}
): (file: File) => Promise<ValidationResult> {
const maxFileSize = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
const aspectRatio = config.aspectRatio ?? DEFAULT_ASPECT_RATIO;
return async (file: File) => {
if (maxFileSize !== false && file.size > maxFileSize) {
return {
valid: false,
reason: `파일 크기가 ${(maxFileSize / 1024 / 1024).toFixed(0)}MB를 초과합니다.`,
};
}
if (
aspectRatio !== false &&
(file.type === 'application/pdf' || file.name.endsWith('.pdf'))
) {
return validatePdfAspectRatio(file, aspectRatio);
// → pdf-lib로 모든 페이지의 width/height 비율을 확인
}
if (aspectRatio !== false && IMAGE_MIME_TYPES.has(file.type)) {
return validateImageAspectRatio(file, aspectRatio);
// → Image API로 이미지 크기를 확인
}
return { valid: true };
};
}
// ── buildContentValidator ──────────────────────────────
// ContentValidationConfig → 실행 가능한 validator 함수로 합성한다.
const DEFAULT_CONTENT_VALIDATOR = createAspectRatioValidator();
function buildContentValidator(
contentValidation: ContentValidationConfig | false | undefined
): ((file: File) => Promise<ValidationResult>) | null {
// false → 콘텐츠 검증 스킵
if (contentValidation === false) return null;
// undefined → 기본 검증 (비율 0.4~5.0, 크기 50MB)
if (contentValidation === undefined) return DEFAULT_CONTENT_VALIDATOR;
// 커스텀 임계값으로 base validator 생성
const base = createAspectRatioValidator(contentValidation);
// extraValidator가 없으면 base만 반환
if (!contentValidation.extraValidator) return base;
// extraValidator가 있으면 base 통과 후 추가 검증
const extra = contentValidation.extraValidator;
return async file => {
const result = await base(file);
if (!result.valid) return result;
return extra(file);
};
}PDF 페이지 크기를 읽는 구체적인 구현(pdf-lib,
ImageAPI 활용)은 이 시리즈의 설계 판단과는 별개의 구현 레시피입니다. 별도 포스트에서 다룰 예정입니다.
6. 검증 실패 파일은 상태에 추가하지 않는다
콘텐츠 검증 실패 파일을 어떻게 처리할지도 판단이 필요했습니다.
두 가지 선택지가 있었습니다.
- A. ERROR 상태로 추가: 4편의 업로드 에러처럼, 파일을 목록에 추가하되 ERROR 상태로 표시
- B. 아예 추가하지 않음: 검증 실패 파일은 state에 넣지 않고, 토스트로만 알림
업로드 에러와 검증 실패는 성격이 다릅니다.
업로드 에러는 네트워크 문제처럼 재시도하면 해결될 수 있는 일시적 실패입니다.
그래서 4편에서 ERROR 상태를 유지하고 retryFile을 제공했습니다.
반면 콘텐츠 검증 실패는 파일 자체의 문제입니다.
PDF 페이지 비율이 0.2인 파일은 재시도해도 비율이 바뀌지 않습니다.
사용자가 해야 할 일은 “재시도”가 아니라 “다른 파일을 선택하는 것”입니다.
그래서 B를 선택했습니다.
검증 실패 파일은 state에 추가하지 않고, toast.error와 onValidationError 콜백으로 어떤 파일이 왜 실패했는지를 알립니다.
Tip
PDF의 경우 “3페이지 비율 0.2”처럼 문제가 된 페이지 번호까지 표시하여, 사용자가
파일을 수정하거나 대체할 수 있게 합니다.
7. 시리즈를 마치며
1편에서 프로젝트를 grep했을 때, postFile과 patchFile이 5곳에 흩어져 있었습니다.
같은 API를 호출하면서 에러 처리는 4가지 패턴으로 제각각이었습니다.
“이건 정리해야 한다”는 것만 분명했고, 어디서부터 손을 대야 할지는 불분명했습니다.
돌아보면, 이 시리즈에서 일관되게 적용한 원칙은 두 가지였습니다.
“공통 로직은 훅이 내장하고, 차이는 설정으로 흡수한다.”
confirmDir을 바꾸면 5곳의 patchFile 중복이 사라지고, concurrency를 바꾸면 동시 업로드 수가 제한되고, contentValidation을 바꾸면 검증 기준이 달라집니다.
사용처에서는 “무엇이 다른지”만 선언하면 되고, “어떻게 처리할지”는 훅이 담당합니다.
“상태 전이를 명시적으로 관리한다.”
v1에서 에러 시 파일을 삭제하던 것을 ERROR 상태로 바꾸고, 업로드 시작 전 QUEUED 상태를 추가하고, 확정 후 CONFIRMED 상태를 도입한 것 모두, 각 단계가 어떤 상태인지를 코드에서 읽을 수 있게 하기 위한 것이었습니다.
| 편 | 핵심 질문 | 판단 |
|---|---|---|
| 1편 | 중복이 어디에 있는가 | 같은 API, 다른 에러 처리 5곳 |
| 2편 | v1은 어디까지 해결했는가 | 업로드만, 에러 복구·확정·진행률 부재 |
| 3편 | 훅의 책임을 어디까지 넓힐 것인가 | API 3개 + 상태 모델 + 설정 객체 |
| 4편 | 설계를 어떻게 코드로 옮기는가 | 에러 복구·큐·취소·확정 통합 |
| 5편 | 메타데이터 너머의 검증을 어떻게 설계하는가 | 설정 기반 콘텐츠 검증 레이어 |
이 시리즈를 통해 정리하고 싶었던 것은 특정 기술의 사용법이 아니라, “왜 이렇게 설계했는가”라는 판단의 과정이었습니다.
같은 문제를 만난 누군가에게, 그 판단의 맥락이 참고가 되었으면 합니다.