[Spring] DB 접근 기술과 JDBC, DriverManager, DataSource, Transaction과 전파, TransactionManager, TransactionSynchronizationManager

벼랑 끝 코딩·2025년 4월 7일

Spring

목록 보기
14/16
post-thumbnail

지금까지 Spring의 기본 요소인 Bean에 대해 알아보고
Bean과 함께 동작하는 Spring의 구조에 대해 알아봤다.
이후 Client의 요청을 어떻게 Spring으로 처리할 수 있는지 Thymeleaf와 함께 알아봤다.

Client는 Server에게 쿼리 파라미터, 또는 message body를 통해 데이터를 보낸다.
Server는 Client의 데이터를 보통 데이터베이스에 저장한다.
이번에는 Spring에서 DB에 데이터를 저장하는 기술에 대해 알아보자.

DB 접근

1. TCP/IP Connection 연결
2. SQL 실행
3. 결과 확인

DB 접근 기술에는 총 3가지 과정이 필요하다.

먼저 데이터베이스와 연결해야 한다.
이 때 내부에서는 3-way handshaking과 같은 네트워크 동작이 수행된다.
이후 실행할 SQL을 전달하고 결과를 확인한다.

이 3가지 과정을 수행하면 DB에 연결하여 데이터를 저장할 수 있는데,
문제는 수많은 DB 기술들이 이 3가지 과정을 수행하는 방식이 제각각 다르다는 점이다.
만약 DB 기술을 변경하게 된다면, 사용자는 새로운 DB 기술을 학습해야 한다.

상황에 따라 기술의 변화는 필연적이다.
그때마다 기술을 학습하는 것은 상당히 비효율적이다.
아마 머릿속에 하나의 공통된 해결 방안을 떠올렸을 것이다.


그렇다.
우리는 DB 접근 기술의 추상화가 필요하다.

JDBC 표준 Interface

Java에서 DB 접근 기술을 추상화한 것을 JDBC 표준 인터페이스라고 한다.

각각의 DB 기술은 JDBC 표준 인터페이스에 맞추어 클래스를 구현한다.
사용자는 DB 기술을 선택하고, JDBC 표준 인터페이스에 따라 사용하면 된다.

DB 기술을 변경하더라도, JDBC 표준 인터페이스에 맞추어 구현되어 있기 때문에
새로운 기술을 학습할 필요 없이 그대로 코드를 사용할 수 있다.

DB Driver

JDBC 표준 인터페이스에 맞추어 구현한 클래스를 DB Driver라고 부른다.

DriverManager

사용자가 DB 접근을 위한 1단계인 Connection 연결을 시도할 때,
Java는 적절한 DB Driver을 탐색하여 라이브러리로 제공해주어야 한다.

그렇다면 적절한 DB Driver을 조회하여 반환할 일꾼이 필요하다.
그 역할을 수행할 객체가 바로 DriverManager이다.

Connection connection = DriverManager.getConnection(url, username, password);

DB Connection을 생성할 때, DB 주소와 ID, PW와 같은 정보를 전송한다.
DriverManager는 DB url을 바탕으로 적절한 DB Driver을 탐색한다.

SQL 실행

DB Connection을 생성했다면 SQL을 실행할 수 있다.

PreparedStatement

// DB Connection 생성
Connection connection = DriverManager.getConnection(url, username, password);

String sql = "select * from user where id = ? and password = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, id1);  // ** 첫번째 파라미터에 문자열 id1 작성 **
pstmt.setString(2, pw1);  // ** 두번째 파라미터에 문자열 pw1 작성 **

ResultSet rs = pstmt.executeQuery();  // ** sql 조회 메서드 **

PreparedStatement에 preparedStatement() 메서드를 호출하여 SQL을 전달한다.
이후 setString() 메서드를 호출하여 파라미터에 작성할 값을 설정한다.
executeQuery()를 호출하여 조회 메서드를 실행한다.

// DB Connection 생성
Connection connection = DriverManager.getConnection(url, username, password);

String sql = "update user set password = ? where id = ?" ;
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, pw1);
pstmt.setString(2, id1);

int row = pstmt.executeUpdate();

executeUpdate() 메서드를 호출하면 조회가 아닌 데이터를 수정할 수 있다.
데이터가 변경된 row 수를 반환한다.

  • SQL Injection

String sql = "select * from table where id = ? and password = ?";

SQL을 살펴보면 파라미터 자리에 '?'를 사용한다.
이후 '?' 자리의 파라미터에 값을 직접 설정한다.
애초에 sql을 파라미터화하지 않고 직접 데이터를 전달하면 되는데 왜 이렇게 사용할까?

바로 SQL Injection을 예방하기 위해서이다.
SQL Injection이란 악의적인 SQL 코드를 입력하여 시스템을 공격하는 것을 말한다.

예를 들어 적절한 데이터 대신 테이블 삭제 명령을 입력하여 SQL이 생성되게 할 수 있다.
하지만 '?'를 사용하여 SQL을 파라미터화 하면,
테이블 삭제 명령을 SQL로 인식하지 않고 문자열 자체로 인식해버린다.

따라서 '?' 방식을 사용을 권장한다.

ResultSet

ResultSet rs = pstmt.executeQuery();

boolean result = rs.next();  // ** 데이터 row가 있으면 true, 없으면 false

rs.getString("columnName");
rs.getInt("columnName");

ResultSet 객체에 조회한 데이터가 테이블 형식으로 저장된다.

테이블의 row에 접근하려면 next() 메서드를 호출해야 한다.
next() 메서드를 호출하면 cursor가 이동하여 접근할 row를 설정한다.
cursor는 맨 처음에 아무것도 가리키지 않기 때문에,
첫번째 row에 접근하기 위해서 적어도 1번의 next() 메서드를 호출해야 한다.

각 row의 데이터는 getType() 메서드를 호출하여 얻을 수 있다.
파라미터에 column명을 전달하면 해당 열의 데이터를 획득할 수 있다.

자원 정리, JdbcUtils

JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(preparedStatement);
JdbcUtils.closeConnection(connection);

DB는 외부 자원이므로 명시적으로 정리하는 메서드를 호출해야 한다.
호출한 순서의 역순으로 정리한다.
try-catch 구문에 자원마다 close() 메서드를 직접 호출할 수도 있지만,
JdbcUtils를 사용하면 자원을 간단하게 정리할 수 있다.

DataSource

// Connection connection = DriverManager.getConnection(url, username, password)
1. Connection 객체 생성 시도(Connection 연결 시도)

2. DriverManager가 url을 바탕으로 DB Driver 조회
3. TCP/IP Connection 연결(네트워크 동작 발생)
4. username, password 등 부가 정보 전달
5. 부가 정보를 바탕으로 내부 인증
6. DB Session 생성
7. Connection 생성 완료 응답 전달
8. Connection 객체 생성, 클라이언트에게 반환

Connection을 생성하기 위한 코드는 한 줄이지만,
실제로는 여러 단계를 거쳐야 하며 특히 네트워크 동작이 발생하기 때문에 무거운 작업이다.
DB에 데이터를 저장하려고 할 때마다 매번 Connection을 생성하는 것은
시간이 오래 걸리기 때문에 성능이 저하되는 문제를 발생시킬 수 있다.

무언가 해결책이 필요하다.

Connection Pool

Spring에서 Client의 요청을 받을 때마다 Thread를 생성하는 것은
무거운 작업이기 때문에 미리 Thread를 생성하는 스레드 풀을 사용했다.

Connection도 마찬가지다.
DB에 데이터를 저장하려고 할 때마다 매번 Connection을 생성하는 것은
무거운 작업이기 때문에 미리 Connection을 생성하여 커넥션 풀에 보관한다.

애플리케이션 시작 시 자동으로 커넥션 풀에 Connection을 미리 생성해두며,
기본 값은 10개이고 운영하는 서비스 환경에 따라 다르게 설정해야 한다.
이제 DB에 접근할 때마다 Connection을 직접 생성하지 않고
커넥션 풀에 생성한 Connection을 가져다 사용하고 작업이 끝나면 반환한다.

Connection Pool 객체는 오픈소스를 사용하는데, 다양한 오픈소스가 존재한다.
문제는 오픈소스 기술마다 Connection Pool을 사용하는 방법이 달라
기술을 변경하면 또 새로운 기술을 학습해야 한다는 점이다.
DB 접근 기술이 DB마다 달라 Jdbc 표준 인터페이스가 등장한 것처럼,

Connection Pool을 추상화하는 기술이 필요한데, 그것이 바로 DataSource이다.

DriverManagerDataSource

DriverManager와 Connection Pool이 각각 커넥션을 획득하는 방법이 다르기 때문에
커넥션 풀을 사용하면 더이상 DriverManager 객체를 사용할 수 없다.
대신 커넥션 풀 추상화 기술인 DataSource를 사용하여 커넥션을 생성하고,
커넥션 풀 기술을 변경해도 코드 수정 없이 사용할 수 있다.

DriverManagerDataSource dataSource = new DriverMangaerDataSource(url, username, password);

dataSource.getConnection();

DataSource 구현체 중에서 DriverManagerDataSource
Spring에서 제공하는 커넥션 풀 기술이다.
DriverManager와 달리 DataSource 객체를 생성할 때 부가 정보를 한 번 전달하면,
Connection 연결을 시도할 때 메서드만 호출해서 간편하게 생성할 수 있다.

HikariDataSource

HikariDataSource hikariDataSource = new HikariDataSource();
hikariDataSource.setJdbcUrl(url);
hikariDataSource.setUsername(username);
hikariDataSource.setPassword(password);
hikariDataSrouce.setMaximumPoolSize(n);
hikariDataSource.setPoolName(poolName);

HikariDataSource는 hikariCP Connection Pool 오픈소스 기술이다.
Spring Boot에서 기본 커넥션 풀로 사용하며 실무에서 주로 사용하는 기술이다.

Connection Pool 오픈소스 기술은 HikariDataSource를 사용하면 된다.

HikariDataSource 객체를 생성한 뒤, 다양한 메서드를 호출하여 부가 정보를 전달한다.
정보가 일치하지 않는 경우 예외가 발생한다.

Spring Boot 자동 등록

// application.properties

spring.datasource.url=url
spring.datasource.username=username
spring.datasource.password=password

application.properties에 부가 정보를 작성하면
Spring Boot는 HikariDataSource를 Bean으로 자동으로 등록한다.
url 속성이 없는 경우 메모리 DB 생성을 시도한다.

Transaction

멀티스레드를 학습하면서 가장 중요한 문제는 동시성 문제였다.
여러 스레드가 동시에 같은 필드에 접근할 때,
변경 사항이 필드에 정상 반영되지 않을 수 있다.
동시성 문제를 해결하기 위해 락 개념을 사용했다.

데이터베이스도 마찬가지다.
여러 사용자가 동시에 같은 데이터베이스에 접근할 때,
변경 사항이 테이블에 정상 반영되지 않을 수 있다.

DB에서 동시성 문제를 해결하기 위한 방안이 바로 Transaction이다.

Session

DB Connection 생성과 동시에 Connection마다 Session을 생성한다.
Session이란 Transaction을 시작하고
commit 또는 rollback하여 Transaction을 종료하는 트랜잭션 관리 객체다.

Transaction을 사용하지 않는 SQL은 Connection이 직접 처리하고,
Transaction 내부에서 실행되는 SQL은 Session이 처리한다.

Lock

Transaction도 락 기능을 지원한다.

Transaction을 시작하면 변경을 시도하는 테이블 row의 락을 획득한다.
다른 Session에서 해당 row에 접근하는 경우,
락을 획득한 Session의 Transaction이 종료될 때까지 대기한다.
락을 대기하는 row외 다른 row의 데이터 변경을 먼저 처리할 수 있다.

LOCK_TIMEOUT

SET LOCK_TIMEOUT n  // 단위는 n ms

대기 시간은 설정 가능하고 초과 시 타임아웃 오류가 발생한다.
LOCK_TIMEOUT 키워드를 사용하고 ms 단위의 시간을 설정한다.

조회 LOCK

기본적으로 데이터 변경이 아닌 조회 시에는 락을 사용하지 않는다.

SELECT * FROM table FOR UPDATE

FOR UPDATE 키워드를 사용하여 조회 시에도 락 기능을 사용할 수 있다.

Commit

Transaction은 시작하고 종료할 수 있으며 이는 작업의 시작과 종료를 의미한다.
특정 Session이 Transaction을 수행하는 동안 락을 획득하여
다른 Session이 접근할 수 없는 것이다.

Transaction 종료 전까지 Session은 변경한 데이터를 임시로 저장해둔다.
특정 Session의 Transaction에서 DB를 변경했다고 하더라도
Transaction이 종료되지 않은 경우 다른 Session에서 변경된 결과를 확인할 수 없다.
Transaction이 종료되고 결과를 DB에 반영해야만 다른 Session에서 확인할 수 있다.

// SQL AUTOCOMMIT 설정
SET AUTOCOMMIT true

// Java autocommit 설정
connection.setAutoCommit(true);
connection.commit();  // 커밋

수정 사항을 DB에 반영하도록 하는 것을 Commit이라고 한다.

기본적으로 커밋은 자동 커밋(AUTOCOMMIT) 설정이 true로 되어 있어
쿼리 실행 직후 항상 결과를 자동으로 즉시 반영한다.

Rollback

connection.rollback();  // 롤백

수정 사항을 DB에 반영하지 않는 것을 Rollback이라고 한다.

롤백을 수행하면 데이터를 변경하고 임시 저장한 값은
Transaction을 시작하기 전으로 다시 되돌려진다.

수동 Commit

SET AUTOCOMMIT false

connection.setAutoCommit(false);

자동 커밋은 SQL 실행 결과를 즉시 반영하기 때문에
데이터 변경 결과 반영 여부에 대한 선택지가 없고 항상 Commit을 수행한다.
우리가 학습한 Commit과 Rollback의 개념은
쿼리 실행 결과 반영 여부를 선택할 수 있는 수동 커밋에 적용되는 것이다.
결과를 반영하려면 Commit, 반영하지 않으려면 Rollback을 선택한다.
Transaction은 바로 이 수동 커밋으로 동작하는 것이다.
Transaction을 시작한다는 것과 수동 커밋을 설정한다는 것은 동일한 의미이다.

AUTOCOMMIT의 값은 Session이 수행되는 동안 계속 유지되며, 도중에 변경할 수 있다.
따라서 하나의 작업에서 변경과 조회를 모두 수행하는 경우,
보통 변경 시에만 수동 커밋을 사용하기 때문에
Transaction의 사용이 끝난 후에는 자동 커밋 모드로 변경해야 한다.

서비스 계층 Transaction

애플리케이션 구조

  • 프레젠테이션 계층 : UI 처리 계층, 사용자 요청을 검증하고 요청에 응답하는 역할
    Servlet과 같은 HTTP 기술 및 Spring MVC 기술 사용
  • 서비스 계층 : 비즈니스 로직만을 나타내는 순수 자바 코드로 구성
    인터페이스 의존을 통해 시간이 지나도 최대한 코드 변경 없이 유지
  • 데이터 접근 계층 : JDBC 바탕으로 DB에 접근하는 역할

데이터 변경은 비즈니스 로직을 수행하는 서비스 계층에서 발생한다.

따라서 Transaction의 시작, 즉 수동 Commit을 시작하는 시점은
서비스 계층에서 시작하도록 설계하면 된다.

ACID

Transaction은 ACID를 보장하는 기술이다.

  • Atomicity(원자성) : 트랜잭션 내 실행 작업은 하나의 작업처럼 모두 성공하거나 실패한다.
  • Consistency(일관성) : 모든 트랜잭션은 일관성 있는 DB 상태를 유지한다.
  • Isolation(격리성) : 트랜잭션이 서로 영향을 미치지 않도록 동시에 데이터를 수정할 수 없다.
  • Durability(지속성) : 결과를 기록하여 문제가 발생해도 트랜잭션을 복구하여 유지할 수 있다.

Isolation

ACID 중 격리성을 100% 보장하기 위해서는 Transaction을 순서대로 실행해야 한다.
하지만 무수히 많은 트랜잭션을 순서대로 실행하면 성능이 매우 나빠진다.
무조건 순서대로 실행하는 것이 아닌 성능을 향상하기 위해
ANSI 표준은 Isolation Level을 4단계로 분리했다.

  • READ UNCOMMIITED : 커밋되지 않은 데이터 읽기 가능
  • READ COMMITTED : 커밋된 데이터만 읽기 가능
  • REPEATABLE READ : 한 Transaction 내에서 읽은 데이터는 항상 동일
  • SERIALIZABLE : 모든 Transaction은 순서대로 실행

TransactionManager

문제점은 Transaction 기술의 사용 방법이 모두 다르다는 것이다.

문제를 해결하기 위해 Transaction 기술을 추상화한 것이 바로 TransactionManager이다.

TransactionManager를 사용하여 기술 변경 시 발생하는 코드 변경을 방지하고
특정 기술에 대한 의존성을 제거한다.

PlatformTransactionManager

private final PlatformTransactionManager transactionManager;

public void method() {
	TransactionStatus status = transactionManager.getTransaction(
    		new DefaultTransactionDefinition());
    
    transactionManager.commit(status)  // 커밋
    transactionManager.rollback(status)  // 롤백
}

Spring은 TransactionManager의 구현체로 PlatformTransactionManager를 제공한다.
트랜잭션 매니저를 통해 트랜잭션을 획득하면 TransactionStatus를 반환한다.
TransactionStatus에는 현재 트랜잭션 상태 정보가 포함되어 커밋, 롤백에 사용된다.

DataAccessException

Transaction 기술 의존성 문제는 구현 코드 뿐만 아니라 예외도 포함된다.
서비스 계층에서 TransactionManager를 사용하여 특정 기술의 의존성을 제거하더라도
데이터 접근 계층에서 발생한 예외를 전달받는 경우 여전히 의존성 문제가 발생한다.
따라서 데이터 접근 계층은 특정 기술에 의존한 예외를 포함하면서도,
언체크 예외로 변환하여 서비스 계층에 전달해야 한다.

하지만 모든 예외를 변환하기 위해 직접 예외를 설계하는 것은 불가능에 가깝다.

Spring은 DB와 관련된 예외를 언체크 예외로 변환하는 추상화 작업을 자동으로 수행한다.

org.springframework.jdbc.support.sql-error-codes.xml

DB와 관련된 예외는 각각 errorCode를 보유하고 있고
errorCode마다 어떤 예외로 추상화하는지 위 파일에 정의되어 있다.

SQLExceptionTranslator exceptionTranslator = 
		new SQLErrorCodeExceptionTranslator(dataSource);

exceptionTranslator.translate("예외 설명", sql, error);

@Repository 애노테이션이 선언된 곳에서 예외가 발생한 경우,
Spring 내부에서는 SQLExceptionTranslator를 사용하여
DB 관련 예외를 언체크 예외로 자동으로 변환해준다.

추상화된 예외의 최상위 부모 클래스가 바로 DataAccessException이다.

DataAccessException은 일시적인 예외로 재시도 시 성공할 수 있는 Transient
비일시적 예외로 반복 시 계속해서 실패하는 NonTransient 예외로 나눌 수 있다.

구현 코드뿐만 아니라 예외까지 추상화해야 연결된 계층의 의존성을 완전히 제거할 수 있다.

TransactionSynchronizationManager

sql을 실행하면 sql마다 Connection을 연결하고 내부에 DB Session을 생성한다.

// A 잔액 : 1,000
// B 잔액 : 0
// A → B 1,000 계좌 이체

1. A 계좌 잔액 - 1,000  // Connection1 생성, SQL 실패
2. B 계좌 잔액 + 1,000  // Connection2 생성, SQL 성공

// A 잔액 : 1,000
// B 잔액 : 1,000   ** 문제 발생 **

계좌 이체와 같이 입금과 출금이 연이어 동작하는 비즈니스 로직이 있다고 가정해보자.
입금과 출금에 각각 Session이 생성되어 sql을 실행한다.
모든 sql이 성공해야 안정적으로 계좌 이체 서비스를 운영할 수 있다.
만약 입금만 성공하고 출금에 실패하는 경우,
돈을 보내지 않았는데 상대방의 계좌에 돈이 증가하는 치명적인 문제가 발생한다.

Connection 동기화

// A 잔액 : 1,000
// B 잔액 : 0
// A → B 1,000 계좌 이체

1. Connection 생성
2. Transaction 시작
3. A 계좌 잔액 - 1,000  // SQL 실패
4. B 계좌 잔액 + 1,000  // SQL 성공
5. 문제 발생 → rollback

// A 잔액 : 1,000
// B 잔액 : 0

이러한 문제를 해결하기 위해서 하나의 동작으로 결합되어야 하는 로직은
하나의 Connection을 사용하여 Transaction을 획득하고
모든 동작이 성공하면 커밋, 실패하면 롤백을 수행해야 한다.

이 과정을 수행하기 위해 특정 비즈니스 로직 마다 Connection 객체를 전달하여 공유해야 한다.
하지만 Connection 객체를 파라미터로 전달하는 것은 매우 번거롭고,
Transaction을 사용하지 않아 파라미터로 전달하지 않는 버전도 만들어야 한다.

트랜잭션 동기화 매니저

1. TransactionManager 트랜잭션 시작
2. TransactionManager → TransactionSynchronizationManager 커넥션 전달
3. TransactionSynchronizationManager ThreadLocal 커넥션 보관
4. SQL 실행 시 TransactionSynchronizationManager에서 Connection을 꺼내서 사용
5. commit 또는 rollback
6. autocommit 자동 모드 전환, 리소스 정리

TransactionManager는 내부에 TransactionSynchronizationManager를 사용하여
Connection 동기화 문제를 해결한다.

TransactionManager를 사용하여 Transaction을 시작하는 경우,
TransactionManager는 내부의 트랜잭션 동기화 매니저에게 Connection을 전달한다.
트랜잭션 동기화 매니저는 전달받은 Connection을 ThreadLocal에 보관한다.
ThreadLocal을 사용하기 때문에 여러 사용자가 DB에 접근해도 다른 Connection을 사용한다.

  • DataSourceUtils

// 커넥션 생성
Connection connection = DataSourceUtils.getConnection(dataSource);

// 커넥션 정리
DataSourceUtils.releaseConnection(connection, dataSource);

트랜잭션 매니저와 트랜잭션 동기화 매니저를 사용하기 위해서는
DataSourceUtils 객체를 사용하여 Connection을 생성하고 정리해야 한다.

TransactionManager를 통해 Transaction을 시작하여
여러 SQL을 실행하기 위해 로직마다 getConnection() 메서드를 호출하면
호출할 때마다 Connection을 생성하는 것이 아닌,
트랜잭션 동기화 매니저에 Connection이 없는 경우 생성하고
Connection이 있다면 트랜잭션 동기화 매니저에서 보관하는 커넥션을 반환하도록 동작한다.
즉, getTransaction() 메서드를 호출한 뒤 커밋 또는 롤백하지 않고
getConnection()을 호출하면 트랜잭션 동기화 매니저에 보관된 커넥션을 가져다 사용한다.

releaseConnection() 메서드를 사용하면 동기화된 Connection은 닫지 않고 유지하고
트랜잭션 동기화 매니저가 관리하는 커넥션이 없을 때만 정리한다.
즉, getTransaction() 메서드를 호출한 뒤 커밋 또는 롤백하지 않고
releaseConnection() 메서드를 호출하면 실제로 커넥션을 정리하지 않는다.
getTransaction() 메서드 호출하지 않은 상태이거나
메서드 호출 이후 커밋 또는 롤백한 뒤에만 실제로 커넥션을 정리한다.

이러한 방식으로 파라미터로 전달하지 않고도 Connection을 동기화하여 사용할 수 있다.

@Transactional

Transaction을 획득, 비즈니스 로직 실행, 커밋 또는 롤백,
그리고 자원을 정리하는 패턴이 반복되면서 지저분한 코드가 만들어진다.
비즈니스 로직을 수행하는 서비스 로직
서비스 로직을 안정적으로 수행하기 위한 Transaction 기술 로직을 분리하면
더욱 깔끔한 코드가 완성되고, 가독성 높은 서비스 로직 코드를 만들어낼 수 있을 것 같다.

TransactionTemplate

public class TransactionTemplate {
	
    private PlatformTransactionManager transactionManager;
    
    // 반환 값이 있는 경우
    public <T> T execute(TransactionCallback<T> action) {..}
    
    // 반환 값이 없는 경우
	void executeWithoutResult(Consumer<TransactionStatus> action) {..}

Teplate Callback Pattern을 사용하면 더 깔끔한 코드를 만들 수 있다.
Spring은 템플릿 콜백 패턴을 사용할 수 있는 TransactionTemplate를 지원한다.

private final TransactionTemplate transactionTemplate;

public void method() {

	transactionTemplate.executeWithoutResult((status) -> {
		try {
			serviceLogic();
		} catch (Exception e) {
			// 예외 처리 로직
		}
	}
}

TransactionTemplate를 사용하면
트랜잭션 획득, 커밋 또는 롤백, 리소스를 정리하는 코드를 Spring이 자동으로 수행하여
순수하게 서비스 로직만을 작성하여 설계할 수 있다.

하지만 이것마저도 TransactionTemplate 객체를 생성하여
람다로 비즈니스 로직을 추가해야 하기 때문에 깔끔해보이지 않는다.
Proxy를 사용하면 정말 순수하게 서비스 로직만을 구현해낼 수 있다.

선언적 트랜잭션 관리

@Transactional 애노테이션이 선언되어 있으면
Spring은 AOP Proxy를 생성하여 스프링 컨테이너에 등록한다.
이제 Transaction 기술 로직은 Proxy에서 담당하게 되어
정말 순수하게 서비스 로직만을 객체에 작성할 수 있다.

AOP Proxy에서 Transaction 기능을 지원하기 위해
내부적으로 TransactionManager를 사용하기 때문에
TransactionManager를 Bean으로 등록해주어야 @Transactional을 사용할 수 있다.
(Spring Boot에서는 TransactionManager를 라이브러리를 참고하여 자동으로 등록해준다.)

이렇게 @Transactional 애노테이션을 사용하지 않고
직접 기술 로직을 구현하는 것을 프로그래밍 방식의 트랜잭션 관리라고 하고
@Transactional 애노테이션을 사용하는 방식은 선언적 트랜잭션 관리라고 일컫는다.

프로그래밍 방식의 트랜잭션 관리는 가끔 테스트 시에만 사용하고,
Spring에서 Transaction을 사용할 때에는 @Transactional 애노테이션을 사용하면 된다.

@Transactional 애노테이션을 사용하여 트랜잭션 기능을 지원하기 위해
Spring은 Transaction AOP Proxy에 필요한 객체를 지원한다.

Advisor : BeanFactoryTransactionAttributeSourceAdvisor
Advice : TransactionInterceptor
Pointcut : TransactionAttributeSourcePointcut

보다시피 Transaction은 AOP Proxy 방식으로 Bean 초기화 메서드 실행 이후에 적용되기 때문에
초기화 시점에는 Transaction을 적용할 수 없다.
초기화 시점에 Transaction 적용하려면 @EventListener 애노테이션을 사용해야 한다.

@Transactional 우선순위

1. 클래스 메서드
2. 클래스
// 인터페이스에는 가급적 @Transactional 선언을 지양한다.
3. 인터페이스 메서드
4. 인터페이스 타입

@Transactional 애노테이션을 클래스 또는 메서드에 선언할 수 있는데,
클래스에 선언하면 모든 public 메서드에 Transaction이 자동으로 적용된다.
(Spring 3.0부터 protected, default 메서드에도 적용된다.)
클래스에 선언되어 있다고 하더라도 메서드에 별도로 @Transactional 애노테이션 설정이 있다면
해당 메서드는 메서드의 트랜잭션 정보를 따른다.

Transaction element

// 트랜잭션 매니저 설정
@Transactional(value = "transactionManager")

// rollback을 수행할 예외 설정
@Transactional(rollbackFor = Exception.class)

// rollback을 수행하지 않을 예외 설정
@Transactional(noRollbackFor = Exception.clasS)

// isolation level 설정
@Transactional(isolation = Isolation.READ_COMMITED)

// Transaction 타임아웃 시간 설정
@Transactional(timeout = n)  // 단위 : 초

// Transaction 읽기 전용 트랜잭션 설정
// 기본값 : readOnly = false, 데이터 변경 트랜잭션
@Transactional(readOnly = true)  

@Transactional은 element를 사용하여 다양한 설정을 추가할 수 있다.

@Transactional 애노테이션 사용 시 언체크 예외는 rollback하고 체크 예외는 commit한다.
하지만 rollbackFor, noRollbackFor 예외를 사용하여 더욱 세밀하게 설정할 수 있다.
이 외에도 isolation, timeout, readOnly 등의 element를 사용할 수 있다.

Transaction Propagation

Transaction Propagation(트랜잭션 전파)란,
트랜잭션 내부에서 트랜잭션을 호출하는 상황을 의미한다.

이러한 상황을 쉽게 이해하기 위해 트랜잭션을 물리 트랜잭션논리 트랜잭션으로 구분한다.

  • 물리 트랜잭션 : 실제 데이터베이스에 적용되는 트랜잭션
  • 논리 트랜잭션 : Transaction Manager를 통해 트랜잭션을 사용하는 단위

isNewTransaction

논리 트랜잭션이 모여 하나의 물리 트랜잭션을 구성한다.
따라서 모든 논리 트랜잭션은 물리 트랜잭션에서 시작하는 하나의 트랜잭션을 사용한다.

가장 외부에서 호출한 트랜잭션은 트랜잭션 동기화 매니저에 Connection을 보관하고
물리 트랜잭션의 commit과 rollback을 관리한다.

내부에서 호출한 트랜잭션은 트랜잭션 동기화 매니저에 보관된 Connection을 가져다 사용한다.
내부 트랜잭션은 commit() 또는 rollback() 메서드를 호출하지만,
실제로 물리 트랜잭션을 commit하거나 rollback하지 않는다.

외부에서 호출한 트랜잭션과 내부 트랜잭션을 구분하기 위해

외부에서 호출한 트랜잭션은 새로롭게 트랜잭션을 획득한다고 해서
isNewTransaction = true 정보를 설정한다.

내부 트랜잭션은 외부 트랜잭션에 참여하는 형태이므로 isNewTransaction = false를 설정한다.
이러한 방식으로 실제 데이터베이스 트랜잭션의 commit과 rollback을 제어할 수 있다.

트랜잭션 전파 규칙

논리 트랜잭션이 모두 commit되어야 물리 트랜잭션이 commit된다.
논리 트랜잭션 중 하나라도 rollback되는 경우 물리 트랜잭션은 rollback된다.

논리 트랜잭션의 commit, rollback 여부를 구분하기 위해
rollback 시 트랜잭션에 rollbackOnlyt=true라는 롤백 마크를 표시한다.

즉, 물리 트랜잭션은 모든 논리 트랜잭션이 commit이라면 안전하게 commit할 수 있지만
논리 트랜잭션 중 하나라도 롤백 마크가 표시된 경우 예외를 발생시키고 rollback한다.
외부에서 호출한 트랜잭션을 제외하고 내부 트랜잭션이 모두 commit 상태여도
물리 트랜잭션을 담당하는 외부 호출 트랜잭션이 rollback하는 경우 rollback한다.

@Transactional 애노테이션에서 isolation, timeout, readOnly element는
외부에서 호출한 트랜잭션에만 적용된다.

DefaultTransactionAttribute

DefaultTransactionAttribute defaultTransactionAttribute = new DefaultTransactionAttribute();
defaultTransactionAttribute.setPropagationBehavior(
		TransactionDefinition.PROPAGATION_TYPE);

트랜잭션 전파 규칙을 제어하기 위해
DefaultTransactionAttribute 객체를 생성한 뒤
setPropagationBehavior() 메서드를 호출하여 트랜잭션 전파 옵션을 설정할 수 있다.

트랜잭션 전파 옵션

  • REQUIRED : 기본값, 기존 트랜잭션이 없으면 새로 생성하고 있으면 기존 트랜잭션에 참여한다.
  • REQUIRES_NEW : 항상 트랜잭션을 새로 생성한다.
  • SUPPORT : 기존 트랜잭션이 없으면 없이 수행하고, 있으면 기존 트랜잭션에 참여한다.
  • NOT_SUPPORT : 항상 트랜잭션 없이 수행한다.
    기존 트랜잭션이 있다면 잠시 보류하고 트랜잭션 없이 수행한다.
    보류하는 동안 다른 Session이 접근할 수 있다.
  • MANDATORY : 트랜잭션이 없으면 예외를 발생시키고 있으면 참여한다.
  • NEVER : 트랜잭션 없이 수행하고 있으면 예외를 발생시킨다.
  • NESTED : 트랜잭션이 없으면 생성하고 있다면 중첩 트랜잭션을 생성한다.
    (중첩 트랜잭션 : 롤백되어도 외부 트랜잭션 커밋이 가능하고
    외부 트랜잭션이 롤백되면 함께 롤백되는 트랜잭션)

일반적으로 REQUIRED 옵션을 사용하지만
외부 트랜잭션과 내부 트랜잭션이 별도로 커넥션이 필요한 경우 REQUIRES_NEW를 사용한다.
커넥션을 2개 사용하기 때문에 성능 이슈가 발생하므로 주의해야 한다.
REQUIRES_NEW를 사용하는 경우, 트랜잭션 내부에서 트랜잭션을 호출하는 구조보다
가능한 경우 호출 자체를 분리해서 설계할 수 있도록 하는 것이 바람직하다.

마무리

DB 접근 기술 시작으로 Connection 연결 담당 DriverManager부터 DataSource 커넥션 풀,
그리고 Transaction을 편리하게 사용할 수 있는 @Transactional까지 알아봤다.

DB에 접근하기 위해 많은 문제들을 해소하고 편의성을 증대했지만,
가장 핵심적인 문제는 SQL을 작성하는 데에 있다.
이제 번거로운 SQL을 작성해야 하는 문제를 어떻게 해결해야 좋을지 고민해보자.

profile
복습에 대한 비판과 지적을 부탁드립니다

0개의 댓글