-
개발환경
-
Intro
-
먼저 Multipartfile이 뭘까요?
-
1. Zip 구현
-
1.1. Zip 기능 테스트
-
2. unZip 기능 구현
-
2.1. UnZip 기능 테스트
-
3. Directory 다운로드 API 구현
-
3.1. Directory 다운로드 API 테스트
-
4. Zip 파일 업로드 API 구현
-
5. 트러블 슈팅
-
5.1. MacOS에서 생성한 zip은 내부에 디렉토리가 숨어있다? (__**MAC_OS__**)
-
5.2. 윈도우 브라우저에서는 확장자가 appication/zip이 아니라 application/x-zip-compressed이라고?
-
5.3. 윈도우에서 업로드한 파일을 unZip했을 때, 내부 파일명들이 특수문자로 보일 때 (인코딩 형식 안맞음)
-
Outro

개발환경
💡 전체 코드는 Github를 참조해주세요.
- spring boot 3.3.1
- JDK 17
Intro
Spring MVC에서는 MultipartFile 이라는 인터페이스를 통해 파일 업로드 기능을 제공하고 있습니다. ZIP, UnZIP 기능을 제공하는 유틸성 클래스를 함께 개발해보고, MultipartFile을 함께 사용하여 파일 업로드, 그리고 HttpServletResponse를 통해 파일을 클라이언트에게 스트림을 통해 내려줘서 다운로드할 수 있는 API를 개발 및 테스트 하는 방법에 대해서 배워보도록 하겠습니다.
먼저 Multipartfile이 뭘까요?
보통 파일을 전송할 때, HTTP body를 여러 부분(Multipart Data
)으로 나눠서 보내는데 이러한 Multipart Data 즉, Multipart 요청으로 받은 업로드된 파일을 말합니다. 해당 인터페이스를 통해 파일의 정보(사이즈, 파일 이름 등)를 쉽게 가져올 수 있습니다.
스프링에서는 요청으로 받은 파일 정보를 메모리 또는 디스크에 일시적으로 저장시켜 사용할 수 있게 해줍니다. 임시 데이터는 요청 처리가 끝날 때 정리됩니다.
1. Zip 구현
먼저 ZIP 기능을 먼저 구현해보겠습니다. API를 어떻게 설계하냐에 따라 달라지겠지만, 원하는 디렉토리 또는 파일을 제공받고 해당 디렉토리 또는 파일을 Zipping하는 간단한 기능을 구현했습니다.
public static byte[] zipFile(File sourceFile) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
addFileToZip(sourceFile, zipOutputStream, null);
}
return byteArrayOutputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
먼저 ZIP으로 쓰기 위해서는 파일 내부에 byte로 작성해야 합니다. ByteArrayOutputStream과 ZipOutputStream을 try-with-resource를 통해 열어줍니다. 그리고는 addFileToZip 메서드를 호출하는데요 핵심 로직은 해당 메서드 내부에 있습니다.
private static void addFileToZip(File parentFile, ZipOutputStream zipOutputStream, String parentFolderName) {
String currentPath = hasText(parentFolderName) ? parentFolderName + "/" + parentFile.getName() : parentFile.getName();
if (parentFile.isDirectory()) {
for (File f : Objects.requireNonNull(parentFile.listFiles())) {
addFileToZip(f, zipOutputStream, currentPath);
}
} else {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
try (FileInputStream fileInputStream = new FileInputStream(parentFile)) {
zipOutputStream.putNextEntry(new ZipEntry(currentPath));
int length;
while ((length = fileInputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, length);
}
zipOutputStream.closeEntry();
} catch (FileNotFoundException e) {
log.error("File not found: {}, Exception: {}", parentFile, e.getMessage());
throw new RuntimeException(e);
} catch (IOException e) {
log.error("Error while zipping file: {}, Exception: {}", parentFile, e.getMessage());
throw new RuntimeException(e);
}
}
}
해당 메서드는 재귀 함수처럼 사용됩니다. ZipOutputStream을 전달받아서 현재 경로를 먼저 생성합니다. (폴더가 여러 depth로 구성되어있는 것을 유지하기 위해)
현재 파일이 디렉토리라면? 재귀 호출을 하고, 파일이라면? 파일 스트림을 열어서 ZipEntry에 write 해줍니다. 여기서 주의할 점은 내부가 비어있다면 fileInputStream.read(buffer) 이 부분에서 예외가 발생합니다. 해당 부분은 try-catch를 통해 잡아주시던지 지금처럼 그냥 예외를 발생하던지 하시면 됩니다.
1.1. Zip 기능 테스트
@Test
void directoryZippingTest() throws IOException {
// given
final Path zipFilePath = Path.of("src/test/resources/test-dir");
final Path testZipPath = Path.of("src/test/resources/test.zip");
Files.deleteIfExists(testZipPath); // delete test-zip
// when
byte[] bytes = ZipUtils.zipFile(zipFilePath.toFile());
Files.write(testZipPath, bytes);
// then
assertThat(testZipPath).exists();
}
@Test
void fileZippingTest() throws IOException {
// given
final Path zipFilePath = Path.of("src/test/resources/한글이름_테스트.txt");
final Path testZipPath = Path.of("src/test/resources/test2.zip");
Files.deleteIfExists(testZipPath); // delete test-zip
// when
byte[] bytes = ZipUtils.zipFile(zipFilePath.toFile());
Files.write(testZipPath, bytes);
// then
assertThat(testZipPath).exists();
}
테스트 코드는 간단합니다. Zipping 할 파일 또는 디렉토리를 넘겨서 byte[]를 반환받은 다음, 해당 byte[]를 파일로 써주면 됩니다.
2. unZip 기능 구현
unZip 기능은 다음과 같이 구현하였습니다. Zip 파일(zipFile) 또는 InputStream을 압축 해제할 디렉토리(dstPath)로 입력받아 처리합니다.
public static void unZipFile(String dstPath, File zipFile) throws IOException {
unZipFile(dstPath, new FileInputStream(zipFile), null);
}
public static void unZipFile(String dstPath, InputStream inputStream) throws IOException {
unZipFile(dstPath, inputStream, null);
}
결론적으론 InputStream으로 변환하여 핵심 로직을 수행하는 메서드로 전달합니다.
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
ZipEntry entry;
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
while ((entry = zipInputStream.getNextEntry()) != null) {
final String entryName = entry.getName();
if (entry.isDirectory() || entryName.startsWith("__MACOSX")) continue;
File entryFile;
if (renameDuplicatedFilename == null) {
entryFile = new File(dstPath, entryName);
} else {
Path path = Path.of(dstPath, entryName);
entryFile = renameDuplicatedFilename.apply(path).toFile();
}
Files.createDirectories(entryFile.getParentFile().toPath());
try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(entryFile))) {
int bytesRead;
while ((bytesRead = zipInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
zipInputStream.closeEntry();
}
}
}
전달 받은 inputStream을 이용해서 ZipInputStream을 열어줍니다. 각 entry를 순회하면서 Directory거나 __MACOSX로 시작한다면 건너띄어줍니다. 이렇게 한 이유는 디렉토리에 속한 엔트리를 옮길 때 속한 디렉토리를 생성 시킬 것이기 때문입니다. 혹시라도 압축을 해제하는 곳에 동일한 이름의 파일이 존재한다면 예외가 발생할 수 있으니 renameDuplicatedFilename를 이용해서 rename을 할 수 있도록 설정할 수도 있습니다.
2.1. UnZip 기능 테스트
@Test
void unzipTest() throws IOException {
// given
Path dstPath = Path.of("src/test/resources/unzip-result");
FileUtils.deleteDirectory(dstPath.toFile());
final Path testZipPath = Path.of("src/test/resources/test.zip");
// when
ZipUtils.unZipFile(dstPath.toString(), testZipPath.toFile());
// then
assertThat(dstPath).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "file3.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "test-dir4", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "test-dir4", "file2.txt")).exists();
}
Unzip 테스트도 간단합니다. unzip하려는 Zip 파일과, 압축 해제할 디렉토리를 넘겨서 압축해제하고 내부에 파일들이 정상적으로 생성됐는지 검증 합니다. 테스트는 성공적으로 수행됩니다.
3. Directory 다운로드 API 구현
@GetMapping(value = "/api/v1/file-zipping-and-download")
public void downloadZipFile(
@RequestParam(value = "folderName") String folderName,
HttpServletResponse response
) {
try {
byte[] bytes = ZipUtils.zipFile(Path.of(folderName).toFile());
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename=".concat(UUID.randomUUID().toString()).concat(".zip"));
response.getOutputStream().write(bytes);
} catch (IOException e) {
throw new RuntimeException("Failed to download zip file", e);
}
}
다운로드 하려는 디렉토리 경로를 입력받아 우리가 구현한 zipFile 메서드를 호출하여 반환 받은 byte[]를 HttpServletResponse의 outputStream을 통해 써주면 클라이언트는 자동적으로 다운받을 수 있습니다. 중요한 점은 ContentType과 Header를 올바르게 설정해줘야 합니다. 현재는 간단하게 테스트 할 목적으로 Controller에 로직을 작성했는데 따로 Service 레이어로 옮기는게 예외 처리 및 유지보수할 때도 편리할 것 입니다.
3.1. Directory 다운로드 API 테스트
@WebMvcTest(controllers = ZipUtilController.class)
class ZipUtilControllerTest {
@Autowired private MockMvc mockMvc;
@DisplayName("Zip Download Test")
@Test
void downloadZipFileTest() throws Exception {
// given
// when & then
mockMvc.perform(get("/api/v1/file-zipping-and-download")
.param("folderName", "src/test/resources/test-dir"))
.andDo(print())
.andExpect(status().isOk())
;
}
}
MockMvc를 이용해서 zipFile Download 테스트를 할 수 있습니다.
4. Zip 파일 업로드 API 구현
@PostMapping(value = "/api/v1/zip-upload", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> uploadZipFile(
@RequestParam("dstPath") String dstPath,
@RequestPart("file") MultipartFile file
) {
try {
ZipUtils.unZipFile(dstPath, file.getInputStream());
} catch (IOException e) {
return ResponseEntity.ok("failed to upload zip");
}
return ResponseEntity.ok("success to upload zip");
}
업로드 API는 Multipart를 이용해서 업로드를 할 수 있습니다. 현재 API 처럼 단일 MultipartFile로 구성해도 되고, List를 통해 여러 개를 받아서 내부에서 Zipping하여 저장할 수도 있습니다.
현재는 Zip 파일을 업로드 받는다고 가정하고 ZIp 파일을 받아서 우리가 만든 unZipFile 메서드를 통해 사용자로부터 입력받은 저장 경로에 압축해제 할 수 있도록 구현하였습니다.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@DisplayName("Zip Upload Test")
@Test
void uploadZipFileTest() throws Exception {
// given
MockMultipartFile file = new MockMultipartFile("file", "test.zip", MULTIPART_FORM_DATA_VALUE, (byte[]) null);
// when & then
mockMvc.perform(multipart("/api/v1/zip-upload")
.file(file)
.param("dstPath", "test-dir"))
.andDo(print())
.andExpect(status().isOk())
;
}
MultipartFile 업로드 API 테스트는 MockMvcRequestBuilders.multipart
를 통해서 테스트할 수 있습니다. MultipartFile은 MockMultipartFile을 이용해서 Mocking하여 테스트하면 됩니다.
5. 트러블 슈팅
5.1. MacOS에서 생성한 zip은 내부에 디렉토리가 숨어있다? (__**MAC_OS__**)
MacOS에서는 Zip 파일들의 메타데이터들을 __MAC_OS__
내부에 저장합니다. (stack overflow 참조)
그래서 혹시 모를 Mac 유저들을 대비해 해당 폴더에 대해 예외처리해줘야 합니다.
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
ZipEntry entry;
...
while ((entry = zipInputStream.getNextEntry()) != null) {
final String entryName = entry.getName();
if (entry.isDirectory() || entryName.startsWith("__MACOSX")) continue;
...
}
}
}
entryName.startsWith("__MACOSX")
를 통해 해당 이름으로 시작하는 폴더는 패스하게끔 예외 처리해줬습니다.
What's inside the __MACOSX hidden folder in zip files created in Mac OS
5.2. 윈도우 브라우저에서는 확장자가 appication/zip이 아니라 application/x-zip-compressed이라고?
윈도우에서는 ZIP 파일을 업로드하면 브라우저에서 application/zip이 아닌 application/x-zip-compressed 값을 넘겨줍니다.
만약 업로드 형식을 제한한다면, 윈도우 사용자를 위해 application/x-zip-compressed Content-Type도 허용해줘야 합니다.
zip mime types, when to pick which one
5.3. 윈도우에서 업로드한 파일을 unZip했을 때, 내부 파일명들이 특수문자로 보일 때 (인코딩 형식 안맞음)
찾아보니 윈도우에서는 EUC-KR 뭐시기로 인코딩을 했..다고 합니다..
하..
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
...
}
}
인코딩만 적용해주면 되기 때문에 어려울 것은 없습니다. ZipInputStream을 생성할 때, Charset.forName("EUC-KR")
인코딩을 통해 생성해주면 됩니다.
Java char set encoding problem(from UTF8 to cp866)
Java // unzip error :MALFORMED
Outro
Spring MVC에서 제공해주는 MultipartFile 인터페이스를 이용하여 API 구현, 그리고 직접 구현해본 Zip, UnZip Util 클래스를 만들어보고 테스트를 해봤습니다. Zip, UnZip 유틸 클래스는 이미 유명한 라이브러리(ex. apache.common.io 등)가 많은데요, 이런 라이브러리에 의존하는 것보단 기본 JDK API를 통해 구현하는게 나중에 라이브러리 보안 취약점, 업데이트의 부재 등등에 대처하기 어렵기 때문에 직접 구현하는게 좋다고 생각이 듭니다.
그렇게 대단한 기능은 아니지만요..
아직 API 테스트 하는 부분이 조금 미흡하고 아쉽습니다. 이 부분은 더 좋은 방법이 있으면 추후에 업데이트 하도록 하겠습니다.
긴 글 읽어주셔서 감사합니다!

개발환경
💡 전체 코드는 Github를 참조해주세요.
- spring boot 3.3.1
- JDK 17
Intro
Spring MVC에서는 MultipartFile 이라는 인터페이스를 통해 파일 업로드 기능을 제공하고 있습니다. ZIP, UnZIP 기능을 제공하는 유틸성 클래스를 함께 개발해보고, MultipartFile을 함께 사용하여 파일 업로드, 그리고 HttpServletResponse를 통해 파일을 클라이언트에게 스트림을 통해 내려줘서 다운로드할 수 있는 API를 개발 및 테스트 하는 방법에 대해서 배워보도록 하겠습니다.
먼저 Multipartfile이 뭘까요?
보통 파일을 전송할 때, HTTP body를 여러 부분(Multipart Data
)으로 나눠서 보내는데 이러한 Multipart Data 즉, Multipart 요청으로 받은 업로드된 파일을 말합니다. 해당 인터페이스를 통해 파일의 정보(사이즈, 파일 이름 등)를 쉽게 가져올 수 있습니다.
스프링에서는 요청으로 받은 파일 정보를 메모리 또는 디스크에 일시적으로 저장시켜 사용할 수 있게 해줍니다. 임시 데이터는 요청 처리가 끝날 때 정리됩니다.
1. Zip 구현
먼저 ZIP 기능을 먼저 구현해보겠습니다. API를 어떻게 설계하냐에 따라 달라지겠지만, 원하는 디렉토리 또는 파일을 제공받고 해당 디렉토리 또는 파일을 Zipping하는 간단한 기능을 구현했습니다.
public static byte[] zipFile(File sourceFile) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
addFileToZip(sourceFile, zipOutputStream, null);
}
return byteArrayOutputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
먼저 ZIP으로 쓰기 위해서는 파일 내부에 byte로 작성해야 합니다. ByteArrayOutputStream과 ZipOutputStream을 try-with-resource를 통해 열어줍니다. 그리고는 addFileToZip 메서드를 호출하는데요 핵심 로직은 해당 메서드 내부에 있습니다.
private static void addFileToZip(File parentFile, ZipOutputStream zipOutputStream, String parentFolderName) {
String currentPath = hasText(parentFolderName) ? parentFolderName + "/" + parentFile.getName() : parentFile.getName();
if (parentFile.isDirectory()) {
for (File f : Objects.requireNonNull(parentFile.listFiles())) {
addFileToZip(f, zipOutputStream, currentPath);
}
} else {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
try (FileInputStream fileInputStream = new FileInputStream(parentFile)) {
zipOutputStream.putNextEntry(new ZipEntry(currentPath));
int length;
while ((length = fileInputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, length);
}
zipOutputStream.closeEntry();
} catch (FileNotFoundException e) {
log.error("File not found: {}, Exception: {}", parentFile, e.getMessage());
throw new RuntimeException(e);
} catch (IOException e) {
log.error("Error while zipping file: {}, Exception: {}", parentFile, e.getMessage());
throw new RuntimeException(e);
}
}
}
해당 메서드는 재귀 함수처럼 사용됩니다. ZipOutputStream을 전달받아서 현재 경로를 먼저 생성합니다. (폴더가 여러 depth로 구성되어있는 것을 유지하기 위해)
현재 파일이 디렉토리라면? 재귀 호출을 하고, 파일이라면? 파일 스트림을 열어서 ZipEntry에 write 해줍니다. 여기서 주의할 점은 내부가 비어있다면 fileInputStream.read(buffer) 이 부분에서 예외가 발생합니다. 해당 부분은 try-catch를 통해 잡아주시던지 지금처럼 그냥 예외를 발생하던지 하시면 됩니다.
1.1. Zip 기능 테스트
@Test
void directoryZippingTest() throws IOException {
// given
final Path zipFilePath = Path.of("src/test/resources/test-dir");
final Path testZipPath = Path.of("src/test/resources/test.zip");
Files.deleteIfExists(testZipPath); // delete test-zip
// when
byte[] bytes = ZipUtils.zipFile(zipFilePath.toFile());
Files.write(testZipPath, bytes);
// then
assertThat(testZipPath).exists();
}
@Test
void fileZippingTest() throws IOException {
// given
final Path zipFilePath = Path.of("src/test/resources/한글이름_테스트.txt");
final Path testZipPath = Path.of("src/test/resources/test2.zip");
Files.deleteIfExists(testZipPath); // delete test-zip
// when
byte[] bytes = ZipUtils.zipFile(zipFilePath.toFile());
Files.write(testZipPath, bytes);
// then
assertThat(testZipPath).exists();
}
테스트 코드는 간단합니다. Zipping 할 파일 또는 디렉토리를 넘겨서 byte[]를 반환받은 다음, 해당 byte[]를 파일로 써주면 됩니다.
2. unZip 기능 구현
unZip 기능은 다음과 같이 구현하였습니다. Zip 파일(zipFile) 또는 InputStream을 압축 해제할 디렉토리(dstPath)로 입력받아 처리합니다.
public static void unZipFile(String dstPath, File zipFile) throws IOException {
unZipFile(dstPath, new FileInputStream(zipFile), null);
}
public static void unZipFile(String dstPath, InputStream inputStream) throws IOException {
unZipFile(dstPath, inputStream, null);
}
결론적으론 InputStream으로 변환하여 핵심 로직을 수행하는 메서드로 전달합니다.
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
ZipEntry entry;
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
while ((entry = zipInputStream.getNextEntry()) != null) {
final String entryName = entry.getName();
if (entry.isDirectory() || entryName.startsWith("__MACOSX")) continue;
File entryFile;
if (renameDuplicatedFilename == null) {
entryFile = new File(dstPath, entryName);
} else {
Path path = Path.of(dstPath, entryName);
entryFile = renameDuplicatedFilename.apply(path).toFile();
}
Files.createDirectories(entryFile.getParentFile().toPath());
try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(entryFile))) {
int bytesRead;
while ((bytesRead = zipInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
zipInputStream.closeEntry();
}
}
}
전달 받은 inputStream을 이용해서 ZipInputStream을 열어줍니다. 각 entry를 순회하면서 Directory거나 __MACOSX로 시작한다면 건너띄어줍니다. 이렇게 한 이유는 디렉토리에 속한 엔트리를 옮길 때 속한 디렉토리를 생성 시킬 것이기 때문입니다. 혹시라도 압축을 해제하는 곳에 동일한 이름의 파일이 존재한다면 예외가 발생할 수 있으니 renameDuplicatedFilename를 이용해서 rename을 할 수 있도록 설정할 수도 있습니다.
2.1. UnZip 기능 테스트
@Test
void unzipTest() throws IOException {
// given
Path dstPath = Path.of("src/test/resources/unzip-result");
FileUtils.deleteDirectory(dstPath.toFile());
final Path testZipPath = Path.of("src/test/resources/test.zip");
// when
ZipUtils.unZipFile(dstPath.toString(), testZipPath.toFile());
// then
assertThat(dstPath).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "file3.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "file2.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "test-dir4", "한글이름_테스트.txt")).exists();
assertThat(Path.of(dstPath.toString(), "test-dir", "test-dir2", "test-dir3", "test-dir4", "file2.txt")).exists();
}
Unzip 테스트도 간단합니다. unzip하려는 Zip 파일과, 압축 해제할 디렉토리를 넘겨서 압축해제하고 내부에 파일들이 정상적으로 생성됐는지 검증 합니다. 테스트는 성공적으로 수행됩니다.
3. Directory 다운로드 API 구현
@GetMapping(value = "/api/v1/file-zipping-and-download")
public void downloadZipFile(
@RequestParam(value = "folderName") String folderName,
HttpServletResponse response
) {
try {
byte[] bytes = ZipUtils.zipFile(Path.of(folderName).toFile());
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename=".concat(UUID.randomUUID().toString()).concat(".zip"));
response.getOutputStream().write(bytes);
} catch (IOException e) {
throw new RuntimeException("Failed to download zip file", e);
}
}
다운로드 하려는 디렉토리 경로를 입력받아 우리가 구현한 zipFile 메서드를 호출하여 반환 받은 byte[]를 HttpServletResponse의 outputStream을 통해 써주면 클라이언트는 자동적으로 다운받을 수 있습니다. 중요한 점은 ContentType과 Header를 올바르게 설정해줘야 합니다. 현재는 간단하게 테스트 할 목적으로 Controller에 로직을 작성했는데 따로 Service 레이어로 옮기는게 예외 처리 및 유지보수할 때도 편리할 것 입니다.
3.1. Directory 다운로드 API 테스트
@WebMvcTest(controllers = ZipUtilController.class)
class ZipUtilControllerTest {
@Autowired private MockMvc mockMvc;
@DisplayName("Zip Download Test")
@Test
void downloadZipFileTest() throws Exception {
// given
// when & then
mockMvc.perform(get("/api/v1/file-zipping-and-download")
.param("folderName", "src/test/resources/test-dir"))
.andDo(print())
.andExpect(status().isOk())
;
}
}
MockMvc를 이용해서 zipFile Download 테스트를 할 수 있습니다.
4. Zip 파일 업로드 API 구현
@PostMapping(value = "/api/v1/zip-upload", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> uploadZipFile(
@RequestParam("dstPath") String dstPath,
@RequestPart("file") MultipartFile file
) {
try {
ZipUtils.unZipFile(dstPath, file.getInputStream());
} catch (IOException e) {
return ResponseEntity.ok("failed to upload zip");
}
return ResponseEntity.ok("success to upload zip");
}
업로드 API는 Multipart를 이용해서 업로드를 할 수 있습니다. 현재 API 처럼 단일 MultipartFile로 구성해도 되고, List를 통해 여러 개를 받아서 내부에서 Zipping하여 저장할 수도 있습니다.
현재는 Zip 파일을 업로드 받는다고 가정하고 ZIp 파일을 받아서 우리가 만든 unZipFile 메서드를 통해 사용자로부터 입력받은 저장 경로에 압축해제 할 수 있도록 구현하였습니다.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@DisplayName("Zip Upload Test")
@Test
void uploadZipFileTest() throws Exception {
// given
MockMultipartFile file = new MockMultipartFile("file", "test.zip", MULTIPART_FORM_DATA_VALUE, (byte[]) null);
// when & then
mockMvc.perform(multipart("/api/v1/zip-upload")
.file(file)
.param("dstPath", "test-dir"))
.andDo(print())
.andExpect(status().isOk())
;
}
MultipartFile 업로드 API 테스트는 MockMvcRequestBuilders.multipart
를 통해서 테스트할 수 있습니다. MultipartFile은 MockMultipartFile을 이용해서 Mocking하여 테스트하면 됩니다.
5. 트러블 슈팅
5.1. MacOS에서 생성한 zip은 내부에 디렉토리가 숨어있다? (__**MAC_OS__**)
MacOS에서는 Zip 파일들의 메타데이터들을 __MAC_OS__
내부에 저장합니다. (stack overflow 참조)
그래서 혹시 모를 Mac 유저들을 대비해 해당 폴더에 대해 예외처리해줘야 합니다.
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
ZipEntry entry;
...
while ((entry = zipInputStream.getNextEntry()) != null) {
final String entryName = entry.getName();
if (entry.isDirectory() || entryName.startsWith("__MACOSX")) continue;
...
}
}
}
entryName.startsWith("__MACOSX")
를 통해 해당 이름으로 시작하는 폴더는 패스하게끔 예외 처리해줬습니다.
What's inside the __MACOSX hidden folder in zip files created in Mac OS
5.2. 윈도우 브라우저에서는 확장자가 appication/zip이 아니라 application/x-zip-compressed이라고?
윈도우에서는 ZIP 파일을 업로드하면 브라우저에서 application/zip이 아닌 application/x-zip-compressed 값을 넘겨줍니다.
만약 업로드 형식을 제한한다면, 윈도우 사용자를 위해 application/x-zip-compressed Content-Type도 허용해줘야 합니다.
zip mime types, when to pick which one
5.3. 윈도우에서 업로드한 파일을 unZip했을 때, 내부 파일명들이 특수문자로 보일 때 (인코딩 형식 안맞음)
찾아보니 윈도우에서는 EUC-KR 뭐시기로 인코딩을 했..다고 합니다..
하..
private static void unZipFile(String dstPath, InputStream inputStream, @Nullable Function<Path, Path> renameDuplicatedFilename) throws IOException {
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream, Charset.forName("EUC-KR"))) {
...
}
}
인코딩만 적용해주면 되기 때문에 어려울 것은 없습니다. ZipInputStream을 생성할 때, Charset.forName("EUC-KR")
인코딩을 통해 생성해주면 됩니다.
Java char set encoding problem(from UTF8 to cp866)
Java // unzip error :MALFORMED
Outro
Spring MVC에서 제공해주는 MultipartFile 인터페이스를 이용하여 API 구현, 그리고 직접 구현해본 Zip, UnZip Util 클래스를 만들어보고 테스트를 해봤습니다. Zip, UnZip 유틸 클래스는 이미 유명한 라이브러리(ex. apache.common.io 등)가 많은데요, 이런 라이브러리에 의존하는 것보단 기본 JDK API를 통해 구현하는게 나중에 라이브러리 보안 취약점, 업데이트의 부재 등등에 대처하기 어렵기 때문에 직접 구현하는게 좋다고 생각이 듭니다.
그렇게 대단한 기능은 아니지만요..
아직 API 테스트 하는 부분이 조금 미흡하고 아쉽습니다. 이 부분은 더 좋은 방법이 있으면 추후에 업데이트 하도록 하겠습니다.
긴 글 읽어주셔서 감사합니다!