트랜잭션은 ACID를 통해 설계됩니다.
원자성(Atomicity), 일관성(Consistency), 격리(Isolation) 그리고 영속성(Durability)
그리고 멀티 스레드를 적용할 때 스레드 간 공유자원을 가지고 동작합니다.
이 과정에서 공유자원을 동시에 사용하는 과정에서 문제가 발생하곤 합니다.
이를 동시성 문제라고 합니다.
동시성 문제를 해결하기 위한 해결방법이 락(Lock)입니다.
JavaCode를 통한 Lock
락에 대해 천천히 고도화 시켜보겠습니다.
public class Product() {
private Long productId;
private String name;
private Long count; // 물건 개수
private Long price;
public void buy() {
if (this.count == null || this.count <= 0) {
System.out.println("물품이 없습니다");
} else {
this.count -= 1;
System.out.println("구매되었습니다. 남은 수량: " + this.count);
}
}
}
public static void main(String[] args) {
Product p = new Product();
p.setCount(1000L);
for (int i=0; i<100; i++) {
new Thread() {
public void run() {
for (int j=0; j<10; j++) {
p.buy();
}
}
}.start();
}
System.out.println(p.getCount().toString());
}
// 29
위와 같은 물건이 있다고 했을 때, 물건이 1개가 있다면 2명의 고객이 구매하려고 할때, 동시성 문제가 발생할 수 있습니다.
위 방법으로 동작을 해보면 1000개가 0개가 되는 것이 아닌 29개로 남는 것을 확인할 수 있었습니다.
왜 이런 문제가 발생할까요?
동시성 문제때문입니다!
위와 같이 rewrite하는 경우가 발생할수 있는데 트랜잭션 A는 count가 0인데도 불구하고 count = 1로 시작했기에 0으로 저장하게 됩니다.
그럼 1개밖에 없는 물건에 대해 2개의 주문이 들어가게 되겠죠. 이를 방지하기 위한 방법을 알아보겠습니다.
Intrinsic Lock
Synchronized 블록을 통한 Intrinsic Lock을 이용해서 스레드의 접근을 제어하는 방법입니다.
public class Counter{
private int count;
}
public synchronized int increase() {
return ++count;
}
}
위 방법들은 가장 간단한 동기화 방법으로 메서드가 호출될 때마다 객체 자체가 잠겨서 동기화가 됩니다.
Thread-Safe한 동작을 위해 사용하는 Synchronized는 다른 스레드에서 해당 영역을 접근할 수 없게 만듭니다.
즉, 특정 영역을 한 스레드에서만 작업해야하는 경우 사용할 수 있습니다.
하지만 메소드 하나가 아닌 객체로써 관리하려면 아래처럼 나타낼 수 있습니다.
ReentrantLock
public class Product {
@Getter
private Lock lock = new ReentrantLock();
private int count;
public void buy() {
count--;
}
public int checkCount() {
return this.count;
}
}
public class StockTest {
public static void main(String[] args) {
Product p = new Product();
for (int i=0; i<100; i++) {
new Thread() {
public void run() {
for (int j=0; j<10; j++) {
p.getLock().lock();
p.buy();
System.out.println(p.checkCount());
p.getLock().unlock();
}
}
}.start();
}
}
}
// 0
위 방법을 이용하면 lock이라는 객체로 관리를 하게됩니다. 그리고 값이 1000개에서 0개가 된것을 알수 있었습니다.
따라서 임의의 객체를 사용해서 동기화를 수행하므로 다른 클래스에서도 이 객체를 사용해서 동기화 할 수 있습니다.
또한 필요한 부분만 락을 걸수도 있습니다. 그렇게 된다면 Lock을 거는 범위가 최소화 되어 실패되는 로직의 수를 줄일 수 있습니다.
DB의 Lock
Spring Boot는 멀티 스레드 환경이므로 한개의 프로세스에서는 위 방법들을 이용해 처리할 수 있습니다.
하지만 서버를 다중화하여 부하 분산을 한 상태에서는 다른 프로세스를 가지게 되고 이를 해결할 방법이 필요합니다.
분산 락을 구현하기 위해서 락에 대한 정보를 공통적으로 '한 장소'에 보관할 필요가 있습니다. 그리고 분산 환경에서 여러대의 서버들은 공통된 '한 장소'를 기준으로 자신이 임계영역에 접근할 수 있는지 확인할 필요가 있습니다.
DB를 이용해서 Lock을 거는 방법을 알아보겠습니다.
Pessimistic Lock
비관적 Lock은 SELECT ~ FOR UPDATE 문을 통해 DB가 제공하는 Lock을 해당 데이터에 거는 방식입니다.
FOR UPDATE는 다른 트랜잭션에서 수정, 삭제, FOR UPDATE문을 통해 조회를 할 수는 없습니다.
JPA에서는 PESSIMISTIC_WRITE라고 합니다.
PESSIMISTIC_READ는 FOR SHARE문을 사용하게 됩니다.
이렇게 사용한다면 Share Lock이 걸린 데이터는 SELECT만 실행 가능하고 UPDATE, DELETE가 불가능한 상태가 됩니다.
즉, 트랜잭션이 끝나기 전까지 조회한 데이터가 변경되지 않을 것임을 보장하는 것입니다.
비관적인 Lock의 경우에도 여러 스레드를 통해 Select는 접근이 가능하지만 Update, Delete는 여전히 락으로 인한 성능 저하가 발생합니다. 그리고 세션을 계속 유지해야 하기 때문에 대규모 시스템에서 불리할 수 있습니다. 다만 DB Level에서 충돌을 방지하고 데이터의 일관성을 유지할 수 있습니다.
Optimistic Lock
낙관적인 Lock의 경우, 동시성 문제가 발생하지 않을 것이라는 것을 간주하고 처리합니다.
DB가 제공하는 Lock은 아니지만 Application 수준에서 확보하는 Lock으로 대표적으로 JPA의 Version이 있습니다.
즉, UPDATE ~ WHERE version = :version 쿼리를 보내서 affected row가 0이면 낙관적인 에러 예외를 발생시키는 것입니다.
특히, JPA에서 버전을 @Version만 사용해서 처리할 수도 있지만 Spring Data JPA가 제공하는 기능의 범위를 벗어날 수도 있습니다.
따라서 조금 더 자세히 알 필요가 있습니다.
접근 방법
1. Version
- version 필드를 추가하고 이를 조건으로 걸어 충돌 방지
2. OptimisticLocking
- 엔티티의 특정 필드를 조건문에 추가해서 충돌 감지
3. DB Produre
- CREATE PROCEDURE ~ 문으로 DB에 프로시저를 미리 만들어 놓고 해당 프로시저를 사용하는 쿼리문을 사용
4. 직접 구현
- 필요한 쿼리문을 직접 작성해서 DB로 보내고 그 결과에 대한 예외 처리를 직접해서 낙관적 잠금을 구현합니다.
낙관적 락으로 사용하면 세션을 유지하지 않기때문에 대규모 시스템에 유리합니다.
분산 Lock
redis를 통해 분산 lock을 관리할 수 있습니다. java의 표준 라이브러리에서 수행하는 것처럼 단순한 Lock보다는 훨씬 복잡하게 구현됩니다.
모듈의 데이터 베이스를 강제하지 않고 서비스들의 결합도를 느슨하게 처리할 수 있다.
또한 많은 요청에 대한 높은 처리량을 redis의 메모리 기반 처리로 처리할 수 있습니다.
방법 1. Redis SETNX를 활용
방법 2. Redis의 Message Broker활용
이러한 분산 락도 문제가 있습니다.
1. 분산 락 타임아웃
- 하나의 트랜잭션이 무한정 락을 소유할 경우 다른 서버들에서 무한정 대기할 수 있습니다.(데드락)
- 이를 타임아웃을 통해 해결할 수 있습니다.
2. 분산락 타임아웃의 문제
- 락은 타임아웃으로 해제되었으나 트랜잭션이 끝나지 않아 다른 트랜잭션과 경합이 발생
- 예를 들어 T1, T2가 동시에 처리될 때, T1이 락을 얻고 락이 타임아웃되면서 T2가 락을 획득합니다. 하지만 그 과정에서 T1이 2000원의 출금을 commit하게 된다면 갱신 유실이 발생합니다.
- JPA 환경에서는 쿼리 쓰기 지연 등으로 인해 발생하곤합니다.
이를 방지하기 위해서 처리 방법
1. 원자적 연산 사용
2. 명시적 잠금
3. 갱신 손실 자동 감지
4. Compare - and - set 연산
원자적 연산 사용은 이전 설명과 같이 트랜잭션으로 관리하는 것인데 비용과 불필요한 처리등의 문제가 있는 것을 확인했습니다.
명시적 잠금, 갱신 손실 자동 감지는 DBMS에 코드로써 관리하는 방법이며, DBMS에 의존적이기 때문에 ORM과 궁합이 좋지 않습니다.
따라서, CAS 연산을 통한 갱신 유실 방지 방법을 추천합니다.
JPA에서 OptimisticLocking을 통해 구현할 수 있습니다.
이때, 분산락 없이 OptimisticLocking만 구현해도 될까요?
정답은 아니다 입니다.
분산락 없이 OptimisticLocking은 대기 없이 실패되거나 별도의 재시도 구현이 필요합니다.
별도의 구현을 하면 코드의 복잡성이 또다시 늘어나며 코드에 의존성이 발생합니다.
따라서, 분산락을 통해 관리하되 OptimisticLocking을 통해 만약의 상황에서도 데이터의 정합성이 일치하도록 유지합니다.
또한 주요 테이블은 하이버네이트 envers를 이용해 변경 히스토리를 저장해 원활한 데이터의 흐름을 찾을 수 있습니다.
하이버네이트 envers란?
envers는 hibernate 에서 만든 데이터 변경 이력을 로깅하기 위한 라이브러리입니다.
과거엔 로깅을 위해 customer_hist 와 같은 엔티티를 정의하고 customer 에 insert, update, delete 등의 작업이 발생하면,
history 테이블에도 같은 작업을 해줘야 했으며, 이력을 쌓아야 하는 테이블이 늘어날 수록 이러한 번거로운 반복작업을 계속해야했습니다.
그러나 envers를 사용하면 이러한 번거로운 작업을 대폭 줄일 수 있습니다.
기본적으로 jpa로 구현되어있으며 후에 spring에도 spring data envers 프로젝트로 추가되었습니다.
spring data envers 역시 hibernate에서 관리하기 때문에 큰 차이는 없습니다.
compile group: 'org.hibernate', name: 'hibernate-envers', version: '5.4.14.Final'
spring:
jpa:
org:
hibernate:
envers:
audit_table_suffix: _history
revision_field_name: rev_id
store_data_at_delete: true
관리할 테이블에는 @Audited를 붙인다.
extends, implements 등의 상속관계 테이블에는 @AuditOverride(forClass=BaseEntity.class)를 추가합니다.
manyToOne, oneToOne에도 @Audited 또는 @NotAudited를 목적에 맞게 넣어줍니다.
join에는 @AuditJoinTable을 사용합니다.
다만 위 ㅏ일은 QueryDSL과 연동시 주의해야하며, EnversQueryDslRepositoryImpl.java를 만들어 따로 처리해줘야합니다.
참고 : https://sehajyang.github.io/2020/04/15/springboot-envers-logging-for-revision/
참고 : https://www.youtube.com/watch?v=UOWy6zdsD-c
참고 : https://redisson.org/glossary/java-distributed-lock.html
'기술 스텍 > Java' 카테고리의 다른 글
JVM 내부 구조 정리 (1) | 2024.01.14 |
---|---|
String과 String Builder, String Buffer 차이점 (1) | 2024.01.11 |
[Java] 가비지 컬렉션이란? (0) | 2024.01.07 |
[Collection] ArrayList를 왜 List로 받아야 하는가? List의 종류 (0) | 2023.06.24 |
서블릿(survlet) (0) | 2023.06.23 |