로그인을 구현하는 과정에서 문제를 맞이했다.
현재 koin 마이그레이션은 인수테스트를 작성하고 기능을 구현하는 방식으로 진행하고있다.
인수테스트를 작성하는 과정에서 몇가지 문제점을 맞이했다.
본 글은 해결 과정을 담고있으므로 완성된 코드를 참고하고싶다면 글의 최하단 부분을 참고해주세요!
현재 로그인은 다음과 같은 흐름으로 구현되어있다.
위와같은 흐름으로 구현되어있기도하고 인프라환경을 동일하게 가져간다는 특징 때문에 로그인 수행 시 Redis를 이용하는 상황이 왔다.
테스트 수행 시 Redis에 값을 어떻게 저장해야할지가 고민이였다.
RDB의 경우 MySQL 대신 h2를 사용하고있지만 Redis는 어떻게 테스트해야할지 고민이 들었다.
이에 여러 정보를 찾아봤다.
가장 간단하고 쉬운 방법이지만 협업하는 모든 개발자의 환경에 동일한 DB 환경을 구성해야한다는 문제가 있다.
게다가 실제 DB를 사용하는만큼 사용중인 DB의 다른 데이터를 건드릴 수 있다는 문제가 있다.
Redis를 컨텍스트에 띄워서 구동시키는 Embedded Library를 활용하는 방법이 있다.
무작정 적용해볼 수도 있겠지만 협업과정에서 새로운 기술스택을 도입하는 만큼 신중을 가할 필요가 있다고 생각하여 조금 더 찾아보려고 한다.
지금은 Spring Data JPA를 사용중이라 다양한 DB Dialect를 지원해주고 있다.
간단한 조회로직만 있는 지금의 상황에서는 아직 겪지 못했던 문제지만 추후 Native Query가 필요한 상황이 온다면 MySQL 문법으로 작성한 쿼리를 H2로 처리하지 못하는 상황도 분명히 올 것이다.
실제 환경과 유사한 환경으로 테스트를 구동하는 방법을 찾아보니 Docker를 이용하는 방법이 있었다.
이 방법은 docker-compose 파일을 관리해야한다는 귀찮음이 존재했다.
보다 간편한 방법으로 TestContainer를 찾아볼 수 있었다.
TestContainer는 도커 컨테이너로 래핑된 실제 서비스를 제공해서, 로컬 테스트 시에도 mocking이나 in-memory 서비스들을 사용하지 않고 운영환경에서 사용하는 실제 서비스에 종속되는 테스트를 작성할 수 있게 해주는 오픈 소스 라이브러리이다.
TestContainer - 홈페이지
외부 API를 Mocking해야하는 상황에서는 testContainer가 컨테이너를 제공한다고 한다.
Koin에서도 S3, Slack, 공공데이터 API 등을 활용중이라 차후 해당 API를 테스트하는 과정에서도 굉장히 유용할 것이라고 여겨진다.
실제로 Redis에 대한 코드를 작성하기 전에 TestContainer로 테스트를 구동하는 환경을 갖춰놓도록 하자.
이제 TestContainer를 적용해보자.
공식문서에 꽤나 설명이 잘 되어있어 해당 문서를 보고 적용해도 문제 없을 정도다.
https://java.testcontainers.org/
간단한 예시로 BCSDLab 트랙정보를 조회하는 인수테스트를 testContainer를 사용하도록 변경해보자.
해당 테스트는 RestAssured를 사용하는 E2E 테스트 환경이다.
🐳 testContainer를 사용하기에 앞서 로컬에서 docker를 실행중이여야합니다! 🐳
우선 testcontainer를 사용하기 위해 의존성을 추가해준다.
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3' // junit5를 이용한 확장을 위해 추가
testImplementation 'org.testcontainers:mysql' // MySQL 사용을 위해 추가
테스트코드에 다음과 같이 MySQLContainer를 선언하고 BeforeEach, AfterEach로 컨테이너를 start, stop 해주는 코드를 추가했다.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD)
class TrackApiTest {
@LocalServerPort
int port;
// ...
private MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
@BeforeEach
void setUp() {
RestAssured.port = port;
mySQLContainer.start();
}
@AfterEach
void tearDown() {
mySQLContainer.stop();
}
@Test
@DisplayName("BCSDLab 트랙 정보를 조회한다")
void findTracks() {
// ...
}
@Test
@DisplayName("BCSDLab 트랙 정보 단건 조회")
void findTrack() {
// ...
}
}
이 상태로 테스트를 구동해보면 다음과 같이 docker container가 구동하는 모습을 볼 수 있다.
매번 @BeforeEach, @AfterEach에 선언해주기는 좀 귀찮다.
testContainer에서는 편의를 위해 @TestContainer, @Container 어노테이션을 제공한다.
@TestContainer의 내부 어노테이션중 TestcontainersExtension
을 확인해보면 JUnit5의 LifeCycle을 오버라이딩하여 컨테이너를 관리하는 것을 확인할 수 있다.
@Testcontainers
class TrackApiTest {
// ...
@Container
private MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
// ...
@Test
@DisplayName("BCSDLab 트랙 정보를 조회한다")
void findTracks() {
// ...
}
@Test
@DisplayName("BCSDLab 트랙 정보 단건 조회")
void findTrack() {
// ...
}
다 좋은데 한가지 문제가있다.
매 테스트마다 컨테이너를 새로 띄우는바람에 테스트 속도가 매우 느려졌다.
겨우 3개 실행하는데 30초가 걸리는 것이 바람직한 상황은 아니라고 느껴진다.
예전에 정리했던 테스트별로 DB 초기화하기 글에서 마주했던 DirtiesContext의 문제점과 유사한 상황이라고 여겨진다.
컨테이너를 단 한번만 띄우고 재사용하도록 개선해보자.
개선 후 3초정도로 약 10배 가량의 속도 개선을 이룰 수 있었다. 👍
테스트 격리를 위해 DirtiesContext를 사용하고 있어 컨텍스트를 다시 띄우는 비용이 들고있다.
DB를 turncate를 수행하는 방식으로 변경해보려고한다.
해당 방식을 적용하기 위해 아래 블로그를 참고해서 적용했다.
참고 블로그 - 테스트 컨테이너를 사용하는 이유 + 테스트 격리
@TestComponent
public class DBInitializer {
private static final int OFF = 0;
private static final int ON = 1;
private static final int COLUMN_INDEX = 1;
private final List<String> tableNames = new ArrayList<>();
@Autowired
private DataSource dataSource;
@PersistenceContext
private EntityManager entityManager;
private void findDatabaseTableNames() {
try (final Statement statement = dataSource.getConnection().createStatement()) {
ResultSet resultSet = statement.executeQuery("SHOW TABLES");
while (resultSet.next()) {
final String tableName = resultSet.getString(COLUMN_INDEX);
tableNames.add(tableName);
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void truncate() {
setForeignKeyCheck(OFF);
for (String tableName : tableNames) {
entityManager.createNativeQuery(String.format("TRUNCATE TABLE %s", tableName)).executeUpdate();
}
setForeignKeyCheck(ON);
}
private void setForeignKeyCheck(int mode) {
entityManager.createNativeQuery(String.format("SET FOREIGN_KEY_CHECKS = %d", mode)).executeUpdate();
}
@Transactional
public void clear() {
if (tableNames.isEmpty()) {
findDatabaseTableNames();
}
entityManager.clear();
truncate();
}
}
해당 방식의 다른 응용 방식은 다음 글을 확인하도록 하자.
테스트별로 DB 초기화하기
Dirties Context 사용 시
DB 초기화 방식 사용 시
컨텍스트 생성비용을 줄임으로써 테스트 수행시간을 5~600ms
에서 250ms
정도로 개선할 수 있었다.
JPA에 컨테이너로 뜬 MySQL에 대한 DataSource를 주입해줘야한다.
yml 파일에 간단하게 testContaine의 properties를 설정하여 사용할 수 있지만 @DynamicPropertySource
를 적용해볼 수 있다.
@DynamicPropertySource
는 Spring 5.2.5부터 지원하는 기능으로 동적으로 속성을 관리하는 기능이다.
구체적인 내용은 Baeldung 문서를 확인하도록 하자.
Guide to @DynamicPropertySource in Spring
다음과 같이 abstract class로 분리하고 DynamicPropertySource까지 적용할 수 있다.
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(DBInitializer.class)
public abstract class AcceptanceTest {
private static final String ROOT = "test";
private static final String ROOT_PASSWORD = "1234";
@LocalServerPort
protected int port;
@Autowired
private DBInitializer dataInitializer;
@Container
protected static MySQLContainer container;
@DynamicPropertySource
private static void configureProperties(final DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.username", () -> ROOT);
registry.add("spring.datasource.password", () -> ROOT_PASSWORD);
}
static {
container = new MySQLContainer("mysql:8")
.withDatabaseName("test")
.withUsername(ROOT)
.withPassword(ROOT_PASSWORD);
container.start();
}
@BeforeEach
void delete() {
dataInitializer.clear();
RestAssured.port = port;
}
}
기존에는 h2 DB를 사용중인 모습을 확인할 수 있다.
DynamicPropertySource를 적용하고 난 뒤에는 h2가 아닌 mysql을 정상적으로 사용하는 모습을 확인할 수 있었다.
TestContainer를 이용하여 docker container 환경에서 테스트를 수행하도록 환경을 구성해봤다.
실제 운영환경과 유사한 환경으로 테스트를 수행할 수 있어 신뢰도가 높아졌다는 부분과 mysql과 h2의 dialect 차이에 대한 신경을 쓰면서 생기는 불필요한 소요가 사라졌다는 부분이 가장 인상적이였다.
실제로 작성한 코드에 대한 정리다.
패키지 구조는 다음과 같이 구성되었다.
TrackApiTest.class
class TrackApiTest extends AcceptanceTest {
// ...
}
AcceptanceTest.class
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(DBInitializer.class)
public abstract class AcceptanceTest {
private static final String ROOT = "test";
private static final String ROOT_PASSWORD = "1234";
@LocalServerPort
protected int port;
@Autowired
private DBInitializer dataInitializer;
@Container
protected static MySQLContainer container;
@DynamicPropertySource
private static void configureProperties(final DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.username", () -> ROOT);
registry.add("spring.datasource.password", () -> ROOT_PASSWORD);
}
static {
container = new MySQLContainer("mysql:8")
.withDatabaseName("test")
.withUsername(ROOT)
.withPassword(ROOT_PASSWORD);
container.start();
}
@BeforeEach
void delete() {
dataInitializer.clear();
RestAssured.port = port;
}
}
DBInitializer.class
// SpringBootApplication 구동 시 ComponentScan의 대상으로 지정되지 않도록 TestComponent로 선언함.
@TestComponent
public class DBInitializer {
private static final int OFF = 0;
private static final int ON = 1;
private static final int COLUMN_INDEX = 1;
private final List<String> tableNames = new ArrayList<>();
@Autowired
private DataSource dataSource;
@PersistenceContext
private EntityManager entityManager;
private void findDatabaseTableNames() {
try (final Statement statement = dataSource.getConnection().createStatement()) {
ResultSet resultSet = statement.executeQuery("SHOW TABLES");
while (resultSet.next()) {
final String tableName = resultSet.getString(COLUMN_INDEX);
tableNames.add(tableName);
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void truncate() {
setForeignKeyCheck(OFF);
for (String tableName : tableNames) {
entityManager.createNativeQuery(String.format("TRUNCATE TABLE %s", tableName)).executeUpdate();
}
setForeignKeyCheck(ON);
}
private void setForeignKeyCheck(int mode) {
entityManager.createNativeQuery(String.format("SET FOREIGN_KEY_CHECKS = %d", mode)).executeUpdate();
}
@Transactional
public void clear() {
if (tableNames.isEmpty()) {
findDatabaseTableNames();
}
entityManager.clear();
truncate();
}
}
우테코하면서 TestContainer를 적용한 팀들이 많았었다. 하지만 우리팀은 크게 필요성을 느끼지 못해 적용을 안했었는데 이렇게 필요한 상황이 오고 적용을 하니 체화가 좀더 잘 되는 것 같다.
이제 겪었던 어려움과 해결 과정을 팀원들에게 잘 전파해봐야겠다.
맛있는 글 잘 보고 갑니다 ~_~