업로드만 관리하는 훅이 있었습니다
1편에서 파일 API 호출이 11곳에 흩어져 있고, 에러 처리·상태 관리·캐시 무효화가 제각각이라는 문제를 다뤘습니다.
사실 이 문제를 해결하기 위한 시도가 이미 있었습니다.
useTempFileManager라는 훅이 만들어져 있었고, 일부 사용처에서 이 훅을 통해 파일 업로드를 처리하고 있었습니다.
이 글에서는 v1이 어떤 구조로 설계되었고, 어디까지 해결했으며, 왜 한계가 있었는지를 분석합니다.
1. v1의 구조. 두 개의 훅
v1은 역할에 따라 두 개의 훅으로 나뉘어 있었습니다.
flowchart TD
A[useTempFileManager] -->|콜백으로 이벤트 수신| B[useFileUpload]
B -->|HTTP multipart| C[fileApi.postFile]
A -.- D["고수준: 파일 목록 상태 관리 + UI 인터페이스"]
B -.- E["저수준: API mutation"]
style D fill:none,stroke:none,color:#888
style E fill:none,stroke:none,color:#888
useFileUpload: “파일 하나를 서버에 보내는 것”만 담당합니다. TanStack Query의useMutation으로 업로드 API를 호출하고, 성공/실패 이벤트를 콜백으로 상위 훅에 전달합니다.useTempFileManager: “사용자가 선택한 파일 목록 전체”를 관리합니다.Map<string, FileAttachment>으로 각 파일의 상태(업로드 중, 완료, 에러)를 추적하고,useFileUpload의 콜백을 받아 상태를 전이시킵니다.
왜 둘로 나눴을까
useMutation은 하나의 비동기 요청을 다루는 도구입니다. 파일을 서버에 보내고, 성공하면 응답을 반환하고, 실패하면 에러를 전달합니다.
하지만 파일 업로드 UI에서는 사용자가 파일을 여러 개 동시에 선택할 수 있습니다.
각 파일이 어떤 상태인지(업로드 중인지, 완료됐는지, 실패했는지)를 UI에 보여주려면, 파일 목록 전체를 추적하는 별도의 상태가 필요합니다.
useMutation이 “파일 하나를 보내는 파이프”라면,Map은 “지금 파이프를 통과 중인 파일들의 현황판”에 해당합니다.
v1은 이 두 관심사를 분리해서, API 호출은 useFileUpload에, 현황판 관리는 useTempFileManager에 맡기는 구조를 택했습니다.
Info
이런 구조를 흔히 저수준 훅과 고수준 훅이라고 부릅니다. 저수준
훅(useFileUpload)은 HTTP 요청이나 캐시 무효화 같은 인프라 계층의 작업을
처리하고, 고수준 훅(useTempFileManager)은 그 위에서 비즈니스 로직과 UI
상태를 조합합니다. 컴포넌트는 고수준 훅만 사용하면 되고, 저수준 훅의 존재를 알
필요가 없습니다.
1-1. useFileUpload (API 호출 담당)
이 훅은 자체적으로 파일 상태를 관리하지 않습니다.
파일이 성공했는지, 실패했는지를 콜백 인터페이스(onUploadStart, onUploadSuccess, onUploadError)로 상위 훅에 알려줄 뿐입니다.
“무슨 일이 일어났는지”만 전달하고, “그래서 UI를 어떻게 바꿀지”는 상위 훅이 결정합니다.
▶useFileUpload 전체 코드
function useFileUpload(options: FileUploadOptions = {}) {
const queryClient = useQueryClient();
const uploadPathUuid = useRef(uuidv4());
const { mutate: uploadFile, isPending } = useMutation({
mutationFn: async ({ file, fileId }: { file: File; fileId: string }) => {
options.onUploadStart?.(fileId);
return fileApi.postFile({ file }, { path: uploadPathUuid.current });
},
onError: (error, variables) => {
options.onUploadError?.(variables.fileId, error);
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: FILE_QUERY_KEY });
options.onUploadSuccess?.(variables.fileId, data);
},
});
return {
uploadFile,
isUploading: isPending,
uploadPathUuid: uploadPathUuid.current,
};
}1-2. useTempFileManager (상태 관리 담당)
useFileUpload가 전달하는 콜백을 받아서, Map<string, FileAttachment>에 담긴 파일의 상태를 전이시킵니다.
파일 선택 → Map에 추가 → 업로드 시작 → 성공/실패에 따라 상태 변경.
컴포넌트는 이 훅이 반환하는 files 배열과 유틸 함수들만 사용하면 됩니다.
이 글의 핵심인 에러 처리 부분만 보면 이렇습니다.
const fileUpload = useFileUpload({
onUploadError: fileId => {
deleteFile(fileId); // ← 에러 시 Map에서 삭제
},
});
▶useTempFileManager 전체 코드
function useTempFileManager() {
const [files, setFiles] = useState<Map<string, FileAttachment>>(new Map());
const fileUpload = useFileUpload({
onUploadError: fileId => {
deleteFile(fileId);
},
onUploadStart: fileId => {
updateFile(fileId, { status: FileStatus.UPLOADING });
},
onUploadSuccess: (fileId, response) => {
updateFile(fileId, {
content_type: response.content_type,
directory: response.directory ?? '',
file_name: response.file_name ?? '',
file_size: response.file_size ?? 0,
full_path: response.full_path ?? '',
status: FileStatus.COMPLETED,
});
},
});
const handleFileInputChange = (fileList: FileList): void => {
// 1. 중복 체크 (이름 + 크기)
// 2. uuid 부여하여 Map에 UPLOADING 상태로 추가
// 3. useFileUpload.uploadFile 호출
};
return {
files: Array.from(files.values()),
handleFileInputChange,
handleFileRemove,
handleClearFiles,
getUploadedFilePaths,
hasUploadingFiles,
isAllUploaded,
completedFilesCount,
};
}1-3. 사용처
| 사용처 | 사용 방식 |
|---|---|
| 채팅 입력 컴포넌트 | 훅 직접 사용 |
| 데이터소스 업로드 다이얼로그 | 훅 사용 + patchFile 별도 mutation |
| 파일 업로드 컴포넌트 | 훅 직접 사용 |
| 그 외 7개 파일 | FileAttachment / FileStatus 타입만 import |
3곳에서 훅을 사용하고 있었고, 7개 파일은 훅의 타입만 가져다 쓰고 있었습니다.
2. v1의 한계. 무엇이 부족했는가
2-1. 에러 나면 파일이 사라진다
v1의 가장 큰 문제는 업로드 실패 시 파일을 Map에서 바로 삭제한다는 점이었습니다.
onUploadError: (fileId) => {
deleteFile(fileId); // ← Map에서 제거
},
이로 인해 세 가지가 불가능했습니다.
- 사용자가 어떤 파일이 실패했는지 UI에서 확인할 수 없었습니다
- 왜 실패했는지 에러 메시지를 보여줄 수 없었습니다 (에러 정보를 저장하지 않음)
- 재시도할 수 없었습니다 (원본
File객체를 보관하지 않으므로 다시 파일을 선택해야 함)
Note
FileStatus.ERROR라는 enum 값이 정의되어 있었지만, 실제로는 한 번도 사용되지
않고 있었습니다.
2-2. 라이프사이클이 업로드에서 끝난다
v1이 관리하는 범위를 정리하면 이렇습니다.
flowchart LR
A[선택] --> B[업로드\npostFile] --> C[완료]
C -.-> D[확정\npatchFile]
C -.-> E[삭제\ndeleteFile]
C -.-> F[다운로드\ngetFile]
subgraph v1["v1이 관리하는 범위"]
A
B
C
end
subgraph outside["사용처에서 직접 구현"]
D
E
F
end
style v1 fill:#e8f5e9,stroke:#4caf50
style outside fill:#fff3e0,stroke:#ff9800
1편에서 정리한 파일 업로드 흐름은 “임시 업로드(postFile) → 최종 확정(patchFile)“의 2단계였습니다.
v1은 이 중 1단계인 임시 업로드만 래핑했습니다.
1편에서 가장 중복이 심했던 patchFile(5곳)은 v1에 포함되지 않았습니다.
결과적으로, 훅을 사용하는 곳조차 2단계인 확정은 별도로 구현해야 했습니다.
// 데이터소스 업로드 다이얼로그
// useTempFileManager로 업로드까지는 관리하지만, 이동은 별도 mutation
const { files, handleFileInputChange } = useTempFileManager();
const { mutateAsync: patchFile } = useMutation({
mutationFn: (req: FilePatchRequest) => fileApi.patchFile(req),
onError: () => { toast.error(t('uploadFailed')); },
onSuccess: async () => { queryClient.invalidateQueries({ ... }); },
});
“산발적 로직”의 절반만 해결된 셈이었습니다.
2-3. 그 외 한계들
에러 처리와 라이프사이클 외에도 부족한 점이 여럿 있었습니다.
- 업로드 과정이 블랙박스. 진행률 추적 없이
UPLOADING→COMPLETED로 점프합니다. 대용량 파일 업로드 시 사용자에게 줄 수 있는 피드백이 “업로드 중…”뿐이었습니다. - 클라이언트 검증이 없다. 파일 크기 제한, 허용 확장자, 최대 파일 수 등의 검증 로직이 훅에 없었습니다. 유일한 검증은 중복 체크(이름+크기)뿐이고, 100MB 파일이든 허용되지 않는 확장자든 검증 없이 업로드가 시작됩니다.
- 초기 파일 세팅이 안 된다. 서버에서 가져온 기존 파일 목록을
COMPLETED상태로 세팅하는 방법이 없었습니다. “수정” 화면에서 기존 첨부파일을 보여주려면 훅 외부에서 별도로 상태를 관리해야 했습니다. - 동시성 제어가 없다. 파일 10개를 선택하면 10개의
mutate가 동시에 실행됩니다. 서버 부하나 브라우저 커넥션 제한(보통 6개)을 고려한 큐잉이 없었습니다. - 타입이 훅 파일에 종속되어 있다.
FileAttachment와FileStatus를 훅 파일에서 export하고 있어서, 훅을 사용하지 않는 7개 파일이 타입 때문에 훅에 의존하고 있었습니다. 나중에 훅을 수정할 때 영향 범위가 불필요하게 넓어지는 구조입니다.
3. v1이 해결한 것과 남은 것
v1은 업로드 상태 관리라는 좁은 범위에서는 확실히 문제를 해결했습니다.
3곳에서 중복되던 업로드 mutation과 파일 리스트 상태 관리를 하나로 통합했습니다.
하지만 파일 라이프사이클의 일부만 커버한 설계 때문에, 통합의 효과가 반감되었습니다.
- 업로드는 훅이 관리하지만, 바로 다음 단계인 확정(
patchFile)은 사용처가 직접 구현 - 에러가 나면 파일이 사라져서, 사용자가 실패를 인지할 수도 재시도할 수도 없음
- 훅을 쓰는 곳조차
patchFile별도 mutation이 필요
이 한계들을 정리하면, v2가 풀어야 할 문제가 명확해집니다.
| v1 한계 | v2에서 해결할 방향 |
|---|---|
| 업로드만 래핑 | 전체 라이프사이클 관리 (업로드 + 확정 + 삭제) |
| 에러 시 파일 삭제 | ERROR 상태 유지 + 에러 정보 저장 + 재시도 |
| 진행률 없음 | 파일별 progress 추적 |
| 검증 없음 | 설정 가능한 검증 레이어 |
| 초기 파일 불가 | initialFiles 옵션 |
| 동시성 미제어 | 큐 기반 동시성 제한 |
| 타입 훅 종속 | 타입 분리 |
다음 편에서는 이 요구사항들을 바탕으로 v2의 인터페이스와 상태 모델을 어떻게 설계했는지를 다룹니다.