프로젝트를 진행하면서 실시간 인기 아파트 정보를 제공하는 기능을 구현 했습니다.
이 과정에서 두 가지 주요 방식에 대해 고민하게 되었습니다.
첫 번째는 조회수 칼럼을 기존 아파트 테이블에 추가하는 방식과, 조회수 데이터를 별도의 테이블로 분리하여 관리하는 방식이었습니다.
두 번째는 조회수의 처리 방식에 대한 고민이었습니다. 조회수가 일정 수치 이상 상승한 경우 바로 DB에 저장하는 방식과, 하루에 한 번 레디스에 저장된 조회수를 DB에 일괄 업데이트하는 방식 중에서 어떤 방법이 더 효율적일지에 대해 고민을 하게 되었습니다.
Redis 선택 이유
- 성능 최적화: 조회수와 같은 실시간 데이터를 DB에 직접 저장하는 대신 Redis를 사용하여 빠르게 읽고 쓸 수 있도록 함으로써 DB 부하를 줄이고 성능을 향상시켰습니다.
- 다양한 자료구조 제공: Redis는 다양한 자료구조를 제공하며, 그 중 Sorted Set을 활용해 상위 N개 아파트를 빠르게 조회할 수 있었습니다.
조회수 칼럼을 아파트 테이블에 추가
- 장점
- 단순한 설계
- 조회수를 별도의 테이블로 분리할 필요 없이, 기존의 아파트 테이블에 칼럼을 추가하는 방식은 간단하고 직관적이다.
- 별도의 테이블을 관리할 필요 없이 기존 테이블 내에서 데이터를 처리할 수 있다.
- JOIN 필요 없음
- 조회수와 아파트 정보를 함께 조회할 때 JOIN 쿼리를 사용할 필요가 없다.
- 아파트 테이블에 조회수 컬럼이 포함되어 있기 때문에, 추가적인 쿼리 작성 없이 간단하게 데이터를 조회할 수 있다.
- 단순한 설계
- 단점
- 쓰기 부하 증가
- 조회수를 갱신할 때마다 아파트 테이블 자체에 변화가 생기기 때문에, 갱신 작업이 일어날 때마다 아파트 테이블에 락(lock)이 발생할 수 있다.
- 이로 인해 다른 쿼리들이 대기하거나 성능 저하가 발생할 가능성이 있다
- 인덱스 성능 저하
- 아파트 테이블이 대규모 데이터를 다룰 경우, 조회수를 자주 갱신하면서 인덱스가 계속해서 업데이트된다.
- 이렇게 되면 인덱스 유지 비용이 증가하고, 쿼리 성능에 영향을 미칠 수 있다.
- 확장성 부족
- 시간이 지남에 따라 조회수와 관련된 요구사항이 복잡해질 수 있다.
- 예를 들어, 시간대별 조회수나 사용자별 조회수 통계를 추가하고 싶을 경우, 기존 테이블 구조에서 이를 처리하기가 어려워질 수 있다.
- 쓰기 부하 증가
조회수 테이블을 별도로 생성
- 장점
- 쓰기 부하 분산
- 조회수 갱신 작업이 아파트 테이블에 영향을 미치지 않으므로, 아파트 테이블에 대한 락(lock) 문제나 부하를 최소화할 수 있습니다. 별도의 테이블에서 조회수를 관리하면, 데이터베이스의 성능 저하를 방지할 수 있습니다.
- 확장성
- 별도의 조회수 테이블을 사용하면 시간대별 조회수, 사용자별 조회수 등 다양한 통계 데이터를 쉽게 추가하고 관리할 수 있습니다. 요구사항이 변화하거나 추가될 경우, 구조를 유연하게 확장할 수 있는 장점이 있습니다.
- 빠른 데이터 삭제
- 특정 기간의 조회수나 오래된 데이터를 삭제할 때, 별도의 조회수 테이블을 사용하는 방식이 훨씬 효율적입니다. 이를 통해 데이터를 간편하게 관리할 수 있습니다.
- 쓰기 부하 분산
- 단점
- JOIN 필요
- 조회수와 아파트 정보를 함께 조회하려면 별도의 JOIN 쿼리가 필요해진다.
- 이는 쿼리 성능에 어느정도 부담을 줄 수 있으며, 데이터가 많을 경우 성능 저하가 발생할 가능성이 있다.
- JOIN 필요
⇒ Redis(캐싱)를 사용할거면 큰 의미가 없다
Redis를 활용해 일정 시간마다 조회수를 업데이트하는 방식은 조회수의 업데이트 빈도를 줄여주기 때문에 테이블을 분리할 필요성이 낮아졌다.
그러나 시간대별 조회수나 사용자별 조회수와 같은 확장성 있는 요구사항을 고려했을 때, 조회수 테이블을 별도로 분리하기로 헀다.
CREATE TABLE real_time_views (
real_time_views_id INT AUTO_INCREMENT PRIMARY KEY, -- 고유 ID
apt_seq varchar(20) NOT NULL, -- 아파트 고유 ID
date varchar(10) NOT NULL, -- 날짜
hour varchar(10) NOT NULL, -- 시간 (00-01,01-02)
view_count INT DEFAULT 0
)
CREATE TABLE daily_views (
daily_views_id INT AUTO_INCREMENT PRIMARY KEY, -- 고유 ID
apt_seq varchar(20) NOT NULL, -- 아파트 고유 ID
date varchar(10) NOT NULL, -- 날짜 (하루 기준)
total_views INT DEFAULT 0, -- 해당 날짜의 총 조회수
apt_seq -> fk
);
조회수가 일정 수치 이상 상승한 경우 DB에 저장하는 방식
- 장점:
- 일간 조회수를 실시간으로 확인할 수 있다.
- 자정에 별도의 배치 작업이나 스케줄러가 필요하지 않음.
- Redis에서 일간 조회수를 캐싱하지 않아도 되어 Redis 메모리 사용량을 줄일 수 있음.
- 단점:
- 실시간 조회수 업데이트가 빈번한 경우, DB에 반복적으로 쓰기 작업이 발생하여 DB 부하를 증가시킬 수 있음.
- 트랜잭션 관리가 필요하므로 DB 락이 자주 발생할 수 있음.
- 적합한 사용 사례:
- 일간 조회수가 실시간으로 확인되어야 하는 경우
- 조회수 업데이트 빈도가 낮거나 DB 성능에 여유가 있는 경우
하루에 한번 레디스에 저장된 조회수를 DB에 업데이트 하는 방식
- 장점:
- DB에 쓰기 작업이 하루에 한 번으로 제한되므로 DB 부하를 최소화할 수 있음.
- Redis 메모리에 데이터를 캐싱하여 빠르게 조회 가능.
- 여러 쓰기 작업이 동시에 발생하는 분산 환경에서도 안정적이며 효율적으로 처리 가능.
- 단점:
- 자정 배치 작업이 실패하거나 Redis 캐시 데이터가 유실되면 해당 조회수를 복구하기 어렵다.
- Redis 메모리에 일간 조회수를 저장해야 하므로, Redis 메모리를 추가로 소모하게 된다.
- 적합한 사용 사례:
- DB 쓰기 작업을 최소화해야 하는 경우.
- 일간 조회수가 실시간으로 반영될 필요가 없고, 자정에 한 번만 정확한 값을 업데이트해도 되는 경우.
⇒ 일일 조회수를 실시간으로 확인할 필요가 없기 때문에 2번을 선택했다.
일일 조회수를 레디스에 저장한 뒤 매일 자정에 해당 정보를 DB에 저장한다.
이때 발생할 수 있는 문제점, 고려사항은 아래와 같다.
1. 서버 비정상 종료 시 데이터 손실 (24시간이라는 비교적 긴 시간)
RDB 또는 AOF ⇒ 조회수는 어느정도의 손실은 감안할 수 있기 때문에 데이터의 손실 가능성은 있지만 성능이 더 좋은 RDB 방식을 선택
2. 일간 조회수를 어떻게 저장할 것인지
1. 실시간 조회수와 일간 조회수를 동시에 증가
2. 실시간 조회수 증가 후, 1시간 이후 일간 조회수에 누적 (실시간 조회수는 1시간 단위로 저장)
일간 조회수가 실시간으로 필요하지 않으며 성능 최적화를 위해 ⇒ 2번 선택
Redis 자료구조 선택
상위 X개 아파트를 조회해야 하므로 Sorted Set을 선택했다.
이유는 조회수를 자동으로 정렬하며 상위 X개 아파트를 빠르게 조회할 수 있기 때문이다.
잘 저장이 된다.
코드
@Service
@RequiredArgsConstructor
@Slf4j
public class ViewCountService {
private final RedisViewCountRepository redisViewCountRepository;
private final ViewCountRepository viewCountRepository;
public void updateViewCount(String aptSeq) {
redisViewCountRepository.updateHourlyViewCount(aptSeq, getCurrentTimeSlot());
}
public List<String> getTopHourViews(String date, int hour, int count) {
// "apartment:views:hour:2024-11-25:00-01"
String key = String.format("apartment:views:hour:%s:%02d-%02d", date, hour, hour + 1);
return redisViewCountRepository.getTopNApartments(key, count);
}
public List<String> getTopDailyViews(String date, int count) {
// "apartment:views:daily:2024-11-25"
String key = "apartment:views:daily:" + date;
log.info("key: {}", key);
return redisViewCountRepository.getTopNApartments(key, count);
}
/**
* 1시간마다 실행: 1시간 단위 조회수를 일간 조회수에 합산
*/
@Scheduled(cron = "0 59 * * * *") // 59분 마다 업데이트
public void processHourlyToDailyViewCounts() {
log.info("[ 1시간 조회수 ==> 일간 조회수에 합산 시작 ]");
String timeSlot = getCurrentTimeSlot(); // ex) "2024-11-25:00-01"
String hourlyKey = "apartment:views:hour:" + timeSlot;
log.info("hourlyKey: {}", hourlyKey);
String dailyKey = "apartment:views:daily:" + timeSlot.split(":")[0];
log.info("dailyKey: {}", dailyKey);
// 1시간 단위 조회수를 일간 조회수에 합산
redisViewCountRepository.mergeHourlyToDaily(hourlyKey, dailyKey);
log.info("==== 일간 조회수에 합산 완료 ====");
// 1시간 단위 조회수 DB에 저장
log.info("[ 1시간 조회수를 DB Insert 시작 ]");
// Redis에서 value와 score 가져오기
Map<String, Double> viewCounts = redisViewCountRepository.getZSetValueAndScores(hourlyKey);
// 데이터베이스에 저장
viewCounts.forEach((aptSeq, views) -> {
ViewsDto viewsDto = ViewsDto.builder()
.aptSeq(aptSeq)
.viewCount(views.longValue())
.date(timeSlot.split(":")[0])
.hour(timeSlot.split(":")[1])
.build();
viewCountRepository.saveViewCount(viewsDto);
});
log.info("==== 1시간 조회수를 DB Insert 완료 ====");
// Redis에서 1시간 단위 조회수 삭제
// redisViewCountRepository.deleteKey(hourlyKey);
// log.info("Redis 1시간 조회수 삭제");
}
/**
* 매일 00시마다 실행: 1일 단위 조회수를 DB에 삽입
*/
@Scheduled(cron = "0 0 0 * * *") // 매일 자정에 실행
public void processDailyViewCounts() {
log.info("[ 일간 조회수 ==> DB에 삽입 시작 ]");
String timeSlot = getCurrentTimeSlot(); // ex) "2024-11-25:00-01"
String dailyKey = "apartment:views:daily:" + timeSlot.split(":")[0]; // ex) "2024-11-25"
// Redis에서 value와 score 가져오기
Map<String, Double> viewCounts = redisViewCountRepository.getZSetValueAndScores(dailyKey);
// 일간 조회수를 DB에 삽입
viewCounts.forEach((aptSeq, views) -> {
ViewsDto viewsDto = ViewsDto.builder()
.aptSeq(aptSeq)
.viewCount(views.longValue())
.date(timeSlot.split(":")[0])
.build();
viewCountRepository.saveDailyViews(viewsDto);
});
log.info("==== 일간 조회수를 DB Insert 완료 ====");
}
/**
* 현재 시간대를 가져옴
* @return 현재 시간대 (예: "2024-11-25:00-01")
*/
private String getCurrentTimeSlot() {
LocalDateTime now = LocalDateTime.now();
int hour = now.getHour();
String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
return String.format("%s:%02d-%02d", date, hour, hour + 1);
}
}
@Repository
@RequiredArgsConstructor
@Slf4j
public class RedisViewCountRepository {
private final RedisTemplate<String, String> redisTemplate;
/**
* 1시간 단위 조회수 업데이트
* @param aptSeq 아파트 ID
* @param timeSlot 시간대 (예: "00-01")
*/
public void updateHourlyViewCount(String aptSeq, String timeSlot) {
// 현재 날짜를 "yyyy-MM-dd" 형식으로 가져오기
String currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
String key = "apartment:views:hour:" + timeSlot;
log.info("Time: {} -> {} 아파트 조회수 증가", key, aptSeq);
// 조회수 1 증가 또는 새로 추가
redisTemplate.opsForZSet().incrementScore(key, aptSeq, 1);
}
/**
* 1시간 단위 조회수를 일간 조회수에 합산
* @param hourlyKey 1시간 단위 조회수 Sorted Set의 키 (예: apartment:views:2024-11-25:00-01)
* @param dailyKey 일간 조회수 Sorted Set의 키 (예: apartment:views:daily:2024-11-25)
*/
public void mergeHourlyToDaily(String hourlyKey, String dailyKey) {
// 1시간 단위 조회수 데이터 가져오기
Set<String> aptSeqs = redisTemplate.opsForZSet().range(hourlyKey, 0, -1);
if (aptSeqs != null) {
for (String aptSeq : aptSeqs) {
// 아파트 seq의 조회수를 가져옴
Double score = redisTemplate.opsForZSet().score(hourlyKey, aptSeq);
if (score != null) {
// 일간 조회수 Sorted Set에 누적
redisTemplate.opsForZSet().incrementScore(dailyKey, aptSeq, score);
}
}
}
}
/**
* Redis에서 특정 키 삭제
* @param key 삭제할 Redis 키
*/
public void deleteKey(String key) {
redisTemplate.delete(key);
}
/**
* 상위 N개 조회수가 많은 아파트를 반환
* @param key 조회할 Redis Key (예: "apartment:views:2024-11-25:00-01" 또는 "apartment:views:daily:2024-11-25")
* @param topN 반환할 상위 N개 아파트의 개수
* @return 상위 N개 aptSeq 리스트
*/
public List<String> getTopNApartments(String key, int topN) {
// 상위 N개 조회수 아파트 (조회수가 높은 순)
Set<String> topApartments = redisTemplate.opsForZSet().reverseRange(key, 0, topN - 1);
// 아파트가 없으면 빈 리스트 반환
return topApartments == null ? new ArrayList<>() : new ArrayList<>(topApartments);
}
/**
* Redis ZSet에서 value와 score를 모두 가져옵니다.
* @param key ZSet의 Redis 키
* @return value와 score를 매핑한 Map<String, Double>
*/
public Map<String, Double> getZSetValueAndScores(String key) {
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().rangeWithScores(key, 0, -1);
Map<String, Double> result = new HashMap<>();
if (tuples != null) {
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
Object value = tuple.getValue();
Double score = tuple.getScore();
if (value != null && score != null) {
result.put(value.toString(), score);
}
}
}
return result;
}
}
'Database , Middleware > Redis' 카테고리의 다른 글
[Redis] Redis 와 RDB, AOF 대해 알아보자 (1) | 2024.10.22 |
---|
댓글