Spring Boot - 파일 업로드 처리 (feat. Multi Threading)

Kyu0·2023년 1월 27일
1

Spring Boot

목록 보기
4/4

이번 게시물에서는 서버에 전송되는 파일을 업로드할 수 있는 기능을 만들고 업로드된 파일을 멀티 쓰레딩 개념을 이용해 효율적으로 서버 스토리지에 저장하는 코드까지 작성해보도록 하겠습니다.

개발 환경 💻

  • 운영체제 : macOS montery 12.4
  • Spring F/W
    • Spring Boot 2.7.5(Java)
    • Spring Boot Starter Security 2.7.5 (Spring Security 5.7.4)
    • Spring Boot Starter JPA 2.7.5
    • Lombok 1.8.24
    • 그 외 기타 dependency 는 생략
  • 데이터베이스 : MySQL
  • Java 11 (JDK : Amazon corretto)

파일 업로드 기능 구현 💻

테이블 설계 ✍🏻

우선, 업로드된 파일의 정보를 데이터베이스에 기록하기 위한 테이블을 다음과 같이 설계했습니다.

컬럼명설명타입
ID(PK)레코드를 식별하기 위한 키BIGINT
POST_ID(FK)파일이 첨부된 게시글의 IDBIGINT
ORIGINAL_NAME저장된 파일의 원본 이름VARCHAR(255)
SAVED_PATH파일의 저장 경로 (마지막 '/' 포함)VARCHAR(255)
SAVED_NAME저장된 파일의 이름VARCHAR(255)
EXTENSION_NAME파일의 확장자명 ('.' 포함)CHAR(8)
SIZE파일의 크기INT

ORIGINAL_NAME : 업로드한 파일은 서버 스토리지에 저장하는데, 이 때 파일 이름이 중복될 경우 제대로 저장이 되지 않습니다. 이를 해결하기 위해 파일의 이름을 무작위의 문자열로 변경해 저장하게 됩니다.
파일을 업로드한 유저는 자신이 예상한 파일 이름과 다른 이름을 보기 때문에 파일을 식별하기가 어려워집니다. 이를 해결하기 위해 유저가 보낸 파일의 이름을 저장하는 컬럼을 지정해줬습니다.

SAVED_PATH, SAVED_NAME, EXTENSION_NAME : 처음에는 서버 스토리지에 'file.jpg' 처럼 확장자명과 같이 저장했으나 이렇게 저장할 경우 서버에 있는 파일을 바로 실행할 수 있어 보안에 취약할 수 있다는 의견이 있어 파일 이름에 확장자명을 제외하고 확장자명은 데이터베이스에서 조회할 수 있도록 수정했습니다.


Entity 작성 ✍🏻

테이블 설계를 마치고 엔티티 클래스를 작성했습니다.

// Attachment.java
@Builder // Builder 패턴 추가
@AllArgsConstructor // Builder 패턴을 이용하기 위한 생성자 메소드 추가
@NoArgsConstructor // JPA 기본 요구사항인 빈 생성자 메소드 추가
@Getter
@Entity(name = "ATTACHMENT")
public class Attachment {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // Primary key
    
    @Embedded
    private FileName fileName;

    @Min(value = 1, message = "첨부 파일의 크기가 잘못되었습니다.")
    @Column(name = "SIZE")
    private long size; // 업로드된 파일의 크기

    @ManyToOne
    @JoinColumn(name = "POST_ID")
    private Post post; // 파일이 첨부된 게시글
}
// FileName.java
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Embeddable
public class FileName {

    @NotBlank(message = "원본 파일을 입력해주세요.")
    @Column(name = "ORIGINAL_NAME", nullable = false)
    private String originalName;

    @NotBlank(message = "업로드된 파일명을 입력해주세요.")
    @Column(name = "SAVED_NAME", nullable = false)
    private String savedName;

    @NotBlank(message = "업로드된 파일의 경로를 입력해주세요.")
    @Column(name = "SAVED_PATH", nullable = false)
    private String savedPath;

    @NotBlank(message = "파일의 확장자명을 입력해주세요.")
    @Column(name = "EXTENSION_NAME", columnDefinition = "CHAR(8)", nullable = false)
    private String extensionName;

    public String getSavedPathWithFileName() {
        return new StringBuilder()
            .append(savedPath)
            .append(savedName)
        .toString();
    }
}

getSavedPathWithFileName() 메소드와 같이 파일 이름과 관련된 작업을 처리하는 메소드를 추가로 정의해야 할 수도 있는데, 엔티티 클래스에 정의하게 되면 가독성이 떨어질 것이라고 판단했습니다.
따라서 파일 이름과 관련된 컬럼은 FileName 이라는 임베디드 클래스로 따로 분리해서 정의했습니다.


Rest Controller 클래스 작성 ✍🏻

// AttachmentApiController.java

@RestController
@RequiredArgsConstructor
public class AttachmentApiController {
	
    private final AttachmentService attachmentService;
    private final FileUploadService fileUploadService;

	@PostMapping("/api/attachments/{postId}")
    public ApiResult<?> save(@RequestParam("attachments") List<MultipartFile> attachments, @PathVariable("postId") Long postId) {
        List<File> uploadedFiles = new ArrayList<>();

        try {
            for (File file : fileUploadService.uploadFiles(attachments)) {
                uploadedFiles.add(file);
            }
            SaveRequest requestDto = new SaveRequest(postId, attachments, uploadedFiles, attachments.size());
            
            List<Long> savedFileIds = attachmentService.save(requestDto);
            SaveResponse responseDto = new SaveResponse(attachments.size(), (int)uploadedFiles.stream().filter(file -> file != null).count() , savedFileIds);

            return ApiUtils.success(responseDto);
        }
        catch (EntityNotFoundException e) {
            for (File uploadedFile : uploadedFiles) {
                if (uploadedFile != null && uploadedFile.exists()) {
                    uploadedFile.delete();
                }
            }
            log.error(e.getMessage());
            return ApiUtils.error(e, HttpStatus.BAD_REQUEST);
        }
    }
}

FileUploadService : 파일을 업로드하는 로직은 범용적이라고 생각해 AttachmentService 에서 책임을 맡지 않고 별도의 클래스가 맡아야 한다고 생각해 해당 클래스를 추가로 작성했습니다.
실제로 싱글 쓰레드 → 멀티 쓰레드 방식으로 리팩토링할 때 해당 작업에 집중할 수 있어 좋았습니다.

Save Request DTO : Service 레이어에 전달할 정보들을 저장하는 객체입니다. 첨부할 게시글의 PK, MultipartFile 객체 배열, 저장될 위치의 임시 파일 객체 배열, 반복문에서 사용할 업로드 요청 파일 수 를 전달해줬습니다.

Save Response DTO : 작업을 수행한 후 사용자에게 전달할 정보를 저장하는 객체입니다. 저는 업로드를 요청한 파일 수, 업로드에 성공한 파일 수, 업로드에 성공한 파일들의 데이터베이스의 PK 배열 을 반환하도록 했습니다.

catch : 예외가 발생했을 경우, 저장한 파일들이 쓸모없어지므로 삭제하는 로직도 추가해줬습니다.


FileUploadService 클래스 작성 ✍🏻

FileUploadService 클래스에서는 업로드 경로 지정, MultipartFile 객체를 실제 파일로 저장하는 작업 등의 파일 업로드와 관련된 모든 작업에 대한 책임을 맡는 것으로 설계했습니다.

@Log4j2
@PropertySource("file.properties")
@RequiredArgsConstructor
@Service
public class FileUploadService {
    
    @Value("${upload.location}")
    private String SAVED_PATH; // private final String SAVED_PATH = "upload";

    public List<File> uploadFiles(List<MultipartFile> files) {
        List<File> result = new ArrayList<>();

        files.forEach(file -> result.add(uploadFile(file)));
        
        return result;
    }

    public File uploadFile(MultipartFile file) {
        try {
            File tempFile = getTempFile(file);
            file.transferTo(Path.of(tempFile.getAbsolutePath()));
            return tempFile;
        }
        catch (IOException e) {
            log.error("파일을 업로드하는 도중 오류가 발생했습니다. 파일명 : {}\n{}", file.getOriginalFilename(), e.getMessage());
            return null;
        }
    }

	// MultipartFile.transferTo(File) 에 전달할 임시 파일 객체를 반환하는 메소드
    private File getTempFile(MultipartFile file) throws IOException {
        File folder = new File(getDailySavedPath());
        if (!folder.isDirectory()) {
            folder.mkdirs();
        }

        File tempFile = new File(
            new StringBuilder(getDailySavedPath())
                .append('/')
                .append(UUID.randomUUID())
            .toString()
        );

        while (tempFile.exists()) {
            tempFile = new File(
                new StringBuilder(getDailySavedPath())
                    .append('/')
                    .append(UUID.randomUUID())
                .toString()
            );
        }

        tempFile.createNewFile();
        return tempFile;
    }

	// 날짜별로 파일 업로드 위치 분리
    private String getDailySavedPath() {
        LocalDate ld = LocalDate.now();

        return new StringBuilder(SAVED_PATH)
            .append('/')
            .append(ld.getYear())
            .append('/')
            .append(ld.getMonthValue())
            .append('/')
            .append(ld.getDayOfMonth())
        .toString();
    }
}

AttachmentService 클래스 작성 ✍🏻

파일 업로드는 FileUploadService 에서 처리했으니 AttachmentService 클래스에서는 전달받은 정보를 기반으로 데이터베이스에 저장하는 코드를 작성했습니다.

@RequiredArgsConstructor
@Service
public class AttachmentService {
    
    private final AttachmentRepository attachmentRepository;
    private final PostRepository postRepository;

    @Transactional(rollbackFor = {EntityNotFoundException.class})
    public List<Long> save(@Valid SaveRequest requestDto) throws EntityNotFoundException{
        List<Long> result = new ArrayList<>(); // 저장된 Attachment 엔티티의 PK를 저장하는 List 객체

        Post post = postRepository.findById(requestDto.getPostId()).orElseThrow(() -> new EntityNotFoundException("해당 게시물을 찾을 수 없습니다."));

        for (int i = 0 ; i < requestDto.getSize() ; ++i) {
            MultipartFile origin = requestDto.getAttachments().get(i);
            File copy = requestDto.getUploadedFiles().get(i);

            if (origin == null || copy == null) {
                continue;
            }

            Attachment entity = Attachment.builder()
                .post(post)
                .size(origin.getSize())
                .fileName(FileName.builder()
                    .originalName(origin.getOriginalFilename())
                    .savedPath(FileUtils.getPathWithoutFileName(copy.getPath()))
                    .savedName(copy.getName())
                    .extensionName(FileUtils.getExtensionName(origin.getOriginalFilename()))
                .build())
            .build();

            result.add(attachmentRepository.save(entity).getId());
        }

        return result;
    }
}

파일 업로드 테스트 👷🏻‍♂️

Postman으로 테스트한 결과 파일이 정상적으로 업로드 되는 것을 확인할 수 있었습니다.


멀티 쓰레딩 적용 👏

이렇게 파일 업로드 기능을 만들고나니 파일을 업로드하는 과정에서 쓸데없이 과정을 기다리고 있다는 생각이 들어 자식 쓰레드를 생성해 파일을 저장하는 작업은 비동기로 수행해 작업 시간을 줄여야겠다는 생각을 했습니다.


ExecutorService Bean 등록 ✍🏻

@Configuration
public class AsyncConfig {

    @Bean
    public ExecutorService executorService () {
		return Executors.newCachedThreadPool();
    }
}

ExecutorService를 Bean으로 등록하기 위해 AsyncConfig 클래스를 작성했습니다. 비동기 작업을 진행할 때는 여기서 Bean 으로 등록된 Executor 를 주입받아 쓰레드가 수행하도록 하기 위함입니다.

또, Thread Pool 이란 여러 개의 쓰레드를 작업 준비 상태로 만들어 놓고 작업이 할당되면 유휴 상태의 쓰레드를 작업마다 배치하는 것을 말합니다.
쓰레드를 미리 만들어 놓는 이유는 생성, 삭제에 오버헤드가 크기 때문입니다. 쓰레드를 미리 만들어 놓고 대기시킴으로써 작업이 요청될 때마다 쓰레드를 생성하고 삭제할 필요가 없어져 프로그램의 성능이 향상되는 것입니다.

하지만 필요 이상으로 쓰레드를 만들어 대기시켜 놓는다면 컴퓨팅 자원이 그만큼 낭비되는 것이기 때문에 쓰레드 풀을 사용하는 작업을 모니터링 하시면서 설정값을 조정하는 것이 좋습니다.

저는 단일 파일 업로드에 걸리는 시간이 길지는 않기 때문에 사용된 쓰레드를 적극적으로 재활용하는 타입의 Cached Thread Pool 을 사용하기로 했습니다.


기존 코드 수정 🤔

이제 기존의 코드를 수정해 멀티 쓰레딩을 이용한 작업으로 변경해주도록 하겠습니다.

FileUploadService 수정

@Log4j2
@RequiredArgsConstructor
@Service
public class FileUploadService {
    
    @Value("${upload.location}")
    private String SAVED_PATH;

    private final ExecutorService executorService;

    public List<Future<File>> uploadFiles(List<MultipartFile> files) {
        List<Future<File>> result = new ArrayList<>();

        files.forEach(file -> result.add(executorService.submit(() -> uploadFile(file))));

        return result;
    }

    public File uploadFile(MultipartFile file) throws IOException {
        log.info("Thread Name : {}", Thread.currentThread().getName());
        log.info("Uploading : {}", file.getOriginalFilename());
        File copy = getTempFile();
        file.transferTo(Path.of(copy.getAbsolutePath()));
        log.info("Uploaded : {}", file.getOriginalFilename());
        
        return copy;
    }
    
    //... 생략
} 

AttachmentApiController 수정


@RestController
@RequiredArgsConstructor
public class AttachmentApiController {
	
    //... 생략

    @PostMapping("/api/attachments/{postId}")
    public ApiResult<?> save(@RequestParam("attachments") List<MultipartFile> attachments, @PathVariable("postId") Long postId) throws InterruptedException, ExecutionException {
        List<File> uploadedFiles = new ArrayList<>();

        try {
            for (Future<File> file : fileUploadService.uploadFiles(attachments)) {
                uploadedFiles.add(file.get());
            }
            SaveRequest requestDto = new SaveRequest(postId, attachments, uploadedFiles, attachments.size());
            
            List<Long> savedFileIds = attachmentService.save(requestDto);
            SaveResponse responseDto = new SaveResponse(attachments.size(), (int)uploadedFiles.stream().filter(file -> file != null).count() , savedFileIds);
            
            return ApiUtils.success(responseDto);
        }
        catch (EntityNotFoundException e) {
            for (File uploadedFile : uploadedFiles) {
                if (uploadedFile != null && uploadedFile.exists()) {
                    uploadedFile.delete();
                }
            }
            log.error(e.getMessage());
            return ApiUtils.error(e, HttpStatus.BAD_REQUEST);
        }
    }

파일 업로드 테스트 👷🏻‍♂️

포스트맨을 이용해 테스트한 결과, 쓰레드를 활용해서 파일을 잘 업로드한 것을 확인할 수 있었습니다.

업로드 작업 성능 변화는 파일 36개 업로드 기준, 멀티 쓰레딩 적용 전 : 평균 2000ms, 멀티 쓰레딩 적용 이후 : 평균 700ms 정도의 응답 시간이 걸렸습니다.


개발 도중 마주친 이슈 🤦🏻

실행 로직 변경

변경 전 :

변경 후 :

처음 코드를 작성할 때는 getTempFile() 메소드를 동시에 실행하면 혹시나 파일명이 겹쳐 예외를 발생할 것이라 판단해 getTempFile() 메소드 수행까지는 동기적으로 수행하고 MultipartFile.transferTo() 작업을 쓰레드를 이용해 수행하도록 작성했습니다.

하지만 예상과는 달리 성능 개선이 이뤄지지 않았고 getTempFile() 메소드의 수행 시간이 길다는 생각이 들어 해당 메소드까지 쓰레드 Task 영역에 포함하기로 결정했습니다.
업로드된 파일을 저장할 임시 파일의 이름은 UUID를 생성하여 정하게 되는데, 이 UUID 가 중복될 확률이 극히 낮아 동시에 생성해도 위험이 현저히 적으며, 해당 위험이 발생할 가능성보다 성능 개선의 이점이 더 가치 있다고 판단했기 때문입니다.
그 결과, 개선 전의 수행 시간보다 개선 후의 수행 시간이 1/3 수준으로 줄어들 수 있었습니다.


마무리

항상 느끼는 점이지만 다른 사람들이 쉽게 이해할 수 있는 글을 작성하기가 정말 어려운 것 같습니다 ㅎㅎ... 그래도 화이팅!

예제 프로젝트 깃허브 주소 : https://github.com/Kyu0/jungo/tree/1c116e8a32e6dfd1d70dee0f25eff354ded6ba1b

잘못된 내용이나 오타 지적 언제나 환영입니다.

profile
개발자

3개의 댓글

comment-user-thumbnail
2023년 5월 24일

안녕하세요 질문 하나 남겨도 될까요?
파일 업로드 할때 업로드되는 위치를 현재 서버가 아니라 별도의 스토리지 서버를 구축해서 올리고 싶은데 어떤 자료를 찾아봐야 하는지 알려주실수 있을까요?
나름 자료를 이것 저것 찾아봐도 다 현재 위치한 서버에만 올라가는 예제만 있네요.

1개의 답글
comment-user-thumbnail
2024년 2월 2일

좋은 정보 감사합니다! 파일 업로드 성능 튜닝중인데 도움 많이되었습니다. 개발환경도 올려주셔서 감사합니다!

답글 달기