모든 코드는 예시를 각색하여 작성하고 있음을 알려드립니다.
필자는 Java 8 & Springboot 2.3.x로 작성된 사내 어드민 API Server를 Java 17 & Springboto 3.1.3으로 고도화 중이다.
레거시 코드에서는 여러 문제점들이 있었고, 최근 사례로는 아래와 같은 문제점을 발견했다.
@Enumerated(EnumType.ORDINAL)
으로 선언되어 있다.문제 파악 :
ORDINAL
속성은 무엇을 의미할까?
EnumType 옵션 종류로는 두 가지가 존재한다.
1. EnumType.ORDINAL
: enum 순서 값을 DB에 저장
2. EnumType.STRING
: enum 이름을 DB에 저장
아래와 같은 Enum 클래스가 존재하고, 레거시 코드처럼 ORDINAL
옵션으로 선언되어있을 경우
public enum Fruit {
APPLE, BANANA, WATERMELON
}
생성된 엔티티가 Fruit.BANANA를 필드값으로 갖는다고 가정하면,
Person person = new Person("leedongyeop", Fruit.BANANA);
데이터베이스에는 아래와 같이 Enum 내에서 선언된 순서로 저장이 된다.
id | name | fruit |
---|---|---|
1 | leedongyeop | 2 |
문제점은 이 선언된 순서로 저장됨에서 시작된다.
만약 Fruit에 새로운 과일이 아래와 같이 추가가 된다면?
public enum Fruit {
APPLE, TOMATO, BANANA, WATERMELON
}
이 상태로, Fruit.TOMATO를 필드값으로 갖는 엔티티를 저장하면 DB는 아래와 같다.
id | name | fruit |
---|---|---|
1 | leedongyeop | 2 |
2 | tomato-man | 2 |
이전에 저장된, leedongyeop의 fruit는 저장될 시점에 2번째 순서였으므로 2로 여전히 저장이 되어 있고,
tomato-man의 fruit 역시 같은 숫자인 2로 저장되어 있다. 실제로는 다른 과일 값을 갖는데도 말이다.
이러한 이유로 실제로 JPA Docs를 보면 ORDINAL 옵션 사용을 지양하고 있다.
아무튼 현재 레거시 코드에는 처음 예시로 들었던 Enum 형태로 존재하며, DB에는 각각 1, 2, 3으로 저장되고 있다.
public enum Fruit {
APPLE, BANANA, WATERMELON
// APPLE -> 1, BANANA -> 2, WATERMELON -> 3
}
따라서 Person 객체를 생성할 때 값은 APPLE이지만, DB에는 1로 저장이 된다는 소리다.
즉, 객체일 때와 DB의 테이블일 때의 값이 다른 상황이다. 이를 어떻게 매핑하면 좋을까?
그럼 레거시 코드에 있는 Enum을
@Enumerated(EnumType.STRING)
으로 바꾸면 해결되는거 아닐까?
개발에 정답은 없다지만 이 부분은 아니다. 라고 할 수 있을 것 같다.
무턱대고 바꾼다면, 바꾼 직후부터 저장된 Person 객체는 아래와 같이 저장될 것이다.
id | name | fruit |
---|---|---|
1 | leedongyeop | 2 |
2 | after-string | BANANA |
바꾸기 전에는 ORDINAL 속성으로 인한 순서값이 저장되고,
바뀐 후에는 STRING 옵션으로 인한 문자열 값이 저장되고 있다.
그럼 조회를 어떻게 할 것인가?
"해당 분기를 기준으로 테이블로부터 값을 가져오는 방법을 달리 프로그램을 작성한다.." 등의 수고스러운 코드를 작성하는 바보는 없길 바란다.
코드에서
@Enumerated(EnumType.STRING)
으로 바꾸고, DB에서 모두 직접 문자열 값으로 바꿔주면 되잖아요!
실무에서는 수많은 데이터가 존재한다.
Person 객체가 데이터베이스에 100만건이 존재해서 직접 바꿔주기에 무리가 있다면?
또한 직접 값을 바꾸는 도중에 사용자로부터 데이터에 접근(수정, 삭제 등)이 일어나 일관성이 깨진다면?
돌아올 수 없는 먼 길을 떠나야 한다.
물론 이를 방지하기 위해 데이터베이스도 다중화를 시켜놓고.. 등등의 작업으로 할 수 있다지만,
고작 Enum 값 하나 때문에 이 수고를 한다면? 그것만큼 바보는 없을 것이다.
JPA 2.1 release에서는 엔터티 속성을 데이터베이스 값으로 또는 그 반대로 변환하는 데에
사용할 수 있는 새로운 표준화된 API인 @Converter
를 도입했다.
사용 방법은 아래와 같다.
기존에 만들었던 Enum 클래스에 DB에 저장되는 값을 속성으로 추가한다.
public enum Fruit {
APPLE("0"), BANANA("1"), WATERMELON("2");
private String code;
private Fruit(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}
이후에는 Person 엔티티 내에 작성했던 @Enumerated
대신에 @Convert
를 사용한다.
아래 코드는 Person의 Fruit 필드에 대해 FruitConverter
를 적용한다는 의미이다.
@Entity
@Getter
// 등등
public class Person {
...
//@Enumerated(EnumType.ORDINAL)
@Convert(converter = FruitConverter.class)
Fruit fruit;
}
이제 컨버터를 작성하면 마무리가 된다.
@Converter
애너테이션은 생략 가능하지만, 명시적으로 나타내기 위해 작성했다.
//@Converter(autoApply = true)
@Converter
public class FruitConverter implements AttributeConverter<Fruit, String> {
/* Entity를 DB에 저장할 때 발생하는 메서드 */
@Override
public String convertToDatabaseColumn(final Fruit fruit) {
if (fruit == null) {
return null;
}
return fruit.getCode();
}
/* DB에서 조회된 값으로 Entity를 생성할 때 발생하는 메서드 */
@Override
public Fruit convertToEntityAttribute(final String code) {
if (code == null) {
return null;
}
return Stream.of(Fruit.values())
.filter(f -> f.getCode().equals(code))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
}
}
주의할 점
@Converter(autoApply = true)
자동 적용 옵션- 이 옵션은 자동 적용을 활성화하여, DB로 접근이 일어날 때마다 반드시 동작되도록 한다.
- 또한 엔티티에
@Convert(converter = FruitConverter.class)
와 같이 명시적 선언을 생략하도로 한다.
- 다만 이는 다른 동료 개발자가 컨버터가 존재하긴 하는지, 왜 동작하는지 등을 파악하는 데에 어려움을 줄 수 있어서 비추천한다.
사실 이 외에도 @Enumerated(EnumType.ORDINAL)
으로 선언된 필드들이 꽤 존재한다.
그 때마다 매번 Converter를 만들어주기에도 부담이 있으니, 아래와 같이 제네릭을 사용해 다형성을 유지해보자.
@Converter
public class GenericConverter<T, C> implements AttributeConverter<T, C> {
/* Entity를 DB에 저장할 때 발생하는 메서드 */
@Override
public C convertToDatabaseColumn(final T t) {
if (t == null) {
return null;
}
return t.getCode();
}
/* DB에서 조회된 값으로 Entity를 생성할 때 발생하는 메서드 */
@Override
public T convertToEntityAttribute(final C code) {
if (code == null) {
return null;
}
return Stream.of(T.values())
.filter(t -> t.getCode().equals(code))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
}
}
참고 자료