AES-GCM 복호화 속도 개선

박은빈·2025년 4월 30일
0

자바

목록 보기
26/26

이전 파일을 다운받을때, 용량이 10mb만 넘어가도 실제 환경에서 20초, 50mb가 넘어가면 1분이 넘게 걸렸었다.

처음에는 인프라의 문제인가 생각을 했지만 단순히 s3에서 파일을 받아 응답할경우 시간이 오래 걸리지 않았기때문에 복호화의 문제라 생각해 복호화 메서드를 검토해보았다.

AES-GCM이란

AES-GCM(Advanced Encryption Standard - Galois/Counter Mode)은 대칭 키 암호화 기법으로

데이터의 빠른 암호화와 복호화와 여러 현대적인 암복호화 기능을 사용할 수 있다.

특징

  • AES 블록 암호 기반
  • CTR(Counter)모드로 데이터 암호화 - 병렬처리가 가능하다
  • 하드웨어 가속 - CPU내부 명령어 세트(AES-NI)가 내장되어있을경우 암호화 및 복호화 과정을 하드웨어 수준에서 가속 가능
  • 스트림 방식으로 데이터 처리가 가능하여 대용량 데이터 처리 가능
  • IV(Initialization Vector)를 이용하여 매번 다른 패턴의 암호문 생성 가능

기존 코드

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

코드의 흐름을 간단히 보면 아래와 같다.

  1. 인자에 있는 encryptedResource의 데이터를 모두 읽는다.
  2. 읽은 데이터를 4kb의 청크 단위로 분리하여 데이터를 복호화한다.
  3. 복호화된 데이터를 ByteArrayInputStream의 decryptedInputStream에 담아 InputStreamResource로 변환하여 응답한다

위의 코드에서의 문제점은 다음과 같았다.

  • InputStreamResouce를 사용하는데 메서드의 응답은 전체 복호화 후 응답이었다
  • 메서드가 다 완료될때까지 응답이 되지 않았다
  • 전체 파일이 복호화된 후 응답을 하였기때문에 메모리에 파일의 데이터가 전부 담기게 된다

즉 스트림으로 복호화가 가능한데, 모든 파일을 한번에 받아 복호화를 하여 속도가 느려졌었다.

새로운 코드

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

코드의 흐름은 아래와 같다.

  1. 새로운 스레드를 생성한다.
  2. 스레드 내부에서 인자로 받은 inputStream을 stream한다.
  3. 64kb 단위로 복호화를 하여 복호화가 끝날경우 즉시 flush()를 하여 클라이언트에게 응답한다.

이 방식으로 하게되면 클라이언트는 stream으로 복호화된 내용을 지속적으로 응답을 받게되기때문에 백엔드 내부 메모리 사용량도 적게 들 뿐 아니라 속도 또한 월등히 빨라지게 된다.

즉, 기존방식은 전체 데이터 처리 후 응답에서, 스트림으로 청크별로 처리시 즉시 응답으로 변경되었다.

ByteArrayInputStream대신 PipedInputStream을 사용하는 이유

ByteArrayInputStream은 이미 메모리에 있는 바이트 배열에 대한 데이터를 읽는 스트림이다.
모든 데이터가 이미 메모리에 올라가있을때 동작하기때문에 첫번째 메서드에서는 클래스를 사용하는것이 옳다

PipedInputStream과 PipedOutputStream은 두 스레드 간 데이터 통신을 위한 파이프 스트림이다.
한 스레드에서 PipedOutputStream에 쓰면 다른 스레가 PipedInputStream에서 읽을 수 있다.

즉, 복호화 스레드에서 복호화를 하자마다 다른 스레드(클라이언트 응답)에서 바로 데이터가 전달되어 응답이 가능하다

성능

  • 스펙 : 맥북에어 m2, 16gb메모리, intellij내부에서 프로젝트 실행

기존 코드


로그를 보면 파일 다운로드 후, 복호화가 모두 끝난다음 클라이언트에게 응답을 시작한다
CPU사용량과 메모리가 들쑥날쑥하며, 많은 사용량을 보이고있다.

  • CPU 피크 20%, 평균 18%
  • 메모리 피크 1500mb, 평균 1000mb
  • 50mb 파일 다운로드 후 복호화시 평균 40초~50초

새로운 코드


로그와 같이 파일 다운로드 후, 스레드를 생성한다음 즉시 클라이언트에게 스트림으로 응답이 가고있다.
CPU사용량과 메모리또한 안정적인 모습을 보이고있다.

  • CPU 피크 15%, 평균 5%
  • 메모리 피크 350mb, 평균 300mb
  • 50mb 파일 다운후 복호화시 평균 4~5초
profile
안녕하세요

0개의 댓글