주요글: 도커 시작하기
반응형
ResultSet의 결과값을 자바빈 객체에 저장하는 방법에 대해서 살펴본다.

Reflection과 JDBC 프로그래밍

웹 어플리케이션을 개발할 때 가장 많이 수행되는 SQL 쿼리를 고르라면 당연 "SELECT" 쿼리일 것이다. 게시판이나 자료실 등을 말할 필요도 없고 웹에서 보여지는 대부분의 정보들은 이제 더 이상 텍스트 파일이 아닌 데이터베이스의 테이블속에 저장되어 있으며, 그러한 정보들을 참조하기 위해서 수많은 "SELECT" 쿼리가 사용되고 있다.

자바 프로그래머에게 있어 SELECT 쿼리가 많이 사용된다는 것은 java.sql.ResultSet 으로부터 정보를 읽어오는 부분의 코드가 상당량을 차지한다는 것을 의미한다. 여기에 자바빈을 사용할 경우에는 ResultSet의 결과 필드의 값을 하나씩 읽어와 빈 프로퍼티의 값을 지정해야만 한다. 여러분들은 아마도 다음과 같은 형태의 코드를 많이 작성해보았을 것이다.

    rs = pstmt.executeQuery();
    if (rs.next()) {
        SomeBean bean = new SomeBean();
        bean.setName(rs.getString("name"));
        bean.setAge(rs.getInt("age"));
        bean.setAddress(rs.getString("address"));
        ...
        ...
        return bean;
    } else {
        ...
    }

만약 null 값이 존재한다면 null 값의 여부를 검사하는 코드도 포함될 것이다. 단순하게 느껴지겠지만 위와 같이 ResultSet의 getXXX() 형태의 메소드를 호출하는 것은 개발자의 실수를 유발하기 쉽다는 특징이 있다. 예를 들어, getString("name")이라고 입력해야 하는데 getString("anme") 이라고 입력할 수도 있으며, getString(1)이어야 하는데, getString(2)라고 입력할 수도 있는 것이다. 또한 null인지의 여부를 검사해야 하는데, 검사하지 않아서 NullPointerException이 발생하는 경우도 있기 마련이며, 이런 경우 많은 개발자들은 버그를 찾아 해매는데 불필요한 시간을 낭비하게 된다.

이와 같이 ResultSet의 getXXX 계열의 메소드를 호출하여 값을 읽어온 후 그 값을 빈 프로퍼티에 저장하는 코드를 다음과 같이 단순화 시킬 수 있다면 어떨까?

    rs = pstmt.executeQuery();
    if (rs.next()) {
        SomeBean bean = new SomeBean();
        BeanPropertySetter.setProperties(rs, bean, true, true);
        return bean;
    } else {
        ...
    }

위와 같이 ResultSet.getXXX() 메소드를 호출하는 부분이 단 한줄로 처리될 수 있다면 개발자들은 더 이상 스펠링을 잘못입력하는 것과 같은 자잘한 버그를 잡는 데 시간을 낭비할 필요가 없어질 것이며, 개발의 효율역시 상승시킬 수 있을 것이다.

이 글에서는 위와 같이 ResultSet의 결과값을 빈 프로퍼티에 저장해주는 기능을 어떻게 구현할 수 있는지에 대해서 살펴보고 1부에서 살펴봤던 BeanPropertySetter 클래스에 위 기능을 구현한 메소드를 추가해보도록 하자.

ResultSetMetaData

이 글에서는 "SELECT" 쿼리를 통해서 읽어온 필드의 이름과 자바빈 객체의 프로퍼티 이름이 같을 경우에 ResultSet의 결과값을 빈객체에 저장하는 방식을 사용할 것이다. 우리는 이미 1부에서 Reflection을 사용하여 자바빈 객체의 프로퍼티 목록을 추출하는 방법을 살펴보았었다. 2부에서도 완전히 같은 방법을 사용하여 자바빈의 프로퍼티 목록을 추출할 것이다. 자바빈 객체의 프로퍼티를 추출할 수 있기 때문에 이제 남아 있는 것은 ResultSet이 어떤 데이터 필드를 포함하고 있는 지를 추출해는 것이다. 예를 들어, 다음과 같은 쿼리를 생각해보자.

    SELECT NAME, ADDRESS, AGE FROM MEMBER;

여기서 NAME 필드와 ADDRESS 필드의 값을 자바빈 객체의 name 속성과 address 속성에 저장하기 위해서는 NAME 필드와 ADDRESS 필드가 존재한다는 사실을 알 수 있는 방법이 필요하다. ResultSet은 ResultSet이 저장하고 있는 데이터들이 어떤 필드에 해당되고 SQL 타입이 무엇인지를 알려주는 메타 데이터를 구할 수 있는 메소드를 제공하고 있는데, 그 메소드가 바로 getMetaData()이다. Result의 getMetaData()는 ResultSetMetaData 객체를 리턴해주는 데, 바로 이 ResultSetMetaData를 사용하면 우리가 원하는 것을 할 수 있게 된다.

ResultSetMetaData는 다음과 같은 메소드를 사용하여 ResultSet이 저장하고 있는 데이터들에 대한 정보를 구할 수 있도록 도와준다.

  • getColumnCount() - ResultSet이 저장된 컬럼의 개수를 구한다. (행의 개수가 아니다!)
  • getColumnName(int column) - 지정한 컬럼의 이름을 구한다.
  • getColumnType(int column) - 지정한 컬럼의 SQL 타입을 구한다.
이 세가지 메소드 이외에 getTableName(int column), isNullable(int column), getColumnClassName(int column) 등 더욱 많은 정보를 얻을 수 있도록 해 주는 메소드를 제공해주고 있긴 하지만, 이번 글에서는 위의 3가지 메소드만을 필요로 한다.

위 메소드에서 getColumnCount() 메소드는 읽어오는 컬럼의 개수를 구해준다. 예를 들어, 다음 코드를 생각해보자.

    rs = stmt.executeQuery("select ID, NAME, LEVEL from MEMBER");
    ResultSetMetaData rsmd = rs.getMetaData();
    int columns = rsmd.getColumnCount();

위 코드에서 "select .." 쿼리는 ID, NAME, LEVEL 등 3개의 컬럼을 읽어오므로 ResultSetMetaData의 getColumnCount() 메소드는 3을 리턴하게 된다. 컬럼의 개수를 알게 되면 다음과 같은 방법으로 각 컬럼의 이름과 타입을 알 수 있게 된다.

    for (int i = 1 ; i <= columns ; i ++) {
        String name = rsmd.getColumnName(i);
        int type = rsmd.getColumnType(i);
        ...
    }

여기서 getColumnName()은 SELECT 쿼리에서 사용한 컬럼 이름을 리턴한다. 만약 select * from 과 같이 모든 필드를 나타내는 '*' 기호를 사용했다면 DBMS에 리턴해주는 컬럼 이름을 리턴할 것이다. getColumnType()은 int 타입을 리턴하는데, 이때 리턴되는 값은 java.sql.Types 클래스에 정의되어 있는 상수값들이다. 예를 들어, 컬럼의 SQL 타입이 VARCHAR일 경우 getColumnType() 메소드는 Types.VARCHAR 상수를 리턴한다. Types 클래스 중 이 글에서 사용되는 상수값들은 다음과 같다.

  • Types.CHAR
  • Types.VARCHAR
  • Types.LONGVARCHAR
  • Types.SMALLINT
  • Types.INTEGER
  • Types.TINYINT
  • Types.BIGINT
  • Types.DOUBLE
  • Types.FLOAT
  • Types.DECIMAL
  • Types.NUMERIC
  • Types.TIME
  • Types.TIMESTAMP
  • Types.DATE
구현 및 테스트

이제 이글에서 살펴볼 기능을 구현하기 위해서 필요한 정보들을 추출할 수 있게 되었으므로 실제 구현에 대해서 살펴보자. ResultSet의 컬럼에 있는 값을 자바빈 프로퍼티의 값으로 지정하는 과정은 다음과 같다.

  1. ResultSet으로부터 필드명과 각 필드의 SQL 타입을 구해서 저장한다.
  2. 자바빈 객체에 존재하는 메소드 목록을 구한다.
  3. 각각의 메소드를 검사한다.
    1. 메소드 이름이 set으로 시작하는 지 검사한다.
    2. set으로 시작할 경우 프로퍼티 이름을 구하고 (set 이후의 메소드 이름이 프로퍼티 이름이다.) 그 메소드의 인자 개수가 1개인지를 검사한다.
    3. 위 조건을 충족할 경우, ResultSet의 getSome() 메소드를 사용하여 값을 읽어온다.
    4. 읽어온 컬럼값의 SQL 타입과 자바빈 객체의 프로퍼티 타입을 비교하여 컬럼의 값을 알맞게 형변환한 후 프로퍼티의 값으로 저장한다.
1단계 - ResultSet으로부터 컬럼에 대한 정보 추출하기

ResultSet으로부터 추출해야 하는 컬럼 정보는 컬럼 이름과 컬럼 타입이다. 컬럼 이름과 컬럼의 SQL 타입은 앞에서 설명한 ResultSetMetaData로부터 추출할 수 있다. 추출한 컬럼 이름과 타입은 임의의 장소에 저장되어 있어야 하는데, 이 글에서는 HashMap을 사용하여 저장할 것이다. HaspMap은 Hashtable과 마찬가지로 <키, 값> 쌍을 저장할 수 있도록 해 주는데, 여기서는 컬럼 이름이 키가 되고 컬럼 타입이 값이 된다. 다음은 컬럼 정보를 추출하는 부분의 코드이다.

public static void setProperties(ResultSet rs, Object bean,
       boolean nullToEmpty, boolean useTrim) throws SQLException {
    // ResultSet Meta Data 추출
    ResultSetMetaData rsMetaData = rs.getMetaData();
    int columnCount = rsMetaData.getColumnCount();    
    String columnName = null; // 컬럼명 저장
    int columnType = 0; // 컬럼 타입 저장
    int underbarIndex = -1;
    String tabName = null; // underbar를 포함한 그 이전까지의 컬럼이름을 저장한다.
    
    // 컬럼이름과 컬럼타입 매핑을 저장한다.
    HashMap typeMap = new HashMap(columnCount);
    for (int i = 0 ; i < columnCount ; i++) {
        columnName = rsMetaData.getColumnName(i+1);
        underbarIndex = columnName.indexOf("_");
        if (underbarIndex < columnName.length()-1) {
            if (tabName == null) tabName =
                columnName.substring(0, underbarIndex+1);
            columnType = rsMetaData.getColumnType(i+1);
            typeMap.put(
                columnName.substring(underbarIndex+1).toUpperCase(),
                new Integer(columnType));
        }
    }
    
    ...
    ...
    
}

위 코드에서 컬럼 이름을 구할 때 '_'이 포함되어 있는지의 여부를 판단하는 부분이 있는데, 이는 많은 기업에서 테이블의 컬럼 이름을 지을 때 [테이블명]_[나머지컬럼명]의 형태를 사용하기 때문이다. 예를 들어, 테이블 이름이 MEMBER일 경우 컬럼이름은 MEM_NAME, MEM_ADDRESS와 같은 형태를 띄는 경우가 많다. 그래서 필자는 '_'을 포함하고 있는 컬럼 이름에 대해서는 '_' 이후의 컬럼 이름을 사용하는 방향으로 구현해보았다. 즉, '_'를 포함하지 않는 경우에는 컬럼 이름과 자바빈 프로퍼티 이름을 비교하고, '_'를 포함하는 경우에는 '_' 이후의 컬럼 이름과 프로퍼티 이름을 비교하게 된다.

HashMap인 typeMap은 컬럼 이름과 컬럼 타입을 저장하는데, 이때 컬럼 이름은 모두 대문자로 변환한 후 저장하는 것을 알 수 있다. 이는 대부분의 DBMS가 컬럼 이름에 대해서 대소문자를 구분하지 않기 때문에 가능한 것이다.

2단계 - 빈 프로퍼티 검사

자바빈이 프로퍼티를 검사하는 과정은 지난 1부에서 살펴본 것과 크게 다르지 않다. 다른 점이 있다면 HttpServletRequest의 getParameter() 메소드를 사용하여 필요한 값을 읽어오는 대신 ResultSet의 getXXX() 메소드를 사용하여 값을 읽어온다는 것이다. 또 하나 차이점이 있다면, HttpServletRequest의 getParameter() 메소드의 리턴타입은 String인 반면에 ResultSet의 getXXX()의 메소드들은 저마다 리턴타입이 다르다는 점이다. 따라서, ResultSet의 결과값을 자바빈의 프로퍼티의 값으로 지정하기 위해서는 보다 복잡한 형변환 방법이 필요하다.

필자는 SQL 타입과 자바빈의 프로퍼티 타입 사이의 매핑을 다음과 같이 정의하였다.

 -------------------------------------------------------------------
 java.sql.Types타입   자바빈 프로퍼티타입  차선타입
 -------------------------------------------------------------------
 Types.CHAR           java.lang.String    
 Types.VARCHAR        java.lang.String
 Types.LONGVARCHAR    java.lang.String
 Types.SMALLINT       int                 long, String
 Types.INTEGER        int                 long, String
 Types.TINYINT        int                 long, String
 Types.BIGINT         long                String
 Types.DOUBLE         double              String
 Types.FLOAT          float               double, String
 Types.DECIMAL        double              float, int, long, String
 Types.NUMERIC        int                 long, double, float, String
 Types.TIME           java.sql.Date       java.util.Date, String
 Types.TIMESTAMP      java.sql.Timestamp  java.util.Date, String
 Types.DATE           java.sql.Date       java.util.Date, String

위 표의 첫번째 열과 두번째 열은 각각 SQL 타입과 그에 해당하는 자바빈 프로퍼티의 타입 사이의 매핑을 나타낸다. 에를 들어, 컬럼의 타입이 Types.INTEGER일 경우 자바빈 프로퍼티는 int 타입이어야 한다는 것을 의미한다. 그런데, 자바빈 프로퍼티가 정확하게 int 타입이 아닐 수도 있다. 이처럼 완전한 매핑이 이루어지지 않는 경우에는 차선타입을 사용하게 된다. 즉, 컬럼 타입이 Types.INTEGER인데 자바빈 프로퍼티의 타입이 int가 아닐 경우, 자바빈 프로퍼티 타입이 차선타입에 있는 long 또는 String 인지의 여부를 판단하게 되며, 그에 알맞게 형변환을 하게 된다.

지금까지 설명한 부분을 구현한 코드는 다음과 같다.

Class beanClass = bean.getClass();
Method[] methods = beanClass.getDeclaredMethods();
Class[] parameters = null;
 // 메소드의 파라미터
String mName = null; // 메소드이름

for (int i = 0 ; i < methods.length ; i++) {
    mName = methods[i].getName();
    parameters = methods[i].getParameterTypes();    
    if (mName.indexOf("set") == 0 && 
        mName.length() > 3 && 
        parameters.length == 1) {
        // 메소드 이름이 set 으로 시작하고 파라미터의 개수가 1개인 경우
        // 프로퍼티 이름을 구한다.
        String propertyName = mName.substring(3,4).toLowerCase() + 
                             ((mName.length() > 4) ? mName.substring(4) : "");
        // 프로퍼티 이름과 동일한 이름의 필드 타입을 구한다.
        Integer columnTypeInteger =
                (Integer)typeMap.get(propertyName.toUpperCase());        
        if (columnTypeInteger != null) {
            // 프로퍼티 이름과 동일한 이름의 컬럼이 존재하는 경우
            try {
                Object[] callParameter = null; // 메소드를 호출할 때 전달한 인자
                switch(columnTypeInteger.intValue()) {
                    case Types.CHAR:
                    case Types.VARCHAR:
                        // 자바빈 프로퍼티가 String 타입이냐?
                        if (parameters[0] == String.class) {
                            String varcharVal = 
                                rs.getString(tabName+propertyName);
                            if (varcharVal == null) {
                                if (nullToEmpty) {
                                    callParameter = new Object[] { "" };
                                }
                            } else {
                                callParameter = new Object[] {
 
                                  (useTrim ?
                                      varcharVal.trim() : varcharVal) };
                            }
                        }
                        break;
                    ...
                    // 나머지 SQL 타입에 대해서도 알맞은 처리를 한다.
                    ...
                    ...
                } // end of switch
                
                if (callParameter != null) {
                    // 메소드를 호출한다.
                    methods[i].invoke(bean, callParameter);
                }
            } catch(Exception ex) {
                // 예외가 발생하면 단순히 넘어간다.
                // 예를 들어, int 타입의 프로퍼티에 'abc'와 같은 문자열을
                // 값으로 사용한 경우는 기본값 그대로 사용한다.
            }
        }
    } // 빈 객체의 메소드가 setSome 형태의 메소드인 경우의 끝
}

위 코드를 보면 (대문자로 변환한) 프로퍼티의 이름을 키값으로 사용하여 typeMap에 저장되어 있는 값을 구한다. typeMap에는 컬럼 이름과 컬럼 타입의 매핑이 저장되어 있으므로, 자바빈의 프로퍼티 이름과 같은 이름을 갖는 컬럼이 존재할 경우 typeMap은 컬럼 타입값을 저장하고 있는 Integer 객체를 리턴하게 된다. 존재하지 않는다면 null을 리턴한다.

typeMap으로부터 Integer 객체를 구하게 되면 그 Integer 객체가 저장하고 있는 값(컬럼 타입을 나타내는 값)을 사용하여 SQL 타입과 자바빈 프로퍼티 사이의 매핑을 처리한다. 필자는 switch를 사용하여 이 부분을 처리하였으며, switch 블럭만 100여줄이 넘어가기 때문에 글에서 생략하였다. switch 블럭의 내용은 관련 링크의 소스 코드를 참고하기 바란다.

사용

BeanPropertySetter의 setProperties(ResultSet, Object, boolean, boolean) 메소드를 사용하는 방법은 매우 간단하다. 1부에서 setProperties(HttpServletRequest, Object, boolean, boolean) 메소드를 사용할 때와 마찬가지로 ResultSet을 메소드에 전달해주기만 하면 된다. 일반적으로 다음과 같은 형태로 메소드를 사용하게 될 것이다.

    try {
        ...
        stmt = conn.createStatement();
        rs = stmt.executeQuery("select ... ");
        if (rs.next()) {
            SomeBean bean = new SomeBean();
            BeanPropertySetter.setProperties(rs, bean, true, false);
            ...
        }
    } catch(SQLException ex) {
        ...
    }
    

결론

이제 BeanPropertySetter 클래스는 ResultSet으로부터 프로퍼티의 값을 읽어오는 기능이 추가되었으며, 이로써 웹 어플리케이션에서 값을 추출할 때 주로 사용되는 두 가지 객체인 HttpServletRequest와 ResultSet과 관련된 코딩량을 상당량 줄일 수 있게 되었다. 이는 코딩을 해야 하는 프로그래머에게 편리함을 제공해주며, 또한 코딩량이 줄어든 것에 비례하여 철자가 틀려서 비롯되는 버그의 양도 상당량 줄여준다.

빈과 관련된 유틸리티 클래스는 여기서 끝나지 않는다. 우리가 또 하나 생각해볼만한 것이 있다면, 그것은 바로 INSERT 쿼리와 UPDATE 쿼리에 자동으로 빈의 값을 채워 넣어주는 기능이다. 예를 들어, 다음과 같은 PreparedStatement 를 생성했다고 해 보자.

    pstmt = conn.prepareStatement(
        "UPDATE SOMETABLE SET ADDRESS=?, ... WHERE ID=?");
    pstmt.setString(1, memberBean.getName());
    ...
    pstmt.setString(n, memberBean.getId());

위와 같은 형태의 코드는 수도 없이 반복적으로 나온다. INSERT 쿼리 역시 마찬가지이다. 이러한 코드를 간결하게 만들수만 있다면 개바자들은 BeanPropertySetter 클래스를 사용하는 것 만큼이나 편리함을 느끼게 될 것이다. 다음 3부에서는 PreparedStatement의 각 인자에 자바빈 프로퍼티의 값을 할당해주는 유틸리티 클래스인 BeanToQueryMapper를 작성해보도록 하자.

+ Recent posts