-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[BE] feat: SchoolFestivalsV1QueryService 추가 및 Spring Cache 적용 (#863) #867
base: dev
Are you sure you want to change the base?
Changes from all commits
ade683c
04c6fcf
da6f196
04ecfb5
27daaf6
84b7e6a
bc9f3fe
79fb628
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
package com.festago.common.cache; | ||
|
||
import java.util.Optional; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
@Slf4j | ||
public class CacheInvalidator { | ||
|
||
private final CacheManager cacheManager; | ||
|
||
public void invalidate(String cacheName) { | ||
Optional.ofNullable(cacheManager.getCache(cacheName)) | ||
.ifPresentOrElse(cache -> { | ||
cache.invalidate(); | ||
log.info("{} 캐시를 초기화 했습니다.", cacheName); | ||
}, () -> log.error("{} 캐시를 찾을 수 없습니다.", cacheName)); | ||
} | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package com.festago.common.cache; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.cache.Cache; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.cache.caffeine.CaffeineCache; | ||
import org.springframework.context.annotation.Profile; | ||
import org.springframework.context.event.ContextClosedEvent; | ||
import org.springframework.context.event.EventListener; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Profile({"!test"}) | ||
@Slf4j | ||
@Component | ||
@RequiredArgsConstructor | ||
public class CacheStatsLogger { | ||
|
||
private final CacheManager cacheManager; | ||
|
||
@EventListener(ContextClosedEvent.class) | ||
public void logCacheStats() { | ||
for (String cacheName : cacheManager.getCacheNames()) { | ||
Cache cache = cacheManager.getCache(cacheName); | ||
if (cache instanceof CaffeineCache caffeineCache) { | ||
log.info("CacheName={} CacheStats={}", cacheName, caffeineCache.getNativeCache().stats()); | ||
} | ||
} | ||
} | ||
} | ||
Comment on lines
+13
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어플리케이션 종료 시점에 캐시 분석을 위해 로그를 남기도록 했습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 찾아보니 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
package com.festago.config; | ||
|
||
import java.util.List; | ||
import org.springframework.cache.Cache; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.cache.annotation.EnableCaching; | ||
import org.springframework.cache.support.SimpleCacheManager; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
@EnableCaching | ||
public class CacheConfig { | ||
|
||
@Bean | ||
public CacheManager cacheManager(List<Cache> caches) { | ||
SimpleCacheManager cacheManager = new SimpleCacheManager(); | ||
cacheManager.setCaches(caches); | ||
return cacheManager; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package com.festago.school.application.v1; | ||
|
||
import com.festago.common.cache.CacheInvalidator; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.scheduling.annotation.Scheduled; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class SchoolFestivalsV1CacheInvalidateScheduler { | ||
|
||
private final CacheInvalidator cacheInvalidator; | ||
|
||
// 매일 정각마다 캐시 초기화 | ||
@Scheduled(cron = "0 0 0 * * *") | ||
public void invalidate() { | ||
cacheInvalidator.invalidate(SchoolFestivalsV1QueryService.SCHOOL_FESTIVALS_V1_CACHE_NAME); | ||
cacheInvalidator.invalidate(SchoolFestivalsV1QueryService.PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package com.festago.school.application.v1; | ||
|
||
import com.festago.school.dto.v1.SchoolFestivalV1Response; | ||
import com.festago.school.repository.v1.SchoolFestivalsV1QueryDslRepository; | ||
import java.time.Clock; | ||
import java.time.LocalDate; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.cache.annotation.Cacheable; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
@Transactional(readOnly = true) | ||
public class SchoolFestivalsV1QueryService { | ||
|
||
public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1"; | ||
public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1"; | ||
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전 이걸 어디 넣나 큰 차이가 없을거라 생각하긴 하는데, 별로 마음에 안드시다면 cache name을 관리해주는 const 클래스 만드는 것도 괜찮을 것 같네요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인터넷에 있는 레퍼런스 보면, ENUM을 만들어서 아예 거기다 만료 시간, 최대 사이즈를 정의한 뒤 CacheManager에 바로 설정하더라구요. CacheManager cacheManager = new SimpleCacheManager();
List<Cache> caches = Arrays.stream(CacheEnums)
.map(cacheStat -> new CaffeineCache(...))
.toList();
cacheManager.setCache(caches); 근데 그렇게 하려고 하니, 하나의 파일에 캐시가 관리되니 충돌이 잦을 것 같더라구요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Caching을 한 곳으로 모은다면 Caching 된 자료를 응용하여 로직을 짤 수 있냐를 판단하기 수월할 것 같은데 Conflict 를 감안하여서 한 곳에 모으는 것은 어떨까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 변경에 취약한 구조가 되지 않을까요? enum Cache {
SCHOOL_FESTIVALS_V1_CACHE(30, 1000),
PAST_SCHOOL_FESTIVALS_V1_CACHE(30, 1000),
;
private final int validateTime;
private final int maxSize;
} 또한 이렇게 해버리면 특정 캐시에 설정을 커스텀하게 할 수 없어요. CacheManager cacheManager = new SimpleCacheManager();
List<Cache> caches = Arrays.stream(CacheEnums)
.map(cache -> Caffeine.newBuilder()
.recordStats() // 특정 캐시에 해당 설정이 필요 없다면? 혹은 다른 빌더 메서드가 필요하다면?
.expireAfterWrite(cache.validateTime, TimeUnit.MINUTES)
.maximumSize(cache.maxSize)
.build())
.toList();
cacheManager.setCache(caches); 또한 Caffeine 캐시 구현체가 워낙 잘 만들어서 대체할 가능성이 무척 낮지만.. |
||
|
||
private final SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository; | ||
private final Clock clock; | ||
Comment on lines
+16
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 오호 학교 상세조회 페이지에 캐싱을 적용시키셨요 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 맞아요 안그래도 메인 페이지 캐싱을 적용해보려고 하는데.. 메인 페이지에 페이징이 들어가 있어서 약간 애매하네요. 😂 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 화면상 메인 페이지를 가장 많이 접할 거라 생각이 들지만 메인 페이지가 가장 데이터의 변화가 많은 곳이라 캐싱 무효화를 가장 많이할 것으로 예상되는데... |
||
|
||
@Cacheable(cacheNames = SCHOOL_FESTIVALS_V1_CACHE_NAME, key = "#schoolId") | ||
public List<SchoolFestivalV1Response> findFestivalsBySchoolId(Long schoolId) { | ||
LocalDate now = LocalDate.now(clock); | ||
return schoolFestivalsV1QueryDslRepository.findFestivalsBySchoolId(schoolId, now); | ||
} | ||
|
||
@Cacheable(cacheNames = PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME, key = "#schoolId") | ||
public List<SchoolFestivalV1Response> findPastFestivalsBySchoolId(Long schoolId) { | ||
LocalDate now = LocalDate.now(clock); | ||
return schoolFestivalsV1QueryDslRepository.findPastFestivalsBySchoolId(schoolId, now); | ||
} | ||
} | ||
Comment on lines
+13
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package com.festago.school.infrastructure; | ||
|
||
import com.festago.school.application.v1.SchoolFestivalsV1QueryService; | ||
import com.github.benmanes.caffeine.cache.Caffeine; | ||
import java.util.concurrent.TimeUnit; | ||
import org.springframework.cache.Cache; | ||
import org.springframework.cache.caffeine.CaffeineCache; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
public class SchoolFestivalsV1CacheConfig { | ||
|
||
private static final long EXPIRED_AFTER_WRITE = 30; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 30분 만료시간은 어떤 이유 / 기준일 까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 별 이유는 없습니다... 그냥 30분이 적절한 것 같더라구요. 😂 |
||
private static final long MAXIMUM_SIZE = 1_000; | ||
|
||
@Bean | ||
public Cache schoolFestivalsV1Cache() { | ||
return new CaffeineCache(SchoolFestivalsV1QueryService.SCHOOL_FESTIVALS_V1_CACHE_NAME, | ||
Caffeine.newBuilder() | ||
.recordStats() | ||
.expireAfterWrite(EXPIRED_AFTER_WRITE, TimeUnit.MINUTES) | ||
.maximumSize(MAXIMUM_SIZE) | ||
.build() | ||
Comment on lines
+20
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. expireAfterWrite 을 명시해주었다면
매일 갱신해주지 않아도 되지 않을까요?? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 하루가 지나는 정각에 캐시를 비워주지 않으면 사용자가 잘못된 축제를 조회할 일이 생깁니다..! |
||
); | ||
} | ||
Comment on lines
+14
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
@Bean | ||
public Cache pastSchoolFestivalsV1Cache() { | ||
return new CaffeineCache(SchoolFestivalsV1QueryService.PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME, | ||
Caffeine.newBuilder() | ||
.recordStats() | ||
.expireAfterWrite(EXPIRED_AFTER_WRITE, TimeUnit.MINUTES) | ||
.maximumSize(MAXIMUM_SIZE) | ||
.build() | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package com.festago.school.repository.v1; | ||
|
||
import static com.festago.festival.domain.QFestival.festival; | ||
import static com.festago.festival.domain.QFestivalQueryInfo.festivalQueryInfo; | ||
|
||
import com.festago.common.querydsl.QueryDslHelper; | ||
import com.festago.school.dto.v1.QSchoolFestivalV1Response; | ||
import com.festago.school.dto.v1.SchoolFestivalV1Response; | ||
import java.time.LocalDate; | ||
import java.util.Comparator; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Repository; | ||
|
||
@Repository | ||
@RequiredArgsConstructor | ||
public class SchoolFestivalsV1QueryDslRepository { | ||
|
||
private final QueryDslHelper queryDslHelper; | ||
|
||
public List<SchoolFestivalV1Response> findFestivalsBySchoolId( | ||
Long schoolId, | ||
LocalDate today | ||
) { | ||
return queryDslHelper.select( | ||
new QSchoolFestivalV1Response( | ||
festival.id, | ||
festival.name, | ||
festival.festivalDuration.startDate, | ||
festival.festivalDuration.endDate, | ||
festival.posterImageUrl, | ||
festivalQueryInfo.artistInfo | ||
) | ||
) | ||
.from(festival) | ||
.leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) | ||
.where(festival.school.id.eq(schoolId).and(festival.festivalDuration.endDate.goe(today))) | ||
.stream() | ||
.sorted(Comparator.comparing(SchoolFestivalV1Response::startDate)) | ||
.toList(); | ||
} | ||
|
||
public List<SchoolFestivalV1Response> findPastFestivalsBySchoolId( | ||
Long schoolId, | ||
LocalDate today | ||
) { | ||
return queryDslHelper.select( | ||
new QSchoolFestivalV1Response( | ||
festival.id, | ||
festival.name, | ||
festival.festivalDuration.startDate, | ||
festival.festivalDuration.endDate, | ||
festival.posterImageUrl, | ||
festivalQueryInfo.artistInfo | ||
) | ||
) | ||
.from(festival) | ||
.leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id)) | ||
.where(festival.school.id.eq(schoolId).and(festival.festivalDuration.endDate.lt(today))) | ||
.stream() | ||
.sorted(Comparator.comparing(SchoolFestivalV1Response::endDate).reversed()) | ||
.toList(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
캐시 초기화를 담당하는 컴포넌트 입니다.
축제 캐싱이 30분 동안 유지되므로, 그 사이 새로운 축제가 추가되면 사용자가 축제 정보를 확인할 수 없는 문제가 생깁니다.
축제는 관리자가 추가하기에 큰 문제는 아니지만, 가끔 즉시 초기화가 필요한 시점이 있을 수 있기에 별도의 컴포넌트로 분리했습니다.
만약, 수동으로 초기화가 필요하다면 관리자 API를 열어서 직접 초기화하면 될 것 같습니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
축제 등록 시점에 Event를 발행해서 해당 축제에 대한 캐시를 삭제하는 방법은 어떻게 생각하시나유?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
약간 이런 느낌이려나요?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
넹 !!!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
생각해보니깐 저희 서버 인스턴스 현재 한대만 돌리고있죠?! (with 블루그린)
담에 서버 여러대 동시에 떠있는 상황이 되면 로컬 이벤트로는 캐시 정합성 문제가 나올순잇겟네여 (A인스턴스에서 업데이트쳤는데, B인스턴스에는 해당 내용 반영안됨)
그땐 redis pub/sub이나 그런걸루 대채하면 좋을것같아요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ㅋㅋㅋ 로컬 캐시라서 어쩔 수 없죠..
그때가 되면 캐싱도 레디스를 사용하지 않을까 싶네요