개인 정보와 관련된 민감한 정보들은 안전하게 보호해야 한다.
또한 단순히 접근하지 못하게 보호하는 것뿐 아닌, 사고로 유출이 되더라도 알아보지 못하게 암호화해야 한다.
개인정보의 안전성 확보조치 기준의 7조(개인정보의 암호화)의 5항을 살펴보면 개인정보를 암호화 알고리즘을 통해 저장해야 한다고 설명되어 있다.
최근에는 HTTPS 통신을 사용하기에 클라이언트와 서버 간 데이터를 주고받을 때는 암호화가 되지만, DB에 저장할 때는 암호화 처리가 되지 않는다.
암호화의 방식에는 비대칭키 암호화와 대칭키 암호화 두 가지 방식이 존재하는데, 앞서 말한 HTTPS는 비대칭키 암호화를 사용한다.
그렇다면 DB에 저장할 때는 어떤 방식을 사용해야 할까? 그리고 어떻게 암호화를 구현해서 DB에 안전하게 개인 정보를 보관할 수 있을까?
결론부터 말하자면 우선 DB에 저장할때는 대칭키 암호화를 사용하는 게 좋다.
혹시나 말하지만, 비밀번호는 반드시 복호화가 되지 않는 단방향 암호화를 수행해야 한다!!!
이유는 비대칭키 암호화 방식은 속도가 느리고, 암호문의 크기가 대칭키 암호화 방식보다 크다.
또한 대칭키 암호화는 하나 키만 관리하면 되지만, 비대칭키 암호화 방식은 두 개의 키를 관리해야 하므로 복잡성 또한 증가한다.
따라서 비대칭키 암호화보다는 대칭키 암호화를 사용하는 것이 효율적이라고 할 수 있다.
대칭키 암호화 알고리즘은 여러 가지 종류가 있지만, 그 중 AES 암호화 방식이 제일 많이 사용된다.
AES는 많은 프로그래밍 언어에서 기본적으로 구현되어 있거나 라이브러리에서 쉽게 제공된다.
Java 또한 java.security
모듈로 기본 제공되므로 쉽게 사용할 수 있다.
구현은 다음과 같이 간단히 할 수 있다.
Hex
클래스는 Apachecommons-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 방식의 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.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을 사용했을 때 자원을 정리해 주지 않으면 메모리 누수가 발생할 가능성이 있다.
따라서 스레드마다 상태의 격리가 유지되고, 상한을 조절하여 메모리 누수가 발생하지 않는 다른 방법이 필요하다.
이는 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는 공식적으로 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 벤치마크를 통해 각 블록 모드 별 성능 측정을 하여 적절한 암호화 모드를 선택하거나 인코딩 방식을 변경하면 될 것 같다.
코드는 깃허브에 있습니다.