동시성 문제
(1) Lost Update
한 트랜잭션의 변경 사항이 다른 트랜잭션에 의해 덮어씌워지는 현상을 말한다. 여러 트랜잭션이 동일 데이터를 읽고, 동시에 수정하려고 할 때 발생한다.
(2) Write Skew
여러 트랜잭션이 동시에 실행될 때, 각각의 트랜잭션이 다른 트랜잭션이 수행한 읽기 결과를 기준으로 조건을 확인하고 쓰기 작업을 수행함으로써, 전체 데이터베이스 상태가 일관되지 않게 되는 문제이다.
여러 트랜잭션이 동일 데이터를 읽고, 읽은 데이터를 기반으로 서로 다른 데이터를 수정할 때 발생한다.
주문, 재고, 판매상태 사례를 통해 Write Skew 개념을 파악해보자.
잠금 전략
(1) 비관적 잠금 (Pessimistic Lock)
다른 트랜잭션이 데이터를 변경하려 할 가능성이 높다. 그러니 미리 막아야 한다.
데이터를 변경할 때 다른 트랜잭션이 동시에 접근하지 못하도록 사전에 잠금(Lock)을 거는 방식이다.
해결방법
(1) Serializable 격리 수준 사용하기
Serializable 격리 수준은 트랜잭션의 순차적 실행을 지원한다. 즉, 여러 트랜잭션이 동시에 같은 데이터를 읽는 경우, 하나의 트랜잭션이 끝나기 전에 다른 트랜잭션이 실행되지 못하게 한다.
(2) 원자적 연산 사용하기
DB에서 지원하는 원자적 연산을 사용하면, DB가 알아서 순차적으로 트랜잭션을 처리한다.
update articles set like_count = like_count +1 where id = 1
(3) 명시적 잠금 사용하기
select * from articles where id = 1 for update;
select * for update 를 사용하면, 다른 트랜잭션이 잠금을 해제해야 조회를 할 수 있다. 트랜잭션은 잠금이 해제될 때까지 대기한다.
@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Article u where u.id=:id")
Optional<Article> findByIdWithPessimisticLock(Long id);
}
Spring Data JPA에서 LockModeType.PESSIMISTIC_WRITE은 "select ... for update" SQL을 생성한다.
(4) 분산락 사용하기
DB의 잠금 기능을 사용하면, DB가 부하를 받을 가능성이 있다. 예를 들어, Lock으로 대기하는 커넥션이 많아져서, 다른 읽기 프로세스에 영향을 미친다거나..
글로벌 캐시를 사용하여 분산락을 구현하면, 이러한 문제를 회피할 수 있다.
Redisson을 사용하면 분산락을 쉽게 구현할 수 있다. 분산락 사례를 참고하여, 커스텀 어노테이션인 DistributedLock에 AOP를 적용하면, 아래처럼 간결하게 구현할 수 있다.
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
@DistributedLock(
key = "#lockName"
waitTime = 10L,
leaseTime = 30L
)
public void like(String lockName, Long id) {
Article article = articleRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Article not found"));
article.like();
}
}
(2) 낙관적 잠금 (Optimistic Lock)
충돌이 드물게 발생할 것이다. 발생하더라도 나중에 처리하면 된다.
데이터를 변경할 때 충돌이 발생하지 않을 것이라고 가정하고 작업을 진행하되, 최종적으로 충돌이 발생했는지 확인하는 방식이다.
해결방법: CAS (Compare-And-Set)
CAS는 데이터 변경 전에 버전을 확인하여, 데이터 손실을 막는다. 락을 걸지 않아 동시성 처리가 좋다는 장점이 있다.
(A) SQL 살펴보기
게시물의 라이크수를 변경하는 쿼리로 CAS을 구현한 사례를 살펴보자.
트랜잭션 A
update articles set like_count = 2 where id = 1 and ver = 1; // 1행 수정
트랜잭션 B
update articles set like_count = 2 where id = 1 and ver = 2; // 0행 수정
애플리케이션은 실행 결과 수를 확인하여, 실행 성공 여부를 판단할 수 있다.
(B) Spring Data JPA 사례 살펴보기
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private int likeCount; // 좋아요 개수
@Version // 낙관적 잠금 구현을 위한 버전 관리 필드
private int version;
public void like(){
this.likeCount += 1;
}
}
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
@Retryable(
value = OptimisticLockException.class, // 변경된 레코드 수가 0개일 때, 발생하는 예외
maxAttempts = 5,
backoff = @Backoff(delay = 100)
)
@Transactional
public void like(Long id) {
Article article = articleRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Article not found"));
article.like();
}
}
참고
- 블로그: Lost Update, Write Skew and Phantom Test
- 블로그: 동시성 문제 해결방법
- 블로그: [kurly] 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
- 유튜브: DB 트랜잭션 조금 이해하기 02 격리
'소프트웨어-이야기 > 데이터 저장소 + 시각화 ' 카테고리의 다른 글
<고승범> 실전 카프카 개발부터 운영까지 (1) | 2023.10.03 |
---|---|
Kafka Windowing (0) | 2023.07.22 |
[키설계] UUID와 increment PK는 언제 사용해야할까? (1) | 2022.03.12 |
(PostgreSQL) AWS PostgreSQL RDS에 Transaction ID Wraparound 알럿 설정하기 (2) | 2021.04.04 |
(PostgreSQL) Truncate TABLE VS delete (0) | 2021.03.28 |