리팩토링을 위한 통합 테스트

Junghoon Song
MUSINSA tech
Published in
16 min readJust now

안녕하세요.
저는 무신사 운영플랫폼 엔지니어링의 송정훈입니다.

운영플랫폼 엔지니어링은 무신사의 물류 시스템을 담당하고 있으며, 현재 WMS(Warehouse Management System)와 OMS(Order Management System) 두 가지 제품을 개발하고 있습니다.

이 포스트에서는 저희가 코드 품질 개선을 위한 리팩토링을 빠르게 진행하는 과정에서 통합 테스트를 활용한 사례를 소개하고자 합니다.

배경

최근에 저희는 무신사 물류센터에서 사용할 WMS를 자체적으로 개발하였습니다.
이 과정에서 짧은 시간 내에 공격적으로 제품을 개발하여 런칭하다 보니 코드 품질에서 다음과 같은 문제점들이 있었습니다.

  • 너무 많은 일을 하고 있는 클래스
  • 클래스 간의 복잡한 의존 관계와 높은 결합도
  • 통일되지 않은 코드 스타일과 낮은 코드 가독성
  • 한 번에 대량의 데이터를 처리하며 발생하는 메모리 이슈

이러한 기술 부채는 WMS 제품의 기능 고도화와 성능 개선 작업에 방해가 되었는데요.
저희는 코드 품질을 개선하기 위해 리팩토링을 진행하게 되었습니다.

위키피디아에서 찾은 리팩토링의 정의는 아래와 같습니다.

리팩터링(refactoring)은 소프트웨어 공학에서 ‘결과의 변경 없이 코드의 구조를 재조정함’을 뜻한다. 주로 가독성을 높이고 유지보수를 편하게 한다. 버그를 없애거나 새로운 기능을 추가하는 행위는 아니다. 사용자가 보는 외부 화면은 그대로 두면서 내부 논리나 구조를 바꾸고 개선하는 유지보수 행위이다.

리팩토링은 코드의 구조를 변경하되 기능은 변경하지 않는 것이 목표입니다.

따라서 리팩토링 후에도 소프트웨어가 원래의 기능을 유지하는지 확인하는 데 테스트 코드가 필수적으로 필요하였고, 저희는 리팩토링 결과를 검증하기 위하여 테스트코드를 작성하게 되었습니다.

왜 통합 테스트를 선택하였나요?

앞에서 설명하였듯 리팩토링에 앞서 테스트 코드 작성은 매우 중요한 작업입니다.
하지만 저희가 진행하고자 하는 리팩토링은 코드 디자인을 수정하는 작업을 동반하고 있었기 때문에 단위 테스트 코드를 작성하기 어려웠습니다.

코드 디자인의 문제로 인해 구현 세부 사항에 밀접하게 작성될 수밖에 없던 테스트 코드는 쉽게 망가지고 유지보수가 어려웠기에, 저희는 단위 테스트 대신 통합 테스트 코드를 작성하는 방법을 선택하게 되었습니다.

단위 테스트(Unit Test)가 메서드와 같은 작은 코드 단위를 독립적으로 테스트한다면, 통합 테스트(Integration Test)는 여러 클래스나 모듈 간의 상호작용을 비롯한 넓은 범위를 테스트합니다.

통합 테스트는 느린 실행 속도와 복잡한 환경 설정이 필요하지만, 넓은 범위의 코드를 실행하는 만큼 회귀 방지가 뛰어나며 무엇보다 리팩토링 내성이 강해 쉽게 망가지지 않는다는 장점을 갖고 있습니다.

회귀 방지란 소프트웨어의 버그를 찾아내는 것을 의미합니다.
일반적으로 실행되는 코드가 많을수록 버그를 찾아낼 가능성이 높아져 회귀 방지가 뛰어나다고 이야기합니다.

리팩토링 내성이 강하다는 것은 테스트 코드가 변경되지 않으면서 리팩토링이 가능한 것을 의미합니다.
테스트 코드가 내부의 구현 세부 사항에 밀접한 경우 리팩토링에 의해 쉽게 망가지는데요, 통합 테스트(Integration Test)는 일반적으로 구현 코드와 무관하게 작성할 수 있는 블랙박스 테스트에 해당하므로 리팩토링 내성이 강하다고 볼 수 있습니다.

테스트 작성 전략

저희는 테스트 코드 작성에 앞서 다음과 같은 목표를 세웠습니다.

  1. 리팩토링 이후에도 동일한 동작을 보장할 수 있어야 합니다.
  2. 적은 시간과 노력으로 작성 할 수 있어야 합니다.

그리고 이러한 목표를 달성하기 위해 아래와 같은 실행 전략을 수립하였습니다.

첫 번째는 통합 테스트의 대상으로 UseCase에 대한 구현을 담당하는 서비스 클래스를 지정하였습니다.

저희는 주로 해당 역할의 클래스들을 퍼사드(Facade)로 명명하여 사용하고 있었습니다.
해당 클래스는 핵심적인 비즈니스 로직을 포함하고 있으면서도 단일 기능이 아닌 특정 비즈니스 시나리오를 모두 포함하고 있어 시스템이 의도하는 대로 동작하는지 점검하는 데 효과적인 대상이었습니다.

두 번째는 테스트 환경을 실제 환경과 유사하게 구성하는 것입니다.

대부분의 애플리케이션과 동일하게 저희도 상태 정보를 저장하기 위해 DB를 사용하고 있었는데요.
DB는 상태 정보를 관리하는 데 있어 애플리케이션에서 핵심적인 역할(일관성 및 무결성)을 수행하기 때문에 실제 환경과 동일하게 구성하는 것이 무엇보다 중요했습니다.

저희는 Testcontainers라는 도구를 사용하여 공유 의존성(Shared Dependency)을 실제와 유사하게 구성하고 테스트를 수행할 수 있도록 하였습니다.

마지막으로 빠른 테스트 작성을 위하여 Golden Master Testing을 채택하였습니다.

Golden Master Testing은 Snapshot Test, Approval Test, Characterization Test로 불리기도 하는데요.
특정 입력에 대한 출력을 캡처하여, 변경된 코드에서의 출력값과 일치하는지를 검사하는 방식을 의미합니다.

Golden Master Testing은 기존 출력값과의 비교를 통해 예상치 못한 변경 사항을 쉽게 감지할 수 있어 리팩토링에 매우 유용하게 활용될 수 있는 테스트 기법입니다.
또한 예상되는 출력값을 일일이 작성하지 않아도 되어 빠른 시간 안에 테스트 코드를 작성할 수 있다는 장점이 있습니다.

여기서 출력은 메서드의 반환값뿐만 아니라, 애플리케이션 특성상 DB의 상태 데이터와 같은 숨겨진 출력도 중요한데요.

저희는 Database Rider라는 도구를 사용하여 DB의 상태를 캡처하고 비교하는 데 활용하였습니다.

Testcontainers 를 사용한 테스트 환경 구성

Testcontainers 는 Docker container 를 활용하여 DB 나 Message Broker, API 등의 의존성을 구성해주는 라이브러리입니다.

스프링부트는 통합 테스트를 작성하기 위한 도구들을 잘 제공하고 있는데요.
3.1 버전부터는 Testcontainers 에 대한 지원이 향상되어 더 편리하게 구성이 가능하도록 돕고 있습니다.
(Improved Testcontainers Support in Spring Boot 3.1)

WMS 는 DB 로 MySQL 을 사용하였기 때문에 다음과 같이 구성을 진행하였습니다.

testImplementation("org.springframework.boot:spring-boot-testcontainers") 
testImplementation("org.testcontainers:mysql")
@TestConfiguration(proxyBeanMethods = false)
class IntegrationTestConfiguration {
@Bean
@ServiceConnection
public MySQLContainer<?> dbContainer() {
return new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("wms")
.withUrlParam("serverTimezone", "Asia/Seoul")
.withUrlParam("characterEncoding", "UTF-8")
.withInitScript("db/init.sql"); // WMS DB DDL
}
}

만약 스프링부트 3.1 미만의 버전을 사용중이라면 아래와 같은 방법으로 구성을 할 수 있습니다.

@Testcontainers
@TestConfiguration(proxyBeanMethods = false)
class IntegrationTestConfiguration {
static MySQLContainer<?> wmsDbContainer = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("wms")
.withUrlParam("serverTimezone", "Asia/Seoul")
.withUrlParam("characterEncoding", "UTF-8")
.withInitScript("db/init.sql"); // WMS DB DDL


@DynamicProperty
static void wmsDbProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", () -> mySQLContainer.getJdbcUrl());
registry.add("spring.datasource.username", () -> mySQLContainer.getUsername());
registry.add("spring.datasource.password", () -> mySQLContainer.getPassword());
}
}

그 외에도 WMS 는 Kafka, 타 서비스 (API) 와 같은 외부 의존성을 가지고 있었는데요. 모두 동일한 방식으로 Testcontainers 를 사용한 구성이 가능합니다.

다만 모든 의존성을 Testcontainers 로 구성하게 되면, 테스트 수행시 여러 Docker container 가 실행되면서 많은 시스템 리소스를 필요로 하게 됩니다.

이런 경우 DB 와 같은 경우 외부에는 노출이 되지 않는 관리 의존성이므로 실제 환경과 유사하게 구성하고, 그 이외의 비관리 의존성 (예를 들면 외부 서비스 API) 는 Mock 으로 대체하는 방법도 활용하는게 좋은 모범사례가 될 수 있습니다.

스프링은 테스트 환경 구성이 변경되지 않을 경우, Application Context 를 다시 로드하지 않고 Context Caching 을 활용하여 빠른 통합테스트를 수행 할 수 있도록 도와줍니다.

Testcontainers 를 활용하는 경우에는 Docker container 가 실행하는데 많은 시간이 소요되므로, 최대한 동일한 구성으로 Context Caching 의 도움을 받아 테스트가 수행 될 수 있도록 주의 할 필요가 있습니다.

저희는 구성정보를 별도의 추상클래스로 분리하여, 모든 통합테스트 코드가 동일한 구성을 사용 할 수 있도록 하였습니다.

@SpringBootTest(classes = IntegrationTestConfiguration.class)  
public abstract class AbstractIntegrationTest {
...
}

class SomeTest extends AbstractIntegrationTest {
@Test
void someTesT() {
sut.some();
}
}

Database Rider 를 사용한 Golden Master Testing 작성

Database Rider는 DBUnit 을 기반으로 한, DB 와 상호작용이 필요한 테스트를 쉽게 작성하고 관리할 수 있도록 도와주는 라이브러리입니다. (DB Rider 라고도 합니다.)

준비된 데이터셋을 활용하여 DB 를 특정 상태로 설정해주는 기능 이외에도, DB 의 상태를 추출하여 데이터 검증에 활용 할 수 있는 기능도 제공하고 있어 Golden Master Testing 에 유용하게 활용 할 수 있습니다.

아래와 같이 테스트 코드를 작성 할 수 있습니다.

testImplementation("com.github.database-rider:rider-spring")
@DBRider // Database Rider 테스트를 활성화하며, default datasource 를 활용하여 구성됩니다.
@SpringBottTest
class OutboundFacadeTest {

@Autowired
OutboundFacade sut;

@DataSet(value = "db/prepare.json") // 테스트를 수행하기 전 DB 상태를 구성하기 위해 준비할 데이터셋입니다.
@ExpectedDataSet(value = "db/expected.json") // 테스트가 수행된 후, 예상되는 DB 상태에 대한 데이터셋입니다.
@Test
void someTest() {
sut.some();
}
}

데이터셋은 yaml, json, xml 과 같은 형식을 지원하고 있는데요.
DataGrip (또는 IntelliJ 의 Database tool) 에서 제공하는 “json export” 기능을 활용하면 DB에서 테스트셋을 쉽게 추출 할 수 있어 json 형식을 사용하게 되었습니다.

예를 들어 출고에 대한 통합 테스트를 위하여 다음과 같은 기초 데이터셋을 준비할 수 있습니다.

{  
"warehouse": [ // warehouse 테이블 데이터
{
"code": "WAREHOUSE-1",
"name": "1센터",
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
}
],
"outbound": [ // outbound 테이블 데이터
{
"outbound_number": "20240717-1",
"warehouse_code": "WAREHOUSE-1",
"item_code": "ITEM-1",
"status": "PACKED", // 포장작업까지 완료된 상태입니다.
"quantity": 3,
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
},
{
"outbound_number": "20240717-2",
"warehouse_code": "WAREHOUSE-1",
"item_code": "ITEM-2",
"status": "PACKED",
"quantity": 1,
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
}
]
}

여기서 출고번호 “20240717–1” 주문에 대하여 출고를 한 후, 의도한대로 DB 상태가 변경되는지를 확인하기 위하여 검증 데이터셋을 아래와 같이 준비 할 수 있습니다.

{  
"warehouse": [ // warehouse 테이블 데이터
{
"code": "WAREHOUSE-1",
"name": "1센터",
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
}
],
"outbound": [ // outbound 테이블 데이터
{
"outbound_number": "20240717-1",
"warehouse_code": "WAREHOUSE-1",
"item_code": "ITEM-1",
"status": "RELEASED", // 출고가 완료된 상태입니다.
"quantity": 3,
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
},
{
"outbound_number": "20240717-2",
"warehouse_code": "WAREHOUSE-1",
"item_code": "ITEM-2",
"status": "PACKED",
"quantity": 1,
"created_at": "2024-07-17 10:00:00",
"last_modified_at": "2024-07-17 10:00:00"
}
]
}

검증 데이터셋은 직접 작성하지 않습니다.
아래와 같이 리팩토링 전 “@ExportDataSet” 을 활용하여 테스트 코드를 수행하고, 출력되는 결과에 해당하는 DB 상태를 데이터셋으로 캡쳐하여 테스트 코드에 활용할 수 있습니다.

@DBRider 
@SpringBottTest
class OutboundFacadeTest {

@Autowired
OutboundFacade sut;

@DataSet(value = "db/prepare.json")
@ExportDataSet(format = DataSetFormat.JSON, outputName="build/exported/output.json") // 생성되는 build/exported/output.json 데이터셋을, 검증용 테스트셋으로 사용합니다.
@Test
void someTest() {
sut.some();
}
}

검증 과정에서는 변경결과를 특정하기 어려운 속성 (컬럼) 을 제외하고 싶을 수 있는데요. 아래와 같이 “ignoreCols” 을 지정하는 방법으로 특정 컬럼을 검증에서 제외할 수 있습니다.

@DBRider
@SpringBottTest
class OutboundFacadeTest {

@Autowired
OutboundFacade sut;

@DataSet(value = "db/prepare.json")
@ExpectedDataSet(value = "db/expected.json", ignoreCols = {"last_modified_at"}) // last_modified_at 컬럼은 비교하지 않습니다.
@Test
void someTest() {
sut.some();
}
}

기본적으로 “@DataSet” 으로 구성된 DB 상태정보는 테스트가 종료되면 삭제가 됩니다.
하지만 그 외 테이블의 경우 데이터가 남아 있게 되므로, 테스트 수행에 앞서 모든 테이블을 truncate 하는 SQL 을 수행하도록 하였습니다.

@SpringBootTest(classes = IntegrationTestConfiguration.class)  
@Sql(scripts = "classpath:db/clean.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) // all table truncate
public abstract class AbstractIntegrationTest {

}
truncate table outbound;
truncate table warehouse;
...

마치며

리팩토링은 매우 중요한 작업이긴 하지만 빠른 비즈니스 변화로 지속적인 제품개발이 무엇보다 중요한 상황에서 리팩토링과 테스트 코드 작성에 많은 시간을 할애하기는 쉽지가 않습니다.

효율적인 리팩토링을 진행하고자 했던 저희의 사례가, 비슷한 상황의 여러 프로젝트에서 유용하게 활용될 수 있기를 바라며 글을 마치도록 하겠습니다.

읽어주셔서 감사합니다.

Musinsa CAREER

함께할 동료를 찾습니다.
운영플랫폼 엔지니어링은 무신사의 풀필먼트 비즈니스의 성장을 견인할 수 있는 물류솔루션 제품 패키지를 구축하고 운영하고 있습니다.
전국민이 사용하는 1위 패션 플랫폼 무신사에서 기술로 비즈니스를 성장시키는 경험을 함께하고 싶으시다면 아래 채용 페이지를 통해 지원해 주세요!

🚀 무신사 채용 페이지 : https://corp.musinsa.com/ko/career

--

--