동적처리 Enum 만들기
최근 프로젝트를 진행하면서, 초기 구축때부터 메타데이터 처리를 동적으로 해야할 것을.. 잊고있다가 뒤늦게 적용해야 할 일이 생겼습니다. 이미 모든 메타데이터가 다음처럼 소스코드에 Enum 으로 하드코딩 되어있는데 말이죠..
switch(foo){
case FOO.001
// do...
case FOO.002
// do...
}
선택지가 두개 생겼습니다. 모든 코드를 동적메타데이터 정보를 가지고 있는 데이터베이스와 Join 하는 구조로 바꿀것이냐? 기존코드처럼 Enum 형식을 유지하면서, 동적처리가 가능한 구조로 만들것이냐..?
이미 프로젝트가 상당히 진척 된 상태이기 때문에 후자로 처리하기로 했습니다.
동적 Enum Class
그런데 사실 Enum 은 컴파일 타임에 수행되는 기능이므로, Enum 을 직접 동적으로 처리하도록 할 수 는 없습니다.
해결책은 Enum과 비슷한 또 다른 클래스를 작성하는 것입니다. 그래서 Enum의 기능을 모방하지만 일반 클래스이므로 상속받을 수 있는 DynaEnum 를 만들어 줍니다. DynaEnum 클래스에서는 values, valueOf
등의 함수를 지원할 수 있게 하고, 자주 쓰는 equals, toString
등의 함수가 오동작을 하지 않도록 꼼꼼하게 작성하여 줍니다.
@NoArgsConstructor
@Getter
@Setter
public class DynaEnum<E extends DynaEnum<E>> {
private static Map<Class<? extends DynaEnum<?>>, Map<String, DynaEnum<?>>> elements =
new LinkedHashMap<Class<? extends DynaEnum<?>>, Map<String, DynaEnum<?>>>();
private String name;
public final String name() {
return name;
}
private int ordinal;
public final int ordinal() {
return ordinal;
}
public final String getCode() {
return name;
}
protected DynaEnum(String name, int ordinal) {
this.name = name;
this.ordinal = ordinal;
Map<String, DynaEnum<?>> typeElements = elements.get(getClass());
if (typeElements == null) {
typeElements = new LinkedHashMap<String, DynaEnum<?>>();
elements.put(getDynaEnumClass(), typeElements);
}
typeElements.put(name, this);
}
@SuppressWarnings("unchecked")
private Class<? extends DynaEnum<?>> getDynaEnumClass() {
return (Class<? extends DynaEnum<?>>) getClass();
}
@Override
public String toString() {
return name;
}
@Override
public final boolean equals(Object other) {
if (other instanceof DynaEnum) {
return this.name.equals(((DynaEnum) other).name);
}
return false;
}
@Override
public final int hashCode() {
return super.hashCode();
}
@Override
protected final Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
public final int compareTo(E other) {
DynaEnum<?> self = this;
if (self.getClass() != other.getClass() &&
self.declaringClass() != other.declaringClass()) {
throw new ClassCastException();
}
return self.ordinal - ((DynaEnum<?>) other).ordinal;
}
@SuppressWarnings("unchecked")
public final Class<E> declaringClass() {
Class clazz = getClass();
Class zuper = clazz.getSuperclass();
return (zuper == DynaEnum.class) ? clazz : zuper;
}
@SuppressWarnings("unchecked")
public static <T extends DynaEnum<T>> T valueOf(Class<T> enumType, String name) {
initializeDefaultEnums(enumType);
initializeDynamicEnums(enumType);
Map<String, DynaEnum<?>> typeElements = elements
.computeIfAbsent(enumType, k -> new LinkedHashMap<String, DynaEnum<?>>());
T t = (T) typeElements.get(name);
if (t != null) {
return t;
}
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}
@SuppressWarnings("unused")
private void readObject(ObjectInputStream in) throws IOException {
throw new InvalidObjectException("can't deserialize enum");
}
@SuppressWarnings("unused")
private void readObjectNoData() throws ObjectStreamException {
throw new InvalidObjectException("can't deserialize enum");
}
public static <E> DynaEnum<? extends DynaEnum<?>>[] values() {
throw new IllegalStateException("Sub class of DynaEnum must implement method values()");
}
@SuppressWarnings("unchecked")
public static <E> E[] values(Class<E> enumType) {
initializeDefaultEnums(enumType);
initializeDynamicEnums(enumType);
Map<String, DynaEnum<?>> typeElements = elements.get(enumType);
if (typeElements == null) {
typeElements = new LinkedHashMap<String, DynaEnum<?>>();
elements.put((Class<? extends DynaEnum<?>>) enumType, typeElements);
}
Collection<DynaEnum<?>> values = typeElements.values();
int n = values.size();
E[] typedValues = (E[]) Array.newInstance(enumType, n);
int i = 0;
for (DynaEnum<?> value : values) {
Array.set(typedValues, i, value);
i++;
}
return typedValues;
}
public static void initializeDefaultEnums(Class<?> enumType) {
try {
Method method = enumType.getDeclaredMethod("defaultEnums");
method.setAccessible(true);
method.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
// do nothing if enum class does not need to initializeDefaultEnums
}
}
public static void initializeDynamicEnums(Class<?> enumType) {
try {
Method method = enumType.getDeclaredMethod("dynamicEnums");
method.setAccessible(true);
method.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
// do nothing if enum class does not need to initializeDynamicEnums
}
}
public static void clear(Class<?> enumType) {
Map<String, DynaEnum<?>> typeElements = elements.get(enumType);
if (typeElements != null) {
typeElements.clear();
}
}
}
동적 Enum 과 기존 정적 Enum 병합
- 기존 정적 Enum 들은 계속 코드에서 활용되어야 한다.
- DB 로부터 동적 Enum 들을 포함시켜 코드에서 활용될 수 있어야 한다.
위의 (Fake?) Enum 클래스는 Enum 이 호출될 때 동적, 정적 Enum 리소스를 모두 불러옵니다. 이 클래스를 상속받아 FOO Enum 코드를 작성할 경우, 다음과 같이 defaultEnums, dynamicEnums 메소드로 로 동적, 정적 Enum 리스트를 지정할 수 있습니다.
@NoArgsConstructor
@Getter
@Setter
@JsonSerialize(using = DynaEnumJsonConverter.Serializer.class)
@JsonDeserialize(using = DynaEnumJsonConverter.Deserializer.class)
public class FOO extends DynaEnum<FOO> {
public static String MAIN_CODE = "FOO";
public static FOO 001 = defaultEnums()[0];
public static FOO 002 = defaultEnums()[1];
public static FOO[] defaultEnums() {
return new FOO[]{
new FOO("001", 0, "첫번째 Foo"),
new FOO("002", 1, "두번째 Foo"),
};
}
public static void dynamicEnums() {
DynaEnumJdbcHelper.initDynamicEnumsByMainCode(GrowthCampaignCode.class, MAIN_CODE);
}
private String description;
protected FOO(String name, int ordinal, String description) {
super(name, ordinal);
this.description = description;
}
public static <E> DynaEnum<? extends DynaEnum<?>>[] values() {
return values(FOO.class);
}
public String getDescription() {
return description;
}
}
defaultEnums 부분은 기존코드에서 하드코딩된 Enum 들을 코드 수정 없이 계속 사용가능하게 합니다.
public static FOO 001 = defaultEnums()[0];
public static FOO 002 = defaultEnums()[1];
public static FOO[] defaultEnums() {
return new FOO[]{
new FOO("001", 0, "첫번째 Foo"),
new FOO("002", 1, "두번째 Foo"),
};
}
dynamicEnums 는 상황에 따라 구현할 수 있지만, 여기서는 DynaEnumJdbcHelper 를 하나 생성하고 DB 로부터 동적 코드를 불러와 신규 Enum 을 생성하는 처리를 하도록 하였습니다.
@NoArgsConstructor
public class DynaEnumJdbcHelper {
public static void initDynamicEnumsByMainCode(Class<?> enumType, String mainCode) {
MetaDataCacheRepository metaDataCacheRepository = BeanHelper
.getBean(MetaDataCacheRepository.class);
if (metaDataCacheRepository != null) {
List<MetadataEntity> metadataEntities = metaDataCacheRepository
.findByMetadataMainCode(mainCode);
IntStream.range(0, metadataEntities.size())
.forEach(ordinal -> {
try {
Constructor<?> constructor = enumType
.getDeclaredConstructor(String.class, int.class, String.class);
MetadataEntity entity = metadataEntities.get(ordinal);
BeanUtils.instantiateClass(constructor, entity.getMetadataCode(), ordinal,
entity.getMetadataName());
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException(e);
}
});
}
}
}
동적 Enum 캐시
동적 Enum 을 포함하는 Class 가 JSON serialize, desirialize 과정을 비롯하여 로직상에서 수시로 호출 될 것이므로, 캐쉬를 구현해야 합니다. 아래 예제와 같이 CacheRepository 를 구현하였다고 할 때, 다음 코드에서 두가지 문제가 있습니다.
@Service
@Slf4j
@CacheConfig(cacheNames = "metaData")
@Transactional
public class MetaDataCacheRepository {
private final MetaDataRepository metaDataRepository;
public MetaDataCacheRepository(
final MetaDataRepository metaDataRepository) {
this.metaDataRepository = metaDataRepository;
}
@Cacheable(key = "#mainCode")
public List<MetadataEntity> findByMetadataMainCode(String mainCode) {
log.info("---------------------- findByMetadataMainCode: {} ----------------------", mainCode);
return metaDataRepository.findByMetadataMainCode(mainCode);
}
@Scheduled(fixedDelay = 60 * 1000, initialDelay = 1000)
@CacheEvict(allEntries = true)
public void refreshDynamicMetadata() {
log.info("---------------------- Evicting metaData Cache ----------------------");
}
}
첫번째는, Enum 클래스는 static 이므로, Enum 에서 CacheRepository 의 DB 쿼리를 호출할 때 MVC 에서 이미 진행중인 트랜잭션에 영향을 미치지 않도록 다음과 같은 처리를 해야 합니다.
이것에 대한 원인을 몰라서 계속 특별한 Exception 이 발생하지 않았음에도 이미 진행중인 트랜잭션과 충돌시 roll-back 이 되는 현상이 발생했는데요, 솔루션빌드팀 김형석 이사님
도움으로 문제를 해결했습니다. (감사합니다!! Propagation 에 개념 이해는 덤으로…)
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public class MetaDataCacheRepository {
///
두번째는 멀티 인스턴스 상황에서의 Cache 갱신명령에 대한 브로드캐스팅 문제입니다. 서버 A,B 가 로드밸런서 뒤에 위치할 경우, 동적코드 생성요청은 A 로, 활용에 대한 요청은 B 로 전달될 경우, Cache 갱신은 생성요청을 받은 A 의 시점에 B 도 함께 이루어저야 합니다.
프로젝트 상황에서는 브로드캐스팅 할 수 있는 인프라가 여의치 않아 다음과 같이 캐쉬갱신 명령을 스케쥴링으로 1분마다 처리하게 하였지만, 좋지 않은 방법입니다. 신규 동적코드 등록후, 다른 인스턴스에서 동적코드를 사용하려면 어찌되었든 1분을 기다려야 하니 말이죠..
@Scheduled(fixedDelay = 60 * 1000, initialDelay = 1000) ==> 좋지 않아요.
@CacheEvict(allEntries = true)
public void refreshDynamicMetadata() {
log.info("---------------------- Evicting metaData Cache ----------------------");
}
만약 Redis 를 가용할 수 있는 환경이라면, Redis Message Pub/Sub 를 사용해 확실한 Cache 갱신 동기화가 이루어질 수 있도록 하는 게 옳습니다. 참조: https://docs.spring.io/spring-data/redis/docs/1.4.0.M1/reference/htmlsingle/#pubsub
그림으로 표현하면 다음과 같을 수 있겠네요.
JsonSerialize, JsonDeserialize
아직 몇가지 작업이 더 남아있습니다. 위의 동적 Enum 클래스는 Enum 이 아닌 일반 클래스이기 때문에, 기존처럼 Controller 를 통해 API 를 호출한다면 다음처럼 나타납니다.
Dto Class
public class SampleDto {
private FOO foo = FOO.001
}
Wrong response
{
"foo": {
"ordinal": 0,
"name": "001",
"description": "첫번째 Foo"
}
}
기존의 UI 코드도 모두 Enum 기준으로 사용되었기 때문에, 나타나야 하는 모양은 다음과 같아야 합니다.
Expect response
{
"foo": "001"
}
DynaEnumJsonConverter
다음과 같이 JsonSerialize, JsonDeserialize 클래스를 생성하고, 동적 Enum 클래스에 등록 해 주면 됩니다.
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import java.io.IOException;
import org.springframework.boot.jackson.JsonComponent;
@JsonComponent
public class DynaEnumJsonConverter {
private DynaEnumJsonConverter() {
}
public static class Serializer extends JsonSerializer<DynaEnum> {
@Override
public void serialize(DynaEnum code, JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(code.name());
}
}
public static class Deserializer extends JsonDeserializer<DynaEnum> implements
ContextualDeserializer {
private Class<DynaEnum> enumType;
public Deserializer() {
}
public Deserializer(Class<DynaEnum> enumType) {
this.enumType = enumType;
}
@Override
@SuppressWarnings("unchecked")
public JsonDeserializer<?> createContextual(DeserializationContext ctx,
BeanProperty property) {
return new Deserializer((Class<DynaEnum>) ctx.getContextualType().getRawClass());
}
@Override
public DynaEnum deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException, JsonProcessingException {
String text = p.getText();
return DynaEnum.valueOf(enumType, text);
}
}
}
DynaEnumJsonConverter 등록
@JsonSerialize(using = DynaEnumJsonConverter.Serializer.class)
@JsonDeserialize(using = DynaEnumJsonConverter.Deserializer.class)
public class FOO extends DynaEnum<FOO> {
Summary
그 외에, JsonConverter 와 마찬가지로 DB 입출력을 위한 JpaConverter 도 생성해야 합니다. 이것까지는 이 글에서 다 적지는 않겠습니다. 되돌아보니 동적코드를 적용하게 하기 위해 할 것이 많네요. 동적 Enum 을 구현하는 것은 경우에 따라서는 좋은 선택이 될 수 있지만, 가장 좋은것은 이러한 WorkAcound 를 사용하지 않도록 구축 초기부터 동적 메타데이터에 대한 고려를 하는 것이 최선인 것 같습니다.