게시글 첨부파일 업로드, 다운로드

뚜우웅이·2023년 4월 17일
0

SpringBoot웹

목록 보기
14/23

첨부파일 업로드

서버에 파일이 올라갈 수 있게 static 폴더 하위에 files 디렉토리를 생성해줍니다.

file_data 테이블 생성

form.html에 파일 업로드를 위한 코드 추가

<form action="#" method="post" th:action="@{/board/form}" th:object="${board}" enctype="multipart/form-data">
        <input th:field="*{id}" type="hidden">
        <div class="form-group">
            <label for="title">제목</label>
            <input class="form-control" id="title"
                   placeholder="제목을 입력하세요." th:classappend="${#fields.hasErrors('title')} ? 'is-invalid'"
                   th:field="*{title}" type="text">
            <div class="invalid-feedback" th:errors="*{title}" th:if="${#fields.hasErrors('title')}">
                제목은 2글자 이상 50글자 이하로 작성해야 합니다.
            </div>
        </div>
        <div class="form-group">
            <label for="content">내용</label>
            <textarea class="form-control" id="content"
                      rows="10"
                      th:classappend="${#fields.hasErrors('content')} ? 'is-invalid'" th:field="*{content}"></textarea>
            <div class="invalid-feedback" th:errors="*{content}" th:if="${#fields.hasErrors('content')}">
                내용은 1글자 이상 15000글자 이하로 작성해야 합니다.
            </div>
            <!--            <p th:text="${#authentication.authorities.contains('ROLE_ADMIN')}"></p>-->
            <!--            <p th:text="${#authentication.authorities}"></p>-->
            <!--            <p th:text="${#authentication}"></p>-->
            <!--            <p sec:authorize="hasAuthority('ROLE_ADMIN')">관리자 권한이 있습니다.</p>-->

            <div class="form-group">
                <label for="file">파일</label>
                <input class="form-control-file" id="file" name="file" type="file" multiple>
            </div>
        </div>
        <div class="text-right">
            <!--            <button type="submit" class="btn btn-primary" th:if="${#authentication.name == board.user.username}">확인</button>-->
            <button class="btn btn-primary" type="submit">확인</button>
            <a class="btn btn-primary" th:href="@{/board/list}">취소</a>
            <button class="btn btn-primary" th:if="${board.title != null and (#authorization.expression('hasAuthority(''ROLE_ADMIN'')') or (board.user != null and
             #authentication.name == board.user.username))}" th:onclick="|deleteBoard(*{id})|"
                    type="button">삭제
            </button>
        </div>
    </form>

enctype="multipart/form-data"는 파일을 전송할 때 사용하는 인코딩 방식입니다.

file input태그에 multiple을 사용하여 Ctrl을 눌러 파일을 여러개 선택할 수 있게 합니다.

FileData Entity 생성

package com.project.myhome.model;

import jakarta.persistence.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "file_data")
public class FileData {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String filename;

    private Long filesize;

    private String filetype;

    private String filepath;

    @Column(name = "upload_date")
    private LocalDateTime uploadDate;

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

}

@ManyToOne(fetch = FetchType.LAZY) 사용 이유
FetchType.LAZY를 사용하면 엔티티의 연관 관계가 필요한 시점까지 데이터베이스에서 로딩되지 않습니다. 이는 불필요한 데이터베이스 쿼리를 줄이고, 애플리케이션의 성능을 개선할 수 있습니다.

Board Entity 수정

package com.project.myhome.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;


@Entity
@Data
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @NotNull
    @Size(min=2,max=50, message = "제목은 2자 이상 50자 이하입니다.")
    private String title;
    private String content;

    private LocalDateTime createdAt;

    @ManyToOne
    @JoinColumn(name ="user_id")
    @JsonIgnore
    private User user;

    @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<FileData> files = new ArrayList<>();
    public void addFile(FileData file) {
        files.add(file);
        file.setBoard(this);
    }

}

cascade = CascadeType.ALL과 orphanRemoval = true은 서로 다른 동작을 수행하므로, 상황에 따라 적절한 옵션을 선택하여 사용해야 합니다. 두 옵션을 모두 사용하면, 부모 엔티티에 대한 작업이 자식 엔티티에도 전파되고, 부모 엔티티를 삭제할 때 연관된 자식 엔티티도 같이 삭제됩니다.

BoardController 수정

@PostMapping("/form")
    public String form(@Valid Board board, BindingResult bindingResult , Authentication authentication,
                       @RequestParam("file") MultipartFile[] files) throws IOException {
        boardValidator.validate(board, bindingResult);
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
        String username = authentication.getName();
        for (MultipartFile file : files) {
            if (!file.isEmpty()) {
                String filename = file.getOriginalFilename();
                int filesize = (int) file.getSize();
                String filetype = file.getContentType();
                UUID uuid = UUID.randomUUID();
                String randomFileName = uuid + "_" + filename;
                Path path = Paths.get("src/main/resources/static/files/" + randomFileName);

                FileData newFile = new FileData();
                newFile.setFilename(filename);
                newFile.setFilesize(filesize);
                newFile.setFiletype(filetype);
                newFile.setFilepath("/files/" + randomFileName);
                newFile.setUploadDate(LocalDateTime.now());

                Files.copy(file.getInputStream(), path);

                board.addFile(newFile);
            }
        }
        boardService.save(username, board);
        return "redirect:/board/list";
    }

MultipartFile은
Spring 프레임워크에서 제공하는 인터페이스 중 하나로, HTTP 요청을 통해 업로드된 파일을 다루기 위한 기능을 제공합니다.
MultipartFile 인터페이스는 다양한 메서드를 제공하여, 업로드된 파일의 정보(파일 이름, 크기, MIME 타입 등)를 조회하거나, 파일을 저장하거나, InputStream 형태로 파일을 읽어올 수 있습니다.

UUID를 사용해 고유한 식별자를 생성해줍니다.
Files.copy를 이용해 업로드하는 파일을 static 폴더 밑에 있는 files 폴더에 저장해줍니다.

application.properties 수정

spring.servlet.multipart.max-file-size=10MB

Spring Boot에서는 application.properties 파일에서 spring.servlet.multipart.max-file-size 속성을 사용하여 파일의 최대 크기를 설정할 수 있습니다. 예를 들어, 다음과 같이 설정하면 파일의 최대 크기가 10MB로 늘어납니다.

파일 업로드시 static/files 디렉터리에 파일이 들어갑니다.

첨부파일 다운로드

FileRepository 생성

package com.project.myhome.repository;

import com.project.myhome.model.FileData;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface FileRepository extends JpaRepository<FileData, Long> {
    List<FileData> findByBoardId(Long boardId);
}

FileService 생성

package com.project.myhome.service;

import com.project.myhome.model.FileData;
import com.project.myhome.repository.FileRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class FileService {
    private final FileRepository fileRepository;

    public FileService(FileRepository fileRepository) {
        this.fileRepository = fileRepository;
    }

    public List<FileData> findByBoardId(Long boardId) {
        return fileRepository.findByBoardId(boardId);
    }

    public void deleteById(Long id) {
        fileRepository.deleteById(id);
    }

    public FileData findById(Long id) {
        return fileRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 파일이 없습니다. id=" + id));
    }
}

orElseThrow()Optional 객체에서 값을 추출하는 메서드 중 하나입니다. Optional 객체에 값이 있으면 해당 값을 반환하고, 값이 없으면 인수로 전달된 예외를 던집니다.

BoardController 수정

@GetMapping("/post")
    public String post(Model model, @RequestParam(required = false) Long id){
        Board board = boardRepository.findById(id).orElse(null);
        List<FileData> files = fileService.findByBoardId(id);
        model.addAttribute("board", board);
        model.addAttribute("files", files);
        return "board/post";
    }
    }

BoardApiController 수정

@GetMapping("/file/download/{id}")
    public ResponseEntity<Resource> downloadFile(@PathVariable Long id) throws MalformedURLException {
        FileData file = fileService.findById(id);
        Path path = Paths.get("src/main/resources/static" + file.getFilepath());
        Resource resource = new UrlResource(path.toUri());

        String encodedFilename = URLEncoder.encode(file.getFilename(), StandardCharsets.UTF_8)
                .replace("+", "%20");

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(file.getFiletype()))
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
                .body(resource);
    }

Path 객체
파일 및 디렉토리 경로를 나타내는 객체입니다. Java Path API는 파일 경로를 조작하는 다양한 방법을 제공합니다.

파일 다운로드 시에 저장되는 파일 이름이 제대로 저장되도록 UTF-8로 인코딩을 해준 뒤 띄어쓰기가 "+"로 변하는 오류를 수정하기 위해 +를 %20으로 변환해줍니다.

ResponseEntity.ok()는 HTTP 응답 코드 200(OK)와 함께 ResponseEntity 객체를 생성합니다.

contentType() 메서드는 HTTP 응답 본문의 MIME 타입을 설정합니다. file.getFiletype()은 파일 객체에서 MIME 타입을 가져와서 MediaType 객체로 변환한 것입니다.

header() 메서드는 HTTP 응답 헤더를 설정합니다. HttpHeaders.CONTENT_DISPOSITION는 첨부 파일의 다운로드에 사용되는 HTTP 응답 헤더입니다.

body() 메서드는 HTTP 응답 본문에 포함될 리소스를 설정합니다. 위 코드에서는 resource 객체를 설정합니다. resource는 다운로드하려는 파일의 위치를 가리키는 Resource 객체입니다.

이렇게 설정된 ResponseEntity 객체는 클라이언트에게 파일 다운로드를 응답하는 데 사용됩니다. 클라이언트는 Content-Disposition 헤더에 따라 파일을 다운로드하게 됩니다.

ResponseEntity
HTTP 응답의 본문(body) 데이터, 헤더(header) 정보, HTTP 상태 코드(status code) 등을 포함할 수 있습니다. 이를 통해 클라이언트가 서버로부터 받은 응답 데이터를 적절하게 처리할 수 있도록 합니다.

Resource
파일 시스템, 클래스 패스, URL 등 다양한 유형의 리소스를 추상화하며, InputStream 또는 URL과 같은 입력 스트림을 반환합니다. 이를 통해 애플리케이션에서 사용하는 리소스를 표준화된 방법으로 로드하고 관리할 수 있습니다.

MalformedURLException
java.net 패키지에서 제공되는 예외 클래스 중 하나입니다. 이 예외는 URL 형식이 잘못되어 생성할 수 없는 경우 발생합니다.

post.html 수정

 <ul>
            <li th:each="file : ${files}">
                <span th:text="${file.filename}"></span>
                <a th:href="@{/api/file/download/{id}(id=${file.id})}">다운로드</a>
            </li>
        </ul>

게시글 삭제시 static 밑에 files에 있는 파일도 삭제되게 수정

BoardApiController 수정

@PreAuthorize("hasRole('ADMIN') or #board.user.username == authentication.name")
    @DeleteMapping("/boards/{id}")
    @Transactional
    void deleteBoard(@PathVariable Long id) {
        Board board = boardRepository.findById(id).orElseThrow();
        List<FileData> files = board.getFiles();
        if(files != null && !files.isEmpty()){
            for(FileData file : files){
                //파일 경로
                Path filePath = Paths.get("src/main/resources/static",file.getFilepath());
                //파일 삭제
                try{
                    Files.delete(filePath);
                }
                catch (IOException e){
                    e.printStackTrace();
                }
            }
        }
        //게시글 삭제
        boardRepository.deleteById(id);

    }

@Transactional
데이터베이스 트랜잭션을 자동으로 처리해주는 스프링 프레임워크의 어노테이션 중 하나입니다.
@Transactional 어노테이션을 deleteBoard 메소드에 추가하면 메소드가 실행되는 동안 세션이 열려 있어서 지연 로딩이 가능합니다.

게시글 파일 수정

board entity 수정

@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    private List<FileData> files = new ArrayList<>();
    public void addFile(FileData file) {
        files.add(file);
        file.setBoard(this);
    }

fetch = FetchType.EAGER를 사용하여 entity가 항상 함께 로딩되게 해줍니다. 하지만 이 코드는 성능에 영향을 줄 수 있습니다.

BoardController

@GetMapping("/form")
    public String form(Model model, @RequestParam(required = false) Long id){
        if(id == null){
            model.addAttribute("board", new Board());
        }
        else {
            Board board = boardRepository.findById(id).orElse(null);
            model.addAttribute("board", board);
            if(board.getFiles() != null){
                model.addAttribute("files", board.getFiles());
            }
        }
        return "board/form";
    }

model.addAttribute("files", board.getFiles());이 코드를 추가해서 form.html에 파일들을 표시할 수 있게 해줍니다.

BoardController

게시글 수정 시 원래 가지고 있던 파일도 유지되게 수정해줍니다.

@PostMapping("/form")
    public String form(@Valid Board board, BindingResult bindingResult , Authentication authentication,
                       @RequestParam("file") MultipartFile[] files) throws IOException {
        boardValidator.validate(board, bindingResult);
        if (bindingResult.hasErrors()) {
            return "board/form";
        }
        String username = authentication.getName();
        for (MultipartFile file : files) {
            if (!file.isEmpty()) {
                String filename = file.getOriginalFilename();
                int filesize = (int) file.getSize();
                String filetype = file.getContentType();
                UUID uuid = UUID.randomUUID();
                String randomFileName = uuid + "_" + filename;
                Path path = Paths.get("src/main/resources/static/files/" + randomFileName);

                FileData newFile = new FileData();
                newFile.setFilename(filename);
                newFile.setFilesize(filesize);
                newFile.setFiletype(filetype);
                newFile.setFilepath("/files/" + randomFileName);
                newFile.setUploadDate(LocalDateTime.now());

                Files.copy(file.getInputStream(), path);

                board.addFile(newFile);
            }
        }
        // 기존 파일 정보 유지
        if (board.getId() != 0) {
            List<FileData> oldFiles = fileRepository.findByBoardId(board.getId());
            for (FileData oldFile : oldFiles) {
                board.addFile(oldFile);
            }
        }
        boardService.save(username, board);
        return "redirect:/board/list";
    }

업로드된 파일 목록 확인

FileService

public void deleteById(Long fileId) {
        FileData file = fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException("파일 정보가 존재하지 않습니다."));
        fileRepository.delete(file);

        String filePath = "src/main/resources/static" + file.getFilepath();
        Path path = Paths.get(filePath);
        try {
            Files.deleteIfExists(path);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

fileRepository.findById(fileId).orElseThrow(() -> new IllegalArgumentException("파일 정보가 존재하지 않습니다."))는 데이터베이스에서 fileId에 해당하는 파일 데이터를 조회하고, 해당 데이터가 존재하지 않을 경우 IllegalArgumentException 예외를 발생시키는 코드입니다.

파일이 존재할 경우 DB에 있는 파일도 삭제하고 static/files 디렉터리에 있는 파일도 삭제를 하는 코드입니다.

BoardApiController

@DeleteMapping("/files/delete/{fileId}")
    void deleteFile(@PathVariable Long fileId){
        FileData fileData = fileRepository.findById(fileId).orElseThrow();
        Path filePath = Paths.get("src/main/resources/static",fileData.getFilepath());
        //실제 파일 삭제
        fileService.deleteById(fileId);
    }

fileService에 있는 deleteById를 통해 static/files에 있는 실제 파일과 DB에 있는 파일들을 삭제해주는 코드입니다.

form.html

<div class="form-group">
                <br>
                <label for="file">파일</label>
                <ul>
                    <li th:each="file : ${files}">
                        <span th:text="${file.filename}"></span>
                        <button th:onclick="|deleteFile(${file.id})|">삭제</button>
                    </li>
                </ul>
                <input class="form-control-file" id="file" name="file" type="file" multiple>
            </div>
function deleteFile(fileId) {
        console.log("Deleting file with ID: " + fileId);
        $.ajax({
            url: '/api/files/delete/' + fileId,
            type: 'DELETE',
            success: function(result) {
                // 파일 삭제 성공 시 처리
                location.reload();
            }
        });
    }

파일이 첨부된 게시글 수정 페이지

게시판에 보이는 게시글 개수 수정

 @GetMapping("/list")
    public String list(Model model, @PageableDefault(size = 15) Pageable pageable, @RequestParam(required = false, defaultValue = "") String searchText){
        //Page<Board> boards =  boardRepository.findAll(pageable);
        Page<Board> boards = boardRepository.findByTitleContainingOrContentContaining(searchText,searchText,pageable);
        int block = 5;
        int currentBlock = (boards.getPageable().getPageNumber() / block) * block;
        int startPage = currentBlock + 1;
        int endPage = Math.min(boards.getTotalPages(), currentBlock + block);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        model.addAttribute("boards", boards);
        return "board/list";
    }

@PageableDefault(size = 15) 한 페이지에 15개씩 보이게 수정해줍니다.

profile
공부하는 초보 개발자

0개의 댓글