
여러가지 애플리케이션 구조가 있지만, 가장 단순하고 많이 사용하는 방법은 역할에 따라 3계층 으로 나누는 것.
→ 서비스 계층 : 핵심 비즈니스 로직
비즈니스 로직이 최대한 변경 없이 유지되는 것이 목표이다!
이렇게 하려면 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다.
MemberService2 예시
@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 static void release(Connection con) {
if (con != null) {
try {
con.setAutoCommit(true);
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
}
}
현재 서비스 계층은 트랜잭션을 사용하기 위해 JDBC 기술에 의존하고 있다.
구현 기술에 따른 트랜잭션 사용법
JDBC 트랜잭션 의존 상황

JDBC → JPA 기술로 변경 상황

트랜잭션 추상화
이를 해결하기 위해 트랜잭션 기능을 추상화 하면 된다.
스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다.
심지어 데이터 접근 기술에 따른 트랜잭션 구현체도 만들어두어서 가져다 사용하기만 하면 된다.

스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스이다.
org.springframework.transaction.PlatformTransactionManagerPlatfromTransactionManager interface
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;
}
트랜잭션 매니저와 트랜잭션 동기화 매니저

MemberRepositoryV3
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* 트랜잭션 - 트랜잭션 매니저
* DataSourceUtils.getConnection()
* DataSourceUtils.releaseConnection()
*/
@Slf4j
public class MemberRepositoryV3 {
private final DataSource dataSource;
public MemberRepositoryV3(DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?,?)";
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, member.getMemberId());
preparedStatement.setInt(2, member.getMoney());
preparedStatement.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(connection, preparedStatement, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection connection = null;
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, memberId);
resultSet = preparedStatement.executeQuery();
if (resultSet.next()) {
Member member = new Member();
member.setMemberId(resultSet.getString("member_id"));
member.setMoney(resultSet.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId = " + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(connection, preparedStatement, resultSet);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, money);
preparedStatement.setString(2, memberId);
int resultSize = preparedStatement.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(connection, preparedStatement, null);
}
}
public void delete(String memberId) {
String sql = "delete from member where member_id = ?";
Connection connection = null;
PreparedStatement preparedStatement = null;
try{
connection = getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, memberId);
preparedStatement.executeUpdate();
log.info("delete memberId={}", memberId);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private void close(Connection connection, Statement statement, ResultSet resultSet) {
JdbcUtils.closeResultSet(resultSet);
JdbcUtils.closeStatement(statement);
DataSourceUtils.releaseConnection(connection, dataSource);
}
private Connection getConnection() throws SQLException {
//주의! 트랜잭션 동기화를 사용하려면, DataSourceUtils 를 사용해야 한다.
// 트랜잭션 동기화 매니저를 사용하는 DataSourceUtils,,,
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
//getConnection <-> releaseConnection 으로 열고 닫는다.
}
MemberServiceV3_1
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@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);
}
}
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 static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
}
}
private final PlatformTransactionManager transactionManager;DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberRepository = new MemberRepositoryV3(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository);transactionManager.getTransaction()
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
트랜잭션을 시작한다
TransactionStatus 를 반환한다. 현재 트랜잭션 상태 정보가 포함되어 있고,
이후 트랜잭션 커밋, 롤백 시 필요하다.
PlatformTransactionManager - getTransaction method
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
new DefaultTransactionDefinition()transactionManager.commit(status)transactionManager.rollback(status)MemberServiceV3_1Test
package hello.jdbc.service;
import com.zaxxer.hikari.HikariDataSource;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@Slf4j
class MemberServiceV3_1Test {
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@BeforeEach
void before() {
// DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
//커넥션 풀링
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
memberRepository = new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}
@AfterEach
void after() {
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
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);
}
}
트랜잭션 사용 코드에서 비즈니스로직을 감싸고 있는 try-catch 문이 각각의 서비스에 반복될 것 이다.
트랜잭션을 시작하고, 실행 이후 커밋 또는 롤백하는 코드는 재사용 가능해야 하고, 반복적으로 사용될 것 이다.
→ Template Call Back Pattern 을 사용하면 반복 문제를 깔끔하게 해결할 수 있다!
TransactionTemplate
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute() : 응답 값이 있을 때 사용 → commit
executeWithoutResult() : 응답 값이 없을 때 사용한다.
트랜잭션 템플릿 기본 동작
SQLException 은 체크 예외인데, 이 상황에서도 롤백하기 위해 catch 에서 언체크 예외 IllegalStateException 으로 바꿔 던지도록 한다.MemberServiceV3_2
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
/**
* 트랜잭션 - 트랜잭션 탬플릿
*/
@Slf4j
public class MemberServiceV3_2 {
// private final PlatformTransactionManager transactionManager;
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) {
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);
}
private static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
}
}
→ “@Transactional” 을 사용하면 스프링이 AOP 를 사용해서 트랜잭션을 편리하게 처리해준다 정도로 이해해도 괜찮다!
프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
프록시 도입 이후

@Transactional” 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.트랜잭션 AOP를 사용하는 새로운 서비스 클래스를 만든다.
MemberServiceV3_3
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
/**
* 트랜잭션 - @Transaction AOP
*/
@Slf4j
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional //성공하면 Commit, 실패하면 Rollback -> 메서드에 붙여도 되고, 클래스에 붙여도 된다.
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 static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체 중 예외 발생");
}
}
}
MemberServiceV3_3Test
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {
@Autowired
private MemberRepositoryV3 memberRepository;
@Autowired
private MemberServiceV3_3 memberService;
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@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() {
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
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);
}
}
@Autowired 를 통해 스프링 컨테이너가 관리하는 빈들을 사용할 수 있다.@TestConfiguration : 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 애노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.트랜잭션 AOP 적용한 전체 흐름

데이터 소스와 트랜잭션 매니저를 스프링 빈으로 직접 등록하는 과정
@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());
}
}
→ 이를 자동 등록해보자!
데이터 소스 - 자동 등록
dataSource이때 스프링 부트는 다음과 같이 application.properties 에 있는 속성을 사용해서 DataSource 를 생성한다.
application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
트랜잭션 매니저 - 자동 등록
MemberServiceV3_4Test
@Bean
DataSource dataSource() {
return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Bean
PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
→ 해당 부분이 빠졌다.