[SpringBoot] 게시판 구현하기 12 (파일 업로드와 다운로드)
FRAMEWORK/Spring

[SpringBoot] 게시판 구현하기 12 (파일 업로드와 다운로드)

반응형

들어가기 전에

하기 포스팅은 "스프링부트 시작하기(김인우 저)" 책을 공부하며 적은 포스팅입니다. 이번 포스팅에서는 파일 업로드와 다운로드에 대해 살펴보도록 하겠습니다.

 

웹 어플리케이션에서 파일 관련 기능은 매우 중요합니다. 일반적인 게시판만 해도 파일을 첨부할 수 있고, 대다수의 SNS는 이미지와 함께 글을 작성합니다. 쉬워보일 수 있으나, 내부적으로는 구현해야 할 기능이 많아 여러 가지를 생각해야 합니다. 예를 들어 사용자 편의를 위해 파일 업로드 시 드래그 앤 드롭 기능, 첨부파일의 유효성 검사, 파일 전송의 진행률이나 예외처리 등 고려할 것들이 많습니다.

 

여기서는 파일 업로드 및 다운로드 시 핵심적인 내용에 대해서만 다룰 것입니다.

파일 첨부를 위한 기본 설정

먼저 파일 업로드와 다운로드 기능 구현 전, 환경 구축이 필요합니다. 먼저 파일 정보를 저장할 테이블을 생성하고 기본 설정을 해두어야 합니다. 파일 관련 기능은 스프링 프레임워크가 제공하는 기능이 아닌, 아파치의 파일 관련 라이브러리를 사용하겠습니다. 

파일 테이블 생성하기

아래 쿼리를 실행하여 파일 정보를 저장하는 테이블을 생성하겠습니다.

CREATE TABLE t_file (
	idx int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '일련번호',
	board_idx int(10) unsigned NOT NULL COMMENT '게시글 번호',
	original_file_name varchar(255) NOT NULL COMMENT '원본 파일 이름',
	stored_file_path varchar(500) NOT NULL COMMENT '파일 저장 경로',
	file_size int(15) unsigned NOT NULL COMMENT '파일 크기',
	creator_id varchar(50) NOT NULL COMMENT '작성자 아이디',
	created_datetime datetime NOT NULL COMMENT '작성시간',
	updater_id varchar(50) DEFAULT NULL COMMENT '수정자 아이디',
	updated_datetime datetime DEFAULT NULL COMMENT '수정시간',
	deleted_yn char(1) NOT NULL DEFAULT 'N' COMMENT '삭제 여부',
	PRIMARY KEY (idx)
);

DBeaver를 이용한 테이블 생성(사진 속 updator_id는 updater_id로 작성해야 함)

라이브러리 추가하기

스프링 프레임워크에는 파일 업로드를 위한 MultipartResolver 인터페이스가 정의되어 있습니다. 따라서 파일 업로드 기능 구현 시 해당 인터페이스를 사용하면 됩니다.

스프링에서 일반적으로 사용되는 MultipartResolver 인터페이스의 구현체는 아래와 같습니다.

  • 아파치의 Common Fileupload를 이용한 CommonMultipartResolver
  • 서블릿 3.0 이상의 API를 이용한 StandartServletMultipartResolver

여기서는 파일 업르도에 관련된 여러 기능을 지원해주는 아파치의 Common Fileupload를 사용합니다. buid.gradle 파일에 아래와 같이 commons-fileupload와 commons-io 라이브러리를 추가합니다. 필자의 경우, 실습날짜 기준 최신 라이브러리로 추가했습니다.

// build.gradle
...
	implementation 'commons-io:commons-io:2.8.0'
	implementation 'commons-fileupload:commons-fileupload:1.4'
...

라이브러리 추가

파일 처리를 위한 빈 설정하기

파일 업로드를 하기 위한 스프링 빈을 설정하도록 하겠습니다. 아파치의 Common Fileupload를 사용할 것이므로 CommonsMultipartResolver를 이용해 MultipartResolver를 구현하고 스프링 빈드로 등록해보겠습니다.

 

먼저, WebMvcConfiguration 클래스에 아래와 같이 코드를 추가합니다.

package board.configuration;

import org.springframework.context.annotation.Bean; //add
import org.springframework.context.annotation.Configuration;
import org.springframework.web.multipart.commons.CommonsMultipartResolver; //add
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import board.interceptor.LoggerInterceptor;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(new LoggerInterceptor());
	}
	
	// ADD START
	@Bean
	public CommonsMultipartResolver multipartResolver() {
		CommonsMultipartResolver commonsMultipartResolver = new CommonsMultipartResolver();
		commonsMultipartResolver.setDefaultEncoding("UTF-8");
		commonsMultipartResolver.setMaxUploadSizePerFile(5 * 1024 * 1024);
		return commonsMultipartResolver;
	}
	// ADD END
}
  • commonsMultipartResolver.setDefaultEncoding("UTF-8"): 파일 인코딩을 UTF-8로 설정합니다.
  • commonsMultipartResolver.setMaxUploadSizePerFile(5 * 1024 * 1024): 업로드되는 파일 크기를 제한합니다. 바이트 단위 설정이므로 여기서는 5MB 제한을 의미합니다.

파일 관련 자동 구성 제거하기

스프링부트의 특성 중 하나는 어플리케이션의 스프링 설정이 자동으로 구성된다는 점입니다. 앞서 multipartResolver를 등록했기 때문에 첨부파일 관련 자동 구성을 사용하지 않도록 해야 합니다(아파치로 구현할 것이기 때문). 스프링부트에서 자동으로 구성된 요소들 중, 첨부파일 관련 구성을 사용하지 않도록 하기 위해서는 BoardApplication 클래스의 @SpringBootApplication 어노테이션을 아래와 같이 변경해야 합니다.

// AS-IS
package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardApplication {

	public static void main(String[] args) {
		SpringApplication.run(BoardApplication.class, args);
	}

}

// TO-BE
package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration;

@SpringBootApplication(exclude={MultipartAutoConfiguration.class})
public class BoardApplication {

	public static void main(String[] args) {
		SpringApplication.run(BoardApplication.class, args);
	}

}

아래 포스팅에서 @SpringBootApplication 어노테이션에 대해 이야기한 적이 있습니다. 

earth-95.tistory.com/30?category=915270

 

[SpringBoot] Springboot 프로젝트 만들어보기

시작하기 전에 하기 포스팅은 "스프링부트 시작하기(김인우 저)" 책을 공부하며 적은 포스팅입니다. SpringBoot의 장점 Spring 프레임워크는 웹 어플리케이션에서 사용되는 많은 기능을 제공합니다.

earth-95.tistory.com

  • @SpringBootApplication은 @SpringBootConfiguration, @ComponentScan, @EnableAutoConfiguration 세 개의 어노테이션으로 구성되어 있습니다.
  • 이때 @EnableAutoConfiguration은 스프링부트의 자동 구성을 사용할 때 exclude를 이용해 특정 자동구성을 사용하지 않도록 할 수 있습니다. 위에서는 MultipartAutoConfiguration 클래스를 자동 구성하지 않도록 설정한 것입니다.

파일 업로드

위에서 기본 설정을 하였으므로, 위 설정을 이용해 파일 업로드 기능을 구현해보도록 하겠습니다.

파일 업로드 및 파일 정보 확인하기

먼저 서버에 파일을 업로드하고 업로드된 파일의 정보를 간단히 출력해보겠습니다. 첨부파일을 전송하는 일은 사용자 화면에서 업로드할 파일을 선택하는 것부터 시작하므로 뷰, 컨트롤러, 서비스 순서로 진행합니다.

 

뷰 변경하기

boardWrite.html 파일을 열고 아래와 같이 수정합니다.

...
		<form id="frm" name="frm" method="post" action="/board/insertBoard.do" enctype="multipart/form-data">
			...
			<input type="file" id="files" name="files" multiple="multiple">
			<input type="submit" id="submit" value="저장" class="btn">
		</form>
	</div>
</body>
</html>
  • <fori id="frm" method="post" action="/board/insertBoard.do" enctype="multipart/form-data">: 폼을 이용해 데이터를 전송할 때 파일도 함께 첨부될 수 있도록 enctype 속성을 multipart/form-data로 지정합니다. 이때, 폼의 전송 방식은 반드시 post로 지정해야 합니다.
  • <input type="file" id="files" name="files" multiple="multiple">: 파일을 첨부할 수 있도록 파일 첨부 태그를 추가합니다. multiple 속성을 추가하면 하나의 태그에서 여러 파일을 첨부할 수 있습니다.
    • 이때, multiple 속성을 추가해도 HTML5를 지원하지 않는 브라우저에서는 하나의 파일만 추가가 가능합니다(익스플로러 10 이전 버전).

컨트롤러 변경하기

BoardController 클래스의 insertBoard를 아래와 같이 수정합니다.

...
import org.springframework.web.multipart.MultipartHttpServletRequest;
...
		@RequestMapping("/board/insertBoard.do")
		public String insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
			boardService.insertBoard(board, multipartHttpServletRequest);
			return "redirect:/board/openBoardList.do";
		}
...
  • public String insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception: MultipartHttpServletRequest가 파라미터로 추가되었습니다. MultipartHttpServletRequest는 ServletRequest를 상속받아 구현된 인터페이스로 업로드된 파일을 처리하기 위한 여러 메소드를 제공합니다.

서비스 변경하기

BoardService 인터페이스와 BoardServiceImple 클래스의 insertBoard 메소드를 아래와 같이 수정합니다.

// BoardService.java
...
import org.springframework.web.multipart.MultipartHttpServletRequest;
...
	void insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception;
...

// BoardServiceImpl.java
package board.board.service;

import java.util.Iterator;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import board.board.dto.BoardDto;
import board.board.mapper.BoardMapper;

import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class BoardServiceImpl implements BoardService {
...
	@Override
	public void insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
		//boardMapper.insertBoard(board);
		if(ObjectUtils.isEmpty(multipartHttpServletRequest) == false) {
			Iterator<String> iterator = multipartHttpServletRequest.getFileNames();
			String name;
			while(iterator.hasNext()) {
				name = iterator.next();
				log.debug("file tag name : " + name);
				List<MultipartFile> list = multipartHttpServletRequest.getFiles(name);
				for(MultipartFile multipartFile : list) {
					log.debug("start file information");
					log.debug("file name : " + multipartFile.getOriginalFilename());
					log.debug("file size : " + multipartFile.getSize());
					log.debug("file content type : " + multipartFile.getContentType());
					log.debug("end file information.\n");
				}
				
			}
		}
	}
...
  • //boardMapper.insertBoard(board): 업로드된 파일의 정보를 확인하는 목적이므로 게시글이 저장되지 않도록 주석처리합니다.
  • Iterator<String> iterator = multipartHttpServletRequest.getFileNames()
    • boardWrite.html에서 파일 첨부 시 사용하는 파일 태그를 생각해보면, files라는 이름(name)으로 서버에 전송되고 하나의 파일 태그로 여러 파일을 전송할 수 있습니다. 
    • 하지만, 화면 구성에 따라 파일 태그가 여러 개 있을 수도 있고, 화면에서 사용되는 파일 태그의 이름을 알 수 없을 수도 있습니다.
    • 또한, 파일 태그의 이름을 알더라도 해당 이름을 코드에 직접 사용하면 추후 파일 태그명이 변경되면 기존에 작성한 코드를 변경해야 하는 불편함이 있습니다.
    • 이런 문제를 해결하기 위해 MultipartHttpServletRequest는 getFileNames라는 메소드를 제공합니다.
      • 이 메소드를 사용하면, 서버로 한번에 전송되는 한 개 이상의 파일 태그 이름을 iterator 형식으로 가져올 수 있습니다.
  • List<MultipartFile> list = multipartHttpServletRequest.getFiles(name): 앞서 가저온 파일 태그 이름을 이용파일 태그에서 선택된 파일을 가져옵니다. 파일 태그는 multiple 속성을 설정함으로써 여러 개의 파일이 첨부될 수 있어 list 형태로 파일 목록을 받아왔습니다.
  • for(MultipartFile multipartFile : list): 받아온 파일 정보를 표시합니다. 업로드된 파일은 MultipartFile 인터페이스로 표현됩니다.

파일 업로드 및 파일 정보 확인이 제대로 되는지 결과 보기

게시글 작성화면에서 파일 선택하여 파일 첨부

multiple로 파일 첨부가 가능한지 체크하기 위해 파일 2개를 첨부했습니다. 위 사진에서 볼 수 있듯이 파일 2개가 첨부가 되었음을 확인할 수 있습니다. 파일 첨부한 후 저장 버튼을 클릭하면 아래와 같이 이클립스 콘솔창에 로그가 찍히는 것을 확인할 수 있습니다.

파일 정보 로그 확인

BoardServiceImpl 클래스에서 insertBoard 메소드 구현했던것처럼 각 파일의 이름과 크기, 파일 형식 등을 로그로 보여주고 있습니다.

또한, 로그 최상단에서는 file tag name : files라고 보여주고 있는데, 이는 앞서 설명했던 multipartHttpServletRequest의 getFileNames 메소드를 통해 첨부된 파일 태그를 확인할 수 있음을 보여줍니다.

  • 위 실습에서는 file tag를 한 가지만 사용해서 사실상 iterator가 가리키는 file tag는 하나지만, 첨부 파일 등록 버튼을 여러 개 생성해 file tag의 name을 서로 달리해주면 iterator가 가리키는 file tag명이 여러개임을 확인할 수 있을 것입니다.

업로드된 파일을 서버에 저장하기

앞서한 실습에서는 BoardServiceImpl 클래스의 insertBoard 메소드에서 boardMapper.insertBoard(board)를 주석처리했습니다. 이로 인해 사실상 업로드된 파일이 서버에 저장되지 않았습니다. 따라서, 실제 업로드된 파일을 서버에 저장해보도록 하겠습니다.

 

첨부파일 DTO 생성하기

먼저 첨부파일의 정보를 저장하는 BoardFileDto 클래스를 board.bard.dto 패키지 내에 생성합니다.  그 후 하기와 같이 작성해줍니다.

package board.board.dto;

import lombok.Data;

@Data
public class BoardFileDto {
	private int idx;
	private int boardIdx;
	private String originalFileName;
	private String storedFilePath;
	private long fileSize;
}

 

파일 처리를 위한 클래스 생성하기

다음으로 첨부파일 정보를 가공하고 지정된 위치에 파일을 저장하는 기능을 만들어야 합니다. 먼저 coomon 패키지에 FileUtils 클래스를 생성하고 하기 내용을 작성합니다.

package board.common;

import java.io.File;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import board.board.dto.BoardFileDto;

@Component
public class FileUtils {

	public List<BoardFileDto> parseFileInfo(int boardIdx, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
		if(ObjectUtils.isEmpty(multipartHttpServletRequest)) {
			return null;
		}
		
		List<BoardFileDto> fileList = new ArrayList<>();
		DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyyMMdd");
		ZonedDateTime current = ZonedDateTime.now();
		String path = "images/" + current.format(format);
		File file = new File(path);
		if(file.exists() == false) {
			file.mkdirs();
		}
		
		Iterator<String> iterator = multipartHttpServletRequest.getFileNames();
		
		String newFileName, originalFileExtension, contentType;
		
		while(iterator.hasNext()) {
			List<MultipartFile> list = multipartHttpServletRequest.getFiles(iterator.next());
			for(MultipartFile multipartFile : list) {
				if(multipartFile.isEmpty() == false) {
					contentType = multipartFile.getContentType();
					if(ObjectUtils.isEmpty(contentType)) {
						break;
					}
					else {
						if(contentType.contains("image/jpeg")) {
							originalFileExtension = ".jpg";
						}
						else if(contentType.contains("image/png")) {
							originalFileExtension = ".png";
						}
						else if(contentType.contains("image/gif")) {
							originalFileExtension = ".gif";
						}
						else {
							break;
						}
					}
					
					newFileName = Long.toString(System.nanoTime()) + originalFileExtension;
					BoardFileDto boardFile = new BoardFileDto();
					boardFile.setBoardIdx(boardIdx);
					boardFile.setFileSize(multipartFile.getSize());
					boardFile.setOriginalFileName(multipartFile.getOriginalFilename());
					boardFile.setStoredFilePath(path + "/" + newFileName);
					fileList.add(boardFile);
					
					file = new File(path + "/" + newFileName);
					multipartFile.transferTo(file);
				}
			}
		}
		return fileList;
	}
}
  • @Component: FileUtils 클래스를 스프링의 빈으로 등록합니다.
  • File file = new File(path): 파일이 업로드될 폴더를 생성합니다. 여기서는 파일이 업로드될 떄마다 images 폴더 밑에 yyyyMMdd 형식으로 폴더를 생성합니다. 이때, 해당 폴더가 존재하지 않으면 폴더를 생성합니다.
  • contentType = multipartFile.getContentType(): 파일 형식을 확인하고 그에 따라 이미지 확장자를 지정합니다. 파일 확장자를 파일 이름에서 가져오는 방식을 간혹 사용하는데, 이렇게 확장자를 확인하는 방식은 위험합니다. 왜냐하면 확장자는 쉽게 바꿀 수 있기 떄문에 실제 파일 형식과 확장자가 다를 수 있고, 파일의 위변조를 확인할 수 없기 때문입니다.
    • 실제 개발을 할 때에는 nio 패키지를 이용하거나 Apache Tika와 같은 라이브러리를 이용하여 파일 형식을 확인합니다.
  • newFileName = Long.toString(System.nanoTime()) + originalFileExtension: 서버에 저장될 파일 이름을 생성합니다. 서버에 같은 이름의 파일이 있다면 업로드된 파일이 정상적으로 저장되지 않기 때문에, 절대 중복되지 않을 이름으로 바꿔줍니다. 여기서는 파일이 업로드된 나노초를 이용해 새로운 파일 이름으로 지정했습니다.
  • boardFile.setBoardIdx(boardIdx): 데이터베이스에 저장할 파일 정보를 앞에서 만든 BoardFileDto에 저장합니다. 업로드된 파일을 추후 화면에 표시하기 위해 파일의 원래 이름, 파일의 크기, 파일이 저장된 경로를 저장합니다. 또한, 해당 파일이 어떤 게시글에 속해 있는지 알 수 있도록 게시글 번호도 같이 저장합니다.
  • multipartFile.transferTo(file): 업로드된 파일을 새로운 이름으로 바꾸어 지정된 경로에 저장합니다.

서비스 및 매퍼 변경하기

앞서 만든 FileUtils 클래스를 이용해 게시판 내용을 저장하고 파일 정보도 같이 저장하도록 변경하기 위해 BoardServiceImpl 클래스를 수정합니다.

// BoardServiceImpl.java
...
import board.board.dto.BoardFileDto;
...
import board.common.FileUtils;
...

@Service
@Slf4j
public class BoardServiceImpl implements BoardService {
...
	@Autowired
	private FileUtils fileUtils;    
...
	@Override
	public void insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
		boardMapper.insertBoard(board);
		
		List<BoardFileDto> list = fileUtils.parseFileInfo(board.getBoardIdx(), multipartHttpServletRequest);
		if(CollectionUtils.isEmpty(list) == false) {
			boardMapper.insertBoardFileList(list);
		}
	}
  • @Autowired privvate FileUtils fileUtils: FileUtils 클래스를 사용할 수 있도록, 먼저 FileUtils 클래스를 주입해줍니다.
  • boardMapper.insertBoard(board): 게시글을 등록합니다. 먼저 게시글을 등록한 후 등록된 게시글 번호를 이용해 파일을 저장합니다.
  • List<BoardFileDto> list = fileUtils.parseFileInfo(board.getBoardIdx(), multipartHttpServletRequest): 앞서 만든 fileUtils 클래스를 이용해 업로드된 파일을 서버에 저장학 파일 정보를 가져옵니다.
  • boardMapper.insertBoardFileList(list): 파일 정보를 데이터베이스에 저장합니다.

매퍼의 경우 앞서 서비스 변경 시 추가한 insertBoardFileList 메소드를 추가하면 됩니다.

// BoardMapper.java
import board.board.dto.BoardFileDto;
...
	void insertBoardFileList(List<BoardFileDto> list) throws Exception;

 

SQL 변경 및 추가하기

먼저, 게시글을 등록하는 insertBoard 쿼리에서 <insert> 태그 부분을 아래와 같이 변경합니다.

// sql-board.xml
// AS-IS
	<insert id="insertBoard" parameterType="board.board.dto.BoardDto">
// TO-BE
	<insert id="insertBoard" parameterType="board.board.dto.BoardDto" useGeneratedKeys="true" keyProperty="boardIdx">
  • usedGeneratedKeys: useGeneratedKeys 속성은 DBMS가 자동 생성키(MySQL의 경우 Auto Increment)를 지원할 때 사용할 수 있습니다.
  • keyProperty: useGeneratedKeys나 selectKey의 하위 엘리먼트에 의해 리턴되는 키를 의미합니다. 게시글의 경우 board_idx 컬럼이 PK이면서 자동 생성되므로 이 컬럼을 사용합니다.
    • 즉, 데이터베이스에서 새로운 게시글이 등록되면 파라미터인 BoardDto 클래스의 boardIdx에 새로운 게시글 번호가 저장되어 반환(return)됩니다. 따라서, 서비스 영역에서 따로 로직을 작성하지 않아도 해당 쿼리의 결과로 게시글 번호를 얻을 수 있습니다.

다음으로는 파일 정보를 저장하는 쿼리를 추가합니다.

...
	<insert id="insertBoardFileList" parameterType="board.board.dto.BoardFileDto">
		<![CDATA[
			INSERT INTO t_file
			( 
				board_idx, 
				original_file_name, 
				stored_file_path, 
				file_size, 
				creator_id, 
				created_datetime
			)
			VALUES	
		]]>
		<foreach collection="list" item="item" separator=",">
		(
			#{item.boardIdx},
			#{item.originalFileName},
			#{item.storedFilePath},
			#{item.fileSize},
			'admin',
			NOW()
		)
		</foreach>
	</insert>
  • <foreach collection="lists" item="item" separator=",">: 파일 목록은 하나 이상이므로 myBatis의 foreach 문을 사용해 collection의 반복처리를 합니다.
    • collection 속성은 전달받은 인자를 의미하며, List나 Array 형식의 데이터를 사용합니다.
    • item 속성은 전달받은 인자의 별칭입니다. 이 별칭을 이용해 collection 데이터에 접근합니다.
    • separator는 반복되는 문자열을 구분하기 위해 사용합니다. collection은 1개 이상의 목록을 가지는데, 각 목록을 구분해야 합니다. 여기서는 INSERT문의 VALUES 값을 구분하는 데 사용됩니다.
      • VALUES가 여러 개인 경우 (저장할 값), (저장할 값)과 같이 구분이 필요한데 separator를 사용하면 자동으로 구분자를 넣어줍니다.

업로드된 파일 서버에 저장 결과 확인하기

파일 업로드하여 게시판 등록

아직 사용자 화면단에서 첨부된 파일이 무엇이 있는지 보는 기능이 추가되지 않아 화면에서는 확인이 어렵습니다. 대신, 데이터베이스를 조회해보면 첨부한 파일이 제대로 올라가 있는 것을 확인할 수 있습니다.

첨부한 파일이 데이터베이스에 저장된 것 확인

소스단에서 지정한 stored_file_path에 파일이 제대로 들어가 있는지 확인해 보도록 하겠습니다. 앞서 파일은 images/yyyyMMdd/nano초시간.확장자로 저장하기로 했습니다. 따로 절대경로를 지정하여 파일을 저장하지 않았으므로, images 폴더는 프로젝트가 위치한 폴더 내 생성됩니다. 

  • 실제 프로젝트에서는 주로 이미지와 같은 첨부파일을 저장할 용도로 스토리지(예: NAS)를 따로 사용하기 때문에, 해당 경로로 저장될 수 있도록 경로설정을 합니다.

파일 업로드 완료

첨부된 파일 목록 화면에서 보여주기

이제 실제 파일 업로드까지는 완료했습니다. 이번에는 게시글 상세 화면에서 첨부된 파일 목록을 표시해보겠습니다. 파일 목록을 보여주기 위해서는 게시글의 상세 내용을 조회할 때 파일 목록 역시 조회해야 합니다. 따라서, 이번에는 파일 등록과는 반대로 쿼리부터 작업을 진행하겠습니다.

 

SQL 추가하기

// sql-board.xml
	<select id="selectBoardFileList" parameterType="int" resultType="board.board.dto.BoardFileDto">
		<![CDATA[
			SELECT
				idx,
				board_idx,
				original_file_name,
				FORMAT(ROUND(file_size / 1024), 0) AS file_size
			FROM
				t_file
			WHERE
				board_idx = #{boardIdx}
				AND deleted_yn = 'N'
		]]>
	</select>
  • 파일 크기는 KB로 보여주기 위해 바이트 단위를 KB 단위로 변경했습니다.

BoardDto 변경하기

// BoardDto.java
...
import java.util.List;
...
	private List<BoardFileDto> fileList;

 

서비스 및 매퍼 변경하기

서비스에서는 게시글 상세내용을 조회할 때, 파일 목록을 조회하는 쿼리로 첨부파일 목록을 조회하는 로직을 추가하면 됩니다. BoardServiceImpl 클래스의 selectBoardDetail 메소드를 아래와 같이 수정합니다.

// BoardServiceImpl.java
...
	@Override
	public BoardDto selectBoardDetail(int boardIdx) throws Exception {
		BoardDto board = boardMapper.selectBoardDetail(boardIdx);
		List<BoardFileDto> fileList = boardMapper.selectBoardFileList(boardIdx);
		board.setFileList(fileList);
		
		boardMapper.updateHitCount(boardIdx);
		
		return board;
	}
...
  • BoardDto board = boardMapper.selectBoardDetail(boardIdx): 게시글 내용을 조회합니다.
  • List<BoardFileDto> fileList = boardMapper.selectBoardFileList(boardIdx): 게시글 번호로 게시글 첨부파일 목록을 조회합니다.
  • board.setFileList(fileList): 게시글 정보를 담고 있는 BoardDto 클래스에 조회된 첨부파일 목록을 저장합니다.
// BoardMapper.java
...
	List<BoardFileDto> selectBoardFileList(int boardIdx) throws Exception;
...

 

뷰 변경하기

이제 뷰에서 첨부파일 목록을 표시하도록 하겠습니다. boardDetail.html 파일을 열어 아래와 같이 내용을 추가합니다.

...
		</form>
		
		<div class="file_list">
			<a th:each="list : ${board.fileList}" th:text="|${list.originalFileName}(${list.fileSize} kb)|"></a>
		</div>
		
		<input type="button" id="list" value="목록으로">
...
  • 게시글 목록을 표시할 때와 마찬가지로 th:each를 이용해 받아온 첨부파일 목록을 표시합니다.
  • Thymeleaf에서는 | 기호를 사용해 변수와 고정된 문자열을 혼합하여 출력할 수 있습니다. 

첨부된 파일 목록 게시글 상세 화면에서 보여주기 결과 확인하기

첨부파일 목록 출력 확인

파일 다운로드

마지막으로 첨부파일을 다운로드하는 방법에 대해 알아보겠습니다. 앞선 실습에서는 서버에서 조회된 정보가 뷰로 전달되었습니다. 파일 다운로드의 경우는, 뷰로 전달되는 것이 아닌 반환값(response)을 직접 변경합니다. 반환값은 DB에서 조회된 첨부파일 정보를 이용해 컨트롤러에서 처리를 합니다. 따라서, 이번에는 다운받을 파일을 선택할 수 있도록 뷰를 개발하고, 쿼리로부터 서비스, 컨트롤러 순서로 진행하겠습니다.

뷰 영역

먼저 파일을 다운받을 수 있도록 파일 목록에 링크를 추가합니다. boardDetail.html을 열고 파일 목록을 보여 주는 <a> 태그를 아래와 같이 변경합니다.

// AS-IS
			<a th:each="list : ${board.fileList}" th:text="|${list.originalFileName}(${list.fileSize} kb)|"></a>
// TO-BE
			<a th:each="list : ${board.fileList}" th:href="@{/board/downloadBoardFile.do(idx=${list.idx}, boardIdx=${list.boardIdx})}" th:text="|${list.originalFileName}(${list.fileSize} kb)|"></a>
  • th:href 속성을 추가하여 /board/downloadBoardFile.do를 호출하도록 변경했습니다. 이때, 선택된 파일을 다운로드하기 위해 필요한 파라미터 두 개를 추가해주었습니다.
    • idx는 파일 번호를 의미하며, boardIdx는 게시글 번호를 의미합니다.
    • Thymeleaf에서 href 태그에 여러 파라미터를 추가하려면 (idx=${list.idx}, boardIdx=${list.boardIdx})와 같이 콤마를 이용해서 구분해줍니다.
      • 이렇게 넣어주면, 브라우저에서 해당 화면이 호출될 때 /board/downloadBoardFile.do?idx=파일 번호&boardIdx=글번호와 같이 파라미터가 추가되어 호출됩니다.

href 설정

SQL 추가하기

sql-board.xml에 파일 정보를 조회하는 쿼리를 추가합니다.

	<select id="selectBoardFileInformation" parameterType="map" resultType="board.board.dto.BoardFileDto">
		<![CDATA[
			SELECT
				original_file_name,
				stored_file_path,
				file_size
			FROM
				t_file
			WHERE
				idx = #{idx}
				AND board_idx = #{boardIdx}
				AND deleted_yn = 'N'
		]]>
	</select>
  • parameterType="map": parameterType으로 map을 사용했습니다. 어플리케이션을 개발할 때 쿼리의 파라미터 전달을 위한 목적만으로 DTO를 만들기 애매한 경우가 있습니다. 이럴 경우 map을 이용해 파라미터를 사용(myBatis가 지원하는 기능)하면 됩니다.

서비스 및 매퍼 추가하기

서비스에서는 특별히 처리할 로직이 없습니다. 하기 코드만 추가하면 됩니다.

// BoardService.java
...
import board.board.dto.BoardFileDto;
...
	BoardFileDto selectBoardFileInformation(int idx, int boardIdx) throws Exception;
}

// BoardServiceImpl.java
...
	@Override
	public BoardFileDto selectBoardFileInformation(int idx, int boardIdx) throws Exception {
		return boardMapper.selectBoardFileInformation(idx, boardIdx);
	}

매퍼 역시 하기 부분만 추가해주면 됩니다.

// BoardMapper.java
...
import org.apache.ibatis.annotations.Param;
...
	BoardFileDto selectBoardFileInformation(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
  • 앞서 쿼리를 추가할 때, parameterType으로 map을 파라미터 타입으로 사용할 수 있다고 했습니다.
  • @Param("idx") int idx: 어플리케이션을 개발하다보면 쿼리의 파라미터가 2~3개인 경우, 이를 위해 DTO를 만들기에는 애매할 수 있습니다. 이럴 때 @Param 어노테이션을 사용하면 해당 파라미터들이 Map에 저장되어 DTO를 만들지 않아도 여러 파라미터를 전달할 수 있습니다.

컨트롤러 변경하기

이제 컨트롤러에 파일 다운로드 기능을 추가하겠습니다. 먼저 BoardController 클래스에 조회된 파일 정보를 이용해 실제로 사용자에게 파일을 전송하는 메소드를 추가합니다.

package board.board.controller;

import java.io.File;
import java.net.URLEncoder;
import java.util.List;

import javax.servlet.http.HttpServletResponse;

import board.board.dto.BoardDto;
import board.board.dto.BoardFileDto;
import board.board.service.BoardService;

import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.ModelAndView;

...
		@RequestMapping("board/downloadBoardFile.do")
		public void downloadBoardFile(@RequestParam int idx, @RequestParam int boardIdx, HttpServletResponse response) throws Exception {
			BoardFileDto boardFile = boardService.selectBoardFileInformation(idx, boardIdx);
			if(ObjectUtils.isEmpty(boardFile) == false) {
				String fileName = boardFile.getOriginalFileName();
				
				byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath()));
				
				response.setContentType("application/octet-stream");
				response.setContentLength(files.length);
				response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(fileName, "UTF-8")+"\";");
				
				response.getOutputStream().write(files);
				response.getOutputStream().flush();
				response.getOutputStream().close();
			}
		}
  • HttpServletResponse 객체를 파라미터로 사용합니다. 사용자로부터 들어오는 모든 요청 정보를 담고 있는 HttpServletRequest 클래스와 반대로 HttpServletResponse 클래스는 사용자에게 전달할 데이터담고 있습니다.
    • 따라서, HttpServletResponse 클래스에 적절한 설정을 하면 사용자에 전달할 결과값을 원하는대로 만들거나 변경할 수 있습니다.
  • BoardFileDto boardFile = boardService.selectBoardFileInformation(idx, boardIdx): 데이터베이스에서 선택된 파일의 정보를 조회합니다.
  • byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath())): selectBoardFileInformation 메소드를 통해 조회된 파일의 정보 중 storedFilePath 값을 이용해 실제 저장되어 있는 파일을 읽어온 후, byte[] 형태로 변환합니다.
    • FileUtils 클래스는 org.apache.commons.io 패키지의 FileUtils를 사용합니다.
  • response.setContentType("application/octet-stream"): response의 헤더에 contentType, 크기 및 형태를 설정합니다. 파일은 반드시 UTF-8로 인코딩해야 합니다. 
    • 간혹 인터넷에서 파일을 다운 받을 때, 깨진 이름으로 파일이 다운로드되는 경우, 인코딩이 되어 있지 않아 발생한 것입니다.
    • response.setXXX구문에서는 띄어쓰기와 대소문자에 주의해야 합니다.
  • response.getOutputStream().write(files): 앞에서 읽어온 파일 정보의 바이트 배열 데이터를 response에 작성합니다.
  • response.getOutputStream().flush(), response.getOutputStream().close(): response의 버퍼를 정리하고 닫아줍니다.

파일 다운로드 결과 확인하기

파일 다운로드

앞으로 알아봐야 할 내용

책에는 업로드한 파일을 삭제하는 방법에 대해서는 나와있지 않다. 추후 다른 포스팅에서 업로드한 파일을 삭제하는 방법을 찾아볼 예정이다. 게시글 삭제와 비슷한 방식으로 진행하면 될 것으로 보인다.

  • 업로드한 파일 옆에 삭제 버튼 생성(boardDetail.html)
    • 해당 버튼의 href로 deleteBoardFile.do와 같은 형식으로 설정
  • controller에서 해당 Request가 들어오면, deleteBoardFile 서비스 및 매퍼 작동하여 해당 파일 삭제여부에 Y 및 수정 날짜 변경할 수 있도록 함
  • 삭제된 후 어느 페이지를 보여줄 지 생각(detail 화면을 리프레시 시켜주면 될 것으로 보임)
  • 일단, 실제 업로드된 파일은 삭제되지 않고, 화면에서만 보이지 않도록 설정 예정
    • 사실상 실제 프로젝트에서는 바로 파일 삭제하지 않고 몇 년간 보관주기를 설정하기 때문에 일단 삭제하지 않는 것으로 생각

 

반응형