본문 바로가기
프로젝트/DailycluB

지역 정보를 Enum으로 할까 테이블로 할까? (@Converter)

by 넬준 2022. 9. 19.

 

프로그램 진행 장소에 대한 지역 정보를 어떤 식으로 저장할까 고민이 있었다.

별도의 테이블을 두어 foreign key로 연결

 처음에는 프로그램 테이블과 별도로 지역 테이블을 두어 프로그램 테이블엔 지역 테이블 foreign key를 저장하는 형식으로 구성했다.

 하지만 진행을 하려다 보니 여러 문제점이 예상됐다.

1) 프로그램 정보를 조회할 때마다 매번 지역 테이블을 Join하거나 따로 조회해야 한다.

2) 프로그램 테이블의 foreign key를 보고 어떤 지역인지 한 번에 알 수 없다.

3) 지역 테이블은 데이터가 변할 일이 거의 없을텐데 테이블로 둘 이유가 있을까?

 

그래서 별도의 테이블을 두기보다 프로그램 클래스 내부에 Enum으로 처리하기로 했다.

 

프로그램 클래스 안에 지역 Enum을 선언

public class Program extends Auditable {
	...
    @Enumerated(EnumType.STRING)
    private Location location;
    
    @Getter
    @AllArgsConstructor
    public enum Location {
        SEOUL("서울"),
        GYEONGGI("경기"),
        GANGWON("강원"),
        INCHEON("인천"),
        DAEJEON_CHUNGCHEONG("대전/충청"),
        DAEGU_GYEONGBUK("대구/경북"),
        BUSAN_ULSAN_GYEONGNAM("부산/울산/경남"),
        GWANGJU_JEOLLA("광주/전라"),
        JEJU("제주");

        private final String description;
    }
}

 

Enum 타입을 테이블에 저장할 때 EnumType을 ORDINAL로 하면, 개발 도중 Enum 데이터 순서가 바뀌게 되면 전까지 저장된 값들이 다 바뀌어야 하기 때문에 보통 EnumType을 STRING으로 주로 설정한다.

 

@Enumerated(EnumType.STRING)으로 설정하면 테이블에는 Enum의 name인 "GYEONGGI", "INCHEON",  "BUSAN_ULSAN_GYEONGNAME" 으로 저장된다. 하지만 이름이 길어질 경우 테이블에서 한 번에 알아보기 힘들 것 같았고, 지저분해 보일 것 같아서 "경기", "인천", "부산/울산/경남" 과 같이 저장하고 싶었다! 그리고 지역으로 필터링을 할 때에도 프론트로부터 "경기", "인천"과 같은 값으로 받기 때문에 중간에 변환과정을 거치지 않고 바로 where절에 넣기 위해서도 같은 형태로 저장하고 싶었다.

 

@Converter

이를 위해 @Converter를 사용했다. 엔티티의 데이터를 변환해서 DB에 저장하고 싶을 때 사용하는 기능이다.

 

Location Enum을 위한 LocationConverter 클래스를 만들어 @Converter에 컨버터 클래스를 지정해주면 된다. 작업을 진행하다보니 Location뿐만 아니라 다른 Enum 필드에서도 위와 같은 과정이 필요할 수가 있다는 생각이 들었다.

 

Program 엔티티에서도 ProgramStatus Enum이 있다.

    @Getter
    @AllArgsConstructor
    public enum ProgramStatus {
        POSSIBLE("모집중"),
        IMMINENT("마감임박"),
        IMPOSSIBLE("마감");

        private final String description;
    }

이 필드 역시 검색값으로 쓰이기 때문에 "모집중" "마감임박" "마감" 으로 직접 저장하고 싶었다.

따라서 LocationConverter 클래스도 만들어야 하고, ProgramStatusConverter 클래스도 따로 만들어야 했다.

두 클래스의 로직이 같기 때문에 이후 다른 Enum 필드에서도 사용할 수 있도록 추상화 작업이 필요하다고 생각했다.

추상화 작업은 같은 고민을 했던 우아한 형제들 기술 블로그 (https://techblog.woowahan.com/2600) 를 참고했다. 

 

추상화 작업

일단 여러 Enum들에 공통으로 필요한 메소드를 선언한 CommonEnum 인터페이스를 선언한다. 위 로직이 필요한 Enum은 해당 인터페이스를 implements한다.

 

CommenEnum

//공통 enum
public interface CommonEnum {
    //공통 메소드
    String getDescription();
}

 

Location

    @Getter //getDescription() 구현
    @AllArgsConstructor
    public enum Location implements CommonEnum { //CommonEnum 구현
        SEOUL("서울"),
	...
        private final String description;
    }

 

EnumValueConvertUtils

입력값 <-> Enum 타입 변환하는 static method를 util 클래스에 선언했다.

여러 Enum Type에서 사용해야 하기 때문에 제네릭을 사용했다.

 

public class EnumValueConvertUtils {

    //description에 해당하는 Enum 타입 반환하는 메소드
    public static <T extends Enum<T> & CommonEnum> T ofDescription(Class<T> enumClass,
                                                                   String description) {
        if (!StringUtils.hasText(description)) return null;

    	//지정한 Enum type을 돌면서 description과 값이 같은 enum 상수를 리턴
 	//해당하는 값 없을 시 BusinessLogicException 
        return EnumSet.allOf(enumClass).stream()
                .filter(value -> value.getDescription().equals(description))
                .findAny()
                .orElseThrow(() -> new BusinessLogicException(ExceptionCode.INVALID_INPUT_VALUE));
    }

    //Enum 타입에서 description으로 변환하는 메소드
    public static <T extends Enum<T> & CommonEnum> String toDescription(T enumValue) {
        if (enumValue == null) return "";
        return enumValue.getDescription();
    }
}

 

AbstractEnumAttributeConverter<E>

위 util을 사용해서 실제 입력값 <-> Enum 변환을 할 상위 클래스 AbstractEnumAttributeConverter를  AttributeConverter 인터페이스를 구현하면서 선언했다. 컨버터들은 이 상위 클래스를 상속받으면 된다.

 

@Slf4j
@Getter
public class AbstractEnumAttributeConverter<E extends Enum<E> & CommonEnum> implements AttributeConverter<E, String> {

    private Class<E> targetEnumClass;

    @Override
    public String convertToDatabaseColumn(E attribute) {
    	targetEnumClass = attribute.getDeclaringClass(); //targetEnumClass 초기화
        return EnumValueConvertUtils.toDescription(attribute);
    }

    @Override
    public E convertToEntityAttribute(String dbData) {
        return EnumValueConvertUtils.ofDescription(targetEnumClass, dbData);
    }
}

 

LocationConverter

실제 Enum 필드의 @Converter에 지정될 컨버터 클래스다. 위에서 언급했듯 AbstractEnumAttributeConverter를 상속받는다. 각 Enum type에 맞게 제네릭을 설정하면 된다. @Converter 어노테이션을 붙여 해당 클래스가 Converter 역할을 하는 클래스라고 선언해야 한다. 

 

@Converter
public class LocationConverter extends AbstractEnumAttributeConverter<Program.Location> {
}

 

ProgramStatusConverter

@Converter
public class ProgramStatusConverter extends AbstractEnumAttributeConverter<Program.ProgramStatus> {
}

 

Program

Program 엔티티에 이제 변환을 원하는 Enum 필드에 작성한 Converter 클래스를 지정해주면 된다.

@Enumerated(EnumType.STRING)
@Convert(converter = LocationConverter.class) //컨버터 지정
private Location location;

@Enumerated(EnumType.STRING)
@Builder.Default //@Builder 사용 시 초기값 설정
@Convert(converter = ProgramStatusConverter.class) //컨버터 지정
private ProgramStatus programStatus = ProgramStatus.POSSIBLE;

 

이슈 핸들링

1. 원하는 대로 저장되지 않는 이슈

converter를 위처럼 잘 적용했는데 계속해서 ENUM의 name으로 그대로 저장되는 것이다! 

 

아..아니?

원인은 @Enumerated 때문이었다!! Enum형에 붙이는 @Enumerated도 @Converter와 같은 역할을 하는 어노테이션이다. 따라서 @Converter를 사용할 땐 @Enumerated를 지워야 한다.

 

Program

@Convert(converter = LocationConverter.class) //컨버터 지정
private Location location;

@Builder.Default //@Builder 사용 시 초기값 설정
@Convert(converter = ProgramStatusConverter.class) //컨버터 지정
private ProgramStatus programStatus = ProgramStatus.POSSIBLE;

 

 

2. AbstractEnumAttributeConverter의 targetEnumClass 필드 초기화 이슈

로컬에서 테스트할 때, 항상 DB가 초기화가 됐기 때문에 데이터를 먼저 DB에 저장하고, DB에서 값을 꺼낼 때는 targetEnumClass 필드가 초기화가 되어있었지만, 저장되어 있는 값을 그냥 꺼낼 때는 초기화가 이뤄지지 않는 이슈가 있었다. 따라서 AbstractEnumAttributeConverter를 상속받는 클래스를 생성할 때 생성자에서 초기화할 수 있도록 작업했다.

 

AbstractEnumAttributeConverter

@Slf4j
@Getter
@AllArgsConstructor //targetEnumClass 요소로 하는 생성자 선언
public class AbstractEnumAttributeConverter<E extends Enum<E> & CommonEnum> implements AttributeConverter<E, String> {

    private Class<E> targetEnumClass;

    @Override
    public String convertToDatabaseColumn(E attribute) {
        //targetEnumClass = attribute.getDeclaringClass(); 삭제
        return EnumValueConvertUtils.toDescription(attribute);
    }

    @Override
    public E convertToEntityAttribute(String dbData) {
        return EnumValueConvertUtils.ofDescription(targetEnumClass, dbData);
    }
}

 

LocationConverter

@Converter
public class LocationConverter extends AbstractEnumAttributeConverter<Program.Location> {
    public LocationConverter() {
        super(Program.Location.class); //targetEnumClass 초기화
    }
}

 


참고

 

https://techblog.woowahan.com/2600

https://sas-study.tistory.com/416 

https://studyandwrite.tistory.com/496

https://jojoldu.tistory.com/137

댓글