티스토리 뷰
이전 게시글
2023.07.06 - [Spring/채팅앱] - [Spring 채팅앱 성능 개선기 3] Cache 설계 전략
[Spring 채팅앱 성능 개선기 3] Cache 설계 전략
이전글 보러가기 2023.07.04 - [Spring/채팅앱] - [Spring 채팅앱 성능개선기 2] Cache를 이용한 속도향상 [Spring 채팅앱 성능개선기 2] Cache를 이용한 속도향상 성능개선기 1편을 이어나가 2편 시작합니다. 20
wans1027.tistory.com
남은 문제점을 되짚어 봅시다.
- 로컬 캐시를 사용하므로, Scale-Out에 적합하지 않다.
- RAM용량은 제한적인데, 채팅방이 늘어남에 따라 캐시가 무한정으로 늘어날 수 있어 제한이 필요하다.
오늘은 REDIS를 활용해 남은 문제점들을 한번에 해결해 보려 합니다.
Redis 란?
Remote Dictionary Sever
Redis는 Remote(원격)에 위치하고 프로세스로 존재하는 In-Memory 기반의 Dictionary(key-value) 구조 데이터 관리 Server 시스템이다.
여기서 key-value 구조 데이터란, mysql 같은 관계형 데이터가 아닌 비 관계형 구조로서 데이터를 그저 '키-값' 형태로 단순하게 저장하는 구조를 말한다.
그래서 관계형 데이터베이스와 같이 쿼리 연산을 지원하지 않지만, 대신 데이터의 고속 읽기와 쓰기에 최적화 되어 있다.
Redis는 인 메모리(In-Memory) 솔루션으로도 분류되기도 하는데, 다양한 데이터 구조체를 지원함으로써 DB, Cache, Message Queue, Shared Memory 용도로 사용될 수 있다.
일반 데이터베이스 같이 디스크(ssd)에 데이터를 쓰는 구조가 아니라 메모리(dram)에서 데이터를 처리하기 때문에 작업 속도가 상당히 빠르다.
Redis를 활용해 첫번째 문제점을 해결할 수 있습니다.
여러대의 서버가 한대의 Redis server에 접근가능하기에 Scale-Out이 가능합니다.
다만, 속도는 로컬 캐시에 비해 느려질 것 입니다. 그래도 DBMS에 비하면 상당히 빠른 속도를 보장합니다.
두번째 문제인 메모리 제한도 해결 가능합니다.
Redis는 캐시 만료기간 설정을 제공합니다.
설정해둔 만료기간이 지나면 해당 캐시는 자동 삭제되므로, 사용자가 오랫동안 읽지않은 채팅내역은 캐시에서 삭제되어 메모리의 용량이 관리 될 수 있습니다.
적용방법
기존에 HashMap을 사용해 '키-값' 로컬캐시를 구현하고 캐싱전략을 전부 구현해 놓았습니다.
이미 어노테이션으로 만들어진 AOP를 통해 간단히 하는 방법도 있지만 기존 코드에 최대한 변경없이 Redis를 적용해 보겠습니다.
Spring 에 Redis 적용
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
build.gradle에 추가합니다.
spring:
cache:
type: redis
redis:
host: 127.0.0.1
port: 6379
expire: 60
application.yml에 추가
Redis는 Docker에 이미지를 올려 사용해도 되고, 윈도우에 직접 설치해도 됩니다.
저는 Docker와 동시에 톰캣을 돌리면 RAM이 오버되서 윈도우에 설치했습니다.
Redis Configuration
@EnableCaching
@Configuration
@Slf4j
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, LinkedList<Message>> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
RedisTemplate<String, LinkedList<Message>> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer);
return redisTemplate;
}
}
Redis에 저장되는 자료구조를 기존 Map<Long, Queue<Message>> 과 같은 형태로 저장해야 기존의 코드를 그대로 사용할 수 있습니다.
대신 Key를 String형태로 저장해 'Room:1234' 이런 식으로 앞에 수식어를 붙여주어 다른 자료구조 형태를 저장할때 구분을 쉽게 할 수 있게 하겠습니다.
RedisCache
@Repository
@RequiredArgsConstructor
public class RedisMessageCache {
private final RedisTemplate<String, LinkedList<Message>> redisTemplate;
@Value("${spring.redis.expire}")
private int expireTime;
public void put(Long roomId, Queue<Message> messageQueue){
redisTemplate.opsForValue().set(roomId.toString(), new LinkedList<>(messageQueue));
redisTemplate.expire(roomId.toString(), expireTime, TimeUnit.MINUTES);
}
public boolean containsKey(Long roomId){
return Boolean.TRUE.equals(redisTemplate.hasKey(roomId.toString()));
}
public LinkedList<Message> get(Long roomId){
return redisTemplate.opsForValue().get(roomId.toString());
}
public Queue<Message> values(){
return get(Long.valueOf(Objects.requireNonNull(redisTemplate.randomKey())));
}
public void deleteKey(Long roomId){
redisTemplate.delete(roomId.toString());
}
public void deleteAll(){
redisTemplate.discard();
}
}
기존에 사용했던 메서드 이름과 똑같은 이름을 사용해 구현합니다.
추가로 캐시 만료시간도 설정합니다.
Redis가 Key를 만료시키는 방법
Redis에 관해서 이래저래 찾아보다 문득 궁금해져서 알아 봤는데 꽤나 흥미로워서 올립니다.
Redis 키는 수동 방식과 능동 방식의 두 가지 방식으로 만료됩니다.
일부 클라이언트가 액세스를 시도하고 키가 시간 초과된 것으로 확인되면 키가 수동적으로 만료됩니다.
물론 다시 액세스할 수 없는 만료된 키가 있으므로 이것만으로는 충분하지 않습니다. 이러한 키는 어쨌든 만료되어야 하므로 Redis는 주기적으로 만료가 설정된 키 중에서 무작위로 몇 개의 키를 테스트합니다. 이미 만료된 모든 키는 키스페이스에서 삭제됩니다.
특히 이것은 Redis가 초당 10회 수행하는 작업입니다.
- 연관된 만료가 있는 키 집합에서 임의의 키 20개를 테스트합니다.
- 만료된 모든 키를 삭제합니다.
- 25% 이상의 키가 만료된 경우 1단계부터 다시 시작합니다.
이것은 간단한 확률 알고리즘입니다. 기본적으로 샘플이 전체 키 공간을 대표하고 만료될 가능성이 있는 키의 비율이 25% 미만이 될 때까지 계속 만료된다고 가정합니다.
이것은 메모리를 사용하고 있는 이미 만료된 키의 최대 양이 주어진 순간에 초당 최대 쓰기 작업 양을 4로 나눈 것과 같다는 것을 의미합니다.
출처: https://redis.io/commands/expire/
본론으로 와서
@Service
@Transactional
@RequiredArgsConstructor
public class MessageService implements DisposableBean {
private final MessageRepository messageRepository;
//private static final Map<Long, Queue<Message>> messageMap = new HashMap<>();
private final RedisMessageCache messageMap;
private final EntityManager em;
}
Service에 messageMap만 갈아 끼면 끝입니다.
Test
잘 동작하는지 확인해보고 성능비교도 해봅시다.
@Test
void put() {
Message message = new Message(TALK, 1L,1L,"Message");
Queue<Message> q = new LinkedList<>();
q.add(message);
q.add(new Message(TALK, 1L,1L,"Message2"));
redisMessageCache.put(1L, q);
LinkedList<Message> messages = redisMessageCache.get(1L);
for (Message message1 : messages) {
System.out.println(message1.getDetailMessage());
}
}
성공이네요!
Redis CLI를 통해서도 확인해봅시다.
성능비교
DB와 Redis와는 약 100배정도의 차이가 나고
Redis와 Local간에는 10배정도의 차이가 납니다.
문제점
Key가 만료되었을 때 DB에 저장되어야 하는데, 그렇게 하려면 Redis에서 WAS Server로 알림을 줘야 합니다.
Redis에서 Key가 만료될 때 알림을 주는 <KeyExpirationEventMessageListener> 를 사용해 만료되는 Key값을 받아 올 수 있습니다.
(Expire Event를 사용할 때 redis.conf 설정을 변경해야 합니다. cpu를 조금 더 사용하기 때문에 사용안함이 디폴트라 이를 변경해야 됨.)
문제는 만료되는 Key의 'Value'를 가져올 방법을 찾을 수 없었습니다.
만료될 때 Key값만 받아올 수 있고, 받아온 Key로 조회하면 캐시가 이미 지워진뒤라 Value는 Null이 출력됩니다.
공식페이지랑 StackOverFlow를 뒤져보았지만 해결방법을 못 찾았습니다. ㅠㅜ
-..............
해결법
해결하는 많은방법을 생각해 봤는데 그 중 제일 괜찮은 방법이라고 생각합니다.
바로 redis에 같은 Key를 하나 더 생성하는 겁니다.
예를 들어, <key,value>가 <100, Messages> 인 roomId 100번에 해당하는 1번 캐시가 만들어진다면 <roomId100, String>인 2번 캐시를 하나더 생성해 만료시간을 2번 캐시에만 부여하는 겁니다.
이렇게 되면 만료될 때 Key값을 받은 후 2번 캐시는 지워지고, 이 Key로 1번 캐시를 조회해 DB에 저장하고 1번 캐시를 삭제하면 약간 꼼수같지만 성능적으로도 크게 나빠보이지 않습니다.
//만료이벤트가 발생하면 발동하는 함수
@Override
public void onMessage(Message message, byte[] pattern) {
//2번 캐시에서 roomId를 뽑아냄
Long roomId = Long.valueOf(message.toString().substring(4));
LinkedList<Chat.chattingApp.entity.Message> messages = redisMessageCache.get(roomId);
Queue<Chat.chattingApp.entity.Message> messageQueue = new LinkedList<>(messages);
//roomId로 1번캐시의 Value를 알아내 DB에 commit
messageService.commitMessageQueue(messageQueue);
//남아있는 1번캐시를 삭제
redisMessageCache.deleteKey(roomId);
}
생각해봐야 할 것
- 서버가 여러대면 그 중에 캐시 만료 알림을 받아 DB에 commit하는 서버는 데이터 정합성을 위해 한대만 둬야하나?
- Client에서 오는 대규모 request를 어떻게 다수의 서버에 효율적으로 분배할까
Reference
https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#redis:connectors
https://spring.io/projects/spring-data-redis
https://loosie.tistory.com/807
https://moonsiri.tistory.com/87
https://stackoverflow.com/questions/49563612/expired-key-trigger-event-spring-data-redis
'Spring > 채팅앱' 카테고리의 다른 글
[Spring 채팅앱 성능 개선기 6] 외부 MessageBroker (RabbitMQ) 적용 (0) | 2023.07.29 |
---|---|
[Spring 채팅앱 성능 개선기 5] Spring Cloud LoadBalancing 적용 (1) | 2023.07.25 |
[Spring 채팅앱 성능 개선기 3] Cache 설계 전략 (0) | 2023.07.06 |
[Spring 채팅앱 성능개선기 2] Cache를 이용한 속도향상 (0) | 2023.07.04 |
[Spring 채팅앱 성능개선기 1] JPA 쓰기지연을 활용한 채팅내역 저장 (0) | 2023.07.03 |
- Total
- Today
- Yesterday
- authorization
- Security
- spring orphan
- springboot
- FCM
- 푸시알림동작원리
- Kubernetes
- 로컬캐시
- ERD설계
- Spring WebSocket
- Stomp RabbitMQ
- Spring 채팅
- Spring 대댓글
- 웹소켓 채팅
- Cache
- Flutter
- ChattingApp
- bcrypt
- Vmmem종료
- nativeQuery
- Bcypt
- loadbalancing
- Stomp Kafka
- Spring RabbitMQ
- Spring Stomp
- Spring
- Vmmem
- 게시판 채팅
- Authentication
- MessageBroker
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |