28. 파일 업로드 - 1 단계(멀티파트가 뭘까?)

1. 멀티파트(Multipart)의 이해

멀티파트(Multipart)는 HTTP 프로토콜을 사용하여 웹 서버로 파일이나 데이터를 업로드할 때 사용되는 데이터 전송 방식 중 하나이다. "멀티파트"라는 용어는 말 그대로 메시지가 여러 부분으로 구성되어 있음을 의미하며, 이러한 각각의 부분은 다른 유형의 데이터를 담을 수 있다.

 

HTTP 메시지에는 클라이언트가 전송하는 HTTP 요청, 그리고 서버가 반환하는 HTTP 응답이 있다.

 

텍스트 기반 HTTP 메세지

POST /example HTTP/1.1
Host: example.com
Content-Type: text/plain
Content-Length: 13
---- CLRF 빈줄 공백 --------
Hello, World!

 

바이너리 기반 16진수 HTTP 메세지

POST /example HTTP/1.1
Host: example.com
Content-Type: application/octet-stream
Content-Length: 5

\x48\x65\x6C\x6C\x6F

 

HTTP 메시지는 크게 시작 라인(start line), 헤더(headers), 그리고 바디(body) 세 부분으로 구성됩니다. 여기서 바디 부분은 실제 전송하려는 데이터를 담고 있고, 멀티파트 요청에서는 이 바디 부분에 텍스트 기반 데이터와 바이너리 데이터가 함께 포함될 수 있다.

 

이해를 돕기 위한 HTTP 예시

POST /submitForm HTTP/1.1
Host: tenco.com
Content-Type: multipart/form-data; boundary="boundary123"
Content-Length: [계산된 총 길이]

--boundary123
Content-Disposition: form-data; name="username"

길동
--boundary123
Content-Disposition: form-data; name="password"

1234
--boundary123
Content-Disposition: form-data; name="binaryData"; filename="data.bin"
Content-Type: application/octet-stream

01010101 01101100 01101100 01101111
--boundary123--

 

boundary="boundary123” 은 멀티파트 메시지의 각 부분을 구분하기 위한 문자열이다 그리고 2진수로 표현되어 있는 부분은 보통 16진수 인코딩 되어 전송이 된다(이해를 돕기 위해 01010로 2진수로 표현)

 

여기서 핵심은 ‘바디 부분에 텍스트 기반 데이터와 바이너리 데이터가 함께 포함 될 수 있다’ 이다.

2. 스프링 프로젝트에서의 멀티파트 처리

스프링 프레임워크에서는 멀티파트 요청을 처리하기 위한 기능을 제공한다. 스프링의 MultipartResolver 인터페이스는 멀티파트 요청을 파싱하고, 업로드된 파일과 폼 데이터에 접근할 수 있는 API를 제공한다. 즉, 스프링 부트(Spring Boot) 프로젝트에서는 추가적인 설정 없이도 멀티파트 지원이 자동으로 활성화되며, 필요한 경우 application.properties 또는 application.yml 파일을 통해 멀티파트 관련 설정을 커스터마이즈할 수 있다.

 

멀티 파트 요청 처리 예시 코드

@PostMapping("/upload")
public String handleFileUpload(@RequestParam("name") String name,
                               @RequestParam("file") MultipartFile file) {
    if (!file.isEmpty()) {
        // 파일 처리 로직
    }
    // 추가 작업
    return "redirect:/success";
}

@RequestParam("file") MultipartFile file

@RequestParam 어노테이션을 사용하여 멀티파트 폼 데이터에서 업로드된 파일을 받아온다. MultipartFile 인터페이스는 업로드된 파일에 대한 정보데이터에 접근할 수 있는 메서드를 제공한다. 우리 프로젝트에서는 WebDataBinder 를 활용한 파싱 방식을 사용할 예정 이다.(DTO)

3. 회원 가입시 파일 업로드 기능 구현

spring:
  mvc:
    view: 
      prefix: /WEB-INF/view/ #JSP파일이 위치한 디렉토리 접두사를 설정합니다.
      suffix: .jsp #뷰 이름에 자동으로 추가될 파일 확장자를 설정합니다.
  servlet:
    multipart:
      max-file-size: 20MB #파일 최대 크기 20MB
      max-request-size: 20MB #멀티파트 전체 요청 크기 20MB제한
  • max-file-size: 기본값은 1MB이다. 이는 단일 파일 업로드 시 최대 파일 크기를 의미한다.
  • max-request-size: 기본값은 10MB이다. 이는 멀티파트 요청의 전체 크기에 대한 최대값을 의미한다.

부트스트랩4 활용 - Custom File Upload 부분을 활용.

 

W3Schools.com

W3Schools offers free online tutorials, references and exercises in all the major languages of the web. Covering popular subjects like HTML, CSS, JavaScript, Python, SQL, Java, and many, many more.

www.w3schools.com

signUp.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>

<!-- header.jsp  -->
<%@ include file="/WEB-INF/view/layout/header.jsp"%>

<!-- start of content.jsp(xxx.jsp)   -->
<div class="col-sm-8">
	<h2>회원 가입</h2>
	<h5>Bank App에 오신걸 환영합니다</h5>

	<form action="/user/sign-up" method="post" enctype="multipart/form-data">
		<div class="form-group">
			<label for="username">username:</label> <input type="text" class="form-control" placeholder="Enter username" id="username" name="username" value="야스오1">
		</div>
		<div class="form-group">
			<label for="pwd">Password:</label> <input type="password" class="form-control" placeholder="Enter password" id="pwd" name="password" value="asd123">
		</div>
		<div class="form-group">
			<label for="fullname">fullname:</label> <input type="text" class="form-control" placeholder="Enter fullname" id="fullname" name="fullname" value="바람검객">
		</div>
		<div class="custom-file">
			<input type="file" class="custom-file-input" id="customFile" name="mFile">
			<label class="custom-file-label"  for="customFile">Choose file</label>
		</div>
		<br>
		<div class="d-flex justify-content-end">
			<button type="submit" class="btn btn-primary mt-md-4">회원가입</button>
		</div>
	</form>


</div>
<!-- end of col-sm-8  -->
</div>
</div>
<!-- end of content.jsp(xxx.jsp)   -->

<script>
// Add the following code if you want the name of the file appear on select
$(".custom-file-input").on("change", function() {
  console.log($(this).val());	
  let fileName = $(this).val().split("\\").pop();
  $(this).siblings(".custom-file-label").addClass("selected").html(fileName);
});
</script>

<!-- footer.jsp  -->
<%@ include file="/WEB-INF/view/layout/footer.jsp"%>
User
package com.tenco.bank.repository.model;

import java.sql.Timestamp;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class User {
	private Integer id; 
	private String username; 
	private String password; 
	private String fullname; 
	private String orginFileName;
	private String uploadFileName;
	private Timestamp createdAt;
	
}
SignUpDTO
package com.tenco.bank.dto;

import java.util.List;

import org.springframework.web.multipart.MultipartFile;

import com.tenco.bank.repository.model.User;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class SignUpDTO {
	
	private String username; 
	private String password; 
	private String fullname;
	private MultipartFile mFile;
	private String originFileName;
	private String uploadFileName; 
	
	// 2단계 로직 - User Object 반환 
	public User toUser() {
		return User.builder()
				.username(this.username)
				.password(this.password)
				.fullname(this.fullname)
				.orginFileName(this.originFileName)
				.uploadFileName(this.uploadFileName)
				.build();
	} 
}
user.xml
<insert id="insert">
    insert into user_tb(username, password, fullname, origin_file_name, upload_file_name ) 
    values( #{username}, #{password}, #{fullname}, #{orginFileName}, #{uploadFileName})
</insert>
UserService 코드 수정 및 파일 추가
package com.tenco.bank.service;

import java.io.File;
import java.io.IOException;
import java.util.UUID;

import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import com.tenco.bank.dto.SignInDTO;
import com.tenco.bank.dto.SignUpDTO;
import com.tenco.bank.handler.exception.DataDeliveryException;
import com.tenco.bank.handler.exception.RedirectException;
import com.tenco.bank.repository.interfaces.UserRepository;
import com.tenco.bank.repository.model.User;
import com.tenco.bank.utils.Define;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserService {

	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	/**
	 * 회원 등록 서비스 기능 트랜잭션 처리
	 * 
	 * @param dto
	 */
	@Transactional
	public void createUser(SignUpDTO dto) {

		int result = 0;
		if (!dto.getMFile().isEmpty()) {
			String[] fileNames = uploadFile(dto.getMFile());
			dto.setOriginFileName(fileNames[0]);
			dto.setUploadFileName(fileNames[1]);
		}
		try {
			// 코드 추가 부분
			// 회원 가입 요청시 사용자가 던진 비밀번호 값을 암호화 처리 해야 함
			String hashPwd = passwordEncoder.encode(dto.getPassword());
			dto.setPassword(hashPwd);
			
			result = userRepository.insert(dto.toUser());
		} catch (DataAccessException e) {
			throw new DataDeliveryException(Define.EXIST_USER, HttpStatus.INTERNAL_SERVER_ERROR);
		} catch (Exception e) {
			throw new RedirectException(Define.UNKNOWN, HttpStatus.SERVICE_UNAVAILABLE);
		}
		if (result != 1) {
			throw new DataDeliveryException(Define.FAIL_TO_CREATE_USER, HttpStatus.BAD_REQUEST);
		}

	}

	public User readUser(SignInDTO dto) {
		User userEntity = null;
		
		try {
			userEntity = userRepository.findByUsername(dto.getUsername());
		} catch (DataAccessException e) {
			throw new DataDeliveryException(Define.FAILED_PROCESSING, HttpStatus.INTERNAL_SERVER_ERROR);
		} catch (Exception e) {
			throw new RedirectException(Define.UNKNOWN, HttpStatus.SERVICE_UNAVAILABLE);
		}

		if (userEntity == null) {
			throw new DataDeliveryException(Define.FAIL_USER_LOGIN, HttpStatus.BAD_REQUEST);
		}
		if (!passwordEncoder.matches(dto.getPassword(), userEntity.getPassword())) {
			throw new DataDeliveryException(Define.FAIL_USER_LOGIN_PASSWORD, HttpStatus.BAD_REQUEST);
		}

		return userEntity;
	}
	
	/**
	 * 서버 운영체제에 파일 업로드 기능
	 * mFile.getgetOriginalFilename() : 사용자가 입력한 파일명
	 * uploadFileName : 서버 컴퓨터에 저장될 파일명
	 * @return
	 */
	private String[] uploadFile(MultipartFile mFile) {
		
		if(mFile.getSize() > Define.MAX_FILE_SIZE) {
			throw new DataDeliveryException("파일 크기는 20MB 이상 클 수 없습니다.", HttpStatus.BAD_REQUEST);
		}
		
		// 서버 컴퓨터에 파일을 넣을 디렉토리가 있는지 검사
		String saveDerectory = Define.UPLOAD_FILE_DERECTORY;
		File directory = new File(saveDerectory);
		if (!directory.exists()) {
			directory.mkdirs();
		}
		
		// 파일 이름 생성(중복 이름 예방)
		String uploadFileName = UUID.randomUUID() + "_" + mFile.getOriginalFilename();
		
		// 파일 전체경로 + 새로 생성한 파일명
		String uploadPath = saveDerectory + File.separator + uploadFileName;
		System.err.println("--------------------------");
		System.out.println(uploadPath);
		System.err.println("--------------------------");
		File destination = new File(uploadPath);
		
		// 반드시 수행
		try {
			mFile.transferTo(destination);
		} catch (IllegalStateException | IOException e) {
			e.printStackTrace();
			throw new DataDeliveryException("파일 업로드 중에 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
		} 
		return new String[] {mFile.getOriginalFilename(), uploadFileName};
	}

}