스프링과 문제 해결 - 트랜잭션
이전 챕터에서 트랜잭션이 필요한 이유를 알아봤고, 트랜잭션 내에서 비즈니스 로직을 처리하는 코드를 작성했다.
문제점들
- 서비스 계층은 최대한 특정 기술에 의존하지 않고, 순수 자바 코드로 비즈니스 로직을 작성하는게 좋다.
- 구현 기술들이 바뀌더라도, 비즈니스 로직은 최대한 변경 없이 유지되어야 함.
- 계층을 나눔으로써 DB쪽 기술 Data Access Layer, UI쪽 Presentation Layer, 서비스 계층은 순수하게 유지 가능
- 기존 서비스 계층의 MemberService 코드는 여러가지 문제점이 있다.
- JDBC 구현 기술이 서비스 계층에 누수되는 문제
- V1(트랜잭션 없는 버전):
SQLException
이라는 JDBC 기술에 의존한다는 점 - V2(트랜잭션 있는 버전): JDBC 기술에 의존하고 있다. JDBC를 통한 트랜잭션 처리 코드가 더 많음.
private final DataSource dataSource;
Connection con = dataSource.getConnection();
con.setAutoCommit(false);
,con.commit();
con.rollback();
,SQLException
- V1(트랜잭션 없는 버전):
- 같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겨야 한다. 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야 한다.
- 트랜잭션 적용하는 코드가 반복된다. (try-catch, finally, …) + 리포지토리의 PreparedStatement, 결과 매핑, 정리, …
- 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파되고, SQLException은 체크 예외라 일단 잡아서 던지든 처리하든 해야 한다. JDBC 기술을 다른 기술로 변경한다면, 서비스 코드도 영향을 받게 된다.
- JDBC 구현 기술이 서비스 계층에 누수되는 문제
스프링은 서비스 계층을 순수하게 유지하기 위한 여러가지 방법과 기술을 제공한다.
트랜잭션 추상화
DB 접근 기술에 따라 달랐던 트랜잭션을 사용방법을 추상화해보자.
ex) 트랜잭션 추상화 인터페이스
public interface TxManager {
begin();
commit();
rollback();
}
JdbcTxManager
: JDBC 트랜잭션 기능을 제공하는 구현체JpaTxManager
: JPA 트랜잭션 기능을 제공하는 구현체
JDBC 트랜잭션 기능이 필요하면 JdbcTxManager
를 서비스에 주입하고, JPA 트랜잭션 기능으로 변경해야 하면 JpaTxManager
를 주입하면 된다.
스프링의 트랜잭션 추상화
스프링 5.3부터는 JDBC 트랜잭션을 관리할 때 DataSourceTransactionManager
를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager
를 제공한다고 한다.
PlatformTransactionManager 인터페이스
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
getTransaction()
: 트랜잭션을 시작한다.- 이미 진행중인 트랜잭션이 있는 경우에는 해당 트랜잭션에 참여하고, 그렇지 않으면 트랜잭션을 시작한다.
commit()
: 트랜잭션을 커밋한다.rollback()
: 트랜잭션을 롤백한다.
트랜잭션 동기화
스프링은 리소스(트랜잭션) 동기화 문제도 해결하였다.
트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다.
기존 코드에서는 직접 메서드 파라미터에 커넥션을 전달하였기 때문에, 동일한 기능의 메서드도 여러 개 생기고, 코드도 지저분했다.
트랜잭션 매니저와 트랜잭션 동기화 매니저
- 스프링은 트랜잭션 동기화 매니저를 제공한다. 내부에서 쓰레드 로컬(
ThreadLocal
)을 사용해서 커넥션을 동기화해준다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.org.springframework.transaction.support.TransactionSynchronizationManager
- 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화할 수 있다. 따라서 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다. 이제는 파라미터로 커넥션을 전달하지 않아도 된다.
- 트랜잭션을 시작할 때? TransactionManager가 커넥션을 만들고, 동기화 매니저에 보관하면서 트랜잭션을 시작한다.
- 리포지토리에서는 트랜잭션 동기화 매니저를 체크한다.
- 보관된 커넥션이 있다? 트랜잭션 동기화구나~ 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용
- 커넥션이 없다? 동기화는 필요 X 리포지토리에서 직접 DataSource를 통해 커넥션 만들고 사용/종료한다.
- 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션도 닫는다.
트랜잭션 문제 해결 - 트랜잭션 매니저1
Repository: 커넥션 획득 방법 변경
private Connection getConnection() throws SQLException {
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
- 기존에는 dataSource에서 직접 커넥션을 얻어왔지만, 이제는 트랜잭션 동기화를 사용하기 위해 DataSourceUtils를 사용!
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
- 메서드 파라미터를 통해 커넥션을 직접 전달받을 필요가 없어졌다!
Repository: 커넥션 종료 방법 변경
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
- 이제는 트랜잭션 동기화를 위해 DataSourceUtils로 커넥션을 닫아줘야 한다. 커넥션을
con.close()
를 사용해서 직접 닫아버리면 커넥션이 유지되지 않는 문제가 발생하기 때문이다.- 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 트랜잭션 종료까지 살게 된다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
서비스 계층: PlatformTranscationManager 도입
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);
}
}
// 생략
private final PlatformTransactionManager transactionManager
: 트랜잭션 매니저 주입. 여기서는 JDBC.transactionManager.getTransaction()
: 트랜잭션을 시작한다.- 반환값인
TransactionStatus
객체는 현재 트랜잭션 정보를 포함하고 있다.
- 반환값인
new DefaultTransactionDefinition()
- 트랜잭션과 관련된 옵션을 지정할 수 있다.
transactionManager.commit(status)
- 트랜잭션이 성공하면 이 로직을 호출해서 커밋하면 된다.
transactionManager.rollback(status)
- 문제가 발생하면 이 로직을 호출해서 트랜잭션을 롤백하면 된다.
테스트 코드를 작성할때는 아래와 같이 만들어서 테스트하면 된다.
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
동작 흐름 정리
- 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않는다.
- 이후 JDBC에서 JPA로 변경해도 서비스 코드를 그대로 유지할 수 있다.
- 기술 변경 시 의존관계 주입만
DataSourceTransactionManager
에서JpaTransactionManager
로 변경해주면 된다. java.sql.SQLException
이 아직 남아있지만 이 부분은 뒤에 예외 문제에서 해결하자.
- 트랜잭션 동기화 매니저 덕분에 커넥션을 파라미터로 넘기지 않아도 된다.
트랜잭션 문제 해결 - 트랜잭션 템플릿
현재 트랜잭션 사용 로직을 살펴보면, 같은 패턴이 반복된다.
- 트랜잭션을 시작하고, 비즈니스 로직을 실행하고, 성공하면 커밋하고, 예외가 발생해서 실패하면 롤백한다.
- 다른 서비스에서 트랜잭션을 시작하려면
try
,catch
,finally
를 포함한 성공 시 커밋, 실패 시 롤백 코드가 반복될 것이다.
이런 형태는 각각의 서비스에서 반복된다. 달라지는 부분은 비즈니스 로직뿐이다. 이럴 때 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 깔끔하게 해결할 수 있다.
트랜잭션 템플릿
템플릿 콜백 패턴을 적용하려면 템플릿을 제공하는 클래스를 작성해야 하는데, 스프링은 TransactionTemplate
이라는 템플릿 클래스를 제공한다.
TransactionTemplate
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute()
: 응답 값이 있을 때 사용한다.executeWithoutResult()
: 응답 값이 없을 때 사용한다.
서비스 계층: TransactionTemplate 사용
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate; // 트랜잭션 매니저 -> 트랜잭션 템플릿
private final MemberRepositoryV3 memberRepository;
// 트랜잭션 매니저를 전달받아 트랜잭션 템플릿 생성
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
// 생략
TransactionTemplate
을 사용하려면transactionManager
가 필요하다. 생성자에서transactionManager
를 주입 받으면서TransactionTemplate
을 생성했다.- 트랜잭션 템플릿 덕분에 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 모두 제거되었다.
- 트랜잭션 템플릿 내부에서 비즈니스 로직이 정상 수행되면 커밋하고, 언체크 예외가 발생하면 롤백한다. 체크 예외는 커밋한다.
bizLogic()
메서드를 호출하면SQLException
체크 예외가 올라와서 밖으로 던질 수 없기에, 언체크 예외로 바꿔 던졌다.
참고로, TransactionTemplate 자체를 빈으로 바로 주입받아도 되는데, TransactionTemplate는 인터페이스나 추상 클래스도 아니기도 하고, 직접 TransactionTemplate을 생성하면 PlatformTransactionManager를 골라서 주입해줄 수 있어서 보통 이렇게 사용하는게 관례였다고 한다.
정리하자면, 트랜잭션 템플릿을 통해 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있었다.
- 하지만, 서비스 계층에서 트랜잭션 처리 기술 로직이 포함되는 건 여전하다.
- 아직도 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있다. 어떻게 해결할까?
트랜잭션 문제 해결 - 트랜잭션 AOP 이해
서비스 계층에 순수한 비즈니스 로직만 남긴다는 목표는 아직 달성하지 못했다. 스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.
프록시 도입 전
프록시 도입 후
프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
이렇게 작성해두면 프록시 기술을 통해 트랜잭션을 사용하는 프록시 객체를 만들어준다.
public class TransactionProxy {
private MemberService target;
public void logic() {
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져가므로, 서비스 계층에서는 순수 비즈니스 로직만 남길 수 있다.
트랜잭션 문제 해결 - 트랜잭션 AOP 적용
스프링이 제공하는 AOP 기능(@Aspect
, @Advice
, @Pointcut
)을 사용하면 트랜잭션 처리 AOP를 만들어 사용할 수 있지만, 트랜잭션이라는 건 매우 자주 사용되는 기능이므로 스프링이 해당 기능을 만들어서 제공한다.
개발자는 트랜잭션 처리가 필요한 곳에 @Transactional
애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
org.springframework.transaction.annotation.Transactional
서비스 계층: 트랜잭션 AOP 적용
@RequiredArgsConstructor
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
txTemplate을 제거하고 @Transactional
를 추가함으로써, 순수 비즈니스 로직만 남게되었다.
테스트 코드 주의사항: AOP가 적용된 코드를 적용하기 위해서는 스프링 통합 테스트를 사용하자.
@SpringBootTest
class MemberServiceV3_3Test {
@Autowired
MemberRepositoryV3 memberRepository;
@Autowired
MemberServiceV3_3 memberService;
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource());
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
// 테스트 메서드 생략
- 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다
@SpringBootTest
로 스프링 컨테이너 생성 및 빈을 등록한다. @TestConfiguration
: 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 에노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.DataSource
스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.DataSourceTransactionManager
트랜잭션 매니저를 스프링 빈으로 등록한다.- 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
트랜잭션 문제 해결 - 트랜잭션 AOP 정리
선언적 트랜잭션 관리 (Declarative Transaction Management)
@Transactional
애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
프로그래밍 방식의 트랜잭션 관리 (programmatic transaction management)
- 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
요즘은 스프링 부트를 통해 스프링 컨테이너와 AOP를 사용하므로, 선언적 방식을 대부분 사용한다. 테스트 코드 작성할 때에는 프로그래밍 방식도 가끔 사용한다고 한다.
스프링 부트의 자동 리소스 등록
스프링 부트는 트랜잭션 매니저와 데이터소스를 자동으로 등록해준다.
스프링부트 없이 스프링으로 개발한다면, Config 코드 또는 XML을 통해 DataSource와 TrnasactionManager를 등록해야 한다.
하지만 스프링 부트는 application.properties
에 있는 속성을 사용해서 DataSource
를 생성하고 스프링 빈에 등록한다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
- 스프링 부트가 기본으로 생성하는 데이터소스는 커넥션 풀을 제공하는
HikariDataSource
이다. 커넥션 풀과 관련된 설정도application.properties
를 통해서 지정할 수 있다. spring.datasource.url
속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.
또한, 스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager
)를 자동으로 스프링 빈에 등록한다.
(자동으로 등록되는 스프링 빈 이름: transactionManager
)
어떤 트랜잭션 매니저를 선택할지는 현재 등록된 라이브러리를 보고 판단하는데, JDBC 기술을 사용하면 JDBC용, JPA를 사용하면 JPA용 트랜잭션 매니저를 등록한다. 둘 다 사용하는 경우JPA꺼(JpaTransactionManager
)를 등록한다. 참고로 JpaTransactionManager
는 JDBC용 트랜잭션 매니저가 제공하는 기능도 대부분 지원한다.
자동 리소스 등록을 사용해서 테스트 코드를 바꾼다면 아래와 같이 깔끔해진다.
@SpringBootTest
class MemberServiceV3_4Test {
@TestConfiguration
static class TestConfig {
// 스프링 부트가 만들어 준 데이터소스 빈을 주입 받는다.
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
// https://application.properties
this.dataSource = dataSource;
}
@Bean
MemberRepositoryV3 memberRepositoryV3() {
return new MemberRepositoryV3(dataSource);
}
@Bean
MemberServiceV3_3 memberServiceV3_3() {
return new MemberServiceV3_3(memberRepositoryV3());
}
}
'Spring > Transaction' 카테고리의 다른 글
[Spring Transaction] 스프링과 문제 해결 - 예외 처리 (0) | 2025.07.06 |
---|---|
[Spring Transaction] 트랜잭션 이해와 적용 (0) | 2025.03.25 |