개주 훈련일지/🏋️ 전집중 호흡 훈련

이미지 업로드 서블릿 만들기(JSP/Servlet + 자동 리사이즈 + 폴더 분기)

lshfood2 2025. 12. 24. 00:45
 

CKEditor5 커스텀 빌드 만들기(이미지 업로드 구현)

왜 커스텀 빌드를 선택했는가?CKEditor5는 CDN 방식으로도 사용할 수 있다.하지만 이미지 업로드를 서버(JSP/Servlet)와 연동하려고 하면CDN 방식은 생각보다 제약이 많다. CDN Super-build의 한계❌ 불필요

lshfood2.tistory.com

지난 포스팅에서 CKEditor 커스텀 빌드를 통해

간단한 이미지 업로드 기능 구현을 소개했다.

 

이번엔 프로젝트에 적용할 수 있도록

조금 더 실용적인 활용방안을 추가하였다.

(CKEditor5 커스텀 빌드 기반으로 구현)

 

[ 서블릿 개요 ] 

서블릿 하나로 뉴스 작성과 게시글 작성 

모두 이미지 업로드가 가능하도록 구현

(단 이미지가 저장되는 경로는 구분이 필요!)

 

주요 특징

  • 서블릿 하나로 간단하게 업로드 구현 가능
  • type 분기로 게시판/뉴스 저장 구조 분리 가능
  • 리사이즈 + 압축으로 성능/용량 안정화
[ContentImageUpload 전체 흐름 (CKEditor5 이미지 업로드 / type 분기 / 자동 리사이즈)]

0) 요청 개요
- CKEditor5(SimpleUploadAdapter)가 이미지를 선택하면 자동으로 POST 요청을 보냄
- 요청 URL 예시
  · /ContentImageUpload?type=board
  · /ContentImageUpload?type=news
- 파일은 multipart/form-data로 전송됨
- 파일 파트 이름(필드명)은 보통 "upload" 임

1) 파라미터(type) 검증
- request.getParameter("type")로 분기값 수신
- 허용 값: "board", "news"
- 그 외 값이면 즉시 실패 처리(400)
  → 목적: 폴더 경로 조작(../../ 같은) 공격 방지 + 저장 정책 통일

2) 업로드 파일(Part) 수신
- request.getPart("upload")로 파일 파트 받음
- null이거나 size=0이면 실패 처리
  → 목적: 빈 업로드/잘못된 요청 방어

3) 용량(Byte) 제한 체크 (전송 크기 제한)
- @MultipartConfig 설정:
  · maxFileSize     : 파일 1개의 최대 크기
  · maxRequestSize  : 요청 전체(파일+오버헤드)의 최대 크기
- 로직에서도 upload.getSize()로 2차 체크하여 사용자 친화적 에러 메시지 제공
  → 참고: 해상도(px) 제한은 MultipartConfig로 불가능(파일 내용을 읽어야 알 수 있음)

4) 1차 파일 타입 검증(MIME)
- upload.getContentType()이 "image/*"인지 확인
  → 목적: 이미지 외 파일(실행파일/스크립트 등) 업로드 방지(1차 필터)

5) 파일명/확장자 검증 (화이트리스트)
- upload.getSubmittedFileName()으로 원본 파일명 수신
- 경로가 섞여 들어오는 케이스(C:\fakepath\...) 방어 후 실제 파일명만 추출
- 확장자 추출 후 허용 목록(jpg/jpeg/png)만 통과
  → 목적: 위험 확장자 차단 + 저장 포맷 통일

6) 이미지 디코딩(진짜 이미지인지 검증)
- ImageIO.read(upload.getInputStream())로 이미지 디코딩 시도
- 반환값이 null이면 "이미지가 아님"으로 판단하고 실패 처리
  → 목적: 확장자만 이미지인 척 하는 파일 방어(2차 검증)

7) 픽셀 폭탄 방지(선택)
- width * height(총 픽셀 수)가 일정 기준(MAX_PIXELS) 초과면 실패 처리
  → 목적: 매우 큰 이미지 디코딩으로 인한 메모리/CPU 폭주 방지

8) 자동 리사이즈(해상도 제한)
- max(width, height)가 MAX_DIMENSION(예: 1920px) 초과면 축소
  · 비율 유지(scale)로 resizeWidth/resizeHeight 계산
  · Graphics2D로 고품질 리사이즈 수행
- PNG는 투명 채널이 있을 수 있어 alpha(투명도) 유지 여부를 고려
  → 목적: 업로드 이미지를 적정 크기로 통일(용량/성능/UX 개선)

9) 저장 경로 결정 (톰캣 웹루트 하위 저장 방식)
- relDir = "/upload/" + type (예: /upload/board)
- realDir = getServletContext().getRealPath(relDir)
  → Eclipse WTP 환경이면 실제 저장 위치는 보통:
    ...\.metadata\.plugins\...\wtpwebapps\프로젝트명\upload\board
- 폴더가 없으면 Files.createDirectories(...)로 생성

10) 저장 파일명 생성(충돌 방지)
- UUID 기반 파일명 생성 (원본명 그대로 저장 X)
  → 목적: 같은 이름 충돌 방지 + 보안(경로/스크립트/특수문자) 리스크 감소

11) 파일 저장(포맷별 저장 정책)
- jpg/jpeg: 품질(압축률) 조절 저장(용량 안정화)
- png: 기본 ImageIO 저장

12) 브라우저 접근 URL 생성
- fileUrl = contextPath + relDir + "/" + savedName
  예) /ANIMAle/upload/board/xxxxx.jpg
- 파일명 URL 인코딩 처리(특수문자/공백 대비) (선택)

13) CKEditor 규격 JSON 응답
- 성공: {"url":"<접근URL>"}
  → CKEditor가 자동으로 <img src="url"> 삽입
  → 브라우저가 해당 src로 GET 요청을 보내 이미지 렌더링
- 실패: {"error":{"message":"사유"}}
  → CKEditor가 업로드 실패 메시지로 표시

 


 

1. 왜 서블릿이 필요한가?

CKEditor5의 SimpleUploadAdapter는

이미지를 선택하면 자동으로 서버에 POST를 보내고,

서버가 아래 형태의 JSON을 반환하면

에디터가 자동으로 <img src="...">를 삽입한다.

 

성공 응답 예시

{ "url": "/프로젝트경로/upload/board/파일명.jpg" }

그래서 서버에서 해야 할 일은 단순하다.

  1. multipart 파일 받기
  2. 이미지인지 검증
  3. 용량/해상도 제한
  4. 서버 저장
  5. 접근 가능한 URL을 JSON으로 반환

2. 구현 목표

  • 에디터 본문 이미지 업로드 전용
  • 타입 분기(type=board | type=news)로 저장 폴더 분리
  • jpg/jpeg/png만 허용 (gif 제외)\
  • 용량 제한(5MB) + 픽셀 폭탄 방지
  • 자동 리사이즈(최대 1920px)
  • JPG는 품질(0.85)로 재압축해서 용량 안정화
  • CKEditor 규격 JSON 성공/실패 응답

3. 업로드 요청 흐름

CKEditor에서 이미지 업로드 시 아래처럼 호출된다.

  • POST /ContentImageUpload?type=board
  • multipart/form-data
  • 파일 필드명은 기본적으로 "upload"

서버는 저장 후

  • /upload/board/xxxx.jpg 같은 URL을 응답
  • CKEditor가 즉시 본문에 <img> 삽입

POINT 1) 서블릿 선언 + 업로드 제한 + 타입/확장자 정책

@WebServlet("/ContentImageUpload")
@MultipartConfig(
    maxFileSize = 5 * 1024 * 1024,      // 파일 1개 최대 5MB
    maxRequestSize = 7 * 1024 * 1024    // 요청 전체 최대 7MB (multipart 오버헤드 포함)
)
public class ContentImageUpload extends HttpServlet {

    // 업로드 허용 타입(저장 폴더 분기용)
    private static final Set<String> ALLOWED_TYPES =
        Collections.unmodifiableSet(new HashSet<>(Arrays.asList("board", "news")));

    // 허용 확장자(GIF 제외)
    private static final Set<String> ALLOWED_EXTENSION =
        Collections.unmodifiableSet(new HashSet<>(Arrays.asList("jpg", "jpeg", "png")));

    // 로직에서도 2차로 체크해 사용자 친화적인 메시지 출력
    private static final long MAX_BYTES = 5L * 1024 * 1024;

    // 자동 리사이즈 기준(가로/세로 중 큰 값)
    private static final int MAX_DIMENSION = 1920;

    // 디코딩 폭탄 방지(총 픽셀 수 제한)
    private static final long MAX_PIXELS = 10_000_000L;

    // JPG 압축 품질(0~1)
    private static final float JPEG_QUALITY = 0.85f;
}

-  체크 포인트

  • @MultipartConfig는 “용량(byte)” 제한만 가능
  • 해상도(px)는 파일 내용을 읽어야 알 수 있으니 로직에서 처리해야 함
  • type 파라미터는 저장 경로 조작 방지에도 효과 있음

POINT 2) 업로드 검증 + 디코딩 + 리사이즈

// 0) type 파라미터 검증(폴더 분기 + 디렉토리 조작 방지)
String type = request.getParameter("type");
if (type == null || !ALLOWED_TYPES.contains(type)) {
    writeCkError(response, "type 파라미터가 올바르지 않습니다. (board/news만 허용)");
    return;
}

// 1) CKEditor의 기본 파일 필드명: upload
Part upload = request.getPart("upload");
if (upload == null || upload.getSize() == 0) {
    writeCkError(response, "업로드된 파일이 없습니다.");
    return;
}

// 2) 용량 제한(로직 2차 체크)
if (upload.getSize() > MAX_BYTES) {
    int maxMB = (int) (MAX_BYTES / (1024 * 1024));
    writeCkError(response, "이미지 용량은 최대 " + maxMB + "MB 까지만 업로드할 수 있습니다.");
    return;
}

// 3) Content-Type 1차 검사(image/*)
String contentType = upload.getContentType();
if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
    writeCkError(response, "이미지 파일만 업로드할 수 있습니다.");
    return;
}

// 4) 파일명에서 확장자 추출 + 화이트리스트 검사
String fileName = upload.getSubmittedFileName();
String originalName = Paths.get(fileName).getFileName().toString(); // fakepath 방어
String extension = getExt(originalName);

if (extension.isEmpty() || !ALLOWED_EXTENSION.contains(extension)) {
    writeCkError(response, "허용되지 않은 확장자입니다. (jpg, jpeg, png만 지원)");
    return;
}

// 5) 이미지 디코딩(진짜 이미지인지 확인)
BufferedImage image = ImageIO.read(upload.getInputStream());
if (image == null) {
    writeCkError(response, "올바른 이미지 파일이 아닙니다.");
    return;
}

// 6) 픽셀 폭탄 방지
int width = image.getWidth();
int height = image.getHeight();
long pixels = (long) width * (long) height;
if (pixels > MAX_PIXELS) {
    writeCkError(response, "이미지가 너무 큽니다. (픽셀 수 제한 초과: " + width + "x" + height + ")");
    return;
}

// 7) 자동 리사이즈(최대 1920px)
int maxSide = Math.max(width, height);
if (maxSide > MAX_DIMENSION) {
    double scale = (double) MAX_DIMENSION / (double) maxSide;
    int resizeWidth = Math.max(1, (int) Math.round(width * scale));
    int resizeHeight = Math.max(1, (int) Math.round(height * scale));

    // PNG만 투명 유지
    boolean keepAlpha = extension.equals("png");
    image = resize(image, resizeWidth, resizeHeight, keepAlpha);
}

-  체크 포인트

  • ImageIO.read()가 진짜 이미지 검증 역할을 함
  • MAX_PIXELS로 메모리 폭탄 예방
  • resize()는 비율 유지로 “큰 이미지만” 줄임
  • PNG만 투명 채널을 유지함

POINT 3) 저장 + URL 반환 (CKEditor 규격)

// 저장 폴더: /upload/{type}
String relDir = "/upload/" + type;
String realDir = request.getServletContext().getRealPath(relDir);

Path saveDir = Paths.get(realDir);
Files.createDirectories(saveDir); // 폴더 없으면 생성

// 파일명은 UUID로 생성(충돌 방지 + 보안)
String savedName = UUID.randomUUID().toString().replace("-", "") + "." + extension;
File savedFile = saveDir.resolve(savedName).toFile();

// JPG는 품질 조절 저장, PNG는 기본 저장
if (extension.equals("jpg") || extension.equals("jpeg")) {
    BufferedImage rgb = toRgb(image);         // 알파 제거 + 흰 배경 처리
    writeJpeg(rgb, savedFile, JPEG_QUALITY);  // 압축률(품질) 조절 저장
} else {
    ImageIO.write(image, extension, savedFile);
}

// 브라우저 접근 URL 생성 → CKEditor가 이 src로 이미지를 보여줌
String fileUrl = request.getContextPath() + relDir + "/" + savedName;

// CKEditor 성공 응답(JSON)
response.getWriter().write("{\"url\":\"" + escapeJson(fileUrl) + "\"}");

-  체크 포인트

  • createDirectories() 덕분에 폴더가 없어도 자동 생성
  • UUID 저장명은 “원본명 충돌/보안” 문제를 없앰
  • 성공 JSON은 반드시 {"url":"..."} 형태여야 CKEditor가 자동 삽입함

[ 최종 코드 ]

아래 최종 코드는 “업로드가 된다”에서 끝나는 게 아니라,

실제 서비스에 가까운 안정성/일관성을 목표로 구성했다.

 

1) 업로드 대상 분리(게시글/뉴스)

type=board | type=news 파라미터로

폴더를 분기해 저장한다.


이렇게 하면 나중에 운영/정리할 때도

콘텐츠 종류별로 관리가 쉬워진다.

 

▼ 저장 경로 예시

  • /upload/board/UUID.jpg
  • /upload/news/UUID.png

2) “확장자만 이미지인 척” 방어

단순히 확장자(jpg/png)만 통과시키면 위험할 수 있다.
그래서 ImageIO.read()로 실제 이미지 디코딩이

되는지까지 확인하고, 안 되면 즉시 실패 처리하도록 했다.

 

3) 큰 이미지에 대한 서버 부하 방지(픽셀 폭탄 + 리사이즈)

이미지는 용량이 작아도 해상도가 큰 경우가 있다.
그래서 MAX_PIXELS로 “너무 큰 이미지” 자체를 차단하고,
그 다음에는 MAX_DIMENSION(1920) 기준으로 자동 리사이즈하여

본문 이미지의 크기/용량을 일정 수준으로 통일했다.

 

결과적으로

  • 업로드 속도 안정
  • 저장 용량 절감
  • 글 상세보기 렌더링도 가벼워짐

4) PNG 투명도 유지 / JPG 저장 안정화

  • PNG는 투명 배경이 깨지면 체감이 커서 alpha 유지 리사이즈
  • JPG는 alpha가 없으니, 알파가 섞인 이미지가 들어와도
    안전하게 저장되도록 toRgb()로 RGB 변환
    + 흰 배경 처리 후 저장한다.

5) CKEditor 규격 응답을 정확히 맞춤

CKEditor(SimpleUploadAdapter)는 응답 형식이 중요하다.

 

성공은 반드시 { "url": "..." } 형태로 내려줘야

에디터가 자동으로 <img>를 삽입한다.


실패는 { "error": { "message": "..." } } 규격으로 내려줘야

에러 메시지가 자연스럽게 표시된다.

[ContentImageUpload 최종 코드 요약]

- CKEditor5(SimpleUploadAdapter)가 이미지 선택 시 자동으로 POST 요청을 보낸다.
- 요청 예시:
  · /ContentImageUpload?type=board
  · /ContentImageUpload?type=news
- multipart/form-data로 전송되며, 파일 파트 이름은 기본적으로 "upload" 이다.

[서버에서 하는 일]
1) type(board/news) 검증 → 저장 폴더 분기 + 경로 조작 방어
2) 업로드 파트(upload) 수신 및 null/empty 체크
3) 용량(Byte) 제한 체크 (@MultipartConfig + 로직 2차 체크)
4) Content-Type(image/*) 1차 검증
5) 파일명/확장자 화이트리스트(jpg/jpeg/png) 검증 (GIF 제외)
6) ImageIO.read로 디코딩 → 진짜 이미지인지 2차 검증
7) 픽셀 수 제한(MAX_PIXELS)으로 디코딩 폭탄 방지
8) 최대 해상도(MAX_DIMENSION=1920) 초과 시 자동 리사이즈(비율 유지)
   - PNG는 투명(alpha) 유지
   - JPG는 투명 없으니 RGB로 처리(흰 배경)
9) /upload/{type} 아래에 UUID 파일명으로 저장
10) CKEditor 규격 JSON 응답:
    - 성공: {"url":"..."}
    - 실패: {"error":{"message":"..."}}
*/

@WebServlet("/ContentImageUpload")
@MultipartConfig(
    // 파일 1개 최대 크기
    maxFileSize = 5 * 1024 * 1024,   // 5MB
    // multipart 요청 전체 최대 크기(파일 + form-data 오버헤드 포함)
    maxRequestSize = 7 * 1024 * 1024 // 7MB
)
public class ContentImageUpload extends HttpServlet {
    private static final long serialVersionUID = 1L;

    // type 파라미터는 폴더 분기 + 경로 조작 방어 목적
    private static final Set<String> ALLOWED_TYPES =
            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList("board", "news")));

    // GIF는 애니메이션 보존/재인코딩 이슈가 있어 제외 (jpg/jpeg/png만)
    private static final Set<String> ALLOWED_EXTENSION =
            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList("jpg", "jpeg", "png")));

    // 로직 2차 용량 체크(사용자 친화적 메시지 목적)
    private static final long MAX_BYTES = 5L * 1024 * 1024; // 5MB

    // 자동 리사이즈 기준(가로/세로 중 큰 값)
    private static final int MAX_DIMENSION = 1920;

    // 디코딩 폭탄 방지(너무 큰 이미지 차단)
    private static final long MAX_PIXELS = 10_000_000L; // 1천만 픽셀

    // JPG 저장 품질(0~1). 값 낮출수록 용량↓ 품질↓
    private static final float JPEG_QUALITY = 0.85f;

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // CKEditor는 JSON 응답을 기대함
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=UTF-8");

        try {
            // 0) type 검증: board/news만 허용 (폴더 분기 + 디렉토리 조작 방지)
            String type = request.getParameter("type");
            if (type == null || !ALLOWED_TYPES.contains(type)) {
                writeCkError(response, "type 파라미터가 올바르지 않습니다. (board/news만 허용)");
                return;
            }

            // 1) CKEditor(SimpleUploadAdapter)의 기본 파일 필드명: "upload"
            Part uploadPart = request.getPart("upload");

            // 2) null / empty 체크
            if (uploadPart == null || uploadPart.getSize() == 0) {
                writeCkError(response, "업로드된 파일이 없습니다.");
                return;
            }

            // 3) 용량 제한(로직 2차 체크)
            // - @MultipartConfig에서 먼저 막히지만, 여기서는 메시지를 예쁘게 주기 위함
            if (uploadPart.getSize() > MAX_BYTES) {
                int maxMB = (int) (MAX_BYTES / (1024 * 1024)); // 5MB면 5
                writeCkError(response, "이미지 용량은 최대 " + maxMB + "MB 까지만 업로드할 수 있습니다.");
                return;
            }

            // 4) Content-Type 1차 체크
            // - 브라우저가 보내는 MIME 타입(예: image/png, image/jpeg)
            // - image/로 시작하지 않으면 이미지가 아니라고 보고 차단
            String contentType = uploadPart.getContentType();
            if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
                writeCkError(response, "이미지 파일만 업로드할 수 있습니다.");
                return;
            }

            // 5) 파일명/확장자 체크
            String submittedName = uploadPart.getSubmittedFileName();
            if (submittedName == null || submittedName.trim().isEmpty()) {
                writeCkError(response, "파일명이 올바르지 않습니다.");
                return;
            }

            // 브라우저가 "C:\\fakepath\\xxx.png" 같이 보내는 경우 방어:
            // - 경로를 제거하고 마지막 파일명만 추출
            String originalName = Paths.get(submittedName).getFileName().toString();

            // 확장자만 추출 후 화이트리스트 검사
            String extension = getExt(originalName);
            if (extension.isEmpty() || !ALLOWED_EXTENSION.contains(extension)) {
                writeCkError(response, "허용되지 않은 확장자입니다. (jpg, jpeg, png만 지원)");
                return;
            }

            // 6) 이미지 디코딩(2차 검증)
            // - 확장자만 이미지인 척하는 파일을 막기 위해 실제 디코딩 시도
            BufferedImage image = ImageIO.read(uploadPart.getInputStream());
            if (image == null) {
                writeCkError(response, "올바른 이미지 파일이 아닙니다.");
                return;
            }

            int width = image.getWidth();
            int height = image.getHeight();

            // 7) 픽셀 폭탄 방지
            long pixels = (long) width * (long) height;
            if (pixels > MAX_PIXELS) {
                writeCkError(response, "이미지가 너무 큽니다. (픽셀 수 제한 초과: " + width + "x" + height + ")");
                return;
            }

            // 8) 자동 리사이즈(최대 1920px)
            int maxSide = Math.max(width, height);
            if (maxSide > MAX_DIMENSION) {
                // 비율 유지 축소 스케일 계산
                double scale = (double) MAX_DIMENSION / (double) maxSide;
                int resizeWidth = Math.max(1, (int) Math.round(width * scale));
                int resizeHeight = Math.max(1, (int) Math.round(height * scale));

                // PNG만 투명도(alpha) 유지
                boolean keepAlpha = extension.equals("png");
                image = resize(image, resizeWidth, resizeHeight, keepAlpha);
            }

            // 9) 저장 폴더: /upload/{type}
            // - Eclipse WTP 환경에서는 실제 저장 위치가 wtpwebapps 아래로 잡힐 수 있음
            String relativeDir = "/upload/" + type; // URL/웹루트 기준 경로
            String realDir = request.getServletContext().getRealPath(relativeDir);
            if (realDir == null) {
                writeCkError(response, "서버 저장 경로를 찾을 수 없습니다. (getRealPath가 null)");
                return;
            }

            Path saveDirectory = Paths.get(realDir);
            Files.createDirectories(saveDirectory); // 폴더 없으면 생성(이미 있으면 통과)

            // 10) 파일명 UUID로 생성(충돌 방지 + 보안)
            String savedName = UUID.randomUUID().toString().replace("-", "") + "." + extension;
            File savedFile = saveDirectory.resolve(savedName).toFile();

            // 11) 파일 저장 정책
            // - JPG: 알파가 없으니 RGB로 변환 후 품질(압축률) 조절 저장
            // - PNG: 투명 유지 가능하므로 기본 ImageIO 저장
            if (extension.equals("jpg") || extension.equals("jpeg")) {
                BufferedImage rgb = toRgb(image);               // 알파 제거 + 흰 배경 처리
                writeJpeg(rgb, savedFile, JPEG_QUALITY);        // 품질 조절 저장
            } else {
                ImageIO.write(image, extension, savedFile);     // png 저장
            }

            // 12) 브라우저 접근 URL 생성
            // - /컨텍스트경로 + /upload/{type}/파일명
            // - CKEditor는 이 URL을 <img src="...">로 삽입함
            String fileUrl = request.getContextPath() + relativeDir + "/" + savedName;

            // 13) CKEditor 성공 JSON 응답
            response.getWriter().write("{\"url\":\"" + escapeJson(fileUrl) + "\"}");

        } catch (IllegalStateException e) {
            // MultipartConfig 용량 제한 초과 등(서블릿 컨테이너가 먼저 던지는 경우)
            writeCkError(response, "파일이 너무 큽니다. (업로드 용량 제한 초과)");
        } catch (Exception e) {
            e.printStackTrace();
            writeCkError(response, "업로드 중 오류가 발생했습니다.");
        }
    }

    /**
     * 파일명에서 확장자 추출 (소문자)
     */
    private String getExt(String filename) {
        int dot = filename.lastIndexOf('.');
        if (dot < 0 || dot == filename.length() - 1) return "";
        return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
    }

    /**
     * 이미지 리사이즈(비율 유지 계산은 호출부에서 수행)
     * - keepAlpha=true : PNG 투명 유지(ARGB)
     * - keepAlpha=false: JPG 등 알파 없는 타입(RGB) + 흰 배경 처리
     */
    private BufferedImage resize(BufferedImage src, int resizeWidth, int resizeHeight, boolean keepAlpha) {
        int imageType = keepAlpha ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
        BufferedImage dst = new BufferedImage(resizeWidth, resizeHeight, imageType);

        Graphics2D g = dst.createGraphics();

        // 고품질 리사이즈 힌트(계단현상/깨짐 최소화)
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
        g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        // 알파가 없는 경우 배경을 흰색으로 채워서 검은 배경(기본값) 방지
        if (!keepAlpha) {
            g.setColor(Color.WHITE);
            g.fillRect(0, 0, resizeWidth, resizeHeight);
        }

        // 원본을 목표 크기로 그려 넣기
        g.drawImage(src, 0, 0, resizeWidth, resizeHeight, null);
        g.dispose();
        return dst;
    }

    /**
     * JPG 저장용 RGB 변환
     * - 알파(투명도)가 있는 이미지(PNG 등)를 JPG로 저장하면 투명 영역이 검게 나올 수 있음
     * - 그래서 흰 배경을 먼저 깔고 RGB로 변환한다.
     */
    private BufferedImage toRgb(BufferedImage src) {
        if (src.getType() == BufferedImage.TYPE_INT_RGB) return src;

        BufferedImage rgb = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_RGB);
        Graphics2D g = rgb.createGraphics();

        // 투명 영역이 있다면 흰색 배경으로 처리
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, rgb.getWidth(), rgb.getHeight());

        g.drawImage(src, 0, 0, null);
        g.dispose();
        return rgb;
    }

    /**
     * JPG 저장(품질/압축률 조절)
     * - quality: 0~1 (낮을수록 용량↓, 품질↓)
     */
    private void writeJpeg(BufferedImage img, File outFile, float quality) throws IOException {
        ImageWriter writer = null;
        ImageOutputStream ios = null;

        try {
            writer = ImageIO.getImageWritersByFormatName("jpeg").next();
            ImageWriteParam param = writer.getDefaultWriteParam();

            // 압축률(품질) 지정 가능하면 적용
            if (param.canWriteCompressed()) {
                param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
                param.setCompressionQuality(quality);
            }

            ios = ImageIO.createImageOutputStream(outFile);
            writer.setOutput(ios);
            writer.write(null, new IIOImage(img, null, null), param);

        } finally {
            if (ios != null) ios.close();
            if (writer != null) writer.dispose();
        }
    }

    /**
     * CKEditor 실패 응답 규격
     * - {"error":{"message":"..."}}
     */
    private void writeCkError(HttpServletResponse response, String message) throws IOException {
        response.setStatus(400);
        response.getWriter().write("{\"error\":{\"message\":\"" + escapeJson(message) + "\"}}");
    }

    /**
     * JSON 문자열 이스케이프(최소한)
     * - 역슬래시/따옴표 처리
     */
    private String escapeJson(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }
}