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