동시성
개요
프로젝트를 하며 동시성 이슈를 발견하였고 관련된 내용을 정리해보려한다.
동시성 이슈란?
동시성 이슈란 동시 다발적인 요청으로 인해 데이터베이스의 정합성이 깨지는 현상을 말한다.
무결성은 데이터가 정확하고 완전하다는 의미, 정합성은 데이터간의 일관성이 있다는 의미이다.
웹은 여러명의 사용자가 동시에 접속하고 작업을 수행한다. 그로 인해 예상치 못한 문제가 나타나기도 한다.
예를 들어 게시글 좋아요 기능을 생각해보자.
게시글의 좋아요 갯수를 게시글 엔티티에서 필드로 가지고 있는 상황에서 거의 동시에 유저 A는 좋아요를 처음 누르고 유저 B는 이미 눌렀던 좋아요를 취소하려한다.
두 트랜잭션이 거의 동시에 시작되고 처음 게시글 좋아요 갯수를 가져온다. 트랜잭션 시작시 좋아요 갯수가 10개라면 유저 A의 트랜잭션은 좋아요 갯수를 11로 증가시키고 유저 B의 트랜잭션은 9개로 감소시킨다.
그 결과 좋아요 갯수는 11개 혹은 9개로 갱신된다.
한 명은 추가하고 한 명은 취소했으니 상쇄되어 10개의 좋아요가 나와야하는데 말이다.
이를 동시성 이슈라고 한다.
가장 간단한 방법으로는 게시글에 좋아요 갯수를 두지 않고 좋아요 갯수를 보여줄 때 count쿼리를 이용해서 계산한다면 동시성 이슈가 해결된다. 우리 프로젝트에선 원래 count쿼리를 이용하던 것을 Post엔티티에서 좋아요 갯수를 관리하도록 변경하여 생긴 문제였다.
동시성 테스트 방법
실제로 동시성 문제가 생기는지 확인해보자.
동시성 테스트는 어떻게 하는걸까?
일단 여러 스레드의 동시 요청을 재현할 수 있는 클래스가 필요하다.
자바에서는 동시성 테스트를 위해 java.util.concurrent 패키지의 ExecutorService 인터페이스를 제공하고 있다.
다음과 같이 사용한다.
// 최대 32개의 스레드를 동시에 처리할 수 있는 스레드풀을 사용한다고 가정하는 코드
ExecutorService executorService = Executors.newFixedThreadPool(32);
executorService.submit(() -> {
로직
})
이와 같이 사용하는 클래스는 CountDownLatch
가 있다.
여러 스레드가 작업을 동시다발적으로 처리하기 때문에 어떤 스레드는 일이 빨리 끝나고 다른 쓰레드는 좀 늦게 끝날 수 있다. 예를 들어 100개의 동시 요청을 가정했는데 위의 코드의 경우 한 번에 처리할 수 있는 스레드는 32개이므로 나머지 68개의 요청은 기다려야한다.
이들의 싱크를 맞춰주는 역할을 하는 것이 바로 CountDownLatch
다.
countDown
메소드는 CountDownLatch
인스턴스를 생성할 때 상정해둔 스레드의 갯수를 넣어주고 스레드가 처리를 마친다면 카운트를 감소시킨다. await
메소드는 스레드가 작업을 마치더라도 나머지 작업들이 마쳐질 때까지(카운트가 0이 될 때까지) 기다리도록 하는 메소드로 이렇게 싱크를 맞출 수 있다. 자세한 내용은 아래의 공식문서에서 찾아볼 수 있다.
@Test
void 동시에_100개의_좋아요요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
postService.likePost(testuser, 1L, 1L);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
Post post = postRepository.findById(1L).orElseThrow();
assertThat(post.getLikeCnt()).isEqualTo(0);
}
게시글 좋아요 기능은 토글식으로 구성되어있고 만약 데이터베이스에 튜플이 존재한다면 취소한다는 의미로 튜플을 삭제하고 좋아요 개수를 감소시킨다.
테스트 결과 -3, -5, 4, 2…가 나왔다. 좋아요 요청이 100개이기 때문에 의도한대로 동작한다면 홀수 번째에는 +1, 짝수 번째에는 -1이 되므로 결과는 0이 나왔어야한다.
해결 방법
크게 3가지 방법이 있다.
- Application
- Database
- Redis
Synchronized
Synchronized
는 멀티스레드 환경에서 하나의 스레드만 접근이 가능하도록 제어하는 키워드로 다음과 같은 위치에 적용시킬 수 있다.
public synchronized Long toggleLike() {
}
주의할 점은 트랜잭셔널 애노테이션을 붙이면 안된다는 것이다.
JPA는 트랜잭션의 종료 시점에 변경사항을 반영하게되는데 실제 데이터베이스에 업데이트되기 전 다른 스레드가 해당 메소드를 호출할 수 있기 때문이다. 그렇다면 다른 스레드는 갱신되기 이전의 값을 가져가서 여전히 동시성문제가 생기게 된다.
아무튼 애플리케이션 레벨에서 Synchronized를 이용하여 테스트해보면 테스트케이스는 통과된다. 이게 1번 방법이다. 이건 데이터베이스의 개념이 들어간 것이 아니라 여러 쓰레드에서 동시다발적으로 일어나는 메소드 호출에서 하나의 쓰레드가 작업을 마칠 때까지 다른 쓰레드는 기다리게 만들어 말하자만 순차적으로 처리하게 하는 것이다.
이러면 동시성 문제가 해결된다. 그러나 응답의 지연이 발생할 수 있기 때문에 좋은 방법은 아닌 듯 하다. 비유하자면 100개의 레일이 있지만 하나의 레일만 사용해서 처리하는 식이다.
또한 동기화는 하나의 프로세스 안에서만 적용되는 개념이기 때문에 만약 서버가 여러 대가 된다면 여전히 동시성 이슈가 발생한다.
Database
데이터베이스의 Lock 개념을 활용하여 동시성 이슈를 막고 데이터 정합성을 지킬 수 있다.
크게 3가지 종류의 락이 있다.
- Pessimistic Lock
- Optimistic Lock
- Named Lock
Pessimistic Lock은 한국어로는 비관적 락이라고 불린다. 모든 트랜잭션이 서로 충돌하는 트랜잭션일 가능성을 가졌다고 보는 관점으로 그래서 비관적 락이라는 이름이 붙었다.
원리는 이렇다.
실제로 데이터에 락을 걸어서 락이 해제되기 전에는 데이터를 읽을 수 없게 하여 데이터의 정합성을 지키는 것이다. 실제 DB의 Lock기능을 이용한다는 특징이 있다.
Optimistic Lock은 낙관적 락이라고 불리고 원리는 다음과 같다.
여러대의 서버가 있다고 했을 때 서버 1에서 다음과 같이 쿼리를 날린다.
update set version = version + 1 ... where id = 1 and version = 1
이 쿼리가 반영되면 서버의 버전은 2로 바뀐다.
그리고 서버 2가 쿼리를 날린다.
update set version = version + 1 ... where id = 1 and version = 1
서버 2의 요청을 확인해보니 조건으로 들어가는 version값이 다르다. 이는 참조했던 데이터가 변경된 상태이고 이대로 반영한다면 무결성이 깨진다는 것을 의미한다. 따라서 쿼리를 반영하지 않고 다시 정보를 읽은 후 쿼리를 수행한다.
이런 식으로 update쿼리를 날릴 때 version을 체크하기 때문에 동시성을 보장할 수 있는 것이다.
Named Lock은 이름을 가진 메타데이터 Lock 로그로 이름을 가진 락을 획득한 후 해제될 때까지 다른 세션은 이 락을 획득할 수 없도록 한다.
Pessimistic Lock
스레드1
-> 좋아요 버튼을 처음 누른 경우
스레드2
-> 좋아요를 취소하는 경우
스레드1
이 락이 걸고 데이터를 가져간다. (좋아요 갯수 10개) 이 시점에 스레드2
가 데이터 획득을 시도한다. 그러나 스레드1
이 점유중인 데이터이기 때문에 잠시 기다린다. 스레드1
의 작업이 끝난 다음(좋아요 개수 11개) 스레드2
가 접근해 데이터를 가져간다. 스레드2
는 다른 트랜잭션에서 갱신된 좋아요 개수를 가져와 좋아요 개수를 다시 10개로 만든다.
이제 직접 스프링 프로젝트에 적용해보자.
JPA에선 이를 위해 @Lock
이라는 애노테이션을 제공한다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from post p where p.id = :postId")
Post findByIdWithPessimisticLock(Long postId);
}
이렇게 적용하고 테스트해보면 데이터 정합성이 맞춰진 것을 확인할 수 있다.
특징
- 데이터 충돌이 빈번하다면 낙관적 락보다 좋은 성능을 기대할 수 있다.
- 데이터에 락을 걸기 때문에 성능이 저하될 수 있다.
Optimistic Lock
public class Post {
...
@Version
Long version;
...
}
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Post p where p.id = :postId")
Post findByIdWithOptimisticLock(Long postId);
이렇게 쿼리를 작성한 뒤 재시도 로직을 작성해야한다.
public void togglePostLike(Long clubId, Long postId, User user) throws InterruptedException {
while(true) {
try {
postService.likePost(user, clubId, postId);
break;
} catch(Exception e) {
Thread.sleep(50);
}
}
}
@Version
애노테이션이 붙은 필드는 엔티티가 삽입될 때 0또는 1로 초기화된다.
엔티티가 수정될 때마다 해당 버전 값은 자동으로 증가하며 별도로 버전 값을 수정할 필요 없이 JPA가 관리한다.
앤티티를 읽을 때 version필드 값을 조회한 뒤 메모리에 저장한다. 이후 수정할 때 데이터베이스의 필드 값과 메모리에 저장된 값을 비교한다.
만약 버전이 다르다면 다른 트랜잭션에서 변경이 발생한 것으로 간주하고 OptimisticLockException
예외를 발생시킨다.
만약 이 이외에 다른 예외가 발생할 수 있는 상황이라서 Exception
위와 같이 작성하고 테스트했더니 무한루프가 발생했다.
jpql쿼리에 @Param 애노테이션을 적어주지 않아서 생긴 문제로 커스텀 쿼리 테스트의 필요성을 느꼈다.
테스트해보면 비관적 락보다 오래걸린다. 충돌이 발생할 경우 계속해서 재시도를 따라서 충돌이 빈번하게 일어난다면 비관적 락을 고려하는 것이 좋고 그렇지 않다면 낙관적 락을 고려하는 것이 좋다.
Named Lock
이름을 가진 락을 생성하고 작업이 완료될 때까지 다른 트랜잭션에서 접근할 수 없도록 하는 것이다. 트랜잭션이 종료될 때 락 자동으로 해제되지 않고 선점시간이 끝나거나 별도의 명령어를 입력해주어야한다. 주의해야할 점은 데이터소스를 분리해서 사용해야한다는 것이다. 같은 소스를 사용한다면 커넥션의 수가 부족해지고 다른 서비스에도 영향을 끼칠 수 있기 때문이다.
위에서 살펴봤던 락과는 다르게 별도의 공간에 락을 건다.
하나의 세션에서 락을 건다면 다른 세션에서는 락이 해제된 후 락을 획들할 수 있다.
MySQL에서는 getLock명령어를 통해 락을 획득할 수 있다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select get_lock(:key, 3000)", nativeQuery=true)
void getLock(String key);
@Query("select release_lock(:key)", nativeQuery=true)
void releaseLock(String key);
}
이렇게 락을 받아온 후 해제시키는 코드를 작성해준다.
@Transactional
public void likePost() {
try {
postRepository.getLock();
postService.likePost();
} finally {
postRepository.releaseLock();
}
}
이렇게 작성하고 부모의 트랜잭션과 별도로 수행되어야하기 때문에 트랜잭션을 변경해준다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
...
네임드 락은 주로 분산락을 구현할 때 사용하고 비관적 락은 타임아웃을 구현하기 힘들지만 네임드락은 타임아웃을 쉽게 구현할 수 있다.
Redis
레디스가 동시성 이슈의 해결책 중 하나가 될 수 있는 이유는 레디스를 사용하면 여러 스레드에 공통된 락을 적용하는 분산락을 활용할 수가 있기 때문이다.
레디스는 구조상 명령을 처리할 때 단일 스레드로 동작하며 한번에 하나의 명령만 실행한다.
크게 2가지 방법이 있는데
Lettuce
: 스핀 락 방식으로 setnx메소드를 사용하여 직접 분산락을 구현한다.Redisson
: Lock 구현체의 형태로 분산락을 제공한다.
Lettuce
setnx는 set if not exists라는 의미의 명령어로 키와 벨류를 set할 때 기존의 값이 없을 때만 set하는 방법이다.
스핀 락이란 락을 획득하려는 스레드가 락을 사용할 수 있는지 확인하면서 락 획득을 시도하는 방법이다.
예를 들어 스레드 1이 데이터를 redis에 set하려고 한다. 처음에는 값이 없기 때문에 set하려는 시도가 성공하게 된다. 그리고 스레드 2가 같은 데이터를 set하려고 한다면 이미 redis에 값이 있기 때문에 실패하게된다.
스레드 2는 일정 시간 이후에 재시도한다.(별도로 작성해주어야한다.)
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generageKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final PostService postService;
public void likePost(Long postId) {
while(!redisLockRepository.lock(postId)) {
Thread.sleep(100);
}
try {
postService.likePost(postId);
} finally {
redisLockRepository.unlock(postId);
}
}
}
Redisson
채널을 하나 만들고 락을 점유중인 스레드가 락을 획득 하려고 대기중인 스레드에게 해제를 알려주면 락 획득을 시도하는 방법으로 재시도 로직을 작성하지 않아도 된다.
Redisson을 이용한 방식은 Redis의 publish channel 채널명, subscribe 채널명 명령어를 사용한다.
@Component
@RequiredArgsConstructor
public class RedissonLockPostFacade {
public void likePost(Long postId) {
RLock rlock = redissonClient.getLock(postId.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if(!available) {
log.info("lock 획득 실패");
return;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}