Spring Boot
에서 MVC
패턴을 사용한 백엔드 프로젝트 테스트
수정 중 입니다.
각 서비스 테스트
단위 테스트(Unit Test): 각 서비스 내부의 개별 함수나 메서드를 테스트합니다. 이는 서비스 내부의 각 부분이 정확하게 작동하는지 확인하는 데 중요합니다.
서비스별 통합 테스트(Service Integration Test): 각 서비스가 외부 리소스(예: 데이터베이스, 다른 서비스 등)와 올바르게 통신하는지 테스트합니다. 이를 위해 모킹(Mocking)이나 스텁(Stub) 등의 기법을 사용하거나, 필요한 경우 실제 리소스를 사용할 수도 있습니다.
모든 서비스 테스트
Controller
단위 테스트@WebMvcTest
어노테이션을 사용하여 컨트롤러를 격리된 환경에서 테스트한다.
이 경우, @WebMvcTest
는 웹 계층에 관련된 빈들만 로드하기 때문에 테스트 실행 시간이 짧다.
@MockBean
을 사용하여 서비스 레이어를 모킹하고, 예상되는 행동을 정의할 수 있다.
// @WebMvcTest는 Spring MVC의 컨트롤러 단위 테스트를 위한 특수화된 테스트 애노테이션입니다.
// 웹 계층에 필요한 구성요소들만 로드되며,
// 이는 웹 계층에 대한 단위 테스트를 빠르고 효율적으로 수행할 수 있게 합니다.
@WebMvcTest(YourController.class)
public class YourControllerTest {
// MockMvc는 Spring MVC 애플리케이션을 테스트하기 위한 라이브러리입니다.
// 이를 사용하면 HTTP 요청을 디스패처 서블릿에 보내고,
// 그 결과로 반환된 모델과 뷰를 검증할 수 있습니다.
@Autowired
private MockMvc mockMvc;
// 실제 객체 대신 Mockito 프레임워크를 사용하여 생성된 Mock 객체가 사용
// 이를 통해, 테스트에서는 외부 시스템에 의존하지 않고 원하는 동작을 정의할 수 있습니다.
@MockBean
private YourService yourService;
// 테스트 메소드들...
}
실제 개발한 프로젝트의 테스트 코드를 보면서 더 자세하게 알아보자.
// 테스트할 컨트롤러 클래스와 연관된 웹 계층에 필요한 구성 요소들만 로드
@WebMvcTest(SurveyDocumentExternalController.class)
public class SurveyDocumentExternalControllerTest {
// JSON 변환 위해
private final ObjectMapper objectMapper = new ObjectMapper();
// HTTP 요청을 보내기 위해
@Autowired
MockMvc mockMvc;
// SurveyController가 의존하고 있는 surveyDocumentService
// @MockBean을 이용하여 가짜 객체를 넣어줬다.
@MockBean
SurveyDocumentService surveyDocumentService;
@Test
@DisplayName("설문 생성 TEST")
public void createSurveyTest() throws Exception {
// given
// 설문 생성을 위한 요청 DTO이다.
SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
// 컨트롤러의 설문 생성 메소드 내의
// 실행되는 서비스의 응답 값을 지정해준다.(현재는 @MockBean으로 가짜 객체를 넣어줬으니 제대로 작동하지 않는다.)
final Long FIRST_CREATED_SURVEY_ID = 100L;
// when 이 메소드가 실행됐을 때 응답값은 .thenReturn에 지정
when(surveyDocumentService.createSurvey(any(HttpServletRequest.class), any(SurveyRequestDto.class)))
.thenReturn(FIRST_CREATED_SURVEY_ID);
// when
// MockMvc를 통해 설문 생성 경로로 HTTP 요청을 보낸 뒤 응답을 확인한다.
MvcResult createResult = mockMvc.perform(post("/api/document/external/create")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(surveyRequestDto))
)
.andExpect(status().isOk())
.andDo(print())
.andReturn();
// then
// 응답 값이 지정한 값과 같은지 확인한다.
assertEquals(createResult.getResponse().getContentAsString(), String.valueOf(FIRST_CREATED_SURVEY_ID));
}
private static SurveyRequestDto createSurveyRequestDto() {
...
return SurveyRequestDto.builder()
.title("설문 제목")
.description("설문 내용")
.type(0)
.reliability(true)
.startDate(new Date())
.enable(true)
.questionRequest(questionRequestDtoList)
.design(design)
.build();
}
}
Service
단위 테스트@ExtendWith(MockitoExtension.class)
어노테이션은 JUnit5
의 기능인 Extension Model
을 활용하여 Mockito
를 JUnit5
와 통합하는 역할을 한다. 이를 사용하면 @Mock, @InjectMocks, @Spy
등의 Mockito
어노테이션을 JUnit5
테스트에서 사용할 수 있게 된다.
@Mock
어노테이션은 원본 객체와 같은 메소드를 가지지만 프로그래머가 정의한 대로 동작하도록 설계될 수 있다.
@InjectMocks
어노테이션은 Mockito
가 @Mock
또는 @Spy
어노테이션으로 생성된 Mock
객체를 해당 어노테이션이 붙은 필드(주로 테스트하려는 클래스의 인스턴스)의 필드에 자동으로 주입한다.
@ExtendWith(MockitoExtension.class)
public class SurveyDocumentServiceTest {
@Mock
private SurveyDocumentRepository surveyDocumentRepository;
@InjectMocks
private SurveyDocumentService surveyDocumentService;
// 테스트 메소드들...
}
Mocking
간단한 유저 정보 서비스 코드가 있다고 해보자.
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public Long createUser(HttpServletRequest request, UserDto userDto) {
// UserDto에서 유저 정보를 가져오기
String name = userDto.getName();
String email = userDto.getEmail();
// User 객체 생성
User user = User.builder()
.name(name)
.email(email)
.build();
// User 객체 저장
User savedUser = userRepository.save(user);
// 저장된 User의 ID 반환
return savedUser.getId();
}
}
위의 서비스 코드를 단위 테스트 하는 코드를 짜보자.
@Test
@DisplayName("유저 생성 성공")
public void createUserSuccess() {
// given
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
UserDto userDto = Mockito.mock(UserDto.class);
when(userDto.getName()).thenReturn("Test Name");
when(userDto.getEmail()).thenReturn("test@example.com");
when(userRepository.save(any(User.class))).thenAnswer(i -> {
User user = i.getArgument(0);
user.setId(1L);
return user;
});
// when
Long userId = userService.createUser(request, userDto);
// then
assertEquals(1L, userId);
}
위의 테스트 메소드를 보면 Mockito.mock
을 통해 서비스 코드의 createUser
메소드가 필요로 하는 객체를 Mocking
하고 있다. 즉, 필요한 객체를 가짜 객체로 만들어서 인자로 넘겨준다는 것이다.
위의 가짜 객체를 넘겨주면 어떻게 될까? 물론 서비스 코드에서 가짜 객체에 접근할 때 에러가 발생할 것이다.
위의 문제를 해결하기 위해서는 when.thenReturn
을 사용한다. 서비스 코드에서 가짜 객체를 사용하는 메소드들이 실행되면 그 결과 값을 미리 지정한 값으로 돌아가게 하는 것이다.
이렇게 Mock
객체를 사용하는 이유는 createUser
메소드가 UserDto
객체의 내부 상태에 의존하지 않고 독립적으로 테스트할 수 있도록 하기 위함이다. 이렇게 하면 객체의 실제 구현에 의존하지 않고도 동일한 인터페이스를 가진 어떤 객체와도 동작할 수 있다. 이는 단위 테스트의 목적에 맞게 특정 메소드의 동작만을 고립시켜 테스트에 도움이 된다.
하지만 위의 방법이 항상 옳은 것은 아니다. 테스트의 목적과 상황에 따라 어떤 방법을 사용할지 결정해야 한다. 위의 방법은 테스트 코드가 복잡해지고, 실제 객체의 동작을 완벽하게 모방하지 못하는 경우가 있을 수도 있으며 각 테스트 케이스에서 Mock
객체의 동작을 일일이 설정해야 하므로 유지 보수도 어려워질 수 있다.
즉, Mocking
을 사용할지, 실제 객체를 사용할지 결정하는 것은 테스트의 목적과 복잡도, 그리고 테스트 대상의 특성에 따라 달라진다. 이를 고려하여 적절한 방법을 선택하는 것이 중요하다.
@Test
@DisplayName("설문 생성 성공")
public void createSurveySuccess() {
// given
// 가짜 HttpServletRequest 객체 사용
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
SurveyDocument surveyDocument = createSurveyDocument();
// 가짜 객체를 사용하는 메소드의 반환 값 설정
when(apiService.getCurrentUserFromJWTToken(request)).thenReturn(Optional.of(1L));
when(designRepository.save(any(Design.class))).thenReturn(surveyDocument.getDesign());
when(dateRepository.save(any(DateManagement.class))).thenReturn(surveyDocument.getDate());
when(questionDocumentRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0));
when(questionDocumentRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(1));
when(choiceRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0).getChoiceList().get(0));
when(choiceRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0).getChoiceList().get(1));
when(surveyDocumentRepository.save(any())).thenReturn(surveyDocument);
// when
Long surveyId = surveyDocumentService.createSurvey(request, surveyRequestDto);
// then
assertEquals(1L, surveyId);
}
@DataJpaTest
어노테이션은 JPA
관련 테스트 설정만 로드한다.
@DataJpaTest
특징JPA
에 관련된 요소들만 테스트하기 위한 어노테이션으로 JPA
테스트에 관련된 설정들만 적용해준다.@Entity
클래스들을 등록하고 JPA Repository
설정들을 해준다. 각 테스트마다 테스트가 완료되면 관련한 설정들은 롤백된다.@DataJpaTest
public class SurveyDocumentRepositoryTest {
@Autowired
SurveyDocumentRepository surveyDocumentRepository;
@Test
@DisplayName("설문 조회 테스트")
public void findSurveyById() {
//given
SurveyDocument surveyDocument = createSurveyDocument();
SurveyDocument saveSurvey = surveyDocumentRepository.save(surveyDocument);
// when
SurveyDocument findSurvey = surveyDocumentRepository.findSurveyById(saveSurvey.getId()).get();
// then
assertEquals(saveSurvey.getId(), findSurvey.getId());
}
단위 테스트를 할때, webClient
를 mock
으로 주입 받는 상황을 보자.
@ExtendWith(MockitoExtension.class)
public class ChatGptServiceTest {
@Mock
private WebClient webClient;
@InjectMocks
private ChatGptService chatGptService;
...
}
위의 코드는 chatGPTService
에서 HTTP
통신을 할때, webClient
을 사용하기 때문에 단위 테스트를 하기 위해서 webClient
를 Mock
하여 테스트 하는 코드이다.
위의 Mock
으로 주입된 가짜 webClient
객체의 결과 지정을 어떻게 할까?
이를 위해서는 webClient
의 체인의 각 호출에 대해 서로 다른 모의 객체를 제공해야 한다.
when(webClient.post()).thenReturn(requestBodyUriSpec);
when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec);
when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec);
when(requestBodySpec.body(any())).thenReturn(requestHeadersSpec);
when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
즉, 이렇게 이어진 체인에 대해 모두 결과를 지정해줘야 한다는 것이다. 이는 매우 복잡하며 번거롭다.
MockWebServer
를 사용하면 실제 HTTP
요청을 보내고 응답을 받을 수 있기 때문에, WebClient
의 동작을 더 정확하게 테스트할 수 있다.
testImplementation 'com.squareup.okhttp3:okhttp:4.12.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
MockServer
적용public class ChatGptServiceTest {
private static MockWebServer mockBackEnd;
private ObjectMapper MAPPER = new ObjectMapper();
private ChatGptService chatGptService;
@BeforeEach
void setUp() throws IOException {
// MockWebServer 생성
mockBackEnd = new MockWebServer();
// MockWebServer 시작
mockBackEnd.start();
// MockWebServer 실행 포트 확인
String baseUrl = String.format("http://localhost:%s", mockBackEnd.getPort());
// 테스트할 서비스에서 사용할 WebClient가 MockWebServer로 요청을 보내도록 수정하여 생성
WebClient webClient = WebClient.builder().baseUrl(baseUrl).build();
// 조작된 webClient 주입
// 이 방법은 문제가 있다. 하단에 추가 설명
chatGptService = new ChatGptService(WebClient.builder());
chatGptService.setWebClient(webClient);
}
@AfterEach
void tearDown() throws IOException {
mockBackEnd.shutdown();
}
@Test
@DisplayName("ChatGPT 질문 후 응답 테스트")
void chatGptResult() throws JsonProcessingException {
// given
ChatGptQuestionRequestDto chatGptQuestionRequestDto = new ChatGptQuestionRequestDto();
chatGptQuestionRequestDto.setQuestion("ChatGPT 질문입니다.");
ChatResultDto expectedResponse = ChatResultDto.builder()
.index(1)
.text("ChatGPT 답변입니다.")
.finishReason("종료 이유입니다.")
.build();
mockBackEnd.enqueue(new MockResponse()
.setBody(MAPPER.writeValueAsString(expectedResponse))
.addHeader("Content-Type", "application/json"));
// when
Mono<ChatResultDto> actualResponseMono = chatGptService.chatGptResult(chatGptQuestionRequestDto);
// then
ChatResultDto actualResponse = actualResponseMono.block();
assertEquals(expectedResponse.getIndex(), actualResponse.getIndex());
assertEquals(expectedResponse.getText(), actualResponse.getText());
assertEquals(expectedResponse.getFinishReason(), actualResponse.getFinishReason());
RecordedRequest recordedRequest = mockBackEnd.takeRequest();
assertEquals("POST", recordedRequest.getMethod());
assertEquals("/api/chatGpt", recordedRequest.getPath());
}
}
// test 코드
chatGptService = new ChatGptService(WebClient.builder());
chatGptService.setWebClient(webClient);
// 실제 ChatGptService 코드 내의 테스트를 위해 WebClient를 조작하는 메소드
public void setWebClient(WebClient webClient) {
this.webClient = webClient;
}
위의 코드를 통해 ChatGptService
가 사용하는 WebClient
조작하여 테스트 시 WebClient
가 MockWebServer
를 바라보도록 하여 테스트를 할 수 있다. 이때, 테스트 코드는 test 패키지에 있으므로 WebClient
를 조작하는 메소드의 접근 제어자는 public
이어야 한다.
캡슐화 위반: 객체 지향 프로그래밍에서는 각 객체의 상태와 행동을 캡슐화하여 외부에서의 직접적인 접근을 제한하는 것이 중요하다. 캡슐화는 객체의 내부 구현을 숨기고, 외부에는 필요한 인터페이스만을 제공함으로써 객체의 독립성과 유지 보수성을 높이는 역할을 한다. 그러나 테스트를 위한 메서드를 public
으로 열어 두면 이러한 캡슐화가 깨지게 된다.
클래스 인터페이스 오염: 클래스의 public
인터페이스는 해당 클래스의 사용 방법을 정의하는 중요한 부분이다. 테스트를 위한 메서드가 public
인터페이스에 포함되면 클래스의 사용자가 이 메서드를 잘못 사용할 가능성이 있다. 또한 이 메서드는 실제 동작에는 필요 없는 메서드이므로 클래스의 인터페이스를 불필요하게 복잡하게 만들 수 있다.
코드 유지 보수성 저하: 테스트를 위한 메서드가 추가되면 이 메서드를 유지 보수해야 하는 부담이 생긴다. 또한 이 메서드는 실제 동작과는 무관하므로 코드를 이해하고 유지 보수하는 데 혼란을 줄 수 있다.
ChapGPTService
가 WebClient
를 생성자를 통해 주입 받는다면, 테스트에서는 MockWebServer
를 바라보는 WebClient
를 주입할 수 있다. 즉, 테스트 환경에서는 테스트를 위한 객체를 이용해 생성한다는 것이다.
@Service
public class ChatGptService {
private WebClient webClient;
// 생성자 주입으로 변경
public ChatGptService(WebClient webClient) {
this.webClient = webClient;
}
...
}
// Test 코드
@ExtendWith(MockitoExtension.class)
public class ChatGptServiceTest {
private static MockWebServer mockBackEnd;
private final ObjectMapper mapper = new ObjectMapper();
@InjectMocks
private ChatGptService chatGptService;
@BeforeEach
void setUp() throws IOException {
mockBackEnd = new MockWebServer();
mockBackEnd.start();
String baseUrl = String.format("http://localhost:%s", mockBackEnd.getPort());
WebClient webClient = WebClient.builder()
.baseUrl(baseUrl)
.filter(WebClientConfig.logRequest())
.build();
// ChatGptService에 조작된 WebClient 주입
chatGptService = new ChatGptService(webClient);
}
@AfterEach
void tearDown() throws IOException {
mockBackEnd.shutdown();
}
...
// 테스트 코드
}
운영 환경에 따라 WebClient
빈의 생성 설정(baseUrl
)을 달리해서 사용하는 방법도 있다.
TestWebClientConfig
: test
환경에서 사용할 WebClient
설정이다.WebClient
설정은 @Profile({"local","prod"})
등 application.properties
별 사용할 설정을 프로필로 지정해주면 된다.@Configuration
@Profile("test")
public class TestWebClientConfig {
@Bean(initMethod = "start", destroyMethod = "shutdown")
public MockWebServer mockWebServer() {
return new MockWebServer();
}
@Bean
public WebClient webClient(MockWebServer mockWebServer) {
String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
return WebClient.builder()
.baseUrl(baseUrl)
.build();
}
}
test
코드 : @ActiveProfile
을 사용하여 활성화할 프로필을 선택한다.@Transactional
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
public class SurveyDocumentIntegrationTest {
@Autowired
MockMvc mockMvc;
@Autowired
MockWebServer mockBackEnd;
private ObjectMapper mapper = new ObjectMapper();
...
}
그러면 이제 운영 환경에 따라 사용하는 WebClient
빈의 설정을 달리하여 등록해서 사용할 수 있다.
WebClient 설정
@Slf4j
@Profile({"local", "server"})
@Configuration
@RequiredArgsConstructor
public class WebClientConfig {
private final ObjectMapper objectMapper;
@Value("${gateway.host}")
private String gateway;
public static ExchangeFilterFunction logRequest() {
return (clientRequest, next) -> {
log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
return next.exchange(clientRequest);
};
}
@Bean
public WebClient webClient() {
final int bufferSize = 16 * 1024 * 1024; // 16MB
final ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
.codecs(configurer -> {
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON));
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON));
configurer.defaultCodecs().maxInMemorySize(bufferSize);
})
.build();
return WebClient.builder()
.baseUrl(gateway)
.exchangeStrategies(exchangeStrategies)
.filter(logRequest())
.build();
}
}
바로 단위 테스트를 이용하면 된다.
@ExtendWith(MockitoExtension.class)
public class WebClientConfigTest {
@InjectMocks
private WebClientConfig webClientConfig;
@Mock
private ObjectMapper objectMapper;
@Test
public void webClientBeanCreationTest() {
WebClient webClient = webClientConfig.webClient();
assertNotNull(webClient);
}
}
위의 코드를 보면 운영 환경의 @ExtendWith(MockitoExtension.class)
를 활용하여 WebClient
가 의존하고 있는 ObjectMapper
를 Mock
객체로 주입한다.
그 이후 테스트 코드에서 WebClientConfig
에 따라 webClient
를 생성하고 WebClient
객체가 null
이 아님을 검증한다.
현재 우리가 수행하고 있는 것은 MSA(Microservices Architecture)
의 독립적인 구성요소인 A 서비스에 대한 통합 테스트이다. A 서비스는 B 서비스와의 인터랙션
을 포함하고 있지만, 이 테스트의 목적은 A 서비스의 독립적인 기능성을 검증하는 것이다. 따라서 B 서비스와의 통신은 MockWebServer
를 이용하여 모킹하고, 이를 통해 A 서비스의 통합 테스트를 수행한다.
특히 MSA(Microservices Architecture)
에서는 서비스 간에 독립성을 유지하는 것이 중요하다. 이 경우 A 서비스의 테스트 도중 B 서비스의 상태에 따라 테스트 결과가 달라지는 것을 방지하기 위해 B 서비스를 MockWebServer
등을 이용해 모킹하는 것이 유용하다.
통합 테스트는 애플리케이션의 모든 계층이 함께 잘 작동하는지 검증하는 것이 목표이다.
@SpringBootTest
애노테이션을 사용하여 전체 애플리케이션 컨텍스트를 로드하는 통합 테스트를 수행할 수 있다.
이 경우, 실제 서비스와 리포지토리 레이어를 사용하여 테스트를 수행한다.
이는 애플리케이션의 실제 동작을 가장 잘 반영하지만, 테스트 실행 시간이 길어질 수 있다. 또한 DB는 인메모리를 사용하였다.
@Transactional // 각 테스트마다 데이터를 롤백하기 위해 적용
@SpringBootTest // 전체 애플리케이션 컨텍스트를 로드하기 위해 적용
@AutoConfigureMockMvc // MockMvc를 사용하기 위해 적용
// 다른 서비스와 통신할때 사용하는 WebClient가 MockWebServer를 바라보는 설정으로 적용하기 위해
// Redis 및 Mysql 인메모리 DB 사용 위해
@ActiveProfiles("test")
public class SurveyDocumentIntegrationTest {
@Autowired
MockMvc mockMvc;
@Autowired
MockWebServer mockBackEnd;
@Autowired
private ObjectMapper mapper;
@Test
@DisplayName("설문 2개 생성 성공")
public void createSurveySuccess() throws Exception {
// given
SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
final Long FIRST_CREATED_SURVEY_ID = 1L;
final Long SECOND_CREATED_SURVEY_ID = 2L;
Long userId = 1L;
mockBackEnd.enqueue(createMockResponse(userId));
mockBackEnd.enqueue(createMockResponse(userId));
// when
MvcResult createResult1 = mockMvc.perform(post("/api/document/external/create")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(surveyRequestDto))
)
.andExpect(status().isCreated())
.andDo(print())
.andReturn();
MvcResult createResult2 = mockMvc.perform(post("/api/document/external/create")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(surveyRequestDto))
)
.andExpect(status().isCreated())
.andDo(print())
.andReturn();
// then
assertEquals(createResult1.getResponse().getContentAsString(), String.valueOf(FIRST_CREATED_SURVEY_ID));
assertEquals(createResult2.getResponse().getContentAsString(), String.valueOf(SECOND_CREATED_SURVEY_ID));
}
TEST
용 MockWebServer
와 WebClient
설정 파일@Profile("test")
@Configuration
public class TestWebClientConfig {
@Bean(initMethod = "start", destroyMethod = "shutdown")
public MockWebServer mockWebServer() {
return new MockWebServer();
}
@Bean
public WebClient webClient(MockWebServer mockWebServer) {
String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
return WebClient.builder()
.baseUrl(baseUrl)
.build();
}
}
TEST
용 application-test.properties
## Redis 임베디드 모드 사용
spring.cache.type=redis
spring.cache.redis.time-to-live=3600
spring.cache.redis.host=localhost
spring.cache.redis.port=6379
# local server
server.port=8082
# local gateway
gateway.host=http://localhost:8080
{"local", "server"}
환경에서 사용하는 Redis Configuration
파일@Configuration
@Profile({"local", "server"})
public class RedissonConfig {
@Value("${spring.cache.redis.host}")
private String redisHost;
@Value("${spring.cache.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public CacheManager cacheManager() {
RedisCacheManager.RedisCacheManagerBuilder builder =
RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경
.entryTtl(Duration.ofMinutes(30)); // 캐시 수명 30분
builder.cacheDefaults(configuration);
return builder.build();
}
}
Redis(local)
만 실행하여 단위 테스트로 검증한다.@ActiveProfiles("local")
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
initializers = {ConfigDataApplicationContextInitializer.class},
classes = RedisConfig.class)
public class RedisConfigTest {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Autowired
private CacheManager cacheManager;
@Test
public void redisConnectionFactoryBeanExists() {
assertThat(redisConnectionFactory).isNotNull();
}
@Test
public void cacheManagerBeanExists() {
assertThat(cacheManager).isNotNull();
}
}
Redis
설정이 적용됐는지 확인한다.테스트 환경에 레디스 서버를 설치하거나 실행하지 않고, 테스트 시간에만 임시로 레디스 서버를 사용하고자 하는 경우에는 임베디드 레디스 서버를 사용할 수 있다. 임베디드 레디스는 JVM
상에서 실행되는 레디스 서버로, 테스트를 위해 자주 사용된다. spring-redis-test
라이브러리를 사용하면 테스트 시간에 임베디드 레디스를 손쉽게 사용할 수 있다.
// gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation ('it.ozimov:embedded-redis:0.7.3') { exclude group: "org.slf4j", module: "slf4j-simple" } // 임베비드 레디스 추가
SLF4J
가 여러번 바인딩 되는 문제를 해결하기 위해 설정
@Slf4j
@Profile("test")
@Configuration
public class TestRedisConfig {
@Value("${spring.cache.redis.port}")
private int redisPort;
private RedisServer redisServer;
@PostConstruct
public void startRedis() throws IOException {
int port = isRedisRunning() ? findAvailablePort() : redisPort;
redisServer = RedisServer.builder()
.port(port)
.setting("maxmemory 128M")
.build();
try {
redisServer.start();
} catch (Exception e) {
log.error("Redis Server Can't Start", e);
}
}
@PreDestroy
public void stopRedis() {
redisServer.stop();
}
public int findAvailablePort() throws IOException {
for (int port = 10000; port <= 65535; port++) {
Process process = executeGrepProcessCommand(port);
if (!isRunning(process)) {
return port;
}
}
throw new RuntimeException("NOT_FOUND_AVAILABLE_PORT");
}
/**
* Embedded Redis가 현재 실행중인지 확인
*/
private boolean isRedisRunning() throws IOException {
return isRunning(executeGrepProcessCommand(redisPort));
}
/**
* 해당 port를 사용중인 프로세스를 확인하는 sh 실행
*/
private Process executeGrepProcessCommand(int redisPort) throws IOException {
String command = String.format("netstat -nat | grep LISTEN|grep %d", redisPort);
// linux 용
// String[] shell = {"/bin/sh", "-c", command};
// window 용
String[] shell = {"cmd.exe", "/c", command};
return Runtime.getRuntime().exec(shell);
}
/**
* 해당 Process가 현재 실행중인지 확인
*/
private boolean isRunning(Process process) {
String line;
StringBuilder pidInfo = new StringBuilder();
try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
while ((line = input.readLine()) != null) {
pidInfo.append(line);
}
} catch (Exception e) {
throw new RuntimeException("ERROR_EXECUTING_EMBEDDED_REDIS");
}
return StringUtils.hasText(pidInfo.toString());
}
}
메모리 설정과 현재 사용할 수 있는 포트를 확인해서 그 포트에 임베디드 레디스를 실행하는 설정이 있다.