이 시리즈에 대해
사내 AI 플랫폼의 프론트엔드를 개발하고 있습니다.
이 플랫폼에서는 사용자가 PDF, 이미지 등의 문서를 업로드하면, AI가 분석하거나 학습 데이터로 활용합니다.
채팅에 파일을 첨부하거나, 학습 데이터셋을 올리거나, 평가용 문서를 등록하는 등 파일 업로드가 필요한 곳이 서비스 곳곳에 있습니다.
이 시리즈는 그 파일 업로드 코드를 정리한 과정을 다룹니다.
산발적으로 흩어져 있던 파일 관리 로직을 하나의 훅으로 통합하면서, 어떤 문제를 발견했고 어떤 설계 판단을 했는지를 기록합니다.
시리즈를 통해 바뀐 것
// Before: 5곳에서 각각 useMutation 정의, 에러 처리 제각각
const { mutateAsync: patchFile } = useMutation({
mutationFn: fileApi.patchFile,
onError: () => toast.error(t('uploadFailed')),
});
// After: 설정 하나로 통합
const { confirmFiles, addFiles, files } = useFileStaging({
maxFiles: 50,
accept: ['.pdf', '.csv'],
confirmDir: currentPath,
});
| Before | After |
|---|---|
| 같은 API를 5곳에서 각자 mutation 정의 | useFileStaging 하나로 통합 |
| 에러 처리 4가지 패턴 공존 | 통일된 에러 코드 + ERROR 상태 유지 |
| 에러 시 파일 삭제 → 재시도 불가 | ERROR 상태 유지 + retryFile() |
| 진행률 없이 “업로드 중…” | 파일별 0~100% 실시간 추적 |
| 50MB 올린 후에야 비율 에러 | 클라이언트 콘텐츠 검증 |
이번 1편에서는 이 파편화를 처음 발견한 시점과, 구체적으로 어떤 문제들이 있었는지를 정리합니다.
파일 업로드 코드가 프로젝트 곳곳에 흩어져 있었습니다
새 페이지에 파일 업로드를 붙여야 했습니다. 이미 다른 페이지에 비슷한 기능이 있으니, 그 코드를 참고하면 금방 끝날 거라고 생각했습니다.
그런데 프로젝트를 검색해보니 생각보다 많은 곳이 나왔습니다.
- 같은 파일 API를 호출하는 곳이 11곳
- 그중
patchFile하나만 5곳에서 각각 별도의useMutation으로 정의
하나씩 열어보니 에러 처리가 제각각이었습니다.
어떤 곳은 ApiError를 분기하고, 어떤 곳은 toast.error만 띄우고, 어떤 곳은 에러 처리 자체가 없었습니다.
MVP부터 v1까지 빠르게 구축하는 과정에서, 기존 코드를 참고해 만들어진 것들이 조금씩 달라지며 11개의 파편이 되어 있었습니다.
1. 배경. 하나의 컨트롤러, 11개의 사용처
프로젝트에서 파일을 다루는 곳은 생각보다 많았습니다.
- 채팅 파일 첨부
- 데이터소스 업로드
- 벡터 문서 추가
- 파인튜닝 데이터셋 업로드
- 에이전트 평가 데이터셋 업로드
최소 5개 페이지에서 파일을 다루고 있었고, 이 페이지들은 모두 동일한 백엔드 API(fileApi)를 호출합니다.
백엔드는 파일 업로드, 이동, 삭제, 복사, 다운로드까지 9개 API를 하나의 컨트롤러에서 제공하고 있었습니다.
1-1. 파일 업로드의 흐름
이 프로젝트에서 파일 업로드는 두 단계로 이루어집니다.
sequenceDiagram
participant U as 사용자
participant C as 프론트엔드
participant S as 서버
U->>C: 파일 선택
C->>S: 1단계. postFile (임시 경로에 업로드)
S-->>C: 임시 파일 정보 반환
U->>C: 제출 버튼 클릭
C->>S: 2단계. patchFile (최종 경로로 이동)
S-->>C: 이동 완료
사용자가 파일을 선택하면 바로 서버에 업로드되지만, 아직 임시 경로에 저장된 상태입니다.
이후 사용자가 “제출” 버튼을 누르는 등 확정 액션을 취하면, 임시 경로에 있던 파일을 최종 경로로 이동시킵니다.
왜 임시 경로를 거칠까?
처음 이 구조를 설계할 때, 채팅 첨부파일은 일정 기간이 지나면 만료되는 것이 기획이었습니다. 확정 전 파일에 만료 기간을 두어 자동 삭제할 수 있어야 했고, 그래서 “일단 임시 저장소에 올리고, 확정되면 영구 저장소로 옮긴다”는 2단계 방식이 자리 잡았습니다.
이 흐름에서 핵심 API는 두 가지입니다.
postFile: 임시 경로에 업로드patchFile: 최종 경로로 이동
이 글에서 반복적으로 등장하는 이름이니 기억해두면 좋습니다.
postFile은 단건 API
postFile은 파일 하나를 받아 서버에 올리는 것만 처리합니다.
처음에는 데이터소스 페이지에서 파일 하나만 업로드하면 되는 상황에 맞춰 만들어졌습니다. 이후 다른 페이지에서 여러 파일을 동시에 업로드해야 하는 요구가 생겼지만, 다중 업로드 API를 새로 만드는 대신 기존 단건 API를 반복 호출하는 방식으로 빠르게 구현되었습니다.
Warning
이 선택이 나중에 상태 관리 복잡도를 높이는 원인이 됩니다.
문제는, 각 프론트엔드 페이지가 이 API들을 독립적으로 useMutation을 정의하여 호출하고 있다는 점이었습니다.
2. 문제의 실체. 같은 API, 다른 모든 것
2-1. 전체 API 사용 지도
백엔드가 제공하는 9개 파일 API와 프론트엔드에서 호출하는 위치를 정리해봤습니다.
fileApi
├── postFile (업로드)
│ ├── 공통 훅 내부 ← useTempFileManager
│ └── 평가 데이터셋 페이지 ← 직접 mutation
│
├── patchFile (확정/이동) ← ⚠️ 5곳에서 각각 별도 mutation
│ ├── 데이터소스 페이지 → toast + 캐시 무효화
│ ├── 채팅 페이지 → 에러 처리 없음
│ ├── 문서 추가 모달 → 에러 처리 없음
│ ├── 파인튜닝 페이지 → toast + ApiError 분기
│ └── 평가 데이터셋 페이지 → toast + ApiError 분기
│
├── deleteFile (삭제)
│ └── 파일탐색기 훅 ← 별도 mutation
│
├── patchFileMove (이동)
│ └── 사이드바 ← 별도 mutation
│
├── patchName (이름 변경)
│ └── 파일탐색기 훅 ← 별도 mutation
│
├── postFileCopy (복사)
│ └── 파일탐색기 훅 ← 별도 mutation
│
├── postDirectory (디렉토리 생성)
│ └── 폴더생성 훅 ← 별도 mutation
│
├── getFile (ID로 다운로드)
│ └── 다운로드 유틸 ← 유틸 함수
│
└── getFileByPath (경로로 다운로드)
└── 다운로드 유틸 ← 유틸 함수
9개 API가 11개 파일에 흩어져 있고, 특히 patchFile은 5곳에서 중복 정의되어 있었습니다.
이 트리를 처음 그려봤을 때, 단순히 “중복이 많다”가 아니라 **“같은 작업인데 동작이 다를 수 있겠다”**는 생각이 들었습니다.
그래서 가장 중복이 심한 patchFile의 에러 처리를 사용처별로 비교해봤습니다.
2-2. 같은 API, 다른 에러 처리
patchFile은 임시 경로에 업로드된 파일을 최종 경로로 이동시키는 API입니다.
5곳이 이 API를 호출하는데, 에러 처리가 전부 달랐습니다.
// ① 데이터소스 페이지: toast만
onError: () => {
toast.error(t('uploadFailed'));
};
// ② 파인튜닝 페이지: ApiError 분기 + toast + description
onError: (error: Error) => {
if (error instanceof ApiError) {
toast.error(error?.title ?? t('errorFileMoveFailed'), {
description: error?.detail,
});
}
};
// ③ 채팅 페이지: 에러 처리 없음
// await로 직접 호출, try-catch 없음
fileRequests = await fileApi.patchFile({
files: files || [],
to_dir: '/uploads/chat',
});
정리하면 네 가지 패턴이 공존하고 있었습니다.
| 패턴 | 사용처 |
|---|---|
ApiError 분기 + toast.error | 파인튜닝 페이지, 파일탐색기, 평가 데이터셋 페이지 |
toast.error만 | 데이터소스 페이지 |
| 에러 처리 없음 | 채팅 페이지, 문서 추가 모달 |
console.error만 | 파일 복사 (파일탐색기) |
사용자 입장에서 보면, 같은 “파일 이동” 작업을 다른 페이지에서 수행했을 때 어떤 곳은 토스트가 뜨고 어떤 곳은 조용히 실패합니다.
같은 서비스 안에서 일관되지 않은 경험을 하게 되는 것입니다.
2-3. 파일 상태를 각자 관리
에러 처리만 다른 게 아니었습니다. 업로드된 파일 목록을 추적하는 방식도 사용처마다 달랐습니다.
// 평가 데이터셋 페이지: react-hook-form fieldArray로 관리
const { fields, append, remove } = useFieldArray({ name: 'files' });
// 채팅 페이지: 파일 상태 추적 없이 await로 직접 호출
if (files && files.length > 0) {
fileRequests = await fileApi.patchFile({ ... });
}
// 문서 추가 모달: ref에 파일 리스트 저장
const uploadFileListRef = useRef<UploadedFile[]>([]);
세 곳이 세 가지 방법으로 같은 일을 하고 있었습니다.
fieldArray: 폼과 결합된 상태 관리- 직접
await: 상태 추적 자체가 없음 useRef: React 리렌더링과 무관한 저장소
Warning
이건 단순히 코드 스타일의 차이가 아닙니다. 파일 상태 추적 방식이 다르면,
나중에 재시도 로직, 취소 처리, 진행률 표시 같은 기능을 추가할 때
5곳을 각각 다르게 구현해야 한다는 뜻입니다.
2-4. 캐시 무효화 누락 위험
파일을 업로드하거나 이동한 뒤에는 관련 쿼리 캐시를 무효화해야 합니다.
파일 목록이 업데이트되어야 사용자가 방금 업로드한 파일을 볼 수 있으니까요.
그런데 어떤 쿼리 키를 무효화할지는 각 사용처가 직접 판단하고 있었습니다.
새로운 사용처가 추가될 때 캐시 무효화를 빠뜨리기 쉬운 구조입니다.
실제로 빠뜨린 곳이 있었는지는 확인하지 못했지만, 빠뜨릴 수 있는 구조 자체가 문제라고 봤습니다.
3. 이 상황이 만들어진 과정
공통화를 미리 하지 않은 이유
이 코드를 보면 “왜 처음부터 공통 훅을 안 만들었지?”라는 생각이 들 수 있습니다.
하지만 당시 상황을 돌아보면, 공통화를 미리 하지 않은 것은 게으름이 아니라 합리적인 선택이었습니다.
MVP부터 v1까지 몇 달 만에 빠르게 구축된 서비스였습니다. 당시 상황은 이랬습니다.
- API 스펙이 계속 변했습니다. 백엔드와 프론트엔드가 동시에 개발을 진행했고, 파일 관련 API 스펙 자체가 안정되지 않은 상태였습니다.
이 상태에서 공통 훅을 만들면, 스펙이 바뀔 때마다 훅을 수정해야 하고 그 훅을 사용하는 모든 곳에 영향이 갑니다. - 담당 개발자가 달랐습니다. 채팅은 A가, 데이터소스는 B가, 파인튜닝은 C가 개발하는 상황에서 “파일 업로드가 전체적으로 어디에서 어떻게 호출되고 있는지”를 한 사람이 파악하기 어려웠습니다.
- 공통화를 논의하는 것 자체가 리소스였습니다. 전체 사용처를 먼저 파악해야 하는데, 초기 구축 단계에서 그 여유가 없었습니다.
복사가 중복이 되기까지
그래서 과정은 이렇게 흘러갔습니다.
- 처음 데이터소스 페이지에서 파일 업로드 + 이동 로직을 구현
- 채팅에 파일 첨부가 필요해져서 채팅 페이지에 구현. 기존 코드를 참고했지만, 에러 처리는 “나중에 붙이자”고 넘어감
- 파인튜닝에 데이터셋 업로드가 필요해져서 또 참고. 이번에는 더 꼼꼼하게
ApiError분기를 추가 - 평가 데이터셋도 필요해져서 또 참고
- 복사할 때마다 에러 처리, 캐시 무효화, 대상 경로가 미세하게 달라짐
파일 관련 기능이 한꺼번에 필요해진 게 아닙니다.
페이지가 하나씩 추가되면서 필요해졌고, 각 페이지 개발자가 가장 합리적으로 할 수 있는 일은 “이미 동작하는 코드를 참고하는 것”이었습니다.
두 번째 사용처가 생겼을 때는 “아직 두 곳이니까”, 세 번째가 생겼을 때는 “지금 일정이 급하니까”.
이렇게 미루다 보면 어느 순간 5곳이 되어 있습니다.
4. 정리가 필요하다는 판단
사실 이 문제를 부분적으로 해결하기 위한 시도는 이미 있었습니다.
useTempFileManager라는 훅이 만들어져 있었고, 일부 사용처에서 이 훅을 통해 파일 업로드를 처리하고 있었습니다.
하지만 이 훅은 9개 API 중 postFile(업로드) 하나만 래핑하고 있었습니다.
가장 중복이 심한 patchFile(이동)을 포함한 나머지 API는 여전히 사용처에서 직접 호출하는 구조로 남아 있었습니다.
다음 편에서는 이 useTempFileManager v1이 어떻게 설계되었고, 어디까지 해결했으며, 왜 한계가 있었는지를 다룹니다.