기술적 고민

메타데이터를 이용한 객체 값 자동 생성 ( DatabaseMetaData )

땍땍 2024. 10. 8. 16:20

테스트 코드 작성 중 객체 내부의 값을 랜덤으로 생성해주는 과정이 반복되어 이를 해결하고자 FixtureMonkey를 사용하게 되었다.

다만 FixtureMonkey를 사용하기 위해서는 엔티티 내부에 어노테이션으로 @Size 등의 제약사항을 작성해주어야 했는데, 작성 중인 프로젝트에서는 Mybatis를 사용해 코드를 작성하는 만큼 @Size와 같은 어노테이션을 오직 FixtureMonkey 만을 위해 엔티티에 작성해야할 이유가 없다고 느껴졌다.

또한 테스트 코드를 작성을 위해 기존 코드에 영향을 주는 것은 좋지 않다 판단하였기에, Mybatis에서 별도의 설정 없이 랜덤한 값을 생성할 수 있도록 직접 코드를 작성하기로 하였다.

코드를 작성하기 전 중요하게 생각한 포인트는 아래와 같았다.

  • 기존 코드에 영향이 가지 않기
  • 데이터 구조가 변경이 되어도 조치 없이 작동하기

이러한 포인트를 고려하며 여러 방안을 생각하던 중, 처음에는 fixtureMonkey와 같이 엔티티의 필드 정보를 읽어와 사용할 수 있는 Reflection 을 활용하는 방안을 떠올렸다.

하지만, 해당 방안으로는 기존 fixtureMonkey와 같이 제약 조건을 엔티티에서 가져올 수 없다는 문제가 발생하여 제약 조건이 작성되어 있는 sql 파일을 활용하기로 하였다.

sql 파일에는 제약 조건은 물론 엔티티에 필요한 모든 정보가 작성되어 있었기에 이를 추출해 사용하고자 하였으나 띄어쓰기, 대소문자 구분 등 작성 형식이나 순서가 고정 되어있지 않았고, 기존 코드에 영향이 가지 않도록 하자는 의도에 맞지 않는 것같다 생각하였다. 그리고 결국 sql을 통해 생성된 DB 정보를 가져올 수는 없을까? 라는 의문에서 시작하여 최종적으로 DB 테이블의 제약 조건과 필드 정보를 자동으로 추출할 수 있는 메타데이터를 활용해 구현하기로 하였다.

어떻게 구현할지 결정했으니, 코드를 작성하기 전 메타데이터에서 컬럼을 조회하고 제약 조건을 가져오는 과정에서 어느 정도의 시간이 소요되는지 확인하기로 하였다.

테스트 환경에는 테이블이 총 6개, 테이블마다 각 5~8개의 컬럼을 가지고 있다.

// 테스트 코드
  @RepeatedTest(1000)
  void test() {
    try (SqlSession session = sqlSessionFactory.openSession()) {
      Connection connection = session.getConnection();
      DatabaseMetaData metaData = connection.getMetaData();

      ResultSet tables = metaData.getTables(null, null, null, new String[] {"TABLE"});

      while (tables.next()) {
        String tableName = tables.getString("TABLE_NAME");

        ResultSet columns = metaData.getColumns(null, null, tableName, null);

        while (columns.next()) {
          String columnName = columns.getString("COLUMN_NAME");
          String columnType = columns.getString("TYPE_NAME");
          int columnSize = columns.getInt("COLUMN_SIZE");
          int nullable = columns.getInt("NULLABLE");
        }
      }
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

6개의 테이블 전체, 단일 테이블 모두 테스트를 진행해보았고 아래와 같은 결과가 나왔다.

전체 테이블 단일 테이블

16초59ms 5초177ms

그리고 어떠한 부분에서 시간이 소요 되는지 자세히 살펴보니 매번 테이블/컬럼을 찾아 조회하는 부분이 문제라는 것을 발견하였다.

기존 [ 데이터 구조가 변경이 되어도 조치 없이 작동하기 ] 라는 의도를 위해서는 테스트와 같이 테이블/컬럼을 테스트와 같이 직접 찾아 생성해야만 하지만, 프로젝트를 진행하며 내부 컬럼은 변경이 되어도 테이블명이 변경 되는 일은 거의 존재하지 않았고, 때문에 테이블명은 직접 명시 하기로 결정 하였다.

이렇게 작성된 코드는 메타데이터 를 읽을 수 있어야만 작동하기에 DB와 연결이 없는 단위 테스트에서는 사용할 수 없다는 문제와, 기존 FixtureMonkey와 같이 특정 컬럼의 값을 임의로 변경하기 위해서는 문자열로 직접 컬럼명을 입력해야한다는 문제가 존재한다.

이러한 문제를 해결하기 위해 queryDSL 과 같이 컴파일 과정에서 엔티티를 복제하여 활용하는 것으로 위와 같은 문제점은 해결할 수도 있을 것같다는 생각도 들어 테스트 후 해당 글을 업데이트 해볼까 한다.

코드는 아래와 같다.

아직 완성된 코드는 아니기에 필요에 따라 수정해서 사용하는 것을 추천한다.

RandomEntityPopulator 은 메타 데이터를 가져오고 전달 받은 객체의 타입을 판별하고 값을 넣어 반환해주는 역할을, ColumnInfo 은 읽어온 테이블 명과 제약 사항들을 ColumnDetail에 저장하고 반환하는 역할을, RandomValueGenerator 은 실제 값을 생성하는 역할을 맡고 있다.

사용 방법

  @Test
  void test() {
    RandomEntityPopulator randomEntityPopulator = new RandomEntityPopulator(sqlSessionFactory, "members");

    Member member = (Member) randomEntityPopulator
        .setValue("id", 3L)
        .getPopulatedEntity(Member.class);
  }

전체 코드

public class RandomEntityPopulator {

  private final Map<String, Object> customValues = new HashMap<>();

  private final ColumnInfo columnInfo;

  Random random = new Random();

  public RandomEntityPopulator(SqlSessionFactory sqlSessionFactory, String tableName) {

    try (SqlSession session = sqlSessionFactory.openSession()) {
      Connection connection = session.getConnection();
      DatabaseMetaData metaData = connection.getMetaData();
      columnInfo = new ColumnInfo(metaData, tableName);
    } catch (Exception e) {
      throw new RuntimeException("Failed to initialize ColumnInfo", e);
    }
  }

  public Object getPopulatedEntity(Class<?> clazz) {
    try {

      Object response = clazz.getDeclaredConstructor().newInstance();
      populateFields(response, clazz.getDeclaredFields(), columnInfo);

      return response;
    } catch (Exception e) {
      throw new RuntimeException("getPopulatedEntity ", e);
    }
  }

  public RandomEntityPopulator setValue(String fieldName, Object value) {
    customValues.put(fieldName.toUpperCase(), value);
    return this;
  }

  private void populateFields(Object entity, Field[] fields
      , ColumnInfo columnInfo) throws Exception {

    for (Field field : fields) {
      field.setAccessible(true);
      String columnName = field.getName().toUpperCase();

      if (customValues.containsKey(columnName)) {
        field.set(entity, customValues.get(columnName));
      } else {
        Object value = generateRandomValue(field, columnInfo, columnName);
        field.set(entity, value);
      }
    }
  }

  public Object generateRandomValue(Field field, ColumnInfo columnInfo
      , String columnName) {

    int columnSize = columnInfo.getColumnSize(columnName);
    int size = columnSize > 0 ? random.nextInt(columnSize) : 0;
    boolean nullable = columnInfo.isColumnNullable(columnName);

    if (size == 0 && nullable && !Enum.class.isAssignableFrom(field.getType())) {
      return null;
    }

    return generateValueForType(field.getType(), size);
  }

  private Object generateValueForType(Class<?> fieldType, int size) {
    if (String.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomString(size);
    } else if (Integer.class.isAssignableFrom(fieldType)
        || int.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomInt(size);
    } else if (Long.class.isAssignableFrom(fieldType)
        || long.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomLong(size);
    } else if (Double.class.isAssignableFrom(fieldType)
        || double.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomDouble(size);
    } else if (Boolean.class.isAssignableFrom(fieldType)
        || boolean.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomBoolean();
    } else if (LocalDateTime.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomLocalDateTime();
    } else if (Enum.class.isAssignableFrom(fieldType)) {
      return RandomValueGenerator.getRandomEnum(fieldType);
    } else {
      return null;
    }
  }
}
public class ColumnInfo {

  private final DatabaseMetaData metaData;
  private final String tableName;
  private final List<ColumnDetail> columnDetails = new ArrayList<>();

  public ColumnInfo(DatabaseMetaData metaData, String tableName) throws SQLException {
    this.metaData = metaData;
    this.tableName = tableName;
    loadColumnDetails();
  }

  private void loadColumnDetails() throws SQLException {
    try (ResultSet columns = metaData.getColumns(null, null, tableName, "%")) {
      while (columns.next()) {
        String columnName = columns.getString("COLUMN_NAME");
        int columnSize = columns.getInt("COLUMN_SIZE");
        boolean isNullable = isColumnNullable(columns);

        columnDetails.add(new ColumnDetail(columnName, columnSize, isNullable));
      }
    }
  }

  private boolean isColumnNullable(ResultSet columns) throws SQLException {
    int nullable = columns.getInt("NULLABLE");
    return nullable == DatabaseMetaData.columnNullable;
  }

  public Integer getColumnSize(String columnName) {
    return getColumnDetail(columnName).map(ColumnDetail::getColumnSize).orElse(0);
  }

  public Boolean isColumnNullable(String columnName) {
    return getColumnDetail(columnName).map(ColumnDetail::isNullable).orElse(false);
  }

  private java.util.Optional<ColumnDetail> getColumnDetail(String columnName) {
    return columnDetails.stream()
        .filter(detail -> detail.getName().equalsIgnoreCase(columnName))
        .findFirst();
  }
}
@Getter
public class ColumnDetail {
  private final String name;
  private final int columnSize;
  private final boolean isNullable;

  public ColumnDetail(String name, int columnSize, boolean isNullable) {
    this.name = formatColumnName(name);
    this.columnSize = columnSize;
    this.isNullable = isNullable;
  }

  private String formatColumnName(String columnName) {
    return columnName.toUpperCase().replace("_", "");
  }

}

public class RandomValueGenerator {

  private static final Random random = new Random();

  public static String getRandomString(int size) {
    byte[] array = getRandomByte();
    Charset charset = getCharset();
    String str = new String(array, charset);
    return str.length() < size ? str : str.substring(0, size);
  }

  public static int getRandomInt(int size) {
    return random.nextInt(size);
  }

  public static long getRandomLong(int size) {
    return random.nextLong();
  }

  public static boolean getRandomBoolean() {
    return random.nextBoolean();
  }

  public static double getRandomDouble(int size) {
    return random.nextDouble() * size;
    }

  public static LocalDateTime getRandomLocalDateTime() {
    return LocalDateTime.now().minusDays(random.nextInt(365)).withNano(0);
  }

  private static byte[] getRandomByte() {
    int randomByteLength = random.nextInt(0, 200);
    byte[] array = new byte[randomByteLength];
    random.nextBytes(array);
    return array;
  }

  public static <T> T getRandomEnum(Class<T> clazz) {
    T[] enumConstants = clazz.getEnumConstants();
    return enumConstants[random.nextInt(enumConstants.length)];
  }

  private static Charset getCharset() {
    Set<String> charset = Charset.availableCharsets().keySet();
    List<String> list = new ArrayList<>(charset);
    int randomIndex = random.nextInt(list.size());
    return Charset.forName(list.get(randomIndex));
  }

}