파일 업로드 리팩토링기 #3. 훅의 책임을 어디까지 넓힐 것인가

파일 업로드 리팩토링기 #3. 훅의 책임을 어디까지 넓힐 것인가

March 24, 2026

v1의 한계가 곧 v2의 요구사항이었습니다

2편에서 v1의 한계를 일곱 가지로 정리했습니다.
업로드만 래핑한 좁은 범위, 에러 시 파일 삭제, 진행률 부재, 검증 부재, 초기 파일 불가, 동시성 미제어, 타입 종속.

이 한계들을 뒤집으면 v2가 풀어야 할 문제 목록이 됩니다.
이 글에서는 그 목록을 바탕으로 v2의 인터페이스와 상태 모델을 어떻게 설계했는지를 다룹니다.

Note

전체 구현 코드는 다음 편에서 다루고, 이 글에서는 설계 판단에 집중합니다.

1. 어떤 API를 훅에 포함할 것인가

1-1. v1의 문제는 범위가 좁았던 것

2편에서 봤듯이, v1은 9개 파일 API 중 postFile(임시 업로드) 하나만 래핑했습니다.
1편에서 가장 중복이 심했던 patchFile(확정)은 포함되지 않았고, 결과적으로 훅을 쓰는 곳조차 확정 로직을 별도로 구현해야 했습니다.

그렇다고 9개 API를 전부 훅에 넣는 것이 답은 아닙니다.
파일 이동, 이름 변경, 복사, 디렉토리 생성 같은 기능은 파일 탐색기에서만 쓰입니다.
이것까지 포함하면 훅이 “파일 업로드 관리”가 아니라 “파일 시스템 클라이언트”가 됩니다.

1-2. 판단 기준. 업로드 중심 라이프사이클

기준을 하나 세웠습니다.

APIv1v2판단 이유
postFile (임시 업로드)OO업로드 핵심
patchFile (확정/이동)XO가장 중복이 심한 API. 업로드 직후 흐름에 속함
deleteFile (삭제)XO파일 제거 시 서버와 동기화 필요
getFile / getFileByPathXX다운로드는 별도 유틸로 유지. 훅의 “관리” 책임과 분리
patchFileMove / patchName / postFileCopy / postDirectoryXX파일 탐색기 전용 기능. 업로드 관리 훅의 범위를 넘음

9개 중 3개(postFile, patchFile, deleteFile)만 선택했습니다.
“전부” 또는 “하나”가 아니라, 라이프사이클이라는 기준으로 경계를 그은 것이 v2 설계의 첫 번째 판단이었습니다.

2. 파일의 상태를 어떻게 표현할 것인가

2-1. v1의 상태 모델

v1은 네 가지 상태를 정의하고 있었지만, 실제로 사용된 건 둘뿐이었습니다.

UPLOADING  ← 사용됨
COMPLETED  ← 사용됨
ERROR      ← 정의만 되어 있고 한 번도 사용되지 않음
DELETED    ← 정의만 되어 있고 한 번도 사용되지 않음

파일이 선택되면 바로 UPLOADING으로 시작하고, 성공하면 COMPLETED로 전이합니다.
실패하면 ERROR로 가는 대신 Map에서 삭제됩니다.

“선택됨” 상태나 “대기 중” 상태가 없으니, 검증이나 동시성 제어를 끼워넣을 틈이 없었습니다.

2-2. v2의 상태 모델

v2에서는 라이프사이클의 각 단계를 상태로 표현합니다.

enum FileStatus {
  IDLE = 'idle', // 선택됨, 검증 전
  QUEUED = 'queued', // 검증 통과, 업로드 대기 중
  UPLOADING = 'uploading', // 서버에 업로드 진행 중
  COMPLETED = 'completed', // 임시 경로에 업로드 완료
  ERROR = 'error', // 실패 (재시도 가능)
  CONFIRMED = 'confirmed', // 최종 경로로 이동 완료
}

v1과 비교하면 세 가지가 달라졌습니다.

IDLE 추가. 파일이 선택되었지만 아직 검증을 거치지 않은 상태입니다.
v1에서는 선택과 동시에 업로드가 시작됐지만, v2에서는 파일 크기나 확장자를 먼저 확인하고 나서 업로드를 시작합니다.

QUEUED 추가. 검증을 통과했지만 아직 업로드가 시작되지 않은 대기 상태입니다.
v1에서는 파일 10개를 선택하면 10개의 mutate가 동시에 실행됐습니다.
v2에서는 동시 업로드 수를 제한하고, 나머지는 큐에서 대기합니다.

CONFIRMED 추가. 임시 경로에 업로드된 파일이 최종 경로로 이동 완료된 상태입니다.
v1에서는 확정을 훅이 관리하지 않았으니 이 상태가 필요 없었지만, v2에서는 확정까지 훅이 관리하므로 “업로드 완료”와 “확정 완료”를 구분할 수 있게 됩니다.

상태 전이를 정리하면 이렇습니다.

stateDiagram-v2
    [*] --> IDLE : 파일 선택
    IDLE --> QUEUED : 검증 통과
    QUEUED --> UPLOADING : 슬롯 확보
    UPLOADING --> COMPLETED : 성공
    UPLOADING --> ERROR : 실패
    ERROR --> QUEUED : 재시도
    COMPLETED --> CONFIRMED : 확정(patchFile)

2-3. ERROR를 상태로 유지하는 이유

2편에서 다뤘듯이, v1은 에러가 나면 파일을 Map에서 삭제했습니다.
v2에서 ERROR를 상태로 유지하려면, 에러 정보를 파일 객체 안에 담아야 합니다.
이 부분은 바로 다음 섹션에서 다룹니다.

3. 파일 하나를 어떤 정보로 표현할 것인가

3-1. v1의 FileAttachment

v1의 FileAttachment는 서버 응답 스키마를 거의 그대로 따르고 있었습니다.
파일명, 크기, MIME 타입, 서버 경로 정도만 담고 있었고, 클라이언트에서 필요한 정보(진행률, 에러 정보, 원본 File 객체)는 없었습니다.

3-2. v2의 ManagedFile

v2에서는 서버 스키마와 분리된 클라이언트 모델을 정의했습니다.

interface ManagedFile {
  /** 클라이언트 추적용 고유 ID */
  id: string;
  /** 파일명 */
  fileName: string;
  /** 파일 크기 (bytes) */
  fileSize: number;
  /** MIME 타입 */
  contentType?: string;
  /** 현재 라이프사이클 상태 */
  status: FileStatus;
  /** 업로드 진행률 (0~100) */
  progress: number;
  /** 서버가 반환한 전체 경로 (업로드 완료 후) */
  fullPath: string;
  /** 에러 정보 (ERROR 상태일 때) */
  error?: {
    message: string;
    isRetryable: boolean;
  };
  /** 원본 File 객체 (재시도용) */
  rawFile?: File;
}

v1 대비 핵심 변경은 세 가지입니다.

progress 추가. 파일별 업로드 진행률을 0~100으로 추적합니다.
v1에서는 UPLOADINGCOMPLETED로 점프했지만, v2에서는 사용자에게 실시간 피드백을 줄 수 있습니다.

error 추가. 실패 원인 메시지와 재시도 가능 여부를 담습니다.
네트워크 에러처럼 재시도하면 해결될 수 있는 에러와, 파일 크기 초과처럼 재시도해도 소용없는 에러를 구분할 수 있습니다.

rawFile 추가. 브라우저의 원본 File 객체를 보관합니다.
v1에서는 에러 시 파일을 삭제했기 때문에 재시도하려면 사용자가 다시 파일을 선택해야 했습니다.
rawFile을 보관하면 “재시도” 버튼 하나로 같은 파일을 다시 업로드할 수 있습니다.

4. 설정 객체. 5곳의 차이를 선언적으로 흡수하기

4-1. 사용처마다 다른 것

1편에서 봤던 5개 사용처를 다시 떠올려보면, 사용처마다 달랐던 건 결국 몇 가지 옵션이었습니다.

  • 허용하는 파일 종류와 크기가 다릅니다
  • 한 번에 올릴 수 있는 파일 수가 다릅니다
  • 확정 시 이동할 경로가 다릅니다
  • 호출하는 업로드 API가 권한에 따라 다릅니다

이 차이들을 훅 외부에서 선언적으로 주입할 수 있으면, 5곳의 로직을 각각 구현할 필요가 없어집니다.

4-2. FileManagerOptions

interface FileManagerOptions {
  /** 권한에 따라 호출되는 업로드 API가 달라짐 */
  permission?: 'user' | 'manager';

  /** ── 검증 ── */
  /** 최대 파일 수 */
  maxFiles?: number;
  /** 파일당 최대 크기 (bytes) */
  maxFileSize?: number;
  /** 허용 확장자 (예: ['.pdf', '.csv', '.xlsx']) */
  accept?: string[];

  /** ── 업로드 ── */
  /** 최대 동시 업로드 수 (기본: 3) */
  concurrency?: number;
  /** 업로드 경로 UUID (외부 주입. 없으면 자동 생성) */
  uploadPath?: string;

  /** ── 확정 ── */
  /** 확정 시 이동할 목적지 경로 */
  confirmDir?: string;

  /** ── 초기값 ── */
  /** 서버에서 가져온 기존 파일 목록 (COMPLETED 상태로 세팅) */
  initialFiles?: FileDetail[];

  /** ── 콜백 ── */
  onAllUploaded?: () => void;
  onValidationError?: (file: File, reason: string) => void;
  onConfirmSuccess?: (response: FileMoveRs) => void;
}

4-3. 사용처별 설정 예시

v1의 useTempFileManager라는 이름은 “임시 파일 관리”라는 좁은 범위를 반영하고 있었습니다.
v2는 선택부터 확정까지의 전체 흐름을 관리하므로, 이름을 useFileStaging으로 바꿨습니다.

같은 훅을 설정만 바꿔서 사용합니다.

// 채팅 파일 첨부: 간단한 업로드
const chatFiles = useFileStaging({
  permission: 'user',
  maxFiles: 5,
  maxFileSize: 10_000_000, // 10MB
  confirmDir: '/uploads/chat',
});

// 데이터소스 업로드: 대용량, 다양한 확장자
const dataSourceFiles = useFileStaging({
  permission: 'manager',
  maxFiles: 50,
  maxFileSize: 500_000_000, // 500MB
  accept: ['.csv', '.json', '.parquet', '.xlsx'],
  concurrency: 3,
  confirmDir: currentPath, // 사용자가 탐색기에서 선택한 경로
});

// 학습 데이터셋: 특정 포맷만, 수정 시 기존 파일 표시
const trainingFiles = useFileStaging({
  permission: 'manager',
  accept: ['.jsonl'],
  maxFiles: 1,
  confirmDir: `/uploads/training/${modelId}`,
  initialFiles: existingFiles, // 서버에서 가져온 기존 파일
});

1편에서 5곳이 각각 useMutation을 정의하던 patchFile이, confirmDir 옵션 하나로 흡수됩니다.
에러 처리가 제각각이던 문제도 훅 내부에서 통일된 방식으로 처리하게 됩니다.

5. 훅이 반환하는 것

5-1. 반환 인터페이스

v1과 비교하면 세 가지 영역이 추가되었습니다.

FileManagerReturn 전체 인터페이스
interface FileManagerReturn {
  /** ── 파일 목록 ── */
  files: ManagedFile[];

  /** ── 액션 ── */
  addFiles: (fileList: FileList) => void;
  removeFile: (fileId: string) => void;
  clearFiles: () => void;
  retryFile: (fileId: string) => void;
  cancelUpload: (fileId: string) => void;
  confirmFiles: () => Promise<FileMoveRs>;

  /** ── 상태 ── */
  hasUploadingFiles: boolean;
  hasErrorFiles: boolean;
  isAllUploaded: boolean;
  isConfirming: boolean;
  confirmError: ConfirmErrorCode | null;
  totalProgress: number;
  completedCount: number;
  uploadPath: string;
}

에러 복구. retryFile로 실패한 파일을 재시도하고, hasErrorFiles로 에러 파일이 있는지 확인할 수 있습니다.
v1에서는 에러 시 파일이 사라졌기 때문에 이 인터페이스 자체가 불가능했습니다.

업로드 제어. cancelUpload로 진행 중인 업로드를 취소하고, totalProgress로 전체 진행률을 추적할 수 있습니다.

확정 관리. confirmFiles로 임시 경로의 파일을 최종 경로로 이동시킵니다.
v1에서 사용처마다 별도로 구현하던 patchFile mutation이 이 함수 안에 들어갑니다.
isConfirmingconfirmError로 확정 진행 상태와 실패 여부도 확인할 수 있습니다.

5-2. 확정 에러 코드

confirmFiles가 실패할 때 반환하는 에러 코드입니다.

type ConfirmErrorCode = 'NO_FILES' | 'HAS_UPLOADING' | 'CONFIRM_FAILED';
  • NO_FILES: 확정할 파일이 없습니다. 파일을 선택하지 않았거나, 모든 파일이 에러 상태일 때.
  • HAS_UPLOADING: 아직 업로드 중인 파일이 있습니다. 모든 업로드가 끝난 뒤에 확정해야 합니다.
  • CONFIRM_FAILED: API 호출 자체가 실패했습니다.

Tip

사용처에서는 이 코드를 받아 적절한 메시지로 변환하면 됩니다. 1편에서 봤던
“에러 처리가 제각각” 문제를 해결하면서도, 메시지 내용은 사용처가 결정할 수
있는 구조입니다.

6. 타입을 훅에서 분리하기

2편에서 지적했듯이, v1에서는 FileAttachmentFileStatus가 훅 파일 안에 정의되어 있었습니다.
훅을 사용하지 않는 7개 파일이 타입 때문에 훅에 의존하는 구조였습니다.

v2에서는 타입을 별도 파일로 분리했습니다.

hooks/file/
  ├── file.types.ts          ← ManagedFile, FileStatus, FileManagerOptions
  ├── useFileUpload.ts       ← 업로드 mutation (저수준)
  ├── useFileConfirm.ts      ← 확정 mutation (저수준, 새로 추가)
  └── useFileStaging.ts      ← 파일 상태 관리 (고수준)

file.types.ts에 모든 타입을 모았습니다.
타입만 필요한 파일은 file.types.ts에서 import하고, 훅이 필요한 파일은 useFileStaging.ts에서 import합니다.

훅 구조도 2편에서 본 v1의 패턴을 유지합니다.
useFileUploaduseFileConfirm이 저수준 훅으로 각각 업로드와 확정 API를 담당하고, useFileStaging이 고수준 훅으로 이 둘을 조합하여 파일 상태를 관리합니다.
v1에서 useFileUploaduseTempFileManager였던 구조에 useFileConfirm이 하나 더 추가된 형태입니다.

7. 설계를 마치며

이 글에서 내린 설계 판단을 정리하면 세 가지입니다.

훅의 범위를 라이프사이클로 정했습니다. 9개 API 중 업로드 흐름에 속하는 3개만 선택했습니다.
“전부 넣기”와 “하나만 넣기” 사이에서, 기준 없이 타협하는 대신 라이프사이클이라는 경계를 명시적으로 세웠습니다.

에러를 상태로 유지하기로 했습니다. v1의 가장 큰 문제였던 “에러 시 삭제”를 뒤집어, ERROR 상태를 유지하고 에러 정보와 원본 파일을 보관하는 구조를 설계했습니다.

사용처의 차이를 설정 객체로 흡수했습니다. 5곳이 각각 다르게 구현하던 로직이, 설정 객체의 옵션 차이로 표현됩니다.
코드 중복이 아닌 선언적 차이로 바뀌는 것입니다.

다음 편에서는 이 설계를 실제로 구현하면서 만난 문제들을 다룹니다.
에러 복구, 업로드 진행률 추적, 동시성 제어를 어떻게 풀었는지가 핵심입니다.