앞서 프로젝트에서 게시글 조회 성능을 위해 반정규화를 적용했다. (반정규화를 통한 조회 성능 개선)
그런데 좋아요 수를 의미하는 likeCount 필드를 추가함으로써 동시성 문제가 발생한다. 예를 들어, 멀티 스레드 환경에서 두 명의 사용자가 동시에 게시글 좋아요 요청 시, 좋아요 수가 0에서 1 그리고 1에서 2로 증가하는 것이 아니라, lost update가 발생하며 0에서 1로 업데이트 될 수 있다. 이에 대한 해결 과정은 다음과 같다.
1. 자바 락
가장 기본적으로 자바에서 제공하는 synchornized, ReentrantLock과 같은 락이 있다. 그러나 이는 애플리케이션 단의 락이기 때문에 멀티 인스턴스 환경에서는 동시성 제어가 불가하다.
2. JPA 낙관적 락
JPA가 제공하는 낙관적 락은 엔티티의 특정 필드 값을 기준으로 버전 관리를 하여, 커밋 직전에 버전이 변경되었는지 확인하고, 버전이 변경되지 않았으면, 즉 그 사이에 다른 스레드에 의해 값이 변경되지 않았으면 커밋을 한다. 그리고 버전이 변경되었으면, 예외 응답을 반환하거나 재시도를 하는 등 상황에 맞게 구현하면 된다.
낙관적 락을 적용할 시, 게시글 엔티티의 likeCount 값을 기준으로 버전 관리를 하면 된다. 그러나 좋아요 요청은 매우 빈번하게 발생한다는 특징을 갖는다. 따라서 버전 충돌이 매우 빈번할 것이며, 재시도도 매우 빈번할 것이다. 그리고 그만큼 스레드가 RUNNABLE 상태로 CPU 자원을 소모하게 된다. 즉, 매우 비효율적이라고 판단했다.
3. DB 비관적 락 + @Async 비동기
SELECT FOR UPDATE 쿼리를 통해 DB 레코드에 배타 락을 거는 방식이다. 그런데 락을 거는 방식은 순차적으로 락을 대기 및 획득하여 좋아요 요청을 처리하기 때문에 성능이 저하된다. 또한 요청이 몰리는대로 DB 커넥션을 소모하기 때문에 다른 요청에 대해서는 DB 커넥션 타임아웃 오류가 발생할 수 있다.
따라서 사용자에게 빠른 응답을 반환하고, 요청이 몰리는대로 DB 커넥션을 소모하지 않기 위해 @Async를 사용하여 비동기로 처리했다. (참고로, 비동기로 처리할 경우 사용자에게는 정상 응답이 반환되었지만 서버에서는 요청을 처리하다가 실패하는 경우가 발생할 수 있다. 그러나 좋아요 요청은 정상 응답과 다르게 요청이 실패되더라도 크게 문제되지 않는다고 판단하여 비동기 처리가 적절하다고 판단했다.)
3-1. @Async 스레드 풀 전략
@Async를 사용할 경우 비동기 작업을 처리할 스레드 풀의 스레드 풀 전략을 세워야 한다. @Async는 기본적으로 ThreadPoolTaskExecutor를 사용한다. (spring.threads.virtual.enabled가 true로 설정된 경우가 아닌 경우)
그리고 ThreadPoolTaskExecutor의 설정 값들을 스프링 부트가 설정한 기본 값으로 가져가거나 혹은 따로 설정할 수 있다. 그렇다면 스레드 풀 전략을 어떻게 하는 것이 좋을까? (스레드 풀 전략 참고: ExecutorService의 스레드 풀 관리)
3-1-1. 캐시 스레드 풀 전략
다음과 같이 core 스레드 수를 0개, 큐 사이즈를 0, max 스레드 수를 무한하게 설정하면 캐시 스레드 풀 전략이 된다.
즉, 요청이 올 때마다 요청을 큐에 저장하지 않고 바로 스레드를 할당한다. 그리고 유휴 스레드가 없으면 바로 새로운 스레드를 생성한다. 따라서 요청이 몰릴 경우, 유연하게 스레드 수가 조절되어 요청을 빠르게 처리할 수 있다. 그러나 스레드 수가 기하급수적으로 늘어나 CPU와 메모리 사용량이 증가하고 시스템 장애로 이어질 수 있다.
하나의 게시글에 5000명의 사용자가 동시에 좋아요를 누르는 상황을 가정하여 테스트했다. 테스트 결과, DB 커넥션 타임아웃 오류가 발생했다. (spring.datasource.hikari.connection-timout을 5000으로 설정했다.)
Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection [HikariPool-1 - Connection is not available, request timed out after 5000ms.]
즉, 좋아요 요청이 몰리며 모든 요청이 DB로 몰려 커넥션 타임아웃 오류가 발생한 것이다. 그리고 이로 인해 좋아요보다 더 중요한 요청을 처리할 때 해당 요청을 처리하는 스레드가 DB에 접근하지 못해 오류가 발생할 위험이 있다.
또한 좋아요는 비교적 중요한 요청이 아니며, 비동기로 처리되기 때문에 요청 처리 속도가 비교적 중요하지 않다. 따라서 빠르게 이를 처리하기 위해 스레드를 생성하고 DB에 부하가 몰리는 것은 부담스러우며 비효율적이라고 판단했다.
3-1-2. 고정 스레드 풀 전략
따라서 고정 스레드 풀 전략을 적용했다. 따로 설정하지 않으면 스프링 부트는 ThreadPoolTaskExecutor를 다음과 같이 설정한다. 즉, 고정 스레드 풀 전략이다.
- queueCapacity: Integer.MAX_VALUE (unbounded)
- coreSize: 8
- maxSize: Integer.MAX_VALUE
이렇게 하면, 유휴 스레드가 없을 경우 작업 요청은 큐에 담긴다. 그리고 큐 사이즈는 무한하므로 요청이 거절되는 문제가 발생하지 않는다. 그리고 좋아요 요청이 몰리더라도 고정된 개수의 스레드만 요청을 처리하므로, DB에 몰리는 부하를 제한할 수 있다. 따라서 DB 타임아웃 오류가 발생하지 않는다.
3-2. 테스트
하나의 게시글에 5000명의 사용자가 동시에 좋아요를 누르는 상황을 가정하여 테스트한 결과, 오류 없이 5000개의 좋아요가 모두 잘 반영되었다. 또한 고정된 개수의 스레드만 작업을 처리하므로 DB로 몰리는 부하가 제어되어 타임아웃 오류가 발생하지 않았다.
그러나 고정 스레드 풀 전략의 단점은 유연하게 스레드 수가 조절되지 않는다는 것이다. 즉, 요청이 몰리더라도 고정된 개수의 스레드만이 작업을 순차적으로 처리한다. 따라서 하나의 게시글에 몰린 5000개의 요청을 처리하는데 약 1분 15초가 걸렸다.
5000개의 요청을 처리하는데 1분이 넘게 소요됐으며, 더 많은 요청이 올 경우 좋아요 요청을 처리하는기 위해 매우 오랜 시간이 소요될 것이다. 즉, 아무리 비동기로 처리하여 사용자에게는 빠른 응답을 줬을지라도, 이는 매우 비효율적이라고 판단했다. 게다가 좋아요 수는 실시간으로 1단위까지 정확하게 보여주어야 하는 데이터는 아니다. 따라서 빈번하게 오는 요청에 대해 매번 DB에 접근하여 값을 업데이트하는 것이 불필요하다고 판단했다. 따라서 Redis를 활용한 스케줄링 작업을 도입했다.
4. Redis와 스케줄링 작업
레디스는 다양한 자료 구조를 지원하고, 인메모리 기반으로 빠르며, 고가용성을 지원하는 등 다양한 장점을 갖는다. 따라서 캐시로 많이 사용된다. 캐싱 전략 중 쓰기 전략으로 write behind(write back) 방식이 있다.
저장되는 데이터가 실시간으로 정확한 데이터가 아니어도 되는 경우 이 방법이 유용할 수 있다. 만약 쓰기가 빈번하게 발생하는 서비스라면, 데이터베이스에 대량의 쓰기 작업이 발생하여 많은 디스크 I/O를 유발해 성능 저하가 발생할 수 있다. 따라서 먼저 데이터를 빠르게 접근 가능한 캐시에 업데이트한 뒤, 이후에는 특정 시간 간격으로 비동기적으로 데이터베이스에 업데이트하는 것이다. (단, 5분 간격으로 캐시 데이터를 데이터베이스에 업데이트한다고 했을 때, 만약 캐시에 문제가 생겨 데이터가 날아갈 경우 최대 5분 동안의 데이터가 날아갈 수 있다는 위험성이 있다.)
4-1. Redis를 통한 효율적인 좋아요 요청 처리
레디스는 싱글 스레드이미로 특정 값에 대한 동시성 문제가 발생하지 않는다. 즉, 좋아요 수를 예로 들면, DB의 likeCount 필드에 +1 하는 로직은 여러 스레드의 접근으로 인해 동시성 문제가 발생할 수 있지만, 레디스의 likeCount 값을 +1 하는 로직은 싱글 스레드로 동작하기 때문에 동시성 문제가 발생하지 않는다.
따라서 다음과 같이 레디스를 사용하여 좀 더 효율적으로 수정했다. 우선 레디스에는 특정 게시글에 좋아요를 누른 사용자 email을 저장하는 set 자료구조가 있다.
- key: post:like:{postId}
- value: email (set)
그리고 좋아요 요청이 들어오면 다음 순서로 처리된다.
- 해당 게시글에 해당 사용자가 좋아요를 누른 이력이 있는지 확인한다. (set 확인)
- 좋아요를 누른 이력이 없으면, set에 사용자 email을 저장하고, Likes 엔티티를 DB에 INSERT 한다.
- 좋아요를 누른 이력이 있으면, set에 사용자 email을 삭제하고, Likes 엔티티를 DELETE 한다.
- 그리고 1분 주기로 set에 size를 구해 해당 게시글의 likeCount 값을 UPDATE 한다.
기존에는 좋아요 요청 처리 시 DB I/O 작업이 다음과 같이 3번 이뤄졌다.
- 좋아요 이력이 있는지 SELECT
- Post 엔티티의 likeCount 필드를 UPDATE
- Like 엔티티를 INSERT/DELETE
그러나 변경 후에는 좋아요 이력이 있는지 확인을 레디스에서 한다. 그리고 Post 엔티티의 likeCount 필드도 1분 주기로 UPDATE 한다. 따라서 좋아요 요청이 들어오면 다음과 같이 한 번의 DB I/O만 발생한다.
- Like 엔티티를 INSERT/DELETE
즉, Likes 엔티티의 저장 및 삭제는 기존과 마찬가지로 즉각적으로 이뤄지지만, 게시글의 좋아요수는 실시간성이 중요하지 않기 때문에 우선 레디스에만 저장한 후 주기적으로 DB에 UPDATE하여 정합성을 맞춰주는 방식을 적용하여, DB I/O 작업의 횟수를 줄였다.
이로써 비관적 락을 사용한 방식보다 더 효율적으로 좋아요 요청을 처리할 수 있게 되었다. 그러나 싱글 스레드인 레디스에서도 동시성 문제가 발생할 수 있으므로, 동시성 제어도 신경써야 한다.
4-2. 동시성 제어
레디스는 싱글 스레드이지만, parallel 할 수 없는 것이지 concurrent 할 수 있다. 따라서 레디스에서도 동시성 문제가 발생할 수 있다.
다음과 같이 if 조건을 여러 스레드가 동시에 통과할 때 동시성 문제가 발생할 수 있다. 예를 들어, 한 명의 사용자가 따닥 좋아요 버튼을 연속으로 눌렀을 때, 조건 결과 두 스레드 모두 false를 반환하여, like() 메서드가 두 번 호출되며 중복으로 좋아요를 저장하게 될 수 있다.
@Async
public void likePost(String email, Post post) {
SetOperations<String, String> setOps = stringRedisTemplate.opsForSet();
String key = LIKE_KEY_PREFIX + post.getId();
if (!setOps.isMember(key, email)) { // 이 부분에 동시에 접근할 경우,
like(setOps, key, post, email); // 좋아요 처리가 중복으로 발생한다.
} else {
unlike(setOps, key, post, email); // 좋아요 취소 처리가 중복으로 발생한다.
}
}
jmeter를 통해 한 명의 사용자가 하나의 게시글에 대해 좋아요 요청을 여러 개 보내는 테스트를 진행했다. 그 결과, 다음과 같이 중복된 Like 엔티티가 여러개 DB에 저장되었다.
즉, 레디스를 사용하더라도 동시성 문제가 발생할 수 있으며, 동시성 제어 방법은 다음과 같이 여러 가지가 있다.
- 자바의 락 (synchronized 또는 ReentrantLock)
- 구현이 간단하고, 추가적인 외부 의존성이 불필요하다.
- 그러나 메서드 전체를 락으로 감쌀 경우, 모든 요청이 순차 처리되므로 성능이 저하된다. 또한 분산 환경에서는 적용할 수 없다.
- 레디스 분산 락
- 분산 환경에서도 동시성 제어가 가능하다.
- 그러나 게시글 ID 단위로 분산 락을 건다고 가정하면, 각 게시글에 대한 좋아요 요청이 순차 처리되므로 성능이 저하된다.
- 레디스 트랜잭션 + WATCH
- 레디스 트랜잭션과 WATCH 명령어를 통해 특정 키를 모니터링하는 방법이다. 즉, 트랜잭션을 EXEC 할 때 모니터링 중인 키 값이 변경되었다면, 트랜잭션이 취소된다.
- 그러나 WATCH는 특정 키의 값에 대한 변경 여부를 모니터링한다. 현재 경우에서는, set에 특정 값이 있는지 없는지가 기준이기 때문에 WATCH로 모니터링하는 것은 불가능하다.
- 루아 스크립트
- 루아 스크립트를 사용하여, set에 특정 값이 있는지 확인 후 like() 메서드를 수행하는 것을 하나의 원자적 연산으로 처리하는 방법이다.
- 따라서 동시성 문제가 발생하지 않는다.
- 그러나 자바 코드와 루아 스크립트 코드가 분리되고, 루아 스크립트를 디버깅하는 것이 상대적으로 어렵다.
- DB 유니크 제약 조건
- 특정 게시글에 좋아요를 누른 사용자들을 저장하는 레디스 자료구조는 set이다. 따라서 한 사용자가 중복으로 좋아요 요청을 누르더라도 set에 중복으로 저장되지 않는다.
- 문제는 DB에 Likes 엔티티가 중복으로 저장된다는 것인데, 이는 DB 유니크 제약 조건을 걸면 해결된다.
위 방법들 중 자바 락과 레디스 분산 락은 동시성 문제가 빈번할 것으로 예상하여, 요청 처리 시 매번 락을 획득하는 비관적 방법이다. 따라서 순차 처리하므로 성능이 저하된다. 반면, 레디스 트랜잭션 + WATCH 방법과 DB 유니크 제약 조건은 우선 요청을 처리하고 충돌 시 예외가 발생하는 낙관적 방법이다.
현재 경우, 좋아요 요청은 매우 빈번하지만, 한 명의 사용자가 하나의 게시글에 대해 중복으로 좋아요 요청을 보내는 경우("따닥")는 예외적인 경우이다. 따라서 낙관적인 방법인 DB 유니크 제약 조건이 적절하다고 판단했다. 그리고 루아 스크립트로도 동시성 제어를 할 수 있는데, 별도의 스크립트를 짜야하는 번거로움이 있고, DB 단에서 유니크 제약 조건을 걸면 매우 간단하게 해결할 수 있기 때문에 유니크 제약 조건을 적용했다.
테스트 결과, Like 엔티티가 중복으로 저장되지 않고 1개만 저장되었다.
'프로젝트' 카테고리의 다른 글
반정규화를 통한 조회 성능 개선 (1) | 2024.11.26 |
---|---|
예약 동시성 제어 과정 (0) | 2024.08.20 |
반정규화를 통한 성능 향상 (0) | 2024.05.21 |
프로젝트 중 SpringSecurity 필터 체인에서 발생한 예외를 처리한 방법 (0) | 2024.01.23 |