단체 신청서 사용자 경험 개선하기

jinvicky·2025년 2월 22일

Intro


이번 2월은 단체 커미션 신청서 접수가 다른 때보다 많았는데, 1~2인 신청만 받다가 6명 이상 단체 신청서를 제출하니 계속 신청 실패 문의를 받았다.

이번 포스팅은 단체커미션 신청에도 거뜬히 버틸 수 있도록 개선한 후기를 적었다.

413 request entity too large

단체신청서는 기본적으로 이미지가 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

  • 프론트에서 생성한 uuid로 신청서 post api를 호출한다.
  • 신청서 post가 성공시 파일 업로드 post api를 호출한다.
  • 파일 업로드 성공/실패 여부와 관계없이 사용자는 완료 응답을 받는다.

    이미지가 일부 소실된 경우 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 확장자는 관리자 콘솔에서만 접근 및 다운로드가 가능하게 설계되어 있다.

  • 단체 신청서를 작성해도 사용자의 응답시간이 빨라졌다.
  • 사용자가 이미지 외의 파일을 업로드해도 OK.

Validation & Alert

그 다음 많았던 문의는 부족한 validation와 사용자 안내가 원인이었다.

예를 들면 통계적으로 리뷰는 100자 안팎이었기에 설계를 varchar(100)으로 했는데 최근에 한 신청자분이 627자 리뷰를 작성해주시면서 털려버렸다. (사랑합니다 💗)

  • Spring은 @Size 등의 validate 어노테이션으로 방지했다.
  • Next.js는 커스텀 ValidationUtil로 유효성 검사를 하고, 글자수 제한이 있는 항목들은 <LetterCount /> ui 컴포넌트를 추가한다.
  • 사전에 알아야 할 안내사항을 ui에 추가한다.

기존에는 router는 router끼리, component는 component끼리 폴더에서 묶어서 작업했는데,기능적으로 관련있는 컴포넌트들을 도메인별로 그룹핑하는 것으로 폴더 구조를 바꿨다. -> 파일 찾는 시간과 리팩토링 시간을 더 단축했다.

변경된 신청서 url

https://jinvicky.shop/commission/apply

Outro


사이트 이슈를 인내해주신 신청자분들께 감사드립니다:)

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

profile
개발, 그림, 기록

0개의 댓글