도커도 불편해
요즘 카페서비스는 MySql
을 사용하고 있는데,
테스트 환경와 운영환경을 최대한 일치 시키기 위해 테스트 역시 MySql
을 사용하고 있다.
처음에는 도커 컨테이너
를 띄워서 테스트를 돌렸다.
물론 로컬 MySql을 사용하는 것 보다는 관리포인트가 줄어들지만,
이 또한 몇 가지 불편한 점들이 있다.
- 테스트를 돌리려면 DB 컨테이너를 실행 시켜줘야 한다.
해본 사람은 알겠지만, 이게 은근히 거슬린다. compose
파일 관리도 비용이다.
프로젝트 초반에는 여러 이유들 때문에compose
파일 변경이 잦았는데,compose
파일을 관리하는 것 역시 비용이 들어가는 행위라는 것을 깨달았다.port
를 신경 써야 한다.
맥북을 이틀 간 수리를 맡긴 적이 있었다.
이틀 동안 놀수는 없어서 여자친구 맥북을 가져다 썼는데, 우리 프로젝트에서 사용하는 테스트DB 포트를 다른 용도로 이미 사용하고 있었다. 컨테이너 마다port
를 계속 신경써야 하겠구나 라는 생각이 들었다.
이런 불-편한 점들을 Testcontainer
를 통해 해결할 수 있었다.
Testcontainer
Testcontainer
를 간략하게 설명해보면, 도커 컨테이너를 자바 코드로 조작 가능 하다!
다시 말해 자바 코드로 도커 이미지를 실행하고 끌 수 있는데, 이걸로 얻을 수 있는 장점들이 제법 괜찮다.
- 실제 운영 DB와 동일한 환경 사용가능
- 테스트 실행 시 자동으로 컨테이너를 실행, 테스트 끝나면 자동 종료
- 테스트를 위한
compose
파일을 관리하지 않아도 됨 - 컨테이너를 매번 랜덤한 포트로 사용하기에 포트도 신경쓰지 않아도 된다!
장점에 비해, 사용법은 생각보다 너무나 간단하다.
우선 의존성을 추가해주자.
testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
testImplementation 'org.testcontainers:mysql'
이러면 사실 준비 끝이다.
직접 적용만 해보면 된다.
class ExampleTc {
MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
@BeforeEach
void setUp() {
mySQLContainer.start();
}
@AfterEach
void tearDown() {
mySQLContainer.stop();
}
@Test
void test1() {
System.out.println(mySQLContainer.getMappedPort(3306));
}
@Test
void test2() {
System.out.println(mySQLContainer.getMappedPort(3306));
}
}
일단 Testcontainer
는 기본적으로 매 테스트마다 랜덤한 포트에 새로운 컨테이너를 실행하고 종료한다.
그 역할을 해주는게 start()
와 stop()
이다.
실제로 test1, 2
에 찍은 sout
을 확인해보면 실행된 포트가 다른 것을 확인할 수 있다.
근데, 저 start와 stop을 매번 해야된다는게 번거롭다 생각했는지@Container
어노테이션을 통해 start()
와 stop()
을 대신할 수 있다.
class ExampleTc {
@Container
MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
}
이렇게 번거로움은 해결 했지만, 매 테스트 마다 컨테이너를 실행하고 종료하고를 반복하다 보니,
테스트 실행속도가 현저하게 느려진다.
container
를 static
필드로 선언하면, 컨테이너를 한번만 실행한다.
class ExampleTc {
static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
static {
mySQLContainer.start();
}
}
아까 처럼 두개의 테스트에서 포트넘버를 출력해보면 이번에는 같은 포트넘버가 출력된다.
실제로 우리 서비스에서는
@SpringBootTest
@ActiveProfiles("test")
public abstract class BaseTest {
private static final String ROOT = "root";
private static final String ROOT_PASSWORD = "test";
@Autowired
private DataInitializer dataInitializer;
@Container
protected static MySQLContainer container;
static {
container = (MySQLContainer) new MySQLContainer("mysql:8.0")
.withDatabaseName("yozm-cafe")
.withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD);
container.start();
}
@DynamicPropertySource
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);
}
@BeforeEach
void delete() {
dataInitializer.deleteAll();
}
}
이런 방식으로 사용하고 있다.
BaseTest
라는 추상 클래스 내에서 컨테이너를 만들고 실행시킨 뒤,
이를 상속한 테스트 클래스에서 테스트를 진행한다.
이렇게 하면, 모든 테스트가 진행되는 동한 최초 한번만 컨테이너가 실행된다.
테스트 격리는?
만약 모든 테스트마다 테스트 컨테이너가 새로 실행되고 종료된다면,
테스트 격리도 자동으로 해결된다.
하지만 이러면 전체 테스트를 돌리면 시간이 어마무시하게 걸릴 거다.
테스트 격리 까짓거 내가 직접 지워 주면 된다.
테스트를 격리하는 방법에는 여러가지가 있다.
@Transactional
@Sql
- 스키마가 변경되면 함께 수정해줘야 한다.
@BeforeEach
- 마찬가지로 스키마 변경되면 함께 수정해야 한다.
- DB Constraints를 고려해서 순서대로 삭제해줘야 한다.
- 사용되는 여러 테이블을 모두 삭제해줘야 하므로 내부 구현에 강하게 결합됨
뭐 하나 마음에 드는게 없다.
아까 실제 우리 서비스에 적용한 BaseTest
를 다시 보면 dataInitializer
라는 친구가 있다.
@BeforeEach
void delete() {
dataInitializer.deleteAll();
}
@BeforEach를 통해 deleteAll()을 실행시키고 있다.
@Component
@Profile("test")
public class DataInitializer {
private static final int OFF = 0;
private static final int ON = 1;
private static final int FIRST_COLUMN = 1;
private static final String FLYWAY = "flyway";
@Autowired
private DataSource dataSource;
@PersistenceContext
private EntityManager em;
private final List<String> deleteDMLs = new ArrayList<>();
@Transactional
public void deleteAll() {
if (deleteDMLs.isEmpty()) {
init();
}
setForeignKeyEnabled(OFF);
truncateAllTables();
setForeignKeyEnabled(ON);
}
private void setForeignKeyEnabled(final int enabled) {
em.createNativeQuery("SET foreign_key_checks = " + enabled).executeUpdate();
}
private void truncateAllTables() {
deleteDMLs.stream()
.map(em::createNativeQuery)
.forEach(Query::executeUpdate);
}
private void init() {
try (final Statement statement = dataSource.getConnection().createStatement()) {
final ResultSet resultSet = statement.executeQuery("SHOW TABLES ");
while (resultSet.next()) {
final String tableName = resultSet.getString(FIRST_COLUMN);
if (tableName.contains(FLYWAY)) {
continue;
}
deleteDMLs.add("TRUNCATE " + tableName);
} } catch (Exception e) {
e.printStackTrace();
}
}
}
deleteAll()
이 실행되면, deleteDMLs를 확인하고 비어있다면init()
해준다.init()
은SHOW TABLES
쿼리를 통해 모든 테이블 네임을 가져오는 데, 이때flyway history
관련 테이블은 제외한다. 가져온 테이블 네임들에TRUNCATE
를 붙여,deleteDMLs
에 저장한다.foreign_key_check
옵션을 끄고,deleteDMLs
에 저장 된TRUNCATE
문을 모두 실행한다.
다시foreign_key_check
옵션을 켜준다.
이 과정이 @BeforEach
를 통해 반복 되는데, 테이블이 추가/삭제 되는 경우에도 코드의 변경이 필요 없고,foreign_key_check
옵션을 설정 하여, 연관관계로부터 자유롭게 TRUNCATE
를 사용할 수 있다.
Testcontainer
와 DataInitializer
로 아주 스마트하게 테스트 환경을 구축할 수 있었다.
'우테코 > 요즘카페' 카테고리의 다른 글
테스트 속도 개선 및 리팩토링! (0) | 2023.10.06 |
---|---|
ID가 있는 엔티티를 save() 할때 발생하는 SELECT 쿼리 없애기 (0) | 2023.09.27 |
N+1 해결하기 (발생하는 쿼리 80% 줄이기) (0) | 2023.09.19 |
공간 인덱스로 조회속도 32배 개선하기(요즘 카페 지도 기능 개발) (2) | 2023.09.17 |
이미지 리사이즈 성능 개선하기 (1) | 2023.09.14 |
댓글