트랜잭션 자바 코드 적용
기존 jdbc 학습 프로젝트에 코드를 추가해 계좌이체 비즈니스 로직을 구현해본다.
우선 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직을 구현해본다.
트랜잭션 X
MemberService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequiredArgsConstructor
public class MemberServiceV1 {
private final MemberRepositoryV1 memberRepository;
public void accountTransfer(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("이체 중 예외 발생");
}
}
}
- fromId의 회원을 조회해 toId 회원에게 money만큼의 돈일 계좌이체하는 로직이다.
- 돈의 증감은 memberRepository.update()를 이용해 update SQL을 실행한다.
- 예외 상황을 보기 위해 toId가 ex인 상황을 예외로 가정한다.
정상 계좌이체 test 코드
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
class MemberServiceV1Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
private MemberRepositoryV1 memberRepository;
private MemberServiceV1 memberService;
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV1(dataSource);
memberService = new MemberServiceV1(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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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(IllegalArgumentException.class);
//then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberEx.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
}
- 작성했던 accountTransfer 메서드를 보면 validation()이 두 update() 중간에 있다. 따라서 첫 update 쿼리는 정상 수행되고, 두 번째 update 쿼리에서 오류가 발생하는 것이다.
- 결과는 A의 잔고만 감소하고 B의 잔고는 증가하지 않은 그대로여야 한다.
이러한 코드는 결국 A에서 잔고가 빠져나갔는데 B에 제대로 돈이 가지 않은 것이기 때문에 실제 상황이라면 아주 큰 문제이다. 이를 트랜잭션을 이용해 해결할 수 있는 것이다.
테스트 데이터의 제거
위 테스트 코드에서는 @AfterEach를 이용해 하나하나 delete 해주는 방식으로 테스트 데이터를 제거해주었다.
테스트에서 사용한 데이터는 제거해야 다음 테스트에 영향을 주지 않는다. 그런데 위와 같은 방법은 불편하다.
더 나은 방법은 트랜잭션을 활용하면 된다. 테스트 전에 트랜잭션을 시작하고, 테스트 이후 트랜잭션을 롤백하면 된다. 방법은 아래에서 설명한다.
트랜잭션 O
애플리케이션에서 트랜잭션을 적용하려면 어떤 계층에 걸어야 할까?
- 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
- 위의 코드를 예시로 들자면, 서비스 계층의 accountTransfer()에서 두 쿼리를 실행하는 update()가 실행된다.
- 여기서 하나의 update가 잘못되면 두 update 모두 롤백해야 한다. (잔고의 차감 및 증가)
- 만약 update가 있는 Repository 계층에서 트랜잭션을 적용한다면 잔고의 증가에서 문제가 생기면 해당 부분만 롤백하게 되고, 차감은 그대로이기 때문이다.
- 즉, accountTransfer()의 시작에서 트랜잭션을 시작하고, 마지막 부분에 커밋을 할 지, 롤백을 할 지의 로직이 들어있는 코드를 작성해야 한다.
- 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용할 수 있다.
트랜잭션을 사용하는 동안 같은 커넥션 유지
만약 accountTransfer()의 update()가 각각 다른 세션에서 동작한다고 가정하면, 우리가 원하는대로 update가 이루어지지 않을 것이다. (세션1에서 update 후 커밋하고, 세션 2에서 update 후 문제 생겨 롤백하게 되는 것. => 잔고 오류는 그대로.)
같은 세션을 유지하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다.
- 애플리케이션에서 같은 커넥션을 유지하려면 가장 간단한 방법으로는 커넥션을 파라미터로 전달해 같은 커넥션이 사용되도록 유지하면 된다.
코드 적용
MemberRepository
리포지토리가 파라미터를 통해 같은 커넥션을 유지할 수 있도록 수정한다.
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
public Member findById(Connection con, 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 (SQLException e) {
log.error("db error", e);
throw e;
} finally {
//close(con, pstmt, rs);
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(pstmt);
//JdbcUtils.closeConnection(con);
}
}
public void update(Connection con, 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);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
//close(con, pstmt, null);
JdbcUtils.closeStatement(pstmt);
}
}
기존 repository에서 추가되는 부분이다. 커넥션 유지가 필요한 두 메서드를 만들었다. 파라미터로 Connection을 받는다.
기존 코드와 다른 점을 파악하기 위해 기존 코드에서 삭제되는 부분은 주석으로 남겨두었다.
- //Connection con = null;
- 파라미터로 커넥션을 받기 때문에 필요없다.
- //con = getConnection();
- 만약 getConnection()을 이용해 커넥션을 받아오는 코드를 남겨두면, dataSource.getConnection()이 실행되기 때문에 새로운 커넥션을 사용하게 된다. 따라서 없앤다.
- //close(con, pstmt, rs);
- 기존에 이 코드를 사용하면 Connection을 닫게 된다. findById, update 메서드에서 커넥션을 유지하고 공유하며 사용해야 하기 때문에 서비스 로직이 끝날 때 까지 커넥션을 닫으면 안된다.
MemberService
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
@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 con = dataSource.getConnection();
try {
con.setAutoCommit(false); //트랜잭션 시작
//비즈니스 로직
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);
con.commit(); //성공 시 커밋
} catch (Exception e) {
con.rollback(); //실패 시 롤백
throw new IllegalStateException(e);
} finally {
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("이체 중 예외 발생");
}
}
}
- Connection con = dataSource.getConnection();
- 트랜잭션을 시작하려면 커넥션이 필요하다.
- con.setAutoCommit(false);
- 자동 커밋 모드를 꺼 트랜잭션을 시작한다 (시작 = 표현).
- con.commit()
- 비즈니스 로직이 정상 수행되면 트랜잭션을 커밋한다.
- con.rollback()
- 예외가 발생하면 롤백한다.
- finally~
- 커넥션을 사용하고 나면 커넥션을 종료해야 한다. 하지만 만약 커넥션 풀을 이용한다고 가정하면, 우리는 setAutoCommit(false)인 상태의 커넥션을 그대로 돌려주게 된다.
- 이러면 다음에 사용하는 쪽에서 true를 기본으로 알고 있어 문제가 발생한다.
- 따라서 con.close() 전 기본 값으로 다시 바꾸어주는 setAutoCommit(true)를 해준다.
Test
1
2
3
4
5
6
@BeforeEach
void before() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
memberRepository = new MemberRepositoryV2(dataSource);
memberService = new MemberServiceV2(dataSource, memberRepository);
}
기존 코드와 같고, 주입 부분만 바꾸어주면 된다.
정상 케이스는 당연히 문제 없이 테스트에 성공한다.
오류가 발생했을 때를 보자.
1
2
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(10000);
기존에 오류가 발생하면 오류가 발생하지 않은 우선순위의 쿼리가 동작해 잔고가 감소하고, 오류가 발생한 쿼리는 잔고가 그대로인 것을 테스트 했었다.
하지만 이제는 오류가 발생하면 롤백해주기 때문에 두 update() 모두 실행되지 않은 데이터인 10000, 10000 이 들어있어야 한다.
정리
같은 세션을 이용해야 하기 때문에 같은 커넥션을 파라미터를 통해 유지하고, 그 커넥션을 바탕으로 트랜잭션을 시작해 서비스 로직을 하나의 트랜잭션으로 묶어 실패하면 전부 롤백, 성공하면 커밋하는 것을 볼 수 있었다.
단, 서비스 계층에 비즈니스로직만 있는 것이 아니라 트랜잭션을 시작하고, 닫는 이러한 부분이 모두 담겨있는 것이 불편하다. 너무 길고 고려할게 많아진다.
그리고 지금은 간단하지만 커넥션을 유지하도록 코드를 변경하는 일도 매우 복잡할 수 있다.
스프링을 사용하면 편하다. 스프링을 사용한 방법을 알아보자.
위 트랜잭션 사용의 문제점들.
우선 위에서의 문제점을 정확하게 파악하기 위해 애플리케이션의 구조를 봐야한다.
여러가지 애플리케이션 구조가 있지만 가장 단순하면서 많이 사용하는 방식은 역할에 따라 위 3가지 계층으로 나누는 것이다.
- 프레젠테이션 계층
- UI와 관련된 처리 담당
- 웹 요청과 응답
- 사용자 요청을 검증
- 주 사용 기술 : 서블릿과 같은 HTTP 웹 기술, 스프링 MVC
- 서비스 계층
- 비즈니스 로직을 담당
- 주 사용 기술 : 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성.
- 데이터 접근 계층
- 실제 데이터베이스에 접근하는 코드
- 주 사용 기술 : JDBC, JPA, File, Redis, Mongo …
이 3가지 계층 중 가장 중요한 곳은 어디일까?
바로 서비스 계층이다.
- 핵심 비즈니스 로직이 들어있는 서비스 계층은 시간이 흘러서 UI(웹)와 관련된 부분이 변하고, 데이터 저장 기술이 변해도 변경 없이 유지되어야 한다.
- 이렇게 하려면 서비스 계층은 특정 기술에 종속적이지 않도록 개발해야 한다.
- 계층을 나누는 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다.
- 기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가진다.
- 예를 들어 HTTP API를 쓰다가 GRPC 같은 기술로 변경해도 프레젠테이션 계층만 변경하면 되고, JDBC를 쓰다가 JPA로 변경해도 데이터 접근 계층만 바꾸고 서비스 계층은 유지하는 것이다.
- 서비스 계층이 특정 기술에 종속되지 않기 때문에 비즈니스 로직을 유지보수하기도 쉽고, 테스트도 쉽다.
그래서 이러한 구조와 위의 트랜잭션을 적용하는 부분의 문제가 무슨 관련이 있느냐.
문제점
애플리케이션 구조를 봤을 때 그 구조의 목적은 서비스 계층을 순수하게 유지하는 것이 큰 비중을 차지한다고 하였다.
그런데 기존의 서비스 코드는?
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
public void accountTransferV1(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
public void accountTransferV2(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); //트랜잭션 시작
//비즈니스 로직
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);
con.commit(); //성공 시 커밋
} catch (Exception e) {
con.rollback(); //실패 시 롤백
throw new IllegalStateException(e);
} finally {
if (con != null) {
try {
con.setAutoCommit(true); //커넥션 풀 고려
con.close();
} catch (Exception e) {
log.info("error", e);
}
}
}
}
- V1의 경우 트랜잭션을 쓰지 않은 코드이다.
- 특정 기술에 종속적이지 않고, 순수 비즈니스 로직만 존재한다.
- 물론 상세히 들어가면 SQLException이 JDBC 기술에 의존한다.
- 이러한 부분은 Repository 단계에서 처리해야 한다.
- 우선은 이런 부분은 무시한다.
- 향후 비즈니스 로직의 변경이 필요하면 이 부분만 수정하면 된다.
- 특정 기술에 종속적이지 않고, 순수 비즈니스 로직만 존재한다.
- V2의 경우 트랜잭션을 적용하기 위해 여러 코드를 추가했다.
- 트랜잭션이 비즈니스 로직이 있는 서비스계층에서 시작하는 것이 좋기 때문에 서비스 계층에서 트랜잭션과 관련된 코드도 다 추가되었다.
- 그런데 트랜잭션을 이용하려면 DataSource, Connection과 같은 JDBC 기술에 의존해야 한다.
- 트랜잭션을 이용하기 위해 위와 같은 JDBC 기술을 사용하는 코드가 더 많아졌다.
- 만약 JDBC 기술을 JPA같은 기술로 변경하려면 서비스 계층인데도 불구하고 JDBC 관련 코드를 전부 수정해야 한다.
- 즉 JDBC 기술이 섞여 유지보수가 어렵다는 것이다.
문제점을 모두 정리해보면 3가지가 있는 것이다.
- 트랜잭션 문제
- 예외 누수 문제
- JDBC 반복 문제
이 문제들을 다음 포스트에서 자세하게 다루고, 스프링을 통한 해결도 다뤄본다.