2024. 8. 10. 20:11ㆍ프로젝트
Flickr(플리커) 프로젝트 개발 중 Redis 캐시를 사용해 다음 로직을 구현했습니다.
1. 피드 좋아요 누르기, 해제하기
위 사진처럼 어떤 게시글에 하트를 누름으로써 좋아요 누름/해제 기능이 일어납니다.
- 주의 사항 : 사용자는 게시글 좋아요를 단시간동안 여러 번 클릭할 수 있음
- 하트를 누르는 것만으로 체크/해제가 발생함
좋아요 로직은 사용자가 빠른 시간에 파바박 클릭할 수 있어, 짧은 시간에 많은 요청이 들어올 수 있습니다.
이런 경우 프런트단에서 디바운싱, 쓰로틀링을 통해 막아줄 수 있다고 합니다. 저는 쓰로틀링 기법을 사용했습니다.
디바운스(Debounce)
- 연속적으로 발생하는 이벤트를 제어하며, 일정 시간 동안 이벤트가 발생하지 않을 때 마지막 이벤트만을 처리하는 방법
- 입력이 멈춘 후, 일정 시간 동안 대기한 후에만 실제로 이벤트를 처리한다.
쓰로틀링 (Throttling)
- 일정 시간 동안 이벤트를 한 번만 처리하도록 제한하는 방법.
- 연속적으로 발생하는 이벤트에서 지정된 시간 간격으로만 이벤트를 처리한다.
사용자가 버튼을 빠르게 연속으로 클릭할 때 이벤트를 제어해야 하므로 쓰로틀링이 더 적합해 보입니다.
FE 단 코드 (index.html)
function throttle(func, limit) {
let lastFunc;
let lastRan;
return function(...args) {
const context = this;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function() {
if ((Date.now() - lastRan) >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}
// 쓰로틀링 적용한 좋아요 토글 함수
const throttledToggleLike = throttle(toggleLike, 1000);
쓰로틀 간격을 1초로 설정해, 1초 간격으로만 함수가 실행되도록 했습니다.
즉 버튼을 아무리 빠르게 클릭해도 1초에 한 번만 실제로 이벤트가 처리되도록 구현했습니다.
2. 사용자 팔로우, 언팔로우
사실 팔로우, 언팔로우도 비슷하다고 생각했습니다.
피드 좋아요 로직과 마찬가지로 특정 사용자를 Follow, Unfollow 가 버튼을 누르면서 자동적으로 일어나고 그에 따라 버튼 UI 가 바뀝니다.
이 경우도 FE 단에서 쓰로틀링을 통해 반복적인 요청을 막았습니다.
캐시 사용
Redis(Remote Dictionary Server)를 사용했습니다.
스프링 부트 자체 캐시를 사용할 수도 있지만 이 경우 단일 서버 메모리에 저장하므로, 서버가 확장되어 분산 환경이 된다면 캐시 일관성 문제가 발생할 수도 있습니다.
또한 EC2 서버 스펙 자체도 크지 않아서 따로 메모리를 할당해 주고 싶어, 이러한 이유들로 Redis를 캐시로 사용했습니다.
캐싱 전략
- 시스템의 성능 향상을 기대할 수 있는 중요한 기술입니다.
- 일반적으로 캐시는 메모리(RAM)을 사용하므로 Disk를 사용할 때보다 훨씬 빠릅니다.
하지만 캐시는 용량이 디스크만큼 크지 않아, 데이터를 모두 저장해 사용하기엔 역부족입니다. 따라서 어떤 데이터를 캐시에 저장할지, 얼마나 오래 저장할지에 대한 전략이 필요합니다.
데이터 정합성 문제 : DB와 캐시 간의 데이터가 일치하지 않아 발생하는 일관성 문제
- 예를 들어 캐시에는 좋아요 개수가 10개인데, DB는 8개라면 불일치 문제가 발생한다.
캐시라는 데이터 저장소를 하나 더 쓰는 것이기 때문에, 서로 저장된 값이 다를 수 있어 발생하는 문제입니다.
따라서 서비스 성격에 맞는 캐싱 전략을 정하는게 중요합니다. 캐시 메모리 용량을 확보하고, 데이터 정합성 문제가 발생하지 않도록!
캐시 읽기 전략
- Look Aside Pattern
본 프로젝트에서는 Look Aside 패턴을 사용했습니다. 데이터를 찾을 때 Redis에서 먼저 조회하고, 데이터가 없다면 DB에서 데이터를 가져와 캐시에 적재합니다.
- Cache Hit 발생 : 캐시에서 데이터를 읽어온다.
- Cache Miss 발생 : DB에서 데이터를 조회한 후 데이터를 캐시에 업로드한다.
시퀀스 다이어그램
위와 같이 Look Aside 패턴을 사용해 유저 팔로우 로직을 구현했습니다.
캐시에 key = {followerId}:{following} 인 값이 없다면 DB에서 불러와 내가 팔로우 하고 있는 유저들의 ID 값들을 불러옵니다. 그다음에 캐시에 저장합니다. 캐시에 이미 있는 경우 캐시에서 값을 불러옵니다.
그 후 Redis에 팔로워(나), 팔로우에게 맞는 키 값 2개에 데이터를 추가해 유저 팔로우를 완료합니다.
데이터 정합성 문제
여기서 한 가지 의문이 생겼습니다.
팔로우 하면 Redis에 해당 데이터를 추가해줬는데, DB에도 데이터를 추가해야 데이터 불일치가 발생하지 않을텐데, 팔로우가 발생할 때마다 DB에 업데이트를 해줘야 할까요?
그렇게 할 수도 있겠지만, 애초에 캐시를 사용한 이유가 빈번한 Disk I/O 작업을 줄이기 위함인데 팔로우가 발생할 때마다 DB에 insert 하면 레디스를 사용하는 이유가 없어집니다. 해당 내역을 모아서 한 번에 insert 쿼리를 날리면 될 것 같습니다.
@Repository
@RequiredArgsConstructor
public class FollowRepositoryImpl implements FollowRepository {
private final FollowJpaRepository followJpaRepository;
private final RedisTemplate<String, Object> redisTemplate;
@Override
public void followUser(int followerId, int followingId) {
redisTemplate.opsForSet().add(followerId + ":following", followingId);
redisTemplate.opsForSet().add(followingId + ":follower", followerId);
// 언팔로우 했다가 다시 팔로우 한 경우, 언팔로우 했던 기록 삭제
if (redisTemplate.opsForSet().isMember("pendingUnfollows", followerId + ":" + followingId)) {
redisTemplate.opsForSet().remove("pendingUnfollows", followerId + ":" + followingId);
}
redisTemplate.opsForSet().add("pendingFollows", followerId + ":" + followingId);
}
@Override
public void unfollowUser(int followerId, int followingId) {
redisTemplate.opsForSet().remove(followerId + ":following", followingId);
redisTemplate.opsForSet().remove(followingId + ":follower", followerId);
// 팔로우 했다가 다시 언팔로우 한 경우, 팔로우 기록 삭제
if (redisTemplate.opsForSet().isMember("pendingFollows", followerId + ":" + followingId)) {
redisTemplate.opsForSet().remove("pendingFollows", followerId + ":" + followingId);
} else {
redisTemplate.opsForSet().add("pendingUnfollows", unfollowKey);
}
}
}
- 팔로우 발생 시, Redis의 "pendingFollows"에 해당 데이터를 잠시 넣어둡니다.
- 언팔로우 발생 시 Redis의 "pendingUnfollows"에 해당 데이터를 임시로 저장합니다.
주의 사항 (중요!)
1 -> 2 : follow
1 -> 2 : unfollow
1 -> 2 : follow
이런 식으로 반복적으로 수행했을 때 처리도 해줘야 합니다. (맨 마지막인 follow 부분만 DB에 업데이트 해야 합니다.)
[상황1]
1 -> 2 : 언팔로우 (여기서는 쉽게 '1:2' 로 하자.)
- 해당 값: '1:2' 가 pendingFollow 에 이미 있다면 pendingFollow 에 있는 값만 삭제한다.
- pendingFollow 에 값이 없다면 pendingUnfollow 에 '1:2'를 저장한다.
팔로우 하고 다시 언팔로우 하는 경우에는 pendingFollow 에 있는 값(1:2)만 지워주면 됩니다.
pendingFollow 에 해당 값이 없다면 DB에 있는 팔로우를 지워줘야 하므로, pendingUnfollow 에 '1:2' 를 넣어줍니다!
[상황2]
1 -> 2 : 팔로우
- '1:2' 가 이미 pendingUnfollow 에 있다면, pendingUnfolllow 에 있는 '1:2' 삭제 후, pendingFollow 에 '1:2'를 넣어준다.
- pendingUnfollow 에 값이 없다면 pendingFollow 에 '1:2'를 넣어준다.
이처럼 여러 가지를 고려해야 합니다.
팔로우 했다가 다시 언팔로우 하는 경우, 언팔로우 했다가 다시 팔로우 하는 경우를 고려해주어야 합니다.
Redis, DB 동기화
사용자가 팔로우 또는 언팔로우를 할 때 Redis에 임시로 저장된 데이터를 주기적으로 데이터베이스에 동기화해줍시다.
@Scheduled(fixedRate = 60000) // 1분마다 실행
public void syncFollowsToDatabase() {
Set<Object> pendingFollows = redisTemplate.opsForSet().members("pendingFollows");
Set<Object> pendingUnfollows = redisTemplate.opsForSet().members("pendingUnfollows");
List<Follow> followsToSave = new ArrayList<>();
List<Follow> followsToDelete = new ArrayList<>();
if (pendingFollows != null && !pendingFollows.isEmpty()) {
for (Object follow : pendingFollows) {
String[] parts = follow.toString().split(":");
int followerId = Integer.parseInt(parts[0]);
int followingId = Integer.parseInt(parts[1]);
String unfollowKey = followerId + ":" + followingId;
if (pendingUnfollows != null && pendingUnfollows.contains(unfollowKey)) {
redisTemplate.opsForSet().remove("pendingUnfollows", unfollowKey);
} else if (!followJpaRepository.existsByFollowerIdAndFollowingId(followerId, followingId)) {
followsToSave.add(new Follow(followerId, followingId));
}
}
}
if (pendingUnfollows != null && !pendingUnfollows.isEmpty()) {
for (Object unfollow : pendingUnfollows) {
String[] parts = unfollow.toString().split(":");
int followerId = Integer.parseInt(parts[0]);
int followingId = Integer.parseInt(parts[1]);
String followKey = followerId + ":" + followingId;
if (pendingFollows != null && pendingFollows.contains(followKey)) {
redisTemplate.opsForSet().remove("pendingFollows", followKey);
} else {
Follow follow = followJpaRepository.findByFollowerIdAndFollowingId(followerId, followingId);
if (follow != null) {
followsToDelete.add(follow);
}
}
}
}
if (!followsToSave.isEmpty()) {
followJpaRepository.saveAll(followsToSave);
}
if (!followsToDelete.isEmpty()) {
followJpaRepository.deleteAll(followsToDelete);
}
redisTemplate.delete("pendingFollows");
redisTemplate.delete("pendingUnfollows");
}
위 메서드는 배치 업데이트를 위한 메서드입니다.
레디스의 "pendingFollows", "pendingUnfollow" 키를 사용해 변경 사항을 임시로 저장하고, @Scheduled 어노테이션을 사용해 일정 시간(1분) 마다 Redis에 저장된 데이터를 데이터베이스에 동기화합니다. 완료되면 해당 키를 삭제해 공간을 확보합니다.
JPA saveAll() 를 통해 insert 쿼리를 각각 1번씩 날려줍시다.
이와 같이 데이터를 저장할 때 DB에 쿼리하지 않고 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영합니다.
캐시와 DB의 동기화 과정을 비동기적으로 진행하는 Write-Back 패턴을 사용했습니다.
(유저 언팔로우 로직은 follow 로직과 동일합니다. follow 정보를 레디스 set에서 제거해주면 됩니다.)
결론
Redis 와 LookAside + WriteBack 패턴을 사용해 캐싱으로 팔로우, 좋아요 로직을 구현했습니다.
데이터 일관성을 해결할 수 있고, 캐시의 메모리 부족 문제도 어느 정도 해결할 수 있다고 생각해 위 캐싱 전략을 선택했습니다.
Elasticache 메모리 설정하기
- 현재 Redis 스펙은 다음과 같습니다.
Elasticache의 무료 버전인 t2.micro를 사용하고 있고, 메모리 크기는 0.5GB 정도밖에 되지 않습니다.
메모리가 가득 차게 되면 Redis 자체 디스크와 메모리 간에 스왑이 일어나고, 한 번 스왑이 일어난 부분은 계속해서 일어날 가능성이 높아진다고 합니다. 따라서 메모리 용량을 초과하는 경우 성능이 악화됩니다.
1. 캐시 데이터에 유효 기간을 설정 : TTL(Time To Live)
FollowService 의 isFollow 메서드
@Override
public boolean isFollowing(int followerId, int followingId) {
String key = followingId + ":follower";
if (!redisTemplate.hasKey(key)) { // 키가 존재하지 않으면(cache miss), DB에서 값 조회
List<Integer> followers = followRepository.findFollowers(followingId);
if (followers != null && !followers.isEmpty()) {
for (Integer follower : followers) {
redisTemplate.opsForSet().add(key, follower);
}
redisTemplate.expire(key, 10, TimeUnit.MINUTES); // TTL 10분 설정
}
}
Boolean isFollow = redisTemplate.opsForSet().isMember(key, followerId);
return isFollow != null && isFollow;
}
위의 isFollowing 메서드는 FollowService 클래스의 코드로 팔로우, 언팔로우 시 제일 먼저 호출됩니다.
따라서 해당 메서드에만 Look Aside 로직을 적용해주면 됩니다. 또한 TTL을 설정해 10분이 지나면 Redis의 key를 삭제해 공간을 확보했습니다.
2. LRU(Least Recently Used) 적용하기
LRU(Least Recently Used) 정책은 캐시에서 가장 적게 사용된 데이터를 지우는 것입니다.
설정된 메모리 한계(0.5GB)에 도달했을 때, 가장 오래된 데이터를 자동으로 삭제해주도록 설정해줍니다.
- Elasticache 는 redis.conf 파일에 직접 접근해 수정할 수 없으므로, 파라미터 그룹을 설정해줍니다.
- maxmemory-policy : volatile-lru
- 만료 시간이 설정된 키에 대해 LRU 정책을 적용합니다.
- 팔로우나 좋아요를 누를 때 임시 저장하는 "pending~" 키는 삭제하지 않도록 volatile-lru 로 설정해주었습니다.
제한 시간은 10분으로 두었다고 했습니다. 이들중에서 LRU 정책을 통해 삭제를 수행합니다. (메모리가 부족할 경우)
- maxmemory-samples : 5
- Redis가 LRU 알고리즘을 적용할 때 샘플링할 키의 수를 의미합니다.
- 메모리 한도를 초과했을 때 어떤 키를 삭제할 지 결정하기 위해 무작위로 선택할 키의 수를 5로 지정했습니다. 샘플링을 통해 선택된 키들 중에서 가장 오래된 키를 삭제합니다.
결론
Redis 캐시를 사용하기 위해 앞서 Read Aside, Write Back 패턴을 적용하였고 거기에 더해 TTL을 추가해 데이터의 제한 시간을 10분으로 지정했습니다.
메모리가 부족한 경우에는 LRU 설정을 통해 5개의 키를 샘플링 해 그 중에서 하나를 제거하도록 설정했습니다.
'프로젝트' 카테고리의 다른 글
[프로젝트] Pagination 으로 피드 랜덤조회 개선 (0) | 2024.08.08 |
---|---|
[프로젝트] 레디스 Pub/Sub, RabbitMQ 도입으로 구조 개선 (0) | 2024.06.18 |
[프로젝트] SSE 로 실시간 알림 기능 구현하기 (0) | 2024.06.13 |
[쿠폰] 서버 모니터링 및 부하테스트 (0) | 2024.05.12 |
레디스 쿼리로 서버 RPS 개선 (0) | 2024.04.29 |