🔖 개요
DB에 어떤 값을 저장할 때 전/후처리가 필요한 요구사항이 있을 수 있다. 예를 들어, 사용자의 개인 정보를 암호화해서 저장하는 경우 혹은 데이터베이스에 저장된 값을 불러올 때 Static 한 인스턴스로 변환해야 하는 경우이다.
예시로 작성한 다음의 Entity를 보자.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/*
* 1. 데이터 베이스에 저장될 때 암호화
* 2. 데이터 베이스에서 값을 읽어올 때 복호화
* */
private String phone;
/*
* 1. Gender 가 여성이면 'w', 남성이면 'm'를 DB에 저장
* 2. DB 에서 값을 읽어올 때 저장된 값이 'w'면 Gender.WOMAN, 'm'면 Gender.MAN 로 변환
* */
@Enumerated(value = EnumType.STRING)
private Gender gender;
@Builder
public Member(String phone, Gender gender) {
this.phone = phone;
this.gender = gender;
}
}
// Gender
public enum Gender {
WOMAN,
MAN;
}
- Member는 사용자의 정보를 저장하는 Table이다.
- 사용자의 기본 정보로 핸드폰 번호와 성별을 가지고 있다.
- 핸드폰 번호 (phone)은 사용자의 개인 정보이기 때문에 DB에 값을 저장할 때 암호화해서 저장하고 값을 불러오고 나서 사용할 땐 복호화 해서 사용한다.
- 성별 (gender)은 DB에 'w', 'm'으로 저장하고 Entity로 사용할 땐 각각 Enum 객체인 Gender.WOMAN, Gender.MAN 으로 사용한다.
위와 같은 요구사항을 만족하기 위해 이전까지는 Dao 혹은 Service 레벨에서 값 변환 코드를 직접 넣어주었다. phone필드의 암호화를 예시로 설명하겠다.
@Transactional
public Long create(MemberCreateRequest createRequestDto) {
String phoneRequest = createRequestDto.getPhone();
// Entity화 이전에 암호화 직접 수행
String phone = "~~ 암호화 모듈로 암호화 수행";
// 암호화된 phone 값을 Entity에 바인딩
Member member = Member.builder()
.phone(phone)
.build();
return memberRepository.save(member).getId();
}
이렇게 구현할 경우 여러 문제점이 발생할 수 있다.
- Member Entity를 생성하기 전에 매번 암호화 코드를 넣어주어야 한다.
- phone 필드가 언제 암호화되는지 명확하지 않다.
- DB에서 Entity를 조회하고 phone 값을 사용하기 위해 매번 복호화 코드를 넣어주어야 한다.
- 개발자의 실수로 암호화 코드를 누락하여 plain text가 저장될 수 있다.
Dto 단에서 암호화를 진행하여도 같은 문제가 발생한다. 또한, 일반적으로 암호화 모듈은 Bean에 등록하여 하나의 Component로 사용하기 때문에 Service단의 의존성이 늘어난다는 문제점도 있다. 예시로 들지 않았던 gender 필드의 경우도 마찬가지이다.
JPA에서 제공하는 AttributeConverter를 사용하면 이러한 문제를 해결할 수 있다. AttributeConverter를 사용하면 DB에 '요청을 날리기 전' / 'DB에 저장된 값을 불러올 때' 자동으로 값을 변환해서 넣어준다.
🔸 AttributeConverter
앞서 언급한 Attribute Converting 기능을 사용하기 위해선 AttributeConverter 인터페이스에서 구현하면 된다.
AttributeConverter 의 명세는 다음과 같다.
package jakarta.persistence;
public interface AttributeConverter<X,Y> {
public Y convertToDatabaseColumn (X attribute);
public X convertToEntityAttribute (Y dbData);
}
명세에 있는 첫 번째 제네릭 X는 Entity의 Field 자료형, 두 번째 제네릭 Y는 Database Column 자료형을 나타낸다.
AttributeConverter의 두 가지 메서드는 각각 다음을 나타낸다.
- Y convertToDatabaseColumn (X attribute) : DB에 요청을 전송할 때 수행
- X convertToEntityAttribute (Y dbData) : DB에서 불러온 값을 Entity 필드에 바인딩할 때 수행
각 메서드가 수행하는 역할에 맞게 원하는 Converting Action을 구현해 주면 된다. 예시를 통해 알아보자.
🔹 Converter 생성
다음은 phone 필드를 암/복호화해주기 위한 Converter 클래스이다.
@RequiredArgsConstructor
@Component
public class CryptoConverter implements AttributeConverter<String, String> {
private final TwoWayEncryption twoWayEncryption;
/*
* 데이터베이스에 요청을 보낼 때 (암호화)
* */
@Override
public String convertToDatabaseColumn(String attribute) {
if (attribute == null) {
return null;
}
return twoWayEncryption.encrypt(attribute);
}
/*
* 데이터베이스에서 값을 읽어올 때 (복호화)
* */
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
return twoWayEncryption.decrypt(dbData);
}
}
본 클래스는 AttributeConverter을 구현하였으며, phone의 Entity field 타입은 String, 해당 필드와 매핑된 Database Column은 varchar 이기 때문에 제네릭으로 String을 넣어주었다. → AttributeConverter<String, String>
Converter는 Spring Bean 으로 등록해주지 않아도 정상 작동한다. 필자는 Bean으로 등록한 암호화 모듈의 의존성을 주입받기 위해 Bean으로 등록해 주었다.
데이터의 암/복호화를 위해 TwoWayEncryption이라는 명세를 작성하고, 양방향 알고리즘을 사용하여 암호화 모듈을 구현하였다. 암호화는 본 포스팅 내용과 무관하기 때문에 자세한 내용은 생략하겠다.
convertToDatabaseColumn 메서드는 데이터베이스에요청을 보낼 때 실행된다. (요청을 보낼 때라 함은, Converter가 적용된 필드가 포함된 query문을 생성하기 전에 실행된다. 실제로 어떻게 적용되는지에 대해서는 `실행 결과`에서 확인하면 되겠다.)
String attribute 파라미터에서 Entity 필드의 값을 받아오고, 암호화 모듈을 통해 암호화를 진행한 뒤 반환해준다. 이렇게 반환해 준 값이 실제 DB에 저장된다.
convertToEntityAttribute 메서드 또한, 데이터베이스에서 불러온 값을 Entity에 바인딩해줄 때 동작하며 해당 메서드에서 반환된 값이 Converter를 적용한 Entity 필드에 바인딩된다.
📌 Converter 적용하기
이렇게 구현한 Converter을 어떤 필드에 적용할지 다음과 같이, @Convert 어노테이션을 통해 지정해 주면 된다.
@Entity
public class Member {
...
@Convert(converter = CryptoConverter.class)
private String phone;
...
}
🔹 Gender Converter 생성
이번에는 앞서 만들었던 Converter와 다른 로직을 수행하는 Converter을 구현해보자.
앞서 Gender의 요구 사항대로 데이터 베이스에는 'w', 'm'으로 저장하고 Entity에선 각각 Enum 객체인 Gender.WOMAN, Gender.MAN 으로 사용하기 위한 Converter다.
// Gender
@Getter
public enum Gender {
WOMAN("w"),
MAN("m");
private final String value;
Gender(String value) {
this.value = value;
}
public static Gender getGender(final String value) {
return Arrays.stream(Gender.values())
.filter(gender -> gender.getValue().equals(value))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
}
}
// Converter
public class GenderConverter implements AttributeConverter<Gender, String> {
/*
* 데이터베이스에 요청을 보낼 때 Gender.MAN -> "m"
* */
@Override
public String convertToDatabaseColumn(Gender attribute) {
if (attribute == null) {
return null;
}
return attribute.getValue();
}
/*
* 데이터베이스에서 값을 읽어올 때 "m" -> Gender.MAN
* */
@Override
public Gender convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
return Gender.getGender(dbData);
}
}
첫 번째 메서드를 통해, 데이터베이스에 요청을 보낼 때 Gender에 정의된 value 값으로 변환되어 쿼리문에 값이 바인딩된다. (Gender.MAN -> "m")
두 번째 메서드는 반대의 작용을 한다.
Converter을 적용해 주기 위해 마찬가지로 필드에 @Convert를 붙여주었다.
@Entity
public class Member {
...
@Convert(converter = GenderConverter.class)
private Gender gender;
...
}
🚩 실행 결과
Converter을 구현하고 적용까지 해보았으니, 위와 같은 Converter을 사용하면 실제 쿼리 파라미터에 어떤 값이 바인딩되는지 확인해 보자.
https://github.com/gavlyukovskiy/spring-boot-data-source-decorator
다음 라이브러리를 사용하여 실제 쿼리 파라미터에 어떤 값이 찍히는지 확인할 수 있다.
Spring boot의 경우 다음의 의존성만 추가하면 된다.
dependencies {
...
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
}
🔵 저장할 때
@Transactional
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Rollback(value = false)
@Test
void findByPhone() {
Member member = Member.builder()
.phone("010-1234-5678")
.gender(Gender.MAN)
.build();
memberRepository.save(member);
}
}
converter가 적용된 phone과 gender 필드에 값을 넣어주고 저장하는 간단한 테스트 코드이다. 위 코드의 실행 결과를 확인해 보면 다음과 같다.
우리의 의도대로 데이터베이스에 요청을 날리기 전에 converter가 적용되어, 원하는 값으로 query를 날리는 모습을 확인할 수 있다.
실제 저장된 값도 원하는 값으로 잘 저장되었다.
🟡 조회할 때
converter가 적용된 필드 값으로 조회를 시도하면 어떻게 될까? 마찬가지로 테스트 코드를 통해 확인해 보자.
@Transactional
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
void findByGender() {
// given
final String memberPhone = "010-1234-5678";
Member member = Member.builder()
.phone(memberPhone)
.gender(Gender.MAN)
.build();
memberRepository.save(member);
// when
Member findMember = memberRepository.findByPhone(memberPhone).get();
// then
assertThat(member.getPhone()).isEqualTo(memberPhone);
System.out.println("find member phone = " + findMember.getPhone());
}
}
Member Entity를 생성하여 저장하고, 암호화되지 않은 값으로 조건문을 걸어 조회를 시도했다.
앞선 예시를 통해 memberRepository.save() 이후, Database상에서는 phone 값이 암호화되어 저장되었음을 판단할 수 있다.
발생한 쿼리는 다음과 같았다. 조건문에 삽입한 값은 암호화되지 않은 값이지만, Converter가 적용된 필드를 통해 조회를 시도한 것이기 때문에 Converter가 적용되어 암호화된 값을 where절 파라미터로 삽입했다는 것을 알 수 있다.
🧷 <Appendix> Converter를 적용하는 다른 방법
1. Class Level
다음과 같이 Entity의 클래스 레벨에서 Converter을 적용할 수 있다. 이 경우, attributeName 과 Entity의 필드명이 같아야 한다.
@Convert(converter = CryptoConverter.class, attributeName = "phone")
@Entity
public class Member {
...
private String phone;
...
}
2. Global
Entity에 convert 어노테이션을 붙이지 않고 Converter가 적용될 필드를 전역적으로 설정할 수도 있다.
Converter 클래스 위에 @Converter(autoApply = true) 를 붙여주면 된다.
하지만 이 경우, Entity 필드가 String 이고 Database column이 varchar인 모든 필드에 적용되므로 원치 않은 결과를 불러올 수 있다.
적용되는 필드에 대한 Type이 명확할 때, (Entity 필드 타입이 Enum이거나 Class일 때) 고려해보자.
@Converter(autoApply = true)
public class CryptoConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
...
}
@Override
public String convertToEntityAttribute(String dbData) {
...
}
}
결론
결론적으로, JPA Attribute Converter을 적용하면 데이터베이스에 값이 변환되는 것을 특별히 신경쓰지 않고 사용할 수 있다. 기존에 Service Level에 혼재되어 있던 암호화 모듈 로직이 있던 것을 생각하면 비즈니스 코드 길이와 관리 포인트가 줄어들기 때문에 여러모로 장점이 많은 기술이라고 볼 수 있다.
다만, Converter는 DB에 저장되는 값을 변환해 주는 기능이기 때문에, 나중에 다른 팀원이 Entity 명세만 보고도 바로 알 수 있도록 필드에 @Convert 어노테이션을 붙여주어, 어떤 필드가 어떤 로직을 통해 값이 변환되는지 명확하게 표현해 주는 것이 좋겠다.