캐시·Resilience·성능 최적화
캐시·Resilience·성능 최적화
요약: 측정 뒤에 캐시·재시도·서킷 브레이커·타임아웃을 넣고, 쿼리와 페이로드 병목을 줄인다.
1. 왜 측정이 먼저인가
감으로 캐시를 켜면 잘못된 키·TTL이 더 큰 장애가 된다. 순서는 측정 → 쿼리/N+1 → 풀·타임아웃 → 캐시 → 스케일 아웃이다. 가장 싼 최적화는 안 하는 일을 줄이는 것이다.
2. Spring Cache
한 줄 정의: 같은 입력의 비싼 조회 결과를 이름 붙은 저장소에 잠시 두는 것이다. @EnableCaching + @Cacheable / @CacheEvict가 선언적 API다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("products", "users");
}
}
@Cacheable(cacheNames = "products", key = "#id")
public ProductResponse get(Long id) { }
@CacheEvict(cacheNames = "products", key = "#id")
public void update(Long id, UpdateProductRequest request) { }
ConcurrentMap은 프로세스 하나용이고 TTL이 없다. 단일 인스턴스에서 TTL이 필요하면 Caffeine이 기본선이다. 인스턴스가 여러 개면 Redis(spring-boot-starter-data-redis)다.
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10_000,expireAfterWrite=5m
캐시 키가 사용자와 무관하면 권한 데이터가 섞인다. TTL과 무효화를 같이 정한다. 캐시 스탬피드는 만료 순간 요청이 한꺼번에 원본을 두드리는 현상이다. 락·singleflight 또는 소프트 만료로 줄인다. @Cacheable 메서드가 self-invocation(같은 클래스 내부 호출)이면 프록시를 안 타서 캐시가 비어 있다.
3. Resilience4j
한 줄 정의: 원격 호출이 실패·지연할 때 재시도·차단·격리로 내 스레드와 의존성을 보호하는 라이브러리다. 아티팩트는 resilience4j-spring-boot3다.
외부 호출에는 타임아웃이 필수다. 타임아웃 없는 클라이언트는 가상 스레드든 플랫폼 스레드든 풀을 고갈시킨다.
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofSeconds(3));
RestClient client = RestClient.builder()
.requestFactory(factory)
.baseUrl(props.baseUrl())
.build();
Retry
멱등하지 않은 POST에 무분별한 재시도는 결제를 두 번 만든다. 네트워크·408·429·5xx만, 그리고 Idempotency-Key가 있을 때만 재시도한다.
resilience4j:
retry:
instances:
payment:
maxAttempts: 3
waitDuration: 200ms
retryExceptions:
- java.io.IOException
@Retry(name = "payment", fallbackMethod = "payFallback")
public PaymentResult pay(PaymentRequest request) {
return paymentClient.charge(request);
}
Circuit Breaker
한 줄 정의: 실패율이 임계를 넘으면 호출을 잠시 끊고(open) 연쇄 실패를 막은 뒤, 시험 호출(half-open)로 닫는다.
resilience4j:
circuitbreaker:
instances:
payment:
slidingWindowSize: 20
failureRateThreshold: 50
waitDurationInOpenState: 10s
Bulkhead / RateLimiter / Fallback
Bulkhead는 동시 실행 수를 나눠 한 의존성이 스레드를 다 먹지 못하게 한다. Fallback은 비즈니스적으로 안전할 때만 쓴다. 가짜 성공은 재고·결제에서 금지다.
private PaymentResult payFallback(PaymentRequest request, Throwable t) {
return PaymentResult.failed("temporary unavailable");
}
4. DB와 페이로드
WHERE/JOIN/ORDER BY에 인덱스,EXPLAIN ANALYZE로 확인- 목록은 엔티티 그래프 대신 DTO 프로젝션
- 페이지 크기 상한, 장시간 트랜잭션 금지
spring:
datasource:
hikari:
maximum-pool-size: 10
connection-timeout: 3000
Hikari maximum-pool-size는 인스턴스 수 × 풀이 DB max_connections를 넘지 않게 둔다. 풀을 키우기 전에 슬로우 쿼리와 트랜잭션 길이를 본다.
오프셋 page=100000은 깊게 갈수록 느리다. 피드성 API는 키셋 페이지네이션—이미 본 마지막 id보다 작은 행만 가져오기—이 맞다.
List<Post> findByIdLessThanOrderByIdDesc(Long cursor, Pageable pageable);
큰 JSON은 전용 DTO와 @JsonIgnore로 줄이고, 파일은 오브젝트 스토리지 + CDN으로 뺀다.
5. 동시성과 멱등
재고 차감은 낙관적 락·DB 제약·단일 큐 중 하나를 고른다. Redis 분산 락은 만료·펜싱 토큰 없이 쓰기 쉽다.
@Entity
@Table(name = "idempotency_keys",
uniqueConstraints = @UniqueConstraint(columnNames = "key_value"))
public class IdempotencyKey {
@Id
private String keyValue;
private String responseHash;
}
컨테이너 메모리 한도에 heap을 맞춘다(MaxRAMPercentage). Native Image는 기동·RSS에 유리하지만 빌드와 리플렉션 제약이 있다.
6. 부하 테스트
k6·Gatling·JMeter로 같은 시나리오를 최적화 전후에 돌린다. RPS, p50/p95/p99, 에러율, CPU·힙·GC, DB 커넥션·slow query, 외부 의존성 지연을 같이 본다.
7. 흔한 실수
| 실수 | 대안 |
|---|---|
| 캐시만 넣고 evict 없음 | 갱신 경로에 @CacheEvict |
같은 클래스 내부에서 @Cacheable 호출 |
별도 Bean으로 분리 |
| 사용자별 데이터를 공용 키 | 키에 주체 포함 |
| 비멱등 POST 재시도 | 키·안전한 상태 코드만 |
| 서킷 없이 재시도만 | 실패 폭풍 → 브레이커 |
| 타임아웃 0에 가까운 무한 대기 | connect/read 명시 |
연습
- 상품 조회에
@Cacheable, 수정에 evict를 붙인다. - 외부 호출에 CircuitBreaker + Retry를 적용한다.
- RestClient read timeout을 3초로 둔다.
- 느린 목록을 키셋 페이지네이션으로 바꾼다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.