캐싱을 도입하면 당연히 성능이 향상될 거라고 생각했는데, 실제로는 TPS가 151에서 146으로 오히려 떨어지는 예상치 못한 결과를 마주했다. 이번 포스트에서는 Redis 캐싱 도입 과정에서 겪은 문제들과 해결 과정을 솔직하게 공유해보려고 한다.
🎯 캐싱 도입 목표
기존 문제점
- 상품 상세 정보 조회(
ProductService.getProductDetail) 같은 자주 호출되는 API가 매번 데이터베이스를 조회 - 불필요한 DB 부하 발생
- 응답 속도 저하
예상 효과
- DB 조회 횟수 감소로 응답 시간 단축
- 서버 리소스 절약
- 전체적인 TPS 향상
하지만 현실은 예상과 달랐다...
🔧 Redis 캐싱 구현
1. 캐시 설정 구성
CacheConfig.java 생성
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("productDetail", config.entryTtl(Duration.ofMinutes(10)));
cacheConfigurations.put("productList", config.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
2. 서비스 레이어에 캐싱 적용
ProductService.java 수정
@Cacheable(value = "productDetail", key = "#productId + '_' + (#userId != null ? #userId : 'guest')")
public ProductDetailResponseDto getProductDetail(Long productId, Long userId) {
// 기존 로직...
}
@CacheEvict(value = "productDetail", key = "#product.id + '_' + '*'")
public void updateProduct(Product product) {
// 상품 수정 로직...
}
@Scheduled(fixedRate = 3600000) // 1시간마다 실행
@CacheEvict(value = "productList", allEntries = true)
public void clearProductListCache() {
// 상품 목록 캐시 주기적 초기화
}
3. 스케줄링 활성화
CoreApplication.java 수정
@SpringBootApplication
@EnableScheduling // 추가
public class CoreApplication {
public static void main(String[] args) {
SpringApplication.run(CoreApplication.class, args);
}
}
📊 첫 번째 테스트 결과 - 충격적인 성능 저하
캐싱 도입 이전 (151 TPS)
{
"전체 평균 TPS": 151.21,
"HTTP 요청/초 (RPS)": 453.62,
"API 성공률": "100%",
"평균 응답시간": "492ms",
"총 트랜잭션": "182617건",
"실패한 요청": "0건"
}
캐싱 도입 후 첫 번째 결과 (141 TPS)
{
"전체 평균 TPS": 141.46,
"HTTP 요청/초 (RPS)": 424.39,
"API 성공률": "100%",
"평균 응답시간": "645ms",
"총 트랜잭션": "170942건",
"실패한 요청": "0건"
}
| 지표 | 캐싱 이전 | 캐싱 이후 | 변화율 |
|---|---|---|---|
| 평균 TPS | 151 TPS | 141 TPS | -6.6% ⬇️ |
| 평균 응답시간 | 492ms | 645ms | +31% ⬆️ |
| HTTP 요청/초 | 453.62 RPS | 424.39 RPS | -6.4% ⬇️ |
완전히 예상과 반대 결과였다. 뭔가 심각하게 잘못되었다는 걸 직감했다.
🚨 문제 진단 과정
1단계: 로그 분석
/image.png)
로그를 확인해보니 GenericJackson2JsonRedisSerializer.deserialize 관련 스택 트레이스가 반복적으로 나타나고 있었다. Redis에서 캐시 데이터를 읽어올 때 역직렬화 과정에서 오류가 발생하고 있었던 것이다.
2단계: 원인 분석
예상되는 원인들:
- 순환 참조 (Circular Reference): DTO 내부에서 서로를 참조하는 구조
- 지연 로딩된 JPA 프록시 객체: 초기화되지 않은 프록시 객체 직렬화 시도
- DTO 구조 변경: 캐시된 데이터와 현재 DTO 구조 불일치
- 기본 생성자 부재: Jackson 역직렬화에 필요한 기본 생성자 없음
3단계: 근본 원인 발견
문제의 핵심은 기본 생성자 부재였다:
ProductDetailResponseDto와ProductResponseDto에@Getter만 있고@NoArgsConstructor가 없었음- Jackson은 역직렬화 시 기본 생성자를 사용해서 인스턴스를 생성하는데, 이게 없어서 실패
- 역직렬화 실패를 처리하는 과정에서 오버헤드 발생 → TPS 저하
🔨 문제 해결 과정
1단계: 기본 생성자 추가
@Getter
@NoArgsConstructor // 추가
public class ProductDetailResponseDto {
// 필드들...
}
@Getter
@NoArgsConstructor // 추가
public class ProductResponseDto {
// 필드들...
}
결과 (138 TPS): 여전히 성능 저하 지속
2단계: 빌드 실패 해결
새로운 문제 발생! 빌드가 실패했다.
원인: DTO 필드에 final 키워드가 있어서 @NoArgsConstructor로 생성된 기본 생성자가 해당 필드들을 초기화할 수 없었음.
final 필드의 문제점:
public class ProductVariantDto {
private final Long variantId; // final은 선언 시점이나 생성자에서만 초기화 가능
private final String size;
// 기본 생성자에서는 final 필드를 초기화할 수 없음
public ProductVariantDto() {
// variantId = null; // 컴파일 에러!
}
}
해결: 모든 DTO에서 final 키워드 제거
public class ProductVariantDto {
private Long variantId; // final 제거
private String size; // final 제거
// ...
}
3단계: 추가 DTO 문제 해결
또 다른 역직렬화 오류 발견:
SerializationException: Could not read JSON: Cannot construct instance of
`com.tryiton.core.product.dto.ProductVariantDto` (no Creators, like default
constructor, exist)
ProductVariantDto에도 같은 문제가 있었다. 모든 관련 DTO에 @NoArgsConstructor 추가하고 final 키워드 제거했다.
🏗️ AWS ElastiCache Redis 설정
로컬 Redis 대신 AWS ElastiCache를 사용하기로 결정했다.
ElastiCache 클러스터 생성
/image1.png)
설정 내용:
- 클러스터 이름: tio-redis-cache
- 노드 타입: cache.t4g.micro (개발용)
- 포트: 6379
- VPC: 애플리케이션과 동일한 VPC
- 보안 그룹: Spring EC2에서 접근 가능하도록 설정
보안 그룹 설정
/image2.png)
Redis 보안 그룹 (TIO-Redis-SG):
- 인바운드: TCP 6379 포트, Spring EC2 보안 그룹에서 접근 허용
- 아웃바운드: 모든 트래픽 허용
📈 최종 성능 테스트 결과
점진적 개선 과정
/image3.png)
/image4.png)
중간 결과들:
- 첫 번째 시도 (141 TPS): 기본 생성자 부재로 역직렬화 실패
- 두 번째 시도 (138 TPS): final 필드 문제로 빌드 실패
- 세 번째 시도 (141.6 TPS): ElastiCache 적용 후 약간 개선
- 네 번째 시도 (145.19 TPS): 모든 DTO 문제 해결 후 개선
최종 결과 (146.88 TPS)
{
"전체 평균 TPS": 146.88,
"HTTP 요청/초 (RPS)": 440.64,
"API 성공률": "100%",
"평균 응답시간": "595ms",
"총 테스트 시간": "1206초",
"총 트랜잭션": "177145건",
"총 HTTP 요청": "531435건",
"성공한 요청": "531435건",
"실패한 요청": "0건"
}
최종 성능 비교
| 지표 | 캐싱 이전 | 최종 결과 | 변화율 |
|---|---|---|---|
| 평균 TPS | 151 TPS | 146.88 TPS | -2.7% ⬇️ |
| 평균 응답시간 | 492ms | 595ms | +21% ⬆️ |
| HTTP 요청/초 | 453.62 RPS | 440.64 RPS | -2.9% ⬇️ |
| API 성공률 | 100% | 100% | 유지 |
🤔 왜 캐싱이 오히려 성능을 저하시켰을까?
분석 결과
- 직렬화/역직렬화 오버헤드
GenericJackson2JsonRedisSerializer가 예상보다 많은 CPU 자원 소모- 복잡한 DTO 구조로 인한 JSON 변환 비용 증가
- 네트워크 레이턴시
- 로컬 메모리 접근 vs Redis 네트워크 통신
- ElastiCache까지의 네트워크 지연시간
- 캐시 미스율
- 캐시 키 전략이 최적화되지 않아 히트율이 낮았을 가능성
- 자주 변경되는 데이터로 인한 캐시 무효화
- 메모리 사용량 증가
- Redis 캐시 + 애플리케이션 메모리 동시 사용
- GC 압박 증가
로그 분석 결과
/image5.png)
EC2 애플리케이션 로그에서 Redis 관련 오류들을 확인할 수 있었다. 역직렬화 실패가 반복적으로 발생하면서 성능에 악영향을 미쳤다.
💡 배운 점과 개선 방향
1. 캐싱이 항상 성능 향상을 보장하지는 않는다
- 단순히 캐싱을 도입한다고 해서 무조건 빨라지는 건 아니다
- 데이터 특성, 접근 패턴, 네트워크 환경 등을 종합적으로 고려해야 한다
2. DTO 설계의 중요성
- Jackson 직렬화/역직렬화를 고려한 DTO 설계 필요
@NoArgsConstructor,final키워드 사용 시 주의사항 숙지
3. 성능 테스트의 중요성
- 예상과 실제 결과가 다를 수 있다
- 단계별 성능 측정을 통한 문제점 파악 필요
4. 로그 모니터링의 중요성
- 성능 저하의 원인을 로그를 통해 빠르게 파악할 수 있었다
- 실시간 모니터링 시스템 구축의 필요성
🎯 향후 개선 계획
1. 캐싱 전략 재검토
- 자주 조회되지만 변경이 적은 데이터 위주로 캐싱 적용
- 캐시 키 전략 최적화
- TTL 설정 재조정
2. 직렬화 방식 변경 검토
GenericJackson2JsonRedisSerializer대신 더 효율적인 직렬화 방식 검토- 프로토콜 버퍼나 Avro 같은 바이너리 직렬화 고려
3. 캐시 히트율 모니터링
- Redis INFO 명령어를 통한 캐시 성능 지표 수집
- 캐시 미스율 분석 및 개선
4. 로컬 캐시와 분산 캐시 하이브리드 전략
- 자주 사용되는 데이터는 로컬 캐시 (Caffeine)
- 공유가 필요한 데이터는 Redis 캐시
🔚 마무리
캐싱 도입이 예상과 다른 결과를 가져왔지만, 이 과정에서 많은 것을 배울 수 있었다. 특히 성능 최적화는 단순히 "좋다고 알려진 기술"을 도입하는 것이 아니라, 실제 환경에서의 측정과 분석을 통해 이루어져야 한다는 점을 깨달았다.
비록 TPS는 약간 감소했지만, Redis 캐싱 인프라를 구축하고 관련 문제들을 해결하는 과정에서 얻은 경험은 향후 더 나은 성능 최적화의 밑거름이 될 것이다.
다음에는 이번 경험을 바탕으로 더 효율적인 캐싱 전략을 수립해서 진짜 성능 향상을 이뤄내보겠다
'Jungle' 카테고리의 다른 글
| JPQL 프로젝션으로 N+1 문제와 Over-fetching 해결하기 (0) | 2025.08.02 |
|---|---|
| 커버링 인덱스로 11초 쿼리를 100ms로 단축시키기: 실제 장애 해결 과정 (0) | 2025.08.02 |
| 데이터베이스 액세스 최적화로 TPS 4.4% 향상시키기 (144.6 → 151 TPS) (0) | 2025.08.01 |
| TIO 성능 테스트: EC2 업그레이드로 144 TPS 달성하기 (0) | 2025.07.29 |
| TIO 성능 테스트 결과 분석 (40명 동시 사용자) (0) | 2025.07.29 |