이전 파일을 다운받을때, 용량이 10mb만 넘어가도 실제 환경에서 20초, 50mb가 넘어가면 1분이 넘게 걸렸었다.
처음에는 인프라의 문제인가 생각을 했지만 단순히 s3에서 파일을 받아 응답할경우 시간이 오래 걸리지 않았기때문에 복호화의 문제라 생각해 복호화 메서드를 검토해보았다.
AES-GCM(Advanced Encryption Standard - Galois/Counter Mode)은 대칭 키 암호화 기법으로
데이터의 빠른 암호화와 복호화와 여러 현대적인 암복호화 기능을 사용할 수 있다.
public InputStreamResource decryptInputStreamResource(InputStreamResource encryptedResource) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// 모든 데이터 가져오기
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(encryptedResource.getInputStream().readAllBytes())) {
// IV 읽기
byte[] iv = new byte[AESGCMUtil.IV_LENGTH];
if (inputStream.read(iv) != AESGCMUtil.IV_LENGTH) {
//IV 읽기 실패
throw new BusinessBaseException(ErrorCode.INTERNAL_SERVER_ERROR);
}
// Cipher 초기화
Cipher cipher = AESGCMUtil.initCipher(Cipher.DECRYPT_MODE, secretKey, iv);
try (CipherInputStream cipherInputStream = new CipherInputStream(inputStream, cipher);
InputStream wrappedInputStream = inputStream;
OutputStream wrappedOutputStream = outputStream) {
// 청크 단위로 처리
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = cipherInputStream.read(buffer)) != -1) {
wrappedOutputStream.write(buffer, 0, bytesRead);
}
wrappedOutputStream.flush();
} catch (IOException e) {
throw new BusinessBaseException(ErrorCode.INTERNAL_SERVER_ERROR);
}
} catch (IOException e) {
throw new BusinessBaseException(ErrorCode.INTERNAL_SERVER_ERROR);
}
ByteArrayInputStream decryptedInputStream = new ByteArrayInputStream(outputStream.toByteArray());
return new InputStreamResource(decryptedInputStream);
}
코드의 흐름을 간단히 보면 아래와 같다.
위의 코드에서의 문제점은 다음과 같았다.
즉 스트림으로 복호화가 가능한데, 모든 파일을 한번에 받아 복호화를 하여 속도가 느려졌었다.
public InputStreamResource decryptInputStreamResourceThread(InputStreamResource encryptedResource) throws Exception {
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos, 64 * 1024); // 64KB 파이프 버퍼
// 별도 스레드에서 복호화 작업 수행
new Thread(() -> {
try (InputStream inputStream = encryptedResource.getInputStream()) {
// IV 읽기
byte[] iv = new byte[AESGCMUtil.IV_LENGTH];
if (inputStream.read(iv) != AESGCMUtil.IV_LENGTH) {
//IV 읽기 실패
throw new BusinessBaseException(ErrorCode.INTERNAL_SERVER_ERROR);
}
// Cipher 초기화
Cipher cipher = AESGCMUtil.initCipher(Cipher.DECRYPT_MODE, secretKey, iv);
// 청크 단위로 처리
byte[] buffer = new byte[64 * 1024]; // 64KB 버퍼
byte[] decryptedBuffer;
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
if (bytesRead > 0) {
// 마지막 청크가 아닌 경우 update() 사용
decryptedBuffer = cipher.update(buffer, 0, bytesRead);
if (decryptedBuffer != null) {
pos.write(decryptedBuffer);
pos.flush(); // 즉시 플러시하여 데이터 전달
}
}
}
// 마지막 블록 처리
decryptedBuffer = cipher.doFinal();
if (decryptedBuffer != null) {
pos.write(decryptedBuffer);
}
pos.close();
} catch (Exception e) {
throw new BusinessBaseException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}).start();
return new InputStreamResource(pis);
}
코드의 흐름은 아래와 같다.
이 방식으로 하게되면 클라이언트는 stream으로 복호화된 내용을 지속적으로 응답을 받게되기때문에 백엔드 내부 메모리 사용량도 적게 들 뿐 아니라 속도 또한 월등히 빨라지게 된다.
즉, 기존방식은 전체 데이터 처리 후 응답에서, 스트림으로 청크별로 처리시 즉시 응답으로 변경되었다.
ByteArrayInputStream은 이미 메모리에 있는 바이트 배열에 대한 데이터를 읽는 스트림이다.
모든 데이터가 이미 메모리에 올라가있을때 동작하기때문에 첫번째 메서드에서는 클래스를 사용하는것이 옳다
PipedInputStream과 PipedOutputStream은 두 스레드 간 데이터 통신을 위한 파이프 스트림이다.
한 스레드에서 PipedOutputStream에 쓰면 다른 스레가 PipedInputStream에서 읽을 수 있다.
즉, 복호화 스레드에서 복호화를 하자마다 다른 스레드(클라이언트 응답)에서 바로 데이터가 전달되어 응답이 가능하다
로그를 보면 파일 다운로드 후, 복호화가 모두 끝난다음 클라이언트에게 응답을 시작한다
CPU사용량과 메모리가 들쑥날쑥하며, 많은 사용량을 보이고있다.
로그와 같이 파일 다운로드 후, 스레드를 생성한다음 즉시 클라이언트에게 스트림으로 응답이 가고있다.
CPU사용량과 메모리또한 안정적인 모습을 보이고있다.