설계를 코드로 만드는 시간
3편에서 FileStatus, ManagedFile, FileManagerOptions 인터페이스를 설계했습니다.
타입을 정의하는 것과 그 타입대로 동작하는 코드를 만드는 것은 다른 문제입니다.
이 글에서는 설계를 구현으로 옮기면서 만난 판단들, v1에서 어떤 부분이 달라졌고 왜 그렇게 바꿨는지를 다룹니다.
먼저 v1과 v2의 차이를 한눈에 보겠습니다.
| 개선 항목 | v1 | v2 |
|---|---|---|
| 에러 복구 | 에러 시 Map에서 삭제 | ERROR 상태 유지 + 재시도 |
| 업로드 진행률 | UPLOADING → COMPLETED 점프 | 파일별 0~100% 실시간 추적 |
| 클라이언트 검증 | 중복 체크만 | 설정 기반 검증 레이어 |
| 동시성 제어 | 전부 동시 업로드 | 큐 기반 최대 N개 제한 |
| 업로드 취소 | 불가능 | AbortController로 파일별 취소 |
| 확정 통합 | 5곳에서 각자 mutation | confirmFiles()로 통합 |
각 항목이 어떤 설계 판단에서 비롯되었는지 하나씩 살펴보겠습니다.
1. useState에서 useReducer로
각 항목을 구현하기 전에, 먼저 상태 관리 방식 자체가 바뀌어야 했습니다.
v1은 useState<Map>으로 파일 목록을 관리했습니다.
상태를 바꾸는 방법은 updateFile과 deleteFile 두 가지뿐이었으니, setFiles(prev => ...) 패턴으로 충분했습니다.
그런데 v2에서는 상태 전이의 종류가 크게 늘어납니다.
파일 추가, 진행률 갱신, 에러 처리, 재시도, 확정 완료까지, 3편에서 설계한 FileStatus의 전이마다 다른 로직이 필요합니다.
이 전이들이 setFiles(prev => ...) 곳곳에 흩어져 있으면, 어떤 조건에서 어떤 상태 변경이 일어나는지 추적하기 어렵습니다.
// v1: 두 가지 조작이면 충분
setFiles(prev => prev.set(fileId, updatedFile)); // updateFile
setFiles(prev => {
prev.delete(fileId);
return prev;
}); // deleteFile
// v2: 전이가 많아지면 리듀서가 자연스럽다
dispatch({ type: 'ADD_FILES', payload: newFiles });
dispatch({ type: 'UPDATE_PROGRESS', payload: { id, progress } });
dispatch({ type: 'UPLOAD_ERROR', payload: { id, error } });
dispatch({ type: 'RETRY_FILE', payload: fileId });
dispatch({ type: 'SET_CONFIRMED', payload: fileIds });
Tip
리듀서에 모아두면 모든 상태 전이가 한 곳에서 일어나고, 각 전이에 가드를
붙이기도 쉽습니다. 예를 들어 RETRY_FILE은 현재 상태가 ERROR이고
rawFile이 존재할 때만 동작해야 합니다. 이런 전제 조건을 리듀서 안에서
명시적으로 확인할 수 있습니다.
2. 에러 복구. 삭제 대신 상태 전이
2편에서 다뤘던 v1의 가장 큰 문제, “에러가 나면 파일이 사라지는” 동작을 해결합니다.
flowchart LR
subgraph v1["v1: 에러 → 삭제"]
A1[UPLOADING] -->|에러 발생| A2[Map에서 삭제]
A2 -.->|재시도 불가| A3["사용자가 파일 재선택"]
end
subgraph v2["v2: 에러 → 상태 유지 → 재시도"]
B1[UPLOADING] -->|에러 발생| B2[ERROR]
B2 -->|재시도| B3[QUEUED]
B3 -->|슬롯 확보| B4[UPLOADING]
end
style v1 fill:#fff3e0,stroke:#ff9800
style v2 fill:#e8f5e9,stroke:#4caf50
2-1. 에러를 상태로 유지
핵심 변경은 한 줄입니다.
에러가 발생했을 때 deleteFile 대신 dispatch({ type: 'UPLOAD_ERROR' })를 호출하는 것.
// v1: 에러 시 파일을 Map에서 삭제
onUploadError: fileId => {
deleteFile(fileId);
};
// v2: 에러 시 상태를 ERROR로 전이, 에러 정보 보존
onUploadError: (fileId, error) => {
dispatch({
type: 'UPLOAD_ERROR',
payload: { id: fileId, error: toFileError(error) },
});
processQueue();
};
하지만 단순히 삭제를 하지 않는 것만으로는 부족합니다.
에러를 어떤 정보와 함께 보존할지가 중요합니다.
v2에서는 에러 정보에 isRetryable 필드를 추가했습니다.
서버 500/429 에러나 네트워크 에러처럼 재시도하면 해결될 가능성이 있는 에러와, 파일 크기 초과처럼 재시도해도 소용없는 에러를 구분합니다.
UI에서는 이 값을 보고 “재시도” 버튼을 노출할지 결정할 수 있습니다.
Warning
에러 처리 후 processQueue()를 호출하는 것도 빠뜨리기 쉬운 부분입니다. 에러가
난 파일이 동시 업로드 슬롯을 차지하고 있었으므로, 슬롯을 반환하고 대기 중인
다음 파일을 처리해야 합니다.
2-2. 재시도
3편에서 ManagedFile에 rawFile(원본 File 객체)을 추가한 이유가 여기서 드러납니다.
// v1: 재시도 불가. 파일이 삭제되어 원본 File 객체가 사라짐
// → 사용자가 탐색기에서 같은 파일을 다시 선택해야 함
// v2: rawFile이 남아있으므로 재시도 가능
retryFile(fileId);
// 내부: ERROR → QUEUED 전이 후 큐에 재등록
리듀서의 RETRY_FILE 액션은 두 가지를 확인합니다.
현재 상태가 ERROR인지, rawFile이 존재하는지.
조건을 충족하면 상태를 QUEUED로 전이하고 큐에 다시 추가합니다.
v1에서는 파일이 Map에서 삭제되면서 원본 File 객체도 함께 사라졌기 때문에, 이런 재시도 자체가 불가능했습니다.
3. 업로드 진행률
v1은 UPLOADING → COMPLETED로 점프했습니다.
대용량 파일을 올릴 때 사용자가 받는 피드백은 “업로드 중…”뿐이었습니다.
// v1: 상태만 있고 진행률은 없음
{ status: 'UPLOADING' } → { status: 'COMPLETED' }
// v2: 파일별 진행률을 실시간 추적
{ status: 'UPLOADING', progress: 0 }
{ status: 'UPLOADING', progress: 45 }
{ status: 'UPLOADING', progress: 100 }
{ status: 'COMPLETED', progress: 100 }
구현 방식은 단순합니다.
저수준 훅인 useFileUpload에서 axios의 onUploadProgress 콜백을 통해 진행률을 받고, 고수준 훅인 useFileStaging이 그 콜백을 받아 dispatch({ type: 'UPDATE_PROGRESS' })로 ManagedFile.progress를 갱신합니다.
이 설계에서 주목할 점은 저수준 훅은 진행률을 보고만 하고, 어떻게 저장할지는 모른다는 것입니다.
2편에서 다뤘던 저수준/고수준 분리가 여기서도 동일하게 적용됩니다.
useFileUpload는 “진행률이 바뀌었다”는 이벤트를 콜백으로 올리고, useFileStaging이 그것을 리듀서 상태로 관리합니다.
전체 진행률(totalProgress)은 개별 파일 progress의 평균으로 계산합니다.
4. 클라이언트 검증
v1의 유일한 검증은 중복 체크(이름+크기)뿐이었습니다.
파일 크기가 제한을 넘거나, 허용하지 않는 확장자의 파일을 올리면, 서버까지 갔다가 에러를 받고서야 사용자가 알 수 있었습니다.
v2는 3편에서 설계한 FileManagerOptions의 설정값을 기반으로, 서버에 보내기 전에 검증합니다.
// v1: 중복만 체크
addFiles(fileList) {
for (file of fileList) {
if (isDuplicate(file)) continue;
uploadFile(file);
}
}
// v2: 설정 기반 검증 레이어
addFiles(fileList) {
if (현재 파일 수 + 새 파일 수 > maxFiles) return; // 전체 거부
for (file of fileList) {
if (isDuplicate(file)) continue;
if (file.size > maxFileSize) continue; // 크기 초과
if (!accept.includes(ext)) continue; // 확장자 불일치
enqueue(file); // 검증 통과 → QUEUED
}
}
검증 판단이 두 단계로 나뉘는 점이 중요합니다.
파일 수 초과는 전체를 거부하고(한 번에 5개 제한인데 7개를 선택했다면, 2개만 골라 넣는 것보다 전체를 거부하는 편이 사용자 의도에 가깝습니다), 개별 파일 검증(크기, 확장자, 중복)은 파일별로 판단하여 통과한 것만 큐에 넣습니다.
한 가지 주의할 점은, 이 검증이 파일의 메타데이터(크기, 확장자, 개수)만 확인한다는 것입니다. 파일 콘텐츠 자체를 분석하는 검증(예를 들어 PDF 페이지 수나 이미지 해상도)은 이 레이어의 책임이 아닙니다. 이것은 5편에서 다룹니다.
5. 동시성 제어
1편에서 언급했듯이, 이 프로젝트는 다중 업로드 API 없이 단건 postFile을 반복 호출하는 구조입니다.
v1에서는 이 반복 호출에 제한이 없었습니다.
파일 10개를 선택하면 10개의 mutate가 동시에 실행됐습니다.
파일이 몇 개 안 될 때는 문제가 없지만, 브라우저의 HTTP 커넥션 제한(보통 6개)이나 서버 부하를 고려하면, 동시 업로드 수를 제한하는 큐가 필요합니다.
// v1: 모든 파일을 즉시 동시 업로드
addFiles(fileList) {
for (file of fileList) {
mutate(file); // 10개면 10개 동시에
}
}
// v2: 큐에 넣고 최대 N개씩 처리
addFiles(fileList) {
for (file of fileList) {
enqueue(file); // QUEUED 상태
}
processQueue(); // 슬롯이 빈 만큼만 시작
}
flowchart TD
A[processQueue 호출] --> B{활성 업로드 < concurrency\n AND 큐에 파일 있음?}
B -->|Yes| C[큐에서 파일 꺼냄]
C --> D[활성 카운트 +1]
D --> E[uploadFile 실행]
E --> B
B -->|No| F[대기]
E -->|완료/실패| G[활성 카운트 -1]
G --> A
3편에서 설계한 QUEUED 상태가 여기서 의미를 갖습니다.
큐에 들어갔지만 아직 업로드가 시작되지 않은 파일은 UI에서 “대기 중”으로 표시됩니다.
concurrency 옵션의 기본값은 3입니다.
6. 업로드 취소
v1에서는 한 번 시작된 업로드를 중단할 방법이 없었습니다.
사용자가 실수로 큰 파일을 올렸더라도 완료될 때까지 기다려야 했습니다.
v2에서는 AbortController를 파일별로 관리합니다.
// v1: 취소 불가
// 업로드가 시작되면 완료될 때까지 기다려야 함
// v2: 파일별 AbortController로 개별 취소
cancelUpload(fileId);
// 내부: controller.abort() → HTTP 요청 중단
// → 슬롯 반환 → processQueue()
설계 판단 하나를 공유하면, cancelUpload은 별도 함수가 아니라 removeFile에 위임합니다.
“업로드를 취소한다”와 “파일을 목록에서 제거한다”가 사용자 입장에서 같은 동작이기 때문입니다.
removeFile 안에서 해당 파일에 연결된 AbortController가 있으면 abort하고, 슬롯을 반환한 뒤 processQueue로 다음 파일을 시작합니다.
취소를 별도 상태(“취소됨”)로 관리하는 방법도 있었지만, 목록에서 사라지는 편이 더 직관적이라고 판단했습니다.
7. 확정 통합. 5곳의 patchFile이 하나로
1편에서 가장 중복이 심했던 patchFile을 해결합니다.
5곳에서 4가지 에러 처리 패턴으로 각각 useMutation을 정의하고 있었던 코드가, 설정 하나로 정리됩니다.
7-1. 사전 검증이 필요한 이유
확정을 호출하기 전에, 현재 파일 상태를 먼저 확인해야 합니다.
아직 업로드 중인 파일이 있는데 확정을 시도하면 서버에서 에러가 나고, 파일이 하나도 없는데 확정을 호출하면 불필요한 API 요청이 발생합니다.
v1에서는 이 검증을 5곳에서 각자 구현했습니다.
어떤 곳은 업로드 중인지 체크했고, 어떤 곳은 체크하지 않았습니다.
v2에서는 confirmFiles() 안에 사전 검증을 통합했습니다.
flowchart TD
A[confirmFiles 호출] --> B{파일이 있는가?}
B -->|No| C["NO_FILES 에러"]
B -->|Yes| D{업로드 중/대기 중\n파일이 있는가?}
D -->|Yes| E["HAS_UPLOADING 에러"]
D -->|No| F{COMPLETED 파일이\n있는가?}
F -->|No| C
F -->|Yes| G[patchFile API 호출]
G -->|성공| H["CONFIRMED 상태 전이"]
G -->|실패| I["CONFIRM_FAILED 에러"]
3편에서 설계한 ConfirmErrorCode 타입('NO_FILES' | 'HAS_UPLOADING' | 'CONFIRM_FAILED')이 이 검증 결과를 표현합니다.
// v1: 5곳에서 각자 patchFile mutation 정의 + 각자 에러 처리
const patchMutation = useMutation({ mutationFn: patchFile, onError: ... });
// v2: confirmDir만 설정하면 검증 + API 호출 + 에러 코드까지 통합
const { confirmFiles, confirmError } = useFileStaging({
confirmDir: '/uploads/documents',
});
confirmDir만 다르게 설정하면, 5곳의 중복 mutation이 사라집니다.
7-2. 마이그레이션
기존 사용처에서 v2로 전환한 결과입니다.
| 사용처 | v1 | v2 |
|---|---|---|
| 채팅 입력 | useTempFileManager('user') | useFileStaging({ permission: 'user', confirmDir: '/uploads/chat' }) |
| 데이터 업로드 다이얼로그 | 훅 + 별도 patchFile mutation | useFileStaging({ confirmDir: currentPath }) (mutation 제거) |
| 파일 업로드 컴포넌트 | useTempFileManager() | useFileStaging({ ... }) |
| 학습 데이터 모달 | 직접 patchFile mutation | useFileStaging({ confirmDir, initialFiles }) |
| 평가 데이터셋 페이지 | 직접 postFile + patchFile | useFileStaging({ accept: ['.jsonl'], confirmDir }) |
가장 변화가 큰 곳은 “데이터 업로드 다이얼로그”입니다.
v1에서는 useTempFileManager로 업로드까지만 관리하고, patchFile은 별도 useMutation으로 정의해야 했습니다.
v2에서는 confirmDir을 설정하면 confirmFiles() 한 줄로 확정까지 처리됩니다.
타입 import도 정리됩니다.
// v1: 훅을 사용하지 않는데 타입 때문에 훅에 의존
import { FileAttachment } from '@/hooks/file/useTempFileManager';
// v2: 타입만 독립적으로 import
import { ManagedFile } from '@/hooks/file/file.types';
▶useFileStaging 훅 구현 코드
export function useFileStaging(
options: FileManagerOptions = {}
): FileManagerReturn {
const {
permission = 'manager',
maxFiles,
maxFileSize,
accept,
concurrency = 3,
confirmDir,
initialFiles,
contentValidation,
onAllUploaded,
onValidationError,
onConfirmSuccess,
} = options;
const contentValidator = buildContentValidator(contentValidation);
const [state, dispatch] = useReducer(fileManagerReducer, INITIAL_STATE);
const [confirmError, setConfirmError] = useState<ConfirmErrorCode | null>(
null
);
const stateRef = useRef(state);
stateRef.current = state;
const activeUploadsRef = useRef(0);
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
// ── Upload mutation ──────────────────────────────────
const fileUpload = useFileUpload(permission, {
onUploadError: (fileId, error) => {
activeUploadsRef.current--;
abortControllersRef.current.delete(fileId);
dispatch({
payload: {
error: {
isRetryable: isRetryableError(error),
message: error.message,
},
id: fileId,
},
type: 'UPLOAD_ERROR',
});
processQueue();
},
onUploadProgress: (fileId, progress) => {
dispatch({ payload: { id: fileId, progress }, type: 'UPDATE_PROGRESS' });
},
onUploadSuccess: (fileId, response) => {
activeUploadsRef.current--;
abortControllersRef.current.delete(fileId);
dispatch({ payload: { id: fileId, response }, type: 'UPLOAD_COMPLETED' });
processQueue();
},
});
// ── Confirm mutation ─────────────────────────────────
const { confirmFiles: confirmMutation, isConfirming } = useFileConfirm({
onSuccess: data => {
const completedIds = Array.from(state.files.values())
.filter(f => f.status === FileStatus.COMPLETED)
.map(f => f.id);
dispatch({ payload: completedIds, type: 'SET_CONFIRMED' });
onConfirmSuccess?.(data);
},
});
// ── Queue processor ──────────────────────────────────
const processQueue = useCallback(() => {
const { queue, files } = stateRef.current;
const toProcess: string[] = [];
let idx = 0;
while (
activeUploadsRef.current + toProcess.length < concurrency &&
idx < queue.length
) {
const fileId = queue[idx];
const file = fileId ? files.get(fileId) : undefined;
if (file?.rawFile && file.status === FileStatus.QUEUED) {
toProcess.push(fileId);
}
idx++;
}
if (toProcess.length === 0) return;
dispatch({ payload: toProcess, type: 'DEQUEUE' });
for (const fileId of toProcess) {
const file = files.get(fileId);
if (!file?.rawFile) continue;
const controller = new AbortController();
abortControllersRef.current.set(fileId, controller);
activeUploadsRef.current++;
fileUpload.uploadFile({
file: file.rawFile,
fileId,
signal: controller.signal,
});
}
}, [concurrency, fileUpload.uploadFile]);
// ── Public API ───────────────────────────────────────
const addFiles = useCallback(
(fileList: FileList) => {
if (!fileList.length) return;
setConfirmError(null);
const incoming = Array.from(fileList);
if (maxFiles && state.files.size + incoming.length > maxFiles) {
toast.info(`최대 ${maxFiles}개만 선택 가능합니다.`);
return;
}
const existingFiles = Array.from(state.files.values());
const newFiles: ManagedFile[] = [];
for (const file of incoming) {
// 중복 체크
if (
existingFiles.some(
ef => ef.fileName === file.name && ef.fileSize === file.size
)
) {
toast.info(`이미 추가된 파일입니다: ${file.name}`);
continue;
}
// 동기 검증 (크기, 확장자)
const error = validateFile(file);
if (error) {
toast.error(`${file.name}: ${error}`);
continue;
}
newFiles.push({
contentType: file.type || undefined,
directory: '',
fileName: file.name,
fileSize: file.size,
fullPath: '',
id: uuidv4(),
progress: 0,
rawFile: file,
status: FileStatus.QUEUED,
});
}
if (newFiles.length === 0) return;
// 비동기 콘텐츠 검증 (5편)
if (contentValidator) {
Promise.all(
newFiles.map(async mf => {
if (!mf.rawFile) return mf;
const result = await contentValidator(mf.rawFile);
if (!result.valid) {
toast.error(
`${mf.fileName}: ${result.reason ?? '유효하지 않은 파일입니다.'}`
);
return null;
}
return mf;
})
).then(results => {
const valid = results.filter((r): r is ManagedFile => r !== null);
if (valid.length > 0) {
dispatch({ payload: valid, type: 'ADD_FILES' });
queueMicrotask(processQueue);
}
});
} else {
dispatch({ payload: newFiles, type: 'ADD_FILES' });
queueMicrotask(processQueue);
}
},
[
state.files,
maxFiles,
maxFileSize,
accept,
contentValidation,
processQueue,
]
);
const removeFile = useCallback((fileId: string) => {
const controller = abortControllersRef.current.get(fileId);
if (controller) {
controller.abort();
abortControllersRef.current.delete(fileId);
activeUploadsRef.current = Math.max(0, activeUploadsRef.current - 1);
}
dispatch({ payload: fileId, type: 'REMOVE_FILE' });
}, []);
const retryFile = useCallback(
(fileId: string) => {
dispatch({ payload: fileId, type: 'RETRY_FILE' });
queueMicrotask(processQueue);
},
[processQueue]
);
const cancelUpload = useCallback(
(fileId: string) => removeFile(fileId),
[removeFile]
);
const confirmFilesHandler = useCallback(async () => {
const allFiles = Array.from(state.files.values());
if (allFiles.length === 0) {
setConfirmError('NO_FILES');
return Promise.reject(new Error('NO_FILES'));
}
if (
allFiles.some(
f => f.status === FileStatus.UPLOADING || f.status === FileStatus.QUEUED
)
) {
setConfirmError('HAS_UPLOADING');
return Promise.reject(new Error('HAS_UPLOADING'));
}
const completedFiles = allFiles.filter(
f => f.status === FileStatus.COMPLETED
);
if (completedFiles.length === 0) {
setConfirmError('NO_FILES');
return Promise.reject(new Error('NO_FILES'));
}
setConfirmError(null);
try {
return await confirmMutation({
files: completedFiles.map(toFileDetail),
to_dir: confirmDir!,
});
} catch (error) {
setConfirmError('CONFIRM_FAILED');
throw error;
}
}, [state.files, confirmDir, confirmMutation]);
// ── Return ───────────────────────────────────────────
const filesArray = useMemo(
() => Array.from(state.files.values()),
[state.files]
);
return {
addFiles,
cancelUpload,
clearFiles,
confirmError,
confirmFiles: confirmFilesHandler,
files: filesArray,
hasErrorFiles: filesArray.some(f => f.status === FileStatus.ERROR),
hasUploadingFiles: filesArray.some(
f => f.status === FileStatus.UPLOADING || f.status === FileStatus.QUEUED
),
isAllUploaded:
filesArray.length > 0 &&
filesArray.every(f => f.status === FileStatus.COMPLETED),
isConfirming,
removeFile,
retryFile,
totalProgress:
filesArray.length > 0
? Math.round(
filesArray.reduce((sum, f) => sum + f.progress, 0) /
filesArray.length
)
: 0,
};
}▶fileManagerReducer 구현 코드
interface FileManagerState {
files: Map<string, ManagedFile>;
queue: string[];
}
type FileManagerAction =
| { type: 'ADD_FILES'; payload: ManagedFile[] }
| { type: 'REMOVE_FILE'; payload: string }
| { type: 'CLEAR_FILES' }
| { type: 'UPDATE_PROGRESS'; payload: { id: string; progress: number } }
| { type: 'UPLOAD_STARTED'; payload: string }
| {
type: 'UPLOAD_COMPLETED';
payload: { id: string; response: FileUploadRs };
}
| {
type: 'UPLOAD_ERROR';
payload: { id: string; error: { message: string; isRetryable: boolean } };
}
| { type: 'RETRY_FILE'; payload: string }
| { type: 'SET_CONFIRMED'; payload: string[] }
| { type: 'DEQUEUE'; payload: string[] };
function updateFile(
files: Map<string, ManagedFile>,
id: string,
update: Partial<ManagedFile>
): Map<string, ManagedFile> {
const existing = files.get(id);
if (!existing) return files;
const next = new Map(files);
next.set(id, { ...existing, ...update });
return next;
}
export function fileManagerReducer(
state: FileManagerState,
action: FileManagerAction
): FileManagerState {
switch (action.type) {
case 'ADD_FILES': {
const next = new Map(state.files);
const newQueue = [...state.queue];
for (const file of action.payload) {
next.set(file.id, file);
if (file.status === FileStatus.QUEUED) {
newQueue.push(file.id);
}
}
return { files: next, queue: newQueue };
}
case 'REMOVE_FILE': {
const next = new Map(state.files);
next.delete(action.payload);
return {
files: next,
queue: state.queue.filter(id => id !== action.payload),
};
}
case 'UPDATE_PROGRESS':
return {
...state,
files: updateFile(state.files, action.payload.id, {
progress: action.payload.progress,
}),
};
case 'UPLOAD_STARTED':
return {
...state,
files: updateFile(state.files, action.payload, {
status: FileStatus.UPLOADING,
}),
};
case 'UPLOAD_COMPLETED': {
const partial = fromUploadResponse(action.payload.response);
return {
...state,
files: updateFile(state.files, action.payload.id, {
...partial,
progress: 100,
rawFile: undefined,
status: FileStatus.COMPLETED,
}),
};
}
case 'UPLOAD_ERROR':
return {
...state,
files: updateFile(state.files, action.payload.id, {
error: action.payload.error,
status: FileStatus.ERROR,
}),
};
case 'RETRY_FILE': {
const file = state.files.get(action.payload);
if (!file || file.status !== FileStatus.ERROR || !file.rawFile)
return state;
return {
files: updateFile(state.files, action.payload, {
error: undefined,
progress: 0,
status: FileStatus.QUEUED,
}),
queue: [...state.queue, action.payload],
};
}
case 'SET_CONFIRMED': {
let next = state.files;
for (const id of action.payload) {
next = updateFile(next, id, { status: FileStatus.CONFIRMED });
}
return { ...state, files: next };
}
case 'DEQUEUE':
return {
...state,
queue: state.queue.filter(id => !action.payload.includes(id)),
};
default:
return state;
}
}8. v2가 해결한 것
| v1 한계 | v2 구현 |
|---|---|
| 에러 시 파일 삭제 | ERROR 상태 유지 + 에러 정보 + retryFile() |
| 진행률 없음 | onUploadProgress → ManagedFile.progress 실시간 추적 |
| 검증 없음 | maxFiles, maxFileSize, accept 설정 기반 검증 |
| 동시성 미제어 | 큐 기반 concurrency 제한 + QUEUED 상태 |
| 취소 불가 | AbortController 파일별 관리 + cancelUpload() |
| patchFile 5곳 중복 | confirmFiles() + confirmDir 옵션 |
| 타입 훅 종속 | file.types.ts 분리 |
1편에서 발견한 “같은 API, 다른 에러 처리 5곳”이, 설정 객체 하나와 통합된 에러 코드로 정리되었습니다.
2편에서 지적한 “에러 나면 파일이 사라지는 훅”이, 에러를 상태로 유지하고 재시도할 수 있는 훅으로 바뀌었습니다.
하지만 이 검증은 파일의 메타데이터(크기, 확장자, 개수)에 한정됩니다.
파일을 업로드한 뒤에야 “이 PDF는 페이지 비율이 안 맞습니다”라고 거부당하는 상황이 남아 있었습니다.
다음 편에서는 메타데이터 너머의 검증, 파일 콘텐츠를 업로드 전에 분석하는 레이어를 어떻게 설계했는지를 다룹니다.