🏠 Home

목차

스프링 부트에서 Multipart – 다운로드편

🗓️

1편의 업로드 기능을 구현했다면 2편에서는 다운로드를 위한 Controller 와 리소스를 가지고오는 부분에 대해서 설명하겠다.

전편에서 업로드를 구현하기 위해 Multipart 를 사용했다면 이번 편에서는 파일 식별자로 어떻게 파일을 특정하고 응답으로 파일을 내려줄 수 있는지 구현해본다.

다운로드

바이너리는 org.springframework.core.io.Resource 를 통해 응답을 내려주는것이 특징이다. 컨트롤러부터 보자. 컨트롤러에서 파일을 내려 줄 때 두가지 유형이 있다.

  1. 웹 브라우저에서 다운로드 속성을 갖는 응답
  2. 이미지 같이 바이너리를 바로 뿌려주는 형태

이 둘의 차이는 Content-Dispositionattachment로 지정함으로써 차이를 둘 수 있다. 자세한 구현은 다음과 같다.

웹 브라우저에서 다운로드 속성을 갖는 파일 서빙

Controller

@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/media")
public class FileController {
    private final FileService fileService;

        @GetMapping("/attach/{fileId}")
    public ResponseEntity<Resource> downloadFile(@PathVariable Long fileId) {
        if (fileId < 1) {
            return null;
        }
        FileDownloadDto fileDto;
        UrlResource resource;
        try {
            fileDto = fileService.findById(fileId);
            resource = new UrlResource("file:" + fileDto.getManagementFilenameWithFullPath());
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        } catch (IllegalArgumentException e) {
            throw new RuntimeException(e);
        }
        String encodedOriginalFileName = UriUtils.encode(fileDto.getOriginalFilename(), StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedOriginalFileName + "\"";
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }

}

우선 fieldId를 통해서 파일 식별자를 전달 받는다. 다른 특별한 부분은 없고 contentDisposition를 통해 Content-Disposition의 속성을 attachment 로 지정하고 있다. 실제 파일은 UrlResource를 통해 실제 파일을 가지고 온다.

파일식별자는 앞선 글에서 StoreManagement.storeFile() 에서 managementFilename 으로 받는 createManagementFilename() 에서 받아온 식별자다. 나는 UUID로 생성했다. 요즘은 길고 지나친 UUID 말고도 NanoID, ULID 같은 난수 생성알고리즘이 많으니 그것들을 사용해도 무방할 듯 한다.

// 파일 고유 식별을 위한 UUID 생성시

    /**
     * ManagementFilename 생성 <br />
     *
     * @param originalFilenameWithExtension 확장자가 포함된 원래 파일 이름
     * @return {UUID}.{확장자}
     */
    private String createManagementFilename(String originalFilenameWithExtension) {
        String uuid = UUID.randomUUID().toString();
        String ext = extractExtensionFromOriginalFile(originalFilenameWithExtension);
        return uuid + "." + ext;
    }

다음은 파일을 불러오는 fileService.findById() 부분에 대한 내용이다.

Service

// FileService >> findById()

    private final FileRepository fileRepository;
    private final StorageManagement storageManagement;
    private final PropertyEnvironment propertyEnvironment;

    @Override
    public FileDownloadDto findById(Long fileId) {
        Optional<File> findFile = fileRepository.findById(fileId);
        if (findFile.isEmpty()) {
            return null;
        }
        File file = findFile.get();
        if (file.getDeleted()) {
            throw new IllegalArgumentException("파일이 없습니다.");
        }
        FileDownloadDto fileDto = FileDownloadDto.fromFile(file);
        String managementFilename = fileDto.getManagementFilename();
        Path storagePath = Paths.get(propertyEnvironment.getAttachFileStoragePath(), fileDto.getStorePathPrefix());
        fileDto.setManagementFilenameWithFullPath(storageManagement.getFileFullPath(storagePath, managementFilename));

        return fileDto;
    }

식별자를 이용해 실제 파일이름과 파일이 저장된 경로를 가지고 온다. 필요한 정보들을 조합하여 Path 로 가지고 오면 된다.

PropertyEnvironment의 경우 스프링 부트의 properties 값을 가지고 오는 바인더다.

@Getter
@ConstructorBinding
@ConfigurationProperties(prefix = "plataboard.environment")
@RequiredArgsConstructor
public class PropertyEnvironment {

    private final String profile;
    private final String frontendAddress;
    private final String attachFileStoragePath;
}

보시다시피 직관적으로 plataboard.environment를 가지고 오는것을 알 수 있는데, 스프링부트에서 스테이징에 따라 프로파일을 구분할 수 있다는 이점으로 여기서는 로컬과 라이브 환경의 파일 저장 참조 경로를 구분할 수 있다.

# 로컬용
plataboard.environment.profile=local
plataboard.environment.frontend-address=http://localhost:3000
plataboard.environment.attach-file-storage-path=./plataboard_local/attach

# 라이브용
plataboard.environment.profile=production
plataboard.environment.frontend-address=${WEBBOARD_FRONTEND_ADDRESS}
plataboard.environment.attach-file-storage-path=${WEBBOARD_ATTACH_FILE_STORAGE_PATH}

이미지와 같은 바이너리 서빙

이미지 같은 바이너리는 Content-Disposition 없이 바로 UrlResource로 내려주면 된다. 단지 파일 다운로드와 다르게 해당 경로를 직접 요청하는 것으로 전달한다.

Controller

    @GetMapping("/image/{*managementFilenameWithStorePathPrefix}")
    @ResponseBody
    public Resource downloadImage(@PathVariable String managementFilenameWithStorePathPrefix) {
        String storeFullPathByFilename = fileService.getStoreFullPathByFilename(managementFilenameWithStorePathPrefix);
        if (storeFullPathByFilename == null) {
            return null;
        }
        try {
            return new UrlResource("file:" + storeFullPathByFilename);
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }
    }

다음은 fileService.getStoreFullPathByFilename() 를 호출하는 서비스 코드다

Service

    @Override
    public String getStoreFullPathByFilename(String managementFilenameWithStorePathPrefix) {
        return Paths.get(propertyEnvironment.getAttachFileStoragePath(),
                        managementFilenameWithStorePathPrefix)
                .toString();

    }

뭐라고 할 것도 없이 바로 Paths로 경로를 조립해서 내려주면 된다.


이렇게 Spring Multipart 를 이용한 기본적인 파일 업로드 다운로드 기능을 구현해봤다.

다음엔 리엑티브 웹소켓을 이용한 채팅에 관한 이야기를 좀 해보겠다. 이건 우여곡절이 많아서 할 말이 많을 것 같다.