Redis가 싱글 스레드 모델임에도 높은 성능을 보장하는 이유
이전 프로젝트에서 사용자들이 동시에 같은 이름으로 운동 그룹을 생성하려고 할 때 그룹 이름이 중복으로 생성된 이슈가 있었다. 이는 애플리케이션 단에서 그룹 이름 중복 검사와 실제 데이터 변경 사이의 짧은 시간 간격에 또 다른 인스턴스에서 동일한 작업이 이루어져 발생하는 RaceCondition 문제였다. RaceCondition이란 둘 이상의 프로세스(스레드)가 공유 데이터에 병행적으로 읽거나 쓰는 작업을 할 때, 공유 데이터에 대한 접근이 어떤 순서에 따라 실행 결과가 달라지는 상황을 말한다.
프로젝트 환경은 API 서버가 ASG에 의해 Scale-Out되어 다중 인스턴스로 운영되는 환경이었다.
먼저 동시성 이슈가 발생할 수 있는 예제 코드이다.
@Transactional
public ExerciseGroup createGroup(String groupName) {
if (groupRepository.existsByName(groupName)) {
return null;
}
ExerciseGroup group = new ExerciseGroup(groupName);
return groupRepository.save(group);
}
위 createGroup 메서드는 그룹 이름의 존재 여부를 확인한 후, 존재하지 않는 경우에만 새 그룹을 생성한다.
@Test
public void testCreateGroupConcurrency() throws InterruptedException {
String groupName = "TestGroup";
int numberOfThreads = 100; // 동시에 실행할 스레드 수
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
executor.submit(() -> {
try {
groupService.createGroup(groupName);
} finally {
latch.countDown();
}
});
}
latch.await();
assertThat(groupRepository.findAll().size()).isEqualTo(1);
}
실제 그럴일은 없겠지만 동시에 100개의 스레드에서 중복된 이름의 그룹을 생성한다 가정하고 createGroup 메서드를 100번 호출하는 테스트 메서드를 작성했다.
테스트 기대 결과는 단 하나의 그룹 인스턴스만 DB에 저장되는 것이지만 실제 결과는 40개의 중복된 그룹이 생성되었다. 이는 그룹 이름 중복 검사 로직과 실제 그룹 생성 사이에 RaceCondition이 발생했기 때문이다.
본격적으로 동시성 문제를 해결하기 위해 가장 먼저 Java의 내장 동기화 매커니즘인 synchronized 키워드 사용을 고려했다. 기본적으로 멀티 쓰레드 환경에서 구동되는 스프링 웹 애플리케이션 환경에서 synchronized를 사용하면 특정 객체에 대한 접근을 한 시점에 하나의 스레드만 수행할 수 있도록 제한할 수 있다.
@Synchronized
@Transactional
public ExerciseGroup createGroup(String groupName) {
if (groupRepository.existsByName(groupName)) {
return null;
}
ExerciseGroup group = new ExerciseGroup(groupName);
return groupRepository.save(group);
}
다음은 syncronized를 사용했을 때 실행 결과이다. 하지만 syncronized 키워드를 붙였음에도 불구하고, 여전히 예상 결과와 달랐다.
이는 @Transactional의 동작 방식 때문인데, @Transactioal을 사용하면 Spring AOP로 인해 실제 메서드를 감싸고 있는 동적 프록시를 생성한다. 이 프록시는 트랜잭션 관리를 위한 추가 작업 (트랜잭션 시작, 커밋)을 메서드 호출 전후에 수행한다. 즉, 원래 객체인 createGroup()은 동시성 제어를 받지만, 프록시 객체는 동시성 제어를 받지 않아 트랜잭션이 커밋되기 전에 다른 스레드가 데이터를 읽었기 때문에 발생했던 문제였다.
몰론 트랜잭션 격리 수준을 SERIALIZABLE로 올리면 DB 트랜잭션 간의 데이터 격리를 보장할 수 있어 문제가 해결된다. 하지만 데이터 격리 수즌을 조절하는 것은 스레드 간의synchronized와는 전혀 다른 접근일 뿐더러 동시 처리 성능이 매우 떨어져 근본적인 해결책이 될 수 없다.
이를 해결하기 위해 아래와 같이 synchronized 안에 @Transactional 메서드를 호출하는 구조로 바꿔주었다.
public class GroupService{
private final GroupRepository groupRepository;
@Transactional
public ExerciseGroup createGroup(String groupName) {
if (groupRepository.existsByName(groupName)) {
return null;
}
ExerciseGroup group = new ExerciseGroup(groupName);
return groupRepository.save(group);
}
}
public class FacadeGroupService {
private final GroupService transactionService;
@Synchronized
public ExerciseGroup createGroup(String groupName) {
return transactionService.createGroup(groupName);
}
}
synchronized 안에 @Transactional 메서드를 넣으면 FacadeGroupService의 createGroup 메서드 호출을 순차적으로 이루어지게 하여, GroupService의 createGroup 메소드에 대한 동시 호출을 방지할 수 있었다.
synchronized는 한 JVM 내에서 실행되는 스레드 간의 동기화를 위해 설계되어 다중 인스턴스 환경에서는 각 인스턴스가 별도의 JVM을 실행하기 때문에 전체 시스템에 걸친 동기화를 제공할 수 없다. DB 레벨의 동시성 제어가 필요한 순간이다.
다음으로 DB 레벨에서 동시성을 제어할 수 있는 방법을 찾아보았다.
비관적 락이란 트랜잭션이 데이터를 읽거나 변경하기 전에 Shared Lock 또는 Exclusive Lock을 걸어 두는 방식이다. 트랜잭션끼리의 충돌이 발생한다고 가정하고 일단 락을 건다. 특정 데이터를 변경하기 전에 해당 데이터에 대해 Exclusive Lock을 요청하고, 해당 트랜잭션이 완료될 때까지 다른 트랜잭션은 읽거나 쓸 수 없다. 또한 데이터를 읽기 위해 Shared Lock을 요청하면, 해당 데이터는 다른 트랜잭션이 동시에 읽을 수는 있지만, 변경할 수는 없다.
비관적 락은 Repeatable Read 또는 Serializable 정도의 격리성 수준을 제공하여 데이터의 무결성을 보장하는 수준이 매우 높아 충돌이 자주 발생하는 환경에 대해서는 성능에서 유리할 수 있다.
하지만 데이터 자체에 락을 걸어버리므로 데드락의 가능성과 성능 저하의 위험도 고려해야한다. 즉, 비관적 락을 적용할 때에는 최소한의 필요한 범위에만 적용하여 부작용을 최소화하는 것이 중요하다.
JPA에서 비관적 락은 간단하게 메서드에 @Lock 어노테이션만으로 설정할 수 있다.
트랜잭션이 종료되고 커밋되거나 롤백되면, DB는 자동으로 락을 해제한다. 아래 @Lock 어노테이션을 적어주고, 비관적 락이 잘 작동하는지 테스트 해보았다.
public interface GroupRepository extends JpaRepository<ExerciseGroup, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // Exclusive Lock
Boolean existsByName(String groupName);
}
비관적 락이 잘 작동하는 것을 볼 수 있다. 하지만 로그를 확인해보니 중간중간에 Deadlock exception이 발생하는 것을 볼 수 있었다. 이는 Exclusive Lock 특성상 트랜잭션이 데이터에 접근하기 전에 미리 락을 걸어두기 때문에, 여러 트랜잭션이 동시에 데이터에 접근하려고 할 때 순환 의존성(Deadlock)이 발생할 수 있기 때문이다.
낙관적 락은 충돌이 발생할 것이라는 '비관적' 가정 대신에 충돌이 드물게 발생할 것이라는 '낙관적'가정에 기반을 둔다. 즉, 트랜잭션이 데이터를 읽을 때 데이터가 변경되지 않았다고 가정하고, 실제 데이터를 업데이트하거나 커밋할 때만 충돌 여부를 검사한다. 낙관적 락에서는 일반적으로 version의 상태를 기반으로 현재 데이터가 읽은 시점 이후에 변경되었는지 확인을 통해 충돌을 확인한다. 트랜잭션 커밋 시, 변경하려는 데이터의 버전이 달라졌다면 충돌이 발생한 것으로 간주하고 롤백을 진행한다.
비관적 락과 달리, DB 레벨이 아닌 애플리케이션 레벨에서 처리된다.
낙관적 락은 락을 사용하지 않아 동시성과 성능 측면에서 이점이 있지만, 충돌이 빈번하게 발생하는 환경에서는 효율이 떨어질 수 있다.
JPA에서 낙관적 락은 엔티티에 @Version 어노테이션을 사용하는 것이다. 각 엔티티에는 @Version 속성이 하나만 있어야 한다.
@Getter
@NoArgsConstructor
@Entity
public class ExerciseGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Long version;
private Integer count;
...생략
}
public interface GroupRepository extends JpaRepository<ExerciseGroup, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select g from ExerciseGroup g where g.id = :id")
ExerciseGroup findByIdWithOptimisticLock(@Param("id") Long id);
}
GroupRepository의 findById와 같은 기본 제공 메서드에는 락 모드를 직접 지정하는 기능이 없으므로, 낙관적 락을 적용하기 위해 쿼리 메서드를 작성했다. 낙관적 락의 주된 목적은 데이터의 동시 변경에 의한 충돌을 방지하는 데 있다. 그래서 이전 엔티티를 생성하는 메서드 대신 그룹 인원수를 업데이트하는 메서드를 따로 작성했다.
@RequiredArgsConstructor
@Component
public class OptimisticLockGroupFacade {
private final GroupService groupService;
private final int maxRetires = 10;
public void decrease(Long id, Integer count) throws InterruptedException {
int attempt = 0;
while (attempt < maxRetires) {
try {
groupService.decreaseMemberCount(id, count);
break;
} catch (ObjectOptimisticLockingFailureException e) {
e.printStackTrace();
attempt++;
if (attempt >= maxRetires) {
throw new OptimisticLockException("낙관적 락 재시도 횟수 초과");
}
Thread.sleep(50);
}
}
}
}
@RequiredArgsConstructor
@Service
public class GroupService{
private final GroupRepository groupRepository;
@Transactional
public void decreaseMemberCount(Long groupId, Integer count) {
ExerciseGroup exerciseGroup = groupRepository.findByIdWithOptimisticLock(groupId);
exerciseGroup.decreaseCount(count);
groupRepository.saveAndFlush(exerciseGroup);
}
}
OptimisticLockGroupFacade 클래스에서는 GroupService의 decrease 메서드를 호출하고, 낙관적 락에 대한 예외를 처리하는 로직을 캡슐화해주었다. 기본 동작은 decrease 메서드에서 커밋하려고 할 때, 현재 버전 번호와 초기에 읽어온 번호가 일치하면 version 필드를 1증가시키고, 불일치하면 OptimisticLockException을 발생시킨다.
예외가 발생하면 잠시 대기한 후(50ms) decrease 메서드 호출을 재시도 한다. 재시도 로직 같은 경우, 개발자가 직접 작성해줘야 하는데 무한 루프에 빠지지 않도록 재시도 횟수를 제한하는 것이 좋다. 즉, 시스템 부하와 충돌 해결 가능성 사이 균형을 맞추는 것이 중요! 위 코드에서는 attempt가 maxRetries에 도달하면 예외를 던지도록 해주었다.
낙관적 락 테스트까지 성공적으로 통과했다.
낙관적 락은 트랜잭션을 필요로하지 않기 때문에 성능적으로 비관적 락보다 더 좋다. 트랜잭션을 필요로 하지 않기 때문에 아래와 같은 로직의 흐름을 가질때도 충돌을 감지할 수 있다.
때문에 충돌이 많이 일어나지 않을 것이라고 보여지는 곳에 사용하면 좋은 성능을 기대할 수 있다. 하지만 낙관적 락의 최대 단점은 롤백이다. 만약 충돌이 났다고 한다면 이를 해결하려면 개발자가 수동으로 롤백처리를 해줘야한다.
수동으로 롤백처리는 구현하기도 까다롭지만 성능적으로 보더라도 update를 한번씩 더해줘야 한다. 결과적으로 상황에 따라 비관적 락보다 성능이 좋지 않을 수 있다. 즉, 낙관적 락은 충돌이 많이 예상되거나 충돌이 발생했을 때 비용이 많이 들것이라고 판단되는 곳에서는 사용하지 않는 것이 좋다.
비관적 락이나 낙관적 락은 레코드나 테이블 단위로 락을 걸지만, 네임드 락은 사용자가 지정한 고유한 이름을 가진 메타데이터에 락을 거는 방법이다. 주의할 점은 트랜잭션이 종료될 때 락이 자동으로 해제되지 않기 때문에 별도의 명령어로 해제를 해주거나 선점 시간이 끝나야 락이 해제된다. 즉, 락의 획득, 반납에 대한 로직을 철저하게 구현해주어야 한다. MySQL에서는 'GET_LOCK()' 명령어를 통해 네임드 락을 획득하고, 'RELEASE_LOCK()' 명령어로 락을 해제할 수 있다.
public interface GroupLockRepository extends JpaRepository<ExerciseGroup, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
getLock 메서드를 호출하면 특정 키에 대한 Named Lock을 획득한다. get_lock은 두 개의 파라미터를 받는데, 첫 번째는 락의 고유한 이름(key)이고, 두 번째는 해당 락을 획득하기 위해 대기할 최대 시간 값이다. 만약 락을 획득하면, 이 락을 획득한 세션만이 락을 해제할 수 있다. 다른 세션이 지정된 타임아웃 내에 락을 획득할 수 없으면, 타임아웃이 발생하고 락 획득을 실패하게 된다.
@RequiredArgsConstructor
@Component
public class NamedLockGroupFacade {
private final GroupLockRepository groupLockRepository;
private final GroupService groupService;
@Transactional
public void decreaseMemberCount(Long groupId, Integer count) {
try{
groupLockRepository.getLock(groupId.toString());
groupService.decreaseMemberCount(groupId, count);
}finally {
groupLockRepository.releaseLock(groupId.toString());
}
}
}
lock 생성과 해제가 같은 세션에 바인딩되게 하기 위해 하나의 트랜잭션으로 묶어준다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decreaseMemberCount(Long groupId, Integer count) {
ExerciseGroup exerciseGroup = groupRepository.findByIdWithOptimisticLock(groupId);
exerciseGroup.decreaseCount(count);
groupRepository.saveAndFlush(exerciseGroup);
}
트랜잭션 Propagation수준을 REQUIRES_NEW로 설정해 메서드가 호출될 때마다 새로운 트랜잭션을 시작하도록 한다. NamedLockGroupFacade에서 Named Lock을 관리하는 동안, GroupService의 비즈니스 로직을 별도의 트랜잭션에서 실행시켜 commit 이후 lock의 해제가 수행되도록 하는 것이다. 이를 통해 GroupService의 로직 실행 중 예외가 발생해 롤백이 된다하더라도, 해당 비즈니스 로직에 대한 롤백만 수행되고, 전체 시스템에 대한 롤백은 피할 수 있어 안전하게 락 해제를 할 수 있게 된다.
네임드 락은 MySQL 서버 전체에서 유일한 식별자를 통해 어떤 클라이언트라도 해당 락을 요청하고 획득이 가능하므로, 데이터 일관성과 무결성을 유지시키는 동기화 메커니즘을 제공한다. 즉, 네임드 락은 MySQL 서버 내에서 전역적으로 관리되므로, 분산 락 구현에 많이 사용된다.
앞서 설명했듯이, 네임드 락은 REQUIRES_NEW와 같은 트랜잭션 전파 옵션을 사용하여 별도의 트랜잭션을 시작해야 한다. 하지만 데이터베이스 커넥션 풀, HikariCP의 최대 크기는 한정되어 있다. 네임드 락이 획득한 쓰레드가 새로운 트랜잭션을 시작하기 위해 새 커넥션을 요구할 때, 이미 사용 중인 커넥션으로 인해 요청이 대기 상태(데드락)에 빠질 수 있다. HikariCP Maximum Pool Size를 늘릴 수 있으나, 놀고 있는 커넥션이 많게 되어 비효율적이다.
DB 레벨에서 네임드 락을 사용하는 대신 Redis나 ZooKeeper와 같은 분산 시스템을 사용하면 데드락 문제를 완화하고 시스템의 확장성을 높일 수 있다.
기존에 별도의 Redis를 운영하고 있지 않으며 MySQL로 락 관리를 하였을 때 별다른 문제가 없다면 MySQL의 네임드 락을 거는 것이 비용면에서 합리적이다. 네임드 락과 Redis를 이용한 락 사이의 선택은 애플리케이션 요구 사항과 인프라 환경에 크게 의존한다. MySQL을 이미 사용중이고 추가 시스템 도입을 원하지 않다면 네임드 락이 좋은 해결책이겠지만 높은 성능과 확장성, 그리고 DB 부하와 의존성을 줄이고 싶다면 Redis를 이용한 분산 락을 고려해볼 수 있다.
분산락이란 여러서버에서 공유된 데이터를 제어하기 위해 사용하는 기술이다. 분산 락을 구현하기 위해서는 락에 대한 정보를 '어딘가'에 공통적으로 보관하고 있어야하며 분산 환경에서 여러 대의 서버들은 공통된 '어딘가'를 바라보며 자신이 임계 영역에 접근할 수 있는지 확인해야 한다. 즉, 여러 서버들이 임계 구역에 접근할 수 있도록 락킹을 하기 위해서 분산 락 기술이 필요하다.
Redis가 분산 락 구현에 좋은 이유 중 하나는 싱글 스레드 모델을 기반으로 동작하기 때문에 Atomic하다는 점이다. 즉, 동시에 하나의 명령어(분산락을 설정하거나 해제)만 처리할 수 있으므로 중간에 다른 명령어에 의해 방해받지 않고 원자적으로 실행되도록 보장해준다. 뿐만 아니라, 싱글 스레드 모델이므로 컨텍스트 스위칭이나 락 경쟁으로 인한 오버헤드가 없기 때문에 I/O Multiplexing 기술을 이용해 매우 빠른 속도를 보장한다.
이제 Redis를 활용한 분산 락을 구현할 때 주로 사용되는 두 가지 클라이언트 라이브러리인 Lettuce와 Redisson에 대해 알아보자.
Lettuce는 비동기적인 이벤트 기반의 Redis 클라이언트 라이브러리로, Netty를 기반으로 만들어져 있다. Lettuce를 사용하여 분산 락을 구현하는 방식은 Redis의 기본 명령어를 활용하는 것이다. Redis에는 setNX()와 expire() 명령어를 지원한다. setNX()는 "Set if Not Exists"의 약자로, 지정된 키가 존재하지 않을 때만 값을 설정하는 명령어이다. 이 setNX()명령이 성공하면, 즉 락 키가 존재하지 않아 새로운 값이 설정되면, 락 획득에 성공한 것이다.
락 키에 대한 만료 시간 설정은 락을 보유하는 시간을 제한하기 위해 필요한데 이때 사용되는 명령어가 expire()이다.
https://sabarada.tistory.com/175
https://www.korecmblog.com/blog/redis-dlm
https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html