JDBC 트랜잭션 동기화의 원리
Spring의 JDBC 트랜잭션 동기화 흐름 구현해보기
JDBC
JDBC는 Java Database Connectivity의 약자로, 이름 그대로 Java로 데이터베이스에 통신할 수 있게끔하는 Java API다. JDBC 표준 인터페이스를 통해 Oracle Database, MySQL, PostgreSQL 등 다양한 DB에 쉽게 접근하고, SQL을 실행해 데이터 관련 처리를 할 수 있다. 주로 사용하는 SQL Mapper나 ORM을 모두 JDBC 기반으로 작동한다.
JDBC 동작 원리
JDBC API를 사용하기 위해서 특정 데이터베이스 벤더(Oracle, MySQL, PostgreSQL)에 대한 접근 및 작업을 할 수 있도록 JDBC 드라이버를 먼저 로딩해 데이터베이스와 연결한다. JDBC 드라이버는 JDBC 인터페이스의 구현체로, JDBC에서 제공하는 DriverManager가 이 드라이버를 관리하고 Connection을 제공해 DB 관련 작업을 수행한다.
JDBC로 DB 작업을 수행하는 과정은 다음과 같다.
- JDBC 드라이버를 로드한다.
- DriverManager로 Connection 객체를 생성한다. 이 때 DB URL, 사용자 이름과 비밀번호 같은 연결 정보가 필요하다.
- Connection으로 SQL 질의를 실행할 Statement (또는 PreparedStatement)를 생성한다.
- Statement로 SQL문을 실행한다.
executeQuery(): SELECT문과 같이 데이터를 조회하는데 사용되며, ResultSet을 반환한다.executeUpdate(): INSERT, UPDATE, DELETE문과 같이 데이터를 변경하는데 사용되며, 변경된 행의 수를 반환한다.
- ResultSet으로 데이터를 얻는다. ResultSet의 각 행을 순회하며 데이터를 추출하고 처리한다.
- 자원을 해제한다. 이 때 ResultSet, Statement, Connection 순서, 즉 사용한 역순으로 자원을 해제하며 try-with-resource를 사용해 자동으로 자원을 해제한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class JdbcExample {
public static void main(String[] args) {
// 데이터베이스 연결 정보 (사용자 환경에 맞게 수정)
String dbUrl = "jdbc:mysql://localhost:3306/mydatabase"; // DB URL
String dbUser = "username"; // DB 사용자 이름
String dbPassword = "password"; // DB 비밀번호
// 1. JDBC 드라이버 로드 및 2. 데이터베이스 연결 (try-with-resources)
try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPassword);
// 3. Statement 객체 생성
Statement stmt = conn.createStatement();
// 4. SQL 질의 실행
ResultSet rs = stmt.executeQuery("SELECT id, name, salary FROM employees")) {
System.out.println("연결 성공!");
System.out.println("------------------------------------");
System.out.println("직원 목록:");
// 5. 결과 처리 (ResultSet)
while (rs.next()) { // 다음 행(row)이 있다면 반복
// 각 컬럼의 데이터를 타입에 맞게 가져옴
int id = rs.getInt("id");
String name = rs.getString("name");
double salary = rs.getDouble("salary");
// 결과 출력
System.out.printf("ID: %d, 이름: %s %s, 연봉: %.2f\\n", id, name, salary);
}
System.out.println("------------------------------------");
} catch (SQLException e) {
System.err.println("데이터베이스 연결 또는 쿼리 오류: " + e.getMessage());
e.printStackTrace();
}
// 6. 자원 해제: try-with-resources 구문 덕분에 자동으로 처리
}
}
Connection Pool
JDBC API로 데이터베이스와 연결할 때 Connection을 매번 생성하면 비용이 많이 들고 비효율적이다.
Connection를 생성하는 과정은 다음과 같다.
- 애플리케이션에서 DB 드라이버로 Connection을 요청한다.
- DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 네트워크 레벨에서 3-way-handshake 과정이 발생한다.
- DB 드라이버는 TCP/IP 연결이 수락되면 DB URL, 사용자 아이디와 비밀번호 등을 DB에 전달한다.
- DB는 인증 정보(아이디, 비밀번호)를 확인한 후 내부에 DB 세션을 생성한다.
- DB는 Connection 생성이 완료되었다는 응답을 보낸다.
- DB 드라이버는 Connection 객체를 생성해서 클라이언트에 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
[애플리케이션] <-----> [JDBC 드라이버] <-----> [데이터베이스 서버]
| | |
1. getConnection() 요청 -->
| 2. TCP/IP 연결 시도 ------------>
| <--------------------------- TCP/IP 연결 수락
| 3. 인증 정보(ID/PW) 전송 ------>
| | 4. 인증 및 세션 생성
| <---------------------- 5. 성공/실패 응답
| |
| 6. Connection 객체 반환
<----
|
[Connection 객체 획득]
이 때 JDBC는 Connection을 매번 새롭게 생성하지 않도록 Connection Pool을 사용한다. 이는 미리 Connection을 생성해서 Connection Pool에 보관하다가 필요할 때 이를 꺼내서 사용하는 방식이다. Spring Boot같은 경우 기본으로 HikariCP를 채택하고 있다.
Connection을 획득할 때 DriverManager을 사용하거나 Connection Pool을 사용하는 등 여러 가지 방법이 있다. 따라서 DataSource 인터페이스로 Connection을 획득하는 방법을 추상화해서 사용해 유연하게 구현체를 교체할 수 있다. JDBC Driver Manager 기반으로 한 DriverManagerDataSource와 HikariCP를 기반으로 한 HikariDataSource가 있다.
Transaction Synchronization
트랜잭션 동기화 적용하기
하나의 트랜잭션이 수행하는 작업(SQL 실행, 자원 관리)은 DB Connection라는 자원을 기반으로 동작한다. Connection의 autoCommit 기능을 통해 트랜잭션의 논리적인 작업 단위를 묶을 수 있다. JDBC를 사용하면 직접 autoCommit 지정 및 커밋과 롤백하는 시점을 설정해야 한다. 이로 인해 비즈니스 로직에 집중해야 하는 서비스 계층과 레포지토리 계층에 Connection 관련 코드가 섞이게 된다. 트랜잭션의 경계가 레이어 간에 혼재되는 문제 외에도 Service가 Connection을 생성함으로써 Dao가 서비스 계층에 종속되는 등의 문제도 있다. 트랜잭션 동기화를 통해 이 문제를 해결할 수 있다.
트랜잭션 동기화(Transaction synchronization)는 하나의 트랜잭션을 유지하기 위한 Connection을 별도로 보관하고, 필요할 때(Service, Dao 트랜잭션 처리를 할 때) 꺼내서 사용하는 방식이다. 트랜잭션 관련 자원(Connection)을 하나의 트랜잭션(Context)으로 다룸으로써 효율적으로 자원을 관리한다. 이를 통해 스프링의 멀티스레드 환경에서도 자원의 동기화가 안정적으로 이루어질 수 있다. 스프링에서는 TransactionSynchronizationManager 클래스로 트랜잭션을 동기화한다.
PlatformTransactionManager
1
2
3
4
5
6
7
8
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
애플리케이션은 데이터베이스, 메시지 큐 등 다양한 자원에 대한 트랜잭션 처리가 필요하다. 스프링에서 @Transactional 애너테이션을 사용하면 PlatformTransactionManager 구현체가 알아서 실제 트랜잭션 처리를 한다. PlatformTransactionManager는 트랜잭션 경계 설정을 위한 스프링 추상 인터페이스다. 일반적으로 PlatformTransactionManager를 구현한 AbstractPlatformTransactionManager를 구현하도록JPATransactionManager, JdbcTransactionManager 등을 구현한다.
트랜잭션은 범위에 따라 2가지로 나눌 수 있다. 로컬 트랜잭션(Local Transaction)은 데이터베이스 커넥션과 같이 단일 자원 내에서만 수행되는 트랜잭션이다. 대부분의 애플리케이션에서 사용하는 일반적인 형태다. 글로벌 트랜잭션 (Global Transaction)은 2개 이상의 자원 (두 개의 다른 데이터베이스, 또는 데이터베이스와 메시지 큐)에 걸친 작업을 하나의 원자적 단위로 묶는 트랜잭션이다. 이러한 이기종 간의 분산 트랜잭션은 JTA(Java Transaction API) 라는 Java 표준 API로 관리된다. JDBC를 활용하는 경우 DataSourceTransactionManager, JPA를 활용하는 경우 JpaTransactionManager, 여러 자원을 활용하는 경우 JtaTransactionManager를 활용한다.
JDBC API 구현하기
JDBC 라이브러리는 개발자가 SQL 쿼리 작성, 쿼리 인자 전달, 조회 결과 추출에만 집중할 수 있도록 JDBC 관련 중복 작업들을 처리한다. 즉 개발자는 DB 연결 매개변수 설정, SQL 쿼리 작성 및 매개변수와 값 설정, 조회 결과에서 데이터를 추출하는 작업만 한다. JDBC 라이브러리는 Connection 생성, Statement 준비 및 실행, SQL 질의를 통한 ResultSet 생성, 예외 처리, 트랜잭션 관리, 자원 해제(Connection, Statement, ResultSet) 관련 작업을 처리한다. 실제 동작 방식과 유사하게 트랜잭션을 동기화하는 JDBCTemplate을 구현해봤다.
Data Access
RowMapper<T>ResultSet의 한 행(Row)를 특정 객체(T)로 매핑(Map)하는 인터페이스다.
1 2 3 4 5 6
@FunctionalInterface public interface RowMapper<T> { @Nullable T mapRow(ResultSet rs, int rowNum) throws SQLException; }
ResultSetExtractor<T>결과셋(ResultSet) 전체에 대한 결과를 담은 특정 객체(T)를 만들어 반환하는 인터페이스다.
1 2 3 4 5
@FunctionalInterface public interface ResultSetExtractor<T> { T extractData(ResultSet rs) throws SQLException, DataAccessException; }
RowMapperResultSetExtractor<T>ResultSetExtractor<T>의 구현체로, 내부적으로RowMapper를 가지고 있어RowMapper를ResultSetExtractor처럼 동작하도록 변환해주는 어댑터 역할을 한다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
public class RowMapperResultSetExtractor<T> implements ResultSetExtractor<List<T>> { private final RowMapper<T> rowMapper; public RowMapperResultSetExtractor(final RowMapper<T> rowMapper) { this.rowMapper = rowMapper; } @Override public List<T> extractData(final ResultSet rs) throws SQLException { final List<T> results = new ArrayList<>(); int rowNum = 0; while (rs.next()) { results.add(rowMapper.mapRow(rs, rowNum++)); } return results; } }
PreparedStatementSetterPreparedStatement객체를 직접 받아 파라미터(?) 설정을 완전히 제어하는 콜백(Callback) 인터페이스다.1 2 3 4 5
@FunctionalInterface public interface PreparedStatementSetter { void setValues(PreparedStatement ps) throws SQLException; }
ArgumentPreparedStatementSetterPreparedStatementSetter의 구현체로, 생성자로 받은Object[] args배열을 받아 내부적으로 각 요소를PreparedStatement에 순서대로 설정해주는 구현 클래스다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
public class ArgumentPreparedStatementSetter implements PreparedStatementSetter { private final Object[] args; public ArgumentPreparedStatementSetter(final Object[] args) { this.args = args; } @Override public void setValues(final PreparedStatement pstmt) throws SQLException { if (args == null) { return; } for (int i = 0; i < args.length; i++) { pstmt.setObject(i + 1, args[i]); } } }
JdbcOperationsJDBC로 수행할 수 있는 작업을 모두 정의한 인터페이스다. 데이터베이스의 CRUD 작업을 위한 메서드를 정의한다.
Object[]로 직접 가변인자를 받거나 직접PreparedStatementSetter함수형 인터페이스를 구현해서 인자로 받아서 사용할 수 있다. 예를 들어서query(String sql, RowMapper<T>, Object[]),query(String, PreparedStatementSetter, RowMapper<T>)와 같이 형태다.PreparedStatemetSetter는 명시적으로 타입 지정을 해야 할 때(널 값 지정), LOB 데이터를 다룰 때(BLOB, CLOB),setObject()가 지원하지 않은 특정 DB의 고유 타입을 다룰 때, 파라미터 설정이 복잡할 때 직접 사용한다.- 주요 메서드
update():INSERT,UPDATE,DELETE등 데이터 변경에 사용된다. 변경된 행의 수를 반환한다.query():SELECT쿼리를 실행하고 결과를 반환합니다.RowMapper나ResultSetExtractor와 함께 사용되어 결과를 객체로 매핑한다.queryForObject(): 단 하나의 행, 열, 객체를 반환하는 쿼리에 사용된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
public interface JdbcOperations { int update(String sql, Object... args); int update(String sql, PreparedStatementSetter pss); <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args); <T> List<T> query(String sql, PreparedStatementSetter pss, RowMapper<T> rowMapper); <T> T queryForObject(String sql, RowMapper<T> rowMapper, Object... args); <T> T queryForObject(String sql, PreparedStatementSetter pss, RowMapper<T> rowMapper); <T> T query(String sql, PreparedStatementSetter pss, ResultSetExtractor<T> rse); }
- 주요 메서드
JdbcTemplateJdbcOperations인터페이스를 구현한 클래스로, 직접 Jdbc를 사용하기 위한 보일러프레이트 코드를 줄여줘 비즈니스 로직에만 집중할 수 있도록 한다.Connection,PreparedStatement객체 생성, 예외 처리, 트랜잭션 처리, 자원 해제 등과 같이 중복되는 작업을 대신 처리해 준다. JdbcTemplate을 통해 개발자는 SQL 쿼리, 파라미터, 결과를 객체로 만드는 방식(RowMapper)만 정의하면 된다.
Transaction Synchronization
PlatformTransactionManager핵심 트랜잭션 메서드(시작, 커밋, 롤백)를 정의하는 인터페이스로 기본 트랜잭션 기술을 추상화한다. JDBC, JPA, JMS 등 다양한 기술의 트랜잭션을 일관되게 제어할 수 있다.
1 2 3 4 5 6 7 8
public interface PlatformTransactionManager { void init(); void commit(); void rollback(); }
AbstractTransactionManagerPlatformTransactionManager인터페이스를 구현한 추상 클래스로, 트랜잭션 매니저 구현체들이 대부분 사용하는 트랜잭션 관리 관련 기본 메서드를 미리 구현해 놓는다. 실제AbstractTransactionManager는 트랜잭션 전파, 격리 수준에 대한 구현도 포함하고 있다.- 트랜잭션을 시작할 때 DataSource에서 Connection을 가져와
autoCommit을 false로 설정하고,DataSourceUtils.getConnection()을 통해 Connection을 가지고 오고 현재 스레드에 바인딩한다. - 트랜잭션이 끝나면 commit() 또는 rollback()을 호출해 해제할 Connection의
autoCommit을 true로 설정하고,DataSourceUtils.releaseConnection()를 통해 스레드에서 리소스를 정리하고 해제한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
public abstract class AbstractTransactionManager implements PlatformTransactionManager { private final DataSource dataSource; public AbstractTransactionManager(final DataSource dataSource) { this.dataSource = dataSource; } @Override public void init() { Connection conn = null; try { conn = DataSourceUtils.getConnection(dataSource); conn.setAutoCommit(false); } catch (SQLException e) { cleanup(conn); throw new DataAccessException("Transaction init error", e); } } @Override public void commit() { final Connection conn = DataSourceUtils.getConnection(dataSource); try { conn.commit(); } catch (SQLException e) { throw new DataAccessException("Transaction commit error", e); } finally { cleanup(conn); } } @Override public void rollback() { final Connection conn = DataSourceUtils.getConnection(dataSource); try { conn.rollback(); } catch (SQLException e) { throw new DataAccessException("Transaction rollback error", e); } finally { cleanup(conn); } } private void cleanup(final Connection conn) { try { conn.setAutoCommit(true); } catch (SQLException e) { throw new DataAccessException("Could not reset auto-commit after transaction", e); } finally { DataSourceUtils.releaseConnection(conn, dataSource); } } }
- 트랜잭션을 시작할 때 DataSource에서 Connection을 가져와
TransactionCallback<T>TransactionTemplate의execute메서드에 인자로 전달되는 콜백 인터페이스다. 트랜잭션 내에서 실행되어야 하는 비즈니스 로직을 구현한다.1 2 3 4 5 6
@FunctionalInterface public interface TransactionCallback<T> { @Nullable T doInTransaction(); }
TransactionTemplateJdbcTemplate과 같이 직접 Jdbc를 사용하기 위한 보일러프레이트 코드를 줄여줘 비즈니스 로직에만 집중할 수 있도록 한다. 트랜잭션 시작, 커밋, 롤백과 같이 중복되는 작업을 대신 처리해 준다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
public class TransactionTemplate { private final PlatformTransactionManager platformTransactionManager; public TransactionTemplate(final PlatformTransactionManager platformTransactionManager) { this.platformTransactionManager = platformTransactionManager; } public <T> T execute(final TransactionCallback<T> callback) { try { platformTransactionManager.init(); final T result = callback.doInTransaction(); platformTransactionManager.commit(); return result; } catch (Exception e) { platformTransactionManager.rollback(); throw new DataAccessException("Transaction failed and rolled back", e); } } }
TransactionSynchronizationManager트랜잭션 동기화 관리자로 트랜잭션 범위 내에서 리소스를 안전하게 공유할 수 있도록 한다. 내부적으로
ThreadLocal을 사용해 현재 실행 중인 스레드에 트랜잭션 정보를 바인딩한다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
public abstract class TransactionSynchronizationManager { private static final ThreadLocal<Map<DataSource, Connection>> resources = ThreadLocal.withInitial(HashMap::new); private TransactionSynchronizationManager() { } public static Connection getResource(final DataSource key) { return getResourceMap().get(key); } public static void bindResource(final DataSource key, final Connection value) { final Map<DataSource, Connection> resourceMap = getResourceMap(); if (resourceMap.containsKey(key)) { throw new IllegalStateException("Already a resource for key: " + key); } resourceMap.put(key, value); } public static Connection unbindResource(final DataSource key) { final Map<DataSource, Connection> resourceMap = getResourceMap(); if (!resourceMap.containsKey(key)) { throw new IllegalStateException("No resource found for key: " + key); } return resourceMap.remove(key); } public static void clear() { resources.remove(); } private static Map<DataSource, Connection> getResourceMap() { return resources.get(); } }
DataSourceUtilsDataSource에서 DB Connection을 갖고 오고 반납하는 작업을 도와준다.DataSourceUtils.getConnection(dataSource)을 사용하면,TransactionSynchronizationManager를 통해 현재 진행 중인 트랜잭션이 있는지 확인한다. 진행 중인 트랜잭션이 있다면TransactionSynchronizationManager에 이미 바인딩된 커넥션을 반환함으로써 트랜잭션을 동기화한다. 진행 중인 트랜잭션이 없다면DataSource에서 새로운 커넥션을 가져와 반환한다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
public abstract class DataSourceUtils { private DataSourceUtils() { } public static Connection getConnection(final DataSource dataSource) throws CannotGetJdbcConnectionException { Connection connection = TransactionSynchronizationManager.getResource(dataSource); if (connection != null) { return connection; } try { connection = dataSource.getConnection(); TransactionSynchronizationManager.bindResource(dataSource, connection); return connection; } catch (SQLException ex) { throw new CannotGetJdbcConnectionException("Failed to obtain JDBC Connection", ex); } } public static void releaseConnection(final Connection connection, final DataSource dataSource) { try { connection.close(); TransactionSynchronizationManager.unbindResource(dataSource); } catch (SQLException ex) { throw new CannotGetJdbcConnectionException("Failed to close JDBC Connection"); } finally { TransactionSynchronizationManager.clear(); } } }




