JDBC 실전 활용: ORM, 커넥션 풀, 배치 처리 완전 정복

JDBC를 사용할 때 데이터베이스의 행(row) 데이터가 자바 코드에서는 여러 개의 분리된 변수로 다뤄지는 문제가 있습니다. 이는 유지보수와 관리 측면에서 비효율적입니다. 자바는 객체 지향 언어이므로, 데이터베이스 테이블은 클래스, 행은 객체, 열은 객체의 속성으로 매핑하는 것이 자연스럽습니다. 이러한 매핑 방식을 ORM(Object Relational Mapping)이라고 합니다. ORM은 객체 지향 개념과 관계형 데이터베이스의 테이블 개념을 연결하여 객체 관점에서 데이터베이스를 다루는 방법입니다.

1. 표준 ORM 구현

먼저 데이터베이스 테이블의 데이터를 캡슐화하는 표준 자바빈(JavaBean) 클래스를 작성합니다. 클래스 이름과 필드 이름은 역할을 명확하게 드러내도록 지정합니다.

package com.example.jdbc.advanced.model;

// 데이터베이스의 'employees' 테이블에 매핑
public class Employee {
    private Integer id;          // employee_id 컬럼에 매핑
    private String name;         // employee_name 컬럼에 매핑
    private Double salary;       // employee_salary 컬럼에 매핑
    private Integer age;         // employee_age 컬럼에 매핑

    public Employee() {}

    public Employee(Integer id, String name, Double salary, Integer age) {
        this.id = id;
        this.name = name;
        this.salary = salary;
        this.age = age;
    }

    // getter/setter 생략
    @Override
    public String toString() {
        return "Employee{" + "id=" + id + ", name='" + name + "', salary=" + salary + ", age=" + age + "}";
    }
}

다음은 데이터베이스에서 데이터를 조회하여 위의 Employee 객체로 변환하는 테스트 코드입니다.

@Test
public void testORM() throws Exception {
    Connection conn = DriverManager.getConnection("jdbc:mysql:///db05", "root", "password");
    PreparedStatement ps = conn.prepareStatement("SELECT employee_id, employee_name, employee_salary, employee_age FROM employees WHERE employee_id = ?");
    ps.setInt(1, 2);
    ResultSet rs = ps.executeQuery();
    Employee emp = null;
    if (rs.next()) {
        emp = new Employee();
        emp.setId(rs.getInt("employee_id"));
        emp.setName(rs.getString("employee_name"));
        emp.setSalary(rs.getDouble("employee_salary"));
        emp.setAge(rs.getInt("employee_age"));
    }
    System.out.println(emp);
    rs.close(); ps.close(); conn.close();
}

여러 행을 처리해야 할 경우 컬렉션을 사용하여 여러 객체를 저장합니다.

@Test
public void testORMList() throws Exception {
    Connection conn = DriverManager.getConnection("jdbc:mysql:///db05", "root", "password");
    PreparedStatement ps = conn.prepareStatement("SELECT employee_id, employee_name, employee_salary, employee_age FROM employees");
    ResultSet rs = ps.executeQuery();
    List<Employee> empList = new ArrayList<>();
    while (rs.next()) {
        Employee emp = new Employee();
        emp.setId(rs.getInt("employee_id"));
        emp.setName(rs.getString("employee_name"));
        emp.setSalary(rs.getDouble("employee_salary"));
        emp.setAge(rs.getInt("employee_age"));
        empList.add(emp);
    }
    for (Employee e : empList) {
        System.out.println(e);
    }
    rs.close(); ps.close(); conn.close();
}

2. 생성된 키 값 회수

데이터베이스에서 새 레코드를 추가할 때 자동 생성된 기본 키 값을 자바 객체에 할당해야 하는 경우가 많습니다. 이 과정을 생성 키 회수(Generated Key Retrieval)라고 합니다.

@Test
public void testReturnGeneratedKey() throws Exception {
    Connection conn = DriverManager.getConnection("jdbc:mysql:///db05", "root", "password");
    String sql = "INSERT INTO employees (employee_name, employee_salary, employee_age) VALUES (?, ?, ?)";
    // Statement.RETURN_GENERATED_KEYS 플래그를 사용하여 생성된 키를 반환받도록 설정
    PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
    Employee emp = new Employee(null, "jack", 12345.6, 26);
    ps.setString(1, emp.getName());
    ps.setDouble(2, emp.getSalary());
    ps.setInt(3, emp.getAge());
    int affectedRows = ps.executeUpdate();
    ResultSet generatedKeys = null;
    if (affectedRows > 0) {
        generatedKeys = ps.getGeneratedKeys();
        if (generatedKeys.next()) {
            emp.setId(generatedKeys.getInt(1));
        }
        System.out.println("생성된 직원: " + emp);
    } else {
        System.out.println("추가 실패");
    }
    if (generatedKeys != null) generatedKeys.close();
    ps.close(); conn.close();
}

3. 배치 처리

대량의 INSERT 문을 하나씩 실행하면 데이터베이스와의 통신 오버헤드가 커져 성능이 저하됩니다. 배치 처리를 사용하면 여러 SQL 문을 하나의 요청으로 묶어 전송하여 효율성을 크게 높일 수 있습니다.

배치 미적용 코드 (느림):

@Test
public void testSequentialInsert() throws Exception {
    Connection conn = DriverManager.getConnection("jdbc:mysql:///db05", "root", "password");
    PreparedStatement ps = conn.prepareStatement("INSERT INTO employees (employee_name, employee_salary, employee_age) VALUES (?, ?, ?)");
    long start = System.currentTimeMillis();
    for (int i = 0; i < 10000; i++) {
        ps.setString(1, "user" + i);
        ps.setDouble(2, 1200 + i);
        ps.setInt(3, 20 + i);
        ps.executeUpdate();
    }
    long end = System.currentTimeMillis();
    System.out.println("순차 삽입 시간: " + (end - start) + "ms");
    ps.close(); conn.close();
}

배치 적용 코드 (빠름):

@Test
public void testBatchInsert() throws Exception {
    // URL에 배치 처리를 활성화하는 파라미터를 추가해야 함
    Connection conn = DriverManager.getConnection("jdbc:mysql:///db05?rewriteBatchedStatements=true", "root", "password");
    PreparedStatement ps = conn.prepareStatement("INSERT INTO employees (employee_name, employee_salary, employee_age) VALUES (?, ?, ?)");
    long start = System.currentTimeMillis();
    for (int i = 0; i < 10000; i++) {
        ps.setString(1, "user" + i);
        ps.setDouble(2, 1200 + i);
        ps.setInt(3, 20 + i);
        ps.addBatch(); // 요청에 SQL 추가
    }
    ps.executeBatch(); // 모든 요청을 한 번에 실행
    long end = System.currentTimeMillis();
    System.out.println("배치 삽입 시간: " + (end - start) + "ms");
    ps.close(); conn.close();
}

배치 처리 핵심 사항:

  • 데이터베이스 URL에 rewriteBatchedStatements=true를 반드시 추가해야 합니다.
  • SQL 문법에서 values 키워드를 사용해야 하며, 문장 끝에 세미콜론(;)을 붙이면 안 됩니다.
  • addBatch()로 명령을 누적하고 executeBatch()로 일괄 실행합니다.

4. 커넥션 풀 활용

매번 데이터베이스 연결을 생성하고 해제하는 것은 큰 비용이 듭니다. 커넥션 풀은 미리 일정 수의 연결을 생성해 두고, 요청 시 빌려주고 사용 후 반납받는 방식으로 이 문제를 해결합니다. javax.sql.DataSource 인터페이스가 커넥션 풀의 표준을 정의합니다. 대표적인 구현체로는 DruidHikariCP가 있습니다.

4.1 Druid 커넥션 풀

하드코딩 방식:

@Test
public void testHardCodedDruid() throws SQLException {
    DruidDataSource dataSource = new DruidDataSource();
    dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
    dataSource.setUrl("jdbc:mysql:///db05");
    dataSource.setUsername("root");
    dataSource.setPassword("password");
    dataSource.setInitialSize(10);
    dataSource.setMaxActive(20);
    Connection conn = dataSource.getConnection();
    System.out.println("연결 성공: " + conn);
    conn.close(); // 풀에 반환
}

소프트코딩 방식 (권장):

리소스 파일 db.properties:

driverClassName=com.mysql.cj.jdbc.Driver
url=jdbc:mysql:///db05
username=root
password=password
initialSize=10
maxActive=20

자바 코드:

@Test
public void testSoftCodedDruid() throws Exception {
    Properties props = new Properties();
    props.load(getClass().getClassLoader().getResourceAsStream("db.properties"));
    DataSource dataSource = DruidDataSourceFactory.createDataSource(props);
    Connection conn = dataSource.getConnection();
    System.out.println("연결 성공: " + conn);
    conn.close();
}

4.2 HikariCP 커넥션 풀

하드코딩 방식:

@Test
public void testHardCodedHikari() throws Exception {
    HikariDataSource dataSource = new HikariDataSource();
    dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
    dataSource.setJdbcUrl("jdbc:mysql:///db05");
    dataSource.setUsername("root");
    dataSource.setPassword("password");
    dataSource.setMinimumIdle(10);
    dataSource.setMaximumPoolSize(20);
    Connection conn = dataSource.getConnection();
    System.out.println("연결 성공: " + conn);
    conn.close();
}

소프트코딩 방식:

리소스 파일 hikari.properties:

driverClassName=com.mysql.cj.jdbc.Driver
jdbcUrl=jdbc:mysql:///db05
username=root
password=password
minimumIdle=10
maximumPoolSize=20

자바 코드:

@Test
public void testSoftCodedHikari() throws Exception {
    Properties props = new Properties();
    props.load(getClass().getClassLoader().getResourceAsStream("hikari.properties"));
    HikariConfig config = new HikariConfig(props);
    DataSource dataSource = new HikariDataSource(config);
    Connection conn = dataSource.getConnection();
    System.out.println("연결 성공: " + conn);
    conn.close();
}

5. JDBC 유틸리티 클래스와 ThreadLocal

커넥션 풀을 전역에서 관리하고, 연결 획득과 반환 코드의 중복을 줄이기 위해 유틸리티 클래스를 작성합니다. 또한, 트랜잭션 등을 위해 한 스레드 내에서는 동일한 연결 객체를 사용하도록 ThreadLocal을 활용할 수 있습니다.

Druid 기반 유틸리티 클래스 (ThreadLocal 적용):

package com.example.jdbc.util;

import com.alibaba.druid.pool.DruidDataSourceFactory;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;

public class JdbcUtil {
    private static final DataSource DATA_SOURCE;
    private static final ThreadLocal<Connection> TL = new ThreadLocal<>();

    static {
        try {
            Properties props = new Properties();
            props.load(JdbcUtil.class.getClassLoader().getResourceAsStream("db.properties"));
            DATA_SOURCE = DruidDataSourceFactory.createDataSource(props);
        } catch (Exception e) {
            throw new RuntimeException("커넥션 풀 초기화 실패", e);
        }
    }

    public static Connection getConnection() {
        Connection conn = TL.get();
        if (conn == null) {
            try {
                conn = DATA_SOURCE.getConnection();
                TL.set(conn);
            } catch (SQLException e) {
                throw new RuntimeException("연결 획득 실패", e);
            }
        }
        return conn;
    }

    public static void releaseConnection() {
        Connection conn = TL.get();
        if (conn != null) {
            TL.remove();
            try {
                conn.close();
            } catch (SQLException e) {
                throw new RuntimeException("연결 반환 실패", e);
            }
        }
    }
}

사용 예시:

@Test
public void testThreadLocalConnection() {
    Connection conn1 = JdbcUtil.getConnection();
    Connection conn2 = JdbcUtil.getConnection();
    System.out.println(conn1 == conn2); // 동일한 스레드이므로 true
    JdbcUtil.releaseConnection();
}

태그: JDBC ORM java PreparedStatement ResultSet

6월 15일 17:00에 게시됨