저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
PreparedStatement의 IN 파라미터의 값을 빈 프로퍼티의 값으로 지정해주는 BeanToQueryMapper 클래스를 작성해본다.

PreparedStatement로의 Reflection 응용

지금까지 2회에 걸쳐서 HTTP 요청 파라미터와 ResultSet의 결과값을 빈의 프로퍼티 값으로 지정하는 방법에 대해서 살펴보았다. 이를 통해서 여러분들은 리플렉션을 사용함으로써 자바빈과 관련된 프로그래밍을 보다 편리하게 할 수 있다는 사실을 알게 되었을 것이다. 이번 마지막 3부에서는 테이블의 값을 변경하는 SQL 쿼리인 INSERT와 UPDATE에서 빈 프로퍼티를 사용하는 방법에 대해서 살펴볼 것이다. 지난 1부와 2부에서는 자바빈의 값을 지정하는 방법을 보여주었다면, 이번 3부에서는 자바빈의 값을 다른 곳에 할당하는 방법을 보여줄 것이다.

PreparedStatement와 자바빈 컴포넌트의 관계?

흔히 PreparedStatement는 다음과 같은 형태로 사용된다.

    pstmt = conn.prepareStatement(
       "INSERT INTO tablename (COLUMN1, COLUMN2, COLUMN3) values (?, ?, ?)");
    pstmt.setString(1, someValue1);
    pstmt.setInt(2, someValue2);
    pstmt.setDate(3, someValue3);
    pstmt.executeUpdate();
    

만약, 빈 컴포넌트의 프로퍼티를 PreparedStatement의 IN 파라미터의 값으로 지정하고 싶다면 다음과 같이 할 것이다.

    pstmt = conn.prepareStatement(
       "INSERT INTO tablename (COLUMN1, COLUMN2, COLUMN3) values (?, ?, ?)");
    pstmt.setString(1, bean.getColumn1());
    pstmt.setInt(2, bean.getColumn2());
    pstmt.setDate(3, bean.getColumn3());
    pstmt.executeUpdate();

UPDATE 쿼리 역시 INSERT 쿼리와 비슷한 형태로 처리될 것이다. 이때, 만약 다음과 같이 한번의 메소드 실행으로 PreparedStatement의 IN 파라미터의 값을 자바빈 프로퍼티의 값으로 지정할 수 있다면 어떨까?

    pstmt = BeanToQueryMapper.prepareInsertStatement(conn, 
      "INSERT INTO tablename (COLUMN1, COLUMN2, COLUMN3) VALUES (?, ?, ?)",
      bean, true, 2000);
    pstmt.executeUpdate();
    
    pstmt1 = BeanToQueryMapper.prepareUpdateStatement(conn,
      "UPDATE tablename SET COLUMN1 = ? , COLUMN2 = ? WHERE COLUMN3 = ?",
      bean, true, 2000);
    pstmt1.executeUpdate();

위와 같이 PreparedStatement의 IN 파라미터의 값을 변경하는 부분을 자동화할 수 있다면 BeanPropertySetter를 사용할 때와 마찬가지로 개발자의 실수에 따른 에러를 최소화시킬 수 있을 것이며, 무엇보다도 귀찮은 작업을 줄일 수 있을 것이다. (Copy & Paste를 사용하여 무수히 많은 pstmt.setString(), pstmt.setInt() 등의 코딩을 해 본 사람은 PreparedStatement의 IN 파라미터의 값을 일일이 지정하는 작업이 얼마나 귀찮은 것인지 알 수 있을 것이다.)

이번 글에서 살펴볼 BeanToQueryMapper 클래스는 바로 위에서 보는 바와 같이 PreparedStatement의 IN 파라미터의 값을 빈 프로퍼티의 값으로 지정해주는 유틸리티 클래스로서 1-2부와 마찬가지로 리플렉션을 사용하여 필요한 기능을 구현한다. BeanToQueryMapper 클래스는 위 코드에서 볼 수 있듯이 두 개의 메소드를 제공하고 있으며, 이들 메소드는 각각 다음과 같은 기능을 제공한다.

  • BeanToQueryMapper.prepareInsertStatement()
    INSERT 쿼리에 해당하는 PreparedStatement의 IN 파라미터를 빈 프로퍼티의 값으로 지정한다.

  • BeanToQueryMapper.prepareUpdateStatement()
    UPDATE 쿼리에 해당하는 PreparedStatement의 IN 파라미터를 빈 프로퍼티의 값으로 지정한다.

이 두 메소드를 구현하기 위해서는 SQL 쿼리를 파싱하는 과정이 필요한데, 파싱 방법은 두 메소드가 서로 다르므로 각각 메소드를 설명할 때 함께 설명하기로 한다. 먼저 INSERT 쿼리를 위한 메소드인 prepareInsertState() 메소드를 구현하는 것에 대해서 살펴보도록 하자.

prepareInsertStatement() 메소드와 prepareUpdateStatement() 메소드의 구현

prepareInsertStatement() 메소드의 구현

prepareInsertStatement() 메소드는 다음과 같은 시그너쳐를 갖고 있다.

    public static PreparedStatement prepareInsertStatement(
        Connection conn, String queryObject bean, boolean useNumber, int varcharMaxLength)
    throws SQLException

각각의 파라미터는 다음과 같은 의미를 지닌다.

  • conn - 데이터베이스와 연결된 DB 커넥션
  • query - IN 파라미터를 갖고 있는 INSERT 쿼리
  • bean - IN 파라미터에 채워넣을 값을 갖고 있는 빈 객체
  • useNumber - 프로퍼티의 타입이 boolean일 경우 숫자(0/1)을 사용할지 문자('F'/'T')를 사용할지의 여부를 지정
  • varcharMaxLength - VARCHAR 타입의 최대 길이
prepareInsertStatement() 메소드가 올바르게 동작하려면 INSERT 쿼리는 다음과 같은 형식을 지녀야 한다.

    insert into TABLENAME (columnname1, columnname2, ..) values (?, ?, ..)    

위 코드에서 유심히 살펴봐야 할 부분은 컬럼의 목록을 반드시 명시해 주어야 한다는 것이다. 이렇게 컬럼의 목록을 명시하는 이유는 컬럼의 이름과 프로퍼티의 이름을 비교하여 프로퍼티의 값을 알맞은 IN 파라미터에 할당하기 때문이다. 또 하나 유의할 점은 컬럼의 개수와 IN 파라미터의 개수가 같아야 한다는 점이다. INSERT 쿼리에서 명시한 컬럼의 개수가 3개인데, IN 파라미터의 개수는 2개라면 올바르게 동작하지 않게 된다.

bean은 IN 파라미터에 저장할 프로퍼티를 갖고 있는 빈 객체이다. 예를 들어 bean 객체가 id, name, address 라는 프로퍼티를 갖고 있다고 할 때 다음과 같은 코드가 있다고 해 보자.

    pstmt = BeanToQueryMapper.prepareInsertStatement(conn,
        "insert into Member (ID, NAME, ADDRESS) values (?, ?, ?)",
        bean, true, 255);

이 경우 위 코드의 결과는 다음의 코드와 같다.

    pstmt = conn.prepareStatement("insert into Member (ID, NAME, ADDRESS) values (?, ?, ?)");
    pstmt.setString(1, bean.getId());
    pstmt.setString(2, bean.getName());
    pstmt.setString(3, bean.getAddress());

prepareInsertStatement() 메소드의 네번째 파라미터인 useNumber는 타입이 boolean인 자바빈 프로퍼티를 어떤 타입을 사용하여 IN 파라미터에 할당할지를 결정할 때 사용된다. useNumber 파라미터가 true일 경우 자바빈 프로퍼티의 값은 true일 경우 정수 1로 false일 경우 정수 0으로 지정된다. 예를 들어, 빈 객체가 receiveMail 이라는 boolean 타입의 프로퍼티를 갖고 있다고 해 보자. 이 때, useNumber 파라미터의 값을 true로 하면 다음과 같이 IN 파라미터의 값을 지정하는 것과 같다.

    pstmt.setInt(n, (bean.isReceiveMail() ? 1 : 0) );

useNumber 파라미터가 false일 경우에는 숫자 1/0 대신 문자 'T'/'F'를 사용하여 프로퍼티의 값을 IN 파라미터에 할당한다. 즉, useNumber 파라미터가 false라면 다음과 같이 IN 파라미터의 값을 지정하는 것과 같다.

    pstmt.setString(n, (bean.isReceiveMail() ?  'T' : 'F') );

마지막 파라미터인 varcharMaxLength는 VARCHAR의 최대 길이는 나타낸다. JDBC API는 PreparedStatement의 IN 파라미터에 String 타입의 값을 지정할 때 값을 저장할 테이블 필드의 SQL 타입이 VARCAR와 LONG VARCHAR인 경우에 대해 각각 setString() 메소드와 setCharacterStream() 메소드를 사용하여 값을 지정하도록 하고 있는데, varcharMaxLength는 타입이 String인 프로퍼티에 대해서 setString()을 사용할지 setCharacterStream()을 사용할지 지정해주는 기준선이 된다. 이 값을 -1로 지정하게 되면 길이에 관계없이 setString()을 사용하여 프로퍼티의 값을 IN 파라미터에 할당한다.

지금까지는 prepareInsertStatement() 메소드의 각 파라미터가 무엇을 의미하는 지 살펴보았다. 이제 실제로 구현 부분을 살펴보자. prepareInsertStatement() 메소드는 다음과 같은 순서로 구현된다.

  1. 컬럼 목록을 추출한다.
  2. 빈 프로퍼티의 값을 읽어와 일치하는 컬럼에 해당하는 IN 파라미터에 값을 할당한다.
앞에서 prepareInsertStatement() 메소드에서 사용할 INSERT 쿼리는 컬럼 목록을 표기해야 한다고 했으며, INSERT 쿼리에서 컬럼 목록은 첫번째 '('와 첫번째 ')' 사이에 표시된다. 따라서 첫번째 괄호영역안에 있는 문자열을 알맞게 분석하면 손쉽게 컬럼 목록을 추출낼 수 있다. 또한, 분석된 컬럼명의 순서는 IN 파라미터의 순서와 같다. 따라서 <컬럼이름, IN 파라미터의 순서>로 구성된 쌍을 Map에 저장하면 자바빈 프로퍼티의 이름을 사용하여 손쉽게 Map으로부터 자바빈 프로퍼티의 값을 할당할 IN 파라미터를 찾을 수 있게 된다.

지금까지 설명한 내용을 바탕으로 prepareInsertStatement() 메소드를 구현하면 다음과 같다.

    public static PreparedStatement prepareInsertStatement(
        Connection conn, String query, Object bean, boolean useNumber, int varcharMaxLength)
    throws SQLException {
        int leftIdx, rightIdx; // 여는 괄호'('와 닫는 괄호의 인덱스 저장
        leftIdx  = query.indexOf("(");
        rightIdx = query.indexOf(")");        
        if (leftIdx < rightIdx) new IllegalArgumentException("Invalid Query:"+query);
        
        // 컬럼 이름 추출 시작
        String columnList = query.substring(leftIdx+1, rightIdx);
        StringTokenizer columnToken = new StringTokenizer(columnList, ",");
        
        HashMap columnMap = new HashMap(); // 컬럼이름을 소문자로 변환한 것이 키
        String columnName = null;
        int questionIndex = 0, underbarIndex=-1;
        while(columnToken.hasMoreTokens()) {
            questionIndex++;
            columnName = columnToken.nextToken().trim();
            underbarIndex = columnName.indexOf("_");
            if (underbarIndex == -1) { // '_'가 없다면
                columnMap.put(columnName.toLowerCase(), new Integer(questionIndex));
            } else if (underbarIndex < columnName.length() - 1) {
                // '_' 이후에 글자가 있다면
                columnMap.put(columnName.substring(underbarIndex+1).toLowerCase(),
                              new Integer(questionIndex));
            } else if (underbarIndex == columnName.length() - 1) {
                columnMap.put(columnName.substring(0, underbarIndex).toLowerCase(),
                              new Integer(questionIndex));
            }
        }
        
        // 이제 PreparedStatement를 생성한다.
        PreparedStatement pstmt = conn.prepareStatement(query);
        parsingBean(pstmt, columnMap, bean, useNumber, varcharMaxLength);
        return pstmt;
    }

위 코드에서 분석과정이 끝나게 되면 Map인 columnMap은 <컬럼명, IN 파라미터인덱스> 쌍을 갖게 된다. 실제로 빈의 프로퍼티를 분석하여 IN 파라미터의 값을 채워넣어주는 부분은 parsingBean() 메소드를 통해서 이루어지는데, parsingBean() 메소드에 대해서는 prepareUpdateStatement() 메소드를 살펴본 다음에 알아볼 것이다.

위 코드에서 눈여겨 볼 부분은 컬럼 이름에 "_"가 있을 경우 "_" 이후에 있는 문자열만 columnMap에 저장한다는 점이다. 이렇게 하는 이유에 대해서는 2부에서 한번 설명한 적이 있으므로 이 글에서는 언급하지 않겠다.

prepareUpdateStatement() 메소드의 구현

prepareUpdateStatement() 메소드는 INSERT 쿼리가 아닌 UPDATE 쿼리에 대해서 동작하는 메소드이다. 이 메소드의 시그너쳐는 다음과 같다.

    public static PreparedStatement prepareUpdateStatement(
        Connection conn, String query, Object bean, boolean useNumber, int varcharMaxLength)
    throws SQLException

각 파라미터의 의미는 prepareInsertStatement() 메소드와 같으며 차이점이 있다면 query 파라미터는 INSERT 쿼리가 아닌 UPDATE 쿼리가 와야 한다는 점이다. prepareUpdateStatement() 메소드는 다음과 같은 구조를 갖는 UPDATE 쿼리에 대해서 올바르게 동작한다.

    update tablename set column1 = ?, column2 = ?, ... where columnM = ? and columnN = ?

위 쿼리를 잘 살펴보면 컬럼의 값을 지정하는 부분이 모두 '컬럼이름 = ?'의 형태로 지정된 것을 알 수 있는데, prepareUpdateStatement() 메소드는 이러한 특징을 이용하여 컬럼명을 분석한다. 실제 prepareUpdateStatement() 메소드는 다음과 같다.

    public static PreparedStatement prepareUpdateStatement(
        Connection conn, String query, Object bean, boolean useNumber, int varcharMaxLength)
    throws SQLException {
        // 'some = ?', 'some= ?', 'some =?' 의 형태를 지닌 문장을 추출해내야 한다.
        query = query.toLowerCase();
        int setIndex = query.indexOf("set");
        if (setIndex == -1) throw new IllegalArgumentException("Invalid Query:"+query);
        
        HashMap columnMap = new HashMap(); // 컬럼이름을 소문자로 변환한 것이 키
        
        // set 이후부터 토큰으로 분석
        // 공백문자(" ")와 콤마(",")를 토큰으로 사용한다.
        StringTokenizer tokens = 
                new StringTokenizer(query.substring(setIndex+1), " ,=<>"); 
        int tokenCount = tokens.countTokens();
        
        String token = null;
        String columnName = null; // 컬럼 이름일 가능성이 있는 문장을 저장
        String propertyName = null; // 컬럼이름으로부터 추출된 프로퍼티 이름
        int equalIdx = -1;
        
        int questionIndex = 0; // 빈 프로퍼티와 일치할 '?' 번호
        int underbarIdx = -1;
        
        while(tokens.hasMoreTokens()) {            token = tokens.nextToken();
            // token은 'some' 또는 '?' 이다.
            if (token.equals("?")) {
                questionIndex++;
                if (columnName != null) {
                    underbarIdx = columnName.indexOf("_");
                    if (underbarIdx == -1) {
                        propertyName = columnName;
                    } else if (underbarIdx == columnName.length()-1) {
                        propertyName = columnName.substring(0, underbarIdx);
                    } else {
                        propertyName = columnName.substring(underbarIdx+1);
                    }
                    columnMap.put(propertyName, new Integer(questionIndex));
                }
            } else {
                columnName = token;
            }
        }
        // 빈 프로퍼티의 값을 알맞은 '?'에 위치시킨다.
        // 이제 PreparedStatement를 생성한다.
        PreparedStatement pstmt = conn.prepareStatement(query);
        parsingBean(pstmt, columnMap, bean, useNumber, varcharMaxLength);
        return pstmt;
    }

parsingBean() 메소드의 구현

prepareInsertStatement() 메소드와 prepareUpdateStatement() 메소드는 컬럼명을 추출해는 과정이 끝나면 parsingBean() 메소드를 호출한다. parsingBean() 메소드는 빈의 프로퍼티 이름과 같은 이름을 갖는 컬럼에 해당하는 IN 파라미터에 알맞은 값을 채워넣어주는 역할을 수행한다. 빈 프로퍼티의 이름을 추출해내는 과정에서 주의할 점이 있다면 메소드의 이름이 다음 조건을 만족시키는 경우에는 빈 프로퍼티로서 처리해야 한다는 점이다.

  • 메소드의 리턴타입이 void 여서는 안 된다.
  • 메소드는 호출인자를 갖지 않는다.
  • 메소드의 이름은 get 또는 is로 시작해야 한다.
위 조건은 일반적인 자바빈 프로퍼티의 조건이기도 하다.

메소드를 분석하는 과정은 2부에서 setXXX() 메소드를 검색할 때의 과정과 비슷하다. 조건에 알맞은 get 또는 is로 시작하는 메소드를 찾았다면 Method 클래스를 사용하여 호출해서 프로퍼티의 값을 리턴받는다. 이때, 리턴받은 프로퍼티의 값이 어떤 타입을 갖느냐에 따라서 알맞게 SQL 타입으로 매핑을 시켜주어야 하는데, 매핑은 다음과 같이 이루어지도록 구현하였다.

  ----------------------------------------------------------
    프로퍼티 타입         호출되는 PreparedStatement의 메소드
  ----------------------------------------------------------
    String               setString(), setCharacterStream()
    int                  setInt()
    long                 setLong()
    double               setDouble()
    float                setFloat()
    java.util.Date       setTimestamp()
    java.sql.Date        setDate()
    java.sql.Timestamp   setTimestamp()
    java.sql.Time        setTime()
    boolean              setInt(0/1), setString("T"/"F")
  ----------------------------------------------------------

실제 parsingBean()을 구현한 코드는 다음과 같다.

   private static void parsingBean(
    PreparedStatement pstmtMap columnMapObject bean,
    boolean useNumberint varcharMaxLength)
    throws SQLException {
        // 그 다음에 자바빈 객체의 메소드 중에서 get 이나 is 로 시작하는
        // 메소드를 조사하여 프로퍼티의 이름을 추출하고
        // 해당하는 리턴타입에 알맞게 ? 에 값을 채운다.
        Class beanClass = bean.getClass();
        Method[] methods = beanClass.getDeclaredMethods();
        String methodName = null; // 메소드 이름
        Class returnType = null; // 메소드의 리턴타입을 나타내는 클래스
        
        int getIdx = -1; // 메소드의 이름이 get 또는 is 로 시작하는 판단하기 위해 사용
        String propertyName; // 자바빈 프로퍼티 이름
        
        for (int i = 0 ; i < methods.length ; i++) {
            methodName = methods[i].getName();
            returnType = methods[i].getReturnType();            
            if (  (
                    ( (getIdx = methodName.indexOf("get")) == 0 && methodName.length() > 3)
                    ||
                    (methodName.indexOf("is") == 0 && methodName.length() > 2)
                  )
                  &&
                  methods[i].getParameterTypes().length == 0
                  &&
                  returnType != void.class ) {
                // 메소드 이름이 get이나 is로 시작하고 
                // 파라미터가 없고 return 타입이 void가 아닌 경우
                if (getIdx == 0) // 메소드 이름이 get 으로 시작
                    propertyName = methodName.substring(3).toLowerCase();
                else // is 로 시작하는 경우
                    propertyName = methodName.substring(2).toLowerCase();
                
                // qIdx에는 컬럼의 위치가 저장되어 있다.
                Integer qIdx = (Integer)columnMap.get(propertyName);
                if (qIdx != null) {
                    try {
                        // 자바빈 프로퍼티와 일치하는 컬럼이 존재한다.
                        Object propertyVal = methods[i].invoke(bean, null);                        
                        if (returnType == String.class) {
                            if (propertyVal == null) {
                                pstmt.setString(qIdx.intValue(), null);
                            } else {
                                String realVal = (String)propertyVal;
                                int byteLength = realVal.getBytes().length;
                                if (varcharMaxLength != -1 && varcharMaxLength < byteLength) {
                                    StringReader sr = null;
                                    sr = new StringReader(realVal);
                                    pstmt.setCharacterStream(qIdx.intValue(), 
                                                             sr, realVal.length());
                                    sr.close();
                                } else {
                                    pstmt.setString(qIdx.intValue(), realVal);
                                }
                            }
                        } else if (returnType == int.class) {
                            pstmt.setInt(qIdx.intValue(), ((Integer)propertyVal).intValue());
                        } else if ( ... ) {
                            ....
                            ....
                            ....
                        } else if (returnType == boolean.class) {
                            boolean bVal = ((Boolean)propertyVal).booleanValue();
                            if (useNumber) {
                                if (bVal)
                                    pstmt.setInt(qIdx.intValue(), 1);
                                else
                                    pstmt.setInt(qIdx.intValue(), 0);
                            } else {
                                if (bVal)
                                    pstmt.setString(qIdx.intValue(), "T");
                                else
                                    pstmt.setString(qIdx.intValue(), "F");
                            }
                        }
                    } catch(Exception e) {
                        // 프로퍼티의 값을 구하는 빈의 메소드를 호출하는 과정에서 에러
                    }
                }
            } // get 또는 is 메소드 처리의 끝
        }
    }

위 코드를 차근 차근 분석해보면 리플렉션을 어떻게 사용하여 프로퍼티의 값을 얻어오는 지 알 수 있을 것이다. 위 코드를 보면 리턴타입에 따라서 알맞은 PreparedStatement의 메소드를 호출하는 부분이 생략되었는데, 완전한 코드는 관련 링크에 있으니 참고하기 바란다.

위 코드에서 프로퍼티의 이름을 구하게 되면 인자로 전달받은 columnMap으로부터 프로퍼티의 값을 할당할 IN 파라미터의 인덱스를 구하게 된다. 이 columnMap은 prepareInsertStatement() 메소드와 prepareUpdateStatement() 메소드에서 생성한 것으로서 앞에서 살펴봤듯이 <컬럼명, IN 파리미터 인덱스>를 값으로 갖고 있다.

parsingBean()이 수행되면 prepareInsertStatement() 메소드와 prepareUpdateStatement() 메소드에서 생성한 PreparedStatement의 IN 파라미터는 알맞은 빈 프로퍼티의 값을 할당받게된다.

BeanToQueryMapper 클래스의 사용

BeanToQueryMapper 클래스의 사용방법은 매우 간단하다. prepareInsertStatement() 메소드와 prepareUpdateStatement() 메소드에 알맞은 파라미터만 넘겨주면 그걸로 끝이다. VARCHAR의 최대길이나 boolean 값을 숫자로 지정할 지 문자로 지정할지의 여부만 알맞게 결정해주면 된다.

결론

지금까지 1-3부에 걸쳐서 살펴본 내용들은 리플렉션을 활용할 수 있는 분야중 빈 컴포넌트와 관련된 부분에 대해서만 살펴본 것이다. 빈 컴포넌트와 리플렉션의 조합은 지금까지 살펴본 것처럼 프로그래밍을 하는 데 있어서 특히 자바빈 컴포넌트와 SQL 문장이 많이 사용되는 웹 프로그래밍에 있어서 간결함을 제공해주고 개발자들의 실수를 줄여주는 등 많은 효과를 발휘한다. 실제로 필자 역시 현재 진행중인 프로젝트에서 BeanPropertySetter 클래스와 BeanToQueryMapper 클래스를 사용하고 있는데 동료들로부터 좋은 반응을 얻고 있다.

이 외에도 리플렉션은 그 활용방법에 따라 많은 편리함과 효과를 제공해줄 수 있는 막강한 API이다. 여러분들도 리플렉션을 활용하여 좀더 편리한 개발을 할 수 있기를 바라며 이번 연재를 마친다.

Posted by 최범균 madvirus

댓글을 달아 주세요

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를 작성해보도록 하자.

Posted by 최범균 madvirus

댓글을 달아 주세요

HTTP 요청의 파라미터의 값을 자바빈 객체에 저장하는 방법에 대해서 알아본다.

Reflection과 웹 프로그래밍

JSP 시장이 확대되면서 많은 개발자들이 서블릿과 JSP를 이용한 모델 2 프로그래밍에 대한 중요성을 인식해가고 있으며, 인식 속도와 비례하여 자바빈을 웹 프로그래밍에서 사용하는 경우가 증가하고 있다. 하지만, 서블릿에서 자바빈을 생성할 때 몇가지 불편한 점이 있는데, 그 중 하나가 자바빈 프로퍼티의 값을 채워넣는 것이다.

예를 들어, 자바빈의 프로퍼티의 값을 HTTP 요청의 프로퍼티 값으로 지정하고 싶다고 해 보자. JSP에서는 <jsp:useBean>과 <jsp:setProperty< 액션 태그를 사용하여 단 2-3줄만으로 대부분의 자바빈 프로퍼티의 값을 HTTP 요청의 파라미터 값으로 채워 넣을 수 있게 된다. 하지만, 서블릿에서는 다음과 같은 형태의 코드를 사용하는 것이 일반적이다.

  SomeBean sb = new SomeBean();
  sb.setProperty1(request.getParameter("property1"));
  sb.setAge(Integer.parseInt(request.getParameter("age")));
  sb.setAddress(request.getParameter("address"));
  ....

보다시피 서블릿에서는 각각의 자바빈 프로퍼티에 대해서 일일이 값의 지정 및 예외 처리를 해 주어야만 한다. 이를 좀더 쉽게 할 수는 없을까? JSP에서 처럼 간단하게 HTTP 요청의 파라미터 값을 자바빈 프로퍼티의 값으로 지정할 수는 없을까? 이번 기사에서는 이 질문에 대한 답을 하고자 한다. 또한 더 나아가 ResultSet의 결과값을 자바빈 프로퍼티의 값으로 지정하는 방법에 대한 것도 살펴보기로 한다.

Reflection, 자바빈 그리고 HTTP 요청

HTTP 요청의 파라미터 값을 자바빈 프로퍼티의 값으로 지정하기 위해서 필요한 것은 파라미터의 이름과 자바빈 프로퍼티의 이름을 알맞게 연결하는 것이다. 이를 하기 위해서 필요한 것이 바로 리플렉션(reflection)이다. 리플렉션은 특정한 클래스가 정의하고 있는 메소드의 목록 및 각 메소드이 시그너쳐 등을 분석할 수 있도록 해 주는 유용한 API로서 자바빈 프러퍼티의 목록을 추출해낼 수 있도록 해 준다.

HTTP 요청의 파라미터 값을 읽어와서 자바빈 프로퍼티의 값에 넣어주기 위한 프로그래밍 순서는 다음과 같다.

  1. 자바빈 객체에 존재하는 메소드 목록을 구한다.
  2. 각각의 메소드를 검사한다.
    1. 메소드 이름이 set으로 시작하는 지 검사한다.
    2. set으로 시작할 경우 프로퍼티 이름을 구하고 (set 이후의 메소드 이름이 프로퍼티 이름이다.) 그 메소드의 인자 개수가 1개인지를 검사한다.
    3. 위 조건을 충족할 경우, request.getParameter("someProperty")의 값을 프로퍼티의 값으로 지정한다.
각 과정이 어떻게 구현되는 지 순차적으로 살펴보도록 하자. 지금부터 설명하는 부분은 이번 글에서 완성할 BeanPropertySetter 라는 클래스의 setProperties()라는 메소드를 순차적으로 살펴보는 것이기도 하다.

step 0: 기본적인 클래스의 형태

먼저 이 글에서 완성할 클래스인 BeanPropertySetter 클래스의 기본적인 형태는 다음과 같다.

    import javax.servlet.http.HttpServletRequest;
    
    public class BeanPropertySetter {
        public static void setProperties(HttpServletRequest request, Object bean) {
            ...
        }
   }

위 코드에서 setProperties() 메소드는 두 개의 파라미터를 받는데, 첫번째는 HTTP 요청이고 두번째는 요청 파라미터의 값으로 프로퍼티의 값을 지정할 자바빈 객체를 의미한다.

step 1: 빈 객체의 메소드 목록을 구한다.

setProperties(HttpServletRequest, Object) 메소드는 자바빈 객체의 메소드 목록을 구하는 것부터 시작하는데, 이는 java.lang.Class 클래스의 getDeclaredMethods() 메소드를 사용하면 간단하게 수행할 수 있다. 코드 형태는 다음과 같다.

    public static void setProperties(HttpServletRequest request, Object bean) {
        Class beanClass = bean.getClass();
        Method[] methods = beanClass.getDeclaredMethods();
        ...
    }

여기서 첫번째 코드는 빈 객체가 어떤 타입의 클래스인지 구하게 된다. 자바에 모든 클래스는 그 타입에 해당하는 Class 객체를 갖고 있다. 실제 객체의 Class 타입은 두 가지 방법으로 구할 수 있는데, 하나는 객체의 getClass() 메소드를 호출하는 것이며 또 하나는 클래스의 정적 필드인 class 를 참조하는 방법이다. 이 두 방법을 코드화하면 다음과 같다.

    String some = new String("");
    Class stringClass1 = some.getClass();
    Class stringClass2 = String.class;

위에서 stringClass1과 stringClass2는 모두 java.lang.String 클래스에 해당하는 Class 객체를 가리키게 된다. 여기서 구현할 setProperties() 메소드는 객체의 getClass() 메소드를 사용하여 해당하는 객체의 실제 클래스 타입에 해당하는 Class 객체를 구하였다.

일단 Class 객체를 구하게 되면, Class 객체의 getDeclaredMethods() 메소드를 사용하여 그 객체에 정의(또는 선언)되어 있는 메소드의 목록을 구할 수 있게 된다. 이 때, 이 메소드는 목록은 java.lang.refletc.Method 객체의 배열형태로 리턴된다.

step 2: 메소드의 분석

메소드 목록을 구했으므로 이제 해야할 일은 메소드를 분석하여 프로퍼티의 값에 알맞은 요청 파라미터를 할당하는 것이다. 메소드를 분석할 때 필요한 정보는 다음과 같다.

  • 메소드의 이름 - 자바빈 프로퍼티의 값을 지정하는 것과 관련된 메소드의 이름은 setPropertyName()의 형태를 띈다.
  • 메소드가 전달받는 인자 개수 - setPropertyName() 형태의 메소드는 인자의 개수가 1개인 것이 일반적이다. 배열을 값으로 갖는 프로퍼티가 존재하긴 하지만, 이 글에서는 간단하게 메소드의 인자가 1개인 것만 처리하도록 한다.
이 두가지 정보는 각각 Method 클래스의 getName() 메소드와 getParameterTypes() 메소드를 사용해서 구할 수 있다. getParameterTypes() 메소드는 Class의 배열을 리턴한다. 즉, setSomeProperty1(String a) 라는 메소드는 길이가 1인 Class 배열을 리턴하게 되며, 전달받는 인자가 없을 경우에는 길이가 0인 Class의 배열을 리턴한다. 따라서, 메소드를 분석하는 코드의 기본 구조는 다음과 같은 형태를 띄게 된다.

    public static void setProperties(HttpServletRequest request, Object bean) {
        Class beanClass = bean.getClass();
        Method[] methods = beanClass.getDeclaredMethods();
        for (int i = 0 ; i < methods.length ; i++) {
            String name = methods[i].getName();
            Class[] parameters = methods[i].getParameterTypes();
            
            if (name.indexOf("set") == 0 && name.length() > 3 &&
                parameters.length == 1) {
                String propertyName = name.substring(3,4).toLowerCase() + 
                       ((name.length() > 4) ? name.substring(4) : "");
                ...
            }
        }
    }

위 코드에서 if 블럭은 메소드의 이름이 set 으로 시작하고 길이가 3 이상이고(즉, setSome의 형태를 띄고) 전달받는 인자의 길이가 1인 메소드를 프로퍼티와 관련된 메소드로 가정하고 필요한 작업을 수행하게 된다. if 블럭의 시작은 프로퍼티의 이름을 구하는 것으로 시작하는데 프로퍼티 이름은 메소드의 이름 중 set 이후의 문장을 사용하게 되는데, 이때 나머지 문장의 첫번째 글자는 소문자로 변경하게 된다. 즉, setCompanyName(String) 메소드로부터 추출된 프로퍼티 이름은 'companyName'이 된다.

step 3: set 메소드를 호출하여 파라미터의 값을 프로퍼티에 할당하기

메소드의 이름으로부터 프로퍼티 이름을 추출한 다음에는 프로퍼티와 동일한 이름을 갖는 파라미터의 값을 프로퍼티의 값으로 지정해주어야 한다. 여기서 파라미터의 값이 존재하는지의 여부를 판단하는 것은 매우 간단하다. 단순히, request.getParameter(propertyName) 메소드를 호출하여 리턴값이 null인지의 여부만 판단하면 된다.

문제는 setPropertyName() 메소드를 호출하는 것인데, 이는 Method 클래스의 invoke(Object obj, Object[] args) 메소드를 사용하면 된다. 예를 들어, 특정 Method 객체인 m이 setCompanyName(String name) 메소드를 나타낸다고 해 보자. 이 경우, 다음과 같은 형태의 코드를 사용하여 setCompanyName() 메소드를 호출할 수 있게 된다.

    
    SomeBean sb = new SomeBean(); // setCompanyName() 메소드를 정의하고 있는 빈 객체
    Class beanClass = sb.getClass();
    Method[] m = beanClass.getDeclaredMethods();
    for (int i = 0 ; i < m.length ; i++) {
        if (m.getName().compareTo("setCompanyName") == 0 &&
            m.getParameterTypes().length == 1) {
            Object[] param = new Object[] {"인자값" };
            m.invoke(sb, param);
        }
    }

Method의 invoke() 메소드의 첫번째 파라미터는 메소드를 호출할 객체를 나타내며, 두번째 파라미터는 메소드를 호출할 때 전달할 파라미터를 나타낸다. 즉, 위 코드는 sb.setCompanyName("인자값")을 실행한 것과 같은 결과를 나타낸다. 물론, setCompanyName() 메소드를 직접 호출할 때에 비해 몇배로 코드 분량이 증가하지만, 프로퍼티의 이름을 알 수 없는 상황에서 동적으로 메소드를 호출하기 위해서는 위와 같이 리플렉션을 사용할 수 밖에 없다.

이제 위 코드를 우리가 하고자 하는 곳에 적용해보자. set 메소드를 호출하여 프로퍼티의 값을 파라미터의 값으로 지정해주는 것은 다음과 같은 코드를 통해서 이루어진다.

    if (name.indexOf("set") == 0 && name.length() > 3
        && parameters.length == 1) {
        String propertyName = name.substring(3,4).toLowerCase() + 
               ((name.length() > 4) ? name.substring(4) : "");
        // 첫번째 단계 - 파라미터 감사
        String paramVal = request.getParameter(propertyName);
        if (paramVal != null) {
            try {
                // 두번째 단계 - 메소드 호출시 전달할 인자 배열 생성
                Object[] callParameter = null;
                if (parameters[0] == String.class) {
                    callParameter = new Object[] { paramVal };
                } else if (parameters[0] == int.class) {
                    callParameter = new Object[] { new Intenger(paramVal) };
                }                
                // 세번째 단계 - 메소드 호출
                if (callParameter != null) {
                    methods[i].invoke(bean, callParameter);
                }
            } catch(Exception ex) {
                // 예외가 발생할 경우 단순히 다음 메소드로 넘어간다.
            }
        }
    }

위 코드는 크게 3가지 단계로 구성되어 있다. 첫번째 단계는 프로퍼티이름과 같은 이름을 갖는 파라미터가 존재하는 지 판단하는 단계이며, 두번째 단계는 파라미터의 타입에 따라 알맞게 메소드 호출시 전달할 인자 배열을 생성하는 것이며, 마지막으로 세번째 단계는 실제 메소드를 호출하는 것이다. 여기서 눈여겨 볼 부분은 두번째 단계인 인자 배열을 생성하는 단계이다.

메소드 호출시 넘겨줄 인자의 타입은 앞에서 Method.getParameterTypes() 메소드를 사용하여 구할 수 있다고 하였는데, 이때 넘겨줄 인자 배열의 값들은 이 타입에 맞춰서 생성해주어야 한다. 위 코드의 경우는 String 클래스와 int 기본데이터 타입에 대해서만 검사를 하였는데, 보다 범용적으로 동작하기 위해서는 자바빈 컴포넌트에서 많이 사용되는 나머지 데이터 타입도 지원해야 할 것이다. 참고적으로 위 코드를 보면 int 타입을 나타내는 클래스가 존재한다는 것을 알 수 있는데(int.class), 나머지 기본 데이터 타입 역시 각 타입에 대한 클래스가 존재하므로 알아두길 바란다.

완전한 소스 코드와 사용방법

지금까지 부분적으로 내용을 살펴보았는데, 이제 완전한 소스 코드를 살펴보도록 하자. 완전한 소스 코드는 다음과 같다.

  package bean.util;
  
  import javax.servlet.http.HttpServletRequest;
  import java.lang.reflect.*;
  
  /**
   * HttpServletRequest의 파라미터값을 자바빈의 특정 프로퍼티값에
   * 저장해준다.
   * <p>
   * 기본적으로 파라미터와 프로퍼티는 이름을 사용하여 매핑한다.
   * 매핑할 때 프로퍼티의 타입에 따라서 파라미터의 값에
   * 다음과 같은 제약사항이 따른다.
   * <ul>
   *  <li>String - 제약사항없음
   *  <li>int/long/double/float - Integer.parseInt(), Long.parseLong(), 
   *  Double.parseDouble(), Float.parseFloat()를 수행하여 에러가 발생하면 안 된다.
   *  <li>boolean - 파라미터값이 대소문자구분없이 "true"일 경우에만 true
   * </ul>
   *
   * @author 최범균 [madvirus@tpage.com]
   */
  public final class BeanPropertySetter {
     
     /**
      * request의 파라미터를 bean에 복사한다.
      * 이때 빈의 프로퍼티와 같은 이름을 갖는 파라미터의 값만 복사된다.
      * @param request HTTP 요청
      * @param bean 자바빈
      * @param nullToempty 파라미터의 값이 null일 경우 ""를 값으로 할지의 여부
      * @param useTrim String.trim() 메소드를 사용할지의 여부.
      */
     public static void setProperties(HttpServletRequest request, Object bean,
                             boolean nullToempty, boolean useTrim) {
        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) : "");
              // 프로퍼티 이름과 동일한 이름의 파라미터값을 구한다.
              String paramValue = request.getParameter(propertyName);
              if (paramValue != null) {
                 try {
                    // 값이 null이 아닌 경우에만 빈의 메소드 호출
                    Object[] callParameter = null; // 메소드를 호출할 때 전달한 인자
                    
                    if (parameters[0] == String.class) {
                       callParameter = new Object[] {
                          (useTrim ? paramValue.trim() : paramValue) };
                    } else if (parameters[0] == int.class) {
                       callParameter = new Object[] { new Integer(paramValue) };
                    } else if (parameters[0] == long.class) {
                       callParameter = new Object[] { new Long(paramValue) };
                    } else if (parameters[0] == double.class) {
                       callParameter = new Object[] { new Double(paramValue) };
                    } else if (parameters[0] == float.class) {
                       callParameter = new Object[] { new Float(paramValue) };
                    } else if (parameters[0] == Boolean.class) {
                       callParameter = new Object[] { new Boolean(paramValue) };
                    }
                    
                    if (callParameter != null) {
                       // 메소드를 호출한다.
                       methods[i].invoke(bean, callParameter);
                    }
                 } catch(Exception ex) {
                    ex.printStackTrace();
                    // 예외가 발생하면 단순히 넘어간다.
                    // 예를 들어, int 타입의 프로퍼티에 'abc'와 같은 문자열을
                    // 값으로 사용한 경우는 기본값 그대로 사용한다.
                 }
              } else {
                 // 파라미터 값이 null일 경우
                 // 프로퍼티 타입이 String이고 nullToEmpty 인자가 true라면
                 // ""를 값으로 지정한다.
                 if (parameters[0] == String.class) {
                    try {
                       methods[i].invoke(bean, new String[] {""});
                    } catch(Exception ex) {
                       // do nothing;
                    }
                 }
              }
           }
        }
     }
  }

위의 완전한 소스 코드에는 앞에서 설명하지 않았던 부분이 추가되었는데, 그것은 바로 파라미터의 값이 null일 경우 그에 해당하는 빈 프로퍼티의 값을 공백문자열("")로 지정하는 것과 문자열의 양쪽에 있는 공백문자를 제거(trim())하는 부분이다. 이 두 가지 기능은 setProperties() 메소드의 세번째와 네번째 인자를 통해서 사용할 지의 여부가 결정된다.

실제 사용

BeanPropertySetter 클래스의 사용 방법은 매우 간단하다. 예를 들어, 다음과 같은 폼이 있다고 해 보자.

    <form action="register">
    ID   : <input type="text" name="<b>id</b>">
    이름 : <input type="text" name="<b>name</b>">
    암호 : <input type="password" name="<b>password</b>">
    <input type="submit" value="전송">
    </form>
    

위 폼을 통해서 입력받은 데이터를 넣을 자바빈 객체는 다음과 같은 메소드를 정의하고 있다고 해 보자.

    public class MemberBean {
        public void setId(String id);
        public void setName(String name);
        public void setPassword(String password);
        
        public String getId();
        public String getName();
        public String getPassword();
    }

이제 위의 폼에서 데이터를 입력받는 서블릿을 생각해보자. BeanPropertySetter 클래스를 사용할 경우 빈 객체에 HTTP 요청 파라미터의 값을 넣는 것은 다음과 같이 간단하게 처리된다.

    public class RegisterServlet extends HttpServlet {
        public void doGet(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException {
            MemberBean mb = new MemberBean();
            BeanPropertySetter.setProperties(req, mb, false, false);
            ...
            ...
        }
    }

만약 이 부분은 request.getParameter() 메소드를 사용해서 처리한다면 다음과 같이 될 것이다.

    public class RegisterServlet extends HttpServlet {
        public void doGet(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException {
            MemberBean mb = new MemberBean();
            mb.setId(request.getParameter("id"));
            mb.setName(request.getParameter("name"));
            mb.setPassword(request.getParameter("password"));
            ...
            ...
        }
    }

이렇게 처리할 경우 코딩량도 늘어날 뿐만 아니라 파라미터의 이름을 잘못 입력하는 등의 사소한 실수 때문에 찾기 어려운 버그를 만들 가능성도 증가하게 된다. 이는 BeanPropertySetter 클래스를 사용하면 코딩량이 줄어들 뿐만 아니라 버그의 발생 가능성도 줄어든다는 것을 의미한다.

결론

이번 글에서는 Relfection을 사용하여 HTTP 요청 파라미터의 값을 자바빈 프로퍼티의 값에 할당해주는 방법에 대해서 살펴보았다. 모델 2 구조가 일반화되면서 서블릿의 역할이 중요해지는 만큼 HTTP 요청의 값을 빈 프로퍼티의 매핑시켜주는 기능은 편리한 개발을 위해서는 필수적인 기본 기능이라 할 수 있다. 이와 함께 또 하나 필요한 기본 기능이 있다. 그것은 바로 ResultSet에 있는 결과값을 빈 프로퍼티에 매핑시켜주는 기능이다. 다음 글에서는 바로 ResultSet과 빈 프로퍼티 사이의 매핑을 처리하는 방법에 대해서 알아보기로 하자. 물론, 다음글에서도 Reflection을 사용하여 필요한 기능을 구현할 것이다.

Posted by 최범균 madvirus

댓글을 달아 주세요

리플렉션 API를 사용하여 런타임에 동적으로 코드를 교체할 수 있는 방법에 대해 알아본다.

리플렉션과 런타임 코드 교체

클래스는 기본적으로 기능을 모아 놓은 집합이며, 특정한 규칙에 따라 기능을 제공하는 개체 타입이다. 대부분의 프로그램에서 어떤 클래스를 사용하기 위해서는 그 클래스가 어떤 규칙을 제공하는 지 알고 있어야만 한다. 그러한 규칙(또는 계약; contract)은 메소드와 필드의 형태로 표현된다.

자바에서, 프로그램이 사용하는 클래스의 기능과 관련된 정보는 컴파일 타임(compile-time)에 정해지며, 자바는 철저하게 이러한 규칙들을 검사하고 확인한다. 이것을 통해서 자바컴파일러(javac)는 클래스를 읽고 이해할 수 있으며, 또한 객체 사이에 주고 받는 메시지의 타입을 알 수 있다. 예를 들어, 다음과 같은 Callee라는 클래스가 있다고 해보자.

class Callee {
   .
   .
   
   public void doIt () throws CantDoException {
      ...
   }
   ....
}

그리고 Callee 클래스의 객체를 갖고 그것의 doIt() 메소드를 호출하는 Caller 클래스가 다음과 같다고 해 보자.

class Caller {
   Callee callee;
   
   public void someMethod() {
      .....
      callee.doIt();
      .....
   }
}

CanDoException이 RuntimeException을 상속받은 것이 아니라고 가정할 경우, Caller를 컴파일할 때 컴파일러는 CanDoException을 잡아서(catch) 처리하거나 또는 메소드의 throws 구문에 추가해야 한다는 에러 메시지를 출력할 것이다. 여기서 컴파일러는 클래스가 제공하는 규칙(여기서는, doIt() 메소드의 선언)에 따라서 에러 메시지를 출력하는 것이다.

하지만, 컴파일 타임에 클래스가 어떤 기능을 제공하는 지 알 수 없는 경우 어떻게 해야 할까? 런타임(Run-time)에 그러한 기능들을 발견할 수 있을까? 자바에서는 런타임에 클래스를 분석할 수 있도록 하기 위해 리플렉션(Reflection) API를 제공하고 있다. 이 API들은 java.lang.reflect 패키지에 정의되어 있으며, 또한, java.lang 패키지의 Class 클래스도 리플렉션 API와 함께 사용된다. 리플렉션 API는 런타임에 클래스가 제공하는 기능들(메소드와 필드)을 알 수 있도록 해 준다. 따라서, 디버거, 인터프리터, 프로파일러 그리고 위지윅(WYSIWYG) GUI 개발툴과 같이 런타임에 클래스를 분석해야 하는 어플리케이션에서 주로 리플렉션 API를 사용한다.

위에서 언급한 어플리케이션을 개발하는 경우가 아니면 리플렉션을 사용해야 하는 경우가 많지는 않다. 하지만, 때때로 컴파일 타임에 메소드 시그너쳐(signature)나 필드 등을 알 수 없는 클래스를 사용하여 작업을 해야 하는 경우가 존재하며, 이러한 경우에는 리플렉션을 사용할 수 밖에 없다.

자바빈 프레임워크는 실제로 빈을 빈박스와 같은 빈을 인식하는 콘테이너에 설치하기 위해서 리플렉션 메커니즘을 사용한다.

예를 들어, 다음과 같은 조건을 만족시켜야 하는 어플리케이션을 개발해야 한다고 가정해보자.

  • 중단없이 24시간 지속적으로 운행되어야 한다.
  • 데이터가 입력되면 특정한 알고리즘을 사용하여 데이터를 처리한다.
위의 두 조건을 만족시켜야 하는 것으로 댐에서 수위를 검사하여 방수량을 결정하는 어플리케이션을 예로 들 수 있다. 댐에 있는 센서로부터 수위에 대한 데이터가 이 어플리케이션에 전달되면, 이 어플리케이션은 특정한 알고리즘을 사용하여 방수량을 조절할 필요가 있는 지 판단하게 된다. 이 어플리케이션은 24시간 지속적으로 동작해야하며, 만약 특정 순간에 동작하지 않게 되면, 댐 주변은 홍수와 같은 재해를 입을 수도 있다.

수위와 방수량 조절과 관련된 알고리즘은 언제든지 변할 수 있다. 누군가가 더 좋은 알고리즘을 발명할 수도 있기 때문이다. 이런 경우, 어플리케이션이 런타임에 동적으로 알고리즘을 변경할 수 없다면, 알고리짐을 변경하기 위해서 어플리케이션의 동작을 중지시켜야 한다. 하지만, 어플리케이션의 동작을 중지시키면 안 되기 때문에, 동적으로 알고리즘을 변경할 수 없는 어플리케이션은 기존의 좋지 않은 알고리즘을 계속해서 사용해야만 한다. 이처럼 런타임에 동적으로 코드를 교체하고자 할 때 사용되는 것이 리플렉션이다.

리플렉션이 실제로 어떻게 동작하는 지 살펴보기전에 Algorithm이라는 인터페이스를 정의해보자. Algorithm 인터페이스는 알고리즘을 구현하는 모든 클래스가 implements 하는 인터페이스라고 가정한다. Algorithm 인터페이스는 다음과 같다.

interface Algorithm {
   public void process();
}

Class.forName()을 이용한 코드의 교체

이제부터 리플렉션을 파고 들어가보자. 교체가능한 코드를 작성하는 가장 손쉬운 방법은 Class 클래스의 newInstance() 메소드를 사용하는 방법이다. 다음 코드를 살펴보자.

Algorithm algo;
...
String className = ...;
algo = (Algorithm)Class.forName(className).newInstance()
algo.process();

Class.forName(String) 메소드는 파라미터로 전달받은 이름을 갖는 클래스와 관련된 Class 클래스의 객체를 생성한다. 예를 들어, 위 코드에서 className의 값이 "com.xyz.someAlgo"라면 Class.forName(className)에 의해 생성되는 Class 클래스의 객체는 com.xyz.SomeAlgo 클래스를 나타낸다. Class 클래스의 newInstance() 메소드는 Class 클래스가 나타내는 클래스의 인스턴스를 생성한다. 즉, com.xyz.SomeAlgo와 관련된 Class 객체의 newInstance() 메소드는 com.xyz.SomeAlgo 클래스의 인스턴스를 생성하는 것이다.

동적으로 코드를 교체하는 간단한 예제를 살펴보자. 여기서 살펴볼 예제는 WaterLevelTracer 클래스로서, 이 클래스는 일정 주기마다 지정된 알고리즘을 수행한다. WaterLevelTracer 클래스는 다음과 같다.

import java.io.*;

public class WaterLevelTracer implements Runnable {
   private final Algorithm DEFAULT_ALGORITHM = new Algo1();
   
   private Algorithm algo;
   private Thread thread;
   
   public WaterLevelTracer(Algorithm algo) {
      if (algo == null) {
         this.algo = DEFAULT_ALGORITHM;
      } else {
         this.algo = algo;
      }
   }
   
   public void start() {
      thread = new Thread(this);
      thread.start();

      try {
         BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
         
         while(true){
            System.out.println("교체할 Algorithm 클래스를 입력하세요.");
            String className = br.readLine();
            
            if(className.length()==0)
               continue;
            else if (className.equals("!quit")) {
               thread.interrupt();
               break;
            }
            // 알고리즘 런타임에 동적으로 교체
            algo = (Algorithm) ((Class.forName(className)).newInstance());
         }
      } catch(Exception ex) {
         ex.printStackTrace();
      }
   }   
   
   public void run() {
      try {
         while(true) {
            Thread.sleep(3000);
            algo.process();
         }
      } catch(InterruptedException ex){}
   }
   
   public static void main(String[] args) {
      new WaterLevelTracer(null).start();
   }
}

WaterLevelTracer 클래스는 쓰레드를 생성하며, 그 쓰레드는 3초 간격으로 지정된 알고리즘의 process() 메소드를 수행한다. 이 알고리즘을 동적으로 변환해주는 부분은 WaterLevelTracer 클래스의 start() 메소드로서, start() 메소드를 보면 키보드로부터 알고리즘으로 사용할 클래스의 이름을 입력받은 후, Class.forName() 메소드를 사용하여 동적으로 알고리즘을 교체하는 것을 알 수 있다. Algo1 클래스와 Algo2 클래스가 다음과 같다고 해 보자.

public class Algo1 implements Algorithm {
   public void process() {
      System.out.println("Algo1에서 처리");
   }
}

public class Algo2 implements Algorithm {
   public void process() {
      System.out.println("Algo2에서 처리");
   }
}

이 경우, WaterLevelTracer를 실행하면 다음과 같은 결과가 출력될 것이다.

c:\test>java WaterLevelTracer
교체할 Algorithm 클래스를 입력하세요.
Algo1에서 처리
Algo1에서 처리
Algo2
교체할 Algorithm 클래스를 입력하세요.
Algo2에서 처리
Algo2에서 처리
Algo2에서 처리
!quit

위에서 중간에 있는 "Algo2"는 키보드를 통해서 입력한 것이다. 키보드를 사용하여 "Algo2"를 입력한 후, WaterLevelTracer가 알고리즘으로 Algo1 클래스가 아닌 Algo2 클래스를 사용하는 것을 볼 수 있다.

리플렉션 API의 사용

이제 다시 원래 우리가 하고자 했던 것을 생각해보자. 이 글을 시작할 때 주목적은 컴파일타임에 클래스와 관련된 메소드와 필드를 모르는 상태에서 런타임에 코드를 교체가능하도록 하는 것이었다. 하지만, 앞에 있는 WaterLevelTracer 클래스는 이미 알고리즘으로 사용할 클래스가 Algorithm 인터페이스에 정의된 기능(또는 행동; behavior)을 제공하다는 사실을 알고 있다. (즉, process() 메소드를 호출할 수 있다는 것을 알고 있다.) 이것은 이미 컴파일타임에 클래스에 대한 정보가 알려졌다는 것을 의미한다.

진정으로 컴파일타임에 클래스에 대한 정보를 알지 못한 상태에서, 런타임에 코드를 교체가능하도록 만들기 위해서는 단순히 Class 클래스의 forName()과 newInstance() 메소드 뿐만 아니라 Class 클래스에서 제공하는 또 다른 메소드인 getMethod(), getMethods(), getInterfaces(), getFiled()와 같은 메소드를 사용해야만 한다. 실제로 자바빈 컴포넌트 프레임워크에서 사용되는 인트로스펙터(introspector)는 이러한 메소드를 사용하여 클래스를 분석한다. Class 클래스의 getMethods()와 같은 메소드를 사용함으로써 우리는 클래스에 있는 각 메소드의 파라미터와 리턴타입을 조사할 수 있고, 메소드 중에서 알고리즘을 사용하고자 하는 것을 호출할 수 있다.

알고리즘을 수행할 때 사용할 process() 메소드가 파라미터로 InputParam 타입을 전달받고 리턴타입이 ReturnValue라고 해보자. 이 경우 다음과 같이 리플렉션 API를 사용하여 파라미터로 InputParam 객체를 입력받고 리턴 타입이 ReturnValue인 메소드를 호출할 수 있게 된다.

Class klass = Class.forName(className);
Method[] methods = klass.getDeclaredMethods();
Class[] params;
for (int i = 0 ; i < methods.length ; i++) {
   params = methods[i].getParameterTypes();
   if (methods[i].getReturnType().getName().equals("ReturnValue") &&
       params.length() == 1 && params[0].getName().equals("InputParam") ) {
      
      retVal = (ReturnValue) (methods[i].invoke(klass.newInstance(), paramObj);
   }
}

앞에서 Class.forName() 만으로 거의 모든 과정이 끝났던 것에 비하면, 위 코드는 더욱 복잡해진 것을 알 수 있다. 위 코드는 Class 객체의 getDeclaredMethods() 메소드를 호출하여 forName() 메소드를 통해서 구한 Class 객체에 선언(또는 정의)되어 있는 메소드를 구한다. 이때 각각의 메소드는 Method 클래스를 통해서 표현된다. 메소드 목록을 구하면, for 문을 사용하여 각 Method의 시그너쳐(signature)를 구한다. 메소드의 시그너쳐는 Method 클래스의 getParameterTypes() 메소드와 getReturnType() 메소드를 통해서 구한다. 그런 후, 메소드 중에 파라미터의 타입이 InputParam이고, 리턴 타입이 ReturnValue인 것을 발견할 경우 Method.invoke(Object, Object[])를 호출함으로써 Method와 관련된 메소드를 호출하게 된다.

위 코드는 어떤 작업을 처리하기 위해 사용하는 인터페이스나 클래스의 이름을 전혀 알지 못한다. (앞의 코드는 Algorithm 인터페이스를 사용한다는 것을 코드 내에서 알고 있었다는 사실과 비교해보라!) 심지어 작업을 처리할 때 호출할 메소드의 이름조차 명시되어 있지 않다. 단지, 파라미터의 타입과 리턴 타입만 명시되어 있을 뿐이다. 즉, 위 코드는 모든 것이 런타임에 동적으로 처리되고 있으며, 진정으로 교체 가능한 코드를 제공하는 방법인 것이다.

java.lang.reflect.Proxy

실제로 JDK1.3에 새롭게 추가된 다이나믹 프록시(Dynamic Proxy) API는 동적인 프록시가 갖는 기능을 제공하기 위해서 이와 비슷한 검색 메커니즘을 사용하고 있다. 다이나믹 프록시 API를 사용하여 개발자는 프록시 객체를 생성할 수 있는데, 프록시 객체는 그것이 처리해야 하는 어떤 인터페이스에 대한 정보를 갖고 있다. 프록시 객체는 그것이 처리할 인터페이스의 특정한 메소드를 호출한다. 프록시 객체는 또한 최종적으로 호출되는 메소드를 포함하고 있는 객체에 대한 정보를 갖는다. 최종적으로 사용되는 객체는 InvocationHandler 인터페이스를 구현한다. InvocationHandler 인터페이스는 다음과 같은 한 개의 메소드만을 갖고 있다.

public Object invoke (Object proxy, Method m, Object[] args) throws Throwable

여기서 proxy는 생성된 프록시 객체에 대한 레퍼런스이다. invoke() 메소드는 런타임에 호출되며, 클라이언트 뿐만 아니라 구현자도 invoke() 메소드에 대해 알지 못한다. 다음은 InvocationHandler 인터페이스를 구현한 예제이다.

public class MyHandler implements java.lang.reflect.InvocationHandler {
   // 이는 딜리게이트 객체이며, 사용자가 정의한 객체가 될 수 있다.
   private Object delegate;
   
   public MyHandler (Object obj) {
      this.delegate = obj ;
   }
   
   public Object invoke (Object proxy, Method  m, Object[] args) throws Throwable {
      try {
         
         // 실제로 딜리게이트 객체의 메소드가 호출될 것이다.
         
      } catch(InvocationTargetException ex) {
         throw ex.getTargetException();
      } catch(Exception ex) {
         throw ex;
      }
      // 특정한 값 리턴
   }
}

예를 들어, 딜리게이트 객체가 세 개의 인터페이스 A, B, C를 구현한다고 해 보자. 이때, 다이나믹 프록시 API를 사용하면 이 인터페이스들 중의 하나의 타입과 관련된 프록시 객체를 생성할 수 있다. 예를 들면 다음과 같다.

A a = (A) java.lang.reflect.Proxy.newProxyInstance(
                                  A.class.getClassLoader(),
                                  new Class[] { A.class, B.class, C.class },
                                  new MyHanlder(delegate);

위와 비슷한 방법으로 B와 C 타입의 레퍼런스를 할당할 수도 있다. 인터페이스 A에 doSomething() 메소드가 선언되어 있다고 가정할 경우, 클라이어?가 "a.doSomething()"를 실행하면 그 호출은 실제로 InvocationHandler 인스턴스에 전달된다. 위의 경우는 MyHandler 객체에 포워드(forwarding)된다. 실제로 호출이 포워드되기 전에, 리?렉션 API를 사용하여 newProxyInstance() 메소드의 두번째 파라미터를 통해서 프록시에 제공된 모든 인터페이스를 검사함으로써 그 Method에 대한 레퍼런스를 구한다. 이 Method 레퍼런스와 그것에 대한 파라미터는 핸들러의 invoke() 메소드에 전달되며, 핸들러의 invoke() 메소드는 실제로 딜리게이트 객체에 있는 메소드를 호출하거나 또는 그 외의 원하는 것을 실행할 것이다.

결론

필자가 생각하기엔 런타임에 동적으로 클래스를 교체할 수 있는 가장 좋은 방법은 이 글에서 설명한 리플렉션 API를 사용하는 것이다. 리플렉션 API를 사용함으로써 우리는 컴파일타임에 클래스가 제공하는 자세한 정보를 알 수 없는 상황에서 런타임에 동적으로 클래스를 교체할 수 있게 된다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요