Spring

[Spring] JDBC에 대한 이해

이덩우 2023. 12. 7. 20:42

JDBC가 등장한 이유

과거에는 수 십개의 관계형 데이터베이스마다 커넥션을 획득하고(Connection), SQL을 담아서 DB에 전달하고(Statement), 결과를 응답받는(ResultSet) 과정이 모두 달랐다. 그래서 데이터베이스를 변경하면 많은 번거로움이 있을 수 밖에 없었다.

 

'굳이 매번 번거롭게 사용해야해? 통일된 느낌으로 만들 수 없나?' 라는 생각에서 등장한 것이 바로 JDBC 표준 인터페이스이다.

개발자들은 JDBC 표준 인터페이스의 규칙에 맞춰 사용하면 되고, 내부적으로는 각 DB 벤더 회사들이 제공해주는 JDBC 드라이버를 이용해 구현체를 생성하게 된다.

MySQL 드라이버를 사용하는 상황

 

이렇게 JDBC 인터페이스를 통해 한 단계 추상화를 거치면서 다음과 같은 두 가지의 이점이 생겼다.

  1. 애플리케이션 로직은 이제 JDBC 표준 인터페이스에만 의존한다. 따라서 다른 DB로 변경하고 싶으면 JDBC 드라이버 구현체만 변경하면 된다.
  2. JDBC 표준 인터페이스 사용법만 알면 각각의 데이터베이스마다의 Connection 획득방법, SQL를 전달하는 방법, 결과를 응답받는 방법을 모두 학습할 필요가 없다.

 


JDBC의 기본 요소

JDBC 표준 인터페이스를 사용해 DB와 연결하고 데이터를 주고 받는 것은 다음과 같은 단계로 이루어진다.

  1. DB와 연결 (Connection)
  2. SQL을 전달하고 실행(Statement)
  3. 결과를 응답(ResultSet)

위 세 가지를 중심으로 아래 흐름을 쭉 따라가면 된다.

 

- Connection

Connection이란 Java에서 지원하는 표준 인터페이스이다.

가장 기초적으로, 필요할 때마다 매번 커넥션을 직접 획득하는 방법에 대해서 살펴보자.

먼저 Connection을 위한 상수들을 모아놓은 추상 클래스를 하나 생성한다.

public abstract class ConnectionConst {
	//h2 데이터베이스 사용
    public static final String URL = "jdbc:h2:tcp://localhost/~/testcase";
    public static final String USERNAME = "sa";
    public static final String PASSWORD = "0000";
}

 

 

이후 실제 커넥션을 획득하는 로직을 작성해보자.

@Slf4j
public class DBConnectionUtil {

    public static Connection getConnection() { // Connection이 jdbc가 지원해주는 그 Connection이다.
        try {
            // DriverManager를 통해서 현재 어떤 데이터베이스를 쓰는지 찾고 해당 커넥션을 반환해준다.
            // -> jdbc는 인터페이스이고, 구현체인 h2 드라이버를 사용하는 것이다.
            Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
            log.info("get connection={}, class={}", connection, connection.getClass());
            return connection;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}

 

자 여기서 DriverManager라는 것이 등장한다.

Connection 자체는 자바의 표준 인터페이스이기 때문에 구현체가 필요한 상황이다.

이 때, 파라미터로 넘긴 커넥션 정보들을 바탕으로 DriverManager에서 구현체인 특정 JDBC 드라이버를 반환해준다.

위와 같은 과정으로 우리가 사용하는 데이터베이스에 맞는 커넥션을 획득할 수 있다.

 

- Statement

Statement는 커넥션을 획득한 뒤 실제로 SQL문을 전달하고 실행하는 단계라고 이야기했다.

어떻게 Statement를 정의하고 SQL문을 전달할 수 있을까? 코드로 직접 확인해보자.

String sql = "delete from member where member_id=?";

Connection con = null;             
PreparedStatement pstmt = null;    

//원래는 try-catch로 SQLException 잡아줘야한다.
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();

private static Connection getConnection() { 
    return DBConnectionUtil.getConnection();
}

 

JDBC에서 Statement는 SQL문을 전달하고 실행하는 역할을 한다고 언급했다.

위 코드에서, 생성한 Connection에 SQL문을 파라미터로 전달하며 Statement를 생성하는 모습을 볼 수 있다.

실제로는 PreparedStatement를 사용했는데, 그 이유는 SQL문에 파라미터를 바인딩해주기 위해서다. -> pstmt.setString()

PreparedStatement는 실제로 Statement를 상속받고있기 때문에 같은 의미로 보면 된다.

 

SQL문, 파라미터 바인딩을 마쳤으면 실행하면 된다.

DB 테이블 내 데이터를 변경시키는 쿼리라면(C, U, D) -> executeUpdate() 명령을,

단순 조회 쿼리라면(R) -> executeQuery() 명령을 사용하면 된다.

조회 쿼리를 날리면 응답으로 ResultSet이 온다.

 

 

- ResultSet

ResultSet은 조회 요청에 대한 응답값을 의미한다.

어떤 쿼리를 전달했는지에 따라 ResultSet을 구성하는 필드가 달라진다.

예시로, findById() 명령을 통해 하나의 회원객체를 받아온다면? 아래 코드를 살펴보자.

if (rs.next()) {
    Member member = new Member();
    member.setMemberId(rs.getString("member_id"));
    member.setMoney(rs.getInt("money"));
    return member;
} else {
    throw new NoSuchElementException("member not found, memberId=" + memberId);
}

 

ResultSet은 내부에 cursor를 가지고있고, 처음에는 빈 공간을 가르키고 있다. 따라서 실제 응답 정보를 얻으려면 rs.next()를 한번 호출해서 cursor를 옮겨줘야한다.

현재는 하나의 회원 객체만 조회하므로 if(rs.next())를 사용한 것이고, 데이터가 추가적으로 있다면 while문을 통해 모든 데이터를 파싱하면 된다.

 

 


CRUD

옛날 개발자들은 어떻게 CRUD 코드를 작성했을까? 순수하게 JDBC만 사용해서 데이터를 변경하고, 조회하는 코드를 작성해보자.

C, U, D는 사실 비슷하므로 C, R 코드만 예시로 만들어보겠다.

public Member save(Member member) throws SQLException {
    String sql = "insert into member(member_id, money) values (?,?)";
          
    Connection con = null;
    PreparedStatement pstmt = null;
    
    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, member.getMemberId());
        pstmt.setInt(2, member.getMoney());
        pstmt.executeUpdate();
        return member;
    } catch (SQLException e) {
        log.info("db error", e);
        throw e;
    } finally {
        close(con, pstmt, null);
    }
}

 

회원 객체를 저장하는 코드이다. 

PreparedStatement를 생성할 때 SQLException이 올라오므로 try-catch로 잡아줘야한다.

DB 테이블 내 데이터를 변경하는 쿼리이므로 executeUpdate()를 사용한다.

(executeUpdate()는 반환타입이 int인데, DB 내 영향을 받은 컬럼 수를 반환해준다.)

따라서 try-catch문 밖에 미리 Connection과 PreparedStatement를 null로 생성해두는 모습이다.

 

finally 구문에 있는 close() 메소드는 아래와 같다.

private void close(Connection con, Statement stmt, ResultSet rs) {
    if (rs != null) {
        try {
            rs.close();
        } catch (SQLException e) {
            log.info("error");
        }
    }
    
    if (stmt != null) {
        try {
            stmt.close();
        } catch (SQLException e) {
            log.info("error");
        }
    }
    
    if (con != null) {
        try {
            con.close();
        } catch (SQLException e) {
            log.info("error");
        }
    }
}

 

Connection은 실제로 TCP/IP 연결을 하고있으므로 필요한 작업이 끝났으면 연결을 끊어줘야한다.

연결했던 역순으로 rs -> stmt -> con 순으로 닫아준다.

주의할 점은 하나를 닫다가 예외가 터지면 나머지를 닫지 못하고 끝나버릴 수 있다.

try-catch로 개별로 감싸줘서 모두 닫을 수 있도록 만들면 된다.

 

 

마지막으로 조회 요청에 대해 살펴보자.

public Member findById(String memberId) throws SQLException {
    String sql = "select * from member where member_id = ?";

    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, memberId);
        rs = pstmt.executeQuery();

        if (rs.next()) {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        } else {
            throw new NoSuchElementException("member not found, memberId=" + memberId);
        }
    } catch (SQLException e) {
        log.info("db error", e);
        throw e;
    } finally {
        close(con, pstmt, rs);
    }
}

 

실행문으로 executeQuery()를 사용해 ResultSet을 반환받고, 데이터를 꺼내서 회원 객체를 만들고 반환해주는 모습이다.

 

 

다음은?

지금까지 순수 JDBC 표준 인터페이스만을 활용해서 데이터베이스에 접근하는 방법에 대해 살펴봤다. 코드를 보면 알겠지만, 굉장히 중복되는 코드도 많고 비효율적인 점이 많다.

하지만 SQL Mapper, ORM 등 모든 기술은 내부적으로 이러한 JDBC 표준 인터페이스를 사용하기 때문에 확실하게 내부 원리를 이해하는 것이 중요하다.

 

이렇게 DriverManager를 통해 직접 커넥션을 획득하는 방법은 매번 새로운 커넥션을 만든다.

커넥션을 생성할 때마다 네트워크를 계속 타기 때문에 비용적인 측면에서도 비효율적이고 응답 시간 마저도 길 것이다.

 

'여러개의 커넥션을 미리 생성해두고 한 곳에 모아놓고, 요청이 들어오면 하나 꺼내주고 다시 챙기고 하는 방식으로 하면 어떨까?' 라는 생각에서 만들어진 것이 바로 Connection Pool이다. 다음 포스팅에서 알아보자!

 

 

 

 

출처 : 인프런, 김영한의 스프링 DB 1편 - 데이터 접근 핵심 원리