Skip to content

Commit

Permalink
#123 - 레디스를 통한 조회수 및 인기 아카이브 관리 구현 (#124)
Browse files Browse the repository at this point in the history
* feat: 레디스를 통한 조회수 및 인기 아카이브 관리 구현

* fix: 조회수 카운팅 로직 수정

* fix: 조회수 반영 주기 수정

* fix: 아카이브 캐싱 방식 수정

* fix: 필요한 어노테이션 추가

* refactor: 코드 정리

* refactor: 임시 구현

* feat: 인기 아카이브에 실시간성 부여

* fix: 이게 맞나 싶은 수준의 구현...

* feat: 임시 구현

* feat: 아카이브 레디스 저장 오류 수정

* fix: 테스트 수정
  • Loading branch information
AnTaeho authored Jan 7, 2025
1 parent 30b6acb commit f47544b
Show file tree
Hide file tree
Showing 16 changed files with 507 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.palettee.archive.controller.dto.response;

import java.util.List;

public record ArchiveRedisList(
List<ArchiveRedisResponse> archives
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.palettee.archive.controller.dto.response;

import com.palettee.archive.domain.Archive;
import java.io.Serializable;

public record ArchiveRedisResponse(
Long archiveId,
String title,
String description,
String introduction,
String username,
String type,
boolean canComment,
long likeCount,
boolean isLiked,
String imageUrl,
String createDate
) implements Serializable {

public static ArchiveRedisResponse toResponse(Archive archive) {
String imageUrl = archive.getArchiveImages().isEmpty() ? "" : archive.getArchiveImages().get(0).getImageUrl();

return new ArchiveRedisResponse(
archive.getId(),
archive.getTitle(),
archive.getDescription(),
archive.getIntroduction(),
archive.getUser().getName(),
archive.getType().name(),
archive.isCanComment(),
archive.getLikeCount(),
false,
imageUrl,
archive.getCreateAt().toLocalDate().toString()
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.palettee.archive.domain.Archive;
import com.palettee.likes.repository.LikeRepository;
import java.io.Serializable;

public record ArchiveSimpleResponse(
Long archiveId,
Expand All @@ -15,10 +16,9 @@ public record ArchiveSimpleResponse(
boolean isLiked,
String imageUrl,
String createDate
) {
) implements Serializable {

public static ArchiveSimpleResponse toResponse(Archive archive, Long userId, LikeRepository likeRepository) {

String imageUrl = archive.getArchiveImages().isEmpty() ? "" : archive.getArchiveImages().get(0).getImageUrl();

return new ArchiveSimpleResponse(
Expand All @@ -29,11 +29,26 @@ public static ArchiveSimpleResponse toResponse(Archive archive, Long userId, Lik
archive.getUser().getName(),
archive.getType().name(),
archive.isCanComment(),
likeRepository.countArchiveLike(archive.getId()),
archive.getLikeCount(),
likeRepository.existByUserAndArchive(archive.getId(), userId).isPresent(),
imageUrl,
archive.getCreateAt().toLocalDate().toString()
);
}

public static ArchiveSimpleResponse changeToSimpleResponse(ArchiveRedisResponse it, Long userId, LikeRepository likeRepository) {
return new ArchiveSimpleResponse(
it.archiveId(),
it.title(),
it.description(),
it.introduction(),
it.username(),
it.type(),
it.canComment(),
it.likeCount(),
likeRepository.existByUserAndArchive(it.archiveId(), userId).isPresent(),
it.imageUrl(),
it.createDate()
);
}
}
10 changes: 8 additions & 2 deletions src/main/java/com/palettee/archive/domain/Archive.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class Archive extends BaseEntity {

private int hits;
private int archiveOrder;
private int likeCount;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
Expand All @@ -55,6 +56,7 @@ public Archive(String title, String description, String introduction,
this.canComment = canComment;
this.hits = 0;
this.archiveOrder = 0;
this.likeCount = 0;

this.user = user;
this.user.addArchive(this);
Expand All @@ -81,8 +83,8 @@ public Archive update(ArchiveUpdateRequest req) {
return this;
}

public void hit() {
this.hits++;
public void like() {
this.likeCount++;
}

public void setOrder() {
Expand All @@ -96,4 +98,8 @@ public void updateOrder(Integer order) {
public boolean isNotOpenComment() {
return !this.canComment;
}

public void setHit(long totalHits) {
this.hits = (int) totalHits;
}
}
7 changes: 7 additions & 0 deletions src/main/java/com/palettee/archive/event/HitEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.palettee.archive.event;

public record HitEvent(
Long archiveId,
String email
) {
}
36 changes: 36 additions & 0 deletions src/main/java/com/palettee/archive/event/HitEventListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.palettee.archive.event;

import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class HitEventListener {

private static final String SET_KEY_PREFIX = "hit:archiveId:";
private static final String VALUE_KEY_PREFIX = "incr:hit:archiveId:";

private final RedisTemplate<String, String> redisTemplate;

@Async
@EventListener(value = HitEvent.class)
public void onHit(HitEvent event) {

String setKey = SET_KEY_PREFIX + event.archiveId();
Long addResult = redisTemplate.opsForSet().add(setKey, event.email());

if (addResult == null || addResult != 1L) {
return;
}

redisTemplate.expire(setKey, Duration.ofDays(1));

String valueKey = VALUE_KEY_PREFIX + event.archiveId();
redisTemplate.opsForValue().increment(valueKey);
}

}
7 changes: 7 additions & 0 deletions src/main/java/com/palettee/archive/event/LikeEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.palettee.archive.event;

public record LikeEvent(
Long archiveId,
Long userId
) {
}
23 changes: 23 additions & 0 deletions src/main/java/com/palettee/archive/event/LikeEventListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.palettee.archive.event;

import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class LikeEventListener {

private static final String LIKE_KEY = "like:archiveId:";

private final RedisTemplate<String, String> redisTemplate;

@Async
@EventListener(value = LikeEvent.class)
public void onLike(LikeEvent event) {
redisTemplate.opsForValue().increment(LIKE_KEY + event.archiveId());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.palettee.archive.repository;

import com.palettee.archive.controller.dto.response.ArchiveRedisList;
import com.palettee.archive.controller.dto.response.ArchiveRedisResponse;
import com.palettee.archive.domain.Archive;
import io.jsonwebtoken.io.SerializationException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

@Repository
@Slf4j
@RequiredArgsConstructor
public class ArchiveRedisRepository {

private static final String INCR_PATTERN = "incr:hit:archiveId:*";
private static final String STD_PATTERN = "std:hit:archiveId:";
private static final String TOP_ARCHIVE = "top_archives";
private static final String DELIMITER = ":";

private final RedisTemplate<String, String> redisTemplate;
private final RedisTemplate<String, Object> redisTemplateForArchive;

private final ArchiveRepository archiveRepository;

@Transactional
public void settleHits() {
Set<String> incrKeys = redisTemplate.keys(INCR_PATTERN);
if (incrKeys == null || incrKeys.isEmpty()) {
return;
}

for (String incrKey : incrKeys) {
String archiveId = extractId(incrKey);

String incrHits = redisTemplate.opsForValue().get(incrKey);
long incrCount = incrHits == null ? 0 : Long.parseLong(incrHits);

String stdKey = STD_PATTERN + archiveId;
String stdHits = redisTemplate.opsForValue().get(stdKey);
long stdCount = stdHits == null ? 0 : Long.parseLong(stdHits);

long totalHits = stdCount + incrCount;

updateHitCount(Long.parseLong(archiveId), totalHits);

redisTemplate.opsForValue().set(stdKey, String.valueOf(totalHits));
redisTemplate.delete(incrKey);
}
}

private void updateHitCount(Long archiveId, long totalHits) {
Archive archive = archiveRepository.findById(archiveId).orElseThrow();
archive.setHit(totalHits);
}

private String extractId(String incrKey) {
return incrKey.split(DELIMITER)[3];
}

@Transactional(readOnly = true)
public void updateArchiveList() {
List<Long> top4IncrKeys = getTop4IncrKeys();
List<Archive> result = archiveRepository.findArchivesInIds(top4IncrKeys);

int redisSize = result.size();
if (redisSize < 4) {
int remaining = 4 - redisSize;
List<Archive> additionalFromDB = archiveRepository.findTopArchives(remaining);
result.addAll(additionalFromDB);
}

List<ArchiveRedisResponse> redis = result.stream()
.map(ArchiveRedisResponse::toResponse)
.toList();
log.info("Redis에 저장될 데이터: {}", redis);
redisTemplateForArchive.opsForValue().set(TOP_ARCHIVE, new ArchiveRedisList(redis), 1, TimeUnit.HOURS);
}

private List<Long> getTop4IncrKeys() {
String hitPattern = "incr:hit:archiveId:*";
String likePattern = "like:archiveId:*";

// Redis에서 키 가져오기
Set<String> hitKeys = redisTemplate.keys(hitPattern);
Set<String> likeKeys = redisTemplate.keys(likePattern);

if (hitKeys == null || likeKeys == null) {
return Collections.emptyList();
}

// 점수 계산
Map<Long, Integer> archiveScores = new HashMap<>();
for (String hitKey : hitKeys) {
Long archiveId = extractArchiveId(hitKey);
Integer hitCount = getValueAsInt(hitKey);

String likeKey = "like:archiveId:" + archiveId;
Integer likeCount = getValueAsInt(likeKey);

int score = hitCount + (likeCount * 5);
archiveScores.put(archiveId, score);
}

// 정렬 및 상위 4개 추출
return archiveScores.entrySet().stream()
.sorted(Map.Entry.<Long, Integer>comparingByValue().reversed())
.limit(4)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}

private Long extractArchiveId(String key) {
return Long.valueOf(key.replace("incr:hit:archiveId:", ""));
}

private Integer getValueAsInt(String key) {
String value = redisTemplate.opsForValue().get(key);
return value != null ? Integer.parseInt(value) : 0;
}

public ArchiveRedisList getTopArchives() {
try {
ArchiveRedisList result = (ArchiveRedisList) redisTemplateForArchive.opsForValue().get(TOP_ARCHIVE);
return result == null ? new ArchiveRedisList(new ArrayList<>()) : result;
} catch (SerializationException e) {
log.error("Redis 역직렬화 실패: {}", e.getMessage());
redisTemplateForArchive.delete(TOP_ARCHIVE);
return new ArchiveRedisList(new ArrayList<>());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ public interface ArchiveRepository extends JpaRepository<Archive, Long>, Archive
@Query("select a from Archive a where a.id in :ids order by a.archiveOrder desc")
Slice<Archive> findAllInIds(@Param("ids") List<Long> ids, Pageable pageable);

@Query("select a from Archive a order by a.hits desc, a.id desc limit 5")
List<Archive> getMainArchives();

@Query("SELECT a.type AS type, COUNT(a) AS count FROM Archive a GROUP BY a.type")
List<ColorCount> countByArchiveType();

Expand All @@ -29,4 +26,14 @@ public interface ArchiveRepository extends JpaRepository<Archive, Long>, Archive

@Query("SELECT a FROM Archive a where a.user.id = :userId")
List<Archive> findAllByUserId(Long userId);

@Modifying
@Query("UPDATE Archive a SET a.hits = :hitCount WHERE a.id = :archiveId")
void updateHitCount(@Param("archiveId") Long archiveId, @Param("hitCount") Long hitCount);

@Query("select a from Archive a order by (a.hits + a.likeCount * 5) desc, a.id desc limit :limit")
List<Archive> findTopArchives(@Param("limit") int limit);

@Query("select a from Archive a where a.id in :ids")
List<Archive> findArchivesInIds(@Param("ids") List<Long> ids);
}
20 changes: 20 additions & 0 deletions src/main/java/com/palettee/archive/service/ArchiveScheduler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.palettee.archive.service;

import com.palettee.archive.repository.ArchiveRedisRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ArchiveScheduler {

private final ArchiveRedisRepository archiveRedisRepository;

@Scheduled(cron = "0 * * * * *")
public void updateMainArchive() {
archiveRedisRepository.updateArchiveList();
archiveRedisRepository.settleHits();
}

}
Loading

0 comments on commit f47544b

Please sign in to comment.