Skip to content
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

Open
wants to merge 8 commits into
base: dev
Choose a base branch
from

Conversation

seokjin8678
Copy link
Collaborator

📌 관련 이슈

✨ PR 세부 내용

이슈 내용 그대로, Spring Cache를 사용하여 학교 식별자로 축제를 조회하는 기능을 추가했습니다.

캐시는 조회한 시점에서 30분 뒤에 초기화가 되도록 했습니다.

또한 날짜가 바뀌는 0시 기준, 스케줄링을 통한 캐시 초기화를 사용하여, 캐시로 인해 기간이 지난 축제를 조회할 수 있는 문제를 해결했습니다.

그 외 구현했던 의도는 코드에 커멘트를 통해 남기겠습니다!

아직 API는 구현하지 않았는데, 응답은 SchoolFestivalV1Response을 그대로 사용하기에 기존에 사용하던 /api/v1/schools/{schoolId}/festivals API를 그대로 대체할 수 있습니다.

다만, 클라이언트 측에서 SliceResponse<> 타입의 응답을 받고 있으므로, 사용하려면 new SliceResponse<>(true, content)와 같이 변환해야 합니다.


브랜치를 잘못 잡아 다시 PR 올립니다! 😂

@seokjin8678 seokjin8678 added BE 백엔드에 관련된 작업 🙋‍♀️ 제안 제안에 관한 작업 🏗️ 기능 기능 추가에 관한 작업 labels Apr 17, 2024
@seokjin8678 seokjin8678 self-assigned this Apr 17, 2024
@github-actions github-actions bot requested review from BGuga, carsago and xxeol2 April 17, 2024 02:56
Comment on lines +9 to +23
@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));
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

캐시 초기화를 담당하는 컴포넌트 입니다.
축제 캐싱이 30분 동안 유지되므로, 그 사이 새로운 축제가 추가되면 사용자가 축제 정보를 확인할 수 없는 문제가 생깁니다.
축제는 관리자가 추가하기에 큰 문제는 아니지만, 가끔 즉시 초기화가 필요한 시점이 있을 수 있기에 별도의 컴포넌트로 분리했습니다.
만약, 수동으로 초기화가 필요하다면 관리자 API를 열어서 직접 초기화하면 될 것 같습니다.

Copy link
Member

Choose a reason for hiding this comment

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

축제 등록 시점에 Event를 발행해서 해당 축제에 대한 캐시를 삭제하는 방법은 어떻게 생각하시나유?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@EventListener(FestvalCreateEvent.class)
@CacheEvict(cacheNames = SCHOOL_FESTIVALS_V1_CACHE_NAME, key = "#event.festival.schoolId")
public void festivalCreateEventSchoolFestivalsV1CacheEvictHandler(FestivalCreateEvent event) {
    ...
}

약간 이런 느낌이려나요?

Copy link
Member

Choose a reason for hiding this comment

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

넹 !!!

Copy link
Member

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이나 그런걸루 대채하면 좋을것같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ㅋㅋㅋ 로컬 캐시라서 어쩔 수 없죠..
그때가 되면 캐싱도 레디스를 사용하지 않을까 싶네요

Comment on lines +13 to +35
@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";

private final SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository;
private final Clock clock;

@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);
}
}
Copy link
Collaborator Author

@seokjin8678 seokjin8678 Apr 17, 2024

Choose a reason for hiding this comment

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

SchoolFestivalsV1QueryService에서 CacheName을 가지고 있습니다.
이유는 누군가 CacheName을 관리해야 하는데, 캐시 구현체인 SchoolFestivalsV1CacheConfig에서 관리하기엔 application 레이어인 Service에서 infrastructure에 대한 의존이 발생하더군요. 😂
따라서 캐시의 직접적인 사용자인 SchoolFestivalsV1QueryService에서 CacheName을 가지고 있도록 하였습니다.
CacheName이 문자열 + 불변하므로, public으로 노출되더라도 큰 문제는 없을 것 같다고 판단됩니다.

Comment on lines 21 to 41
public List<SchoolFestivalV1Response> findFestivalsBySchoolId(
Long schoolId,
LocalDate today
) {
return queryDslHelper.select(
new QSchoolFestivalV1Response(
festival.id,
festival.name,
festival.startDate,
festival.endDate,
festival.thumbnail,
festivalQueryInfo.artistInfo
)
)
.from(festival)
.leftJoin(festivalQueryInfo).on(festivalQueryInfo.festivalId.eq(festival.id))
.where(festival.school.id.eq(schoolId).and(festival.endDate.goe(today)))
.stream()
.sorted(Comparator.comparing(SchoolFestivalV1Response::startDate))
.toList();
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

DB를 통해 정렬을 수행하지 않고, 어플리케이션 레벨에서 정렬을 수행하도록 했습니다.
이유는 조회 시 가져오는 축제의 개수가 매우 적기 때문에, 어플리케이션 레벨에서 정렬하는게 효율적이라 판단하였습니다.
실제 성능 비교를 해보면, 정말 미미한 차이겠지만.. 해당 구현으로 FESTIVAL 테이블에 index_festival_end_date_desc, index_festival_start_date 인덱스를 제거할 수 있습니다.


해당 인덱스가 다른 쿼리에서 사용중이라, 제거가 힘들겠네요 😂
ms 단위라, 성능 비교에도 큰 차이는 없을 것 같은데.. DB 부담을 덜기에도 캐싱이 적용된터라 큰 차이도 없을 것 같네요.

Comment on lines +9 to +22
public class CacheClearTestExecutionListener implements TestExecutionListener {

@Override
public void beforeTestMethod(TestContext testContext) {
ApplicationContext applicationContext = testContext.getApplicationContext();
CacheManager cacheManager = applicationContext.getBean(CacheManager.class);
for (String cacheName : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.invalidate();
}
}
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CacheConfig@Profile("!test") 어노테이션을 사용할 수도 있었지만, 캐싱이 제대로 작동하는지 테스트로 검증할 필요도 있고, CacheInvalidator에도 @Profile("!test") 어노테이션을 붙여야 하기 때문에, 해당 TestExecutionListener를 구현하였습니다.

Copy link

github-actions bot commented Apr 17, 2024

Test Results

248 files  248 suites   29s ⏱️
805 tests 805 ✅ 0 💤 0 ❌
823 runs  823 ✅ 0 💤 0 ❌

Results for commit 79fb628.

♻️ This comment has been updated with latest results.

Comment on lines +13 to +30
@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());
}
}
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

어플리케이션 종료 시점에 캐시 분석을 위해 로그를 남기도록 했습니다.
마찬가지로 어드민 API를 열어서, 특정 시점에 남길 수 있도록 해도 좋을 것 같네요.
테스트 시 별도의 로그를 남기는 것이 불필요하다 판단되어 @Profile({"!test"})를 적용했습니다!

Copy link
Collaborator Author

@seokjin8678 seokjin8678 Apr 19, 2024

Choose a reason for hiding this comment

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

찾아보니 Spring Actuator에 캐시 상태를 확인할 수 있는 기능이 있네요!
따라서 Actuator를 사용하면 될 것 같습니다.

Copy link
Member

@xxeol2 xxeol2 left a comment

Choose a reason for hiding this comment

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

마침 캐시에 대해서 알아보다가 메일에 이 PR 알림이 와있길래 ㅎ 오랜만에 들러봤습니다
여전히 멋지네유 페스타고

Comment on lines +16 to +22
public class SchoolFestivalsV1QueryService {

public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1";
public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1";

private final SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository;
private final Clock clock;
Copy link
Member

Choose a reason for hiding this comment

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

오호 학교 상세조회 페이지에 캐싱을 적용시키셨요
먼가 메인 페이지의 트래픽이 더 많아서, 거기서의 캐싱도 중요할 것 같은데 거기도 적용해보면 좋을것같아유 (인기축제 목록이라덩가..)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 맞아요 안그래도 메인 페이지 캐싱을 적용해보려고 하는데.. 메인 페이지에 페이징이 들어가 있어서 약간 애매하네요. 😂
그래서 페이징 처리를 하지 않는 첫 요청일때만 해보려고 생각도 해봤어요. (커서 기반 페이징이니 키를 조합해서 사용해도 될 것 같기도 하네요)

Copy link
Member

Choose a reason for hiding this comment

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

화면상 메인 페이지를 가장 많이 접할 거라 생각이 들지만 메인 페이지가 가장 데이터의 변화가 많은 곳이라 캐싱 무효화를 가장 많이할 것으로 예상되는데...
저는 그래도 캐싱을 하는 것이 더 낫다고 생각됩니다.
그 이유는 메인 화면에서 제공되는 데이터가 1. 인기 축제 목록 2. 축제 목록 두 가지인데
이 두 가지 데이터가 변경되었다 하더라도 그것을 반드시 실시간으로 반영해서 보여줄 필요는 없을 것 같습니다.
캐싱 정보를 한 시간 정도로 확인하고 갱신하는 스케줄러로 풀어가는 것이 제공하는 서비스를 크게 헤칠 것이라 판단되지는 않네요!!

Comment on lines +9 to +23
@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));
}
}
Copy link
Member

Choose a reason for hiding this comment

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

축제 등록 시점에 Event를 발행해서 해당 축제에 대한 캐시를 삭제하는 방법은 어떻게 생각하시나유?

@seokjin8678
Copy link
Collaborator Author

마침 캐시에 대해서 알아보다가 메일에 이 PR 알림이 와있길래 ㅎ 오랜만에 들러봤습니다 여전히 멋지네유 페스타고

ㅋㅋㅋㅋ 회사 열심히 다니고 계신가요
간만에 뵈니 반갑네요

Comment on lines +14 to +26
private static final long EXPIRED_AFTER_WRITE = 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()
);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

MAXIMUM_SIZE를 좀 크게 잡긴 했는데... 캐시의 최대 사이즈가 학교 이상으로 커질 수 없기 때문에 의미가 있나 싶긴하네요. 😂

Comment on lines +18 to +19
public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1";
public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1";
Copy link
Collaborator

Choose a reason for hiding this comment

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

전 이걸 어디 넣나 큰 차이가 없을거라 생각하긴 하는데, 별로 마음에 안드시다면 cache name을 관리해주는 const 클래스 만드는 것도 괜찮을 것 같네요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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);

근데 그렇게 하려고 하니, 하나의 파일에 캐시가 관리되니 충돌이 잦을 것 같더라구요.
(캐시 설정에 구현체에 대한 의존이 생기는 것도 덤이구요)
그런 이유로 캐시를 사용하는 클라이언트에 캐시 이름을 놔뒀습니다.

Copy link
Member

Choose a reason for hiding this comment

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

Caching을 한 곳으로 모은다면 Caching 된 자료를 응용하여 로직을 짤 수 있냐를 판단하기 수월할 것 같은데 Conflict 를 감안하여서 한 곳에 모으는 것은 어떨까요?

Copy link
Collaborator Author

@seokjin8678 seokjin8678 May 29, 2024

Choose a reason for hiding this comment

The 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 캐시 구현체가 워낙 잘 만들어서 대체할 가능성이 무척 낮지만..
캐시를 설정하는 곳에서 특정 구현체에 의존적이게 되는 문제도 있습니다..!

@Configuration
public class SchoolFestivalsV1CacheConfig {

private static final long EXPIRED_AFTER_WRITE = 30;
Copy link
Collaborator

Choose a reason for hiding this comment

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

30분 만료시간은 어떤 이유 / 기준일 까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

별 이유는 없습니다... 그냥 30분이 적절한 것 같더라구요. 😂

@seokjin8678
Copy link
Collaborator Author

캐싱을 적용한 것과 캐싱을 적용하지 않은 것에 대해 부하 테스트를 통해 비교를 해봤습니다.
우선 과거 축제를 포함하지 않는 학교 축제 조회의 경우 다음과 같은 결과가 나왔습니다.
(학교 당 2000년 ~ 2024년까지 각 년도 마다 5월, 9월에 축제가 있음, 2023년 까지 46개 + 2024년 1개)

캐싱 미적용

1번째 테스트

image

2번째 테스트

image

캐싱 적용

image

결과를 보시면 아시겠지만, 캐싱을 적용한 것이 적용을 하지 않은 것에 비해 2~3배 이상의 RPS를 보여주고 있습니다.
(캐싱을 적용하지 않았을 때 테스트 결과의 RPS가 300, 187 정도로 나온게 있는데 아마 여러 번 테스트를 수행하여 MySQL의 버퍼풀이 가득차서 그런게 아닌가 합니다)


그리고 과거 축제 조회 때 캐싱 적용 결과입니다.

캐싱 미적용

1번째 테스트

image

2번째 테스트

image

캐싱 적용

image

보시면 캐싱을 적용한 것이 RPS 수치가 매우 낮은 것을 볼 수 있습니다.

이유는 Average Size 수치를 보시면 아실 수 있는데, 과거 축제는 캐싱에서 페이징을 적용하지 않았기 때문입니다.

캐싱을 적용한 것은 46개의 축제가 조회되는데, 캐싱 미적용은 페이징 처리가 되어 있으므로 10개의 축제가 조회됩니다.

그런데 여기엔 함정이 있는 것이, 실제 운영 환경에서는 과거 데이터가 없기도 하고, 있더라도 서비스 운영 기간 동안 축제 목록이 있어봤자 5년이 지나야 10개의 축제가 겨우 조회됩니다.

따라서 캐싱을 사용하여 페이징을 적용하지 않더라도, 5년간은 페이징을 처리한 것에 비해 2~3배의 RPS를 보장 받을 수 있습니다.

Copy link
Member

@BGuga BGuga left a comment

Choose a reason for hiding this comment

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

수고하셨습니다 글렌!!!

보시면 캐싱을 적용한 것이 RPS 수치가 매우 낮은 것을 볼 수 있습니다.

이유는 Average Size 수치를 보시면 아실 수 있는데, 과거 축제는 캐싱에서 페이징을 적용하지 않았기 때문입니다.

캐싱을 적용한 것은 46개의 축제가 조회되는데, 캐싱 미적용은 페이징 처리가 되어 있으므로 10개의 축제가 조회됩니다.

그런데 여기엔 함정이 있는 것이, 실제 운영 환경에서는 과거 데이터가 없기도 하고, 있더라도 서비스 운영 기간 동안 축제 목록이 있어봤자 5년이 지나야 10개의 축제가 겨우 조회됩니다.

따라서 캐싱을 사용하여 페이징을 적용하지 않더라도, 5년간은 페이징을 처리한 것에 비해 2~3배의 RPS를 보장 받을 수 있습니다.

해당 부분에서 이해가 되지 않는 부분이 과거 데이터가 페이징이 되지 않았다 하더라도 메모리 상에 있는 46개의 데이터에 대한 접근과 반환이 네트워크를 통한 DB에서의 10개 축제 조회보다 느린가요..?

Comment on lines +18 to +19
public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1";
public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1";
Copy link
Member

Choose a reason for hiding this comment

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

Caching을 한 곳으로 모은다면 Caching 된 자료를 응용하여 로직을 짤 수 있냐를 판단하기 수월할 것 같은데 Conflict 를 감안하여서 한 곳에 모으는 것은 어떨까요?

Comment on lines +16 to +22
public class SchoolFestivalsV1QueryService {

public static final String SCHOOL_FESTIVALS_V1_CACHE_NAME = "schoolFestivalsV1";
public static final String PAST_SCHOOL_FESTIVALS_V1_CACHE_NAME = "pastSchoolFestivalsV1";

private final SchoolFestivalsV1QueryDslRepository schoolFestivalsV1QueryDslRepository;
private final Clock clock;
Copy link
Member

Choose a reason for hiding this comment

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

화면상 메인 페이지를 가장 많이 접할 거라 생각이 들지만 메인 페이지가 가장 데이터의 변화가 많은 곳이라 캐싱 무효화를 가장 많이할 것으로 예상되는데...
저는 그래도 캐싱을 하는 것이 더 낫다고 생각됩니다.
그 이유는 메인 화면에서 제공되는 데이터가 1. 인기 축제 목록 2. 축제 목록 두 가지인데
이 두 가지 데이터가 변경되었다 하더라도 그것을 반드시 실시간으로 반영해서 보여줄 필요는 없을 것 같습니다.
캐싱 정보를 한 시간 정도로 확인하고 갱신하는 스케줄러로 풀어가는 것이 제공하는 서비스를 크게 헤칠 것이라 판단되지는 않네요!!

Comment on lines +20 to +24
Caffeine.newBuilder()
.recordStats()
.expireAfterWrite(EXPIRED_AFTER_WRITE, TimeUnit.MINUTES)
.maximumSize(MAXIMUM_SIZE)
.build()
Copy link
Member

Choose a reason for hiding this comment

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

expireAfterWrite 을 명시해주었다면

 @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);
    }

매일 갱신해주지 않아도 되지 않을까요??
제가 이해한 바로는 해당 값이 1. 데이터 생성 2. 데이터 갱신 시점 이후 N분이 지날 경우 Cache 데이터를 없애는 설정으로 이해했는데
저희는 명시적으로 Cache 데이터가 있을 때 값을 대체하지 않기 때문에 2번의 경우는 해당사항이 없는 것으로 이해했습니다.
따라서 제일 처음 사용자가 요청했을 때는 값이 없으니 새로 Cache에 삽입하고, 30분이 지난 시점에는 사라졌기 때문에 또다시 데이터를 가져온다면, 매일 단위로 캐시 초기화는 하지 않아도 될 것으로 생각되네요!!
혹시 제가 해당 설정에 대해서 잘못 이해하고 있는 것인가요 😂

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

하루가 지나는 정각에 캐시를 비워주지 않으면 사용자가 잘못된 축제를 조회할 일이 생깁니다..!
예를들어 23시 40분에 사용자가 조회를 하여, 캐시가 갱신이 되었다고 했을 때, 30분이 지난 다음날 0시 10분 까지 해당 캐시는 유지가 됩니다.
그런데 0시가 지나면, 당일 끝난 축제는 조회가 되면 안됩니다.
하지만 캐시로 축제가 유지되고 있기에 0시 0분에서 0시 10분까지는 잘못된 축제를 조회하게 됩니다.
따라서 이러한 상황을 막아야 하기 때문에 0시마다 강제로 모든 캐시를 비워야 합니다..!

@seokjin8678
Copy link
Collaborator Author

수고하셨습니다 글렌!!!

보시면 캐싱을 적용한 것이 RPS 수치가 매우 낮은 것을 볼 수 있습니다.

이유는 Average Size 수치를 보시면 아실 수 있는데, 과거 축제는 캐싱에서 페이징을 적용하지 않았기 때문입니다.

캐싱을 적용한 것은 46개의 축제가 조회되는데, 캐싱 미적용은 페이징 처리가 되어 있으므로 10개의 축제가 조회됩니다.

그런데 여기엔 함정이 있는 것이, 실제 운영 환경에서는 과거 데이터가 없기도 하고, 있더라도 서비스 운영 기간 동안 축제 목록이 있어봤자 5년이 지나야 10개의 축제가 겨우 조회됩니다.

따라서 캐싱을 사용하여 페이징을 적용하지 않더라도, 5년간은 페이징을 처리한 것에 비해 2~3배의 RPS를 보장 받을 수 있습니다.

해당 부분에서 이해가 되지 않는 부분이 과거 데이터가 페이징이 되지 않았다 하더라도 메모리 상에 있는 46개의 데이터에 대한 접근과 반환이 네트워크를 통한 DB에서의 10개 축제 조회보다 느린가요..?

우선 이유를 말씀드리면 제 인터넷 대역폭이 100Mbps라서 그렇습니다. 😂

기존 페이징 처리 시 크기가 16KB이고, 캐싱이 적용되고 페이징 처리가 안 된 경우 77KB 입니다.

그리고 캐싱이 처리된 요청은 DB를 거치지 않고 서버와 연결을 주고받으므로, 서버와 통신 시 다운로드 속도를 계산할 수 있습니다.
그리고 RPS가 44.9이니 1.3초에 한 번 요청 한다고 볼 수 있습니다.
그리고 응답의 크기는 77KB 이므로 초당 다운로드 속도는 약 59KB/s 정도로 계산할 수 있습니다.
(핸드셰이킹 과정 때문에 정확한 다운로드 속도는 아닙니다)

기존 캐싱을 적용하지 않은 경우 RPS 117 기준, 0.5초에 한 번 요청을 한다고 볼 수 있습니다.
그리고 응답이 DB I/O를 거치지 않는다고 가정했을 때 시간을 구하면, 16KB / 59KB = 0.27초 입니다.
그리고 요청 당 0.5초가 걸리므로, 0.5 - 0.27을 계산해보면 DB I/O 시간을 0.23초 정도로 계산할 수 있습니다.

따라서 캐싱을 사용해도 접근이 느린 이유가 제 대역폭이 병목이 되어 낮은 다운로드 속도 때문에 그런 것으로 확인할 수 있습니다.

만약 제 대역폭이 1Gbps면, 결과가 달라졌을 수 있겠네요.
대역폭을 계산해보면 최대 다운로드 속도가 59KB/s인 이유가 명확하게 나옵니다.
59KB * 200(톰캣 스레드) = 11.8MB (100Mbps의 최대 다운로드 속도는 12.5MB 입니다)

따라서 각 클라이언트마다 요청을 한다면 서버의 대역폭이 받아주는대로 성능이 나올 것 같네요.
(저희가 쓰는 오라클 클라우드는 대역폭이 1Gbps 정도라고 하네요. 따라서 실제 성능은 600RPS를 넘을 수 있을 것 같습니다)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
BE 백엔드에 관련된 작업 🏗️ 기능 기능 추가에 관한 작업 🙋‍♀️ 제안 제안에 관한 작업
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BE] 학교 식별자로 축제를 조회하는 쿼리에 페이징을 삭제하고 캐싱을 적용한다.
4 participants