목차

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

🗓️

시작에 앞서서..

이번에도 전편의 인기에 의해 불려나온 후속편이다. 지난번 아무것도 모르던 시절, 아파치의 commons-io 를 활용해 CommonsMultipartResolver 로 구현했다. 이번에는 스프링의 MultipartFile 를 이용해 파일 업로드 기능을 구현해보겠다 (현재 개인 프로젝트에 이 방식을 사용중) 아울러 김영한 강사님의 스프링 MVC 강의에 이같은 내용이 많기 때문에 이번에는 파일을 서빙하고 저장하고 관리되는 관점에서 글을 더 풀어보겠다.

이 글을 작성하는 시점에서 스프링6가 정식으로 나와있는데 아파치의 commons-io가 미지원된다고 한다.
어쩌면 미리 잘 선수친걸지도..(?)

글을 한편으로 마무리 하려니 도저히 짬이 안나서 업로드와 다운로드 두편으로 나눴다. 처음엔 파일을 업로드 하는 기능을 정리해봤다.

여기 나오는 코드가 어디 있냐 하면 여기 있다


업로드

파일 업로드는 스프링이나 DB의 역할보다는 파일을 다루는 그 자체가 거의 대부분의 일이다. 파일은 DB와 달리 말 그대로 파일시스템에 다이렉트로 꽂힌다. 때문에 OS레벨에서 RAID또는 분산파일시스템을 사용하지 않으면 고가용성을 떠나 파일 자체의 보존을 기대할 수 없다. 이는 오브젝트 스토리지 같은 가상 스토리지도 마찬가지다. 어플리케이션처럼 소스를 수정해 다운타임을 짧게 가져서 반영을 할 수 있는게 아니다. 스토리지 설계를 잘못하면 짧지않은 다운타임을 가지고 파일을 이리저리 옮겨야 할 수도 있다.

Models

서버로 업로드하는 파일정보를 저장하기 위한 테이블이다. 원래의 파일명과, 서버에 파일이 저장된 경로를 같이 보관한다. 파일 이름이 겹칠 수 있으므로 유니크한 키를 가지고 파일에 접근 할 수 있도록 한다.

CREATE TABLE `files`
(
    `ID`                  INT          NOT NULL AUTO_INCREMENT,
    `USER_ID`             INT          NOT NULL,
    `ORIGINAL_FILENAME`   VARCHAR(255) NULL,
    `ORIGINAL_EXTENSION`  VARCHAR(128) NULL,
    `MANAGEMENT_FILENAME` VARCHAR(255) NULL,
    `STORE_PATH_PREFIX`   TEXT         NULL,
    `SIZE`                INT          NULL,
    `CREATE_DATE`         DATETIME     NULL,
      `UPDATE_DATE`         DATETIME     NULL,
    `DELETED`             TINYINT      NULL,
    CONSTRAINT PK_files PRIMARY KEY (ID)
);

다음은 모델이다.

public class File {
    /* 파일을 구분 짓는 유니크 */
    private Long id;

    /**
     * 파일을 업로드 유저, 소유자.
     */
    private Long userId;

    /**
     * 사용자가 업로드 한 원래 파일 이름 <br />
     * {파일명}.{확장자}
     */
    private String originalFilename;

    /**
     * 원래 파일의 확장자 <br />
     * {확장자}
     */
    private String originalExtension;

    /**
     * 관리 파일 이름 <br />
     * {UUID}.{확장자}
     */
    private String managementFilename;

    /**
     * 저장 경로 <br />
     * {년}/{월}/{일}
     */
    private String storePathPrefix;

    /**
     * 파일 크기<br />
     * {byte}
     */
    private Long size;

    /**
     * 파일 생성(업로드) 일자
     */
    private LocalDateTime createDate;

    /**
     * 파일 레코드 수정 일자 <br />
     * ex) 파일 삭제
     */
    private LocalDateTime updateDate;

    /**
     * 파일 삭제 여부<br />
     * 이 삭제 여부를 가지고 배치를 통해 실제 파일 삭제.
     */
    private Boolean deleted;
}

원래의 파일 이름과 어플리케이션에서 관리하기 위한 유니크를 매핑하기 위한 테이블 구조는 일반적으로 파일 서빙 시스템에 많이 사용되고 있다. 나는 여기에 저장 경로를 이야기하는 storePathPrefix 를 붙여보고자 한다.

주석에 보면 {년}/{월}/{일} 로 경로를 구분해놨는데 실제로 저장할 때 이러한 규칙으로 저장하려고 한다. 파일 속성을 실제 경로에 반영하는 이유는 파일 관리의 용이성에 있다. 규칙 없는 게시판의 파일 업로드 특성상 과거로 갈 수록 중요하지 않은 더미 데이터가 될 수도 있다. 콜드 아카이빙 같이 상대적으로 접근이 자주 일어나지 않는 스토리지로 옮길 수 있다면 비용을 절감할 수 있을것이다. 이때 데이터를 특정하기 위해 DB에서 조회해서 스크립트를 통해서 옮기거나 이것을 위해 마이그레이션 어플리케이션을 따로 만드는것은 번거롭다. 서버 디렉토리를 봤을때 직관적으로 특정할 수 있다면 삽질하는 수고로움을 덜 수 있을 것이다.

Controller

아래는 사용자가 파일을 업로드 하기 위한 컨트롤러다. REST 컨트롤러다.

@RequestMapping("/api/v1/media")
public class FileRestControllerV1 {
    // ..
        private final FileService fileService;
    //..

        /**
     * 파일 업로드를 위한 컨트롤러.<br />
     * FileUploadDto 필드를 모두 만족해야 한다.<br />
     * ROLE_USER 권한 필수<br />
     *
     * @param uploadDto FileUploadDto
     * @param user
     * @return 업로드 성공 시 fileId가 포함된 File 반환
     */
    @PostMapping
    @ResponseBody
    @HasUserRole
    public ResponseEntity<?> uploadFile(@ModelAttribute FileUploadDto uploadDto,
                                        @AuthenticationPrincipal UserClaimDto user) {
        List<File> uploadedFiles;
        uploadDto.setUserId(user.getId());
        try {
            uploadedFiles = fileService.uploadFiles(uploadDto);
        } catch (Exception e) {
            ErrorDto errorDto = ErrorDto.builder()
                    .errorId(999)
                    .errorCode(MessageConstant.FILE_STORE_UPLOAD_ERROR_CODE)
                    .errorMessage(MessageConstant.FILE_STORE_UPLOAD_ERROR_MSG)
                    .build();
            return ResponseEntity.badRequest().body(errorDto);
        }
        return ResponseEntity.ok().body(uploadedFiles);
    }
    // 하략..
}

업로드 컨트롤러에는 별게 없다. 멀티파트로 받은 DTO를 fileService.uploadFiles()에 전달한다. 이제와서 보니 RAW 타입 ResponseEntity 왜 안고쳐져있지.. 아무튼 FileUploadDto 는 다음과 같다.

import org.springframework.web.multipart.MultipartFile;

public class FileUploadDto {
    /**
     * 파일을 업로드 유저, 소유자.
     */
    private Long userId;
    /**
     * Multipart 객체 목록
     */
    private List<MultipartFile> files;
    /**
     * 파일 만료 일자<br />
     * String으로 먼저 받아서 LocalDateTime으로 convert한다.
     */
    private String expireDate;
    /**
     * 파일 삭제 여부
     */
    private Boolean deleted;
}

중요한것은 파일 업로드 유저와 MultipartFile 리스트다. 나머지 맴버는 파일 관리를 위한 메타데이터다.

Services

서비스 코드는 파일의 실제 저장과 그에 따른 파일 정보를 DB에 저장하는 두가지 내용이 주다.

//FileService.java -> uploadFiles

//...
    private final FileRepository fileRepository;
    private final StorageManagement storageManagement;
//...

    @Override
    public List<File> uploadFiles(FileUploadDto attachDto) throws IOException {
        if (attachDto.getUserId() < 1) {
            return null;
        }
        try {
            userService.findById(attachDto.getUserId());
        } catch (Exception e) {
            return null;
        }
        List<File> uploadedFiles = new ArrayList<>();
        // 파일시스템에 파일 저장.
        List<FileStoreDto> attachFiles = storageManagement.storeFiles(attachDto.getFiles()); // (1)
        // DB에 파일 저장 정보 기록.
        for (FileStoreDto attachFile : attachFiles) {
            File uploadedFile = File.builder()
                    .userId(attachDto.getUserId())
                    .originalFilename(attachFile.getOriginalFilename())
                    .originalExtension(attachFile.getOriginalExtension())
                    .managementFilename(attachFile.getManagementFilename())
                    .storePathPrefix(attachFile.getStorePathPrefix())
                    .size(attachFile.getSize())
                    .deleted(false)
                    .expireDate(LocalDateTime.parse(attachDto.getExpireDate()))
                    .createDate(LocalDateTime.now())
                    .updateDate(LocalDateTime.now())
                    .build();
            uploadedFiles.add(uploadedFile);
            fileRepository.save(uploadedFile); // (2)
        }
        return uploadedFiles;
    }

FileStoreDTO에서 실제로 파일을 꺼내 서버에 저장하는 클래스로 이관환다. 여기는 중요한 목적이 몇가지 있는데. 첫번째는 파일을 적절한 경로로 분배해서 저장하는 전략, 두번재는 해당 경로를 DTO에 실어서 다시 반환해야 하는 점, 세번째는 파일 이름의 난수화 전략이다.

적절한 경로를 만드는 전략은 앞에서 설명했다. 만든 경로를 DTO에 실어서 반환해야 데이터베이스에 저장하고, 파일을 조회했을 때 실제 경로를 찾아 서빙할 수 있다. 파일 이름을 난수화 하는 이유는 같은 이름의 파일이 업로드 되면 덮어쓰기 때문이다.

(1) 에서 먼저 파일을 저장하고, 그에 반환되는 파일 경로, 파일 이름등의 정보로 (2) 에서 DB에 저장한다. 아래는 (1) 에 해당하는 코드.

// StorageManagement.java -> storeFiles -> storeFile

    /**
     * Multipart 목록으로 부터 파일 추출하여 저장하는 메소드 <br />
     *
     * @param files 저장하고자 하는 파일 목록.
     * @return 저장 성공 시 storePathPrefix가 포함된 FileStoreDto 목록 반환.
     * @throws IOException
     */
    public List<FileStoreDto> storeFiles(List<MultipartFile> files) throws IOException {
        List<FileStoreDto> storeFiles = new ArrayList<>();
        for (MultipartFile file : files) {
            if (!file.isEmpty()) {
                storeFiles.add(storeFile(file));
            }
        }
        return storeFiles;
    }

   /**
     * 파일을 실제로 저장하는 메소드 <br />
     *
     * @param file
     * @return 저장 성공 시 storePathPrefix가 포함된 FileStoreDto 반환.
     * @throws IOException
     */
    private FileStoreDto storeFile(MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            return null;
        }
        String originalFilename = file.getOriginalFilename(); 
        String managementFilename = createManagementFilename(originalFilename); 
        Path storeDirectoryPath = getUploadStoragePath();
        String storePathWithManagementFilename = getFileFullPath(storeDirectoryPath, managementFilename);
        // 사용자가 업로드한 원래 파일명에서 확장자를 분리하고 UUID 파일명을 생성.
        // 실제 저장할 경로 지정.
        FileStoreDto storeFile = FileStoreDto.builder()
                .originalFilename(originalFilename)
                .originalExtension(extractExtensionFromOriginalFile(originalFilename))
                .managementFilename(managementFilename)
                .storePathPrefix(getStoragePathPrefix().toString())
                .managementFilenameWithFullPath(storePathWithManagementFilename)
                .size(file.getSize())
                .build();
        // 지정된 경로에 디렉토리가 없으면 만드는 부분.
        File pathAsFile = new File(storeDirectoryPath.toString());
        if (!Files.exists(Paths.get(storeDirectoryPath.toString()))) {
            Files.createDirectories(storeDirectoryPath);
            //pathAsFile.mkdirs();
        }
        // 저장소에 파일 저장
        try {
            file.transferTo(new File(storeFile.getManagementFilenameWithFullPath()));
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        log.info(MessageConstant.STORE_SUCCESS_LOG, storePathWithManagementFilename, file.getSize());
        return storeFile;
    }

원래 파일 이름은 originalFilename으로, 관리하기 위한 파일 이름은 managementFilename으로 지정했다. 그리고 이것을 합쳐 실제 저장 경로인 storePathWithManagementFilename을 명시한다.

Repository

FileManagement 에서 파일 저장 후 경로와 관리하기 위한 파일 이름을 담은 정보를 토대로 데이터베이스에 저장한다. 이 글을 계획하기 시작할때쯤 JDBCTemplate였던 repository 소스가 정리하면서 JPA로 컨버전 되면서 사실 save()는 구현할게 없다.

@Repository
public interface FileRepository extends JpaRepository<File, Long> {

}

JDBCTemplate로 하면 이렇게 하면 된다.

@Repository
@RequiredArgsConstructor
public class JdbcTemplateFileRepository implements FileRepository {

    private final JdbcTemplate jdbcTemplate;
    private SimpleJdbcInsert jdbcInsert;

    @PostConstruct
    public void init() {
        jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("FILES").usingGeneratedKeyColumns("id");
    }
        @Override
    public File upload(File file) {
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("user_id", file.getUserId());
        parameters.put("original_filename", file.getOriginalFilename());
        parameters.put("original_extension", file.getOriginalExtension());
        parameters.put("management_filename", file.getManagementFilename());
        parameters.put("store_path_prefix", file.getStorePathPrefix());
        parameters.put("size", file.getSize());
        parameters.put("create_date", file.getCreateDate());
        parameters.put("update_date", file.getUpdateDate());
        parameters.put("deleted", file.getDeleted());
        parameters.put("expire_date", file.getExpireDate());
        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        file.setId(key.longValue());
        return file;
    }
    //.. 
}

MyBatis는 하기싫다 (?) 농담이고 MyBatis로 컨버전 하다가 집어치우고 JPA로 갔다.

아래는 FileService의 메소드 repository로 저정하는 (2)에서 사용하는 File 모델이다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "FILES")
public class File {
    /* 파일을 구분 짓는 유니크 */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 파일을 업로드 유저, 소유자.
     */
    private Long userId;

    /**
     * 사용자가 업로드 한 원래 파일 이름 <br />
     * {파일명}.{확장자}
     */
    private String originalFilename;

    /**
     * 원래 파일의 확장자 <br />
     * {확장자}
     */
    private String originalExtension;

    /**
     * 관리 파일 이름 <br />
     * {UUID}.{확장자}
     */
    private String managementFilename;

    /**
     * 저장 경로 <br />
     * {년}/{월}/{일}
     */
    private String storePathPrefix;

    /**
     * 파일 크기<br />
     * {byte}
     */
    private Long size;

    /**
     * 파일 생성(업로드) 일자
     */
    private LocalDateTime createDate;

    /**
     * 파일 레코드 수정 일자 <br />
     * ex) 파일 삭제
     */
    private LocalDateTime updateDate;

    /**
     * 파일 삭제 여부<br />
     * 이 삭제 여부를 가지고 배치를 통해 실제 파일 삭제.
     */
    private Boolean deleted;

    /**
     * 파일 만료 일자 <br />
     * 만료 일자를 가지고 배치를 통해 실제 파일 삭제.
     */
    private LocalDateTime expireDate;

많은 정보가 저장된다고 생각 할 수 있지만 꺼내서 만들거나 경로를 탐색 또는 유추하는것 보다 id로 한방에 찍어서 한방에 가져가는게 좋을것이다. 섬세하게 만들어서 저장만 잘 해놓으면 여기저기 쓰기엔 좋을 것.

REST로 업로드 하기

기능을 만들었으면 업로드를 해봐야겠다. http api 코드를 가지고 VSCode나 IntelliJ에서 테스트해볼 수 있다. 아래는 예시 코드다.

### 파일 업로드
POST http://localhost:8080/api/v1/media
Content-Type: multipart/form-data; boundary=PLATABOARDTEST
Authorization: Bearer ...

--PLATABOARDTEST
Content-Disposition: form-data; name="expireDate"

2022-08-28T01:26:46.653101
--PLATABOARDTEST
Content-Disposition: form-data; name="files"; filename="imageuploadtest.jpg"
Content-Type: image/jpeg

< ./IMG_5181.JPG
--PLATABOARDTEST
Content-Disposition: form-data; name="files"; filename="test2.xml"
Content-Type: application/xml

< ./checkstyle-google-4depth.xml
--PLATABOARDTEST
Content-Disposition: form-data; name="files"; filename="test3.xml"
Content-Type: application/xml

< ./checkstyle-google-4depth.xml
--PLATABOARDTEST--

multipart/from-data를 content-type으로 정하고 이와같이 붙여서 서버로 전송할 수 있다. 파일 외에도 다른 정보들도 포함해서 전송할 수 있다.

여기까지 업로드 기능이였고 다운로드 기능은 다음 이시간에…

는 언제 올릴 수 있을지 모르겠다.