[Jpa] Transaction Scope and Isolation
트랜잭션은 어떤 작업을 하는데의 단위 묶음이라 한다. Jpa에서 중요한 역할을 하는데 Jpa에서 쿼리가 나가는 시점은 트랜잭션이 시작되고 끝나는 시점에 커밋이 되는 시점에서 쿼리가 발생한다. 또한 강제로 flush() 를 하면 쓰기 지연 sql저장소에 들어 있던 쿼리가 비워지면서 쿼리를 발생시킨다.
영속성과 트랜잭션의 관계
@Test
@DisplayName("member save")
@Transactional
@Rollback(value = false)
void memberSave() {
Member member = new Member("이기영", "기영@naver.com", Gender.MALE);
em.persist(member);
}
memberSave() 메서드를 한 작업의 묶음으로 설정하고 member 객체를 영속성에 올리는 과정이다. 어느정도 익숙한 코드일 것이다. 여기서 중요한 점은 db에 insert 를 시키는 코드가 없다.
테스트를 돌려보면 insert 쿼리가 발생하는 것을 볼 수 있고 연결된 db를 확인 해 보면 데이터가 들어간 것을 볼 수 있다.
만약 트랜잭션 단위로 되어 있지 않다면 어떻게 될까? 지속성이 필요한 작업의 단위에서 작업하라고 예외가 발생한다.
즉, 영속성(persistence)도 트랜잭션과 관련이 있다는 것을 알 수 있다.
트랜잭션 적용 범위
앞서 Member 객체를 저장하는 행위를 하였다. 코드를 리펙터링 해보자.
@Test
@DisplayName("member save")
@Rollback(value = false)
void memberSave() {
Member member = new Member("이기영", "기영@naver.com", Gender.MALE);
save(member);
}
@Transactional
private void save(Member member) {
em.persist(member);
}
save하는 과정을 메서드를 추출하여 private으로 설정하고 트랜잭션도 적용하였다. 과연 이 코드는 동작이 되는가?
😅 컴파일에서 바로 에러가 발생한다.
스프링에서 @Transactional 은 AOP 방식으로 프록시 패턴 동작이 된다.
해당 에노테이션이 붙은 메서드를 프록시 모드로 만들어 메서드를 사용하는데 non-public 메서드면 사용하지 못한다.
다음으로 final 키워드가 붙은 메서드도 사용하지 못한다. 기본적으로 오버라이딩 규칙을 생각하면 편할 것이다.
@Test
@DisplayName("member save")
@Rollback(value = false)
void memberSave() {
Member member = new Member("이기영", "기영@naver.com", Gender.MALE);
save(member);
}
@Transactional
public void save(Member member) {
em.persist(member);
}
이 코드는 성공일까?
실패다. 지속성이 있는 단위에서 작업을 하라고 예외가 발생한다.
🤔 분명 save()에 트랜잭션을 적용했는데 왜 적용이 안될까?
여기서 짚고 넘어가야 할 부분이 있다. 트랜잭션의 동작 원리이다.
트랜잭션의 동작은 AOP 기반으로 동작이 되고 있다. AOP는 관점 지향 프로그래밍으로 프록시 패턴으로 사용되고 있다.
🖐 프록시 패턴에 대해 잠깐 설명
구체적인 인터페이스를 사용하고 실행 시킬 클래스에 대한 객체가 들어갈 자리에 대리인의 객체를 대신 투입하게 하여 동작한다.
코드 구현
구체적인 인터페이스 생성
public interface Subject {
public String doAction();
}
인터페이스를 구현한 클래스 생성
public class RealSubject implements Subject{
@Override
public String doAction() {
return "Action";
}
}
프록시 클래스 생성
public class Proxy implements Subject {
private Subject proxy;
@Override
public String doAction() {
if (proxy == null) {
proxy = new RealSubject();
}
return proxy.doAction() + " Proxy";
}
}
클라이언트 입장에서 RealSubject 클래스를 사용하지 않고 Porxy 클래스 개게를 생성해서 RealSubject 클래스의 일을 대신 할 수 있다.
😭 그러므로 프록시가 내부 메서드에 있는 @Transactional 까지는 가져와서 사용하지 못한다는 얘기이다.
개별적으로 트랜잭션을 묶던가 별도로 트랜잭션 클래스를 만들어야 한다.
격리 수준
DEFAULT
- 데이터베이스의 격리 수준을 따른다(의존한다)
- mysql 기본 격리 수준
동작 테스트
트랜잭션의 한 단위 이므로 member 를 save() 하는 시점엔 insert 쿼리가 지연된다. db 반영은 안된다.
review를 save() 한 다음 커밋이 발생한다.
커밋이 발생되면 지연된 insert 쿼리가 발생하며 db에 반영이 된다.
READ_UNCOMMITTED (level 0)
- 다른 commit되지 않은 트랜잭션에 의해 변경된 데이터를 볼 수 있다
해당 트랜잭션에서 저장된 데이터를 조회한다. 이때 별도의 트랜잭션을 만들어 데이터 변경을 해보자.
mysql> start transaction;
mysql> update members set name='이기철';
다음으로 변경한 데이터를 다시 한번 조회한다.
변경이 된 데이터를 볼 수 있다.
하지만 아직 커밋이 되지 않아 데이터가 db에 반영이 안될 것이다. 마지막 스텝까지 넘어가서 조회를 해보면 반영이 안되어 조회가 된다.
이처럼 READ_UNCOMMIT 옵션은 커밋 되지 않은 데이터를 읽을 수 있게 된다. 이것을 더티 리드라고 한다.
만약 중간 로직에 데이터를 추가하거나 수정한다고 하면 더티 리드로 인해 데이터의 정확성을 해친다.
READ_COMMITTED (level 1)
- read_uncommitted의 더티 리드를 보완
- 커밋된 내용만 읽는다.
위의 테스트 한 내용에서 더티 리드를 방지하게 되어 commit된 내용만 읽게 된다. 그러므로 rollback을 하게 되면 업데이트한 내용은 반영이 되지 않는다.
하지만 조회만 하는 트랜잭션에서 다른 트랜잭션의 수정 작업으로 인해 조회 값이 달라질 수 있다. 이러한 상태를 unrepeatable read 라고 한다.
REPEATABLE_READ (level 2)
- unrepeatable read를 보안
- 반복 조회를 하는 트랜잭션에서 다른 트랜잭션의 수정 작업으로 인해 조회 값이 변경되더라도 항상 초기값 즉, 변경 되기 전의 값을 리턴해준다.
- 영속성 컨텍스트의 스냅샷과 비슷한 원리이다.
- 초기 값을 스냅샷으로 저장 하여 실제 db 데이터를 리턴해주는 것이 아니라 스냅샷의 데이터를 리턴 해 준다.
- commit 이 완료 되면 db의 반영된 데이터 값을 리턴 해 준다.
이 격리 레벨에서도 데이터의 정확성에 문제점이 있다. 한 트랜잭션 단위에서 다른 트랜잭션이 데이터를 수정하거나 추가를 하고 commit을 하면 기존 트랜잭션 내에서 Phantom Read 가 발생되고 다른 트랜잭션의 데이터 정보를 반영하게 된다.
다른 트랜잭션에서 insert를 해보자.
insert가 성공이 되면 커밋을 하여 다음 스텝을 확인해보자.
추가된 데이터가 조회 되었다.
SERIALIZABLE (level 3)
- 완벽한 읽기 모드를 제공한다.
- 키밋이 일어나지 않은 트랜잭션이 존재 하게 되면 select 하는 문장에서 lock을 걸어 놓는다.
다른 트랜잭션에서 insert 작업을 한다.
조회 스텝으로 넘어가서 확인해보자.
조회 쿼리에 lock 이 발생하는 것을 확인 할 수 있다.
이러한 결과를 보면 격리 레벨 마지막 단계는 다른 트랜잭션과는 완전히 격리하는 수준의 성격을 가진다.
참조 :
http://wiki.gurubee.net/pages/viewpage.action?pageId=21200923
https://www.baeldung.com/spring-transactional-propagation-isolation