https://www.youtube.com/watch?v=aX9c7z9l_u8

 

서론. 트랜잭션 사용.

스프링으로 프로젝트를할때 습관적으로 @Transactional 어노테이션을 사용한다.

이 @Transactional 을 왜 사용하는지.어떻게 사용하는지 알아보자.

그리고 데이터베이스 사용시 '트랜잭션'을 신경쓰고 중요시 해야한다고 한다.

이 트랜잭션은 무엇이기에 그래야할까?

한번 그 이유에 대해 알아보고, 스프링에서는 트랜잭션을 어떻게 지원하고 있는지 알아보자.

 

트랜잭션을 사용하는 이유

우리가 데이터베이스에 쿼리를 날릴때, 데이터베이스가 올바른 응답을 줄것이라는 확신을 어떻게 확신할까?

예시로 Crew들에 대한 간단한 정보를 가진 테이블이 존재한다고 가정한다.

모든 크루들의 나이를 조회하고자하자. 

SELECT AGE from CREW.

우리는 이 결과를 어떻게 신뢰할까?

데이터에 어떠한 장애가 발생해서 4개의Row만 출력될 수 있고, 16으로 출력되어야할 샐리의 나이가 20 으로 출력되는 것과 같이 잘못된 정보가 출력될 수도 있는것 아닐까?

 

이때, 우리가 기다리던 트랜잭션이 등장하게 된다.

데이터베이스에서는 트랜잭션을 조작함으로써 사용자가 데이터베이스에 대한 완전성을 신뢰할 수 있도록 하고 있다.

트랜잭션이랑 더 이상 나눌 수 없는 가장 작은 하나의 단위를 의미한다.

모든 데이터베이스는 자체적으로 트랜잭션을 지원하는데, 하나의 명령을 실행했을떄 데이터베이스가 온전히 그 명령을 실행해줄것을 의미한다.

데이터베이스는 기본적으로 트랜잭션을 관리하기 위한 설정을 갖고 잇다.

show variables like '%isolation';

데이터베이스에서는 명령을 끝마칠때까지 수행내역을 자신의 로그에 저장해둡니다.

데이터베이스에 반영된 내용을 재반영하기 위한 redo log와 수행을 실패해 이전의 상태로 되돌리는 undo log를 이용해 지원합니다. 

 

 

트랜잭션 예시로 알아보기

하지만 이렇게 데이터베이스에서 제공하는 하나의 명령이 아닌 여러명령을 하나의 트랜잭션으로 보고 싶은경우에는 어떻게 적용할 수 있을까?

트랜잭션에서 가장 흔히 볼 수 있는 입출금으로 예시를 들어보자.

샐리는 2만원이 있다. 바다는 5만원이 잇다.

1. 샐리는 바다에게 10000 원을 송금하고자 한다.

2. 샐리가 송금버튼을 누르면 샐리 계좌에 10000원보다 많은 금액이 존재하는지 확인한다. 20000원이 존재하므로 샐리의 계좌에서 10000원을 차감하고, 바다의 계좌에 10000원을 더한다. 일련의 업무는 절대로 분리되어서는 안되고 일부만 실행되어서도 안된다.

이렇게 절대로 깨져서는 안되는 하나의 작업을 트랜잭션이라고 한다.

 

트랜잭션은 4가지 성질을 바탕으로 신뢰를 보장한다. 원자성, 일관성, 지속성, 독립성이다.

4가지성질줄 하나라도 보장하지 않는경우의 예시이다.

만일 송금과정에서 3번까지 무탈하게 진행했는데 데이터베이스에서 어떤 장애가 발생해 4번 동작이 실행되지 않았다면(샐리에게서 1만원이 인출된 상태이고, 바다에게 실제로 돈은 안들어간상태다.),  바다는 왜 돈을 보내지 않았느냐고 물어보고, 샐리는 이미 계좌에서 돈이 나갔기에 전송했다고 생각한다.

이처럼 트랜잭션은 절대 꺠지지 않는 원자처럼 하나가 전부 실행되든, 실패하든 해야지 일부만 실행되는 경우는 없다는 원자성을 지닙니다.

 

이렇게 여러 명령을 실행 시에 어느 하나라도 실패했다면 이전에 성공한 내용인 1번부터 3번 동작 모두를 다시 없었던 일처럼 되돌려야한다. 이를 롤백이라 한다.

 

반대로 하나로 묶여있는 1번부터 4번까지의 동작을 모두 성공적으로 수행했다면, 수정된 내용을 데이터베이스에 반영한다. 이를 트랜잭션 커밋이라고 한다.

롤백이나 커밋이 수행되어야 트랜잭션이 종료되는 것이다.

 

다음으로는 데이터베이스의 상태. 데이터베이스 내의 계층 관계. 컬럼의 속성 등이 항상 일관되게 유지되어야한다는 일관성이 있다. 

예를들어 어떠한 컬럼의 속성이 수정되었다면 trigger를 통해 일괄적으로 모든 데이터베이스에 적용해야한다.

다음으로는 트랜잭션이 성공적으로 수행되어 커밋되었다면 어떠한 문제가 발생되더라도 데이터베이스에 그 내용이 영원히 지속되어야한다는 지속성이 있다.

이를 위해 모든 트랜잭션은 로그로 남겨져 어떠한 장애에도 대비할 수 있도록 한다.

마지막으로 트랜잭션 수행시 다른 트랜잭션이 작업을 끼어들 수 없고, 각 트랜잭션을 독립적으로 수행해야하는 독립성이 있다.

 

샐리 -> 삭정 <- 바다

1. 예를들어, 샐리와 삭정이 바다에게 10000원을 동시에 송금하고자한다.

2. 순차적으로 진행될경우 바다의 잔액은 7만원이 되어야하지만, 삭정이 송금했을때와 샐리와 송금했을때 바다의 금액이 동일하게 5만원으로 조회되어 잔액이 6만원인 문제가 발생가능하다.

트랜잭션은 격리 수준 설정을 통한 독릾성 보장으로 이러한 경우를 방지하고 있다.

 

하지만 데이터베이스에 작업이 들어왔을때 모든 작업의 독립성을 보장해 하나씩 순차적으로 진행하게 된다면, CPU는 DBMS보다 인풋 아우풋 작업을 빈번히 수행하기 때문에 트랜잭션을 순차적으로 수행하면 CPU는 점점 응답을 기다리는 시간이 길어져 프로그램이 비효율적으로 동작하는 문제가 발생할 수 있다.

이처럼 데이터베이스에 저장된 데이터의 무결성과 동시성의 성능을 지키기 위해 트랜잭션의 설정이 중요한 것이다.

데이터베이스에서는 각각의 명령을 하나의 트랜잭션으로 보고 보장해주기 때문에 여러 명릉을 하나의 트랜잭션으로 묶고 싶은경우 개발자가 직접 트랜잭션의 경계설정을 통해 트랜잭션을 명시하는 일이 필요하다.

스프링에서 트랜잭션 사용방법

우리가 사용하는 프레임워크인 스프링을 통해 트랜잭션의 경계설정을 데이터베이스에 전달할 수 잇다.

그렇다면 스프링에서는 이러한 트랜잭션을 어떻게 지원할까?

  • 스프링은 트랜잭션 추상화 인터페이스인 PlatformTransactionManager를 제공하여 다양한 DataSource에 맞게 Trnasaction을 제공하도록 한다.
  • getTransaction, commit, rollback 메소드들이 존재한다.
  • getTransaction 메서드 : 파라미터로 전달되는 TransactionDefinition 에 따라 트랜잭션을 시작한다.
  • commit 메서드 : 트랜잭션을 문제없이 진행하면 commit.
  • roolback 메서드 : 문제 발생시 Rollback 호출
public interface PlatformTransactionManager extends TransactionManager{
	TransactionStatus getTransaction(@Nullable TransactionDefinition definition);
    void commit(TransactionStatus status);
    void rollback(TransactionStatus status);
}

 

getTransaction 부터 commit이나 rollback을 하는 부분까지가 트랜잭션 경계설정이다.

그렇다면 스프링이 제공하는 다양한 트랜잭션 매니저 구현체는 무엇이 있을까?

대표적으로 DataSourceTransactionMaanger, JpaTranscationManager, JtaTransactionManager가 존재한다.

DataSourceTransactionMAnager 는 JDBC.

JpaTranscationManager 는 JPA.

public void method(){
	PlatformTransactionManager transactionManger = new DataSourceTransactionManager(datasource);
    
    //트랜잭션 시작
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    
    try{
    	update(user); //비즈니스 로직
        transactionManager.commit(status);
    } catch(Exception e){
    	transactionManager.rollback(status);
        throw e;
    }

}

 

이 Manager들은 하나의 데이터베이스를 사용하거나, 각각의 데이터를 독립적으료 사용하는 로컬 트랜잭션의 경우에 사용할 수 있다.

하나의 이상의 데이터베이스가 참여하는 경우라면, 글로벌 트랜잭션에 사용되는 JtaTransactionManager를 사용할 수 있다.

여러개의 데이터베이스에 대한 작업을 하나의 트랜잭션으로 묶을 수 있고, 다른 서버에 분산된 것도 묶을 수 있다.

이외에도 다른 DataSource가 들어올때도 사용할 수 있는 다양한 구현체들이 있다. 하지만, 이렇게 직접적으로 코드에 구현하는 방식 외에도 스프링은 AOP를 이용한 선언적 트랜잭션을 제공하고 있다.

 

 

선언적 트랜잭션은 tx 네임스페이스를 사용하는 방안과,

어노테이션을 기반으로 설정하는 방안이 잇다.

 

tx 네임스페이스를 이용하는 방식은 Bean 설정 파일에서 트랜잭션 매니저를 빈으로 등록하고 속성과 대상을 정의해 트랜잭션을 적용하겠다고 명시하는 것이다.

이렇게 적용하면 코드에는 영향을 주지않고, 일괄적으로 트랜잭션을 적용하고 변경할 수 있다는 장점이 잇다.

<bean id="transactionManager" class="org.springframework.jdbc.dataSource.DataSourceTransactionManager">
	<property name="dataSource"></property>
</bean>

<tx:advice id="txAdvice" transaction-manager="transactionManager">
	<tx:attributes>
    	<tx:method name="*" />
    </tx:attributes>
</tx:advice>

<aop:config>
	<aop:pointcut id="txPointcut" expression="execution(" "..MemberDao.*(..})" />
    <aop:advisor advice-ref="txAdvice" pointcut-ref="txPointcut" />
</aop:config>

 

 

다음으로는 우리가 가장 많이 사용하는 어노테이션 기반으로 트랜잭션을 선언하는 방식이다.

@Service
@Transactional
public class CrewService{
	private final CrewRepository crews;
    
    public CrewService(final CrewRepository crews){
    	this.crews = crews;
    }
    
    @Transactional
    public sendMoney(){
    	Crew sally = crews.findByName("셀리");
        if(sally.money < 10000){
        	throw new illegalArgumentException("잔액이 부족합니다");
        }
        sally.money -= 10000;
        Crew bada = crews.findByName("바다");
        bada.money += 10000;
    }
}

 

트랜잭션 어노테이션은 메서드, 클래스, 인터페이스에 적용할 수 있다.

클래스 상단에 적용된 어노테이션에 대해서는 해당 클래스에 존재하는 모든 메서드에 어노테이션이 적용된다.

중첩되어 존재하는 경우에는 클래스 메서드, 클래스, 인터페이스 메서드, 인터페이스 순으로 우선순위를 갖고 적용된다.

어노테이션이 적용된 메서드는 메서드 시작부터 트랜잭션이 시작되고, 메서드를 성공적으로 끝마치면 트랜잭션 커밋, 도중에 문제가 발생하면 롤백하는 과정이 진행된다.

어노테이션은 데이터베이스에 여러번 접근하면서 하나의 작업을 수행하는 서비스 계층 메서드에 붙이는 것이 통상적이다.

코드에 일일히 붙이기 번거롭고 쉽게 놓칠 수 있다는 단점이 있지만 보다 세밀한 설저을 손쉽고 간편히 할 수 있다는 장점이 잇다.

다음은 트랜잭션 어노테이션에 실제 존재하는 코드이다.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
	@AliasFor("transactionManager")
    String value() default "";
    
    @AliasFor("value")
    String transactionManager() default "";
    
    String[] label() default {};
    
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
    String timeoutString() default "";
    boolean readOnly() default false;
    Class<? extends Throwable>[] rollbackFor() default {};
    String[] rollbackForClassName() default {};
    Class<? extends Throwable>[] noRllbackFor() default {};
    String[] noRollbackForClassName() default {};
}

 

코드에서 알수 있다시피 트랜잭션 매니저도 속성으로 지정이 가능하다. 

빈으로 등록되어있는 특수한 트랜잭션 매니저를 저장하고 싶은경우에 지정해서 사용할 수 있다.

그렇다면 하단에 있는 다양한 속성은 무엇일까.?

트랜잭션이라고 모두 같은 방식이 아니다.

트랜잭션은 몇가지 속성을 가져 필요에 따라 속서지정이 필요한 추가할 수 잇다.

어떠한 속성을 줄 수 있는지 알아보자

 

@Transactional(propagation=Propagation.REQUIRED)

@Transactional(propagation=Propagation.SUPPORTS)

@Transactional(propagation=Propagation.MANDATORY)

@Transactional(propagation=Propagation.REQUIRES_NEW)

@Transactional(propagation=Propagation.NOT_SUPPORTED)

@Transactional(propagation=Propagation.NEVER)

@Transactional(propagation=Propagation.NESTED)

첫번째는 Propagation으로 전달받는 트랜잭션 전파이다.

트랜잭션 전파란 트랜잭션의 경계에서 이미 진행중인 트랜잭션이 있을떄 어떻게 동작할지를 결정하는 것이다.

디폴트 설정인 REQUIRED가 지정된 메서드1은 진행중인 트랜잭션이 없어 새로 트랜잭션 1 로 시작하고 이미 시작된 트랜잭션 1 이 있으면, 메서드2는 트랜잭션1에 참여하게 된다.

두 메서드가 하나의 트랜잭션으로 실행되기 때문에 어느 메서드에서 문제가 발생해도 실행했던 모든 메서드가 롤백됩니다.

 

다음은  Supports 입니다. 해당 설정이 된 메서드는 진행중인 트랜잭션이 있으면 REQUIRED처럼 참여하고, 트랜잭션이 없으면 트랜잭션없이 메서드를 실행합니다.

 

다음은 ManDATORY입니다. 설정이 된 메서드는 진행중인 트랜잭션이 있으면 참여하고 없으면 예외가 발생한다.

혼자서는 트랜잭션을 실행할 수 없고, 메서드를 실행할 수 없다.

 

다음은 REQUIRES_NEW 이다. 항상 새로운 트랜잭션을 시작한다. 이에 따라 메서드1은 트랜잭션1이 된다. 새로운 메서드2가 시작되었을때 진행중인 트랜잭션은 트랜잭션1을 잠시 보류시키고 자신의 메서드인 메서드2를 트랜잭션으로 실행한다.

 

NOT_SUPPORTED는 이미 시작된 트랜잭션이 있으면 보유하고, 자신의 메서드를 실행하는 트랜잭션을 사용하지 않는 설정이다. 그림과 같이 메서드2를 실행하려는데 트랜잭션1이 존재한다면 트랜잭션 1은 보류하고, 메서드2를 실행한다.

 

NEVER 설정은 트랜잭션을 사용하지 않도록 강제한다. 이미 진행중인 트랜잭션이 없다면 자신의 메서드를 실행하지만 그림과 같이 트랜잭션이 있다면 에외가 발생한다.

 

마지막으로 NESTED. 이미 진행중인 트랜잭션이 있으면 그 안에 새로운 트랜잭션을 만드는 설정이다. 그림과 같이 트랜잭션 1이 진행중인데 메서드2가 시작되었다면 트랜잭션1 내부에 메서드2 를 트랜잭션 2로 삽입한다. 이렇게 중첩된 트랜잭션2는 부모인 트랜잭션1의 커밋, 롤백에는 영향을 받지만, 트랜잭션 2의 커밋 롤백에는 트랜잭션1이 영향을 받지 않는다.

 

두번쨰로는 isolation으로 전달받는 트랜잭션 격리수준이다.

@Transactional(isolation=isolation.READ_UNCOMMITTED)

@Transactional(isolation=isolation.READ_COMMITTED)

@Transactional(isolation=isolation.REPEATABLE_READ)

@Transactional(isolation=isolation.SERIALIZABLE)

 

동시에 여러 트랜잭션이 실행될때 트랜잭션의 작업내역을 다른 트랜잭션에게 보여줄지 말지 결정하는것으로 가능한 많은 트랜잭션을 동시에 진행하면서도 문제가 생기지 않도록 하려는 설정이다.

 

기본적으로 데이터베이스에 설정되어있지만 이 속성을 통해 재설정할 수 있다.

 

첫번째는, READ_UNCOMMITTED는 가장 낮은 격리 수준으로 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽을 수 있는 설정이다.

이런경우, 트랜잭션 1 진행중에 A+1한데이터를 트랜잭션 2가 읽어왔는데 +100 실행 중 문제가 발생해 트랜잭션1이 Roll Back되면 트랜잭션2는 존재하지 않는 데이터를 읽어온것이 될 수 있다. 이 문제를 해결한것이 READ_COMMITEED이다.

 

두번쨰는 READ_COMMITTED이다. 가장 많이 사용되는 두번째로 낮은 격리수준으로 커밋되지 않은 정보는 읽을 수 없다. 트랜잭션 1 에서 작업이 완료되어 커밋되어야만 트랜잭션 2 는 A의 정보를 읽어올 수 있다. 하지만 트랜잭션2가 읽은 A를 또 다른 트랜잭션인 트랜잭션3이 수정할 수 있어 트랜잭션2가 다시 읽으면 값이 다르다는 문제가 발생한다. 이를 REPEATABLE_READ가 수정해주었다.

 

세번째는, REPEATABLE_READ, 세번쨰로 낮은 격리 수준이다.

하나의 트랜잭션이 읽은 로우를 다른 트랜잭션이 수정할 수 없게하엿다. 하지만 기존에 샐리만 있던 데이터베이스를 트랜잭션1이 조회해 셀리라는 로우를 얻었는데, 트랜잭션2가 바다라는 새로운 로우를 추가하는것은 제한되지 않아 다시 조회했을때 발견하지 못한 새로운 로우가 발견될 수 있다는 문제가 있다.

 

마지막 가장 강력한 격리수준인 SERIALIZABLE로 이 문제를 해결할 수 있다.

이 속성을 가진 트랜잭션이 있으면 동시에 같은 테이블의 정보를 접근할 수 없다. 하지만 트랜잭션을 순차적으로 수행하는것과 다를바없어서 성능이 매우 떨어지니 극단적인 상황에만 사용하도록 한다.

 

Isolation의 DEFAULT 설정은 데이터베이스의 기본설정을 따른다는 의미이다. 대부분의 DB는 READ_COMMITTED를 기본 격리 수준으로 갖고있지만, 올바른 트랜잭션 설정을 위해서는 확인을 필수적으로 해야한다.

데이터베이스에서는 이러한 격리수준에 따라 트랜잭션이 실행되는 동안 각기 다른 Lock을 걸고 데이터를 보호하고자 한다. 격리 수준이 높아질수록 더욱 강하게 Lock을 걸고 트랜잭션을 마치면 Lock을 해제한다.

 

다음 트랜잭션 속성은 timeout으로 지정되는 트랜잭션 제한시간이 있습니다.

@Transactional(timeout=10)

초단위로 제한시간을 지정할 수 있는데, 예시와 같이 어노테이션이 달린 메서드를 수행하는데 10초가 지나면 예외가 발생해 롤백된다. 따로 설정하지 않으면, timeout은 지정되어 있지 않다.

 

다음은 readOnly로 지정하는 읽기전용 트랜잭션이다.

@Transactional(readOnly=true)

true로 설정하면 트랜잭션 작업안에서 update, insert, delete 작업이 일어나는것을 방지한다.

추가적으로 이 옵션을 적용하면 flush 모드가 manual로 설정되어 jpa의 더티체킹기능을 무시할 수 있다.

이는 성능향상에 도움이 되기도 한다.

기본값은 false로 모든 작업을 허용한다.

 

다음은 rollbackFor로 전달받는 트랜잭션 롤백 예외이다.

@Transactional(rollbackFor=NoSuchElementException.class)

기본적으로 트랜잭션은 런타임예외와 Error가 발생했을때만 롤백한다.

하지만, 체크예외나 예외가 발생하지 않으면 커밋한다.

체크 예외를 롤백대상으로 삼고싶으면 특정 Exception을 클래스로 전달해 사용할 수 있다.

그림과 같이 설정하면 전달받은 No Such Element Exception에 대해 예외가 발생하면 롤백된다.

반대로 noRollbackFor로 지정하는 트랜잭션 커밋 예외도 존재한다.

이 설정이 존재하면 런타임에외인 IOExcpetion이나 SQLException이 발생하면 롤백대상인 두 예외를 롤백하지않고 커밋하도록 한다.

 

 

마무리

단순히 트랜잭션을 보는것 외에도 트랜잭션에 다양한 설정을 할 수 있다.

무작정 어노테이션을 붙이는것보다 상황에 맞게 어떠한 속성을 사용해야할지 고려하며 사용하자.

+ Recent posts