본문 바로가기
springboot/data access

Spring의 트랜잭션 추상화로 JDBC 기반 트랜잭션 처리의 문제점 해결하기

by cactuslog 2025. 4. 27.

트랜잭션은 왜 필요할까?

●  A가 B에게 10000원을 이체하는 경우를 생각해보자.

START TRANSACTION;

UPDATE member SET balance = balance - 10000 WHERE id = 'A';
UPDATE member SET balance = balance + 10000 WHERE id = 'B';

COMMIT;

 

●  A의 잔고를 줄이고 B의 잔고를 늘리면 되므로 간단해 보인다.

@Slf4j
@RequiredArgsConstructor
public class MemberService {

	private final MemberRepository memberRepository;

	public void accountTransfer(String fromId, String toId, int amount) throws SQLException {
		MemberDto fromMember = memberRepository.findById(fromId);
		MemberDto toMember = memberRepository.findById(toId);

		memberRepository.update(fromId, fromMember.getBalance() - amount);
		memberRepository.update(toId, toMember.getBalance() + amount);
	}

}

 

●  그러나 다음과 같이 작업 중간에 어떤 로직으로 인해 에러가 발생한다면 어떻게 될까?

public void accountTransfer(String fromId, String toId, int amount) throws SQLException {
		MemberDto fromMember = memberRepository.findById(fromId);
		MemberDto toMember = memberRepository.findById(toId);

		memberRepository.update(fromId, fromMember.getBalance() - amount);

		// 에러 발생하는 경우
		Random random = new Random();
		random.setSeed(System.currentTimeMillis());
		int errorNumber = random.nextInt(2);
		if (errorNumber == 1) {
			throw new IllegalStateException();
		}

		memberRepository.update(toId, toMember.getBalance() + amount);
	}

 

●  A는 돈이 빠져나갔지만 B는 돈을 받지 못한 상태가 된다.

 

●  이는 심각한 데이터 무결성 위반이다.

 

●  이 순간 필요한 게 바로 트랜잭션이다.

 

●  모든 작업이 한 덩어리로 처리되고, 일부 실패할 경우 전체가 되돌아가야 한다.

 

트랜잭션과 ACID

트랜잭션은 하나의 작업 단위를 묶은 것으로이 안에 있는 여러 작업은 모두 성공하거나 하나라도 실패하면전부 실패한 것으로 처리한다. 예를 들어 A가 B에게 10000원을 계좌 이체 한다고 할 때 A

cactuslog.tistory.com


트랜잭션은 어느 계층에서 처리해야 할까?

●  트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 처리해야 한다.

 

●  비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.

 

●  서비스 계층에서 트랜잭션을 시작할 때 커넥션을 획득하고, 트랜잭션이 끝날 때 commit 이나 rollback 후에, 커넥션을 반환 또는 종료해야 한다.

 

●  트랜잭션은 DB 세션 단위로 관리되는데, 이 세션은 커넥션을 통해 생성된다.

 

●  따라서 트랜잭션을 유지하는 동안에는 반드시 같은 커넥션을 사용해야 한다.

 

이해를 위해 DB 연결 구조와 세션에 대해 알아보자.

1. 웹 어플리케이션 서버는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺는다.

 

2. 이 때 데이터베이스 서버는 내부에 세션을 생성한다.

 

3. 커넥션을 통한 모든 요청은 이 세션을 통해 실행하게 된다.

 

4. 세션은 트랜잭션을 시작하고 commit 또는 rollback을 통해 트랜잭션을 종료한다.

 

5. 따라서 커넥션이 다르면 서로 독립적인 트랜잭션으로 동작한다.

 

6. 즉 트랜잭션으로 여러 작업을 묶으려면 connection이 같아야 한다는 것이다.


서비스 계층에서 트랜잭션 처리하기

Repository 코드

@Slf4j
public class MemberRepository {
	
	public MemberDto findById(Connection conn, String id) throws SQLException {
		String sql = "select * from member where id = ?";

		PreparedStatement pstmt = null;
		ResultSet rs = null;

		try {
			pstmt = conn.prepareStatement(sql);
			pstmt.setString(1, id);

			rs = pstmt.executeQuery();

			if (rs.next()) {
				MemberDto memberDto = new MemberDto();
				memberDto.setId(rs.getString("id"));
				memberDto.setBalance(rs.getInt("balance"));

				return memberDto;

			} else {
				throw new NoSuchElementException();
			}

		} catch (SQLException e) {
			log.error("db error", e);
			throw e;

		} finally {
			JdbcUtils.closeResultSet(rs);
			JdbcUtils.closeStatement(pstmt);
		}

	}

	public void update(Connection conn, String id, int balance) throws SQLException {
		String sql = "update member set balance=? where id=?";
		PreparedStatement pstmt = null;
		try {
			pstmt = conn.prepareStatement(sql);
			pstmt.setInt(1, balance);
			pstmt.setString(2, id);

			int resultSize = pstmt.executeUpdate();
			log.info("resultSize={}", resultSize);
		} catch (SQLException e) {
			log.error("db error", e);
			throw e;
		} finally {
			JdbcUtils.closeStatement(pstmt);
		}
	}
}

 

Service 코드

@RequiredArgsConstructor
public class MemberServiceV1 {

	private final DataSource dataSource;
	private final MemberRepository memberRepository;

	public void accountTransfer(String fromId, String toId, int amount) throws SQLException {

		Connection conn = dataSource.getConnection();

		try {
			conn.setAutoCommit(false); //트랜잭션 시작
			businessLogic(conn, fromId, toId, amount);
			conn.commit();
		} catch (Exception e) {
			conn.rollback();
			throw new RuntimeException(e);
		} finally {
			if (conn != null) {
				try {
					conn.setAutoCommit(true); // 커넥션 풀에 반환하기 전 원복
					conn.close();
				} catch (SQLException e) {
					log.info("error", e);
				}
			}
		}
	}

	private void businessLogic(Connection conn, String fromId, String toId, int amount) throws SQLException {
		MemberDto fromMember = memberRepository.findById(conn, fromId);
		MemberDto toMember = memberRepository.findById(conn, toId);

		memberRepository.update(conn, fromId, fromMember.getBalance() - amount);

		// 에러 발생하는 경우
		Random random = new Random();
		random.setSeed(System.currentTimeMillis());
		int errorNumber = random.nextInt(2);
		if (errorNumber == 1) {
			throw new IllegalStateException();
		}

		memberRepository.update(conn, toId, toMember.getBalance() + amount);
	}

}

 

●  같은 커넥션을 사용할 수 있게 되었고, 따라서 트랜잭션으로 작업을 묶을 수 있게 되었다.

 

그러나 비즈니스 로직과 트랜잭션 코드가 섞이면서 다음과 같은 문제가 존재한다.

1. 변경에 취약하다.

 

    ●  javax.sql.DataSource, java.sql.Connectionjava.sql.SQLException 같은 JDBC 기술에 의존하고 있기 때문에, 향후 JPA로  바꾸어 사용하면 트랜잭션을 사용하는 코드가 다르므로 모든 서비스 코드도 함께 변경해야 한다.

 

    ●  즉 확장에 닫힌 구조가 되어버린다. (OCP 위배)

 

2. 하나의 서비스 내부에서 다른 서비스를 호출할 경우 중첩 트랜잭션 관리가 난해하다.

 

3. 관심사 분리(SRP)를 위반하여 코드 중복, 유지보수 지옥이 열린다.

 

    ●  트랜잭션 관련 코드가 모든 서비스마다 반복된다.

 

    ●  트랜잭션 로깅 추가, 모니터링 변경이 발생하면 모든 서비스 로직을 수정 해야한다.

 

4. 트랜잭션 코드가 섞이면 비즈니스 로직만 테스트 하기 어려워진다.

 

5. 같은 커넥션을 사용하기 위해 서비스 계층에서 repository로 매 작업마다 커넥션을 직접 넘겨야한다.

 

    ●  트랜잭션을 유지하지 않아도 되는 기능까지 커넥션을 받게 된다.


스프링의 문제 해결 전략

트랜잭션 추상화

●  트랜잭션을 사용하는 코드는 데이터 접근 기술마다 다르다.

 

●  JDBC, JPA 어떤 기술을 사용해도 상관 없도록 트랜잭션 기능을 추상화한다.

 

●  서비스는 PlatformTransactionManager 인터페이스에 의존한다.

 

●  원하는 구현체를 DI를 통해서 주입하면 되기 때문에 더이상 기술에 종속되지 않는다.

 

begin이나 start가 아닌 getTransaction인 이유는, 기존에 이미 진행중인 트랜잭션이 존재할 경우 해당 트랜잭션에 참여할 수 있기 때문이다.

 

 

리소스 동기화로 커넥션 공유

●  트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 DB 커넥션을 유지해야 한다.

 

●  이를 위해 파라미터로 커넥션을 넘겨야하는 문제가 있었다.

 

●  이 문제를 해결하기 위해스프링은 트랜잭션 동기화 매니저를 제공한다.

org.springframework.transaction.support.TransactionSynchronizationManager

 

●  트랜잭션 동기화 매니저는 ThreadLocal을 활용해 커넥션을 안전하게 동기화한다.

ThreadLocal이란 스레드마다 독립적인 값을 저장할 수 있는 변수 공간을 말한다.
멀티스레드 환경에서도 각 스레드는 자기만의 커넥션을 안전하게 사용할 수 있다.

1. 동작 방식을 보면 먼저 서비스 계층에서 getTransaction()을 호출하여 트랜잭션을 시작한다.

 

2. 트랜잭션 매니저는 DataSource를 통해 커넥션을 생성한다.

 

3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.

 

4. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.

 

5. 비즈니스 로직에서 repository 메서드를 호출한다.

 

6. DataSourceUtils.getConnection()을 사용해 동기화 매니저에 보관된 커넥션을 사용하여 SQL을 DB에 전달해서 실행한다.

    ●  이 과정을 통해 같은 커넥션을 사용하므로 트랜잭션이 유지된다.

 

7. 비즈니스 로직이 끝나고 commit 또는 rollback 후 트랜잭션을 종료한다.

 

8. 트랜잭션을 종료하기 위해 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.

 

9. 획득한 커넥션을 통해 DB에 트랜잭션을 commit 하거나 rollback 한다.

 

10. 전체 리소스를 정리한다.

    ●  ThreadLocal은 사용후 꼭 정리해야 하므로 트랜잭션 동기화 매니저를 정리한다.

    ●  close로 커넥션을 종료하는데 풀에 반납할 경우는 auto commit을 true로 되돌린 후 반납한다.

 

위는 JdbcTransactionManager 동작방식 위주로 설명하였고 Jpa로 변경이 필요하면 의존관계 주입만JpaTransactionManager로 변경해주면 된다.

 

●  여기까지 적용하면 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않는다.

 

●  또한 커넥션을 더이상 파라미터로 넘기지 않아도 된다.


트랜잭션 템플릿으로 반복되는 패턴 해결

public void accountTransfer(String fromId, String toId, int amount) throws SQLException {

		TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

		try {
			businessLogic(fromId, toId, amount);
			transactionManager.commit(status);
		} catch (Exception e) {
			transactionManager.rollback(status);
			throw new RuntimeException(e);
		} finally {} // TransactionManager가 자원 정리 해줌

	}

 

●  트랜잭션 추상화를 반영한 후 코드를 살펴보면 트랜잭션 시작, 성공하면 커밋, 예외 발생하면 롤백하는 코드가 반복된다.

 

●  즉 다른 서비스에서 트랜잭션을 사용하려면 try, catch, finally 코드가 반복될 것이다.

 

●  달라지는 부분은 비즈니스 로직 뿐이다.

 

●  이럴 때  Template Callback Pattern 을 활용하면 반복되는 문제를 깔끔하게 해결할 수 있다.

템플릿 콜백 패턴은 고정된 로직(템플릿) 안에서 변경이 필요한 부분만 외부에서 Callback으로 전달하여 유연하게 동작을 확장하는 패턴이다.

 

●  이 패턴을 적용하기 위해 스프링은 TransactionTemplate 클래스를 제공한다.

public class TransactionTemplate {

 private PlatformTransactionManager transactionManager;
 
 public <T> T execute(TransactionCallback<T> action){..} // 리턴 값이 있을 때 사용
 void executeWithoutResult(Consumer<TransactionStatus> action){..} // 리턴 값이 없을 때 사용
}

 

public class MemberServiceV3 {

	private final TransactionTemplate txTemplate;
	private final MemberRepository memberRepository;

	public MemberServiceV3(PlatformTransactionManager transactionManager, MemberRepositoryV2 memberRepository) {
		this.txTemplate = new TransactionTemplate(transactionManager);
		this.memberRepository = memberRepository;
	}

	public void accountTransfer(String fromId, String toId, int amount) throws SQLException {

		// 람다에서는 체크 예외를 밖으로 못 던진다.
		txTemplate.executeWithoutResult(transactionStatus -> {
			try {
				businessLogic(fromId, toId, amount);
			} catch (SQLException e) {
				throw new RuntimeException(e);
			}
		});
	}
}

 

●  TransactionTemplate을 사용하려면 TransactionManager를 주입해주어야 한다.

 

●  트랜잭션 템플릿 덕분에 트랜잭션을 시작하고,commit하거나 rollback하는 코드가 모두 제거되었다.

 

●  하지만 서비스 로직에 트랜잭션을 처리하는 기술이 여전히 남아있다.

 

●  서비스 입장에서 비즈니스 로직은 핵심 기능이고 트랜잭션은 부가 기능이다.

 

●  완전한 관심사의 분리가 필요하다.


AOP와 프록시로 트랜잭션 코드 완전 분리

AOP (Aspect-Oriented Programming) 이란?

●  공통적인 부가 기능인 트랜잭션, 로깅, 보안 등을 핵심 로직과 분리해서 관리하는 설계 방식

 

●  스프링 AOP는 proxy pattern을 사용해서 트랜잭션같은 부가기능을 비즈니스 로직 바깥에서 처리한다.

 

●  proxy는 대상 객체 앞에서 대신 요청을 가로채고 부가기능을 처리한 뒤 실제 대상 객체를 호출하는 구조이다.

 

트랜잭션 AOP 적용

import org.springframework.transaction.annotation.Transactional;

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {

	private final MemberRepository memberRepository;

	@Transactional
	public void accountTransfer(String fromId, String toId, int amount) throws SQLException {

		businessLogic(fromId, toId, amount);

	}
}

 

●  스프링이 제공하는 트랜잭션 AOP를 적용하기위해 Transactional annotation을 추가한다

 

●  Transactional annotation은 메서드, 클래스에 둘 다 붙일 수 있다.

 

●  클래스에 붙이면 외부에서 호출가능한 public 메서드가 AOP 적용 대상이 된다.

 

●  위 코드를 보면 순수 비즈니스 로직만 남고, 트랜잭션 관련 코드는 모두 사라졌다.

 

하지만 체크 예외인 SQLException을 throws하는 의존 관계 문제가 남아 있다.

●  아래에 체크 예외 문제에 대한 해결책을 정리 하였다.

 

자바 예외 이해하기 (체크 예외, 런타임 예외)

예외 클래스 계층도Object● 자바에서 모든 객체의 최상위 부모는 Object이다. ● 예외도 객체이므로 예외의 최상위 부모도 Object이다. Throwable ● throw, throws 키워드로 던질 수 있는 객체는 반드시 Thr

cactuslog.tistory.com

 

 

 

 

 

 

'springboot > data access' 카테고리의 다른 글

커넥션 풀과 DataSource  (1) 2025.03.22
JDBC 기초부터 이해하기  (0) 2025.03.10