JPA : 트랜잭션 격리 수준과 JPA의 락
- Spring/JPA
- 2022. 2. 23.
이 게시글은 자바 표준 ORM JPA를 공부하고 개인적으로 정리한 글입니다
트랜잭션의 성질
트랜잭션은 ACID 성질을 만족해야한다. 트랜잭션의 ACID는 아래를 이야기한다.
- Atomic(원자성)
- 트랜잭션 내의 동작은 같이 성공하거나, 같이 실패해야한다.
- Consistency (일관성)
- 트랜잭션이 성공적으로 완료되면 일관적인 DB상태를 유지한다. 예를 들면 트랜잭션이 성공하더라도, DB의 유니크 제약 조건이 지켜지는 것을 의미한다.
- Isolation(격리성)
- 트랜잭션과 트랜잭션은 서로 영향을 미치지 않아야 한다.
- Durability(지속성)
- 트랜잭션이 완료된 것은 DB에 반영되어야한다.
기본적으로 트랜잭션을 이용하면 ACD(원자성, 일관성, 지속성)은 만족한다. 그렇지만 한 가지 만족하지 못하는 것이 있다. 트랜잭션을 사용하더라도 트랜잭션끼리 영향은 끼칠 수 있다. 트랜잭션끼리 영향성을 미치게 되면 동시성에서 문제가 발생한다.
트랜잭션 격리수준 4단계
트랜잭션은 기본적으로 서로 다른 트랜잭션끼리 영향을 미친다. 그렇지만 트랜잭션이 현재 어떤 격리 수준이냐에 따라 트랜잭션끼리 미치는 영향의 정도가 달라질 수 있다. 격리 수준은 총 4단계로 나누어진다.
- Read Uncommited
- Read Commited
- Repaeatable Read
- Serilazation
Drity Read | Non-Repeatable Read | Phantom Read | |
Read Uncommited | O | O | O |
Read Committed | X | O | O |
Repeatable Read | X | X | O |
Serialization | X | X | X |
각 단계가 어느정도 격리 수준을 이야기하고 실제로 어떤 문제가 있을 수 있는지를 살펴보자.
Read Uncommitted
Read Uncommitted는 트랜잭션 내에서 수정되었지만 아직 커밋되지 않은 값도 읽는다. 이 때 문제점은 커밋되지 않은 값을 읽었을 때, 그 트랜잭션이 문제가 있어 RollBack이 발생하면 데이터 정합성이 깨진다는 문제점이 있다. 이 문제점을 "Ditry Read"라고 한다.
Read Committed
Read Committed는 Commit된 값만 읽는 것을 보장한다. 현재 트랜잭션에서 수정 중인 값은 읽지 않는다. 대부분의 DB는 Read Commited 수준의 트랜잭션 격리 수준을 기본으로 한다고 한다. Read Commited에서 발생할 수 있는 문제점은 Non-Repeatable Read라는 것이 있다.
예를 들어 트랜잭션 A가 값을 불러와서 수정하고 있다. 이 때, 트랜잭션 B가 엔티티를 조회했다. 이 때 값은 nklee다. 트랜잭션 A가 수정한 값을 Commit했다. 이 때 DB에는 nklee → nklee2로 변경점이 발생한다. 이 때 트랜잭션 B가 다시 한번 동일한 엔티티를 조회했다. 이 때 값은 nklee2가 된다. 트랜잭션 B는 동일 트랜잭션 내에서 동일 엔티티를 조회했으나 조회한 시점에 따라 nklee, nklee2 라는 값을 각각 보게 된다. 즉, 동일 트랜잭션 내에서 똑같은 것을 읽었을 때 서로 다른 값이 나오는 문제가 있고, 이걸 Non-Repeatable Read라고 한다.
Repeatable Read
Repeatable Read는 동일 트랜잭션 내에서 같은 엔티티를 조회하면 항상 동일한 값이 있는 것을 보장하는 트랜잭션 격리 수준이다. 예를 들어 트랜잭션 A에서 PK = 1 인 엔티티를 조회하면 항상 동일한 값이 나온다는 것이다. 그렇지만 Repeatable Read에도 'Phantom Read'라는 문제가 발생한다.
Phantom Read는 쉽게 말해 없던게 생기고, 있던게 없어지는 현상을 의미한다. 한 가지 예를 들어보자. 트랜잭션 A는 emp 테이블 전체를 조회했고, 이 때 10개의 엔티티를 받았다. 트랜잭션 B는 이후 emp 테이블에 엔티티 하나를 insert하고 commit한다. 트랜잭션 A는 다시 한번 emp 테이블 전체를 조회하고, 이 때 11개의 엔티티를 반환받는다. 트랜잭션 A는 동일한 emp 테이블를 조회했으나, 갑자기 Row가 하나 더 증가하는 것을 확인했다. 이처럼 없던게 생기고, 있던게 없어지는 현상을 Phantom Read라고 한다.
Serialization
트랜잭션 격리 수준이 가장 높은 단계다. 주로 SQL의 "Select for Update" 구문 등을 이용해 현재 트랜잭션이 이용하고 있는 부분에 락을 걸어 사용할 수 없도록 만든다. 따라서 데이터 정합성은 향상된다. 그렇지만 락이 걸려 다른 트랜잭션은 그 데이터를 사용할 수 없기 때문에 '병렬성'은 저하된다.
Non-Repeatable Read vs Phnatom Read
Non-Repeatable Read는 데이터를 읽었을 때 동일 Row에서 다른 값이 읽힐 때를 의미한다.
Phnatom Read는 동일한 값을 읽었을 때, Row가 생성 / 삭제되는 것을 바라본 문제점이다.
두 번의 갱신 문제
두 번의 갱신 문제는 트랜잭션의 격리 수준과는 조금은 다른 문제다. 두 번의 갱신 문제는 동일한 엔티티를 트랜잭션 A, 트랜잭션 B가 각각 수정했을 때 어떤 것을 DB에 반영할지를 결정하는 문제다. 이 때, 고려할 수 있는 방법은 총 세 가지가 있다.
- 먼저 Commit 된 것이 DB에 반영됨.
- 나중에 Commit 된 것이 DB에 반영됨.
- 트랜잭션 A,B에서 Commit된 내용을 Merge해서 반영함
트랜잭션 A,B의 변경 내용을 Merge해서 반영하는 것은 아주 특별한 경우라고 한다. 마지막에 Commit 된 것이 DB에 반영되는 것이 기본적인 동작방식이다. 그렇지만 JPA에서는 @Version 어노테이션을 이용해 "먼저 Commit된 것을 DB에 반영한다"라는 전략을 손쉽게 구현할 수 있다.
JPA의 낙관적 락
낙관적 락은 '트랜잭션 충돌이 거의 발생하지 않는다'라는 낙관적인 가정에서 동작한다. 낙관적 락의 특징 중 하나는 트랜잭션이 Commit 되는 시점에 충돌 여부를 알 수 있다. 아래 설명을 참고하면 알겠지만, 트랜잭션을 커밋하는 시점에 Select 혹은 업데이트 쿼리를 보내면서 충돌 여부를 체크하기 때문이다.
동작 | 격리 수준 | 이점 | |
None | 커밋 시, Version 정보를 Update 쿼리로 보냄 |
조회 ~ 수정 보장 | 두 번의 갱신 문제 해결 |
Optimistic | 커밋 시, Version 정보를 Select 쿼리로 보냄 |
조회 ~ 조회 보장 | Dirty Read, Non-Repeatable Read 해결 |
Optimistic.Force_Increment | 커밋 시, Version 정보를 Update 쿼리로 보냄 |
조회 ~ 수정 보장 | 연관관계 엔티티 함께 버전 관리 |
LockModeType.None
용도
조회한 엔티티를 수정할 때, 다른 트랜잭션에 의해 변경되지 않도록 한다. 조회 시점 → 수정 시점까지의 트랜잭션 격리를 보장한다.
동작
- 엔티티를 불러옴.
- 커밋 시점에 업데이트 쿼리를 보냄. 이 때 where절에 Version 정보를 함께 보냄.
- Version 정보 분기
- Version 정보로 검색됨 → 엔티티가 수정되지 않았음 → Version을 올리고 종료함.
- Version 정보로 검색 안됨 → 엔티티가 수정됨 → "ObjectOptimisticLockingFailureException"
이점
두 번의 갱신 분실 문제를 예방한다.
테스트 코드1 → 업데이트 없이 조회만 하는 경우
@Test
@DisplayName("None Type Test : 업데이트 없으므로 쿼리가 나가지 않음. ")
void test5() {
Member member = new Member();
member.setName("memberA");
memberRepository.save(member);
memberNum = member.getId();
// Member 정보 수정 X → 업데이트 X
memberService.noneLockTypeUpdateOnlySelect();
}
- 업데이트 없이 조회만 하는 경우에는 Version의 증가가 없음
- Update 쿼리를 날릴 때, Version을 증가시키기 때문
- Query는 Select 쿼리만 발생함.
테스트 코드2 → 업데이트 없이 조회만 하는 경우
@Test
@DisplayName("None Type Test : 업데이트 쿼리 나감.")
void test6() {
Member member = new Member();
member.setName("memberA");
memberRepository.save(member);
memberNum = member.getId();
// Member 정보 수정 → 업데이트 발생
memberService.noneLockTypeUpdate();
}
- 업데이트 쿼리가 나감. 업데이트 쿼리 시, where절에 현재 Version 정보가 들어감.
- Update 실행은 현재 Version + 1 이 되어있음. 따라서 커밋이 완료되면, Version + 1이 됨.
- Query는 Select + Update 쿼리가 발생함.
테스트 코드3 → 엔티티 조회 시점 / 엔티티 수정 시점 Version 정보가 다른 경우.
@Test
@DisplayName("None Type Test : 조회 시점 Ver != 수정 시점 Version → 예외 발생 ")
void test7() throws InterruptedException {
Member member = new Member();
member.setName("memberA");
memberRepository.save(member);
memberNum = member.getId();
CountDownLatch latch = new CountDownLatch(2);
ExecutorService executorService = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
executorService.execute(() -> {
memberService.noneLockTypeUpdate();
latch.countDown();
});
}
Thread.sleep(1000);
}
- 멀티 쓰레드 환경으로 동일한 엔티티 수정하도록 함.
- 이 때, 한 쓰레드가 먼저 조회 → 수정 완료 후 Version을 0 → 1로 올림
- 다음 쓰레드가 Update 쿼리 시 where 절에 Version = 0이 들어감. 그렇지만 DB에는 없기 때문에 Exception이 발생함.
실행결과 ObjectOptimisticLockingFailureExceptino 발생함.
테스트 코드4 → 연관관계 주인이 아닌 엔티티에서 연관관계 주인 추가
@Test
@DisplayName("None Type Test : 연관관계의 주인 엔티티 추가 → Version은 바뀌지 않는다. ")
void test8() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
// Member 엔티티 Orders 필드에 새로운 Order 추가함.
memberService.noneLockTypeUpdateAddOrder();
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("Result Version = {}", findMember.getVersion());
}
- Member 엔티티 Orders 필드에 새로운 Order 추가함(연관관계 주인을 하나 추가함)
- 연관관계의 주인이 FK를 관리하고 있으므로, Member 객체에는 Orders가 추가되지만 Member Table에는 물리적인 변화가 없음
- 따라서 업데이트 쿼리가 나가지 않음 → 업데이트 쿼리가 나가지 않기 때문에 Member Entity의 Version은 동일함.
실행 결과 Version = 0으로 동일함.
테스트 코드5 → 연관관계 주인이 아닌 엔티티가 가지는 연관관계 주인 필드의 수정
@Test
@DisplayName("None Type Test : 연관관계의 주인 값 수정 → Version은 바뀌지 않는다. ")
void test9() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
memberService.noneLockTypeUpdateUpdateOrder();
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("Result Version = {}", findMember.getVersion());
}
- Member 엔티티 Orders 필드에 있는 Order의 이름을 변경함
- 연관관계의 주인이 FK를 관리하고 있으므로, Member 객체에는 Orders가 추가되지만 Member Table에는 물리적인 변화가 없음
- 따라서 Member 엔티티 업데이트 쿼리가 나가지 않음 → 업데이트 쿼리가 나가지 않기 때문에 Member Entity의 Version은 동일함
- Order 엔티티는 업데이트 쿼리가 나감.
실행 결과 Version = 0으로 동일함.
테스트 코드6 → 연관관계 주인의 Member 필드 수정
@Test
@DisplayName("None Type Test : 연관관계 주인의 Member 필드 수정 → Version 증가 ")
void test11() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member, order);
log.info("Before Version of Order = {}, PK = {}", order.getVersion(), order.getId());
// Order의 Member Field를 변경함
orderService.orderAddMember(order.getId());
Order findOrder = orderRepository.findOrderById(order.getId());
log.info("After Version of Orders = {}, PK = {}", findOrder.getVersion(), findOrder.getId());
}
- Order 엔티티에 있는 Member 필드를 변경함.
- Order가 연관관계의 주인이므로 OrderTable에는 Member 값이 저장되고 있음.
- Member 필드 변경되니, OrderTable의 Member에 변동이 있어 OrderTable에 대한 Update 쿼리가 나감.
- 따라서, Order의 Version은 증가함.
동일한 Order(PK=2)에서 수정 시, Version이 증가하는 것이 확인됨.
LockModeType.Optimistic
용도
조회한 엔티티가 트랜잭션이 커밋될 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다.
동작
- 엔티티를 불러옴.
- 커밋 시점에 Version 조회 쿼리를 보냄.
- Version 정보 분기
- Version 정보로 검색됨 → 엔티티가 수정되지 않았음 → Version을 올리고 종료함.
- Version 정보로 검색 안됨 → 엔티티가 수정됨 → "ObjectOptimisticLockingFailureException"
- 마지막으로 Select 쿼리를 보냈기 때문에 Version의 증가는 없음.
이점
Transaction 된 Commit만 읽기 때문에 Dirty Read와 Non-Repeatable Read를 방지한다.
테스트코드 1
@Test
@DisplayName("Optimistic Type Test : 단순 조회 시, Select 쿼리가 한번 더 나가는 것을 확인한다. ")
void test12() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Order의 Member Field를 변경함
Member findMember = memberService.findMemberByMemberIdOptimistic(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Optimistic Lock은 Commit 시점에 Version 정보를 조회하는 Select 쿼리를 한번 더 보냄.
테스트코드 2
@Test
@DisplayName("Optimistic Type Test : 조회한 엔티티가 커밋 전 Version 정보가 수정되면 예외 발생 ")
void test13() throws InterruptedException {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
CountDownLatch latch = new CountDownLatch(2);
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
memberService.findMemberByMemberIdOptimistic(member.getId());
try {
memberService.findMemberByMemberIdOptimistic(member.getId());
Thread.sleep(1000);
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.execute(() -> {
memberService.findMemberByMemberIdOptimisticAndUpdate(member.getId());
latch.countDown();
});
latch.await();
}
- Optimistic Lock은 Commit 시점에 Version 정보를 조회하는 Select 쿼리를 한번 더 보냄.
- 이 때, Version 정보로 검색되는 것이 없으면 Exception이 발생함.
Version 정보로 조회했는데 없음 → Exception 발생함.
LockModeType.Force_Increment
용도
논리적인 단위의 엔티티 묶음을 관리한다. 예를 들어 게시물의 첨부파일을 추가하면 일반적으로는 게시물의 엔티티의 버전이 증가하지 않는다. (연관관계의 주인은 첨부파일이기 때문). 물리적으로는 변하지 않았으나, 논리적으로는 변했다. 이 때 게시물의 버전도 강제로 증가할 경우, 사용함.
동작 (조회되면 항상 Version 증가함)
- 엔티티를 불러옴.
- '게시물'이 수정되지 않아도, 커밋 시점에 Update 쿼리를 보냄. (첨부 파일이 수정되지 않아도)
- Version 정보 분기
- Version 정보로 검색됨 → 엔티티가 수정되지 않았음 → Version을 올리고 종료함.
- Version 정보로 검색 안됨 → 엔티티가 수정됨 → "ObjectOptimisticLockingFailureException"
- '게시물'이 수정되면 커밋 시점에 Update 쿼리를 한번 더 보냄(총 2회의 Version 증가)
이점
논리적인 단위로 묶인 엔티티의 버전을 함께 관리할 수 있음.
테스트코드 1
@Test
@DisplayName("Optimistic Force Increment Type Test : 연관관계 주인 필드 추가 시, Version 증가해야함. ")
void test14() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Member 엔티티의 Orders에 Order를 추가함.
memberService.findMemberByMemberIdForceIncrementAndAddOrder(member.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Force Increment는 조회를 하면, 반드시 Version이 증가함. → Member Entity 버전 증가함.
테스트코드 2
@Test
@DisplayName("Optimistic Force Increment Type Test : 단순 조회 시, Version 1회 증가함. ")
void test15() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Member 엔티티 단순 조회
memberService.findMemberByMemberIdForceIncrement(member.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Force Increment는 조회를 하면, 반드시 Version이 증가함. → Member Entity 버전 증가함.
1회의 업데이트 쿼리가 발생함.
테스트코드 3
@Test
@DisplayName("Optimistic Force Increment Type Test : 엔티티 수정 시 Version 2회 증가함. ")
void test16() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Member 엔티티의 Name을 변경함. → 업데이트 쿼리 2회
memberService.findMemberByMemberIdForceIncrementAndEditMemberName(member.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Member Field name이 변경되므로 먼저 업데이트 쿼리가 나감. (Version ↑ + name 변경)
- Force_Increment 설정 시 항상 나가는 업데이트 쿼리 한번 더 나감.
총 2회의 업데이트 쿼리가 발생함.
테스트코드 4
@Test
@DisplayName("Optimistic Force Increment Type Test : 연관관계 주인 수정 시, Update 쿼리 2회 발생")
void test17() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Order의 이림을 변경함.
orderService.findOrderByIdForceIncrementAndEditOrderName(order.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Order의 Field Name을 변경했음.
- 따라서 Order Field Name 변경 Update 1회 + 기본 Update 1회가 나감
- 총 2회의 Update 쿼리가 발생함.
총 2회의 업데이트 쿼리가 발생함.
테스트코드 5
@Test
@DisplayName("Optimistic Force Increment Type Test : 연관관계 주인의 가짜 객체 수정 시, 양 객체에 모두 업데이트 쿼리 발생")
void test18() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Order의 Member Field의 이름을 변경함.
orderService.findOrderByIdForceIncrementAndEditMemberName(order.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Member 엔티티가 지연로딩으로 불러와진 후 변경됨 → 더티체킹 Update 쿼리 발생.
- Order 엔티티가 Force Increment에 의해 강제적으로 Version 증가 Update 쿼리 발생
총 2회의 Update 쿼리가 발생함.
테스트코드 6
@Test
@DisplayName("Optimistic Force Increment Type Test : 가짜 객체의 연관관계 수정 시, 양 객체에 모두 업데이트 쿼리 발생")
void test19() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
log.info("Before Version of Member = {}, PK = {}", member.getVersion(), member.getId());
// Member의 OrerList의 첫번째 Order의 이름을 변경함
memberService.findMemberByMemberIdForceIncrementAndEditOrderName(member.getId());
Member findMember = memberRepositoryImpl.findMemberById(member.getId());
log.info("After Version of Member = {}, PK = {}", findMember.getVersion(), findMember.getId());
}
- Member의 OrderList의 첫번째 Order의 이름을 변경함 → OrderTable 업데이트 쿼리 발생 (Order Versino 증가)
- Force Increment에 의한 Member의 강제 Version 증가
JPA 소프트락 → 스칼라 타입에는 적용 X
JPA 소프트락은 스칼라 타입에는 적용되지 않는다. JPA 소프트락은 엔티티가 가지고 있는 Version 필드를 Commit 시점에 Select / Update 쿼리를 이용해 확인하는 방법으로 수행된다. 스칼라 타입으로 조회 시, 스칼라 타입만 불러와진다. 따라서 영속화 되지도 않고, Version 정보도 없기 때문에 JPA의 소프트락을 적용할 방법이 없다. 따라서 JPA 소프트락은 스칼라 타입에는 적용되지 않는다.
JPA 소프트락 적용 X(스칼라 타입) 테스트 코드
@Test
@DisplayName("Optimistic Type Test : 스칼라 타입으로 조회 → 소프트락 적용 X ")
void test20() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
// Member의 Name 필드 스칼라 타입으로 조회
memberService.findMemberNameByMemberIdOptimistic(member.getId());
}
- Member의 Name 필드를 스칼라 타입으로 조회하는 단순 쿼리를 보냄
- Member 엔티티 조회 시, 조회 시점 Select 쿼리 1회 + Commit 시점 Select 쿼리 1회가 나감.
- 스칼라 타입으로 조회 시, Version 정보가 없기 때문에 조회 시점 Select 쿼리 1회 나감.
따라서 스칼라 타입에는 JPA의 소프트락이 적용이 되지 않음.
실제로 Select 쿼리는 1회만 나감.
JPA의 비관적 락
JPA의 비관적 락은 "트랜잭션 충돌이 발생한다"를 가정하는 락이다. JPA의 비관적 락은 주로 DB의 "SELECT FOR UPDATE" 쿼리를 이용해 구현한다. 주로 "SELECT FOR UPDATE"가 걸린 Row에 접근을 제어하는 방식으로 비관적 락을 적용한다. 또한, Row 자체에 Lock을 걸기 때문에 Version 정보를 사용하지 않는다. 따라서 다음 두 가지 특징을 가진다.
- 스칼라 타입으로 조회해도 락이 걸린다 → Row 자체에 락이 걸리기 때문
- 데이터를 수정하는 즉시 트랜잭션의 충돌을 알 수 있다. → 데이터를 가져오는 순간, 충돌 여부를 알 수 있음.
- 락을 얻을 때 까지 트랜잭션은 대기한다 → Row 자체에 락이 풀릴 때까지 대기함.
JPA의 비관적 락, 타임아웃
비관적 락을 적용하면, 락을 획득할 때까지 트랜잭션이 대기한다. 그렇지만 무한정 대기를 하게 되면 트랜잭션이 마를 수 있다. 따라서 비관적 락을 사용할 때 타임아웃 시간을 줄 수 있다.
JPA의 비관적 락 종류
동작 | 격리 수준 | 이점 | |
PESSIMISTIC_WRITE | select for update 쿼리 사용 | Repeatable Read 보장 | Non-Repeatable Read 해결 |
PESSIMISTIC_FORCE_INCREMENT | for update nowait 쿼리 사용 for update 쿼리 사용 version 정보 사용 |
Repeatable Read 보장 | 연관 엔티티 함께 관리 |
JPA LOCK : PESSIMISTIC_WRITE
용도
일반적으로 사용하는 비관적 락이다. DB의 "SELECT FOR UPDATE" 쿼리를 이용해 현재 조회하고 있는 Row에 Lock을 건다. 따라서 DB에 데이터를 조회하는 시점에 트랜잭션 충돌 여부를 확인할 수 있다.
동작 (Version 정보를 사용하지 않음. )
- 데이터베이스에 "SELECT FOR UPDATE" 쿼리를 보내 조회하고 있는 Row에 Lock을 건다.
이점
현재 조회하고 있는 Row의 커밋 시점까지 데이터가 변경되지 않음을 보장한다. 따라서 Dirty Read, Non Repeatable Read를 보장한다. 또한, Lock이 걸린 Row는 다른 트랜잭션이 수정할 수 없다.
테스트 코드1
@Test
@DisplayName("Pessimistic Write Type Test : 비관적락 조회 → select for update 쿼리 확인")
void test21() {
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
// Member의 Name 필드 스칼라 타입으로 조회
memberService.findMemberByMemberIdPessimisticWrite(member.getId());
}
- 단순히 Member를 "비관적 락"을 적용해서 조회함.
- Select for update 쿼리가 나가는지 확인용
SELECT FOR UPDATE 쿼리가 나가는 것을 확인했다. 즉, DB에 member_id = member_id인 Row에 대해서 Lock이 걸렸다.
테스트 코드2
@Test
@DisplayName("Pessimistic Write Type Test : 비관적락 조회 → 멀티 쓰레드 환경, 시간 오래 걸리는거 확인 ")
void test22() throws InterruptedException {
int threadNum = 100;
Member member = new Member();
member.setName("memberA");
memberRepositoryImpl.saveMember(member);
ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
CountDownLatch latch = new CountDownLatch(threadNum);
for (int i = 0; i < threadNum; i++) {
executorService.execute(() -> {
long a = System.currentTimeMillis();
memberService.findMemberByMemberIdPessimisticWrite(member.getId());
latch.countDown();
log.info("result Time = {}", System.currentTimeMillis() - a );
});
}
latch.await();
}
- 멀티 쓰레드 환경에서 여러 트랜잭션이 동일한 Row에 접근하는 상황을 가정
- 이 때 비관적 락이 걸려있는 Row를 모두 조회하고자 하기 때문에 데이터 조회 시점에 Select For Update 쿼리에 의해 트랜잭션 충돌을 알게 됨.
- 락을 얻을 때까지 각각의 쓰레드는 대기함.
위는 실행 결과다. 쓰레드 100개를 돌렸는데, 첫번째 종료된 쓰레드는 665ms가 소요되었고, 마지막에 종료된 쓰레드는 1488ms가 소요되었다. select For Update 쿼리를 하게 되면 각 쓰레드는 트랜잭션에서 락을 얻을 때까지 무작정 기다린다. 이런 이유 때문에 실제 트랜잭션이 종료되는 시점이 오래 걸리게 된다.
이걸 보면 느낄 수 있는 점은 트랜잭션의 격리성이 올라가면 동시성? 병행성이 완전히 줄어든다는 것이다. 따라서 비관적 락은 정말 중요한 상황이 아니면 사용을 자제해야한다.
JPA LOCK : PESSIMISTIC_FORCE_INCREMENT
용도
DB에 Update 쿼리를 보내 사용하고 있는 Row에 Lock을 건다. 또한 비관적 락이지만 Version 정보를 사용해서 연관 엔티티의 버전을 함께 관리해준다.
동작 (Version 정보를 사용하지 않음. )
- 데이터베이스에 "FOR UPDATE" 쿼리를 보내 조회하고 있는 Row에 Lock을 건다.
이점
현재 조회하고 있는 Row의 연관된 엔티티의 변화를 Version 정보로 함께 관리할 수 있다.
유의할 점
Member에 비관적락을 걸고 연관된 엔티티인 Order를 수정한다고 가정해보자. 따라서 DB에는 Member에만 Lock이 걸려있다. 따라서 Member 엔티티가 조회된 상황에서 다른 트랜잭션에 의해 Member의 연관관계 엔티티인 Order는 얼마든지 수정할 수 있다. Lock이 걸린 Member만 트랜잭션 충돌이 예방된다.
연관된 엔티티의 버전을 함께 관리하기 위해 Order에도 Version을 함께 사용한다고 가정해보자. @Version 어노테이션은 기본적으로 조회 ~ 수정까지 Soft Lock을 걸어준다. 따라서 Member 엔티티를 불러와서 이 때, Order를 수정한다고 가면 자동적으로 Soft Lock이 걸린다. 이 때 Member는 비관적 락, Order는 낙관적 락이 걸리는 셈이다.
Force_Increment는 Member를 불러왔을 때, Order가 수정되면 Order의 Version이 Update 쿼리로 증가하고, Member의 Version도 Update 쿼리로 함께 증가하는 역할을 한다.
테스트코드1
@Test
@DisplayName("Pessimistic Force Increment Type Test : 단순 조회 시, Member Version 정보 증가 + for Update 쿼리 나가는지 확인")
void test24() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
// Member 비관적 락으로 단순 조회
memberService.findMemberByMemberIdPessimisticForceIncrement(member.getId());
}
- Member 정보를 단순 조회함.
- 비관적 락이므로 for Update 쿼리가 나감.
- Force_Increment 이기 때문에 기본적으로 Commit 시점에 Update 쿼리가 1회 나감
테스트코드2
@Test
@DisplayName("Pessimistic Force Increment Type Test : Member 수정 시, Member Version 정보 증가 + for Update 쿼리 나가는지 확인")
void test25() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
// Member 비관적 락으로 조회 + 수정
memberService.findMemberByMemberIdPessimisticForceIncrementEditMember(member.getId());
}
- Member를 비관적락으로 조회 + 수정함. 비관적 락이므로 for Update 쿼리가 나감.
- Force_Increment 이기 때문에 기본적으로 Commit 시점에 Update 쿼리가 1회 나감. 또한 Member가 수정되었기 때문에 Update 쿼리가 1회 더 나감.
테스트코드3
@Test
@DisplayName("Pessimistic Force Increment Type Test : Member 수정 시, Member Version 정보 증가 + for Update 쿼리 나가는지 확인")
void test26() {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
// Member를 비관적 락으로 조회
// Member의 OrderList의 0번 인덱스의 Order를 수정
memberService.findMemberByMemberIdPessimisticForceIncrementEditOrder(member.getId());
}
- Member를 비관적 락으로 조회한다.
- Member의 OrderList의 0번 인덱스의 Order를 수정한다.
- Order가 @Version이 있을 경우, Version 정보가 증가하며 Member의 Version 정보도 함께 증가한다.
- Order가 @Version이 없을 경우, Member의 Version 정보만 증가한다.
위의 경우 총 3번의 쿼리가 나가는 것을 확인할 수 있다.
테스트코드4
@Test
@DisplayName("Pessimistic Force Increment Type Test : Order 수정 시, Order에는 트랜잭션 락이 먹는지?")
void test27() throws InterruptedException {
Member member = new Member();
member.setName("memberA");
Order order = new Order();
order.addMember(member);
memberRepositoryImpl.saveMember(member);
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch latch = new CountDownLatch(100);
// Order 정보 수정 : 99개의 쓰레드
for (int i = 0; i < 99; i++) {
executorService.execute(() -> {
orderService.orderEdit(order.getId());
latch.countDown();
});
}
// Member 비관적 락 불러와서 Order 수정
executorService.execute(() -> {
memberService.findMemberByMemberIdPessimisticForceIncrementEditOrder(member.getId());
latch.countDown();
});
latch.await();
}
- 멀티 쓰레드 환경의 테스트를 시작한다.
- 99개의 쓰레드는 Order를 Update한다. 1개의 쓰레드는 Member를 비관적 락으로 불러와 Order를 Update한다.
- 동시에 접근 시, Order에 @Version이 있으면 OptimissticException이 발생한다. 왜냐하면 @Version으로 None Type의 소프트락으로 관리되기 떄문이다.
- 동시에 접근 시, Order에 @Version이 없으면 어떤 예외도 발생하지 않고 잘 동작한다. 왜냐하면 비관적락은 Member Table에만 걸려있기 때문이다.
JPA의 Lock 적용
JPA의 Lock은 트랜잭션이 Commit되는 시점에 동작한다. 따라서 Transaction을 Commit 해주는 역할이 반드시 들어가야 테스트 코드를 정상적으로 사용할 수 있다. 이를 위해서 @Transactional 어노테이션을 Service 계층에 달고, Repository에 Lock을 걸어두면 정상적으로 JPA Lock이 적용된다.
JPA Lock 적용 방법
- 메서드에 @Lock 어노테이션 설정 → 스프링 Data JPA
- 엔티티 매니저를 이용한 설정 → 순수 JPA
- Query DSL / JPQL 이용한 설정 → 순수 JPA
JPA Lock을 적용하기 위해서는 크게 세 가지 방법이 있는 것 같다. 위 방법 중 @Lock은 스프링 Data JPA에만 적용되는 것으로 확인된다. 나처럼 순수 JPA를 쓰는 사람이나, QUERY DSL 복잡 쿼리를 이용할 때는 @Lock Mode가 아닌 직접 설정하는 방법을 사용해야한다.
테스트를 위한 서비스 코드 → @Trasnactional 어노테이션 유의
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class MemberService {
private final MemberRepository memberRepository;
private final MemberRepositoryImpl memberRepositoryImpl;
@Transactional
public void doSomethingJpaRepository(Long memberId) {
memberRepository.findMemberById(memberId);
}
@Transactional
public void doSomethingEntityManager(Long memberId) {
memberRepositoryImpl.simpleSelect(memberId, 1);
}
@Transactional
public void doSomethingQueryDsl() {
memberRepositoryImpl.simpleSelectAllByQueryDsl();
}
@Transactional
public void doSomethingJpqlQuery() {
memberRepositoryImpl.simpleSelectAllByJpqlQuery();
}
}
엔티티 매니저 사용할 때, 락
// @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) // 어노테이션은 동작X
public void simpleSelect(Long memberId, int i) {
Member member = em.find(Member.class, memberId);
// 조회 시, JPA LOCK MODE 먹음
// Member member = em.find(Member.class, memberId,LockModeType.OPTIMISTIC_FORCE_INCREMENT);
// 조휘 후, 엔티티 매니저 LOCK MODE 먹음
// em.lock(member, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
}
- @Lock 어노테이션 → 정상 동작 X
- em.find + Lock Mode → 정상 동작 O
- em.lock + Lock Mode → 정상 동작 O
JPQL 사용할 때, 락
//@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
public void simpleSelectAllByJpqlQuery() {
em.createQuery("select m from Member m")
.setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT);
}
- @Lock 어노테이션 → 정상 동작 X
- setLockMode() 메서드를 이용해 Lock 모드 설정 → 정상 동작함.
QueryDsl 사용할 때, 락
//@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
public void simpleSelectAllByQueryDsl() {
queryFactory.selectFrom(member)
.setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
.fetch();
}
- @Lock 어노테이션 → 정상 동작 X
- setLockMode() 메서드를 이용해 Lock 모드 설정 → 정상 동작함.
Spring Data JPA 사용 시
public interface MemberRepository extends JpaRepository<Member, Long> {
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query(value = "select m from Member m where m.id=:id")
void findMemberById(@Param(value = "id") Long id);
}
- @Lock 어노테이션 정상 동작함.
테스트 코드
'Spring > JPA' 카테고리의 다른 글
JPA : Collection과 JPA 동작 방식 (1) | 2022.02.23 |
---|---|
JPA : 2차 캐시 (0) | 2022.02.23 |
JPA : Batch 처리하기 (0) | 2022.02.21 |
JPA : N+1 문제 및 해결방법 정리 (0) | 2022.02.21 |
JPA : Collection Join 시 페이징 불가능 (0) | 2022.02.21 |