[TIL] Day43 - 파일 업로드

JIONY·2022년 11월 13일
0

TIL - Web BE - Spring Boot

목록 보기
19/20
post-thumbnail

아니 파일 올리고 받는 거 너무 신기하네..


파일 업로드 구조

  • html에서는 form을 제외하고 데이터를 보낼 수 있는 방법이 없음
    • a 태그 뒤에 붙일 수 있지만 개발자가 정해놓은대로 보내는 것. 사용자가 능동적으로 보낼 수 있는 건 아님
  • DB에 파일을 저장할 수 있지만, 파일 저장에 적합하지는 않음
    • DB는 작은 데이터를 의미 있게 저장하고 빨리 검색하고 내가 원하는 형태로 불러오기 위한 목적임
    • 무료 db에서 10~20기가까지 사용 가능
      ex. 2기가짜리 영화1개 : 회원정보 600만개 저장 가능 → 낭비

저장 위치

  • 파일: 하드디스크
  • 정보: DB
    • File I/O(java.io 패키지)
    • apache-commons 라이브러리


전송 방식

  • 지금까지 사용했던 GET, POST 방식(key, value)으로는 파일을 업로드할 수 없음. 파일은 객체를 전송해야 함

multipart/form-data 방식

  • 인코딩 타입을 지정해야 함
    • <form action="/" method="post" enctype="multipart/form-data">
  • 절취선 방식(절취선+난수)
    • 파일 업로드는 반드시 이 방식을 사용해야 함
    • 기존 방식(GET/POST 무관)으로는 파일명만 전송 가능
    • form에 post 방식 + enctype="multipart/form-data"로 설정
      • 절취선이 생기면서 파일의 여러 정보가 하나의 구역에 담겨 전송



스프링 서버에서의 파일 수신

컨트롤러에서의 파일 수신 처리

  • Multipart Request를 처리한다고 부름
  • Spring boot에서 내부적으로 multipartResolver를 등록
    • 설정만 추가로 진행하면 됨

  • Spring Controller에서는 MultipartFile 형태(신규 클래스)로 파일을 수신
  • 기존에 사용하던 annotation 전부 지원

설정(application.properties)

 # application.properties
 
  # multipart resolver
  spring.servlet.multipart.max-file-size=1MB //낱개로 1mb
  spring.servlet.multipart.max-request-size=10MB //전체 10mb 제한(총 10개 전송 가능)

컨트롤러

@PostMapping("/")
public String upload(@RequestParam String uploader,
            @RequestParam MultipartFile attachment) {
    System.out.println("uploader = " + uploader);
    System.out.println("attachment = " + attachment);
    return "redirect:/";
}

attachment

  • 전달되는 MultipartFile(attachment)의 getter 출력해보기
//attachment 분석
System.out.println("content type = " + attachment.getContentType());
System.out.println("name = " + attachment.getName());
System.out.println("original file name = " + attachment.getOriginalFilename());
System.out.println("size = " + attachment.getSize());
  • 파일 내용 자체를 불러오는 명령: .getBytes()
    • 선정적인 내용 검사, 해상도 조정 등의 작업 가능

사용자가 올린 파일 저장

컨트롤러

File directory = new File("D:/upload");
  //OS 무관한 홈 폴더 경로: 
  //System.getProperty("user.home") + "/upload");
directory.mkdirs(); //폴더 생성 명령
File target = new File(directory, 
							attachment.getOriginalFilename()); //저장될 파일 생성
attachment.transferTo(target); //예외 (전가: 첫 번째 옵션 선택) //실제 저장 처리 명령
  • 예외: 읽기 전용 경로, 없는 파일 등에 대한 상황
  • 파일 업로드 실행 시, 지정한 폴더에 해당 파일이 저장됨
  • File: java.io에 있는 클래스
    • 폴더, 파일 둘 다 제어 가능
    • 업로드할 폴더(경로) 선택
  • 실제로는 사용자가 올린 파일 이름 그대로 저장하지 않음(변조 필요)

문제점

  • 같은 이름의 파일을 업로드하면 덮어쓰기 처리됨

파일 이름 처리 방법

아래 세 가지 방법 중 3번 선택

  1. 파일 이름은 그대로 두고 폴더로 구분
    • 탐색기/Finder를 만드는 것과 유사함

  2. 폴더는 그대로 두고 파일 이름을 랜덤으로 설정

  3. 데이터베이스 PK로 이름을 설정
    • 상황1: 회원 프로필 이미지
      1. 어떤 파일을 올리든 파일 이름을 회원 아이디로 저장
      2. 단점: 프로필은 파일을 하나만 업로드 가능(ex.카톡 프사 여러 개 - 안 됨)
      3. 파일을 저장할 테이블을 만들지 않아도 됨

    • 상황 2: 첨부파일(pdf, ppt, mp3) 업로드
      1. 파일을 저장하는 것 뿐 아니라, 정보DB에 따로 저장
      2. 별도의 시퀀스 등을 활용해 겹치지 않는 이름을 생성
      3. 사용자가 올린 정보를 따로 저장해 사용자는 이름이 변경된 것을 모르게 처리
        • 바꾼 이름, 오리지널 파일 이름 둘 다 저장


DB 미사용

업로드

컨트롤러

  • 회원가입 시, 프로필 사진을 업로드할 수 있도록 처리하려고 함. 회원가입에서 에러 발생 시 파일이 저장되면 안되므로 DB에 회원 정보를 등록하는 구문보다 뒤에 파일 저장 명령을 추가함

  • 유저가 프로필 이미지 안 올렸을 때 폴더에 저장되면 안되므로 첨부파일이 있는 경우에만 upload/member 폴더 생기고 거기에 아이디가 이름인 파일 추가하도록 처리

  • 파일을 열어볼 게 아니므로 확장자를 지정하지 않음

    • 확장자: 특정 프로그램을 사용할 수 있도록 해주는 연결 용도 → 실행을 안 할 거니까 서버에서는 확장자 불필요
//(+) 첨부파일(프로필 이미지)을 받아서 저장
@PostMapping("/join")
public String join(@ModelAttribute MemberDto dto,
			@RequestParam MultipartFile memberProfile) throws IllegalStateException, IOException {
	//DB 등록
	memberDao.join(dto);

	if(! memberProfile.isEmpty()) {//첨부파일이 있다면
	//프로필 저장
	File directory = new File("D:/upload/member");
	directory.mkdirs();
	File target = new File(directory, dto.getMemberId());
		memberProfile.transferTo(target);
	}

	return "redirect:join_finish";			
}

다운로드

  • 사용자가 업로드한 파일을 화면에서 보여주려면 다운로드가 필요함
    • 서버가 다운로드해서 사용자에게 노출
  • 지금까지 @ResponseBody를 통해 스트링을 보여주거나 view resolver를 통해 jsp 연결했음
  • 파일은 화면과 무관하므로 @ResponseBody 계속 사용

파일 다운로드 매핑 추가

특정 사용자의 프로필 이미지를 다운로드하는 매핑

  • 다운로드: 현재의 서버에서 사용자에게 파일을 전송하는 것
  • 전송을 하려면 화면을 무시하는 설정 필요(@ResponseBody)
  • 전송을 부탁하려면 ResponseEntity\ 형태가 반환되어야 함
    • Generic 타입\ 지정해줘야 에러 사라짐

  • 불러오는 거니까 생성(저장)명령 사용 x
  • builder 패턴: 응답객체를 못찾은 것처럼 만들어서 반환해라 와 같이 말하듯이 만들 수 있음
    • new 대신 사용 가능: 에러메시지 출력할 수 있으므로 이 방법이 더 좋긴 함

  • apache commons io 의존성 수동 추가 필요
    • pom.xml에 아래 코드 추가
<!-- appache-commons io 파일 입출력 자동화시켜주는 의존성 -->
<dependency>
  <groupId>commons-io</groupId>
  <artifactId>commons-io</artifactId>
  <version>2.11.0</version>
</dependency>

컨트롤러

@GetMapping("/download")
	@ResponseBody
	public ResponseEntity<ByteArrayResource> download(//반환형:ResponseEntity
			@RequestParam String memberId) throws IOException {
		//A. 파일 찾기
		File directory = new File("D:/upload/member");
		File target = new File(directory, memberId); //directory 내에서 memberId 파일명 조회
		
		if(target.exists()) {//파일 존재		
			//B. 해당 파일 내용 불러오기(apache commons io 의존성 수동 추가 필요)
			byte[] data = FileUtils.readFileToByteArray(target);//예외 전가
			ByteArrayResource resource = new ByteArrayResource(data);//포장
			
			//C. 사용자에게 보낼 응답 생성
			return ResponseEntity.ok().body(resource); //ok:200번
		}else {
			//1. 우리가 정한 예외를 발생시키는 방법
			throw new TargetNotFoundException("프로필 이미지 없음");
			
			//2. 사용자에게 못찾음(404) 전송
			//return ResponseEntity.notFound().build();
		}
		
	}

테스트

  • member/download?memberId=sweet1234(파일 있는 memberId 입력)
  • 이미지라고 알려주지 않아서 텍스트로 나옴
    • header에 보낼 파일 정보 첨부

return ResponseEntity.ok().header("Content-Encoding", "UTF-8")
				.header("Content-Length", String.valueOf(data.length))
				//사이즈 알려주면 다운로드 진행률 알 수 있음
				//html 통신이므로 String 타입만 보낼 수 있음
				.header("Content-Disposition", "attachment; filename=" + memberId)
				//파일을 다루는 방법을 알려줌
				//브라우저에 인라인으로 표시될 것인지,
                //다운로드 된 파일로 표시될 것인지 등
				.header("Content-Type","application/octet-stream")
				//파일 유형
				//image/jpg로 적을 수 있으나
                //db에 저장하지 않았으므로 확장자를 모름
				//이미지로 지정하지 않고 무조건 다운로드 받으라고 지정
				//DB가 없으면 알려줄 수 있는 정보가 제한적이고 못하는 게 많음
				.body(resource); //ok:200번

사용자 화면에 다운로드 받아 표시하도록 설정

  • 사용자가 브라우저에 직접 주소를 치지는 않음
    • member/download?memberId=sweet1234
    • 위 주소를 마이페이지 조회 jsp에 사용
      <!-- 프로필 이미지 출력 -->
       <img src="download?memberId=${dto.memberId}" width="100" height="100">
  • 문제점: 이미지가 없는 경우를 구분할 수 없음(다운로드 받기 전이니까)
  • DB가 없으면 알려줄 수 있는 정보가 제한적이고 못하는 게 많음222...

DB 사용

업로드

테이블 생성

  • 파일 관련된 정보 중 무엇을 DB에 넣을지?
  • 파일 사이즈/유형 등을 파일을 직접 읽고 분석한 다음 빼내려면 성능 저하 이슈 발생함
    • 파일 업로드 시에도 알 수 있는 정보는 DB에 저장해두면 불필요한 작업 줄이고 유연한 처리 할 수 있음
  • 파일 크기를 int로 하면 안됨. long으로 저장


-- 파일 테이블
create table attachment(
attachment_no number primary key,
attachment_name varchar2(256) not null,
attachment_type varchar2(30) not null,
attachment_size number not null check(attachment_size>=0),
attachment_time date default sysdate not null
);

create sequence attachment_seq;

DTO

  • 테이블 바탕으로 생성

DAO

  • 수정은 없음(덮어쓰기도 없어졌다가 다시 만드는 것)
  • 검색 없음
//Dao
int sequence();
void insert(AttachmentDto attachmentDto);
List<AttachmentDto> selectList();
AttachmentDto selectOne(int attachmentNo);
boolean delete(int attachmentNo);

View

<h1>DB 사용하는 파일 업로드</h1>
	<form action="/upload" method="post" enctype="multipart/form-data">
		<input type="file" name="attachment"><br><br>

		<button type="submit">등록</button>
	</form>

Controller

  • DB 저장 이후에 실제 파일이 저장되도록 설정
@PostMapping("/upload")
public String upload(@RequestParam MultipartFile attachment) throws IllegalStateException, IOException {
	//DB 저장
	int attachmentNo = attachmentDao.sequence();
	attachmentDao.insert(AttachmentDto.builder()
				.attachmentNo(attachmentNo)
				.attachmentName(attachment.getOriginalFilename())
				.attachmentType(attachment.getContentType())
				.attachmentSize(attachment.getSize())
				.build());
		
	//file 저장
	File dir = new File("D:/upload");
	dir.mkdirs();
	File target = new File(dir, String.valueOf(attachmentNo));//파일명이 시퀀스 번호로 생성됨
	attachment.transferTo(target); //예외 전가
				
	return "redirect:/";
}

테스트 결과


다운로드

  • 목록에서 버튼 누르면 다운로드 되도록 하기

Controller

  • list.jsp에 파일 목록을 첨부
@GetMapping("/list")
public String list(Model model) {
	model.addAttribute("list", attachmentDao.selectList());
	return "list"; 
}

view (list.jsp)

  • 파일 목록 출력
<c:forEach var="attachementDto" items="${list}">
	<h1>
		[${attachmentDto.attachmentType}]
		${attachmentDto.attachmentName}
		(${attachmentDto.attachmentSize} bytes)
	</h1>
</c:forEach>

Controller

  • /download 추가
  • apache common io 의존성 추가 필요
  • 파일은 한 번에 하나만 다운로드 가능
    • 다운로더: 하나를 이어서 받게 해주는 역할
  • 파일이 DB보다 느림(제일 느림)
    • DB도 안가는 게 좋지만 DB에서 끝낼 수 있으면 그나마 나음
    • 파일 탐색을 DB에서 하도록 하면, 정보를 불러오기 전에 파일이 없는 경우 필터링 가능해짐
@GetMapping("/download")
public ResponseEntity<ByteArrayResource> download(
			@RequestParam int attachmentNo) throws IOException{
	//[1] 파일 탐색(DB)
	AttachmentDto dto = attachmentDao.selectOne(attachmentNo);
	if(dto == null) {//파일이 없으면
		return ResponseEntity.notFound().build(); //404 error
	}
		
	//[2] 파일 불러오기
	File dir = new File("D:/upload");
	File target = new File(dir, String.valueOf(attachmentNo));
	byte[] data = FileUtils.readFileToByteArray(target);
	ByteArrayResource resource = new ByteArrayResource(data);
		
	//[3] 응답 객체를 만들어 데이터를 전송
	return ResponseEntity.ok()//ok:200번
			.header("Content-Encoding", "UTF-8")
			//.header("Content-Length", String.valueOf(data.length))
			.header("Content-Length", 
            	String.valueOf(dto.getAttachmentSize()))
			.header("Content-Disposition", 
            	"attachment; filename=" + dto.getAttachmentName())
				//.header("Content-Type","application/octet-stream")
			.header("Content-Type",dto.getAttachmentType())
			.body(resource); 
		
}
  • [3]은 문자열이 많아서 오타의 가능성도 높음 → 상수화 가능

### view(list.jsp)
  • 파일명 누르면 다운로드 되도록 링크 추가
  • 미리보기(썸네일) 띄우기
    • 위 주소를 <img>에 추가


응답객체 만들기 상수화

//[3] 응답 객체를 만들어 데이터를 전송
return ResponseEntity.ok()//ok:200번
	//.header("Content-Encoding", "UTF-8")
	.header(HttpHeaders.CONTENT_ENCODING, 
				StandardCharsets.UTF_8.name())
				
	//.header("Content-Length", String.valueOf(data.length))
	//.header("Content-Length", String.valueOf(dto.getAttachmentSize()))
	.contentLength(dto.getAttachmentSize())//알아서 스트링 변환해줌
				
	//.header("Content-Disposition", 
          //"attachment; filename=" + dto.getAttachmentName())
	.header(HttpHeaders.CONTENT_DISPOSITION, 
				ContentDisposition.attachment()
				.filename(dto.getAttachmentName(),
							StandardCharsets.UTF_8)
				.build().toString())
                //파일명 한글, 띄어쓰기 다 처리 가능(위에 건 안됨)
				
	//.header("Content-Type","application/octet-stream")
	//.header("Content-Type",dto.getAttachmentType())
	.contentType(MediaType.APPLICATION_OCTET_STREAM)
    //다양한 타입 사용하려면 위처럼 사용
	.body(resource);

0개의 댓글