본문 바로가기

SPRING

[SPRING] Transaction과 EventLister_트랜잭션 기본 개념

이벤트기반 API를 만들면서 겪어던 Transaction 이슈/ EventLister & TransasctionalEventLister 사용 등 생각 정리 차원에서 글정리,
스프링 트랜잭션의 내용은 `스프링5 레시피' 책의 내용을 참조


스프링 트랜잭션 관리

트랜잭션 관리는 엔터프라이즈 애플리케이션에서 데이터 무결성과 일관성을 보장하는 데 필수 기법이다. 스프링에서도 프로그램 방식의 트랜잭션 관리 및 선언전 트랜잭션 관리 기능을 지원한다.

  • 트랜잭션이란 쉽게 말해 연속된 여러 액션을 한 단위의 작업으로 뭉뚱그린 것.

트랜잭션의 속성은 ACID(원자성, 일관성, 격리성, 지속성)로 설명할 수 있다.

  • 원자성(Automicity) : 트랜잭션은 연속적인 액션들로 이루어진 원자성 작업이다. 트랜잭션의 액션은 전부 수행되거나 아무것도 수행되지 않도록 보장해야 한다.
  • 일관성(Consistency) : 트랜잭션의 액션이 모두 완료되면 커밋되고 데이터 및 리소스는 비즈니스 규칙에 맞게 일관된 상태를 유지한다.
  • 격리성(Isolation) : 동일한 데이터를 여러 트랜잭션이 동시에 처리할 경우, 데이터가 변질되지 않게 하려면 각각의 트랜잭션을 격리해야 한다.
  • 지속성(Durability) : 트랜잭션 완료 후 그 결과는 설령 시스템이 실패(트랜잭션 커밋 도중 전기가 끊어지는 경우와 같은)하더라도 살아남아야 한다. 보통 트랜잭션 결과물은 펏기스턴스 저장소에 씌어진다.

트랜잭션 관리(프로그램 방식 / 선언전 트랜잭션)

  • 프로그램 방식의 트랜잭션 관리
    • 비즈니스 메서드 중간에 트랜잭션 관리 코드를 직접 삽힙하여 커밋/롤백을 제어
    • 직접 코드를 명시하는 형태로 트랜잭션을 시작, 커밋, 병합할 수 있고, 여러 속성값을 지정하면 정교한 제어 가능
    • 선언전 트랜잭션 관리에 비해 유연하다.
    • 스프링 프록시를 추가하면 성능에 문제가 생길 수 있다고 여겨지는 경우에는 프로그램 방식의 트랜잭션 관리를 고려해보자.
  • 선언전 트랜잭션 관리
    • 선언을 사용해 트랜잭션 관리 코드를 비즈니스 메서드와 떼어놓는 것
    • 대부분의 경우 프로그램형 트랜잭션 관리보다 낫다.
      • 트랜잭션 관리는 공통 관심사이므로, AOP를 이용해 모듈화할 수 있으며, 스프링은 AOP 프레임워크를 사용해 선언전 트랜잭션 관리를 지원

TransactionTemplate을 이용해 '프로그램 방식'으로 트랜잭션 관리하기

트랜잭션 템플릿은 트랜잭션이 적용될 코드 블록을 캡슐화한 트랜잭션 콜백 객체를 실행한다. 콜백 인터페이스는 별도 클래스 또는 내부 클래스 형태로 구현하는데, 내부 클래스로 구현할 경우에는 메서드 인수 앞에 final을 선언해야 한다.

 public class TrnsactionalJdbcBookShop extends JdbcDaoSupport implements BookShop {

    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlaformTransactionManager transactionManager) {
      this.transactionManager = transactionManager;
    }

   @Override
   public void purchase(final String isbn, final String username) {
     TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);

     transactionTemplate.execute(new TransactionCallbckWithoutResult() {
       protected void doInTransactionWithoutResult(TransactionStatus status) {
         ....
       }
     });
   }
TransactionTeplate은 TransactionCallback<T> 인터페이스를 구현한 트랜잭션 콜백 객체, 또는 이 인터페이스를 구현한 프레임워크 내장 객체 TransactionCallbackWithoutResult를 받는다.

purchase () 메서드처럼 반환값이 필요없으면 'TransactionCallbackWithoutResult 정도로 충분하고,
어떤 값을 반환하는 코드 블록은 반드시 TransactionCallback<T> 인터페이스를 구현해야 하며 콜백 객체의 반환값은 템플릿에 있는 T execute() 메서드가 반환한다.

트랜잭션을 직접 시작, 커밋/롤백해야하는 부담에서 벗어난 것이 가장 큰 혜택
- 콜백 객체를 실행하다가 UncheckedExeception이 발생하거나 명시적으로 doInTransactionWithoutResult() 메서드의 TransactionStatus 인수에 대해 setRollbackOnly() 메서드를 호출하면 트랜잭션이 롤백된다. 그밖에는 콜백 객체 실행이 끝나자마자 트랜잭션이 커밋된다.

@Transactional을 붙여 선언적으로 트랜잭션 관리하기

메서드에 @Transactional만 붙이면 트랜잭션이 걸린 메서드로 선언된다.
주의할 점은 스프링 AOP가 프록시 기반으로 움직이는 한계 때문에 public 메서드에만 이런 방법이 통한다.

트랜잭션 전달 속성 설정하기

트랜잭션이 걸린 메서드를 다른 메서드가 호출할 경우엔 트랜잭션을 어떻게 전달할지 지정할 필요가 있다.
이를테면 호출한 메서드 역시 기존 트랜잭션 내에서 실행하거나, 트랜잭션을 하나 더 생성해 자신만의 고유한 트랜잭션에서 실행하거나 할 수 있어야 한다.

트랜잭션 전달 방식은 propagation 트랜잭션 속성에 명시한다.

  • 스프링에서 지원되는 트랜잭션 전달방식 7 가지

    • REQUIRED : 진행 중인 트랜잭션이 있으면 현재 메서드를 그 트랜잭션에서 실행하되, 그렇지 않을 경우 새 트랜잭션을 시작해서 실행한다.
    • REQUIRES_NEW : 항상 새 트랜잭션을 시작해 현재 메서드를 실행하고 진행 중인 트랜잭션이 있으면 잠시 중단시킨다.
    • SUPPORTS : 진행 중인 트랜잭션이 있으면 현재 메서드를 그 트랜잭션 내에서 실행하되, 그렇지 않을 경우 트랜잭션 없이 실행한다.
    • NOT_SUPPORTED : 트랜잭션 없이 현재 메서드를 실행하고 진행 중인 트랜잭션이 있으면 잠시 중단시킨다.
    • MANDATORY : 반드시 트랜잭션을 걸고 현재 메서드를 실행하되 진행 중인 트랜잭션이 없으면 예외를 던집니다.
    • NEVER : 반드시 트랜잭션 없이 현재 메서드를 실행하되 진행 중인 트랜잭션이 있으면 예외를 던진다.
    • NESTED : 진행 중인 트랜잭션이 있으면 현재 메서드를 이 트랜잭션의 중첩 트랜잭션 내에서 실행한다. 진행 중인 트랜잭션이 없으면 새 트랜잭션을 시작해서 실행한다. 이 방식은 장시간 실행되는 업무(처리할 레코드가 1,000,000 개라고 할 경우)를 처리하면서 배치 실행 도중 끊어서 커밋하는 경우 유용하다. 이를테면 10,000 레코드당 한번씩 커밋하는 경우, 중간에 일이 잘못되어도 중첩 트랜잭션을 롤백하면 (1,000,000 개 전체가 아닌) 10,000개 분량의 작업만 소실

    사례

    • REQUIRED
      user a가 도서 2권을 계산대에서 체크아웃할 때 이 user의 잔고 사정상 첫 번째 도서는 구매할 수 있지만 두 번째 도서는 구매하기 부족하다고 할 경우
      check() 메서드의 시작, 종료 지점을 경계로 그 안에서 오직 하나의 트랜잭션만 존재하다가 메서드가 끝나면 커밋된다.
      즉 user1 유저는 도서를 한권도 구입하지 못한다.(예외 발생으로 인한 rollback)
@Override 
@Transactional 
public void checkout(List<String> isbns, String username) {
  for (String isbn: isbns:) { 
    bookShop.purchase(isbn, username); 
  } 
}

@Transactional(propagation = Propagation.REQUIRED) // default 옵션
public void purchase (String isbn, String useraname) {
 ...
}
  • REQUIRES_NEW
    진행 중인 트랜잭션이 있으면 잠깐 중단시키고 트랜잭션을 새로 시작해서 그 트랜잭션 안에서 메서드를 실행시킨다.
    따라서 첫 번쨰 트랜잭션은 checkout() 메서드에서 시작하지만 이 메서드가 첫 번쨰 purchase() 메서드를 호출하면 첫 번째 트랜잭션은 잠시 중단되고
    새 트랜잭션이 시작된다. 새 트랜잭션은 첫 번째 purchase() 메서드가 끝나면 커밋되기 때문에 두 번쨰 purchase() 메서드에서 롤백이 발생해도 첫 번쨰 트랜잭션(purchase)은 처리된다.

트랜잭션 격리 속성 설정하기

동일한 애플리케이션 또는 상이한 애플리케이션에서 여러 트랜잭션이 동시에 같은 데이터를 대상으로 작업을 수행하면 어떤 일이 일어날지 예측하기 어렵다.
이럴 때엔 여러 트랜잭션이 다른 트랜잭션과 어떻게 격리되어야 하는지 지정해야 한다.

두 트랜잭션 T1, T2가 있을 때 동시성 트랜잭션으로 발생할 수 있는 문제는 다음 네 가지이다.

  • 오염된 값 읽기(Dirty read) : T2가 수정 후 커밋하지 않은 필드를 T1이 읽는 상황에서 나중에 T2가 롤백되면 T1이 읽은 필드는 일시적인 값으로 더이상 유효하지 않다.
  • 재현 불가능한 읽기(Nonrepetable read) : 어떤 필드를 T1이 읽은 후 T2가 수정할 경우, T1이 같은 필드를 다시 읽으면 다른 값을 얻는다.
  • 허상 읽기(Phantom read) : T1이 테이블의 로우 몇 개를 읽은 후 T2가 같은 테이블에 새 로우를 삽입할 경우, 나중에 T1이 같은 테이블을 다시 읽으면 T2가 삽입한 로우가 보인다.
  • 소실된 수정(Lost updates): T1, T2 모두 어떤 로우를 수정하려고 읽고 그 로우의 상태에 따라 수정하려는 경우이다.
    T1아 먼저 로우를 수정 후 커밋하기 전, T2가 T1이 수정한 로우를 똑같이 수정했다면 T1이 커밋한 후에 T2 역시 커밋을 하게 된다.
    그러면 T1이 수정한 로우를 T2가 덮어쓰게 되어 T1이 수정한 내용이 소실된다.

이론적으로 이런 저수준의 문제를 예방하려면 트랜잭션을 서로 완전히 격리(SERIALIZABLE)하면 되겠지만, 그렇게 하면 트랜잭션을 한 줄로 세워놓고 하나씩 실행하는 꼴이라서 엄청난 성능저하가 유발된다. 이떄문에 실무에서는 성능을 감안하여 트랜잭션 격리 수준을 낮추는 게 일반적이다.

스프링이 지원하는 트랜잭션 격리 수준

  • DEFAULT : DB 기본 격리 수준을 사용. 대다수 DB는 READ_COMMITTED이 기본 격리 수준이다.
  • READ_UNCOMMITTED : 다른 트랜잭션이 아직 커밋하지 않은(UNCOMMITTED) 값을 한 트랜잭션이 읽을 수 있다.
    따라서 오염된 값 읽기, 재현 불가능한 읽기, 허상 읽기 문제가 발생할 수 있다.
  • READ_COMMITTED : 한 트랜잭션이 다른 트랜잭션이 커밋한(COMMITTED) 값만 읽을 수 있다.
    이로써 오염된 값 읽기 문제는 해결되지만 재현 불가능한 읽기, 허상 읽기 문제는 여전히 남아있다.
  • REPETABLE_READ : 트랜잭션이 어떤 필드를 여러 번 읽어도 동일한 값을 읽도록 보장한다.
    트랜잭션이 지속되는 동안에는 다른 트랜잭션이 해당 필드를 변경할 수 없다.
    오염된 값 읽기, 재현 불가능한 읽기 문제는 해결되지만 허상 읽기는 여전히 남아있다.
  • SERIALIZABLE : 트랜잭션이 테이블을 여러 번 읽어도 정확히 동일한 로우를 읽도록 보장.
    트랜잭션이 지속되는 동안에는 다른 트랜잭션이 해당 테이블에 삽입, 수정, 삭제 할 수 없다. 동시성 문제는 모두 해소되지만 성능은 현저히 떨어진다.