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" }
그래서 서버에서 해야 할 일은 단순하다.
- multipart 파일 받기
- 이미지인지 검증
- 용량/해상도 제한
- 서버 저장
- 접근 가능한 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("\"", "\\\"");
}
}'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| 프로필 이미지 변경 최종 확정 구현: temp→final 이동 + 캐시 차감 + 롤백 설계 (0) | 2025.12.25 |
|---|---|
| 프로필 이미지 업로드 UX 개선: 비동기 리사이징 미리보기 + 최종 확정 시 캐시 차감 연계 (0) | 2025.12.24 |
| 카카오페이 API 결제 준비(READY) & 결제 승인(APPROVE) 구현 정리 (0) | 2025.12.22 |
| n8n으로 뉴스 요약 자동화 기능 만들기 (0) | 2025.12.20 |
| Generative AI 시대에서 Agentic Workflow 시대로 (0) | 2025.12.20 |