BeanUtils 성능비교 - Apache commons, Opensymphony, Spring

수정이력
  • 2019.04.14

  • 2010.06.21

    • EP님의 지적으로 Apache commons Beanutils에서도 PropertyUtils.getPropertyDescriptor를 사용한 방식으로 비교해봤습니다.

    • 이상민님의 지적으로 직접 Bean의 setter를 호출하는 방식의 자료도 추가

    • BeanUtils.populate의 경우 다양한 type을 가진 property 변환을 하는 등의 실제로 많은 기능을 수행함을 설명 추가를 했습니다.

덧붙임

2019년 2월 시점에 다시 이글을 보니, JMH로 테스트하는 것이 좋았을것 같다는 생각이 듭니다.

조사 내용

getter, setter가 있는 Java Bean들의 property들을 복사할 때 apache commons beanutils(http://commons.apache.org/beanutils/ ) 가 많이 쓰이고 있습니다.

그리고 Spring이나 Opensympony 쪽에도 간단한 BeanUtils가 따로 있습니다.

이러한 BeanUtils들은 reflection을 내부적으로 쓰고 있으니 성능이 안 좋을 것이라는 우려를 할 수 있습니다. 어느 정도 성능 손해는 개발 편의성을 위해서 희생할 수 있는 경우도 많습니다. 그런데, 유사한 용도로 Bean복사를 하더라도 Apache commons BeanUtils쪽이 성능이 더 안 좋다는 소문은 있고 아래 자료에 의하면 100k byte object를 다룰 때는 Spring의 beanUtils에 비하면 거의 5~6배정도의 성능차이가 나는 것으로 알려져있습니다.

이는 commons BeanUtils가 단순히 bean을 복사하는 것보다는 많은 기능을 가진 상위수준의 라이브러리 이기 때문에 당연한 결과라고 보여집니다. Spring이나 Opensymphony의 BeanUtils는 단순히 Bean간의 데이터 복사, 속성정보를 얻어오는 정도인데, apache commons의 BeanUtils는 DynaBean 등 보다 넓은 개념들을 확장해서 제공하고 있습니다. 그리고 BeanUtils.populate는 복사하려는 type이 정확하게 일치하지 않을 경우에도 복사를 해주는 등, 더 확장된 기능을 제공하고 있습니다. (첨부한 소스의 BeanConverterTypeConversionTest 파일을 보면 Map에 String으로 들어간 속성이 Bean에는 Integer, BigDecimal로 선언되어 있을 때, BeanUtils.populate는 제대로 복사를 해주는 것을 보여줍니다.)

그렇다면 type이 일치하는 단순한 Bean간의 복사등에는 굳이 apache commons beanutils까지 사용할 필요가 없은 경우도 많을 것입니다.

아래에서는 List<Map>List<Bean> 로 변환을 시켜주는 기능을 각각의 library로 구현해서 성능비교를 해보았습니다. 소스들은 Eclipse project형태로 첨부파일에 들어가 있습니다.

비교군은 아래와 같습니다.

  • By Hand : 직접 map.get과 bean의 setter를 이용해서 복사

  • Spring(property descriptor) : Spring의 BeanUtils.getPropertyDescriptors를 사용. Spring의 BeanUtils에는 직접적으로 Map에서 Bean으로 복사해주는 메소드가 없어서 이 방식을 이용했습니다.

  • Apache Commons(property descriptor) : 2번과 같은 방식을 사용하고, 라이브러리만 Apache commons BeanUtils의 PropertyUtils.getPropertyDescriptor를 사용

  • Apache Commons(populate) : Apache comons BeanUtils의 BeanUtils.populate 메소드를 사용

  • OpenSymphony(setValues) : Opensymphony의 BeanUtils.setValues를 사용

실행 시간 비교 결과

1만번부터 10만번까지 변환갯수를 늘여주면서(X축), 밀리세컨트 단위로 간단히 수행시간(Y축)을 측정했습니다.

image

Opensymphony 쪽이 너무 많이 차이가 나서 제외하고 나머지 4개만 다시 그려봤습니다.

image

size By hand Spring (property descriptor) Apache Commons (property descriptor)

0

0

0

0

10,000

15

78

63

20,000

32

93

125

30,000

15

141

141

40,000

32

188

187

50,000

15

218

250

60,000

15

250

297

70,000

32

297

359

80,000

32

359

360

90,000

46

406

438

100,000

63

454

484

결론

19개의 속성을 가진 Bean을 대상으로 했을 때, Map→Bean 변환의 10만건의 경우 순위는 아래와 같았습니다.

By Hand > Spring(property descriptor) = Apache Commons(property descriptor) > Apache Commons(populate) > OpenSymphony(setValues)

직접 손으로 한 것이 Sprng BeanUtils나 Commons의 PropertyUtils로 propery descriptor를 통해 호출한 것보다 7배 이상 빨랐습니다.

그리고 PropertyDescriptor를 활용한 방식들이 그 다음 순위로, 비슷한 실행속도가 나왔습니다. Spring BeanUtils쪽에서는 Bean객체의 정보를 CachedIntrospectionResults라는 클래스에 저장을 해 두고 있습니다. 그리고 Apache commons의 PropertyUtils에서도 유사하게 PropertyUtilsBean안에 descriptorsCache라는 속성으로 Bean정보를 Cache하고 있습니다. 그래서 실행속도가 거의 비슷하게 나온듯 합니다.

Apache commons BeanUtils.populate는 2,3위 순위의 Property descriptor를 활용한 것들보다 6배 정도 실행시간이 더 걸리는 것으로 나왔습니다.

BeanUtils.populate가 더 느린 이유는 두가지로 분석이 됩니다.

  • 위에서 말한 것처럼 좀 더 확장된 type변환을 지원하기 떄문입니다.

  • PropertyDescriptor의 배열을 순회하는 방식이 아닌, Map의 keySet을 순회하는 방식을 쓰고 있는데, 아무래도 배열의 순회보다는 성능에는 불리할 것 같습니다.

Iterator names = properties.keySet().iterator();
while (names.hasNext()) {
    // Identify the property name and value(s) to be assigned
    String name = (String) names.next();
    if (name == null) {
        continue;
    }
    Object value = properties.get(name);
    // Perform the assignment for this property
    setProperty(bean, name, value);
}

Opensymphony쪽은 70배이상 더 느린데, 제가 구현한 방식이 문제가 있는 건지도 모르겠습니다.

아뭏든 위의 결과를 봐서는 되도록 성능이 민감한 곳에는 직접 setter를 호출해서 복사를 하고, BeanUtils.populate의 다양한 기능이 필요하지 않다면 Spring의 BeanUtils나 Apache commons PropertyUtils를 통해 캐쉬된 PropertyDescriptor를 정보를 통해 Bean에 접근하는 것이 성능에는 유리하다는 것을 알 수 있습니다.

소스

Apache commons BeanUtils의 BeanUtils.populate 활용
public <T extends Map<String,Object>, C> List<C> convertMapToBean(List<T> list,
  Class<C> clazz) {
    List<C> beanList = new ArrayList<C>();

    for (T item : list) {
        C bean = null;
        try {
            bean = clazz.newInstance();
            BeanUtils.populate(bean, item);
        } catch (InstantiationException e) {
            new IllegalArgumentException("Cannot initiate class",e);
        } catch (IllegalAccessException e) {
            new IllegalStateException("Cannot access the property",e);
        } catch (InvocationTargetException e) {
            new IllegalArgumentException(e);
        }
        beanList.add(bean);
    }
    return beanList;
}
Apache commons BeanUtils : PropertyUtils.getPropertyDescriptors활용
public <T extends Map<String, Object>, C> List<C> convertMapToBean(
    List<T> list, Class<C> clazz) {
    List<C> beanList = new ArrayList<C>();

    for (T source : list) {
        C bean = null;

        try {
            bean = clazz.newInstance();

            PropertyDescriptor[] targetPds = PropertyUtils.getPropertyDescriptors(clazz);

            for (PropertyDescriptor desc : targetPds) {
                Object value = source.get(desc.getName());
                if (value != null) {
                    Method writeMethod = desc.getWriteMethod();
                    if (writeMethod != null) {
                        writeMethod.invoke(bean, new Object[] { value });
                    }
                }
            }
        } catch (InstantiationException e) {
            new IllegalArgumentException("Cannot initiate class",e);
        } catch (IllegalAccessException e) {
            new IllegalStateException("Cannot access the property",e);
        } catch (InvocationTargetException e) {
            new IllegalArgumentException(e);
        }
        beanList.add(bean);
    }
    return beanList;
}
OpenSymphony BeanUtils : setValues 활용
public <T extends Map<String, Object>, C> List<C> convertMapToBean(List<T> list, Class<C> targetClass) {
    List<C> beanList = new ArrayList<C>();

    for (Map<String, Object> map : list) {
        C bean = null;
        try {
            bean = targetClass.newInstance();
            BeanUtils.setValues(bean, map, null);
        } catch (InstantiationException e) {
            new IllegalArgumentException("Cannot initiate class", e);
        } catch (IllegalAccessException e) {
            new IllegalStateException("Cannot access the property", e);
        }
        beanList.add(bean);
    }
    return beanList;
}
Spring BeanUtils : getPropertyDescriptors 활용
public <T extends Map<String, Object>, C> List<C> convertMapToBean(
    List<T> list, Class<C> clazz) {
    List<C> beanList = new ArrayList<C>();
    for (Map<String, Object> source : list) {
        C bean = toBean(source, clazz);
    beanList.add(bean);

    }
    return beanList;
}

private <C> C toBean(Map<String, Object> source, Class<C> targetClass) {
    C bean = null;
    try {
        bean = targetClass.newInstance();
        PropertyDescriptor[] targetPds = BeanUtils.getPropertyDescriptors(targetClass);

        for (PropertyDescriptor desc : targetPds) {
            Object value = source.get(desc.getName());
            if (value != null) {
                Method writeMethod = desc.getWriteMethod();
                if (writeMethod != null) {
                    writeMethod.invoke(bean, new Object[] { value });
                }
            }
        }
    } catch (InstantiationException e) {
        new IllegalArgumentException("Cannot initiate class",e);
    } catch (IllegalAccessException e) {
        new IllegalStateException("Cannot access the property",e);
    } catch (InvocationTargetException e) {
        new IllegalArgumentException(e);
    }
    return bean;
}

성능측정 코드

@Test
public void testApacheCommonsBeanUtils() {
    BeanConverter converter = new ApacheCommonsBeanUtilsBeanConverter();
    executeIncrementally(converter);
}

@Test
public void testApacheCommonsPropertyUtils() {
    BeanConverter converter = new ApacheCommonsPropertyUtilsBeanConverter();
    executeIncrementally(converter);
}

@Test
public void testOpenSymphony() {
    BeanConverter converter = new OpenSymphonyBeanConverter();
    executeIncrementally(converter);
}

@Test
public void testSpring() {
    BeanConverter converter = new SpringBeanConverter();
    executeIncrementally(converter);
}

@Test
public void testByHand() {
    BeanConverter converter = new UserConverter();
    executeIncrementally(converter);
}

private void excuecteBeanConverter(BeanConverter converter, int iterations) {
    List<Map<String, Object>> testList = createMapListForTest(iterations);
    long start = System.currentTimeMillis();
    List<User> beanList = converter.convertMapToBean(testList, User.class);
    long end = System.currentTimeMillis();
    System.out.printf("%s,%d times, %d milliseconds \r\n", converter.getClass().getSimpleName(), iterations, (end - start));
}

private void executeIncrementally(BeanConverter converter) {
    for (int i = 0; i <= 100000; i += 10000) {
        excuecteBeanConverter(converter, i);
    }
}

private List<Map<String, Object>> createMapListForTest(int iterations) {
    List<Map<String, Object>> list = new ArrayList<Map<String, Object>>();

    Map<String, Object> user = new HashMap<String, Object>();
    user.put("id", 1);
    user.put("age", 1);
    user.put("name", "내이름");
    user.put("name1", "내이름");
    user.put("name2", "내이름");
    user.put("name3", "내이름");
    user.put("name4", "내이름");
    user.put("name5", "내이름");
    user.put("name6", "내이름");
    user.put("name7", "내이름");
    user.put("name8", "내이름");
    user.put("name9", "내이름");
    user.put("name10", "내이름");
    user.put("income", new BigDecimal("1000100100"));
    user.put("address", "오늘 아침 내가 행복한 이유는 이런거지 오늘아침 내가 서러운 이유는 그런거야 ");
    user.put("introduce", "오늘 아침 내가 행복한 이유는 이런거지 오늘아침 내가 서러운 이유는 그런거야 ");
    user.put("married", true);
    user.put("nickName", "뻐꾸기");

    for (int i = 0; i < iterations; i++) {
        list.add(user);
    }
    return list;
}

Customer tag library에 대한 테스트 코드 작성

JSP의 커스텀 태그 라이브러리는 그 실행결과를 확인하는 것이 많이 번거롭습니다. 따로 테스트 코드를 짜지 않는다면, Web Application 를 띄우고 커스템 태그를 사용하는 JSP를 직접 실행한 다음에 나오는 텍스트값을 확인해서 눈으로 값이 제대로 찍히는지 검증하고, 틀리면 다시 코드를 고치는 방식을 반복하는 경우도 많습니다. 그리고 보통 커스텀태그에서는 setter로 지정된 속성에 따라서 조건분기도 많이 들어가기 때문에 더욱 디버깅이 까다롭습니다. 이 때 위와 같이 JSP를 거치지 않고 바로 출력될 값을 찎어주고, 검증로직을 추가할 수 있는 테스트 코드를 짠다면 개발할 때 많은 시간이 절약될 것입니다.

javax.servlet.jsp.tagext.TagSupport를 상속한 클래스라면, TagSupport.setPageContext 메소드를 활용해서 mock같은 테스트용 객체들을 삽입하면 됩니다. 이 메소드를 통해서 PageContext와 PageContext.getOut으로 돌려주는 javax.servlet.jsp.JspWriter객체를 모두 mock으로 지정할 수도 있습니다.

Spring에서는 이를 더욱 간편하게 할 수 있는 MockPageContext라는 객체를 제공합니다.

이를 활용한 Custom 태그 테스트 코드는 아래와 같이 만들 수 있습니다.

ButtonTag tag = new ButtonTag();
tag.setFunctionName("alert");
tag.setType("basic");

PageContext pageContext = new MockPageContext();
tag.setPageContext(pageContext);

assertEquals(TagSupport.EVAL_BODY_INCLUDE, tag.doStartTag());
assertEquals(TagSupport.EVAL_PAGE, tag.doEndTag());
String output = ((MockHttpServletResponse) pageContext.getResponse()).getContentAsString();
System.out.println(output);
assertTrue(output.contains("<span class='r'>"));
//출력된 결과에 대한 추가  검증

Oracle을 사용해 입출력하는 Map-Reduce

Hadoop의 Map-Reduce처리에서는 DB를 바로 연결해서 처리할 수 있는 DBInputFormat, DBOutputFormat의 클래스가 제공되고 있습니다.

그러나 이 클래스들은 이름이 'DB’가 붙어있는 것이 무색하게 Oracle과 연결해서 사용해보면 에러가 납니다. DBInputFormat에서는 웹에서의 페이지 처리 쿼리처럼 데이터를 잘라서 가지고 오기 위해 원래 쿼리에다 LIMIT와 OFFSET 키워드를 붙이는데, 이 것은 Oracle에서는 지원되지 않습니다. 그리고 DBOutputFormat에서는 insert문의 맨 뒤에 세미콜론(;)을 붙여버리는데, 이것 역시 Oracle의 JDBC를 사용할 때는 에러를 냅니다.

따라서, 결국 이 클래스들을 Oracle에서 쓸 수 있도록 상속해서 구현을 해 줄수 밖에 없었습니다. 얼핏 생각하면 쿼리만 바꾸어주면 되는 것이니 메소드 하나만 오버라이딩 해주면 될 것으로 예상했으나, 원래 클래스들의 구조가 그 정도로 단순하지 않았습니다.Inner클래스가 많아서 여러 클래스와 메서드들을 다 overriding해 줄 수 밖에 없었습니다. 더군다나, 새로 상속한 클래스의 내부에서 꼭 호출해야 하는 DBConfiguration클래스의 생성자가 public이 아닌 package private(아무것도 선언안한 디폴트 접근자)인 탓에, 패키지를 원래의 DBInputFormat, DBOutputFormat와 같은 패키지로 맞추어야 하는 불편함도 있었습니다. protected로 선언된 메소드들이 많은 것보면 분명히 상속해서 덮어쓰라고 만들어놓은 클래스 같은데, 막상 그렇게 활용하기에는 간편하지 않았던 것이죠.

그리고 또 구조적으로 아쉬운 점은 두 클래스가 같은 DBConfiguration을 보게 있어서 Map에서 입력자료를 얻어오는 DB와 Reduce에서 쓰는 DB가 다를 때는 다시 별도의 클래스를 만들어주어야 한다는 것입니다.

Spring Batch에서도 JdbcPagingItemReader라는 약간 유사한 클래스가 있습니다. DBInputFormat이 하나의 쿼리에서 가지고 올 데이터를 동시에 여러번 쿼리해서 나누어 가지고 오는 반면에 JdbcPagingItemReader에서는 부분씩 가지고 오더라도 순차적으로 쿼리를 하는 차이점이 있기는 합니다. 그래도, 페이지 처리 쿼리처럼, 데이터를 나누어서 가지고 오는 쿼리를 제공한다는 점에서는 유사합니다. JdbcPagingItemReader에서는 내부적으로 PagingQueryProvider 라는 인터페이스를 사용하게 되어 있고, 이 인터페이스는 각 DB종류별로 OraclePagingQueryProvider, HsqlPagingQueryProvider, MySqlPagingQueryProvider, SqlServerPagingQueryProvider, SybasePagingQueryProvider 등의 구현클래스를 가지고 있습니다. Hadoop의 DBInputFormat도 이런 구조였다면 이를 응용하려는 개발자가 훨씬 쉽게 클래스 확장방법을 이해했을 것입니다.

아뭏든 지금까지 현재 공개된 API만으로는 Hadoop의 DB연결 지원 클래스들은 빈약해 보이고, API도 좋은 설계요건을 갖추었다고 느껴지지는 않습니다. 아무래도 포털 등에서 대용량 데이터를 처리하는 곳에 쓰이다보니 DB와 함께 연결되는 쓰임새가 그리 많지는 않았나봅니다. 더군다나 Oracle에서는 한번도 안 돌려본 클래스가 버젓이 DB…​로 시작되는 이름으로 들어간 것 보면 Oracle이 쓰이는 동네와 Hadoop이 사는 곳은 아주 멀리 떨어져 있었던 것 같습니다. 그러나, 앞으로 엔터프라이즈 환경에서도 Hadoop이 쓰이려면 DB와의 integration은 반드시 거쳐야할 다리인 것 같습니다. Enterprise 시장에서의 mapreduce 링크를 보아도 이미 그런 시도들이 시작된 것을 알 수 있습니다.

한편, Hadoop의 FileInputFormat가 Spring batch의 FlatFileItemReader와 유사한 것 등이나 Spring batch도 2.0에서 아직 분산, 동시처리 등을 지원하기 시작했다는 점은 두 프레임웍의 겹치는 지점이 늘어날 수도 있다는 생각도 듭니다. 뭐 아직 Spring batch의 분산지원은 걸음마 단계이기는 합니다만, DB에서 HDFS에 들어가는 파일을 쓸 때 Spring batch의 API를 활용하는 것 깉은 활용법은 시도해 볼만하다고 생각됩니다.

소스

OracleInputFormat
package org.apache.hadoop.mapred.lib.db;

import java.io.IOException;
import java.sql.SQLException;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.mapred.InputSplit;
import org.apache.hadoop.mapred.JobConf;
import org.apache.hadoop.mapred.RecordReader;
import org.apache.hadoop.mapred.Reporter;

public class OracleInputFormat<T extends DBWritable> extends DBInputFormat<T>{

    private DBConfiguration dbConf = null;
    private DBInputSplit split;

    @Override
    public RecordReader<LongWritable,T> getRecordReader(InputSplit split, JobConf job,
            Reporter reporter) throws IOException {
        dbConf = new DBConfiguration(job);
        this.split = (DBInputSplit)split;

        @SuppressWarnings("unchecked")
        Class inputClass = dbConf.getInputClass();
        try {
            @SuppressWarnings("unchecked")
            RecordReader<LongWritable,T> reader = new OracleRecordReader((DBInputSplit) split, inputClass, job);
            return reader;
        } catch (SQLException ex) {
            throw new IOException(ex.getMessage());
        }
    }

    public static void setInput(JobConf job, Class<? extends DBWritable> inputClass,
              String inputQuery, String inputCountQuery) {
        DBInputFormat.setInput(job, inputClass, inputQuery, inputCountQuery);
        job.setInputFormat(OracleInputFormat.class);
     }

    protected class OracleRecordReader extends DBRecordReader{
        protected OracleRecordReader(DBInputSplit split, Class<T> inputClass,
                JobConf conf) throws SQLException {
            super(split, inputClass, conf);
        }

        @Override
        protected String getSelectQuery() {
            long length = 0;
            long start = 0;
            try{
                length = split.getLength();
                start = split.getStart();
            } catch(IOException e){
                throw new IllegalArgumentException
                        ("cannot read length or start variable from DBInputSplit",e);
            }
            StringBuilder query = new StringBuilder();
            query.append(" SELECT * \r\n");
            query.append(" FROM (SELECT m.* , ROWNUM rno ");
            query.append("       FROM ( ");
            query.append(              dbConf.getInputQuery());
            query.append("             )  m");
            query.append("       WHERE ROWNUM <= " + start + " + " + length + ")");
            query.append(" WHERE RNO > " + start);
            System.out.println(query.toString());
            return query.toString();
        }
    }
}
OracleOutputFormat
package org.apache.hadoop.mapred.lib.db;
import org.apache.hadoop.mapred.JobConf;

public class OracleOutputFormat<K  extends DBWritable, V> extends DBOutputFormat<DBWritable, V>{
    @Override
    protected String constructQuery(String table, String[] fieldNames) {
            if(fieldNames == null) {
              throw new IllegalArgumentException("Field names may not be null");
            }
            StringBuilder query = new StringBuilder();
            query.append("INSERT INTO ").append(table);

            if (fieldNames.length > 0 && fieldNames[0] != null) {
              query.append(" (");
              for (int i = 0; i < fieldNames.length; i++) {
                query.append(fieldNames[i]);
                if (i != fieldNames.length - 1) {
                  query.append(",");
                }
              }
              query.append(")");
            }
            query.append(" VALUES (");

            for (int i = 0; i < fieldNames.length; i++) {
              query.append("?");
              if(i != fieldNames.length - 1) {
                query.append(",");
              }
            }
            query.append(")");
            return query.toString();
          }
    public static void setOutput(JobConf job, String tableName, String... fieldNames) {
        DBOutputFormat.setOutput(job, tableName, fieldNames);
        job.setOutputFormat(OracleOutputFormat.class);
    }
}
Job 구성 예
public class SampleJob {

    public static void main(String args[]) throws IOException, URISyntaxException{
        JobConf conf = new JobConf(SampleJob.class);
        initClasspath(conf);
        conf.setJobName("sampleJob");
        DBConfiguration.configureDB(conf, "oracle.jdbc.driver.OracleDriver",
                "jdbc:oracle:thin:@localhost:1525:TEST",
                "myuser", "mypassword");
        OracleInputFormat.setInput(conf, Query.class,
                "SELECT query, category, user_id FROM query_log ",
                "SELECT COUNT(*) FROM query_log");
        conf.setOutputKeyClass(Query.class);
        conf.setOutputValueClass(IntWritable.class);
        conf.setMapperClass(SampleMapper.class);
        conf.setReducerClass(SampleReducer.class);
        conf.setCombinerClass(SampleReducer.class);
        OracleOutputFormat.setOutput(conf, "category", "user_id","cnt");

        JobClient.runJob(conf);
    }

    private static void initClasspath(JobConf conf) throws URISyntaxException,
            IOException {
        DistributedCache.addCacheFile(new URI("lib/ojdbc5-11.1.0.6.jar"), conf);
        DistributedCache.addFileToClassPath(new Path("lib/ojdbc5-11.1.0.6.jar"), conf);
    }
}