[Spring DB] 4.트랜잭션과 문제해결
Updated:
트랜잭션과 문제해결
트랜잭션을 적용했을때 발생하는 문제점에 대해서 알아보고 Spring을 이용하여 해결하는 과정을 살펴본다
배경지식
애플리케이션 구조에 따라 역할이 분담되는데 이 과정에서 트랜잭션을 적용했을때 어떤 문제점이 발생하는지 우선 알아본다
문제점들
- 애플리케이션 구조는 각자 역할에 따라 프레젠테이션 계층, 서비스 계층, 데이터 접근 계층으로 나뉜다
- 프레젠테이션 계층
- 웹 요청과 응답에 관한 처리를 담당
- 서비스 계층
- 실제 비즈니스 로직을 담당
- 되도록이면 특정 기술에 종속적이지 않고 순수 언어 코드로 개발
- 데이터 접근 계층
- DB 접근 및 처리를 담당
- 프레젠테이션 계층
트랜잭션을 적용한 실제 계좌이체 로직을 살펴보며 문제점을 알아보자
@RequiredArgsConstructor
@Slf4j
public class MemberServiceV2 {
private final DataSource dataSource;
private final MemberRepositoryV2 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); //트랜잭션 시작
bizLogic(conn, fromId, toId, money); //비즈니스 로직
conn.commit(); //성공시 커밋
} catch (Exception e) {
conn.rollback(); //실패시 롤백
throw new IllegalStateException(e);
} finally {
release(conn);
}
}
private void bizLogic(Connection conn, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(conn, fromId);
Member toMember = memberRepository.findById(conn, toId);
memberRepository.update(conn, fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(conn, toId, toMember.getMoney() + money);
}
}
- 트랜잭션은 비즈니스 로직이 존재하는 서비스 계층에서 시작해야 한다
- 여기서는 JDBC를 사용하기 때문에
javax.sql.DataSource,java.sql.Connection,java.sql.SQLException등에 의존하지만, JPA로 변경하는 순간 모든 코드를 변경해야 한다 - 의존성 주입 시에 인터페이스가 아닌
MemberRepositoryV2라는 구체 클래스를 주입받는다 - 즉, 데이터 처리 관련 로직과 비즈니스 로직이 섞여 있어 유지보수가 어렵다
정리하자면 크게 3가지 문제점이 존재한다
- 트랜잭션 문제
- JDBC 기술 같은 데이터 접근 계층 로직들이 서비스 계층에 섞여 있다
- 같은 트랜잭션을 유지하기 위해 파라미터로 커넥션을 넘겨야 한다
- 예외 누수 문제
SQLException같은 데이터 접근 계층 예외가 서비스 계층으로 전파된다SQLException예외는 JDBC 전용이므로 JPA로 변경시에 불가피하게 수정해야 한다
- JDBC 반복 문제
try ~ catch ~ finally문의 반복이 너무 많다Connection,PreparedStatement,ResultSet등의 반복이 너무 많다
위 3가지 문제들을 해결할 수 있도록 Spring이 다양한 기능을 제공한다
1. 트랜잭션 추상화
위에서 살펴본 코드는 JDBC에 의존하기 때문에 추후 JPA로 변경 시에 코드 수정이 불가피하다. 이 문제를 Spring을 이용하여 어떻게 해결하는지 살펴보자
1-1. 인터페이스를 이용한 트랜잭션 추상화

- JDBC든 JPA든 트랜잭션을 처리하는 구체 클래스는 Spring이 제공하는
PlatformTransactionManager라는 인터페이스를 상속받아 사용한다 - 이렇게 되면 서비스 계층에서
PlatformTransactionManager라는 인터페이스에만 의존하면 Spring이 알아서 상황에 따라 알맞은 구체 클래스를 주입한다- DI를 이용하여 OCP 원칙이 지켜진다
1-2. PlatformTransactionManager
실제 PlatformTransactionManager는 아래와 같다
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(): 트랜잭션을 롤백한다
2. 트랜잭션 동기화
이전에는 트랜잭션을 파라미터를 통해 넘기는 방식으로 동기화했다. 이번에는 Spring을 이용하여 트랜잭션을 동기화하는 방법에 대해 살펴보자

- Spring은 트랜잭션 동기화 매니저를 사용하여 커넥션을 동기화한다
- 쓰레드 로컬을 사용하여 안전하게 커넥션을 동기화해주므로 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다
3. 실제 트랜잭션 동작과정
3-1. 트랜잭션 시작

- 트랜잭션 매니저가
getTransaction()을 통해 트랜잭션을 시작한다 - 트랜잭션 매니저가 주입받은
DataSource를 이용하여 커넥션을 생성한다 - 커넥션을 수동커밋으로 변경하여 실제 DB 트랜잭션을 시작한다
- 생성된 커넥션을 트랜잭션 동기화 매니저에 보관한다
- 커넥션은 쓰레드 로컬에 안전하게 보관된다
3-2. 비즈니스 로직 수행

- 서비스 계층에서 비즈니스 로직 수행시에 데이터 접근 계층의 로직의 메서드를 호출한다
- 데이터 접근 계층은 트랜잭션 동기화 매니저에 보관된 커넥션을 가져다 사용한다
- 이때 가져온 커넥션은 하나의 트랜잭션 내에서 수행되는 동일한 커넥션이다
- 커넥션을 사용하여 DB에 SQL을 전달한다
3-3. 트랜잭션 종료

- 비즈니스 로직을 마치고 트랜잭션을 종료한다
- 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다
- 받아온 커넥션을 통해 DB에 커밋 or 롤백한다
- 전체 리소스를 정리한다
- 트랜잭션 동기화 매니저를 정리한다
- 자동커밋으로 되돌린다
- 커넥션을 종료하여 커넥션 풀에 반납한다
3-4. 실제 서비스 로직
트랜잭션 매니저를 이용하여 트랜잭션 추상화와 동기화를 수행한 로직은 아래와 같다
@RequiredArgsConstructor
@Slf4j
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 void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
- 위의 코드와 달리
PlatformTransactionManager를 주입받아 트랜잭션을 시작하고 커넥션을 생성한 후 트랜잭션 동기화 매니저에 보관한다 - 데이터 접근 계층 즉, 비즈니스 로직 상의
Repository는 트랜잭션 동기화 매니저에서 동일한 커넥션을 가져와 DB에 SQL을 전달한다 - 비즈니스 로직 수행 후 예외 발생 여부에 따라 커밋 혹은 롤백한다
4. 트랜잭션 템플릿
현재 코드를 살펴보면
try ~ catch ~ finally등의 코드가 각각의 서비스마다 계속 반복되는 구조이다. 이런 불필요한 반복 구조를 템플릿 콜백 패턴을 통해 해결해보자. 지금은 Spring이TransactionTemplate라는 기능을 통해 반복 문제를 해결한다는 정도로 이해하고 있어도 된다
4-1. TransactionTemplate
Spring은 아래의 TransactionTemplate 클래스를 제공하여 반복 문제를 해결하도록 한다
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute(): 응답값이 있을 때 사용executeWithoutResult(): 응답값이 없을 때 사용
4-2. 실제 서비스 로직
@Slf4j
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);
}
});
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
@RequiredArgsConstructor가 아니라 직접 생성자를 통해 의존성 주입을 수행한다txTemplate.executeWithoutResult()를 이용하여 내부의 비즈니스 로직이 정상적으로 작동하면 커밋을, 실패하면 롤백을 수행한다- 람다식에서
SQLException같은 체크 예외를 밖으로 던질 수 없기 때문에 언체크 예외로 바꾸어 던지도록 한다
5. 트랜잭션 AOP
트랜잭션 반복문제를 해결했지만 여전히 비즈니스 로직을 제외한 부가적인 로직들이 서비스 계층에 존재한다는 문제가 남아있다. 이를 해결하기 위해 Spring은 AOP를 통해 프록시를 도입한다. 지금은
@Transactional을 사용하면 Spring이 AOP를 이용하여 트랜잭션을 편리하게 처리해준다 정도로만 알고 있으면 된다
5-1. 프록시

- 프록시를 도입하면 Client가 서비스 계층을 직접 호출하는 것이 아니라 프록시를 거쳐 비즈니스 로직을 수행한다
- 프록시에서 트랜잭션을 시작하고 서비스 계층의 비즈니스 로직 수행 후에 성공 여부에 따라 커밋 혹은 롤백한다
다음은 프록시 코드와 프록시 적용 후의 서비스 코드이다
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);
}
}
}
public class Service {
public void logic() {
bizLogic(fromId, toId, money); //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
}
}
- 코드를 살펴보면 비즈니스 로직을 제외한 모든 부가적인 로직들은 프록시에서 동작함을 알 수 있다
5-2. Spring이 제공하는 트랜잭션 AOP
- Spring이 제공하는 AOP 기능을 이용하면 프록시를 편리하게 사용가능하다
- Spring은 트랜잭션 AOP를 처리하기 위한 다양한 기능을 제공하는데 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional만 붙여주면 된다
5-3. 실제 서비스 및 테스트 로직
우선 @Transactional을 적용한 서비스 코드를 살펴보자
@RequiredArgsConstructor
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
- 비즈니스 로직을 제외한 나머지 코드들이 깔끔히 사라진 것을 확인할 수 있다
@Transactional을 이용하면 프록시를 생성하여 트랜잭션을 비롯한 부가적인 로직을 모두 처리하고 프록시 내부에서 비즈니스 로직을 호출한다
이번에는 테스트 코드를 살펴보자
@SpringBootTest //Spring Boot 환경에서 테스트 실행
@Slf4j
class MemberServiceV3_3Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired //의존성 주입
private MemberRepositoryV3 memberRepository;
@Autowired //의존성 주입
private 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());
}
}
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@Test
@DisplayName("정상 이체")
void accountTransfer() throws SQLException {
//given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
//when
log.info("start TX");
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
log.info("end TX");
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@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
assertThrows(IllegalStateException.class, () -> {
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000);
}, "이체중 예외 발생");
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
- 대부분의 코드는 이전과 같고
@SpringBootTest,@Autowired,@TestConfiguration이 변경된 부분이다 - 서비스 계층의 비즈니스 로직에서 Spring AOP를 이용한
@Transactional을 사용하기 때문에@SpringBootTest로 테스트해야 한다 @Autowired로 필요한 의존성을 주입하고@TestConfiguration을 통해 테스트에 추가로 필요한 빈을 등록한다@Autowired로 명시한MemberRepositoryV3와MemberServiceV3_3는@Component를 붙여주지 않았기 때문에@TestConfiguration내부에서 직접 빈으로 등록한다- 그외 필요한
DataSource와PlatformTransactionManager를 추가로 빈으로 등록한다PlatformTransactionManager가 왜 필요한지 의문점이 들 수 있는데, 프록시를 이용한다고 해도 결국 트랜잭션을 시작하기 위해서는 트랜잭션 매니저가 필요하다
- 참고로 위에서 주입받는
MemberServiceV3_3는 실제 서비스 클래스가 아닌 프록시 객체이다
5-4. 트랜잭션 AOP 전체 흐름

- Client가 비즈니스 로직을 수행하기 위해 프록시를 호출한다
- 프록시는 스프링 컨테이너에 등록된 트랜잭션 매니저를 가져온다
- 트랜잭션 매니저를 이용하여 트랜잭션을 시작한다
- 트랜잭션 매니저는 주입받은
DataSource를 통해 커넥션을 생성한다 - 수동커밋으로 설정하여 트랜잭션을 시작한다
- 위에서 생성한 커넥션은 트랜잭션 동기화 매니저에 보관한다
- 커넥션은 쓰레드 로컬에 안전하게 보관된다
- 프록시에서 서비스 계층의 실제 비즈니스 로직을 호출한다
- 비즈니스 로직에서 데이터 접근 계층의 메서드를 호출하고, 데이터 접근 계층에서는 트랜잭션 동기화 매니저에 접근하여 커넥션을 획득한다
- 해당 커넥션을 통해 DB에 SQL을 전달한다
6. Spring Boot의 자동 리소스 등록
지금까지
DataSource와TransactionManager를 직접 등록했다. 여기서는application.preperties설정파일을 통해 자동으로 등록하는 방법에 대해 알아본다
Spring Boot의 자동 리소스 등록 기능을 이용하기 위해서는 application.preperties에 아래와 같이 적어주면 된다
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
6-1. DataSource 자동등록
- Spring Boot는
DataSource를 스프링 컨테이너에 자동으로 등록한다dataSource의 이름으로 빈을 등록한다
- Spring Boot가 기본으로 등록하는
DataSource는HikariDataSource이다
6-2. 트랜잭션 매니저 자동등록
- Spring Boot는 적절한 트랜잭션 매니저를 스프링 컨테이너에 자동으로 등록한다
transactionManager의 이름으로 빈을 등록한다
- 어떤 트랜잭션 매니저를 등록할지는 등록된 라이브러리를 확인하고 판단한다
- JDBC 이용시에는
DataSourceTransactionManager를, JPA 이용시에는JpaTransactionManager를 빈으로 등록한다
- JDBC 이용시에는
6-3. 실제 테스트 로직
application.properties를 위와 같이 등록하고 테스트 코드 중 아래 부분만 수정하면 된다
@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());
}
}
- Spring Boot가 생성자 주입을 통해
DataSource를 스프링 컨테이너에 자동으로 등록한다 - 트랜잭션 매니저 역시 Spring Boot가 스프링 컨테이너에 자동으로 등록한다
Leave a comment