HyunJun 기술 블로그

스프링 프레임워크 게시판, 파일 업로드 다운로드 구현하기 본문

Spring Framework

스프링 프레임워크 게시판, 파일 업로드 다운로드 구현하기

공부 좋아 2023. 5. 21. 22:11
728x90
반응형

1. 게시판 구현하기


기능 구현에 있어서 CRUD(Create, Read, Update, Delete)라는 표현을 많이 사용하는데 CRUD를 연습하기에 게시판같이 좋은 것이 없다고 생각합니다. 이 글에서는 단순 CRUD보다는 파일 입출력에 더 초점을 맞춰서 진행해 볼까 합니다.

 

  • 간단한 게시판 기능
  • File Download, Upload를 같이 구현할 예정.
  • File들을 조회 시 URL로 반환. (모든 확장자) -> exe, zip 파일은 업로드 불가.

2. 게시판 (파일 업, 다운로드) 로직

기본적으로 아래와 같은 로직으로 구현하려 합니다.

  • 게시글 작성 시 json, 멀티 파트로 게시글 제목, 내용, 파일 여러 개(확장자 제한 기능)를 받는다.
  • 확장자 제한 확인 후, 파일 자체는 프로젝트 루트 내 ex)) /Users/mycomputer/Spring-Framework/Spring-CRUD-Board-File-Upload-Download/media/파일 이름.확장자로 저장을 하되 db에 fileUri는 http://localhost:8080/api/files/파일 이름.확장자로 저장을 한다. 이때 중복방지를 위해 게시글 고유번호를 파일명 앞에 붙여 저장한다.
  • /api/files/{PathVariable}로 api를 만들어 해당 주소로 접근하면 파일을 리턴한다.

 

3. 구현하기

  • Database 환경만 RDS 환경이고 나머지는 Local 환경입니다.
  • 실 서버 사용 시 URL 관련 부분만 변경하면 되고, 서버 용량은 최대한 아껴야 하므로, 파일 저장소를 S3로 변경하여 사용해도 됩니다.

 

 

build.gradle

/*web*/
implementation 'org.springframework.boot:spring-boot-starter-web'

/*database*/
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'

/*lombok*/
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

/*test*/
testImplementation 'org.springframework.boot:spring-boot-starter-test'

/*file io*/
implementation 'commons-io:commons-io:2.6'

 

 

application.yml

spring:
  profiles:
    # datasource 숨기기
    include: datasource

  jpa:
    hibernate:
      # update 변경이 이루어지는 부분만 반영
      ddl-auto: update
    show-sql: true # 쿼리 확인

  servlet:
    multipart:
      max-file-size: 50MB # 하나의 파일당 최대 용량 제한 사이즈
      max-request-size: 70MB # 한 요청당 파일들의 최대 용량 제한 사이즈
      # 50MB인 2개의 파일 100MB를 업로드 시 실패.

 

application-datasource.yml

spring:
  datasource:
    url: jdbc:mysql://myserver.rds.amazonaws.com:3306/spring-board
    username: 계정
    password: 비밀번호

 

.gitignore

application-datasource.yml

 

 

 

 

 

패키지 구조

 

 

BoardController

package com.example.springcrudboardfileuploaddownload.controller;

import com.example.springcrudboardfileuploaddownload.dto.BoardReqDto;
import com.example.springcrudboardfileuploaddownload.dto.BoardReadResDto;
import com.example.springcrudboardfileuploaddownload.service.BoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class BoardController {

    private final BoardService boardService;

    /*글 작성(파일 업로드)*/
    @PostMapping("/boards")
    public void createPost(@RequestPart(value = "requestDto") BoardReqDto reqDto,
                           @RequestPart(value = "files") List<MultipartFile> files) throws IOException {
        boardService.createPost(reqDto, files);
    }

    /*글 읽기(파일 링크)*/
    @GetMapping("/boards")
    public BoardReadResDto readPost(@RequestParam Long boardId) {
        return boardService.readPost(boardId);
    }

    /*글 삭제*/
    @DeleteMapping("/boards")
    public void deletePost(@RequestParam Long boardId) {
        boardService.deletePost(boardId);
    }

    /*글 업데이트*/
    @PutMapping("/boards")
    public void updatePost(@RequestParam Long boardId, @RequestBody BoardReqDto reqDto) {
        boardService.updatePost(boardId, reqDto);
    }

    /*파일 링크 클릭 시 파일 저장*/
    @GetMapping("/files/{fileName}")
    public ResponseEntity<?> downloadFile(@PathVariable("fileName") String fileName,
                                          HttpServletRequest request) throws IOException {
        /*프로젝트 루트 경로*/
        String rootDir = System.getProperty("user.dir");

        /*file의 path를 저장 -> 클릭 시 파일로 이동*/
        Path filePath = Path.of(rootDir + "/media/" + fileName);

        /*파일의 패스를 uri로 변경하고 resource로 저장.*/
        Resource resource = new UrlResource(filePath.toUri());

        /*컨텐츠 타입을 가지고 온다.*/
        String contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
                .body(resource);
    }

}

 

 

 

createPost()의 @RequestPart를 DTO와 MultiPartFile로 나누면 아래와 같은 구현이 가능합니다.

 

 

BoardReadResDto

package com.example.springcrudboardfileuploaddownload.dto;

import lombok.Getter;
import lombok.Setter;

import java.util.List;

@Getter
@Setter
public class BoardReadResDto {

    private String title;
    private String contents;
    private List<FileFormat> fileFormat;

    public BoardReadResDto(String title, String contents, List<FileFormat> fileFormat) {
        this.title = title;
        this.contents = contents;
        this.fileFormat = fileFormat;
    }
}

 

 

 

 

BoardReqDto

package com.example.springcrudboardfileuploaddownload.dto;

import lombok.Getter;
import lombok.Setter;


@Getter
@Setter
public class BoardReqDto {
    private String title;
    private String contents;
}

 

 

FileFormat

package com.example.springcrudboardfileuploaddownload.dto;

import com.example.springcrudboardfileuploaddownload.entity.File;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class FileFormat {
    private String fileName;
    private String fileExtension;
    private String fileUri;

    public FileFormat(File file) {
        this.fileName = file.getFileName();
        this.fileExtension = file.getFileExtension();
        this.fileUri = file.getFileUri();
    }
}

 

Board

package com.example.springcrudboardfileuploaddownload.entity;

import com.example.springcrudboardfileuploaddownload.dto.BoardReqDto;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@Getter
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String contents;

    public Board(BoardReqDto reqDto) {
        this.title = reqDto.getTitle();
        this.contents = reqDto.getContents();

    }
    public void updateBoard(BoardReqDto reqDto) {
        this.title = reqDto.getTitle();
        this.contents = reqDto.getContents();
    }
    
}

 

 

File

package com.example.springcrudboardfileuploaddownload.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@Getter
public class File {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String fileName;

    @Column
    private String fileUri;

    @Column
    private String fileExtension;

    @ManyToOne
    @JoinColumn(name = "BOARD_ID", nullable = false)
    private Board board;


    public File(MultipartFile validatedFile, Board board) {
       	this.fileName = validatedFile.getOriginalFilename();
    	this.fileUri = "http://localhost:8080/api/files/" + board.getId() + "_" + this.fileName;
        this.fileExtension = this.fileName.substring(this.fileName.lastIndexOf(".") + 1);
        this.board = board;
    }


}

 

 

BoardRepository

package com.example.springcrudboardfileuploaddownload.repository;

import com.example.springcrudboardfileuploaddownload.entity.Board;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
}

 

 

 

FileRepository

package com.example.springcrudboardfileuploaddownload.repository;

import com.example.springcrudboardfileuploaddownload.entity.File;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface FileRepository extends JpaRepository<File, Long> {
    List<File> findAllByBoardId(Long id);

    void deleteAllByBoardId(Long id);
}

 

 

 

 

 

BoardService

package com.example.springcrudboardfileuploaddownload.service;

import com.example.springcrudboardfileuploaddownload.dto.BoardReqDto;
import com.example.springcrudboardfileuploaddownload.dto.BoardReadResDto;
import com.example.springcrudboardfileuploaddownload.dto.FileFormat;
import com.example.springcrudboardfileuploaddownload.entity.Board;
import com.example.springcrudboardfileuploaddownload.entity.File;
import com.example.springcrudboardfileuploaddownload.repository.BoardRepository;
import com.example.springcrudboardfileuploaddownload.repository.FileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;


import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
public class BoardService {

    private final BoardRepository boardRepository;
    private final FileRepository fileRepository;

    /*게시글 생성*/
    @Transactional
    public void createPost(BoardReqDto reqDto, List<MultipartFile> files) throws IOException {

        /*게시글 entity 생성*/
        Board board = new Board(reqDto);
        boardRepository.save(board);

        /*지원하지 않는 확장자 파일 제거*/
        List<MultipartFile> validatedFiles = filesValidation(files);

        /*걸러진 파일들 업로드*/
        filesUpload(validatedFiles, board.getId());


        /*유효성 검증을 끝낸 파일들을 하나씩 꺼냄.*/
        for (MultipartFile validatedFile : validatedFiles) {

            /*File Entity 생성 후 저장*/
            File file = new File(validatedFile, board);
            fileRepository.save(file);

        }
    }

    /*게시글 읽기*/
    public BoardReadResDto readPost(Long boardId) {

        /*board*/
        Optional<Board> optionalBoard = boardRepository.findById(boardId);

        if (optionalBoard.isEmpty()) {
            throw new IllegalArgumentException("해당 글이 없습니다.");
        }

        Board board = optionalBoard.get();
        /*File*/
        List<File> fileList = fileRepository.findAllByBoardId(boardId);
        List<FileFormat> fileFormatList = new ArrayList<>();

        /*파일이 존재한다면*/
        if (fileList != null) {
            for (File file : fileList) {
                FileFormat fileFormat = new FileFormat(file);
                fileFormatList.add(fileFormat);
            }
        }

        BoardReadResDto responseDto = new BoardReadResDto(board.getTitle(), board.getContents(), fileFormatList);
        return responseDto;
    }


    /*게시글 삭제*/
    @Transactional
    public void deletePost(Long boardId) {

        /*해당 boardId를 가지고 있는 file 먼저 삭제*/
        fileRepository.deleteAllByBoardId(boardId);

        /*board 삭제*/
        boardRepository.deleteById(boardId);
    }

    /*게시글 업데이트*/
    @Transactional
    public void updatePost(Long boardId, BoardReqDto reqDto) {
        Optional<Board> optionalBoard = boardRepository.findById(boardId);

        if (optionalBoard.isEmpty()) {
            throw new IllegalArgumentException("존재하지 않는 글입니다.");
        }

        Board board = optionalBoard.get();
        board.updateBoard(reqDto);
    }

    /*파일의 유효성 검증*/
    private List<MultipartFile> filesValidation(List<MultipartFile> files) throws IOException {
        /*접근 거부 파일 확장자명*/
        String[] accessDeniedFileExtension = {"exe", "zip"};
        /*접근 거부 파일 컨텐츠 타입*/
        String[] accessDeniedFileContentType = {"application/x-msdos-program", "application/zip"};


        ArrayList<MultipartFile> validatedFiles = new ArrayList<>();


        for (MultipartFile file : files) {
            /*원본 파일 이름*/
            String originalFileName = file.getOriginalFilename();
            /*파일의 확장자명*/
            String fileExtension = originalFileName.substring(originalFileName.lastIndexOf(".") + 1);
            /*파일의 컨텐츠타입*/
            String fileContentType = file.getContentType();

            /*accessDeniedFileExtension, accessDeniedFileContentType -> 업로드 불가*/
            if (Arrays.asList(accessDeniedFileExtension).contains(fileExtension) ||
                    Arrays.asList(accessDeniedFileContentType).contains(fileContentType)) {
                log.warn(fileExtension + "(" + fileContentType + ") 파일은 지원하지 않는 확장자입니다.");
            } else {/*업로드 가능*/
                validatedFiles.add(file);
            }


        }
        return validatedFiles;
    }

    /*파일 업로드 메소드*/
    private void filesUpload(List<MultipartFile> files, Long boardId) throws IOException {

        /*프로젝트 루트 경로*/
        String rootDir = System.getProperty("user.dir");

        for (MultipartFile file : files) {
            /*업로드 경로*/
            java.io.File uploadPath = new java.io.File(rootDir + "/media/" + boardId + "_" + file.getOriginalFilename());
            /*업로드*/
            file.transferTo(uploadPath);
        }
    }
    
}

 

 

이때, media 디렉터리를 만들어 놓지 않으면 파일 저장 시 FileNotFOundException이 발생한다.

파일이 저장될 디렉터리는 수동으로 생성

 

 

결과

마지막 fileUri를 Postman이 아닌 브라우저에 입력하면 해당 파일이 다운로드됨.
확장자 제한을 설정해 놓은 exe 파일은 걸러짐.

 

 

 

Github

728x90
반응형
Comments