이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.
2023.11.24 - [🌱 Spring] - Redis Cache를 적용하여 Read API 기능을 개선해보자 (1) 이론학습
앞서 기본적으로 Cache에 대해서 학습하고 어노테이션을 적용해서 캐시 저장을 해보았는데요.
여기까지 학습과정 중에 아쉬웠던 점은 캐시 추상화 어노테이션을 사용하는 것만으로는 팀원과 구현해보기로 한 `write-back` 을 구현하기 힘들었다는 것입니다.
- `write-through`는 이런 방식으로 구현할 수 있습니다.
@Service
public class MyService {
@Cacheable(value = "myCache", key = "#id")
public String getDataFromDatabase(String id) {
// 조회로직
}
@Caching(evict = {
@CacheEvict(value = "myCache", key = "#id"),
@CacheEvict(value = "myCache", key = "'all'")
})
public void updateDataInDatabase(String id, String newData) {
// 업데이트 로직
// 레포지토리 접근
}
}
그래서 다른 방식으로 구현해보자고 했어요.
구체적으로는 이런 로직을 구현하기 위함이었습니다.
채팅 메타 정보의 경우, 사용자 접속 여부와 안읽은 채팅, 마지막 채팅 등 채팅을 보낼 때마다 업데이트가 되어야 하기 때문에 사용자가 채팅 서비스를 사용하는 동안 캐시에 올라와있게 구현해보자고 팀원과 얘기해보았습니다. 더 나아가 채팅 저장도 쓰기 버퍼를 구현해볼 수 있고요.
그런데 이렇게 구현해보기 위해서는 직접 Redis에 데이터를 넣고 확인하는 과정이 필요했기 때문에 꽤 골치아픈 요구사항이었습니다.
Redis에 데이터를 저장하는 두 가지 방법과
CRUD Repository를 사용하지 않은 이유
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Spring data Redis 를 주입하면 두 가지 방식으로 Redis의 데이터에 접근할 수 있습니다.
- `CRUD Repository`를 extends 받는 Interface를 구현합니다.
- `RedisTemplate` 을 사용하여 operations 를 꺼내 사용합니다.
이번 프로젝트에서는 후자의 방식을 택했습니다. 아무래도 직접 구현하는 것이 목적에 맞는 구현을 할 수 있는 것도 있었지만 `CRUD Repository`를 사용했을 때 어떻게 Redis에 저장되는지 확인하고 나서 정한 것이었는데요.
CRUD Repository를 사용하면 기존 Spring data Repository를 사용했던것과 같은 방식으로 간편하게 구현할 수 있습니다.
@Getter
@ToString
@RedisHash("chat-bubble")
public class ChatBubble implements Serializable {
@Id @JsonIgnore
private final String id;
@Indexed
private final String roomId;
// 기타 등등 ...
@TimeToLive
private Integer expirationSeconds;
}
그러나 저장되는 구조가 굉장히 예상 밖이더라고요.
총 2가지 타입이 동시에 저장되는데, 이는 다음과 같습니다.
- ChatBubble의 `@Id` 로 이루어진 Set을 구성합니다.
- ChatBubble의 `@Id`를 Key로, 객체를 Value로 갖는 Hash를 여러개 구성합니다.
이렇게 구현되어있는 이유는 Redis가 Key-Value 데이터베이스이기 때문입니다.
Key를 조회하기 위해 모든 키를 순회하기보다 해당 set을 순회하는 편이 더 빠르기 때문에 같은 entity의 경우 하나의 set에 entity의 모든 id를 저장합니다.
만약 `@Indexed`를 지정해주게 되면 이또한 다른 자료구조인 Hash set을 구성하는 것을 확인할 수 있었습니다. 마치 Secondary Index처럼요.
Spring Data Redis Repository에서는 앞서 말씀드린 연산을 빠르게 수행하기 위해 key 값만 저장하는 set과 secondary index
를 추가로 이용해 primary key access만 제공하는 Key-Value 데이터베이스의 한계를 극복했습니다. [참고링크]
그러나 이러한 방식이면 중복된 데이터를 너무 많이 저장한다는 고민이 들게 합니다. 특히 In memory로 동작하는 Redis의 특성상 메모리를 불필요하게 차지하게 하고 싶지 않았습니다.
그리고 이를 조회하기 위한 오버헤드가 추가로 발생한다는 점은 캐시를 사용하는 이점을 떨어뜨린다고 생각했습니다.
TTL을 적용하는데에도 추가적인 로직을 구현해야 했는데요. 엔티티에 설정된 TTL이 0 이하로 떨어지면 Redis `activeExpireCycle` 에 의해서 삭제되는 등 저장된 데이터는 삭제되지만 Set에서는 삭제가 되지 않습니다. Set에서 삭제되는 로직을 별도로 구현해주지 않으면 계속 메모리가 누적되게 되는 치명적인 오류가 생긱는 것이죠.
그리고 `size()` 등의 메서드를 호출할 때 데이터 정합성이 맞지 않게 됩니다. 실제로 Redis에 존재하지 않는 데이터임에도 계수에 포함되게 되는 것입니다.
이런 데이터 비정합을 고치기 위해서 별도의 코드를 작성하여 사라진 엔티티를 삭제해주어야 하는 번거로움이 있을 것이란 예상이 들었습니다.
해당 코드에 대해서 당시 리뷰어에게 코멘트를 받고 Redis Repository를 쓰는 것을 포기하였습니다.
직접적으로 `Redis Template` 을 사용하는 것이 원하는 타입으로 저장할 수 있고, 추후에 조회시에도 훨씬 유리할 것이라는 판단이었습니다.
관련해서 참조한 글을 링크합니다. [참고링크 : Spring Data Redis Repository 미숙하게 사용해 발생한 장애 극복기]
과도기 : RedisTemplate을 사용하여 Redis에 List 타입으로 저장해보기
위에서 언급한 Redis Repository를 포기하고 `RedisTemplate`을 사용하기로 하면서 좋았던 점은 타입에 대한 자유가 생겼다는 것입니다.
Redis Repository는 Value와 이에 대한 조회를 뒷받침하는 자료구조를 사용했다면, 이제 직접 Operation을 꺼내 사용하게 된 것이죠.
Redis에는 다양한 타입이 있습니다. 채팅 메시지를 저장하기 위해 고려했던 것은 `Streams`, `Lists`, `Hashes` 등 연속된 key-value를 저장할 수 있는 형태들이었는데요. (채팅방 정보(참여자 접속 정보)는 여러개가 연속할 필요는 없었기 때문에 `opsForValue` 메서드를 사용해 Strings 타입으로 저장하기로 했어요.)
결국 선택했던 것은 List였습니다. 채팅 메시지를 순서대로 저장하고 있어 조회할 때도 편리할 것이라는 예상이었습니다.
(Redis에는 다양한 타입이 있는데, 어떨 때 어떻게 쓰면 좋은지 알아두면 좋겠죠..?)
다음은 관련해서 구현했던 코드입니다.
@Getter
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.cache.redis.host}")
private String host;
@Value("${spring.cache.redis.port}")
private int port;
private final SimpMessageSendingOperations messagingTemplate;
private final ObjectMapper objectMapper;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
// 생략
@Bean
public RedisTemplate<String, ChatBubble> redisChatBubbleTemplate() {
final RedisTemplate<String, ChatBubble> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
Jackson2JsonRedisSerializer<ChatBubble> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(ChatBubble.class);
jsonRedisSerializer.setObjectMapper(objectMapper);
template.setValueSerializer(jsonRedisSerializer);
return template;
}
- 처음 채팅 메시지를 발송하면, 채팅방 Id를 뒷쪽에 붙인 Key를 가진 리스트를 생성합니다.
- 이름을 왜 이렇게 짓냐면, Redis에서는 Key로 모든 데이터가 정렬되기 때문에 앞에 Prefix를 붙여 조회의 일관성을 주고 싶었습니다.
- 채팅 메시지를 다음부터 발송할 때 해당 리스트에 하나씩 추가됩니다.
@Service
@RequiredArgsConstructor
public class ChatLogService {
@Value("${const.chat.bucket}")
private String chatBucketPrefix;
@Value("${const.chat.page-size}")
private int chatLoadSize;
private final RedisTemplate<String, ChatBubble> redisChatBubbleTemplate;
// 채팅 로그를 조회합니다.
public Slice<ChatBubble> getChatBubbles(int page, String roomId) {
ListOperations<String, ChatBubble> listOperations = redisChatBubbleTemplate.opsForList();
String key = chatBucketPrefix + roomId;
long startIndex = getStartIndex(page);
long endIndex = startIndex - chatLoadSize;
List<ChatBubble> messages = listOperations.range(key, endIndex, startIndex);
Pageable pageable = PageRequest.ofSize(chatLoadSize);
return getSlice(messages, pageable);
}
// 목록 조회시 offset을 계산합니다.
private long getStartIndex(int page) {
return (-1L * chatLoadSize * page) - 1;
}
// 목록 조회시 slice형태로 만들어줍니다.
private Slice<ChatBubble> getSlice(List<ChatBubble> messages, Pageable pageable) {
boolean hasNext = false;
if (messages.size() > pageable.getPageSize()) {
hasNext = true;
messages.remove(pageable.getPageSize());
}
return new SliceImpl<>(messages, pageable, hasNext);
}
// 채팅 로그를 저장합니다.
public void saveChatBubble(ChatBubble chatBubble) {
String key = chatBucketPrefix + chatBubble.getRoomId();
redisChatBubbleTemplate.opsForList().rightPush(key, chatBubble);
}
}
그런데 여기서 문제를 하나 발견합니다.
리스트라며... 근데 Redis에서는 리스트가 linked list로 구현되어있었습니다 🥲....
통상적으로 리스트 자료구조가 가지는 특징을 가지고 있을거라고 생각했는데.. 😵 멘붕
어쩐지 `-1` 인덱스로 마지막부터 조회가 가능하더라니.. 수상하더라니....
이렇게되면 페이징을 offset 방식으로 한 것처럼 페이지 조회를 할때마다 앞의 메시지 조회하게 되어서 중간 요소 탐색시 O(n)의 시간복잡도가 소요되게 됩니다.
Redis lists are implemented via Linked Lists. This means that even if you have millions of elements inside a list, the operation of adding a new element in the head or in the tail of the list is performed in constant time. The speed of adding a new element with the LPUSH command to the head of a list with ten elements is the same as adding an element to the head of list with 10 million elements.
`sorted set` 을 사용하는 것이 더 나을 수 있다고 판단했지만 결국 바꾸지 않았습니다.
다른 구현 과제들이 많기도 했고..
중고거래 채팅 특성상 채팅이 길게 이어지거나 옛날 기록을 조회하기 위해 거슬러 올라가는 일은 드물 것이라는 판단이었습니다. 하지만 다음에 할 때는 List 타입으로 하진 않을 것 같아요 🫠
그리고 지금의 코드
그리고 현재는 코드를 어느정도 추상화시켰습니다. 반복되는 코드가 많아 역할별로 메서드를 묶기로 해습니다.
그전에 `RedisConfig`에 저장 타입마다 RedisTemplate을 정의하던 코드가 있었는데요. 이를 `Object` 로 통일시켜 등록된 `@Bean`을 줄였습니다. Object mapper를 통해 타입 변환해주는 역할을 해주는 클래스를 별도로 만들어주기로 했습니다.
@Getter
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
private final RedisConnectionFactory redisConnectionFactory;
private final ObjectMapper objectMapper;
@Bean
public RedisTemplate<String, Object> redisObjectTemplate() {
objectMapper.registerModule(new JavaTimeModule());
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); // Use Jackson2JsonRedisSerializer
return template;
}
}
그리고 이 타입변환을 하게 해줄 `Redis Operations` 와 이를 상속받는 `RedisListOperationsHelper`, `RedisOperationsHelper`를 구현하였습니다.
그리고 각자의 `cache` 레포지토리를 구현할 때 자료구조에 맞춰 이 operationsHelper들을 포함하도록 할 것입니다.
@RequiredArgsConstructor
public abstract class RedisOperations {
protected final RedisTemplate<String, Object> redisTemplate;
protected final ObjectMapper objectMapper;
// 원하는 타입으로 반환할 수 있도록합니다. 제네릭으로 리팩토링할 수 있을 것 같은데 아직 안했어요.
protected <T> T getT(Class<T> clazz, Object object) throws JsonProcessingException {
String s = objectMapper.writeValueAsString(object);
return objectMapper.readValue(s, clazz);
}
public void deleteAll() { // 모든 데이터를 삭제합니다.
redisTemplate.execute((RedisCallback<Object>) connection -> {
connection.flushAll();
return null;
});
}
}
- List 타입 operationsHelper를 구현한 코드입니다.
@Component
public class RedisListOperationsHelper extends RedisOperations {
public RedisListOperationsHelper(RedisTemplate<String, Object> redisTemplate,
ObjectMapper objectMapper) {
super(redisTemplate, objectMapper);
}
/**
* Redis List에 데이터를 추가합니다.
* @param key
* @param value
*/
public void add(String key, Object value) {
redisTemplate.opsForList().rightPush(key, value);
}
/**
* Redis List에 데이터를 가져옵니다. (RPOP)
* @param key
* @param T
* @return List<T>
*/
public <T> List<T> popAll(String key, Class<T> clazz) {
Set<String> keys = redisTemplate.keys(key+"*");
List<T> result = new ArrayList<>();
for (String k : keys) {
Long size = redisTemplate.opsForList().size(k);
List<Object> objects = redisTemplate.opsForList().rightPop(k, size);
result.addAll(objects.stream().map(e -> {
try {
return getT(clazz, e);
} catch (JsonProcessingException ex) {
throw new RuntimeException(ex);
}
}).collect(Collectors.toList()));
}
return result;
}
/**
* Redis List 개수를 확인합니다.
* @param key
* @return size (long)
*/
public long size(String key) {
return redisTemplate.opsForList().size(key);
}
public <T> List<T> getList(String key, long start, long end, Class<T> clazz) {
List<Object> range = redisTemplate.opsForList().range(key, start, end-1);
return range.stream().map(e -> {
try {
return getT(clazz, e);
} catch (JsonProcessingException ex) {
throw new RuntimeException(ex);
}
}).collect(Collectors.toList());
}
/**
* Redis List에서 데이터를 삭제합니다.
* @param key
* @param value
*/
public void trim(String key, int size) {
redisTemplate.opsForList().trim(key, 0, size);
}
public void delete(String key) {
redisTemplate.delete(key);
}
}
- 그리고 이를 활용하여 cache를 구현하였습니다.
- 현재 언급하지 않지만, String 타입으로 저장하는 `ChatMetaInfoCache` 도 있습니다.
@Repository
@RequiredArgsConstructor
public class ChatBubbleCache {
private final RedisListOperationsHelper operationsHelper;
private final ChatCacheProperties chatBubbleProperties;
private long getStartIndex(Pageable page) {
return (-1L * page.getPageSize() * page.getPageNumber());
}
private Slice<ChatBubble> getSlice(List<ChatBubble> messages, Pageable pageable) {
if (messages.isEmpty()) {
throw new IndexOutOfBoundsException("빈 페이지 입니다.");
}
if (messages.size() > pageable.getPageSize()) {
messages.remove(pageable.getPageSize());
}
return new SliceImpl<>(
messages.subList(0, Math.min(pageable.getPageSize(), messages.size())), pageable,
pageable.getPageSize() < messages.size());
}
public Slice<ChatBubble> findAllByRoomId(String key, Pageable pageable) {
long startIndex = getStartIndex(pageable);
long endIndex = startIndex - pageable.getPageSize();
List<ChatBubble> messages = operationsHelper.getList(generateChatLogKey(key), endIndex - 1, startIndex,
ChatBubble.class);
return getSlice(messages, pageable);
}
public List<ChatBubble> findAllByRoomId(String key) {
long size = operationsHelper.size(generateChatLogKey(key));
return operationsHelper.getList(generateChatLogKey(key), 0, size, ChatBubble.class);
}
public ChatBubble save(String key, ChatBubble chatBubble) {
operationsHelper.add(generateChatLogKey(key), chatBubble);
return chatBubble;
}
public int getLastPage(String key, int pageSize) {
Long size = operationsHelper.size(generateChatLogKey(key));
return (int) (size / pageSize);
}
public void clear(String key) {
operationsHelper.delete(generateChatLogKey(key));
}
public List<ChatBubble> findAllBubbles() {
return operationsHelper.popAll(chatBubbleProperties.getKey(), ChatBubble.class);
}
private String generateChatLogKey (String roomId) {
return String.format("%s%s", chatBubbleProperties.getKey(), roomId);
}
}
- 이제 이 구현체를 이용하여 service에서 Redis를 이용하여 쓰기 버퍼를 사용하기 위해 로직을 직접 구현해 주었습니다. 타입을 다르게하니 어노테이션으로는 안되기 때문에 직접 구현한 것입니다.
- 캐싱하기로 한 다른 데이터들도 비슷하게 구현해보았습니다.
- 그리고 주기적으로 캐시에 있는 데이터를 동기화 시킴으로써 `write-back`을 구현해보았습니다.
@Service
@RequiredArgsConstructor
public class ChatBubbleService {
private final ChatBubbleRepository bubbleRepository;
private final ChatBubbleCache bubbleCache;
private final ChatCacheProperties chatBubbleProperties;
// 채팅 목록 조회
@Transactional(readOnly = true)
public Slice<ChatBubble> getChatBubbles(int page, String roomId) {
String key = generateChatLogKey(roomId);
Pageable pageable = PageRequest.of(page, chatBubbleProperties.getPageSize(), Sort.by("createdAt").descending());
try {
return bubbleCache.findAllByRoomId(key, pageable);
} catch (IndexOutOfBoundsException e) { // 캐시에서 존재하지 않는 데이터 페이지를 조회하려고 하면 예외가 발생합니다.
page -= bubbleCache.getLastPage();
Pageable pageable = PageRequest.of(page, chatBubbleProperties.getPageSize(), Sort.by("createdAt").descending());
Slice<BubbleEntity> list = bubbleRepository.findAllByChatroomId(roomId, pageable);
return list.map(BubbleEntity::toDomain);
}
}
@Transactional
public ChatBubble saveChatBubble(ChatBubble chatBubble) {
String key = generateChatLogKey(chatBubble.getChatroomId());
return bubbleCache.save(key, chatBubble); // 캐시에만 저장
}
private String generateChatLogKey (String roomId) {
return String.format("%s%s", chatBubbleProperties.getKey(), roomId);
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatBubbleScheduledWorker {
private final ChatBubbleCache chatBubbleCache;
private final ChatBubbleRepository chatBubbleRepository;
@Scheduled(cron = "0 * * * * *") // 매 1분마다
public void clearChatBubbleCache() throws JsonProcessingException {
log.info("🧹START : {} Thread clear chat bubble cache", Thread.currentThread().getId());
List<ChatBubble> all = chatBubbleCache.findAllBubbles();
if (all.isEmpty()) return;
List<BubbleEntity> entities = all.stream().map(BubbleEntity::from)
.collect(toList());
chatBubbleRepository.saveAll(entities);
log.info("🧹 END : clear chat bubble cache = {}", entities.size());
}
}
여기까지 실제로 캐싱을 공부하고 전략을 세워 어노테이션과 `RedisTemplate`을 통해 직접 구현해보니 이해가 빠르게 될 수 있었습니다.
현재 Spring data Redis, Spring Cache로 추상화 되어있는 것이 얼마나 소중한지(...)도 깨달을 수 있었습니다.
구현을 시작해보니 생각보다 신경써야 할 로직들이 많았기 때문입니다. 특히 캐시의 라이프사이클이 어떻게 되어야 하는지, 어떤 방식으로 업데이트가 되어야 하는지 구현하는 것이 까다로웠던 것 같습니다.
- 해당 포스트에서 언급되고 있는 전체 코드는 Github에서 확인할 수 있습니다.
참고 링크/자료