백엔드API
4

Spring Boot multipart

그리드 행별 파일 첨부 + 데이터 저장을 단일 API로 통합한 구현 기록

Spring Boot multipart

목차

  1. 배경 - 왜 multipart 통합인가
  2. 핵심 개념 - Blob으로 JSON 파트 전송
  3. 프론트엔드 - FormData 구성
  4. 백엔드 - 컨트롤러 설계
  5. 백엔드 - 서비스 처리 순서
  6. 전체 흐름 요약

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번
트랜잭션분리단일
고아 파일 위험있음없음
서버 복잡도낮음약간 높음
프론트 복잡도약간 높음낮음

댓글

(0)
Spring Boot multipart | 강민석의 개발블로그