본문 바로가기

스프링

[Spring] 파일 및 이미지 업로드

시작하기에 앞서

이번에는 Spring Boot, Spring MVC, Spring Data JPA, Thymeleaf를 활용하여 파일 및 이미지 업로드를 구현해보았다.

파일 및 이미지 업로드를 구현하는데 있어서 다음과 같은 요구사항을 설정하였다.

  • 복수개의 이미지를 업로드 할 수 있으며 이미지는 다운로드 형식이 아니라 직접 출력한다.
  • 복수개의 파일을 업로드 할 수 있으며 파일은 다운로드 형식으로 진행한다.

기존에 만들었던 게시판 프로젝트에 이어 구현하였으며 프로젝트의 클래스 다이어그램은 다음과 같다.

  • 학생 클래스 - 한 학생은 여러 개의 게시물을 작성할 수 있다.
  • 게시물 클래스 - 게시물은 여러 개의 첨부파일과 댓글을 가질 수 있다.
  • 댓글 클래스 - 댓글은 여러 개의 답글을 가질 수 있다.
  • 첨부파일 클래스

엔티티

이번 포스트는 파일 및 이미지 업로드에 초점을 둔 포스트이기에 이 부분에 대해서 집중적으로 다루려고한다.

먼저, 파일에 대한 클래스는 다음과 같이 구성했다.

@Getter
@Setter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SequenceGenerator(
        name="ATTACHMENT_SEQ_GENERATOR",
        sequenceName = "ATTACHMENT_SEQ"
)
public class Attachment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String originFilename;
    private String storeFilename;
    @Enumerated(EnumType.STRING)
    private AttachmentType attachmentType;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;

    @Builder
    public Attachment(Long id, String originFileName, String storePath, AttachmentType attachmentType) {
        this.id = id;
        this.originFilename = originFileName;
        this.storeFilename = storePath;
        this.attachmentType = attachmentType;
    }
}
  • id(PK)는 Sequence 전략을 사용하여 Attachment를 위한 Generator를 따로 생성하여 이를 만들어주는 방식을 이용했다.
  • 업로드된 파일의 이름이 겹칠 수 있기 때문에 파일의 원본 이름(originFilename)파일을 저장한 이름(storeFilename)을 따로 설정하였다.
  • 요구사항에 따르면, 출력을 위한 이미지에 대한 파일과 일반적인 파일을 받을 수 있으므로 추후 출력과 다운로드를 위해 구분할 수 있는 Enum타입의 클래스를 만들어 이를 적용해주었다.
  • Board와의 연관 관계 매핑을 설정해주었다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    ...

    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
    private List<Attachment> attachedFiles = new ArrayList<>();

    ...
}
  • 첨부파일은 게시판의 온전한 소유물이다.
  • 게시판과의 생명주기가 동일하기때문에 Cascade를 통해 생명주기를 동일하게 관리해준다.

위에서 설정한 AttachmentType은 다음과 같다.

public enum AttachmentType {
    IMAGE, GENERAL
}
  • 이미지 파일을 위한 IMAGE
  • 일반적인 파일을 위한 GENERAL

Repository

Repository는 Spring Data JPA를 적극 활용하여 구성하였다.

public interface AttachmentRepository extends JpaRepository<Attachment, Long> {
}

FileStore Class

파일을 저장하는데 있어서 특화된 메소드들을 따로 클래스로 구성하여 이를 분리해두었다.

1. 파일 경로

@Value("${file.dir}/")
private String fileDirPath;
  • properties에 파일을 실제로 저장해둘 경로를 지정하여 저장해두었다.

2. 확장자 추출

private String extractExt(String originalFilename) {
    int idx = originalFilename.lastIndexOf(".");
    String ext = originalFilename.substring(idx);
    return ext;
}
  • 파일을 저장하는데 있어서 파일의 확장자를 붙여 저장하면 파일을 구분하는데 도움이 되기 떄문에 파일의 확장자를 추출하는 메소드를 따로 구성하였다.

3. 저장할 파일 이름 구성

private String createStoreFilename(String originalFilename) {
    String uuid = UUID.randomUUID().toString();
    String ext = extractExt(originalFilename);
    String storeFilename = uuid + ext;

    return storeFilename;
}
  • 위에서 언급했듯이, 파일의 이름은 겹칠 수 있기에 파일의 이름이 겹치지 않게 UUID를 통해 설정하는 부분을 구현하였다.
  • 확장자를 추출하는 메소드를 통해 확장자를 추출하여 UUID 뒤에 붙여 출력하는 것을 구현하였다.

4. 파일 경로 구성

public String createPath(String storeFilename, AttachmentType attachmentType) {
    String viaPath = (attachmentType == AttachmentType.IMAGE) ? "images/" : "generals/";
    return fileDirPath+viaPath+storeFilename;
}
  • 파일을 실제로 저장하기 위해 경로를 구성하는 부분이다.
  • 파일을 저장하는데 있어 이미지 파일을 저장할 경로와 일반적인 파일을 저장할 경로를 구분하기 위해 AttachmentType을 파라미터로 입력받아 파일의 경로를 설정해주었다.

5. 파일 저장 로직

public Attachment storeFile(MultipartFile multipartFile, AttachmentType attachmentType) throws IOException {
    if (multipartFile.isEmpty()) {
        return null;
    }

    String originalFilename = multipartFile.getOriginalFilename();
    String storeFilename = createStoreFilename(originalFilename);
    multipartFile.transferTo(new File(createPath(storeFilename, attachmentType)));

    return Attachment.builder()
            .originFileName(originalFilename)
            .storePath(storeFilename)
            .attachmentType(attachmentType)
            .build();

}
  • 파일을 실제로 저장하는 부분이다.
  • MultipartFile의 getOriginalFilename() 메소드를 활용하여 이름을 추출하고 저장할 파일이름을 위의 메소들을 통해 구성한다.
  • 위의 메소드들을 통해 파일을 실제로 저장할 경로와 파일의 이름을 조합하여 파일을 만들어 이를 저장한다.
  • 추후, 파일을 불러오기 위해 원본 파일의 이름과 저장 파일의 이름과 타입을 통해 Attachment를 구성하여 반환한다.

6. 전체 파일 저장

public List<Attachment> storeFiles(List<MultipartFile> multipartFiles, AttachmentType attachmentType) throws IOException {
    List<Attachment> attachments = new ArrayList<>();
    for (MultipartFile multipartFile : multipartFiles) {
        if (!multipartFile.isEmpty()) {
            attachments.add(storeFile(multipartFile, attachmentType));
        }
    }

    return attachments;
}
  • 입력받은 수 많은 MultipartFile들에 대해 storeFile 메소드를 수행하면서 파일 저장을 진행한다.
  • 반환받은 Attachment를 List로 구성하여 반환한다.

전체 코드

@Component
public class FileStore {

    @Value("${file.dir}/")
    private String fileDirPath;

    public List<Attachment> storeFiles(List<MultipartFile> multipartFiles, AttachmentType attachmentType) throws IOException {
        List<Attachment> attachments = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                attachments.add(storeFile(multipartFile, attachmentType));
            }
        }

        return attachments;
    }

    public Attachment storeFile(MultipartFile multipartFile, AttachmentType attachmentType) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }

        String originalFilename = multipartFile.getOriginalFilename();
        String storeFilename = createStoreFilename(originalFilename);
        multipartFile.transferTo(new File(createPath(storeFilename, attachmentType)));

        return Attachment.builder()
                .originFileName(originalFilename)
                .storePath(storeFilename)
                .attachmentType(attachmentType)
                .build();

    }

    public String createPath(String storeFilename, AttachmentType attachmentType) {
        String viaPath = (attachmentType == AttachmentType.IMAGE) ? "images/" : "generals/";
        return fileDirPath+viaPath+storeFilename;
    }

    private String createStoreFilename(String originalFilename) {
        String uuid = UUID.randomUUID().toString();
        String ext = extractExt(originalFilename);
        String storeFilename = uuid + ext;

        return storeFilename;
    }

    private String extractExt(String originalFilename) {
        int idx = originalFilename.lastIndexOf(".");
        String ext = originalFilename.substring(idx);
        return ext;
    }

}

Service

Service에 대해서 객체지향의 이점을 살리기 위해 Interface로 구현하여 유연성을 확보하였다.

1. 파일 저장

private final AttachmentRepository attachmentRepository;
private final FileStore fileStore;
  • 실제 비지니스 로직을 구성하기 위해 Repository와 이전에 구현한 FileStore를 주입받는다.
public List<Attachment> saveAttachments(Map<AttachmentType, List<MultipartFile>> multipartFileListMap) throws IOException{
    List<Attachment> imageFiles = fileStore.storeFiles(multipartFileListMap.get(AttachmentType.IMAGE), AttachmentType.IMAGE);
    List<Attachment> generalFiles = fileStore.storeFiles(multipartFileListMap.get(AttachmentType.GENERAL), AttachmentType.GENERAL);
    List<Attachment> result = Stream.of(imageFiles, generalFiles)
            .flatMap(f -> f.stream())
            .collect(Collectors.toList());

    return result;
}
  • 첨부파일을 저장하는 메소드이다.
  • FileStore를 통해 이미지 파일과 일반 파일을 업로드 및 저장한다.
  • 업로드 및 저장 처리가 완료된 첨부파일들을 넘겨준다.
public Board post(BoardPostDto boardPostDto) throws IOException {
    List<Attachment> attachments = attachmentService.saveAttachments(boardPostDto.getAttachmentFiles());
    for (Attachment attachment : attachments) {
        log.info(attachment.getOriginFilename());
    }
    Board board = boardPostDto.createBoard();
    attachments.stream()
            .forEach(attachment -> board.setAttachment(attachment));

    return boardRepository.save(board);
}
  • 게시물을 작성할 때 첨부파일을 첨부하여 작성하는 형식으로 진행하기에 위에서 언급했듯이, Board의 Cascade 속성을 통해 저장할 것이다.
  • attachmentService를 주입받아 실제로 저장하는 비지니스 로직을 실행하고 반환된 결과를 게시물(Board)에 실어 저장해준다.
  • Cascade.ALL안에 포함되어 있는 Cascade.Persist를 통해 같이 영속화되어 저장된다.

여기서 사용한 Form과 Dto은 다음과 같다.

  • Form - 뷰(Form)으로부터 컨트롤러로 데이터를 받아오기 위해 사용
  • Dto - 계층 간 데이터의 전달을 위해 사용
@Data
@NoArgsConstructor
public class BoardAddForm {
    @NotBlank
    private String title;
    @NotBlank
    private String content;
    private List<MultipartFile> imageFiles;
    private List<MultipartFile> generalFiles;

    @Builder
    public BoardAddForm(String title, String content, List<MultipartFile> imageFiles, List<MultipartFile> generalFiles) {
        this.title = title;
        this.content = content;
        this.imageFiles = (imageFiles != null) ? imageFiles : new ArrayList<>();
        this.generalFiles = (generalFiles != null) ? generalFiles : new ArrayList<>();
    }


    public BoardPostDto createBoardPostDto(Student student) {
        Map<AttachmentType, List<MultipartFile>> attachments = getAttachmentTypeListMap();
        return BoardPostDto.builder()
                .title(title)
                .writer(student)
                .content(content)
                .attachmentFiles(attachments)
                .build();
    }

    private Map<AttachmentType, List<MultipartFile>> getAttachmentTypeListMap() {
        Map<AttachmentType, List<MultipartFile>> attachments = new ConcurrentHashMap<>();
        attachments.put(AttachmentType.IMAGE, imageFiles);
        attachments.put(AttachmentType.GENERAL, generalFiles);
        return attachments;
    }
}
  • 게시글을 작성할 때, 제목(Title)과 내용(Content)을 폼에 입력한다.
  • 다중의 이미지 파일과 다중의 일반 파일을 업로드한다.
  • Form에서 Submit할 때, 이를 multipart/form-data형식으로 전송하여 이를 Form 객체를 통해 받아온다.
  • createBoardPostDto 메소드를 통해 Form 객체를 Dto로 변환한다.
@Data
@NoArgsConstructor
public class BoardPostDto {
    private Student writer;
    @NotBlank
    private String title;
    @NotBlank
    private String content;
    private Map<AttachmentType, List<MultipartFile>> attachmentFiles = new ConcurrentHashMap<>();

    @Builder
    public BoardPostDto(Student writer, String title, String content, Map<AttachmentType, List<MultipartFile>> attachmentFiles) {
        this.writer = writer;
        this.title = title;
        this.content = content;
        this.attachmentFiles = attachmentFiles;
    }

    public Board createBoard() {
        return Board.builder()
                    .writer(writer)
                    .title(title)
                    .writeTime(LocalDateTime.now())
                    .content(content)
                    .attachedFiles(new ArrayList<>())
                    .isDeleted(false)
                    .hit(0)
                    .build();
    }
}
  • 첨부파일에 대해서는 Map으로 구성하여 키를 첨부파일 타입으로 구분하여 저장한다.
  • 변환된 Dto는 서비스 레이어로 전달한다.
  • Dto는 createBoard 메소드를 통해 Dto를 Board로 변환한다.

2. 파일 로드

public Map<AttachmentType, List<Attachment>> findAttachments() {
    List<Attachment> attachments = attachmentRepository.findAll();
    Map<AttachmentType, List<Attachment>> result = attachments.stream()
            .collect(Collectors.groupingBy(Attachment::getAttachmentType));

    return result;
}
  • Repository에서 모든 파일을 불러온다.
  • 첨부파일 타입에 따라 구분하여 맵에 저장한 뒤, 이를 반환한다.
  • 테스트 용도로 활용하였다. (실제로 파일을 끌어오는데는 양방향 매핑으로 설정되어 @EntityGraph로 끌어왔다.)

전체 코드

@Service
@RequiredArgsConstructor
public class AttachmentServiceImpl implements AttachmentService{

    private final AttachmentRepository attachmentRepository;
    private final FileStore fileStore;

    public List<Attachment> saveAttachments(Map<AttachmentType, List<MultipartFile>> multipartFileListMap) throws IOException{
        List<Attachment> imageFiles = fileStore.storeFiles(multipartFileListMap.get(AttachmentType.IMAGE), AttachmentType.IMAGE);
        List<Attachment> generalFiles = fileStore.storeFiles(multipartFileListMap.get(AttachmentType.GENERAL), AttachmentType.GENERAL);
        List<Attachment> result = Stream.of(imageFiles, generalFiles)
                .flatMap(f -> f.stream())
                .collect(Collectors.toList());

        return result;
    }

    public Map<AttachmentType, List<Attachment>> findAttachments() {
        List<Attachment> attachments = attachmentRepository.findAll();
        Map<AttachmentType, List<Attachment>> result = attachments.stream()
                .collect(Collectors.groupingBy(Attachment::getAttachmentType));

        return result;
    }
}

Controller

@PostMapping("/post")
public String doPost(@Login LoginForm loginForm, @Validated @ModelAttribute BoardAddForm boardAddForm,
                     BindingResult bindingResult) throws IOException {

    if (bindingResult.hasErrors()) {
        log.info("bindingResult : {}", bindingResult.getFieldError());
        return "doPost";
    }

    Student student = studentService.findStudent(loginForm.getEmail()).get();
    BoardPostDto boardPostDto = boardAddForm.createBoardPostDto(student);
    Board post = boardService.post(boardPostDto);
    return "redirect:/main/board/"+post.getId();
}
  • 입력받은 객체를 Dto로 변환한다.
  • BoardService를 통해 게시글을 실제로 저장한다.
  • 작성된 post로 Redirect하여 이동한다.
<div >
    <img th:each="imageFile : ${board.imageFiles}"
         th:if="${imageFile.attachmentType == T(register.demo.domain.file.AttachmentType).IMAGE}"
         th:src="@{/main/board/images/{imageFile}(imageFile=${imageFile.storeFilename})}" width="300" height="300" style="margin-right: 5px"/>
</div>
<div>
    <a th:each="generalFile : ${board.generalFiles}"
       th:if="${generalFile.attachmentType == T(register.demo.domain.file.AttachmentType).GENERAL}"
       th:href="@{/main/board/attaches/{generalFile}(generalFile=${generalFile.storeFilename}, originName=${generalFile.originFilename})}"
       th:text="${generalFile.originFilename}" style="margin-right: 5px"/><br/>
</div>
  • 업로드된 첨부파일은 다음과 같은 형태로 출력된다.
  • 이미지의 경우 img 태그를 통해 "/main/board/image/이미지저장된이름"을 호출하여 이미지를 불러온다.
  • 일반 파일의 경우 a 태그를 통해 "/main/board/attaches/파일저장된이름"을 호출하여 파일을 다운받을 수 있는 경로를 생성한다.
@ResponseBody
@GetMapping("/images/{filename}")
public Resource processImg(@PathVariable String filename) throws MalformedURLException {
    return new UrlResource("file:" + fileStore.createPath(filename, AttachmentType.IMAGE));
}
  • 파일이름을 통해 경로를 생성하여 UrlResource로 구성하여 이를 반환하여 이미지를 로드할 수 있게한다.
@GetMapping("/attaches/{filename}")
public ResponseEntity<Resource> processAttaches(@PathVariable String filename, @RequestParam String originName) throws MalformedURLException {
    UrlResource urlResource = new UrlResource("file:" + fileStore.createPath(filename, AttachmentType.GENERAL));

    String encodedUploadFileName = UriUtils.encode(originName, StandardCharsets.UTF_8);
    String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(urlResource);
}
  • 파일이름을 통해 경로를 생성하여 UrlResource로 구성한다.
  • 파일 다운로드의 경우 attachment를 헤더에 붙여주고 해당 파일 이름의 한글 깨짐 방지를 위해 UTF-8로 인코딩된 이름을 넣어 이를 반환한다.
  • 헤더 추가의 편리성을 위해 ResponseEntity를 활용하였다.

결과

게시글 작성

  • 제목과 내용을 작성하였다.
  • 이미지 한 개와 첨부파일 한 개를 첨부하였다.

게시글 작성 완료

  • 이미지파일은 실제로 출력한다.
  • 파일의 경우 다운로드 받을 수 있는 경로가 하이퍼링크로 연결되어있는 모습을 확인할 수 있다.