File Upload
MultipartFile
DB
create table memboard(num smallint auto_increment primary key,
content varchar(2000),
uploadfile varchar(500),
viewcount smallint default 0,
writeday datetime);
- uploadfile는 업로드할 파일의 명칭을 자의적으로 지정해서 저장하기 위한 컬럼
DTO_MultipartFile
@Data
@Alias("memboard")
public class MemBoardDto {
private String num;
private String content;
private String uploadfile;
private MultipartFile multi;
private int viewcount;
private Timestamp writeday;
}
<form>
으로 전송 시 <input type=”file”>
은 MultipartFile
객체의 형태로 전송되므로 String uploadfile
에는 저장 및 출력되지 않음
- 따라서
MultipartFile
객체 형태의 DTO 변수로 지정 (file
타입의 input
name
속성 값과 일치)
<form action="insert" method="post" enctype="multipart/form-data">
<table>
<tr>
<th>제목</th>
<td><input type="text" name="subject" class="form-control" required="required">
</tr>
<tr>
<th>파일업로드</th>
<td><input type="file" name="multi" class="form-control"></td>
</tr>
<tr>
<td colspan="2">
<textarea name="content" class="form-control" required="required"></textarea>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<button type="submit">등록</button>
</td>
</tr>
</table>
</form>
- 파일 업로드를 위한
<form>
이므로 반드시 enctype=”multipart/form-data”
속성 지정
<input type=”file”>
의 name
속성은 DTO의 변수명과 일치 (DTO를 통해 교환되므로)
Mapper_SQL
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="boot.data.mapper.MemBoardMapperInter">
<select id="getMaxNum" resultType="int">
select ifnull(max(num),0) from memboard
</select>
<select id="getList" parameterType="HashMap" resultType="memboard">
select * from memboard order by num desc limit #{start},#{perpage}
</select>
</mapper>
Service
@Service
public class MemBoardService implements MemBoardServiceInter {
@Autowired
MemBoardMapperInter mapperInter;
}
Controller_Insert Logic
@Controller
@RequestMapping("/memboard")
public class MemBoardController {
@Autowired
MemBoardService service;
@PostMapping("/insert")
public String insert(@ModelAttribute MemBoardDto dto,HttpSession session) {
SimpleDateFormat sdf=new SimpleDateFormat("yyyyMMddHHmmss");
String path=session.getServletContext().getRealPath("/savefile");
String uploadfile=sdf.format(new Date())+"_"+dto.getMulti().getOriginalFilename();
if(dto.getMulti().getOriginalFilename().equals(""))
dto.setUploadfile("no");
else {
try {
dto.getMulti().transferTo(new File(path+"\\"+uploadfile));
} catch (IllegalStateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
dto.setUploadfile(uploadfile);
}
dto.setMyid((String)session.getAttribute("myid"));
dto.setName((String)session.getAttribute("loginname"));
service.insertBoard(dto);
return "redirect:content?num="+service.getMaxNum();
}
}
- 업로드한 파일은 DTO의
MultipartFile
객체에 저장되어있음 (이를 호출하여 처리 가능)
- DB의 uploadfile
컬럼은 실제 파일이 아닌, 처리된 String
(varchar
) 형태의 자료가 저장됨 (DTO를 통해 DB에 저장하기 위해서는 set
해주어야함)
- 실제 파일은 transferTo()
객체로 실제 저장 경로에 저장
- Insert Form에서 입력하지 않은 기본 정보는 Controller에서 별도로
set
하여 DTO를 통해 저장
- insert하는 동시에 상세페이지(content)에 이동하여 해당 데이터를 출력하기 위해 DB의 최종 시퀀스 값을 구하여 넘거주어야 함 (
getMaxNum()
)
Controller_Detail Page
@Controller
@RequestMapping("/memboard")
public class MemBoardController {
@GetMapping("/content")
public ModelAndView content(@RequestParam String num,int currentPage) {
ModelAndView model=new ModelAndView();
model.setViewName("/memboard/content");
service.updateviewcount(num);
MemBoardDto dto=service.getData(num);
model.addObject("dto", dto);
int dotloc=dto.getUploadfile().lastIndexOf(".");
String ext=dto.getUploadfile().substring(dotloc+1);
if(ext.equalsIgnoreCase("jpg")||ext.equalsIgnoreCase("gif")||
ext.equalsIgnoreCase("png")||ext.equalsIgnoreCase("jpeg"))
model.addObject("bupload", true);
model.addObject("currentPage", currentPage);
return model;
}
}
- 업로드한 파일이 이미지 파일이 아닐 수 있으므로, 파일의 확장자를 구분하여 조건 지정
- 파일의 확장자가 이미지 관련이면 특정
boolean
데이터를 View에 전달 (bupload
, true
)
- 확장자명은 대소문자 구분을 무시하기 위해
equalsIgnoreCase()
사용
- 전체 목록은 Pagination 처리를 할 것이므로 현재 페이지 데이터(
currentPage
)를 항상 넘겨주고 받아야 함
Download File
Controller
@Controller
public class DownloadController {
@GetMapping("/memboard/download")
public void download(HttpServletRequest request,
HttpServletResponse response,
@RequestParam String clip)
{
String path=request.getSession().getServletContext().getRealPath("/savefile");
File file=new File(path+"\\"+clip);
System.out.println("파일 경로:"+file);
setHeaderType(response, request, file);
try {
transport(new FileInputStream(file),
response.getOutputStream(), file);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
private void setHeaderType(HttpServletResponse response,
HttpServletRequest request,
File file)
{
String mime = request.getSession().getServletContext().getMimeType(file.toString());
if(mime != null)
mime = "application/octet-stream";
response.setContentType(mime);
response.setHeader("Content-Disposition",
"attachment;filename=" + toEng(file.getName()));
response.setHeader("Content-Length", "" + file.length());
}
private void transport(InputStream in, OutputStream out, File file)
throws IOException
{
BufferedInputStream bin = null;
BufferedOutputStream bos = null;
try{
bin = new BufferedInputStream(in);
bos = new BufferedOutputStream(out);
byte[] buf=new byte[(int)file.length()];
int read=0;
while((read = bin.read(buf)) != -1)
{
bos.write(buf, 0, read);
}
}catch(Exception e){
System.out.println("transport error : " + e);
}finally{
bos.close();
bin.close();
}
}
public String toEng(String str)
{
String tmp=null;
try{
tmp = new String(str.getBytes("utf-8"), "8859_1");
}catch(Exception e){}
return tmp;
}
}
- 외부 서버의 파일을 다운로드 받기 위한 Controller
- 다운로드 받을 파일명을
clip
변수로 받으므로 매핑 시 clip
으로 전달 (임의 지정 가능)
clip
변수로 해당 클래스의 매핑 주소(/memboard/download
)로 데이터 이동 시, 해당 데이터를 자동 다운로드
View_Detail Page
<c:if test="${dto.uploadfile!='no' }">
<span style="float: right"><a href="download?clip=${dto.uploadfile }">
<i class="bi bi-arrow-down-circle"></i> <b>${dto.uploadfile }</b></a>
</span>
</c:if>
<c:if>
조건으로 업로드한 파일이 있을 경우(DB에 저장된 파일 컬럼 데이터가 존재할 경우)만 다운로드 링크 출력
- 파일명(링크) 클릭 시
<a>
의 매핑 주소로 이동하여 해당 파일 자동 다운로드
- 매핑 주소가
/memboard/download
이므로 링크 주소는 download
- 전달 데이터를 Controller에서
clip
으로 받으므로 clip
을 넘겨주며, 그 값은 실제 저장 경로에 저장된 파일명(그대로 DB에 저장해놓음)

- 예시 이미지의 링크를 클릭 시, 저장 경로에 해당 이름으로 저장된 파일이 자동 다운로드 됨
<c:if test="${bupload}">
<img src="../savefile/${dto.uploadfile }">
</c:if>
- 업로드된 파일이 이미지가 아닐 경우,
src
경로가 올바르게 지정되어도 View에서는 xbox 출력
- 따라서 해당 파일이 이미지 파일인지 여부에 따른 조건문 생성
- Controller에서 파일의 확장자를 구분하여 특정
boolean
데이터 전달
- 전달한 데이터 조건(
bupload
)에 따라 파일을 <img>
src
경로에 삽입한 정보를 출력할지 결정


<c:if test="${sessionScope.loginok!=null }">
<button type="button" onclick="loaction.href='form'">글쓰기</button>
</c:if>
<c:if test="${sessionScope.loginok!=null and sessionScope.myid==dto.myid }">
<button type="button" onclick="loaction.href='updateform?num=${dto.num}'">수정</button>
<button type="button" onclick="loaction.href='delete?num=${dto.num}'">삭제</button>
</c:if>
<button type="button" onclick="loaction.href='list'">목록</button>
- 글쓰기 버튼 : 로그인한 경우에만 출력
- 수정, 삭제 버튼 : 로그인했으며, 로그인 아이디가 게시물 작성자 아이디와 동일한 경우만 출력
- 목록 버튼 : 항상 출력
Controller
@Controller
@RequestMapping("/memboard")
public class MemBoardController {
@Autowired
MemBoardService service;
@GetMapping("/list")
public ModelAndView list(@RequestParam(value = "currentPage",defaultValue = "1") int currentPage) {
ModelAndView model=new ModelAndView();
model.setViewName("/memboard/memList");
int totalcount=service.getTotalCount();
model.addObject("totalcount", totalcount);
int totalPage;
int startPage;
int endPage;
int startNum;
int perPage=10;
int perBlock=5;
totalPage=totalcount/perPage+(totalcount%perPage==0?0:1);
startPage=(currentPage-1)/perBlock*perBlock+1;
endPage=startPage+perBlock-1;
if(endPage>totalPage)
endPage=totalPage;
startNum=(currentPage-1)*perPage;
int printNum=totalcount-startNum;
List<MemBoardDto> list=service.getList(startNum, perPage);
model.addObject("list", list);
model.addObject("currentPage", currentPage);
model.addObject("totalpage", totalPage);
model.addObject("startpage", startPage);
model.addObject("endpage", endPage);
model.addObject("startnum", startNum);
model.addObject("perpage", perPage);
model.addObject("perblock", perBlock);
model.addObject("printnum", printNum);
return model;
}
}
- 목록의 현재 페이지 데이터(
currentPage
)는 항상 연속적으로 전달 (초기 데이터는 null
이므로 defaultValue=1
로 지정 : 첫 시작 시 1페이지)
- Pagination에 필요한 데이터 생성 및 전달
- 전체 페이지 수 : Sql을 통해 전체 데이터의
count(*)
조회
- 페이징 블럭(
perPage
) 당 시작 페이지 :
- 페이징 블럭에 필요한 페이지 개수는 양의 정수를
perPage
로 나누었을 때 도출 가능한 나머지 개수
/
연산자를 통해 나머지 제거 후 *
연산을 하여 나머지 제거 (동일한 몫을 가진 페이지는 동일한 페이징 블럭에 속함)
- 현재 페이지
-1
을 하지 않으면 perPage
의 배수에 해당하는 페이지의 경우, 해당 페이지보다 +1
인 페이지가 시작 페이지(startPage
)가 되는 모순 발생
- 페이징 블럭 당 끝 페이지 : 첫 페이지
+
블럭 당 페이지 개수
- 페이지 별 시작 데이터의 순번 : 직전 페이지까지의 모든 데이터
+1
- Sql문에서는
limit a,b
의 a
에 해당
- 일반적으로 최신순(
desc
)으로 출력하므로 Sql 조회 결과의 순서와 View에서 출력하는 순서가 반대
- View의 출력 순번 : 전체 페이지
-
Sql 조회 결과의 순번
- 생성한 모든 데이터를 View에 전달하여 사용
View_Full List
<c:if test="${totalcount==0 }">
<tr>
<td colspan="5" align="center"><h4>등록된 글이 없습니다</h4></td>
</tr>
</c:if>
<c:if test="${totalcount!=0 }">
<c:forEach var="dto" items="${list }">
<tr>
<td>${printnum }</td>
<c:set var="printnum" value="${printnum-1 }"></c:set>
<td><a href="content?num=${dto.num }¤tPage=${currentPage}">${dto.subject }</a></td>
<td>${dto.name } (${dto.myid })</td>
<td>${dto.viewcount }</td>
<td><fmt:formatDate value="${dto.writeday }" pattern="yyyy-MM-dd"/>
</tr>
</c:forEach>
</c:if>
<c:forEach>
로 데이터 개수만큼 반복
- Controller에서 현재 페이지인
currentPage
에 해당하는 데이터만 조회하므로(limit
조건) 조회 데이터 전체 출력해도 자동으로 Pagination 처리됨
- JSTL은 증감 연산자가 없으므로
<c:set>
으로 반복 시마다 출력 순번을 조작해주어야 함
<!-- 페이징 -->
<c:if test="${totalcount>0 }">
<div style="width: 800px;text-align: center">
<ul class="pagination justify-content-center">
<!-- 이전 -->
<c:if test="${startPage>1 }">
<li class="page-item"><a href="list?currentPage=${startPage-1 }">이전</a></li>
</c:if>
<c:forEach var="pp" begin="${startPage }" end="${endPage }">
<c:if test="${pp==currentPage }">
<li class="page-item active">
<a class="page-link" href="list?currentPage=${pp }">${pp }</a>
</li>
</c:if>
<c:if test="${pp!=currentPage }">
<li class="page-item">
<a class="page-link" href="list?currentPage=${pp }">${pp }</a>
</li>
</c:if>
</c:forEach>
<!-- 다음 -->
<c:if test="${endPage<totalPage }">
<li class="page-item"><a href="list?currentPage=${endPage+1 }">다음</a></li>
</c:if>
</ul>
</div>
</c:if>
- Pagination 자체의 출력 조건 : 전체 데이터가 하나 이상 존재
- ‘이전(←)’ 버튼
- 출력 조건 : 페이징 블럭이 최초 값(첫번째 블럭)이 아닐 경우
- 클릭 시 이벤트 : 현재 블럭의 시작 페이지 직전 페이지로 이동
- ‘다음(→)’ 버튼
- 출력 조건 : 페이징 블럭이 최후 값(마지막 블럭)이 아닐 경우
- 클릭 시 이벤트 : 현재 블럭의 끝 페이지 직후 페이지로 이동
- 중간 페이지 : 블럭 당 시작 페이지부터 끝 페이지까지 전부 출력
- 출력되는 페이지 중 현재 페이지와 같은 페이지는 BootStrap 효과를 통해 구별