스프링 DB 1편 - 데이터 접근 핵심 원리 (11) - 스프링과 문제 해결 - 트랜잭션 2
인프런 스프링 DB 1편 - 데이터 접근 핵심 원리편을 학습하고 정리한 내용 입니다.
트랜잭션 문제 해결 - 트랜잭션 매니저 1
애플리케이션 코드에 트랜잭션 매니저를 적용해보자.
MemberRepositoryV3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/**
* 트랜잭션 - 트랜잭션 매니저
* DataSourceUtils.getConnection()
* DataSourceUtils.releaseConnection() *
*/
@Slf4j
public class MemberRepositoryV3 {
private final DataSource dataSource;
public MemberRepositoryV3(final DataSource dataSource) {
this.dataSource = dataSource;
}
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values (?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
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 (Exception e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
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 {
close(con, pstmt, null);
}
}
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (Exception e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() throws SQLException {
//주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection= {}", con);
return con;
}
}
크게 바뀐건 커넥션을 얻는 부분과, 커넥션을 닫는 부분에서 DataSourceUtils를 사용한다는 점이다.
- 커넥션을 파라미터로 전달하는 부분이 모두 제거되었다. (findById, update)
DataSourceUtils.getConnection()
getConnection()에서DataSourceUtils.getConnection()를 사용하도록 변경된 부분을 특히 주의야 한다.DataSourceUtils.getConnection()는 다음과 같이 동작한다.- 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
DataSourceUtils.releaseConnection()
close()에서DataSourceUtils.releaseConnection()를 사용하도록 변경된 부분을 특히 주의해야 한다. 커넥션을con.close()를 사용해서 직접 닫아버리면 커넥션이 유지되지 않는 문제가 발생한다. 이 커넥션은 이후 로직은 물론이고, 트랜잭션을 종료(커밋, 롤백)할 때 까지 살아있어야 한다.DataSourceUtils.releaseConnection()을 사용하면 커넥션을 바로 닫는 것이 아니다.- 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
- 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
이제 트랜잭션 매니저를 사용하는 서비스 코드를 작성해보자.
MemberServiceV3_1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 트랜잭션 - 트랜잭션 매니저
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
//private final DataSource dataSource; // SQL에만 의존
private final PlatformTransactionManager transactionManager; // SQL, JPA, NoSQL 등등 다 받아줌
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); // 성공시 commit
} 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 void validation(Member toMember) {
if (toMember.getMemberId().equals("ex"))
throw new IllegalStateException("이체중 예외 발생");
}
}
private final PlatformTransactionManager transactionManager- 트랜잭션 매니저를 주입 받는다. 지금은 JDBC 기술을 사용하기 때문에
DataSourceTransactionManager구현체를 주입 받아야 한다. - 물론 JPA 같은 기술로 변경되면
JpaTransactionManager를 주입 받으면 된다.
- 트랜잭션 매니저를 주입 받는다. 지금은 JDBC 기술을 사용하기 때문에
transactionManager.getTransaction()- 트랜잭션을 시작한다.
TransactionStatus status를 반환한다. 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후 트랜잭션을 커밋 롤백할 때 필요하다.
new DefaultTransactionDefinition()- 트랜잭션과 관련된 옵션을 지정할 수 있다
transactionManager.commit(status)- 트랜잭션이 성공하면 이 로직을 호출해서 커밋하면 된다.
transactionManager.rollback(status)- 문제가 발생하면 이 로직을 호출해서 트랜잭션을 롤백하면 된다.
이제 이 코드를 테스트 해보자.
MemberServiceV3_1Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 트랜잭션 - 트랜잭션 매니저
*/
class MemberServiceV3_1Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_1 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}
@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
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
//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); // 트랜잭션 처리했기때문에 이제 10000원 정상
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
1
2
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_1(transactionManager, memberRepository);
이 부분이 중요하다. JDBC를 쓰기때문에 DataSourceTransactionManager을 사용해서 MemberServiceV3_1에 의존성을 주입해줬다.
트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource가 필요하다.

테스트 해보면 모든 결과가 정상 동작하는 것을 확인할 수 있다. 당연히 롤백 기능도 잘 동작한다.
트랜잭션 문제 해결 - 트랜잭션 매니저 2
그림으로 트랜잭션 매니저의 전체 동작 흐름을 자세히 이해해보자.

클라이언트의 요청으로 서비스 로직을 실행한다.
- 서비스 계층에서
transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다. - 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
- 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
- 커넥션을 트랜잭션 동기화 매니저에 보관한다.
- 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관 할 수 있다.

- 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지 않는다.
- 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는
DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다. - 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

- 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
- 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
- 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
- 전체 리소스를 정리한다
- 트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용후 꼭 정리해야 한다.
con.setAutoCommit(true)로 되돌린다. 커넥션 풀을 고려해야 한다.con.close()를 호출해셔 커넥션을 종료한다. 커넥션 풀을 사용하는 경우con.close()를 호출하면 커넥션 풀에 반환된다.
정리
- 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않는다.
- 이후 JDBC에서 JPA로 변경해도 서비스 코드를 그대로 유지할 수 있다.
- 기술 변경시 의존관계 주입만
DataSourceTransactionManager에서JpaTransactionManager로 변경해주면 된다. java.sql.SQLException이 아직 남아있지만 이 부분은 뒤에 예외 문제에서 해결하자.
- 트랜잭션 동기화 매니저 덕분에 커넥션을 파라미터로 넘기지 않아도 된다.
트랜잭션 문제 해결 - 트랜잭션 템플릿
트랜잭션을 사용하는 로직을 살펴보면 다음과 같은 패턴이 반복되는 것을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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); // 성공시 commit
} catch (Exception e) {
transactionManager.rollback(status);// 실패시 롤백
throw new IllegalStateException(e);
}
}
- 트랜잭션을 시작하고, 비즈니스 로직을 실행하고, 성공하면 커밋하고, 예외가 발생해서 실패하면 롤백한다.
- 다른 서비스에서 트랜잭션을 시작하려면
try , catch , finally를 포함한 성공시 커밋, 실패시 롤백 코드가 반복될 것이다. - 이런 형태는 각각의 서비스에서 반복된다. 달라지는 부분은 비즈니스 로직 뿐이다.
- 이럴 때 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 깔끔하게 해결할 수 있다.
트랜잭션 템플릿
템플릿 콜백 패턴을 적용하려면 템플릿을 제공하는 클래스를 작성해야 하는데, 스프링은 TransactionTemplate라는 템플릿 클래스를 제공한다.
TransactionTemplate
1
2
3
4
5
6
7
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
public <T> T execute(TransactionCallback<T> action){..}
void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
execute(): 응답 값이 있을 때 사용한다.executeWithoutResult(): 응답 값이 없을 때 사용한다.
트랜잭션 템플릿을 사용해서 반복하는 부분을 제거해보자.
MemberServiceV3_2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 트랜잭션 - 트랜잭션 템플릿
*/
@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);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex"))
throw new IllegalStateException("이체중 예외 발생");
}
}
TransactionTemplate을 사용하려면transactionManager가 필요하다. 생성자에서transactionManager를 주입 받으면서TransactionTemplate을 생성했다.

- 트랜잭션 템플릿 덕분에 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 모두 제거되었다.
- 트랜잭션 템플릿의 기본 동작은 다음과 같다.
- 비즈니스 로직이 정상 수행되면 커밋한다.
- 언체크 예외가 발생하면 롤백한다. 그 외의 경우 커밋한다. (체크 예외의 경우에는 커밋하는데, 이 부분은 뒤에서 설명한다.)
- 코드에서 예외를 처리하기 위해
try~catch가 들어갔는데,bizLogic()메서드를 호출하면SQLException체크 예외를 넘겨준다. 해당 람다에서 체크 예외를 밖으로 던질 수 없기 때문에 언체크 예외로 바꾸어 던지도록 예외를 전환했다.
MemberServiceV3_2Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 트랜잭션 - 트랜잭션 템플릿
*/
class MemberServiceV3_2Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberRepositoryV3 memberRepository;
private MemberServiceV3_2 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV3(dataSource);
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
memberService = new MemberServiceV3_2(transactionManager, memberRepository);
}
@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
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
//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); // 트랜잭션 처리했기때문에 이제 10000원 정상
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
MemberServiceV3_1 -> MemberServiceV3_2로 바꾸기만 했다. 생성자에서는 transactionManager가 필요해서 크게 달라지는 것은 없다.

잘 통과 된다.
정리
- 트랜잭션 템플릿 덕분에, 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있었다.
- 하지만 이곳은 서비스 로직인데 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있다.
- 애플리케이션을 구성하는 로직을 핵심 기능과 부가 기능으로 구분하자면 서비스 입장에서 비즈니스 로직은 핵심 기능이고, 트랜잭션은 부가 기능이다.
- 이렇게 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 한 곳에 있으면 두 관심사를 하나의 클래스에서 처리하게 된다. 결과적으로 코드를 유지보수하기 어려워진다.
- 서비스 로직은 가급적 핵심 비즈니스 로직만 있어야 한다. 하지만 트랜잭션 기술을 사용하려면 어쩔 수 없이 트랜잭션 코드가 나와야 한다. 어떻게 하면 이 문제를 해결할 수 있을까?
댓글남기기