본문 바로가기

Server Oriented/Spring

스프링 파일 업로드 MultipartFile #2/2

"스프링 파일 업로드 MultipartFile #1/2" 에선,

로컬 파일을 서버에 업로드 할 때..

로컬 파일 속성을 그대로 업로드하므로,

로컬에서의 작성일 등등의 정보가 그대로 업로드.

 

"스프링 파일 업로드 MultipartFile #2/2" 에선,

서버에 업로드 할 때..

경로와 파일명을 변경하면서 저장하므로,

로컬에서의 작성일 등등의 정보가 변경됨.

덧붙여서, 이미지 게시판 (테스트를 위한 간소화 버전) 관리 내용 추가.

여러 파일을 한 번에 업로드 하는 것은,

"스프링 파일 업로드 MultipartFile #1/2" 참조.

 

Ajax 를 이용해서 파일들을 미리 업로드 하고,

본문을 나중에 올려서 통합하는 방법도 있음.


* build.gradle

 

plugins {
  id 'org.springframework.boot' version '2.7.5'
  id 'io.spring.dependency-management' version '1.0.15.RELEASE'
  id 'java'
}

group = '도메인'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-jdbc'
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  runtimeOnly 'com.oracle.database.jdbc:ojdbc8'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
  useJUnitPlatform()
}


* /src/main/resources/application.yml

 

server.port : 8086

spring :
  datasource :
    driver-class-name : oracle.jdbc.driver.OracleDriver
    url : jdbc:oracle:thin:@10.1.3.21:1521/skyin
    username : csuser
    password : ria72
  jpa :
    generate-ddl : 'true' # true 데이터베이스 고유 기능 사용
    database : oracle
    database-platform : org.hibernate.dialect.Oracle10gDialect
    hibernate :
      ddl-auto : update  # update 변경 부분만 반영, create 기존 테이블 삭제후 재생성, none 사용하지 않음, fk 와 관련 예민
    show-sql : 'true'   # true 로그에 쿼리 인쇄
  mybatis :
    mapper-locations : classpath:mappers/**/*.xml
    config-location : classpath:mybatis-config.xml
    
logging :
  level :
    org : 
      hibernate : info
      
servlet :
  multipart :
    max-request-size : 10MB
    max-file-size : 10MB
    
upload :
  path : C:/sts/workspace/도메인/src/main/resources/upload


* /src/main/java/도메인/domain/FileBoard.java

 

import javax.persistence.*;
import org.springframework.web.multipart.*;
import lombok.*;

@Entity
@Getter @Setter @ToString
@Table(name="web_test_file")
public class FileBoard{

  @Id
  @SequenceGenerator(name="FILE_BOARD_SEQUENCE_GEN", 

      sequenceName="seq_web_test_file", initialValue=1, allocationSize=1)
  @GeneratedValue(strategy=GenerationType.SEQUENCE, generator="FILE_BOARD_SEQUENCE_GEN")
  private Long fileId;              // PK
  
  @Column(length=50)                // javax.persistence.*
  private String subject;           // 제목
  
  @Lob                              // javax.persistence.*
  private String note;              // 설명

  
  @Transient                        // javax.persistence.*, DB 연동 X
  private MultipartFile file;       // org.springframework.web.multipart.*
  
  private String    filePath;       // web 접근시 파일 경로. /yyyy/mm/dd
  private String    fileName;       // 업로드 후 서버에서의 파일명 (확장자 없음)
  private String    orgFileName;    // 업로드 전 로컬에서의 파일명 (확장자 있음)
  private Long      fileSize;       // 단위 bytes
  private String    contentType;    // 미디어 구분

}


* /src/main/java/도메인/contoller/FileBoardController.java

import java.io.*;
import java.text.*;
import java.util.*;
import lombok.*;
import lombok.extern.slf4j.*;
import 도메인.domain.*;
import 도메인.service.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.http.*;
import org.springframework.stereotype.*;
import org.springframework.ui.*;
import org.springframework.util.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.*;

@Controller @Slf4j
@RequiredArgsConstructor
@RequestMapping("/fileBoard")
public class FileBoardController{

  private final FileBoardService service;
  
  @Value("${upload.path}") // application.yml 파일 참조. org.springframework.beans.factory.annotation.*
  private String uploadPath;
  
  
  @GetMapping("/list")
  public void list(Model model) throws Exception {
    List<FileBoard> fbList = service.list();
    model.addAttribute("fileBoardList", fbList);
  }

  @GetMapping("/read")
  public String read(Long fileId, Model model) throws Exception { // FileBoard fileBoard 보다 Long fileId 가 가벼워서 사용
    FileBoard fileBoard = service.read(fileId);
    model.addAttribute(fileBoard);
    return "fileBoard/read";
  }


  @GetMapping("/register")
  public String registerForm(Model model) throws Exception {
    model.addAttribute(new FileBoard());
    return "fileBoard/register";
  }

  @PostMapping("/register")
  public String register(FileBoard fileBoard, Model model) throws Exception {
    this.uploadFile(fileBoard); // 서버에 파일 업로드
    service.regist(fileBoard);  // DB 신규 등록
    model.addAttribute("msg","등록이 완료되었습니다."); // service.regist() 의 파라미터인 fileBoard.fileId 가 세팅되므로

                                                                                       // fileBoard.fileId 값이 있으면 성공
    return "fileBoard/read"; // read view 호출 (fileBoard.fileId 기준으로 조회)
  }
  

  @GetMapping("/modify")
  public String modifyForm(Long fileId, Model model) throws Exception {
    FileBoard fileBoard = service.read(fileId);
    model.addAttribute(fileBoard);
    return "fileBoard/modify";
  }

  @PostMapping("/modify")
  public String modify(FileBoard fileBoard, Model model) throws Exception {
    this.uploadFile(fileBoard); // 서버에 파일 업로드
    service.modify(fileBoard);  // DB 수정, 수정할 때마다 백업하는 기능은 DB 트리거 사용하는 것이 간편
    model.addAttribute("msg","수정이 완료되었습니다."); // 예외 상황이 아니면 성공
    return "fileBoard/read"; // read view 호출 (fileBoard.fileId 기준으로 조회)
  }


  @PostMapping("/remove")
  public String remove(FileBoard fileBoard, Model model) throws Exception {
    service.remove(fileBoard.getFileId());
    model.addAttribute("msg","삭제가 완료되었습니다."); // fileBoard.fileid 가 null 이면 삭제 성공.
    return "redirect:/fileBoard/list"; // 삭제한 다음 list view 를 호출하지 않고(이곳엔 list view 에 필요한 데이타가 없음) url 호출
  }
  
  
  private void uploadFile(FileBoard fileBoard) throws Exception { // 파일 업로드
    MultipartFile mf  = fileBoard.getFile();                // org.springframework.web.multipart.*
    if(mf!=null && mf.getSize()>0) {                        // 수정시 파일이 변경되지 않을 수 있음.

                                                                            // 이전 파일이 변경되는 경우라면 이전 파일을 삭제하기 위한 로직이 필요.

                                                                           // 아니면, 이전 내용도 백업해서 계속 참조 가능하도록 할 수는 있음
      FileBoard     fb    = this.saveFile(mf);              // 서버에 업로드 된 스트림을 파일로 저장. 서버 디스크 상의 파일 형태.

                                                                            // 이 부분을 별도 기술하지 않으면 관련 프로세스에서 원본 파일명으로 서버에 업로드 작업을 완료하여

                                                                           // 서버의 하드디스크에 저장한다
      fileBoard.setFilePath(fb.getFilePath());              // DB 저장을 위한 세팅. 하나의 디렉토리에 너무 많은 파일이 생성되지 않도록

                                                                                                      // /yyyy/mm/dd 형태로 배분하여 I/O 부하 감소

                                                                             // 하나의 디렉토리에 파일 갯수가 많아지면 access time 이 길어져서 퍼포먼스 떨어짐
      fileBoard.setFileName(fb.getFileName());              // DB 저장을 위한 세팅. 서버에서의 파일명 (업로드 후)
      fileBoard.setOrgFileName(mf.getOriginalFilename());   // DB 저장을 위한 세팅. 로컬에서의 파일명 (업로드 전)
      fileBoard.setFileSize(mf.getSize());                  // DB 저장을 위한 세팅. 서버에서의 파일 사이즈 (로컬과 약간 상이 가능)
      fileBoard.setContentType(mf.getContentType());        // DB 저장을 위한 세팅. 파일 유형
    }
  }
  
  private FileBoard saveFile(MultipartFile mFile) throws Exception {
    SimpleDateFormat  sdf       = new SimpleDateFormat("/yyyy/MM/dd");  // uploadPath 하위에 생성될 경로, java.text.*
    String            filePath  = sdf.format(new Date());               // URL 접속 가능한 웹 경로, java.util.*
    String            realPath  = uploadPath+filePath;                  // 파일이 생성될 절대경로
    File              directory = new File(realPath);                   // 디렉토리 체크용
    if(!directory.exists()) directory.mkdirs();                         // 서버 디렉토리가 없으면 생성

    String            fileName  = UUID.randomUUID().toString();         // 서버에서의 파일명. 확장자 필요시 원본파일명의 확장자 복사
    File              out       = new File(realPath, fileName);         // 파일 저장용. java.io.*
    FileCopyUtils.copy(mFile.getBytes(), out);                          // Memory 스트림에서 File 로 복사. org.springframework.util.*
    
    FileBoard         fb        = new FileBoard();
    fb.setFilePath(filePath);                                           // 서버에서의 경로명. /yyyy/mm/dd 형태
    fb.setFileName(fileName);                                           // 서버에서의 파일명. 10a99a49-2ee6-4c32-b4c7-09f36c166f1e 형태
    
    return fb;
  }

  
  @ResponseBody
  @GetMapping("/file")
  public ResponseEntity<byte[]> getFile(Long fileId) throws Exception{ // org.springframework.http.*
    ResponseEntity<byte[]> entity = null;
    try {
      
      FileBoard   fb          = service.read(fileId);             // DB 정보 추출. getFile()을호출하는쪽에서 아래 내용들을 전달해 줄 수 있다면,

                                                                             // DB 를 이렇게 호출해 줄 필요가 없다
      String      filePath    = fb.getFilePath();
      String      fileName    = fb.getFileName();
      String      orgFileName = fb.getOrgFileName();
      String      contentType = fb.getContentType(); // contentType 체크해서 이미지인지 일반 파일인지 체크가 필요할 수도..
      
      MediaType   mt          = MediaType.parseMediaType(contentType);    // MutipartFile.contentType 에서 따는 방법

      HttpHeaders headers     = new HttpHeaders();                                          // org.springframework.http.*
      File        file        = new File(uploadPath +filePath+ "/" +fileName);              // java.io.*, web 서비스 경로가 아닌 곳에 파일이 있는 경우
      if(mt!=null) headers.setContentType(mt);
      entity = new ResponseEntity<byte[]>(FileCopyUtils.copyToByteArray(file),              // org.springframework.http.ResponseEntity, org.springframework.util.FileCopyUtils
        headers,
        HttpStatus.CREATED);                                                                // org.springframework.http.*
      
    }catch(Exception e) {
      e.printStackTrace();
      entity = new ResponseEntity<byte[]>(HttpStatus.BAD_REQUEST);
    }finally {
    }
    
    return entity;
  }
  
}

 


* /src/main/java/도메인/service/FileBoardService.java

 

import java.util.List;
import 도메인.domain.*;

public interface FileBoardService{
  
  public List list() throws Exception;
  public FileBoard read(Long fileId) throws Exception;
  public void regist(FileBoard fileBoard) throws Exception;
  public void modify(FileBoard fileBoard) throws Exception;
  public void remove(Long fileId) throws Exception;

}


* /src/main/java/도메인/service/FileBoardServiceImpl.java

 

import java.util.*;
import lombok.*;
import lombok.extern.slf4j.*;
import 도메인.domain.*;
import 도메인.repository.*;
import org.springframework.data.domain.Sort.*;
import org.springframework.data.domain.*;
import org.springframework.stereotype.*;
import org.springframework.transaction.annotation.*;

@Service @Slf4j
@RequiredArgsConstructor
public class FileBoardServiceImpl implements FileBoardService{

  private final FileBoardRepository repository;
    
  @Override
  @Transactional(readOnly=true)
  public List<FileBoard> list() throws Exception {
    return repository.findAll(Sort.by(Direction.DESC,"fileId")); // org.springframework.data.domain.*

                                                                                                                               // org.springframework.data.domain.Sort.*
  }

  @Override
  @Transactional(readOnly=true)
  public FileBoard read(Long fileId) throws Exception {
    return repository.getOne(fileId);
  }
  
  @Override

  @Transactional // 다른 DB 와 트랜잭션이 걸릴 경우를 대비해서 세팅
  public void regist(FileBoard fileBoard) throws Exception {
    repository.save(fileBoard);
  }
  
  @Override
  @Transactional
  public void modify(FileBoard fileBoard) throws Exception {
    FileBoard fb = read(fileBoard.getFileId()); // 기존 DB 정보 추출
    fb.setSubject(fileBoard.getSubject());
    fb.setNote(fileBoard.getNote());
    String  fp  = fileBoard.getFilePath();
    if(fp!=null && fp.length()>0){ // 수정화면에서 파일을 변경하는 경우 이를 적용
      fb.setFilePath(fp);
      fb.setFileName(fileBoard.getFileName());
      fb.setOrgFileName(fileBoard.getOrgFileName());
      fb.setFileSize(fileBoard.getFileSize());
      fb.setContentType(fileBoard.getContentType());
    }
    //repository.save(fileBoard); // 주석처리 해도 DB 에 저장됨
  }
  
  @Override
  @Transactional
  public void remove(Long fileId) throws Exception {
    repository.deleteById(fileId);
  }  

 

}


* /src/main/java/도메인/repository/FileBoardRepository.java

 

 

import 도메인.domain.*;
import org.springframework.data.jpa.repository.*;

public interface FileBoardRepository extends JpaRepository<FileBoard,Long>{ // org.springframework.data.jpa.repository.*
  // extends 내용으로 충분. 꼭 필요한 경우가 아니면 JPA 가 쿼리를 자동 생성하는 것이 좋음
}


/src/main/resources/templates/fileBoard/list.html

 

 

<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorate="~{/layouts/default.html}">
<head>
<title>[JPA] File Board, List</title>
<script src="/js/board/fileBoard.js"></script>
</head>
<body>
<div layout:fragment="content"><!--/*  default.html 의 <div layout:fragment="content"></div> 대체 */-->

<form id="pr">
<input type="hidden" name="fileId" id="fileId">

<h2>List</h2>

<p><a th:href="@{register}">등록</a></p>

<table class="df" style="width:1060px">
  <col width="80">
  <col width="200">
  <col width="320">
  <col width="200">
  <col width="120">
  <col width="140">
  <tr>
    <th rowspan="2">Id</th>
    <th rowspan="2">제목</th>
    <th rowspan="2">설명</th>
    <th colspan="3">파일 정보</th>
  </tr>
  <tr>
    <th>이름</th>
    <th>사이즈 bytes</th>
    <th>종류</th>
  </tr>
  
  <tr th:if="${#lists.isEmpty(fileBoardList)}">
    <td colspan="6">List is empty</td>
  </tr>
  
  <tr th:each="board : ${fileBoardList}">
    <td th:text="${board.fileId}"></td>
    <td><a th:href="|javascript:goReadFileBoard(${board.fileId})|" th:text="${board.subject}"></a></td>
    <td th:text="${board.note}"></td>
    <td th:text="${board.orgFileName}"></td><!--/* 서버에서의 파일명이 다르므로 업로드시 로컬 파일명 */-->
    <td th:text="${#numbers.formatInteger(board.fileSize, 3, 'COMMA')}"></td>
    <td th:text="${board.contentType}"></td>
  </tr>
  
</table>

</form>
<script th:inline="javascript">
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#pr');// 여기서 사용된 form 태그
  });
  
  $(window).load(function(){// 목록 화면이 표시되고 alert 되기를 바랐건만, 흰 바탕에 alert 된 다음 view 가 표시됨
    let msg = [[${msg}]]; if(msg) alert(msg);
  });
</script>

</div>
</body>
</html>


* /src/main/resources/templates/fileBoard/read.html

 

...
<form id="fileBoard" th:object="${fileBoard}">
<input type="hidden" th:field="*{fileId}">

<h2>Read</h2>

<table class="df">
  <col width="80">
  <col width="320">
  <tr>
    <th class="r">fileId</th>
    <td class="l" th:text="*{fileId}"></td>
  </tr>
  <tr>
    <th class="r">제목</th>
    <td class="l" th:text="*{subject}"></td>
  </tr>
  <tr>
    <th class="r">설명</th>
    <td class="l" th:text="*{note}"></td>
  </tr>
  <tr>
    <th class="r">파일</th>
    <td class="l">
      <p>- 원본 파일명 <span th:text="*{orgFileName}"></span></p>
      <p>- 파일사이즈 <span th:text="*{fileSize}"></span></p>
      <p>- 파일종류 <span th:text="*{contentType}"></span></p>
      <p><img th:src="@{|file?fileId=*{fileId}|}" width="200" height="300"></p><!--/* 이미지 */-->
    </td>
  </tr>
</table>

<div class="btn">
  <a id="btnModify">수정</a>
  <a id="btnRemove">삭제</a>
  <a id="btnList">목록</a>
</div>

</form>
<script>
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#fileBoard');
    $('#btnModify').on('click',function(){
      act('get','modify');  // 수정 양식으로 이동
    });
    $('#btnRemove').on('click',function(){
      act('post','remove'); // remove 는 post
    });
    $('#btnList').on('click',function(){
      act('get','list') // list 는 get. 페이지나 목록 검색 조건 전달
    });
  });
</script>
...


* /src/main/resources/teplates/fileBoard/register.html

 

...

<form id="fileBoard" th:object="${fileBoard}" enctype="multipart/form-data">

<h2>Register</h2>

<table>
  <tr>
    <th>제목</th>
    <td>
      <input type="text" th:field="*{subject}">
      <p th:if="${#fields.hasErrors('subject')}" th:errors="*{subject}"></p>
    </td>
  </tr>
  <tr>
    <th>설명</th>
    <td>
      <textarea th:field="*{note}"></textarea>
      <p th:if="${#fields.hasErrors('note')}" th:errors="*{note}"></p>
    </td>
  </tr>
  <tr>
    <th>파일</th>
    <td>
      <input type="file" th:field="*{file}">
    </td>
  </tr>
</table>

<div class="btn">
  <a id="btnRegister">저장</a>
  <a id="btnList">목록</a>
</div>

</form>

<script>
  var frmObj;
  $(document).ready(function(){
    frmObj = $('#fileBoard');
    $('#btnRegister').on('click',function(){
      if(!validForm2()) return;
      act('post','register');
    });
    $('#btnList').on('click',function(){
      act('get','list') // list 는 get. 페이지나 목록 검색 조건 전달
    });
  });
</script>

...


* /src/main/resources/templates/fileBoard/modify.html

 

...

<form id="fileBoard" th:object="${fileBoard}" enctype="multipart/form-data">
<input type="hidden" th:field="*{fileId}">

<h2>Modify</h2>

<table class="df">
  <col width="80">
  <col width="320">
  <tr>
    <th class="r">fileId</th>
    <td class="l" th:text="*{fileId}"></td>
  </tr>
  <tr>
    <th class="r">제목</th>
    <td class="l">
      <input type="text" th:field="*{subject}">
    </td>
  </tr>
  <tr>
    <th class="r">설명</th>
    <td class="l">
      <textarea th:field="*{note}"></textarea>
    </td>
  </tr>
  <tr>
    <th class="r">파일</th>
    <td class="l">
      <p>- 원본 파일명 : <span th:text="*{orgFileName}"></span></p><!--/* 기존 정보. 수정하고 나면 갱신 */-->
      <p>- 파일사이즈 : <span th:text="*{fileSize}"></span></p>
      <p>- 파일종류 : <span th:text="*{contentType}"></span></p>
      <p><img th:src="@{|file?fileId=*{fileId}|}" width="200" height="300"></p>

      <input type="file" th:field="*{file}"><!--/* 파일 변경하려면 기술 */-->
    </td>
  </tr>
</table>

<div class="btn">
  <a id="btnModify">저장</a>
  <a id="btnList">목록</a>
</div>

</form>
<script>
  var frmObj;
  $(document).ready(function(){
    $('#btnModify').on('click',function(){
      frmObj = $('#fileBoard');
      if(!validForm2()) return;
      act('post','modify')
    });
    $('#btnList').on('click',function(){
      act('get','list');
    });
  });
</script>

...


* /src/main/resources/templates/layouts/default.html

 

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head n="0">
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
<link rel="stylesheet" type="text/css" href="/css/default.css"/><!--/* 전체 공통 */-->
<script src="/js/jquery-1.10.2.min.js"></script><!--/* 전체 공통 */-->
<script src="/js/default.js"></script><!--/* 전체 공통 */-->
</head>
<body>
  <div class="header">페이지 헤더 | <a href="/" style="font-size:8pt">메인 화면 바로가기</a></div><!--/* 외부 파일로 만들 수도 있고, 여기에 기술할 수도.. */-->
  <div layout:fragment="content" class="content"></div>
  <div class="footer">페이지 푸터</div><!--/* 외부 파일로 만들 수도 있고, 여기에 기술할 수도.. */-->
</body>
</html>


* /src/main/resources/static/css/default.css


* /src/main/resources/static/js/board/fileBoard.js

 

function goReadFileBoard(fi){//목록에서 상세화면 오픈
  goRead('fileId', fi);
}

function validForm2(){//등록/수정시 입력내용 체크
  if($('#subject').val()==''){ alert('제목을 입력해 주세요. '); return false; }
  if($('#note').val()==''){ alert('설명을 입력해 주세요. '); return false; }
  //if($('#file').val()==''){ alert('파일을 선택해 주세요. '); return false; }
  return true;
}


* /src/main/resources/static/js/default.js

 

function act(method,action){
frmObj // 화면용 .html 에서 정의
.attr('method',method)
.attr('action',action)
.submit();
}

function goRead(id, n){ $('#'+id).val(n); act('get','read'); }
function goList(pn){ $('#page').val(pn); act('get','list'); }