백엔드›API•
4분
•Spring Boot multipart
그리드 행별 파일 첨부 + 데이터 저장을 단일 API로 통합한 구현 기록
목차
- 배경 - 왜 multipart 통합인가
- 핵심 개념 - Blob으로 JSON 파트 전송
- 프론트엔드 - FormData 구성
- 백엔드 - 컨트롤러 설계
- 백엔드 - 서비스 처리 순서
- 전체 흐름 요약
1. 배경
기존 방식 (선업로드)
① /fileUpload → 파일 먼저 업로드 → fileGroupId 반환
② /saveData → fileGroupId 포함해서 JSON 저장
문제점
- API 호출이 2번 → 파일은 올라갔는데 저장 실패 시 고아 파일 발생
- 트랜잭션 분리로 정합성 보장 어려움
변경 방식 (multipart 통합)
① /saveData → 파일 + JSON 데이터 한 번에 전송
→ 서버에서 파일 처리 후 DB 저장까지 단일 트랜잭션
2. 핵심 개념
@RequestPart로 JSON을 Map으로 직접 받으려면 Blob이 필요하다
// ❌ 이렇게 하면 Content-Type 없음 → 서버에서 Map으로 파싱 불가
formData.append('body', JSON.stringify(data));
// ✅ Blob으로 감싸서 application/json 명시
formData.append('body', new Blob([JSON.stringify(data)], { type: 'application/json' }));
이유:
@RequestPart는 각 파트의 Content-Type을 보고 Jackson이 역직렬화함.
Blob 없이 단순 문자열로 append하면 Content-Type이 없어서 Map<String, Object>로 바인딩 실패.
3. 프론트엔드
행별 파일 상태 관리 구조
그리드 행마다 파일 상태를 독립적으로 관리한다.
rowFileState[rowId] = {
savedFiles : [], // 서버에 이미 저장된 파일 (화면 표시용)
newFiles : [], // 새로 추가한 File 객체 (업로드 대상)
deleteFiles: [], // 삭제할 기존 파일 (fileSeq 포함 객체)
};
UI 상태(savedFiles)와 서버 반영 상태(newFiles, deleteFiles)를 분리하는 게 핵심.
사용자가 저장 전에 변경 사항을 검토/취소할 수 있는 구조가 된다.
FormData 구성 (핵심)
const formData = new FormData();
// JSON 데이터 → Blob으로 감싸서 전송
formData.append('body', new Blob([JSON.stringify(saveParam)], { type: 'application/json' }));
// 신규 파일
state.newFiles.forEach(file => formData.append('newFiles', file));
// 삭제 파일 ID 추출 (객체 배열 → ID 문자열 배열)
const delIds = state.deleteFiles.map(f => String(f.fileSeq));
delIds.forEach(id => formData.append('deletedFileIds', id));
저장 흐름 (fnSave)
const fnSave = async () => {
const saveRows = displayList.value.filter(r => r.status === 'I' || r.status === 'U');
const deleteRows = displayList.value.filter(r => r.status === 'D');
for (const row of saveRows) {
const state = rowFileState.value[row._rowId]
?? { savedFiles: [], newFiles: [], deleteFiles: [] };
// INSERT / UPDATE 구분: PK 존재 여부로 판단
const saveParam = { ...row, status: !row.dataSeq ? 'I' : (row.status || 'U') };
const formData = new FormData();
formData.append('body', new Blob([JSON.stringify(saveParam)], { type: 'application/json' }));
state.newFiles.forEach(file => formData.append('newFiles', file));
state.deleteFiles.map(f => String(f.fileSeq)).forEach(id => formData.append('deletedFileIds', id));
await http.post("/api/saveData", formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
}
for (const row of deleteRows) {
await http.post("/api/deleteData", {
compCd : row.compCd,
userId : row.userId,
dataSeq: row.dataSeq,
});
}
};
4. 백엔드 컨트롤러
ObjectMapper 없이 Map으로 직접 받기
@PostMapping(
value = "/api/saveData",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public ResponseEntity<?> saveData(
@AuthenticationPrincipal UserDetails userDetails,
@RequestPart("body") Map<String, Object> body,
@RequestPart(value = "newFiles", required = false) List<MultipartFile> newFiles,
@RequestPart(value = "deletedFileIds", required = false) List<String> delFiles
) throws Exception {
String userId = userDetails.getUsername();
return ResponseEntity.ok(dataService.saveData(body, newFiles, delFiles, userId));
}
포인트
objectMapper.readValue()불필요 → Blob의Content-Type: application/json덕분에 Jackson이 자동 바인딩- 컨트롤러에서
body.put()같은 데이터 조작 제거 → 서비스로 위임 - 컨트롤러 역할은 수신 → 위임 → 반환 3가지만
5. 백엔드 서비스
처리 순서가 중요하다
@Transactional(rollbackFor = Exception.class)
public Object saveData(Map<String, Object> param,
List<MultipartFile> newFiles,
List<String> delFiles,
String userId) throws Exception {
param.put("createdBy", userId);
param.put("updatedBy", userId);
// 1. 기존 파일 삭제
if (delFiles != null && !delFiles.isEmpty()) {
fileService.deleteFiles(delFiles);
}
// 2. 신규 파일 업로드 → fileGroupId 확정
if (newFiles != null && !newFiles.isEmpty()) {
String fileGroupId = fileService.uploadFiles(
(String) param.get("fileGroupId"), newFiles
);
param.put("fileGroupId", fileGroupId); // DB에 저장할 ID 반영
}
// 3. DB 저장
String status = (String) param.get("status");
if ("U".equals(status)) return dataMapper.updateData(param);
return dataMapper.insertData(param);
}
순서가 중요한 이유:
fileGroupId가 확정된 후 DB에 저장해야 파일 묶음 ID가 정확히 들어감.
파일 삭제 → 파일 저장 → fileGroupId 반영 → DB 저장, 이 순서를 반드시 지킬 것.
6. 전체 흐름 요약
[프론트]
저장 대상 행 수집 (I / U / D)
↓
row별 FormData 구성
- body : Blob(JSON) → Content-Type: application/json
- newFiles : File 객체
- deletedFileIds : fileSeq 문자열
↓
POST /api/saveData (multipart/form-data)
[컨트롤러]
@RequestPart → Jackson 자동 바인딩
userId 추출 → 서비스 위임
[서비스]
파일 삭제 → 파일 저장 → fileGroupId 확정 → insert/update
(단일 @Transactional 보장)
방식 비교
| 항목 | 선업로드 방식 | multipart 통합 방식 |
|---|---|---|
| API 호출 수 | 2번 | 1번 |
| 트랜잭션 | 분리 | 단일 |
| 고아 파일 위험 | 있음 | 없음 |
| 서버 복잡도 | 낮음 | 약간 높음 |
| 프론트 복잡도 | 약간 높음 | 낮음 |