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

de_sj_awa·2021년 9월 2일
1

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

자바 기반 애플리케이션의 성능을 진단해 보면, 애플리케이션의 응답 속도를 지연시키는 대부분의 요인은 DB 쿼리 수행 시간과 결과를 처리하는 시간이다.

1. DB Connection과 Connection Pool, DataSource

JDBC 관련 API는 클래스가 아니라 인터페이스이다.

JDK의 API에 있는 java.sql 인터페이스를 각 DB 벤더에서 상황에 맞게 구현하도록 되어 있다. 같은 인터페이스라고 해도, 각 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(SQLExcpetion e) {
    System.out.println("Connection fail");
    throw e;
} finally {
    rs.close();
    ps.close();
    con.close();
}

이 예제에서 수행되는 내용을 보면 다음과 같다.

1) 드라이버를 로드한다.
2) DB 서버의 IP와 ID, PW 등을 DriverManager 클래스의 getConnection 메서드를 사용하여 Connection 객체를 만든다.
3) Connection으로부터 PreparedStatement 객체를 받는다.
4) executeQuery를 수행하여 그 결과로 ResultSet 객체를 받아서 데이터를 처리한다.
5) 모든 데이터를 처리한 이후에는 finally 구문을 사용하여 ResultSet, PreparedStatement, Connection 객체들을 닫는다. 물론 각 객체를 close할 때 예외가 발생할 수 있으므로, 해당 메서드에서는 예외를 던지도록 처리해야 한다.

위의 소스처럼 구성되어 있을 때 쿼리가 0.1초 소요된다면, 어느 부분에서 가장 느릴까? 가장 느린 부분은 Connection 객체를 얻는 부분이다. 왜냐하면 같은 장비에 DB가 구성되어 있다고 하더라도, DB와 WAS 사이에는 통신을 해야 하기 때문이다. DB가 다른 장비에 있다면 이 통신 시간은 더 소요된다. 사용자가 갑자기 증가하면 Connection 객체를 얻기 위한 시간이 엄청나게 소요될 것이며, 많은 화면이 예외를 발생시킬 것이다.

이렇게 Connection 객체를 생성하는 부분에서 발생하는 대기 시간을 줄이고, 네트워크의 부담을 줄이기 위해서 사용하는 것이 DB Connection Pool이다. JSP와 서블릿 기술이 나오면서 Connection Pool에 대한 소스 코드들도 많이 나왔다. 그러나 동시에 그에 따른 문제점도 많이 발견되었다. 실제 검증되지 않은 소스가 많았기 때문에, 많은 사이트의 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을 포함한다고 생각해 두면 된다. 여기서 유의할 점은 DB Connection Pool은 자바 표준으로 지정되어 있는 것이 없다는 것이다. 따라서 WAS 벤더에 따라서 사용법이 많이 상이할 수 있다. 그러나 DataSource는 자바 표준이므로 WAS에 상관없이 사용법은 동일하다.

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

CallableStatement는 PL/SQL을 처리하기 때문에 성능상 비교 대상이 없으므로 Statement와 PreparedStatement를 비교해보자. Statement와 PreparedStatement의 가장 큰 차이점은 캐시(cache) 사용 여부이다. Statement를 사용할 때와 PreparedStatement를 처음 사용할 때는 다음과 같은 프로세스를 거친다.

1) 쿼리 문장 분석
2) 컴파일
3) 실행

여기서 수행한 점은 Statement를 사용하면 매번 쿼리를 수행할 때마다 1~3 단계를 거치게 되고, PreparedStatement는 처음 한 번만 세 단계를 거친 후 캐시에 담아서 재사용을 한다는 것이다. 만약 동일한 쿼리를 반복적으로 수행한다면 PreparedStatement가 DB에 훨씬 적은 부하를 주며, 성능도 좋다. 또한 쿼리에서의 변수를 "로 묶어서 처리하지 않고 ?로 처리하기 때문에 가독성도 좋아진다.

이제 Statement 관련 인터페이스를 사용하여 만든 쿼리를 수행하여야 한다. 쿼리를 수행하는 메서드는 여러 가지 있는데, 그 중 많이 사용하는 것이 executeQuery(), executeUpdate(), execute() 메서드이다.

executeQuery() 메서드는 select 관련 쿼리를 수행한다. 수행 결과로 요청한 데이터 값이 ResultSet 객체의 형태로 전달된다. executeUpdate() 메서드는 select 관련 쿼리를 제외한 DML(INSERT, UPDATE, DELETE 등) 및 DDL(CREATE TABLE, CREATE VIEW 등) 쿼리를 수행한다. 결과는 int 형태로 리턴된다. execute() 메서드는 쿼리의 종류와 상관 없이 쿼리를 수행한다. execute() 메서드의 수행 결과는 ResultSet이 아닌 boolean 형태의 데이터를 리턴하는데, 만약 데이터가 있을 경우에는 true를 리턴하여 getResultSet() 메서드를 사용하여 결과 값을 받을 수 있다. 그렇지 않은 경우에는 false를 리턴하여 변경된 행의 개수를 확인하기 위해서는 getUpdateCount() 메서드를 사용하여 값을 확인하면 된다.

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

2. 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 리소스를 해제하기 위함이다. 0.00001초라도 빨리 닫으면, 그만큼 해당 DB 서버의 부담이 적어지게 된다.

Statment 객체가 닫히는 경우도 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) {
    ...
}

이렇게 해 놓으면 close가 될 수도 있고 아닐 수도 있다. 어짜피 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(Exception e) {
    ...
} finally {
    rs.close();
    ps.close();
}
try {
    con.close();
} catch(Exception e) {
    ...
}

Connection 클래스의 중요성을 너무 많이 생각한 나머지 con 객체를 위한 try~catch 구문을 별도로 구성하는 경우다. 이렇게 할 경우, 쿼리를 수행하는 부분에서 에러를 던져버리면, 해당 Connection 클래스의 객체는 절대 닫히지 않을 수도 있다. 가장 좋은 방법은 아닐지라도, 가장 정상적인 방법은 다음과 같다.

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 인터페이스에는 리턴 타입이 void인 close() 메서드 단 한 개만 리턴되어 있다. close() 메서드의 설명을 보면 다음과 같이 되어 있다.

  • try~with~resource 문장으로 관리되는 객체에 대해서 자동적으로 close() 처리를 한다.
  • InterruptedException을 던지지 않도록 하는 것을 권장한다.
  • 이 close() 메서드를 두 번 이상 호출할 경우 뭔가 눈에 보이는 부작용이 나타나도록 해야 한다.

뭔가 제약이 많고 특이한 메서드라는 것을 확인할 수 있다. 여기서 가장 중요한 것은 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(BufferedRedaer br = new BufferedRedaer(Reader)) {
        return br.readLine();
    }
}

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

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

4. ResultSet.last() 메서드

ResultSet 객체가 rs라고 할 때, rs.last()를 사용하는 경우가 많이 있다. 이 메서드는 ResultSet 객체가 갖고 있는 결과의 커서(Cursor)를 맨 끝으로 옮기는 지시를 하는 메서드이다. 이 메서드를 수행하는 이유는 뭘까?

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

전체 데이터 개수를 확인하고 배열에 담아서 사용하기 위해서라면 그나마 양호하다. 배열을 Vector로 변경하고 사용하면 되기 때문이다. 하지만 게시판과 같은 화면을 구성할 때 전체 건수를 확인하기 위해서 이렇게 사용하는 경우도 있다. 이런 경우에는 select count(*) from 쿼리를 던져서 확인하는 것이 훨씬 빠르다.

그럼 rs.last()에는 문제가 있을까? rs.last() 메서드의 수행 시간은 데이터의 건수 및 DB 통신 속도에 따라서 달라진다. 건수가 많으면 많을수록 대기 시간(Wait time)이 증가한다. 결국 rs.next()를 수행할 때와 비교할 수 없을 정도로 속도 차이가 나기 때문에, 이 메서드의 사용은 자제해야 한다.

5. JDBC를 사용하면서 유의할 만한 몇 가지 팁

DB 부분을 처리하면서 발생할 수 있는 문제는 수도 없이 많다. 이를 해결하기 위한 몇 가지 팁을 간단하게 나열해 보면 다음과 같다.

  • setAutoCommit() 메서드는 필요할 때만 사용하자.
    setAutoCommit() 메서드를 사용하여 자동 커밋 여부를 지정하는 작업은 반드시 필요할 때만 하자. 단순한 select 작업만을 수행할 때에도 커밋 여부를 지정하여 사용하는 경우가 많은데, 여러 개의 쿼리를 동시에 작업할 때 성능에 영향을 주게 되므로 되도록 자제하자.
  • 배치성 작업은 executeBatch() 메서드를 사용하자.
    배치성 작업을 할 때는 Statement 인터페이스에 정의되어 있는 addBatch() 메서드를 사용하여 쿼리를 지정하고, executeBatch() 메서드를 사용하여 쿼리를 수행하자. 여러 개의 쿼리를 한 번에 수행할 수 있기 때문에 JDBC 호출 횟수가 감소되어 성능이 좋아진다.
  • setFetchSize() 메서드를 사용하여 데이터를 더 빠르게 가져오자.
    한 번에 가져오는 열의 개수는 JDBC의 종류에 따라서 다를 것이다. 하지만 가져오는 데이터의 수가 정해져 있을 경우에는 Statement와 ResultSet 인터페이스에 있는 setFetchSize() 메서드를 사용하여 원하는 개수를 정의하자. 하지만 너무 많은 건수를 지정하면 서버에 많은 부하가 올 수 있으니, 적절하게 사용해야 한다.
  • 한 건만 필요할 때는 한 건만 가져오자.
    실제 쿼리에서는 100건 정도를 가져오는데, ResultSet.next()를 while 블록을 사용해서 수행하지 않고, 단 한 번만 메서드를 수행해 결과를 처리하는 경우가 있다. 이 경우에 단 한 건만을 가져오도록 쿼리를 수정해야 한다.

참고

  • 자바 성능 튜닝 이야기
profile
이것저것 관심많은 개발자.

0개의 댓글