Home 비동기 환경에서 MultipartFile이 사라진다.
Post
Cancel

비동기 환경에서 MultipartFile이 사라진다.

글을 작성하게 된 계기


여신협회에서 받은 파일을 업로드할 때, 비동기를 사용했고, 이 과정에서 겪었던 이슈와 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.





1. 문제 상황


현재 영세/중소 상인들의 카드 수수료를 감면해 주는 시스템 을 만들고 있습니다. 이를 위해서는 여신협회에서는 반기마다 전달하는 영세/중소 상인들의 사업자 번호가 담긴 파일(.txt) 들을 데이터베이스에 저장해야 하는데요, 한 파일에 데이터가 수 십, 수 백만 건이다 보니 @Async 를 사용해 비동기 파일 업로드 를 하고 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping(path = ["/api/files"])
class FileUploadController(
    private val bizNoService: BizNoService,
) {

    @PostMapping(path = ["/upload"], consumes = [MULTIPART_FORM_DATA_VALUE])
    fun uploadFile(@RequestParam("file") file: MultipartFile): ResponseEntity<String> {
        bizNoService.saveAll(file)
        return ResponseEntity.ok()
            .build()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class FileUploadService(
    private val dataSource: DataSource,
) {

    @Async
    fun saveAll(file: MultipartFile) {
        
        ......
        
    }

}





그런데, 파일을 업로드하며 간헐적 으로 MultipartFile이 삭제 되는 현상이 발생했습니다. 다음과 같은 에러를 내면서요. 왜 이런 문제가 발생했을까요?

1
2
3
[-] Service.saveAll error: /private/var/folders/5d/kz_9h2h95wn1zf4gw1029brm0000gn/T/tomcat.8080.7016492714616296367/work/Tomcat/localhost/ROOT/upload_58aeee46_c13d_4453_b9b6_e86b6ebdc6b3_00000004.tmp
java.nio.file.NoSuchFileException: /private/var/folders/5d/kz_9h2h95wn1zf4gw1029brm0000gn/T/tomcat.8081.7016492714616296367/work/Tomcat/localhost/ROOT/upload_58aeee46_c13d_4453_b9b6_e86b6ebdc6b3_00000004.tmp
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92) ......







2. 문제 원인


문제 원인은 MultipartFile의 생명주기가 사용자 요청의 생명주기와 일치 하는데, 비동기는 실행 결과를 기다리지 않고 응답을 반환 하기 때문입니다. 이를 이해하기 위해서는 MultipartFile의 저장 위치생명주기 를 이해해야 합니다.

  1. MultipartFile의 저장 위치
  2. MultipartFile의 생명주기





2-1. MultipartFile의 저장 위치

스프링은 MultipartFile 파일 내용을 서버의 임시 디렉터리에 저장 한 뒤 처리합니다. 이는 서블릿 컨테이너 또는 스프링 내부적으로 사용하는 파일 업로드 라이브러리에 의해 수행되며, 스프링부트를 사용할 경우, 기본 경로는 $CATALINA_BASE/temp 가 됩니다.

CATALINA_TMPDIR (Optional) Directory path location of temporary directory the JVM should use (java.io.tmpdir). Defaults to $CATALINA_BASE/temp.





이 값은 jakarta.servlet.context.tempdir 에 대응하며, 톰캣 실행 시 별도로 지정하지 않는다면 자동으로 $CATALINA_BASE/temp 로 지정됩니다.

1
val tempDir = System.getProperty("jakarta.servlet.context.tempdir")





이는 Request 클래스 내부를 보면 알 수 있는데요, 다음과 같이 jakarta.servlet.context.tempdir 경로에 임시 파일을 저장하는 것을 볼 수 있습니다. 이 디렉터리는 톰캣이 시작될 때 생성되어 종료될 때까지 유지되며, 업로드된 임시 파일은 HTTP 요청이 종료되는 시점에 자동으로 삭제 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Request implements HttpServletRequest {

    private void parseParts(boolean explicit) {
        if (this.parts == null && this.partsParseException == null) {
            
            ......

            try {
                String locationStr = mce.getLocation();
                File location;
                if (locationStr != null && locationStr.length() != 0) {
                    location = new File(locationStr);
                    if (!location.isAbsolute()) {
                        location = (new File((File) context.getServletContext().getAttribute("jakarta.servlet.context.tempdir"), locationStr)).getAbsoluteFile();
                    }
                } else {
                    location = (File) context.getServletContext().getAttribute("jakarta.servlet.context.tempdir");
                }
            }
            
            ......
        }
    }
}





2-2. MultipartFile의 생명주기

파일은 HTTP 요청 처리 중 생성 되며, 사용자 요청이 끝나고 클라이언트에 응답이 반환되는 시점에 삭제 됩니다. 즉, 사용자 요청의 라이프사이클과 동일합니다. 사용자 요청이 애플리케이션에 도달하면 DispatcherServlet을 거치게 되고, 여기서 StandardServletMultipartResolver를 통해 MultipartFile 객체를 생성 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DispatcherServlet extends FrameworkServlet {
    
    ......

    protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {
        if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
            if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) {
                if (DispatcherType.REQUEST.equals(request.getDispatcherType())) {
                    this.logger.trace("Request already resolved to MultipartHttpServletRequest, for example, by MultipartFilter");
                }
            } else if (hasMultipartException(request)) {
                this.logger.debug("Multipart resolution previously failed for current request - skipping re-resolution for undisturbed error rendering");
            } else {
                try {
                    return this.multipartResolver.resolveMultipart(request);
                } catch (MultipartException var3) {
                    if (request.getAttribute("jakarta.servlet.error.exception") == null) {
                        throw var3;
                    }
                }
                this.logger.debug("Multipart resolution failed for error dispatch", var3);
            }
        }
        return request;
    }
    
    ......
}





저장된 임시 파일은 HTTP 요청이 처리되는 동안에만 유효하며, 요청 처리가 완료되고 클라이언트에 응답이 반환된 이후 자동으로 삭제됩니다. 임시 파일, 삭제 키워드가 반복적으로 나타나고 있는데요, 왜 문제가 발생했는지 대략 감이 오시죠?

image





문제가 발생한 원인을 정리하면 다음과 같습니다.

  1. MultipartFile은 사용자 요청 라이프사이클과 생명주기가 같으며, 생성시 임시 디렉터리에 저장된다.
  2. MultipartFile은 사용자 요청이 끝나면 삭제된다.
  3. 비동기로 처리하면 실행 결과를 기다리지 않기 때문에 파일이 삭제될 수 있다.
  4. 따라서 비동기로 처리하기 전, 파일을 별도로 관리해야 한다.





MultipartFile은 사용자 요청 처리 중에만 유효 합니다. 그런데 @Async가 붙은 비동기 메서드를 호출 하면 작업의 완료 여부와 관계 없이 응답이 반환 되고, 이 과정에서 임시파일을 삭제합니다. 여전히 비동기 작업은 수행 중 인데도요. 이 때문에 간헐적으로 NoSuchFileException 이 발생한 것이죠.

1
2
3
[-] Service.saveAll error: /private/var/folders/5d/kz_9h2h95wn1zf4gw1029brm0000gn/T/tomcat.8080.7016492714616296367/work/Tomcat/localhost/ROOT/upload_58aeee46_c13d_4453_b9b6_e86b6ebdc6b3_00000004.tmp
java.nio.file.NoSuchFileException: /private/var/folders/5d/kz_9h2h95wn1zf4gw1029brm0000gn/T/tomcat.8081.7016492714616296367/work/Tomcat/localhost/ROOT/upload_58aeee46_c13d_4453_b9b6_e86b6ebdc6b3_00000004.tmp
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92) ......







3. 문제 해결


해결 방법은 다양한데요, 크게 파일 복사, byte 배열로 처리, InputStream 사용 등의 방법이 있습니다. 이번 프로젝트에서는 파일 복사 를 통해 문제를 해결했는데, 파일의 크기가 그렇게 크지 않았기 때문 입니다.

  1. 파일 복사
  2. byte 배열로 처리
  3. InputStream 사용




3-1. 파일 복사

먼저 파일 복사 입니다. 이는 해당 파일을 비동기 작업 전, 미리 복사 한 후, 처리하는 방식입니다. 이를 사용할 때 주의할 점이 있는데요, 파일을 복사하기 때문에 디스크 사용량이 2배 로 증가할 수 있다는 점입니다. 만약 파일 크기가 클 경우, 디스크 사용량에 주의 하도록 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping(path = ["/api/files"])
class FileUploadController(
    private val fileUploadService: FileUploadService,
) {

    @PostMapping(path = ["/upload"], consumes = [MULTIPART_FORM_DATA_VALUE])
    fun uploadFile(@RequestParam("file") file: MultipartFile): ResponseEntity<String> {
        val copyFile = File.createTempFile("temp", ".csv")
        file.transferTo(copyFile)
        fileUploadService.saveAll(copyFile)
        return ResponseEntity.ok()
            .build()
    }
}
1
2
3
4
5
6
7
8
9
10
@Service
class FileUploadService(
    private val dataSource: DataSource,
) {

    @Async
    fun saveAll(file: File) {
        ......
    }
}





3-2. byte 배열로 처리

두 번째는 파일 객체를 byte 배열로 변환 한 후, 처리하는 방식 입니다. 이는 내부적으로 전체 파일을 JVM 힙 메모리에 올리기 때문에, 파일 크기가 크면 OutOfMemoryError 가 발생할 수도 있습니다. 따라서 대용량 파일을 처리할 경우, 메모리 사용량을 신경써야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping(path = ["/api/files"])
class FileUploadController(
    private val fileUploadService: FileUploadService,
) {

    @PostMapping(path = ["/upload"], consumes = [MULTIPART_FORM_DATA_VALUE])
    fun uploadFile(@RequestParam("file") file: MultipartFile): ResponseEntity<String> {
        val bytes = file.bytes  // MultipartFile → ByteArray
        fileUploadService.saveAll(bytes)
        return ResponseEntity.ok().build()
    }
}





3-3. InputStream 사용

파일이 커서 byte 배열 형태로 한 번에 JVM 힙 메모리에 올리는 것이 부담스럽거나 위험한 경우, InputStream 을 사용할 수도 있습니다. 이는 파일을 한 줄씩 또는 일정 크기 단위로 읽어 처리하기 때문에, 한 번에 전체 파일을 메모리에 적재하지 않아도 되어, 파일이 큰 경우에도 안정적으로 처리할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping(path = ["/api/files"])
class FileUploadController(
    private val fileUploadService: FileUploadService,
) {

    @PostMapping(path = ["/upload"], consumes = [MULTIPART_FORM_DATA_VALUE])
    fun uploadFile(@RequestParam("file") file: MultipartFile): ResponseEntity<String> {
        val result = mutableListOf<String>()

        file.inputStream.bufferedReader().useLines { lines ->
            lines.forEach { line ->
                result.add(line)  // 라인 단위 사업자 번호 저장
            }
        }
        fileUploadService.saveAll(result)
        return ResponseEntity.ok().build()
    }
}





InputStream을 사용하더라도 결국 비동기 처리를 하기 전, 전체 파일을 읽어야 하는 것은 똑같은데요, 이 경우, 다음과 같이 처리할 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping(path = ["/api/files"])
class FileUploadController(
    private val fileUploadService: FileUploadService,
) {

    @PostMapping(path = ["/upload"], consumes = [MULTIPART_FORM_DATA_VALUE])
    fun uploadFile(@RequestParam("file") file: MultipartFile): ResponseEntity<String> {
        file.inputStream.bufferedReader().useLines { lines ->
            lines.forEach { line ->
                fileUploadService.processLine(line)
            }
        }
        return ResponseEntity.ok().build()
    }
}







4. 내부 코드 살펴보기


여기서 끝내기는 조금 아쉬운데요, 스프링 내부에서 MultipartFile이 어떻게 생성되는지 조금 더 살펴보겠습니다. MultipartFile을 만드는 MultipartResolver는 StandardServletMultipartResolver 입니다. 이는 Servlet 3.0 Part API 를 기반으로 구현되어 있습니다.

Standard implementation of the MultipartResolver interface, based on the Servlet 3.0 Part API. To be added as “multipartResolver” bean to a Spring DispatcherServlet context, without any extra configuration at the bean level (see below).





StandardServletMultipartResolver를 거칠 때, FileItemFactory가 사용되는데, FileItemFactory는 업로드된 각 필드를 메모리나 디스크에 저장할 FileItem 객체를 생성해주는 인터페이스입니다. 일반적으로는 DiskFileItemFactory 구현체가 사용되며, 설정된 임계값(sizeThreshold)저장 위치(repository) 를 바탕으로 DiskFileItem 객체를 생성합니다.

1
2
3
public interface FileItemFactory {
    FileItem createItem(String var1, String var2, boolean var3, String var4);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class DiskFileItemFactory implements FileItemFactory {
    
    ......

    public FileItem createItem(String fieldName, String contentType, boolean isFormField, String fileName) {
        DiskFileItem result = new DiskFileItem(fieldName, contentType, isFormField, fileName, this.sizeThreshold, this.repository);
        result.setDefaultCharset(this.defaultCharset);
        return result;
    }
    
    ......
    
}





MultipartFile은 실제 데이터의 크기에 따라 메모리 또는 임시 디스크 파일로 저장되며, 이는 DiskFileItem 클래스에서 내부적으로 관리하는 DeferredFileOutputStream 을 통해 결정됩니다. 이 스트림은 설정된 임계값을 기준으로 데이터를 메모리에 유지 할지 디스크로 저장 할지를 판단합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class DiskFileItem implements FileItem {
    
    ......
    
    private long size = -1L;
    private final int sizeThreshold;
    private final File repository;
    private byte[] cachedContent;
    private transient DeferredFileOutputStream dfos;

    ......

    public InputStream getInputStream() throws IOException {
        if (!this.isInMemory()) {
            return Files.newInputStream(this.dfos.getFile().toPath());
        } else {
            if (this.cachedContent == null) {
                this.cachedContent = this.dfos.getData();
            }

            return new ByteArrayInputStream(this.cachedContent);
        }
    }

    public boolean isInMemory() {
        return this.cachedContent != null ? true : this.dfos.isInMemory();
    }
    
    ......

}





DeferredFileOutputStream의 isInMemory 메서드는 내부적으로 isThresholdExceeded를 호출해 임계값 초과 여부를 확인하고, 현재 저장 위치가 메모리인지 디스크인지를 반환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DeferredFileOutputStream extends ThresholdingOutputStream {
    
    ......

    protected void thresholdReached() throws IOException {
        if (this.prefix != null) {
            this.outputFile = File.createTempFile(this.prefix, this.suffix, this.directory);
        }

        FileUtils.forceMkdirParent(this.outputFile);
        FileOutputStream fos = new FileOutputStream(this.outputFile);

        try {
            this.memoryOutputStream.writeTo(fos);
        } catch (IOException var3) {
            fos.close();
            throw var3;
        }

        this.currentOutputStream = fos;
        this.memoryOutputStream = null;
    }

    public boolean isInMemory() {
        return !this.isThresholdExceeded();
    }
    
    ......

}





임계값 초과는 ThresholdingOutputStream의 checkThreshold 메서드에서 수행되며, 초과 시 thresholdReached 메서드를 호출해 메모리에서 디스크로 전환됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class ThresholdingOutputStream extends OutputStream {
    
    ......

    public boolean isThresholdExceeded() {
        return this.written > (long) this.threshold;
    }
    
    ......

}





이렇게 생성된 객체는 copy 메서드를 통해 서버의 임시 디렉터리에 저장됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public abstract class FileUploadBase {
    
    ......

    public List<FileItem> parseRequest(RequestContext ctx) throws FileUploadException {
        
        ......
        
        try {
            ......
            while (true) {
                ......
                FileItemStream item = iter.next();
                String fileName = item.getName();
                fileItem = fileItemFactory.createItem(item.getFieldName(), item.getContentType(), item.isFormField(), fileName);
                items.add(fileItem);

                try {
                    Streams.copy(item.openStream(), fileItem.getOutputStream(), true, buffer);
                }
                
                ......
                
                FileItemHeaders fih = item.getHeaders();
                fileItem.setHeaders(fih);
            }
        }
            ......
        }
    }
    
    ......
    
}





임시 파일이 사용자 요청이 종료되는 시점에 삭제되는 것은 알았는데, 어떻게 생성되는지 정확한 과정을 몰라 아쉬웠는데, 이렇게 생성과정도 살펴보니 이제야 만족스럽네요. 추가로 스프링이 제공하는 생성방식 외에도 Apache Commons FileUpload 라이브러리와 같은 외부 라이브러리를 사용해 MultipartFile을 생성할 수도 있습니다. 시간이 난다면 이 부분도 살펴보면 좋을 것 같습니다.

Note that the outdated CommonsMultipartResolver based on Apache Commons FileUpload is not available anymore, as of Spring Framework 6.0 with its new Servlet 5.0+ baseline.







5. 정리


스프링이 제공해 주는 MultipartFile은 파일을 임시 디렉터리에 저장합니다. 이는 사용자 요청이 애플리케이션으로 전달된 후, 처리되기까지 유지되며, 이후에는 삭제됩니다. 비동기로 이를 실행하면 해당 파일을 이용해 작업을 처리한 후, 결과를 보지 않고 응답을 내리기 때문에, 작업 도중 파일이 삭제되는 현상이 발생할 수 있습니다. 따라서 작업 전, 파일을 복사한 후, 비동기로 처리해야 합니다.


This post is licensed under CC BY 4.0 by the author.

pt-online-schema-change를 사용하다 결제 시스템이 마비 됐다?

1분 30초 내에 수천만 건의 여신협회 데이터 저장하기