Guide_Uploading Files

Dev.Hammy·2023년 12월 10일
0

Spring Guides

목록 보기
5/46

HTTP multi-part 파일 업로드를 받을 수 있는 서버 애플리케이션을 생성해보겠습니다.

What You Will Build

파일 업로드 요청을 받아들일 수 있는 스프링 부트 웹 애플리케이션을 생성합니다. 테스트 파일을 올릴 수 있는 간단한 HTML 인터페이스도 빌드할 것입니다.

Starting with Spring Initializr

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'guides'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

애플리케이션 개발 배경 지식

Spring 에서 제공하는 HTTP 처리용 어노테이션

클래스 레벨 어노테이션:

  1. @Controller, @RestController: 클래스가 컨트롤러임을 지정합니다. @RestController@Controller@ResponseBody를 합친 것으로 RESTful 서비스에서 사용됩니다.
  2. @Service: 비즈니스 로직을 담당하는 서비스 클래스임을 지정합니다.
  3. @Repository: 데이터베이스와의 상호 작용을 담당하는 DAO(Data Access Object) 클래스임을 지정합니다.
  4. @Configuration: Spring Bean 구성을 담당하는 클래스임을 지정합니다.

메서드 레벨 어노테이션:

  1. @ResponseBody: 메서드가 반환하는 값을 HTTP 응답 본문으로 사용하도록 지정합니다.
  2. @GetMapping, @PostMapping, @PutMapping, @DeleteMapping: 각각 GET, POST, PUT, DELETE 요청을 처리하는 메서드로 지정합니다.
  3. @ExceptionHandler: 특정 예외를 처리하는 메서드로 사용됩니다.

메서드 및 클래스 레벨 어노테이션
이러한 애너테이션들은 메서드 레벨에서는 해당 메서드에 적용되며, 클래스 레벨에서는 클래스 내의 모든 메서드에 적용됩니다. 이를 통해 특정한 기능이나 설정을 일괄적으로 적용할 수 있습니다.

  1. @RequestMapping: 이미 언급한 것처럼 URL 경로를 매핑하며, 메서드 레벨과 클래스 레벨에서 모두 사용될 수 있습니다.
  2. @Transactional: 데이터베이스 트랜잭션 처리를 위한 애너테이션으로, 메서드 또는 클래스에 적용할 수 있습니다.
  3. @Secured, @PreAuthorize, @PostAuthorize: 스프링 시큐리티를 이용하여 메서드 또는 클래스의 접근 권한을 제어하는 애너테이션입니다.
  4. @CrossOrigin: CORS(Cross-Origin Resource Sharing)을 허용하기 위해 사용되며, 메서드 또는 클래스에 적용할 수 있습니다.

메서드 매개변수 레벨 어노테이션

  1. @RequestParam: HTTP 요청에서 쿼리 매개변수나 폼 매개변수를 추출합니다. 이는 메서드의 매개변수로 전달된다. 예를 들어, ?id=1과 같은 쿼리 매개변수를 처리할 때 사용됩니다.

  2. @PathVariable: URI 경로의 일부인 경로 변수를 추출합니다. /users/{userId}와 같은 경로에서 userId를 추출할 때 사용됩니다.

  3. @RequestBody: HTTP 요청의 본문을 자바 객체로 변환합니다. 주로 JSON 또는 XML과 같은 형태의 요청 본문을 자바 객체로 변환할 때 사용됩니다.

  4. @RequestHeader: HTTP 요청의 헤더 값을 추출합니다. 특정 헤더 값을 추출하거나 헤더 값을 메서드의 매개변수로 전달할 때 사용됩니다.

  5. @RequestAttribute: HTTP 요청의 어트리뷰트 값을 추출합니다. 주로 프레임워크 수준의 요청 어트리뷰트 값을 가져올 때 사용됩니다.

  6. @RequestParamMap: HTTP 요청의 모든 쿼리 매개변수를 Map으로 받습니다.

데이터 구조 및 데이터 전송 관련 객체

  • Buffer: 임시 데이터 저장 공간으로 입출력 속도 조절이나 데이터 버퍼링에 사용됩니다.

  • List: 데이터를 순차적으로 저장하고, 삽입, 삭제 등의 연산에 유용한 데이터 구조입니다.

  • Channel: 입출력을 위한 연결 통로로, 파일이나 네트워크 등 데이터를 읽고 쓰는데 사용됩니다.

  • Queue: 선입선출(FIFO) 구조로 데이터를 저장하며, 대기열을 다루거나 작업을 조율할 때 유용합니다.

  • Stream: 연속적인 데이터의 흐름을 다루며, 데이터의 처리 및 전송에 사용됩니다.

구분특징사용 목적예시
Buffer데이터를 일시적으로 저장하는 영역데이터를 임시로 버퍼링하거나 입출력 속도를 조절하기 위해 사용ByteBuffer, CharBuffer 등
List순차적인 데이터 구조데이터를 순차적으로 저장하고 관리하는 목적에 사용ArrayList, LinkedList 등
Channel입출력을 위한 연결 통로데이터를 읽고 쓰는데 사용되는 입출력 채널FileChannel, SocketChannel 등
QueueFIFO(First-In-First-Out) 구조데이터를 먼저 넣은 순서대로 꺼내는 구조LinkedList, ArrayDeque 등
Stream데이터의 연속적인 흐름을 다룸데이터를 연속적으로 처리하거나 전송하는 용도에 사용InputStream, OutputStream, FileInputStream 등

java.nio 패키지와 java.io 패키지

java.niojava.io 패키지는 Java에서 파일과 입출력을 다루는 데 사용되는 패키지들입니다. 각각의 패키지는 다음과 같은 차이점이 있습니다:

  1. 버퍼 지향(I/O) vs 스트림 지향(I/O):

    • java.io 패키지는 스트림 기반의 입출력을 제공합니다. InputStreamOutputStream을 사용하여 데이터를 읽고 쓸 수 있습니다. 이는 데이터가 연속적으로 흐르는 스트림을 통해 이루어집니다.
    • java.nio 패키지는 버퍼 지향의 입출력을 제공합니다. Buffer 클래스와 Channel 인터페이스를 사용하여 데이터를 버퍼에 읽고 쓰며, 데이터를 읽고 쓸 때 더욱 유연한 작업이 가능합니다.
    • 버퍼 지향 접근 방식은 데이터를 스트림으로부터 읽어와 버퍼에 쓰고, 버퍼에서 데이터를 읽어오는 방식으로 데이터를 처리합니다.
  2. 블로킹 vs 논블로킹 I/O:

    • java.io는 주로 블로킹 I/O를 사용합니다. I/O 작업이 완료될 때까지 프로그램이 대기하며, 데이터가 도착하기 전까지 다른 작업을 수행할 수 없습니다.
    • java.nio는 논블로킹 I/O를 지원합니다. 비동기적인 입출력 작업을 통해 다중 채널을 단일 스레드로 관리할 수 있으며, 채널이 데이터를 기다리는 동안에도 다른 작업을 수행할 수 있습니다.
  3. 스케일러블한 I/O:

    • java.nio는 다중 스레드로 작업을 처리하는 경우에 더 효율적입니다. 대용량 데이터 처리나 다중 채널 관리 시에 java.nio는 더 나은 성능을 보일 수 있습니다.
  4. 새로운 기능들:

    • java.nio는 비동기 파일 채널, 멀티플렉서, 셀렉터 등의 새로운 기능을 제공합니다. 이러한 기능들은 네트워킹과 관련된 작업이나 파일 I/O 작업을 더욱 효과적으로 다룰 수 있게 도와줍니다.

일반적으로, java.nio는 더욱 많은 기능과 유연성을 제공하지만, 사용하기에는 더 복잡할 수 있습니다. 작은 파일이나 간단한 입출력 작업을 처리할 때는 java.io가 간단하고 편리할 수 있습니다.

코드에 쓰일 주요 요소

  1. Files: 자바 NIO(Non-blocking I/O) 파일 관련 유틸리티를 제공하는 클래스로, 파일의 복사, 이동, 삭제 등 다양한 파일 작업을 지원합니다.

  2. MultipartFile: HTTP 멀티파트 요청에서 업로드된 파일을 나타내는 인터페이스로, 스프링에서 파일 업로드 처리를 위해 사용됩니다. InputStreamSource를 통해 데이터를 읽을 수 있습니다.

  • 멀티파트는 HTTP 요청에서 여러 종류의 데이터를 한꺼번에 전송하는 방식을 의미합니다. 각 파일은 멀티파트 형식으로 요청에 포함되며, 이러한 요청은 파일의 확장자에 관계없이 동일한 HTTP 요청 내에서 처리됩니다.
  • 멀티파트를 사용하여 파일을 전송한다고 해서 그 자체가 멀티스레딩을 의미하지는 않습니다. 멀티스레딩은 요청을 처리하는 방식이며, 이는 요청을 동기적으로 처리할지 아니면 비동기적으로 처리할지에 따라 결정됩니다. 파일 업로드는 멀티파트를 통해 여러 파일을 동시에 전송할 수 있게 해주지만, 요청의 처리 방식은 서버 측의 구현 및 설정에 따라 달라집니다.
  1. Resource: 파일이나 리소스를 추상화하는 인터페이스로, 스프링 프레임워크에서 리소스를 로드하고 읽는 기능을 제공합니다. InputStreamSource를 확장하여 스트림으로부터 데이터를 읽을 수 있도록 합니다.
  • Resource의 추상화는 절대경로 또는 상대경로 같은 파일 경로나, 하드링크 도는 심벌릭 링크 같은 파일시스템과 관련 된 것이 아니라, 개발자가 현재 개발중인 애플리케이션 내부의 파일, URL, ServeletContext 상의 리소스에 접근하여 읽기, 쓰기 를 할 수 있는 표준화된 방법을 제공한다
  • Resource를 사용하면 애플리케이션 코드에서 이러한 리소스에 정적이고 표준화된 방식으로 접근할 수 있지만, 런타임에 바이트코드를 수정하거나 프록시를 생성하는 등의 동적인 작업을 하는 것은 아닙니다.
  1. Model: 스프링에서 데이터를 뷰로 전달하기 위한 인터페이스로, 뷰에 전달할 데이터를 담는 역할을 합니다.

  2. MvcUriComponentsBuilder: 스프링 MVC에서 URI(Uniform Resource Identifier)를 생성하는 빌더 클래스로, 컨트롤러 및 메서드에 대한 URI를 동적으로 생성하는데 사용됩니다.

  • URI는 Uniform Resource Identifier의 약자로, 인터넷에서 리소스를 고유하게 식별하는 방법을 나타냅니다. URL(Uniform Resource Locator)의 일종으로, 웹 페이지, 파일, 서비스 등을 가리키는 주소입니다. 예를 들어, https://www.example.com/about와 같은 웹 주소가 URI의 한 형태입니다.
  • 컨트롤러 및 메서드에 대한 동적 URI 생성은 주로 웹 애플리케이션에서 사용되는데, 이것은 코드의 하드 코딩된 URL을 피하고, 대신 컨트롤러와 연결된 메서드에 대한 URI를 더 유연하게 생성하는 것을 의미합니다. 이를 통해 URL 구조가 변경되거나 컨트롤러 메서드의 이름이 변경되더라도, 동적으로 생성된 URI는 해당 변경 사항에 대응하여 자동으로 업데이트됩니다. 이는 유지보수성을 높이고, 재사용 가능한 코드를 작성하는 데 도움이 됩니다.
  1. RedirectAttributes: 리다이렉트 요청을 처리하기 위한 인터페이스로, 모델 데이터를 유지하고 리다이렉트 요청으로 전달하는 데 사용됩니다. Model을 확장하여 모델 데이터를 리다이렉트 시에도 유지할 수 있게 합니다.
  • 리다이렉트 요청은 클라이언트의 요청을 다른 위치로 전환하는 데 사용됩니다. 일반적으로, 이전에 요청한 URL에서 새로운 URL로 사용자를 전송하거나, 웹 애플리케이션에서 다른 페이지로 이동할 때 사용됩니다. 이는 사용자가 새로운 위치로 이동하거나, 새로운 리소스를 찾을 수 있도록 돕는 데 사용됩니다. 종종 HTTP 상태 코드 중 하나인 3xx 코드와 함께 사용되며, 서버에서 클라이언트로 리다이렉션 정보를 전송하여 처리합니다.

  • Flash attribute는 일시적으로 데이터를 저장하고 다음 요청으로 전달하는 메커니즘입니다. 스프링 프레임워크에서는 주로 리다이렉트 후에 데이터를 유지하고 싶을 때 사용됩니다. Flash attribute를 사용하면 다음과 같은 상황에서 데이터를 전달할 수 있습니다:

    • 한 번의 요청에서만 데이터를 유지하고자 할 때
    • 리다이렉트 후에 데이터를 전달하고자 할 때
  • Flash attribute는 일시적으로 유지되기 때문에 한 번의 요청에 대한 응답 이후 소멸됩니다. 이를 통해 리다이렉트 직후에 데이터를 전달하고 이후에는 필요하지 않을 때 사용됩니다.

  • 일반적으로 Flash attribute는 주로 사용자에게 성공 또는 실패 메시지를 표시하거나 리다이렉트 후에 데이터를 전달할 때 활용됩니다.

  1. Path: 파일 시스템 경로를 나타내는 인터페이스로, 파일 또는 디렉토리의 경로를 추상화합니다. Comparable로 비교 가능하며, Iterable로 요소를 순회하고, Watchable로 파일 시스템의 변경을 감시할 수 있습니다.

  2. Paths: 파일 시스템의 경로를 생성하는 유틸리티 클래스로, 파일 시스템 경로를 다루기 쉽게 만들어 줍니다.

  3. StandardCopyOption: 파일 복사에 대한 옵션을 제공하는 열거형 클래스로, 파일을 복사할 때 동작을 지정하는 옵션들을 정의합니다.

  4. FileSystemUtils: 파일 시스템 관련 유틸리티를 제공하는 추상 클래스로, 파일 및 디렉토리 관련 작업을 수행하는 메서드를 제공합니다.

주요 메서드

  1. Files.walk(Path start, int maxDepth, FileVisitOption... options)

    • 시작 경로부터 하위 디렉토리를 재귀적으로 탐색하는 메서드입니다.
    • start 경로부터 시작하여 maxDepth 깊이까지 탐색하며, FileVisitOption으로 탐색 옵션을 설정할 수 있습니다.
    • 디렉토리 내의 모든 하위 경로를 방문하고, Stream<Path> 형태로 반환하여 각 경로를 처리할 수 있습니다.
  2. Path.resolve(Path other)

    • 현재 경로와 다른 경로를 결합하여 새로운 경로를 생성하는 메서드입니다.
    • 현재 경로가 디렉토리라면, 주어진 다른 경로를 현재 경로에 덧붙여 새로운 경로를 반환합니다.
    • 상대 경로를 절대 경로로 변환하거나, 경로를 결합하여 새로운 경로를 생성할 때 사용합니다.
  3. Path.relativize(Path other)

    • 두 경로 간의 상대적인 경로를 생성하는 메서드입니다.
    • 현재 경로를 기준으로 다른 경로와의 상대적인 경로를 계산하여 반환합니다.
    • 두 경로가 같은 루트인 경우 상대적인 경로를 찾을 수 없을 때 예외가 발생합니다.
  4. Path.normalize()

    • 경로의 정규화된 형태를 반환하는 메서드입니다.
    • 상대 경로("..")나 현재 경로(".") 등을 처리하여 경로를 더욱 의미 있는 형태로 바꿉니다.
    • 경로의 중복된 요소를 제거하고, 상대 경로를 절대 경로로 변환하여 반환합니다.

Creating an HTML Template

다음 Thymeleaf 템플릿(src/main/resources/templates/uploadForm.html)은 파일을 업로드하는 방법과 업로드된 내용을 보여주는 예를 보여줍니다.

이 템플릿은 세 부분으로 구성됩니다.

  • Spring MVC가 flash-scoped 메시지를 작성하는 상단의 선택적 메시지입니다.
  • 사용자가 파일을 업로드할 수 있는 양식입니다.
  • 백엔드에서 제공되는 파일 목록입니다.
<html xmlns:th="https://www.thymeleaf.org">
<body>

	<div th:if="${message}">
		<h2 th:text="${message}"/>
	</div>

	<div>
		<form method="POST" enctype="multipart/form-data" action="/">
			<table>
				<tr><td>File to upload:</td><td><input type="file" name="file" /></td></tr>
				<tr><td></td><td><input type="submit" value="Upload" /></td></tr>
			</table>
		</form>
	</div>

	<div>
		<ul>
			<li th:each="file : ${files}">
				<a th:href="${file}" th:text="${file}" />
			</li>
		</ul>
	</div>

</body>
</html>

애플리케이션 기능과 구조

  1. 서비스 : 기능
    (1) 저장소 초기화 : initialize 할 때 필요한 것? 초기값, 기존에 저장된 파일 목록

    • 저장된 파일 목록 보이기
    • 새로 추가한 파일 반영하여 리스트 갱신하기
    • 에러 발생시 이전 상태로 돌아가기 (리다이렉션 = HTTP 3XX)

    (2) 파일 열기 : 파일을 검색하고 선택하고 가져오기 위해 필요한 단서가 무엇일까? 경로, 이름, 유형

    • 특정 위치 내에서 파일 이름으로 지정하기
    • 특정 위치 내의 파일 여러 개 지정하기

    (3) 파일 저장하기

    • 데이터베이스 연동(??)

    (4) 저장소의 파일 지우기

  2. 기능 별 요소

    (1-1) 초기화 : 기존에 저장된 파일 목록

    • 프론트와 관련된 부분 : 템플릿으로 보여지는 부분
    • Rest, HTTP 관련 GetMapping, ResponseBody, ResponseEntity 등 어노테이션 예상
    • 예상 반환 타입 : List<?>
    • 메서드 레벨에서 HTTP 관련 어노테이션으로 사용할 것 같음
    • 예상 메서드 : get

    (1-2) 초기화 : 리다이렉션 - 에러, 실패

    • 프론트와 관련된 부분 : Flash-Scoped 메시지 처리
    • 뷰에 전달되는 데이터를 담는 'Model' 객체에 flash-scoped 메시지 관련 속성 더하기
    • Http Header 메시지에 리다이렉션 코드 및 메세지를 기재하기 위해 ResponseEntity 사용
    • 에러 유형 : 찾기 실패, 업로드 실패
    • 에러 구현 : IOException 또는 RuntimeException 상속 또는 구현
    • 예상 반환 타입 : ResponseEntity
    • 예상 메서드 :

    (1-3) 초기화 : 올린 파일 찾기

    • 애플리케이션에서 추상화할 수 있도록 Resource 형태로 추상화
    • 추상화가 완료 후 변경 및 반영된 내역 반환 : 헤더메시지
    • 예상 반환 타입 : ResponseEntity
    • 예상 메서드 : 저장소 클래스 내부 검색 기능

    (1-4) 초기화 : 리디렉션 : 업로드, 성공

    • 프론트와 관련된 부분 : Flash-Scoped 메시지 처리
    • 뷰에 전달되는 데이터를 담는 'Model' 객체에 flash-scoped 메시지 관련 속성 더하기
    • Http Header 메시지에 리다이렉션 코드 및 메세지를 기재하기 위해 ResponseEntity 사용
    • 예상 반환 타입 : ResponseEntity
    • 예상 메서드 : 저장소 저장 기능

    (2-1) 파일 열기 : 초기값 경로(루트) 가져오기

    • 예상 타입 : Path
    • 경로를 나타내는 주소를 스트링으로 표현하여 직접 조작하기보다 이미 만들어진 Path 의 메서드를 활용하는 것이 편해보임.
    • 저장소로 쓸 디렉터리를 직접 기재 : String
    • String으로 표현된 디렉터리를 Path로 변환

    (2-2) 파일 열기 : 파일 이름으로 가져오기

    • 예상 반환 타입 : Path
    • 매개변수 타입 : String
    • 매개변수 : 파일 이름
    • 예상 메서드 : 현재 경로와 다른 경로 결합하여 새로운 경로를 생성하거나 상대 경로를 절대 경로로 변환하는 Path.resolve

    (2-3) 파일 열기 : 저장된 파일 전체 가져오기

    • 예상 반환 타입 : 여러 개의 경로를 가져오므로 기본타입이 Path인 Buffer 또는 Stream
    • 예상 메서드 : 경로를 재귀적으로 검색하고 Stream<Path> 형태로 반환하여 각 경로를 처리하는 Files.walk, 현재 경로 기준 다른 경로와의 상대 경로 계산하는 Path.relativize

    (2-4) 파일 열기 : 파일을 리소스 형태로 변환하기

    • 예상 반환 타입 : Resource
    • filename으로 찾은 Path를 URI로 변환 후 이것을 다시 URL로 만들기

    (3-1) 파일 저장하기

    • 예산 반환 타입 : void
    • 매개변수 타입 : MultipartFile
    • 매개변수 : 파일 자체
    • 저장 지점 : 루트에서 resolve하여 normalize
    • 저장 작업 : InputStream으로 file을 받아서 저장 지점에 복사/덮어씌우기

Create an Application Class

Spring Boot MVC 애플리케이션을 시작하려면 먼저 스타터가 필요합니다. 이 샘플에서는 spring-boot-starter-thymeleafspring-boot-starter-web이 이미 종속성으로 추가되었습니다. 서블릿 컨테이너로 파일을 업로드하려면 MultipartConfigElement 클래스(web.xml<multipart-config>)를 등록해야 합니다. Spring Boot 덕분에 모든 것이 자동으로 구성됩니다!

이 애플리케이션을 시작하는 데 필요한 것은 다음 UploadingFilesApplication 클래스(src/main/java/guides/uploadingfiles/UploadingFilesApplication.java)입니다.

package guides.uploadingfiles;

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

@SpringBootApplication
public class UploadingFilesApplication {

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

Spring MVC 자동 구성(auto-configure)의 일부로 Spring Boot는 MultipartConfigElement 빈을 생성하고 파일 업로드를 준비합니다.

스프링 부트에서 사용하는 주요 컨테이너

Spring Boot는 주로 서블릿 컨테이너를 내장하여 사용되지만, 필요에 따라 리액티브 컨테이너를 사용할 수도 있고, 외부의 웹 서버나 컨테이너 오케스트레이션 플랫폼과 통합하여 애플리케이션을 실행할 수 있습니다. Spring Boot는 이러한 다양한 환경에서 유연하게 실행될 수 있도록 다양한 설정 옵션을 제공합니다.

  1. 리액티브 웹 컨테이너:

    • 리액티브 웹 애플리케이션을 위한 컨테이너로, 비동기적이고 논블로킹 I/O를 사용하여 웹 요청을 처리합니다. Netty와 같은 컨테이너가 리액티브 애플리케이션을 지원합니다.
  2. 웹 서버:

    • 웹 서버는 HTTP 요청을 받아들이고 처리하는 서버입니다. 이 서버는 정적 파일을 제공하거나 웹 애플리케이션을 실행할 수 있습니다. Nginx, Apache HTTP Server 등이 대표적인 웹 서버입니다.
  3. 컨테이너 오케스트레이션 플랫폼:

    • 컨테이너 오케스트레이션 플랫폼은 여러 컨테이너를 관리하고 배포하는데 사용됩니다. Kubernetes, Docker Swarm, Apache Mesos 등이 있으며, 이러한 플랫폼을 사용하여 마이크로서비스를 구축하고 운영할 수 있습니다.
  4. 애플리케이션 서버:

    • WAS(웹 애플리케이션 서버)는 여러 기능을 포함하고 있는 서버로, Java EE(Enterprise Edition) 애플리케이션을 실행하는 데 사용됩니다. 대표적으로는 Oracle WebLogic, IBM WebSphere 등이 있습니다.

Create a FileUploadController Controller

클래스 : FileUploadController

초기 애플리케이션에는 업로드된 파일을 디스크에 저장하고 로드하는 작업을 처리하는 몇 가지 클래스가 이미 포함되어 있습니다. 모두 guides.uploadingfiles.storage 패키지에 있습니다. 이를 새 FileUploadController에서 사용하게 됩니다. 다음 목록(src/main/java/guides/uploadingfiles/FileUploadController.java)은 파일 업로드 컨트롤러를 보여줍니다.

package guides.uploadingfiles;

import guides.uploadingfiles.storage.StorageFileNotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.io.IOException;
import java.util.stream.Collectors;

import guides.uploadingfiles.storage.StorageFileNotFoundException;
import guides.uploadingfiles.storage.StorageService;

@Controller
public class FileUploadController {

    private final StorageService storageService;

    @Autowired
    public FileUploadController(StorageService storageService) {
        this.storageService = storageService;
    }

    @GetMapping("/")
    public String listUploadFiles(Model model) throws IOException{

        model.addAttribute(
                "files",
                storageService.loadAll().map(
                        path -> MvcUriComponentsBuilder.fromMethodName(
                                FileUploadController.class,
                                "serveFile",
                                path.getFileName().toString()
                        ).build().toUri().toString()
                ).collect(Collectors.toList()));

        return "uploadForm";

    }

    @GetMapping("/files/{filename:.+}")
    @ResponseBody
    public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

        Resource file = storageService.loadAsResource(filename);

        if (file==null) {
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"" + file.getFilename() + "\"")
                .body(file);
    }

    @PostMapping("/")
    public String handleFileUpload(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) {

        storageService.store(file);
        redirectAttributes.addFlashAttribute(
                "message",
                "You successfully uploaded " + file.getOriginalFilename() + "!");

        return "redirect:/";
    }

    @ExceptionHandler(StorageFileNotFoundException.class)
    public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {

        return ResponseEntity.notFound().build();

    }

}

FileUploadController 클래스는 @Controller로 주석 처리되어 Spring MVC가 이를 선택하고 경로를 찾을 수 있습니다. 각 메서드에는 @GetMapping 또는 @PostMapping 태그가 지정되어 경로와 HTTP 작업을 특정 컨트롤러 작업에 연결합니다.

이 경우:

  • GET /: StorageService에서 현재 업로드된 파일 목록을 조회하고 이를 Thymeleaf 템플릿에 로드합니다. MvcUriComponentsBuilder를 사용하여 실제 리소스에 대한 링크를 계산합니다.

  • GET /files/{filename}: 리소스(존재하는 경우)를 로드하고 Content-Disposition 응답 헤더를 사용하여 다운로드할 브라우저로 보냅니다.

  • POST /: 여러 부분으로 구성된 메시지 파일을 처리하고 저장을 위해 StorageService에 제공합니다.

프로덕션 시나리오에서는 파일을 임시 위치, 데이터베이스 또는 NoSQL 저장소(예: Mongo의 GridFS)에 저장할 가능성이 높습니다. 애플리케이션의 파일 시스템에 콘텐츠를 로드하지 않는 것이 가장 좋습니다.


  1. @Controller
    컨트롤러는 웹 애플리케이션에서 주로 사용자 인터페이스와 비즈니스 로직 간의 중간 매개체로 작동하며, 클
    라이언트의 요청에 따라 적절한 데이터를 검색하고, 가공하여 적절한 뷰에 전달합니다.

    웹 애플리케이션에서 컨트롤러는 다양한 엔드포인트에 매핑되어 각각의 요청을 처리합니다. 엔드포인트는 네트워크 통신에서 특정한 서비스, 기능 또는 리소스에 접근하기 위한 경로나 URL을 나타냅니다.

    이러한 요청에는 HTTP 메소드(GET, POST, PUT, DELETE 등)와 함께 오는 데이터가 포함됩니다. 컨트롤러는 이러한 요청을 처리하고, 비즈니스 로직이나 서비스를 호출하여 데이터를 가져오거나 변경한 뒤, 적절한 뷰를 렌더링하여 사용자에게 응답을 제공합니다.

  2. @Autowired: 생성자 주입 방식을 사용하여 StorageService의 인스턴스를 주입합니다.

  3. @GetMapping("/"): 루트 경로(/)에 대한 GET 요청을 처리합니다. Model을 인자로 받아 파일 목록을 불러와 uploadForm 템플릿을 렌더링합니다.

  • Model은 스프링에서 데이터를 뷰에 전달하는 데 사용되는 클래스입니다.
  • uploadForm은 뷰를 나타내는 문자열이며, 스프링에서는 이것을 템플릿의 이름으로 사용합니다. 이 이름은 렌더링할 실제 템플릿 파일을 찾기 위해 사용됩니다. 일반적으로 uploadForm은 프론트엔드에서 사용되는 HTML 템플릿 파일의 이름이 될 수 있습니다.
  • model.addAttribute("files", ...): 모델에 files라는 속성을 추가합니다.
  • storageService.loadAll().map(...).collect(Collectors.toList()) : storageService에서 파일 목록을 가져오는 메서드를 호출하고, 파일 목록의 각 파일에 대해 (...)에 해당하는 내용을 처리 합니다. 그리고 처리한 파일을 리스트로 수집합니다.
  • MvcUriComponentsBuilder.fromMethodName은 컨트롤러 클래스와 메서드 이름을 기반으로 URI를 생성하는 데 사용됩니다. 위 코드에서는 FileUploadController 클래스의 serveFile 메서드에 대한 URI를 생성하고 있어요. serveFile 메서드는 /files/{filename:.+}와 연결되어 있고, path.getFileName().toString()은 파일 이름을 의미합니다. 따라서 MvcUriComponentsBuilder.fromMethodName은 파일을 다운로드하기 위한 URI를 생성하는 데 사용됩니다.
    • path.getFileName()Path의 파일 이름을 나타내는 Path 객체를 반환합니다. 이 객체는 toString() 메서드를 통해 문자열로 변환될 수 있습니다
    • build() 메서드 호출로 UriComponents 객체가 만들어집니다. 이 객체는 URI를 나타내는데, .toUri()를 호출하면 실제 java.net.URI 객체로 변환됩니다. 그리고 이렇게 만들어진 java.net.URI 객체를 문자열로 변환하기 위해 .toString()을 사용할 수 있습니다.
    • UriComponents는 스프링 프레임워크의 URI 관련 유틸리티 클래스입니다. 이 클래스는 URI의 각 부분을 개별적으로 가져오고 조작할 수 있는 메서드들을 제공합니다. 반면 java.net.URI 클래스는 Java 표준 라이브러리에서 제공하는 URI를 다루기 위한 클래스입니다. 두 클래스는 비슷한 목적으로 URI를 다루지만, UriComponents는 스프링에서 제공하는 확장된 기능과 유틸리티를 제공하는 반면, java.net.URI는 Java 표준 기능만을 포함하고 있습니다.
    • UriComponentstoString() 메서드를 호출하면 URI의 문자열 표현을 얻을 수 있으므로, toUri() 메서드를 따로 사용하지 않아도 됩니다

4.@GetMapping("/files/{filename:.+}"): /files/{filename}

  • {filename}: 경로 변수. 여기에는 실제 파일 이름이 올 것입니다.
  • :.+: .은 임의의 문자 하나를 의미하고, +는 하나 이상의 문자가 있는지를 나타냅니다. 따라서 :.+는 파일 이름에 마침표를 포함한 어떤 문자열이든 허용한다는 의미입니다.
  • 즉, /files/ 다음에 오는 경로 변수(filename)에는 확장자를 포함한 모든 파일 이름이 올 수 있도록 정의되었습니다.

5.@ResponseBody: HTTP 응답 본문으로 직접 데이터를 작성하는 데 사용되는 어노테이션입니다.

6.@PathVariable: Spring MVC에서 URL 경로의 일부를 매개변수로 캡처하기 위해 사용되는 어노테이션입니다. @PathVariable은 URL 경로에서 특정한 부분을 추출하여 메서드의 매개변수로 전달합니다. 이 경우, "/files/{filename:.+}"에서 {filename}에 해당하는 값을 String filename 매개변수로 받아옵니다.

  • ResponseEntity: HTTP 응답을 나타내는 Spring 프레임워크의 클래스입니다. ResponseEntity는 HTTP 응답 코드, 헤더, 본문 등을 포함할 수 있는 강력한 반환 유형을 제공합니다. 이 클래스를 사용하면 응답을 특정 상태 코드와 함께 보낼 수 있고, 헤더 및 본문을 추가적으로 조작할 수 있습니다.

  • HttpHeaders: HTTP 헤더를 나타내는 Spring 프레임워크의 클래스입니다. HttpHeaders를 사용하여 HTTP 응답 또는 요청의 헤더를 설정할 수 있습니다. 위 코드에서는 HttpHeaders.CONTENT_DISPOSITION을 사용하여 응답의 Content-Disposition 헤더를 설정하여 다운로드 파일의 이름을 지정하고 있습니다.

7.@PostMapping("/"): 루트 경로(/)에 대한 POST 요청을 처리합니다. 업로드된 파일을 저장하고, redirectAttributes를 사용하여 메시지를 추가한 뒤 루트 경로로 리다이렉션합니다.

8.@ExceptionHandler(StorageFileNotFoundException.class): StorageFileNotFoundException 예외를 처리하는 메소드로, 해당 예외가 발생하면 404 상태 코드를 반환합니다.


Create a FileSystemStorageService Service

인터페이스 : StorageService

컨트롤러가 스토리지 계층(예: 파일 시스템)과 상호 작용할 수 있도록 StorageService를 제공해야 합니다. 다음 목록(src/main/java/guides/uploadingfiles/storage/StorageService.java)은 해당 인터페이스를 보여줍니다.

package guides.uploadingfiles.storage;

import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {

	void init();

	void store(MultipartFile file);

	Stream<Path> loadAll();

	Path load(String filename);

	Resource loadAsResource(String filename);

	void deleteAll();

}

또한 StorageService를 지원하려면 네 가지 클래스가 필요합니다.

클래스 : StorageProperties

package guides.uploadingfiles.storage;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("storage")
public class StorageProperties {

	/**
	 * Folder location for storing files
	 */
	private String location = "upload-dir";

	public String getLocation() {
		return location;
	}

	public void setLocation(String location) {
		this.location = location;
	}

}

클래스 : StorageException

package guides.uploadingfiles.storage;

public class StorageException extends RuntimeException {

	public StorageException(String message) {
		super(message);
	}

	public StorageException(String message, Throwable cause) {
		super(message, cause);
	}
}

클래스 : StorageNotFoundException

package guides.uploadingfiles.storage;

public class StorageFileNotFoundException extends StorageException {

	public StorageFileNotFoundException(String message) {
		super(message);
	}

	public StorageFileNotFoundException(String message, Throwable cause) {
		super(message, cause);
	}
}

클래스 : FileSystemStorageService

package guides.uploadingfiles.storage;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;

@Service
public class FileSystemStorageService implements  StorageService {
    private final Path rootLocation;

    @Autowired
    public FileSystemStorageService(StorageProperties properties) {

        if (properties.getLocation().trim().length() == 0) {
            throw new StorageException("File upload location can not be Empty");
        }

        this.rootLocation = Paths.get(properties.getLocation());

    }

    @Override
    public void store(MultipartFile file) {
        try {
            if (file.isEmpty()) {
                throw new StorageException("Failed to storage empty file.");
            }

            Path destinationFile = this.rootLocation.resolve(
                    Paths.get(file.getOriginalFilename())
            ).normalize().toAbsolutePath();

            if (!destinationFile.getParent().equals(
                    this.rootLocation.toAbsolutePath()
            )) {
                throw new StorageException("Cannot store file outside current directory.");
            }

            try (InputStream inputStream = file.getInputStream()) {
                Files.copy(
                        inputStream,
                        destinationFile,
                        StandardCopyOption.REPLACE_EXISTING
                );
            }
        } catch (IOException e) {
                throw new StorageException("Failed to store file.", e);
        }
    }

    @Override
    public Stream<Path> loadAll () {
        try {
            return Files.walk(this.rootLocation, 1)
                    .filter(path -> !path.equals(this.rootLocation))
                    .map(this.rootLocation::relativize);
        } catch (IOException e) {
            throw new StorageException("Failed to read stored files", e);
        }
    }

    @Override
    public Path load (String filename) {
        return rootLocation.resolve(filename);
    }

    @Override
    public Resource loadAsResource (String filename) {
        try {
            Path file = load(filename);
            Resource resource = new UrlResource(file.toUri());
            if (resource.exists() || resource.isReadable()) {
                return resource;
            } else {
                throw new StorageFileNotFoundException("Could not read file: " + filename);
            }
        } catch (MalformedURLException e) {
            throw new StorageFileNotFoundException("Could not read file: " + filename, e);
        }
    }

    @Override
    public void deleteAll () {
        FileSystemUtils.deleteRecursively(rootLocation.toFile());
    }

    @Override
    public void init () {
        try {
            Files.createDirectories(rootLocation);
        } catch (IOException e) {
            throw new StorageException("Could not initialize storage", e);
        }
    }
}
  1. public void store(MultipartFile file) {...}
  • this.rootLocation.resolve(...).normalize().toAbsolutePath : rootLocation에 새로운 경로(...)를 연결하여 표준 경로로 정규화 한 후 절대 경로로 변환 합니다.

    • rootLocation은 파일이 저장될 기본 디렉터리입니다. 이 저장소는 파일 시스템의 특정 위치를 가리키며, 데이터베이스가 아닌 파일 시스템에 파일을 저장합니다. 사용자가 지정한 위치에 파일을 저장하거나 불러올 수 있게끔 구성되어 있습니다.
    • normalize로 중복된 경로 구분자를 삭제 : /folder1//folder2///file.txt와 같이 중복된 슬래시가 있는 경우, 정규화를 하면 /folder1/folder2/file.txt와 같이 중복된 슬래시를 제거
    • normalize로 상대 경로를 간결하게 : /myFolder/../myFile.txt와 같은 경로는 상위 디렉토리로 이동하는 ..을 포함하고 있습니다. normalize를 하게 되면 이런 상위 경로 이동 구문을 해석하여 경로를 더 간결하게 만들어줍니다. 따라서 이 경우 /myFile.txt로 경로가 정규화
    • toAbsolutePath()는 좀 더 철저한 절대 경로
  • destinationFile.getParent() : getParent()는 현재 경로의 부모 경로를 나타내는 Path 객체를 반환합니다. 이 메서드는 파일 시스템에 직접 접근하지 않으며, 경로 또는 해당 부모가 존재하지 않을 수 있습니다. 또한, 이 메서드는 구현에 따라 ".", ".."과 같은 특수 이름을 제거하지 않습니다. 예를 들어 UNIX 시스템에서 /a/b/c의 부모는 /a/b이며, x/y/.의 부모는 x/y입니다.

  • try (InputStream inputStream = file.getInputStream()) {...} : Java 7에서 추가된 기능 중 하나인 'try-with-resources'입니다. 이를 사용하면 try 블록 내에서 자동으로 리소스를 닫을 수 있습니다.

    • try-with-resources : 이 방식은 AutoCloseable 인터페이스를 구현하는 리소스를 사용할 때 특히 유용합니다. 이것을 사용하면 코드 블록이 끝나는 시점에 리소스를 자동으로 닫아 메모리 누수를 방지할 수 있습니다. Java에서 파일, 소켓 및 데이터베이스 연결과 같은 외부 리소스를 다룰 때 유용하게 활용됩니다.
    • 이 경우 inputStreamgetInputStream()으로 얻은 값을 파일에 복사할 때 사용됩니다
  1. public Stream<Path> loadAll () {...}
  • Files.walk(this.rootLocation, 1): 지정된 경로(this.rootLocation)에서 하위 파일과 디렉토리를 포함하여 최대 1단계까지 탐색합니다.
    • .filter(path -> !path.equals(this.rootLocation)): 시작 경로(this.rootLocation)를 제외한 모든 경로를 필터링합니다.
    • .map(this.rootLocation::relativize): 시작 경로(this.rootLocation)로부터 상대 경로를 가져옵니다.
      • 두 경로가 모두 루트 컴포넌트를 가지지 않는다는 것은 서로 동일한 루트를 공유하지 않는 부모를 가지고 있다는 것을 의미합니다. 예를 들어, 경로 A가 /root/folder1이고 경로 B가 /root/folder2인 경우, 이 두 경로는 같은 루트인 /root를 가지고 있으며, 서로 다른 하위 디렉토리인 folder1folder2를 부모로 가지고 있습니다. 따라서 이런 상황에서 relativize 메서드를 사용하여 상대 경로를 만들 수 있습니다.
  • catch (IOException e) { throw new StorageException("Failed to read stored files", e); }: 입출력 예외가 발생하면 StorageException을 던집니다.
  1. deleteRecursively(rootLocation.toFile())
  • deleteRecursively()는 스프링 프레임워크의 FileSystemUtils 클래스에서 제공되는 메서드이며, 지정된 경로를 재귀적으로 삭제하는데 사용됩니다. 지정된 경로가 디렉터리인 경우, 해당 디렉터리와 그 내용물을 모두 삭제합니다.
  • deleteRecursively(rootLocation.toFile())rootLocation에 지정된 디렉터리를 모두 삭제합니다.
  • rootLocation 자체는 삭제하지 않습니다.
  1. Files.createDirectories(rootLocation)
  • createDirectories() 메서드는 파일 시스템에 디렉터리를 생성할 때 사용됩니다. 해당 경로에 디렉터리가 없으면 디렉터리를 생성하고, 디렉터리가 이미 존재하는 경우에는 아무런 동작을 하지 않습니다.
  • Files.createDirectories(rootLocation)rootLocation에 해당하는 디렉터리가 존재하지 않으면 그 디렉터리를 생성합니다.
  1. public Resource loadAsResource (String filename) {...}
  • Path file = load(filename) : 같은 클래스 내부의 load 메서드를 사용한 것이며 rootLocation.resolve(filename) 과 같다.
  • Resource resource = new UrlResource(file.toUri()) : UrlResourceResource 인터페이스를 구현한 추상 클래스 AbstractResource를 상속한 AbstractFileResolvingResource를 상속한 클래스이다. - 업캐스팅을 위한 (Resource)가 생략되었다.
  1. public Path load (String filename) { return rootLocation.resolve(filename);}
  • load 의 반환 값을 보았을 때, 컨트롤러에서 model에 files 라는 속성명으로 storageService.loadAll()로 가져온 Stream<Path>map으로 처리하는 일련의 가공을 단순화 할 수도 있을 것 같다.
    • model.addAttribute( "files", storageService.loadAll().map(path -> path.toUri()).collect(Collectors.toList()));
    • 여기서 더 나아가 람다 표현식path -> path.toUri()를 메서드 참조 Path::toUri로 교체 가능하다.
      • 둘 다 Path 객체를 가져와 toUri() 메서드를 호출하여 해당 경로를 URI로 변환합니다. 이 두 가지 방법은 기능적으로 동등하다.
      • 함수형 인터페이스를 통한 람다 표현식 또는 메서드 참조를 사용하여 같은 결과를 얻을 수 있습니다.

Tuning File Upload Limits

파일 업로드를 구성할 때 파일 크기에 대한 제한을 설정하는 것이 유용한 경우가 많습니다. 5GB 파일 업로드를 처리한다고 상상해보세요! Spring Boot를 사용하면 일부 속성 설정으로 자동 구성된 MultipartConfigElement를 조정할 수 있습니다.

기존 속성 설정(src/main/resources/application.properties)에 다음 속성을 추가합니다.

spring.servlet.multipart.max-file-size=128KB
spring.servlet.multipart.max-request-size=128KB

멀티파트 설정은 다음과 같이 제한됩니다.

  • spring.servlet.multipart.max-file-size는 128KB로 설정됩니다. 이는 총 파일 크기가 128KB를 초과할 수 없음을 의미합니다.
  • spring.servlet.multipart.max-request-size는 128KB로 설정됩니다. 이는 multipart/form-data에 대한 총 요청 크기가 128KB를 초과할 수 없음을 의미합니다.
    • HTML<form> 태그의 enctype 속성은 폼 데이터(form data)가 서버로 제출될 때 해당 데이터가 인코딩되는 방법을 명시합니다.
    • multipart/form-data 이 속성값은 모든 문자를 인코딩하지 않음을 명시하며 <form> 요소가 파일이나 이미지를 서버로 전송할 때 주로 사용합니다.

Update the Application

파일을 업로드할 대상 폴더가 필요하므로 Spring Initializr가 생성한 기본 UploadingFilesApplication 클래스를 강화하고 Boot CommandLineRunner를 추가하여 시작 시 해당 폴더를 삭제하고 다시 생성해야 합니다. 다음 목록(src/main/java/guides/uploadingfiles/UploadingFilesApplication.java)은 이를 수행하는 방법을 보여줍니다.

package guides.uploadingfiles;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

import guides.uploadingfiles.storage.StorageProperties;
import guides.uploadingfiles.storage.StorageService;

@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class UploadingFilesApplication {

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

    @Bean
    CommandLineRunner init(StorageService storageService) {
        return (args) -> {
            storageService.deleteAll();
            storageService.init();
        };
    }
}
  • @ConfigurationProperties : 외부화된 구성에 대한 애너테이션입니다. 일부 외부 속성(예: .properties 파일에서)을 바인딩하고 유효성을 검사하려면 @Configuration 클래스의 @Bean 메서드 또는 클래스 정의에 이를 추가하세요.
    바인딩은 애너테이션이 달린 클래스에서 setter를 호출하거나 @ConstructorBinding을 사용하는 경우 생성자 매개 변수에 바인딩하여 수행됩니다.
    @Value와는 반대로 SpEL 표현식은 속성 값이 외부화되므로 평가되지 않습니다.

  • @ConfigurationProperties("storage")는 속성 파일(application.properties)이나 환경 변수에서 storage.*와 일치하는 속성을 찾습니다. 이 코드에서는 StorageProperties 클래스의 필드와 매핑하려고 시도합니다. 만약 storage.*와 매치되는 속성이 없다면 그 필드들은 디폴트 값(null 또는 primitive type의 default 값)으로 초기화됩니다.

  • @EnableConfigurationProperties@ConfigurationProperties로 설정된 클래스들을 스프링에서 사용할 수 있도록 빈으로 등록합니다. 설정 클래스에 정의된 필드들과 매칭하여, 애플리케이션의 설정 파일에 있는 값을 읽어올 수 있게 됩니다.

  • @Bean CommandLineRunner init(StorageService storageService) : init 메서드를 통해 StorageService를 주입받아 빈을 생성하고 있음. @Bean CommandLineRunner run(@Autowired StorageService storageService) 는 생성자 주입 annotation인 @Autowired를 통해 StorageService를 주입받는 run 메서드를 통해 빈을 생성하므로 두 코드가 기능적으로 같음.

테스트 관련 개발 지식

1.@SpringBootTest : Spring Boot 기반 테스트를 실행하는 테스트 클래스에 지정할 수 있는 주석입니다. 일반 Spring TestContext Framework 외에 다음 기능을 제공합니다.

  • 특정 @ContextConfiguration(loader=...)이 정의되지 않은 경우 SpringBootContextLoader를 기본 ContextLoader로 사용합니다.
  • 중첩된 @Configuration이 사용되지 않고 명시적 클래스가 지정되지 않은 경우 @SpringBootConfiguration을 자동으로 검색합니다.
  • properties 속성을 사용하여 사용자 정의 Environment 속성을 정의할 수 있습니다.
  • args 속성을 사용하여 애플리케이션 인수를 정의할 수 있습니다.
  • 정의된 포트 또는 임의의 포트에서 수신 대기하는 완전히 실행되는 웹 서버를 시작하는 기능을 포함하여 다양한 webEnvironment 모드에 대한 지원을 제공합니다.
  • 완전히 실행되는 웹 서버를 사용하는 웹 테스트에 사용할 TestRestTemplate 및/또는 WebTestClient Bean을 등록합니다.

2.Mockito 와 BDDMockito

3.@MockBean
Spring ApplicationContext에 Mock을 추가하는 데 사용할 수 있는 어노테이션입니다.

  • 클래스 레벨 어노테이션,
  • @Configuration 클래스,
  • SpringRunner로 실행되는(@RunWith) 테스트 클래스의 필드에 사용할 수 있습니다.

Mock의 등록 방식

  • 유형(type)별로 등록하면 컨텍스트에 있는 해당 유형의 기존 단일 빈(하위 클래스 포함)은 Mock으로 대체됩니다.
  • 이름(name)별로 등록하면 특정 빈을 명시적으로 Mock으로 대상화할 수 있습니다.
  • 어느 경우에도 기존 빈이 정의되지 않은 경우 새로운 빈이 추가됩니다.
  • 애플리케이션 컨텍스트에 알려진 하지만 빈이 아닌 종속성(직접 등록된 것과 같은)은 찾을 수 없으며 기존 종속성 옆에 Mock된 빈이 추가됩니다.

(1) 일반적인 사용 예시

@RunWith(SpringRunner.class)
public class ExampleTests {

    @MockBean
    private ExampleService service;

    @Autowired
    private UserOfService userOfService;

    @Test
    public void testUserOfService() {
        given(this.service.greet()).willReturn("Hello");
        String actual = this.userOfService.makeUse();
        assertEquals("Was: Hello", actual);
    }

    @Configuration
    @Import(UserOfService.class) // ExampleService로 주입되는 @Component
    static class Config {
    }
}

테스트의 목적은 UserOfService를 테스트하고, 이 때 ExampleService를 Mock으로 대체하여 특정 상황을 시뮬레이션하는 것으로 보입니다.

  • @RunWith(SpringRunner.class): 이 어노테이션은 테스트를 실행할 때 스프링의 테스트 컨텍스트와 함께 JUnit을 실행하도록 지시합니다. SpringRunner는 JUnit에 통합된 스프링 테스트 지원을 활용하여 테스트를 실행합니다.

  • @MockBean: 이 어노테이션은 테스트에서 사용할 Mock 빈을 선언합니다. 여기서는 ExampleService를 Mock으로 선언하고 있습니다. MockBean 어노테이션을 사용하면 해당 빈이 스프링 애플리케이션 컨텍스트에 Mock으로 등록되고, 필요한 경우 실제 빈 대신 Mock이 사용됩니다.

  • @Autowired: 이 어노테이션은 필드 또는 생성자에 사용되어 해당 타입의 빈을 주입받습니다. 여기서는 UserOfService를 주입받고 있습니다. 스프링은 @Autowired를 통해 필요한 빈을 찾아서 주입해줍니다.

  • @Configuration: 이 어노테이션은 스프링의 자바 기반 설정 클래스를 나타냅니다. 여기서는 Config 클래스가 @Configuration 어노테이션을 가지고 있어서 스프링에게 이 클래스가 애플리케이션의 설정을 포함하고 있다고 알려줍니다.

  • @Import(UserOfService.class): 이 어노테이션은 다른 설정 클래스나 컴포넌트를 현재의 설정 클래스에 추가로 포함하도록 합니다. 여기서는 UserOfService 클래스를 추가로 포함시켜서 해당 클래스에 정의된 빈들을 사용할 수 있도록 합니다.

(2) 동일한 타입의 빈을 여러개 생성한 경우 : 필드 레벨에서 한정자(@Qulaifier) 메타데이터를 지정해야 합니다.

여러 데이터베이스 연결을 위해 동일한 타비의 빈을 생성한 상황

@Component
@Qualifier("mysql")
public class MySQLDataSource implements DataSource {
    // MySQL 데이터베이스 연결에 필요한 설정
}

@Component
@Qualifier("postgresql")
public class PostgreSQLDataSource implements DataSource {
    // PostgreSQL 데이터베이스 연결에 필요한 설정
}

위의 코드에서 MySQLDataSourcePostgreSQLDataSource는 모두 DataSource 인터페이스를 구현하고 있습니다. 그러나 이 두 개의 빈은 각각 MySQLPostgreSQL과 같은 다른 데이터베이스와 연관되어 있습니다.

이제 두 개의 빈을 사용하여 다른 클래스에서 주입할 때 @Qualifier를 사용할 수 있습니다.

@Service
public class DataService {
    
    private final DataSource mysqlDataSource;
    private final DataSource postgresqlDataSource;
    
    @Autowired
    public DataService(@Qualifier("mysql") DataSource mysqlDataSource, 
                       @Qualifier("postgresql") DataSource postgresqlDataSource) {
        this.mysqlDataSource = mysqlDataSource;
        this.postgresqlDataSource = postgresqlDataSource;
    }

    // ...
}

Testing Your Application

FileUploadTests

우리 애플리케이션에서 이 특정 기능을 테스트하는 방법에는 여러 가지가 있습니다. 다음 목록(src/test/java/guides/uploadingfiles/FileUploadTests.java)은 서블릿 컨테이너를 시작할 필요가 없도록 MockMvc를 사용하는 한 가지 예를 보여줍니다.

이러한 테스트에서는 다양한 모의 객체를 사용하여 컨트롤러 및 StorageService와의 상호 작용을 설정하지만 MockMultipartFile을 사용하여 서블릿 컨테이너 자체와의 상호 작용도 설정합니다.

package guides.uploadingfiles;

import java.nio.file.Paths;
import java.util.stream.Stream;

import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import guides.uploadingfiles.storage.StorageFileNotFoundException;
import guides.uploadingfiles.storage.StorageService;

@AutoConfigureMockMvc
@SpringBootTest
public class FileUploadTests {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private StorageService storageService;

    @Test
    public void shouldListAllFiles() throws Exception {
        given(this.storageService.loadAll())
                .willReturn(Stream.of(Paths.get("first.txt"), Paths.get("second.txt")));

        this.mvc.perform(get("/")).andExpect(status().isOk())
                .andExpect(model().attribute("files",
                        Matchers.contains("http://localhost/files/first.txt",
                                "http://localhost/files/second.txt")));
    }

    @Test
    public void shouldSaveUploadedFile() throws Exception {
        MockMultipartFile multipartFile = new MockMultipartFile("file", "test.txt",
                "text/plain", "Spring Framework".getBytes());
        this.mvc.perform(multipart("/").file(multipartFile))
                .andExpect(status().isFound())
                .andExpect(header().string("Location", "/"));

        then(this.storageService).should().store(multipartFile);
    }

    @SuppressWarnings("unchecked")
    @Test
    public void should404WhenMissingFile() throws Exception {
        given(this.storageService.loadAsResource("test.txt"))
                .willThrow(StorageFileNotFoundException.class);

        this.mvc.perform(get("/files/test.txt")).andExpect(status().isNotFound());
    }

}
  • 여기 코드는 Spring 프레임워크에서 제공하는 테스트 환경과 함께 사용하는 테스트 클래스입니다. 각 부분을 요약하겠습니다.
  1. @AutoConfigureMockMvc@SpringBootTest: 이 테스트 클래스는 Spring의 MockMvc를 사용하여 웹 애플리케이션을 테스트하며, @SpringBootTest를 통해 Spring Boot 애플리케이션의 전체적인 컨텍스트를 로드합니다.

  2. @Autowired private MockMvc mvc: MockMvc를 주입받아 사용할 수 있도록 합니다.

  • MockMvc 객체는 스프링 MVC를 테스트하기 위한 프레임워크입니다.
    • HTTP 요청을 보내고 응답을 수신합니다.
    • 요청 헤더, 요청 본문, 응답 코드, 응답 본문 등을 확인합니다.
    • 컨트롤러의 요청 매핑, 핸들러 메서드, 파라미터 바인딩 등을 테스트합니다.
  1. @MockBean private StorageService storageService: StorageService의 Mock 객체를 주입받습니다. 이는 실제 파일 저장소를 대체하여 테스트에서 사용됩니다.

  2. shouldListAllFiles(): 특정 경로에서 파일 목록을 불러오는지 테스트하는 메서드입니다. given을 통해 storageService.loadAll()이 호출되면 몇 가지 파일을 반환하도록 설정하고, 이를 테스트합니다.

  3. shouldSaveUploadedFile(): 업로드된 파일을 저장하는지 확인하는 메서드입니다. MockMultipartFile을 사용하여 가짜 파일을 만들고, 해당 파일을 업로드하고 저장되었는지 확인합니다.

  4. should404WhenMissingFile(): 파일이 없을 때 404 응답을 반환하는지 테스트합니다. 특정 파일을 요청했을 때 StorageFileNotFoundException을 던지도록 Mock 객체를 설정하고, 이에 대한 404 응답을 테스트합니다.

FileUploadIntegrationTEsts

통합 테스트의 예는 FileUploadIntegrationTests 클래스(src/test/java/guides/uploadingfiles에 있음)를 참조하세요.

package guides.uploadingfiles;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.ArgumentMatchers.any;

import guides.uploadingfiles.storage.StorageService;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class FileUploadIntegrationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @MockBean
    private StorageService storageService;

    @LocalServerPort
    private int port;

    @Test
    public void shouldUploadFile() throws Exception {
        ClassPathResource resource = new ClassPathResource("testupload.txt", getClass());

        MultiValueMap<String, Object> map = new LinkedMultiValueMap<String, Object>();
        map.add("file", resource);
        ResponseEntity<String> response = this.restTemplate.postForEntity("/", map,
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND);
        assertThat(response.getHeaders().getLocation().toString())
                .startsWith("http://localhost:" + this.port + "/");
        then(storageService).should().store(any(MultipartFile.class));
    }

    @Test
    public void shouldDownloadFile() throws Exception {
        ClassPathResource resource = new ClassPathResource("testupload.txt", getClass());
        given(this.storageService.loadAsResource("testupload.txt")).willReturn(resource);

        ResponseEntity<String> response = this.restTemplate
                .getForEntity("/files/{filename}", String.class, "testupload.txt");

        assertThat(response.getStatusCodeValue()).isEqualTo(200);
        assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
                .isEqualTo("attachment; filename=\"testupload.txt\"");
        assertThat(response.getBody()).isEqualTo("Spring Framework");
    }

}

파일 업로드를 수신하는 서버 측 부분을 실행합니다. 로깅 출력이 표시됩니다. 서비스는 몇 초 내에 시작되어 실행되어야 합니다.

서버가 실행 중인 상태에서 브라우저를 열고 http://localhost:8080/을 방문하여 업로드 양식을 확인해야 합니다. (작은) 파일을 선택하고 업로드를 누르세요. 컨트롤러에서 성공 페이지가 표시되어야 합니다. 너무 큰 파일을 선택하면 보기 흉한 오류 페이지가 표시됩니다.

그러면 브라우저 창에 다음과 유사한 줄이 표시됩니다.

“<파일 이름>을(를) 성공적으로 업로드했습니다!”

0개의 댓글