[토비의 스프링] Week10(6.6~6.7)

15 분 소요

토비의 스프링 3.1 Chapter 6.6 ~ 6.7

6.6 트랜잭션 속성

앞에서 살펴봤었던 DefaultTransactionDefinition 오브젝트는 TransactionDefinition 인터페이스를 구현하고 있고, 트랜잭션 동작 방식에 영향을 줄 수 있는 네 가지 속성이 정의되어 있다.

public void upgradeLevels() throws Exception {
    //------------------트랜잭션 경계설정 시작
    TransactionStatus status = this.transactionManager
						.getTransaction(new DefaultTransactionDefinition());
    //------------------트랜잭션 경계설정 시작 끝
    //------------------비즈니스 로직 시작
    try {
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
    //------------------비즈니스 로직 끝
    //------------------트랜잭션 경계설정 마무리 시작
        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
    //------------------트랜잭션 경계설정 끝
}
  • 트랜잭션 전파(Transaction Propagation)
  • 격리수준(Isolation level)
  • 제한시간(Timeout)
  • 읽기 전용(Read only)

트랜잭션 전파(Transaction Propagation)

트랜잭션 전파는 트랜잭션의 경계설정 코드를 수행하는 시점에, 이미 진행중인 다른 트랜잭션의 존재에 따라서 새 트랜잭션이 어떻게 동작할지 결정하는 방법을 말한다.

A라는 루틴이 트랜잭션 경계설정을 하고 트랜잭션이 진행중일 때, A 내부의 B 메서드가 호출되고 B 메서드 역시 트랜잭션을 시작한다면?

  • B는 A의 트랜잭션에 참여한다. 즉 하나의 트랜잭션 내에서 A와 B 로직을 모두 수행한다.
  • A와 B의 트랜잭션을 개별적으로 생성한다.
트랜잭션 시작
// logics...
B.method();
// logics...
트랜잭션 종료

method(){
		트랜잭션 시작...
		트랜잭션 종료..
}

PROPAGATION_REQUIRED

진행중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있다면 이에 참여한다.

PROPAGATION_REQUIRES_NEW

항상 새로은 트랜잭션을 시작한다.

PROPAGATION_NOT_SUPPORTED

트랜잭션 없이 동작하도록 한다. 진행중인 트랜잭션이 있더라도 무시한다. 트랜잭션을 안 쓸거면 뭐하러 경계설정을 하겠나 싶겠지만 AOP를 통해 메서드들에 일괄적으로 적용하는 상황에서 특정 메서드만을 트랜잭션에서 제외시키고 싶을 때 사용한다.

작은 의문이지만 beginTransaction이 아닌 getTransaction인 이유도 이런 트랜잭션 전파 옵션 입장에서는 항상 begin하지 않고 때론 기존의 트랜잭션에 참여할 수도 있기 때문이다.

격리 수준

모든 DB 트랜잭션은 격리수준을 가지고 있어야 한다. 이상적으론 모든 트랜잭션이 순차적으로 진행될 수 있으면 좋겠지만, 성능의 문제가 있을 수 있다. 가능한 많은 트랜잭션을 수행할 수 있으면서 동시에 문제가 생기지 않도록 조정이 필요하다.

참고 : UNDO와 REDO

트랜잭션은 상황에 따라 복구해야 할 일이 생긴다.

REDO는 사용자의 명령을 따라가며 마지막 확인된 체크포인트로부터 순차적으로 실행하여 복구한다면, UNDO는 사용자의 명령을 역순으로 실행하며 복구합니다.

즉 미완의 완성 vs 없던 것처럼 감쪽같이 되돌리기로 정리할 수 있습니다.

모든 SQL의 수행은 UNDO와 REDO의 로그로 저장이 됩니다.

참고 : 격리 수준 레벨 정리

READ UNCOMMITTED

어떤 트랜잭션에서 변경된 내용이, 다른 트랜잭션에서 commit이나 rollback과 관계없이 보여진다.

예를 들어서 A 트랜잭션에서 특정 row의 특정 column의 값을 변경하였지만 커밋하지 않은 상태에서, B 트랜잭션이 A 트랜잭션에서 변경한 내용을 읽어들일 수 있는 레벨이다. 이를 Dirty Read라고 하며 데이터 정합성의 문제가 생긴다.

READ COMMITTED

어떤 트랜잭션에서 변경된 내용은 commit되어야만 다른 트랜잭션에서 변경된 내용을 읽을 수 있다. 즉 UNDO 영역의 값을 불러들이며, 가장 널리 쓰이는 격리 수준이다. 그러나 NON-REPEATABLE READ 부정합 문제가 존재할 수 있다.

경기 정보를 담고 있는 DB가 있다고 가정하자.

  • A 트랜잭션에서 4번 경기의 현재 스코어를 조회했을 때 0:0 이었다.
  • B 트랜잭션에서 4번 경기의 현재 스코어를 1:0으로 업데이트 하고 커밋했다.
  • A 트랜잭션이 다시 4번 경기의 스코어를 조회했을 때 1:0으로 조회된다.

하나의 트랜잭션 내에서 SELECT를 수행하였는데 다른 결과가 나타나는 것을 NON-REPEATABLE READ 부정합이라고 한다.

금융정보와 같은 민감한 정보를 다룰 때 문제가 있을 수 있다.

REPEATABLE READ

어떤 트랜잭션에서 읽어들이는 값은 해당 트랜잭션이 시작되기 전에 commit된 값에 한정한다. InnoDB의 트랜잭션은 순차적으로 증가하는 번호를 부여받으며, 자신보다 낮은 번호를 가진 트랜잭션에서 commit된 값만을 보게 된다.

트랜잭션 별로 다양한 DB의 상태 버젼을 관리해야 하는 오버헤드가 있지만, 일반적으로는 트랜잭션이 금방금방 끝나기 때문에 큰 문제는 되지 않는다고 한다.

UPDATE 부정합이 발생할 수 있다.

START TRANSACTION; -- transaction id : 1
SELECT * FROM Member WHERE name='daebalprime';

    START TRANSACTION; -- transaction id : 2
    SELECT * FROM Member WHERE name = 'daebalprime';
    UPDATE Member SET name = 'daeyeon' WHERE name = 'daebalprime';
    COMMIT;

UPDATE Member SET name = 'supersexyguy' WHERE name = 'daebalrpime'; -- 0 row(s) affected
COMMIT;
  • 2번 트랜잭션에서 daebalprime 이름을 가진 사람의 이름을 daeyeon으로 변경한다.
  • 1번 트랜잭션에서 정보를 일관되게 읽을 수 있도록 2번 트랜잭션에서 UPDATE를 수행할 때 name = ‘daebalprime’의 내용을 언두로그에 남긴다.
  • 맨 아래의 UPDATE 문을 수행할 때, DB에 존재하는 실제 데이터, 즉 레코드 데이터의 해당 row에 잠금이 필요하지만, name = ‘daebalprime’의 경우 UNDO 로그에 있고, 이 영역에 대해선 쓰기 잠금이 불가능하다.
  • 결국 1번 트랜잭션이 바라보는 name = ‘daebalprime’에 대해 쓰기 잠금을 얻지 못하여 아무런 변화도 일어나지 않는다.

SERIALIZABLE

가장 강한 격리 수준이며 읽기까지 잠금을 관리한다. 가장 동시성이 떨어지며 성능 이슈가 동반된다. 데이터 정합성만큼은 확실하게 관리할 수 있다.

일반적으로 DB에 설정되어 있으며, JDBC 드라이버나 DataSource 등에서 재설정이 가능하며 트랜잭션 단위로도 수준 설정이 가능하다. DefaultTransactionDefinition의 기본 설정 격리 수준은 ISOLATION_DEFAULT, 즉 DataSource에 설정되어 있는 격리 수준을 그대로 따른다는 의미이다.

제한시간(Timeout)

트랜잭션을 수행하는 타임아웃을 설정한다. DefaultTransactionDefinition의 기본 설정은 무제한이다.

읽기 전용(Read only)

트랜잭션 내에서 데이터를 쓰지 못하도록 막는다. 상황과 기술에 따라서 성능 향상을 볼 수도 있다.

만약 트랜잭션 정의를 적용하고 싶다면, TransactionDefinition 오브젝트를 DI 받아서 TransactionAdvice가 사용하도록 하면 되지만, 모든 트랜잭션의 속성이 일괄적으로 바뀌는 문제가 있다. 원하는 메서드만 개별적으로 트랜잭션을 적용할 수 있도록 해보자.

TransactionInterceptor

스프링에서 제공하는 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 TransactionInterceptor를 제공한다.

프로퍼티

  • PlatformTransactionManager
  • Properties 타입의 transactionAttributes
    • 네 가지 기본 항목을 정의한 TransactionDefinition 인터페이스 + rollbackOn() 메서드를 가지고 있는 TransactionAttribute 인터페이스
    • rollbackOn() 메서드는 롤백을 수행할 예외상황을 결정하는 메서드다.
		// ...
    TransactionStatus status = this.transactionManager
						.getTransaction(new DefaultTransactionDefinition());
						// 트랜잭션 정의
		//...
    } catch (Exception e) { // 어떤 예외가 발생할 때 롤백을 처리할 지
        //...
    }
		//...

위의 코드에서 나타나듯이 트랜잭션 부가기능의 동작방식은 트랜잭션 정의 4가지 + 롤백이 수행될 예외를 통해 변경된다.

기존 코드의 문제는, 모든 종류의 예외에 대해서 트랜잭션을 롤백하도록 해야할까? 물어보면 아니다. 몇몇 비즈니스 로직상의 문제를 정의한 체크 예외의 경우 DB 트랜잭션은 커밋시켜야 하기 때문이다.

TransactionInterceptor는 2가지의 예외 처리 방식을 제공한다.

  • 런타임 예외의 경우 트랜잭션은 롤백된다.
  • 체크 예외를 던지는 경우 비즈니스 로직에 따라서 개발자가 의도한 예외라고 해석하고 트랜잭션을 커밋해버린다.

그리고 위 2가지 상황에 부합하지 않는 예외에 대해서 rollbackOn() 속성을 통해 특정 체크 예외의 경우에도 롤백시키거나, 그 반대도 가능하다.

TransactionAttribute를 일종의 Map 타입인 Properties 타입으로 전달 받는데, 그 이유는 메서드 패턴에 따라 다른 트랜잭션 속성을 부여할 수 있도록 하기 위함이다.

(Properties) transactionAttributes

메서드 패턴을 키로, 트랜잭션 속성을 값으로 갖는다.

메서드 패턴은 다음과 같이 정의한다.

PROPAGATION_NAME, ISOLATION_NAME, readOnly, timeout_NNNN, -Exception1, +Exception2
  • PROPAGATION_NAME : 필수항목, PROPAGATION_ 으로 시작
  • ISOLATION_NAME : 생략가능, ISOLATION_으로 시작
  • readOnly : 생략가능, 읽기전용
  • timeout_NNNN : 생략가능, 제한시간 지정. NNNN은 초단위로 지정
  • -Exception1 : 한 개 이상 등록가능, 체크 예외중 롤백 대상으로 추가할 것
  • +Exception2 : 한 개 이상 등록가능, 런타임이지만 롤백되지 않을 예외 지정

생략 시에는 DefaultTransactionDefiniton에 정의된 속성이 부여된다. 순서는 상관없으며 하나의 문자열로 모든 속성을 지정한다.

		<bean id="transactionAdvice" 
					class="org.springframework.transaction.interceptor.TransactionInterceptor">
        <property name="transactionManager" ref="transactionManager"/>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_REQUIRED,readOnly,timeout_30</prop>
                <prop key="upgrade*">PROPAGATION_REQUIRES_NEW,ISOLATION_SERIALIZABLE</prop>
                <prop key="*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>

주의할 점은, 이미 readonly가 아닌 트랜잭션이 시작된 상태에서 새로운 readonly 트랜잭션이 참여할 경우 무시된다.

readonly나 timeout의 경우 트랜잭션이 처음 시작될 때에만 적용이 된다. 그 외의 경우에는 진행중인 트랜잭션 속성을 따른다.

tx 네임스페이스 이용하기

tx 스키마의 전용 태그로 정의할 수 있다. 컨테이너에 의해 자주사용되는 기반 기술 설정의 한 가지 종류이기 때문이다.

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            https://www.springframework.org/schema/beans/spring-beans.xsd
                            http://www.springframework.org/schema/aop
                            http://www.springframework.org/schema/aop/spring-aop.xsd
                            http://www.springframework.org/schema/tx
                            http://www.springframework.org/schema/tx/spring-tx.xsd">

		// ...
		// advice 태그에 의해 TransactionInterceptor 빈이 등록됨
    <tx:advice id="transactionAdvice", transaction-manager="transactionManager">
				// 만약 transaction-manager 빈 아이디가 transactionManager이면 생략 가능
        <tx:attributes>
            <tx:method name="get*" /*... 각종 속성들 ...*/ />
						<tx:method name="upgrade*" propagation="..." isolation="SERIALIZABLE"/>
						// 오타 발생 시 xml 유효성 검사 선에서 알려줌.
            <tx:method name="*" />
        </tx:attributes>
    </tx:advice>
    

포인트컷과 트랜잭션 속성의 적용 전략

  1. 트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다
    • 트랜잭션을 적용할 타깃 클래스의 메서드는 모두 트랜잭션 적용 후보가 되어야 한다. 세밀하게 메서드 단위의 선정을 하는 대신.
    • 쓰기 작업이 없고 단순한 읽기 작업만 하는 메서드도 트랜잭션을 적용한다.
    • execution() 방식의 포인트컷 대신 bean() 표현식을 사용하는 것도 고려해보자.
  2. 공통된 메서드 이름규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다.
    • get, find와 같이 조회전용 메서드의 접두어를 정해두기
  3. 프록시 방식 AOP는 같은 타깃 오브젝트 내의 메서드를 호출할 때는 적용되지 않는다.
    • 다르게 말해 타겟 오브젝트가 자기 자신의 메서드를 호출할 때는 부가기능의 적용이 일어나지 않는다.
    • 이를 해결하기 위해선 스프링 API를 이용하는 방법과 AspectJ를 이용하는 방법이 있다.

트랜잭션 속성 적용의 원칙과 전략 정리하기

트랜잭션 경계설정 일원화

트랜잭션 경계설정 부가기능을 다양한 계층에서 중구난방으로 적용하는 것은 좋지 않다. 일반적으로 서비스 계층 오브젝트 메서드가 가장 적절하다.

서비스 계층을 트랜잭션 경계로 정했다면, DAO에 직접 접근하는 일은 지양하도록 한다.

서비스 빈에 적용되는포인트컷 표현식 등록하기

포인트컷 표현식을 비즈니스 로직의 서비스 빈에 적용되도록 작성한다.

트랜잭션 속성을 가진 트랜잭션 어드바이스 등록

바로 위에서 다루었던 tx 스키마를 이용한 트랜잭션 어드바이스 정의 방법을 이용한다. TransactionInterceptor를 tx:advice 태그를 이용해 등록하고, attributes를 잘 정의하도록 한다.

그리고 테스트를 수행한다.

6.7 애너테이션 기반의 트랜잭션 속성과 포인트컷

클래스나 메서드에 따라 세밀하게 정의된 트랜잭션 속성을 적용하고 싶을 경우 애너테이션을 지정하는 방법이 있다.

@Transactional

DOCS 참조하기

이 애너테이션의 타겟은 메서드와 타입이다. 즉

  • 메서드
  • 클래스
  • 인터페이스

에 적용할 수 있다.

이 애너테이션이 부착되어 있으면 TransactionAttributeSourcePointcut 포인트컷이 부착된 모든 오브젝트를 타겟 오브젝트로 알아서 인식한다.

애너테이션을 부착하는 것은 트랜잭션 속성을 정의함과 동시에 포인트컷의 자동등록에도 사용된다.

동작 방식

  • TransactionInterceptor는 패턴을 통해 부여되는 트랜잭션 속성정보를 무시하고,
  • TransactionAttributeSourcePointcut을 통해 타겟 오브젝트를 선정한다.
  • 애너테이션에서 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource를 사용한다.

대체 정책

@Transactional을 이용하면 메서드마다 유연하기 속성 제어는 쉬워지겠지만 코드가 지저분해지는 단점이 있다. 동일한 속성 정보를 가진 애너테이션을 반복적으로 부여해주어야 할 필요도 있는데, @Transactional을 적용할 때 fallback 정책을 통해 이를 줄일 수 있다.

  • 타겟 메서드
  • 타겟 클래스
  • 선언 메서드 (인터페이스 메서드)
  • 선언 타입 (인터페이스 그 자체)

순서를 따라 @Transactional이 적용되었는지 확인하면서 가장 먼저 발견되는 속성정보를 이용하도록 한다. 결국 이 순서를 통해 찾지 못한다면 트랜잭션 적용 대상이 아니라고 판단한다.

주의할 점

  • 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 적용한 @Transactional은 무시된다. 확신을 위해 타겟 클래스와 타겟 메서드에 적용하도록 하자.
  • 타입 레벨에 먼저 정의하고 특별한 케이스에 대해서만 메서드 레벨에 다시 애너테이션을 부여하도록 작성하는게 좋다. 대체 정책을 이용하여 중복을 줄이자.
  • 정보를 등록하여야 사용가능하다.

  • 트랜잭션 적용 여부가 확인하기 쉽지 않기 때문에 사용 정책을 확실하게 두어야 지저분해지지 않는다.

댓글남기기