[말모] DB 데이터 암호화 전략 (feat. AES-NI 성능 테스트)

Choi Wontak·2025년 9월 12일

말모

목록 보기
7/9
post-thumbnail

난이도 ⭐️
작성 날짜 2025.08.13

고민 내용

동아리 내 사용해 보신 분들이 이런 질문을 하셨다.

이거 채팅 내용 운영자 분들이 보실 수 있으신가요?
부끄러운데...

아무래도 아는 사람들이 채팅 내용을 볼 수 있다는 점이
사용이 꺼려지시는 포인트인 것 같다.

기획 분께서도 해당 피드백을 받으시고
최대한 빨리 채팅 암호화!!
를 요청하셨다.

🤔 채팅 메시지 데이터, 어떻게 암호화 할까?


찾아보기

우리의 비즈니스 로직 상 지켜져야 하는 부분은 다음과 같다.

  1. 사용자의 채팅 메시지를 암호화 해야 한다.
  2. AI 채팅 메시지를 암호화 해야 한다.
  3. 채팅 메시지는 사용자가 읽을 수 있어야 한다.
  4. AI가 사용자의 메시지를 알 수 있어야 한다. (채팅 맥락 확인 용)

대칭 키? 비대칭 키?

대칭 키 (Symmetric Key)
대칭 키 암호화는 암호화와 복호화에 동일한 키를 사용하는 방식이다.
데이터를 보내는 사람과 받는 사람 모두가 같은 비밀 키를 공유해야 한다.
이 방식은 하나의 문을 같은 열쇠로 열고 잠그는 것과 비유할 수 있다.

작동 원리
1. 송신자는 공유된 대칭 키를 사용하여 원본 데이터(평문)를 암호화한다.
2. 암호화된 데이터(암호문)를 수신자에게 전송한다.
3. 수신자는 미리 공유받은 동일한 대칭 키를 사용하여 암호문을 복호화하여 원본 데이터를 확인한다.

장점

  • 암호화 및 복호화 과정이 단순하여 계산 속도가 매우 빠르다. 따라서 대용량 데이터를 암호화하는 데 적합하다.
  • 조가 간단하여 구현이 비교적 쉽다.

단점

  • 암호화와 복호화를 위해 양측이 동일한 키를 안전하게 공유해야 하는 어려움이 있다.
    키가 전송 과정에서 탈취되면 암호 전체가 무력화된다.
  • 통신하는 사람의 수가 많아질수록 관리해야 할 키의 개수가 기하급수적으로 늘어난다. (N명이 통신할 경우, N(N−1)/2개의 키가 필요하다.)
  • 새로운 사용자가 추가될 때마다 기존 사용자들과 각각의 비밀 키를 생성하고 공유해야 하므로 확장성이 떨어진다.

알고리즘 종류: DES, 3DES, AES, SEED, ARIA

비대칭 키 (Asymmetric Key)
비대칭 키 암호화는 암호화와 복호화에 서로 다른 키를 사용하는 방식이다.
이 방식은 공개 키(Public Key)와 비밀 키(Private Key)라는 한 쌍의 키로 구성된다.

공개 키는 이름처럼 누구에게나 공개될 수 있지만, 비밀 키는 소유자만이 안전하게 보관해야 한다.

자물쇠와 열쇠에 비유할 수 있는데, 누구나 공개된 자물쇠(공개 키)로 상자를 잠글 수 있지만, 오직 상자 주인만이 가진 열쇠(비밀 키)로만 열 수 있다.

작동 원리
1. 수신자는 자신만의 공개 키와 비밀 키 쌍을 생성한다.
2. 자신의 공개 키를 송신자를 포함한 다른 사람들에게 공개한다.
3. 송신자는 수신자로부터 받은 공개 키를 사용하여 데이터를 암호화하고 전송한다.
4. 수신자는 자신이 가진 비밀 키를 사용하여 암호문을 복호화한다. 이 암호문은 오직 해당 개인 키로만 열 수 있다.

장점

  • 공개 키는 누구나 알아도 되므로 키를 배송하는 과정에서 보안 문제가 발생하지 않는다.
  • 각 사용자는 자신의 키 쌍(공개 키, 개인 키)만 관리하면 되므로, 통신 상대방이 늘어나도 관리해야 할 키의 수가 늘어나지 않는다.
  • 개인 키로 서명한 데이터는 해당 개인 키의 소유자만이 작성했음을 증명할 수 있어, 메시지 출처를 확인하고 데이터 전송 사실을 부인하는 것을 방지할 수 있다.

단점

  • 대칭 키 방식에 비해 암호화 및 복호화 과정이 복잡하여 속도가 현저히 느리다.
  • 수학적으로 복잡한 계산을 기반으로 한다.

알고리즘 종류: RSA, DSA, ECC

하이브리드 방식 (Hybrid Cryptosystem)

실제 통신 환경에서는 대칭 키와 비대칭 키의 장점을 결합한 하이브리드 방식이 널리 사용된다.

대용량 데이터를 암호화하는 데는 속도가 빠른 대칭 키를 사용한다.

이때 사용된 대칭 키를 안전하게 전달하기 위해, 속도는 느리지만 보안성이 뛰어난 비대칭 키 방식으로 암호화하여 상대방에게 전송한다.

데이터를 받은 수신자는 자신의 개인 키로 암호화된 대칭 키를 복호화하고, 그 대칭 키를 사용하여 실제 데이터를 복호화한다.

이러한 하이브리드 방식은 우리가 일상적으로 사용하는 SSL/TLS 프로토콜(HTTPS)의 핵심 원리이며, 빠르고 안전한 데이터 통신을 가능하게 한다.


그래서 뭘 선택해야 할까?

1. 클라이언트-서버 간 암호화
이 방식은 애초에 클라이언트에서 메시지를 보낼 때 암호화를 해서 보내는 방식이다.

클라이언트가 보낸 암호화 된 메시지를 그대로 저장하면 되기 때문에 편리하다고 생각했다.

  • 클라이언트가 대칭 키를 갖는 경우
    → 서버가 복호화를 해서 메시지를 AI한테 줘야 되는데 그게 안 된다.

  • 클라이언트가 비밀 키를 갖는 비대칭 키 방식
    → 메시지 암호화는 공개 키로 해야 한다. 비밀 키로 암호화 하면 누구나 복호화 할 수 있기 때문.
    애초에 클라이언트가 키를 생성해서 갖는 방식은 유실 시 데이터를 버리는 것과 마찬가지기 때문에 불가능!

  • 서버가 대칭 키를 갖는 경우
    → 암호화 된 텍스트를 클라이언트가 해석할 수 없다.

  • 서버가 비밀 키를 갖는 비대칭 키 방식
    → 이미 HTTPS를 사용 중이라 중복된 보안이다.
    그리고 비대칭 키 방식은 복잡한 알고리즘을 사용하기 때문에 느리다.

2. 서버-DB 간 암호화
클라이언트 - 서버 간 데이터 유실에 대한 걱정은 HTTPS가 해결해 주기로 하고,
그렇게 받아온 메시지를 DB에 넣을 때 암호화 하는 방식으로 결정했다.

메시지 암호화와 복호화의 주체 모두가 서버이기 때문에,
비대칭 키 방식은 의미가 없고 대칭 키 방식을 선택했다.

대칭 키 방식에는 DES, 3DES, AES, SEED, ARIA 등등이 있는데,
그중에서도 AES를 선택했다.

  • AES: 128/192/256bit 키 지원 → 현재까지 알려진 공격에 안전하다.
    특히 미국 NIST에서 표준으로 채택되어 전 세계적으로 가장 많이 사용된다.

추가적인 장점은 속도이다.
요즘 CPU에는 AES-NI라는 것을 지원하는데, 이 기술은 암호화를 코어 레벨에서 할 수 있도록 지원하여 소프트웨어 레벨의 연산보다 8배의 암호화 속도를 보여준다고 한다.

부록 - JVM에서도 AES-NI는 동작하는가?

이런 코드로 테스트 해보았다.

AES-NI를 활성화

AES-NI를 비활성화

뭐야 똑같네

알고보니까 M1 맥은 인텔과 달라서 AES-NI를 사용하지 않는다.
다만 CoreCrypto 모듈을 이용해 암호화를 처리한다는 것 같다.

https://csrc.nist.gov/CSRC/media/projects/cryptographic-module-validation-program/documents/security-policies/140sp4854.pdf

그럼 우분투에서는 어떨까?

ec2에 ssh 접속해서 java 코드를 vi로 작성했다.

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.security.SecureRandom;

public class AESTest {

    public static void main(String[] args) throws Exception {
        System.out.println("Java version: " + System.getProperty("java.version"));
        System.out.println("OS: " + System.getProperty("os.name") + " " + System.getProperty("os.arch"));
        System.out.println("==============================================");

        // 1. AES-NI 비활성화 테스트
        System.out.println("### AES-NI 비활성화 테스트 (순수 Java) ###");
        System.setProperty("com.sun.crypto.provider.AESCipherNI", "false");
        performAESTest();

        System.out.println("==============================================");

        // 2. AES-NI 활성화 테스트 (기본값)
        System.out.println("### AES-NI 활성화 테스트 (하드웨어 가속) ###");
        System.setProperty("com.sun.crypto.provider.AESCipherNI", "true");
        // 혹은 System.clearProperty("com.sun.crypto.provider.AESCipherNI");
        performAESTest();
    }

    private static void performAESTest() throws Exception {
        KeyGenerator kg = KeyGenerator.getInstance("AES");
        kg.init(256);
        SecretKey key = kg.generateKey();

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        byte[] data = new byte[1024 * 1024]; // 1MB
        new SecureRandom().nextBytes(data);

        // Warm-up
        for (int i = 0; i < 10; i++) {
            cipher.init(Cipher.ENCRYPT_MODE, key);
            cipher.doFinal(data);
        }

        // Measurement
        long start = System.nanoTime();
        for (int i = 0; i < 1000; i++) {
            cipher.init(Cipher.ENCRYPT_MODE, key);
            cipher.doFinal(data);
        }
        long end = System.nanoTime();

        double seconds = (end - start) / 1e9;
        double mbPerSecond = (1000.0 * data.length) / (1024 * 1024) / seconds;

        System.out.println("실행 시간: " + String.format("%.4f", seconds) + " 초");
        System.out.println("처리율: " + String.format("%.2f", mbPerSecond) + " MB/s\n");
    }
}

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintIntrinsics AESTest | grep "AES"

이걸로 실제 AES를 사용하는지 검사해봤다.

성능 차이가 20배 난다.
비활성화 테스트에서 AES 사용이 찍히는 이유는 JIT 컴파일러 때문인데,
JVM은 인터프리터로 네이티브 코드를 처리하다가 몇 백 번 반복문이 실행되는 걸 확인하고 HOT하다고 판단하여 JIT 컴파일러로 최적화해버린다.
이 과정에서 현재 CPU가 AES-NI 명령어를 지원하는 것을 감지하고, AES-NI 네이티브 명령어로 교체하는 과정에서 AES가 찍힌 것으로 유추된다.

활성화 테스트에서는 이미 JIT로 최적화된 반복 메서드를 그대로 사용하기 때문에 엄청나게 빠르게 나오는 것이다!

JIT의 개입을 최소화하기 위해
코드를 performAESTest()만 실행하도록 하고, java 실행 시 설정 값으로 주입하도록 변경했다.

java -XX:+UnlockDiagnosticVMOptions -XX:-UseAESIntrinsics AESNIBenchmark

요렇게.

<AES-NI 비활성화>

<AES-NI 활성화>

1.67배 가까이 되는 성능 차이를 확인할 수 있었다.

아까보다 AES-NI 활성화가 느려진 이유는 초반에 네이티브로 실행하다가 중간부터 바뀌기 때문이다.
따라서 Full로 AES-NI를 사용하지는 않았지만, 비활성화의 경우보다 중간에 AES-NI를 사용한 것이 더 빠르다는 것을 알 수 있었다.

실제 AES 호출도 설정 처리하지 않은 자바에서만 이루어진 모습

프리티어라 cpu 성능 차이도 좀 있었을 텐데 M1 맥 보다 처리율 자체도 높은 거 보면 AES-NI의 성능이 굉장히 좋은 것 같다.

혹시나 좀 더 많은 양으로 처리하면 확실한 차이가 보이지 않을까?

500만 회 비교

<AES-NI 사용 하지 않은 경우>

<AES-NI 사용 한 경우>

이번에는 5.72 배의 소요 시간이 발생하였다.


스프링 JPA에서 AES 암호화/복호화 적용하기

컨버터 클래스를 만들어준다.

@Component
public class AESGCMConverter implements AttributeConverter<String, String> {

    private static final String ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_TAG_LENGTH = 128; // bits
    private static final int IV_LENGTH = 12;       // bytes (권장 12)

    private SecretKey secretKey;

    public AESGCMConverter(@Value("${encryption.aes.key}") String key) {
        this.secretKey = new SecretKeySpec(key.getBytes(), "AES");
    }

    @Override
    public String convertToDatabaseColumn(String attribute) {
        if (attribute == null) return null;
        try {
            byte[] iv = new byte[IV_LENGTH];
            new SecureRandom().nextBytes(iv); // 매번 랜덤 IV 생성

            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);

            byte[] encrypted = cipher.doFinal(attribute.getBytes());

            // IV + Ciphertext 합쳐서 Base64 저장
            byte[] result = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, result, 0, iv.length);
            System.arraycopy(encrypted, 0, result, iv.length, encrypted.length);

            return Base64.getEncoder().encodeToString(result);
        } catch (Exception e) {
            throw new RuntimeException("AES-GCM 암호화 실패", e);
        }
    }

    @Override
    public String convertToEntityAttribute(String dbData) {
        if (dbData == null) return null;
        try {
            byte[] decoded = Base64.getDecoder().decode(dbData);

            // 저장된 데이터에서 IV 분리
            byte[] iv = new byte[IV_LENGTH];
            byte[] encrypted = new byte[decoded.length - IV_LENGTH];
            System.arraycopy(decoded, 0, iv, 0, IV_LENGTH);
            System.arraycopy(decoded, IV_LENGTH, encrypted, 0, encrypted.length);

            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);

            byte[] decrypted = cipher.doFinal(encrypted);
            return new String(decrypted);
        } catch (Exception e) {
            throw new RuntimeException("AES-GCM 복호화 실패", e);
        }
    }
}

AES 중에서도 모드는 128-GCM을 선택했다.

  • ECB: 같은 평문 → 같은 암호문 (패턴 노출 → 보안 취약)
  • CBC: IV 사용, 패턴은 막지만 위·변조 탐지 불가능 → 무결성 보장 X
  • GCM: IV + 카운터 기반, 인증 태그(Tag) 포함 → 암호화 + 무결성 보장 (AEAD: Authenticated Encryption with Associated Data)

256이 더 안전하지만, 속도를 위해 128을 선택하였다.

GCM 방식은 같은 평문 + 같은 iv를 사용하면 이런 오류를 발생시키는데,
동일한 결과로 인해 KEY 값을 유추해 내는 방식이 가능할 수도 있어서 이런 방식을 강제한다.

무작위 문자열은 이런 식으로 만들어서 환경 변수로 넣어주었다.

public static void main(String[] args) throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(128); // 128비트 = 16바이트

        SecretKey secretKey = keyGen.generateKey();

        byte[] keyBytes = secretKey.getEncoded();
        String base64Key = Base64.getEncoder().encodeToString(keyBytes);

        System.out.println("AES-128 Key (16바이트, Base64): " + base64Key);
        System.out.println("Key length (bytes): " + keyBytes.length);
}

주의해야 할 점은 KEY 값은 16, 24, 32 의 크기만 지원한다는 것이다.
아니면 이런 오류가 뜬다.

이제 원하는 필드에 Convert 어노테이션을 붙여 컨버터를 쉽게 적용할 수 있다.

테스트 결과

저장 시에도 암호화가 잘 된다.
그리고 복호화도 문제 없이 잘 되었다.


결론

결론
JPA 덕분에 DB 레벨의 암호화-복호화를 쉽게 처리할 수 있었다!
AES-NI 성능 테스트에서 JIT 컴파일러의 동작 방식도 알 수 있어서 유의미한 경험이었던 것 같다!


profile
백엔드 주니어 주니어 개발자

0개의 댓글