좋아요 기능의 동시성 이슈 해결법(Redisson)

임동혁 Ldhbenecia·2024년 6월 18일

SpringBoot

목록 보기
8/28
post-thumbnail
💡 드디어 동시성 문제를 해결했다. 예상치도 못하게 해결이 되어서 당황스러웠다.

진행 과정

@Service
@RequiredArgsConstructor
public class RedisTodoLikeService {
    private final TodoRepository todoRepository;
    private final UserRepository userRepository;
    private final TodoLikeRepository todoLikeRepository;
    private final RedissonClient redissonClient;

    @Transactional
    public void likeTodo(Long id, Long currentUserId) {
        String lockKey = "todo-likes-lock-" + id;
        RLock rLock = redissonClient.getLock(lockKey);

        try {
            boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!available) {
                throw new CustomErrorException(ErrorCode.CAN_NOT_USE_LOCK);
            }

            try {
                User currentUser = userRepository.findById(currentUserId)
                        .orElseThrow(() -> new CustomErrorException(ErrorCode.NOT_FOUND_USER));
                Todo todo = todoRepository.findById(id)
                        .orElseThrow(() -> new CustomErrorException(ErrorCode.NOT_FOUND_TODO));

                boolean alreadyLiked = todoLikeRepository.existsByTodoIdAndUserId(id, currentUserId);
                if (alreadyLiked) {
                    throw new CustomErrorException(ErrorCode.ALREADY_LIKED);
                }

                TodoLike todoLike = new TodoLike();
                todoLike.setTodo(todo);
                todoLike.setUser(currentUser);
                todoLikeRepository.save(todoLike);

                todo.incrementLikes();
            } finally {
                rLock.unlock();
            }

        } catch (InterruptedException e) {
            throw new CustomErrorException(ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }
}

분산락을 걸었는데도 불구하고 동시성 제어가 되지 않았다.
기존의 경우 likes가 41개정도였는데 해당 코드를 작성하고 나서 90개가 넘도록 데이터 정합성은 올랐다.
하지만 이래도 왜 전부 처리가 되지 않을까? 락이 잘 안걸리는 것인가? 에 대한 의문이 생겼다.

boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);

이 부분에서 두번째 인자인 lease time을 10으로도 바꿔보았는데도 되지 않았다.
10으로 올린 이유는 락이 만료되는 시간을 늘려서 그 시간 안에 처리해보라고 했던 것이었다..

  • wake time: 락 획득을 대기하는 제한 시간
  • lease time: 락이 만료되는 시간

바로 어제 executorService에서 트랜잭션 문제에 대해 다뤄보았다.
그렇기에 코드 분리를 해보았다.

package com.example.spring_todo.domain.todo.service;

import com.example.spring_todo.domain.todo.domain.Todo;
import com.example.spring_todo.domain.todo.domain.TodoLike;
import com.example.spring_todo.domain.todo.repository.TodoLikeRepository;
import com.example.spring_todo.domain.todo.repository.TodoRepository;
import com.example.spring_todo.domain.user.domain.User;
import com.example.spring_todo.domain.user.repository.UserRepository;
import com.example.spring_todo.global.exception.CustomErrorException;
import com.example.spring_todo.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class RedisTodoLikeService {

    private final TodoRepository todoRepository;
    private final UserRepository userRepository;
    private final TodoLikeRepository todoLikeRepository;
    private final RedissonClient redissonClient;

    public void likeTodoWithLock(Long id, Long currentUserId) {
        String lockKey = "todo-likes-lock-" + id;
        RLock rLock = redissonClient.getLock(lockKey);

        try {
            boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!available) {
                throw new CustomErrorException(ErrorCode.CAN_NOT_USE_LOCK);
            }

            try {
                likeTodoTransactional(id, currentUserId);
            } finally {
                if (rLock.isHeldByCurrentThread()) {
                    rLock.unlock();
                }
            }

        } catch (InterruptedException e) {
            throw new CustomErrorException(ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    @Transactional
    public void likeTodoTransactional(Long id, Long currentUserId) {
        User currentUser = userRepository.findById(currentUserId)
                .orElseThrow(() -> new CustomErrorException(ErrorCode.NOT_FOUND_USER));
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new CustomErrorException(ErrorCode.NOT_FOUND_TODO));

        boolean alreadyLiked = todoLikeRepository.existsByTodoIdAndUserId(id, currentUserId);
        if (alreadyLiked) {
            throw new CustomErrorException(ErrorCode.ALREADY_LIKED);
        }

        TodoLike todoLike = new TodoLike();
        todoLike.setTodo(todo);
        todoLike.setUser(currentUser);
        todoLikeRepository.save(todoLike);

        todo.incrementLikes();
        todoRepository.save(todo);
    }
}

기존 코드에서는 @Transactional이 메서드 전체에 적용되어 있어, 잠금을 획득하기 전에 트랜잭션이 시작되고 잠금을 해제하기 전에 트랜잭션이 종료될 수 있었다.
이는 잠금과 트랜잭션의 순서 문제가 발생할 수 있다고 한다.

새로운 코드에서는 잠금을 먼저 획득한 후에 트랜잭션을 시작하므로, 트랜잭션 범위 내에서만 작업이 이루어지며, 잠금이 제대로 적용된다.
이는 트랜잭션이 제대로 종료되기 전까지 다른 스레드가 동일한 리소스에 접근하지 못하도록 보장한다.

분산 락을 먼저 걸어준다. 이러면 다른 스레드들은 접근할 수가 없어진다.
락이 걸린 상태에서 likeTodoTransactional 로직을 수행하고 락을 해제하는 것이다.

그러면 다음 스레드가 이러한 작업을 반복하면 된다.

후기

이번 동시성 문제를 겪으면서 트랜잭션에 대한 이해도가 필요하다고 느꼈고, 아직 트랜잭션에 대한 이해도가 부족하다는 느낌을 받았다.

그런데 이 과정을 겪으면서 감을 정말 잘 잡게된 것 같다.

0개의 댓글