MultipartFile과 HttpServletRequest를 이용하여 파일을 업로드 및 다운로드 해보자
게시판을 구현하다보면 첨부파일 기능이 필요한 경우가 있습니다. 현재 진행중인 Spring 기반 프로젝트 버전과 맞는 첨부파일 핸들링을 해보며 관련 내용을 정리해보려고 합니다.
선요약 : NIO API를 이용하거나 전통적인 IO API를 이용할 수 있습니다.
테이블 구성
게시글 하나당 하나의 첨부파일만 업로드한다면 게시글 테이블 하나로도 구현이 가능하겠지만, 여러개의 첨부파일을 올리고 싶다면 첨부파일 테이블을 따로 구성해야 합니다.
저는 게시판 테이블의 이름을 HappyBoard라 정하였고, 이에 따라 HappyBoardAtfi라는 첨부파일 관리 테이블을 만들었습니다.
두 테이블에는 ATGP_SN(그룹파일번호) 컬럼을 두어 연결을 했습니다.
publicStringhappyFileUpload(MultipartFilefile,StringhappyAtgpSn)throwsException{StringfilePath="happyBoard/atch/";StringfileExt=file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf(".")+1).toLowerCase();StringfileName="happy"+"_"+System.nanoTime()+"."+fileExt;PathhappyFilePath=Paths.get(filePath,fileName);// 원본파일명이 없으면 패스if(file.getOriginalFilename().equals("")){happyAtgpSn="400";returnhappyAtgpSn;}try{//폴더 경로가 없다면 생성FilechkDir=newFile(filePath);if(!chkDir.exists()){chkDir.mkdirs();}// 파일 데이터가 있다면try(InputStreaminputStream=file.getInputStream()){// 파일 저장Files.copy(inputStream,happyFilePath,StandardCopyOption.REPLACE_EXISTING);// 파일 정보 객체에 저장HappyBoardAtfiVOhappyBoardAtfiVO=newHappyBoardAtfiVO();happyBoardAtfiVO.setHappyAtgpSn(happyAtgpSn);// 첨부파일 그룹 일련번호happyBoardAtfiVO.setHappyAtfiOgName(file.getOriginalFilename());// 첨부파일 원본명happyBoardAtfiVO.setHappyAtfiSfName(fileName);// 첨부파일 저장명happyBoardAtfiVO.setHappyAtfiExt(fileExt);// 첨부파일 확장자명happyBoardAtfiVO.setHappyAtfiUrl(filePath);// 첨부파일 저장 경로// 첨부파일 정보 등록happyAtgpSn=mypageService.insertHappyAtfiInfo(happyBoardAtfiVO);}}catch(NullPointerExceptionnp){np.printStackTrace();happyAtgpSn="400";}catch(IOExceptionie){ie.printStackTrace();happyAtgpSn="400";}catch(Exceptione){e.printStackTrace();happyAtgpSn="400";}returnhappyAtgpSn;}
위 메서드가 정상적으로 종료되어 happyAtgpSn을 return한다면 서버에 첨부파일이 저장되고, 첨부파일 테이블에도 관련 정보가 저장됩니다. 이후 happyAtgpSn을 게시글 데이터에 추가하여 게시글 테이블에 해당 게시글을 등록합니다(코드는 생략).
publicStringhappyFileUpload(MultipartFilefile,StringhappyAtgpSn)throwsException{StringfilePath="happyBoard/atch";//확장자명 가져오기StringfileExt=file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf(".")+1).toLowerCase();StringfileName="happy"+"_"+System.nanoTime()+"."+fileExt;// 저장할 경로의 파일객체를 생성(transferTo 메서드에 사용)FilehappyFile=newFile(filePath+fileName);// 원본파일명이 존재하는 경우if(!"".equals(file.getOriginalFilename())){happyAtgpSn="400";returnhappyAtgpSn;}try{// 파일 경로가 없다면 생성FilechkDir=newFile(filePath);if(!chkDir.exists()){chkDir.mkdirs();}// 임시 파일 저장 경로로 이동(서버에 저장)file.transferTo(happyFile);// 파일 정보 객체에 저장HappyBoardAtfiVOhappyBoardAtfiVO=newHappyBoardAtfiVO();happyBoardAtfiVO.setHappyAtgpSn(happyAtgpSn);// 첨부파일 그룹 일련번호happyBoardAtfiVO.setHappyAtfiOgName(file.getOriginalFilename());// 첨부파일 원본명happyBoardAtfiVO.setHappyAtfiSfName(fileName);// 첨부파일 저장명happyBoardAtfiVO.setHappyAtfiExt(fileExt);// 첨부파일 확장자명happyBoardAtfiVO.setHappyAtfiUrl(filePath);// 첨부파일 저장 경로// 첨부파일 정보 DB에 등록happyAtgpSn=mypageService.insertHappyAtfiInfo(happyBoardAtfiVO);}catch(NullPointerExceptionnp){np.printStackTrace();happyFile.delete();happyAtgpSn="400";}catch(IOExceptionie){ie.printStackTrace();happyFile.delete();happyAtgpSn="400";}catch(Exceptione){e.printStackTrace();happyFile.delete();happyAtgpSn="400";}returnhappyAtgpSn;}
publicvoidhappyFileDownload(HttpServletRequestrequest,HttpServletResponseresponse)throwsException{StringhappyAtfiSn=request.getParameter("happyAtfiSn");StringhappyAtgpSn=request.getParameter("happyAtgpSn");StringhappyAtfiOgName=request.getParameter("happyAtfiOgName");StringhappyAtfiSfName=request.getParameter("happyAtfiSfName");// 요청 파일명 확인if(happyAtfiSfName==null){return;}StringfilePath="happyBoard/atch/";Pathfile=Paths.get(filePath,happyAtfiSfName);// 파일이 없다면 종료if(!Files.exists(file)||!Files.isRegularFile(file)){return;}// 브라우저에 따른 인코딩Stringbrowser=request.getHeader("User-Agent");StringencodedFileName="";// 브라우저 종류에 따른 파일명 인코딩if(browser.contains("MSIE")||browser.contains("Trident")||browser.contains("Chrome")){// 브라우저 확인 파일명encodedFileName=URLEncoder.encode(happyAtfiOgName,"UTF-8").replaceAll("\\+","%20").replace("+","%20");}else{encodedFileName=newString(happyAtfiOgName.getBytes("UTF-8"),"ISO-8859-1");}// response 타입 설정response.setHeader("Content-Disposition","attachment;filename="+encodedFileName);response.setContentType("application/octet-stream");// 파일 response로 전송Files.copy(file,response.getOutputStream());}
일반적인 파일 다운로드 기능은 NIO API를 이용하여 파일 다운로드 기능을 구현할 수 있습니다.
참고:Files.copy()는 8192byte의 버퍼 사이즈를 가집니다.
IO API (Before JDK7)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Filefile=newFile(filePath+happyAtfiSfName);// 중략fis=newFileInputStream(file);bis=newBufferedInputStream(fis);so=response.getOutputStream();bos=newBufferedOutputStream(so);byte[]data=newbyte[2048];intinput=0;// 버퍼에 데이터를 담아 없을 때까지 전송while((input=bis.read(data))!=-1){bos.write(data,0,input);bos.flush();}
NIO API가 나오기 전, 직접 stream을 열어 loop를 통해 전달하는 전통적인 방식입니다. FileInputStream과 ServletOutputStream만 이용해도 되지만 성능을 높이기 위해 Buffer를 붙입니다.
FileInputStream: 이 클래스는 파일로부터 바이트 단위로 데이터를 읽어오는데 사용됩니다. 파일을 열고 그 내용을 읽어들일 때 주로 활용됩니다.
BufferedInputStream: 이 클래스는 데이터를 읽어올 때 성능을 향상시키기 위해 사용됩니다. FileInputStream과 같이 사용되며, 데이터를 버퍼에 저장해두고 필요할 때 버퍼로부터 읽어오는 방식으로 동작합니다. 이는 입출력 작업을 보다 효율적으로 수행할 수 있도록 도와줍니다.
ServletOutputStream: 이 클래스는 서블릿에서 클라이언트로 데이터를 보낼 때 사용됩니다. 서블릿 컨테이너에서 제공되며, HTTP 응답의 출력 스트림을 나타냅니다. 클라이언트로 데이터를 보내기 위해 사용됩니다.
BufferedOutputStream: 이 클래스는 데이터를 쓸 때 성능을 향상시키기 위해 사용됩니다. ServletOutputStream과 같이 사용되며, 데이터를 버퍼에 저장해두고 필요할 때 버퍼의 내용을 출력하는 방식으로 동작합니다. 이는 입출력 작업을 보다 효율적으로 수행할 수 있도록 도와줍니다.
이렇게 설정된 스트림들은 파일의 내용을 읽어들여 클라이언트에게 전송하는 데 사용됩니다. FileInputStream과 BufferedInputStream은 파일에서 데이터를 읽어오고, ServletOutputStream과 BufferedOutputStream은 클라이언트로 데이터를 전송합니다.
그 밖에
Apache Commons IO
Guava(Google)
등을 이용하여 구현할 수도 있습니다.
성능 비교
NIO API와 IO API의 파일 처리 속도를 테스트했습니다.
81.4MB 크기의 PDF 파일로 테스트했으며 괄호는 buffer 크기입니다.
- NIO API(8192)
메서드 실행 시간: 284밀리초
메서드 실행 시간: 228밀리초
메서드 실행 시간: 219밀리초
메서드 실행 시간: 197밀리초
메서드 실행 시간: 206밀리초
- JAVA IO(2048)
메서드 실행 시간: 1000밀리초
메서드 실행 시간: 594밀리초
메서드 실행 시간: 887밀리초
메서드 실행 시간: 730밀리초
메서드 실행 시간: 895밀리초
- JAVA IO(4096)
메서드 실행 시간: 288밀리초
메서드 실행 시간: 281밀리초
메서드 실행 시간: 359밀리초
메서드 실행 시간: 360밀리초
메서드 실행 시간: 570밀리초
- JAVA IO(8192)
메서드 실행 시간: 448밀리초
메서드 실행 시간: 343밀리초
메서드 실행 시간: 312밀리초
메서드 실행 시간: 351밀리초
메서드 실행 시간: 341밀리초
NIO API의 평균값이 더 좋은 성능을 보이며 이는 buffer 크기 차이는 아닙니다.
Using NIO.2 can significantly increase file copying performance since the NIO.2 utilizes lower-level system entry points.
이는 NIO API의 files.copy() 메소드는 Direct Buffer를 사용하여 데이터를 전송하기 때문에 뛰어난 퍼포먼스를 보여준다고 합니다.
Direct Buffer는 JVM 힙 메모리 외부에 위치하므로, 데이터 복사 시 메모리 복사 오버헤드가 줄어듭니다.
Files.copy() 메서드 중
1
2
3
4
5
6
7
8
publicstaticlongcopy(Pathsource,OutputStreamout)throwsIOException{// ensure not null before opening fileObjects.requireNonNull(out);try(InputStreamin=newInputStream(source)){returncopy(in,out);}}
1
2
3
4
5
6
7
8
9
10
11
12
13
publicInputStreamnewInputStream(Pathpath,OpenOption...options)throwsIOException{if(options.length>0){for(OpenOptionopt:options){// All OpenOption values except for APPEND and WRITE are allowedif(opt==StandardOpenOption.APPEND||opt==StandardOpenOption.WRITE)thrownewUnsupportedOperationException("'"+opt+"' not allowed");}}returnChannels.newInputStream(Files.newByteChannel(path,options));}
내부 코드를 보면 newInputStream()을 호출하는데 해당 메서드에서 Channel을 이용하는 것을 확인할 수 있습니다.
채널
입출력 채널(I/O channel)은 프로세서가 다른 일을 하지 못하는 문제를 해결하기 위해 개발되었습니다.
채널은 중앙 처리 장치와 주변 장치의 동작을 분리시켜, 프로세서가 다른 작업을 수행할 수 있도록 합니다.
채널은 주기억 장치에 직접 접근하여 정보를 저장하거나 검색할 수 있습니다.
채널 방식을 이용할 때 다음과 같은 장점이 있습니다.
양방향 통신 지원
채널 방식은 단일 채널로 양방향 입출력이 가능합니다.
이를 통해 데이터 송수신을 동시에 처리할 수 있어 효율적입니다.
버퍼 메커니즘 활용
채널 방식은 버퍼를 사용하여 입출력 데이터를 관리합니다.
버퍼를 통해 데이터를 일시적으로 저장하고 관리할 수 있어, 프로세서가 다른 작업을 수행할 수 있습니다.
프로세서 활용도 향상
채널 방식은 프로세서와 주변 장치의 동작을 분리시켜, 프로세서가 다른 작업을 수행할 수 있도록 합니다.
이를 통해 프로세서의 활용도가 높아져 전체적인 시스템 성능이 향상됩니다.
비동기 처리 지원
채널 방식은 비동기 입출력을 지원하여, 데이터 전송 중에도 다른 작업을 수행할 수 있습니다.
이를 통해 전체적인 시스템 응답성이 향상됩니다.
따라서 채널 방식은 버퍼 메커니즘, 양방향 통신, 비동기 처리 등의 기능을 통해 스트림 방식보다 성능이 우수합니다. 이는 프로세서의 활용도를 높이고 전체적인 시스템 성능을 향상시킬 수 있습니다.
결론
NIO API의 files.copy() 메소드는 Direct Buffer 사용, FileChannel 활용, 비동기 처리 지원 등의 기능을 통해 기존 java.io 방식보다 더 높은 성능을 발휘할 수 있다고 정리할 수 있습니다.
// 리눅스 경우 root에서 시작하는 폴더 경로 지정 할 경우 .addResourceLocations("file:///usr/download/")// 리소스 템플릿 경로를 지정할 경우.addResourceLocations("classpath:/templates/","classpath:/static/")// 윈도우에서 실행 시 다음과 같은 형태로 드라이브 문자 포함 경로 지정 .addResourceLocations("file:///C:/~/")