Java 어플리케이션에서 구현하는 AES 암호화 + JMH 벤치마크

Glen·6일 전
0

배운것

목록 보기
38/38
post-thumbnail

서론

개인 정보와 관련된 민감한 정보들은 안전하게 보호해야 한다.

또한 단순히 접근하지 못하게 보호하는 것뿐 아닌, 사고로 유출이 되더라도 알아보지 못하게 암호화해야 한다.

개인정보의 안전성 확보조치 기준의 7조(개인정보의 암호화)의 5항을 살펴보면 개인정보를 암호화 알고리즘을 통해 저장해야 한다고 설명되어 있다.

최근에는 HTTPS 통신을 사용하기에 클라이언트와 서버 간 데이터를 주고받을 때는 암호화가 되지만, DB에 저장할 때는 암호화 처리가 되지 않는다.

암호화의 방식에는 비대칭키 암호화와 대칭키 암호화 두 가지 방식이 존재하는데, 앞서 말한 HTTPS는 비대칭키 암호화를 사용한다.

그렇다면 DB에 저장할 때는 어떤 방식을 사용해야 할까? 그리고 어떻게 암호화를 구현해서 DB에 안전하게 개인 정보를 보관할 수 있을까?

본론

결론부터 말하자면 우선 DB에 저장할때는 대칭키 암호화를 사용하는 게 좋다.

혹시나 말하지만, 비밀번호는 반드시 복호화가 되지 않는 단방향 암호화를 수행해야 한다!!!

이유는 비대칭키 암호화 방식은 속도가 느리고, 암호문의 크기가 대칭키 암호화 방식보다 크다.

또한 대칭키 암호화는 하나 키만 관리하면 되지만, 비대칭키 암호화 방식은 두 개의 키를 관리해야 하므로 복잡성 또한 증가한다.

따라서 비대칭키 암호화보다는 대칭키 암호화를 사용하는 것이 효율적이라고 할 수 있다.

대칭키 암호화 알고리즘은 여러 가지 종류가 있지만, 그 중 AES 암호화 방식이 제일 많이 사용된다.

AES는 많은 프로그래밍 언어에서 기본적으로 구현되어 있거나 라이브러리에서 쉽게 제공된다.

Java 또한 java.security 모듈로 기본 제공되므로 쉽게 사용할 수 있다.

구현은 다음과 같이 간단히 할 수 있다.

Hex 클래스는 Apache commons-codec:commons-codec 라이브러리를 사용했다.
또한 굳이 16진수로 인코딩할 필요는 없고, Base64를 사용해서 인코딩해도 된다.
Base64를 사용하면 길이는 줄어들지만, 처리하는데 시간과 메모리 사용량이 많기에 16진수로 인코딩을 하였다.

import java.nio.charset.StandardCharsets;  
import javax.crypto.Cipher;  
import javax.crypto.SecretKey;  
import javax.crypto.spec.SecretKeySpec;  
import org.apache.commons.codec.binary.Hex;
  
public class AesEncryptor {  
  
    private final SecretKey secretKey;  
  
    public AesEncryptor(String secretKey) {  
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);  
        this.secretKey = new SecretKeySpec(keyBytes, 0, 16, "AES");  
    }  
  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");  
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);  
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));  
        return Hex.encodeHexString(encrypted);  
    }  
  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");  
        cipher.init(Cipher.DECRYPT_MODE, secretKey);  
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText));  
        return new String(decrypted, StandardCharsets.UTF_8);  
    }  
}
class AesEncryptorTest {  
  
    private static final String PLAIN_TEXT = "HELLO";  
  
    @Test  
    void encrypt() throws Exception {  
        // given
        AesEncryptor aesEncryptor = new AesEncryptor("1234567890".repeat(10));  
        
        // when  
        String encrypted = aesEncryptor.encrypt(PLAIN_TEXT);  
        String actual = aesEncryptor.decrypt(encrypted);  
  
        // then  
        assertEquals(PLAIN_TEXT, actual);  
    }  
}

단순한 구현은 이처럼 간단하지만, 아직 생각해 볼 것이 더 남아있다.

AES 암호화를 적용할 때는 키 길이와 블록 암호화 모드에 대해 이해하는 것이 필요하다.

키 길이

AES는 128비트, 192비트, 256비트 3가지의 키 길이를 지원한다.

이 중에서 예시에서 사용한 것은 128비트로, SecretKeySpec 객체를 생성할 때 3번째 인수로 넣은 16(바이트)이 그것이다.

키의 길이가 길수록 키의 조합이 다양해지고, 암호화 과정에서 사용하는 라운드의 수가 많아진다.

키 크기의 순서대로 각각 10, 12, 14 라운드를 실행하는데, 라운드에 따라 128비트의 키를 여러 개 생성한 뒤, 각 라운드에 여러 변환을 통해 최종적으로 암호화를 수행한다.

라운드에서 수행하는 작업에 대한 자세한 내용을 적기엔 길어지니 궁금하면 구글링을 통해 알아보자.

블록 암호화 모드

AES 암호화는 128비트 단위로 암호화를 수행하는데 이는 블록 암호화 모드에 따라 다르게 처리된다.

예시에서 설정한 블록 암호화 모드는 ECB로, 단순히 데이터를 128비트로 나눈 뒤, 이를 암호화한다.

하지만 ECB 방식을 사용하면 보안에 문제가 될 수 있는데, 블록마다 동일한 암호화가 이뤄지므로 패턴이 나타나게 된다.

따라서 패턴이 노출되지 않는 다른 블록 암호화 모드를 사용해야 한다.

주로 사용되는 블록 암호화 모드는 CBC, GCM이 있는데 Java에서는 모두 지원한다.

둘의 공통점은 IV(Initial Value)라고 하는 초깃값을 사용한다.

CBC 방식은 블럭을 암호화할 때, 이전 블럭(첫 블럭은 IV)과 XOR 연산을 통해 암호화하고, GCM 방식은 CTR 방식을 기반으로 인증 태그(무결성 검증용)를 통해 암호화를 수행한다.

CBC 방식은 이전 블럭을 통해 순차적으로 처리해야 하므로 병렬 연산이 구조적으로 불가능하지만, GCM 방식은 카운터 기반으로 동작하기에 병렬 연산이 가능하여 속도가 빠르다고 한다.

과연 그런지는 벤치마크를 통해 알아보겠다.

그리고 ECB와 CBC 방식은 블록 크기가 동일해야만 암호화가 가능한 데 비해, GCM 방식은 그럴 필요가 없다.

따라서 GCM 방식을 사용하면 패딩을 적용하지 않아도 된다.

CBC, GCM 구현

CBC와 GCM에 대해 간단히 설명했으니, 코드로 CBC 방식과 GCM 방식의 AES를 구현해 보자

public interface AesEncryptor {  
  
    String encrypt(String plainText);  
  
    String decrypt(String cipherText);  
}
import java.nio.charset.StandardCharsets;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;

public class EcbAesEncryptor implements AesEncryptor{

    private final SecretKey secretKey;

    public EcbAesEncryptor(String secretKey) {
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        this.secretKey = new SecretKeySpec(keyBytes, 0, 16, "AES");
    }

    @Override
    public String encrypt(String plainText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(encrypted);
    }

    @Override
    public String decrypt(String cipherText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, secretKey);
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText));
        return new String(decrypted, StandardCharsets.UTF_8);
    }
}
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ThreadLocalRandom;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;

public class CbcAesEncryptor implements AesEncryptor {

    private static final int IV_LENGTH = 16;
    private final SecretKey secretKey;

    public CbcAesEncryptor(String secretKey) {
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        this.secretKey = new SecretKeySpec(keyBytes, 0, 16, "AES");
    }

    @Override
    public String encrypt(String plainText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        byte[] iv = new byte[IV_LENGTH];
        ThreadLocalRandom.current().nextBytes(iv);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);
    }

    @Override
    public String decrypt(String cipherText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        byte[] iv = Hex.decodeHex(cipherText.substring(0, IV_LENGTH * 2));
        cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText.substring(IV_LENGTH * 2)));
        return new String(decrypted, StandardCharsets.UTF_8);
    }
}

여러 방식을 지원하기 위해 AesEncryptor 인터페이스를 정의하고 CBC 방식을 지원하는 클래스를 구현했다.

CBC 방식은 IV를 추가로 생성하고, Cipher.init() 메서드에 IvParameterSpec 객체를 만들어서 넣어주면 된다.

IV의 길이는 AES 암호 블럭의 크기와 동일하게 128비트(16바이트) 길이로 만들면 된다.

IV는 복호화할 때도 필요하므로, 16진수 문자열로 만들어 암호문 앞에 붙이고, 복호화할 때는 앞의 32자리(Byte는 16진수 2자리) 기준으로 IV와 암호문을 구분한다.

GCM 방식을 지원하는 코드도 다음과 같이 구현할 수 있다.

import java.nio.charset.StandardCharsets;  
import java.util.concurrent.ThreadLocalRandom;  
import javax.crypto.Cipher;  
import javax.crypto.SecretKey;  
import javax.crypto.spec.GCMParameterSpec;  
import javax.crypto.spec.SecretKeySpec;  
import org.apache.commons.codec.binary.Hex;  
  
public class GcmAesEncryptor implements AesEncryptor {  
  
    private static final int GCM_IV_LENGTH = 12;  
    private static final int GCM_BIT_LENGTH = 128;  
  
    private final SecretKey secretKey;  
  
    public GcmAesEncryptor(String secretKey) {  
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);  
        this.secretKey = new SecretKeySpec(keyBytes, 0, 32, "AES");  
    }  
  
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = new byte[GCM_IV_LENGTH];  
        ThreadLocalRandom.current().nextBytes(iv);  
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));  
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = Hex.decodeHex(cipherText.substring(0, GCM_IV_LENGTH * 2));  
        cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText.substring(GCM_IV_LENGTH * 2)));  
        return new String(decrypted, StandardCharsets.UTF_8);  
    }  
}
class GcmAesEncryptorTest {  
  
    private static final String PLAIN_TEXT = "HELLO";  
  
    @Test  
    void encrypt() throws Exception {  
        // given  
        GcmAesEncryptor gcmAesEncryptor = new GcmAesEncryptor("1234567890".repeat(10));  
  
        // when  
        String encrypted = gcmAesEncryptor.encrypt(PLAIN_TEXT);  
        String actual = gcmAesEncryptor.decrypt(encrypted);  
  
        // then  
        assertEquals(PLAIN_TEXT, actual);  
    }  
}

GCM 방식은 앞서 말했듯, CBC 방식과 다르게 패딩이 필요하지 않다.

또한 IV의 길이가 128비트가 아닌, 96비트(12바이트)를 사용했고, GCMParameterSpec 객체를 생성할 때, 128 값을 넣어줬다.

이는 GCM 방식에서 IV의 길이가 96비트가 아니면 내부에서 해시 함수를 통해 추가 처리 과정이 생기기 때문이고, 태그의 길이는 보안과 효율성 측면에서 128비트가 권장되기 때문이다.

이렇게 ECB, CBC, GCM 방식의 AES 암호화 코드를 구현했다.

하지만, 이 셋 중 하나의 방법을 선택해야 한다.

ECB 방식으로 암호화된 암호문은 CBC, GCM 방식으로 복호화할 수 없다. (반대의 경우도 마찬가지)

당연하게 암호화 성능이 좋은 CBC, GCM을 선택할 것이지만, ECB 방식이 보안에 취약하다는 것을 모르고 이미 DB 데이터 대부분이 ECB 방식으로 암호화가 되어 있었다면?

시간이 흘러 하드웨어 성능이 좋아져서 128비트로 저장된 암호문이 취약해진다면?

16진수로 인코딩한 암호문의 길이가 길어져서 다른 인코딩 방식을 사용해야 한다면?

이 경우 기존에 암호화한 방식이 호환되지 못해 무척 난감해지는 상황이 생긴다.

이를 대비해서 좀 더 유연한 암호화를 적용할 수 있도록 코드를 개선해 보자.

위임 패턴

이는 위임(Delegate) 패턴을 사용하면 쉽게 해결할 수 있다.

Spring Security 모듈의 DelegatingPasswordEncoder를 참고했다.

public class DelegatingAesEncryptor implements AesEncryptor {

    private static final String PREFIX = "{";
    private static final String SUFFIX = "}";
    
    private final String idToEncrypt;
    private final Map<String, AesEncryptor> idToEncryptor;
    private final AesEncryptor defaultEncryptor;
    private final AesEncryptor fallback;

    public DelegatingAesEncryptor(String idToEncrypt, Map<String,AesEncryptor> idToEncryptor,
        AesEncryptor fallback) {
        this.idToEncrypt = idToEncrypt;
        this.idToEncryptor = idToEncryptor;
        this.fallback = fallback;
        this.defaultEncryptor = idToEncryptor.get(idToEncrypt);
    }

    @Override
    public String encrypt(String plainText) throws Exception {
        if (plainText == null) {
            return null;
        }
        return PREFIX + idToEncrypt + SUFFIX + defaultEncryptor.encrypt(plainText);
    }

    @Override
    public String decrypt(String cipherText) throws Exception {
        String id = extractId(cipherText);
        if (id == null) {
            return fallback.decrypt(cipherText);
        }
        AesEncryptor encryptor = idToEncryptor.get(id);
        if (encryptor == null) {
            return fallback.decrypt(cipherText);
        }
        return encryptor.decrypt(extractCipherText(cipherText));
    }

    private String extractId(String cipherText) {
        if (cipherText == null) {
            return null;
        }
        int start = cipherText.indexOf(PREFIX);
        if (start != 0) {
            return null;
        }
        int end = cipherText.indexOf(SUFFIX, start);
        if (end < 0) {
            return null;
        }
        return cipherText.substring(start + PREFIX.length(), end);
    }

    private String extractCipherText(String cipherText) {
        int start = cipherText.indexOf(SUFFIX);
        return cipherText.substring(start + SUFFIX.length());
    }
}
class DelegatingAesEncryptorTest {  
  
    private static final String PLAIN_TEXT = "HELLO";  
  
    @Test  
    void encrypt() throws Exception {  
        // given  
        EcbAesEncryptor ecbAesEncryptor = new EcbAesEncryptor("1234567890".repeat(10));  
        AesEncryptor cbcAesEncryptor = new CbcAesEncryptor("1234567890".repeat(10));  
        DelegatingAesEncryptor delegatingAesEncryptor = new DelegatingAesEncryptor(  
            "cbc",  
            Map.of("cbc", cbcAesEncryptor),  
            ecbAesEncryptor  
        );  
        String oldEncrypted = ecbAesEncryptor.encrypt(PLAIN_TEXT);  
  
        // when  
        String newEncrypted = delegatingAesEncryptor.encrypt(PLAIN_TEXT);  
        String oldDecrypted = delegatingAesEncryptor.decrypt(oldEncrypted);  
        String newDecrypted = delegatingAesEncryptor.decrypt(newEncrypted);  
  
        // then  
        Assertions.assertTrue(newEncrypted.startsWith("{cbc}"));  
        Assertions.assertEquals(oldDecrypted, newDecrypted);  
    }  
}

위임 패턴을 사용한 DelegatingAesEncryptor를 사용하면 다른 방식의 암호문을 지원하면서, 새로운 암호화를 사용할 수 있다.

이 정도 구현을 통해 하위 호환성도 보장하고, 적절한 보안 능력도 챙겼으나 추가로 개선할 점이 있다.

중복된 코드

public class EcbAesEncryptor implements AesEncryptor {
    ...
    @Override
    public String encrypt(String plainText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(encrypted);
    }

    @Override
    public String decrypt(String cipherText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, secretKey);
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText));
        return new String(decrypted, StandardCharsets.UTF_8);
    }
}

public class CbcAesEncryptor implements AesEncryptor {
    ...
    @Override
    public String encrypt(String plainText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        byte[] iv = new byte[IV_LENGTH];
        ThreadLocalRandom.current().nextBytes(iv);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);
    }

    @Override
    public String decrypt(String cipherText) throws Exception {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        byte[] iv = Hex.decodeHex(cipherText.substring(0, IV_LENGTH * 2));
        cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText.substring(IV_LENGTH * 2)));
        return new String(decrypted, StandardCharsets.UTF_8);
    }
}

public class GcmAesEncryptor implements AesEncryptor {
    ...
    @Override
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = new byte[GCM_IV_LENGTH];  
        ThreadLocalRandom.current().nextBytes(iv);  
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));  
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
    }  
      
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = Hex.decodeHex(cipherText.substring(0, GCM_IV_LENGTH * 2));  
        cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        byte[] decrypted = cipher.doFinal(Hex.decodeHex(cipherText.substring(GCM_IV_LENGTH * 2)));  
        return new String(decrypted, StandardCharsets.UTF_8);  
    }
}
public abstract class AesEncryptHelper {  
  
    protected byte[] encrypt(  
        Cipher cipher,  
        String plainText,  
        SecretKey secretKey,  
        AlgorithmParameterSpec parameterSpec  
    )  
        throws InvalidKeyException, InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException, DecoderException {  
  
        if (parameterSpec == null) {  
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);  
        } else {  
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);  
        }  
        return cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));  
    }  
  
    protected String decrypt(  
        Cipher cipher,  
        byte[] cipherBytes,  
        SecretKey secretKey,  
        AlgorithmParameterSpec parameterSpec  
    )  
        throws InvalidKeyException, InvalidAlgorithmParameterException, DecoderException, IllegalBlockSizeException, BadPaddingException {  
  
        if (parameterSpec == null) {  
            cipher.init(Cipher.DECRYPT_MODE, secretKey);  
        } else {  
            cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);  
        }  
        return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8);  
    }  
}

다음과 같이 중복된 암호화 로직을 추상 클래스에 정의하고 구현체가 이를 상속하게 해서 사용하도록 할 수 있다.

또는 정적 유틸 클래스를 정의하여 사용할 수 있을 것 같다.

public class EcbAesEncryptor extends AesEncryptHelper implements AesEncryptor {
    ...
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");  
        return Hex.encodeHexString(encrypt(cipher, plainText, secretKey, null));  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");  
        return decrypt(cipher, Hex.decodeHex(cipherText), secretKey, null);  
    }  
}

public class CbcAesEncryptor extends AesEncryptHelper implements AesEncryptor {  
    ...
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");  
        byte[] iv = new byte[IV_LENGTH];  
        ThreadLocalRandom.current().nextBytes(iv);  
        byte[] encrypted = encrypt(cipher, plainText, secretKey, new IvParameterSpec(iv));  
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");  
        byte[] iv = Hex.decodeHex(cipherText.substring(0, IV_LENGTH * 2));  
        byte[] decodedCipher = Hex.decodeHex(cipherText.substring(IV_LENGTH * 2));  
        return decrypt(cipher, decodedCipher, secretKey, new IvParameterSpec(iv));  
    }  
}

public class GcmAesEncryptor extends AesEncryptHelper implements AesEncryptor {  
    ...
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = new byte[GCM_IV_LENGTH];  
        ThreadLocalRandom.current().nextBytes(iv);  
        byte[] encrypted = encrypt(cipher, plainText, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] iv = Hex.decodeHex(cipherText.substring(0, GCM_IV_LENGTH * 2));  
        byte[] decodedCipher = Hex.decodeHex(cipherText.substring(GCM_IV_LENGTH * 2));  
        return decrypt(cipher, decodedCipher, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
    }  
}

그리고 이를 상속한 뒤, 사용하면 블록 모드를 정하고 어떤 인코딩 방식을 사용할지, 어떤 키를 사용할지에 대한 해당 구현체가 알아야 할 핵심적인 부분만 남게 된다.

또한 키를 생성하는 부분도 다음과 같이 Enum과 유틸 클래스를 사용하면 원하는 키의 길이를 간편하게 주입할 수 있다.

public enum AesBit {
    BIT128(16),
    BIT192(24),
    BIT256(32),
    ;

    private final int byteLength;

    AesBit(int byteLength) {
        this.byteLength = byteLength;
    }

    public int getByteLength() {
        return byteLength;
    }
}

public class SecretKeyUtil {

    private SecretKeyUtil() {
        throw new UnsupportedOperationException();
    }

    public static SecretKey createAes(String secretKey, AesBit aesBit) {
        if (secretKey == null || secretKey.isBlank()) {
            throw new IllegalArgumentException();
        }
        int aesByteLength = aesBit.getByteLength();
        byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
        if (keyBytes.length > aesByteLength) {
            byte[] newKey = new byte[aesByteLength];
            System.arraycopy(keyBytes, 0, newKey, 0, aesByteLength);
            int idx = 0;
            for (int i = aesByteLength; i < keyBytes.length; i++) {
                newKey[idx] = (byte) (newKey[idx] ^ keyBytes[i]);
                idx++;
                if (idx >= aesByteLength) {
                    idx = 0;
                }
            }
            keyBytes = newKey;
        } else if (keyBytes.length < aesByteLength) {
            byte[] newKey = new byte[aesByteLength];
            System.arraycopy(keyBytes, 0, newKey, 0, keyBytes.length);
            for (int i = keyBytes.length; i < newKey.length; i++) {
                newKey[i] = 0;
            }
            keyBytes = newKey;
        }
        return new SecretKeySpec(keyBytes, "AES");
    }
}

키를 생성하는 코드가 조금 복잡한데, 이는 MySQL에서 AES를 사용할 때, 키 생성 로직을 가져왔기 때문이다.

private static final String PLAIN_TEXT = "HELLO";  
  
EcbAesEncryptor aesEncryptor = new EcbAesEncryptor(SecretKeyUtil.createAes("1234567890", AesBit.BIT128));  
  
@Test  
void encrypt() throws Exception {  
    String encrypted = aesEncryptor.encrypt(PLAIN_TEXT);  
    System.out.println(encrypted); // ff06a56b4e88c828a010e9bd0ce70a89  
}
select hex(aes_encrypt('HELLO', '1234567890')) from dual;
# FF06A56B4E88C828A010E9BD0CE70A89

이렇게 키를 생성하면 키를 해당 비트의 길이만큼 조절하지 않아도 되고, MySQL의 AES 함수로 저장한 암호문을 호환시킬 수 있다.

Cipher 객체의 생성 비용

추가로 신경을 써야 할 부분은 Cipher 객체를 생성하는 부분인데, Cipher.getInstance() 메서드의 내부를 보면 많은 연산을 수행하는 것을 알 수 있다.

public static final Cipher getInstance(String transformation)  
        throws NoSuchAlgorithmException, NoSuchPaddingException  
{  
    if ((transformation == null) || transformation.isEmpty()) {  
        throw new NoSuchAlgorithmException("Null or empty transformation");  
    }  
    List<Transform> transforms = getTransforms(transformation);  
    List<ServiceId> cipherServices = new ArrayList<>(transforms.size());  
    for (Transform transform : transforms) {  
        cipherServices.add(new ServiceId("Cipher", transform.transform));  
    }  
    List<Service> services = GetInstance.getServices(cipherServices);  
    // make sure there is at least one service from a signed provider  
    // and that it can use the specified mode and padding    Iterator<Service> t = services.iterator();  
    Exception failure = null;  
    while (t.hasNext()) {  
        Service s = t.next();  
        if (JceSecurity.canUseProvider(s.getProvider()) == false) {  
            continue;  
        }  
        Transform tr = getTransform(s, transforms);  
        if (tr == null) {  
            // should never happen  
            continue;  
        }  
        int canuse = tr.supportsModePadding(s);  
        if (canuse == S_NO) {  
            // does not support mode or padding we need, ignore  
            continue;  
        }  
        // S_YES, S_MAYBE  
        // even when mode and padding are both supported, they        // may not be used together, try out and see if it works        try {  
            CipherSpi spi = (CipherSpi)s.newInstance(null);  
            tr.setModePadding(spi);  
            // specify null instead of spi for delayed provider selection  
            return new Cipher(null, s, t, transformation, transforms);  
        } catch (Exception e) {  
            failure = e;  
        }  
    }  
    throw new NoSuchAlgorithmException  
        ("Cannot find any provider supporting " + transformation, failure);  
}

백만 개의 Cipher 객체만을 생성한다고 했을 때, 약 1.2초의 시간이 소요된다.

맥북 M1 16GB, Eclipse Temurin JDK 17.0.12 - aarch64 기준

@Test  
void cipherTest() throws Exception {  
    List<Cipher> ciphers = new ArrayList<>();  
    long start = System.currentTimeMillis();  
    for (int i = 0; i < 1_000_000; i++) {  
        ciphers.add(Cipher.getInstance("AES/CBC/PKCS5Padding"));  
        ciphers.remove(0);  
    }  
    long end = System.currentTimeMillis();  
    System.out.println((end - start) + "ms"); // 1275ms
}

remove를 해주지 않으면 OOM이 발생할 정도로 객체가 많이 무겁다.

참고로 단순 문자열 생성은 70ms 밖에 소요되지 않는다.

@Test
void StringTest() {
    List<String> strings = new ArrayList<>();
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1_000_000; i++) {
        strings.add(ThreadLocalRandom.current().nextLong() + "");
    }
    long end = System.currentTimeMillis();
    System.out.println((end - start) + "ms");
    System.out.println(strings.get(ThreadLocalRandom.current().nextInt(strings.size()))); // 70ms
}

이는 단순히 속도를 넘어서, GC가 자주 호출되게 만드는 원인이 될 수 있다.

그렇다면 매번 객체를 생성하지 않고, 필드로 둬서 사용하면 되지 않을까 싶지만, Cipher 객체는 상태를 가지고 있으며 스레드 안전하지 않다.

그렇다면 스레드마다 상태를 유지하도록 ThreadLocal을 사용하면 되겠지만, ThreadLocal을 사용했을 때 자원을 정리해 주지 않으면 메모리 누수가 발생할 가능성이 있다.

따라서 스레드마다 상태의 격리가 유지되고, 상한을 조절하여 메모리 누수가 발생하지 않는 다른 방법이 필요하다.

ObjectPool

이는 Apache Commons 라이브러리의 ObjectPool을 사용하면 된다.

ObjectPool을 사용하면 스레드 풀 또는 커넥션 풀 처럼 알아서 자원의 상한을 조절하고 스레드 간 격리를 유지하며 빠르게 암/복호화를 수행할 수 있다.

dependencies {
    implementation 'org.apache.commons:commons-pool2:2.12.1'
}

참고로 Spring dependency-management 플러그인을 사용하면 버전을 명시하지 않아도 된다.

ObjectPool은 다음과 같이 간단히 구현할 수 있다.

예외가 발생하면 invalidateObject() 메서드를 호출해야 하지만, Cipher를 가져온 뒤, init() 메서드를 호출하므로 생략하였다. 자세한 내용은 Javadoc 또는 공식 문서를 참조하자

public class CipherPool {  
  
    private final ObjectPool<Cipher> cipherPool;  
  
    public CipherPool(String transformation) {  
        GenericObjectPoolConfig<Cipher> config = new GenericObjectPoolConfig<>();  
        config.setMaxTotal(20);  
        config.setMaxIdle(5);  
        config.setMinIdle(2);  
        this.cipherPool = new GenericObjectPool<>(new AesCipherPooledObjectFactory(transformation), config);  
    }  
  
    public Cipher borrowCipher() throws Exception {  
        return cipherPool.borrowObject();  
    }  
  
    public void returnCipher(Cipher cipher) throws Exception {  
        cipherPool.returnObject(cipher);  
    }  
  
    public static class AesCipherPooledObjectFactory extends BasePooledObjectFactory<Cipher> {  
  
        private final String transformation;  
  
        public AesCipherPooledObjectFactory(String transformation) {  
            this.transformation = transformation;  
        }  
  
        @Override  
        public Cipher create() throws Exception {  
            return Cipher.getInstance(transformation);  
        }  
  
        @Override  
        public PooledObject<Cipher> wrap(Cipher cipher) {  
            return new DefaultPooledObject<>(cipher);  
        }  
    }  
}

그리고 각 AES 구현체에 ObjectPool을 사용하도록 변경하면 다음과 같다.

public class EcbAesEncryptor extends AesEncryptHelper implements AesEncryptor {  
  
    private final SecretKey secretKey;  
    private final CipherPool cipherPool;  
  
    public EcbAesEncryptor(SecretKey secretKey) {  
        this.secretKey = secretKey;  
        cipherPool = new CipherPool("AES/ECB/PKCS5Padding");  
    }  
  
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            return Hex.encodeHexString(encrypt(cipher, plainText, secretKey, null));  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            return decrypt(cipher, Hex.decodeHex(cipherText), secretKey, null);  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
}

public class CbcAesEncryptor extends AesEncryptHelper implements AesEncryptor {  
  
    private static final int IV_LENGTH = 16;  
  
    private final SecretKey secretKey;  
    private final CipherPool cipherPool;  
  
    public CbcAesEncryptor(SecretKey secretKey) {  
        this.secretKey = secretKey;  
        this.cipherPool = new CipherPool("AES/CBC/PKCS5Padding");  
    }  
  
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            byte[] iv = new byte[IV_LENGTH];  
            ThreadLocalRandom.current().nextBytes(iv);  
            byte[] encrypted = encrypt(cipher, plainText, secretKey, new IvParameterSpec(iv));  
            return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            byte[] iv = Hex.decodeHex(cipherText.substring(0, IV_LENGTH * 2));  
            byte[] decodedCipher = Hex.decodeHex(cipherText.substring(IV_LENGTH * 2));  
            return decrypt(cipher, decodedCipher, secretKey, new IvParameterSpec(iv));  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
}

public class GcmAesEncryptor extends AesEncryptHelper implements AesEncryptor {  
  
    private static final int GCM_IV_LENGTH = 12;  
    private static final int GCM_BIT_LENGTH = 128;  
  
    private final SecretKey secretKey;  
    private final CipherPool cipherPool;  
  
    public GcmAesEncryptor(SecretKey secretKey) {  
        this.secretKey = secretKey;  
        this.cipherPool = new CipherPool("AES/GCM/NoPadding");  
    }  
  
    @Override  
    public String encrypt(String plainText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            byte[] iv = new byte[GCM_IV_LENGTH];  
            ThreadLocalRandom.current().nextBytes(iv);  
            byte[] encrypted = encrypt(cipher, plainText, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
            return Hex.encodeHexString(iv) + Hex.encodeHexString(encrypted);  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
  
    @Override  
    public String decrypt(String cipherText) throws Exception {  
        Cipher cipher = null;  
        try {  
            cipher = cipherPool.borrowCipher();  
            byte[] iv = Hex.decodeHex(cipherText.substring(0, GCM_IV_LENGTH * 2));  
            byte[] decodedCipher = Hex.decodeHex(cipherText.substring(GCM_IV_LENGTH * 2));  
            return decrypt(cipher, decodedCipher, secretKey, new GCMParameterSpec(GCM_BIT_LENGTH, iv));  
        } finally {  
            cipherPool.returnCipher(cipher);  
        }  
    }  
}

그리고 ObjectPool을 사용하면 성능이 약 3~4배 정도 개선된 것을 볼 수 있다.

ObjectPool을 사용하지 않았을 때는 2,887ms가 소요됐다.

AesEncryptor aesEncryptor = new CbcAesEncryptor(SecretKeyUtil.createAes("1234567890", AesBit.BIT128));  
  
@Test  
void encrypt() throws Exception {  
    List<String> encrypted = new ArrayList<>();  
    long start = System.currentTimeMillis();  
    for (int i = 0; i < 1_000_000; i++) {  
        encrypted.add(aesEncryptor.encrypt(String.valueOf(ThreadLocalRandom.current().nextLong())));  
    }  
    long end = System.currentTimeMillis();  
    System.out.println((end - start) + "ms"); // 848ms
    System.out.println(encrypted.get(ThreadLocalRandom.current().nextInt(encrypted.size())));
}

백만 번을 수행했을 때 대략적인 시간은 측정했지만, 초에 얼마나 처리되고 GC가 얼마나 일어났고, 메모리를 얼마나 차지했는지에 대한 비교는 위의 테스트로는 알기 어렵다.

JMH

이는 JMH라는 벤치마킹 툴을 사용하면 알 수 있는데, JMH는 공식적으로 Maven만 지원하지만, 다른 사용자가 만든 Gradle 플러그인을 사용하면 Gradle 환경에서도 사용할 수 있다.

plugins {  
    id 'java'  
    // 작성 시점에서 0.7.2 버전이 최신이라 사용했다.
    id "me.champeau.jmh" version "0.7.2" 
}

...

dependencies {
    // 작성 시점에서 1.37 버전이 최신이라 사용했다.
    jmh 'org.openjdk.jmh:jmh-core:1.37'  
    jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.37'  
    jmh 'org.openjdk.jmh:jmh-generator-bytecode:1.37'

    // 벤치마크에서 사용할 의존성을 다음과 같이 추가한다.
    jmh 'org.apache.commons:commons-pool2:2.12.1'
    jmh 'commons-codec:commons-codec:1.18.0'
}

...

jmh {  // 간단한 벤치마크를 위해 모두 1로 설정했다. 더 신뢰성 있는 결과를 보고 싶으면 수치를 조절하면 된다.
    fork = 1  
    warmupIterations = 1  
    iterations = 1  
    profilers = ["gc"]  // gc 프로파일러를 추가하면 메모리 할당량, GC에 관련된 정보를 알 수 있다.
}

그리고 src 폴더에 jmh 라는 이름의 Gradle Source Sets을 생성한다.

IntelliJ를 사용한다면 src에서 새로운 폴더를 생성하면 자동 완성으로 뜨니, 간단하게 생성할 수 있다.

그리고 원하는 이름의 클래스를 만들고 JMH 어노테이션을 추가한다.

각 어노테이션과 Enum에 대한 설명은 Javadoc에 상세히 설명되어 있으니 직접 살펴보기를 추천한다.

import org.openjdk.jmh.annotations.Benchmark;  
import org.openjdk.jmh.annotations.BenchmarkMode;  
import org.openjdk.jmh.annotations.Level;  
import org.openjdk.jmh.annotations.Mode;  
import org.openjdk.jmh.annotations.OutputTimeUnit;  
import org.openjdk.jmh.annotations.Scope;  
import org.openjdk.jmh.annotations.Setup;  
import org.openjdk.jmh.annotations.State;  
import org.openjdk.jmh.infra.Blackhole;

@BenchmarkMode(Mode.Throughput)  
@OutputTimeUnit(TimeUnit.SECONDS)  
@State(Scope.Benchmark)  
public class AesEncryptBenchmark {  
  
    private static final int TEXT_SIZE = 100;  
  
    AesEncryptor aesEncryptor;  
    List<String> plainTexts;  
    List<String> encryptedTexts;  
  
    @Setup(Level.Trial)  
    public void setup() throws Exception {  
        aesEncryptor = new CbcAesEncryptor(SecretKeyUtil.createAes("1234567890", AesBit.BIT128));  
        plainTexts = ThreadLocalRandom.current().ints(TEXT_SIZE)  
            .mapToObj(String::valueOf)  
            .toList();  
        encryptedTexts = new ArrayList<>();  
        for (String plainText : plainTexts) {  
            encryptedTexts.add(aesEncryptor.encrypt(plainText));  
        }  
    }  
  
    @Benchmark  
    public void encrypt(Blackhole bh) throws Exception {  
        String plainText = plainTexts.get(ThreadLocalRandom.current().nextInt(TEXT_SIZE));  
        bh.consume(aesEncryptor.encrypt(plainText));  
    }  
  
    @Benchmark  
    public void decrypt(Blackhole bh) throws Exception {  
        String encryptText = encryptedTexts.get(ThreadLocalRandom.current().nextInt(TEXT_SIZE));  
        bh.consume(aesEncryptor.decrypt(encryptText));  
    }  
}

여기서 주의해야 할 점은 JMH가 @BenchMark가 달린 메서드를 실행하는데, 반복적으로 이를 실행하므로 혹여나 내부에 반복문을 사용하는 것에 대해 주의해야 한다.

// 백만번 씩 벤치마크가 실행되는 동안 N번 반복한다! 
@Benchmark
public void decrypt(Blackhole bh) throws Exception {
    for (int i = 0; i < 1_000_000; i++) {
        String encryptText = encryptedTexts.get(ThreadLocalRandom.current().nextInt(TEXT_SIZE));
        bh.consume(aesEncryptor.decrypt(encryptText));
    }
}

벤치마크 실행은 Gradle Task에서 jmh - jmh를 실행하면 된다.

테스트한 결과는 콘솔에도 출력되지만, build/results/jmh 안에 txt 파일로 저장된다.

Benchmark                                        Mode  Cnt        Score   Error   Units  
AesEncryptBenchmark.decrypt                     thrpt       1583506.473           ops/s  
AesEncryptBenchmark.decrypt:gc.alloc.rate       thrpt          1043.736          MB/sec  
AesEncryptBenchmark.decrypt:gc.alloc.rate.norm  thrpt           691.162            B/op  
AesEncryptBenchmark.decrypt:gc.count            thrpt            70.000          counts  
AesEncryptBenchmark.decrypt:gc.time             thrpt            38.000              ms  
AesEncryptBenchmark.encrypt                     thrpt       2105384.963           ops/s  
AesEncryptBenchmark.encrypt:gc.alloc.rate       thrpt          1379.816          MB/sec  
AesEncryptBenchmark.encrypt:gc.alloc.rate.norm  thrpt           687.231            B/op  
AesEncryptBenchmark.encrypt:gc.count            thrpt            92.000          counts  
AesEncryptBenchmark.encrypt:gc.time             thrpt            32.000              ms

이를 통해 초당 몇 개의 암/복호화를 할 수 있는지와 메모리를 얼마나 사용했는지 GC가 몇 번 수행되었는지를 알 수 있다.

참고로 ObjectPool을 사용하지 않은 결과는 다음과 같다.

Benchmark                                        Mode  Cnt       Score   Error   Units  
AesEncryptBenchmark.decrypt                     thrpt       430580.395           ops/s  
AesEncryptBenchmark.decrypt:gc.alloc.rate       thrpt         2427.160          MB/sec  
AesEncryptBenchmark.decrypt:gc.alloc.rate.norm  thrpt         5910.879            B/op  
AesEncryptBenchmark.decrypt:gc.count            thrpt          162.000          counts  
AesEncryptBenchmark.decrypt:gc.time             thrpt           54.000              ms  
AesEncryptBenchmark.encrypt                     thrpt       437622.732           ops/s  
AesEncryptBenchmark.encrypt:gc.alloc.rate       thrpt         2467.154          MB/sec  
AesEncryptBenchmark.encrypt:gc.alloc.rate.norm  thrpt         5911.681            B/op  
AesEncryptBenchmark.encrypt:gc.count            thrpt          165.000          counts  
AesEncryptBenchmark.encrypt:gc.time             thrpt           60.000              ms

속도도 느리고, 메모리 사용량도 매우 많은 것을 확인할 수 있다.

보너스

GCM이 CBC에 비해 병렬 연산이 되므로 얼마나 더 빠를까 궁금하여 종합적으로 벤치마크를 실행해 봤다.

결과는 예상외로 GCM 방식이 매우 느린 것을 확인할 수 있었다.

Benchmark                                                         Mode  Cnt        Score   Error   Units  
CbcAesEncryptBenchMark.decryptWithObjectPool                     thrpt       1764495.239           ops/s  
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt          1187.084          MB/sec  
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt           705.750            B/op  
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt            80.000          counts  
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            30.000              ms 

CbcAesEncryptBenchMark.encryptWithObjectPool                     thrpt       1545350.143           ops/s  
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt          1113.606          MB/sec  
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt           755.950            B/op  
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt            76.000          counts  
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            29.000              ms  

EcbAesEncryptBenchMark.decryptWithObjectPool                     thrpt       2027989.956           ops/s  
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt           711.462          MB/sec  
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt           368.020            B/op  
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt            48.000          counts  
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            19.000              ms  

EcbAesEncryptBenchMark.encryptWithObjectPool                     thrpt       2000834.238           ops/s  
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt           733.233          MB/sec  
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt           384.443            B/op  
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt            50.000          counts  
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            21.000              ms  

GcmAesEncryptBenchMark.decryptWithObjectPool                     thrpt        826474.052           ops/s  
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt          1219.234          MB/sec  
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt          1547.521            B/op  
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt            83.000          counts  
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            31.000              ms  

GcmAesEncryptBenchMark.encryptWithObjectPool                     thrpt        833918.775           ops/s  
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt          1292.470          MB/sec  
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt          1625.880            B/op  
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt            87.000          counts  
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            33.000              ms

그래서 혹시 몰라 JDK 버전을 21로 올리고 다시 테스트를 했더니 놀라운 결과가 나타났다.

M1 16GB Eclipse Temurin 21.0.3 기준

Benchmark                                                         Mode  Cnt        Score   Error   Units
CbcAesEncryptBenchMark.decryptWithObjectPool                     thrpt       1868497.342           ops/s
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt          1254.019          MB/sec
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt           704.124            B/op
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt            85.000          counts
CbcAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            36.000              ms

CbcAesEncryptBenchMark.encryptWithObjectPool                     thrpt       1837711.260           ops/s
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt          1234.635          MB/sec
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt           704.823            B/op
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt            84.000          counts
CbcAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            35.000              ms

EcbAesEncryptBenchMark.decryptWithObjectPool                     thrpt       2413368.191           ops/s
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt           842.012          MB/sec
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt           366.029            B/op
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt            57.000          counts
EcbAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            26.000              ms

EcbAesEncryptBenchMark.encryptWithObjectPool                     thrpt       2379011.732           ops/s
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt           868.221          MB/sec
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt           382.876            B/op
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt            59.000          counts
EcbAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            26.000              ms

GcmAesEncryptBenchMark.decryptWithObjectPool                     thrpt       1699158.295           ops/s
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate       thrpt          2559.015          MB/sec
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.alloc.rate.norm  thrpt          1579.994            B/op
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.count            thrpt           172.000          counts
GcmAesEncryptBenchMark.decryptWithObjectPool:gc.time             thrpt            65.000              ms

GcmAesEncryptBenchMark.encryptWithObjectPool                     thrpt       1710980.883           ops/s
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate       thrpt          2545.705          MB/sec
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.alloc.rate.norm  thrpt          1560.903            B/op
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.count            thrpt           171.000          counts
GcmAesEncryptBenchMark.encryptWithObjectPool:gc.time             thrpt            66.000              ms

GCM 방식에서 메모리 사용량이 2배로 늘긴 했지만, 속도도 그만큼 2배로 올라간 것을 확인할 수 있다.

덩달아 나머지 방식도 약간의 성능 향상이 나타난 것을 볼 수 있다.

개인적인 생각으로, 선형적으로 성능이 향상된 것을 보아 내부에 경합이 발생하는 로직이 최적화된 것이 아닌가를 의심해 본다.

결론

간단하게 AES 암호화와 이를 Java로 구현하는 법을 알아보았다.

그리고 암/복호화를 할 때 Cipher 객체를 매번 생성하지 않고 ObjectPool을 사용하여 효율성을 크게 높일 수 있었다.

또한 객체지향 언어의 장점을 살려 암호화의 하위 호환성을 보장하며 새로운 암호화를 적용할 수 있었다.

JMH 벤치마크를 통해 각 블록 모드 별 성능 측정을 하여 적절한 암호화 모드를 선택하거나 인코딩 방식을 변경하면 될 것 같다.

코드는 깃허브에 있습니다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글

관련 채용 정보