1. 배경 및 문제 상황 (Background)
최근 사내 프로젝트 개발 중, 특정 API의 응답 속도가 현저히 느려지는 현상을 발견했습니다. 프로파일링 결과, 범인은 사용자 정보를 불러오는 로직이었습니다.
- 현상: 사용자 정보 조회 시 응답 시간이 약 2,000ms (2초)까지 지연됨.
- 원인: 레거시 시스템 연동 및 복잡한 조인 연산으로 인해 DB 조회 비용이 매우 높음.
- 제약 사항:
- 현재 다른 핵심 기능 개발로 인해 인프라를 변경하거나 대대적인 리팩토링을 할 시간적 여유가 없음.
- 하지만 사용자의 체감 성능 향상을 위해 즉각적인 개선이 필요함.
2. 기술 검토: Local Cache vs Global Cache
DB 튜닝이 어렵다면 가장 효과적인 대안은 캐싱(Caching)입니다. 캐시 저장소를 어디에 둘 것인가를 두고 Local Cache(Memory)와 Global Cache(Redis 등) 두 가지 선택지를 놓고 고민했습니다.
| 비교 항목 | Local Cache (In-Memory) | Global Cache (External - ex. Redis) |
| 속도 | 매우 빠름 (네트워크 통신 없음) | 빠름 (네트워크 통신 발생) |
| 구현 난이도 | 매우 낮음 (라이브러리 추가만으로 가능) | 중간 (별도 서버 구축 및 연동 필요) |
| 데이터 정합성 | 낮음 (서버 간 데이터 불일치 가능성 있음) | 높음 (모든 서버가 동일한 데이터 바라봄) |
| 인프라 비용 | 0원 (애플리케이션 메모리 사용) | 추가 비용 발생 (운영/관리 포인트 증가) |
| 메모리 제한 | 애플리케이션 힙 메모리에 의존 | 별도 서버 메모리 사용으로 여유로움 |
3. 의사결정: 왜 Local Cache를 선택했는가?
일반적으로는 분산 환경에서의 정합성 문제 때문에 Global Cache(Redis)를 선호하지만, 우리 서비스의 현재 상황을 분석해본 결과 Local Cache가 더 합리적이라는 결론을 내렸습니다. 이유는 크게 세 가지입니다.
① 낮은 DAU (Daily Active Users)
현재 서비스의 DAU는 ~100명 수준입니다. 트래픽이 적기 때문에 애플리케이션 서버의 메모리에 캐시를 저장하더라도 리소스 부담이 거의 없습니다.
② 변경이 거의 없는 데이터 (Data Volatility)
캐싱하려는 데이터는 '사용자 프로필'과 같이 한번 생성되면 거의 변경되지 않는 정적인 정보입니다. 데이터 변경이 빈번하지 않으므로, 서버 간 데이터 불일치(Data Inconsistency) 문제가 발생할 확률이 극히 낮으며, 발생하더라도 치명적이지 않습니다.
③ 오버엔지니어링 방지 (Efficiency)
당장 개발 리소스가 부족한 상황에서, 단순히 캐싱을 위해 Redis 서버를 띄우고 관리 포인트를 늘리는 것은 배보다 배꼽이 더 큰 오버엔지니어링입니다. 별도의 인프라 구축 없이 코드 몇 줄로 즉시 적용 가능한 Local Cache가 현시점에서는 'Best Practice'입니다.
4. 적용 및 구현 (Implementation)
Spring Boot 환경에서 가장 간단하게 적용할 수 있는 Caffeine Cache (또는 기본 ConcurrentMapCache)를 사용했습니다.
설정 (Configuration)
@EnableCaching
@Configuration
public class CacheConfig {
// 10분 뒤 만료, 최대 1000개 항목 저장 등 간단한 정책 설정
}
적용 코드 (Service Layer)
@Service
public class UserService {
@Cacheable(value = "userInfoCache", key = "#userId")
public UserDto getUserInfo(String userId) {
// 기존: 약 2000ms 소요되던 로직
return userRepository.findComplexUserInfo(userId);
}
}
5. 결과 및 향후 계획 (Result)
Local Cache 도입 후 성능은 드라마틱하게 개선되었습니다.
- Before: 평균 2,000ms
- After: 평균 0ms ~ 5ms (Cache Hit 기준)
개발 소요 시간은 1시간 미만이었으며, 추가적인 인프라 비용 없이 사용자의 답답함을 완벽하게 해소했습니다.
Next Step:
물론 서비스가 성장하여 DAU가 수만 명 단위로 늘어나거나, 사용자의 정보 변경이 잦아지는 시점이 오면 그때는 주저 없이 Redis(Global Cache)로 마이그레이션 할 계획입니다. 하지만 지금 우리 서비스 단계에서는 Local Cache가 가장 적절하고 실용적인 공학적 선택이었습니다.
6. Next Step: 캐시 뒤에 숨은 비효율 제거 (Fetch Optimization)
캐싱 전략으로 트래픽을 방어할 순 있었지만 쿼리 자체의 효율성을 높이기 위해 최적화 하였습니다. 캐시 히트율(Hit Rate)이 아무리 높아도, 캐시 미스(Miss) 발생 시 DB에 가해지는 부하 자체가 크다면 잠재적인 위험 요소가 되기 때문입니다.
발견된 문제: JPA N+1 프로파일링 결과, Hibernate가 연관 관계를 조회할 때 불필요한 쿼리를 반복 호출하는 현상 발견
최적화 적용
- Fetch Join 적용: join fetch 구문을 사용하여 한 번의 쿼리로 필요한 모든 데이터를 즉시 로딩.
- Batch Size 설정: 컬렉션 조회 시 IN 쿼리로 묶어서 조회하도록 설정.
이 과정을 통해 순수 DB 조회 성능 자체를 2초에서 0.2초 대로 단축시켰으며, 결과적으로 시스템의 전체적인 내구성을(Fault Tolerance) 높였습니다.
'Spring' 카테고리의 다른 글
| [Spring Batch] JpaPagingItemReader 성능 차이 확인 필수 (Fetch Join OOM부터 transacted의 숨겨진 함정까지) (0) | 2026.02.28 |
|---|