Redisson 을 사용한 반정규화 칼럼 동시성 제어

이진우·2025년 2월 16일

스프링 학습

목록 보기
48/48

이전에...

이전에 했었던 프로젝트를 다시 보던 중
리팩토링이 필요한 부분을 발견했다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(indexes = {@Index(name = "IDX_tm_db_program_id_type", columnList = "tmDbProgramId,type")})
public class Program {

    @Id
    @Column(name = "program_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
    
    @OneToMany(mappedBy = "program", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Review> reviewList = new ArrayList<>();

    public void addReview(Review review) {
        this.reviewList.add(review);

        double beforeRatingSum = this.averageRating * this.reviewCount;
        double afterRatingSum = beforeRatingSum + review.getRating();

        this.reviewCount++;
        this.averageRating = afterRatingSum / this.reviewCount;

    }

바로 이 부분

아쉬운 점은

  • Review 가 전적으로 Program 에 종속되지 않는데 양방향 연관관계를 사용했다는 점

  • 아무런 생각없이 반정규화 코드를 사용함으로써 동시성 이슈가 발생할 수 있다는 점

이다.

동시성 이슈는 데이터 정합성과 관련된 중요한 문제이기 때문에
이 부분에 대한 내용을 그동안 알고 있었던 내용으로 정리한다.

평점 조회 용 방법

Count 쿼리

리뷰 수와 리뷰 평점은

count 쿼리 , sum 쿼리로 정확도를 보장한 채 사용할 수 있지만
단점이 있다.

조회 성능이 압도적인 프로그램 정보 조회에서 반복적으로 count 쿼리와 sum 쿼리가 발생한다면

특정 프로그램에 대한 리뷰의 수가 많으면 많을 수록 count 쿼리와 sum 쿼리에 대한 부담감을 가질 수 밖에 없다.

특히 벤치마킹 모델인 와챠 피디아의 경우 베놈 같은 인기 프로그램은 리뷰의 수가 50만개 가까이 되는 경우도 있다.

따라서 이 프로젝트를 진행했던 1년 반 전에는 count 쿼리 도입을 생각도 하지 않았다.
(지금 와서 생각해보면 프로그램을 초기에 도입할 떄는 그냥 count 쿼리 쓰는 게 좋아보인다. )

반정규화

단순히 program 테이블에 review_countaverage_rating 을 추가한다.

리뷰를 쓰면 리뷰 count 가 하나씩 증가하고 average_rating 을 갱신하면 되는 것이다.

하지만 유저가 동시에 리뷰를 쓰거나 리뷰를 삭제하는 어떤 행위를 수행한다면

갱신 분실이나 데드락의 문제점이 발생할 가능성이 커진다.

캐시 , 배치 , 그 외

기회가 되면 적기 ...

결론

리뷰 서비스의 경우 동시에 리뷰가 수행되는 행위보다
읽히는 행위가 더 자주 사용된다고 생각했기 때문에 ,
count 쿼리의 반복적인 수행도 역시 문제가 된다고 생각했기 때문에
반정규화 하는 방식을 채택했다.

낙관적 락을 고려 안한 이유

동시성 이슈가 거의 없는 경우 직접 DB나 어떤 곳에 Lock 을 거는 행위가 없고,

version 정보를 따로 관리하여 update 를 수행할 때 이 버전 정보가 내가 알고 있는 것이 맞나? 를 확인한 이후 수행한다고 알려진 낙관적 락이다.

 @Version
    @Column(nullable = false)
    private Long version = 1L;

위와 같은 코드 만으로 낙관적 락을 사용할 수 있지만 ,
나의 경우 이 낙관적 락으로는 데드락을 피할 수 없다.

리뷰 작성의 경우

user - review - program

이러한 테이블 관계에서

review.save 에서 program 외래 키에 대해 SLock(공유락, 읽기락 , shard Lock: 주로 데이터 정합성이 중요한 읽기 작업에서 사용) 이 잡히고

program 의 review_count 와 , average_rating 을 갱신하는 과정에서
xLock(베타락, 쓰기락) 이 잡히기 때문에

2개 이상의 쓰레드가 들어온다면

SLock -> xLock
SLock -> xLock

SLock 과 SLock 은 서로 통과 ,
xLock 에 진입할 때 내가 아닌 다른 쓰레드에서 가지고 있는 sLock 에 의해 진입 하지 못하는 데드락 발생

의 흐름을 가진다.

이런 흐름을 막기 위해서는

아예 외래키를 사용하지 않거나
다른 쓰레드에서 메서드 자체로 들어오는 행위를 막아야 한다.

어쨌거나 낙관적 락은 적절하지 않다.

비관적 락을 고려안한 이유

비관적 락은

select .. for update 

구문으로 구성되어 있고 ,

일반 조회와 다른 점은 (고유 인덱스와 고유 검색을 통해서 검색 시 ) 레코드 레벨에 잠금을 건다. (그렇지 않으면 갭락이나 넥스트 키 락을 통해서 새로운 데이터 삽입 역시 막힐 수 있다)

그러면 다른 트랜잭션에서 이 레코드를 수정하려고 시도하거나
똑같이 (select .. for update) 구문을 시도하려고 한다면 ,

락에 의해 대기한다.

하지만 select for share 가 아닌 일반 조회에 있어서는 대기를 하지 않는다.

참고: https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html

이러한 특성에 의해 반정규화한 칼럼을 제외한 다른 칼럼은
수정이 거의 안 발생하기 때문에 ( 배치를 통해 새벽 5시,6시에 프로그램 내용 갱신 가능성 있음)
즉 다른 트랜잭션에서 이 레코드를 수정하려고 시도할 경우가 거의 없기 때문에

비관적 락을 고려할 뻔 했지만

이런 식이라면 program 에 대해 community 기능이 insert 될 때 program 에 대해 SLock 이 잡히므로 아무 상관없는

community 게시글이 insert 될 때마저 program 에 리뷰를 못달고 Lock 에 의해 대기해야 하는 상황이 발생할 수 있다.

참고로 비관적 락 도 Named Lock 처럼 time out 설정이 쉽다.

아래와 같이 @QueryHints 를 통해서 말이다.

 @Lock(value = LockModeType.PESSIMISTIC_WRITE)
   @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout",value = "1000")})
   Optional<Post> findWithPessimisticWriteById(Long id);

참고로 이런 부분이 없으면 dead lock 에 빠지기 쉽다.

A프로그램 Lock -> B프로그램 Lock 해야 하는데 대기
B프로그램 Lock -> A 프로그램 Lock 해야 하는데 대기

Named Lock 을 고려하지 않은 이유

비관적 락에서는 아까 말했듯이

고유 인덱스를 통해 검색 하면 레코드 락이 걸리지만 그렇지 않은 경우 갭락이나
넥스트 키 락이 걸릴 수 있다.

또한 다른 트랜잭션에서 이 레코드를 수정하려고 한다면 반정규화 칼럼을 건드리지 않더라도 xLock 에 의해 접근이 막히게 된다.

이러한 단점을 극복하기 위해 NamedLock 을 사용할 수 있다.

NamedLock 은 특정한 문자에 대해 Lock 을 걸음으로써
동시성 문제가 발생할 수 있는 메소드에만 붙여주면 어느 정도 문제를 해결할 수 있다.

public interface ProgramLockRepository extends JpaRepository<Program, Long> {

    @Query(value = "select get_lock(:key, 10)", nativeQuery = true)
    void getLock(@Param("key") String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(@Param("key") String key);
}

이렇게 위와 같이 쓸 수 있지만 ,
문제점이 몇몇 있다.

  1. Lock 관련 메타테이블에 정보를 저장하는 과정이 발생한다.
  1. Named Lock 은 같은 데이터 베이스 커넥션 안에서 해제해야 한다는 점 .
 @Transactional
    public void saveReviewWithLock(User user, ReviewSaveDto reviewSaveDto)
            throws InterruptedException {
        try {
            
            //printConnection();
            programLockRepository.getLock(
                    PROGRAM_PREFIX_LOCK + reviewSaveDto.getProgramId().toString(), jdbcTemplate);

            reviewCUDService.saveReview(user, reviewSaveDto);

        } finally {
            //printConnection();
            programLockRepository.releaseLock(
                    PROGRAM_PREFIX_LOCK + reviewSaveDto.getProgramId().toString(), jdbcTemplate);
        }
  • 여기서 @Transactional 을 붙이지 않으면 같은 데이터 베이스 커넥션 안에서 lock 을 획득하고 release 하는 것을 보장하지 않는다. , mysql 에서 select * from performance_schema.metadata_locks;
    을 통해 확인하면 lock 을 release 하였다고 생각하는데도 lock 이 여전히 잡혀있는 것을 알 수 있다.

참고: https://www.inflearn.com/community/questions/529878/transactional-%EA%B4%80%EB%A0%A8-%EC%BB%A4%EB%84%A5%EC%85%98%ED%92%80-%EB%B0%98%ED%99%98-%EC%9B%90%EB%A6%AC-%EC%A7%88%EB%AC%B8%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4?srsltid=AfmBOork3SF9uRzGv4NizDp9Og4Qx8RWR290cLvPKD8wIylKCW6XjHAC

  • reviewCUDService.saveReview 에서는 동시성이 발생할 수 있는 코드이고 여기서는 @Transactional(propagation = Propagation.REQUIRES_NEW) 을 붙여야 한다. 그렇지 않으면 변경 감지를 통해서 update 쿼리가 발생하는 것이 Lock 을 해제한 이후에 발생할 수 있고 여기서 다른 쓰레드가 lock 을 잡고 program 데이터를 읽어버리면 mysql 의 기본 transactiona isolation levelRepeatable Read 때문에 프로그램의 변경 되기 전 값을 읽고 갱신 분실이 될 수 있기 때문이다.

  • 그래서 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 선언하면 역설적이게 데드락이 발생할 수 있다. 스프링 부트에서 기본 Hikari CPconnection pool 개수가 10개인데 이 10개가 모두 각기 다른 프로그램에 대해 lock 을 잡고 새로운 트랜잭션을 통해서 비즈니스 로직에 접근하려고 할 때 connection pool 이 없어서 접근이 제한 될 수 있다. 이런 경우 30 초 (기본 connection pool timeout) 후에 timeOut 예외가 발생한다.

  • 따라서 이를 해결하려면 connection pool 을 분리하여야 한다.
@Configuration
public class DataSourceConfig {

    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.primary.hikari")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "lockDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.lock.hikari")
    public DataSource lockDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "primaryJdbcTemplate")
    public JdbcTemplate primaryJdbcTemplate(@Qualifier("primaryDataSource") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    @Bean(name = "lockJdbcTemplate")
    public JdbcTemplate lockJdbcTemplate(@Qualifier("lockDataSource") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

}
        minimum-idle: 5
        maximum-pool-size: 5
        pool-name: lock-pool
@Autowired
    public ReviewLockService(NamedLockRepository programLockRepository,
            ReviewCUDService reviewCUDService, ReviewRepository reviewRepository,
            UserLevelLockFinal userLevelLockFinal,
            @Qualifier("lockJdbcTemplate") JdbcTemplate jdbcTemplate) {
        this.programLockRepository = programLockRepository;
        this.reviewCUDService = reviewCUDService;
        this.reviewRepository = reviewRepository;
        this.userLevelLockFinal = userLevelLockFinal;
        this.jdbcTemplate = jdbcTemplate;
    }

예를 들면 위와 같은 코드로 말이다.

이렇게 오직NamedLock 을 위해서 DB Connection 을 추가로 잡고 DataSource 를 분리해야 한다는 점이 개인적으로 생각했을 때의 단점이다.

참고: https://0soo.tistory.com/255 , https://techblog.woowahan.com/2631/

비관적 락에서 부하 테스트

비관적 락 - 30명의 유저가 동일한 프로그램에 리뷰 작성 시

비관적 락 - 100명의 유저가 각기 다른 프로그램에 리뷰 작성

Redisson 을 고려한 이유

Redisson 은
Redis를 활용한 고급 자바 클라이언트 이다.

따라서 많은 사람들이 꼽는 장점은

  • 데이터 베이스에 Lock 을 설정하지 않아도 된다.
  • dB 대신 redis 를 활용함(메모리 기반 작동)으로 훨씬 빠르게 설정이 가능하다.
  • DB connection 을 안물어도 된다.
  • pub/sub 구조로 동작하여 lock을 해제할 때까지 redis 에 계속 요청을 하지 않는다. 다만 구독을 하고 있는 친구에게 해제헀다는 알림을 보내는 식으로 동작한다.

뭐 이러한 이유로 실무에서는 주요하게 Redisson 을 사용하고 있다고 한다.

동일 환경에서 성능 테스트를 실시한다.

redisson - 30명의 유저가 동일한 프로그램에 리뷰 작성 시

redisson - 100명의 유저가 각기 다른 프로그램에 리뷰 작성

실험 환경

실험 환경은 모두 같다.
local, rds , db connection 10 , 유저 기본 쓰레드 200

결론

  • 낙관적 락을 사용하고자 하니 SLock 과 XLock 의 존재로 인한 데드락을 막지 못함이 단점
  • 비관적 락을 사용하고자 하니 review 에 대한 수정이 이루어질 뿐인데 program 과 관련된 community 기능, 프로그램에 대한 즐겨 찾기 과정이 영향을 받는 다는 점이 단점

  • 낙관적 락을 사용하고자 하니 mysql 에 Lock 관련 데이터를 저장해야 한다는 점, Database Connection 을 물고 있는 기간이 길다는 점, Dead Lock 을 회피하기 위해서 Database Connection pool 을 따로 만들어야 한다는 점이 단점

따라서 Redisson 을 사용하여 동시성을 개선하였다. (솔직히 조금 과하긴 한것 같지만, 아니 ... 아니다)

profile
기록을 통해 실력을 쌓아가자

0개의 댓글