Home [Spring] Redis 사용하기 (1) - 좋아요 기능 성능 개선
Post
Cancel

[Spring] Redis 사용하기 (1) - 좋아요 기능 성능 개선

좋아요 기능

좋아요 기능은 게시글을 추천하는 기능입니다.

  • 게시글을 추천하면 ‘좋아요’ 수가 1 증가한다.
  • 게시글별로 한 유저당 한 번만 ‘좋아요’ 표시를 할 수 있다.
  • ‘좋아요’는 취소할 수 있다.
  • (선택) ‘좋아요’한 사람들 목록을 볼 수도 있다.

‘좋아요’한 사람들의 목록을 보는 것은 이 포스트에서 다루지 않습니다.

Redis없이 구현 (기존 방식)

기존에는 중간에 in-memory cache없이 직접 DB에 Read/Write해서 ‘좋아요 수’를 관리했습니다.

Like 테이블만으로 구현

가장 쉬운 방법은 Like 테이블을 만들고, 좋아요를 누를 때마다 이 Like 테이블에 게시글 ID와 유저 ID를 넣으면 됩니다.

Like 테이블

게시글 ID유저 ID
23
53
54

위의 테이블을 통해 3번 유저가 2, 5번 글에 좋아요했다는 것을 알 수 있습니다. 그리고 2, 5번 글은 각각 좋아요가 1, 2개인 것도 알 수 있습니다.

이렇게 하면 어떤 유저가 어떤 Post에 좋아요를 했는지, 특정 Post가 몇 개의 좋아요를 받았는지 알 수 있습니다.

하지만 매번 좋아요 개수를 가져올 때마다 count해야하는 문제가 있습니다. 즉 좋아요 수 조회 성능이 느려집니다.

DB에 좋아요 수 캐싱

각 post마다 좋아요 수를 가지고 있으면 쉽게 해결됩니다.

Post 테이블 일부

게시글 ID좋아요 개수
15
210

사용자가 좋아요를 누르면 좋아요 개수를 +1해주어야 합니다. 반대로 좋아요를 취소하면 -1해주어야 해요.

이렇게 게시글 테이블에 좋아요 개수를 저장하고 있으면 매번 count할 필요 없이 바로 좋아요 개수 조회가 가능합니다.

한계

이런 방식으로 하면 구현은 쉽지만, 좋아요 R/W 요청이 순간 많이 들어오면 느려집니다. Mysql과 같은 데이터베이스는 기본적으로 Disk에 데이터를 저장합니다. 그래서 IO작업에 의한 오버헤드가 발생할 수 밖에 없습니다.

사실 대부분의 상황에서는 DB만으로도 충분히 성능 커버가 될 것 같습니다. 하지만 순간 대규모로 좋아요 transaction이 발생할 우려가 있다면 문제가 될 수 있습니다.

제 프로젝트에서는 종종 좋아요 이벤트가 열렸습니다. 그래서 좋아요 API를 Redis로 캐싱해서 사용해보고자 했습니다.

Redis 도입

Redis는 기본적으로 Disk가 아닌 주메모리에 데이터를 저장합니다. 그래서 Disk IO작업에 의한 오버헤드가 없고, RAM에 저장하므로 Disk에 비해 속도가 빠릅니다.

대신 비휘발성 메모리에 저장하므로 전원이 꺼지면 데이터가 날라갑니다. 물론 영속성을 지원하므로 Disk에 저장할 수도 있습니다. 그런데 이 기능은 백업을 위해 주로 사용되는 듯 합니다. (AOF, RDB) 결국 영속성 데이터는 DB에 저장하는편이 관리, 효율면에서 좋다고 생각했습니다.

그리고 RAM에 저장하니 데이터를 많이 저장하기는 어렵습니다. OS는 RAM 용량이 부족해지면 Disk에 저장하기 시작합니다. 그리고 필요할때 SWAP하는데요. 이 SWAP이 발생하면 성능이 저하됩니다. 그래서 결국 Redis를 사용해도 RAM이 부족하다면 성능이 느려집니다. 실제로 저는 처음에 RAM을 1GB로 사용했고, 캐시된 데이터를 expire하지 않고 사용했었습니다. 이렇게 하니 장기적으로 메모리 누수가 발생했고 서버가 다운된 적이 있었습니다.

이런 Redis의 특징들 때문에 보통 Cache에 많이 사용됩니다. 저희도 좋아요 수를 caching하기 위해 Redis를 사용했습니다. 사용자가 좋아요를 누르면 Redis에 이 액션을 저장합니다. 좋아요를 취소한 경우도 마찬가지입니다. 그리고 주기적으로 Redis에 저장된 액션들을 DB로 dump해주는 매커니즘입니다.

좋아요를 누른 경우

우선 기본적으로 위에서 설명했던 DB 테이블 구조는 그대로 들고갑니다. Redis를 사용하지만 단지 캐싱 용도로만 사용하므로, 영구적으로 좋아요 데이터들을 저장하기 위해서 DB는 필요합니다.

이제 좋아요를 눌러볼까요? 좋아요 요청이 들어오면, Redis에 좋아요 누른 액션을 남깁니다.

1
2
3
"postlike:post:<userId>": {
    "<postId>": "LIKED"
}

캐싱을 위해 Redis의 HashMap 구조를 사용할 예정입니다. 이 구조를 보기 편하게 표현하기 위해 json형태로 나타내봤습니다. 위와 같이 user마다 HashMap을 생성하여 postId=LIKED를 추가합니다.

Redis에는 좋아요만 들어가는게 아니라 다른 캐싱 정보도 포함됩니다. 이들을 쉽게 구분하기 위해서 “PostLikeKey”라는 곳에 HashMap형태로 값들을 저장해도 상관없습니다. 아래와 같은 형태가 되겠네요.

1
2
3
"PostLikeKey": {
    "postId:userId": "LIKED"
}

구조적으로 나누는게 편하다면 이렇게 따로 PostLikeKey를 만들어 저장해도 좋을 것 같습니다. 이렇게 하면 나중에 Redis-cli에서 특정 값들을 정리해서 보기도 편합니다.

하지만 이렇게 키를 구성하면 몇 가지 문제점이 있습니다.

상황에 맞는 적합한 자료구조로 구성하면 될 것 같습니다.

이제 실제 예시로 볼게요. 5번 게시글에 3번 유저가 좋아요를 누른다면…

1
2
3
"postlike:post:3": {
    "5": "LIKED"
}

6번 게시글에 3번 유저가 좋아요를 누른다면…

1
2
3
4
"postlike:post:3": {
    "5": "LIKED",
    "6": "LIKED"
}

6번 게시글에 3번 유저가 좋아요를 또 누른다면 이미 6:3인 키가 있으니 요청을 무시해야겠죠?

좋아요를 취소한 경우

좋아요를 취소하면 아래 데이터를 Redis에 추가합니다.

1
2
3
"postlike:post:<postId>": {
    "postId": "CANCELLED"
}

만약 이미 좋아요를 누른 상태라면 기존 value를 CANCELLED로 덮어씌웁니다.

왜 Set이 아니라 Hashmap?

그런데 왜 Set이 아니라 HashMap으로 만들었을까요? 그 이유는 좋아요 취소 때문입니다. 그래서 좋아요를 취소한 경우에도 삭제가 아니라 기존에 있던 “postId:userId”에 CANCELLED로 덮어씌웁니다.

왜 이렇게 했을까요? CANCELLED를 사용하지않고 그냥 Set으로 만들면서, 좋아요 취소하면 지워버리는 경우를 가정해보겠습니다. 나중에 DB에 데이터를 dump할 때 문제가 생깁니다. Redis에 5번 게시글에 3번 유저가 Like를 한 기록이 없다면, 이건 취소시켜야하나요? 아닙니다. 물론 유저가 Like를 취소했을 수도 있지만, 옛날에 유저가 Like하고 이후에 dump되었을 수도 있죠. 즉 Redis에 캐싱이 되지 않은 경우가 있을 수 있다는 말입니다.

그리고 유저가 해당 Post에 Like했는지 어떻게 확인할 수 있나요? redis에서 먼저 찾아보고, DB를 탐색할겁니다. 근데 Redis에 “postId:userId”가 존재하지 않는다면 캐싱되지 않았다고 말할 수 있나요? 아닙니다. 사용자가 좋아요를 취소한 경우에도 Redis에 키가 없을텐데, 캐싱되어있는건지 확신할 수 없습니다.

이런 문제들을 해결하기위해 DB에 있는 모든 좋아요 정보를 항상 redis로 끌고오는 방법이 있습니다. redis에 저장된 데이터들이 DB에 저장되어야할 데이터임을 보장해주는 것이죠. 하지만 이 방식은 매번 모든 좋아요 데이터를 redis로 끌고오니까 좋아요 데이터의 규모가 커지면 메모리를 많이 사용하고, 성능이 저하될 우려가 있습니다.

그래서 redis를 좋아요, 좋아요 취소 update 저장소로 보고, 각 update를 redis에 저장해두었다가 DB에 반영해주는 방식으로 구현했습니다.

이렇게 하면 DB에 dump할 때 CANCELLED인 경우에는 db에서 삭제해서 취소처리하면 되고, redis에 없는 경우에는 아무런 update가 발생하지 않은 것이므로 그냥 두면됩니다.

좋아요 수도 캐싱

마찬가지로 좋아요 수도 redis에 캐싱해두면 좋을 것 같습니다. 기본적으로 DB에 영구 저장하지만, redis에 캐싱해두면 조회 성능이 좋아질 겁니다.

만약 redis에 캐싱된 정보가 없으면, DB에서 가져오고 아래와 같은 구조로 redis에 저장합니다.

1
2
3
4
"PostLikeCount": {
    "3": 15,
    "7": 10
}

3번 게시글은 15개의 좋아요가 있음을 알 수 있죠. 만약 좋아요를 누른 경우 저 값들도 increase해주어야 합니다.

DB와 동기화

지금까지 Redis에 어떻게 데이터가 저장되는지 살펴봤습니다. 이제 실제로 DB와 어떻게 데이터를 동기화하는지 설명하겠습니다.

1
2
private final PostLikeMemoryRepository memoryRepository;
private final PostLikePersistenceRepository persistenceRepository;

우선 두 repository가 필요하죠. memoryRepository는 Redis 저장소, persistenceRepository는 DB저장소입니다.

이 repository들의 실제 구현은 아래 링크를 참고해주세요.

PostLikeMemoryRepository 구현

PostLikePersistenceRepository 구현

Redis repository 구현 방식 자체는 위에서 설명해서 따로 자세히 설명하지는 않겠습니다.

좋아요를 이미 했는지 확인하는 방법

좋아요를 했었는지 확인하는 방법은 우선 redis를 살펴보는 것입니다. redis에 없다면? DB에서 끌고와서 redis에 캐싱해두면 됩니다.

저희는 좋아요를 취소한 경우에는 CANCELLED로 표시하므로 이미 캐싱이 되어있는지 확인할 수 있습니다. Redis저장소에 키가 없으면 캐싱이 안되어있는 것으로 판단하면 되겠죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional(readOnly = true)
public boolean isPostLiked(Long postId, Long userId) {
    Boolean liked = memoryRepository.isPostLiked(postId, userId);
    if (liked == null) {
        liked = persistenceRepository.findByPostIdAndUserId(postId, userId).isPresent();
        if (liked) {
            memoryRepository.addPostLike(postId, userId);
        } else {
            memoryRepository.removePostLike(postId, userId);
        }
    }
    return liked;
}

liked타입을 보면 boolean이 아니라 Boolean입니다. 왜냐하면 캐싱이 안된 경우에는 null이 반환되기 때문입니다. 좋아요를 한 경우에는 true, 취소한 경우에는 false, 캐싱 안된 경우에는 null이 반환됩니다.

그래서 캐싱안된 경우에는 실제 DB에서 과거에 좋아요를 했었는지 가져오고, redis에 캐싱합니다. 그리고 결국은 좋아요를 눌렀는지 판단해서 반환합니다.

이때 주의할 점은 여기서는 DB 연산이 필요하므로 JPA를 사용하시면 service에 Transaction을 걸어주어야 한다는 점입니다.

좋아요 눌렀을 때

1
2
3
4
5
6
public void like(Long postId, Long userId) {
    if (!isPostLiked(postId, userId)) {
        memoryRepository.addPostLike(postId, userId);
        memoryRepository.setLikeCount(postId, getCountOfLikes(postId) + 1);
    }
}

좋아요를 눌렀으면, 위에서 살펴본대로 redis에 누가 좋아요를 눌렀는지 저장해야합니다. 그리고 좋아요 수도 1 증가시켜주어야해요.

좋아요를 2회 중복으로 누르는 경우를 무시하기위해 앞에 isPostLiked로 확인 로직을 넣어줍니다.

좋아요를 취소했을 때

1
2
3
4
5
6
public void cancelLike(Long postId, Long userId) {
    if (isPostLiked(postId, userId)) {
        memoryRepository.removePostLike(postId, userId);
        memoryRepository.setLikeCount(postId, getCountOfLikes(postId) - 1);
    }
}

좋아요를 취소했다면, redis에 누가 좋아요를 취소했는지 저장합니다. 좋아요 수도 1 빼줘야겠죠?

그리고 마찬가지로 좋아요를 누르지도 않았는데 취소하는 경우를 막기위해 앞에 확인 로직을 넣어줍니다.

좋아요 수 가져오기

좋아요 수를 가져오는 로직도 단순합니다. redis에 캐싱되어있다면? 바로 리턴합니다. 아니라면? DB에서 가져와서 캐싱 후 리턴합니다.

1
2
3
4
5
6
7
8
9
@Transactional(readOnly = true)
public int getCountOfLikes(Long postId) {
    int count = memoryRepository.getCachedLikeCount(postId);
    if (count == -1) {
        count = persistenceRepository.countByPostId(postId);
        memoryRepository.setLikeCount(postId, count);
    }
    return count;
}

좋아요 수는 캐싱이 안된 경우 -1를 뱉습니다. 좋아요 수는 음수가 될 수 없으니, 음수를 non caching signal로 사용했습니다.

마찬가지로 DB 연산이 포함되어있어서 JPA를 사용한다면 transaction안에서 실행되어야합니다.

DB에 dump하기

실제로 Redis에 저장된 데이터들을 DB에 반영하는 로직입니다. Redis에 저장된 데이터들을 읽어서 LIKED인 경우에는 DB에 좋아요를 데이터를 저장하고, CANCELLED인 경우에는 좋아요 테이블에서 삭제합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public long dumpToDB() {
    List<LikeEntry> allLikes = memoryRepository.getAllPostLikes();
    for (LikeEntry ent : allLikes) {
        if (ent.getState() == LikeState.LIKED) {
            User user = userRepository.getReferenceById(ent.getUserId());
            Post post = postRepository.getReferenceById(ent.getPostId());
            persistenceRepository.save(new PostLike(user, post));
        } else if (ent.getState() == LikeState.CANCELLED) {
            persistenceRepository.deleteByPostIdAndUserId(ent.getPostId(), ent.getUserId());
        }
    }

    return allLikes.size();
}

user와 post를 얻기위해 repository를 통해 getReferenceById를 하고있습니다. getReferenceById는 lazy loading이 적용된 proxy 객체를 반환합니다. 그래서 실제 내부 필드를 조회하기 전까진 로딩되지 않습니다.

이렇게 작성한 이유는 User와 Post 모두 내부 구체적인 필드까지는 필요 없기 때문입니다. 실제 DB PostLike 테이블에는 UserID와 PostID만 저장됩니다. 그래서 id를 넘겨서 바로 저장하고 싶었지만, JPA를 사용한다면 객체로 넘겨주어야 합니다.

이때 객체를 만들기 위해 findById로 찾아서 넣으면 내부 필드값이 모두 로딩됩니다. 이러면 불필요한 오버헤드가 발생합니다. 그래서 getReferenceById를 사용해 id만 로드된 상태의 entity객체들을 사용합니다. 이렇게 하면 추가적인 DB 조회없이 PostLike 테이블에 id만 골라 저장할 수 있습니다.

Dump는 주기적으로

DB에 dump하는 과정은 주기적으로 해줘야합니다. 안 그러면 혹시나 redis서버가 다운되었을 때 중간 좋아요했던 정보들이 날라갑니다.

1
2
3
4
5
// post.like-dump-delay = PT1H  # 1시간마다
@Scheduled(fixedDelayString = "${post.like-dump-delay}")
public void dumpToDB() {
    service.dumpToDB();
}

제 프로젝트에서는 사실 좋아요 자체가 비즈니스적으로 일관성이 크게 중요한 도메인은 아니었습니다. 그래서 dump 주기를 길게 해줘도 괜찮았습니다.

동기화 문제

Redis는 single thread로 동작합니다. 그런데 왜 동기화 문제가 발생할까요?

Event loop

Redis의 각 연산은 atomic하게 실행됨을 보장합니다. 하지만 연산을 2개이상 사용한다면, 동기화 문제가 발생할 수 있습니다.

Redis는 Event Loop을 두어 명령어들을 처리합니다. 명령어가 입력된대로 순차적으로 처리합니다. 그래서 일반적인 경우에는 대부분 정상동작하지만, 동시에 2개 이상의 연산을 요청하면 각 연산들이 꼬일 수 있습니다.

위의 그림처럼 좋아요를 누르면 기존의 좋아요 수 얻기, 1더해서 저장하기 이렇게 2개의 연산이 필요합니다. 그래서 좋아요 기능에 동시성 문제가 발생할 수도 있습니다. 특히 좋아요 수좋아요 누른 대상이 따로 저장되지만 좋아요 수는 좋아요 누른 대상 개수와 같아야 합니다. 만약 둘 중 한 곳에서만 동시성 문제가 발생한다면, 데이터 정합성을 해칠 수도 있습니다.

이를 해결하는 방법은 크게 2가지입니다.

  • 1개 연산으로 줄이기
  • Lock 사용하기 (따로 포스팅)

가장 쉬운 해결 방법은 1개 연산으로 줄이는 것입니다. 앞서 좋아요 수에 1을 더하는 연산을 위해 2개 명령을 사용했습니다. 하지만 Redis에서는 INCR명령을 제공합니다.

1
2
3
4
redis> SET likes "3"
redis> INCR likes # 핵심
redis> GET likes
"4"

INCR명령을 사용하면 하나의 명령어만으로 수를 1만큼 올릴 수 있습니다. (1이상의 수를 더해야한다면 INCRBY를 참고하세요.)

제 경우에는 이 방법으로 쉽게 해결할 수 있었습니다. 다만 1개의 연산으로 줄일 수 없는 복잡한 경우는 Lock/Transaction을 사용해야합니다.

※ Lock을 사용하는 방법은 내용이 방대해서 따로 포스팅할 예정입니다.

성능 테스트

테스트 결과

성능 테스트 결과는 위의 링크를 참고해주세요.

결론은 기존에 비해 약 86%만큼의 성능 향상이 있었습니다.

This post is licensed under CC BY 4.0 by the author.

[Spring] 시간표 기능 ERD 구상하기

[CS] CPU Bound와 IO Bound