(1) 스프링 동시성 문제
1. 서론
나의 프로젝트들 중에는 '조회수'와 '북마크 수'를 카운트하는 기능이 있다.
- 조회수가 증가하는 로직은 중복을 방지하여 사용자가 게시글을 조회하면 조회수에 + 1 갱신
- 북마크 수는 DB 테이블을 반정규화하여 Board 테이블의 컬럼에 북마크 수를 두고,
사용자가 게시글을 북마크하거나 해제하면 +1, -1 하도록 구현했다.얼마전 유튜브를 보다가 실제 인스타그램에서도 좋아요 수를 표시하기 위해 이렇게 테이블을 구성했다고 한다.
나의 기능들을 보면, 조회수나 북마크 수는 비즈니스에 엄청난 영향을 미치는 것은 아니기에 무시할 수도 있다. 하지만 상품의 재고 수량과 계좌의 입출금 같은 경우 동시성 문제가 일어난다면 비즈니스에 치명적인 피해를 입힐 것이다.
2. 동시성 문제가 뭔데 ?
하나의 자원을 2개 이상의 스레드 또는 프로세스가 동시에 접근하여 read/write 할 때 발생하는 문제다. (모두 read만 한다면 동시성 문제는 일어나지 않는다.)
하나의 스레드가 아직 수정 중이고 변경 사항이 저장되지 않은 상태에서, 다른 스레드가 변경 이전의 데이터를 읽거나 수정하여 데이터의 조회 혹은 수정이 정상적으로 이루어 지지 않는 문제를 말한다.
운영체제 관점
운영체제 수준에서의 동시성 문제는 race condition(경쟁 상태)이다.
이 또한 하나의 공유 자원이 여러 스레드가 동시에 접근하여 실행 순서에 따라 결과가 달라지는 것이다.
왼쪽 표처럼 count를 1 증가시키기 위해, register에 count를 등록, +1, count에 register를 저장이 순서대로 일어난 후 이 과정을 한번 더 반복해야 하는데,
오른쪽 표처럼 3가지 연산이 모두 실행되기 전에 다른 스레드에서 count 변수에 접근하여 count를 1 증가시키기 때문에 경쟁 상태에 의해 한 번밖에 증가되지 않았다.
서버와 DB 관점
서버와 DB에서는 위 같은 상황이 DB의 한 데이터에 접근할 때 발생할 수 있다.
왼쪽 표처럼 스레드 1이 조회수를 1 증가 시키고 DB에 저장된 후 스레드 2가 게시글을 조회하여 조회수를 1 증가시켜야 한다.
오른쪽 표는 스레드 1이 게시글을 조회하여 조회수 증가가 DB에 반영되지 않은 상태에서 스레드 2가 게시글을 조회 후 조회수를 증가하여 동시성 문제가 발생했고 증가 2번이 정상적으로 반영되지 못했다.
정말 그럴까 ?
간단하게 로컬에서 테스트 코드를 작성해봤다.
@DisplayName("100명이 동시에 북마크로 등록하면, 게시글의 북마크 수는 100명이 된다.")
@Test
void concurrency_test() throws InterruptedException {
/** given */
//
Board board = //
boardRepository.save(board);
/** when */
final int threadCount = 100;
final ExecutorService executorService = Executors.newFixedThreadPool(32);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
//
bookmarkService.saveBookmark(dto);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
/** then */
Board result = boardRepository.findById(1l).orElseThrow();
assertThat(result.getBookmarkCount()).isEqualTo(100);
}
100개의 스레드를 병렬로 실행하여 게시글을 북마크 했고, 그렇다면 게시글의 북마크 수는 100이 되어야 한다.
하지만 결과는 22로, 약 5분의 1밖에 반영되지 않았다.
3. 어떻게 해결할까 ?
3-1. synchronized 사용
@Transactional(readOnly = false)
public synchronized void saveBookmark( // ) {
...
//북마크할 게시글
Board board = ...
...
//게시글의 북마크 개수 1 증가
board.addBookmarkCount();
}
자바에서 제공하는 synchrinized 키워드로, 한 개의 스레드만 접근이 가능하도록 설정할 수 있다.
한 개의 스레드만 접근이 가능하도록 했으면, 동시성 문제가 해결되어야 할 것이다. 그런데 약 20% -> 60% 반영으로 올랐지만, 여전히 동시성 문제가 있다.
왜?
@Transactional 어노테이션의 트랜잭션 때문이다.
이 어노테이션이 적용된 메소드는 스프링 AOP에 의해 메소드를 호출하기 전에 트랜잭션을 관리하는 프록시 객체를 생성하여,
원본 객체를 감싸고 트랜잭션 시작 후 메소드 호출, 트랜잭션 종료가 일어난다.
정리하면
- 트랜잭션 시작 메소드 호출
- @Transactional 어노테이션이 적용된 메소드 호출
- 트랜잭션 종료 메소드 호출
순으로 실행된다.
그런데 @Transactional 어노테이션이 적용된 메소드가 호출되고 트랜잭션을 종료하는 메소드를 호출되기 전에 다른 스레드에서 같은 메소드를 호출할 수 있기 때문에 동시성 문제가 해결되지 않은 것이다.
즉, 트랜잭션 범위 동안 synchronized가 적용되어야 하는데, 비즈니스 함수에만 적용되기 때문이다.
synchronized의 문제점
- synchronized 키워드로 동시성 문제를 해결하기 위해 @Transactional을 제거한다면, 역으로 트랜잭션이 보장되지 않을 수 있다.
- 스프링 서버가 여러 개일 때는 적용할 수 없다.
synchronized 키워드는 하나의 프로세스에만 적용되기 때문에, 서버를 scale-out 해서 여러 개가 된다면
여전히 동시성 문제가 발생한다.
3-2. 낙관적 락(Optimistic Lock)
- 실제로 데이터에 Lock을 거는 것이 아니라, version이라는 별도의 컬럼을 추가하여 데이터의 정합성을 맞춘다.
- 데이터를 읽고 update하여 수정할 때, 현재 읽은 버전이 변경되지 않았는지 확인하여 수정한다.
- 만약 수정사항이 생겼다면 어플리케이션 레벨에서 다시 읽은 후 작업해야 한다.
즉, 낙관적 락은 DB가 제공해주는 특징을 사용하는 것이 아니라, Application Level에서 사용하는 Lock이다.
낙관적 락 적용하는 방법
@Entity
@NoArgsConstructor
public class Board {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long boardId;
...
@Version
private Long version;
private int view;
public void addView() {
this.view++;
}
}
엔티티에 @Version 필드가 추가해준다.
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<Board> findByBoardId(Long boardId);
}
@Lock 어노테이션에 LockModeType.OPTIMISTIC을 적용한다.
재시도 로직을 작성하지 않았을 때
JMeter로 100개의 동시 요청을 보냈을 시 StaleOnjectStateException이 발생했고,
작성된 테스트 코드로 테스트 했을 시 여전히 동시성 문제가 발생한다.
재시도 로직 작성
@Service
@RequiredArgsConstructor
public class OptimisticLockBoardSupport {
private final BoardService boardService;
public void getBoard() {
while (true) {
try {
boardService.getBoard();
break;
} catch (Exception e) {
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
락을 획득할 때까지 while 문으로 반복한다.
이때 Thread.sleep()으로 스레드를 멈추지 않으면 락을 획득할 때까지 무한 반복하고, 이것은 자원의 낭비로 부하가 심해질 것이므로 기능의 요구사항을 고려해서 적당한 시간동안 반복하도록 설정한다.
재시도 로직을 작성했을 때
@DisplayName("낙관적 락을 적용하여, 100명이 동시에 북마크 요청했을 때 북마크 수가 100이 된다.")
@Test
void optimisticLockTest() throws InterruptedException {
/** given */
...
Board board = //
boardRepository.save(board);
/** when */
final int threadCount = 100;
final ExecutorService executorService = Executors.newFixedThreadPool(32);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
//
bookmarkService.saveBookmark(dto);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
/** then */
Board result = boardRepository.findById(1l).orElseThrow();
assertThat(result.getBookmarkCount()).isEqualTo(100);
}
동시성 문제가 해결되어, 정상적으로 북마크 수가 카운트 된다.
3-3. 비관적 락(Pessimistic Lock)
비관적 락은 트랜잭션이 시작되면 DB의 데이터에 X-Lock을 걸어서 데이터의 정합성을 보장한다.
모든 트랜잭션은 충돌이 발생한다고 가정하여 우선 Lock을 걸고, 트랜잭션을 커밋하기 전 데이터를 수정하는 시점에 미리 트랜잭션 충돌을 감지할 수 있다.
낙관적 락과 다르게, 비관적 락은 DB Level에서 Lock을 사용한다.
* X-Lock이란 쓰는 동안 수정이 발생하지 않게 잠그는 것이고, 쓰기 락과 배타 락이랑 동일한 의미이다.
비관적 락 적용하는 방법
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Board> findByBoardId(Long boardId);
}
비관적 락은 낙관적 락의 @Version을 사용할 필요 없이, 쿼리 함수에 @Lock(LockModeType.PESSIMISTIC_WRITE)을 적용하여 간단하게 적용할 수 있다.
@Override
public Optional<Board> getBoardWithPessimisticLock(Long id) {
return Optional.ofNullable(
jpaQueryFactory
.selectFrom(board)
.where(board.boardId.eq(id))
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchOne());
}
또는 QueryDsl을 사용할 경우, setLockMode()를 사용하여 Lock을 지정할 수 있다.
테스트 결과
@DisplayName("비관적 락이 적용되어, 100명이 동시 북마크하면 북마크 수는 100이 된다.")
@Test
void pessimisticLock() throws InterruptedException {
//given
...
Board board = Board.create();
boardRepository.save(board);
//when
final int threadCount = 100;
final ExecutorService executorService = Executors.newFixedThreadPool(32);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
...
boardService.saveBookmark();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
//then
Board result = boardRepository.findById(1l).orElseThrow();
assertThat(result.getView()).isEqualTo(100);
}
쿼리 실행시 for update 키워드으로 비관적 락이 적용되어, 정상적으로 카운트 되었다.
4. 낙관적 락과 비관적 락의 비교
낙관적 락
낙관적 락의 장점
- 낙관적 락은 충돌이 자주 일어나지 않는다는 가정하에, 동시 요청에 대한 성능이 비관적 락에 비해 우수하다.
- 다른 트랜잭션이 데이터에 접근하는 것을 제어하지 않으므로, 트랜잭션이 길어질 때 다른 작업이 영향을 받지 않아 성능상 더 좋을 수 있다.
낙관적 락의 단점
- 충돌 시 데이터를 롤백 처리해야 하는데, 이는 충돌이 많을 때 오버헤드가 크다. (충돌을 방지하지 못한다.)
- 버전이 맞지 않아 예외가 발생할 때 재시도 로직을 구현해야 한다.
비관적 락
비관적 락의 장점
- 동시성 문제(Race Condition)이 자주 일어날 때, 낙관적 락보다 성능이 우수하다.
- DB의 Lock을 통해 동시성을 제어하기 때문에 데이터의 일관성이 보장된다.
비관적 락의 단점
- 다른 트랜잭션을 데이터에 접근할 수 없으므로, 동시성이 낮아진다.
- 트랜잭션끼리 서로 다른 자원을 선점하여 데드락에 걸릴 위험이 있다. (데이터가 여러 개 필요할 때)
- 충돌이 일어나지 않을 때도, 자원을 선점하는 과정이 필요하기 때문에 오버헤드가 있다.
그럼 언제 어떤 Lock을 사용하면 좋을까?
결론부터 말하면
동시성 문제(Race Condition)가 자주 일어난다고 예상되면 비관적 락을 사용하는 것이 좋고,
그게 아니라면 낙관적 락을 사용하는 것이 좋다.
충돌이 잦으면 낙관적 락은 쿼리를 재시도하는 오버헤드가 크고,
충돌이 잦지 않다면 비관적 락은 자원을 선점하는 과정에서 오버헤드가 클 것이다.
어떤 환경에서 환경에서 어떤 Lock을 사용하면 좋을지 자료를 찾아보면서 인프런 질문글을 봤다.
갱신 손실 문제가 일어날 것 같은 로직에는 무조건 동시성 제어를 해줘야할까요? - 인프런
안녕하세요 선생님, 먼저 좋은 강의 감사드립니다.강의를 들으며 갱신 손실 문제를 해결하기 위한 여러 방법들을 학습하며 문득 이런 고민이 생겼습니다. 갱신 손실 문제가 일어날 것 같은 로직
www.inflearn.com
이 글에서는 먼저 낙관적 락으로 구현 후, 운영하면서 충돌이 많다면 비관적 락과 Redis를 사용한 Lock을 고려한다고 한다. 또는 순간적인 트래픽이 많을 것이라고 생각되는 경우에는 설계단계부터 redis lock을 고려하여 설계하는 것을 추천한다.
5. 마무리
동시성 문제를 해결하기 위해서는 크게 낙관적 락과 비관적 락을 많이 사용한다.
낙관적 락은 어플리케이션 레벨에서 동시성 문제를 제어할 수 있고, 비관적 락은 DB 레벨에서 DB의 Lock을 사용하여 동시성 문제를 제어한다.
동시성 문제가 많이 발생하지 않을 것이라 예상된다면 낙관적 락을 우선으로 고려하고, 운영 중 충돌이 잦다면 비관적 락과 redis 사용한 lock을 고려하여 설계한다.
나는 프로젝트에서 조회수와 북마크수에 동시성 문제를 처리해줘야 한다.
조회와 북마크 등록은 짧은 순간에 많은 충돌이 일어나지 않을 기능으로 판단하여 모두 낙관적 락으로 동시성 문제를 해결하기로 했다.
더 나아가 redis를 사용한 lock을 고려해야 할 것이다.
6. 더 공부해야할 것
- named lock
- redis를 사용한 lock
분산된 서버와 DB 환경에서 lock을 적용하려면 ?
https://prefercoding.tistory.com/66
(2) 스프링 Redisson 분산락
진행 중인 프로젝트는 여러 개의 서버가 nginx로 로드밸런싱 되고 있고, DB는 Master-Slave 구조로 이중화되어 있다. 이런 환경에서는 어떤 락을 사용해서 동시성 문제를 해결해야 할까 ? 목차 1. 분산
prefercoding.tistory.com
Reference
lock도 rock이다.
https://docs.spring.io/spring-data/jpa/reference/jpa/locking.html
Locking :: Spring Data JPA
To specify the lock mode to be used, you can use the @Lock annotation on query methods, as shown in the following example: This method declaration causes the query being triggered to be equipped with a LockModeType of READ. You can also define locking for
docs.spring.io
https://ksh-coding.tistory.com/125
[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락)
0. 들어가기 전 이전에는 DB 단의 동시성 처리 방법인 Lock에 대해서 알아봤습니다. https://ksh-coding.tistory.com/121 [DB] DB Lock이란? (feat. Lock 종류, 블로킹, 데드락) 0. 락(Lock)이란? 여러 커넥션에서 동시
ksh-coding.tistory.com
https://ttl-blog.tistory.com/1568
[Spring] 동시성 문제 해결방법 (2) - 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock)
🤔 서론 이전 글에서 알아본 synchronized는 단일 서버 환경에서만 동시성 문제를 해결할 수 있었습니다. 이번 글에서는 다중 서버 환경에서 동시성 문제를 해결할 수 있는 방법에 대해 알아보도록
ttl-blog.tistory.com