학습 테스트로 배우는 서킷브레이커(Resilience4j)
Resilience4j로 Circuit Breaker 적용하며 배운 것들
안녕하세요. 최근 사이드 프로젝트에서 외부 API 연동 부분에 Circuit Breaker 패턴을 적용하면서 배운 내용을 공유하고자 합니다.
외부 API를 호출하는 시스템을 운영하다 보면 네트워크 지연이나 장애 상황은 피할 수 없습니다. 이런 상황에서 시스템의 안정성을 높이기 위해 Circuit Breaker 패턴을 적용해보기로 했습니다.
Circuit Breaker에 대한 이론적인 설명은 이미 많은 글들이 있어서, 이번 글에서는 실제 코드에 적용하면서 겪은 경험을 중심으로 작성하겠습니다.
실제 적용 사례
저는 전자도서관 API를 호출하는 부분에 Resilience4j의 Circuit Breaker를 적용했습니다.
@CircuitBreaker(name = "smallBusiness", fallbackMethod = "smallBusinessFallbackMethod")
override fun smallBusiness(searchKeyword: String): LibrarySearchServiceResponse {
// API 호출 및 데이터 처리 로직
}
fallback 메서드는 대체 호출할 서비스가 없어서 빈 응답을 제공하도록 구현했습니다:
fun smallBusinessFallbackMethod(searchKeyword: String, throwable: Throwable): LibrarySearchServiceResponse {
log.warn("SmallBusiness API 호출 실패: ${throwable.message}")
return LibrarySearchServiceResponse.of(
bookDtoList = emptyList(),
bookSearchTotalCount = 0,
moreViewLink = emptyList(),
libraryTypeText = LibraryType.SMALL_BUSINESS.koreanText
)
}
Circuit Breaker 설정
resilience4j:
circuitbreaker:
configs:
default:
sliding-window-size: 10 # 최근 10개의 요청을 모니터링하여 실패율 계산
minimum-number-of-calls: 10 # 서킷 브레이커가 작동하기 위한 최소 요청 수 (10개 미만이면 실패율 계산하지 않음)
wait-duration-in-open-state: 3000 # OPEN 상태에서 HALF-OPEN 상태로 전환되기 전 대기 시간(밀리초) 현재 3초
failure-rate-threshold: 50 # 서킷 브레이커가 OPEN 상태로 전환되는 실패율 임계값(%) 현재 50%
permitted-number-of-calls-in-half-open-state: 10 # HALF-OPEN 상태에서 10개의 테스트 요청만 허용
automatic-transition-from-open-to-half-open-enabled: true # OPEN에서 HALF-OPEN으로 자동 전환 여부 true로 설정하면 wait-duration 이후 자동으로 HALF-OPEN으로 전환
record-failure-predicate: com.example.config.CustomFailurePredicate # 어떤 예외를 실패로 간주할지 정의하는 클래스의 경로
instances:
smallBusiness:
base-config: default
timelimiter:
configs:
default:
timeout-duration:
seconds: 5 # 5초 이상 걸리는 요청은 타임아웃 처리
Circuit Breaker 테스트 꿀팁
테스트 환경에서는 automatic-transition-from-open-to-half-open-enabled 값을 true로 설정하는 것을 강력히 추천드립니다. 이 설정의 기본값은 false인데, false로 설정된 경우 HALF-OPEN 상태로 전환되기 위해서는 대기 시간이 지난 후에 추가로 요청이 한 번 더 와야 합니다.
이로 인해 테스트 코드에서 HALF-OPEN 상태 전환을 검증할 때마다 불필요한 추가 메서드 호출이 필요해져 테스트가 복잡해집니다. 실제로 저도 이 설정 때문에 반나절 동안 테스트 실패 원인을 찾느라 고생을 했습니다.
실제 운영 환경에서는 시스템 요구사항에 따라 이 값을 적절히 설정하시면 되지만, 테스트 환경에서는 true로 설정하여 불필요한 복잡성을 제거하는 것이 좋습니다.
Circuit Breaker 학습 테스트로 검증하기
package com.example.config
import com.example.domain.LibraryType
import com.example.domain.SmallBusiness
import com.example.infrastructure.*
import com.example.service.FunIntegrationTest
import com.example.service.LibrarySearchServiceImpl
import com.ninjasquad.springmockk.MockkBean
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.verify
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.ResourceAccessException
class CircuitBreakerTest @Autowired constructor(
private val libraryService: LibrarySearchServiceImpl,
private val circuitBreakerRegistry: CircuitBreakerRegistry,
@MockkBean(relaxed = true)
private val smallBusinessLibraryReader: SmallBusinessLibraryReader
) : FunIntegrationTest() {
private lateinit var circuitBreaker: CircuitBreaker
override fun initTest() {
beforeTest {
circuitBreaker = circuitBreakerRegistry.circuitBreaker("smallBusiness")
circuitBreaker.reset()
clearAllMocks()
}
context("서킷 브레이커 기본 동작 테스트") {
test("서킷 브레이커가 작동하기 위한 최소 요청 수 10개 이전에는 CLOSED") {
// given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
val roopNumber = 9
// when
repeat(roopNumber) { libraryService.smallBusiness(searchKeyword) }
// then
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
verify(exactly = roopNumber) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
test("서킷 브레이커가 작동하기 위한 최소 요청 수 10개 초과 시 OPEN 으로 전환") {
// given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
// when
repeat(10) {
libraryService.smallBusiness(searchKeyword)
}
// then
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
verify(exactly = 10) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
}
context("실패율 임계값 테스트") {
test("50% 미만 실패율에서는 서킷이 CLOSED 유지") {
//given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
val responses = listOf(
"success",
"success",
"success",
"success",
"success",
"success"
)
var callCount = 0
every { smallBusinessLibraryReader.getHtml(searchUrl) } answers {
callCount++
if (callCount <= 4) {
throw ResourceAccessException("실패")
} else {
responses[callCount - 5]
}
}
//when
repeat(10) {
libraryService.smallBusiness(searchKeyword)
}
//then
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
}
test("50% 이상 실패율에서는 서킷이 OPEN으로 전환") {
//given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
//when
repeat(10) { index ->
if (index % 5 < 3) { // 60% 실패
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
} else {
every { smallBusinessLibraryReader.getHtml(searchUrl) } returns "success"
}
libraryService.smallBusiness(searchKeyword)
}
//then
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
}
}
context("HALF-OPEN 상태 테스트") {
test("HALF-OPEN 상태에서 설정한 성공률 미만 응답후 OPEN 상태로 변경 시 초과 호출 수는 응답하지 않는다") {
// given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
// OPEN 상태로 전환
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
repeat(10) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
// HALF-OPEN 상태로 전환
Thread.sleep(3500)
circuitBreaker.state shouldBe CircuitBreaker.State.HALF_OPEN
// 일부 호출 실패 설정
var callCount = 0
every { smallBusinessLibraryReader.getHtml(searchUrl) } answers {
callCount++
if (callCount <= 6) {
throw ResourceAccessException("Network error")
} else {
"success"
}
}
repeat(15) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
verify(exactly = 20) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
test("HALF-OPEN 상태에서 일부 실패 시 다시 OPEN으로 전환") {
// given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
// OPEN 상태로 전환
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
repeat(10) { libraryService.smallBusiness(searchKeyword) }
// HALF-OPEN 상태로 전환
Thread.sleep(3500)
circuitBreaker.state shouldBe CircuitBreaker.State.HALF_OPEN
// 일부 호출 실패 설정
repeat(10) { index ->
if (index < 6) {
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
} else {
every { smallBusinessLibraryReader.getHtml(searchUrl) } returns "success"
}
libraryService.smallBusiness(searchKeyword)
}
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
}
}
test("타임아웃 발생 시 fallback 메소드 응답 값을 반환한다.") {
// given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
every { smallBusinessLibraryReader.getHtml(searchUrl) } answers {
Thread.sleep(5001) // 5초 타임아웃 초과
"delayed response"
}
// when
val result = libraryService.smallBusiness(searchKeyword)
// then
result.bookDtoList shouldBe emptyList()
result.bookSearchTotalCount shouldBe 0
result.moreViewLink shouldBe emptyList()
verify(exactly = 1) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
test("4xx 에러는 실패로 기록하지 않기때문에 CLOSED 상태이다") {
//given
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
HttpClientErrorException(HttpStatus.BAD_REQUEST)
//when
repeat(10) { libraryService.smallBusiness(searchKeyword) }
//then
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
}
context("서킷브레이커 전체 흐름 테스트") {
test("CLOSED -> OPEN -> HALF_OPEN ->CLOSED") {
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
repeat(10) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
Thread.sleep(3500)
circuitBreaker.state shouldBe CircuitBreaker.State.HALF_OPEN
every { smallBusinessLibraryReader.getHtml(searchUrl) } returns "success"
repeat(10) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
verify(exactly = 20) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
test("CLOSED -> OPEN -> HALF_OPEN ->OPEN") {
val searchKeyword = "테스트"
val searchUrl = SmallBusiness.basicUrlCreate(searchKeyword)
circuitBreaker.state shouldBe CircuitBreaker.State.CLOSED
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
repeat(10) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
Thread.sleep(3500)
circuitBreaker.state shouldBe CircuitBreaker.State.HALF_OPEN
every { smallBusinessLibraryReader.getHtml(searchUrl) } throws
ResourceAccessException("Network error")
repeat(10) { libraryService.smallBusiness(searchKeyword) }
circuitBreaker.state shouldBe CircuitBreaker.State.OPEN
verify(exactly = 20) { smallBusinessLibraryReader.getHtml(searchUrl) }
}
}
}
}
####
1. 기본 동작 테스트
최소 요청 수 검증: 설정된 최소 요청 수(10개) 이전까지는 실패해도 CLOSED 상태를 유지하는지 확인
상태 전환 검증: 최소 요청 수 이상에서 실패 시 OPEN 상태로 전환되는지 확인
2. 실패율 임계값 테스트
임계값 미만 케이스: 40% 실패율(10번 중 4번 실패)에서 CLOSED 상태 유지 검증
임계값 초과 케이스: 60% 실패율(5번 중 3번 실패)에서 OPEN 상태 전환 검증
3. HALF-OPEN 상태 테스트
부분 요청 처리:
HALF-OPEN 상태에서 설정된 요청 수(10개)만 처리
추가 요청(15번 시도)은 차단되는지 확인
실패가 많은 경우 다시 OPEN 상태로 전환
상태 전환 검증:
HALF-OPEN 상태에서 60% 실패율로 요청 시
OPEN 상태로 다시 전환되는지 확인
4. 예외 처리 테스트
타임아웃 처리: 5초 초과 시 fallback 응답 검증
클라이언트 에러 처리: 4xx 에러는 실패로 집계하지 않고 CLOSED 상태 유지 확인
5. 전체 상태 흐름 테스트
정상 복구 시나리오 (CLOSED → OPEN → HALF-OPEN → CLOSED)
초기 CLOSED 상태
연속 실패로 OPEN 전환
대기 시간 후 HALF-OPEN 전환
성공적인 요청으로 CLOSED 복귀
실패 지속 시나리오 (CLOSED → OPEN → HALF-OPEN → OPEN)
초기 CLOSED 상태
연속 실패로 OPEN 전환
대기 시간 후 HALF-OPEN 전환
추가 실패로 다시 OPEN 전환
각 테스트는 상태 전환과 Thread.sleep()을 통해 시간 기반 상태 전환도 테스트했습니다. 이러한 테스트를 통해 Circuit Breaker가 다양한 상황에서 어떻게 동작하는지 실제로 확인할 수 있었고, 설정값들이 실제로 어떤 영향을 미치는지 이해할 수 있었습니다.
정리
이번에 처음으로 학습 테스트를 작성하면서, 새로운 기술을 익히는 매우 효과적인 방법이라는 것을 경험했습니다. 특히 Circuit Breaker처럼 여러 상태 전환과 조건들을 꼼꼼히 확인해야 하는 기술을 학습할 때 더욱 그렇다는 것을 체감했습니다.
문서로만 학습했다면 추상적으로 이해하고 넘어갔을 Circuit Breaker의 동작 방식을, 다양한 시나리오의 테스트 코드를 직접 작성하고 실행하면서 깊이 있게 이해할 수 있었습니다. 이론적으로만 알고 있던 내용들을 실제 코드로 검증하면서, 더 명확하고 구체적인 지식으로 습득할 수 있었습니다.
Comments