[프로젝트] Modulo
Redis 캐시 도입을 통한 이력서 관리 시스템 성능 최적화: 응답 시간 87% 감소, 처리량 413% 향상 - Redis Template API vs Lua Script
kwon-record
2025. 1. 10. 16:12
1. 도입 배경
이력서 관리 시스템 'Modulo'의 성능 테스트 중 여러 모듈(기본 정보, 경력, 학력, 프로젝트 등)의 데이터를 조합하는 과정에서 API 응답 시간 지연이 발생했다. 특히 SavedModule과 Resume 조회 시 여러 서비스의 데이터를 조합하는 과정에서 성능 저하가 두드러졌다. 캐시 도입 전 k6 성능 테스트 결과는 다음과 같다:
# 캐시 적용 전 k6 테스트 결과
✓ basic_info_get_all_duration....: avg=17.128329 min=13 med=15 max=232 p(90)=18 p(95)=21
✓ resume_get_all_duration........: avg=43.515738 min=36 med=40 max=365 p(90)=47 p(95)=54.4
✓ http_req_duration..............: avg=10.77ms min=1.78ms med=3.84ms max=364.39ms
http_reqs......................: 5369 89.378919/s
2. 캐시 전략 설계
2.1. Look Aside 패턴과 Spring Cache 추상화 선택
Look Aside 패턴과 Spring의 @Cacheable
, @CachePut
, @CacheEvict
어노테이션을 선택한 주요 이유는 다음과 같다:
서비스 특성에 따른 선택
- 읽기가 빈번하고 쓰기는 상대적으로 적은 서비스 특성
- 캐시 미스 시에도 원본 데이터 보장
- 구현과 관리가 상대적으로 단
개인화된 데이터 특성
- 각 사용자는 자신의 데이터만 접근/수정 가능
- 사용자별로 독립적인 캐시 키 사용
- Thundering Herd Problem (다수의 요청이 동시에 캐시 미스를 경험하는 현상) 발생 가능성 없음
Spring Cache 추상화 사용 이유
@Cacheable(value = CACHE_NAME, key = "'member:' + #memberId")
public List<BasicInfoResponse> getMyBasicInfos(Long memberId) {
return basicInfoRepository.findAllByMemberId(memberId);
}
- 개인화된 데이터 특성으로 인해 복잡한 캐시 제어 로직 불필요
- Spring이 제공하는 기본 어노테이션만으로 충분한 캐시 관리 가능
- 캐시 키가 사용자별로 분리되어 있어 세밀한 락이나 동시성 제어 불필요
Redis 명령어 선택
- 멤버별로 독립적인 캐시를 사용하므로 동시성 문제 없음
- TTL 관리를 위한 getExpire와 expire 명령어를 별도의 Lua 스크립트 없이 RedisTemplate으로 직접 호출
- 동일한 캐시 키에 대해 여러 스레드가 동시 접근하는 상황이 발생하지 않아 원자성 보장이 불필요
2.2. TTL 관리 전략
모든 서비스에서 공통적으로 사용되는 TTL 설정:
private static final long EXTEND_TTL_THRESHOLD = 30 * 60; // 30분
private static final long EXTEND_TTL_DURATION = 60 * 60; // 60분
Temporal Locality 최적화를 위한 TTL 관리:
Long ttl = redisTemplate.getExpire(cacheKey);
if (ttl != null && ttl < EXTEND_TTL_THRESHOLD) {
redisTemplate.expire(cacheKey, EXTEND_TTL_DURATION, TimeUnit.SECONDS);
}
2.3. 캐시 정합성 관리
데이터 수정 시 연관된 캐시들을 함께 제거하는 방식을 채택:
private void evictRelatedCaches() {
String memberId = SecurityContextHolder.getContext().getAuthentication().getName();
redisTemplate.delete(CACHE_NAME + "::member:" + memberId);
redisTemplate.delete("savedModules::member:" + memberId);
redisTemplate.delete("resumes::member:" + memberId);
}
3. 주요 서비스별 구현
3.1. SavedModuleService - 통합 데이터 캐싱
@Service
@RequiredArgsConstructor
public class SavedModuleService {
private static final String CACHE_NAME = "savedModules";
@Cacheable(value = CACHE_NAME, key = "T(org.springframework.security.core.context.SecurityContextHolder).getContext().getAuthentication().getName()")
public SavedModuleResponse getMySavedModules() {
return SavedModuleResponse.builder()
.basicInfos(new ArrayList<>(basicInfoService.getMyBasicInfos()))
.careers(new ArrayList<>(careerService.getMyCareers()))
.educations(new ArrayList<>(educationService.getMyEducations()))
.etcs(new ArrayList<>(etcService.getMyEtcs()))
.selfIntroductions(new ArrayList<>(selfIntroductionService.getMySelfIntroductions()))
.projects(new ArrayList<>(projectService.getMyProjects()))
.build();
}
}
3.2. ResumeService - 복합 데이터 캐싱
@Service
@RequiredArgsConstructor
public class ResumeService {
@Transactional(readOnly = true)
public ResumeDetailResponse getResumeDetailById(Long resumeId) {
Resume resume = findResumeById(resumeId);
validateMemberAccess(resume);
return ResumeDetailResponse.builder()
.id(resume.getId())
.title(resume.getTitle())
.sections(resume.getSections().stream()
.map(section -> {
List<Object> contentDetails = section.getContents().stream()
.map(content -> switch (section.getSectionType()) {
case BASIC_INFO -> basicInfoService.getBasicInfoById(content.getContentId());
case CAREER -> careerService.getCareerById(content.getContentId());
case EDUCATION, PROJECT, ETC, SELF_INTRODUCTION ->
// 각 도메인 서비스 호출
})
.collect(Collectors.toList()))
.build())
.collect(Collectors.toList()))
.build();
}
}
3.3. 도메인 서비스 - 기본 데이터 캐싱
@Service
@RequiredArgsConstructor
public class BasicInfoService {
private static final String CACHE_NAME = "basicInfo";
@Cacheable(value = CACHE_NAME, key = "'member:' + T(org.springframework.security.core.context.SecurityContextHolder).getContext().getAuthentication().getName()")
public List<BasicInfoResponse> getMyBasicInfos() {
String memberId = SecurityContextHolder.getContext().getAuthentication().getName();
return basicInfoRepository.findAllByMemberId(Long.parseLong(memberId));
}
@CacheEvict(value = CACHE_NAME, key = "'member:' + T(org.springframework.security.core.context.SecurityContextHolder).getContext().getAuthentication().getName()")
public void updateBasicInfo(Long id, BasicInfoUpdateRequest request) {
// 업데이트 로직
evictRelatedCaches();
}
}
4. 성능 개선 결과
캐시 도입 후 k6 테스트 결과:
# 캐시 적용 후 k6 테스트 결과
✓ basic_info_get_all_duration....: avg=2.214906 min=0 med=2 max=149 p(90)=3 p(95)=4
✓ resume_get_all_duration........: avg=2.147014 min=0 med=2 max=152 p(90)=3 p(95)=4
✓ http_req_duration..............: avg=2.03ms min=729µs med=1.63ms max=152.07ms
http_reqs......................: 27426 457.065537/s
주요 개선 지표:
- 응답 시간: 평균 87% 감소
- 처리량: 413% 증가 (89.37 → 457.06 요청/초)
5. 향후 고려사항
캐시 워밍업 전략
@Component
@RequiredArgsConstructor
public class CacheWarmer {
@PostConstruct
public void warmUpCache() {
List<Member> activeMembers = memberRepository.findAll();
activeMembers.forEach(member -> {
String memberId = member.getId().toString();
// 주요 데이터 사전 캐싱
basicInfoService.getMyBasicInfos();
resumeService.getMyResumes();
});
}
}
6. 결론
Look Aside 패턴과 Temporal Locality 최적화를 통해 시스템 성능을 크게 개선했다. 특히 Resume와 SavedModule과 같은 복합 데이터 조회에서 캐시가 효과적으로 작동했다. 개인화된 데이터 특성으로 인해 Thundering Herd 문제나 복잡한 캐시 제어가 불필요했으며, Spring Cache 추상화만으로도 충분한 성능 개선을 달성할 수 있었다. 또한 멤버 ID 기반의 캐시 키 설계와 연관 캐시 제거 전략으로 데이터 정합성도 효과적으로 관리할 수 있었다.