java,

동적처리 Enum 만들기

박승필 박승필 Linkedin Jun 09, 2020 · 8 mins read
동적처리 Enum 만들기
Share this

동적처리 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 를 사용하지 않도록 구축 초기부터 동적 메타데이터에 대한 고려를 하는 것이 최선인 것 같습니다.

박승필
Written by 박승필
배우는걸 좋아하는 사람입니다.