Home 트랜잭션 (애플리케이션 적용)
Post
Cancel

트랜잭션 (애플리케이션 적용)

트랜잭션 자바 코드 적용

기존 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 반복 문제

이 문제들을 다음 포스트에서 자세하게 다루고, 스프링을 통한 해결도 다뤄본다.

This post is licensed under CC BY 4.0 by the author.