스프링과 트랜잭션(트랜잭션 추상화, 동기화, 템플릿, @Transactional)

도토리·2023년 5월 7일
0

스프링 DB 접근

목록 보기
4/6

애플리케이션의 구조

  1. presentation layer(@Controller)
  • 웹 요청과 응담
  • 사용자의 요청을 검증
  • UI 관련 처리 담당
  1. service layer(@Service)
  • 비지니스 로직 담당
  • 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성하자
  1. data access layer(@Repository)
  • 실제 DB에 접근하는 코드

여기에서 가장 중요한 계층은 service layer이다.

  • 시간이 흘러 UI 관련된 부분(presentation layer)이 변하고 데이터 저장 기술(data access layer)이 변경되어도, 비지니스 로직은 최대한 변경 없이 유지되어야 한다.
    이렇게 하려면, service layer는 특정 기술에 종속적이지 않게, 순수 자바 코드로 개발하면 된다.
  • 위와 같이 계층을 나눈 이유도 service layer를 최대한 순수하게 유지하기 위한 목적이 크다. 기술에 종속적인 부분은 presentation layer, data access layer에서 가지고 가는 것이다.
    ▶ presentation layer: UI 관련 기술로부터 service layer를 보호
    추후 UI 관련 기술이 변경되더라도 presentation layer의 코드만 변경하면 된다.
    ▶ data access layer: 데이터 접근 기술(JDBC, JPA 등)로부터 service layer를 보호
    추후 데이터 접근 기술이 JDBC -> JPA로 변경되더라도 data access layer의 코드만 변경하면 된다.

나의 코드가 가진 문제점 3가지 (시리즈의 이전 글 참조)

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); //트랜잭션 시작
            //비즈니스 로직
            bizLogic(con, fromId, toId, money);
            con.commit(); //성공시 커밋
        } catch (Exception e) {
            con.rollback(); //실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }
    
    private void bizLogic(Connection con, String fromId, String toId, int
            money) throws SQLException {
        Member fromMember = memberRepository.findById(con, fromId);
        Member toMember = memberRepository.findById(con, toId);
        memberRepository.update(con, fromId, fromMember.getMoney() - money);
        memberRepository.update(con, toId, toMember.getMoney() + money);
    }
}
  • 비지니스 로직이 담긴 service layer에 트랜잭션 기능을 추가하였다.
  • service layer에서 트랜잭션 기능을 사용하기 위해서는 DataSource, Connection, SQLException과 같은 JDBC 기술에 의존해야 한다.
  • 즉, service layer는 JDBC라는 특정 기술에 의존하고 있고, 데이터 접근 기술을 변경하게 되면 service layer의 코드도 변경해야 한다.

  1. 트랜잭션 기능을 적용하며 발생한 문제
  • service layer에 트랜잭션을 적용하면서 service layer가 JDBC라는 특정 기술에 의존하게 되었다. service layer는 특정 구현 기술에 의존하지 않고 가급적 순수 자바 코드로 작성 -> 추후 데이터 접근 기술이 변경되어도 최대한 코드 변경이 없도록 해야 한다.
  • 한 트랜잭션에서 같은 커넥션을 유지하기 위해 커넥션을 파라미터로 넘기면서, 기능이 같은 메소드를 트랜잭션용, not 트랜잭션용으로 각각 만들어야 한다.
  • 트랜잭션 코드의 반복(try-catch-finally의 반복)
  • service에 트랜잭션 처리 코드, 비지니스 로직이 섞여 있다. 두 관심사가 하나의 클래스에 존재하기 때문에, 유지보수하기 어렵다. service에는 가급적 비지니스 로직만 존재해야 한다.

  1. 예외 누수 문제
    repository에서 발생한 SQLException(JDBC 기술의 예외)가 service에 전달된다. 예외를 잡으려면 'catch(SQLException e)', 예외를 던지려면 'throws SQLException'해야 하므로(checked 예외라서 throws 선언 필수), service에 SQLException이 누수되는 문제가 발생한다. 추후 다른 데이터 접근 기술로 변경하면 그에 맞는 다른 예외가 발생할 것이고, 이에 따라 service 코드도 함께 수정해야 한다.

  2. JDBC 반복 문제
    try-catch-finally 코드의 반복

스프링의 도움을 받아서 위의 문제점들을 하나씩 해결해보자!
(문제1은 이번 글에서, 문제2는 예외 전환 관련 글에서, 문제3은 SQL Mapper, ORM 기술 관련 글에서 해결하겠다)


트랜잭션 추상화

  • 등장 배경: 트랜잭션을 사용하는 코드는 데이터 접근 기술마다 다르다. 데이터 접근 기술을 JDBC -> JPA로 변경하면, service의 트랜잭션 처리 코드를 변경해야 한다.
  • 트랜잭션 시작, 커밋, 롤백 메소드를 가진 인터페이스를 만든다.
  • 스프링이 제공하는 트랜잭션 추상화: PlatformTransactionManager 인터페이스
    심지어 데이터 접근 기술에 따른 트랜잭션 구현체도 제공한다.
    (참고. 스프링 5.3부터는 DataSourceTransactionManager를 상속받아서 약간의 기능을 확장한 JdbcTransactionManager를 제공)
  • PlatformTransactionManager 인터페이스
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException; //트랜잭션 시작
void commit(TransactionStatus status) throws TransactionException; //트랜잭션 커밋
void rollback(TransactionStatus status) throws TransactionException; //트랜잭션 롤백

service에 트랜잭션을 도입하면서 service가 JDBC 기술에 의존
-> 트랜잭션 추상화(PlatformTransactionManager 도입) 통해서 해결


트랜잭션 동기화

  • 등장 배경: 하나의 트랜잭션은 동일한 커넥션을 사용해야 한다.
  • 이전에는 커넥션을 파라미터로 전달하는 방법을 사용했는데, 코드 지저분 + 기능 동일한 메서드를 2개 정의하는 등 여러 단점이 있다.


동작 방식

  • 트랜잭션 매니저는 DataSource 통해서 커넥션을 만들고, 트랜잭션 시작한다.
  • 트랜잭션 매니저는 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  • repository는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
    -> 하나의 트랜잭션 내에서 같은 커넥션을 사용하게 된다.
  • 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션 통해 트랜잭션 종료하고, 커넥션을 풀에 반납한다.

  • 위 그림에서 '트랜잭션 매니저'란 PlatformTransactionManager와 그 구현체를 의미한다.
  • 스프링은 트랜잭션 동기화 매니저(TransactionSynchronizationManager)를 제공
  • 트랜잭션 동기화 매니저는 내부적으로 ThreadLocal을 사용하는데, ThreadLocal을 사용하면 각 thread마다 별도의 저장소(커넥션 존재)가 부여되기 때문에, 해당 thread만 해당 커넥션에 접근할 수 있다(1thread 1저장소 == 1트랜잭션 1커넥션). 즉, multi thread 상황에서 안전하게 커넥션을 동기화할 수 있는 것이다.

[before]

Connection con = dataSource.getConnection(); //DataSource 통해서 커넥션 얻기
JdbcUtils.closeConnection(con);

[after]

Connection con = DataSourceUtils.getConnection(dataSource); //트랜잭션 동기화 매니저 통해서 커넥션 얻기
DataSourceUtils.releaseConnection(con, dataSource);
  • DataSourceUtils.getConnection()
    트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면, 해당 커넥션을 반환한다. 트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면, 새로운 커넥션을 생성해서 반환한다.
  • DataSourceUtils.releaseConnection()
    해당 커넥션이 트랜잭션 동기화 매니저가 관리하는 커넥션인 경우, 커넥션을 풀에 반납하지 않는다. 트동매가 관리하는 커넥션이 아닌 경우, 해당 커넥션을 풀에 반납한다.

private final PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);

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

    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); //트랜잭션 시작
    try {
        bizLogic(fromId, toId, money); //비지니스 로직: SQLException, IllegalStateException
        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);
}
  • 트랜잭션 매니저를 생성할 때 DataSource를 주입한다. -> 트랜잭션 매니저는 DataSource 통해 커넥션을 생성한다.
  • commit() 또는 rollback() 시, 트랜잭션 매니저가 알아서 커넥션을 release해준다.
  • TransactionDefinition: 트랜잭션 관련 옵션(EX. 읽기 전용) 지정
    TransactionStatus: 현재 트랜잭션의 상태 정보 포함, 이후 commit() 또는 rollback()할 때 필요하다.

한 트랜잭션 내에서 같은 커넥션을 유지하기 위해 커넥션을 매개변수로 넘기는 문제
-> 트랜잭션 동기화 통해서 해결


트랜잭션 템플릿

  • 등장 배경: 트랜잭션을 사용하는 로직을 살펴보면, 같은 패턴이 반복된다.
    각 service에서 패턴이 반복될 것이고, 오직 비지니스 로직만 달라질 것이다.
TransactionStatus status = transactionManager.getTransaction(ew DefaultTransactionDefinition()); //트랜잭션 시작
try {
	bizLogic(); //비지니스 로직
    transactionManager.commit(status); //트랜잭션 커밋
} catch(Exception e) {
	transactionManager.rollback(status); //트랜잭션 롤백
}
  • 템플릿 콜백 패턴을 사용하면 이러한 반복 문제를 깔끔하게 해결할 수 있다.
    스프링이 제공하는 TransactionTemplate는 템플릿 콜백 패턴으로 구현되어 있다.

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) {

    txTemplate.executeWithoutResult((status) -> {
        try {
            bizLogic(fromId, toId, money); //비지니스 로직: SQLException, IllegalStateException
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    });
}
  • TransactionTemplate은 내부적으로 PlatformTransactionManager를 가지고 있어(TransactionTemplate를 생성할 때 주입 필요), PlatformTransactionManager 관련 로직을 대신 수행해준다.
  • 비지니스 로직이 정상 수행되면 커밋, unchecked 예외가 발생하면 롤백(단, checked 예외가 발생하면 커밋)
  • try-catch문 작성 이유: bizLogic()는 checked 예외(SQLException)를 던지는데, 람다는 checked 예외를 던질 수 없도록 설계되어 있어, unchecked 예외로 전환하여 던지는 것이다.
  • TransactionTemplate은 주입받지 않고 new 연산자로 생성했는데, 관례상 이렇게 작성하는 것도 있고, 또한 TransactionTemplate은 인터페이스가 아닌 클래스라서 유연성을 고려할 필요가 없다.

트랜잭션 코드 반복 문제
-> 트랜잭션 템플릿(TransactionTemplate) 통해서 해결


@Transactional과 트랜잭션 AOP

  • 등장 배경: service에 비지니스 로직, 트랜잭션 처리 코드가 섞여있다. 그렇다고, service에 트랜잭션을 도입하지 않을 수도 없고... service에 트랜잭션 기능은 도입하되 트랜잭션 코드는 남기고 싶지 않다.
  • @Transactional을 사용하면, 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다.

  • 트랜잭션 프록시를 사용하면 트랜잭션 처리 로직, 비지니스 로직을 분리할 수 있다. 트랜잭션 프록시가 트랜잭션 처리 로직을 가져가고, service에는 비지니스 로직만 남는다.
  • 스프링은 트랜잭션 프록시 클래스를 만들어주고, 빈으로 등록해준다.
  • 클라이언트는 프록시를 호출하고, 프록시가 service를 호출한다. 클라이언트가 service를 호출하는 것이 아니다.
  • 스프링은 트랜잭션 AOP를 처리(트랜잭션 프록시 도입, 프록시-service 간의 AOP 작업) 하기 위한 기능을 제공한다(@Transactional).
    개발자는 트랜잭션이 필요한 곳에 @Transactional만 붙이면 된다.

MemberService

@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    bizLogic(fromId, toId, money); //비지니스 로직: SQLException, IllegalStateException
}
  • service에서 트랜잭션 관련 코드는 제거되었고, 이제 비지니스 로직만 존재한다.
  • @Transactional은 메소드 또는 클래스 레벨에 붙이는데, 클래스에 붙이면 public 메소드가 AOP 적용 대상이 된다.
  • @Transactional -> @Transactional이 붙은 클래스를 상속받은 후, 트랜잭션 코드를 만든다. 이를 스프링 빈으로 등록하고, service 필드에 주입해준다. 이게 바로 트랜잭션 프록시! (예를 들어, @Transactional이 존재하는 클래스가 MemberService라면, MemberService$$EnhancerBySpringCGLIB$$225ec946)
    service 필드에 주입되는 것은 MemberService가 아닌 트랜잭션 프록시이다.
  • @Transactional이 존재하는 클래스는 스프링 빈으로 등록되어 있어야 한다. 그래야 @Transactional이 인식될 수 있다.
  • 스프링이 트랜잭션 프록시를 만들때 트랜잭션 매니저를 사용하기 때문에, PlatformTransactionManager를 스프링 빈으로 등록해두어야 한다. 또한 PlatformTransactionManager는 DataSource 통해 커넥션을 얻기 때문에, DataSource도 스프링 빈으로 등록해두어야 한다.

참고. 선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리

  • 선언적 트랜잭션 관리: @Transactional 선언만으로 트랜잭션을 매우 편리하게 적용하는 것
  • 프로그래밍 방식 트랜잭션 관리: PlatformTransactionManager 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 개발자가 직접 작성하는 것
  • 전자가 훨씬 간편하고 실용적이어서 실무에서는 대부분 전자를 사용한다.
    후자는 테스트 시에 가끔 사용될 때는 있다.

service에 비지니스 로직, 트랜잭션 처리 로직이 함께 섞여있는 문제
-> @Transactional 통해서 해결


스프링부트의 DataSource, PlatformTransactionManager 자동 등록

  • 스프링부트가 등장하기 이전에는 DataSource, PlatformTransactionManager를 개발자가 직접 빈으로 등록해서 사용했다.
  • 스프링부트는 DataSource, PlatformTransactionManager를 자동으로 빈으로 등록한다.
  • 만약, 개발자가 직접 빈으로 등록하면 스프링부트는 등록하지 않는다.

스프링부트가 자동 빈 등록

  • 등록되는 빈 이름: dataSource, transactionManager
  • 어떤 DataSource가 등록? HikariDataSource
  • 스프링부트는 application.properties에 있는 다음 속성을 통해 DataSource를 생성한다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

만약, spring.datasource.url 속성이 없으면 내장 DB(메모리 DB)를 생성하려고 시도한다.

참고) 스프링부트는 application.properties를 바탕으로 다음 코드를 만들어주는 것이다.

HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
  • 어떤 PlatformTransactionManager가 등록? 현재 등록된 라이브러리를 보고 판단한다.
    예를 들어, JDBC 라이브러리가 있다면 DataSourceTransactionManager를, JPA 라이브러리가 있다면 JpaTransactionManager를 빈으로 등록한다. 만약, 두 라이브러리 모두 존재하면, JpaTransactionManager를 등록한다. (참고. JpaTransactionManager는 DataSourceTransactionManager를가 제공하는 기능을 대부분 지원함)

0개의 댓글