스프링에서 트랜잭션을 어떻게 적용하는지에 대해서 글을 작성해 보겠다.
전에 포스팅 했던 트랜잭션 + 경쟁하면 생기는 문제점 글을 참고해서 보면 좋다!!
트랜잭션 (Transaction) + 경쟁하면 생기는 문제점 3가지
1. 트랜잭션 (Transaction) 데이터베이스의 상태를 변화시키기 위해 수행하는 작업단위 (1) 상태 변화데이터베이스의 상태를 변화시킨 다는 것은 쿼리문을 가지고 데이터베이스에 접근해 수정하고,
hyeonju0121.tistory.com
1. Spring에서의 트랜잭션 처리 방법 - @Transcational
클래스, 메서드 위에 @Transcational 어노테이션을 추가할 수 있다.
이 어노테이션을 추가하게 되면, 추가된 클래스나 메서드에 트랜잭션 기능이 적용된 프록시 객체가 생성된다.
프록시 객체는 @Transactional 이 포함된 메서드가 호출되는 경우에
PlatformTransaction Manager (트랜잭션 안에 매니징 해준다는 뜻)를 사용해서 트랜잭션을 시작하고, PlatformTransaction Manager 가 트랜잭션 전체가 모두 성공했는지, 아니면 예외가 있는지를 판단해서 커밋 또는 롤백을 해주는 작업을 한다!
스프링에서 @Transactional 이 어노테이션으로 트랜잭션의 단위도 표시해 주고 하나의 함수를 트랜잭션으로 만들어 줄 수 있다.
2. Spring 트랜잭션 세부설정
스프링에서는 트랜잭션을 처리할 때 이 다섯 가지의 속성들을 설정할 수 있게 지원해 준다.
Spring 트랜잭션 세부설정
1. Isolation (격리 수준)
2. Propagation (전파 수준)
3. Read Only 속성
4. 트랜잭션 롤백 예외
5. timeout 속성
이 세부설정들이 있다고 하면 생길 수 있는 의문들이 있다.
이 트랜잭션의 세부 설정들을 지정할게 뭐가 있나?
그냥 모든 트랜잭션들이 경쟁하는 일이 애초에 아예 안 생기도록 트랜잭션의 속성들을 지켜주면서 줄 세워서 트랜잭션이 기다려주면 되는 거 아닌가?
하지만, 이런 의문들이라면 트랜잭션의 세부 설정들을 할 필요가 없어진다.
아예 모든 트랜잭션의 경쟁 상황이 없도록 상황을 만든다면,
당연히 각각의 트랜잭션이 Dirty Read, Non-Repeatable Read, Phantom Read 등 이런 문제들이 발생하지 않는 걸 보장할 수 있지만 반대로 큰 단점도 존재한다!
모든 트랜잭션이 일일이 줄을 서서 수행하기 때문에 수행시간이 아주 길어진다 는 단점이 존재한다.
백엔드 개발자로서, 응답시간, 트랜잭션의 처리 등 모두 중요한 사항이다.
그래서 각자 만들고 있는 서비스에 따라서 이 두 가지를 합리적으로 만족시켜야 한다.
(1) 어떤 서비스에 어떤 로직의 경우에는 응답시간에 상관없이 각각의 트랜잭션이 어떠한 오류도 발생해서는 안된다.
그렇기 때문에 응답시간은 약간 포기하고 빡빡한 규정의 트랜잭션을 처리해야 되는 상황
(2) 잠깐 조회했을 때 값이 잘못 나오는 것쯤은 괜찮다. 그것보다는 빠른 응답시간을 가져오는 게 내 서비스에는 더 합리적일 것 같다는 상황
위 상황 예시들처럼 이 다섯 가지의 설정들을 잘 활용해서 트랜잭션을 얼마나 빡빡하게 강제를 할 것인지, 어떤 제약조건을 줄 것인지 등 설정할 수 있다.
2.1 Isolation (격리 수준)
트랜잭션에서 일관성이 없는 데이터를 허용하는 수준을 말한다.
Isolation 은 5단계의 격리 수준이 존재한다.
@Transactional(isolation=Isolation.DEFAULT)
(1) Default
- 기본 설정
- 각자 사용하고 있는 DB 마다 Isolation의 default 값이 있다. DEFAULT로 설정을 하게 되면, 사용하고 있는 DB의 default 값을 따르겠다!라는 의미
(2) READ_UNCOMMITTED
- Dirty Read 발생
- 헐거운 격리 수준이라고 생각하면 된다.
- 커밋되지 않은 (아직 트랜잭션이 진행되고 있는) 그 데이터에 대해서도 읽기를 허용하는 수준이다.
- ex) 어떤 A 사용자가 데이터를 수정하고 있을 때, B 사용자가 같은 데이터를 조회하고 접근을 했다. -> Dirty Read 문제가 발생할 수 있다.
(3) READ_COMMITTED
- Dirty Read 방지
- 커밋이 된 확정 데이터만 읽기를 허용한다.
- 트랜잭션이 진행 와중에는 읽을 수 없고, 트랜잭션이 진행되고 있는 와중에서 해당 데이터를 아무도 조회할 수 없게 격리를 시켜두었다가, 커밋이 되고 나면 접근을 할 수 있다. -> Dirty Read 방지 가능
(4) REPEATABLE_READ
- Dirty Read, Non-Repeatable Read 방지
- 트랜잭션 전체가 완료될 때까지 이 SELET 문장이 사용되고 있는 테이블의 부분은 Shared lock을 걸어서 다른 사용자는 그 영역에 해당하는 데이터를 조회할 수 조차 없게 격리시켜 버린다.
- 따라서, 앞선 트랜잭션이 읽고 있는 데이터는 트랜잭션 전체가 종료되기 전까지는 그 뒤에 트랜잭션이 값을 바꾼다거나 삭제하거나 이런 작업들이 불가능하다.
- 같은 데이터를 두 번 쿼리 했을 때도 일관성 있는 데이터를 리턴하게 된다!
(5) SERIALIZABLE
- Dirty Read, Non-Repeatable Read, Phantom Read 방지
- 정말 이 트랜잭션의 일관성이 유지되어야 한다!라고 할 때 사용하는 빡빡한 격리 수준이다.
- 데이터의 일관성과 동시성을 위해서 트랜잭션이 완료될 때까지 사용하고 있는 모든 데이터에 Shared lock이 걸린다. 따라서 다른 사용자는 그 영역에 해당하는 데이터를 수정하고 입력할 수 없다.
- SERIALIZABLE 격리 수준을 사용하면 트랜잭션의 일관성을 빡빡하게 지켜지겠지만, 사용하고 있는 해당 데이터는 Shared lock이 걸리게 되면서 다른 트랜잭션이 접근을 못하기 때문에 그에 따른 성능 저하가 발생한다.
2.2 Propagation (전파 수준)
A라는 함수를 트랜잭션으로 지정해 놨다.
B라는 함수에도 트랜잭션으로 지정해 놨다.
A라는 함수 내부적으로 함수 B를 호출하게 되어있다.
위 예시 상황처럼, 트랜잭션 동작 도중에 다른 트랜잭션을 호출하는 상황이 있다.
그러면, 트랜잭션이 동작하고 있는 와중에 다른 트랜잭션이 동작하게 된다.
이런 경우에는 기존 트랜잭션을 그대로 쓸 것인지,
트랜잭션이 동작하고 있다가 다른 트랜잭션이 동작했을 때 새로운 트랜잭션으로 여겨줄 것인지 결정해야 한다.
Propagation 은 트랜잭션을 시작하거나 기존 트랜잭션에 참여하는 방법에 대해서 결정하는 속성값이다.
(1) REQUIRED
- default 속성
- 트랜잭션 어노테이션에 전파 수준에 관련한 어떠한 옵션을 넣지 않는다면 REQUIED 가 DEFAULT로 지정되게 된다.
- 트랜잭션 A 안에 트랜잭션 B를 호출하는 게 있다면, 트랜잭션 A는 부모 트랜잭션이 되고 트랜잭션 B는 자식 트랜잭션이 된다.
- 부모 트랜잭션 안에 자식 트랜잭션까지 함께 실행되게 된다.
(2) SUPPORTS
- 이미 시작된 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 진행하게 만드는 옵션
- 부모 트랜잭션이 있었는데 그 안에 자식 트랜잭션이 들어오게 되면, 부모 트랜잭션에 참여를 하고 그렇지 않으면 트랜잭션 없이 진행하는 것!
(3) REQUIRES_NEW
- 부모 트랜잭션 안에서 함수 B가 동작하게 된 경우에 부모 트랜잭션이 있어도 자기만의 자식 트랜잭션을 위한 트랜잭션을 하나 새로 생성해서 동작을 하는 옵션
(4) NESTED
- 이미 진행 중인 트랜잭션이 있는 경우에 중첩 트랜잭션을 시작하는 옵션
- 중첩 트랜잭션이란, 이미 동작하고 있는 트랜잭션 안에 트랜잭션을 하나 더 생성하는 것을 말한다.
- 중첩된 트랜잭션은 먼저 시작된 부모 트랜잭션이 커밋되는지 롤백되는지 영향을 받지만, 이 안에 있는 중첩 트랜잭션이 커밋이 되든 롤백이 되든 부모 트랜잭션에는 영향을 주지 않는다.
- 부모 트랜잭션의 일부가 예외가 생겼으면 자식 트랜잭션도 같이 롤백이 되는데.. 부모 트랜잭션이 커밋이 되고 자식 트랜잭션이 예외가 생겼다면 자식 트랜잭션만 롤백이 된다!
NESTED 사용 예시
ex) 일기 작성 관련해서 로그를 DB에 저장하는 상황
1. 로그 저장이 실패한다고 해서 -> 일기 작성까지 롤백되면 안 된다.
2. 일기 작성이 실패하면 -> 로그 작성까지 롤백되어야 한다.
로그는 개발자 편의를 위해서 서버에 어떤 문제가 있는지 잘 동작하고 있는지 살펴보기 위한 툴일 뿐이지 로그 저장이 실패한다고 해서 사용자가 일기를 작성했는데 저장이 안 되면 안 된다!
하지만, 일기 작성이 실패했다면 그 관련된 로그는 DB에 저장이 되면 안 된다.
로그 저장은 실패하건 말건 일기 작성은 무조건 돼야 한다.
일기 작성이 실패하면 로그 작성까지 무조건 롤백돼야 한다.
이럴 때, 사용하는 게 NESTED이다!
일기 작성이라는 트랜잭션과 로그를 저장하는 트랜잭션은 서로 의존성이 다르다.
일기 작성 트랜잭션은 부모 트랜잭션이 돼야 하고,
로그 저장 트랜잭션은 일기 작성이라는 트랜잭션 안에 속해있는 중첩 트랜잭션이 되어야 한다.
이렇게 일기 작성 트랜잭션은 메인이라고 생각하고, 로그 저장 트랜잭션을 서브 트랜잭션이라고 생각해서 전파 수준을 NESTED로 설정하면,
의도한 대로 로그 저장이 실패한다면 로그 저장한 부분만 롤백이 되고, 일기 작성이 실패하면 일기 작성과 로그 저장 전체가 롤백된다.
만약에, 이런 상황에서 전파 수준을 NESTED 로 설정하지 않고 디폴트로 REQUIRED로 설정했다면
로그 저장 트랜잭션이 일기 저장 트랜잭션 안에 속해서 돌아가기 때문에
로그 저장만 잘못됐는데도 사용자가 작성한 일기가 날아가게 된다.
이럴 때는 전파 수준을 잘 고려해서 NESTED와 같은 옵션을 사용해야 한다.
2.3 read only 속성
트랜잭션을 읽기 전용 속성으로 지정하는 옵션이다.
이 옵션은 정말 유용하게 잘 쓰인다.
성능을 최적화하기 위해서 사용할 수도 있고, 특정 트랜잭션 안에서 읽기 외에 쓰기, 삭제, 수정과 같은 작업을 의도적으로 방지하기 위해서 사용할 수 도 있다.
@Transactional(readOnly=true)
읽기 전용 트랜잭션으로 설정했는 데, 그 안에서 쿼리문이 INSERT, UPDATE, DELETE와 같은 쿼리문이 동작하게 되면 예외가 발생한다.
기본적으로 이 옵션을 지정하지 않으면, readOnly = false이다.
조회만 하는 트랜잭션은 readOnly = true를 사용하게 되면, 혹시 모를 조회가 아닌 데이터를 수정하는 데이터 작업이 있다면 readOnly 가 true로 설정되어 있기 때문에 예외를 알려주게 된다. (로직 실수 방지)
그리고, readOnly = true 로 설정하면 이 트랜잭션 동작 성능이 매우 빨라진다!
ex). 어떤 클래스에 트랜잭션이 여러 개가 있을 때 클래스 전체에다가 readOnly = true 로 설정을 해둔다.
그런 다음에 클래스 안에 5개의 트랜잭션이 있다면, 그중 3개는 read만 하는 트랜잭션이고
2개는 쓰기, 수정하기 트랜잭션이라고 한다면, 쓰기, 수정 작업이 필요한 트랜잭션에만 readOnly = false로 설정해 두고그 외에는 true로 붙여주면 로직 성능 향상에 도움 줄 수 있다!
2.4 트랜잭션 롤백 예외
예외가 발생했을 때 트랜잭션 롤백시킬 경우를 설정하는 옵션이다.
만약에, 트랜잭션에 rollbackFor, noRollbackFor을 지정하지 않으면
스프링에서 발생하는 RuntimeException과 Error 가
트랜잭션 하는 와중에 발생하면 롤백이 되고, 그렇지 않으면 커밋이 되는 게 정상이다.
근데 스프링에서 발생할 수 있는 여러 Exception들이 있는 데 그럴 때 특정 Exception에 대해서는 롤백을 안 하고,
Exception이 발생해도 커밋을 진행시켰으면 좋겠다! 할 때, noRollbackFor = 특정 Exception으로 사용하면 된다.
이 트랜잭션 안에 어떤 Exception이 발생하더라도 롤백되지 않고, 커밋되는 것을 뜻한다.
런타임 외에도 발생할 수 있는 모든 Exception에 대해서 롤백을 하고 싶다면,
rollbackFor = Exception.class를 사용하면 된다.
2.5 Timeout 속성
일정 시간 내에 트랜잭션을 끝내지 못하면 롤백시키는 옵션이다.
@Transactional(timeout=10)
DB에 문제가 있거나, 엄청 오랜 시간 동안 트랜잭션이 끝나지 않는 상황이 발생할 수도 있다.
하나의 트랜잭션이 오랜 시간 동안 끝나지 않는데, 만약에 그 트랜잭션의 격리 수준을 빡빡하게 지정해 뒀다고 가정하면..
격리 수준도 빡빡해서 하나의 DB를 꽉 잡고 있는데 그 작업이 문제가 생겨서 10초 이상.. 20초 이상 막혀버리게 된다.
이 외에 다른 트랜잭션도 같은 위치의 데이터를 사용할 때 많이 오래 걸리게 된다.
그러게 되면, 이 서비스 자체에 응답 속도가 정말 확연히 떨어지는 결과가 생기게 돼버린다.
따라서, 트랜잭션의 격리 수준을 빡빡하게 설정해두었다면, timeout 설정도 같이 걸어두면 좋다.
격리수준을 빡빡하게 하지 않더라도, DB에서 데이터를 가져오는 과정이 오래 걸린다면 timeout 설정을 걸어두는 게 좋다.
3. 정리
Isolation, Propagation, Read Only, 트랜잭션 롤백 예외, timeout 등 이러한 속성들을 상황에 맞게 잘 사용하는 것이 중요하다.
DB의 예외상황이란 것은 백엔드 개발을 하다 보면 불가피하게 발생하는 일이기도 하다.
서버의 응답속도와 DB의 안정성. 이 두 개를 모두 완벽하게 잡는 것은 어렵다.
상황에 따라서 이러한 속성들을 잘 이용해서 내가 개발하고 있는 로직에 맞게
기존보다 더 효율적으로 설정할 수 있는 방법들은 없을까? 이런 고민들을 해봐야 한다.
그리고 스프링에서도 이런 트랜잭션을 설정할 수 있고, DB 상에서도 격리 수준이나 트랜잭션 관련 설정들도 할 수 있다.
격리 수준, 격리 수준을 가지고 여러개의 트랜잭션이 하나의 레코드와 결쟁할 수 있는 문제와 그거에 따라서 스프링에서 격리수준을 어떻게 바꾸고 어떤 문제를 해결할 수 있는지 잘 기억해야 한다.
그리고 ReadOnly는 사용하면 응답 속도의 도움이 되는 조건이기 때문에 이 옵션도 기억해둬야 한다!