Back-End/Spring 자료실

[JPA] 민감한 데이터 암호화 / 마스킹하기 (Attribute Converter, @Converter)

Chipmunks 2024. 6. 23. 10:23
728x90

 

어느 여름 낮.

시원한 에어컨 아래, 냉커피를 마시며,

막바지 API 개발을 하고 있는 다람쥐 사원.

 

그러던 중 메신저 알림이 울립니다.

 

PM님 : 람쥐님 안녕하세요~ 😊
오전에 전달주신 기능들 테스트 서버에서 확인했어요!
누락된 내용은 없네요~ 최고 👍👍

다만 한 가지 스펙이 갑작스레 추가된 게 있어요. 😭😭
법적으로 문제될 수 있는 민감한 데이터가 추가로 발견되어서요.

데이터베이스에 암호화하거나, 서버에서 보여줄 땐 마스킹,

이 두 가지 기능 추가 부탁드려도 될까요?

내일 오전 QA 시작 전까지 부탁드려요.
람쥐님만 믿습니다! 🙇‍♂️🙇‍♂️🙇‍♂️
혹시 이슈 있으면 언제든 알려주세요! 😉😉

< 요청 기능 >
1. cardNo : 데이터베이스 저장 시 원문 / 불러올 시 마스킹
2. holderName : 데이터베이스 저장 시 암호화 / 불러올 시 원문
...

(기획서 XX 페이지에 해당 스펙 추가)

< 기한 >
- 내일 QA 시작 전까지 테스트 서버에 배포

fyi. @슈나우저(개발팀) @수달(개발팀 팀장)

 

이럴수가.

행복한 마음으로 퇴근할 생각이었는데...

중요한 스펙이라 미룰 수도 없네요. 😭

 

얼른 에디터 검색 기능으로

cardNo, holderName 등의 수정 범위를 확인합니다.

영향을 받는 파일만 20여 개가 넘어가네요.

 

일일이 수정할 생각에,

정시 퇴근은 물 건너 갔다고 생각한 순간...

 

?!

누군가 불쑥 얼굴을 내밀었습니다.

슈나우저님 / 시니어 개발자 / 특징 : 무서움..

 

슈나우저님 : 람쥐님 메시지 확인했어요~ 수정할 것 많으시죠?

혹시 'Attribute Converter' 들어 보셨어요? ( 아니요... )

아, 그래요? 잠깐 같이 해볼까요?

 


 

들어가기 전에...

전체 예제 코드는 링크에서 확인할 수 있습니다.

(위 에피소드는 실제가 아닌 허구입니다.)


 

요구사항 분석

요구사항을 다시 한 번 볼까요?

  1. 마스킹
  2. 암호화 / 복호화

'데이터베이스에 어떻게 저장되는가' 가 핵심입니다.

1번은 평문으로 저장됩니다.

2번은 '암호화' 한 채로 저장됩니다.

 

그 다음은 '서버에서 어떻게 불러오는가' 입니다.

1번은 '마스킹' 으로 내용을 가려 불러옵니다.

2번은 '복호화' 하여 평문으로 불러옵니다.

 

1번은 개인 정보 외부 노출 보안 정책에 따릅니다.

2번은 데이터베이스 보안 정책에 따릅니다.

 

기존 코드를 크게 수정하지 않는 방법은 어디 없을까요?

 

기존 객체 PlainCard 소개

본격적으로 들어가기에 앞서 기존 객체를 소개합니다.

객체 명은 'PlainCard' 입니다.

암호화/복호화, 마스킹이 필요한 cardNo, holderName 속성 필드가 있습니다.

예시를 위해 불필요한 다른 필드는 제외합니다.

package com.example.demo.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class PlainCard {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String cardNo;

    private String holderName;

}

 

참고로 JPA 엔티티는 JPA 스펙에 따라 빈 생성자(기본 생성자)가 필요합니다.

이는 JPA 에서 객체를 생성하는 데 필요하기 때문입니다.

다음 세가지 경우가 있습니다.

 

  1. 리플렉션으로 인스턴스 만들기 : 리플렉션은 기본 생성자가 필요합니다.
  2. 프록시 객체 생성 : 프록시 객체는 지연 로딩(lazy loading)을 구현체(Hibernate 등)에서 지원합니다. 마찬가지로 리플렉션을 사용합니다.
  3. 데이터베이스에서 데이터를 읽을 때 : 엔티티 객체를 초기화할 때 기본 생성자를 사용합니다. 이후 각 필드에 값이 할당됩니다.

 

다른 생성자를 생성(예제에선 @AllArgsConstructor 롬복 어노테이션)하면 기본 생성자가 자동으로 생기지 않습니다.

따라서 기본 생성자를 명시해줘야 합니다. (예제에선 @NoArgsConstructor 롬복 어노테이션)

 

Attribute Converter

Attribute Converter 를 알아보죠! ( jakarta.persistence-api:3.1.0 기준 )

 

AttributeConverter (Jakarta Persistence API documentation)

convertToEntityAttribute X convertToEntityAttribute​(Y dbData) Converts the data stored in the database column into the value to be stored in the entity attribute. Note that it is the responsibility of the converter writer to specify the correct dbData

jakarta.ee

Jakarta EE (구 Java EE) 의 스펙입니다.

엔티티 속성과 데이터베이스 칼럼 간의 표현 방법을 정의한 인터페이스입니다.

다음 두 가지 메소드가 있습니다.

 

  1. 엔티티 속성 값 -> 데이터베이스 칼럼 변환 : Y convertToDatabaseColumn(X attribute)
  2. 데이터베이스 칼럼 -> 엔티티 속성 값 변환 : X convertToEntityAttribute(Y dbData)

X는 엔티티 속성 타입, Y는 데이터베이스 칼럼 타입입니다.

 

카드 마스킹 컨버터

먼저 카드 번호를 마스킹하는 컨버터 코드입니다.

package com.example.demo.entity.converter;

import com.example.demo.util.StringUtil;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter
public class CardMaskingConverter implements AttributeConverter<String, String> {

    @Override
    public String convertToDatabaseColumn(String raw) {
        return raw;
    }

    @Override
    public String convertToEntityAttribute(String raw) {
        String masked = raw;
        masked = StringUtil.mask(masked, 10, 14);
        masked = StringUtil.mask(masked, 15, 19);
        return masked;
    }
}

 

convertToDatabaseColumn 메소드는 엔티티 속성 값에서 데이터베이스 칼럼으로 저장합니다.

매개변수 raw 를 처리하고, 반환하면 그 값대로 데이터베이스 칼럼으로 들어갑니다.

 

현재 요구사항에서 카드 번호는 그대로 데이터베이스에 저장되어야 합니다.

따라서 매개변수 raw 를 그대로 반환합니다.

 

로직이 들어간 메소드는 convertToEntityAttribute 메소드입니다.

마스킹 로직이 들어갔는데요.

 

카드 형식은 XXXX-XXXX-XXXX-XXXX 으로 가정했습니다.

마스킹은 XXXX-XXXX-****-**** 으로 세 번째, 네 번째 카드 번호를 별표(*)로 마스킹됩니다.

 

StringUtil.mask 유틸 메소드는 아래와 같습니다.

package com.example.demo.util;

public final class StringUtil {
    private StringUtil() {}

    /**
     * 문자열을 마스킹합니다. 시작 위치부터 끝 위치까지 마스킹합니다.
     * @param plain 마스킹할 문자열
     * @param inclusiveStartIndex 시작 위치 (포함)
     * @param exclusiveEndIndex 끝 위치 (포함 안됨)
     * @return 마스킹된 문자열
     */
    public static String mask(
            String plain,
            int inclusiveStartIndex,
            int exclusiveEndIndex
    ) {
        StringBuilder builder = new StringBuilder(plain);

        int maskLength = exclusiveEndIndex - inclusiveStartIndex;
        String masked = "*".repeat(maskLength);

        builder.replace(inclusiveStartIndex, exclusiveEndIndex, masked);

        return builder.toString();
    }

}

 

@Converter 어노테이션

@Target({TYPE}) @Retention(RUNTIME)
public @interface Converter {
     boolean autoApply() default false;
}

 

Converter 어노테이션은 이 클래스가 'Attribute Converter' 임을 명시합니다.

Attribute Converter는 @Converter 어노테이션을 적용해야 합니다.

 

autoApply 속성이 true 이면

엔티티 속성에 @Convert 어노테이션으로 명시해줄 필요가 없습니다.

자동으로 해당 타입의 필드에 Attribute Converter 가 적용됩니다.

 

단, 같은 타입의 적용되어야 하는 Attribute Converter 가 여러 개면

필드에 명시적으로 정의해야 합니다.

 

카드 마스킹 컨버터 테스트 코드

카드 마스킹 컨버터가 잘 동작하는지 테스트 코드로 확인합니다.

 

package com.example.demo.entity.converter;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class CardMaskingConverterTest {

    @InjectMocks
    private CardMaskingConverter cardMaskingConverter;

    @Nested
    @DisplayName("암호화")
    class EncryptTest {
        @Test
        @DisplayName("데이터베이스 저장할 때 원문 그대로 저장합니다.")
        void shouldSaveOriginalWhenSave() throws Exception {
            String privacyData = "1234-5678-1234-5678";
            String expected = "1234-5678-1234-5678";

            String actual = cardMaskingConverter.convertToDatabaseColumn(privacyData);

            assertEquals(expected, actual);
        }
    }

    @Nested
    @DisplayName("복호화")
    class DecryptTest {
        @Test
        @DisplayName("데이터베이스에서 불러올 때 카드 정보를 마스킹합니다.")
        void shouldMaskCardNoWhenLoad() throws Exception {
            String privacyData = "1234-5678-1234-5678";
            String expected = "1234-5678-****-****";

            String actual = cardMaskingConverter.convertToEntityAttribute(privacyData);

            assertEquals(expected, actual);
        }
    }
}

 

@ExtendWith(MockitoExtension.class) 와 @InjectMocks 으로 컨버터 클래스를 초기화합니다.

최대한 불필요한 목킹, 스텁을 막기 위해 Mockito 에서 지원하는 Extension 과 어노테이션들입니다.

Mockito 라이브러리 단에서 목 객체, 스텁 객체를 생성해주고 주입시켜줍니다.

무분별하고 관리가 되지 않는 목, 스텁 객체를 라이브러리 단에서 해결하고자 하네요.

 

@InjectMocks 어노테이션이 적용된 클래스에 목 객체를 주입합니다.

현재 CardMaskingConverter 컨버터엔 따로 필요한 객체가 없으므로

새로운 객체만 할당해줍니다.

 

목 객체 주입은 암호화 / 복호화 컨버터에서 다시 살펴보죠.

 

개인 정보 암호화 / 복호화 컨버터

개인 정보 암호화 / 복호화 컨버터도 마찬가지로 AttributeConverter 를 구현합니다.

카드 마스킹 컨버터와 다른 점이 있습니다.

코드를 살펴볼까요?

 

package com.example.demo.entity.converter;

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Converter
public class EncryptedConverter implements AttributeConverter<String, String> {

    private final PrivacyEncryptor privacyEncryptor;

    @Override
    public String convertToDatabaseColumn(String raw) {
        try {
            return privacyEncryptor.encrypt(raw);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String convertToEntityAttribute(String encrypted) {
        try {
            return privacyEncryptor.decrypt(encrypted);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
package com.example.demo.entity.converter;

public interface PrivacyEncryptor {
    String encrypt(String raw) throws Exception;
    String decrypt(String encrypted) throws Exception;
}

 

바로 컨버터가 사용하는 'PrivacyEncryptor' 입니다.

비밀키로 암호화 및 복호화를 해주는 객체를 주입받습니다.

엔티티 필드와 데이터베이스 칼럼을 표현할 때 암호화 / 복호화를 적용합니다.

 

프로젝트 코드에선 예시로 AES 암호화 / 복호화를 사용했습니다.

다만 테스트 코드에선 목 객체를 사용할 예정이므로 따로 소개하진 않겠습니다.

궁금하신 분들은 com.example.demo.security.AESService 클래스 파일을 참고해주세요.

실제 애플리케이션 실행 시 AESService 가 주입받게 됩니다.

 

개인 정보 암호화 / 복호화 컨버터 테스트 코드

바로 코드부터 가보죠.

package com.example.demo.entity.converter;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class EncryptedConverterTest {

    @InjectMocks
    private EncryptedConverter encryptedConverter;

    @Mock
    private PrivacyEncryptor privacyEncryptor;

    @Nested
    @DisplayName("암호화")
    class EncryptTest {
        @Test
        @DisplayName("데이터베이스 저장할 때 민감한 데이터를 암호화합니다.")
        void shouldEncryptWhenSave() throws Exception {
            String privacyData = "사적이고 민감한 데이터입니다.";
            String expected = "dNd1dns21nAZpa2nxmaA590";

            when(privacyEncryptor.encrypt(privacyData)).thenReturn(expected);

            String actual = encryptedConverter.convertToDatabaseColumn(privacyData);

            assertEquals(expected, actual);
        }
    }

    @Nested
    @DisplayName("복호화")
    class DecryptTest {
        @Test
        @DisplayName("데이터베이스에서 불러올 때 암호화된 민감한 데이터를 복호화합니다.")
        void shouldDecryptWhenLoad() throws Exception {
            String encrypted = "dNd1dns21nAZpa2nxmaA590";
            String expected = "사적이고 민감한 데이터입니다.";

            when(privacyEncryptor.decrypt(encrypted)).thenReturn(expected);

            String actual = encryptedConverter.convertToEntityAttribute(encrypted);

            assertEquals(expected, actual);
        }
    }
}

 

카드 마스킹 컨버터 테스트 코드와 다른 점이 바로 보이시나요?

 

첫 번째로 @Mock 어노테이션이 붙은 객체입니다.

PrivacyEncryptor 을 목 객체로 생성합니다.

 

@InjectMocks 어노테이션이 붙인 EncryptedConverter 객체를 생성합니다.

EncryptedConverter 객체는 생성자로 PrivacyEncryptor 객체가 필요합니다.

이 때, PrivacyEncryptor 은 목 객체로 선언되어 있어 생성자로 주입됩니다.

 

Mockito 프레임워크에서 Mock 객체를 생성하고 관리합니다.

그리고 객체를 주입할 때 Mock 객체를 대신 주입해줍니다.

 

두 번째로 Mockito.when() 메소드입니다.

PrivacyEncryptor 목 객체의 메소드의 반환값을 정의할 수 있습니다.

따라서 직접 PrivacyEncryptor 인터페이스를 구현하는 객체를 만들지 않아도 됩니다.

테스트를 위한 코드를 작성하는 걸 방지합니다.

 

실제 데이터베이스 암호화 테스트

앞서 컨버터를 만들고 테스트 코드까지 작성해 봤는데요~

이제 본격적으로 기존 엔티티를 수정해봅시다.

PlainCard 엔티티를 보안 요구사항이 들어간 SecureCard 로 바꿔봅니다.

 

package com.example.demo.entity;

import com.example.demo.entity.converter.EncryptedConverter;
import com.example.demo.entity.converter.CardMaskingConverter;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class SecureCard {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Convert(converter = EncryptedConverter.class)
    private String holderName;

    @Convert(converter = CardMaskingConverter.class)
    private String cardNo;
}

 

기존 PlainCard 와 다른점이 보일까요?

바로 @Convert 어노테이션이 들어갔습니다.

holderName 필드는 암호화/복호화 컨버터를 설정했습니다.

cardNo 필드는 마스킹 컨버터를 설정했습니다.

 

데이터베이스에 암호화가 정말로 들어갔는지 궁금해졌습니다.

테스트 코드를 만들러 가볼까요?

예제 환경에선 데이터베이스는 H2 임베디드 데이터베이스를 이용했습니다. 

 

시도 1: JpaRepository 으로 확인하기

기존 코드는 JpaRepository 를 상속하는 CardRepository 인터페이스를 구현했는데요~

예제 환경을 위해 다른 메소드는 제외했습니다.

package com.example.demo.entity;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CardRepository extends JpaRepository<SecureCard, Long> {
}

 

JpaRepository 로 테스트 코드를 아래와 같이 만들어봤습니다.

테스트 하고자 하는 케이스는 다음과 같아요.

  • 암호화 값 확인 : 데이터베이스에 실제로 암호화 값이 들어가는지 확인
  • 마스킹 컨버터 확인 : 마스킹이 실제로 되는지 확인

암호화 테스트는 실제 값이 궁금한 거고,

마스킹 테스트는 컨버터 동작이 궁금합니다.

 

package com.example.demo.entity;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.mockito.Mockito.when;

import com.example.demo.entity.converter.PrivacyEncryptor;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SecureCardEntityManagerTest {
    @Autowired
    private TestEntityManager entityManager;

    @MockBean
    private PrivacyEncryptor privacyEncryptor;

    @Test
    @DisplayName("민감한 데이터는 암호화된다.")
    void shouldEncryptPrivacyData() throws Exception {
        // GIVEN
        String privacyHolderName = "다람쥐";
        String privacyCardNo = "1234-5678-1234-5678";

        String expectedHolderName = "nAdnq1Ns0dxn02Snasx2nx";

        when(privacyEncryptor.encrypt(privacyHolderName)).thenReturn(expectedHolderName);

        // WHEN
        SecureCard secureCard = new SecureCard(null, privacyHolderName, privacyCardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        EntityManager em = entityManager.getEntityManager();

        TypedQuery<SecureCard> query = em.createQuery(
                "SELECT id, cardNo, holderName From SecureCard WHERE id = :id",
                SecureCard.class);
        query.setParameter("id", secureCard.getId());
        SecureCard actualCard = query.getSingleResult();

        // THEN
        assertAll(() -> {
            assertThat(actualCard).isNotNull();
            assertThat(actualCard.getHolderName()).isEqualTo(expectedHolderName); // Failed. actual : 다람쥐
        });
    }

    @Test
    @DisplayName("민감한 데이터는 마스킹된다.")
    void shouldMaskPrivacyData() throws Exception {
        // GIVEN
        String holderName = "다람쥐";
        String cardNo = "1234-5678-1234-5678";

        String expected = "1234-5678-****-****";

        // WHEN
        SecureCard secureCard = new SecureCard(null, holderName, cardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        SecureCard actualCard = entityManager.find(SecureCard.class, secureCard.getId());

        // THEN
        assertSoftly(it -> {
            it.assertThat(actualCard.getCardNo()).isEqualTo(expected);
        });
    }
}

 

기쁜 마음으로 테스트 코드를 실행했습니다!

다만 두 테스트 케이스가 실패하는데요.

두 테스트 케이스에서 발생한 쿼리는 아래와 같습니다.

Hibernate: select next value for secure_card_seq
Hibernate: insert into secure_card (card_no,holder_name,id) values (?,?,?)

 

 

findById 에서 select 쿼리를 발생시키지 않는 걸 확인할 수 있습니다.

영속성 컨텍스트에 캐싱된 객체를 불러옵니다.

따라서 데이터베이스에 실제로 값을 불러오는지,

컨버터가 실제로 동작하는지 테스트를 할 수 없습니다.

 

참고로 saveAndFlush 대신 save 메소드를 사용했다면

테스트 케이스 안에서 insert 쿼리도 발생하지 않습니다.

 

시도 2: 엔티티 매니저로 쿼리 전송하기

 

혹시 쿼리로 직접 전송하고 결과를 받으면

데이터베이스 칼럼 값을 그대로 쓸 수 있지 않을까 싶은데요~

@DataJpaTest 에서 엔티티 매니저도 주입 받을 수 있게 빈을 생성해줍니다.

 

@DataJpaTest 문서를 살펴볼까요?

다음과 같은 역할을 해줘요.

  • JPA 컴포넌트 테스트에만 초점을 맞춥니다.
  • 모든 자동 설정을 끄고 JPA 테스트와 관련된 설정만 불러옵니다.
  • 임베디드 인메모리 데이터베이스를 지원합니다. (@AutoConfigureTestDatabase 로 자세한 설정 가능)
    • 모든 애플리케이션 설정을 원한다면 @SpringBootTest 를 사용할 것
  • 각 테스트 끝마다 트랜잭션 / 롤백을 지원합니다.
  • 앞서 살펴봤던 것 처럼, SQL 쿼리를 로그에 남깁니다. (spring.jpa.show-sql 속성 true, 'showSql' 속성으로 제어 가능)

 

package com.example.demo.entity;

import static org.assertj.core.api.SoftAssertions.assertSoftly;
import static org.mockito.Mockito.when;

import com.example.demo.entity.converter.PrivacyEncryptor;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SecureCardEntityManagerTest {
    @Autowired
    private TestEntityManager entityManager;

    @MockBean
    private PrivacyEncryptor privacyEncryptor;

    @Test
    @DisplayName("민감한 데이터는 암호화된다.")
    void shouldEncryptPrivacyData() throws Exception {
        // GIVEN
        String privacyHolderName = "다람쥐";
        String privacyCardNo = "1234-5678-1234-5678";

        String expectedHolderName = "nAdnq1Ns0dxn02Snasx2nx";

        when(privacyEncryptor.encrypt(privacyHolderName)).thenReturn(expectedHolderName);
        when(privacyEncryptor.decrypt(expectedHolderName)).thenReturn(privacyHolderName);

        // WHEN
        SecureCard secureCard = new SecureCard(null, privacyHolderName, privacyCardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        EntityManager em = entityManager.getEntityManager();

        TypedQuery<SecureCard> query = em.createQuery(
                "SELECT id, holderName, cardNo From SecureCard WHERE id = :id",
                SecureCard.class);
        query.setParameter("id", secureCard.getId());
        SecureCard actualCard = query.getSingleResult();

        // THEN
        assertSoftly(it -> {
            it.assertThat(actualCard).isNotNull();
            it.assertThat(actualCard.getHolderName()).isEqualTo(expectedHolderName); // Failed. actual : 다람쥐
        });
    }

    @Test
    @DisplayName("민감한 데이터는 마스킹된다.")
    void shouldMaskPrivacyData() throws Exception {
        // GIVEN
        String holderName = "다람쥐";
        String cardNo = "1234-5678-1234-5678";

        String expected = "1234-5678-****-****";

        // WHEN
        SecureCard secureCard = new SecureCard(null, holderName, cardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        SecureCard actualCard = entityManager.find(SecureCard.class, secureCard.getId());

        // THEN
        assertSoftly(it -> {
            it.assertThat(actualCard.getCardNo()).isEqualTo(expected);
        });
    }
}

 

엇, 초록 불이 들어왔습니다!

마스킹 테스트 케이스만요...!

마스킹 테스트 케이스 통과

 

마스킹 테스트 케이스의 쿼리 로그를 볼까요?

Hibernate: select next value for secure_card_seq
Hibernate: insert into secure_card (card_no,holder_name,id) values (?,?,?)
Hibernate: select sc1_0.id,sc1_0.card_no,sc1_0.holder_name from secure_card sc1_0 where sc1_0.id=?

 

JpaRepository 테스트와 다르게 select 쿼리를 보냅니다.

컨버터가 동작하여 마스킹 동작이 정상이라는 걸 확인할 수 있습니다.

 

암호화 테스트 케이스의 로그를 확인해 봅시다.

Hibernate: select next value for secure_card_seq
Hibernate: insert into secure_card (card_no,holder_name,id) values (?,?,?)
Hibernate: select sc1_0.id,sc1_0.holder_name,sc1_0.card_no from secure_card sc1_0 where sc1_0.id=?


Multiple Failures (1 failure)
-- failure 1 --
expected: "nAdnq1Ns0dxn02Snasx2nx"
 but was: "다람쥐"

 

데이터베이스에서 값을 불러올 때 복호화 컨버터가 동작한다는 걸 확인할 수 있습니다.

원하는 건 복호화 컨버터를 통하지 않고, 실제 값이 어떤지 확인해 보는 건데요..!

그럼 이제 데이터베이스에 실제로 값이 들어가는지 어떻게 확인할 수 있을까요?

 

시도 3: 엔티티 매니저의 네이티브 쿼리로 변경

엔티티 매니저의 CreateQuery 메소드는 JPQL을 사용합니다.

JPQL 으로 변환되면 컨버터 동작이 이뤄집니다.

JPQL 쿼리가 아닌 '네이티브 쿼리'로 바꿔 컨버터 동작을 멈춥니다.

 

package com.example.demo.entity;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.mockito.Mockito.when;

import com.example.demo.config.DataJpaConfig;
import com.example.demo.entity.converter.PrivacyEncryptor;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ContextConfiguration;

@ContextConfiguration(classes = {DataJpaConfig.class})
@DataJpaTest
class SecureCardTest {
    @Autowired
    private TestEntityManager entityManager;

    @MockBean
    private PrivacyEncryptor privacyEncryptor;

    @Test
    @DisplayName("민감한 데이터는 암호화된다.")
    void shouldEncryptPrivacyData() throws Exception {
        // GIVEN
        String privacyHolderName = "다람쥐";
        String privacyCardNo = "1234-5678-1234-5678";

        String expectedHolderName = "nAdnq1Ns0dxn02Snasx2nx";

        when(privacyEncryptor.encrypt(privacyHolderName)).thenReturn(expectedHolderName);

        // WHEN
        SecureCard secureCard = new SecureCard(null, privacyHolderName, privacyCardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        EntityManager em = entityManager.getEntityManager();

        // @Convert 피하기 위한 네이티브 쿼리
        Query query = em.createNativeQuery(
                "SELECT id, holder_name, card_no From secure_card WHERE id = :id",
                SecureCardProjection.class);
        query.setParameter("id", secureCard.getId());
        SecureCardProjection actualCard = (SecureCardProjection) query.getSingleResult();

        // THEN
        assertAll(() -> {
            assertThat(actualCard).isNotNull();
            assertThat(actualCard.holder_name).isEqualTo(expectedHolderName);
        });
    }

    @Test
    @DisplayName("민감한 데이터는 마스킹된다.")
    void shouldMaskPrivacyData() throws Exception {
        // GIVEN
        String holderName = "다람쥐";
        String cardNo = "1234-5678-1234-5678";

        String expected = "1234-5678-****-****";

        // WHEN
        SecureCard secureCard = new SecureCard(null, holderName, cardNo);

        entityManager.persist(secureCard);
        entityManager.flush();
        entityManager.clear();

        SecureCard actualCard = entityManager.find(SecureCard.class, secureCard.getId());

        // THEN
        assertSoftly(it -> {
            it.assertThat(actualCard.getCardNo()).isEqualTo(expected);
        });
    }
}
package com.example.demo.entity;

public class SecureCardProjection {
    public Long id;
    public String holder_name;
    public String card_no;

    public SecureCardProjection(Long id, String holder_name, String card_no) {
        this.id = id;
        this.holder_name = holder_name;
        this.card_no = card_no;
    }
}
package com.example.demo.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@ComponentScan(basePackages = "com.example.demo")
@Configuration
public class DataJpaConfig {
}

 

핵심은 엔티티 매니저의 'createNativeQuery' 를 사용했다는 점입니다.

Row 결과를 받을 Projection 객체도 생성했습니다.

IDE에서 브레이크 포인트를 걸고 디버그 모드로 실행하면, 아래처럼 암호화된 값을 받아옵니다.

( SecuredTest.java 에선 decrypt 컨버터 동작을 목킹하지 않았다는 걸 확인해주세요~ )

실제 데이터베이스 값 확인

 

DataJpaConfig 클래스는 @SpringApplication 이 위치한 베이스 패키지를 명시하기 위함입니다.

현재 예제 프로젝트에선 다른 테스트 클래스처럼 없어도 무방한데요~

만약 JPA 모듈이 Spring 애플리케이션 모듈과 분리가 되어 있을 수도 있습니다.

ApplicationContext 를 불러오지 못하면, JPA와 관련한 자동 설정이 이뤄지지 않게 됩니다.

 

테스트 패키지 내에 베이스 패키지를 명시한 설정 객체를 만들고,

이를 @DataJpaTest 와 @ContextConfiguration(classes = {DataJpaConfig.class}) 같이 붙여줍니다.

 

요구사항 확인

요구사항을 다음과 같이 만족했습니다.

 

1. cardNo : 데이터베이스 저장 시 원문 / 불러올 시 마스킹

엔티티 필드 : 1234-5678-1234-5678

 

데이터베이스 값 : 1234-5678-1234-5678

컨버터 적용한 엔티티 필드 : 1234-5678-****-****


2. holderName : 데이터베이스 저장 시 암호화 / 불러올 시 원문

엔티티 필드 : 다람쥐

 

데이터베이스 값 : nAdnq1Ns0dxn02Snasx2nx

컨버터 적용한 엔티티 필드 : 다람쥐

 

마무리

일련의 과정으로 PM님이 요구한 요구사항을 빠른 시간 안에 구현했어요!

테스트 서버에 배포까지 하는 시간도 벌었어요.

PM님께 인정도 받고 무서워 보이는(?) 시니어 개발자 분과도 한걸음 다가간 기분이라

만족스럽게 퇴근한 다람쥐 사원이였습니다. 😁

 

Attribute Converter 는 객체와 데이터베이스 칼럼 값 사이의 간극을 해소해줍니다.

책임을 할당하고 관심사를 분리할 수 있는 장점이 있습니다.

 

다만 컨버터가 꼭 필요한 상황인가는 유심히 고민해봐야 할 점이에요.

데이터베이스 칼럼 값과 일치해도 상관이 없다면, 객체 생성 단계부터 전처리를 해줄 수도 있고요!

'autoApply' 속성이 true 인 컨버터가 많다면, 그 코드 이력을 따라가는 것도 만만치 않고요.

( ??? : 어라? 왜 이렇게 데이터베이스에 들어가지? 왜 컨버터 적용 오류가 나지? [다중 컨버터 매핑 오류] )

 

보안과 관련한 요구사항 이거나,

데이터베이스 타입과 언어에서 사용하는 타입이 다르다거나 (Json 문자열 <-> Json 객체, Enum 처리, 커스텀 객체 변환),

데이터 품질을 일정 수준 유지해야 하는 경우에 유용할 것으로 보입니다.

 

독자분이 컨버터와 관련해서 유용하다고 생각하거나,

또는 불편하다고 생각하거나,

개선이 필요하다거나 등등

의견이 있으면 댓글로 알려주세요~