Spring Transaction, 제대로 이해하기

2022. 3. 13. 23:42Spring

반응형

Spring의 @TransactionalAOP를 이용한 선언적 Transaction 처리 기법에 대해 이해하고,

각각의 방법에 따라 속성을 적용하는 것이 이 포스팅의 목표입니다.

 

본 내용은 토비의 스프링 3.1 을 학습하고 정리한 글입니다.

자세한 정리본은 깃허브를 참고해주세요.

 

포스팅을 적으며 실제로 테스트를 하기 위해 작성한 테스트 코드를 깃허브에 올려두었으니,

필요하다면 참고하시길 바랍니다.

 

 

Transaction?

데이터베이스의 상태를 변경하는 작업 또는 한번에 수행되어야 하는 연산들을 의미합니다.

즉, 병행 제어 시 처리되는 작업의 논리적 단위입니다.

 

Transaction은 하나의 흐름으로 하나의 실행이 성공하거나 실패하면 모든 연산들을 동일하게 처리합니다.

A, B, C의 연산을 한 묶음이라고 할 때 A는 정상 작동 됐지만,

B가 실패한다면 A의 작업 이력도 이전으로 돌립니다.

 

모든 작업이 성공한다면 완료 작업 상태를 모두 반영하고, 이것을 Commit이라고 합니다.

반면, 하나라도 실패하게 된다면 모든 사항을 폐기하고, 이것을 Rollback이라고 합니다.

 

Spring에서는 Data Access 계층에서 데이터베이스에 접근하여 데이터를 조작합니다.

스프링에서도 데이터를 조작하는 작업 단위가 있고,

여러개로 묶인 접근 동작 중 하나의 동작이라도 실패한다고 했을 때 문제가 될 수 있죠.

그래서 이번 포스팅으로 Spring의 트랜잭션 처리를 제대로 알아보도록 합시다.

 

트랜잭션은 4가지의 성질을 가지고 있습니다.

ACID 원칙이라고 하는데, 아주 기본적이기 때문에 반드시 알아야 하는 개념이라 훑고 가겠습니다.

 

ACID

✔️ Atomicity 원자성

: 한 트랜잭션 내의 실행 작업은 하나의 단위로 처리

즉, 모두 성공 또는 모두 실패하는 것을 의미합니다.

 

✔️ Consistency 일관성

: 트랜잭션은 일관성 있는 데이터베이스 상태를 유지

제약조건이나 데이터 규칙에 위반하지 않는 일관성을 의미합니다.

하나의 동작이 정상적인 흐름을 일관적으로 가질 수 있어야 한다는 원칙입니다.

 

✔️ Isolation 독립성

: 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 독립

격리성이라고도 합니다.

하나의 동작 중 다른 트랜잭션의 개입으로 데이터가 변경되어서는 안되겠죠?

따라서, 한 동작이 독립적으로 처리해야한다는 원칙입니다.

 

✔️ Durability 영속성

: 트랜잭션을 성공적으로 마치면 결과가 항상 저장

트랜잭션의 동작이 성공 후 Commit 된다면 영원히 반영되어야 한다는 원칙입니다.

 

 

 

TransactionManager

스프링은 트랜잭션 추상화를 반영했는데,

덕분에 특정 기술에 종속되지 않는 일관된 방식으로 트랜잭션을 적용할 수 있습니다.

 

PlatformTransactionManager가 바로 그 추상형으로,

스프링 트랜잭션 매니저의 핵심 인터페이스입니다.

 

 

PlatformTransactionManager

 

 

위의 그림에서 보시다시피, 모든 트랜잭션 매니저는 PlatformTransactionManager을 구현합니다.

 

트랜잭션을 관리할 매니저 객체의 추상형을 제작해두었기 때문에,

사용할 데이터베이스 및 데이터 액세스 기술이 달라도 일관되게 형식으로 적용할 수 있습니다.

 

참고로, PlatformTransactionManager는 아래와 같이 정의됩니다.

 

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
			throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;

	void rollback(TransactionStatus status) throws TransactionException;

}

 

getTransaction을 통해 트랜잭션의 경계를 설정하고, CommitRollback을 처리합니다.

트랜잭션의 경계를 설정한다는 의미는 아래에서 자세히 다룰 텐데요.

경계 설정에 따라 적절한 트랜잭션을 가져온다고 해서 begin()이 아닌 getTransaction()를 사용합니다.

 

PlatformTransactionManager 인터페이스는 직접적으로 사용할 일은 없기 때문에 사용법을 몰라도 되지만,

이를 구현한 트랜잭션 매니저의 종류를 알고 적절한 것을 선택해서 등록하는 방법은 알아둘 필요가 있습니다.

 

 

TransactionManager 종류

✔️ DataSourceTransactionManager

: JDBC 및 MyBatis 등의 JDBC 기반 라이브러리로 데이터베이스에 접근하는 경우에 이용합니다.

이 트랜잭션 매니저를 사용하려면 트랜잭션을 적용할 DataSource가 스프링의 빈으로 등록되어야 합니다.

 

✔️ HibernateTransactionManager

: 하이버네이트를 이용해 데이터베이스에 접근하는 경우에 이용합니다.

 

✔️ JpaTransactionManager

: JPA로 데이터베이스에 접근하는 경우에 이용합니다.

JpaTransactionManager는 LocalContainerEntityManagerFactoryBean타입의 빈을 등록해줘야합니다.


✔️ JtaTransactionManager

: 하나 이상의 DB 나 글로벌 트랜잭션을 적용하려면 JTA 이용할 수 있습니다.

JTA는 여러 개의 트랜잭션 리소스(DB, JMS 등)에 대한 작업을 하나의 트랜잭션으로 묶을 수 있고,

여러 대의 서버에 분산되어 진행되는 작업을 트랜잭션으로 연결해주기도 합니다.

 

 

DB가 하나라면 트랜잭션 매니저 또한 하나만 등록되어야 하고,
여러개라도 JTA를 이용한 글로벌 트랙잭션을 적용할것이라면 JtaTransactionManager 하나만 등록되어야 합니다.

독립된 두 개 이상의 DB라면 DataSource도 두 개가 등록되므로,

DB 수에 따른 트랜잭션 매니저를 등록할 수 있습니다.

 

 

 

Transaction 경계 설정

트랜잭션 경계를 지정한다는 의미는 트랜잭션이 어디서 시작하고 종료하는지 결정하는 것을 위미합니다.

그에 따라 종료할 때 정상 종료(commit)인지 비정상 종료(rollback)인지 결정합니다.

 

스프링에서는 선언적 트랜잭션 설정방식으로 유연하고 편리하게 Transaction을 설정합니다.

 

선언적 Transaction

선언적 트랜잭션을 이용하면 코드에는 전혀 영향을 주지 않으면서 

특정 메소드 실행 전후에 트랜잭션이 시작되고 종료되거나 기존 트랜잭션에 참여하도록 만들 수 있습니다.


선언적 Transaction은 선언형의 설정 방식으로,

Transaction Template와 달리 트랜잭션 처리를 코드에서 직접적으로 수행하지 않습니다.

 

 

Transaction Template(Transaction Script)

하나의 트랜잭션 안에서 동작해야 하는 코드를 한 군데 모아서 만드는 방식으로,

보통 1 트랜잭션 = 1 메소드로 구성되는데 아래와 같은 형식을 가집니다

 

(메소드의 앞부분) DB 연결
        ⬇
트랜잭션 시작 코드
        ⬇
(트랜잭션 내) DB를 액세스하는 코드
혹은 그 결과를 가지고 비즈니스 로직을 적용하는 코드

 

굉장히 반복적이고 수고스러운 코드가 되겠죠?

위와 같은 Template을 모든 메소드에서 적는 게 아닌,

단순한 표시, 선언으로 Transaction을 적용합니다.

 

 

이 선언적 방식에는 AOP를 통한 Transaction과 @Transactional가 있습니다.

자세히 알아볼까요?

 

 

@Aspect

AOP를 통한 Global Transaction을 설정할 수 있습니다.

트랜잭션을 반영하고 싶은 범위나 메소드가 있을 텐데, 하나하나 설정해주기에 코드 관리나 시간에 무리가 갈 수 있는데요.

규칙에 맞게 글로벌한 Transaction을 설정하면 편리한 관리를 할 수 있습니다.

 

이 때, 적용되는 타겟은 인터페이스가 되어야 합니다.

AOP의 동작원리인 JDK dynamic proxy가 인터페이스를 이용해 프록시를 만들기 때문입니다.

 

인터페이스 없이 설정을 통해 등록된 빈에도 AOP를 적용할 수도 있는데,

가능한 한 인터페이스를 사용할 것을 권장합니다.

클래스에 바로 적용할 겨웅 불필요한 수정자나 내부에서 사용할 메소드(가령 private 메소드같은)까지 트랜잭션이 적용되면 쓸데없는 트랜잭션 경계설정 작업을 수행하느라 그만큼 시간과 리소스를 소모하게 될 수 있기 때문입니다.

 

아래의 예시 코드를 보면 훨씬 이해가 될 것 입니다.

 

@Aspect
@Configuration
@RequiredArgsConstructor
public class TransactionConfig {
    private final static String BASE_DEFAULT_POITCUT = "execution(* com.gngsn.toby.transaction..*AopTargetService.*(..))";
    private final PlatformTransactionManager txManager;

    @Bean
    public TransactionInterceptor txAdvice() {
        TransactionInterceptor txAdvice = new TransactionInterceptor();
        Properties txAttributes = new Properties();

        DefaultTransactionAttribute defaultAttribute = new DefaultTransactionAttribute(TransactionDefinition.PROPAGATION_REQUIRED, rollbackRules);
        String attributesDefinition = defaultAttribute.toString();

        txAttributes.setProperty("*", attributesDefinition);

        txAdvice.setTransactionAttributes(txAttributes);
        txAdvice.setTransactionManager(txManager);
        return txAdvice;
    }

    @Bean
    public DefaultPointcutAdvisor txAdviceAdvisor() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(BASE_DEFAULT_POITCUT);
        return new DefaultPointcutAdvisor(pointcut, txAdvice());
    }
}

 

 

@Transactional

트랜잭션이 적용될 타깃 인터페이스나 클래스, 메소드 등에 @Transactional 어노테이션을 부여하여,

트랜잭션 대상으로 지정하고 트랜잭션의 속성을 제공합니다.

 

즉, 트랜잭션을 적용하고자 하는 곳에 @Transactional 을 붙이면 적용된다는 의미입니다.

@Transactional의 정의를 보면 아래와 같습니다.

 

@Target(value={TYPE,METHOD})
 @Retention(value=RUNTIME)
 @Inherited
 @Documented
public @interface Transactional {}

 

Transactional Annotation은 Target이 TYPE과 METHOD 입니다.

즉, Class, interface, enum (TYPE) 이나, Method (METHOD) 에 적용할 수 있다는 의미입니다.

 

두 개의 Target에 동시에 붙일 수도 있는데요, 그 때에는 메소드에 붙은 애노테이션이 우선시되어 적용됩니다.

아래의 예시를 보면, readMember에는 readOnly 속성의 default값인 false가 무시되고

메소드 범위의 설정이 우선시 되어 readOnly가 true로 적용됩니다.

 

@Transactional
public interface MemberAnnotService {
    @Transactional(readOnly=true)
    void readMember(long memberId);

    void addMember(List<Member> members);
}

 

이처럼 트랜잭션마다 다른 속성을 가질 수도 있습니다.

그럼, 트랜잭션이 가지는 속성에 대해 더 깊이 알아볼까요?

 

 

Transaction Properties

트랜잭션의 경계를 설정할 때에는 Propagation, Isolation, Timeout, ReadOnly

네 가지 트랜잭션 속성으로 트랜잭션마다의 설정을 지정할 수 있습니다.

 

또, 선언적 트랜잭션에서는 롤백과 커밋의 기준을 변경하기 위해

RollbackFor, noRollbackFor 이라는 두 가지 추가 속성을 지정할 수 있습니다.

 

즉, 선언적 트랜잭션은 여섯가지의 속성을 가진다고 볼 수 있는 것인데요.

각각의 속성을 자세히 알아보겠습니다.

 

 

📌 Propagation

: 트랜잭션 전파

 

트랜잭션을 시작하고, 혹은 트랜잭션을 수행하다가 다른 트랜잭션을 마주하면 어떻게 할까요?

그 처리 방식에 대한 설정입니다.

 

즉, 트랜잭션을 시작하거나 기존 트랜잭션에 참여하는 방법을 결정하는 속성입니다.

선언적 트랜잭션 방식의 장점은, 여러 트랜잭션 적용 범위를 묶어서 커다란 트랜잭션 경계를 만들 수 있습니다.

그렇다면 어떤 속성을 지정할 수 있는지 알아보겠습니다.


✔️ REQUIRED

: Default. 이미 시작된 트랜잭션이 있으면 참여하고, 없으면 새로 생성해 시작합니다.

대개 이 속성이면 충분하며, 자연스럽고 간단한 트랜잭션 전파 방식이지만 사용해보면 매우 강력하고 유용합니다.

하나의 트랜잭션이 시작된 후 다른 트랜잭션 경계가 설정된 메소드를 호출하면 자연스럽게 같은 트랜잭션으로 묶입니다.

 

✔️ SUPPORT

: 이미 시작된 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 진행합니다.

 

✔️ MANDATORY

: 이미 시작된 트랜잭션이 있으면 참여하고, 없으면 예외를 발생 시킵니다.

 

✔️ REQUIRES_NEW

: 항상 새로운 트랜잭션을 시작하고, 이미 시작된 트랜잭션이 있으면 보류하고 생성합니다.

 

✔️ NOT_SUPPORTED

: 트랜잭션을 전혀 사용하지 않고, 이미 시작된 트랜잭션이 있으면 보류합니다.

 

✔️ NEVER

: 트랜잭션을 강제로 사용하지 않고, 이미 시작된 트랜잭션이 있으면 예외를 발생합니다.

트랜잭션을 지원하지 않는 설정입니다.

NOT_SUPPORTED와 비슷하지만, 예외를 발생시키는 점이 다릅니다.

 

✔️ NESTED

: 이미 시작된 트랜잭션이 있으면 중첩 트랜잭션을 시작하고, 부모 트랜잭션에 영향을 주지 않습니다.

 

중첩 트랜잭션이라 트랜잭션 안에 트랜잭션을 만듭니다.

먼저 시작된 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만 자신의 커밋과 롤백은 부모 트랜잭션에 영향을 주지 않죠.

 

예를 들어 로그 파일을 저장하는 로직은 메인 로직의 중첩 트랜잭션으로 진행 될 수 있는데,

로그 저장 로직에 문제가 있어도 메인 로직은 실행이 되어야 하기 때문에 영향을 주면 안되지만

메인 로직에 문제가 있어서 롤백을 하면 로그 저장 로직에도 영향을 줄 수 있어야 합니다.

 

 

✍🏻 Example Code

// AOP - Advice 등록
DefaultTransactionAttribute defaultAttribute = new DefaultTransactionAttribute();
defaultAttribute.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);

// @Transactional
@Transactional(propagation = Propagation.PROPAGATION_REQUIRED)

 

 

 

📌 Isolation

트랜잭션 격리 수준

 

트랜잭션 격리 수준은 동시에 여러 트랜잭션이 진행될 때,

트랜잭션의 작업 결과를 다른 트랜잭션에게 어떻게 노출할 것인지를 결정하는 기준입니다.

 

예를 들어 Transaction A가 읽기 작업을 할 때, Transaction B 가 접근할 수 있느냐,

혹은 Transaction A가 Commit 전일 때 Transaction B가 접근할 수 있느냐 를 설정합니다.

 

 

✔️ DEFAULT

: 데이터 액세스 기술 또는 DB의 디폴트 설정을 따릅니다.

대부분의 DB는 READ_COMMITTED를 기본 격리 수준으로 갖지만,

데이터 액세스 기술이나 DB마다 다를 수 있기 때문에 DB나 드라이버 문서를 살펴볼 필요가 있습니다.

 

✔️ READ_UNCOMMITTED (level 0)

: 커밋되지 않는 데이터에 대한 읽기 허용

Dirty Read가 발생 가능
가장 낮은 격리 수준입니다. 하나의 트랜잭션이 커밋되기 전에 그 변화가 다른 트랜잭션에 그대로 노출되는 문제가 있습니다.

하지만 가장 빠르기 때문에 데이터의 일관성이 조금 떨어지더라도 성능을 극대화할 때 의도적으로 사용할 수 있습니다.

 

✔️ READ_COMMITTED (level 1)

: 커밋된 데이터에 대한 읽기 허용

-Dirty Read 방지
가장 많이 사용되는 격리 수준으로, 커밋되지 않은 정보는 읽을 수 없습니다.

대신 하나의 트랜잭션이 읽은 로우를 다른 트랜잭션이 수정할 수 있습니다.

따라서, 트랜잭션이 같은 로우를 다시 읽었을 때 다른 내용일 수 있습니다.

 

✔️ REPEATABLE_READ (level 2)

: 동일 필드에 대해 다중 접근 시 모두 동일한 결과를 보장

Non-Repeatable Read 방지
하나의 트랜잭션이 읽은 로우를 다른 트랜잭션이 수정하는 것을 막지만, 새로운 로우를 추가하는 것은 제한하지 않습니다.

따라서 SELECT로 조건에 맞는 로우를 전부 가져오는 경우, 트랜잭션이 끝나기 전에 새로 추가된 로우가 발견될 수 있습니다.

 

✔️ SERIALIZABLE (level 3)

: 읽기 작업에도 공유 잠금을 설정하게 되고, 다른 트랜잭션에서 변경하지 못함

트랜잭션이 완료될 때까지 SELECT 문장이 사용하는 모든 데이터에 shared lock이 걸리므로

다른 사용자는 그 영역에 해당되는 데이터에 대한 수정 및 입력이 불가능합니다.

가장 안전한 격리수준이지만 가장 성능이 떨어지기 때문에 극단적으로 안전한 작업이 필요한 경우가 아니라면 자주 사용되지 않습니다.

 

 

✍🏻 Example Code

// AOP - Advice 등록
DefaultTransactionAttribute defaultAttribute = new DefaultTransactionAttribute(TransactionDefinition.PROPAGATION_REQUIRED);
defaultAttribute.setIsolationLevel(1);

// @Transactional
@Transactional(isolation = Isolation.READ_UNCOMMITTED)

 

 

 

📌 Timeout

트랜잭션 제한시간

 

초 단위로 제한 시간을 지정할 수 있습니다.

디폴트는 시스템의 제한 시간을 따르는 것입니다.

직접 지정하는 경우 이 기능을 지원하지 못하는 일부 트랜잭션 매니저는 예외를 발생할 수 있습니다.

 

 

 

📌 ReadOnly

트랜잭션 읽기 전용

 

트랜잭션을 읽기 전용으로 설정할 수 있습니다.

성능을 최적화하기 위해 사용할 수도 있고,

특정 트랜잭션 작업 안에서 쓰기 작업이 일어나는 것을 의도적으로 방지하기 위해 사용할 수도 있습니다.

 

하지만 일부 트랜잭션 매니저의 경우 읽기 전용 속성을 무시할 수도 있으니 주의해야 합니다.

일반적으로 INSERT, UPDATE, DELETE 같은 쓰기 작업이 진행되면 예외가 발생합니다.

 

 

📌 RollbackFor

RollbackFor, RollbackForClassName

트랜잭션 롤백 대상 지정

 

선언적 트랜잭션에서는 런타임 예외가 발생하면 롤백합니다.
예외가 발생하지 않거나 체크 예외가 발생하면 커밋합니다.

 

체크 예외를 커밋 대상으로 삼는 이유는 체크예외가 예외적인 상황에서 사용되기 보다는,

리턴값을 대신해서 비즈니스 적인 의미를 담은 결과를 돌려주는 용도로 많이 사용되기 때문입니다.

 

스프링에서는 데이터 액세스 기술의 예외는 런타임 예외(언체크 예외)로 전환되어서

던져지므로 런타임 예외만 롤백 대상으로 삼은 것입니다.

 

하지만 원한다면 기본 동작방식을 바꿀 수 있는데요.

바로 이 RollbackFor, RollbackForClassName속성을 이용해서 설정할 수 있습니다.

체크 예외지만 롤백 대상으로 삼아햐 하는 것이 있다면 이 속성에 Rollback을 적용할 예외를 설정합니다.

 

 

✍🏻 Example Code

// AOP - Advice 등록
List<RollbackRuleAttribute> rollbackRules = new ArrayList<>();
rollbackRules.add(new RollbackRuleAttribute(Exception.class));

RuleBasedTransactionAttribute masterAttribute = new RuleBasedTransactionAttribute();
masterAttribute.setRollbackRules(rollbackRules);

// @Transactional
@Transactional(rollbackFor = Exception.class)

 

 

📌 noRollbackFor

noRollbackFor, noRollbackForClassName

트랜잭션 롤백 예외 대상 지정

 

위와는 반대로, 기본적으로 롤백 대상인 런타임 예외를 제외시키고 싶을 때 사용하는 속성입니다.

롤백 대상의 예외를 커밋 대상으로 지정해줍니다.

 

사용법은 위와 동일합니다.

 

 

 

 

 

그럼 지금까지 트랜잭션에 대해 알아보았습니다.

오타나 잘못된 내용이 있다면 댓글로 남겨주세요!

감사합니다 ☺️ 

 

 

반응형

Backend Software Engineer

Gyeongsun Park