Merge branch 'feature/circuit-breaker-test' into master

This commit is contained in:
curry772
2026-04-29 14:47:15 +09:00
2 changed files with 460 additions and 13 deletions
@@ -17,10 +17,6 @@ import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.custom.alarm.AlarmStateManager;
import com.eactive.eai.custom.alarm.condition.AlarmCondition;
import com.eactive.eai.custom.alarm.event.AlarmEvent;
import com.eactive.eai.custom.alarm.key.CoreAlarmKey;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -45,7 +41,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
TypedCircuitBreakerVO defaultConfigVo;
HashMap<String, TypedCircuitBreakerVO> idCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> apiIdCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
// HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
private boolean started;
@Autowired
@@ -116,7 +112,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
CircuitBreakerConfig circuitBreakerConfig = this.createCircuitBreakerConfig(vo);
vo.setCircuitBreakerConfig(circuitBreakerConfig);
this.apiIdCBConfigMap.put(vo.getName(), vo);
this.idCBConfigMap.remove(vo.getId(), vo);
this.idCBConfigMap.put(vo.getId(), vo);
}
}
@@ -359,14 +355,15 @@ public class TypedCircuitBreakerManager implements Lifecycle {
// } else {
// this.sendAlert(stateTransitionEvent);
// }
AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
.condition(AlarmCondition.builder().build())
.message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
.build();
AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
// AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
// .condition(AlarmCondition.builder().build())
// .message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
// .build();
//
// AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
TypedCircuitBreakerManager.logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
} else {
TypedCircuitBreakerManager.logger.error(
@@ -0,0 +1,450 @@
package com.eactive.eai.common.circuitBreaker.type;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
/**
* TypedCircuitBreakerManager 단위 테스트
*
* DB / 실 Spring 컨텍스트 없이 동작한다.
* - PropManager : Mockito Mock (DB 없이 설정값 반환)
* - TypedCircuitBreakerDAO : Mockito Mock (JPA 없이 VO 반환)
* - Spring Bean : GenericApplicationContext 로 수동 구성
*
* 테스트 격리:
* 각 테스트는 고유한 apiId 를 사용하거나 manager.reload() 로 레지스트리를 초기화한다.
* manager.reload() 는 CircuitBreakerRegistry 를 새로 생성하므로 이전 상태가 제거된다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class TypedCircuitBreakerManagerTest {
// 슬라이딩 윈도우·최소 호출을 4로 설정해 빠른 상태 전이를 유도한다.
private static final int DEFAULT_FAILURE_RATE = 50;
private static final int DEFAULT_SLIDING_WINDOW = 4;
private static final int DEFAULT_MIN_CALLS = 4;
private static final int DEFAULT_WAIT_DURATION_SEC = 1; // OPEN 대기 1초 (테스트용)
private static final int DEFAULT_HALF_OPEN_PERMITS = 2;
private static final int DEFAULT_SLOW_RATE = 100;
private static final int DEFAULT_SLOW_DURATION_SEC = 5;
private static GenericApplicationContext ctx;
private static PropManager mockPropManager;
private static TypedCircuitBreakerDAO mockDao;
private static TypedCircuitBreakerManager manager;
// -------------------------------------------------------------------------
// 픽스처 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpClass() throws Exception {
mockPropManager = mock(PropManager.class);
mockDao = mock(TypedCircuitBreakerDAO.class);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
// TypedCircuitBreakerManager 인스턴스 생성 후 DAO 리플렉션 주입
manager = new TypedCircuitBreakerManager();
injectField(manager, "dao", mockDao);
// DB / 실 Spring 없이 최소 컨텍스트 구성
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
ctx.getBeanFactory().registerSingleton("typedCircuitBreakerManager", manager);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
manager.start();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void resetMocks() throws Exception {
reset(mockDao);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void setupDefaultPropManagerMock() {
when(mockPropManager.getProperty("CircuitBreaker", "failureRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_FAILURE_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slidingWindowSize"))
.thenReturn(String.valueOf(DEFAULT_SLIDING_WINDOW));
when(mockPropManager.getProperty("CircuitBreaker", "minimumNumberOfCalls"))
.thenReturn(String.valueOf(DEFAULT_MIN_CALLS));
when(mockPropManager.getProperty("CircuitBreaker", "waitDurationInOpenState"))
.thenReturn(String.valueOf(DEFAULT_WAIT_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "permittedNumberOfCallsInHalfOpenState"))
.thenReturn(String.valueOf(DEFAULT_HALF_OPEN_PERMITS));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallDurationThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("1");
}
private static void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/** 테스트용 TypedCircuitBreakerVO 생성 (기본 설정값 사용) */
private TypedCircuitBreakerVO buildVo(String id, String apiId, String useYn) {
return TypedCircuitBreakerVO.builder()
.id(id).name(apiId).type("COUNT_BASED").desc(apiId + " 테스트 설정")
.failureRateThreshold(DEFAULT_FAILURE_RATE)
.slidingWindowSize(DEFAULT_SLIDING_WINDOW)
.minimumNumberOfCalls(DEFAULT_MIN_CALLS)
.waitDurationInOpenState(DEFAULT_WAIT_DURATION_SEC)
.permittedNumberOfCallsInHalfOpenState(DEFAULT_HALF_OPEN_PERMITS)
.slowCallRateThreshold(DEFAULT_SLOW_RATE)
.slowCallDurationThreshold(DEFAULT_SLOW_DURATION_SEC)
.useYn(useYn)
.build();
}
/** CB를 count 회 실패 처리한다 */
private void recordFailures(CircuitBreaker cb, int count) {
for (int i = 0; i < count; i++) {
try {
cb.executeCallable(() -> { throw new RuntimeException("테스트 강제 오류"); });
} catch (Exception ignored) {}
}
}
// =========================================================================
// 1. getCircuitBreaker - 기본 조회 동작
// =========================================================================
@Test
@DisplayName("1-1. 등록된 apiId → 해당 설정의 CircuitBreaker 반환")
void testGetCircuitBreaker_registeredApiId_returnsTypedCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-001", "API_REG", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_REG");
assertNotNull(cb);
assertEquals("API_REG", cb.getName());
}
@Test
@DisplayName("1-2. 미등록 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_unregisteredApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_UNKNOWN");
assertNotNull(cb, "미등록 apiId 는 default CB 를 반환해야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-3. useYn=0 인 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_disabledApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-002", "API_DISABLED", "0")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_DISABLED");
assertNotNull(cb, "비활성 apiId 는 default CB 로 fallback 되어야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-4. apiId 도 default 도 비활성(useYn=0) → null 반환")
void testGetCircuitBreaker_defaultDisabled_returnsNull() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("0");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNull(cb, "default 도 비활성이면 null 을 반환해야 한다");
}
@Test
@DisplayName("1-5. PropManager useYn=Y → normalizeUseYn 으로 '1' 정규화 → default 활성")
void testGetCircuitBreaker_defaultUseYn_Y_isNormalizedToEnabled() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("Y");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNotNull(cb, "PropManager useYn=Y 는 활성으로 정규화되어야 한다");
assertEquals("default", cb.getName());
}
// =========================================================================
// 2. apiId 별 독립성
// =========================================================================
@Test
@DisplayName("2-1. 서로 다른 apiId → 독립적인 CircuitBreaker 인스턴스 반환")
void testDifferentApiIds_haveIndependentCircuitBreakerInstances() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-A", "API_INDEP_A", "1"),
buildVo("uuid-B", "API_INDEP_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_INDEP_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_INDEP_B");
assertNotNull(cbA);
assertNotNull(cbB);
assertNotSame(cbA, cbB, "서로 다른 apiId 는 다른 CB 인스턴스여야 한다");
assertEquals("API_INDEP_A", cbA.getName());
assertEquals("API_INDEP_B", cbB.getName());
}
@Test
@DisplayName("2-2. API_A 실패 → OPEN, API_B 는 CLOSED 유지 (상태 격리)")
void testDifferentApiIds_statesAreIsolated() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-ISO-A", "API_ISO_A", "1"),
buildVo("uuid-ISO-B", "API_ISO_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_ISO_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_ISO_B");
// API_ISO_A 만 minimumNumberOfCalls=4 회 실패
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "API_ISO_A 는 OPEN 이어야 한다");
assertEquals(CircuitBreaker.State.CLOSED, cbB.getState(), "API_ISO_B 는 CLOSED 를 유지해야 한다");
}
// =========================================================================
// 3. 상태 전이
// =========================================================================
@Test
@DisplayName("3-1. 실패율 50% 초과(4/4 실패) → CLOSED → OPEN")
void testCircuitBreaker_closedToOpen_afterFailureThresholdExceeded() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-001", "API_ST_OPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_OPEN");
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "초기 상태는 CLOSED 여야 한다");
// minimumNumberOfCalls=4, failureRateThreshold=50% → 4/4 실패(100%) → OPEN
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(), "실패율 초과 시 OPEN 상태여야 한다");
}
@Test
@DisplayName("3-2. 실패율 미달(1/4 실패, 25%) → CLOSED 유지")
void testCircuitBreaker_belowFailureThreshold_remainsClosed() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-002", "API_ST_BELOW", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BELOW");
// 4회 중 3회 성공, 1회 실패 → 실패율 25% < 50% → CLOSED 유지
// Resilience4j 는 >= 비교: 50% >= 50% 는 OPEN 이므로 25% 로 낮춰야 CLOSED 유지
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
recordFailures(cb, 1);
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "실패율 25% (< threshold 50%) 이면 CLOSED 를 유지해야 한다");
}
@Test
@DisplayName("3-3. CB OPEN 상태 → 모든 요청 즉시 차단 (CallNotPermittedException)")
void testCircuitBreaker_open_blocksAllRequests() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-003", "API_ST_BLOCK", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BLOCK");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
assertThrows(CallNotPermittedException.class,
() -> cb.executeCallable(() -> "도달하면 안 됨"),
"OPEN 상태에서 CallNotPermittedException 이 발생해야 한다");
}
@Test
@DisplayName("3-4. OPEN → waitDuration(1초) 경과 → 첫 요청 시 HALF_OPEN 전이")
void testCircuitBreaker_open_transitionsToHalfOpenAfterWaitDuration() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-004", "API_ST_HALFOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_HALFOPEN");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
// waitDurationInOpenState = 1초 대기 후 요청 → HALF_OPEN 전이
Thread.sleep(1200);
try { cb.executeCallable(() -> "HALF_OPEN 트리거"); } catch (Exception ignored) {}
assertEquals(CircuitBreaker.State.HALF_OPEN, cb.getState(),
"waitDuration 경과 후 첫 요청 시 HALF_OPEN 상태여야 한다");
}
@Test
@DisplayName("3-5. HALF_OPEN → 성공(permittedCalls=2회) → CLOSED 복구")
void testCircuitBreaker_halfOpen_transitionsToClosedOnSuccess() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-005", "API_ST_RECOVER", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_RECOVER");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 회 성공
for (int i = 0; i < DEFAULT_HALF_OPEN_PERMITS; i++) {
final String result = cb.executeCallable(() -> "성공");
assertEquals("성공", result);
}
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(),
"HALF_OPEN 에서 허용 호출 수 이상 성공하면 CLOSED 로 복구되어야 한다");
}
@Test
@DisplayName("3-6. HALF_OPEN → 실패 → 다시 OPEN 재전이")
void testCircuitBreaker_halfOpen_transitionsBackToOpenOnFailure() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-006", "API_ST_REOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_REOPEN");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 회 모두 실패해야 OPEN 으로 재전이된다.
// Resilience4j 는 허용 호출 수(2회)를 모두 소진한 후에 실패율을 평가하므로
// 1회만 실패하면 HALF_OPEN 상태가 유지된다.
recordFailures(cb, DEFAULT_HALF_OPEN_PERMITS);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(),
"HALF_OPEN 에서 허용 호출 수(2회) 모두 실패하면 다시 OPEN 으로 전이되어야 한다");
}
// =========================================================================
// 4. reload
// =========================================================================
@Test
@DisplayName("4-1. reload(key) → CircuitBreakerConfig 갱신 (waitDuration 1초 → 60초)")
void testReload_key_updatesCircuitBreakerConfig() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-001", "API_RELOAD", "1")));
manager.reload();
// waitDurationInOpenState 60초로 변경
TypedCircuitBreakerVO updated = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-001").name("API_RELOAD").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(60)
.permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("1").build();
when(mockDao.get("uuid-RLD-001")).thenReturn(updated);
manager.reload("uuid-RLD-001");
CircuitBreaker cb = manager.getCircuitBreaker("API_RELOAD");
assertEquals(60,
cb.getCircuitBreakerConfig().getWaitDurationInOpenState().getSeconds(),
"reload 후 waitDurationInOpenState 가 60초로 갱신되어야 한다");
}
@Test
@DisplayName("4-2. reload(key) useYn=0 → 맵에서 제거 → default fallback")
void testReload_key_disabledVo_removesFromMap() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-002", "API_RM", "1")));
manager.reload();
assertEquals("API_RM", manager.getCircuitBreaker("API_RM").getName());
// useYn=0 으로 비활성화
TypedCircuitBreakerVO disabled = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-002").name("API_RM").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(1).permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("0").build();
when(mockDao.get("uuid-RLD-002")).thenReturn(disabled);
manager.reload("uuid-RLD-002");
CircuitBreaker cb = manager.getCircuitBreaker("API_RM");
assertEquals("default", cb.getName(),
"비활성화된 CB 는 맵에서 제거 후 default 로 fallback 되어야 한다");
}
@Test
@DisplayName("4-3. reload() 전체 → CircuitBreakerRegistry 재생성으로 이전 CB 상태 초기화")
void testReload_all_refreshesAllCircuitBreakers() throws Exception {
// 초기: API_FULL_A 등록 후 OPEN 상태로 만들기
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-FULL-A", "API_FULL_A", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_FULL_A");
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "전체 reload 전 API_FULL_A 는 OPEN 이어야 한다");
// 전체 reload → CircuitBreakerRegistry 재생성
// init() 은 apiIdCBConfigMap 을 초기화하지 않으므로 API_FULL_A 는 맵에 남아있다.
// 하지만 새 레지스트리를 생성하므로 CB 상태(OPEN)는 초기화(CLOSED)된다.
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-FULL-A", "API_FULL_A", "1"),
buildVo("uuid-FULL-B", "API_FULL_B", "1")));
manager.reload();
// 레지스트리 재생성으로 API_FULL_A CB 상태가 CLOSED 로 초기화되어야 한다
CircuitBreaker cbAAfter = manager.getCircuitBreaker("API_FULL_A");
assertEquals(CircuitBreaker.State.CLOSED, cbAAfter.getState(),
"전체 reload 후 CircuitBreakerRegistry 재생성으로 이전 CB 상태가 CLOSED 로 초기화되어야 한다");
// 새 apiId 도 정상 등록
assertEquals("API_FULL_B", manager.getCircuitBreaker("API_FULL_B").getName(),
"전체 reload 후 새 apiId 는 정상 등록되어야 한다");
}
// =========================================================================
// 5. 생명주기
// =========================================================================
@Test
@DisplayName("5-1. start() 후 isStarted() = true")
void testIsStarted_afterStart_returnsTrue() {
assertTrue(manager.isStarted(), "start() 후 isStarted() 는 true 여야 한다");
}
}