
안녕하세요 동재입니다.
이번에 인증 등록 API를 개발하면서 추가되는 요구사항을 만족시키다 보니 API 성능이 저하되었습니다. 평균 속도가 2000ms를 넘어가면서 성능 개선이 시급했습니다.
다양한 방법을 시도한 결과, Async 사용이 가장 효과적이었기에 문제의 원인 파악과 해결 과정을 기록하고자 합니다.
처음 한 일은 등록 API에서 어떤 부분이 느린지 파악하는 것이었습니다. 이를 위해 등록 API의 전체 로직을 살펴보았습니다:
- 데이터 검증
- 데이터(사진 제외) DB에 저장
- 사진(최대 5장) 웹서버에 저장
- 사진 주소 DB에 저장
- 사진 유해성 검증 (HTTP 연결, NCP 서비스 이용)
- .jpg -> .webp로 변환
- Object Storage에 업로드 (HTTP 연결, NCP 서비스 이용)
- Object Storage 주소 DB에 저장
- .jpg 파일 삭제
- Response 객체 반환
분석 결과 가장 큰 원인은 다수의 외부 컴포넌트와의 HTTP 연결이었습니다.
특히 5번과 7번 작업은 사진 개수에 따라 최대 10번까지도 요청이 발생했습니다. 초기 API는 모든 작업이 완료된 후에야 응답을 반환했기에, 모든 HTTP 연결이 완료될 때까지 기다려야 했습니다.

문제를 해결하기 위해 UI에서 필수적인 데이터를 제외한 로직을 비동기로 처리하기로 했습니다.
(그림 기준)
즉, 3번까지의 로직이 완료되면 즉시 응답을 반환하고, 5번부터 10번까지는 Async를 사용해 비동기로 처리했습니다.
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<?> createCert(@Validated @RequestPart(value = "data") CertCreate certCreate, @RequestPart(required = false) List<MultipartFile> photos) {
// 1. 사진 파일 Validation (최소 1개 이상 필수)
if (photos.isEmpty()) {
ErrorReturn(ResponseCode.PARAM_ERROR);
}
// 2. 인증 등록
Certification certification = certCommandService.create(certCreate);
// 3. 등록 사진 웹서버에 파일로 업로드
List<String> certPhotoList = photoService.createCertPhotoList(certification.getCertificationId(), photos);
// 4. 파일 주소 DB에 저장 및 인증 객체에 매핑.
certCommandService.setPhotos(certification.getCertificationId(), certPhotoList);
// 비동기
// 5. 유해성 검증 6. .webp 변환 7. Object Storage에 업로드 8. Object Storage 주소 DB에 저장 9. 웹서버 파일(.jpg) 삭제
certAsyncService.convertAndUpload(certification.getCertificationId());
// 10. Response 객체 반환
return SuccessReturn(CertResponse.from(certification));
}
코드를 보면 1, 2, 3, 4번을 진행한 후 Certification -> Response 객체로 변환 후 반환하는 걸 확인할 수 있습니다.
@Async
public void convertAndUpload(Integer certificationId) {
// 웹 서버에 저장된 파일 위치 조회
Certification certification = certQueryService.getOneById(certificationId);
List<String> photoList = certQueryService.getOneById(certificationId).getPhotos();
List<String> uploadedList = new ArrayList<>();
for (String url : photoList) {
String fileName = photoService.getFileNameFromURL(url); // ex) 1359_cert_1.png
// 5. 유해성 검증
boolean isCorrect = greenEyeService.isCorrect(url);
if (!isCorrect) certCommandService.changeIsCorrect(certificationId, false);
// 6. .jpg -> .webp로 변환
File convertedFile = photoService.convertToWebp(new File(DIR + fileName));
// 7. Object Storage에 업로드
String uploadedURL = photoService.upload(convertedFile, BucketName.CERTIFICATION);
uploadedList.add(uploadedURL);
}
// 8.Object Storage 주소 DB에 저장
certification.setPhotos(uploadedList);
certCommandService.save(certification);
// 9. 웹서버 파일(.jpg) 삭제
for (String url : photoList) {
String fileName = photoService.getFileNameFromURL(url); // ex) 1359_cert_1.pn
photoService.deleteFileByName(fileName);
}
}

기존에는 certAsyncService.convertAndUpload 서비스 즉 사진 처리 로직이 완료된 후 클라이언트에게 응답을 반환했습니다.
API 성능을 개선하기 위해 해당 서비스를 @Async를 사용하여 비동기로 설정해 Response 반환 후 동작하도록 변경했습니다.

기존에는 사진 파일 처리 로직이 완료된 후, Object Storage 주소를 데이터베이스에 저장하고 이를 클라이언트에 반환하는 방식이었습니다. 이 방식에서는 사진 파일의 유해성 검증과 .webp 변환 작업이 완료된 후, 최종 결과를 반환하기 때문에 전체 처리 시간이 길어지고, 사용자 응답 속도가 느려지는 문제가 발생했습니다.
하지만 모든 처리가 완료된 사진의 주소를 클라이언트가 받기 때문에 사용자는 그 즉시 사진파일을 눈으로 볼 수 있었습니다.

사진 파일 처리 로직을 비동기로 변경한 후, 기존처럼 Object Storage 주소를 즉시 반환할 수 없게 되었습니다.
따라서, 웹 서버 주소를 먼저 클라이언트에게 반환한 후, 사진 처리가 완료되면 결과를 데이터베이스에 반영해 추후 조회 시 Object Storage 주소를 제공하도록 변경했습니다.
하지만 이 로직 변경으로 예상치 못한 문제가 발생했습니다. 사용자가 등록한 사진이 다른 사용자에게 무조건 보인다는 점이었습니다.
저희는 Naver Cloud Platform의 GreenEye 서비스를 이용해 유해 사진을 판별하고 유해한 사진은 다른 사용자에게 보이지 않도록 처리하고 있었는데, 비동기 처리로 인해 유해한 사진도 일시적으로 다른 사용자에게 보일 수 있었습니다.
이를 해결하기 위해 Response 필드에 있는 isValid를 이용했습니다. 사진 처리 로직이 동작하기 전에는 isValid를 False로 설정해 모든 사용자에게 노출되지 않도록 하고, 사진 전처리 후 유해하지 않다고 판단되면 isValid 값을 True로 변경해 다른 사용자에게 노출되도록 했습니다.
// 3. 사진'.jpg'를 웹서버에 저장
service of List com.delgo.reward.service.PhotoService.uploadCertPhotos(int,List) took 0 ms
// 4. DB에 저장
service of Certification com.delgo.reward.service.CertService.create(CertRecord,List) took 87 ms
// 현 시점에서 클라이언트에 Rseponse에 반환
mongoService of Log com.delgo.reward.mongoService.LogService.createLog(String,String,String,ArrayMap,Map) took 3 ms
2023-10-19 10:22:14.297 INFO 12548 --- [nio-8080-exec-6] com.delgo.reward.comm.aop.LogAop :
[LogAop]
http method: POST
controller name: com.delgo.reward.controller.CertController
method name: createCert
response code: 200
response codeMsg: SUCCESS
response data: CertResDTO(certificationId=17461
2023-10-19 10:22:14.300 INFO 12548 --- [nio-8080-exec-6] c.d.r.comm.interceptor.LogInterceptor : /api/certification || Result : code = 200 msg = SUCCESS
Parameter : null
// 5. 업로드된 사진 파일의 유해성 검증 (HTTP Connection)
service of boolean com.delgo.reward.comm.ncp.greeneye.GreenEyeService.checkHarmfulPhoto(String) took 341 ms
Object 17461_cert_1.webp has been created.
// 6 .jpg -> .webp 변환 및 7. Obejct Storage Upload (HTTP Connection)
service of void com.delgo.reward.comm.ncp.storage.ObjectStorageService.uploadObjects(BucketName,String,String) took 90 ms
service of String com.delgo.reward.service.PhotoService.uploadCertPhotoWithWebp(String,File) took 868 ms
// 5. 업로드된 사진 파일의 유해성 검증 (HTTP Connection)
service of boolean com.delgo.reward.comm.ncp.greeneye.GreenEyeService.checkHarmfulPhoto(String) took 334 ms
Object 17461_cert_2.webp has been created.
// 6 .jpg -> .webp 변환 및 7. Obejct Storage Upload (HTTP Connection)
service of void com.delgo.reward.comm.ncp.storage.ObjectStorageService.uploadObjects(BucketName,String,String) took 35 ms
service of String com.delgo.reward.service.PhotoService.uploadCertPhotoWithWebp(String,File) took 851 ms
// 5. 업로드된 사진 파일의 유해성 검증 (HTTP Connection)
service of boolean com.delgo.reward.comm.ncp.greeneye.GreenEyeService.checkHarmfulPhoto(String) took 411 ms
Object 17461_cert_3.webp has been created.
// 6 .jpg -> .webp 변환 및 7. Obejct Storage Upload (HTTP Connection)
service of void com.delgo.reward.comm.ncp.storage.ObjectStorageService.uploadObjects(BucketName,String,String) took 52 ms
service of String com.delgo.reward.service.PhotoService.uploadCertPhotoWithWebp(String,File) took 786 ms
// 5. 업로드된 사진 파일의 유해성 검증 (HTTP Connection)
service of boolean com.delgo.reward.comm.ncp.greeneye.GreenEyeService.checkHarmfulPhoto(String) took 451 ms
Object 17461_cert_4.webp has been created.
// 6 .jpg -> .webp 변환 및 7. Obejct Storage Upload (HTTP Connection)
service of void com.delgo.reward.comm.ncp.storage.ObjectStorageService.uploadObjects(BucketName,String,String) took 23 ms
service of String com.delgo.reward.service.PhotoService.uploadCertPhotoWithWebp(String,File) took 716 ms
// Async 총 걸린 시간
service of void com.delgo.reward.comm.async.CertAsyncService.doSomething(Integer) took 4768 ms
로그 분석 결과, 사진의 유해성 검증과 .webp 파일 변환에서 많은 시간이 소요됨을 확인할 수 있었고, 이를 비동기로 처리함으로써 4768ms(약 4초)를 절약할 수 있었습니다.
해당 개선을 통해 전체 처리 시간을 크게 단축시키고, 사용자 경험(UX) 측면에서 중요한 향상을 가져올 수 있었습니다.
API 성능 튜닝이 필요할 경우 비동기를 사용해 Response 값을 조정하는 것이 도움이 된다는 것을 기억하는게 중요한 것 같습니다. 이 글이 유사한 문제를 겪는 분들께 도움이 되길 바랍니다.
긴 글 읽어주셔서 감사합니다.😄