= 20190404 [spring] RestTemplate 를 이용한 파일 업로드 기능 honeymon, ihoneymon@gmail.com v0.0.1, 2019-04-04 == 기존 방식의 문제점 [source,java] ---- public AgreementResponse uploadAgreement(String memberId, File agreementFile) { log.info("Upload CMS Agreement: memberId: {}, agreement: {}, size: {}", memberId, agreement.getName(), agreement.getSize()); MultiValueMap<String, Object> bodyMap = new LinkedMultiValueMap<>(); bodyMap.add(BODY_MEMBER_ID, memberId); bodyMap.add(BODY_FILE_NAME, new FileSystemResource(agreementFile)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(bodyMap, headers); String targetUri = UriComponentsBuilder.fromUriString(properties.getPostAgreementUri()).buildAndExpand(properties.getCustId()).toUriString(); ResponseEntity<AgreementResponse> response = apiClient.postForEntity(targetUri, requestEntity, AgreementResponse.class); log.debug("Response: {}", response); log.debug("Response body: {}", response.getBody()); return response.getBody(); } ---- 위의 코드로 구현한 파일업로드 기능은 실행될 때마다 임시파일을 시스템 임시디렉터리에 생성한다. 그게 반복되다보면 시스템 디스크 자원을 모두 잡아먹는 상황이 발생한다. 이를 개선하기 위해 다음과 같은 시도를 했다. [source,java] ---- public AgreementResponse uploadAgreement(String memberId, MultipartFile agreement) { log.info("Upload CMS Agreement: memberId: {}, agreement: {}, size: {}", memberId, agreement.getName(), agreement.getSize()); MultiValueMap<String, Object> bodyMap = new LinkedMultiValueMap<>(); bodyMap.add(BODY_MEMBER_ID, memberId); bodyMap.add(BODY_FILE_NAME, new ByteArrayResource(agreement.getBytes())); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(bodyMap, headers); String targetUri = UriComponentsBuilder.fromUriString(properties.getPostAgreementUri()).buildAndExpand(properties.getCustId()).toUriString(); ResponseEntity<AgreementResponse> response = apiClient.postForEntity(targetUri, requestEntity, AgreementResponse.class); log.debug("Response: {}", response); log.debug("Response body: {}", response.getBody()); return response.getBody(); } ---- 코드리뷰를 거치는 중에 이 코드는 ``ByteArrayResource``를 생성하는 과정에서 JVM 힙(Heap) 메모리를 파일크기만큼 차지한다. 업로드 파일이 저장되는 것을 피하려고 하다가 더 비싼 메모리 자원을 허비하는 꼴이 된다. 이에 원래 의도했던 ``InputStream`` 으로 처리하는 방법을 사용한다. [source,java] ---- public AgreementResponse uploadAgreement(String memberId, MultipartFile agreement) { log.info("Upload CMS Agreement: memberId: {}, agreement: {}, size: {}", memberId, agreement.getName(), agreement.getSize()); MultiValueMap<String, Object> bodyMap = new LinkedMultiValueMap<>(); bodyMap.add(BODY_MEMBER_ID, memberId); bodyMap.add(BODY_FILE_NAME, generateFilenameAwareByteArrayResource(memberId, agreement)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(bodyMap, headers); String targetUri = UriComponentsBuilder.fromUriString(properties.getPostAgreementUri()).buildAndExpand(properties.getCustId()).toUriString(); ResponseEntity<AgreementResponse> response = apiClient.postForEntity(targetUri, requestEntity, AgreementResponse.class); log.debug("Response: {}", response); log.debug("Response body: {}", response.getBody()); return response.getBody(); } public AgreementResponse getAgreement(String agreementKey) { String targetUri = UriComponentsBuilder.fromUriString(properties.getGetAgreementUri()).buildAndExpand(properties.getCustId(), agreementKey).toUriString(); ResponseEntity<AgreementResponse> responseEntity = apiClient.getForEntity(targetUri, AgreementResponse.class); log.debug("Response: {}", responseEntity); log.debug("Response body: {}", responseEntity.getBody()); return responseEntity.getBody(); } private FilenameAwareInputStreamResource generateFilenameAwareByteArrayResource(String memberId, MultipartFile agreement) { try { return new FilenameAwareInputStreamResource(agreement.getInputStream(), agreement.getSize(), String.format("%s.%s", memberId, FileUtils.getFileExtensions(agreement.getOriginalFilename()))); } catch (Exception e) { log.error("Occur exception", e); throw new PaymentMethodException(e); } } public static class FilenameAwareInputStreamResource extends InputStreamResource { private final String filename; private final long contentLength; public FilenameAwareInputStreamResource(InputStream inputStream, long contentLength, String filename) { super(inputStream); this.filename = filename; this.contentLength = contentLength; } @Override public String getFilename() { return filename; } } ---- 위의 코드를 실행해보면 [source,console] ---- InputStream has already been read - do not use InputStreamResource if a stream needs to be read multiple times java.lang.IllegalStateException: InputStream has already been read - do not use InputStreamResource if a stream needs to be read multiple times at org.springframework.core.io.InputStreamResource.getInputStream(InputStreamResource.java:97) at org.springframework.http.converter.ResourceHttpMessageConverter.writeContent(ResourceHttpMessageConverter.java:130) at org.springframework.http.converter.ResourceHttpMessageConverter.writeInternal(ResourceHttpMessageConverter.java:124) at org.springframework.http.converter.ResourceHttpMessageConverter.writeInternal(ResourceHttpMessageConverter.java:45) at org.springframework.http.converter.AbstractHttpMessageConverter.write(AbstractHttpMessageConverter.java:226) at org.springframework.http.converter.FormHttpMessageConverter.writePart(FormHttpMessageConverter.java:409) at org.springframework.http.converter.FormHttpMessageConverter.writeParts(FormHttpMessageConverter.java:385) at org.springframework.http.converter.FormHttpMessageConverter.writeMultipart(FormHttpMessageConverter.java:365) at org.springframework.http.converter.FormHttpMessageConverter.write(FormHttpMessageConverter.java:273) at org.springframework.http.converter.FormHttpMessageConverter.write(FormHttpMessageConverter.java:94) at org.springframework.web.client.RestTemplate$HttpEntityRequestCallback.doWithRequest(RestTemplate.java:923) at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:685) at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:644) at org.springframework.web.client.RestTemplate.postForEntity(RestTemplate.java:430) .... ---- [NOTE] ==== 나도 읽지않고 고이고이 넘긴 스트림을 어디서 읽은거냐!! ==== 인터넷 검색과 함께 ``InputStreamResource`` 소스코드를 살펴봤다. 그러다가, 문득 발견했다. .`AbstractResource.contentLength()` [source,java] ---- @Override public long contentLength() throws IOException { InputStream is = getInputStream(); try { long size = 0; byte[] buf = new byte[256]; int read; while ((read = is.read(buf)) != -1) { size += read; } return size; } finally { try { is.close(); } catch (IOException ex) { } } } ---- 컨텐츠 길이를 확인하기 위해서 ``contentLength()`` 메서드를 호출하는 순간 호로록 내가 담은 ``InputStream``을 읽어버렸다. ``getInputStream()`` 메서드를 살펴보면 호출되는 순간 바로 읽은 상태가 되어 사용할 수 없는 상태가 되어버린다. [source,java] ---- /** * This implementation throws IllegalStateException if attempting to * read the underlying stream multiple times. */ @Override public InputStream getInputStream() throws IOException, IllegalStateException { if (this.read) { throw new IllegalStateException("InputStream has already been read - " + "do not use InputStreamResource if a stream needs to be read multiple times"); } this.read = true; return this.inputStream; } ---- 그래서 다음과 같이 ``InputStreamResource``를 확장한 ``FilenameAwareInputStreamResource`` 를 생성하는 시점에 컨텐트 길이도 받아서 전달하는 방식을 구현했다. [source,java] ---- public static class FilenameAwareInputStreamResource extends InputStreamResource { private final String filename; private final long contentLength; public FilenameAwareInputStreamResource(InputStream inputStream, long contentLength, String filename) { super(inputStream); this.filename = filename; this.contentLength = contentLength; } @Override public String getFilename() { return filename; } @Override public long contentLength() { return contentLength; } } ----