Refactor: 이미지 서비스 멀티 스레드 도입하여 최적화
This commit is contained in:
parent
0d7cf263fc
commit
db54b11a5f
@ -19,7 +19,7 @@ public class Image extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
@Id
|
@Id
|
||||||
@Column(name = "project_image_id", nullable = false)
|
@Column(name = "project_image_id", nullable = false)
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.SEQUENCE)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
package com.worlabel.domain.image.service;
|
||||||
|
|
||||||
|
import com.worlabel.domain.folder.entity.Folder;
|
||||||
|
import com.worlabel.domain.image.entity.Image;
|
||||||
|
import com.worlabel.domain.image.repository.ImageRepository;
|
||||||
|
import com.worlabel.global.exception.CustomException;
|
||||||
|
import com.worlabel.global.exception.ErrorCode;
|
||||||
|
import com.worlabel.global.service.S3UploadService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageAsyncService {
|
||||||
|
|
||||||
|
private final S3UploadService s3UploadService;
|
||||||
|
private final ImageRepository imageRepository;
|
||||||
|
private final ThreadPoolTaskExecutor imageUploadExecutor;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Async("imageUploadExecutor")
|
||||||
|
public CompletableFuture<Void> asyncImageUpload(final List<MultipartFile> imageList, final Folder folder, final Integer projectId) {
|
||||||
|
log.debug("현재 스레드 - {} 업로드 파일 개수 - {}, 현재 작업 큐 용량 - {}",
|
||||||
|
Thread.currentThread().getName(),
|
||||||
|
imageList.size(),
|
||||||
|
imageUploadExecutor.getThreadPoolExecutor().getQueue().size()); // 큐에 쌓인 작업 수 출력);
|
||||||
|
|
||||||
|
imageList.forEach(file -> {
|
||||||
|
try{
|
||||||
|
String extension = getExtension(file.getOriginalFilename());
|
||||||
|
String imageKey = s3UploadService.uploadImageFile(file, extension, projectId);
|
||||||
|
|
||||||
|
createImage(file, imageKey, folder);
|
||||||
|
}catch (Exception e){
|
||||||
|
log.error("이미지 업로드 실패: {}", file.getOriginalFilename(), e);
|
||||||
|
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("배치 처리 완료");
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createImage(MultipartFile file, String imageKey, Folder folder) {
|
||||||
|
try {
|
||||||
|
String name = file.getOriginalFilename();
|
||||||
|
String extension = getExtension(name);
|
||||||
|
Image image = Image.of(name, imageKey, extension, folder);
|
||||||
|
imageRepository.save(image);
|
||||||
|
}catch (Exception e){
|
||||||
|
log.debug("이미지 DB 저장 실패: ", e);
|
||||||
|
s3UploadService.deleteImageFromS3(imageKey);
|
||||||
|
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String fileName) {
|
||||||
|
return fileName.substring(fileName.lastIndexOf(".") + 1);
|
||||||
|
}
|
||||||
|
}
|
@ -26,13 +26,14 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -41,6 +42,7 @@ import java.util.List;
|
|||||||
public class ImageService {
|
public class ImageService {
|
||||||
|
|
||||||
private final ProjectRepository projectRepository;
|
private final ProjectRepository projectRepository;
|
||||||
|
private final ImageAsyncService imageAsyncService;
|
||||||
private final FolderRepository folderRepository;
|
private final FolderRepository folderRepository;
|
||||||
private final S3UploadService s3UploadService;
|
private final S3UploadService s3UploadService;
|
||||||
private final ImageRepository imageRepository;
|
private final ImageRepository imageRepository;
|
||||||
@ -51,25 +53,34 @@ public class ImageService {
|
|||||||
@CheckPrivilege(value = PrivilegeType.EDITOR)
|
@CheckPrivilege(value = PrivilegeType.EDITOR)
|
||||||
public void uploadImageList(final List<MultipartFile> imageList, final Integer folderId, final Integer projectId) {
|
public void uploadImageList(final List<MultipartFile> imageList, final Integer folderId, final Integer projectId) {
|
||||||
Folder folder = getOrCreateFolder(folderId, projectId);
|
Folder folder = getOrCreateFolder(folderId, projectId);
|
||||||
|
long prev = System.currentTimeMillis();
|
||||||
|
|
||||||
// 이미지 리스트를 순차적으로 처리 (향후 비동기/병렬 처리를 위해 분리된 메서드 호출)
|
// 동적 배치 크기 계산
|
||||||
imageList.forEach(file -> uploadAndSave(file, folder, projectId));
|
int totalImages = imageList.size();
|
||||||
}
|
int batchSize;
|
||||||
|
|
||||||
/**
|
if (totalImages <= 100) {
|
||||||
* 폴더 생성 또는 가져오기
|
batchSize = 25; // 작은 이미지 수는 작은 배치 크기
|
||||||
* 폴더 생성 작업이 있음으로 트랜잭션 보호
|
} else if (totalImages <= 3000) {
|
||||||
*/
|
batchSize = 50; // 중간 이미지 수는 중간 배치 크기
|
||||||
public Folder getOrCreateFolder(Integer folderId, Integer projectId) {
|
|
||||||
if (folderId != 0) {
|
|
||||||
return getFolder(folderId);
|
|
||||||
} else {
|
} else {
|
||||||
String currentDateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
batchSize = 100; // 큰 이미지 수는 큰 배치 크기
|
||||||
Project project = getProject(projectId);
|
|
||||||
Folder folder = Folder.of(currentDateTime, null, project);
|
|
||||||
folderRepository.save(folder); // 새로운 폴더를 저장
|
|
||||||
return folder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<CompletableFuture<Void>> futures = new ArrayList<>();
|
||||||
|
for (int i = 0; i < imageList.size(); i += batchSize) {
|
||||||
|
List<MultipartFile> batch = imageList.subList(i, Math.min(i + batchSize, imageList.size()));
|
||||||
|
|
||||||
|
CompletableFuture<Void> future = imageAsyncService.asyncImageUpload(batch, folder, projectId);
|
||||||
|
// 모든 비동기 작업이 완료될 때까지 기다림
|
||||||
|
futures.add(future);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 비동기 작업이 완료될 때까지 기다림
|
||||||
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||||
|
|
||||||
|
long after = System.currentTimeMillis();
|
||||||
|
log.debug("업로드 완료 - 경과시간 {}", ((double) after - prev) / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -243,6 +254,22 @@ public class ImageService {
|
|||||||
.orElseThrow(() -> new CustomException(ErrorCode.DATA_NOT_FOUND));
|
.orElseThrow(() -> new CustomException(ErrorCode.DATA_NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 생성 또는 가져오기
|
||||||
|
* 폴더 생성 작업이 있음으로 트랜잭션 보호
|
||||||
|
*/
|
||||||
|
public Folder getOrCreateFolder(Integer folderId, Integer projectId) {
|
||||||
|
if (folderId != 0) {
|
||||||
|
return getFolder(folderId);
|
||||||
|
} else {
|
||||||
|
String currentDateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
Project project = getProject(projectId);
|
||||||
|
Folder folder = Folder.of(currentDateTime, null, project);
|
||||||
|
folderRepository.save(folder); // 새로운 폴더를 저장
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 이미지 가져오면서 프로젝트 소속 여부를 확인
|
// 이미지 가져오면서 프로젝트 소속 여부를 확인
|
||||||
private Image getImageByIdAndFolderIdAndFolderProjectId(final Integer folderId, final Long imageId, final Integer projectId) {
|
private Image getImageByIdAndFolderIdAndFolderProjectId(final Integer folderId, final Long imageId, final Integer projectId) {
|
||||||
return imageRepository.findByIdAndFolderIdAndFolderProjectId(imageId, folderId, projectId)
|
return imageRepository.findByIdAndFolderIdAndFolderProjectId(imageId, folderId, projectId)
|
||||||
@ -256,7 +283,7 @@ public class ImageService {
|
|||||||
/**
|
/**
|
||||||
* MultipartFile 업로드 및 저장
|
* MultipartFile 업로드 및 저장
|
||||||
*/
|
*/
|
||||||
private void uploadAndSave(MultipartFile file, Folder folder, int projectId) {
|
public void uploadAndSave(MultipartFile file, Folder folder, int projectId) {
|
||||||
try {
|
try {
|
||||||
String key = uploadToS3(file, projectId);
|
String key = uploadToS3(file, projectId);
|
||||||
saveImage(file, key, folder);
|
saveImage(file, key, folder);
|
||||||
@ -274,7 +301,6 @@ public class ImageService {
|
|||||||
String key = uploadToS3(file, project.getId());
|
String key = uploadToS3(file, project.getId());
|
||||||
saveImage(file, key, folder);
|
saveImage(file, key, folder);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("파일 업로드 중 오류 발생", e);
|
|
||||||
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE, "이미지 업로드 중 오류 발생");
|
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE, "이미지 업로드 중 오류 발생");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -284,7 +310,7 @@ public class ImageService {
|
|||||||
*/
|
*/
|
||||||
public void saveImage(final MultipartFile file, final String key, final Folder folder) {
|
public void saveImage(final MultipartFile file, final String key, final Folder folder) {
|
||||||
try {
|
try {
|
||||||
Image image = createImage(file, key, folder);
|
Image image = createImage(file.getOriginalFilename(), key, folder);
|
||||||
imageRepository.save(image);
|
imageRepository.save(image);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("이미지 DB 저장 실패 원인: ", e);
|
log.error("이미지 DB 저장 실패 원인: ", e);
|
||||||
@ -298,7 +324,7 @@ public class ImageService {
|
|||||||
*/
|
*/
|
||||||
public void saveImage(final File file, final String key, final Folder folder) {
|
public void saveImage(final File file, final String key, final Folder folder) {
|
||||||
try {
|
try {
|
||||||
Image image = createImage(file, key, folder);
|
Image image = createImage(file.getName(), key, folder);
|
||||||
imageRepository.save(image);
|
imageRepository.save(image);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
s3UploadService.deleteImageFromS3(key);
|
s3UploadService.deleteImageFromS3(key);
|
||||||
@ -309,9 +335,10 @@ public class ImageService {
|
|||||||
/**
|
/**
|
||||||
* S3 파일 업로드
|
* S3 파일 업로드
|
||||||
*/
|
*/
|
||||||
private String uploadToS3(final MultipartFile file, final Integer projectId) {
|
public String uploadToS3(final MultipartFile file, final Integer projectId) {
|
||||||
String extension = getExtension(file.getOriginalFilename());
|
String extension = getExtension(file.getOriginalFilename());
|
||||||
return s3UploadService.uploadMultipartFile(file, extension, projectId);
|
// return s3UploadService.uploadImageFile(file, extension, projectId);
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -319,17 +346,12 @@ public class ImageService {
|
|||||||
*/
|
*/
|
||||||
private String uploadToS3(final File file, final Integer projectId) {
|
private String uploadToS3(final File file, final Integer projectId) {
|
||||||
String extension = getExtension(file.getName());
|
String extension = getExtension(file.getName());
|
||||||
return s3UploadService.uploadFile(file, extension, projectId);
|
return s3UploadService.uploadImageFile(file, extension, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Image createImage(MultipartFile file, String key, Folder folder) {
|
public Image createImage(String fileName, String key, Folder folder) {
|
||||||
String extension = getExtension(file.getOriginalFilename());
|
String extension = getExtension(fileName);
|
||||||
log.debug("이미지 업로드 이름 :{}",file.getOriginalFilename());
|
log.debug("이미지 업로드 이름 :{}", fileName);
|
||||||
return Image.of(file.getOriginalFilename(), key, extension, folder);
|
return Image.of(fileName, key, extension, folder);
|
||||||
}
|
|
||||||
|
|
||||||
public Image createImage(File file, String key, Folder folder) {
|
|
||||||
String extension = getExtension(file.getName());
|
|
||||||
return Image.of(file.getName(), key, extension, folder);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,26 @@
|
|||||||
package com.worlabel.global.config;
|
package com.worlabel.global.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@Configuration
|
@Configuration
|
||||||
public class AsyncConfig {
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean(name = "imageUploadExecutor")
|
||||||
|
public ThreadPoolTaskExecutor imageUploadExecutor(){
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
|
||||||
|
// 최적 스레드 수 = CPU 코어 수 * (1 + (I/O 작업 시간 / CPU 작업 시간))
|
||||||
|
executor.setCorePoolSize(5);
|
||||||
|
executor.setMaxPoolSize(5);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
|
||||||
|
executor.setThreadNamePrefix("imageUploadExecutor-");
|
||||||
|
executor.initialize();
|
||||||
|
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,20 +8,20 @@ import com.worlabel.global.exception.ErrorCode;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.MalformedURLException;
|
import java.net.MalformedURLException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
// TODO: 추후 비동기로 변경해야합니다.
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -60,26 +60,15 @@ public class S3UploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MultipartFile 업로드
|
* MultipartFile 업로드
|
||||||
*/
|
*/
|
||||||
public String uploadMultipartFile(final MultipartFile file, final String extension, final Integer projectId) {
|
public String uploadImageFile(final MultipartFile file, final String extension, final Integer projectId) {
|
||||||
try {
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
return uploadImageToS3(file.getInputStream(), extension, projectId);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.debug("MultipartFile 업로드 에러 발생 ", e);
|
|
||||||
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File 업로드
|
|
||||||
*/
|
|
||||||
public String uploadFile(final File file, final String extension, final Integer projectId) {
|
|
||||||
try (InputStream inputStream = new FileInputStream(file)) {
|
|
||||||
return uploadImageToS3(inputStream, extension, projectId);
|
return uploadImageToS3(inputStream, extension, projectId);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.debug("이미지 업로드에서 에러 발생 ", e);
|
log.debug("MultipartFile 업로드 에러 발생 {}",file.getOriginalFilename(), e);
|
||||||
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,22 +76,39 @@ public class S3UploadService {
|
|||||||
/**
|
/**
|
||||||
* AWS S3 이미지 업로드
|
* AWS S3 이미지 업로드
|
||||||
*/
|
*/
|
||||||
private String uploadImageToS3(final InputStream inputStream, final String extension, final Integer projectId) throws IOException {
|
|
||||||
|
public String uploadImageToS3(final InputStream inputStream, final String extension, final Integer projectId) throws IOException {
|
||||||
String s3Key = getS3FileName(projectId);
|
String s3Key = getS3FileName(projectId);
|
||||||
String s3FileName = s3Key + "." + extension;
|
String s3FileName = s3Key + "." + extension;
|
||||||
|
|
||||||
ObjectMetadata metadata = new ObjectMetadata(); // S3에 업로드할 파일의 메타데이터 설정
|
ObjectMetadata metadata = new ObjectMetadata(); // S3에 업로드할 파일의 메타데이터 설정
|
||||||
metadata.setContentType("image/" + extension); // 콘텐츠 타입 설정
|
metadata.setContentType("image/" + extension); // 콘텐츠 타입 설정
|
||||||
|
metadata.setContentLength(inputStream.available());
|
||||||
|
|
||||||
uploadToS3(inputStream, s3FileName, metadata);
|
PutObjectRequest putRequest = new PutObjectRequest(bucket, s3FileName, inputStream, metadata);
|
||||||
|
amazonS3.putObject(putRequest);
|
||||||
|
|
||||||
return url + "/" + s3Key;
|
return url + "/" + s3Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InputStream 사용 파일을 S3 업로드
|
* File 업로드
|
||||||
*/
|
*/
|
||||||
private void uploadToS3(InputStream inputStream, final String s3Key, final ObjectMetadata metadata) {
|
public String uploadImageFile(final File file, final String extension, final Integer projectId) {
|
||||||
|
try (InputStream inputStream = new FileInputStream(file)) {
|
||||||
|
// return uploadImageToS3(inputStream, extension, projectId);
|
||||||
|
return "";
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("이미지 업로드에서 에러 발생 ", e);
|
||||||
|
throw new CustomException(ErrorCode.FAIL_TO_CREATE_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InputStream 사용해서 파일을 S3 업로드
|
||||||
|
*/
|
||||||
|
public void uploadToS3(InputStream inputStream, final String s3Key, final ObjectMetadata metadata) {
|
||||||
try {
|
try {
|
||||||
byte[] bytes = IOUtils.toByteArray(inputStream);
|
byte[] bytes = IOUtils.toByteArray(inputStream);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user