본문 바로가기

Dev Log

파일 업로드: “썸네일/메인 이미지” 업로드에서 두 필드가 동기화되는 버그 트러블슈팅

728x90

문제 요약

이벤트 등록/수정 화면에서 2개의 이미지를 관리한다.

  • 썸네일 이미지 (thumbnail)
  • 상세 페이지 메인 콘텐츠 이미지 (main)

정상 동작

  • 최초 업로드 시: thumbnailFileSeq / mainFileSeq가 각각 서로 다른 값으로 정상 저장

버그 현상

  • 둘 중 하나를 삭제하고 저장한 순간부터
    • thumbnailFileSeq와 mainFileSeq가 같은 값으로 동기화되기 시작
  • 그 이후에는
    • 둘 중 하나만 수정해도 두 개가 같이 바뀌는 버그 발생

 

아키텍처 개요

프론트엔드 저장 흐름

저장 버튼 클릭 시, 2개의 업로드 컴포넌트 배열을 순회하며 업로드를 수행한다.

for (const [index, key]of fileKeyArray) {const result =await fileComponents[index].upload();if (result?.data?.fileList?.length >0) {
    params[key] = result.data.fileList[0].fileSeq;
    params.fileInfoList = params.fileInfoList.concat(result.data.fileList);
  }
}

업로드 컴포넌트는 최종 파일 배열을 만들고 multipart/form-data로 업로드한다.

upload:async () => {const data = form.serializeObject()[attrName];const finalList =getFinalData(data);if (finalList.length <1) {return {code:"NO_CHANGE" };
  }const formData =toFormData({fileList: finalList });returnawait api.uploadFiles(formData);
}

 

서버 업로드 처리 방식

서버는 fileList의 각 항목을 보고 flag로 처리한다.

  • I : insert (신규 업로드)
  • D : delete (논리 삭제)
  • 그 외 / 없음 : “그냥 응답 리스트에 담아줌” (여기서 냄새가 난다)
if ("I".equals(flag)) {
  insertFile(fileInfo);
  response.add(toResponse(fileInfo));
}elseif ("D".equals(flag)) {
  deleteFile(fileInfo);// delYn=Y, useYn=N
}else {// ⚠️ 변경이 없어도 응답 리스트에 들어갈 수 있음
  response.add(toResponse(fileInfo));
}

 

원인 분석

핵심 원인 1) “변경 없음”도 업로드 루프를 타면서 응답에 섞임

삭제/수정이 발생하면, 프론트에서는 두 컴포넌트를 무조건 루프한다.

  • 실제로는 “썸네일만 변경” 또는 “메인만 삭제” 같은 상황이 대부분인데
  • 업데이트 대상 필터링이 없음 → 둘 다 upload() 호출됨

그런데 upload()는 변경이 없을 경우 NO_CHANGE를 리턴해야 정상인데,

데이터 구성 과정에서 flag 없는 기존 파일 정보가 최종 리스트에 포함되는 케이스가 생겼다.

즉, 결과적으로:

  • 한쪽은 D(삭제)
  • 다른쪽은 flag 없음(변경 없음)
  • 그런데 서버는 flag 없음도 응답 리스트에 담아버림

핵심 원인 2) 서버 update 로직이 fileInfoList “첫 번째”를 메인 seq로 박아버림

이게 “동기화”가 시작되는 트리거였다.

publicintupdateEvent(Request req) {if (req.getFileInfoList() !=null && !req.getFileInfoList().isEmpty()) {// ❌ 첫 번째를 메인 이미지로 가정
    req.setMainFileSeq(req.getFileInfoList().get(0).getFileSeq());
  }return mapper.updateEvent(req);
}

삭제 직후에는 fileInfoList의 순서/내용이 흔들리면서

  • 썸네일의 fileSeq가 mainFileSeq로 들어가거나
  • 메인 삭제 후 남아있던 썸네일 fileSeq가 양쪽에 들어가며
  • thumbnailFileSeq == mainFileSeq 상태가 만들어짐

그 이후 “한쪽만 바꿔도 둘 다 바뀌는” 상태로 고착된다.

 

재현 시나리오

  1. 썸네일/메인 모두 업로드 → 정상
  2. 메인 이미지만 삭제하고 저장
  3. 서버 update 시 fileInfoList.get(0)이 썸네일을 가리킴
  4. mainFileSeq가 썸네일 seq로 덮임
  5. 다음부터는 어느 한쪽 수정 시도 시, 서버/프론트 로직 때문에 둘 다 같은 seq로 처리

 

해결 방향

사실 코드로 봤을 때 서로 다른 두개 파일을 업로드 하는 것을 고려해서 설계되지 않았던 것 같았다. 그래서 썸네일, 메인 이미지로 수정되는 순간 기존 로직들이 꼬이는 부분이 많았다. 이미 파일 업로드 부분에 대한 로직은 공통으로 쓰이는 영역이 많아서, 기존 로직을 건들지 않으면서 해결할 수 있는 방안으로 수정을 했다.

  1. 파일이 무엇인지(썸네일/메인) 구분자가 반드시 있어야 한다
  2. 변경 없는 데이터는 update 대상에서 제거해야 한다
  3. 서버는 “첫 번째 원소” 같은 위치 기반 가정을 하면 안 된다

1) 파일 구분자(imageCode) 추가

fileInfoList에 다음 중 하나를 포함시키도록 설계:

  • imageCode = THUMBNAIL
  • imageCode = MAIN

그리고 서버에서는 imageCode 기준으로 seq를 set 한다.

2) “실제 업로드된 파일만” 업데이트 대상으로 처리

fileFullPath가 null이면 “업로드로 생성된 결과가 아님” → skip

 

수정된 서버 로직

대략 아래와 같은 플로우이다.

public int updateEntity(UpdateRequest req) {
    List<FileRef> files = req.getFiles();
    if (files == null || files.isEmpty()) {
        return repository.update(req);
    }

    for (FileRef f : files) {
        // ✅ 변경(업로드)이 실제로 있었던 파일만 처리
        if (!f.isUploaded()) continue; // 예: fileFullPath != null 로 판단해도 됨 > 이 판단 조건 자체도 100% 마음에 들지는 않았다. 

        // ✅ 파일 메타 갱신(선택): useYn, audit 등
        fileRepo.markInUse(f.getFileSeq(), f.getFileDetailSeq(), req.getUpdaterId());

        // ✅ role 기준으로 해당 슬롯에만 반영 (순서/index 의존 X)
        if ("THUMBNAIL".equals(f.getRole())) {
            req.setThumbnailFileSeq(f.getFileSeq());
        } else if ("MAIN".equals(f.getRole())) {
            req.setMainFileSeq(f.getFileSeq());
        }
    }

    return repository.update(req);
}

class FileRef {
  Long fileSeq;
  Long fileDetailSeq;
  String role;        // "THUMBNAIL" | "MAIN" ...
  boolean uploaded;   // 이번 요청에서 실제 업로드로 생성된 결과인지
  boolean isUploaded() { return uploaded; }
}

 

 

배운 점

1) “index 기반으로 파일을 구분”하면 언젠가 터진다

  • 삭제/추가/정렬/필터가 들어가는 순간 index는 신뢰할 수 없다
  • 특히 파일 업로드같이 여러 컴포넌트가 생기는 부분을 loop로 처리할 때 위험이 커진다

2) 서버에서 “리스트 첫 번째를 대표값으로” 가정하면 데이터가 깨진다

  • 리스트의 순서는 UI/상태/삭제 로직에 따라 항상 바뀔 수 있다
  • “대표값”은 반드시 명시적인 키(imageCode) 로 결정해야 한다

3) 변경 없는 데이터는 payload에서 제거하거나, 서버에서 확실히 필터링해야 한다

  • “flag 없음” 상태로 서버에 들어오면
    • 응답 list 오염
    • update 조건 오판
    • 결과적으로 다른 필드까지 덮어쓰기

 

추가 개선 아이디어

버그는 수정됐지만 전반적인 플로우 자체가 마음에 들지는 았았다. 프론트엔드에서 dirty check 를 하는 부분과 백엔드에서 해당 플래그를 바탕으로 동작하는 부분이 매끄럽게 맞아 떨어지지 않았고 그 부분을 수정하려니 맞물려있는 동작들이 많아서 수정할 엄두가 안났다.

만약 이후 리팩토링해야 한다면, 아래 방향으로 리팩토링하지 않을까 싶다.

  • 프론트에서 애초에 dirty check를 해서 변경된 컴포넌트만 upload 호출
  • 서버 upload 응답은 “변경된 파일만” 내려주고, 변경 없음은 아예 리스트에 포함하지 않도록 정책
  • loop 으로 판단하고 파일 처리하는 로직 검토

 

728x90