트랜잭션(Transactional)

2022. 5. 24. 16:00 Spring Data/Spring Data JPA

트랜잭션

트랜잭션이란 더 이상 쪼갤 수 없는 최소 단위의 작업을 뜻하는 개념이다. 트랜잭션 경계 안에서 진행된 작업은 commit() 을 통해 모두 성공하던지, 아니면 rollback()을 통해 모두 취소돼야 한다.

하지만 트랜잭션이라고 모두 같은 방식으로 동작하는 것은 아니다. 이 밖에도 트랜잭션의 동작방식을 제어할 수 있는 몇 가지 조건이 있다.

트랜잭션 전파

트랜잭션 전파란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. 예를들어 A 라는 트랜잭션이 시작되고, 아직 끝나지 않은 시점에서 B 라는 트랜잭션안에 있는 메소드를 호출한다면 B는 어떤 트랜잭션에서 동작해야 할까?

자신을 호출한 A 트랜잭션에서 동작해야 할까, 아니면 자신의 트랜잭션인 B에서 동작해야 할까?

트랜잭션 전파

여러 가지 시나리오를 생각해볼 수 있다.

  1. A에서 트랜잭션이 시작돼서 진행 중이라면 B의 코드는 새로운 트랜잭션을 만들지 않고 A에서 이미 시작한 트랜잭션에 참여한다. 이 경우 B를 호출한 작업인 B.method() 까지 마치고, 그 이후 작업인 (2) 에서 예외가 발생한다면 A와 B가 A라는 트랜잭션에 하나로 묶여있기 때문에 A와 B의 작업이 모두 취소될 것이다.
  1. 반대로 B의 트랜잭션이 자신을 호출한 A 트랜잭션과 무관하게 독립적인 트랜잭션을 만들 수 있다. 이 경우 B의 트랜잭션 경계를 빠져나오는 순간 (B에서 작업이 끝난 순간) B트랜잭션은 독자적으로 커밋 또는 롤백될 것이고, A 트랜잭션은 그에 영향을 받지 않고 진행될 것이다. 이 경우 만약 A의 (2) 에서 예외가 발생하더라도 A의 트랜잭션만 롤백될 뿐 B에서 이미 종료된 트랜잭션의 결과에는 영향을 주지 않는다.

이렇게 B와 같이 독자적인 트랜잭션 경계를 가진 코드에 대해 이미 진행 중인 트랜잭션이 어떻게 영향을 미칠 수 있는가를 정의하는 것이 트랜잭션 전파 속성이다.

 

PROPAGATION_REQUIRED

가장 많이 사용되는 트랜잭션 전파 속성으로, 이미 진행 중인 트랜잭션이 없으면 새로 시작하고, 만약 A처럼 이미 진행 중인 트랜잭션이 있다면 새로 시작하지 않고 기존 트랜잭션에 참여 하는 방식이다.

 

PROPAGATION_REQUIRES_NEW

항상 새로운 트랜잭션을 시작하는 방식이다. 즉 앞에서 시작된 트랜잭션이 있든 없은 상관없이 새로운 트랜잭션을 만들어서 독자적으로 동작하게 한다. 독립적인 트랜잭션이 보장돼야 하는 코드에 적용할 수 있다.

 

PROPAGATION_NOT_SUPPORTED

이 속성을 사용하면 트랜잭션을 무시한다. 진행 중인 트랜잭션이 있어도 무시한다. 즉, 트랜잭션 없이 동작하도록 만드는 것이다. 그런데 트랜잭션 없이 동작하게 만들거라면 뭐하러 트랜잭션 경계를 설정할까? 그냥 트랜잭션 관련 코드를 지워버리면 그만 아닐까?

하지만 트랜잭션을 무시하는 속성도 아무 이유 없이 만들어진 것은 아닐것이다. 트랜잭션의 경계 설정 대부분은 AOP를 이용해 한 번에 많은 메소드에 동시에 적용하는 방법을 사용한다.

그 중에서 특별한 메소드만 트랜잭션 적용에서 제외하려면 어떻게 해야 할까? 물론 포인트컷을 잘 만들어서 특정 메소드가 AOP 적용 대상이 되지 않게 하는 방법도 있겠지만 포인트 컷이 상당히 복잡해질 수 있다.

그래서 차라리 모든 메소드에 AOP가 적용되게 하고, 특정 메소드의 트랜잭션 전파 속성한 해당 방식으로 설정해서 트랜잭션 없이 동작하게 만드는 것이다. 즉, 개발자의 편의를 위한 전파 속성이라고 생각해도 좋을 것 같다.

PROPAGATION_NESTED

이미 실행중인 트랜잭션이 존재한다면 중첩 트랜잭션을 만든다. 중첩 트랜잭션 이란 트랜잭션 안에 다시 트랜잭션을 만드는 것으로, 해당 방식에서는 부모 트랜잭션 안에 트랜잭션을 만든다고 생각하면 된다.

REQUIRES_NEW와 다른점은 REQUIRES_NEW은 독립적인 트랜잭션을 만드는 반면에, NESTED는 부모 트랜잭션 안에서 트랜잭션을 만든다는 점이다.

 

중첩 트랜잭션은 부모 트랜잭션의 커밋/롤백에는 영향을 받지만, 자신(중첩 트랜잭션)은 부모 트랜잭션에게 영향을 주지 않는다. REQUIRED와 마찬가지로 부모 트랜잭션이 존재하지 않으면 독립적으로 트랜잭션을 생성해서 사용한다.

 

PROPAGATION_MANDATORY

REQUIRED와 비슷하게 이미 진행중인 트랜잭션이 있으면 해당 트랜잭션에 합류한다. REQUIRED와 다른점은, 만약 진행중인 트랜잭션이 없다면 예외를 발생시킨다.

독립적인 트랜잭션을 생성하면 안되는 경우에 사용한다.

 

PROPAGATION_SUPPORT

이미 진행중인 트랜잭션이 있다면 해당 트랜잭션에 합류한다. 이미 진행중인 트랜잭션이 없다면 트랜잭션 없이 진행한다.

 

PROPAGATION_NEVER

트랜잭션을 사용하지 않도록 강제한다. 즉, 트랜잭션을 사용하지 않는다. NOT_SUPPORT와 다른점은 NOT_SUPPORT는 트랜잭션을 무시하고 보류하는 반면에, NEVER는 트랜잭션이 존재하면 예외를 발생 시킨다.

 

 

참고 : 트랜잭션 전파 옵션은 @Transactional(propagation = Propagation.NESTED) 와 같이 사용할 수 있다.

 

 

격리 수준

모든 DB 트랜잭션은 격리 수준을 갖고 있어야 한다. 서버에서는 여러개의 트랜잭션이 동시에 진행될 것이다.

가능하다면 트랜잭션이 순차적으로 실행돼서 다른 트랜잭션의 작업에 독립적인 것이 좋지만, 그렇게 하면 성능이 크게 떨어질 것이다.

따라서 각 트랜잭션마다 적절한 격리 수준을 설정해 가능한 많은 트랜잭션을 동시에 실행시키면서 문제가 발생하지 않게 하는 제어가 필요하다. 즉 트랜잭션 격리수준이 필요한 이유를 정리하자면 아래와 같다.

  • 트랜잭션이 DB를 다루는 동안 다른 트랜잭션이 관여하지 못하게 막는 Locking이라는 개념이 존재한다.
  • 무조건적인 Locking은 트랜잭션이 순서대로 처리되며, 이는 DB 성능을 저하시킨다.
  • 반대로, 응답성을 높이기 위해 Locking범위를 줄인다면 잘못된 값이 처리될 여지가 있다.
  • 따라서 최대한 효율적인 방법이 필요하다.

트랜잭션 격리수준 종류

1. READ UNCOMMITTED : 각 트랜잭션의 변경 내용이 커밋/롤백 여부와 관련 없이 다른 트랜잭션에서 값을 읽을 수 있다. 정합성 문제가 많은 격리 수준 이므로 사용을 지양해야 한다.

예를들어 S라는 대출 업체가 있다고 가정해보자.

  1. A 트랜잭션에서는 S 대출 업체의 이율을 20%에서 15%로 낮춤. 하지만 아직 커밋은 하지 않음
  2. B 트랜잭션에서 S 대출업체의 이율을 조회하면 15%가 조회됨 ( Dirty read)
  3. S 대출 업체는 이율이 너무 낮은 것 같아서 다시 20%로 올림 (즉, Rollback)
  4. B 트랜잭션은 S 대출 업체의 이율이 15%라고 생각하고 로직을 수행함

이런식으로 정합성 문제가 발생한다.

 

 

2. READ COMMITTED : 대부분의 RDBMS(MySQL은 아님!)에서 Default로 사용되고 있는 격리 수준이다. 어떤 트랜잭션의 변경 내용이 commit 되어야만 다른 트랜잭션에서 조회할 수 있다.

해당 격리 수준에서는 B 트랜잭션에서 S 대출 업체의 이율을 조회해도 20%로 읽는다. (커밋되지 않았기 때문에)

이는 실제 테이블 값을 가져오는 것이 아니라, Undo 영역의 백업된 레코드에서 값을 가져온다.

하지만 해당 격리 수준에도 문제점이 존재한다. 데이터의 정합성 문제는 해결되었지만, 하나의 트랜잭션 내에서 똑같은 SELECT를 수행했을 경우 항상 동일한 결과를 반환해야 한다는 REPEATABLE READ 정합성에 어긋난다.

  1. B 트랜잭션에서 S 대출 업체의 이율을 조회함. 결과는 20% (아직 커밋 되지 않았기 때문에)
  2. A 트랜잭션에서 커밋을 진행함. (이율 20% -> 15%)
  3. B 트랜잭션에서 다시 S 대출 업체의 이율을 조회함. 결과는 15%

즉, 위에서 설명한 REPEATABLE READ 정합성을 위반하는 문제가 발생하게 된다. 해당 문제점은 대부분의 애플리케이션에는 문제가 없지만 위 예제처럼 금전적인 부분을 다루는 문제라면 심각한 문제가 발생할 수 있다.

 

 

3. REPETABLE READ : MySQL에서 기본으로 사용하고 있는 격리 수준이며, 트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있는 격리 수준이다. 해당 격리 수준에서는 READ COMMITED 에서 발생하는 NON-REPEATABLE READ 정합성 문제가 발생하지 않는다.

간단히 말해서 자신의 트랜잭션 번호보다 낮은 트랜잭션 번호에서 커밋된 것만 읽어오는 것이다. 참고로 모든 DB의 트랜잭션은 고유한 트랜잭션 번호를 가지고 있으며, Undo 영역에 백업된 모든 레코드는 자신만의 고유한 트랜잭션 번호를 가지고 있다.

 

 

4. SERIALIZABLE : 가장 단순하고 엄격한 격리 수준이다. 모든 작업에 공유 잠금을 설정하기 때문에 동시에 다른 트랜잭션에서 해당 레코드를 변경하지 못하게 된다. 해당 격리 수준이 위에서 언급한 트랜잭션이 순차적으로 실행돼서 다른 트랜잭션의 작업에 독립적인 것 을 의미한다. 하지만 동시처리 능력이 다른 격리 수준보다 현저히 떨어지고 성능 저하가 발생하기 때문에 DB에서는 거의 사용하지 않는다.

 

트랜젝션 제한시간

트랜잭션을 수행하는 제한시간(timeout)을 설정할 수 있다.

@Transactional(timeout=10) 처럼 사용할 수 있으며, 지정된 시간 내에 메소드 수행이 완료되지 않으면 롤백된다. 이는 트랜잭션을 직접 시작할 수 있는 PROPAGATION_REQUIRED 또는 PROPAGATION_REQUIRES_NEW와 함꼐 사용해야만 의미가 있다.

 

읽기 전용

읽기 전용(read only)로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다. 즉 성능 최적화 또는 쓰기 작업 방지로 사용된다.

일반적으로 읽기 전용 트랜잭션이 시작된 이후 INSERT,UPDATE,DELETE 같은 쓰기 작업이 진행되면 예외가 발생한다. 만약 클래스 레벨에 @Transactional을 선언한다면, 쓰기 작업을 하지 않는 메소드에는 일일이 readOnly 설정을 해줘야 성능 최적화를 할 수 있다.

 


Reference

토비의 스프링 6장 - AOP

 

출처 : https://1-7171771.tistory.com/133?category=885255