JDBC 트랜잭션에서 스프링 트랜잭션 동기화와 트랜잭션 AOP까지
커넥션을 파라미터로 넘기던 JDBC 트랜잭션을 트랜잭션 매니저와 @Transactional로 개선하는 과정을 정리한다.
시리즈 · Spring11 / 14
- SOLID 원칙과 OCP·DIP를 지키는 방법
- 의존성 주입(DI)과 스프링 컨테이너
- 쓰레드 풀과 싱글톤 서블릿의 동시성 문제
- FrontController 패턴과 핸들러 어댑터로 스프링 MVC 구조 이해하기
- @ModelAttribute와 PRG 패턴으로 폼 요청 처리하기
- System.out 대신 SLF4J로 로그 남기기
- 세션 직접 만들어 보기와 HttpSession
- 스프링 인터셉터와 예외 발생 시 오류 페이지 흐름
- 서블릿 Part와 MultipartFile로 파일 업로드 처리하기
- JDBC로 CRUD 구현하기와 커넥션 풀, DataSource
- JDBC 트랜잭션에서 스프링 트랜잭션 동기화와 트랜잭션 AOP까지
- JPA 변경 감지(dirty checking)와 병합(merge)
- 서블릿 컨테이너 초기화와 스프링 컨테이너 등록 과정
- 싱글톤 빈의 동시성 문제와 ThreadLocal
계좌 이체 예제로 트랜잭션 처리 코드를 세 단계로 개선해 본다.
- JDBC로 직접 처리: 커넥션을 파라미터로 전달
- 트랜잭션 매니저와 트랜잭션 동기화
- 트랜잭션 AOP (
@Transactional)
1단계: JDBC로 트랜잭션 직접 처리
JDBC에서 트랜잭션을 시작하려면 자동 커밋(AutoCommit)을 끄고, 비즈니스 로직의 결과에 따라 커밋하거나 롤백한다. 이때 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 가장 단순한 방법은 커넥션을 파라미터로 전달하는 것이다.
Repository
데이터 접근 메서드가 커넥션을 파라미터로 받는다. 같은 커넥션을 계속 써야 하므로 리포지토리에서 커넥션을 닫으면 안 된다.
@Slf4j
public class MemberRepositoryV2 {
// ...
public Member findById(Connection con, String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
// 커넥션은 여기서 닫지 않는다.
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
}
}
public void update(Connection con, String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
PreparedStatement pstmt = null;
try {
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
JdbcUtils.closeStatement(pstmt);
}
}
}
Service
서비스에서 커넥션을 얻어 자동 커밋을 끄고, 비즈니스 로직이 성공하면 커밋, 예외가 발생하면 롤백한다.
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); // 트랜잭션 시작
bizLogic(con, fromId, toId, money);
con.commit(); // 성공 시 커밋
} catch (Exception e) {
con.rollback(); // 실패 시 롤백
throw new IllegalStateException(e);
} finally {
release(con);
}
}
private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
private void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true); // 커넥션 풀 고려
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
}
}
커넥션 풀을 사용하면 con.close()는 연결을 끊는 것이 아니라 풀에 반납하는 것이다. 자동 커밋이 꺼진 상태로 반납하면 다음에 이 커넥션을 받는 쪽에 영향을 주므로, 자동 커밋을 다시 켠 뒤 반납한다.
테스트
받는 회원의 ID가 ex이면 이체 도중 예외가 발생한다. 출금은 이미 수행된 뒤지만 롤백되므로 두 회원의 잔액 모두 10000으로 유지되어야 한다.
@Test
@DisplayName("이체 중 예외 발생")
void accountTransferEx() throws SQLException {
// given
Member memberA = new Member(MEMBER_A, 10000);
Member memberEx = new Member(MEMBER_EX, 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
// when
assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
문제점
- 비즈니스 로직보다 트랜잭션을 처리하는 코드가 더 길다.
- 커넥션을 모든 메서드에 파라미터로 넘겨야 한다.
- 서비스 계층이
DataSource,Connection,SQLException같은 JDBC 기술에 의존한다. DB 접근 기술을 바꾸면 서비스 코드도 수정해야 한다.
2단계: 트랜잭션 매니저와 트랜잭션 동기화
스프링은 트랜잭션 매니저(PlatformTransactionManager)와 트랜잭션 동기화 매니저를 제공한다.

- 서비스가 트랜잭션 매니저를 통해 트랜잭션을 시작하면, 트랜잭션 매니저가 커넥션을 생성한다.
- 생성한 커넥션을 트랜잭션 동기화 매니저에 보관한다.
- 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내 사용한다. 커넥션을 파라미터로 전달할 필요가 없다.
- 트랜잭션이 종료되면 트랜잭션 매니저가 보관된 커넥션으로 커밋 또는 롤백하고, 커넥션을 닫는다.
Repository
트랜잭션 동기화를 사용하려면 커넥션을 얻고 닫을 때 DataSourceUtils를 사용해야 한다.
@Slf4j
public class MemberRepositoryV3 {
// ... 커넥션 파라미터를 제거한 CRUD 메서드
private Connection getConnection() throws SQLException {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
}
DataSourceUtils.getConnection(): 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 그것을 반환하고, 없으면 새 커넥션을 생성해 반환한다.DataSourceUtils.releaseConnection(): 트랜잭션을 위해 동기화된 커넥션이면 닫지 않고 그대로 유지하고, 동기화된 커넥션이 아니면 닫는다.con.close()로 직접 닫아 버리면 트랜잭션 도중에 커넥션이 끊긴다.
Service
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
bizLogic(fromId, toId, money);
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
// 커밋 또는 롤백 시 커넥션은 자동으로 정리된다.
}
}
서비스는 DataSource 대신 PlatformTransactionManager를 주입받는다. 커넥션 전달과 release 코드가 사라졌다.
3단계: 트랜잭션 AOP
트랜잭션 매니저로 코드가 간결해졌지만, 서비스 계층에는 여전히 트랜잭션을 시작하고 커밋·롤백하는 기술 코드가 남아 있다. 서비스는 비즈니스 로직만 처리해야 한다.
스프링 AOP로 프록시를 도입하면 이 문제를 해결할 수 있다.

트랜잭션 프록시가 트랜잭션 처리 로직을 수행한 뒤 실제 서비스를 대신 호출한다. 서비스에는 @Transactional만 붙이면 된다.
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
}
테스트
스프링 AOP를 적용하려면 스프링 컨테이너가 필요하므로 @SpringBootTest를 사용한다. application.properties에 아래 정보가 있으면 스프링 부트가 DataSource와 트랜잭션 매니저를 자동으로 빈으로 등록해 준다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
@Slf4j
@SpringBootTest
class MemberServiceV3_4Test {
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
// ... 테스트 로직은 1단계와 동일
}
전체 흐름

- 클라이언트가 프록시를 호출한다.
- 프록시가 스프링 컨테이너에서 트랜잭션 매니저를 획득하고
getTransaction()으로 트랜잭션을 시작한다. - 트랜잭션 매니저가 데이터소스에서 커넥션을 생성하고
setAutoCommit(false)를 호출한 뒤, 트랜잭션 동기화 매니저에 보관한다. - 프록시가 실제 서비스를 호출한다.
- 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 획득해 사용한다.
이제 서비스 계층에는 비즈니스 로직만 남았다. 다만 메서드 시그니처의 SQLException처럼 JDBC에 종속된 예외는 아직 남아 있어, DB 접근 기술을 JPA 등으로 바꾸면 수정이 필요하다.
참고
- 김영한, 스프링 DB 1편 - 데이터 접근 핵심 원리 (인프런)