JAVA

[JDBC] 올바른 JDBC 프로그래밍

예나부기 2021. 8. 30.

초보 개발자들이 범하기 쉬운 잘못된 JDBC 프로그래밍 형태와 이에 대응하는 올바른 JDBC 프로그래밍에 대해서 알아본다.

잘못된 예외 처리

JDBC를 처음 접하는 사람들은 대부분 기초 서적에 나와 있는 코딩 스타일을 따라하게 되며, 이러한 책 중 다수가 다음과 같은 형태의 코딩 스타일을 독자들에게 알려주고 있다.

String userId = .. // 어떤 값을 할당
try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    Connection conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery(
        "select name from Member where id='"+userId+"'");
    if (rs.next()) {
        ....
    }
    ...
    rs.close();
    stmt.close();
    conn.close();
} catch(SQLException ex) {
    // 어떤 처리를 한다.
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
} catch(... ) {
    // 기타 예외 처리
}


지금 이 글을 보고 있는 사람들 역시 위와 같은 형태로 코딩을 하는 사람들이 많을 것이다. 위와 같은 코딩 스타일을 사용하는 사람들은 대부분 문제점이 없다고 생각하며, 실제로도 문제가 발생하는 경우는 드물다고 할 수 있다. 하지만, 위 코드는 ResultSet, Statement, Connection의 close() 메소드가 호출되지 않을 수도 있다는 문제점을 안고 있다. 예를 들어, 현재 생성된 Statement의 개수가 DBMS가 제한하고 있는 Statement의 개수(즉, 커서의 개수)와 같다고 하자. 이 경우 위 코드의 conn.createStatement() 메소드는 Statement 객체를 생성할 수 없으므로 SQLException을 발생할 것이다. 그렇다면 catch 부분에서 SQLException이 처리된다. 여기서부터 문제점이 발생한다. 여기서 예외가 발생한 시점은 createStatement() 메소드를 호출하는 순간이며, 곧 바로 catch 블럭을 수행하게 된다. 즉, try .. catch .. 블럭이 마지막으로 수행해야 하는 conn.close()를 수행하지 않게 되는 것이다.

Connection 객체의 close() 메소드를 호출하지 않는다면 어떤 문제가 발생할까? 가장 먼저 발생하는 문제는 한정된 시스템 자원을 낭비하게 된다는 점이다. 데이터베이스와의 연결 역시 일정한 시스템 자원을 차지하고 있으며, 이 자원은 close() 메소드를 호출할 때 까지는 반환되지 않는다. 따라서 위와 같이 중간에 예외가 발생하여 close() 메소드를 호출할 수 없는 경우 그 Connection 객체는 계속해서 데이터베이스 연결된 상태로 남아 있게 되며, 결국 그 데이터베이스 연결은 쓸데없이 시스템 자원만을 차지하게 된다. 즉, 시스템 자원을 효율적으로 사용하기는 커녕 오히려 낭비하게 되는 것이다. 만약, 예외가 자주 발생한다면 그 만큼 낭비되는 시스템 자원은 늘어나게 되며, 결국 어플리케이션이 사용할 수 있는 자원이 모자란 상황이 발생하게 된다.

이처럼 자원 부족 현상이 발생하지 않도록 하기 위해서는 예외의 발생 여부에 상관없이 사용한 모든 자원(Connection, Statement, PreparedStatement, ResultSet)의 close() 메소드를 호출할 수 있도록 해야 한다. 이를 하기 위해서는 try .. catch .. finally 블럭을 사용하면 된다. finally 블럭은 try { .. } catch 블럭에서 예외가 발생했는지의 여부에 상관없이 항상 실행된다. 따라서, finally 블럭은 사용한 모든 자원을 반납(close)하기에 가장 알맞은 곳이다. finally 블럭을 사용하여 위 코드를 재구성하면 다음과 같다.

String userId = .. // 어떤 값을 할당
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;

try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    stmt = conn.createStatement();
    rs = stmt.executeQuery(
             "select name from Member where id='"+userId+"'");
    if (rs.next()) {
        ....
    }
} catch(SQLException ex) {
    // 어떤 처리를 한다.
    ....
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
    ....
} catch(Exception ex) {
    // 기타 예외 처리
} finally {
    if (rs != null) try { rs.close(); } catch(SQLException ex) {}
    if (stmt != null) try { stmt.close(); } catch(SQLException ex) {}
    if (conn != null) try { conn.close(); } catch(SQLException ex) {}
}


close() 메소드를 호출하는 부분이 모두 finally 블럭에 들어간 것을 알 수 있다. 이렇게 finally 블럭에 자원을 반환하는 부분을 위치시킴으로써 예외의 발생 여부에 상관없이 안전하게 자원을 반납할 수 있게 된다. 자원을 안전하게 반환할 수 있다는 것은 여러분이 작성한 어플리케이션이 좀 더 안정적으로 동작하게 된다는 것을 의미한다.

Statement vs. PreparedStatement

이제 자원의 반환과 관련된 문제는 해결되었다. 하지만, 아직도 위 코드는 문제를 안고 있다. 위 코드에서 executeQuery() 메소드를 실행하는 부분을 다시 한번 살펴보자.

rs = stmt.executeQuery("select name from Member where id='"+userId+"'");


여기서 userId가 "era13"이라면 실제로 실행하는 SQL 문은 다음과 같다.

select name from Member where id='era13'


이 문장은 아무 이상이 없는 SQL 문장이다. 하지만 만약 userId가 따옴표(')를 포함하고 있는 "era'13"이라면, 실행하는 SQL 문은 다음과 같이 된다.

select name from Member where id='era'13'


SQL 표준에서 따옴표(')는 특별한 용도로 쓰이며, 따라서 SQL 문장내에 있는 따옴표(')는 알맞게 변경해주어야 한다. 일반적으로 대부분의 DBMS는 위와 같이 문자열이 따옴표를 포함하고 있는 경우 다음과 같이 표현하도록 하고 있다.

select name from Member where id='era''13'


즉, 따옴표를 연속적으로 두 개 사용함으로써 따옴표를 표현하는 것이다. 따옴표를 표시하는 방법은 DBMS마다 다르므로 각각의 DBMS에 알맞게 일일이 따옴표 부분을 변경해주어야 한다. 이것이 어려운 일은 아니지만 귀찮은 일이다. 이처럼 문자열속에 특수 문자가 들어가 있는 경우에는 Statement 대신 PreparedStatement를 사용하는 것이 훨씬 더 안전하다. 예를 들어, 지금까지 살펴본 코드를 PreparedStatement를 사용하여 변경하면 다음과 같이 된다.

String userId = .. // 어떤 값을 할당
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;

try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    pstmt = conn.prepareStatement("select from name from Member where id=?");
        pstmt.setString(1, userId);
    rs = pstmt.executeQuery();
    
    if (rs.next()) {
        ....
    }
} catch(SQLException ex) {
    // 어떤 처리를 한다.
    ....
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
    ....
} catch(Exception ex) {
    // 기타 예외 처리
} finally {
    if (rs != null) try { rs.close(); } catch(SQLException ex) {}
    if (pstmt != null) try { pstmt.close(); } catch(SQLException ex) {}
    if (conn != null) try { conn.close(); } catch(SQLException ex) {}
}


이와 같이 PreparedStatement를 사용하면 개발자가 일일이 SQL 문장에 있는 특수 문장을 변경해줄 필요가 없으며, 단순히 PreparedStatement 클래스에서 제공하는 setString()이나 setInt(), setObject()와 같은 메소드를 사용하여 값을 지정하면 된다. (PreparedStatement에 대한 자세한 내용은 자바 관련 서적을 참고하기 바란다.) 이처럼 개발자가 일일이 변경해줄 필요가 없으므로써 예상치 못했던 에러가 발생할 확률도 그만큼 줄어들게 된다.

JDBC 드라이버의 로딩

이제 SQL 문장에서 발생할 수 있는 문제도 PreparedStatement를 사용하여 해결할 수 있게 되었다. 하지만, 위 코드는 여전히 문제점을 안고 있다. 위 코드가 회원의 이름을 구해주는 getMemberName() 이라는 메소드의 일부분이라고 생각해보자. getMemberName() 메소드는 다음과 같은 형태를 띌 것이다.

public String getMemberName(String userId) {
    String userName = null;
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    
    try {
        Class.forName("oracle.jdbc.driver.OracleDriver");
        
        conn = DriverManager.getConnection(
                         "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                         "user", "password");
        pstmt = conn.createStatement("select name from Member where id=?");
        rs = pstmt.executeQuery();
        
        if (rs.next()) {
            userName = rs.getString("name");
        }
    } catch(SQLException ex) {
        // 어떤 처리를 한다.
    } catch(ClassNotFoundException ex) {
        // 어떤 처리를 한다.
    } catch(Exception ex) {
        // 기타 예외 처리
    } finally {
        if (rs != null) try { rs.close(); } catch(SQLException ex) {}
        if (pstmt != null) try { pstmt.close(); } catch(SQLException ex) {}
        if (conn != null) try { conn.close(); } catch(SQLException ex) {}
    }
    return userName;
}


이제 회원의 이름을 사용하고 싶을 경우에는 getMemberName() 메소드를 사용할 것이며, 매번 호출이 있을 때 마다 getMemberName() 메소드는 Class.forName() 을 통해서 같은 JDBC 드라이버를 등록할 것이다. getMemberName() 메소드를 처음 호출할 때와 달리 두번째 이후부터는 JDBC 드라이버를 매번 다시 등록하는 결과를 낳는다. 이미 등록해서 다시 등록할 필요가 없는 JDBC 드라이버를 매번 등록해야 한다는 건 불필요한 일이다.

따라서 이처럼 JDBC 드라이버를 등록하는 부분은 어플리케이션의 초기화 부분에 옮겨놓는 것이 좋다. 즉, 다음과 같은 형태의 초기화 메소드가 있어서 그 메소드에서 처리하는 것이 좋다.

private void init() throws Exception {
    Class.forName("jdbcDriverClass");
}


이렇게 함으로써 getMemberName() 메소드에서 매번 JDBC 드라이버를 등록할 필요가 없어지며, 따라서 비록 적은 시간이긴 하지만 JDBC 드라이버를 등록하는 데 따른 불필요하게 낭비되는 시간을 없앨 수 있다.

결론

이 글에서는 JDBC 프로그래밍을 하면서 잘못하기 쉬운 부분에 대해 살펴보았다. 이 글에서 언급한 내용들만 충실히 지켜도 여러분이 작성한 어플리케이션이 엉뚱하게 실행되는 경우가 상당히 줄어들 것이다. 특히, 예외를 잘못 처리하여 Connection이나 Statement의 close() 호출되지 않아 데이터베이스와 관련된 자원이 낭비되는 경우는 발생하지 않을 것이다.

관련링크:

출처 : https://javacan.tistory.com/entry/8

댓글