
이번 2월은 단체 커미션 신청서 접수가 다른 때보다 많았는데, 1~2인 신청만 받다가 6명 이상 단체 신청서를 제출하니 계속 신청 실패 문의를 받았다.
이번 포스팅은 단체커미션 신청에도 거뜬히 버틸 수 있도록 개선한 후기를 적었다.
단체신청서는 기본적으로 이미지가 10장은 기본으로 들어간다. 이미지가 크고 화질이 높다면 body가 기본 허용용량을 넘어가 413 에러가 나면서 신청이 실패한다.
Front와 Back 모두 기본 용량말고 대용량을 고려해서 설정해야 한다.
Front
nginx.conf 설정에서 client_max_body_size 설정을 추가한다.
http블록에 추가하면 모든 location 블록, 전역에 추가된다.
http {
client_max_body_size 50M;
}
꼭 nginx 재시작 제대로 되었는지 확인
sudo nginx -t
sudo nginx -s reload
Back
spring:
servlet:
multipart:
enabled: true
max-file-size: 50MB
max-request-size: 50MB
스토리지로 cloudinary를 사용하고 있는데 1장당 업로드에 평균 4-5초가 소요된다. 2~4장 정도면 뭐 조금 걸리네~ 하고 넘어갈 수 있었는데, 10장의 이미지를 업로드하면 모든 이미지를 업로드하기까지 35초가 소요된다.
문제의 코드
@Transactional
public int postNewCmsApply(CmsApplyForm formVO) throws CommonException {
if (hasSameApply(formVO.getId())) throw new CommonException(ErrorCode.ALREADY_PROCESSED); // 중복 신청 방지
log.info("cmsApplyRegVO: {}", formVO);
ExceptionUtil.dataAffectedCheck(mapper.insertCommissionApply(formVO));
if (formVO.getFiles() == null) return 1;
for (MultipartFile mf : formVO.getFiles()) {
// 파일 업로드...
}
}
이 요청은 1개의 스레드를 사용해서 하나씩 업로드하고 다음 업로드를 시작하는 동기로 동작한다. 하지만 이걸 다 기다려줄 수 없어서 executorService를 사용한 비동기 구현으로 개선했다.
Before
for (MultipartFile mf : formVO.getFiles()) {
// 파일 업로드...
}
After
10개의 스레드를 설정해서 비동기 구현으로 변경한 결과 35초에서 5초로 이미지 업로드 시간을 효과적으로 줄일 수 있었다.
스레드 수를 늘리면 작업을 더 빨리 처리할 수 있지만 지나친 개수 늘리기를 지양하고, 평균 단체 이미지 업로드 장수인 10개로 설정했다.
public int insertNewApply (CmsApplyForm formVO) throws CommonException {
log.info("CmsApplyForm: {}", formVO);
return ExceptionUtil.dataAffectedCheck(mapper.insertCommissionApply(formVO));
}
@Transactional
public int insertApplyFile (CmsApplyFileForm fileForm) throws CommonException {
List<MultipartFile> fileList = fileForm.getFiles();
if (fileList == null || fileList.isEmpty()) return 0;
int threadPoolSize = Math.min(fileList.size(), 10);
ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
for (MultipartFile mf : fileList) {
if (mf == null || mf.isEmpty()) continue;
try {
CloudinaryResponse cloudResp = cloudinaryUtil.fileUpload(mf, "cms_apply");
log.info("CmsApplyFileForm - Cloudinary : {}", cloudResp);
ApplyFileVO applyFileVO = ApplyFileVO.builder()
.applyId(fileForm.getApplyId())
.resourceType(cloudResp.getResourceType())
.publicId(cloudResp.getPublicId())
.format(cloudResp.getFormat())
.build();
log.info("CmsApplyFileForm - 1 : {}", fileForm);
log.info("CmsApplyFileForm - ApplyFileVO : {}", applyFileVO);
ExceptionUtil.dataAffectedCheck(mapper.insertApplyFile(applyFileVO));
} catch (IOException e) {
log.error("Upload failed for file {}", mf.getOriginalFilename(), e);
throw new CommonException(ErrorCode.UPLOAD_FILE_FAILED);
}
}
executorService.shutdown();
return 1;
}
executorService는 병렬적인 요청 처리를 가능케 하는 자바 라이브러리다.
submit()으로 task를 할당하고 마지막에 shutdown()으로 종료한다.
이미지 업로드 시간을 평균 5초로 단축했지만, 이 5초조차도 사용자가 기다려야 할 필요가 있을까? 의문이 든다. 월요일마다 진행하는 기술 스터디에서 11번가에서 주문과 결제를 비동기 처리한다는 이야기에 영감을 얻어서 신청서 추가 api와 신청서 파일 업로드 api를 분리했다.
Front
이미지가 일부 소실된 경우 log를 바탕으로 신청자와 추가 협의를 하면 된다.
기존에도 신청서 외에도 추가 이미지 협의가 흔한 게 커미션이다.
setLoading(true);
const respJson = await insertNewApply(formData);
if (Number(respJson.status) === ResponseStatus.Success) {
if (form.files.length > 0) {
const fileListForm = new FormData();
fileListForm.append("applyId", uuid);
form.files.forEach((file) => {
fileListForm.append("files", file);
});
insertNewApplyFileList(fileListForm);
}
setLoading(false);
alert("신청이 완료되었습니다.");
// 폼을 초기화한다.
} else {
alert(respJson.data.message ? respJson.data.message : "요청 도중 오류가 발생했습니다. 재시도해주세요");
}
setLoading(false);
결과적으로 신청서 응답을 1초로, 파일업로드 요청 또한 병렬처리로 지연 시간을 1/5까지 단축했다.
그런데 또 커다란 허점을 발견한다. 단체 신청서는 40%의 확률로 정리된 pdf, excel 문서가 업로드되는데 현재 cloudinary 설정으로는 업로드된 pdf를 콘솔에서 다운받아도 열 수가 없었다!!!
공식 문서를 뒤져보니, 이미지가 아닌 raw 형식 파일 업로드는 public_id 뒤에 파일 확장자를 추가해야 한다.

이미지인 경우는 public_id에 확장자를 붙이지 않는다.
따라서 아래처럼 기존의 CloudinaryUtil을 개선했다. exe 확장자를 업로드하지 못하도록 하는 것 또한 보안상 중요했다.
public String getResourceType(MultipartFile mf) throws CommonException {
if (mf.getContentType() == null) {
String extension = getExtension(mf.getOriginalFilename());
if (extension.matches("jpg|jpeg|png|gif|bmp")) {
return "image";
} else if (extension.matches("exe")) {
throw new CommonException(ErrorCode.MEDIA_TYPE_NOT_SUPPORTED);
} else {
return "raw";
}
}
return mf.getContentType().startsWith("image/") ? "image" : "raw";
}
// 이미지와 raw를 구분하는 개선된 업로드
public CloudinaryResponse fileUpload(MultipartFile mf, String folderName) throws IOException, CommonException {
if(getResourceType(mf).equals("image")) {
return imageFileUpload(mf, folderName);
} else {
return rawFileUpload(mf, folderName);
}
}
public CloudinaryResponse imageFileUpload(MultipartFile mf, String folderName) throws IOException {
Map option = ObjectUtils.asMap(
"resource_type", "image",
"folder", folderName
);
return bytesUpload(mf.getBytes(), option);
}
// 오직 raw만 public_id에 확장자를 붙이는 것을 허용
public CloudinaryResponse rawFileUpload(MultipartFile mf, String folderName) throws IOException {
Map option = ObjectUtils.asMap(
"resource_type", "raw",
"folder", folderName,
"public_id", System.currentTimeMillis() + "." + getExtension(mf.getOriginalFilename()));
return bytesUpload(mf.getBytes(), option);
}
❗ cloudinary 정책상 pdf 확장자는 관리자 콘솔에서만 접근 및 다운로드가 가능하게 설계되어 있다.
그 다음 많았던 문의는 부족한 validation와 사용자 안내가 원인이었다.
예를 들면 통계적으로 리뷰는 100자 안팎이었기에 설계를 varchar(100)으로 했는데 최근에 한 신청자분이 627자 리뷰를 작성해주시면서 털려버렸다. (사랑합니다 💗)
@Size 등의 validate 어노테이션으로 방지했다. <LetterCount /> ui 컴포넌트를 추가한다. 기존에는 router는 router끼리, component는 component끼리 폴더에서 묶어서 작업했는데,기능적으로 관련있는 컴포넌트들을 도메인별로 그룹핑하는 것으로 폴더 구조를 바꿨다. -> 파일 찾는 시간과 리팩토링 시간을 더 단축했다.
변경된 신청서 url
사이트 이슈를 인내해주신 신청자분들께 감사드립니다:)
jinvicky's commission
💻 Front-Github: https://github.com/jinvicky/ktalk-review-front
💻 Back-Github: https://github.com/jinvicky/ktalk-review-back
🌟 Website: https://jinvicky.shop
jinvicky
Front-End, Back-End Developer / SD Illustrator
✉️ Email: jinvicky@naver.com
💻 Github: https://github.com/jinvicky
🍏 Blog: https://velog.io/@jinvicky/posts
🌟 Website: https://jinvicky.shop