'빼곡'을 개발하면서, 유저의 최근검색어를 보여주는 API를 구현하게 되었다.
기본적으로 RDB를 사용하고 있었으므로 CRUD로 구현해도 되지만, 몇가지 의문이 생긴다.
1. 해당 데이터는 검색 페이지에 접근할때마다 필요한데, 매번 DB에 접근해서 가져오는건 너무 느리지 않을까?
2. 최근 검색한 5개의 검색어만 저장하는데, 검색어 추가에 따라 insert 및 delete(5개가 넘을때) 작업이 수행되는건 너무 오버헤드가 크지 않을까?
3. 최근검색어를 삭제할때마다 RDB에서 해당 검색어를 삭제하는 작업도 기능에비해 오버헤드가 크다.
결론적으로, '최근검색어'라는 기능을 구현하기에 RDB는 오버헤드가 큰 접근법이라는 판단을 하게 되었다.
그렇다면 RDB에 접근하지 않고 이 기능을 구현하려면 어떻게 해야할까?
나는 그 결론으로, Redis를 활용해 효율적으로 구현하는 방식을 사용하게 되었다.
왜 Redis여야 할까?
Redis는 메모리 기반의 Key-Value 저장소로, 속도가 매우 빠르다는 장점이 있다.
따라서 잘못 클릭했을때 등 다양한 사용자 액팅에 따라 SELECT 및 DELETE문이 반복적으로 나가는것을 방지하여 DB에 대한 병목을 방지 할 수 있을것이다.
다만 메모리에 저장하는 방식이므로, 서버에 문제가 생기면 없어지는것이 아닐까? 하는 고민이 있었는데, 필요에 따라 디스크에도 저장이 가능하고 마스터-슬레이브 복제와 같은 기능으로 안정성까지 보장 할 수 있다는것을 알게 되었다.
Redis로 최근 검색어 구현해보기(로컬)
Redis 설치
백문이 불여일견! 일단 실행해보면서 공부해봤다.
이곳에서 Redis의 최근 릴리즈 버전을 다운로드 받을 수 있다.
셋팅후 Spring에서 사용하는 방법은 다음과같다(참고용).
1. Redis 서버 실행
- 명령 프롬프트를 열고 Redis가 설치된 디렉토리로 이동.
- redis-server.exe 명령어를 실행.
- 이 창은 열어둔 채로 유지. (Redis 서버가 실행 중인 상태를 유지)
2. Spring 애플리케이션 실행
- 새로운 명령 프롬프트 창을 열거나 IDE를 사용하여 Spring 애플리케이션을 실행.
주의사항
- Redis 서버가 실행 중인 명령 프롬프트 창을 닫으면 Redis 서버도 종료됨.
- Spring 애플리케이션은 실행 중인 Redis 서버에 연결을 시도.
TIP
- Redis를 Windows 서비스로 설치했다면, 매번 수동으로 실행할 필요 없이 자동으로 시작되게 할 수 있다.
- 서비스로 설치 >> Copy redis-server.exe --service-install
- 서비스 시작 >> Copy redis-server.exe --service-start
서비스로 설치하면 컴퓨터를 재시작해도 자동으로 Redis 서버가 실행되어, 매번 수동으로 서버를 실행할 필요가 없어진다.
Spring에서 최근 검색어 구현
조건은 다음과 같다.
- 사용자별로 중복되지 않은 10개의 검색어를 저장한다.
- Redis의 Sorted Set이라는 자료구조를 사용한다.(정렬된 집합으로, 순위가 있는 데이터)
- key는 "SearchLog + 사용자ID"
- value는 {검색어, 검색일자}
우선 스프링에서 관련 설정 빈을 만들어준다.
# redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
redis.size=10
redis.prefix=SearchLog
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, SearchLogRedis> SearchLogRedis() {
RedisTemplate<String, SearchLogRedis> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(SearchLogRedis.class));
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(SearchLogRedis.class));
return redisTemplate;
}
}
그리고 Redis와 통신할 레포지토리를 만들어주는데, 자료구조 사용을 위해선 공식문서를 통한 메소드 학습이 필수다.
동작 방식은 다음과 같이 구상했다.
- Redis가 지원하는 leftPush 등의 메소드를통해 자유롭게 집어 넣을수있다
- 각 사용자만의 저장소는 KEY_PREFIX+USERID를 통해 구분
- Delete의 경우 자바와는 다르게, .remove()사용시 내용이 같은 객체면 같은 객체라고 인식한다. 따라서 시각과 키워드명을 받아서 삭제하면 된다.
@Repository
@RequiredArgsConstructor
public class SearchLogRepository {
@Value("${redis.size}")
private long RECENT_KEYWORD_SIZE;
@Value("${redis.prefix}")
private String KEY_PREFIX;
private final RedisTemplate<String, SearchLogRedis> redisTemplate;
public void saveRecentSearchLog(String userId, String keyword){
String createdAt = LocalDateTimeUtil.getCurrentTimeAsString();
SearchLogRedis searchLog = new SearchLogRedis(keyword,createdAt);
String key = KEY_PREFIX + userId;
if ((redisTemplate.opsForList().size(key) != null) &&
(redisTemplate.opsForList().size(key) == RECENT_KEYWORD_SIZE)) {
redisTemplate.opsForList().rightPop(key);
}
redisTemplate.opsForList().leftPush(key, searchLog);
}
public List<SearchLogRedis> getRecentSearchLogs(String userId) {
String key = KEY_PREFIX + userId;
return redisTemplate.opsForList().
range(key, 0, RECENT_KEYWORD_SIZE);
}
public void deleteRecentSearchLog(String userId, String keyword, String createdAt) {
String key = KEY_PREFIX + userId;
SearchLogRedis searchLog = new SearchLogRedis(keyword,createdAt);
long count = redisTemplate.opsForList().remove(key, 1, searchLog);
if (count == 0) {
throw new GeneralException(Code.SEARCH_LOG_NOT_EXIST);
}
}
}
SortedSet의 경우 정렬된 집합으로, 검색 순서대로 저장해야되는 최근검색어 구현에 알맞는 자료구조라는 판단이 들었다. 또한 Set이므로 중복된 검색어를 저장하지 않아 따로 조건을 체크해주지 않아도 된다는 장점이 있었다. 삽입,삭제,조회 모두 O(log n)의 복잡도를 가지므로 매우 빠른 성능을 보인다. 그래서 선택했다!
(CF) 배포환경에 구성하기
리눅스 환경에도 같은 내용을 구성해줘야 했다. 글의 주제와는 조금 벗어난 느낌이라 참고용으로 작성한다.
내 배포환경은 리눅스이고, docker-compose를 사용중이기 때문에 이 설정파일에 다음 내용을 추가해줬다.
redis:
image: redis.alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: ["redis-server", "--appendonly", "yes"]
volumes:
mysql-data:
portainer_data:
services:
app:
image: ${DOCKERHUB_USERNAME}/bbegok:latest
platform: linux/arm64
ports:
- "8080:8080"
depends_on:
- db
- redis
이렇게 최근검색어를 RDB가 아닌 Redis로 구현해보았다. 포스팅 초반에 제시했던 RDB구현시의 문제점을 해결 할 수 있었다. 자세히 설명해보자면, 우선 RDB는 디스크 기반이기에 I/O작업이 많고 속도가 느리다는 단점을 갖고있었다. 이것을 메모리기반인 Redis를 통해 해결했다. 또한 RDB CRUD시의 복잡한 트랜잭션 처리에서 벗어나 단순 명령으로 처리되어 오버헤드가 적다. 그리고 요구사항 변화에 따라 데이터구조를 바꿔가면서 중복관리 및 정렬을 편리하게 수행할 수 있었다.
이를 통해 CRUD는 RDB에 요청하는것!과 같은 약간의 고정관념같은걸 부술 수 있었다. DB에는 다양한 종류가 있고, Redis나 NoSQL등을 활용해서 기능에 훨씬 알맞는 기술을 선택하는것의 중요성을 느꼈다. 이또한 백엔드 개발자로서 가져야 할 능력중 하나라고 생각하게 되었다😎
아 이러한 Redis의 특징 활용해서 동시성 제어가 가능하다고 하는데, 이것도 조만간 시도해보고 포스팅할 예정이다! 굉장히 단순하고 깔끔한 기술인데 활용도가 좋은것같다ㅎㅎ
관련 코드는 여기에서 볼 수있다!
'프로젝트 > 빼곡' 카테고리의 다른 글
Spring 명시적 Null값으로 부분 업데이트(PATCH) 구현하기 (3) | 2025.01.02 |
---|---|
여러기기에서 로그인, 다중 로그인 Spring에서 구현하기 (4) | 2024.12.20 |
다른 도메인 환경에서 쿠키 셋팅하기(Feat.samesite Cookie) (4) | 2024.12.12 |
서버이전을 고려한 Jenkins기반 아키텍처 개선(Feat. GithubActions) (3) | 2024.11.22 |
라즈베리파이를 이용한 홈서버 구축 (1) | 2024.11.21 |