"스프링 파일 업로드 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'); }
'Server Oriented > Spring' 카테고리의 다른 글
@SpringBootTest 에서 HttpServletRequest 나 HttpSession 등을 mock 하는 방법 (0) | 2024.04.29 |
---|---|
스프링 Transaction 트랜잭션 (0) | 2023.02.10 |
스프링 파일 업로드 MultipartFile #1/2 (0) | 2023.02.03 |
JUnit5 JPA DB 테이블 1개 목록/상세/등록/수정/삭제 (0) | 2022.12.05 |
스프링 JPA, 평범한 게시판 구현 DB 테이블 1개 (2) | 2022.12.03 |