회로차단기, 폴백 시도하려고 mvn clean package -> dockerfile build -> docker-compose up -> 포스트맨 테스트를 해봤다.
여기서 dockerfile build가 4분 정도 걸리더라. 왤케 느려... 나중에 원인 찾아봐야 할듯.
그래서 매번 테스트할 때마다 기다리기 짜증나서 일지 서비스 내에 단위 테스트를 넣었다. 다른 원격 자원 호출은 Mocking하면 쉽게 해결된다.
테스트 코드 기준으로, 4개 전부를 테스팅하는데 2초도 안걸렸다. 테스트는 가볍고 빠를 수록 좋다!
@Service
public class SaveDiaryService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final DiaryRepository diaryRepository;
private final FindWriterFeignClient findWriterFeignClient;
public SaveDiaryService(DiaryRepository diaryRepository, FindWriterFeignClient findWriterFeignClient) {
this.diaryRepository = diaryRepository;
this.findWriterFeignClient = findWriterFeignClient;
}
public Long postDiaryWithEntities(SecurityDiaryPostRequestDTO dto) throws TimeoutException {
logger.info("call writer micro service for finding writer id. correlation id :{}", UserContextHolder.getContext().getCorrelationId());
Long writerId = findWriterFeignClient.findWriterById(dto.getWriterId());
logger.info("saving diary... correlation id :{}", UserContextHolder.getContext().getCorrelationId());
DiabetesDiary diary = new DiabetesDiary(writerId, dto.getFastingPlasmaGlucose(), dto.getRemark());
diaryRepository.save(diary);
return diary.getId();
}
}
원격 자원 호출이 2번 발생하는 것을 확인할 수 있다.
findWriterFeignClient.findWriterById(dto.getWriterId());
는 다른 마이크로 서비스를 호출한다.
diaryRepository.save(diary);
는 DB를 호출한다.
두 지점 모두 네트워크 지연 등의 장애가 발생하면 서비스에 악영향을 줄 수 있는 지점이다.
@FeignClient("writer-service")
public interface FindWriterFeignClient {
@CircuitBreaker(name="writerService")
@RequestMapping(method= RequestMethod.GET,value="/writer/{writerId}",consumes = "application/json")
Long findWriterById(@PathVariable("writerId")Long writerId) throws TimeoutException;
}
@RestController
@RequestMapping("/api/diary/user/diabetes-diary")
public class SecurityDiaryRestController {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final SaveDiaryService saveDiaryService;
public SecurityDiaryRestController(SaveDiaryService saveDiaryService) {
this.saveDiaryService = saveDiaryService;
}
@PostMapping
@RateLimiter(name = "diaryService")
@CircuitBreaker(name = "diaryService", fallbackMethod = "fallBackPostDiary")
public ApiResult<?> postDiary(@RequestBody @Valid SecurityDiaryPostRequestDTO dto) throws TimeoutException {
logger.debug("SecurityDiaryRestController correlation id in posting diary:{}", UserContextHolder.getContext().getCorrelationId());
Long diaryId = saveDiaryService.postDiaryWithEntities(dto);
return ApiResult.OK(new SecurityDiaryPostResponseDTO(diaryId));
}
@SuppressWarnings("unused")
private ApiResult<?> fallBackPostDiary(SecurityDiaryPostRequestDTO dto, Throwable throwable) {
logger.error("failed to call outer component in posting Diary. correlation id :{} , exception : {}", UserContextHolder.getContext().getCorrelationId(), throwable.getClass());
return ApiResult.ERROR(throwable.getClass().getName(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
ApiResult는 ResponseEntity
와 비슷하게 만든 공통 규약용 클래스일 뿐이다. ResponseEntity
로 적절히 대체해도 무방하다.
package com.dasd412.api.diaryservice.controller;
import com.dasd412.api.diaryservice.DiaryServiceApplication;
import com.dasd412.api.diaryservice.controller.dto.SecurityDiaryPostRequestDTO;
import com.dasd412.api.diaryservice.domain.diary.DiabetesDiary;
import com.dasd412.api.diaryservice.domain.diary.DiaryRepository;
import com.dasd412.api.diaryservice.service.client.FindWriterFeignClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = DiaryServiceApplication.class)
@Execution(ExecutionMode.SAME_THREAD)
public class SaveDiaryControllerTest {
@Autowired
private WebApplicationContext context;
@MockBean
private DiaryRepository diaryRepository;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@MockBean
private FindWriterFeignClient findWriterFeignClient;
private MockMvc mockMvc;
private SecurityDiaryPostRequestDTO dto;
private final String url = "/api/diary/user/diabetes-diary";
@Before
public void setUpDTO() {
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
dto = new SecurityDiaryPostRequestDTO(1L, 100, "TEST");
}
@After
public void clean() {
diaryRepository.deleteAll();
}
@Test
public void testSaveDiaryWhenCircuitBreakClosedAndMicroServiceCallTimeOut() throws Exception {
//given
circuitBreakerRegistry.circuitBreaker("diaryService")
.transitionToClosedState();
//when
when(findWriterFeignClient.findWriterById(1L)).thenThrow(new TimeoutException());
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(dto)))
.andExpect(jsonPath("$.success").value("false"))
.andExpect(jsonPath("$.error.message").exists())
.andExpect(jsonPath("$.error.status").value("500"));
//then
assertThat(diaryRepository.findAll().size()).isEqualTo(0);
}
@Test
public void testSaveDiaryWhenCircuitBreakClosedAndDataBaseTimeOut() throws Exception {
//given
circuitBreakerRegistry.circuitBreaker("diaryService")
.transitionToClosedState();
given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);
given(diaryRepository.save(any(DiabetesDiary.class))).willAnswer(invocation -> {
throw new TimeoutException();
});
//when
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(dto)))
.andExpect(jsonPath("$.success").value("false"))
.andExpect(jsonPath("$.error.message").exists())
.andExpect(jsonPath("$.error.status").value("500"));
//then
assertThat(diaryRepository.findAll().size()).isEqualTo(0);
}
@Test
public void testSaveDiaryWhenCircuitBreakOpen() throws Exception {
//given
circuitBreakerRegistry.circuitBreaker("diaryService")
.transitionToOpenState();
given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);
given(diaryRepository.save(any(DiabetesDiary.class))).willAnswer(invocation -> {
throw new TimeoutException();
});
//when
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(dto)))
.andExpect(jsonPath("$.success").value("false"))
.andExpect(jsonPath("$.error.message").exists())
.andExpect(jsonPath("$.error.status").value("500"));
//then
assertThat(diaryRepository.findAll().size()).isEqualTo(0);
}
@Test
public void testSaveDiaryWhenCircuitBreakClosedAndAllServiceAvailable() throws Exception {
//given
circuitBreakerRegistry.circuitBreaker("diaryService")
.transitionToClosedState();
given(findWriterFeignClient.findWriterById(1L)).willReturn(1L);
//when
when(diaryRepository.save(any(DiabetesDiary.class))).thenReturn(new DiabetesDiary());
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value("true"));
//then
verify(diaryRepository).save(any());
}
}
testSaveDiaryWhenCircuitBreakClosedAndDataBaseTimeOut
의 경우의 로그이다.
2022-12-02 11:36:53.231 INFO 2560 --- [ main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2022-12-02 11:36:53.231 INFO 2560 --- [ main] o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
2022-12-02 11:36:53.247 INFO 2560 --- [ main] o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 16 ms
2022-12-02 11:36:53.394 DEBUG 2560 --- [ main] c.d.a.d.c.SecurityDiaryRestController : SecurityDiaryRestController correlation id in posting diary:
2022-12-02 11:36:53.394 INFO 2560 --- [ main] c.d.a.d.service.SaveDiaryService : call writer micro service for finding writer id. correlation id :
2022-12-02 11:36:53.409 INFO 2560 --- [ main] c.d.a.d.service.SaveDiaryService : saving diary... correlation id :
2022-12-02 11:36:53.409 ERROR 2560 --- [ main] c.d.a.d.c.SecurityDiaryRestController : failed to call outer component in posting Diary. correlation id : , exception : class java.util.concurrent.TimeoutException
https://ynovytskyy.medium.com/unit-testing-circuit-breaker-8ed2c9098e11
링크를 참고하면 회로 차단기 패턴에 대해 자세히 설명이 나와 있다.
그런데 폴백 패턴의 경우 서비스 레이어에서 테스트하면 실행이 안되더라. 그래서 컨트롤러 레이어로 폴백과 회로 차단기를 같이 올린 후, 테스트했더니 폴백 패턴 실행이 확인되었다.
회로 차단기 활성 여부 or 마이크로 서비스 정상 호출 여부 or DB 정상 호출 여부에 대해 케이스를 나눠서 테스트했다.
open
이 연결이 끊어진 상태이고 closed
가 정상적으로 연결이 된 상태이다.https://jojoldu.tistory.com/226
https://junroot.github.io/programming/AssertJ-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-(2)/