[Spring Boot] Converter를 이용한 레거시 코드 Enum 개선기

이동엽·2023년 9월 11일
4

spring

목록 보기
12/21


모든 코드는 예시를 각색하여 작성하고 있음을 알려드립니다.

💡 개요

필자는 Java 8 & Springboot 2.3.x로 작성된 사내 어드민 API Server를 Java 17 & Springboto 3.1.3으로 고도화 중이다.
레거시 코드에서는 여러 문제점들이 있었고, 최근 사례로는 아래와 같은 문제점을 발견했다.

  • Entity 내에 선언된 몇몇 필드들이 @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 내에서 선언된 순서로 저장이 된다.

idnamefruit
1leedongyeop2


문제점은 이 선언된 순서로 저장됨에서 시작된다.

만약 Fruit에 새로운 과일이 아래와 같이 추가가 된다면?

public enum Fruit {
	APPLE, TOMATO, BANANA, WATERMELON
}

이 상태로, Fruit.TOMATO를 필드값으로 갖는 엔티티를 저장하면 DB는 아래와 같다.

idnamefruit
1leedongyeop2
2tomato-man2

이전에 저장된, 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 객체는 아래와 같이 저장될 것이다.

idnamefruit
1leedongyeop2
2after-stringBANANA

바꾸기 전에는 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);
    }
}



참고 자료

profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

0개의 댓글