"트랜잭션과 Connection의 관계는 어떻게 될까요?"
이번글은 트랜잭션과 Connection에 대한 관계를 알아보겠습니다.
먼저, 트랜잭션 커밋과 롤백하는 상황에 Connection은 어떻게 사용될까요?
트랜잭션 커밋
Spring에서 트랜잭션 커밋 은 트랜잭션이 정상적으로 완료되었을 때, 모든 변경 사항을 데이터베이스에 반영하는 과정입니다.
트랜잭션을 커밋하는 코드는 아래와 같습니다.
package hello.itemservice.propagation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;
import javax.sql.DataSource;
@Slf4j
@SpringBootTest
public class BasicTxTest {
@Autowired
PlatformTransactionManager txManger;
@TestConfiguration
static class Config {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@Test
void commit() {
log.info("✅트랜잭션 시작");
TransactionStatus status = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션 커밋 시작");
txManger.commit(status);
log.info("✅트랜잭션 커밋 완료");
}
}
출력 결과
출력 결과의 순서는 다음과 같습니다.
- Creating new transaction (새로운 트랜잭션 생성)
- Acquired Connection [HikariProxyConnection] (커넥션 풀에서 커넥션 획득)
- Switching JDBC Connection (JDBC Connection으로 변경)
- Initiating transaction commit (트랜잭션 커밋 준비)
- Committing JDBC transaction on Connection (트랜잭션 커밋)
- Releasing JDBC Connection (사용한 Connection 반납 conn0)
트랜잭션을 시작하면 Connection Pool에서 Connection을 가져오고, 커밋을 한 후엔 사용한 Connection을 다시 Connection Pool에 되돌려 줍니다.
참고, HikariCP는 스프링부트에 기본으로 내장되어 있는 JDBC 데이터베이스 커넥션 풀링 프레임워크입니다.
트랜잭션 롤백
Spring에서 트랜잭션 롤백은 트랜잭션 중 발생한 변경 사항을 모두 취소하는 작업입니다.
트랜잭션을 롤백하는 코드는 아래와 같습니다.
@Test
void rollback() {
log.info("✅트랜잭션 시작");
TransactionStatus status = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("⚠️트랜잭션 롤백 시작");
txManger.rollback(status);
log.info("⚠️트랜잭션 롤백 완료");
}
출력 결과
출력 결과의 순서는 다음과 같습니다.
- Creating new transaction (새로운 트랜잭션 생성)
- Acquired Connection [HikariProxyConnection] (커넥션 풀에서 커넥션 획득)
- Switching JDBC Connection (JDBC Connection으로 변경)
- Initiating transaction rollback (트랜잭션 롤백 준비)
- Rolling back JDBC transaction on Connection (트랜잭션 롤백)
- Releasing JDBC Connection (사용한 Connection 반납 conn0)
트랜잭션 커밋과 동일하게 트랜잭션을 시작하면 Connection Pool에서 Connection을 가져오고, 롤백을 한 후엔 사용한 Connection을 다시 Connection Pool에 되돌려 줍니다.
이제 트랜잭션을 1개 더 추가해보겠습니다.
독립된 트랜잭션 2개 커밋
서로 연관성이 없는 트랜잭션 2개를 커밋하는 경우엔 어떻게 될까요?
아래의 코드를 통해 살펴보겠습니다.
@Test
void two_transaction_double_commit() {
log.info("✅트랜잭션-A 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-A 커밋");
txManger.commit(statusA);
System.out.println();
System.out.println();
log.info("✅트랜잭션-B 시작");
TransactionStatus statusB = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-B 커밋");
txManger.commit(statusB);
}
출력 결과
두 트랜잭션이 서로 연관이 없는 독립된 트랜잭션이므로, 동일한 커밋과정이 2번 발생하게 됩니다. 추가로, 두 트랜잭션에서 사용되는 커넥션은 conn0으로 같은 커넥션이 사용되고 있습니다. 즉, 트랜잭션A에서 사용한 커넥션(conn0)은 커넥션 풀에 반납이 되고, 트랜잭션B에서는 반납된 커넥션(conn0)을 재사용합니다.
만약, 커넥션 풀을 사용하지 않았다면 어떻게 될까요?
커넥션 풀을 사용하지 않는 경우엔 아래의 이미지와 같이 동작하게 됩니다.
(단, 이미지에서는 con0부터가 아닌 con1부터 시작됩니다.)
Connection Pool을 사용하지 않을 경우, 각 트랜잭션 별로 사용되는 DB 커넥션이 다릅니다.
독립된 트랜잭션 2개 롤백
서로 연관성이 없는 트랜잭션 2개를 롤백하는 경우엔 어떻게 될까요?
결과가 예상은 가지만, 테스트 코드로 확인해보겠습니다. 테스트 코드는 다음과 같습니다.
@Test
void two_transaction_double_rollback() {
log.info("✅트랜잭션-A 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-A 롤백");
txManger.rollback(statusA);
System.out.println();
System.out.println();
log.info("✅트랜잭션-B 시작");
TransactionStatus statusB = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-B 롤백");
txManger.rollback(statusB);
}
출력 결과
커밋할때와 마찬가지로 랜잭션A에서 사용한 커넥션(conn0)은 커넥션 풀에 반납이 되고, 트랜잭션B에서는 반납된 커넥션(conn0)을 재사용합니다.
멀티 스레딩 환경에선 Connection을 어떻게 사용할까요?
테스트 코드는 다음과 같습니다.
@Test
void multi_thread_two_transaction_commit() throws InterruptedException {
Runnable transactionACommit = () -> {
log.info("✅트랜잭션-A 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-A 커밋");
txManger.commit(statusA);
};
Runnable transactionBCommit = () -> {
log.info("✅트랜잭션-B 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-B 커밋");
txManger.commit(statusA);
};
Thread threadA = new Thread(transactionACommit);
Thread threadB = new Thread(transactionBCommit);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
출력 결과
TransactionA가 conn0이 커넥션 풀에 반납되기 전에 TransactionB가 시작되므로 conn1을 사용하게 됩니다.
멀티 스레딩 환경에서 10개의 스레드를 사용할 경우엔 어떻게 될까요?
테스트 코드는 다음과 같습니다.
(⚠️ 리펙토링을 따로 해주지 않았습니다 :) )
@Test
void multi_thread_commit() throws InterruptedException {
Runnable transactionACommit = () -> {
log.info("✅트랜잭션-A 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-A 커밋");
txManger.commit(statusA);
};
Runnable transactionBCommit = () -> {
log.info("✅트랜잭션-B 시작");
TransactionStatus statusB = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-B 커밋");
txManger.commit(statusB);
};
Runnable transactionCCommit = () -> {
log.info("✅트랜잭션-C 시작");
TransactionStatus statusC = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-C 커밋");
txManger.commit(statusC);
};
Runnable transactionDCommit = () -> {
log.info("✅트랜잭션-D 시작");
TransactionStatus statusD = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-D 커밋");
txManger.commit(statusD);
};
Runnable transactionECommit = () -> {
log.info("✅트랜잭션-E 시작");
TransactionStatus statusE = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-E 커밋");
txManger.commit(statusE);
};
Runnable transactionFCommit = () -> {
log.info("✅트랜잭션-F 시작");
TransactionStatus statusF = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-F 커밋");
txManger.commit(statusF);
};
Runnable transactionGCommit = () -> {
log.info("✅트랜잭션-G 시작");
TransactionStatus statusG = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-G 커밋");
txManger.commit(statusG);
};
Runnable transactionHCommit = () -> {
log.info("✅트랜잭션-H 시작");
TransactionStatus statusH = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-H 커밋");
txManger.commit(statusH);
};
Runnable transactionICommit = () -> {
log.info("✅트랜잭션-I 시작");
TransactionStatus statusI = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-I 커밋");
txManger.commit(statusI);
};
Runnable transactionJCommit = () -> {
log.info("✅트랜잭션-J 시작");
TransactionStatus statusJ = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-J 커밋");
txManger.commit(statusJ);
};
Thread threadA = new Thread(transactionACommit);
Thread threadB = new Thread(transactionBCommit);
Thread threadC = new Thread(transactionCCommit);
Thread threadD = new Thread(transactionDCommit);
Thread threadE = new Thread(transactionECommit);
Thread threadF = new Thread(transactionFCommit);
Thread threadG = new Thread(transactionGCommit);
Thread threadH = new Thread(transactionHCommit);
Thread threadI = new Thread(transactionICommit);
Thread threadJ = new Thread(transactionJCommit);
threadA.start();
threadB.start();
threadC.start();
threadD.start();
threadE.start();
threadF.start();
threadG.start();
threadH.start();
threadI.start();
threadJ.start();
threadA.join();
threadB.join();
threadC.join();
threadD.join();
threadE.join();
threadF.join();
threadG.join();
threadH.join();
threadI.join();
threadJ.join();
}
출력 결과
요청한 커넥션만 보기위해서 Acquired문장으로 시작되는 로그를 확인하면 아래와 같습니다.
커넥션이 반납도 되기전에 요청이 들어오므로 전부 다른 커넥션을 사용하게 됩니다.
먼저 반납되는 커넥션을 먼저 사용할까요? 아니면 순서와 상관없이 사용하게 될까요?
Transaction A,B,C,D 4개의 트랜잭션 중 A,B,C 트랜잭션은 동시에 시작을 하고, D 트랜잭션은 0.1초 후에 시작합니다.
테스트 코드는 아래와 같습니다.
@Test
void multi_thread() throws InterruptedException {
Runnable transactionACommit = () -> {
log.info("✅트랜잭션-A 시작");
TransactionStatus statusA = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-A 커밋");
txManger.commit(statusA);
};
Runnable transactionBCommit = () -> {
log.info("✅트랜잭션-B 시작");
TransactionStatus statusB = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-B 커밋");
txManger.commit(statusB);
};
Runnable transactionCCommit = () -> {
log.info("✅트랜잭션-C 시작");
TransactionStatus statusC = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-C 커밋");
txManger.commit(statusC);
};
Runnable transactionDCommit = () -> {
log.info("✅트랜잭션-D 시작");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
TransactionStatus statusD = txManger.getTransaction(new DefaultTransactionAttribute());
log.info("✅트랜잭션-D 커밋");
txManger.commit(statusD);
};
Thread threadA = new Thread(transactionACommit);
Thread threadB = new Thread(transactionBCommit);
Thread threadC = new Thread(transactionCCommit);
Thread threadD = new Thread(transactionDCommit);
threadA.start();
threadB.start();
threadC.start();
threadD.start();
threadA.join();
threadB.join();
threadC.join();
threadD.join();
}
총 3번의 테스트를 통해 A,B,C스레드와 D스레드가 반납하는 커넥션만 확인해보도록 하겠습니다.
출력 결과
test1
conn0이 사용됩니다.
test2
conn0이 사용됩니다.
test3
conn0이 사용됩니다.
어떤 순서로 커넥션이 선택되는 걸까요?
HikariCP 공식문서를 참고해보겠습니다.
https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
About Pool Sizing
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
아래 코드는 커넥션을 가져올 때의 코드 입니다.
public Connection getConnection(final long hardTimeout) throws SQLException
{
suspendResumeLock.acquire();
final var startTime = currentTime();
try {
var timeout = hardTimeout;
do {
var poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
if (poolEntry == null) {
break; // We timed out... break and throw exception
}
final var now = currentTime();
if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && isConnectionDead(poolEntry.connection))) {
closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);
timeout = hardTimeout - elapsedMillis(startTime);
}
else {
metricsTracker.recordBorrowStats(poolEntry, startTime);
if (isRequestBoundariesEnabled) {
try {
poolEntry.connection.beginRequest();
} catch (SQLException e) {
logger.warn("beginRequest Failed for: {}, ({})", poolEntry.connection, e.getMessage());
}
}
return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry));
}
} while (timeout > 0L);
metricsTracker.recordBorrowTimeoutStats(startTime);
throw createTimeoutException(startTime);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
}
finally {
suspendResumeLock.release();
}
}
커넥션 풀에서 사용 가능한 커넥션을 가져오는 코드는 connectionBag.borrow() 입니다. connectionBag은 어디에 선언되어 있을까요?
private final ConcurrentBag<PoolEntry> connectionBag;
connectionBag은 필드 변수로 선언되어 있습니다. 그렇다면, connectionBag은 뭘까요?
import링크를 따라가보니, 직접 만든 자료구조 같습니다. 코드가 너무 길어 링크 첨부합니다.
HikariCP/src/main/java/com/zaxxer/hikari/util/ConcurrentBag.java at dev · brettwooldridge/HikariCP
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
ConectionBag의 borrow 메서드를 살펴보겠습니다. 코드는 다음과 같습니다.
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
// Try the thread-local list first
final var list = threadLocalList.get();
for (var i = list.size() - 1; i >= 0; i--) {
final var entry = list.remove(i);
@SuppressWarnings("unchecked")
final T bagEntry = useWeakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
// Otherwise, scan the shared list ... then poll the handoff queue
final var waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
// If we may have stolen another waiter's connection, request another bag add.
if (waiting > 1) {
listener.addBagItem(waiting - 1);
}
return bagEntry;
}
}
listener.addBagItem(waiting);
timeout = timeUnit.toNanos(timeout);
do {
final var start = currentTime();
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
timeout -= elapsedNanos(start);
} while (timeout > 10_000);
return null;
}
finally {
waiters.decrementAndGet();
}
}
전체적인 코드 동작 흐름은 다음과 같습니다.
- Thread-Local 캐시에서 커넥션을 먼저 가져옴 (성능 최적화)
- 공유 리스트(sharedList)에서 커넥션을 찾음
- 사용 가능한 커넥션이 없으면 대기 큐(handoffQueue)에서 대기
- 일정 시간 내에 커넥션을 확보하지 못하면 null 반환
Thread-Local 캐시에서 커넥션을 가져오는 코드는 아래와 같습니다.
final var list = threadLocalList.get();
for (var i = list.size() - 1; i >= 0; i--) {
final var entry = list.remove(i);
@SuppressWarnings("unchecked")
final T bagEntry = useWeakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
Thread-Local 캐시에서 가장 마지막에 추가된 커넥션부터 역순으로 가져오게 됩니다.
정리
트랜잭션시 사용되는 커넥션은 커넥션 풀로부터 가져오고, 트랜잭션이 종료되면 커넥션 풀에 반납하게 됩니다.
HikariCP에선 Connection을 가져올때, Thread-Local캐시에 가장 마지막에 추가된 커넥션부터 역순으로 가져오게 됩니다.
'Spring' 카테고리의 다른 글
트랜잭션 전파(REQUIRED) COMMIT편 (0) | 2025.02.12 |
---|