포폴/Coffies Vol.02

[CoffiesVol.02] Redisson을 활용해서 분산락을 적용

slown 2024. 8. 6. 22:30

목차

1.문제상황
2.분산락??
3.코드 및 검증

 

1.문제상황

현재 내 프로젝트에서 자유게시글에서 조회수와 좋아요 기능과 가게 댓글에 좋아요 기능에서 게시글과 댓글에 좋아요를 눌렀을 경우 좋아요의 수가 제대로 카운팅이 되지 않는 상황이 발생을 했다. 

 

현재 좋아요의 공통적인 로직은 다음과 같습니다.

 

좋아요 엔티티에서 좋아요 여부를 확인 

-> 데이터가 없으면 좋아요 추가와 좋아요 수 증가

-> 데이터가 있으면 좋아요 취소 좋아요 수감소

 

하지만 이 로직에서의 문제점은 다음과 같습니다. 

 

1. 경쟁조건 

여러 사용자가 동시에 같은 게시글이나 댓글에 좋아요를 누르거나 취소할 때, 다음과 같은 문제가 발생할 수 있습니다.

  • 중복된 좋아요/싫어요: 이미 좋아요를 누른 사용자가 다시 누르거나, 싫어요를 누른 사용자가 다시 싫어요를 누르는 경우입니다.
  • 잘못된 좋아요/싫어요 수: 동시에 여러 요청이 들어와 데이터베이스에 반영되는 순서에 따라 좋아요/싫어요 수가 정확하게 반영되지 않을 수 있습니다.

2. 데이터 베이스의 부담

 동시에 많은 요청이 들어와 데이터베이스에 부하가 집중되면 데이터베이스 충돌이 발생할 수 있습니다.

 

3.분산 서버의 환경

 

현재 내 프로젝트의 아키텍처는 다음과 같다.

 

보시다시피 아키텍처가 단일 서버가 아닌 분산 서버의 환경인데 단일 서버의 경우라면 한 서버내에서만 락처리를 하면 되겠지만 분산서버의 경우에는 락을 처리하고 난 뒤의 결과를 모든 서버에 공유가 되질 않는 상황이기 때문입니다. 

 

그래서 이러한 문제점을 해결을 하기 위해서 어떤 방법이 있을지를 찾아봤고 2가지 방안을 고안했습니다.

 

1. JPA에서 제공하는 락(낙관적 락, 비관적 락)

2. Redis에서 제공하는 분산락

 

1번의 경우에는 JPA에서 제공을 하는 락을 사용해서 동시성을 제어하는 방법입니다. 하지만 분산 서버를 사용하고 있는 경우에는 동시에 같은 로직을 처리한다고 하면 데이터 베이스의 값을 갱신을 할 때 서버 한곳에만 적용이 되지 나머지 서버에도 데이터의 변경이 반영이 되지 않는다는 단점이 있습니다.

 

그래서 고안을 적용을 해볼 것은 Redis에서 제공하는 분산락을 사용을 해서 동시성을 제어를 해볼 것입니다.

2.분산락??

분산 락은 여러 서버나 프로세스가 동시에 특정 자원에 접근하려고 할 때 이를 조율하여 데이터 정합성을 유지하고, 동시성 문제를 해결하기 위해 사용됩니다. Redis에서는 다양한 형태로 분산락을 사용하게 제공을 하는데 대표적인 방법은  Lettuce와 Redisson이 있습니다.

 

Lettuce는 사용하기 쉽다는 장점이 있지만 다음과 같은 단점이 존재합니다

  • 스핀락을 사용한다.
    • 이 방법은 SETNX라는 명령어를 사용해서 Redis에 락 획득 요청을 보내는데 이때 Redis에 많은 부하가 갑니다.
  • 자체적인 타임아웃이 존재하지 않음.
    • 스핀락의 경우에는 락을 획득한 이후 락을 반환하지 못하거나 어떠한 이유로 어플리케이션에 문제가 생기면 무한루프가 발생해서 시스템에 문제가 생깁니다.

반면 Redisson의 경우에는 Lettuce보다는 구현이 어렵지만 다음과 같은 장점이 있습니다.

  • Pub/Sub 방식을 사용한다.
    • 계속해서 락 획득 요청을 하는 것이 아닌 pub/sub을 이용하여 락을 획득하고 해제합니다.
  • Lock에 타임아웃을 지정할 수 있습니다.
    • Redisson은 락 획득시도시 타임아웃을 명시하여 무한정 대기상태로 빠지는 것을 방지할 수 있습니다.

3.코드작성 및 검증

build.gradle

// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'

 

RedisConfig.java

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisConfig {

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

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

    /**
     * 내장 혹은 외부의 Redis를 연결
     */
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(redisHost);
        redisStandaloneConfiguration.setPort(redisPort);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

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

    //redisson 설정
    @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;
    }
}

 

Redisson 분산락 관련 코드

Aop 와 커스텀 어노테이션을 만들어서 분산락을 사용하겠습니다.

 

1. 분산락의 커스텀 어노테이션

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
    //락의 이름
    String key();
    //시간 단위
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    //락을 획득하기 위한 시간
    long waitTime() default 5L;
    //락을 임대하는 시간
    long leaseTime() default 3L;
}

 

 

2. 분산락에 사용되는 Aop

@Log4j2
@Aspect
@Order(1)
@Component
@RequiredArgsConstructor
public class DistributeLockAop {

    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;

    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.example.coffies_vol_02.config.redis.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());
        log.info(key);
        RLock rLock = redissonClient.getLock(key);
        log.info("rLock::"+rLock);
        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 (Exception e) {
            Thread.currentThread().interrupt();
            throw new InterruptedException();
        } finally {
            rLock.unlock();
        }
    }
}

 

위의 코드를 설명을 하자면 다음과 같습니다.

 

  • @Aspect로 정의된 이 클래스는 @DistributeLock 애너테이션이 붙은 메소드에 대한 AOP(Aspect-Oriented Programming) 로직을 처리합니다.
  • @Around 어드바이스는 메소드 실행 전과 후에 로직을 삽입할 수 있습니다.
  • 메소드가 호출될 때, 먼저 Redis에 정의된 키로 락을 획득합니다. 락 획득이 성공하면 메소드를 실행하고, 락을 해제합니다.
  • 락을 획득하지 못하면 false를 반환하거나 적절한 예외 처리를 해야 합니다.

3. 표현식 파싱

 

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);
    }
}

 

위의 코드의 설명은 다음과 같습니다.

 

  • Spring Expression Language (SpEL)를 사용하여 락 키를 동적으로 생성합니다.
  • 메소드의 파라미터 이름과 값을 바탕으로 락 키를 평가하여 동적 값을 생성합니다

4. 트랜잭션 Aspect

 

@Component
public class AopForTransaction {

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

 

 

  • @Transactional(propagation = Propagation.REQUIRES_NEW)로 설정된 이 클래스는 메소드 실행을 새로운 트랜잭션으로 감쌉니다.
  • 데이터베이스 작업을 트랜잭션 내에서 수행하도록 보장합니다.

5. 분산락에 사용될 서비스 코드 

@Log4j2
@Component
@RequiredArgsConstructor
public class RedissonService {

    private final BoardRepository boardRepository;

    private final MemberRepository memberRepository;

    private final LikeRepository likeRepository;

    private final CommentRepository commentRepository;

    private final CommentLikeRepository commentLikeRepository;

    //게시글 조회수 증가
    @DistributeLock(key = "#lockKey")
    public BoardResponse boardDetailReadCountUp(String lockKey, Integer boardId){
        BoardResponse response = Optional.ofNullable(boardRepository
                        .boardDetail(boardId))
                .orElseThrow(()->new CustomExceptionHandler(ERRORCODE.BOARD_NOT_FOUND));

        boardRepository.ReadCountUpToDB(boardId);

        return response;
    }

    //게시글 좋아요 증가.
    @DistributeLock(key = "#key")
    public void boardLikeUp(String key,Integer memberId,Integer boardId){
        Optional<Board>board = boardRepository.findById(boardId);
        Optional<Member>member = memberRepository.findById(memberId);

        //게시글 내에 있는 좋아요수 증가.
        board.orElseThrow().likeCountUp();
        Like like = new Like(member.orElseThrow(), board.orElseThrow());
        likeRepository.save(like);
    }

    //게시글 좋아요 감소.
    @DistributeLock(key = "#key")
    public void boardLikeDown(String key,Integer memberId,Integer boardId){
        Optional<Board>board = boardRepository.findById(boardId);
        Optional<Member>member = memberRepository.findById(memberId);

        Like like = Like
                .builder()
                .member(member.orElseThrow())
                .board(board.orElseThrow())
                .build();

        //게시글 좋아요수 감소
        board.get().likeCountDown();

        likeRepository.save(like);
    }

    //가게 댓글 좋아요 증가.
    @DistributeLock(key = "#key")
    public void placeCommentLikeUp(String key,Integer memberId,Integer commentId){
        Optional<Comment>comment = Optional
                .ofNullable(commentRepository.findById(commentId)
                        .orElseThrow(() -> new CustomExceptionHandler(ERRORCODE.NOT_REPLY)));

        Optional<Member>member = Optional
                .ofNullable(memberRepository.findById(memberId)
                        .orElseThrow(() -> new CustomExceptionHandler(ERRORCODE.NOT_FOUND_MEMBER)));

        //댓글 좋아요 유무
        Optional<CommentLike> existingLike = commentLikeRepository
                .findByMemberAndComment(member.orElseThrow(), comment.orElseThrow());

        if(existingLike.isEmpty() && !existingLike.isPresent()){
            CommentLike commentLike = CommentLike
                    .builder()
                    .comment(comment.orElseThrow())
                    .member(member.orElseThrow())
                    .build();
            //댓글 좋아요수 증가.
            comment.get().commentLikeUp();
            //좋아요 저장
            commentLikeRepository.save(commentLike);
        } else {
            log.info("Member {} has already liked comment {}", memberId, commentId);
        }
    }

    //가게 댓글 좋아요 감소.
    @DistributeLock(key = "#key")
    public void placeCommentLikeDown(String key,Integer memberId,Integer commentId){

        Optional<Comment>comment = Optional
                .ofNullable(commentRepository.findById(commentId)
                        .orElseThrow(() -> new CustomExceptionHandler(ERRORCODE.NOT_REPLY)));

        Optional<Member>member = Optional
                .ofNullable(memberRepository.findById(memberId)
                        .orElseThrow(() -> new CustomExceptionHandler(ERRORCODE.NOT_FOUND_MEMBER)));

        //댓글 좋아요 유무
        Optional<CommentLike> existingLike = commentLikeRepository
                .findByMemberAndComment(member.orElseThrow(), comment.orElseThrow());
        log.info(existingLike.isPresent());

        if (existingLike.isPresent()) {
            Comment commentEntity = comment.orElseThrow();
            commentLikeRepository.delete(existingLike.get());

            if (commentEntity.getLikeCount() > 0) {
                commentEntity.commentLikeDown();
            } else {
                log.info("Comment like count is already zero and cannot be decreased further");
            }
        } else {
            log.info("Member {} has not liked comment {}", memberId, commentId);
        }
    }
}

 

    public void commentLikePlus(Integer replyId,Member member){
        String key = CacheKey.LIKES;
        redissonService.placeCommentLikeUp(key, member.getId(), replyId);
    }

    public void commentLikeMinus(Integer replyId,Member member){
        String key = CacheKey.LIKES;
        redissonService.placeCommentLikeDown(key,member.getId(),replyId);
    }
    
    public BoardResponse findFreeBoard(Integer boardId){
        String key = CacheKey.BOARD + ":" + boardId;
        return redissonService.boardDetailReadCountUp(key,boardId);
    }

 

완성된 코드는 위와 같고 사용하는 방법은 간단합니다. 우선 분산락을 사용할 서비스 클래스에 커스텀 어노테이션을 붙이고 메서드 안에 관련 비지니스 로직을 작성을 하면 됩니다. 

 

그럼 마지막으로 테스트 코드를 작성을 하면서 검증을 해보자면 다음과 같습니다.

@SpringBootTest
public class BoardRedisTest {

    @Autowired
    private BoardService boardService;

    @Autowired
    private LikeService likeService;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    @Disabled
    @DisplayName("자유 게시글 조회수 증가(동시성 테스트)")
    public void BoardReadCountUpTest() throws InterruptedException {
        //100명이 동시에 조회수를 증가
        int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(35);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for(int i =0; i< numberOfThreads; i++){
            executorService.submit(()->{
                try{
                    boardService.findFreeBoard(15);
                }finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
    }

    @Test
    @DisplayName("자유게시글 좋아요 증가 테스트")
    public void BoardLikeCountUpTest()throws Exception{

        Optional<Member>member = memberRepository.findById(1);

        //100명이 동시에 조회수를 증가
        int numberOfThreads = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(35);

        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for (int i=0;i<numberOfThreads;i++){
            executorService.submit(()->{
                try{
                    likeService.boardLikePlus(15,member.get().getId());
                }catch (Exception e){
                    e.getMessage();
                }finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
    }
}


    @Test
    @DisplayName("가게 댓글 좋아요 증가 테스트")
    public void placeCommentLikePlusTest() throws InterruptedException {

        final int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.execute(() -> {
                try {
                    likeService.commentLikePlus(comment.getId(),member);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(5, TimeUnit.SECONDS);

        Comment updatedComment = commentRepository.findById(comment.getId()).orElseThrow();
        System.out.println(updatedComment.getLikes().size());
    }
    
    @Test
    @DisplayName("가게 댓글 좋아요 감소 테스트")
    public void placeCommentLikeMinusTest() throws InterruptedException {

        final int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.execute(() -> {
                try {
                    likeService.commentLikeMinus(comment.getId(), member);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(5, TimeUnit.SECONDS);

        Comment updatedComment = commentRepository.findById(comment.getId()).orElseThrow();
        System.out.println(updatedComment.getLikes().size());
    }

 

이렇게 작성을 하면서 분산서버 내에서 동시성을 제어를 할 수 있고 데이터의 정합성을 보장을 할 수 있게 됩니다.