HuggingFace 업로드 모달: 파라미터 검증과 에러 핸들링¶
개요¶
XGEN 2.0의 데이터 프로세서에서 전처리한 데이터셋을 HuggingFace Hub에 직접 업로드할 수 있다. 리포지토리 ID, 파일명, 공개/비공개 설정, HuggingFace 계정 정보를 입력하면 백엔드가 Hub API를 호출해서 업로드한다.
간단해 보이는 기능이지만, 필수/선택 파라미터 구분, 빈 문자열 vs undefined 처리, 백엔드 에러 메시지 추출, 사용자 피드백 토스트 등 검증과 에러 핸들링의 기본기가 집약되어 있다. 이 글에서는 HuggingFaceUploadModal의 3단계 검증 레이어와, 데이터 프로세서 전체의 관심사 분리 패턴을 다룬다.
아키텍처¶
flowchart LR
subgraph Sidebar["DataProcessorSidebar"]
A["저장 카테고리"]
B["HuggingFace에 업로드"]
end
subgraph Modal["HuggingFaceUploadModal"]
C["폼 입력"]
D["검증 1: repoId 필수"]
E["onUpload 콜백"]
end
subgraph Parent["DataProcessor"]
F["검증 2: 빈 문자열→undefined"]
G["handleUploadToHuggingFace"]
H["toast 피드백"]
end
subgraph API["dataManagerAPI.js"]
I["검증 3: 타입+필수 체크"]
J["uploadToHuggingFace"]
K["POST /api/data-manager/processing/upload-to-hf"]
end
B --> C --> D --> E --> F --> G --> I --> J --> K
K -->|성공| H
K -->|실패| H
3단계 검증 레이어:
- 모달 UI:
repoId비어있으면 버튼 disabled + alert - 부모 컴포넌트: 빈 문자열을
undefined로 변환 - API 함수: 필수 파라미터 타입 검증 + 선택적 파라미터 조건부 포함
핵심 구현¶
HuggingFaceUploadModal 컴포넌트¶
5개 폼 필드를 가진 모달이다. repoId만 필수이고 나머지는 선택적이다.
interface HuggingFaceUploadModalProps {
isOpen: boolean;
onClose: () => void;
onUpload: (
repoId: string,
filename: string,
isPrivate: boolean,
hfUserId: string,
hubToken: string
) => void;
}
export const HuggingFaceUploadModal: React.FC<HuggingFaceUploadModalProps> = ({
isOpen, onClose, onUpload
}) => {
const [repoId, setRepoId] = useState('');
const [filename, setFilename] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [hfUserId, setHfUserId] = useState('');
const [hubToken, setHubToken] = useState('');
if (!isOpen) return null;
const handleSubmit = () => {
// 검증 1: 필수 필드
if (!repoId.trim()) {
alert('리포지토리 ID를 입력해주세요.');
return;
}
onUpload(repoId, filename, isPrivate, hfUserId, hubToken);
// 폼 초기화
setRepoId('');
setFilename('');
setIsPrivate(false);
setHfUserId('');
setHubToken('');
onClose();
};
return createPortal(
<div className={styles.dialogOverlay}>
<div className={styles.uploadModal}>
<div className={styles.modalHeader}>
<h3>HuggingFace Hub 업로드</h3>
<button onClick={onClose}><IoClose /></button>
</div>
<div className={styles.formGroup}>
<label>리포지토리 ID *</label>
<input
value={repoId}
onChange={(e) => setRepoId(e.target.value)}
placeholder="username/dataset-name"
/>
</div>
<div className={styles.formGroup}>
<label>파일명 (선택)</label>
<input
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="data.parquet"
/>
</div>
<div className={styles.formGroup}>
<label>HuggingFace 사용자 ID (선택)</label>
<input
value={hfUserId}
onChange={(e) => setHfUserId(e.target.value)}
/>
</div>
<div className={styles.formGroup}>
<label>Hub Token (선택)</label>
<input
type="password"
value={hubToken}
onChange={(e) => setHubToken(e.target.value)}
placeholder="hf_..."
/>
</div>
<div className={styles.checkboxGroup}>
<label>
<input
type="checkbox"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
/>
비공개 리포지토리
</label>
</div>
<button
className={styles.confirmButton}
onClick={handleSubmit}
disabled={!repoId.trim()}
>
업로드
</button>
</div>
</div>,
document.body
);
};
disabled={!repoId.trim()}으로 필수 필드가 비어있으면 버튼 자체가 비활성화된다. handleSubmit에서의 추가 검증은 이중 안전장치다. Hub Token은 type="password"로 마스킹 처리한다.
DataProcessor — 부모 컴포넌트의 역할¶
모달은 순수하게 UI와 폼 입력만 담당한다. 실제 API 호출과 에러 처리는 부모인 DataProcessor가 한다.
const handleUploadToHuggingFace = async (
repoId: string,
filename: string,
isPrivate: boolean,
hfUserId: string,
hubToken: string
) => {
try {
showSuccessToastKo('HuggingFace Hub에 데이터셋을 업로드하는 중...');
const result = await uploadToHuggingFace(
managerId,
repoId,
filename || undefined, // 빈 문자열 → undefined
isPrivate,
hfUserId || undefined, // 빈 문자열 → undefined
hubToken || undefined // 빈 문자열 → undefined
);
if (result.success) {
showSuccessToastKo(
`데이터셋이 HuggingFace Hub에 성공적으로 업로드되었습니다!\n`
+ `리포지토리: ${result.upload_info?.repo_id || repoId}`
);
} else {
showErrorToastKo(
result.message || 'HuggingFace 업로드에 실패했습니다.'
);
}
} catch (error) {
showErrorToastKo(
`HuggingFace 업로드 실패: ${
error instanceof Error ? error.message : '알 수 없는 오류'
}`
);
}
};
핵심 포인트는 filename || undefined다. 모달에서 빈 문자열("")이 넘어올 수 있는데, API 함수에 빈 문자열을 보내면 백엔드에서 빈 파일명을 사용하려 해서 에러가 난다. || 연산자로 falsy 값을 undefined로 변환하면 API 함수에서 해당 파라미터를 요청 본문에 포함하지 않는다.
uploadToHuggingFace API 함수 — 3단계 검증의 마지막¶
export const uploadToHuggingFace = async (
managerId, repoId, filename, isPrivate = false, hfUserId, hubToken
) => {
try {
// 검증 3: 필수 파라미터
if (!managerId) {
throw new Error('Manager ID는 필수입니다.');
}
if (!repoId || typeof repoId !== 'string' || repoId.trim() === '') {
throw new Error('Repository ID는 필수입니다.');
}
// 요청 본문 구성 — 필수 필드만 먼저
const requestBody = {
manager_id: managerId,
repo_id: repoId.trim(),
private: isPrivate
};
// 선택적 파라미터: truthy + 타입 체크 + trim 후 추가
if (filename && typeof filename === 'string' && filename.trim() !== '') {
requestBody.filename = filename.trim();
}
if (hfUserId && typeof hfUserId === 'string' && hfUserId.trim() !== '') {
requestBody.hf_user_id = hfUserId.trim();
}
if (hubToken && typeof hubToken === 'string' && hubToken.trim() !== '') {
requestBody.hub_token = hubToken.trim();
}
const response = await apiClient(
`${API_BASE_URL}/api/data-manager/processing/upload-to-hf`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
}
);
// HTTP 에러 처리 — 에러 본문에서 detail 추출
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.detail
|| `HuggingFace 업로드 요청 실패: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
devLog.info(
`Dataset uploaded to HuggingFace: ${managerId} → ${repoId}:`,
data
);
return data;
} catch (error) {
devLog.error(
`Failed to upload to HuggingFace: ${managerId} → ${repoId}:`,
error
);
throw error; // 상위로 전파
}
};
선택적 파라미터 처리 패턴이 핵심이다:
if (filename && typeof filename === 'string' && filename.trim() !== '') {
requestBody.filename = filename.trim();
}
세 가지를 동시에 검사한다:
1. truthy: null, undefined, "" 모두 거름
2. typeof string: 잘못된 타입 방어
3. trim 후 비어있지 않음: 공백만 있는 문자열 거름
이렇게 검사를 통과한 값만 requestBody에 추가하므로, 백엔드에는 실제로 의미 있는 값만 전달된다.
HTTP 에러 응답에서 메시지를 추출하는 부분도 방어적이다:
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.detail
|| `HuggingFace 업로드 요청 실패: ${response.status} ${response.statusText}`
);
response.json()이 실패할 수 있다(응답이 JSON이 아닌 경우). .catch(() => null)로 안전하게 처리하고, detail 필드가 없으면 HTTP 상태 코드로 폴백한다.
사이드바 트리거¶
사이드바에서 업로드 기능을 선택하면 데이터 존재 여부를 먼저 확인한다:
const handleUploadToHuggingFace = async () => {
if (!dataTableInfo || !dataTableInfo.success
|| dataTableInfo.sample_count === 0) {
showErrorToastKo('업로드할 데이터가 없습니다.');
return;
}
if (onHuggingFaceUploadModal) {
onHuggingFaceUploadModal();
}
};
빈 데이터셋을 업로드하려는 시도를 사이드바 레벨에서 차단한다. 이것이 검증의 "0단계"라고 할 수 있다.
관심사 분리¶
이 기능의 설계에서 가장 중요한 패턴은 관심사 분리다. 세 계층이 각각 명확한 역할을 가진다:
| 계층 | 역할 | 검증 범위 |
|---|---|---|
| HuggingFaceUploadModal | 폼 UI + 필수 필드 비어있는지 | repoId 비어있음 |
| DataProcessor | API 호출 + toast 피드백 | 빈 문자열→undefined 변환 |
| uploadToHuggingFace | HTTP 통신 + 타입 안전성 | 필수/선택 파라미터 검증, 에러 메시지 추출 |
같은 커밋에서 사이드바의 불필요한 API 임포트도 정리했다:
- import { removeDataset, uploadLocalDataset, exportDatasetAsCSV,
- exportDatasetAsParquet, getDatasetStatistics, dropDatasetColumns,
- replaceColumnValues, applyColumnOperation, removeNullRows,
- uploadToHuggingFace } from '@/app/_common/api/dataManagerAPI';
+ import { removeDataset, uploadLocalDataset, exportDatasetAsCSV,
+ exportDatasetAsParquet, getDatasetStatistics,
+ removeNullRows } from '@/app/_common/api/dataManagerAPI';
사이드바가 직접 API를 호출하지 않으므로, dropDatasetColumns, replaceColumnValues, applyColumnOperation, uploadToHuggingFace 임포트가 불필요했다. 사이드바는 콜백 함수를 통해 부모에게 "이 기능을 실행해달라"고 요청할 뿐이다.
스타일링¶
// HuggingFaceUploadModal.module.scss
.dialogOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.uploadModal {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.formInput {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
}
.confirmButton {
width: 100%;
padding: 10px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
}
disabled 상태에서 배경색이 회색(#9ca3af)으로 바뀌어 시각적으로 비활성 상태임을 명확히 전달한다.
결과 및 회고¶
HuggingFace 업로드 모달은 코드 양이 많지 않지만(모달 ~100줄, API ~50줄, 핸들러 ~30줄), 프론트엔드의 기본적인 검증과 에러 핸들링 패턴이 잘 드러나는 사례다.
3단계 검증 레이어(UI → 부모 컴포넌트 → API 함수)는 "방어적 프로그래밍"의 전형이다. 각 레이어가 자기 수준에서 잡을 수 있는 문제를 잡는다. UI는 사용자 입력을 즉시 피드백하고, 부모 컴포넌트는 데이터 형변환을 담당하고, API 함수는 최종적으로 타입 안전성을 보장한다.
선택적 파라미터의 truthy + typeof + trim 삼중 검사는 과도하다고 느낄 수 있지만, JavaScript의 타입 유연성을 고려하면 합리적이다. 특히 여러 개발자가 다양한 위치에서 같은 API 함수를 호출하는 상황에서, 잘못된 타입이 들어오는 것을 함수 레벨에서 방어하는 것이 안전하다.
관련 글
- 문서 디렉토리 트리 UI: 파일 카운트와 컴팩트 레이아웃
FrontendNext.jsReact - Admin 모델 서빙 매니저: GPU 현황과 모델 배포 UI
FrontendGPUModel Serving - Workflow Execution Panel: 검증과 에러 핸들링 UI 패턴
FrontendReactSSE - MinIO 기반 모델 선택 UI: 로딩 상태와 에러 처리
FrontendMinIOModel Management - 인증 플로우 개선: 토큰 검증과 리프레시 처리
AuthenticationFrontendNext.js