오늘은 Redis를 활용하여 조회수를 관리하는 기능을 구현해보려고한다. 부끄럽게도 기존에는 사용자가 거의 없기 때문에 DB로만 관리해왔다. 이 글을 통해 기존 방식에서 Redis로 변경한 이유와 그 과정에 대해 기록해보고자 한다.
1. 기존의 DB 방식에서 문제점
기존에는 데이터베이스를 사용하여 조회수를 관리했다. 사용자가 웹 페이지를 조회할 때마다 해당 페이지의 조회수를 1씩 증가시키는 방식이었다. 하지만 이 방식에는 문제점이 있다.
- 동시성 문제: 여러 사용자가 동시에 같은 페이지를 조회할 경우, 조회수의 정확성을 보장하기 어렵다. 동시에 조회수를 업데이트하려다 보면 한 사용자의 조회가 누락되는 경우가 발생할 수 있다.
- 데이터베이스 부하: 조회수 업데이트는 매우 빈번한 작업이다. 많은 사용자가 활동하는 서비스에서는 이러한 작업이 데이터베이스에 부하를 주고, 결국 서비스의 성능을 저하시킬 수 있다.
이러한 문제를 해결하기 위해 Redis를 활용한 방식으로 변경하게 되었다.
2. 왜 Redis를 사용하나?
Redis는 인메모리 데이터 구조 저장소로, 키-값 쌍의 데이터를 메모리에 저장하여 매우 빠른 응답 속도를 제공한다. 또한, Redis는 다양한 데이터 구조를 제공하며, 이 활용하면 다양한 유형의 데이터 관리가 가능하다. 대표적인 데이터 구조는 아래와 같다.
- String: 가장 기본적인 데이터 구조로, 문자열을 저장하고 검색하는 데 사용
- List: 순서가 있는 문자열의 집합입니다. 스택이나 큐로 사용할 수 있음
- Set: 순서가 없는 문자열의 집합입니다. 중복된 값을 허용하지 않음
- Sorted Set: Set과 동일하지만, 각 멤버에 점수(score)를 부여하여 정렬할 수 있음
- Hash: 객체를 저장하는 데 사용됩니다. 키-값 쌍의 집합을 나의 키에 저장할 수 있음
특히, 조회수 관리 기능에서는 Set과 Sorted Set을 활용하였다. Set은 특정 페이지를 조회한 IP 주소를 저장하는 데 사용되었으며, 중복된 IP 주소의 저장을 방지하여 동일 사용자에 의한 중복 조회를 막을 수 있다. Sorted Set은 각 페이지의 조회수를 관리하는 데 사용되었으며, 각 페이지의 아이디를 멤버로, 조회수를 점수로 사용하여 조회수를 쉽고 빠르게 관리할 수 있다.
3. Redis 설정
Gradle
dependencies {
compile('org.springframework.boot:spring-boot-starter-data-redis')
}
Yaml
spring:
redis:
host: localhost
port: 6379
아래 코드는 Redis를 사용하기 위한 기본 설정입니다.
@Configuration
public class RedisConfig {
/*
@Value 애너테이션을 통해 spring.redis.host와 spring.redis.port의 값을 각각 host와 port에 주입
*/
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
/*
LettuceConnectionFactory를 사용하여 Redis 연결 팩토리를 생성
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
/*
RedisTemplate은 Spring Data Redis에서 제공하는 클래스로, Redis 연산을 수행하기 위한 도구
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
redisConnectionFactory
LettuceConnectionFactory를 사용하여 Redis 연결 팩토리를 생성했다.
Lettuce는 Redis 클라이언트 라이브러리 중 하나로, 비동기 이벤트 기반의 연결을 지원한다.
Redis 설정에는 여러 가지 방법이 있는데, 그 중에서도 LettuceConnectionFactory를 사용하는 이유는
- 이벤트 기반 아키텍처: Lettuce는 netty라는 비동기 이벤트 기반의 네트워킹 프레임워크를 기반으로 만들어졌다. 이로 인해 Redis 서버와의 통신이 비동기적으로 이루어지며, 이는 고성능 환경에서 매우 중요한 요소
- 쓰레드 세이프: Lettuce의 연결 인스턴스는 다중 스레드에서 공유될 수 있다. 이는 다른 Redis 클라이언트 라이브러리인 Jedis와는 다른 점으로, Jedis는 멀티 스레드 환경에서는 연결을 공유할 수 없다.
- 클러스터 지원: Lettuce는 Redis의 클러스터 모드를 지원한다. 이를 통해 클러스터 모드를 사용하는 Redis 서버와의 통신이 가능
redisTemplate
RedisTemplate은 Spring Data Redis에서 제공하는 클래스로, Redis 연산을 수행하기 위한 도구이다. RedisTemplate를 사용하면, Redis의 다양한 데이터 구조를 Java에서 쉽게 다룰 수 있다. 예를 들어, Redis의 String, List, Set, Hash 등의 데이터 구조에 대한 연산을 RedisTemplate에서 제공하는 메서드를 통해 수행할 수 있다.
따라서, Redis를 사용하는 Java 애플리케이션에서는 RedisTemplate를 사용하여 Redis 연산을 수행하는 것이 일반적이다.
RedisTemplate를 사용하지 않고 직접 Redis 클라이언트 라이브러리를 사용하여 Redis 연산을 수행하는 것도 가능하지만, Redis의 데이터 구조를 직접 관리해야 하기 때문에 더 복잡해질 수 있다.
setDefaultSerializer
setDefaultSerializer는 RedisTemplate에서 사용하는 기본 직렬화 도구를 설정하는 메서드이고,
직렬화는 객체를 바이트 코드로 변환하는 과정을 의미하며, 이는 네트워크를 통해 객체를 전송하거나, 객체를 파일에 저장할 때 사용된다. StringRedisSerializer는 문자열을 바이트 코드로 변환하는 직렬화 도구이다.
setDefaultSerializer를 통해 기본 직렬화 도구로 StringRedisSerializer를 사용하도록 설정하고, 이를 통해 Redis에 저장되는 값들이 문자열 형태로 저장도록 한다
다른 직렬화 도구 중에는 JdkSerializationRedisSerializer가 있는데, JdkSerializationRedisSerializer가 defult이다. 처음에는 기본직렬화 도구를 설정하지 않았다가 데이터가 이상하게 들어가는 문제가 발생했었다.
JdkSerializationRedisSerializer는 Java의 기본 직렬화 도구로, 객체를 바이트 코드로 변환할 때 사용된다. 하지만 JdkSerializationRedisSerializer를 사용하면 객체가 직렬화되어 저장되므로 Redis에서 직접 값을 확인하는 것이 어렵다. 또한, 직렬화된 객체를 역직렬화할 때 해당 객체의 클래스가 필요하므로, 동일한 클래스를 가지고 있지 않은 다른 시스템에서는 이 값을 사용하기 어렵다. 이러한 이유로 StringRedisSerializer를 사용하는 것이 좋다.
여기서잠깐!!!!!
Spring Boot 2.0 이상에서는 Redis에 대한 auto-configuration이 제공된다. Spring Boot가 클래스 경로에 Redis 관련 라이브러리(redisson, lettuce, jedis 등)가 존재하고, spring.redis.host와 같은 필요한 프로퍼티들이 설정되어 있다면, 자동으로 RedisConnectionFactory와 RedisTemplate 빈을 생성하고 설정해준다.
따라서, 별도로 RedisConnectionFactory, RedisTemplate, StringRedisTemplate 등의 빈을 생성해주는 Java Config 클래스를 작성하지 않아도, 이들 빈을 @Autowired를 통해 주입받아 사용할 수 있다.
단, 이때 사용되는 RedisConnectionFactory는 LettuceConnectionFactory가 기본적으로 사용된다.
필요한 경우 RedisAutoConfiguration을 비활성화하고 사용자 정의 RedisConnectionFactory, RedisTemplate, StringRedisTemplate 빈을 만들어 사용하는 것도 가능하다. 이러한 경우에는 @EnableAutoConfiguration 애노테이션에 exclude 속성을 사용하여 RedisAutoConfiguration을 제외하면 된다
@SpringBootApplication(exclude = RedisAutoConfiguration.class)
public class MyApplication {
// ...
}
4. Redis를 활용한 조회수 관리 구현
이 코드는 Redis를 사용하여 웹 페이지의 조회수를 관리하는 기능을 구현한 것입니다.
Redis를 활용한 조회수 관리는 크게 4가지 메서드로 구성되어 있습니다.
@Service
public class ViewCountService {
private final RedisTemplate<String, String> redisTemplate;
publicCountService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// 특정 id가 특정 ip에서 조회되었는지 확인(존재하면 true, 존재하지 않으면 false)
public boolean isViewed(Long id, String ipAddress) {
String key = "view:" + id;
return redisTemplate.opsForSet().isMember(key, ipAddress);
}
// 특정 id가 특정 ip에서 조회되었는지 확인
public void addView(Long id, String ipAddress) {
String key = "view:" + id;
redisTemplate.opsForSet().add(key, ipAddress);
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
// isViewed를 호출하여 이미 해당 페이지를 조회한 ip인지 확인하고 조회하지 않은 ip이면 score(조회수)를 1증가 시키고 set에 id(key)와 ip주소(value)를 추가한다
public void increaseViewCount(Long id, String ipAddress) {
if (!isViewed(id, ipAddress)) {
String key = "viewCount";
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(id), 1);
addView(id, ipAddress);
}
}
// 해당 id의 조회수(score)를 조회
public Double getViewCount(Long id) {
String key = "viewCount";
return redisTemplate.opsForZSet().score(key, String.valueOf(id));
}
}
- isViewed(Long id, String ipAddress): 특정 페이지가 특정 IP 주소에서 조회되었는지 확인하는 기능
Redis의 Set 데이터 구조를 활용하여, 특정 페이지를 조회한 IP 주소를 저장한다. isMember(key, ipAddress)를 호출하여 해당 IP 주소가 Set에 포함되어 있는지 확인 후, 포함되어 있으면 true를, 그렇지 않으면 false를 반환. - addView(Long id, String ipAddress): 특정 페이지를 특정 IP 주소에서 조회한 정보를 Redis에 추가하는 기능
add(key, ipAddress)를 호출하여 Set에 IP 주소를 추가하고, expire(key, 1, TimeUnit.MINUTES)를 호출하여 이 정보를 1분 동안만 유지하도록 설정. 이로 인해, 동일한 IP 주소에서 1분 이내에 동일한 페이지를 여러 번 조회하더라도 조회수는 1번만 증가한다. - increaseViewCount(Long id, String ipAddress): 특정 페이지의 조회수를 증가시키는 기능
isViewed(id, ipAddress)를 호출하여 해당 페이지를 이미 조회한 IP 주소인지 확인하고, 아직 조회하지 않았다면 incrementScore(key, String.valueOf(id), 1)를 호출하여 조회수를 1 증가시킨다. 그리고 addView(id, ipAddress)를 호출하여 조회 정보를 추가한다. - getViewCount(Long id): 특정 아이디(페이지)의 조회수를 반환
Redis의 Sorted Set에서 해당 아이디의 점수(score)를 조회하며, 이 점수는 조회수를 나타낸다.
Redis를 사용하면 메모리 기반의 빠른 응답 속도로 조회수 관리 기능을 구현할 수 있다. 또한, 각 IP 주소별로 조회 여부를 확인함으로써 동일 사용자에 의한 중복 조회를 방지할 수 있다.
확인하기
#설정한 host와 port로 Redis cli 실행
redis-cli -h 127.0.0.1 -p 6379
# 잘 연결이 됐다면
127.0.0.1:6379> ping
PONG
# keys * 명령어로 전체 키를 조회 할 수 있다. *대신 키값을 넣으면 값이 있을 때는 키캆이 반환 되고 없으면 (empty array)
127.0.0.1:6379> keys *
1) "viewCounts"
# 게시글 조회 후 캐시로 1분간 키값 생성
127.0.0.1:6379> keys *
1) "view:set:31"
2) "viewCounts"
3) "view:set:34"
# 값이 잘 들어왔는지 확인할려면 자료구조에 맞는 명령어를 사용해야됨
if value is of type string -> GET <key>
if value is of type lists -> lrange <key> <start> <end>
if value is of type sets -> smembers <key>
if value is of type sorted sets -> ZRANGEBYSCORE <key> <min> <max>
if value is of type hash -> HGETALL <key>
# set을 사용한 사용자 확인 로직은 smembers로 view:set:34를 조회했더니 value에 ip값이 들어와 있다
127.0.0.1:6379> smembers view:set:34
1) "0:0:0:0:0:0:0:1"
# sorted set을 사용한 조회수 로직은 ZRANGEBYSCORE를 사용하여 viewCounts의 1위부터 10위까지 출력했다
127.0.0.1:6379> ZRANGEBYSCORE viewCounts 1 10
1) "31"
2) "34"
3) "81"
4) "82"
5) "85"
#ZRANGE 명령어를 사용하면 각 value별 score값도 확인이 가능하다
127.0.0.1:6379> ZRANGE viewCounts 0 -1 WITHSCORES
1) "34"
2) "1"
3) "81"
4) "1"
5) "82"
6) "1"
7) "85"
8) "2"
9) "31"
10) "3"
이런 레디스를 활용한 조회수 구현에도 단점은 있다!
- IP 주소 기반의 제한
- 현재 구현에서는 IP 주소를 기반으로 사용자를 구분하고 있습니다. 그러나, 여러 사용자가 같은 IP 주소를 공유하는 경우(예: 같은 네트워크를 사용하는 사무실이나 가정 환경)에는 정확한 조회수를 파악하기 어렵습니다.
- 로그인 유저라면 사용자의 이메일, 사용자를 구분하고자 할 때는 쿠키나 세션 등을 사용하는것도 방법
- 데이터 유실 위험: Redis는 메모리 기반의 저장소이므로, 서버가 다운되거나 재시작하는 경우 저장된 데이터가 모두 사라질 수 있습니다. 이로 인해, 실제 조회수와 Redis에 저장된 조회수 사이에 차이가 발생할 수 있습니다.
나의 프로젝트에는 로그인을 하지 않은 유저를 대상으로 했기 때문에 ip 주소를 기반으로 구현하였고,
2번 데이터 유실 위험의 경우 내가 서버를 재시작했을 때 실제로 경험했던 문제였다. 이부분을 해결한 방법은 다음에 또 글을 써볼려고한다.
5. 마치며
기존 데이터베이스를 활용한 조회수 관리 방식의 문제점에 대해 설명하고, 이를 해결하기 위한 방법으로 Redis를 활용한 조회수 관리 방식을 설명해보았다. 또한, Redis를 활용한 조회수 관리 기능의 세부적인 구현 방법에 대해 자세히 설명해봤다.
열심히 구현했는데 Spring Boot 2.0 이상에서는 config를 작성하지 않아도 된다고해서 조금 충격... 경험 했다 생각하기로!
또한 동일한 IP 주소를 공유하는 사용자를 구분하는 문제나 서버의 재시작 등으로 인 데이터 유실 위험이 존재하는데, 이러한 문제를 해결하는 방법에 대해 추가적인 고민과 연구가 필요하다는 것을 알게 되었다. 끝이 없는 문제의 연속. 개발에는 정답이 없기 때문에 문제점과 해결방법, 상황 등을 명확히 파악하고 적절한 방법을 선택할 수 있게끔 열심히 경험과 지식을 쌓아야겠다!!
추가로 부트캠프시절 Redis로 인기검색어를 구현했던 경험이 있어 링크를 첨부해본다(엉망진창😅)
진짜 마지막으로 혹시 조회수를 구현하는 다른방법이 있다면 이야기해주시면 감사하겠습니다!🙇♀️
[reference]
'개발 하나둘셋 > Java & Spring' 카테고리의 다른 글
Spring Boot 3.x 주요 변경 사항과 마이그레이션 방법 (0) | 2024.02.04 |
---|---|
Redis 서버 재시작 시 데이터 초기화 문제와 해결 방법: RDB와 AOF (1) | 2024.01.21 |
Java 'MultipartFile'에서 파일 이름 가져오기 문제와 해결방법, 유니코드 (0) | 2023.12.21 |
AWS SDK for Java V1, V2 차이 / s3객체 업로드, 복사, 삭제 구현하기 (0) | 2023.06.26 |
TeamCity로 CI/CD 적용하기 (0) | 2022.11.10 |