개발/Spring DB

트랜잭션 문제 해결

Debin 2022. 6. 16.
반응형
본 게시글은 인프런 김영한 선생님 강의 스프링 DB 1편을 완강하고 배운 것을 남기고자 적은 포스팅입니다.
강의 링크는 아래와 같습니다.

웹 애플리케이션은 크게 3가지 구조로 나뉜다.

 

  1. 프레젠테이션 계층
    UI 관련 로직 담당, 형식적 검증, 사용자 요청을 담당, 웹 요청과 응답. 주 사용 기술은 서블리과 HTTP 기술 및 스프링 MVC
  2. 서비스 계층
    비즈니스 로직을 담당한다. 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성하는 것이 좋다.
  3. 데이터 접근 계층
    실제 DB에 접근하는 계층이다. 기술로는 JDBC, JPA, Mongo 등이 있다. 

제일 중요한 부분은 비즈니스 로직을 담당하는 서비스 계층이다.

시간이 흘러서 UI도 변하고, 데이터 저장 기술을 다른 기술로 변경해도, 비즈니스 로직은 최대한 변경없이 유지되야 한다.

이렇게 하려면 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다.

그러면 구현 기술이 변경될 때 변경의 영향 범위를 최소화 할 수 있다.

 

저번 포스팅을 확인하면,

우리 MemberServiceV2는 트랜잭션을 비즈니스 로직이 있는 서비스 계층에서 시작하기 위해

DataSource, Connection과 같은 JDBC 기술에 의존하게 되었다.

자세한 코드는 아래 포스팅에서 확인 가능하다.

https://devdebin.tistory.com/205

 

트랜잭션

본 게시글은 인프런 김영한 선생님 강의 스프링 DB 1편을 완강하고 배운 것을 남기고자 적은 포스팅입니다. 강의 링크는 아래와 같습니다. https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1/das..

devdebin.tistory.com

직접보면 비즈니스 로직보다는 JDBC를 사용해서 트랜잭션을 처리하는 코드가 많다.

만약 우리가 JDBC를 사용하다가 JPA 같은 다른 기술로 바꾸면 서비스 코드도 전부 변경해야 한다.

비즈니스 로직이 JDBC 기술이 섞여 있어서 유지보수하기 어렵다.

 

정리해보면 문제는 3가지가 있다.

 

  1. 트랜잭션 문제
  2. 예외 누수 문제
  3. JDBC 반복 문제

트랜잭션 문제

트랜잭션을 적용하면서 문제가 발생했다.

 

  • 비즈니스 로직을 수행하는 서비스 계층 코드는 순수해야 하는데,
    트랜잭션을 적용하기 위해 JDBC 구현 기술이 서비스 계층에 누수되었다. 이러면 추후 변화에 대응하기 어렵다
  • 트랜잭션 동기화 문제
    같은 트랜잭션을 유지하려면 커넥션을 파라미터로 넘겨야 한다. 이 때 파생되는 문제도 있는데, 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야 한다.
  • 트랜잭션 적용 반복 문제
    너무 많은 중복 코드가 존재한다.

예외 누수

  • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파된다.
  • SQLException은 체크드 예외이므로 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 잡아서 처리하거나 명시적으로 throws를 통해서 다시 밖으로 던져야한다.
  • SQLException은 JDBC 전용 기술이다. 향후 JPA나 다른 데이터 접근 기술을 사용하면, 그에 맞는 다른 예외로 변경해야 하고, 결국 서비스 코드도 수정해야 한다.

JDBC 코드 반복 문제

너무 많은 JDBC 코드가 반복이 된다.

하지만 역시 스프링은 이에 대한 해결책을 점진적으로 제공해왔는데, 이에 대해 알아보자.

트랜잭션 추상화

현재 서비스 계층은 트랜잭션 사용을 위해 JDBC 기술에 의존하고 있다.

트랜잭션은 원자적 단위의 비즈니스 로직을 처리하기 위해 사용되며, 구현 기술마다 트랜잭션을 사용하는 법이 다르다.

만약에 우리가 JDBC 트랜잭션을 사용하다가 JPA 트랜잭션을 사용하려고 하면 코드를 몽땅 수정해야 한다.

정말 비효율적이다. 그러면 이런 문제를 스프링은 어떻게 해결할까? 이제 익숙해진 것 같다. 바로 추상화를 통해 해결한다.

공통적인 기능을 뽑아 만든 추상화한 트랜잭션 인터페이스는 아래와 같다.

public interface TxManager{
    begin();
    commit();
    rollback();
}

이렇게 트랜잭션 시작, 커밋, 롤백 기능만 추상 메서드로 만들면 된다. 이제 아래와 같이 구현체를 만들면 된다.

스프링은 이런 고민을 모두 해결해놓았다. 그래서 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다.

심지어 트랜잭션 구현체도 다 있어서 개발자는 그것을 그대로 사용하면 된다.

스프링 트랜잭션 추상화

스프링 트랜잭션 추상화의 핵심 인터페이스는 PlatformTransactionManager 이다.

트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.

 

  1. 트랜잭션 동기화
  2. 리소스 동기화

트랜잭션은 앞에서 언급했지만 리소스 동기화란 무엇일까?

리소스 동기화

트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다.

결국 같은 커넥션을 동기화하기 위해서는 이전에는 파라미터로 커넥션을 전달했다.

파라미터로 커넥션을 전달하면 코드가 지저분해지고, 커넥션을 넘기는 메서드와 안넘기는 메서드를 중복해서 만들어야 한다.

여러가지 단점이 많다.

 

이런 문제를 해결하기 위해 스프링은 트랜잭션 동기화 매니저를 제공한다.

이것은 쓰레드 로컬을 사용해 커넥션을 동기화해주는 것이다.  트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.

트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화 할 수 있다.

따라서 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다.

따라서 이전처럼 파라미터로 커넥션을 전달하지 않아도 된다.

트랜잭션 동기화 매니저

동작 방식은 다음과 같다.

 

  1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 파라미터로 커넥션을 전달하지 않아도 된다.
  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션도 닫는다.
  5. 트랜잭션 동기화 매니저 클래스는 TransactionSynchronizationMangaer 클래스다.

트랜잭션 문제 해결

첫 번째 방법   실습 정리                                                                                                     

저번 레포지토리 코드에서 몇 가지를 수정했다.

먼저 DataSourceUtils.getConnection() 를 사용해 커넥션을 파라미터로 전달하는 부분을 제거했다.

기존 코드에서 getConnection에서 DataSourceUtils.getConnection()를 사용하도록 변경했다.

DataSourceUtils.getConnection()는 아래와 같이 동작한다.

 

  • 트랜잭션 동기화 매니저는 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.

DataSourceUtils.releaseConnection();을 사용하도록 수정했다.

con.close()를 사용하면 커넥션이 유지가 되지 않는 문제가 발생한다.

이 커넥션 이후 로직은 물론이고, 트랜잭션을 종료(커밋, 롤백)할 때까지 살아있어야 한다.

 

  • DataSourceUtils.releaseConnection();은 바로 커넥션을 닫지 않는다.
  • 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
  • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.

서비스 코드도 아래와 같이 작성했다.

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {
    private final MemberRepositoryV3 memberRepository;
    private final PlatformTransactionManager transactionManager;

    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 void validation(Member toMember){
        if(toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체 중 예외가 발생하였습니다");
        }
    }
}
  • PlatformTransactionManager transactionManager 트랜잭션 매니저를 주입 받았다.
    지금은 JDBC 기술을 사용하므로 DataSourceTransactionManager 구현체를 주입 받아야 한다.
  • transactionManager.getTransaction 메소드를 사용해 트랜잭션을 시작한다.
  • TransactionStatus status를 반환한다. 현재 트랜잭션의 정보가 포함되어 있으며, 이후 트랜잭션을 커밋, 롤백할 때 사용한다.
  • new DefaultTransactionDefinition()은 트랜잭션과 관련된 옵션을 지정한다.
  • 로직이 성공하면 transacionManager.commit(status)로 커밋을 하면된다.
  • 로직을 실패, 언체크드 예외가 발생하면 transacionManager.rollback(status)로 롤백을 하면된다.
  • 트랜잭션 매니저는 JDBC 기술을 사용하므로, JDBC용 트랜잭션 매니저를 주입했다.

이렇게 첫 번째 실습을 완료했고, 동작을 정리해보자.

 

  1. 서비스 계층에서 트랜잭션매니저 인스턴스의 getTransaction 메소드를 사용해 트랜잭션을 시작한다.
  2. 트랜잭션을 시작하려면 데이터베이스 커낵션이 필요하다. 트랜잭션 매니저는 내부에서 데이터 소스를 사용해서 커넥션을 생성한다.
    물론 트랜잭션 매니저 인스턴스를 생성할 때 생성자 매개변수로 데이터 소스를 받았다. 
  3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있다.
  6. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드를 호출하는데, 이때 커넥션을 매개변수로 전달하지 않는다.
  7. 리포지토리는 커넥션이 필요한데 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은  커넥션을 사용하고, 트랜잭션도 유지된다.
  8. 획득한 커넥션을 사용해 쿼리문을 데이터베이스에 전달해서 실행한다.
  9. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션 매니저에서 트랜잭션은 커밋하거나 롤백된다.
  10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
  11. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  12. 전체 리소스를 정리한다.
    트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용 후 꼭 정리해야 한다.
    con.setAutoCommit(true)로 되돌린다. 커넥션 풀을 고려한다.
    con.close()를 호출해서 커넥션을 종료한다. 커넥션 풀을 사용하는 경우 con.close()를 사용하면 커넥션 풀에 반환된다.

이제 트랜잭션 추상화를 통해 서비스 코드가 JDBC 기술에 의존하지 않는다.

두 번째 실습 정리

  • 아직까지 트랜잭션 매니저 관리 코드가 중복되는 것을 확인할 수 있었다.
  • 이를 템플릿 콜백 패턴을 사용해 수정하려고 시도했다.
  • TransactionTemplate 클래스의 인스턴스를 만들어서 executeWithoutResult 메서드를 사용해 템플릿 콜백 패턴을 적용했다.
  • 트랜잭션 템플릿 덕분에 트랜잭션을 시작하고 커밋하거나 롤백하는 코드가 모두 제거되었다.
  • 하지만 아직도 서비스 계층에 비즈니스 로직과 트랜잭션을 처리하는 기술 로직이 함께 존재한다.
  • 이에 대한 해결법으로 다음 실습에서는 트랜잭션 AOP를 사용할 것이다.
  • 자주 언급한 쓰레드 로컬, 템플릿 콜백 패턴, 앞으로 나올 AOP는 스프링 고급편에서 학습할 수 있다고 하셨다. 추후에 이를 들어보자.

세 번째 실습 정리

  • 다양한 방법을 통해 트랜잭션 반복 코드는 해결했지만 여전히 비즈니스 로직만 남긴다는 목표는 달성하지 못했다.
  • 이럴 때 스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.
  • 앞으로 나올 핵심 키워드는 AOP, 프록시, @Transactional 이다.
  • 프록시를 도입하기 전에는 서비스의 로직에서 트랜잭션을 직접 시작한다.
  • 그러나 프록시를 도입하면 아래 이미지와 같다.

트랜잭션 프록시 도입 후

프록시를 사용해서 트랜잭션을 처리하는 객체와 비즈니스 로직을 수행하는 서비스 객체를 명확하게 분리할 수 있다.

스프링이 제공하는 트랜잭션 AOP

스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다.

스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.

개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다.

스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.

트랜잭션 AOP를 적용한 서비스 코드는 다음과 같다.

@Slf4j
public class MemberServiceV3_3 {

    private final MemberRepositoryV3 memberRepository;

    public MemberServiceV3_3(MemberRepositoryV3 memberRepository) {
        this.memberRepository = memberRepository;
    }

   @Transactional //성공하면 커밋, 런타임 에러가 발생하면 롤백
    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 void validation(Member toMember){
        if(toMember.getMemberId().equals("ex")){
            throw new IllegalStateException("이체 중 예외가 발생하였습니다");
        }
    }
}

코드가 완전 깔끔해졌다.

테스트를 작성하고 진행하는데 몇 가지 주의사항이 있었다.

 

  1. 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. @SpringBootTest 애노테이션을 사용해 테스트시 스프링 부트를 통해 스프링 컨테이너를 생성했다. 그리고 테스트에서는 @Autowired 필드 주입을 사용해 스프링 컨테이너가 관리하는 빈을 사용했다.
  2. @TestConfiguration 을 이용해 내부 설정 클래스를 만들어 스프링 빈을 만들어 테스트를 진행했다.
  3. DataSource 스프링에서 기본으로 사용할 데이터소스를 스프링 빈으로 등록한다. 추가로 트랜잭션 매니저에서도 사용한다.
  4. DataSourceTransactionManager : 트랜잭션 매니저를 스프링 빈으로 등록한다. 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해야 한다.

아래 AOP 전체 흐름이다.

스프링 AOP 전체 흐름

@Transacional 애노테이션을 붙인 방식을 선언전 트랜잭션 관리라고 하는데 실무에서 대부분 이 방식을 사용한다고 한다.

스프링 부트의 자동 리소스 등록

마지막 파트는 application.properties와 application.yml에서 리소스를 자동 등록하는 방식이다.

역시 지난 시간에 생각한 부분이 맞았다. datasource에 url, username, password를 모두 application.yml에서 등록할 수 있었다.

코드에서 확인해보면 알 수 있는데 생성자를 통해서 스프링 부트가 만들어준 데이터 소스 빈을 주입 받을 수 있었다.

또한 스프링 부트는 적절한 트랜잭션 매니저( PlatformTransactionManager )를 자동으로 스프링 빈에 등록한다.

 

이상으로 포스팅을 마칩니다. 감사합니다.

반응형

댓글