스프링 파일 업로드 #1. 비동기 처리 (1)

Chan Young Jeong·2023년 4월 27일
0

스프링

목록 보기
5/7

😁 multipart/form-data

multipart/form-dataform 데이터를 전송할 때 사용되는 인코딩 방식 중 하나입니다. 주로 파일 업로드와 같이 바이너리 데이터를 전송할 때 사용됩니다.

...

<form method="post" enctype="multipart/form-data">
    <input type="file" name="file">
    <input type="text" name="itemName">
    <input type="submit"/>
</form>

...
  • 위에서 보는 것과 같이 form 안에는 file 타입도 있고 text 타입도 있습니다. 이렇게 여러가지 타입의 데이터들을 전송하기 위한 타입입니다.
  • 이 때 그냥 보내게 된다면 데이터를 받아서 처리할 때 각 데이터를 구별할 수 없기 때문에 multipart/form-data를 바디에 담아 보낼 때는 다음과 같은 규칙을 따른다.

  • Content-Type : multipart/form-data; boundary =

  • boundary 값을 기준으로 타입이 뭔지, 그 안에 값은 뭔지를 담아서 전송한다. 그렇기 때문에 서버에서 처리할 때 해당 타입에 알맞게 처리할 수 있게 된다.

  • 마지막 줄에 boudary 뒤에 --값은 종료를 알리는 값이다.

😂서버

  • 스프링 부트 2.5.1
  • 스프링 웹과 타임리프 사용
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
  • 스프링에서 multipart/form-data를 처리하기 위해서
    StandardServletMultipartResolver 가 동작한다.
  • resolveMulitipart() 메소드에서 각 part를 resolve한다.

StandardServletMultipartResolver

return new StandardMultipartHttpServletRequest(request, this.resolveLazily) 이 부분을 따라가 보면

parseRequest(request) 가 등장하고 이 부분을 다시 따라가 보면 각 part를 일일이 resolve 하고 있다.

참고

다음 코드는 multipart/form-data를 직접 컨트롤러에서 resolve해주는 것이다. StandardServletMultipartResolver 구현을 단순화 한 것으로 보면된다.

여기서 form에서 file type 같은 경우는 헤더에 업로드한 파일 이름이 filename에 저장되어 있다. 그렇기 때문에 해당 part가 file type인지 구분하는 방법은 StringUtils.hasText(part.getSubmittedFileName()))를 이용해서 할 수 있다. 파일이라면 part.write(fullPath)를 이용해 파일을 저장한다.

	@PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("request={}", request);

        String itemName = request.getParameter("itemName");
        log.info("itemName={}", itemName);

        Collection<Part> parts = request.getParts();
        log.info("parts={}", parts);

        for (Part part : parts) {
            log.info("==== PART ====");
            log.info("name={}", part.getName());
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info("header {}: {}", headerName, part.getHeader(headerName));
            }
            //편의 메서드
            //content-disposition; filename
            log.info("submittedFilename={}", part.getSubmittedFileName());
            log.info("size={}", part.getSize()); //part body size

            //데이터 읽기
            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            log.info("body={}", body);

            //파일에 저장하기
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);
                part.write(fullPath);
            }
        }

        return "upload-form";
    }


👍 컨트롤러

BasicController

@RestController
@RequiredArgsConstructor
public class BasicController{

  private final BasicService service;
  
  @Value("${file.dir}")
  private String fileDir;

  @PostMapping("/upload")
  public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file) {

      if (!file.isEmpty()) {
          String fullPath = fileDir + file.getOriginalFilename
          log.info("파일 저장 fullPath={}", fullPath);
          service.processFile(file,fullPath);
      }

  return filePath;
  }
}

😘 서비스

BasicService

@Service
public class BasicService {
    public void processFile(MultipartFile file,String fullPath) {

        if (!file.isEmpty()) {
			try {
                file.transferTo(new File(fullPath));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

실행

주의할점

BasicService 에서 지금 transferTo() 메소드를 사용하고 있다. 이 때 코드를 따라가 보면 Stream.Utils에서 다음과 같이 InputStream을 받아 OutputStream으로 Copy하고 있다. 여기서 BUFFER_SIZE 가 정의 되어 있는데 4096로 되어 있기 때문에 한번에 4KB만큼 버퍼에 담아 두었다가 저장하고 있다.

public static int copy(InputStream in, OutputStream out) throws IOException {
		Assert.notNull(in, "No InputStream specified");
		Assert.notNull(out, "No OutputStream specified");

		int byteCount = 0;
		byte[] buffer = new byte[BUFFER_SIZE];
		int bytesRead;
		while ((bytesRead = in.read(buffer)) != -1) {
			out.write(buffer, 0, bytesRead);
			byteCount += bytesRead;
		}
		out.flush();
		return byteCount;
	}

만약 transferTo메소드를 사용하지 않고 아래와 같은 방식으로 파일을 저장하려고 한다면 OutOfMemoryError가 발생한다. 왜냐하면 앱에 할당된 힙 메모리는 한정되어 있기 때문이다. 따라서 버퍼를 사용하지 않으면 멀티파트로 전달된 데이터를 메모리에 모두 저장하려하기 때문에 에러가 발생한다.

혹은 파일을 분할해서 저장하는 방법도 있는데 이 방법은 다음에 다뤄보기로 하자.

   
 Path filepath = Paths.get(fileDir, file.getOriginalFilename());
 
 try (OutputStream os = Files.newOutputStream(filepath)) {
 
	os.write(file.getBytes());
    
  } catch (IOException e) {
  
  	throw new RuntimeException(e);
    
  }

0개의 댓글