1. 문제 상황
특정 사용자의 미니홈에 방문하면 방문자 수가 1 증가한다. 그러나 멀티 스레드 환경에서 방문자 수 필드에 대한 동시성 문제가 발생한다.
JMeter를 통해 20명의 사용자가 동시에 user_id=1인 사용자의 미니홈에 방문하는 상황을 테스트해보자. 테스트 결과, 총 방문자 수 필드가 20이 아닌 7이 된 것을 확인했다.

애플리케이션 코드를 보면, 동시에 여러 스레드가 미니홈 데이터를 조회한 후, 동시에 +1을 하기 때문에 race condition이 발생한 것을 알 수 있다.
@Transactional
public Minihome visitMinihome(User minihomeUser) {
Minihome minihome = minihomeRepository.findByUser(minihomeUser)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_MINIHOME));
minihome.visit(); // 방문자수 1 증가시킴
minihomeRepository.update(minihome);
return minihome;
}
2. 해결 과정
2-1. synchronized
동시성을 제어하기 위한 첫번째 방법으로 자바의 synchronized을 떠올렸다. 가장 단순하게 애플리케이션 단에서 제어를 하고자 한다면, synchronized을 사용하면 된다.
그러나 서버 증설 시 동시성 제어가 되지 않기 때문에 추천되는 방법은 아니다.
2-2. 쓰기 락
데이터베이스 단에서 동시성 제어를 하고자 한다면, 락을 걸면 된다. 락에는 읽기 락과 쓰기 락이 있다.
우선 읽기 락을 거는 상황을 테스트해보자. JPA에서 '@Lock(LockModeType.PESSIMISTIC_READ)'를 사용하면 SELECT FOR SHARE 쿼리가 실행되며 조회하는 row에 읽기 락을 건다. 읽기 락은 동시 점유가 가능하므로, 요청이 몰렸을 때 여러 스레드가 동시에 같은 row에 읽기 락을 걸고 있게 된다. 그리고 총 방문자 수 필드를 UPDATE하기 위해서는 쓰기 락을 획득해야 하는데, 상대 트랜잭션이 읽기 락을 쥐고 있기 때문에 대기하게 된다. 즉, 트랜잭션 간에 서로 상대 트랜잭션이 읽기 락을 내려놓기를 기다리게 되며 데드락이 발생한다.
테스트 결과, 데드락이 발생한 것을 확인할 수 있다.

다음으로 쓰기 락을 거는 상황을 테스트해보자. JPA에서 '@Lock(LockModeType.PESSIMISTIC_WRITE)'를 사용하면 SELECT FOR UPDATE 쿼리가 실행되며 조회하는 row에 쓰기 락을 건다. 쓰기 락은 동시 점유가 불가하고, 한 트랜잭션이 쓰기 락을 획득했다면 다른 트랜잭션은 해당 데이터를 조회조차 하지 못하고 대기한다. 따라서 요청이 몰렸을 때 여러 스레드 간 요청이 순차적으로 처리됨이 보장된다.
테스트 결과, 20개의 미니홈 조회 요청을 보냈을 때, 총 방문자 수 필드 값이 20이 된 것을 확인할 수 있다.

2-3. 낙관적 락
JPA에서 제공하는 락인 낙관적 락을 통해서도 동시성 제어를 할 수 있다. 총 방문자 수 필드를 버전 관리 필드로 설정하고, 버전 충돌 시 UPDATE를 재시도하도록 하면 된다.
그러나 동시에 특정 사용자의 미니홈 조회 요청은 동시다발적으로 일어날 수 있다. 따라서 낙관적 락을 적용하면, 그만큼 충돌이 빈번하고 그만큼 재시도가 잦다. 즉, 그만큼 CPU가 RUNNABLE 상태로 소모되는 것이다.
2-4. 레디스를 활용한 배치 방식
앞서 데이터베이스 쓰기 락을 통해 동시성 제어에 성공했다. 그러나 방문자 수는 실시간성이 보장될 필요가 없다. 즉, 실시간으로 정확한 데이터가 요구되지는 않는다. 따라서 매 요청마다 DB I/O가 발생하는 것은 비효율적이다.
따라서 레디스를 활용한 배치 방식을 적용해보자. 방문자 수를 레디스에 저장해두고 스케줄러를 통해 주기적으로 레디스에 저장된 값을 RDB에 UPDATE하는 방식이다.
이때 레디스에 저장하는 방식이 두가지가 될 수 있다.
- RDB에 반영한 후부터 현재 시점까지 추가된 총 방문자 수. 따라서 RDB에 UPDATE할 때 + 연산자를 사용한다.
- 해당 미니홈의 총 방문자 수. 따라서 RDB에 UPDATE할 때 = 연산자를 사용한다.
두 방식 중 두번째 방식을 적용했다. 첫번째 방식을 적용할 경우, RDB에 데이터를 반영한 후 레디스의 값을 0으로 만들어줘야 하는데, 그 사이에 방문자 수를 증가시키는 요청이 들어올 수 있다. 따라서 RDB에 데이터를 반영하고 레디스 값을 0으로 만드는 작업들의 원자성을 보장해야 한다. 그러나 두번째 방식은 RDB에 데이터를 반영하는 중에 방문자 수 증가 요청이 들어오더라도, 그 다음 배치 시 대입 연산자를 통해 RDB에 반영되기 때문에 구현이 훨씬 간단하다.
그리고 저장되는 데이터 형태는 hash를 사용했다. hash의 키는 미니홈 id, 값은 방문자 수가 된다. 그리고 방문자 수를 증가시키는 레디스의 HINCRBY 명령어는 원자적(atomic)이기 때문에 동시성 문제가 발생하지 않는다.
코드는 다음과 같다.
@Service
@RequiredArgsConstructor
public class MinihomeService {
private final MinihomeRedisRepository minihomeRedisRepository;
@Async
public void visitMinihome(Long minihomeId) {
minihomeRedisRepository.increaseVisitorCount(minihomeId);
}
}
@Repository
@RequiredArgsConstructor
public class MinihomeRedisRepository {
private final RedisTemplate<String, String> redisTemplate;
private static final String MINIHOME_VISITOR_COUNT_KEY = "minihome:visitor:count";
public void increaseVisitorCount(Long minihomeId) {
redisTemplate.opsForHash().increment(MINIHOME_VISITOR_COUNT_KEY, String.valueOf(minihomeId), 1);
}
public Map<Object, Object> getAllMinihomeIds() {
return redisTemplate.opsForHash().entries(MINIHOME_VISITOR_COUNT_KEY);
}
}
@Component
@RequiredArgsConstructor
public class MinihomeVisitorCountFlushScheduler {
private final MinihomeRedisRepository minihomeRedisRepository;
private final MinihomeRepository minihomeRepository;
@Scheduled(fixedRate = 30000)
public void publishOutboxMessage() {
Map<Object, Object> minihomeMap =
minihomeRedisRepository.getAllMinihomeIds();
for (Object minihomeId : minihomeMap.keySet()) {
Long minihomeIdLong = Long.valueOf(minihomeId.toString());
int visitorCount = Integer.valueOf(minihomeMap.get(minihomeId).toString());
minihomeRepository.updateVisitorCount(minihomeIdLong, visitorCount);
}
}
}
3. 결론
지금까지 살펴본 방식 중 쓰기락 방식과 레디스 배치 방식이 동시성 제어에 성공한 방식들이다. 그러나 방문자 수는 실시간성이 보장될 필요가 없다는 특성을 갖기 때문에, 매번 DB I/O를 발생시키는 것을 비효율적이다. 따라서 레디스 배치 방식을 적용했다.
락 방식은 특정 미니홈 방문 요청이 100개가 들어오면 100번의 UPDATE 쿼리가 발생하며 잦은 I/O가 발생했지만, 배치 방식을 적용함으로써 1번의 I/O만 발생하도록 개선되었다.