Ich habe viel Zeit damit verbracht, dieses Problem zu beheben, und an dieser Stelle versuche ich festzustellen, ob es sich hierbei um ein Interaktionsproblem zwischen Safari + Netzwerk-/Serverumgebung und nicht um einen Fehler auf Anwendungsebene handelt.
Das Hochladen von Bildern funktioniert einwandfrei auf:
Chrome / Firefox (alle Plattformen)
Safari, wenn der Server auf meinem lokalen Windows 11-Laptop läuft
Bild-Upload schlägt fehl (beschädigtes Bild) auf:
Safari (macOS / iOS)
Wenn der Server auf AWS EC2 bereitgestellt wird
Das Bild ist bereits vor der Verarbeitung beschädigt, direkt am Spring Boot-Controller-Ebene
Die Beschädigung ist sichtbar als: Zufällige horizontale Linien Defekte JPEG-Struktur
Anderer Hex-/Binärinhalt im Vergleich zur Originaldatei
Was ich versucht habe
- Mehrere getestete Upload-Methoden
multipart/form-data
application/octet-stream
XMLHttpRequest
Rohdateiobjekt ohne Umbruch senden
Kein manueller Content-Type-Header
Keine Dateinamenmanipulation
Serverspezifikationen
- Getestet mit EC2 Instanzen: t2.micro, t3.xlarge
Gleiches Verhalten unabhängig von CPU/Speicher



@PostMapping(value = "/api/ckeditor/imageUpload", verbraucht = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE} )
@ResponseBody
public Map saveContentsImage(@RequestPart(value = "upload", erforderlich = true) MultipartFile uploadFile, HttpServletRequest req, HttpServletResponse httpsResponse) {
Code: Select all
// ────────────────────────────────
// 1️⃣ 기본 요청 정보 로깅
// ────────────────────────────────
String userAgent = req.getHeader("User-Agent");
String contentType = uploadFile.getContentType();
long fileSize = uploadFile.getSize();
log.info("=== [UPLOAD DEBUG] CKEditor Image Upload Request ===");
log.info("Request Method : {}", req.getMethod());
log.info("Client IP : {}", req.getRemoteAddr());
log.info("User-Agent : {}", userAgent);
log.info("Detected Browser : {}", detectBrowser(userAgent));
log.info("Content-Type (Part) : {}", contentType);
log.info("Content-Length (Hdr) : {}", req.getHeader("Content-Length"));
log.info("Multipart Size : {} bytes", fileSize);
log.info("=====================================================");
// Safari 업로드 이슈 발생 빈도가 높으므로 별도 디버그 경로에 저장
boolean isSafari = userAgent != null && userAgent.contains("Safari") && !userAgent.contains("Chrome");
// ================== [DEBUG] Save raw file at Controller level (User's requested method) ==================
try {
String projectRootPath = System.getProperty("user.dir");
java.io.File debugDir = new java.io.File(projectRootPath, "tmp_debug");
if (!debugDir.exists()) {
debugDir.mkdirs();
}
// Sanitize the original filename to prevent path traversal issues
String originalFilename = org.springframework.util.StringUtils.cleanPath(uploadFile.getOriginalFilename());
// Differentiate the filename to indicate it's from the controller
java.io.File rawDebugFile = new java.io.File(debugDir, "controller_raw_" + originalFilename);
log.info("CONTROLLER DEBUG: Attempting to copy uploaded file to: {}", rawDebugFile.getAbsolutePath());
// Use InputStream to copy the file, which does not move the original temp file.
try (java.io.InputStream in = uploadFile.getInputStream();
java.io.OutputStream out = new java.io.FileOutputStream(rawDebugFile)) {
in.transferTo(out);
}
log.info("CONTROLLER DEBUG: File successfully copied. Size: {} bytes", rawDebugFile.length());
} catch (Exception e) {
log.error("CONTROLLER DEBUG: Failed to copy debug file.", e);
}
// ================== [DEBUG] END ===================
Long sessionCustomerId = SessionUtils.getSessionCustomerId(req);
if (sessionCustomerId == null) {
Map errorResponse = new HashMap();
errorResponse.put("uploaded", 0);
errorResponse.put("error", Map.of("message", "세션이 만료되었거나 로그인 상태가 아닙니다."));
return errorResponse;
}
String customerId = sessionCustomerId.toString();
String imageUrl = postUtils.saveContentsImage(uploadFile, customerId);
Map response = new HashMap();
response.put("uploaded", 1);
response.put("fileName", uploadFile.getOriginalFilename());
response.put("url", imageUrl);
return response;
}
JS CODE
const data = new FormData();
Code: Select all
// *** CSRF 토큰을 FormData에 직접 추가 ***
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfParameterName = document.querySelector('meta[name="_csrf_parameter"]')?.getAttribute('content') || '_csrf';
if (csrfToken) {
console.log(csrfToken);
data.append(csrfParameterName, csrfToken);
}
// const safeFile = new File([finalBlob], finalFileName, { type: finalMimeType });
//const safeFile = new File([ab], finalFileName, { type: finalMimeType });
// ✅ 2) File() 감싸지 말고 그대로 append
//data.append('upload', finalBlob, finalFileName);
// 원본 File 그대로 append (Blob 래핑 ❌)
//data.append('upload', originalFile);
// 원본 파일의 내용을 기반으로 Content-Type이 'application/octet-stream'으로 지정된 새 File 객체를 생성합니다.
const octetStreamFile = new File([finalBlob], finalFileName, {
type: 'application/octet-stream'
});
console.log(`[UploadAdapter] Content-Type을 'application/octet-stream'으로 강제 변환하여 업로드를 시도합니다.`
);
// 새로 생성된 File 객체를 FormData에 추가합니다.
data.append('upload', octetStreamFile);
//const cleanBlob = new Blob([originalFile], { type: originalFile.type });
//data.set('upload', originalFile, toAsciiFilename(originalFile.name));
//data.set('upload', originalFile,toAsciiFilename(originalFile.name));
//data.append("upload", safeFile);
// === fetch 업로드 (대체) 시작 ===
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ckeditor/imageUpload", true);
xhr.withCredentials = true;
// Safari의 multipart/form-data 업로드 안정성을 높이기 위해 수동 boundary 지정 없이 자동 생성하게 둠
// Content-Type을 직접 지정하지 않음 — 브라우저가 자동으로 생성하도록
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round((event.loaded * 100) / event.total);
const progressBarFill = document.querySelector('.progressBarFill');
if (progressBarFill) {
progressBarFill.style.width = percentCompleted + '%';
}
const progressText = document.querySelector('.uploadingText');
if (progressText) {
progressText.textContent = `이미지 업로드 중... ${Math.round(event.loaded / 1024)}KB / ${Math.round(event.total / 1024)}KB (${percentCompleted}%)`;
}
}
};
Mobile version