Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ihoneymon/836cd6ca162cc2b436e70a3cbd035760 to your computer and use it in GitHub Desktop.
Save ihoneymon/836cd6ca162cc2b436e70a3cbd035760 to your computer and use it in GitHub Desktop.
[spring] RestTemplate 를 이용한 파일 업로드 기능: ByteArrayResource -> InputStreamResource 변경

20190404 [spring] RestTemplate 를 이용한 파일 업로드 기능

기존 방식의 문제점

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();
}

위의 코드로 구현한 파일업로드 기능은 실행될 때마다 임시파일을 시스템 임시디렉터리에 생성한다. 그게 반복되다보면 시스템 디스크 자원을 모두 잡아먹는 상황이 발생한다. 이를 개선하기 위해 다음과 같은 시도를 했다.

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 으로 처리하는 방법을 사용한다.

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;
    }
}

위의 코드를 실행해보면

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()
@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() 메서드를 살펴보면 호출되는 순간 바로 읽은 상태가 되어 사용할 수 없는 상태가 되어버린다.

/**
 * 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 를 생성하는 시점에 컨텐트 길이도 받아서 전달하는 방식을 구현했다.

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;
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment