수연초이 2022. 10. 2. 19:22
[10분 테코톡] 후니의 스프링 트랜잭션
[10분 테코톡]🌼 예지니어스의 트랜잭션
[10분 테코톡] 🐤 샐리의 트랜잭션
을 정리한 글

 

JDBC API에서의 트랜잭션

다음 구조의 문제점은?

RuntimeException이 발생하고 DB 테이블에 두개의 위치가 빈칸이 된다.

  • ChessService의 move 메서드는 두번의 update query를 날린다.
  • 각 update query는 새로운 트랜잭션을 생성하여 DB에 저장한다.
  • 이 때 각 쿼리마다 트랜잭션을 commit하기 때문에 updateSquare() 메서드 실행 시 트랜잭션이 DB에 커밋이 되고 그 상태로 RuntimeException이 발생하여 빈칸만 남은 데이터만 저장되고 메서드가 종료된다. (쉽게 말해 트랜잭션이 걸려있지 않은 경우, 롤백이 안된다는 것)

 

해결 방법: 하나의 비즈니스 로직을 단일 트랜잭션으로 관리하기

비즈니스 로직만 존재하던 코드에 트랜잭션 경계가 추가되고 비즈니스 로직은 하나의 트랜잭션에서 작동하게 되었다.

  • 트랜잭션을 비즈니스 로직이 시작되기 전에 열고 종료하기 전에 닫는다. ➡️ 트랜잭션 경계 설정
  • 커넥션을 가져온 뒤, 모든 내용이 성공할 때는 해당 트랜잭션을 커밋하고 실패하면 롤백한다. 마지막으로 트랜잭션을 종료한다.
  • 서비스의 비즈니스 로직에서 생성된 트랜잭션을 받기 위해, 인터페이스에서 Connection을 파라미터로 받도록 변경해야한다.🤢🤢
public interface SquareRepository {
	Long save(Connection conn);
    
	Square findById(Connection conn, Long id);
    
	void updateSquare(Connection conn, String position, String piece);    
}

 

이때, JPA를 사용하겠다는 요구사항이 생긴다면? ➡️ 데이터 접근 기술이 인터페이스를 변경해야한다.

public interface SquareRepository {
	Long save(EntityManager em);
    
	Square findById(EntityManager em, Long id);
    
	void updateSquare(EntityManager em, String position, String piece);    
}

 

(정리) 순수 JDBC API로 트랜잭션을 적용하였을 때의 단점

1. 깔끔하던 Service 코드가 복잡해진다.

2. 데이터 액세스 기술에 의존적인 코드를 작성한다.

3. 비즈니스 로직과는 다른 관심사(트랜잭션 경계설정 등)의 일을 수행한다.

 

스프링 트랜잭션 기술

JDBC API의 단점을 개선

1. 트랜잭션 동기화: 데이터 접근 기술과 트랜잭션 서비스 사이에 종속성 제거

2. 트랜잭션 추상화: 트랜잭션 기능을 쉽게 활용 가능

3. 선언적 트랜잭션: 비즈니스 로직과 트랜잭션 관련 로직을 완전히 분리

 

1. 트랜잭션 동기화

Service에서 생성한 Connection을 저장한다.

  • DataSourceUtils 클래스(Spring JDBC가 제공)의 getConnection 메서드를 사용하여 트랜잭션을 생성한다.
    • 트랜잭션이 존재하는 경우 가져다 쓰고, 존재하지 않으면 새로 등록해서 사용한다.
    • 트랜잭션 동기화가 active일 경우 현재 스레드에 바인딩된 Connection을 알 수 있다.
  • 트랜잭션은 thread-safe한 트랜잭션 동기화 매니저(TransactionSynchronizationManager)에 저장한다.
  • 트랜잭션 동기화 매니저에 저장된 커넥션이 존재하면 JDBC Template이 query를 날릴 때 저장된 커넥션을 가져온다.
    • 비즈니스 로직에서 날리는 쿼리 모두 하나의 local transaction으로 관리 가능
    • 파라미터에서 Connection 제거 가능
  • 트랜잭션이 종료되는 지점에 커넥션을 release(닫아)하면서 트랜잭션 동기화 매니저에 존재하는 커넥션을 함께 제거한다.
    • Connection을 종료하면서 동기화 작업도 중단한다.

트랜잭션 동기화를 적용하여 변경된 코드

동기화만으로 모든 문제점들을 해결하는 것은 아니다. 트랜잭션 경계 설정 방법은 JDBC Template이 내부적으로 DatasourceUtils를 사용하여 connection을 가져오기 때문에 가능하고, 이는 아직 데이터 접근 기술에 의존적이다.

 

2. 트랜잭션 추상화

(문제) 서비스에서는 SquareRepository 인터페이스를 의존하여 내부 구현에 대해서는 알지 못하는 상태이다. 하지만 Service 코드에는  DatasourceUtils를 사용한다. 즉, JDBC Template을 사용하는 구현체에 의존적이다. 데이터 접근 기술마다 데이터베이스와의 연결 방법이 다르기 때문에 이런 문제가 발생한다. JDBC는 Connection, JPA는 EntityManager, Hibernate는 Session을 사용해서 데이터베이스와 연결을 만든다. 따라서 JDBC를 사용하는 경우, JPA 트랜잭션 내에서 쿼리를 실행할 수 없다.

그러나 가져오는 트랜잭션의 객체가 다를 뿐이지, 실제로 모두 트랜잭션을 가져오고, 커밋하고, 롤백하는 역할을 한다! 구현 방식에 상관 없이 동일한 임무를 수행하는 구현체들에 대한 추상화가 가능하다. 따라서 스프링에서는 PlatformTransactionManager라는 인터페이스를 생성하여 각 구현체들이 트랜잭션을 가져오는 방식을 명세로 추상화하였다. 인터페이스에서 트랜잭션을 가져오고, 커밋하고, 롤백하는 기능을 명세로 만들고 추상 클래스를 거쳐서 데이터 접근 기술에 관계 없이 트랜잭션 동기화 매니저에서 트랜잭션을 가져온다. 

 

특정 데이터 접근 기술을 의존하지 않는다.

  • TransactionManager를 DI하여 사용한다.
  • 기본 트랜잭션 속성으로 트랜잭션을 생성한다.

트랜잭션 추상화를 적용하여 변경된 코드

하지만 여전히 비즈니스 로직(moveLogic())과는 다른 관심사의 일을 하고 있다.

 

3. 선언적 트랜잭션

@Transactional 어노테이션으로 트랜잭션을 생성하고 종료하는 일을 비즈니스 로직과 분리하게 도와준다. @Transactional은 AOP로 구현되어 있으며, AOP 프록시를 통해 활성화되고 트랜잭션 관련 메타 데이터를 참조하여 생성한다. @Transactional 어노테이션을 통해 타겟 메서드를 가진 클래스를 상속받아 트랜잭션 경계로 감싼 프록시로 만든다. 따라서 @Transactional으로 트랜잭션 경계설정을 하는 다른 관심사의 코드를 완전히 분리가능하다.

트랜잭션 어노테이션은 메서드, 클래스, 인터페이스 등에 적용가능하다. 클래스 상단에 적용된 어노테이션에 대해서는 해당 클래스에 존재하는 모든 메서드에 어노테이션이 적용된다. 중첩되어 존재하는 경우에는 메서드, 클래스, 인터페이스 순으로 우선 순위를 갖고 적용된다. 어노테이션으 적용된 메서드는 메서드 시작부터 트랜잭션이 시작되고, 메서드를 성공적으로 마치면 트랜잭션이 커밋되고 문제가 생기면 롤백된다. 코드에 일일히 붙이기 번거롭고 쉽게 놓칠 수 있다는 단점이 있지만, 세밀한 설정을 손쉽고 간편하게 할 수 있다.

이 외에도 tx namespace를 이용하여 선언적 트랜잭션을 만드는 방법이 있다. Bean 설정 파일에서 트랜잭션 매니저를 빈으로 등록하고 속성과 대상읠 정의해 트랜잭션을 적용하겠다고 명시하는 방식이다. 코드에 영향없이 일괄적으로 트랜잭션을 적용하고 변경할 수 있다는 장점이 있다.

 

부가 기능을 분리하여 순수한 비즈니스 로직만 코드에 담을 수 있다.

  • 선언적 트랜잭션은 타겟 오브젝트의 메서드부터 탐색한다.
  • 따라서 Service를 상속 받는 모든 클래스, 메서드에 동일한 트랜잭션 속성을 부여할 수 있다.
    • 클래스에 공통적으로 분류할 수 있다면 클래스에 @Transactional을 추가하면 되고, 세부적으로 설정해야하는 옵션이 있다면 메서드에 @Transactional을 추가하면 된다.

최종적으로 부가 기능을 분리하여 순수한 비즈니스 로직만 코드

 

스프링 트랜잭션 속성

스프링은 트랜잭션 경계설정 후 전파(propagation), 고립(isolation), 읽기동작(read-only), 타임아웃(timeout) 이라는 속성을 지정할 수 있다. 선언적 트랜잭션은 추가적으로 rollback-for, no-rollback-for이라는 추가 속성을 지정 가능하다. 

 

propagation - 전파 속성

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

트랜잭션의 경계에서 트랜잭션이 어떻게 동작할 것인가 결정

 

REQUIRED

  • 전파의 기본 속성
  • 트랜잭션이 있으면 참여하고, 없으면 새로 시작
    • 모든 트랜잭션 매니저가 지원하고 일반적으로 이 옵션만으로 개발 가능
  • 두 메서드가 하나의 트랜잭션으로 실행되므로 어느 메서드에서 문제가 발생해도, 실행한 모든 데이터가 롤백됨

 

REQUIRES_NEW

  • 항상 새로운 트랜잭션을 시작
  • 진행 중인 트랜잭션이 있는 경우 기존의 트랜잭션을 잠시 보류

 

SUPPORTS

  • 이미 트랜잭션이 존재하는 경우 참여
  • 그렇지 않을 경우 트랜잭션 없이 진행(커넥션이나 Hibernate 세션은 공유 가능)
    • 기존의 트랜잭션이 존재하면 참여하고 트랜잭션 active는 true이다. SUPPORT 트랜잭션만 실행하면 트랜잭션은 있지만, active는 false이다.

 

MANDATORY

  • 이미 트랜잭션이 존재하는 경우 참여하고 그렇지 않을 경우 예외 발생
  • 혼자서는 트랜잭션을 시작할 수 없고 메서드를 실행할 수도 없음

 

NESTED

  • 이미 진행 중인 트랜잭션이 있는 경우 중첩 트랜잭션을 시작
  • (REQUIRES_NEW와의 차이) 부모 트랜잭션의 커밋과 롤벡에 영향을 받는다. 하지만 자신의 커밋과 롤백은 부모 트랜잭션에게 영향을 줄 수 없다.
    • 부모 트랜잭션이 롤백되면 모든 데이터가 롤백되지만 NESTED 트랜잭션이 롤백되는 경우, 부모 트랜잭션에는 영향이 없다.

 

NEVER

  • 트랜잭션을 사용하지 않게 한다.
  • 트랜잭션이 존재하는 경우 예외를 발생시킨다.

 

NOT_SUPPORTED

  • 트랜잭션을 사용하지 않게 한다.
  • 트랜잭션이 존재하는 경우 트랜잭션을 보류한다.
  • 클래스에 @Transactional 어노테이션이 달려있지만 특정 메서드만 트랜잭션을 쓰고 싶은 경우 적용 가능한 옵션

정리

 

isolation - 격리 수준

여러 트랜잭션이 진행될 때에 트랜잭션의 작업 결과를 타 트랜잭션에게 어떻게 노출할지 결정

동시에 DB에 접근할 때 그 접근을 어떻게 제어할지에 대한 설정.

격리 수준과 성능은 반비례하므로, 케이스에 맞게 잘 선택해서 사용하자.

Dirty Read: 트랜잭션 A가 종료되지 못하고 롤백된다면, 트랜잭션 B는 무효가 된 데이터 값을 읽고 처리하면서 문제 발생
Non-Repeatable Read: 같은 트랜잭션 내에서 select 문으로 a의 값을 두 번 조회했는데 (다른 트랜잭션이 그 사이 a 값을 커밋해서) 두 값이 다른 값이 나오는 데이터 불일치 문제
Phantom Read: Non-Repeatable Read의 한 종류로, 조건이 걸려있던 걸려있지 않던, select 문을 사용할 때 나타나는 현상. 해당 쿼리로 읽히는 데이터에 들어가는 행위. 새로운 데이터가 생기거나 없어져 있는 현상. 즉, 새로운 로우를 추가하는 것은 제한되지 않아 새로운 로우가 생길 수 있음

 

DEFAULT

현재 사용하는 데이터 접근 기술의 기본 설정 혹은 DB 드라이버의 기본 설정을 따른다.

Oracle은 READ_COMMITED, MySQL의 Storage Engine인 InnoDB는 REPEATABLE_READ가 기본 격리 수준

 

READ_UNCOMMITED

트랜잭션1이 A를 2로 업데이트하고 커밋을 하지 않아도 트랜잭션2가 변경된 A의 값(2)을 읽는다.

  • 가장 낮은 격리 수준
  • 커밋 전의 트랜잭션의 데이터 변경 내용을 다른 트랜잭션이 읽는 것을 허용. 커밋되지 않아도 데이터가 노출
  • 데이터의 정확성은 떨어지지만 성능이 좋기 때문에 성능을 극대화할 때 사용하는 옵션
  • Dirty Read, Non-Repeatable Read, Phantom Read 문제 발생

 

READ_COMMITTED

트랜잭션1이 A를 2로 업데이트 했을 때, 커밋하기 전은 트랜잭션2가 A를 1로 읽고 커밋을 하고 난 뒤에는 트랜잭션2가 A를 2로 읽는다.

  • 커밋이 완료된 트랜잭션의 변경사항만 다른 트랜잭션에서 조회 가능하다. 다른 트랜잭션이 커밋하지 않은 정보는 읽을 수 없다.
  • 커밋되지 않은 데이터는 노출되지 않는다.
  • 트랜잭션2는 여전히 하나의 트랜잭션 내부이므로 읽는 시점에 따라 데이터가 변경될 수 있음을 유의하자.
  • Non-Repeatable Read, Phantom Read 문제 발생

 

REPEATABLE_READ

트랜잭션 1이 트랜잭션을 시작하고 A를 읽어왔고 트랜잭션2가 A를 2로 업데이트하고 커밋한다. 트랜잭션1에서 A는 여전히 1로 읽힌다. 커밋을 한 후 수정된 A의 값이 트랜잭션1에서 반영된다.

  • 트랜잭션 범위 내에서 조회한 내용이 항상 동일함을 보장한다. 다른 트랜잭션이 읽는 정보를 수정 반영할 수 없다.
  • 이미 읽은 Row를 수정 반영하지 않는다.
  • 트랜잭션 시작 시점에 snapshot을 생성해서 그때 읽은 정보들을 snapshot으로 생성한다. 해당 트랜잭션 내에서는 snapshot이 정보를 읽어오기 때문에 데이터가 커밋되기 전에는 동일하다. 
  • Phantom Read 문제 발생

 

SERIALIZABLE

트랜잭션 1이 커밋하기 전에는 트랜잭션2가 진행되지 않는다.

  • 트랜잭션이 순차적으로 진행된다. 한 트랜잭션에서 사용하는 데이터를 다른 트랜잭션에서 접근 불가
  • 매우 높은 격리성을 가지므로 극단적으로 안전한 작업이 필요한 경우에만 가끔 사용한다.

정리

각 격리 레벨에서 발생할 수 있는 문제점

 

timeout

  • 트랜잭션을 수행하는 제한 시간을 지정할 수 있는 옵션(초 단위)
  • 기본 옵션으로는 제한 시간이 없으므로 별도로 설정 가능하다.
  • REQUIRED나 REQUIRES_NEW와 같이 새로운 트랜잭션을 시작하는 전파 옵션과 함께 사용할 수 있도록 고안된 옵션이다.

 

readOnly

  • 기본 값은 false로 모든 작업 허용. 힌트로 사용된다.
  • 트랜잭션 내에서 데이터를 조작하려는 시도(update, insert, delete)를 막을 수 있다. 데이터 접근 기술, 사용 DB에 따라 힌트를 구현하지 않았다면 실제로 쓰기 행동이 발생되더라도 예외가 발생하지 않을 수 있다.
    • 구현이 되어있다면 트랜잭션 id 관련 설정의 오버헤드를 줄여서 실제 읽기 행동 시 참조하는 데이터를 감소시키기 때문에 성능이 개선된다.
    • 해당 옵션을 적용하면 flush 모드가 manual로 설정되어 JPA의 더티 체크 기능 무시. 성능 향상에 도움

 

rollback-for

  • 선언적 트랜잭션의 옵션
  • 트랜잭션은 기본적으로 RuntimeException(비검사 예외의 일종)시 롤백. CheckedException나 예외가 발생하지 않으면 커밋
  • CheckedException를 롤백 대상으로 삼고 싶은 경우 사용

 

no-rollback-for

  • 선언적 트랜잭션의 옵션
  • 롤백 대상인 RuntimeException(IOException 또는 SqlException)이 발생해도 커밋 대상으로 지정(롤백하지 않도록)하고 싶은 경우 사용

 

결론적으로 스프링 트랜잭션은 여러 격리레벨과 전파타입을 제공하는데, 중요한 데이터의 안전성에 관한 설정이므로 케이스에 따라 문제가 발생하지 않도록 각 속성을 잘 이해하고 선택해야한다.