Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package com.deare.backend.api.letter.cache;

public record RandomLetterCacheValue(
long userId,
boolean hasLetter,
String fullDate,
Long letterId,
String randomPhrase,
boolean isPinned
String randomPhrase
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -38,61 +38,40 @@ public RandomLetterResponseDTO getTodayRandomLetter(long userId) {
// userId + 날짜 키
String key = cacheKey(userId, today);

// 1) Redis HIT: 이미 오늘의 랜덤 결과가 있으면 그대로 반환
// 1) Redis HIT: 오늘 이미 랜덤 결과가 있으면 그걸 사용
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
RandomLetterCacheValue v = fromJson(cached);
Comment on lines 137 to 138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raw 파일을 열어서 fromJson이라는 코드를 확인했는데, 지금 파싱이 실패하면 GeneralException(INTERNAL_ERROR)를 throw 하고 있더라구요! 그런데 여기서는 깨진 캐시면 삭제 후 재생성을 목표로 하고 있어서 오류가 날 것 같아요! (파싱이 깨지면 v == null 경로로 가지 않고 그냥 500 에러 반환)

fromJson 코드를 에러로 throw 하지 말고 null을 반환하게 하는 건 어떨까요!?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 맞네요!! 수정하겠습니다


// 캐시 불일치(삭제된 letterId)면 캐시 삭제 후 재생성
if (v.hasLetter() && v.letterId() != null && !letterRepository.existsById(v.letterId())) {
// 캐시가 깨진 경우 -> 캐시 삭제 후 재생성
if (v == null || v.letterId() == null) {
redisTemplate.delete(key);
return createAndCache(userId, today, key);
}

RandomLetterCacheValue recreated = createValue(userId, today);
Duration ttl = ttlUntilNextMidnight();
redisTemplate.opsForValue().set(key, toJson(recreated), ttl);
Optional<Boolean> pinnedOpt = letterRepository.findIsPinnedByUserIdAndLetterId(userId, v.letterId());

return toResponseDTO(recreated, today);
if (pinnedOpt.isEmpty()) {
// 삭제/숨김/권한불일치 등 -> 캐시 삭제 후 재생성
redisTemplate.delete(key);
return createAndCache(userId, today, key);
}

return toResponseDTO(v, today);
// pinned는 최신값
boolean pinnedNow = pinnedOpt.orElse(false);
return toResponseDTO(true, today, v.letterId(), v.randomPhrase(), pinnedNow);
}

// 2) Redis MISS: 오늘의 랜덤 편지 새로 생성
RandomLetterCacheValue created = createValue(userId, today);

// TTL은 다음 자정까지로 설정 → 자정 지나면 자동 만료
Duration ttl = ttlUntilNextMidnight();
String json = toJson(created);

// 동시성 처리
Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, json, ttl);

// 다른 요청이 먼저 캐시를 생성한 경우, 해당 값 반환
if (Boolean.FALSE.equals(ok)) {
String latest = redisTemplate.opsForValue().get(key);
if (latest != null) {
return toResponseDTO(fromJson(latest), today);
}
}

// setIfAbsent 성공 또는 재시도
return toResponseDTO(created, today);
return createAndCache(userId, today, key);
}

private RandomLetterCacheValue createValue(long userId, LocalDate today) {

private RandomLetterResponseDTO createAndCache(long userId, LocalDate today, String key) {
long count = letterRepository.countVisibleLettersByUser(userId);

// 편지가 없으면 hasLetter=false
// 편지가 없으면 캐시 저장 없이 바로 응답
if (count == 0) {
return new RandomLetterCacheValue(
userId,
false,
today.toString(),
null,
null,
false
);
return toResponseDTO(false, today, null, null, false);
}

// 0 ~ count-1 사이 랜덤 offset
Expand All @@ -105,15 +84,30 @@ private RandomLetterCacheValue createValue(long userId, LocalDate today) {

// 편지 본문에서 랜덤 문구 추출
String phrase = extractRandomPhrase(letter.getContent());
RandomLetterCacheValue created = new RandomLetterCacheValue(letter.getId(), phrase);

return new RandomLetterCacheValue(
userId,
true,
today.toString(),
letter.getId(),
phrase,
letter.isPinned()
);
Duration ttl = ttlUntilNextMidnight();
String json = toJson(created);

// 동시성 처리: 먼저 저장한 요청이 있으면 그 값을 사용
Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, json, ttl);

RandomLetterCacheValue finalValue = created;
if (Boolean.FALSE.equals(ok)) {
String latest = redisTemplate.opsForValue().get(key);
if (latest != null) {
RandomLetterCacheValue parsed = fromJson(latest);
if (parsed != null && parsed.letterId() != null) {
finalValue = parsed;
}
}
}

// pinned는 항상 최신 조회
boolean pinnedNow = letterRepository.findIsPinnedByUserIdAndLetterId(userId, finalValue.letterId())
.orElse(false);

return toResponseDTO(true, today, finalValue.letterId(), finalValue.randomPhrase(), pinnedNow);
}

private String extractRandomPhrase(String content) {
Expand Down Expand Up @@ -148,8 +142,13 @@ private String ellipsis(String s, int maxChars) {
return s.substring(0, Math.max(0, maxChars - 1)) + "…";
}

private RandomLetterResponseDTO toResponseDTO(RandomLetterCacheValue v, LocalDate today) {
// 화면 표시용 month(Jan, Feb...) - Locale.ENGLISH로 고정
private RandomLetterResponseDTO toResponseDTO(
boolean hasLetter,
LocalDate today,
Long letterId,
String randomPhrase,
boolean isPinned
) {
String month = today.getMonth().getDisplayName(TextStyle.SHORT, Locale.ENGLISH);

// 요일 한글 표시
Expand All @@ -164,16 +163,16 @@ private RandomLetterResponseDTO toResponseDTO(RandomLetterCacheValue v, LocalDat
};

return new RandomLetterResponseDTO(
v.hasLetter(),
hasLetter,
new RandomLetterResponseDTO.DateDTO(
today.toString(),
month,
today.getDayOfMonth(),
dowKo
),
v.letterId(),
v.randomPhrase(),
v.isPinned()
letterId,
randomPhrase,
isPinned
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,18 @@ int softDeleteAllByUserIdAndFromId(@Param("userId") Long userId,


List<Letter> findAllByUser_IdAndFrom_IdAndIsDeletedFalse(Long userId, Long fromId);

@Query("""
select l.isPinned
from Letter l
where l.id = :letterId
and l.user.id = :userId
and l.isDeleted = false
and l.isHidden = false
""")
Optional<Boolean> findIsPinnedByUserIdAndLetterId(
@Param("userId") Long userId,
@Param("letterId") Long letterId
);

}