스프링 DB-트랜잭션

트랜잭션

DB에서 데이터 관리를 안전하게 하기 위해서는 트랜잭션을 사용해야 한다. 트랜잭션이 무엇인지는 학부 시절 강의에서 배웠으니 넘어가고, 복습하는 느낌으로 ACID만 다시 한번 알아보자.

  • 원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.
  • 일관성 : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation level)을 선택할 수 있다.
  • 지속성 : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

격리성은 성능에 관계되는 민감한 문제이므로 ANSI에서 표준적으로 트랜잭션의 격리 수준을 정의했는데 다음과 같다.

  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMITTED(커밋된 읽기)
  • REPEATABLE READ(반복 가능한 읽기)
  • SERIALIZABLE(직렬화 가능)

일반적으로 사용되는 것은 READ COMMITTED(커밋된 읽기)이다.

트랜잭션에서 사용되는 rollback, commit 등을 사용하기 위해서는 DB의 자동 커밋수동 커밋으로 변경해야 한다. 따라서 트랜잭션을 시작할 때 수동 커밋으로 전환하고, commit, 혹은 rollback 후 다시 자동커밋으로 바꿔주어 트랜잭션을 종료한다.

트랜잭션 적용

이제 트랜잭션을 스프링에서 어떻게 활용하는지 알아보기 위해 간단한 계좌이체 서비스를 예제로 구현해보자.
먼저 트랜잭션이 적용되지 않은 서비스 로직만을 구현하였다.

  
@RequiredArgsConstructor
public class MemberServiceV1 {
    final MemberRepositoryV1 memberRepositoryV1;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        //트랜잭션 시작
        Member fromMember = memberRepositoryV1.findById(fromId);
        Member toMember = memberRepositoryV1.findById(toId);
        memberRepositoryV1.update(fromId, fromMember.getMoney()-money);
        validation(toMember);
        memberRepositoryV1.update(toId, toMember.getMoney()+money);
        //커밋 또는 롤백
    }

    private static void validation(Member toMember) {
        if(toMember.getMemberId().equals("ex")) {
            throw new IllegalArgumentException("이체중 예외 발생");
        }
    }
}

리포지토리는 저번 포스팅에서 커넥션 풀을 사용해 구현한 것을 그대로 사용하였다. 이후 트랜잭션이 들어갈 부분은 주석으로 처리했고, MemberId가 ex라면 예외를 던지도록 구현하였다.

위 코드를 테스트해보면 정상 이체의 경우엔 잘 작동하지만 MemberId에 ex를 넣어 예외가 발생하도록 하면 계좌이체가 출금은 성공하지만 입금은 작동하지 않아 DB의 일관성이 깨지는 것을 확인할 수 있었다.

이러한 문제를 해결하기 위해 이제 위 코드에 트랜잭션을 적용해보자.

트랜잭션

트랜잭션을 적용하려면 비즈니스 로직이 있는 서비스 코드에서 적용해야 한다. 비즈니스 로직 중간에 무언가 이상이 있다면 롤백해야 하기 때문이다.
그런데 트랜잭션을 시작하기 위해서는 일단 커넥션이 있어야 한다. 따라서 우리는 기존 리포지토리에서 커넥션을 받아오는 구조에서 서비스 코드에서 커넥션을 받아오도록 변경해야 한다.
또한 서비스에서 받아온 커넥션과 리포지토리에서 조작하는 커넥션이 같은 세션을 사용해야 트랜잭션이 제대로 이루어지는데, 가장 간단한 방법으로 파라미터를 통해 커넥션을 넘겨주는 방법을 일단 사용하자.

  
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {
    final MemberRepositoryV2 memberRepositoryV2;
    final DataSource dataSource;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {

        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false);//트랜잭션 시작
            bizLogic(fromId, toId, money, con);
            con.commit(); //성공 시 커밋
        } catch (Exception e) {
            con.rollback(); //실패 시 롤백
            throw new IllegalArgumentException(e);
        } finally {
            releaseCon(con);
        }
    }

    private void bizLogic(String fromId, String toId, int money, Connection con) throws SQLException {
        //비즈니스 로직
        Member fromMember = memberRepositoryV2.findById(con, fromId);
        Member toMember = memberRepositoryV2.findById(con, toId);
        memberRepositoryV2.update(con, fromId, fromMember.getMoney()- money);
        validation(toMember);
        memberRepositoryV2.update(con, toId, toMember.getMoney()+ money);
    }

    private static void releaseCon(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 IllegalArgumentException("이체중 예외 발생");
        }
    }
}

이제 서비스 코드 내에서 커넥션을 받아오고, 비즈니스 로직을 처리한 뒤 커넥션을 종료하는 역할까지 담당한다. 리포지토리에는 기존 파라미터에 더해 커넥션도 전달하도록 변경되었다.
트랜잭션을 사용하는 로직만 있는게 아니므로 리포지토리에서는 기존 코드에 더해 파라미터로 커넥션을 받는 동일한 역할의 메서드가 추가되었다.
비즈니스 로직은 메서드로 따로 추출하여 트랜잭션 과정과 구분할 수 있도록 하였다.

이제 다시 테스트를 돌려보면 예외가 발생하더라도 트랜잭션 내부에서 rollback 해주기 때문에 연산이 이루어지기 전 상태로 되돌려놓는 것을 확인할 수 있다.

대신 위에서 설명한대로 서비스 코드의 역할이 너무 많아지고, 리포지토리도 코드가 지저분해지며 커넥션을 주고받아야 하는 등 많은 문제점이 발생하였다.
이러한 문제를 어떻게 해결하는지에 대해서는 내용이 너무 길어지므로 다음 포스팅에서 설명하도록 하겠다.