diff --git a/build.gradle b/build.gradle index 25ff15c..be2a6cd 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,9 @@ dependencies { // Virtual Thread 전용 지표 implementation 'io.micrometer:micrometer-java21' + + // 서킷 브레이커 + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.4.0' } tasks.named('test') { diff --git a/docs/01-requirements.md b/docs/01-requirements.md index 03db430..2d59b3d 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -76,6 +76,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 - 캐시 데이터가 유실돼도 MySQL을 통해 원본 URL을 복구할 수 있어야 한다. - 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다. - 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다. +- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다. ### 확장성 @@ -115,6 +116,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * Redis 장애 시 MySQL Fallback 실험 * 다중 애플리케이션 인스턴스 및 Nginx Failover 실험 * 다중 인스턴스 Snowflake nodeId 검증 +* Redis Circuit Breaker 적용 및 장애 구간 응답 지연 비교 ### 제외 @@ -141,4 +143,5 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * [ ] Redis 장애 시 대응 방법을 확인했다. * [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다. * [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다. -* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. \ No newline at end of file +* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. +* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다. \ No newline at end of file diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 1110287..792deb0 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -81,11 +81,13 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시 ### URL 리다이렉트 -1. `shortCode`로 Redis를 조회한다. +1. Circuit Breaker가 CLOSED이면 `shortCode`로 Redis를 조회한다. 2. Cache Hit이면 원본 URL을 반환한다. 3. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다. 4. Redis 조회에 실패하면 MySQL로 Fallback한다. -5. 원본 URL을 `302 Found`로 반환한다. +5. Redis 실패가 반복돼 Circuit Breaker가 OPEN되면 Redis 호출을 생략하고 바로 MySQL을 조회한다. +6. Redis 복구가 확인되면 Circuit Breaker가 CLOSED로 돌아가 Cache Aside 경로를 다시 사용한다. +7. 원본 URL을 `302 Found`로 반환한다. ### 실패 흐름 @@ -249,15 +251,26 @@ Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해 | DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 | | Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 | | Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 | -| Redis 장애 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | MySQL Fallback 후 자동 복귀 | +| Redis 장애 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | Circuit Breaker OPEN 후 MySQL Fallback | 초기 구조에서는 DB 장애 시 요청을 처리할 대체 저장소가 없다. Redis GET에 실패하면 MySQL로 Fallback한다. -Redis 장애가 확인된 요청에서는 Redis SET을 생략해 Timeout이 중복되지 않도록 했다. +Redis 장애가 확인된 요청에서는 Redis SET을 생략해 +한 요청에서 Redis Timeout이 중복되지 않도록 한다. -이는 기능 지속을 위한 Graceful Degradation이며 Redis 자체의 고가용성을 구성한 것은 아니다. +Redis 실패가 반복돼 Circuit Breaker의 실패율 임계값을 초과하면 +Circuit이 OPEN 상태로 전환된다. + +OPEN 상태에서는 Redis GET 자체를 호출하지 않고 +즉시 MySQL로 Fallback한다. + +일정 시간이 지난 뒤 제한된 요청으로 Redis 복구 여부를 확인하고, +정상 응답이 확인되면 다시 Cache Aside 경로로 복귀한다. + +이는 기능 지속과 장애 구간의 반복 Timeout을 줄이기 위한 +Graceful Degradation이며 Redis 자체를 이중화한 것은 아니다. App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다. @@ -340,6 +353,7 @@ Client * 코드 생성 재시도 횟수 * Redis Cache Error 수 * MySQL Fallback 수 +* Redis Circuit Breaker Rejected 수 ### Logs diff --git a/docs/04-experiment.md b/docs/04-experiment.md index d6ce463..588398d 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -619,6 +619,107 @@ App1 재기동 후 Healthy 상태로 복구됐으며 시스템 전체의 SPOF를 제거한 것은 아니다. 이번 실험은 애플리케이션 계층의 단일 장애 지점을 개선하는 데 범위를 한정한다. +## 19. Redis Circuit Breaker + +Redis 장애 시 MySQL Fallback을 적용해 서비스 가용성은 유지했지만, +각 요청이 Redis Timeout을 기다린 뒤 MySQL로 전환되면서 +장애 구간 p95가 약 200ms까지 증가하는 문제가 남았다. + +Redis 장애가 지속될 때 반복적인 Timeout을 줄이기 위해 +Redis GET 경로에 Circuit Breaker를 적용했다. + +### 설정 + +| 항목 | 값 | +|---|---:| +| Sliding Window | 최근 10건 | +| 최소 호출 수 | 5건 | +| 실패율 임계값 | 50% | +| OPEN 유지 시간 | 5초 | +| HALF_OPEN 시험 호출 | 3건 | +| Redis Timeout | 200ms | + +```text +CLOSED +→ Redis 호출 허용 + +Redis 실패율 임계값 초과 +→ OPEN +→ Redis 호출 차단 +→ MySQL Fallback + +OPEN 5초 경과 +→ HALF_OPEN +→ Redis 시험 호출 + +Redis 복구 확인 +→ CLOSED +→ Cache Aside 경로 복귀 +``` + +### 실험 조건 + +Fallback-only 실험과 동일한 조건으로 측정했다. + +| 항목 | 조건 | +|---|---| +| VU | 100 | +| 실행 시간 | 120초 | +| 정상 구간 | 0~30초 | +| Redis 중지 | 30~60초 | +| 복구 관찰 | 60~120초 | +| Redis Timeout | 200ms | +| HikariCP | 최대 10개 | + +### 결과 + +Redis 장애 직후에는 실제 Redis 호출 실패가 발생하지만, +실패가 누적되면서 Circuit Breaker가 OPEN 상태로 전환된다. + +OPEN 이후에는 Redis 호출 자체가 차단되고 +요청은 즉시 MySQL Fallback 경로로 처리됐다. + +Grafana에서 Circuit Breaker Rejected는 최대 약 5.7K req/s, +MySQL Fallback은 약 5.8K req/s까지 증가했으며, +5xx 오류는 발생하지 않았다. + +장애 구간의 p95는 초기 약 35ms를 기록한 뒤 +대체로 20~30ms 수준으로 유지됐다. +Fallback만 적용했을 때 장애 구간 p95가 약 200ms였던 것과 비교하면, +반복적인 Redis Timeout 대기가 크게 감소했다. + +Redis 복구 후에는 Circuit Breaker Rejected, +MySQL Fallback과 DB Lookup이 다시 0으로 감소했고 +Cache Hit이 증가하면서 정상 조회 경로로 복귀했다. + +### Fallback-only 비교 + +| 지표 | Fallback only | Circuit Breaker | +|---|---:|---:| +| VU | 100 | 100 | +| Redis Timeout | 200ms | 200ms | +| Redis 장애 시간 | 30초 | 30초 | +| 장애 구간 p95 | 약 200ms | 약 20~30ms | +| 장애 구간 p99 | 약 220ms | 약 40~80ms | +| 5xx 오류율 | 0% | 0% | +| Redis 호출 | 장애 중 반복 | OPEN 이후 차단 | +| MySQL Fallback | 발생 | 발생 | +| Redis 복구 후 Cache 복귀 | 성공 | 성공 | + +Fallback만 적용했을 때는 Redis 장애가 전체 서비스 장애로 +이어지는 것은 막을 수 있었지만, +각 요청이 Redis Timeout을 기다린 뒤 DB를 조회하는 문제가 남았다. + +Circuit Breaker 적용 후에는 장애를 감지한 뒤 Redis 호출을 차단해 +MySQL을 바로 조회하도록 변경했다. + +따라서 Fallback은 Redis 장애 시 기능을 유지하고, +Circuit Breaker는 장애가 지속되는 동안 반복적인 Redis 호출과 +Timeout 비용을 줄이는 역할을 한다. + +#### Grafana 측정 결과 + +![Redis Circuit Breaker](images/redis-circuit-breaker-100vu.png) ## 19. 실험 한계 @@ -635,6 +736,8 @@ App1 재기동 후 Healthy 상태로 복구됐으며 - Redis 장애 실험은 프로세스 중지만 재현했으며 네트워크 지연과 패킷 손실은 검증하지 않았다. - Redis 장애 중 더 높은 부하에서는 MySQL과 커넥션 풀이 포화될 수 있다. - 다중 인스턴스 실험은 로컬 Docker 환경에서 App 2개와 Nginx 1개로 수행했으며, 실제 독립 서버 장애를 재현한 것은 아니다. +- Circuit Breaker의 상태 전환은 Rejected 지표를 통해 간접적으로 확인했으며, + CLOSED, OPEN, HALF_OPEN 상태 자체를 별도 메트릭으로 기록하지 않았다. ## 20. 후속 실험 @@ -648,5 +751,5 @@ App1 재기동 후 Healthy 상태로 복구됐으며 - [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교 - [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증 - [x] 다중 애플리케이션 인스턴스와 장애 전환 검증 -- [ ] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소 +- [x] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소 - [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성 diff --git a/docs/images/redis-circuit-breaker-100vu.png b/docs/images/redis-circuit-breaker-100vu.png new file mode 100644 index 0000000..95e4e33 Binary files /dev/null and b/docs/images/redis-circuit-breaker-100vu.png differ diff --git a/monitoring/grafana/dashboards/spring-boot-overview.json b/monitoring/grafana/dashboards/spring-boot-overview.json index 3e88536..884100f 100644 --- a/monitoring/grafana/dashboards/spring-boot-overview.json +++ b/monitoring/grafana/dashboards/spring-boot-overview.json @@ -4,7 +4,26 @@ "metadata": { "name": "spring-boot-overview", "namespace": "default", - "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9" + "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9", + "resourceVersion": "1786074716204019", + "generation": 3, + "creationTimestamp": "2026-08-06T07:12:35Z", + "labels": { + "grafana.app/deprecatedInternalID": "96116541288448" + }, + "annotations": { + "grafana.app/createdBy": "access-policy:service", + "grafana.app/folder": "system-design", + "grafana.app/managedBy": "classic-file-provisioning", + "grafana.app/managerId": "System Design Dashboards", + "grafana.app/sourceChecksum": "2a0fb7954a76a3c3df21deb5e3d2d830", + "grafana.app/sourcePath": "/var/lib/grafana/dashboards/spring-boot-overview.json", + "grafana.app/sourceTimestamp": "1786074605000", + "grafana.app/updatedBy": "access-policy:service", + "grafana.app/updatedTimestamp": "2026-08-07T03:51:56Z", + "grafana.app/folderTitle": "System Design", + "grafana.app/folderUrl": "/dashboards/f/system-design/system-design" + } }, "spec": { "annotations": [ @@ -716,7 +735,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(\n short_url_cache_error_total{\n application=\"url-shortener\",\n operation=\"get\"\n }[$__rate_interval]\n)", + "expr": "rate(\n short_url_cache_error_total{\n application=\"url-shortener\",\n operation=\"get\"\n }[10s]\n)", "legendFormat": "Redis GET Error/s", "range": true }, @@ -737,7 +756,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(\n short_url_cache_fallback_total{\n application=\"url-shortener\"\n }[$__rate_interval]\n)", + "expr": "rate(\n short_url_cache_fallback_total{\n application=\"url-shortener\"\n }[10s]\n)", "instant": false, "legendFormat": "MySQL Fallback/s", "range": true @@ -840,6 +859,126 @@ } } }, + "panel-15": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "prometheus" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "rate(short_url_cache_circuit_rejected_total[10s])", + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 15, + "links": [], + "title": "Redis Circuit Breaker Rejected", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.1.1" + } + } + }, "panel-2": { "kind": "Panel", "spec": { @@ -1852,7 +1991,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-1" + "name": "panel-2" }, "height": 8, "width": 8, @@ -1865,7 +2004,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-2" + "name": "panel-3" }, "height": 8, "width": 8, @@ -1878,7 +2017,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-3" + "name": "panel-7" }, "height": 8, "width": 8, @@ -1891,7 +2030,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-4" + "name": "panel-14" }, "height": 8, "width": 8, @@ -1904,7 +2043,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-5" + "name": "panel-8" }, "height": 8, "width": 8, @@ -1917,7 +2056,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-6" + "name": "panel-15" }, "height": 8, "width": 8, @@ -1930,7 +2069,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-7" + "name": "panel-4" }, "height": 8, "width": 8, @@ -1943,7 +2082,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-8" + "name": "panel-5" }, "height": 8, "width": 8, @@ -1956,7 +2095,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-9" + "name": "panel-6" }, "height": 8, "width": 8, @@ -1969,10 +2108,10 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-10" + "name": "panel-1" }, "height": 8, - "width": 12, + "width": 8, "x": 0, "y": 24 } @@ -1982,11 +2121,11 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-14" + "name": "panel-13" }, "height": 8, - "width": 12, - "x": 12, + "width": 8, + "x": 8, "y": 24 } }, @@ -1995,7 +2134,20 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-11" + "name": "panel-9" + }, + "height": 8, + "width": 8, + "x": 16, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-10" }, "height": 8, "width": 8, @@ -2021,7 +2173,7 @@ "spec": { "element": { "kind": "ElementReference", - "name": "panel-13" + "name": "panel-11" }, "height": 8, "width": 8, @@ -2062,4 +2214,4 @@ "title": "Spring Boot Overview", "variables": [] } -} +} \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/config/RedisCircuitBreakerConfig.java b/src/main/java/com/backendsystemdesignlab/urlshortener/config/RedisCircuitBreakerConfig.java new file mode 100644 index 0000000..c851ab5 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/config/RedisCircuitBreakerConfig.java @@ -0,0 +1,29 @@ +package com.backendsystemdesignlab.urlshortener.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataAccessException; + +import java.time.Duration; + +@Configuration +public class RedisCircuitBreakerConfig { + + @Bean + public CircuitBreaker redisCircuitBreaker() { + CircuitBreakerConfig config = CircuitBreakerConfig.custom() + .failureRateThreshold(50) // 실패율 >= 50% + .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // COUNT_BASED: 최근 N번 호출, TIME_BASED: 최근 N초 동안 호출 + .slidingWindowSize(10) // 최근 10건 기준 + .minimumNumberOfCalls(5) // 최소 5건이 쌓인 뒤 (5개중 3개 실패시 OPEN == 차단) + .waitDurationInOpenState(Duration.ofSeconds(5)) // 5초 동안 Redis 호출 차단 => CallNotPermittedException + .permittedNumberOfCallsInHalfOpenState(3) // 5초 후 HALF_OPEN(Redis가 복구됐는지 시험): 3건 시험 호출 + .automaticTransitionFromOpenToHalfOpenEnabled(true) + .recordException(throwable -> throwable instanceof DataAccessException) + .build(); + + return CircuitBreaker.of("redis", config); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/metrics/RedirectMetrics.java b/src/main/java/com/backendsystemdesignlab/urlshortener/metrics/RedirectMetrics.java index 27cf5ff..2312d7a 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/metrics/RedirectMetrics.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/metrics/RedirectMetrics.java @@ -13,6 +13,7 @@ public class RedirectMetrics { private final Counter cacheSetErrorCounter; private final Counter cacheFallbackCounter; private final Counter dbLookupCounter; + private final Counter circuitBreakerRejectedCounter; public RedirectMetrics(MeterRegistry meterRegistry) { this.cacheHitCounter = Counter.builder("short_url.cache.hit") @@ -40,6 +41,10 @@ public RedirectMetrics(MeterRegistry meterRegistry) { this.dbLookupCounter = Counter.builder("short_url.db.lookup") .description("Short URL database lookup count") .register(meterRegistry); + + this.circuitBreakerRejectedCounter = Counter.builder("short_url.cache.circuit.rejected") + .description("Redis calls rejected by circuit breaker") + .register(meterRegistry); } public void recordCacheHit() { @@ -54,4 +59,5 @@ public void recordCacheMiss() { public void recordDbLookup() { dbLookupCounter.increment(); } + public void recordCircuitBreakerRejected() { circuitBreakerRejectedCounter.increment(); } } diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java index 5a63084..c650284 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/service/RedirectService.java @@ -5,6 +5,8 @@ import com.backendsystemdesignlab.urlshortener.metrics.RedirectMetrics; import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; @@ -18,14 +20,20 @@ public class RedirectService { private final ShortUrlRepository shortUrlRepository; private final ShortUrlCache shortUrlCache; private final RedirectMetrics redirectMetrics; + private final CircuitBreaker redisCircuitBreaker; public String findLongUrl(String shortCode) { try { - Optional cachedLongUrl = shortUrlCache.find(shortCode); + Optional cachedLongUrl = redisCircuitBreaker.executeSupplier(() -> shortUrlCache.find(shortCode)); if (cachedLongUrl.isPresent()) { return cachedLongUrl.get(); } + } catch (CallNotPermittedException e) { // Circuit OPEN + redirectMetrics.recordCircuitBreakerRejected(); + redirectMetrics.recordCacheFallback(); + + return findFromDatabase(shortCode, false); } catch (DataAccessException e) { redirectMetrics.recordCacheGetError(); redirectMetrics.recordCacheFallback(); diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java index 6b26fac..d2d1bad 100644 --- a/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java +++ b/src/test/java/com/backendsystemdesignlab/urlshortener/service/RedirectServiceTest.java @@ -1,21 +1,26 @@ package com.backendsystemdesignlab.urlshortener.service; import com.backendsystemdesignlab.urlshortener.cache.ShortUrlCache; +import com.backendsystemdesignlab.urlshortener.config.RedisCircuitBreakerConfig; import com.backendsystemdesignlab.urlshortener.exception.ShortUrlNotFoundException; import com.backendsystemdesignlab.urlshortener.metrics.RedirectMetrics; import com.backendsystemdesignlab.urlshortener.url.domain.ShortUrl; import com.backendsystemdesignlab.urlshortener.url.repository.ShortUrlRepository; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.data.redis.RedisConnectionFailureException; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.BDDMockito.*; @ExtendWith(MockitoExtension.class) @@ -31,6 +36,14 @@ class RedirectServiceTest { @Mock private RedirectMetrics redirectMetrics; + private CircuitBreaker circuitBreaker; + + @BeforeEach + void setUp() { + circuitBreaker = new RedisCircuitBreakerConfig().redisCircuitBreaker(); + redirectService = new RedirectService(shortUrlRepository, shortUrlCache, redirectMetrics, circuitBreaker); + } + @Test void 캐시에_URL이_있으면_DB를_조회하지_않는다() { String shortCode = "2TX"; @@ -132,4 +145,44 @@ class RedirectServiceTest { then(shortUrlCache).should().save(shortCode, longUrl); then(redirectMetrics).should().recordCacheSetError();; } + + @Test + void Redis_장애가_반복되면_Circuit이_열리고_이후_Redis_호출을_차단한다() { + String shortCode = "2TX"; + String longUrl = "https://www.google.com"; + + ShortUrl shortUrl = ShortUrl.create(longUrl); + + given(shortUrlCache.find(shortCode)) + .willThrow(new DataAccessResourceFailureException("Redis down")); + + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.of(shortUrl)); + + for (int i = 0; i < 5; i++) { + redirectService.findLongUrl(shortCode); + } + + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + redirectService.findLongUrl(shortCode); + then(shortUrlCache).should(times(5)).find(shortCode); + then(shortUrlRepository).should(times(6)).findByShortCode(shortCode); + } + + @Test + void Circuit이_OPEN이면_Redis를_조회하지_않고_DB로_Fallback한다() { + String shortCode = "2TX"; + String longUrl = "https://www.google.com"; + + ShortUrl shortUrl = ShortUrl.create(longUrl); + + given(shortUrlRepository.findByShortCode(shortCode)).willReturn(Optional.of(shortUrl)); + + circuitBreaker.transitionToOpenState(); + + String result = redirectService.findLongUrl(shortCode); + + assertThat(result).isEqualTo(longUrl); + then(shortUrlCache).shouldHaveNoInteractions(); + then(shortUrlRepository).should().findByShortCode(shortCode); + } } \ No newline at end of file