좋아요 기능 데이터 정합성 맞추기

sonnng·2024년 2월 22일
0

Spring

목록 보기
41/41
post-thumbnail

데이터 정합성이 맞지 않는 상황

좋아요 동시요청해보면 200개의 쓰레드를 두 개의 서버에서 동시에 요청했으나 400개가 아닌 303개로 좋아요 수가 찍혔다. 이를 통해 트랜잭션 보장이 되지 않고있다는 결과를 알 수 있었다.

이 문제를 해결하고자 DB 락, Redisson, Lettuce에 대해 레퍼런스를 찾을 수 있었고 게시글 좋아요 수의 데이터 정합성을 맞춰보고자 글을 쓰게 되었다.

1. DB 락을 이용해서 맞춰보기

DB락에는 낙관적 락과 비관적 락 두 가지가 있고 동시성을 보장할 수 있다.
낙관적 락은 어플리케이션 상에서의 락, 비관적 락은 DB레벨에서 락을 걸어주는 원리라고 한다.

비관적 락에 대해..
자원에 대해 동시요청이 발생할때 일관성문제가 발생할 것으로 예상하고 이를 방지하기 위해 트랜잭션이 시작될때 락을 먼저 거는 방식을 말한다.
비관적 락은 배타락과 공유락으로 분류되는데, 배타락을 걸면 다른 트랜잭션에서 읽기/쓰기 모두 불가능하고 공유락을 걸면 읽기는 가능하지만 쓰기는 불가능해진다.

하지만 저는 비관적 락을 사용하기로 했다. 낙관적 락은 어플리케이션에서 동시성 예외가 발생하는 확률이 낮을 때 사용하는 방법이고 예외발생으로 요청 재시도가 발생하지 않아서 유저가 다시 재시도해야하는 불편함이 있다. 이는 계속해서 요청을 DB가 받아서 부하가 지속되기 때문에 락을 걸고 푸는 비관적 락이 더 효율적이라고 생각했다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<LikedCommunity> findByCommunityIdAndUserId(Long communityId, Long userId);
 @Override
    public boolean withLockCommunityLike(Long subjectId) {
        AtomicBoolean flag = new AtomicBoolean(false);
        User savedUser = getUser();
        Community findCommunity = communityRepository.findById(subjectId).orElseThrow(() ->
            new NotFoundException(ErrorCode.COMMUNITY_NOT_FOUND));

        likedCommunityRepository.findByCommunityIdAndUserId(findCommunity.getId(),
            savedUser.getId()).ifPresentOrElse(
            likedCommunityRepository::delete,
            () -> {
                likedCommunityRepository.save(
                    LikedCommunity.builder()
                        .user(savedUser)
                        .community(findCommunity)
                        .build()
                );
                flag.set(true);
            }
        );
        if (flag.get()) {
            findCommunity.increaseLikeCount();
        } else {
            findCommunity.decreaseLikeCount();
        }

        return flag.get();
    }

톰캣 서버에서 ExecuteService로 멀티스레드 환경에서의 100개 요청을 진행했을때 5s라는 시간지연이 발생했다. DB에 직접 락을 걸고 해제함으로써 발생하는 병목현상때문이다. 이는 사용자 UX에서 큰 불편함으로 느껴질 수 있다고 생각했다.


2. Redisson을 이용해서 정합성 맞추기

  • Redisson이 Lock을 거는 방식

락을 걸고 pub/sub 기능을 이용해 작업이 끝나면 채널을 이용해 점유가 끝남을 다른 스레드에게 알린다.

  • Redisson에서는 재시도할때의 서버 부하가 얼만큼 나는지

작업이 마치면 대기하던 스레드에게 푸시하므로 스핀락으로 구현할 필요가 없고 부담이 적다.

*여기서 스핀락은 대기중이던 스레드가 공유자원의 상태를 무한루프를 이용해 확인하는 방식으로, 락이 걸려있지않으면 작업할 수 있으니 무작정 반복적으로 락이 반환될때까지 무한루프를 도는 것을 말한다. 이러한 스핀락은 Lettuce의 동작원리다.(Lettuce도 동시성 문제를 해결할 수 있는 방법 중 하나에 속한다.)

스핀락으로 인해,
➡️ Busy waiting으로 cpu에 오버헤드가 된다.
➡️ Starvation으로 다른 스레드들이 대기상태에 오래 머무를 수 있다.

EC2 서버 하나로만 운영중인 환경에서는 적절하지 않다고 판단되어 Lettuce 대신 Redisson을 사용하기로 했다.


Redisson 설정 파일1

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);

        return redisson;
    }
}

RedisClient에서 url를 통해 레디스에 연결, 서버와 통신하므로 url을 설정하는 작업이 필요하다. private static final String REDISSON_HOST_PREFIX = "redis://"; 는 레디스 서버의 위치를 식별하기 위한 표준 규칙이다.

Redisson 설정 파일2

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }

}

Redisson 설정 파일3

import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }

}

Redisson 설정 파일4

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;
}

Redisson 설정 파일5

package tavebalak.OTTify.common.lock;

import java.lang.reflect.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import tavebalak.OTTify.error.ErrorCode;
import tavebalak.OTTify.error.exception.InterruptedException;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {

    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(tavebalak.OTTify.common.lock.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);

        String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(
            signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key());

        RLock rLock = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(),
                distributeLock.timeUnit());
            if (!available) {
                return false;
            }

            log.info("get lock success {}", key);
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException(ErrorCode.LOCK_INTERRUPT);
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already Unlocked");
            }
        }
    }
}

Redisson을 적용하고자하는 코드

@Override
@DistributeLock(key = "T(java.lang.String).format('LikeSubject%d', #subjectId)")
public void likeSubject(Long subjectId) {

TPS는 86.9가 나왔고 비관적락을 이용했을때인 0.6보다 높게 나온 것을 확인할 수 있었다.


테스트코드

1) Redisson 적용 전

 //    @DistributeLock(key = "T(java.lang.String).format('LikeSub%d', #id)")
public void likeSub(User user, Community community, Long id) {
package tavebalak.OTTify.community.service;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import tavebalak.OTTify.community.entity.Community;
import tavebalak.OTTify.community.repository.CommunityRepository;
import tavebalak.OTTify.error.ErrorCode;
import tavebalak.OTTify.error.exception.NotFoundException;
import tavebalak.OTTify.user.entity.User;
import tavebalak.OTTify.user.repository.LikedCommunityRepository;
import tavebalak.OTTify.user.repository.UserRepository;

@SpringBootTest
public class RedissonLockTest {

    @Autowired
    private LikedCommunityRepository likedCommunityRepository;
    @Autowired
    private CommunityRepository communityRepository;
    @Autowired
    private CommunityServiceImpl communityService;

    @Autowired
    private UserRepository userRepository;
    private static final int THREAD_COUNT = 2;

    @Test
    @DisplayName("likeCount에서 Redisson 적용후 동시성 문제 있는 경우")
    @Rollback(false)
    public void likedCommunity() throws Exception {

        User user1 = userRepository.findById(5L).get();
        User user2 = userRepository.findById(7L).get();

        Community community = communityRepository.findById(12L).orElseThrow(() ->
            new NotFoundException(ErrorCode.COMMUNITY_NOT_FOUND));

        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        executorService.submit(() -> {
            try {
                communityService.likeSub(user1, community, 12L);
            } finally {
                latch.countDown();
            }
        });

        executorService.submit(() -> {
            try {
                communityService.likeSub(user2, community, 12L);
            } finally {
                latch.countDown();
            }
        });

        latch.await();
        assertThat(community.getLikeCount()).isNotEqualTo(2);
    }
}

Redisson 적용 전, MySQL에는 likeCount가 2가 찍혀야 하지만 1로 저장되었고 동시성 문제가 있음을 확인했다.

2) Redisson 적용 후

@DistributeLock(key = "T(java.lang.String).format('LikeSub%d', #id)")
public void likeSub(User user, Community community, Long id) {

Redisson 적용 후 2이 정상적으로 저장됨을 확인했다. 따라서 Redis에서 락이 해제되면 해제된 이벤트를 발생, 이를 채널(Publisher)로 보내고 이 채널을 구독한 각각의 클라이언트에서는 해제알림을 받아 락을 걸고 likeCount를 증가시켜서 데이터 정합성을 증가시킬 수 있는 것이다.

0개의 댓글