본문 바로가기

트러블슈팅

통합테스트 환경에서 @Transaction rollback이 안된다..

 

문제 발생..

 

김영한 선생님의 스프링강의를 수강하면서 테스트 코드를 작성했는데.. 이런 예외가 발생했습니다..

 

예외발생시점 확인

 

 

해당 예외가 발생하는 시점은.. join메소드를 실행하기 전 

findByName메소드를 실행하여 매개값으로 받은 멤버객체의 name필드와

같은 값을 가진 컬럼이 존재한다면 발생하는 예외입니다.

 

 

문제원인 추측

기존에 DB안에 중복 데이터가 있었을 것이다.

 

 

DB에 예전에 테스트할때 데이터가 들어갔겠구나 

하고 DB테이블을 날리러 갔습니다..

 

 

문제원인 추측2

롤백이 정상적으로 진행되지 않고 커밋이 되었다.

 

findOne에서도 join 메소드를 사용하나..

테스트코드는 비동기로 실행되니까..

얘가 제일 먼저 실행이되서 그런거같고..

흠.. 실제로 그럼 DB에도 커밋이 되었겠네요?.. 왜?

 

역시 DB에 그대로 커밋까지 되어있네요..

 

문제 인식

테스트환경에서 @Transactional을 통해 rollback이 진행되지않음

 

분명 클레스레벨에 @Transactional 어노테이션을 작성해두었는데 왜 롤백이 되지않았고 커밋이 된걸까요?..

"테스트코드 트랜잭션 롤백이 안됨"이라고 구글링 해보았습니다 ㅋㅋ

https://www.inflearn.com/questions/314188/transactional-rollback%EC%9D%B4-%EC%95%88%EB%90%98%EB%84%A4%EC%9A%94

 

@Transactional rollback이 안되네요 - 인프런

안녕하세요. 강의 너무 잘 듣고 있습니다.@Transactional을 붙이면 종료하면서 rollback이 되어 테이블에 커밋되지 않는다고 이해했습니다.로그상에는 rollback이 되었다고 나오는데 쿼리 조회해보면 데

www.inflearn.com

 

 

https://www.inflearn.com/questions/314188/transactional-rollback%EC%9D%B4-%EC%95%88%EB%90%98%EB%84%A4%EC%9A%94 - Stanley님의 댓글

해결방법 

하나의 트랜잭션단위는 하나의 커넥션으로 작업하게 한다.

 

다른 메소드는 전부다 이 getConnection을 사용했는데..

얘만 datasource에서 커넥션을 받아왔구나..

일단 수정해서 테스트해보죠.

 

 

 

 

테스트에 통과했네요.

해결방법 검증 및 문제원인 분석

왜 다중 커넥션을 사용하면 롤백이 안되는 가?

 

근데 왜 이런 결과가 나오게 되는걸까요 ?

예전에 자바예제를 공부할때 트랜잭션을 구현하는 방식이..

커넥션을 커넥션풀로부터 받아와서 .. 커넥션의 오토커밋을 종료하고

실행중 예외가 발생하면 롤백..

정상종료라면 커밋후에 오토커밋을 다시 켜주고 반납하는 형식이였는데.. 

저장은 A커넥션으로 한 후 롤백이 일어나 데이터를 불러오는 B커넥션에서 애초에 조회를 할 수 없어야 하지 않았나?

B커넥션의 작업을 수행하기 위해 A커넥션은 커밋을 하는 식으로 동작한건가?

그것보단 A커넥션을 롤백시키고 B작업에서 문제를 발생시키는게 더 합리적이지 않았나?..

그럼 위 같은 상황에서 그럼 A커넥션에서 일어난 일은 커밋시키고 B커넥션에서 수행한 작업은 롤백을 시키려나?..

테스트 해보죠..

save는 DataSourceUtils의 정적메소드로 .. 

save2는 datasource의 인스턴스메소드로..

만들었습니다.. (테스트 하기위함)

 

join은 앞에 만든 save메소드를 사용하고..

join2는 save2메소드를 사용합니다.

 

 

일단 만든 deleteMember가 잘 작동하는지 테스트 해보죠..

잘 작동하는 듯합니다.. ㅎ

 그럼 코드를 복사해서..

join을 join2로 한 후(서로 다른 커넥션을 사용하기위함) 딜리트를 하게된다면 ?!

당연하게도 test는 성공했고.. 과연 db에 데이터가 남아있을까요?

남아있게되네요.. 

근데 왜 먼저 접근하게되는 커넥션은 분명히 인스턴스메소드로 얻게된 커넥션일텐데

먼저 접근한 커넥션으로 수행한 작업에 대해 롤백을 우선 수행하기보다

DataSourceUtils의 커넥션만 롤백이 되었을까요?

DataSourceUtils의 커넥션으로 수행한 작업만 롤백이 되는걸까요?

해결방법 검증 및 문제원인 분석

DataSourceUtils로부터 얻은 커넥션만 롤백되는가?

 

뒤집어서 해보죠..

이제 기존 deleteByIdTest코드를 실행시키면..?

 

이러면 datasource의 인스턴스 메소드로 얻은 커넥션은 롤백이 수행되지 않는건가?..

같은 커넥션을 쓰면 롤백이 된다는 논제를 검증해보기 위해

기존의 findOne의 실행부인 findById메소드를 수정해보죠..

우선 성공.. db엔 ?..

계신다.. 왜지?.. 같은 커넥션을 사용했음에도 롤백이 일어나지 않았네요..

 

해결방법 검증 및 문제원인 분석

DataSourceUtils.getConnection()과 HikariDataSource.getConnection()은

어떻게 다른가?

 

 DataSourceUtils로 얻은 커넥션만 하나의 커넥션으로 유지가 가능한듯합니다..

왜일까요?.. 

구현 클래스를 까보죠..

DataSourceUtils는 Spring에서 관리하는 트랜잭션에 대한 특별한 지원을 DataSourceTranscationalManager라는 이름으로 하고있답니다..

 

구현체를 확인하기 위해.. 로그를 출력시키고..

 

테스트를 하고..

를 확인하면.. 음.. HikariDataSource라는 친구를 쓰고 있네용.

역시나.. HikariDataSource는 DataSourceTranscationalManager지원을 받고 있지 않네요..

구체적으로 어떻게 다른걸까요 ?

우선

DataSourceTransactionalManager의 지원을 받는 DataUtils의 정적메소드 getConnection()부터 보죠.

음 실질적인 실행동작은 doGetConnection에서 이루어지네요.

doGetConnection으로 가보죠..

public static Connection doGetConnection(DataSource dataSource) throws SQLException {
Assert.notNull(dataSource, "No DataSource specified");

ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
conHolder.requested();
if (!conHolder.hasConnection()) {
logger.debug("Fetching resumed JDBC Connection from DataSource");
conHolder.setConnection(fetchConnection(dataSource));
}
return conHolder.getConnection();
}
// Else we either got no holder or an empty thread-bound holder here.

logger.debug("Fetching JDBC Connection from DataSource");
Connection con = fetchConnection(dataSource);

if (TransactionSynchronizationManager.isSynchronizationActive()) {
try {
// Use same Connection for further JDBC actions within the transaction.
// Thread-bound object will get removed by synchronization at transaction completion.
ConnectionHolder holderToUse = conHolder;
if (holderToUse == null) {
holderToUse = new ConnectionHolder(con);
}
else {
holderToUse.setConnection(con);
}
holderToUse.requested();
TransactionSynchronizationManager.registerSynchronization(
new ConnectionSynchronization(holderToUse, dataSource));
holderToUse.setSynchronizedWithTransaction(true);
if (holderToUse != conHolder) {
TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
}
}
catch (RuntimeException ex) {
// Unexpected exception from external delegation call -> close Connection and rethrow.
releaseConnection(con, dataSource);
throw ex;
}
}

return con;
}

음..얼추 해석했을때.. 하나의 트랜잭션단위의 작업을 수행할때,

하나의 커넥션만을 사용하게끔 하고 동기화를 지원하는 코드인 걸로 파악됩니다..(아직 배워야할게 많음을 느낍니다..ㅠ) 

이제 제가 사용한 HikariDataSource는

이런 ConnectionHolder나 TransactionalSynchronizationManager을 통해

proxy로 datasource를 감싸고 이런과정이 없을듯합니다..

확인해보죠..

public Connection getConnection() throws SQLException
{
if (isClosed()) {
throw new SQLException("HikariDataSource " + this + " has been closed.");
}

if (fastPathPool != null) {
return fastPathPool.getConnection();
}

// See http://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java
HikariPool result = pool;
if (result == null) {
synchronized (this) {
result = pool;
if (result == null) {
validate();
LOGGER.info("{} - Starting...", getPoolName());
try {
pool = result = new HikariPool(this);
this.seal();
}
catch (PoolInitializationException pie) {
if (pie.getCause() instanceof SQLException) {
throw (SQLException) pie.getCause();
}
else {
throw pie;
}
}
LOGGER.info("{} - Start completed.", getPoolName());
}
}
}

return result.getConnection();
}

.커넥션 풀이 있다면 커넥션 풀로부터 커넥션을 받아오고,

커넥션풀이없다면 커넥션풀을 생성해서 커넥션풀에서 커넥션을 받아오는 로직이네요 .

커넥션 풀로 가볼까요?

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);
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();
}
}

당연하게도 트랜잭션과 관련하여..

어떤로직도. 커넥션유지를 위한 어떤로직도 보이지 않네요.

 

결론

DataSourceUtils는 DataSourceTranscationalManager의 지원을 받으며

하나의 트랜잭션 단위에 하나의 커넥션만을 사용하게끔합니다.

스프링부트에서 기본으로 제공하는 HikariDataSource는 하나의 트랜잭션 단위에

하나의 커넥션만을 사용하게 하는 로직이 별도로 존재하지 않습니다.

하나의 트랜잭션 단위에 여러개의 커넥션으로 동작하게되면 아마 불가능하진 않을 듯한데.. 

롤백을 구현하는 것이 좀 더 세부적인 로직이 필요로 할 것 같습니다.

하나의 DB에 접근하는데 여러개의 커넥션을 붙잡고 있는 게 효율적인 방법도 아니고.. 

저는 이런 세부적인 로직을 작성한적이 없습니다..

한다고 하더라도 여러DB에 접근해야하는게 아니니..

HikariDataSource에서 사용하는 getConnection이 아닌

DataSourceUtils에서 제공하는 getConnection을 사용하여 문제를 해결했습니다.