[프로젝트] 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 기반의 캐시 키 설계와 연관 캐시 제거 전략으로 데이터 정합성도 효과적으로 관리할 수 있었다.