[Spring boot] 좋아요수 증가 분산락을 이용하여 동시성 제어하기 (redis활용하기)

모지리 개발자·2022년 10월 29일
11

spring

목록 보기
3/8
post-thumbnail

Intro

기존 코드에서 좋아요수를 증가하는 로직은 하나의 서버에 대해서 들어오는 요청에 대해서는 트랜잭션을 보장하지만 다중 서버인 경우에는 트랜잭션을 보장하지 않았습니다. 이 글은 이 문제를 겪고 해결하는 과정을 작성한 글입니다.

기존코드

기존 코드는 아래와 같았습니다.

Post.java

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Builder
@AllArgsConstructor
public class Post implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(name = "title")
    private String title;

    @Column(name = "content")
    private String content;

    @ColumnDefault("0")
    @Column(name = "like_count", nullable = false)
    private Integer likeCount;

    public void likeCountUp() {
        this.likeCount++;
    }
}

PostService.java

    @Transactional
    public void updateLikeCount(Long postId) {
        Optional<Post> byId = postRepository.findById(postId);
        Post post = byId.get();
        post.likeCountUp();
    }

되게 단순한 로직입니다.
1. 게시글을 가져온다.
2. 카운트를 1 더해준다.
입니다.

하나의 서버이고 10000개의 쓰레드를 생성해서 요청했을 때 좋아요수가 10000으로 트랜잭션이 잘 보장된 것을 알 수 있습니다.

문제상황

실제 현업에서는 서버를 하나만 두고 서비스를 운영한다는 것은 상당히 리스크가 있는 운영이기 때문에 하나가 죽더라도 문제 없이 서비스 하기 위해 다중서버를 구성합니다. 제 코드는 이상황에서 문제가 발생했습니다.

다중서버는 포트 다르게 springboot실행하기에서 참고하여 실행하였습니다.

같은 서버 2개를 다른 포트로 띄운 후 테스트를 해보았습니다.

10000개의 쓰레드를 2개의 서버에서 각각 실행하였기 때문에 20000이라는 좋아요수가 나오길 기대했지만 11320(계속 달라짐) 이라는 결과가 나온것을 알 수 있었습니다. 이를 통해 트랜잭션 보장이 되지 않고있다는 결과를 알 수 있었습니다.

Redis를 활용하기

redis를 활용하는 방안을 생각했습니다. 기존에 글로벌 캐시로 활용하던 redis서버가 있었고 redis는 싱글쓰레드 이기 때문에 여러 요청에 대해서 트랜잭션을 보장해줄 수 있을 것이라고 생각했습니다. (이 생각이 잘못되었다는 것은 밑에서 나옵니다.)

Redis 캐시 전략 중 write-through전략을 사용해보기로하였습니다. 캐시전략에는 많은 전략들이 있지만 이 글에서는 write-through에 대한 설명만 하고 넘어가겠습니다. 캐시전략에 대한 종류와 내용은 Redis 캐시전략 블로그를 통해 확인해주시면 감사하겠습니다.

Write-Through 전략


Write Through 패턴은 Cache Store에도 저장하고 Data store에도 동시에 반영하는 형식입니다.

장점

  • DB와 캐시를 동시에 업데이트 하기 때문에 데이터의 일관성을 유지할 수 있습니다.

단점

  • 쓰기 작업을 할 때마다 캐시와 DB에 모두 생성하기 때문에 쓰기 작업이 많은 시스템이라면 딜레이를 유발할 수 있습니다.
  • 대부분의 데이터는 읽히지 않으므로 리소스 낭비로 이어질 수 있습니다.

redis를 사용했지만 실패

    @Transactional
    public void updateLikeCountUseRedis(Long postId) {
        Post post = postRMapCache.get(String.valueOf(postId));
        post.likeCountUp();
        postRMapCache.put(String.valueOf(postId), post);
    }
  1. redis로부터 해당 게시글을 조회한다.
  2. 카운트를 1 더한다.
  3. redis cache에 업데이트하여 다시 저장한다.
  4. write through 전략에 의해 업데이트 된다.

이렇게 코드를 구성하게 되면 트랜잭션을 보장하는 결과가 나올 것이라고 예상했지만 결과는 아니였습니다. 트랜잭션이 보장되고 있지 않았습니다.

왜 안됐을까?(redis의 특징)

redis의 특징을 다시 살펴보았습니다.
우선 대표적인 특징으로 redis는 싱글스레드 입니다.

레디스는 Event Loop를 이용하여 요청을 수행합니다.
즉 실제 명령에 대한 작업은 커널 I/O 레벨에서 멀티플렉싱을 통해 처리하여 동시성을 보장합니다.
따라서 유저 레벨에서는 싱글스레드로 동작하지만, 커널 I/O 레벨에서는 스레드 풀을 이용합니다.

하지만 이 동시성을 보장한다는 의미 자체가 동시성으로 인한 문제가 발생할 수 있다는 뜻을 의미했습니다. 따라서 동시성 문제에 대한 처리가 필요했습니다.

락을 사용하기 위한 Redisson 사용

Lettuce의 락을 안 쓴 이유

기존 저희의 프로젝트는 Lettuce라는 자바 기반의 레디스 클라이언트를 사용하고 있었습니다.
Lettuce에서도 락을 제공하고 있었지만 락을 setnx메서드를 이용해 사용자가 직접 스핀락 형태로 구성하게 하였습니다. 락이 점유 시도를 실패하게되면 계속 락 점유 시도를 하게도고 이로 인해 레디스는 계속 부하를 받게 되면 응답시간이 지연됩니다.
또 만료시간을 제공하고 있지 않아서 락을 점유한 서버가 장애가 생기면 다른 서버들도 해당 락을 점유할 수 없는 상황이 연출될 수 있습니다.

Redisson 사용하기

Redisson 클라이언트에서는 위에서 언급한 Lettuce 문제를 해결하기 위해 새로운 방법을 도입했습니다. 우선 Redisson은 기본적으로 스핀락을 사용하지 않기 때문에 레디스에 주는 엄청난 트래픽을 줄였습니다. 락이 해제 될때마다 subscribe하는 클라이언트들에게 "락에 대한 획득 가능"이라는 알림을 주어 일일이 레디스에 요청을 보내 락의 획득 가능 여부를 체크하지 않아도 되도록 개선하였습니다.

또 Lettuce에서는 Lock에 대한 기능을 별도로 제공하지 않고, 기존 Key-Value를 Setting 하는 방법과 동일하게 사용합니다.

하지만 Redisson에서는 RLock이라는 클래스를 따로 제공합니다.
그래서 RLock을 이용하여 쉽게 분산락을 구현할 수 있습니다.

코드로 살펴보기

의존성 추가
build.gradle

dependencies {
	implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'
}

RedisTestService.java

    @Transactional
    public void updateLikeCountUseRedisson(Long postId) {
        final String lockName = "like:lock";
        final RLock lock = redissonClient.getLock(lockName);

        try {
            if(!lock.tryLock(1, 3, TimeUnit.SECONDS))
                return;

            Post post = postRMapCache.get(String.valueOf(postId));
            post.likeCountUp();
            postRMapCache.put(String.valueOf(postId), post);

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }

주요 메서드를 살펴보겠습니다.
tryLock

  • 파라미터로 들어오는 leaseTime시간 동안 락을 점유하는 시도를 합니다.
  • 락을 사용할 수 있을 때까지 waitTime 시간까지 기다립니다.
  • leaseTime시간이 지나면 자동으로 락이 해제됩니다.

선행 락 점유 스레드가 존재하면 waitTime동안 락 점유를 기다리고 leaseTime시간 이후로는 자동으로 락이 해제되기 때문에 다른 스레드도 일정 시간이 지난 후 락을 점유 할 수 있습니다.
더 자세한 내용은 락이란? 분산락, 스핀락의 개념에서 확인해주시면 감사하겠습니다.

주의해야할 내용

  • 만약 A라는 프로세스가 leaseTime을 1초로 설정
  • 근데 A프로세스의 작업이 2초가 걸리는 작업이었다면 Lock은 leaseTime이 경과하여 도중에 해제, A프로세스 Lock에 대해서는 Monitor상태가 아닌데도 Lock을 해제하려고 하게 됩니다.

-> IlleagalMonitorStateException 발생
따라서 분산락을 적용할 경우에는 위와 같이 Lock해제 시 접근하는 경우도 있게 되므로 적절한 예외처리가 필요합니다.

테스트 해보기

위와 같이 트랜잭션이 보장된 결과를 확인할 수 있었습니다.

결론

다중서버를 구성하게되면 제가 마주했던 공유자원에 대한 문제 뿐만이 아니라 다양한 문제를 맞이할 수 있다는 것을 느끼게되었습니다. 단순한 좋아요 수 증가로직이었지만 고려해야 할 것도 많다고 느끼게 되었고 앞으로 개발을 할 때는 다중서버일 때는 어떻게 할 것인지에 대해 고려하면서 개발하는 습관을 들이는게 좋다고 생각했습니다. 감사합니다!

제가 잘못이해하고 있거나 잘못 작성한 부분이 있다면 지적, 비판, 피드백 뭐든 해주시면 감사하겠습니다!

전체코드
https://github.com/korea3611/test/tree/master/src/main/java/com/example/test/redistest

참고한 블로그
https://velog.io/@hgs-study/redisson-distributed-lock
https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC
https://way-be-developer.tistory.com/m/274

profile
항상 부족하다 생각하며 발전하겠습니다.

2개의 댓글

comment-user-thumbnail
2023년 12월 21일

좋은 글 잘 보고 갑니다!

답글 달기
comment-user-thumbnail
2024년 7월 29일

'기존코드'에서 하나의 서버에 updateLikeCount 함수를 다수의 쓰레드에서 동시에 요청했을 때 동시성이 보장되지 않을 것 같은데 보장되는 이유가 무엇인지 궁금합니다!

답글 달기