주요글: 도커 시작하기
반응형
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이다. 여러분들도 리플렉션을 활용하여 좀더 편리한 개발을 할 수 있기를 바라며 이번 연재를 마친다.

+ Recent posts