InputStream 사용시 발생한 간단 장애 회고
배경
회사에서 이미지-저장 API를 개발 및 유지 보수 하고 있습니다.
클라이언트에서 이미지를 전송하면 해당 이미지를 S3에 저장하는 모듈입니다.
기획 정책상 S3 에 저장하기 전, 용량을 줄이고자 리사이즈 작업을 선 진행 후 S3 에 업로드 합니다.
사건
S3 에 저장된 이미지 중, 리사이즈 된 이미지의 용량이 0byte 로 나타나는 케이스가 간헐적으로 발생했습니다.
0byte 이미지는 무엇을 의미하는 걸까요? 그렇습니다. 이미지가 유실됐다는 거죠 😇
다행인 점은 리사이즈 전, 원본 사이즈의 이미지도 별도로 S3 에 저장하기 때문에 큰 장애로 이어지진 않았습니다.
원인 규명
다시 사건으로 돌아가서, 사건의 전날 상용 배포 커밋을 우선 비교했습니다.
리사이즈 된 이미지에서 0byte 이슈가 발생했으니, 이미지를 리사이즈 하는 부분을 집중적으로 보겠습니다.
기존
public byte[] resizeImage(MultipartFile originalImage) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { // (0)
BufferedImage image = ImageIO.read(originalImage.getInputStream()); // (1)
int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
if (originalWidth <= 360) {
return IOUtils.toByteArray(originalImage.getInputStream()); // (2)
}
...
return baos.toByteArray();
}
}
(1), (2) 에서 InputStream 을 생성하는 것을 확인할 수 있습니다. 해당 inputStream 은 클라이언트에서 받은 MultipartFile 로 부터 생성됩니다.
여기서 실수한 점이 바로 보이시나요? 그렇습니다. 생성된 stream 을 닫는, stream 의 close() 를 호출하는 코드가 없습니다. (0) 의 ByteArrayOutputStream 은 try with resource 구문 내부에 선언하여 자동으로 close() 시켰지만, (1) (2) 의 inputStream 은 close() 하지 않았죠.
여기서 저는 븅띤 같은 실수를 저지릅니다. 바로 (1) (2) 를 하나로 통일한 후 try with resource 구문으로 넣어버린 것이죠. 이게 왜 실수일까요? 아래에서 계속 풀어보겠습니다.
변경 후
public byte[] resizeImage(MultipartFile originalImage) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
InputStream inputStream = originalImage.getInputStream()) {
BufferedImage image = ImageIO.read(inputStream); // (1)
int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
if (originalWidth <= RESIZE_360) {
return IOUtils.toByteArray(inputStream); // (2)
}
...
return baos.toByteArray();
}
}
close 되지 않은 (1) (2) 의 inputStream 을 try with resource 로 넣었으니, try 코드 블럭이 끝나면 자동으로 스트림 자원이 종료됩니다. 그럼 잘 된거 아니냐구요? inputStream 변수가 사용되는 지점 (1) (2) 를 다시 보겠습니다.
(1) 에선 ImageIO.read() 의 인자로, (2) 에선 IOUtils.toByteArray() 의 인자로 inputStream 을 사용합니다.
디버거를 통해 (2) 지점의 inputStream 의 내부를 까봤습니다.
buf 배열의 길이가 78928 인데, pos 도 78928
네요. (응?)
이미 pos (= position)가 바이트 배열의 마지막에 위치하는 것을 알 수 있습니다.
(2) IOUtils.toByteArray() 메서드에서는 배열로 변환할, 즉 읽어들일 스트림이 더 이상 없는 상태입니다. 이미지가 0byte로 저장될 수 밖에 없는 원인이 파악됐습니다.
그렇다면 변수 inputStream 의 pos 는 왜 버퍼의 마지막 인덱스를 가리키고 있었을까요?
(1) 에서 ImageIO.read() 의 인자로 들어갈 때 해당 스트림을 다 읽어버렸기 때문입니다.
InputStream 은 데이터의 통로이기 때문에 , 한번 읽은 스트림은 되돌아가서 읽을 수 없습니다. (* InputStream 내부의 mark(), reset() 등을 이용하면 되돌아가서 읽을 수 있지만...)
읽을 수 있는 바이트가 없었기에 IOUtils.toByteArray() 메서드는 0byte 의 배열을 리턴했고, S3 에는 0byte 의 배열이 저장됐던 것이지요.
요약
정리해보겠습니다.
1) 이미지의 InputStream 을 읽어들이는 지점은 2군데였다. 코드 수정 전에는 두 지점에서 스트림을 개별 생성했다.
2) 장애의 원인이 된 코드 수정은 스트림 선언부를 하나의 변수로 통일한 것이었다. 즉 InputStream을 읽어들이는 두 지점에 동일한 변수(스트림) 를 인자로 넘긴 것.
3) 첫번째 지점(메서드)에서 해당 변수를 먼저 읽어버리니, 당연히 두번째 지점에서는 읽어들일 스트림 데이터가 남아있지 않았다. 그래서 0byte 배열이 리턴됐다.
해결
우선 하나의 변수로 선언한 inputStream 을 다시 개별로 롤백했습니다.
그 후 자원 해제를 위해 두 InputStream 모두 try resource 구문 내부에서 선언했습니다.
public byte[] resizeImage(MultipartFile originalImage) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
InputStream inputStream = originalImage.getInputStream(); // (1)
InputStream belowResize = originalImage.getInputStream();) { // (2)
BufferedImage image = ImageIO.read(inputStream); // (1*)
int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
if (originalWidth <= RESIZE_360) {
return IOUtils.toByteArray(belowResize); // (2*)
}
...
return baos.toByteArray();
}
}
(1) 의 인풋스트림은 (1*) 에서,
(2) 의 인풋스트림은 (2*) 에서만 사용됨으로 더 이상 0바이트 스트림이 리턴되지 않게 됐습니다.
배운점
기본이 탄탄해야 한다는 뼈저린 교훈을 얻었습니다. 스트림에 대해 어렴풋이 알고 있다보니, 한번 읽은 스트림은 다시 사용하지 못한다는 기본적인 개념을 놓쳤습니다.
API 자체에 대해서도 다시 생각해보게 됐는데, 애당초 이미지를 MultipartFile 로 받을 필요가 없다는 것을 스쿼드 TL 님을 통해 알게 됐습니다. 어차피 이미지의 바이트 배열만 필요하기 때문이죠.
멀티파트파일 대신 바이트 배열이 메서드 인자로 넘어왔다면, 괜히 스트림을 사용할 필요도 없었습니다. 하여 이 부분을 좀 더 리팩토링 해보기로 했습니다. 다음 포스팅에서는 이미지 업로드 API 의 리팩토링을 소재로 돌아오겠습니다.
ref