본문 바로가기

Java/자바 성능 개선

12. DB를 사용하면서 발생 가능한 문제점들

💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.

들어가며

  간단한 프로젝트를 진행하던 중 DB를 사용하게 되었습니다. 그런데 DB를 사용하면서 예상치 못한 문제점들이 발생하였습니다. 가장 눈에 띄는 문제점은 자바 기반 애플리케이션의 성능에 있었습니다. 특히, 응답 속도를 지연시키는 주요 요인은 DB 쿼리 수행 시간과 그 결과를 처리하는 데 걸리는 시간이었습니다.

 

해당 교재의 저자는 애플리케이션에서의 DB 접속 관련 공지가 있다고 합니다.

그에 대한 주요 내용은 다음과 같았습니다.

 (1) DB Connection을 할 때는 반드시 공통 유틸리티를 사용해야 한다.

 (2) 각 모듈별 DataSource를 사용하여 리소스 부족 현상이 발생하지 않도록 해야 한다.

 (3) Connection, Statement 관련 객체, ResultSet은 반드시 close해야 한다.

 (4) 페이지 처리를 하기 위해 ResultSet 객체의 last() 메서드를 사용하지 말아야 한다.

 

 이러한 공지를 보고, 왜 이런 조치를 취해야 하는지 궁금증이 생겨서 이를 알아보기로 결정하였습니다. 이를 통해 DB를 효율적으로 활용하는 방법을 배우고, 애플리케이션의 성능을 향상시키는 데 도움이 될 수 있을 것으로 기대하였습니다.


1) DB Connection과 Connection Pool, DataSource

(1) DB 연결, 연결 풀(Connection Pool), 데이터 소스(DataSource)

JDBC API는 클래스가 아니라 인터페이스입니다. 이 java.sql 인터페이스는 JDK API에 포함되어 있으며, 각 DB 벤더가 상황에 맞게 구현하도록 되어 있습니다. 같은 인터페이스라 하더라도 각 벤더에 따라 처리 속도와 내부 처리 방식은 다릅니다.

 

DB에 연결하여 사용하는 일반적인 방식은 아래와 같습니다.

try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    Connection con = DriverManager.getConnection("jdbc:oracle:thin:@ServerIP:1521:SID", "ID", "Password");
    PreparedStatement ps = con.prepareStatement("SELECT ... where id = ?");
    ps.setString(1, id);
    ResultSet rs = ps.executeQuery();
    // 중간 데이터 처리 부분 생략
} catch(ClassNotFoundException e) {
    System.out.println("드라이버 load fail");
    throw e;
} catch(SQLException e) {
    System.out.println("Connection fail");
    throw e;
} finally {
    rs.close();
    ps.close();
    con.close();
}

 

이 예제에서 수행되는 절차는 아래와 같습니다.

 (1) 드라이버를 로드합니다.

 (2) DriverManager 클래스의 getConnection 메서드를 사용하여 DB 서버의 IP, ID, PW 등을 통해 Connection 객체를 생성합니다.

 (3) Connection을 통해 PreparedStatement 객체를 생성합니다.

 (4) executeQuery를 수행하여 그 결과로 ResultSet 객체를 생성하고 데이터를 처리합니다.

 (5) 모든 데이터 처리 후, finally 구문을 사용하여 ResultSet, PreparedStatement, Connection 객체들을 닫습니다. 각 객체를 닫을 때 예외가 발생할 수 있으므로 처리가 필요합니다.

 

 쿼리가 0.1초 소요된다면, 가장 느린 부분은 Connection 객체를 얻는 부분입니다. 이는 DB와 WAS 사이의 통신 때문입니다. 이러한 대기 시간을 줄이고, 네트워크 부담을 감소시키는 방법으로 DB Connection Pool을 사용합니다.

 

 하지만 이 기술이 처음 등장했을 때는 검증되지 않은 소스로 인해 많은 문제점이 있었습니다. 현재는 WAS에서 Connection Pool을 제공하고, DataSource를 이용해 JNDI로 호출할 수 있어 이러한 문제가 많이 줄었습니다. 따라서 가능한 한 안정된 WAS에서 제공하는 DB Connection Pool이나 DataSource를 사용하는 것이 좋습니다.

 

 DataSource와 DB Connection Pool의 차이점은 DataSource가 JDK 1.4부터 도입된 표준이며, Connection Pool로 연결을 관리하고 트랜잭션 관리도 가능하게 만듭니다. 따라서 DataSource는 DB Connection Pool을 포함한다고 볼 수 있습니다.


(2) Statement와 PreparedStatement

 Statement 인터페이스와 그 자식 클래스인 PreparedStatement는 거의 동일하게 사용할 수 있습니다. CallableStatement는 PL/SQL을 처리하기 위해 사용하는 PreparedStatement의 자식 클래스입니다.

 

Statement와 PreparedStatement의 가장 큰 차이점은 캐시 사용 여부입니다. Statement를 사용하면 매번 쿼리를 수행할 때마다 쿼리 문장 분석, 컴파일, 실행의 과정을 거치게 되지만, PreparedStatement는 처음 한 번만 이 세 단계를 거치고, 그 후에는 캐시에 담아서 재사용합니다. 따라서 동일한 쿼리를 반복적으로 수행한다면 PreparedStatement가 DB에 훨씬 적은 부하를 주며, 성능도 좋습니다. 또한 쿼리에서의 변수를 "로 묶어서 처리하지 않고 ?로 처리하기 때문에 가독성도 좋아집니다.


(3) 쿼리 수행 메서드

 쿼리를 수행하는 메서드로는 executeQuery(), executeUpdate(), execute() 등이 있습니다. executeQuery() 메서드는 select 관련 쿼리를 수행하고, 결과로 요청한 데이터가 ResultSet 객체의 형태로 전달됩니다. executeUpdate() 메서드는 select와 관련 없는 DML(INSERT, UPDATE, DELETE 등) 및 DDL(CREATE TABLE, CREATE VIEW 등) 쿼리를 수행하며, 결과는 int 형태로 리턴됩니다. execute() 메서드는 쿼리의 종류와 상관 없이 쿼리를 수행하며, 결과는 ResultSet이 아닌 boolean 형태의 데이터를 리턴합니다.


(4) ResultSet

 쿼리를 수행한 결과는 ResultSet 인터페이스에 담깁니다. 여러 건의 데이터가 넘어오기 때문에 next() 메서드를 사용하여 데이터의 커서를 다음으로 옮기면서 처리할 수 있습니다. 또한 first() 메서드나 last() 메서드를 이용하여 첫 커서나 마지막 커서로 이동할 수 있습니다. 데이터를 읽어오기 위해서는 get으로 시작하는 getInt(), getFloat(), getLong(), getBlob() 등의 메서드를 사용하면 됩니다.


2) DB를 사용할 때 닫아야 할 것들

 DB 작업을 수행할 때는 Connection, Statement, ResultSet 인터페이스 관련 객체들을 close() 메서드를 활용하여 종료해야 합니다. 일반적으로 이 객체들은 Connection, Statement, ResultSet 순서로 얻어지며, 반대로 ResultSet, Statement, Connection 순서로 종료됩니다. 즉, 먼저 얻은 객체를 마지막에 닫습니다.

 

ResultSet 객체가 종료되는 경우는 아래와 같습니다.

  • close() 메서드를 호출했을 때
  • GC의 대상이 되어 GC가 실행될 때
  • 관련된 Statement 객체의 close() 메서드가 호출될 때

 그렇다면 여기서 의문이 생길 수 있는데, GC가 실행되면 자동으로 닫히고, Statement 객체가 close되면 자동으로 닫히는데 굳이 close()를 해야 할까요?

 

 그렇습니다.

Connection, Statement, ResultSet 인터페이스 관련 객체들에서 close() 메서드를 호출하는 이유는 자동으로 종료되기 이전에 연관된 DB나 JDBC 리소스를 해제하기 위함입니다. 아무리 짧은 시간이라도 빠르게 닫히면, 그만큼 해당 DB 서버의 부담을 줄일 수 있습니다.

 

 Statement 객체가 종료되는 경우도 ResultSet과 비슷합니다. Statement 객체는 Connection 객체를 close() 해도 자동으로 닫히지 않습니다. 따라서 아래 두 가지 경우에만 닫히므로, 반드시 close() 메서드를 호출하여 종료해야 합니다.

  • close() 메서드를 호출했을 때
  • GC의 대상이 되어 GC가 실행될 때

마지막으로 Connection 인터페이스의 객체에 대해 알아봅시다. Connection 객체는 아래 세 가지 경우에 종료됩니다.

  • close() 메서드를 호출했을 때
  • GC의 대상이 되어 GC가 실행될 때
  • 치명적인 에러가 발생했을 때

 Connection은 대부분 Connection Pool을 통해 관리됩니다. 시스템이 시작되면 지정된 개수만큼 연결이 생성되며, 필요에 따라 추가로 연결이 생성됩니다.

 

 사용자가 많아져 더 이상 사용 가능한 연결이 없을 경우, 여유가 생길 때까지 대기하게 됩니다. 이후 일정 시간이 지나면 오류가 발생합니다. 그러므로 close() 메서드를 호출하여 연결을 종료해야 합니다. GC가 실행될 때까지 기다린다면, Connection Pool이 고갈되는 것은 시간 문제일뿐입니다.

 

따라서 아래와 같은 코드는 좋지 않은 방법입니다.

try {
    // 상단 부분 생략
    Connection con = ...;
    PreparedStatement ps = ...;
    ResultSet rs = ...;
    // 중간 생략
    rs = null;
    ps = null;
    con = null;
} catch(Exception e) {
    ...
}

 

 null로 치환하면 GC의 대상이 되긴 하지만, 언제 GC가 실행될지 모르므로 좋지 않은 방법입니다. 또한 아래와 같이 사용하는 것도 좋은 방법이 아닙니다.

try {
    // 상단 부분 생략
    Connection con = ...;
    PreparedStatement ps = ...;
    ResultSet rs = ...;
    // 중간 생략
    rs.close();
    ps.close();
    con.close();
} catch(Exception e) {
    ...
}

 

 예외가 발생하지 않으면 정상적으로 close되겠지만, 예외가 발생하면 어떤 객체도 close되지 않는다는 문제가 있습니다. 따라서 가장 적절한 방법은 아래와 같습니다.

Connection con = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
    // 상단 부분 생략
    con = ...;
    ps = ...;
    rs = ...;
    // 중간 생략
} catch(Excpetion e) {
    ...
} finally {
    try{rs.close();} catch(Exception rse){}
    try{ps.close();} catch(Exception pse){}
    try{con.close();} catch(Exception cone){}
}

이와 같이 처리해야 합니다.

 

 이러한 방식으로 작성된 메서드에 만약 throws 구문이 없다면 컴파일도 되지 않습니다.

또한, DB와 관련된 처리를 담당하는 관리 클래스를 만드는 것도 좋은 방법입니다. 일반적으로 DBManager라는 이름의 클래스를 많이 사용합니다.

 

 Connection 객체를 얻는 데에는 JNDI를 찾아서 (lookup하여) 사용하는 DataSource를 이용하며, 이 경우 ServiceLocator 패턴까지 적용하면 DB 연결 시의 시간을 크게 단축시킬 수 있습니다.


3) JDK 7에서 등장한 AutoClosable 인터페이스

 JDK 7부터 java.lang 패키지에 새롭게 도입된 인터페이스인 AutoClosable에 대해 알아보겠습니다. AutoClosable 인터페이스는 close()라는 메서드를 단 하나만 포함하고 있으며, 이 메서드의 리턴 타입은 void입니다.

 

close() 메서드의 주요 특징은 다음과 같습니다:

  • try~with~resource 문장을 통해 자동으로 close() 처리를 수행합니다.
  • InterruptedException을 발생시키지 않는 것이 권장됩니다.
  • 이 메서드를 두 번 이상 호출하면 눈에 띄는 부작용이 발생할 수 있습니다.

 이 중에서 가장 중요한 부분은 try~with~resource라는 새로운 구문입니다.

이는 JDK 7부터 도입된 기능으로, 예를 들어, 한 줄만 존재하는 파일을 읽는 작업을 수행한다고 가정해봅시다.

이전에는 finally 블록에서 close() 메서드를 호출해야 했으며, 이 작업은 다음과 같이 수행했습니다. (close() 메서드에서 예외가 발생할 수 있지만, 여기서는 메서드 선언부에서 throws로 예외를 던지는 방식을 선택했습니다.)

public String readFile(String fileName) throws Exception {
    FileReader reader = new FileReader(new File(fileName));
    BufferedReader br = new BufferedReader(reader);
    String data = null;
    try {
        data = br.readLine();
    } finally {
        if (br != null) br.close();
    }
    return data;
}

 

 그러나 JDK 7부터는 try 블록이 시작될 때 괄호 안에 close() 메서드를 호출해야 하는 객체를 생성하면, 아래와 같이 간단하게 처리할 수 있게 되었습니다.

public String readFileName(String fileName) throws IOException {
    FileReader reader = new FileReader(new File(fileName));
    try(BufferedReader br = new BufferedReader(reader)) {
        return br.readLine();
    }
}

 

 이렇게 하면, 별도로 finally 블록에서 close() 메서드를 호출할 필요가 없어집니다. 만약 close() 메서드를 호출해야 하는 대상이 여러 개라면, 세미콜론으로 구분하여 try~with~resource 구문에 여러 문장을 추가하면 됩니다.

 

 그러므로, JDK 7 이상을 사용할 때는 close() 메서드를 호출해야 하는 대상이 AutoClosable 인터페이스를 구현하고 있는지 확인하는 것이 중요합니다.


4) ResultSet.last() 메서드 

  ResultSet 객체를 rs라고 가정하고, rs.last() 메서드를 자주 사용하는 경우를 보아봅시다. 이 메서드는 ResultSet 객체의 결과 커서(Cursor)를 결과 집합의 맨 끝으로 이동시키는 역할을 합니다.

 

그렇다면, 왜 이 메서드를 사용하는 것일까?

rs.last();
int totalCount = rs.getRow();
ResultArray[] result = new ResultArray[totalCount];

 

 위와 같이 전체 데이터의 개수를 확인하고 배열에 저장해 사용하려는 의도라면 상대적으로 적절한 사용법입니다. 배열 대신 Vector를 사용하면 더 효율적일 것입니다. 그러나 게시판과 같은 화면을 구성할 때 전체 데이터의 개수를 확인하기 위해 rs.last()를 사용하는 경우도 있습니다. 이런 경우에는 select count(*) from 쿼리를 사용해 데이터의 개수를 확인하는 것이 훨씬 빠르므로, 이 방법을 더 권장합니다.

 

 그렇다면 rs.last() 메서드에는 어떤 문제가 있을까요? rs.last() 메서드의 실행 시간은 데이터의 건수와 데이터베이스 통신 속도에 따라 크게 달라집니다. 데이터의 건수가 많을수록 대기 시간(Wait time)이 증가하게 되며, 이로 인해 rs.next() 메서드를 실행할 때와 비교했을 때 상당한 속도 차이가 발생합니다. 따라서, rs.last() 메서드의 사용은 가능한 한 자제하는 것이 좋습니다.


5) JDBC 사용 시 주의해야 할 팁들

 데이터베이스 처리 과정에서는 다양한 문제가 발생할 수 있습니다. 이러한 문제들을 해결하거나 방지하기 위한 몇 가지 팁을 간략하게 정리해 보았습니다.

 

(1) setAutoCommit() 메서드는 신중하게 사용하자.

 setAutoCommit() 메서드는 자동 커밋 옵션을 설정하는 데 사용됩니다. 이 메서드는 반드시 필요한 경우에만 사용하는 것이 좋습니다. 단순한 select 작업을 수행할 때에도 불필요하게 이 메서드를 자주 사용하면, 여러 쿼리를 동시에 수행할 때 성능에 부정적인 영향을 미칠 수 있습니다.

 

(2) 배치 작업 시엔 executeBatch() 메서드를 활용하자.

 배치 작업을 수행할 때는 Statement 인터페이스의 addBatch() 메서드를 사용해 쿼리를 묶고, executeBatch() 메서드를 통해 한 번에 쿼리를 수행하는 것이 좋습니다. 이 방법을 통해 JDBC 호출 횟수를 줄일 수 있어 성능이 향상됩니다.

 

(3) setFetchSize() 메서드로 데이터를 빠르게 가져오자.

 한 번에 가져오는 열의 개수는 JDBC의 유형에 따라 다를 수 있습니다. 그러나 가져올 데이터의 수가 정해져 있다면, Statement와 ResultSet 인터페이스의 setFetchSize() 메서드를 사용해 원하는 개수를 설정하면 좋습니다. 그러나 이때 너무 많은 건수를 설정하면 서버에 부담을 주므로 적절히 사용해야 합니다.

 

(4) 한 건만 필요한 경우, 한 건만 가져오자.

 실제 쿼리에서는 100건 정도를 가져오지만, ResultSet.next()를 단 한 번만 실행하여 결과를 처리하는 경우가 있습니다. 이런 경우, 필요한 데이터가 한 건뿐이라면 쿼리를 수정하여 한 건만 가져올 수 있도록 해야 합니다.