자바 애플리케이션을 위한 JDBC: 데이터베이스 연동 기술

대부분의 애플리케이션은 데이터베이스와 상호작용하여 데이터를 저장, 관리 및 검색합니다. 개발자가 데이터베이스 작업을 수행하는 방식에는 여러 가지가 있지만, 효율적이고 안전하며 확장 가능한 솔루션을 제공하는 것이 중요합니다. 이 글에서는 자바에서 데이터베이스와 연동하기 위한 핵심 기술인 JDBC(Java Database Connectivity)의 기본 개념부터 고급 활용법까지 다룹니다.

JDBC 소개

데이터베이스 접근 방식

수동으로 데이터베이스에 접근할 때는 클라이언트 도구를 사용합니다. 이 과정에서 직접 연결을 설정하고, 사용자 이름과 암호를 입력하여 로그인한 다음, SQL 쿼리를 작성하고 실행하여 결과를 확인합니다. 하지만 실제 애플리케이션 개발에서는 사용자의 데이터 변경 요청마다 수동으로 SQL을 실행하는 것은 비효율적이며 오류 발생 가능성이 높습니다. 따라서 프로그래밍 방식으로 데이터베이스를 조작하는 방법이 필요합니다.

JDBC란 무엇인가?

JDBC는 자바 언어를 사용하여 관계형 데이터베이스에 접속하고, 데이터 추가(Create), 조회(Read), 수정(Update), 삭제(Delete) 작업을 수행할 수 있도록 하는 자바 API입니다. JDBC는 자바 개발자가 다양한 데이터베이스에 표준화된 방식으로 접근할 수 있는 통일된 인터페이스를 제공합니다.

JDBC 핵심 원리

JDBC의 핵심은 자바 표준 API가 데이터베이스 접근 인터페이스를 정의하고, 각 데이터베이스 벤더가 이 인터페이스의 구현체(데이터베이스 드라이버)를 제공한다는 점입니다. 이를 통해 개발자는 특정 데이터베이스에 종속되지 않고 동일한 JDBC 코드로 여러 데이터베이스를 사용할 수 있습니다.

MySQL 데이터베이스 드라이버

MySQL 데이터베이스를 사용하려면 해당 버전의 JDBC 드라이버 JAR 파일이 필요합니다.

  • `mysql-connector-java-5.1.X-bin.jar`: MySQL 5.X 버전에 적합
  • `mysql-connector-java-8.0.X-bin.jar`: MySQL 8.X 버전에 적합 (드라이버 클래스 이름이 `com.mysql.cj.jdbc.Driver`로 변경되었음을 유의)
JDBC API 주요 인터페이스 및 클래스
유형 전체 경로 설명
클래스 java.sql.DriverManager 여러 데이터베이스 드라이버를 관리하며, 데이터베이스 연결을 얻는 정적 메서드를 제공합니다.
인터페이스 java.sql.Connection 활성 데이터베이스 연결을 나타냅니다. null이 아니면 연결이 설정된 상태입니다.
인터페이스 java.sql.Statement 정적인 SQL 문을 데이터베이스로 전송하는 데 사용되는 객체입니다.
인터페이스 java.sql.ResultSet SQL 쿼리 문의 결과 데이터를 포함하는 테이블 형태의 데이터 집합입니다.
클래스 java.sql.SQLException 데이터베이스 애플리케이션 작업 중 발생하는 예외를 처리합니다.

개발 환경 설정

  1. 프로젝트 루트 디렉터리 아래에 lib 폴더를 생성합니다.
  2. 다운로드한 MySQL JDBC 드라이버 JAR 파일(예: mysql-connector-java-8.0.X.jar)을 lib 폴더에 복사합니다.
  3. IDE(예: IntelliJ IDEA, Eclipse)에서 lib 폴더를 라이브러리로 추가하여 프로젝트의 빌드 경로에 포함시킵니다.

JDBC 개발의 핵심 단계

JDBC를 사용하여 데이터베이스와 상호작용하는 기본적인 6단계는 다음과 같습니다.

1. 드라이버 로드

Class.forName() 메서드를 사용하여 데이터베이스 드라이버 클래스를 JVM에 동적으로 로드합니다. 이는 드라이버가 자신을 DriverManager에 등록하도록 합니다.

import java.sql.SQLException;

public class DbDriverLoader {
    public static void loadDriver(String driverClassName) {
        try {
            Class.forName(driverClassName);
            System.out.println("데이터베이스 드라이버 로드 성공: " + driverClassName);
        } catch (ClassNotFoundException e) {
            System.err.println("드라이버 클래스를 찾을 수 없습니다: " + driverClassName + " - " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        // MySQL 8.0 이상 드라이버
        loadDriver("com.mysql.cj.jdbc.Driver"); 
        // MySQL 5.x 이전 드라이버
        // loadDriver("com.mysql.jdbc.Driver");
    }
}

2. 데이터베이스 연결 설정

DriverManager.getConnection() 메서드를 사용하여 데이터베이스 연결 객체(Connection)를 얻습니다. 연결 시에는 데이터베이스 URL, 사용자 이름, 암호가 필요합니다.

  • URL 형식: jdbc:mysql://[호스트]:[포트]/[데이터베이스명]?옵션1=값1&옵션2=값2
  • 예시 사용자: dbuser
  • 예시 암호: dbpass
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class DbConnector {
    public static Connection establishConnection(String url, String user, String password) throws SQLException {
        return DriverManager.getConnection(url, user, password);
    }

    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "rootpassword"; // 실제 비밀번호로 변경하세요.

        try (Connection dbConn = establishConnection(dbUrl, dbUser, dbPass)) {
            System.out.println("데이터베이스에 성공적으로 연결되었습니다.");
        } catch (SQLException e) {
            System.err.println("데이터베이스 연결 오류: " + e.getMessage());
        }
    }
}

URL(Uniform Resource Locator): 프로토콜, IP 주소, 포트 번호, SID(데이터베이스 인스턴스 이름 또는 데이터베이스명)로 구성됩니다.

3. SQL 실행 객체 생성

Connection 객체를 통해 Statement 객체를 얻습니다. 이 객체는 데이터베이스에 SQL 쿼리를 전송하는 데 사용됩니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class SqlStatementCreator {
    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "rootpassword";

        try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
             Statement stmt = dbConn.createStatement()) {
            System.out.println("SQL Statement 객체 생성 완료.");
            // 여기서 stmt를 사용하여 SQL 실행 가능
        } catch (SQLException e) {
            System.err.println("SQL Statement 생성 오류: " + e.getMessage());
        }
    }
}

4. SQL 문 실행

Statement 객체의 executeUpdate() 또는 executeQuery() 메서드를 사용하여 SQL 문을 실행하고 결과를 받습니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class SqlExecutor {
    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "rootpassword";

        String insertQuery = "INSERT INTO products(product_id, product_name, price) VALUES('P101', '노트북', 1200000)";

        try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
             Statement stmt = dbConn.createStatement()) {

            int rowsAffected = stmt.executeUpdate(insertQuery);
            System.out.println("데이터 삽입 완료. 영향 받은 행 수: " + rowsAffected);

        } catch (SQLException e) {
            System.err.println("SQL 실행 오류: " + e.getMessage());
        }
    }
}
  • DML 문 (INSERT, UPDATE, DELETE): executeUpdate() 메서드를 사용하며, 작업에 의해 영향을 받은 행의 수를 int 타입으로 반환합니다.
  • DQL 문 (SELECT): executeQuery() 메서드를 사용하며, 쿼리 결과를 포함하는 ResultSet 객체를 반환합니다.

5. 결과 처리

SQL 실행 결과를 적절히 처리합니다.

  • DML 결과: 반환된 행 수를 확인하여 작업 성공 여부를 판단합니다.
  • DQL 결과: ResultSet 객체를 반복하여 데이터를 추출합니다.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class ResultHandler {
    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "rootpassword";

        String updateQuery = "UPDATE products SET price = 1300000 WHERE product_id = 'P101'";

        try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
             Statement stmt = dbConn.createStatement()) {

            int rowsUpdated = stmt.executeUpdate(updateQuery);

            if (rowsUpdated > 0) {
                System.out.println("제품 가격이 성공적으로 업데이트되었습니다. (" + rowsUpdated + "개 행 영향)");
            } else {
                System.out.println("제품 가격 업데이트 실패 또는 해당 제품 없음.");
            }

        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 오류: " + e.getMessage());
        }
    }
}

6. 리소스 해제

사용이 완료된 JDBC 리소스(ResultSet, Statement, Connection)는 역순으로 닫아(가장 최근에 열린 것부터) 시스템 자원을 반환해야 합니다. Java 7부터 도입된 try-with-resources 문을 사용하면 이 과정을 자동으로 처리할 수 있어 편리합니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class ResourceReleaser {
    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/sample_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "rootpassword";

        // try-with-resources 문을 사용하여 Connection, Statement가 자동으로 닫히도록 합니다.
        try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
             Statement stmt = dbConn.createStatement()) {

            String deleteQuery = "DELETE FROM products WHERE product_id = 'P101'";
            int rowsDeleted = stmt.executeUpdate(deleteQuery);

            if (rowsDeleted > 0) {
                System.out.println("제품 삭제 성공. 영향 받은 행 수: " + rowsDeleted);
            } else {
                System.out.println("제품 삭제 실패 또는 해당 제품 없음.");
            }

        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 중 오류 발생: " + e.getMessage());
        } // try-with-resources 덕분에 여기서 리소스가 자동으로 닫힙니다.
    }
}

종합 예시: 데이터 삭제

위의 6단계를 통합하여 데이터베이스 테이블에서 특정 레코드를 삭제하는 예시입니다.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public class DatabaseRecordRemover {

    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password"; // 실제 비밀번호로 변경

        try {
            // 1. 드라이버 로드 (Class.forName()은 한 번만 호출하면 됩니다. static 블록에서 로드하는 것이 일반적)
            Class.forName("com.mysql.cj.jdbc.Driver"); 
            
            // try-with-resources를 사용하여 Connection과 Statement를 자동으로 닫습니다.
            try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
                 Statement sqlExec = dbConn.createStatement()) {

                // 2. SQL 실행 객체 생성 (try-with-resources로 해결)
                // 3. SQL 문 실행 및 결과 수신
                String deleteJobSql = "DELETE FROM t_jobs WHERE job_id = 'JAVA_Mgr'";
                int affectedRows = sqlExec.executeUpdate(deleteJobSql);

                // 4. 결과 처리
                if (affectedRows > 0) {
                    System.out.println("작업 레코드 삭제 성공!");
                } else {
                    System.out.println("작업 레코드 삭제 실패 또는 해당 레코드 없음!");
                }
            } // 5. 리소스 해제 (try-with-resources로 자동 해결)

        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버를 찾을 수 없습니다: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 중 오류 발생: " + e.getMessage());
        }
    }
}

ResultSet(결과 집합)

ResultSet 객체는 executeQuery() 메서드를 통해 실행된 SQL 쿼리 문의 결과 데이터를 저장합니다. 결과는 테이블 형태이며, 특정 행과 열의 데이터를 추출할 수 있습니다.

ResultSet 수신

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class ResultSetReceiver {
    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password";

        String selectEmployeesSql = "SELECT employee_id, first_name, last_name FROM t_employees";

        try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
             Statement stmt = dbConn.createStatement();
             ResultSet rs = stmt.executeQuery(selectEmployeesSql)) { // ResultSet 객체 획득
            System.out.println("직원 데이터 조회 결과:");
            while (rs.next()) { // 다음 행이 존재하는 동안 반복
                int empId = rs.getInt("employee_id");
                String fName = rs.getString("first_name");
                String lName = rs.getString("last_name");
                System.out.println("ID: " + empId + ", 이름: " + fName + " " + lName);
            }
        } catch (SQLException e) {
            System.err.println("데이터 조회 오류: " + e.getMessage());
        }
    }
}

ResultSet 데이터 반복 처리

ResultSet은 내부적으로 커서(포인터)를 사용하여 현재 데이터를 가리킵니다. 초기 커서는 첫 번째 행 앞에 위치하며, boolean next() 메서드를 호출할 때마다 커서가 다음 행으로 이동합니다. next() 메서드는 다음 행에 데이터가 있으면 true를 반환하고, 없으면 false를 반환합니다.

  • rs.getXxx(int columnIndex): 1부터 시작하는 열 번호(인덱스)를 사용하여 데이터를 가져옵니다.
  • rs.getXxx(String columnLabel): 열 이름(레이블)을 사용하여 데이터를 가져옵니다.
주요 데이터 추출 메서드
// 다음 행의 존재 여부를 확인하고 커서를 이동합니다.
boolean next() throws SQLException;

// 현재 행의 N번째 열(int) 값을 가져옵니다. (열 인덱스는 1부터 시작)
int getInt(int columnIndex) throws SQLException;
// 현재 행의 'columnLabel' 열(int) 값을 가져옵니다.
int getInt(String columnLabel) throws SQLException;
    
// 현재 행의 N번째 열(double) 값을 가져옵니다.
double getDouble(int columnIndex) throws SQLException;
// 현재 행의 'columnLabel' 열(double) 값을 가져옵니다.
double getDouble(String columnLabel) throws SQLException;
    
// 현재 행의 N번째 열(String) 값을 가져옵니다.
String getString(int columnIndex) throws SQLException;
// 현재 행의 'columnLabel' 열(String) 값을 가져옵니다.
String getString(String columnLabel) throws SQLException;

주의: 열 인덱스는 1부터 시작합니다.

종합 예시: 작업 데이터 조회

1. 열 이름을 통한 데이터 가져오기
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class JobDataFetcherByName {

    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password";

        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
            try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
                 Statement stmt = dbConn.createStatement();
                 ResultSet queryResult = stmt.executeQuery("SELECT job_id, job_title, min_salary, max_salary FROM t_jobs")) {

                System.out.println("작업 데이터 (열 이름으로 조회):");
                while (queryResult.next()) {
                    String jobId = queryResult.getString("job_id");
                    String jobTitle = queryResult.getString("job_title");
                    String minSalary = queryResult.getString("min_salary");
                    String maxSalary = queryResult.getString("max_salary");
                    System.out.println(jobId + "\t" + jobTitle + "\t" + minSalary + "\t" + maxSalary);
                }
            }
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버 로드 실패: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 오류: " + e.getMessage());
        }
    }
}
2. 열 번호(인덱스)를 통한 데이터 가져오기
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class JobDataFetcherByIndex {

    public static void main(String[] args) {
        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password";

        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
            try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
                 Statement stmt = dbConn.createStatement();
                 ResultSet queryResult = stmt.executeQuery("SELECT job_id, job_title, min_salary, max_salary FROM t_jobs")) {

                System.out.println("작업 데이터 (열 인덱스로 조회):");
                while (queryResult.next()) { // 다음 행이 존재하는지 확인
                    String jobId = queryResult.getString(1); // 첫 번째 열 (job_id)
                    String jobTitle = queryResult.getString(2); // 두 번째 열 (job_title)
                    String minSalary = queryResult.getString(3); // 세 번째 열 (min_salary)
                    String maxSalary = queryResult.getString(4); // 네 번째 열 (max_salary)
                    System.out.println(jobId + "\t" + jobTitle + "\t" + minSalary + "\t" + maxSalary);
                }
            }
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버 로드 실패: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 오류: " + e.getMessage());
        }
    }
}

JDBC에서 흔히 발생하는 오류

  • java.lang.ClassNotFoundException: 드라이버 클래스를 찾을 수 없을 때 발생합니다. 클래스 이름이 잘못되었거나, JDBC 드라이버 JAR 파일이 프로젝트 빌드 경로에 제대로 추가되지 않았을 수 있습니다.
  • java.sql.SQLException: 일반적인 SQL 관련 오류입니다. SQL 문법 오류, 제약 조건 위반, 테이블 또는 컬럼 이름 오류 등이 원인일 수 있습니다. SQL 문을 데이터베이스 클라이언트 도구에서 먼저 테스트하는 것이 좋습니다.
  • com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Unknown column: SQL 쿼리에서 존재하지 않는 컬럼 이름을 사용했거나, 문자열 타입 값에 따옴표(`'`)를 누락했을 때 발생할 수 있습니다.
  • Duplicate entry '1' for key 'PRIMARY': 기본 키 제약 조건을 위반하여 이미 존재하는 기본 키 값을 삽입하려고 할 때 발생합니다.
  • com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Unknown column 'password' in 'field list': SQL 쿼리에서 컬럼 이름이 잘못되었거나, 데이터 유형이 일치하지 않을 때 발생할 수 있습니다.

종합 예시: 사용자 로그인 (Statement 방식)

사용자가 콘솔을 통해 사용자 이름과 암호를 입력받아 데이터베이스에 저장된 정보와 일치하는지 확인하는 로그인 기능을 구현합니다.

1. 사용자 테이블 생성

`app_users`라는 이름의 사용자 테이블을 생성합니다. 여기에는 ID(자동 증가, 기본 키), 사용자 이름(고유, 필수), 암호(필수), 전화번호 컬럼이 포함됩니다.

CREATE TABLE app_users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    user_name VARCHAR(20) UNIQUE NOT NULL,
    user_password VARCHAR(20) NOT NULL,
    phone_number VARCHAR(15)
) CHARSET=utf8;

INSERT INTO app_users(user_name, user_password, phone_number) VALUES('alice', 'pass123', '010-1111-2222');
INSERT INTO app_users(user_name, user_password, phone_number) VALUES('bob', 'securepwd', '010-3333-4444');

2. 로그인 기능 구현

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Scanner;

public class UserAuthenticatorLegacy {
    public static void main(String[] args) {
        Scanner inputScanner = new Scanner(System.in);
        System.out.print("사용자 이름을 입력하세요: ");
        String enteredUsername = inputScanner.next();
        System.out.print("비밀번호를 입력하세요: ");
        String enteredPassword = inputScanner.next();

        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password";

        try {
            Class.forName("com.mysql.cj.jdbc.Driver"); // 드라이버 로드

            try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
                 Statement sqlExec = dbConn.createStatement()) {

                // 사용자 입력 값을 직접 SQL 쿼리에 삽입
                String loginQuery = "SELECT user_name, user_password FROM app_users WHERE user_name = '" 
                                    + enteredUsername + "' AND user_password = '" + enteredPassword + "'";
                
                ResultSet queryResult = sqlExec.executeQuery(loginQuery);

                if (queryResult.next()) { // 데이터가 조회되면 로그인 성공
                    System.out.println("로그인 성공!");
                } else {
                    System.out.println("로그인 실패: 사용자 이름 또는 비밀번호가 올바르지 않습니다.");
                }
                // ResultSet은 try-with-resources에서 Statement에 의해 자동으로 닫히거나 명시적으로 닫아야 합니다.
                // 여기서는 Statement와 함께 닫힙니다.
            }
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버 로드 오류: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 오류: " + e.getMessage());
        } finally {
            inputScanner.close();
        }
    }
}

SQL 인젝션 문제

SQL 인젝션이란?

SQL 인젝션은 사용자 입력 데이터에 SQL 키워드나 문법이 포함되어 SQL 쿼리 구문에 영향을 미쳐, 개발자가 의도하지 않은 방식으로 쿼리가 실행되는 보안 취약점입니다. 예를 들어, 로그인 폼에 특정 문자열을 입력하면 항상 true가 되는 조건을 만들어 인증 과정을 우회할 수 있습니다.

SQL 인젝션 방지 방법

SQL 인젝션을 방지하려면 사용자 입력 데이터를 SQL 쿼리에 직접 결합하기 전에, 쿼리를 미리 컴파일하여 구조를 고정해야 합니다. 이후 컴파일된 쿼리의 '자리 표시자'에 사용자 입력을 안전하게 바인딩하는 방식을 사용합니다. JDBC에서는 PreparedStatement 객체가 이 역할을 수행합니다.

PreparedStatement (준비된 문장)

PreparedStatementStatement 인터페이스를 상속하며, SQL 문 실행 방식은 유사하지만 중요한 차이점이 있습니다. PreparedStatement는 SQL 인젝션을 방지하고, 미리 컴파일된 SQL 문을 재사용하여 성능을 향상시킵니다.

PreparedStatement 활용

  • 효율성: SQL 문을 미리 컴파일하여 데이터베이스에 전송하므로, 동일한 SQL 문을 여러 번 실행할 때 재컴파일 비용이 들지 않아 효율적입니다.
  • 보안성: SQL 인젝션 공격을 방지합니다. 사용자 입력이 쿼리 자체의 일부가 아닌, '매개변수'로 처리됩니다.
  • 동적 데이터 채우기: SQL 문 내에 '?'(물음표)를 사용하여 매개변수 자리 표시자를 지정하고, 나중에 이 자리 표시자에 값을 동적으로 바인딩할 수 있습니다.
매개변수 자리 표시자 사용
// SQL 문에 '?' (물음표)를 사용하여 매개변수를 지정합니다.
PreparedStatement prepStmt = dbConn.prepareStatement("SELECT * FROM app_users WHERE user_name = ? AND user_password = ?");

JDBC의 모든 매개변수는 '?'로 표시됩니다. SQL 문을 실행하기 전에 각 매개변수에 값을 제공해야 합니다.

동적 매개변수 바인딩

ps.setXxx(parameterIndex, value) 메서드를 사용하여 자리 표시자에 값을 바인딩합니다. 매개변수 인덱스는 1부터 시작합니다.

// 첫 번째 '?'에 사용자 이름 값을 바인딩합니다.
prepStmt.setString(1, enteredUsername);
// 두 번째 '?'에 비밀번호 값을 바인딩합니다.
prepStmt.setString(2, enteredPassword);

전체 코드 예시: PreparedStatement를 사용한 로그인

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Scanner;

public class UserAuthenticatorSecure {
    public static void main(String[] args) {
        Scanner inputReader = new Scanner(System.in);
        System.out.print("사용자 이름을 입력하세요: ");
        String usernameInput = inputReader.nextLine();
        System.out.print("비밀번호를 입력하세요: ");
        String passwordInput = inputReader.nextLine();

        String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
        String dbUser = "root";
        String dbPass = "your_root_password";

        try {
            Class.forName("com.mysql.cj.jdbc.Driver"); // 드라이버 로드

            // try-with-resources로 Connection, PreparedStatement, ResultSet을 자동으로 닫습니다.
            try (Connection dbConn = DriverManager.getConnection(dbUrl, dbUser, dbPass);
                 // PreparedStatement 객체를 얻고 SQL을 미리 컴파일합니다.
                 PreparedStatement authStmt = dbConn.prepareStatement("SELECT user_id FROM app_users WHERE user_name = ? AND user_password = ?")) {

                // '?' 자리 표시자에 값 바인딩
                authStmt.setString(1, usernameInput);
                authStmt.setString(2, passwordInput);

                // SQL 실행
                ResultSet authResult = authStmt.executeQuery();

                if (authResult.next()) { // 조회된 데이터가 있으면 로그인 성공
                    System.out.println("로그인 성공!");
                } else {
                    System.out.println("로그인 실패: 사용자 이름 또는 비밀번호가 올바르지 않습니다.");
                }
            }
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버 로드 오류: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 오류: " + e.getMessage());
        } finally {
            inputReader.close();
        }
    }
}

JDBC 유틸리티 클래스 캡슐화

실제 JDBC 애플리케이션에서는 데이터베이스 연결 및 자원 해제와 같은 반복적인 코드가 많이 발생합니다. 이러한 중복을 줄이고 코드의 재사용성을 높이기 위해 JDBC 관련 작업을 처리하는 유틸리티 클래스를 만드는 것이 일반적입니다.

재사용성 개선 방안

데이터베이스 연결을 가져오고 리소스를 해제하는 두 가지 핵심 메서드를 유틸리티 클래스로 캡슐화합니다.

  • public static Connection getConnection(): 데이터베이스 연결 객체를 반환합니다.
  • public static void closeResources(Connection conn, Statement stmt, ResultSet rs): 사용된 모든 JDBC 리소스를 안전하게 해제합니다.
기본 재사용 유틸리티 클래스 구현
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class SimpleDbUtil {

    // 클래스가 로드될 때 한 번만 드라이버를 로드합니다.
    static {
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버 로드 실패: " + e.getMessage());
            throw new ExceptionInInitializerError(e); // 초기화 실패 시 에러 발생
        }
    }

    // 1. 데이터베이스 연결 가져오기
    public static Connection createConnection() {
        Connection connection = null;
        try {
            // 실제 데이터베이스 정보로 변경하세요.
            String dbUrl = "jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC";
            String dbUser = "root";
            String dbPass = "your_root_password";
            connection = DriverManager.getConnection(dbUrl, dbUser, dbPass);
        } catch (SQLException e) {
            System.err.println("데이터베이스 연결 오류: " + e.getMessage());
        }
        return connection;
    }

    // 2. 리소스 해제
    public static void closeResources(Connection conn, Statement stmt, ResultSet rs) {
        try {
            if (rs != null) {
                rs.close();
            }
            if (stmt != null) {
                stmt.close();
            }
            if (conn != null) {
                conn.close();
            }
        } catch (SQLException e) {
            System.err.println("JDBC 리소스 해제 오류: " + e.getMessage());
        }
    }
}

플랫폼 독립적인 설정 관리

데이터베이스 연결 정보(드라이버 이름, URL, 사용자, 암호)를 코드 내에 직접 하드코딩하는 대신, 외부 설정 파일(예: `.properties` 파일)에서 읽어오는 방식으로 개선하여 유연성과 유지보수성을 높일 수 있습니다.

1. `database.properties` 설정 파일

src 디렉터리 아래에 `database.properties` 파일을 생성하고 다음 내용을 추가합니다.

# JDBC 드라이버 클래스
driver=com.mysql.cj.jdbc.Driver
# 데이터베이스 연결 URL
url=jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
# 데이터베이스 사용자 이름
username=root
# 데이터베이스 비밀번호
password=your_root_password
2. 설정 파일을 활용하는 유틸리티 클래스
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;

public class ConfigurableDbUtil {
    private static final Properties DB_CONFIG = new Properties();

    static {
        // 클래스 로드 시점에 properties 파일을 로드하고 드라이버를 로드합니다.
        try (InputStream input = ConfigurableDbUtil.class.getResourceAsStream("/database.properties")) {
            if (input == null) {
                throw new IOException("database.properties 파일을 찾을 수 없습니다.");
            }
            DB_CONFIG.load(input); // 설정 파일 내용 로드
            Class.forName(DB_CONFIG.getProperty("driver")); // 드라이버 로드
            System.out.println("데이터베이스 드라이버 및 설정 로드 완료.");
        } catch (IOException e) {
            System.err.println("설정 파일 로드 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        } catch (ClassNotFoundException e) {
            System.err.println("JDBC 드라이버를 찾을 수 없습니다: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        }
    }

    // 데이터베이스 연결 객체 반환
    public static Connection getConnection() {
        Connection connection = null;
        try {
            connection = DriverManager.getConnection(
                DB_CONFIG.getProperty("url"),
                DB_CONFIG.getProperty("username"),
                DB_CONFIG.getProperty("password")
            );
        } catch (SQLException e) {
            System.err.println("데이터베이스 연결 오류: " + e.getMessage());
        }
        return connection;
    }

    // JDBC 리소스 해제
    public static void closeResources(Connection conn, Statement stmt, ResultSet rs) {
        try {
            if (rs != null) rs.close();
            if (stmt != null) stmt.close();
            if (conn != null) conn.close();
        } catch (SQLException e) {
            System.err.println("JDBC 리소스 해제 중 오류: " + e.getMessage());
        }
    }
}

ORM (객체-관계 매핑)

ORM(Object Relational Mapping)은 데이터베이스의 관계형 데이터를 자바 객체와 매핑하는 기술입니다. JDBC를 통해 조회된 ResultSet의 데이터를 개별적으로 처리하는 대신, 이 데이터를 비즈니스 로직에서 사용하기 편리한 자바 객체(엔티티)로 캡슐화하는 것이 일반적입니다.

엔티티 클래스: 분산된 데이터의 컨테이너

엔티티 클래스는 데이터베이스 테이블의 한 행을 나타내는 자바 객체입니다. 테이블의 각 컬럼은 엔티티 클래스의 속성(필드)으로 매핑됩니다.

  • 테이블 이름은 클래스 이름과 매핑됩니다 (예: `t_jobs` 테이블 → `JobEntity` 클래스).
  • 테이블 컬럼은 클래스의 속성과 매핑됩니다 (예: `job_id` 컬럼 → `jobId` 속성).
  • 각 속성에 대한 Getter 및 Setter 메서드를 제공합니다.
  • 기본 생성자(인자 없는 생성자)를 포함하는 것이 좋습니다.
ORM 적용 예시

`t_jobs` 테이블에 네 개의 컬럼(job_id, job_title, min_salary, max_salary)이 있다고 가정하고, 이를 `JobEntity` 클래스로 정의합니다.

import java.util.Objects;

public class JobEntity {
    private String jobId;
    private String jobTitle;
    private int minSalary;
    private int maxSalary;

    public JobEntity() {
    }

    public JobEntity(String jobId, String jobTitle, int minSalary, int maxSalary) {
        this.jobId = jobId;
        this.jobTitle = jobTitle;
        this.minSalary = minSalary;
        this.maxSalary = maxSalary;
    }

    // Getter 및 Setter 메서드
    public String getJobId() {
        return jobId;
    }

    public void setJobId(String jobId) {
        this.jobId = jobId;
    }

    public String getJobTitle() {
        return jobTitle;
    }

    public void setJobTitle(String jobTitle) {
        this.jobTitle = jobTitle;
    }

    public int getMinSalary() {
        return minSalary;
    }

    public void setMinSalary(int minSalary) {
        this.minSalary = minSalary;
    }

    public int getMaxSalary() {
        return maxSalary;
    }

    public void setMaxSalary(int maxSalary) {
        this.maxSalary = maxSalary;
    }

    @Override
    public String toString() {
        return "JobEntity{" +
               "jobId='" + jobId + '\'' +
               ", jobTitle='" + jobTitle + '\'' +
               ", minSalary=" + minSalary +
               ", maxSalary=" + maxSalary +
               '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        JobEntity jobEntity = (JobEntity) o;
        return minSalary == jobEntity.minSalary &&
               maxSalary == jobEntity.maxSalary &&
               Objects.equals(jobId, jobEntity.jobId) &&
               Objects.equals(jobTitle, jobEntity.jobTitle);
    }

    @Override
    public int hashCode() {
        return Objects.hash(jobId, jobTitle, minSalary, maxSalary);
    }
}

조회된 데이터를 `JobEntity` 객체로 캡슐화하고, 이 객체들을 리스트에 저장하여 활용합니다.

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class JobDataMapper {
    public static void main(String[] args) {
        List<JobEntity> jobList = new ArrayList<>();
        Connection dbConn = null;
        PreparedStatement pstmt = null;
        ResultSet queryResult = null;

        try {
            dbConn = ConfigurableDbUtil.getConnection(); // 유틸리티 클래스에서 연결 가져오기
            if (dbConn == null) {
                System.err.println("데이터베이스 연결 실패.");
                return;
            }

            pstmt = dbConn.prepareStatement("SELECT job_id, job_title, min_salary, max_salary FROM t_jobs");
            queryResult = pstmt.executeQuery();

            while (queryResult.next()) {
                // ResultSet에서 개별 데이터 추출
                String jobId = queryResult.getString("job_id");
                String jobTitle = queryResult.getString("job_title");
                int minSalary = queryResult.getInt("min_salary");
                int maxSalary = queryResult.getInt("max_salary");

                // JobEntity 객체 생성 및 데이터 캡슐화
                JobEntity job = new JobEntity(jobId, jobTitle, minSalary, maxSalary);
                
                // 생성된 객체를 리스트에 추가
                jobList.add(job);
            }

            // 캡슐화된 객체 리스트 출력
            System.out.println("조회된 작업 목록:");
            for (JobEntity job : jobList) {
                System.out.println(job);
            }

        } catch (SQLException e) {
            System.err.println("데이터베이스 작업 중 오류 발생: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(dbConn, pstmt, queryResult); // 리소스 해제
        }
    }
}

DAO (데이터 접근 객체)

DAO(Data Access Object) 패턴은 애플리케이션의 비즈니스 로직과 데이터베이스 접근 로직을 분리하는 디자인 패턴입니다. 이를 통해 데이터베이스 변경이 비즈니스 로직에 미치는 영향을 최소화하고 코드의 재사용성과 유지보수성을 높일 수 있습니다.

  • 하나의 데이터베이스 테이블에 대한 모든 CRUD(생성, 조회, 수정, 삭제) 작업을 하나의 DAO 구현체에 캡슐화합니다.
  • 각 CRUD 기능별로 구체적인 메서드(예: `insert`, `update`, `delete`, `selectById`, `selectAll`)를 정의합니다.

1. `person` 테이블 생성

CREATE TABLE person_records (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    age INT NOT NULL,
    birth_date DATE,
    email VARCHAR(100),
    address VARCHAR(100)
) CHARSET=utf8;

2. `PersonRecord` 엔티티 클래스 캡슐화

import java.sql.Date; // java.sql.Date 사용
import java.util.Objects;

public class PersonRecord {
    private int id;
    private String name;
    private int age;
    private Date birthDate; // java.sql.Date로 변경
    private String email;
    private String address;

    public PersonRecord() {
    }

    public PersonRecord(String name, int age, Date birthDate, String email, String address) {
        this.name = name;
        this.age = age;
        this.birthDate = birthDate;
        this.email = email;
        this.address = address;
    }

    public PersonRecord(int id, String name, int age, Date birthDate, String email, String address) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.birthDate = birthDate;
        this.email = email;
        this.address = address;
    }

    // Getter 및 Setter 메서드
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
    public Date getBirthDate() { return birthDate; }
    public void setBirthDate(Date birthDate) { this.birthDate = birthDate; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getAddress() { return address; }
    public void setAddress(String address) { this.address = address; }

    @Override
    public String toString() {
        return "PersonRecord{" +
               "id=" + id +
               ", name='" + name + '\'' +
               ", age=" + age +
               ", birthDate=" + birthDate +
               ", email='" + email + '\'' +
               ", address='" + address + '\'' +
               '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PersonRecord that = (PersonRecord) o;
        return id == that.id &&
               age == that.age &&
               Objects.equals(name, that.name) &&
               Objects.equals(birthDate, that.birthDate) &&
               Objects.equals(email, that.email) &&
               Objects.equals(address, that.address);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name, age, birthDate, email, address);
    }
}

3. `PersonRepositoryImpl` DAO 클래스 작성

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class PersonRepositoryImpl {

    // 1. 새 레코드 추가
    public int insertPerson(PersonRecord person) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        String sql = "INSERT INTO person_records(name, age, birth_date, email, address) VALUES(?, ?, ?, ?, ?)";
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return 0;
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, person.getName());
            pstmt.setInt(2, person.getAge());
            pstmt.setDate(3, person.getBirthDate()); // java.sql.Date 사용
            pstmt.setString(4, person.getEmail());
            pstmt.setString(5, person.getAddress());
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            System.err.println("인물 레코드 삽입 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, null);
        }
        return 0;
    }

    // 2. 레코드 수정
    public int updatePerson(PersonRecord person) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        String sql = "UPDATE person_records SET name=?, age=?, birth_date=?, email=?, address=? WHERE id = ?";
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return 0;
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, person.getName());
            pstmt.setInt(2, person.getAge());
            pstmt.setDate(3, person.getBirthDate());
            pstmt.setString(4, person.getEmail());
            pstmt.setString(5, person.getAddress());
            pstmt.setInt(6, person.getId());
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            System.err.println("인물 레코드 업데이트 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, null);
        }
        return 0;
    }

    // 3. 레코드 삭제
    public int deletePerson(int id) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        String sql = "DELETE FROM person_records WHERE id = ?";
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return 0;
            pstmt = conn.prepareStatement(sql);
            pstmt.setInt(1, id);
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            System.err.println("인물 레코드 삭제 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, null);
        }
        return 0;
    }

    // 4. 단일 레코드 조회
    public PersonRecord findPersonById(int id) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        String sql = "SELECT id, name, age, birth_date, email, address FROM person_records WHERE id = ?";
        PersonRecord person = null;
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return null;
            pstmt = conn.prepareStatement(sql);
            pstmt.setInt(1, id);
            rs = pstmt.executeQuery();
            if (rs.next()) { // 데이터가 존재하면 객체 생성 및 매핑
                person = new PersonRecord(
                    rs.getInt("id"),
                    rs.getString("name"),
                    rs.getInt("age"),
                    rs.getDate("birth_date"),
                    rs.getString("email"),
                    rs.getString("address")
                );
            }
            return person;
        } catch (SQLException e) {
            System.err.println("인물 레코드 조회 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, rs);
        }
        return null;
    }

    // 5. 모든 레코드 조회
    public List<PersonRecord> findAllPeople() {
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        String sql = "SELECT id, name, age, birth_date, email, address FROM person_records";
        List<PersonRecord> personList = new ArrayList<>();
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return personList;
            pstmt = conn.prepareStatement(sql);
            rs = pstmt.executeQuery();
            while (rs.next()) {
                PersonRecord person = new PersonRecord(
                    rs.getInt("id"),
                    rs.getString("name"),
                    rs.getInt("age"),
                    rs.getDate("birth_date"),
                    rs.getString("email"),
                    rs.getString("address")
                );
                personList.add(person);
            }
            return personList;
        } catch (SQLException e) {
            System.err.println("모든 인물 레코드 조회 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, rs);
        }
        return personList;
    }
}

Date 유틸리티 클래스

자바 애플리케이션에서 날짜를 다룰 때 `java.util.Date`와 `java.sql.Date` 사이의 변환이 필요할 수 있습니다. `java.util.Date`는 일반적인 날짜와 시간 정보를 담는 반면, `java.sql.Date`는 SQL `DATE` 타입에 매핑되어 시분초 정보가 없는 날짜만을 표현합니다. 또한, 문자열과 `Date` 객체 간의 변환을 위해 `SimpleDateFormat`이 사용됩니다.

1. `java.util.Date`

자바 언어에서 일반적으로 사용되는 날짜/시간 타입입니다. 문자열로부터 생성할 수 있지만, JDBC를 통해 데이터베이스에 직접 삽입하기는 어렵습니다.

2. `java.sql.Date`

JDBC에서 데이터베이스의 `DATE` 타입과 매핑되는 타입입니다. 1970년 1월 1일 00:00:00 GMT 이후의 밀리초 값을 통해 객체를 생성할 수 있으며, 문자열로부터 직접 생성할 수는 없습니다. JDBC를 통해 데이터베이스에 날짜를 삽입할 때 사용됩니다.

3. `SimpleDateFormat`

날짜와 시간을 특정 형식의 문자열로 포맷(날짜 -> 텍스트)하거나, 특정 형식의 문자열을 날짜 객체로 파싱(텍스트 -> 날짜)하는 데 사용됩니다.

`SimpleDateFormat` 활용 예시
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date; // java.util.Date

public class DateTimeConversionTest {
    public static void main(String[] args) throws ParseException {
        // SimpleDateFormat을 사용하여 "yyyy-MM-dd" 형식 지정
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

        // 1. 문자열을 java.util.Date로 파싱 (parse)
        String dateString = "2023-10-26";
        Date utilDate = sdf.parse(dateString);
        System.out.println("문자열 -> java.util.Date: " + utilDate); // 출력: Thu Oct 26 00:00:00 KST 2023

        // 2. java.util.Date를 문자열로 포맷 (format)
        String formattedDate = sdf.format(utilDate);
        System.out.println("java.util.Date -> 문자열: " + formattedDate); // 출력: 2023-10-26

        // 3. java.util.Date를 java.sql.Date로 변환 (밀리초 사용)
        java.sql.Date sqlDate = new java.sql.Date(utilDate.getTime());
        System.out.println("java.util.Date -> java.sql.Date: " + sqlDate); // 출력: 2023-10-26
    }
}

`DateTimeConverter` 유틸리티 클래스 캡슐화

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date; // java.util.Date

public class DateTimeConverter {

    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");

    // 1. 문자열을 java.util.Date로 변환
    public static Date stringToUtilDate(String dateStr) {
        try {
            return DATE_FORMAT.parse(dateStr);
        } catch (ParseException e) {
            System.err.println("날짜 문자열 파싱 오류: " + dateStr + " - " + e.getMessage());
            return null;
        }
    }

    // 2. java.util.Date를 java.sql.Date로 변환
    public static java.sql.Date utilDateToSqlDate(Date utilDate) {
        if (utilDate == null) {
            return null;
        }
        return new java.sql.Date(utilDate.getTime());
    }

    // 3. java.util.Date를 문자열로 변환
    public static String utilDateToString(Date utilDate) {
        if (utilDate == null) {
            return null;
        }
        return DATE_FORMAT.format(utilDate);
    }

    // 4. java.sql.Date를 java.util.Date로 변환 (필요시)
    public static Date sqlDateToUtilDate(java.sql.Date sqlDate) {
        if (sqlDate == null) {
            return null;
        }
        return new Date(sqlDate.getTime());
    }
}

Service (비즈니스 로직) 계층

서비스(Service) 계층은 애플리케이션의 비즈니스 로직을 구현하는 곳입니다. 사용자 관점에서 하나의 기능이 하나의 비즈니스 단위가 되며, 이 기능은 여러 DAO(데이터 접근 객체) 메서드를 조합하여 구현될 수 있습니다.

예를 들어, "계좌 이체"라는 비즈니스 기능은 '보내는 계좌에서 출금' DAO 메서드와 '받는 계좌에 입금' DAO 메서드를 포함할 수 있습니다.

// 서비스 계층의 역할 예시:
// AccountTransferService {
//     public void transferFunds(String fromAccountNo, String toAccountNo, double amount) {
//         // 1. 보내는 계좌 유효성 검사
//         // 2. 받는 계좌 유효성 검사
//         // 3. 잔액 확인
//         // 4. 보내는 계좌 잔액 감소 (DAO 호출)
//         // 5. 받는 계좌 잔액 증가 (DAO 호출)
//         // 6. 트랜잭션 처리 (성공 시 커밋, 실패 시 롤백)
//     }
// }

// AccountRepository (DAO) {
//     public void debitAccount(String accountNo, double amount); // 출금
//     public void creditAccount(String accountNo, double amount); // 입금
// }

서비스 계층 개발 흐름

클라이언트는 서비스 계층의 메서드를 호출하여 비즈니스 기능을 요청합니다. 서비스 계층은 필요한 유효성 검사 및 비즈니스 규칙을 적용하고, DAO 계층을 호출하여 데이터베이스 작업을 수행합니다. 이 과정에서 트랜잭션을 관리하는 역할도 서비스 계층에서 주로 담당합니다.

계좌 이체 서비스 기능 구현 예시

`Accounts` 엔티티와 `AccountsDaoImpl`이 있다고 가정하고, 계좌 이체 서비스를 구현합니다. (DAO에서 사용하는 `ConfigurableDbUtil`은 아직 `ThreadLocal`과 트랜잭션 관리 기능이 추가되지 않은 상태입니다.)

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Objects;

// Accounts 엔티티 (예시)
class Accounts {
    private String accountNumber;
    private String password;
    private double balance;

    public Accounts(String accountNumber, String password, double balance) {
        this.accountNumber = accountNumber;
        this.password = password;
        this.balance = balance;
    }

    // Getters and Setters
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public double getBalance() { return balance; }
    public void setBalance(double balance) { this.balance = balance; }

    @Override
    public String toString() {
        return "Accounts{" + "accountNumber='" + accountNumber + '\'' + ", balance=" + balance + '}';
    }
}

// AccountsDaoImpl (예시, ConfigurableDbUtil 사용)
class AccountsDaoImpl {
    // ConfigurableDbUtil은 아직 ThreadLocal을 사용하지 않으므로, 이 DAO는 하나의 Connection을 직접 받지 않습니다.
    // 각 메서드 내에서 새로운 Connection을 얻고 해제합니다. (나중에 ThreadLocal로 개선될 예정)
    public Accounts selectAccount(String accNo) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        String sql = "SELECT account_number, account_password, balance FROM bank_accounts WHERE account_number = ?";
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return null;
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, accNo);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                return new Accounts(
                    rs.getString("account_number"),
                    rs.getString("account_password"),
                    rs.getDouble("balance")
                );
            }
        } catch (SQLException e) {
            System.err.println("계좌 조회 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, rs);
        }
        return null;
    }

    public int updateAccount(Accounts account) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        String sql = "UPDATE bank_accounts SET balance = ? WHERE account_number = ?";
        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return 0;
            pstmt = conn.prepareStatement(sql);
            pstmt.setDouble(1, account.getBalance());
            pstmt.setString(2, account.getAccountNumber());
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            System.err.println("계좌 업데이트 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(conn, pstmt, null);
        }
        return 0;
    }
}

public class AccountTransferService {

    private AccountsDaoImpl accountsDao = new AccountsDaoImpl();

    /**
     * 계좌 이체 비즈니스 로직을 수행합니다.
     * @param fromAccNo 송금 계좌 번호
     * @param fromAccPwd 송금 계좌 비밀번호
     * @param toAccNo 수금 계좌 번호
     * @param amount 이체 금액
     * @return 이체 결과 메시지
     */
    public String performTransfer(String fromAccNo, String fromAccPwd, String toAccNo, double amount) {
        String transferResult = "이체 실패!";
        
        try {
            // 1. 송금 계좌 존재 여부 확인
            Accounts senderAccount = accountsDao.selectAccount(fromAccNo);
            if (senderAccount == null) {
                throw new RuntimeException("보내는 계좌가 존재하지 않습니다!");
            }

            // 2. 송금 계좌 비밀번호 확인
            if (!senderAccount.getPassword().equals(fromAccPwd)) {
                throw new RuntimeException("보내는 계좌 비밀번호가 올바르지 않습니다!");
            }

            // 3. 송금 계좌 잔액 확인
            if (senderAccount.getBalance() < amount) {
                throw new RuntimeException("보내는 계좌 잔액이 부족합니다!");
            }

            // 4. 수금 계좌 존재 여부 확인
            Accounts receiverAccount = accountsDao.selectAccount(toAccNo);
            if (receiverAccount == null) {
                throw new RuntimeException("받는 계좌가 존재하지 않습니다!");
            }

            // 5. 송금 계좌 잔액 감소
            senderAccount.setBalance(senderAccount.getBalance() - amount);
            accountsDao.updateAccount(senderAccount);

            // 6. 수금 계좌 잔액 증가
            receiverAccount.setBalance(receiverAccount.getBalance() + amount);
            accountsDao.updateAccount(receiverAccount);

            transferResult = "이체 성공!";

        } catch (RuntimeException e) {
            System.err.println("이체 실패: " + e.getMessage());
            transferResult = "이체 실패: " + e.getMessage();
        } catch (Exception e) {
            System.err.println("예기치 않은 오류 발생: " + e.getMessage());
            transferResult = "이체 실패: 예기치 않은 오류 발생";
        }
        return transferResult;
    }
}

트랜잭션 관리

트랜잭션은 데이터베이스의 논리적인 작업 단위를 의미합니다. 여러 개의 데이터베이스 작업이 하나의 논리적인 단위로 묶여 모두 성공하거나, 하나라도 실패하면 모두 실패(롤백)하도록 보장합니다. JDBC에서 트랜잭션은 Connection 객체를 통해 제어됩니다.

  • conn.setAutoCommit(false): 자동 커밋 모드를 비활성화하여 수동 트랜잭션 관리를 시작합니다.
  • conn.commit(): 현재 트랜잭션을 확정하고 데이터베이스에 변경 사항을 영구적으로 반영합니다.
  • conn.rollback(): 현재 트랜잭션을 취소하고 모든 변경 사항을 되돌립니다.

서비스 계층에서 트랜잭션 제어

트랜잭션은 비즈니스 로직의 논리적인 작업 단위를 정의하므로, 서비스 계층에서 트랜잭션을 시작하고 커밋/롤백하는 것이 적절합니다.

import java.sql.Connection;
import java.sql.SQLException;

public class AccountTransferServiceWithTx {

    private AccountsDaoImpl accountsDao = new AccountsDaoImpl(); // 이 DAO는 Connection을 직접 받지 않고, ConfigurableDbUtil로부터 받음

    /**
     * 계좌 이체 비즈니스 로직을 트랜잭션과 함께 수행합니다.
     * @param fromAccNo 송금 계좌 번호
     * @param fromAccPwd 송금 계좌 비밀번호
     * @param toAccNo 수금 계좌 번호
     * @param amount 이체 금액
     * @return 이체 결과 메시지
     */
    public String performTransferWithTransaction(String fromAccNo, String fromAccPwd, String toAccNo, double amount) {
        String transferOutcome = "이체 실패!";
        Connection currentConn = null; // 서비스 계층에서 트랜잭션 제어를 위해 Connection 객체를 관리

        try {
            currentConn = ConfigurableDbUtil.getConnection();
            if (currentConn == null) {
                throw new RuntimeException("데이터베이스 연결을 얻을 수 없습니다.");
            }
            System.out.println("서비스: 연결 객체 확보 " + currentConn);

            // 트랜잭션 시작: 자동 커밋 모드 비활성화
            currentConn.setAutoCommit(false);

            // 중요: DAO 메서드들이 동일한 Connection 객체를 사용해야 함.
            // 현재 ConfigurableDbUtil과 AccountsDaoImpl은 매 호출마다 새로운 Connection을 얻음.
            // 이 문제를 해결하기 위해 ThreadLocal을 사용하여 Connection을 공유해야 함.
            // 이 예시 코드에서는 'accountsDao'의 메서드가 내부적으로 ConfigurableDbUtil.getConnection()을 호출하므로,
            // 트랜잭션이 의도대로 동작하지 않을 수 있음. 다음 섹션에서 ThreadLocal을 사용하여 개선할 것임.

            // 1. 송금 계좌 존재 여부 확인
            Accounts senderAccount = accountsDao.selectAccount(fromAccNo);
            if (senderAccount == null) {
                throw new RuntimeException("보내는 계좌가 존재하지 않습니다!");
            }

            // 2. 송금 계좌 비밀번호 확인
            if (!senderAccount.getPassword().equals(fromAccPwd)) {
                throw new RuntimeException("보내는 계좌 비밀번호가 올바르지 않습니다!");
            }

            // 3. 송금 계좌 잔액 확인
            if (senderAccount.getBalance() < amount) {
                throw new RuntimeException("보내는 계좌 잔액이 부족합니다!");
            }

            // 4. 수금 계좌 존재 여부 확인
            Accounts receiverAccount = accountsDao.selectAccount(toAccNo);
            if (receiverAccount == null) {
                throw new RuntimeException("받는 계좌가 존재하지 않습니다!");
            }

            // 5. 송금 계좌 잔액 감소
            senderAccount.setBalance(senderAccount.getBalance() - amount);
            accountsDao.updateAccount(senderAccount);

            // (테스트를 위한 의도적인 오류 발생 지점)
            // if (true) throw new SQLException("의도적인 오류 발생!");

            // 6. 수금 계좌 잔액 증가
            receiverAccount.setBalance(receiverAccount.getBalance() + amount);
            accountsDao.updateAccount(receiverAccount);

            transferOutcome = "이체 성공!";
            // 모든 작업이 성공적으로 완료되면 트랜잭션 커밋
            currentConn.commit();
            System.out.println("트랜잭션 커밋 완료.");

        } catch (RuntimeException | SQLException e) {
            System.err.println("이체 실패 및 오류 발생: " + e.getMessage());
            try {
                if (currentConn != null) {
                    currentConn.rollback(); // 오류 발생 시 트랜잭션 롤백
                    System.out.println("오류 발생, 트랜잭션 롤백 완료!");
                }
            } catch (SQLException rollbackEx) {
                System.err.println("트랜잭션 롤백 중 오류 발생: " + rollbackEx.getMessage());
            }
            transferOutcome = "이체 실패: " + e.getMessage();
        } finally {
            // finally 블록에서 Connection을 닫습니다. (ThreadLocal 사용 시에도 중요)
            ConfigurableDbUtil.closeResources(currentConn, null, null);
            currentConn = null; // null로 설정하여 참조 해제
        }
        return transferOutcome;
    }
}

문제점: 위의 코드에서 accountsDao.selectAccount()accountsDao.updateAccount() 메서드는 여전히 ConfigurableDbUtil.getConnection()을 호출하여 매번 새로운 Connection 객체를 생성하고 해제합니다. 이는 서비스 계층에서 `currentConn.setAutoCommit(false)`로 시작한 트랜잭션이 DAO 계층에서 사용되는 `Connection`과는 다른 `Connection`이므로, 의도한 대로 트랜잭션이 동작하지 않습니다. 트랜잭션은 단일 Connection 범위 내에서만 유효합니다.

해결책 1: Connection 객체 전달

DAO 메서드에 `Connection` 객체를 인자로 전달하여 모든 DAO 작업이 동일한 `Connection`을 사용하도록 할 수 있습니다. 하지만 이는 모든 DAO 인터페이스와 구현 메서드의 시그니처를 변경해야 하므로, 인터페이스 오염(Bad Smell)을 유발하고 유연성을 저해할 수 있습니다.

// AccountsDao 인터페이스 (Connection을 인자로 받도록 변경)
// public interface AccountsDao {
//     Accounts selectAccount(Connection conn, String accNo);
//     int updateAccount(Connection conn, Accounts account);
// }

해결책 2: ThreadLocal 사용

ThreadLocal은 현재 실행 중인 스레드에 특화된 데이터를 저장할 수 있는 기능을 제공합니다. 이를 사용하여 단일 스레드 내에서 Connection 객체를 공유함으로써, 서비스 계층에서 생성된 Connection을 DAO 계층의 모든 메서드가 동일하게 사용할 수 있도록 합니다. 이는 인터페이스 오염 없이 트랜잭션 범위를 유지할 수 있는 효과적인 방법입니다.

각 스레드는 `ThreadLocal` 객체와 연결된 자체적인 '맵(Map)'을 가지며, `ThreadLocal` 객체를 키로, 저장할 값을 값으로 사용하여 데이터를 관리합니다.

ThreadLocal 적용

ConfigurableDbUtil 클래스에 `ThreadLocal<Connection>`을 추가하여 현재 스레드의 `Connection` 객체를 저장하고 관리합니다.

`ConfigurableDbUtil`에 `ThreadLocal` 통합
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;

public class ConfigurableDbUtil {
    private static final Properties DB_CONFIG = new Properties();
    // ThreadLocal 객체를 사용하여 현재 스레드에 Connection을 저장
    private static final ThreadLocal<Connection> threadConnection = new ThreadLocal<>();

    static {
        try (InputStream input = ConfigurableDbUtil.class.getResourceAsStream("/database.properties")) {
            if (input == null) throw new IOException("database.properties 파일을 찾을 수 없습니다.");
            DB_CONFIG.load(input);
            Class.forName(DB_CONFIG.getProperty("driver"));
            System.out.println("데이터베이스 드라이버 및 설정 로드 완료.");
        } catch (IOException | ClassNotFoundException e) {
            System.err.println("유틸리티 초기화 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        }
    }

    // 데이터베이스 연결 객체 반환 (ThreadLocal 활용)
    public static Connection getConnection() {
        Connection conn = threadConnection.get(); // 현재 스레드에 저장된 Connection 가져오기

        try {
            if (conn == null || conn.isClosed()) { // 연결이 없거나 닫혔으면 새로 생성
                conn = DriverManager.getConnection(
                    DB_CONFIG.getProperty("url"),
                    DB_CONFIG.getProperty("username"),
                    DB_CONFIG.getProperty("password")
                );
                threadConnection.set(conn); // 새로 생성된 연결을 현재 스레드에 저장
            }
        } catch (SQLException e) {
            System.err.println("데이터베이스 연결 오류: " + e.getMessage());
            conn = null; // 오류 발생 시 null 반환
        }
        return conn;
    }

    // JDBC 리소스 해제 및 ThreadLocal에서 Connection 제거
    public static void closeResources(Connection conn, Statement stmt, ResultSet rs) {
        try {
            if (rs != null) rs.close();
            if (stmt != null) stmt.close();
            if (conn != null) conn.close(); // 실제 Connection 닫기
        } catch (SQLException e) {
            System.err.println("JDBC 리소스 해제 중 오류: " + e.getMessage());
        } finally {
            // Connection이 닫힌 후에는 반드시 ThreadLocal에서도 제거해야 합니다.
            // 그렇지 않으면 다음 요청 시 이전에 닫힌 Connection이 반환될 수 있습니다.
            // 또한, 메모리 누수를 방지하기 위함입니다.
            threadConnection.remove(); 
        }
    }
}

이제 `AccountsDaoImpl`의 `selectAccount` 및 `updateAccount` 메서드에서 `ConfigurableDbUtil.getConnection()`을 호출하면, 현재 스레드에 이미 `Connection`이 있다면 동일한 `Connection`이 반환됩니다. 이렇게 하면 서비스 계층에서 시작한 트랜잭션 범위 내에서 모든 DAO 작업이 일관된 `Connection`을 사용할 수 있게 됩니다.

트랜잭션 기능 캡슐화

트랜잭션 시작, 커밋, 롤백과 같은 트랜잭션 제어 로직도 `ConfigurableDbUtil`에 캡슐화하여 서비스 계층에서 더욱 간결하게 호출할 수 있도록 합니다.

`ConfigurableDbUtil`에 트랜잭션 메서드 추가

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;

public class ConfigurableDbUtil {
    private static final Properties DB_CONFIG = new Properties();
    private static final ThreadLocal<Connection> threadConnection = new ThreadLocal<>(); // ThreadLocal 추가

    static {
        try (InputStream input = ConfigurableDbUtil.class.getResourceAsStream("/database.properties")) {
            if (input == null) throw new IOException("database.properties 파일을 찾을 수 없습니다.");
            DB_CONFIG.load(input);
            Class.forName(DB_CONFIG.getProperty("driver"));
            System.out.println("데이터베이스 드라이버 및 설정 로드 완료.");
        } catch (IOException | ClassNotFoundException e) {
            System.err.println("유틸리티 초기화 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        }
    }

    // 데이터베이스 연결 객체 반환 (ThreadLocal 활용)
    public static Connection getConnection() {
        Connection conn = threadConnection.get();
        try {
            if (conn == null || conn.isClosed()) {
                conn = DriverManager.getConnection(
                    DB_CONFIG.getProperty("url"),
                    DB_CONFIG.getProperty("username"),
                    DB_CONFIG.getProperty("password")
                );
                threadConnection.set(conn); // 새로 생성된 연결을 ThreadLocal에 저장
            }
        } catch (SQLException e) {
            System.err.println("데이터베이스 연결 오류: " + e.getMessage());
            conn = null;
        }
        return conn;
    }

    // JDBC 리소스 해제 (Connection은 ThreadLocal에서 제거)
    public static void closeResources(Statement stmt, ResultSet rs) {
        try {
            if (rs != null) rs.close();
            if (stmt != null) stmt.close();
        } catch (SQLException e) {
            System.err.println("JDBC Statement/ResultSet 해제 중 오류: " + e.getMessage());
        } finally {
            // Connection은 트랜잭션 관리 메서드에서 직접 닫고 ThreadLocal에서 제거
        }
    }
    
    // 트랜잭션 시작
    public static void beginTransaction() {
        Connection conn = null;
        try {
            conn = getConnection(); // ThreadLocal에서 Connection 가져오기 또는 새로 생성
            if (conn != null) {
                conn.setAutoCommit(false); // 자동 커밋 비활성화
                System.out.println("트랜잭션 시작: 자동 커밋 비활성화");
            }
        } catch (SQLException e) {
            System.err.println("트랜잭션 시작 오류: " + e.getMessage());
            // 예외 발생 시 ThreadLocal에서 Connection 제거 (clean-up)
            threadConnection.remove(); 
            try { if (conn != null) conn.close(); } catch (SQLException ex) { /* ignore */ }
        }
    }

    // 트랜잭션 커밋
    public static void commitTransaction() {
        Connection conn = threadConnection.get(); // 현재 스레드의 Connection 가져오기
        try {
            if (conn != null && !conn.isClosed()) {
                conn.commit(); // 커밋
                System.out.println("트랜잭션 커밋 완료.");
            }
        } catch (SQLException e) {
            System.err.println("트랜잭션 커밋 오류: " + e.getMessage());
        } finally {
            // 커밋 후 Connection 닫기 및 ThreadLocal에서 제거
            try { if (conn != null) conn.close(); } catch (SQLException ex) { /* ignore */ }
            threadConnection.remove();
        }
    }

    // 트랜잭션 롤백
    public static void rollbackTransaction() {
        Connection conn = threadConnection.get(); // 현재 스레드의 Connection 가져오기
        try {
            if (conn != null && !conn.isClosed()) {
                conn.rollback(); // 롤백
                System.out.println("트랜잭션 롤백 완료.");
            }
        } catch (SQLException e) {
            System.err.println("트랜잭션 롤백 오류: " + e.getMessage());
        } finally {
            // 롤백 후 Connection 닫기 및 ThreadLocal에서 제거
            try { if (conn != null) conn.close(); } catch (SQLException ex) { /* ignore */ }
            threadConnection.remove();
        }
    }
}

이제 `AccountTransferService`는 다음과 같이 간결하게 트랜잭션을 관리할 수 있습니다.

public class AccountTransferServiceFinal {

    private AccountsDaoImpl accountsDao = new AccountsDaoImpl(); // 이 DAO는 이제 ConfigurableDbUtil의 공유 Connection을 사용

    public String performTransfer(String fromAccNo, String fromAccPwd, String toAccNo, double amount) {
        String transferOutcome = "이체 실패!";
        ConfigurableDbUtil.beginTransaction(); // 트랜잭션 시작

        try {
            // 1. 송금 계좌 존재 여부 확인
            Accounts senderAccount = accountsDao.selectAccount(fromAccNo);
            if (senderAccount == null) throw new RuntimeException("보내는 계좌가 존재하지 않습니다!");

            // 2. 송금 계좌 비밀번호 확인
            if (!senderAccount.getPassword().equals(fromAccPwd)) throw new RuntimeException("보내는 계좌 비밀번호가 올바르지 않습니다!");

            // 3. 송금 계좌 잔액 확인
            if (senderAccount.getBalance() < amount) throw new RuntimeException("보내는 계좌 잔액이 부족합니다!");

            // 4. 수금 계좌 존재 여부 확인
            Accounts receiverAccount = accountsDao.selectAccount(toAccNo);
            if (receiverAccount == null) throw new RuntimeException("받는 계좌가 존재하지 않습니다!");

            // 5. 송금 계좌 잔액 감소
            senderAccount.setBalance(senderAccount.getBalance() - amount);
            accountsDao.updateAccount(senderAccount);

            // (테스트를 위한 의도적인 오류 발생 지점 - 주석 해제하여 테스트 가능)
            // if (true) throw new RuntimeException("의도적인 이체 오류 발생!");

            // 6. 수금 계좌 잔액 증가
            receiverAccount.setBalance(receiverAccount.getBalance() + amount);
            accountsDao.updateAccount(receiverAccount);

            transferOutcome = "이체 성공!";
            ConfigurableDbUtil.commitTransaction(); // 모든 작업 성공 시 커밋

        } catch (RuntimeException e) {
            System.err.println("이체 실패: " + e.getMessage());
            ConfigurableDbUtil.rollbackTransaction(); // 오류 발생 시 롤백
            transferOutcome = "이체 실패: " + e.getMessage();
        } finally {
            // ConfigurableDbUtil의 commit/rollback 메서드에서 Connection을 닫고 ThreadLocal에서 제거하므로,
            // 여기서는 추가적으로 닫을 Connection이 없음.
        }
        return transferOutcome;
    }
}

삼층 아키텍처 (Three-tier Architecture)

삼층 아키텍처는 애플리케이션을 세 가지 논리적인 계층으로 분리하여 개발하는 구조입니다. 이는 코드의 모듈성, 확장성, 유지보수성을 크게 향상시킵니다.

세 가지 계층 구성

  • 표현(Presentation) 계층 (UI, View)
    • 명명 규칙: `XXXView`, `XXXController`
    • 책임:
      1. 사용자 입력 데이터 수집 (예: 웹 폼, 콘솔 입력)
      2. 비즈니스 로직 계층 호출 및 비즈니스 메서드 실행
      3. 처리 결과(데이터 또는 메시지)를 사용자에게 표시
  • 비즈니스 로직(Business Logic) 계층 (Service)
    • 명명 규칙: `XXXService`, `XXXServiceImpl`
    • 책임:
      1. 비즈니스 트랜잭션 시작 및 관리 (커밋/롤백)
      2. 데이터 접근 객체(DAO) 계층 호출 및 데이터 처리
      3. 특정 비즈니스 규칙 및 유효성 검사 적용
  • 데이터 접근(Data Access) 계층 (DAO, Persistence)
    • 명명 규칙: `XXXDao`, `XXXDaoImpl`, `XXXRepository`
    • 책임:
      1. 데이터베이스 테이블에 대한 CRUD 작업 수행
      2. 비즈니스 로직 계층에 데이터 제공 또는 데이터 수정

삼층 아키텍처 프로젝트 구조 (일반적인 개발 단계)

  • `utils`: 유틸리티 클래스 (예: `ConfigurableDbUtil`, `DateTimeConverter`)
  • `entity`: 엔티티 클래스 (예: `PersonRecord`, `Accounts`)
  • `dao`: DAO 인터페이스 (`PersonDao`, `AccountsDao`)
    • `impl`: DAO 인터페이스 구현체 (`PersonDaoImpl`, `AccountsDaoImpl`)
  • `service`: 서비스 인터페이스 (`PersonService`, `AccountsService`)
    • `impl`: 서비스 인터페이스 구현체 (`PersonServiceImpl`, `AccountsServiceImpl`)
  • `view`: 프로그램 시작 클래스 (`MainApp`, `ConsoleView`)

설계 원칙: 서비스 계층과 DAO 계층에 인터페이스를 설계하는 것은 변경 용이성, 확장성, 테스트 용이성을 높여 미래에 구현체를 쉽게 교체할 수 있도록 합니다.

// DAO 인터페이스 예시
public interface AccountRepository {
    int insert(Accounts account);
    int delete(String accountNumber);
    int update(Accounts account);
    Accounts findByNumber(String accountNumber);
    List<Accounts> findAll();
}

// Service 인터페이스 예시
public interface AccountManagementService {
    String transferFunds(String fromAccountNo, String password, String toAccountNo, double amount);
}

Generic CRUD DAO 유틸리티

DAO 계층에서 데이터베이스 테이블에 대한 CRUD 작업은 많은 반복적인 코드를 포함합니다. 이러한 중복을 제거하고 재사용성을 극대화하기 위해, 일반적인 CRUD 작업을 처리하는 범용 DAO 유틸리티 클래스를 만들 수 있습니다.

`GenericCrudDao` - 공통 업데이트 메서드

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class GenericCrudDao {

    /**
     * INSERT, UPDATE, DELETE와 같은 DML 작업을 처리하는 범용 메서드입니다.
     * @param sql 실행할 SQL 문 (PreparedStatement의 ? 자리 표시자 포함)
     * @param params SQL 자리 표시자에 바인딩할 파라미터 목록
     * @return 작업에 의해 영향을 받은 행의 수
     */
    public int executeUpdate(String sql, Object... params) {
        Connection conn = null;
        PreparedStatement pstmt = null;

        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return 0;
            pstmt = conn.prepareStatement(sql);

            // 파라미터 바인딩
            for (int i = 0; i < params.length; i++) {
                pstmt.setObject(i + 1, params[i]);
            }
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            System.err.println("DML 작업 실행 오류: " + e.getMessage());
        } finally {
            // Connection은 트랜잭션 관리 메서드에서 닫히거나 ThreadLocal에 유지되므로 여기서는 Statement만 닫음
            ConfigurableDbUtil.closeResources(pstmt, null); 
        }
        return 0;
    }
}

`GenericCrudDao` - 공통 조회 메서드

이 메서드는 단일 또는 다중 객체를 조회할 수 있으며, 어떤 테이블의 데이터라도 캡슐화할 수 있도록 제네릭(`<T>`) 타입을 사용합니다. 객체 매핑(ORM) 로직은 `ResultSetMapper` 인터페이스를 통해 호출자에게 위임합니다.

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class GenericCrudDao {
    // (이전 executeUpdate 메서드 포함)

    /**
     * SELECT 작업을 처리하는 범용 메서드입니다.
     * @param sql 실행할 SQL 문
     * @param mapper ResultSet의 각 행을 T 타입 객체로 매핑하는 인터페이스 구현체
     * @param params SQL 자리 표시자에 바인딩할 파라미터 목록
     * @param <T> 조회 결과로 매핑될 객체의 타입
     * @return 조회된 T 타입 객체들의 리스트
     */
    public <T> List<T> executeQuery(String sql, ResultSetMapper<T> mapper, Object... params) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        List<T> resultList = new ArrayList<>();

        try {
            conn = ConfigurableDbUtil.getConnection();
            if (conn == null) return resultList;
            pstmt = conn.prepareStatement(sql);

            // 파라미터 바인딩
            if (params != null) {
                for (int i = 0; i < params.length; i++) {
                    pstmt.setObject(i + 1, params[i]);
                }
            }
            
            rs = pstmt.executeQuery();
            while (rs.next()) {
                // 각 ResultSet 행을 T 타입 객체로 매핑 (콜백 방식)
                T entity = mapper.mapRow(rs);
                if (entity != null) {
                    resultList.add(entity);
                }
            }
            return resultList;
        } catch (SQLException e) {
            System.err.println("DQL 작업 실행 오류: " + e.getMessage());
        } finally {
            ConfigurableDbUtil.closeResources(pstmt, rs);
        }
        return resultList;
    }
}

`ResultSetMapper` 인터페이스:

import java.sql.ResultSet;
import java.sql.SQLException;

// ResultSet의 한 행을 특정 타입 T의 객체로 매핑하는 인터페이스
public interface ResultSetMapper<T> {
    T mapRow(ResultSet rs) throws SQLException;
}

`PersonRecordMapper` 구현체:

import java.sql.ResultSet;
import java.sql.SQLException;

public class PersonRecordMapper implements ResultSetMapper<PersonRecord> {
    @Override
    public PersonRecord mapRow(ResultSet rs) throws SQLException {
        // ResultSet에서 데이터를 추출하여 PersonRecord 객체로 매핑
        int id = rs.getInt("id");
        String name = rs.getString("name");
        int age = rs.getInt("age");
        java.sql.Date birthDate = rs.getDate("birth_date");
        String email = rs.getString("email");
        String address = rs.getString("address");

        return new PersonRecord(id, name, age, birthDate, email, address);
    }
}

Druid 커넥션 풀

JDBC 연결을 매번 생성하고 닫는 작업은 높은 오버헤드를 발생시킵니다. 커넥션 풀은 애플리케이션 시작 시 미리 정해진 수의 데이터베이스 연결을 생성하여 풀(pool)에 저장해둡니다. 애플리케이션이 데이터베이스 연결이 필요할 때 풀에서 기존 연결을 가져와 사용하고, 사용이 끝나면 닫는 대신 다시 풀에 반환하여 재사용합니다. Druid는 고성능의 오픈 소스 자바 데이터베이스 커넥션 풀 구현체입니다.

Druid 커넥션 풀 사용 단계

  • `database.properties` 설정 파일 생성
  • Druid JAR 파일 (`druid-X.Y.Z.jar`) 프로젝트에 포함
1. `druid-config.properties` 설정 파일
# 연결 설정
driverClassName=com.mysql.cj.jdbc.Driver
url=jdbc:mysql://localhost:3306/companydb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
username=root
password=your_root_password

# 초기 연결 수
initialSize=10
# 최대 활성 연결 수
maxActive=30
# 최소 유휴 연결 수
minIdle=5
# 연결을 기다리는 최대 시간 (밀리초)
maxWait=5000
# 연결이 유효한지 테스트하는 SQL 쿼리
validationQuery=SELECT 1
# 연결 유효성 검사 시간 (밀리초)
validationQueryTimeout=1
# 연결이 풀에 반환될 때 유효성 검사 수행 여부
testOnReturn=false
# 연결을 가져올 때 유효성 검사 수행 여부
testOnBorrow=false
# 유휴 연결을 주기적으로 검사할지 여부
testWhileIdle=true
# 유휴 연결 검사 간격 (밀리초)
timeBetweenEvictionRunsMillis=60000
# 연결이 사용되지 않고 유지될 수 있는 최대 시간 (밀리초)
minEvictableIdleTimeMillis=300000
2. `DataSourceProvider` 커넥션 풀 유틸리티 클래스
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;

public class DataSourceProvider {
    private static DruidDataSource druidDataSource; // Druid 커넥션 풀 객체

    static {
        Properties configProps = new Properties();
        try (InputStream is = DataSourceProvider.class.getResourceAsStream("/druid-config.properties")) {
            if (is == null) throw new IOException("druid-config.properties 파일을 찾을 수 없습니다.");
            configProps.load(is);
            // DruidDataSourceFactory를 사용하여 커넥션 풀 생성
            druidDataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(configProps);
            System.out.println("Druid 커넥션 풀 초기화 완료.");
        } catch (IOException e) {
            System.err.println("커넥션 풀 설정 파일 로드 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        } catch (Exception e) {
            System.err.println("Druid 커넥션 풀 생성 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        }
    }

    // 커넥션 풀에서 연결 가져오기
    public static Connection getConnection() {
        try {
            return druidDataSource.getConnection();
        } catch (SQLException e) {
            System.err.println("커넥션 풀에서 연결 가져오기 오류: " + e.getMessage());
            return null;
        }
    }

    // DataSource 객체 자체를 반환 (외부에서 QueryRunner 등에서 사용)
    public static DataSource getDataSource() {
        return druidDataSource;
    }
    
    // 이 메서드는 실제 Connection을 닫지 않고 풀에 반환합니다.
    // try-with-resources를 사용하면 자동으로 반환됩니다.
    // public static void closeConnection(Connection conn) {
    //     try {
    //         if (conn != null) conn.close(); 
    //     } catch (SQLException e) {
    //         e.printStackTrace();
    //     }
    // }
}

Apache Commons DbUtils 사용

Apache Commons DbUtils는 JDBC 작업을 단순화하고 개발 시간을 단축하기 위해 Apache 소프트웨어 재단에서 제공하는 경량 유틸리티 라이브러리입니다. JDBC API를 직접 사용하는 것보다 훨씬 적은 코드로 동일한 작업을 수행할 수 있도록 도와주며, 성능에 미치는 영향이 적습니다.

DbUtils 주요 구성 요소

  • `ResultSetHandler` 인터페이스: ResultSet의 데이터를 원하는 자바 객체 또는 컬렉션으로 변환하는 인터페이스입니다.
    • `BeanHandler`: ResultSet의 첫 번째 행을 단일 객체로 변환합니다.
    • `BeanListHandler`: ResultSet의 모든 행을 객체 리스트로 변환합니다.
    • `ScalarHandler`: ResultSet의 첫 번째 행, 첫 번째 컬럼의 단일 값을 가져올 때 사용됩니다 (예: COUNT(*) 쿼리).
  • `QueryRunner` 클래스: SQL 문을 실행하는 데 사용되는 핵심 클래스입니다.
    • `update()`: INSERT, UPDATE, DELETE와 같은 DML 문 실행
    • `query()`: SELECT와 같은 DQL 문 실행

DbUtils 사용 단계

  • 필요한 JAR 파일 (`mysql-connector-java`, `druid`, `commons-dbutils`) 프로젝트에 추가
  • 커넥션 풀 설정 (`druid-config.properties`)
1. `ApacheDbUtilsConfig` 커넥션 풀 설정 (Druid 재사용)
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;

// Apache DbUtils에서 사용할 DataSource를 제공하는 유틸리티 클래스
public class ApacheDbUtilsConfig {
    private static DruidDataSource dataSource; // Druid 커넥션 풀 인스턴스

    static {
        Properties configProps = new Properties();
        try (InputStream is = ApacheDbUtilsConfig.class.getResourceAsStream("/druid-config.properties")) {
            if (is == null) throw new IOException("druid-config.properties 파일을 찾을 수 없습니다.");
            configProps.load(is);
            dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(configProps);
            System.out.println("Apache DbUtils용 Druid 커넥션 풀 초기화 완료.");
        } catch (IOException e) {
            System.err.println("커넥션 풀 설정 파일 로드 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        } catch (Exception e) {
            System.err.println("Druid 커넥션 풀 생성 오류: " + e.getMessage());
            throw new ExceptionInInitializerError(e);
        }
    }

    // 외부에서 DataSource 객체를 가져올 수 있도록 제공
    public static DataSource getDataSource() {
        return dataSource;
    }

    // 필요시 직접 Connection을 가져오는 메서드 (주로 DataSource를 통해 간접적으로 사용)
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

    // DbUtils는 Connection을 자동으로 닫고 풀에 반환하므로 명시적인 close 메서드는 필요 없음
}
2. `UserRepositoryUsingApacheDbUtils` DAO 구현

`user_profiles` 테이블이 있다고 가정하고 User 엔티티가 다음과 같다고 가정합니다.

public class UserProfile {
    private int id;
    private String username;
    private String password;
    private String address;
    private String phoneNumber;

    // Constructors, Getters, Setters, toString, equals, hashCode
    public UserProfile() {}
    public UserProfile(int id, String username, String password, String address, String phoneNumber) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.address = address;
        this.phoneNumber = phoneNumber;
    }
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public String getAddress() { return address; }
    public void setAddress(String address) { this.address = address; }
    public String getPhoneNumber() { return phoneNumber; }
    public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }

    @Override
    public String toString() {
        return "UserProfile{" + "id=" + id + ", username='" + username + '\'' + ", address='" + address + '\'' + '}';
    }
}
import org.apache.commons.dbutils.QueryRunner;
import org.apache.commons.dbutils.handlers.BeanHandler;
import org.apache.commons.dbutils.handlers.BeanListHandler;
import org.apache.commons.dbutils.handlers.ScalarHandler;

import java.sql.SQLException;
import java.util.List;

public class UserRepositoryUsingApacheDbUtils {

    // QueryRunner 객체를 생성할 때 DataSource를 전달하여 커넥션 풀을 사용합니다.
    private QueryRunner queryExecutor = new QueryRunner(ApacheDbUtilsConfig.getDataSource());

    // 1. 새 사용자 추가
    public int addUser(UserProfile user) {
        String sql = "INSERT INTO user_profiles(username, password, address, phone_number) VALUES(?, ?, ?, ?)";
        Object[] params = {user.getUsername(), user.getPassword(), user.getAddress(), user.getPhoneNumber()};
        try {
            return queryExecutor.update(sql, params);
        } catch (SQLException e) {
            System.err.println("사용자 추가 오류: " + e.getMessage());
        }
        return 0;
    }

    // 2. 사용자 삭제
    public int deleteUser(int userId) {
        String sql = "DELETE FROM user_profiles WHERE id = ?";
        try {
            return queryExecutor.update(sql, userId);
        } catch (SQLException e) {
            System.err.println("사용자 삭제 오류: " + e.getMessage());
        }
        return 0;
    }

    // 3. 사용자 정보 수정
    public int updateUser(UserProfile user) {
        String sql = "UPDATE user_profiles SET username = ?, password = ?, address = ?, phone_number = ? WHERE id = ?";
        Object[] params = {user.getUsername(), user.getPassword(), user.getAddress(), user.getPhoneNumber(), user.getId()};
        try {
            return queryExecutor.update(sql, params);
        } catch (SQLException e) {
            System.err.println("사용자 업데이트 오류: " + e.getMessage());
        }
        return 0;
    }

    // 4. 단일 사용자 조회
    public UserProfile findUserById(int userId) {
        String sql = "SELECT id, username, password, address, phone_number FROM user_profiles WHERE id = ?";
        try {
            // BeanHandler는 ResultSet의 첫 번째 행을 지정된 클래스 타입의 객체로 변환합니다.
            return queryExecutor.query(sql, new BeanHandler<>(UserProfile.class), userId);
        } catch (SQLException e) {
            System.err.println("단일 사용자 조회 오류: " + e.getMessage());
        }
        return null;
    }

    // 5. 모든 사용자 조회
    public List<UserProfile> findAllUsers() {
        String sql = "SELECT id, username, password, address, phone_number FROM user_profiles";
        try {
            // BeanListHandler는 ResultSet의 모든 행을 지정된 클래스 타입의 객체 리스트로 변환합니다.
            return queryExecutor.query(sql, new BeanListHandler<>(UserProfile.class));
        } catch (SQLException e) {
            System.err.println("모든 사용자 조회 오류: " + e.getMessage());
        }
        return List.of(); // 오류 발생 시 빈 리스트 반환
    }

    // 6. 전체 사용자 수 조회 (단일 값)
    public long getUserCount() {
        String sql = "SELECT COUNT(*) FROM user_profiles";
        try {
            // ScalarHandler는 ResultSet의 첫 번째 행, 첫 번째 컬럼의 값을 가져옵니다.
            Long count = queryExecutor.query(sql, new ScalarHandler<Long>());
            return (count != null) ? count : 0;
        } catch (SQLException e) {
            System.err.println("사용자 수 조회 오류: " + e.getMessage());
        }
        return 0;
    }
}

태그: JDBC java Database Connectivity SQL PreparedStatement

6월 13일 19:37에 게시됨