본문 바로가기
개발/Java

JDBC, DB 접근을 위한 자바 표준 인터페이스

by kadokok 2023. 3. 12.

JDBC란?

JDBC(Java Database Connectivity)

먼저 개념적인 의미부터 살펴볼까요?

JDBC는 이름에서도 알 수 있듯이, 자바를 통해서 데이터베이스에 접근할 수 있도록 도와주는 자바 표준 API 입니다. 즉, JDBC API를 통해 자바 코드로 데이터베이스를 연결할 수 있고, SQL 쿼리문을 보내서 쿼리를 실행할 수도 있습니다.

 

여기서 주목해볼 점은 JDBC가 자바의 표준 API 라는 점입니다. 현존하는 자바 DB 관련 기술들 중, 인기 있는 SQL Mapper나 ORM 기술들 대부분 Low-Level 단에서는 JDBC API가 사용되고 있습니다.

 

이처럼 JDBC는 자바의 표준 인터페이스로서 정말 다양한 DB 기술들에 사용되고 있는데요, 정말 많이 사용되고 있는 JDBC, 과연 어떤 필요성에 의해 JDBC라는 자바 표준이 나오게 되었을까요?


왜 표준이 필요했을까?

왜 표준이 필요했는지 알아보기 전에, JDBC가 없었던 과거의 개발자 고고닥의 상황을 살펴보죠.

(이해를 돕기 위해 실제와는 거리가 먼 예시가 포함되어 있습니다!)

 

우아한 DB

여기, 우아한 회사에서 새롭게 출시한 데이터베이스, 우아한 DB 가 있습니다.

평소 우아한 회사의 제품을 좋아하던 kokodak은, “이건 못참지!” 하며 우아한 DB 를 사용해서 서비스 개발을 하려고 합니다. 먼저, 우아한 DB 를 사용하기 위한 방법부터 익혀볼까요?

 

1. 우아한 DB를 연결하려면 이 코드를 사용하세요!

제일 먼저, 코드를 통해 DB 연결부터 해야할 것 같습니다. 우아한 DB 설명서에는 이렇게 적혀있습니다.

// 우아한 DB를 연결하려면 이 코드를 사용하세요!
public static WoowaDB 우아한DB를_연결합니다() {
    // 알아서 우아하게 연결해주는 코드...
}

우아한DB를_연결합니다() 메서드를 통해 WoowaDB 라는 이름의 우아한 DB를 받을 수 있는 것 같습니다.

그럼 이제 DB를 연결했으니, SQL문을 DB로 보내기만 하면 되겠네요!

 

2. SQL 쿼리를 전달하려면 이 코드를 사용하세요!

// SQL 쿼리를 전달하려면 이 코드를 사용하세요!
public WoowaResult SQL을_전달합니다(final String sql) {
    // 알아서 우아하게 SQL을 전달해주는 코드...
}

SQL을_전달합니다() 메서드로 SQL문을 전달하면, 쿼리의 결과인 WoowaResult 가 반환되는 형식인 것 같네요.

그럼 이제 사용법은 대략 익힌 것 같으니, 한번 전체적인 흐름을 코드로 작성해볼까요?

 

public static void main(String[] args) {
    // 우아한 DB에 연결하고!
    WoowaDB woowaDB = WoowaDBDriver.우아한DB를_연결합니다();

    // SQL 쿼리를 전달한 뒤, 쿼리 결과를 받는다!
    String sql = "SELECT * FROM MEMBER WHERE NAME = 'kokodak'";
    WoowaResult woowaResult = woowaDB.SQL을_전달합니다(sql);
}

이렇게 단 세 줄의 코드만으로 DB를 연결하고, SQL 쿼리를 날려 결과까지 받아올 수 있다니, 참 멋진 일입니다.

이제 고고닥은 우아한 DB의 기술에 감탄하며 우아한 회사의 충성도 높은 고객이 되었습니다.

 

더 우아한 DB

시간이 꽤 지난 뒤, 우아한 회사에서 또 새로운 데이터베이스를 출시했습니다. 이름하여.. 더 우아한 DB …!

기존의 우아한 DB는 최대 100명의 정보만 저장할 수 있었는데, 더 우아한 DB는 최대 1000명까지 저장할 수 있고, 성능도 많이 최적화돼서 속도가 굉장히 빨라졌다고 합니다.

고고닥은 절대 참을 수가 없었습니다. 그래서 결국 데이터베이스를 기존의 우아한 DB에서, 신제품인 더 우아한 DB로 교체하려고 합니다. 더 우아한 DB 코드 설명서를 살펴볼까요?

 

1. 더 우아한 DB를 연결하려면 이 코드를 사용하세요!

// 더 우아한 DB를 연결하려면 이 코드를 사용하세요!
public static ThePremiumPerformanceWoowaDB 더_우아한DB를_연결합니다(final AreYouWoowa areYouWoowa) {
    // 알아서 더욱 우아하게 연결해주는 코드...
}

2. SQL 쿼리를 전달하려면 이 코드를 사용하세요!

// SQL 쿼리를 전달하려면 이 코드를 사용하세요!
public ThePremiumPerformanceWoowaResult SQL을_더_우아하게_전달합니다(final String sql) {
    // 알아서 더욱 우아하게 SQL을 전달해주는 코드...
}

이럴수가! 더 우아한 DB 코드들의 이름이 더 우아하고 멋있어진 것 같습니다. 고고닥은 매우 만족하며 자신의 프로덕션 코드에 적용시키려고 하는데, 아뿔싸.. 큰 문제가 있었습니다.

 

1. AreYouWoowa

기존에는 DB를 연결하기 위해 필요한 파라미터가 하나도 없어서 간편했는데, 이제는 아닙니다.

더 우아한 DB에 연결하기 위해서는, AreYouWoowa 인스턴스 setter 메서드를 통해 자신이 충분히 우아한 존재임을 증명해야만 합니다.

세팅해야 할 필드 값이 너무 많고 복잡해서, 고고닥은 3일동안 밤을 새서 공식 문서를 읽었습니다. 그렇게 겨우 필요한 필드 값을 다 넣었더니 우아하지 못한 존재 이슈로 Denied..

 

2. 방대한 프로덕션 코드

고고닥의 프로덕션 코드는 10만 줄이 넘어가는 상태입니다. 정말 문제는, 우아한 DB와 더 우아한 DB가 사용하는 코드가 서로 다른 탓에 최소 3만 줄은 수정해야할 것 같습니다.


표준의 부재, 이로 인한 문제점

위의 예시를 잘 보셨나요? 표준이 존재하지 않았던 과거에는 데이터베이스 종류를 바꿨을 때, 크게 두 가지 문제점이 존재했습니다.

 

첫 번째, 새로운 데이터베이스의 사용법을 배워야 합니다. 표준이 없으니 각 데이터베이스마다 사용법이 다를 수 밖에 없고, 결국 학습의 몫은 해당 기술을 사용할 개발자들에게 가겠죠.

 

두 번째, 프로덕션 코드의 변경 범위가 커질 수 있습니다. 이는 DIP(의존 역전 원칙)를 위배한 대가이기도 합니다. 기대하는 기능은 같지만, 데이터베이스를 바꿨다는 이유만으로 프로덕션 코드의 변경 범위가 굉장히 커질 수 있습니다.

 

이처럼, 표준 인터페이스가 없어서 발생하는 엄청난 기술 부채는 해당 프로덕션을 유지보수하는 개발자가 고스란히 안고 가게 됩니다.


표준 인터페이스, JDBC 등장!

이 시점에서, 자바 진영에서는 DB 접근 기술에 대한 표준 인터페이스를 만들게 되는데, 이것이 바로 오늘 날의 JDBC입니다.

JDBC는 표준 인터페이스로서, DB 접근을 위한 기능들을 크게 4가지로 추상화시킵니다.

 

Driver

데이터베이스에 접근하려면, 제일 먼저 해당 데이터베이스와 연결부터 되어야겠죠?

JDBC Driver는 실제 데이터베이스와 연결하는 기능을 가집니다. 다이어그램으로 살펴보죠.

 

 

Driver는 connect() 메서드를 통해 실제 데이터베이스와 연결을 할 수 있습니다.

 

다만 여기서 주목할 점은, 클라이언트가 데이터베이스와 연결할 때 Driver API를 직접 사용하지 않는다는 점입니다. 데이터베이스 연결에 Driver API를 사용한다면서, 그럼 어떻게 이를 직접 사용하지 않고 데이터베이스와 연결할 수 있을까요?

 

정답은 바로 DriverManager를 사용하는 것입니다. 그렇다면 DriverManager는 또 무엇일까요?

DriverManager를 간단히 설명하자면, 다양한 JDBC Driver들을 한 곳에 묶어 관리할 수 있게 해주는 정적 클래스입니다.

 

라이브러리를 통해 가져온 각각의 구체 Driver는, 위 그림과 같이 static 블럭 안에서 DriverManager.registerDriver() 메서드를 수행하게 됩니다. 이를 통해서 클래스 로딩 시점에 구체 Driver들은 DriverManager 내부의 ArrayList에 보관됩니다.

 

결과적으로 DriverManager 내부에는 다양한 JDBC Driver들이 들어있고, 클라이언트는 DriverManager API를 통해 Driver API를 간접적으로 사용하게 됩니다.

 

Connection

JDBC Driver를 통해 데이터베이스와 연결이 되었다면, 이 연결에 대한 정보를 가지고 있어야 합니다.

그렇다면 이 연결 정보를 누가 가지고 있을까요? 바로! JDBC Connenction 객체가 이 역할을 하게 됩니다.

 

 

대략적인 흐름은 위 그림과 같은데, 이 흐름 방금 어디서 본 것 같지 않나요? 바로 전에 Driver에서 봤던 흐름이죠!

일단 위 그림을 보며 흐름을 하나씩 따라가볼까요?

 

첫 번째, 클라이언트는 DriverManager.getConnection() 메서드를 호출하여 Connection 요청을 합니다.

아까 Driver에서 말했던, Driver API 대신 DriverManager API를 사용한다고 한 부분이 바로 이 부분입니다.

 

두 번째, 적절한 Driver를 찾습니다.

DriverManager의 내부에는 다양한 구체 Driver가 보관되고 있다는 것 기억하시죠!

getConnection() 메서드에 현재 접속하고 싶은 DB URL을 넘겨주면, 해당 URL을 처리할 수 있는 구체 Driver를 찾습니다.

 

세 번째, 실제 DB와 연결합니다.

이 과정은 전에 봤던 Driver의 connect() 메서드를 통해 이루어집니다.

 

네 번째, Connection을 생성합니다.

실제 DB와 연결이 되었다면, 이 연결에 대한 정보를 가지는 Connection 객체를 생성합니다.

이때 Connection 객체는, DB에 접속하기 위한 URL, USER ID, PASSWORD 같은 정보와 현재 연결 돼 있는 세션(Session) 정보도 포함하고 있습니다.

 

마지막으로, 생성된 Connection을 클라이언트에게 반환합니다.

 

Statement

데이터베이스와 연결이 잘 되었으니, 이제 SQL 쿼리를 다뤄 볼 차례겠죠!

 

JDBC Statement 객체가 바로 이 역할을 수행하게 됩니다. Statement 객체에게 SQL문을 넘겨주면, 해당 SQL문을 관리하고 실행할 수 있습니다.

 

흐름도를 보기전에 추가로 잠깐 언급하자면, 제가 예시에서 사용할 Statement는 PreparedStatement 라는 친구입니다.

Statement의 종류는 크게 Statement, PreparedStatement로 나눠지는데, 각각 쓰임새가 좀 다릅니다.

둘의 차이는 여기 에 잘 설명되어 있으니, 궁금하신 분들은 참고하시면 될 것 같습니다.

 

 

그림이 조금 복잡한데, 천천히 따라가면서 읽어보죠!

 

첫 번째, SQL 쿼리를 prepareStatement() 메서드를 통해 전달합니다.

이때 SQL 쿼리가 확정되지 않았다고 적었는데, 생긴 모양은 이런 식입니다.

String sql = "SELECT * FROM MEMBER WHERE NAME = ?"

주목할 점은 맨 뒤의 ? 입니다. 이를 placeholder라고도 부르는데, PreparedStatement 객체를 이용하면 ? 값을 동적으로 지정해줄 수 있습니다. 즉, 런타임 시점에 값을 주입할 수 있다는 뜻입니다. 이러한 과정을 바인딩(Binding) 이라고 부릅니다.

 

두 번째 및 세 번째, 넘겨준 SQL 쿼리를 관리하는 Statement 객체를 생성하여 반환합니다. (정확히는 PreparedStatement를 생성하여 반환합니다.)

 

네 번째, SQL 바인딩을 통해 SQL 쿼리를 확정시킵니다.

위에서 언급한 바인딩 과정을 통해 SQL 쿼리를 동적으로 만들어줄 수 있습니다. 밑 코드를 참고해주세요.

// 바인딩되지 않은 SQL 쿼리!
String sql = "SELECT * FROM MEMBER WHERE NAME = ?";

// 첫 번째 ? 값에 "kokodak"을 넣어준다.
preparedStatement.setString(1, "kokodak");

// 바인딩된 SQL 쿼리는 아래의 쿼리와 같다!
String sql = "SELECT * FROM MEMBER WHERE NAME = 'kokodak'";

 

ResultSet

SQL 쿼리를 실행했다면, 그 쿼리에 대한 결과가 따라오겠죠?

JDBC ResultSet 객체는 SQL 쿼리에 대한 결과를 저장하는 기능을 가집니다.

 

 

첫 번째, executeQuery() 메서드를 통해 SQL 쿼리 실행 요청을 합니다.

 

두 번째 및 세 번째, SQL 쿼리를 데이터베이스에서 실행시키고 이에 대한 결과를 반환합니다.

 

네 번째 및 다섯 번째, 데이터베이스에서 얻은 SQL 쿼리 결과를 이용해 ResultSet 객체를 생성하여 반환합니다.

이때 ResultSet 객체 내부에는 Map 자료구조를 사용하여 쿼리 결과를 관리합니다.

 

쿼리 결과 조회는 아래의 코드처럼 조회할 수 있습니다.

// ResultSet 내부의 커서(cursor)를 이용해 결과가 있는지 탐색합니다.
if (resultSet.next()) {
    // 만약 다음 커서의 위치에 SQL 쿼리 결과가 존재한다면, 해당 결과를 가져옵니다.
    String memberId = resultSet.getString("id");
    String memberName = resultSet.getString("name");
    String memberMessage = resultSet.getString("message");

    System.out.println("Id: " + memberId);
    System.out.println("Name: " + memberName);
    System.out.println("Message: " + memberMessage);
}

 

리소스 반환

마지막으로 다뤄볼 것은 리소스 반환인데, 간단하게 다루고 넘어가겠습니다.

 

JDBC API를 사용해서 데이터베이스를 연결하고 쿼리를 실행할 때, 내부적으로는 데이터베이스와 커넥션을 유지하기 위해서 다양한 비용이 발생하는데, 대표적으로 네트워크, 메모리 등이 있습니다.

 

따라서, JDBC API 사용을 모두 마쳤다면, 이 과정에서 발생한 리소스를 모두 반환시켜서 쓸모없는 비용 지출을 막을 필요가 있습니다. 여기서 말하는 리소스는 Connection, Statement, ResultSet인데, 만약 이를 반환하지 않고 남겨둔다면 전체적인 성능 저하나 메모리 누수 문제가 발생할 수 있습니다.

 

이때 명시적으로 리소스 반환을 시켜주는 코드가 바로 close() 메서드 입니다.

사용한 리소스를 반환하여 추가적인 비용 지출을 막을 수 있습니다. 보통 리소스 반환은 반드시 이루어져야 하기 때문에, try-catch-finally 구문을 이용해 finally 블럭 안에서 리소스 반환을 하게 됩니다.

 

close() 메서드를 호출하는 순서가 상당히 중요한데, 받아온 객체 순서의 역순으로 반환해주어야 합니다. 자세한 예시는 아래 코드로 참고해주세요.

finally {
    // finally 에서는 DB 사용을 마치고, 사용했던 리소스를 반납하는 과정을 거쳐야 합니다.
    // close() 로 리소스를 반납하는 순서는 객체를 받아온 순서의 역순입니다.

    // ResultSet 객체가 null 이 아닌 경우에만 close() 해줍니다.
    if (resultSet != null) {
        resultSet.close();
    }
    // PreparedStatement 객체가 null 이 아닌 경우에만 close() 해줍니다.
    if (preparedStatement != null) {
        preparedStatement.close();
    }
    // Connection 객체가 null 이 아닌 경우에만 close() 해줍니다.
    if (connection != null) {
        connection.close();
    }
}

해결된 문제들

위에서 개발자 고고닥이 전에 겪었던 문제점 2가지를 기억하실 겁니다. 다시 한 번 가져와볼까요?

이전의 문제점

첫 번째, 새로운 데이터베이스의 사용법을 배워야 한다.

두 번째, 프로덕션 코드를 반드시 변경해야 한다.

 

그렇다면 JDBC가 등장한 후, 이 문제점들은 어떤 식으로 해결됐을까요?

해결된 문제점

첫 번째, 새로운 데이터베이스의 사용법을 더 이상 배우지 않아도 된다.

→ 이제 더 이상 데이터베이스가 변경될 때마다 새로운 사용법을 배울 필요가 없습니다.

JDBC API 라는 추상화된 표준이 생겼고, 모든 데이터베이스 회사들은 이 표준에 맞춰 구현체를 만들면 됩니다.

즉, 추상화된 표준 API의 사용법만 숙지하고 있다면, 어떤 데이터베이스가 와도 동일한 기능을 할 것이라고 기대할 수 있습니다.

 

두 번째, 프로덕션 코드의 변경 범위를 최소화 할 수 있다.

→ 이제 프로덕션 코드는 각각의 구체 DB 클래스에 의존하지 않습니다. 추상화된 JDBC에만 의존하고 있죠.

즉, 프로덕션과 구체 DB 클래스간의 의존 관계가 역전됐다고 볼 수 있고, 이는 객체지향 원칙 중 DIP 원칙을 만족하는 설계입니다. 때문에, 구체 클래스가 변경되더라도 프로덕션 코드 변경 범위를 최소화할 수 있고, 다형성을 활용할 수 있기 때문에 데이터베이스 변경으로부터 자유로워졌습니다.

 

JDBC의 등장으로 인해 많은 점이 편리해진 것 같습니다. 그렇다면 이제 JDBC API가 실제로 어떻게 사용되는지 코드로 예제를 살펴볼까요?


JDBC API, 직접 사용해보자!

다이어그램으로 이론적인 흐름은 알아봤으니, 이제 본격적으로 코드를 보면서 실제로 어떻게 사용되는지 이해해볼까요?

 

예제로 다루어 볼 쿼리는 2개로, INSERT 쿼리와 SELECT 쿼리입니다. 이 두 쿼리의 흐름만 알고 있으면 나머지 쿼리도 똑같이 작성할 수 있기 때문에 따로 다루지는 않겠습니다.

 

예시에서는 ID, NAME, MESSAGE 라는 열(column)을 가진 MEMBER 테이블을 기준으로 코드가 작성되어 있습니다.

 

INSERT 쿼리

public void insertQuery(final String id, final String name, final String message) throws SQLException {
    // SQL 쿼리를 작성합니다. (확정 X)
    String sql = "INSERT INTO member(id, name, message) values(?, ?, ?)";

    // finally에서 close() 메서드로 리소스 반납을 해야하므로 try 문 안쪽이 아닌, 바깥 쪽에서 선언해야 합니다.
    Connection connection = null;
    PreparedStatement preparedStatement = null;

    try {
        // DB와 연결하고, Connenction 객체를 받아옵니다.
        connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);

        // SQL 쿼리를 넘겨주고, 해당 쿼리를 실행할 PreparedStatement 객체를 받아옵니다.
        preparedStatement = connection.prepareStatement(sql);

        // ?(placeholder)의 값을 바인딩하여 SQL 쿼리를 확정시킵니다.
        preparedStatement.setString(1, id); // 첫 번째 ? 파라미터
        preparedStatement.setString(2, name); // 두 번째 ? 파라미터
        preparedStatement.setString(3, message); // 세 번째 ? 파라미터

        // 바인딩된 SQL 쿼리를 실행시킵니다.
        // 이때 반환되는 정수형은, 쿼리 실행으로 인해 영향을 받은 행(row)의 수를 뜻합니다.
        int update = preparedStatement.executeUpdate();

        // 쿼리 실행으로 인해 영향 받은 행(row)이 존재한다면 쿼리가 정상 수행된 것이고, 존재하지 않는다면 쿼리가 수행되지 못한 상황입니다.
        if (update > 0) {
            System.out.println("INSERT 쿼리가 정상적으로 수행됐습니다.");
        } else {
            System.out.println("INSERT 쿼리가 수행되지 못했습니다.");
        }
    } finally {
        // finally에서는 DB 사용을 마치고, 사용했던 리소스를 반납하는 과정을 거쳐야 합니다.
        // close()로 리소스를 반납하는 순서는 객체를 받아온 순서의 역순입니다.

        // PreparedStatement 객체가 null이 아닌 경우에만 close() 해줍니다.
        if (preparedStatement != null) {
            preparedStatement.close();
        }
        //  Connection 객체가 null이 아닌 경우에만 close() 해줍니다.
        if (connection != null) {
            connection.close();
        }
    }
}

 

SELECT 쿼리

public void selectQuery(final String id) throws SQLException {
    // SQL 쿼리를 작성합니다. (확정 X)
    String sql = "SELECT * FROM member WHERE id = ?";

    // finally 에서 close() 메서드로 리소스 반납을 해야하므로 try 문 안쪽이 아닌, 바깥 쪽에서 선언해야 합니다.
    Connection connection = null;
    PreparedStatement preparedStatement = null;
    ResultSet resultSet = null;

    try {
        // DB와 연결하고, Connection 객체를 받아옵니다.
        connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);

        // SQL 쿼리를 넘겨주고, 해당 쿼리를 실행할 PreparedStatement 객체를 받아옵니다.
        preparedStatement = connection.prepareStatement(sql);

        // ?(placeholder)의 값을 바인딩하여 SQL 쿼리를 확정시킵니다.
        preparedStatement.setString(1, id);

        // 바인딩된 SQL 쿼리를 실행시킵니다.
        // 이때 반환되는 ResultSet 은 실행한 SQL 쿼리의 결과를 가지고 있습니다.
        resultSet = preparedStatement.executeQuery();

        // ResultSet 내부의 커서(cursor)를 이용해 결과가 있는지 탐색합니다.
        if (resultSet.next()) {
            // 만약 다음 커서의 위치에 SQL 쿼리 결과가 존재한다면, 해당 결과를 가져옵니다.
            String memberId = resultSet.getString("id");
            String memberName = resultSet.getString("name");
            String memberMessage = resultSet.getString("message");

            System.out.println("Id: " + memberId);
            System.out.println("Name: " + memberName);
            System.out.println("Message: " + memberMessage);
        } else {
            System.out.println("찾을 수 없습니다");
        }
    } finally {
        // finally 에서는 DB 사용을 마치고, 사용했던 리소스를 반납하는 과정을 거쳐야 합니다.
        // close() 로 리소스를 반납하는 순서는 객체를 받아온 순서의 역순입니다.

        // ResultSet 객체가 null 이 아닌 경우에만 close() 해줍니다.
        if (resultSet != null) {
            resultSet.close();
        }
        // PreparedStatement 객체가 null 이 아닌 경우에만 close() 해줍니다.
        if (preparedStatement != null) {
            preparedStatement.close();
        }
        // Connection 객체가 null 이 아닌 경우에만 close() 해줍니다.
        if (connection != null) {
            connection.close();
        }
    }
}

댓글