본문 바로가기
Spring/DB Migrator

[DB Migrator] 테스트주도개발 시작하기

by Taler 2022. 10. 3.

2022.09.21 - [Spring/DB Migrator] - [DB Migrator] SpringBoot 동적으로 MongoRepository 생성 및 등록하기

 

[DB Migrator] SpringBoot 동적으로 MongoRepository 생성 및 등록하기

2022.09.20 - [Spring/DB Migrator] - [DB Migrator] SpringBoot 동적으로 JpaRepository 생성 및 등록하기 [DB Migrator] SpringBoot 동적으로 JpaRepository 생성 및 등록하기 설명은 싸다(?) 코드를 보여줘라 h..

taler.tistory.com


이전 글의 말미에서 앞으로의 프로젝트 로드맵을 언급했다. 다시 가져와보자면 아래와 같다.

 

  1. 작업 분산 로직 구성하기
  2. 테스트 코드 작성하기 
  3. 멀티스레딩을 테스트 가능한 구조로 리팩터링 하기
  4. 생각했던 두 가지 멀티스레딩 모델에 대해서 구현해보기
  5. 각각 성능 비교하기
  6. 빌드 후 사용자가 LegacyDB와 MigrationDB를 지정할 수 있도록 변경하기
  7. AWS 등의 원격 DB에도 연동할 수 있도록 변경하기

 

1번부터 개발을 다시 시작하기에 앞서 설계를 다시 구체적으로 할 때가 왔음을 느꼈다. 1번은 간단히 구현할 수 있지만, 작업 분산을 함에 있어서도 간단히 Map<repository, repository>으로 할지 아니면 HashMap<String, Map<Repository, Repository>> 형식을 사용해서 Entity 이름까지 넘겨줄 지 고민해봐야 한다. 이런 작은 고민에서 시작해서 Migration Task 안의 여러 단계를 어떻게 쪼갤 지까지 상당한 부분들을 고민해봐야 한다. 또한 개발을 하다가도 더 나은 방법이 생각나기도 할 것 같았다.

 

단순하게 말하자면, 계속해서 수정 및 확장을 진행해야 했다.

 

여러 가지 수정을 하기에도 용이하고, 각 수정을 할 때마다 제대로 동작하는지 확인할 수 있는 구조를 원했다. 하지만 본 프로젝트는 DB와 연결되어 Migration을 하는 것이 주 기능이기 때문에 한 번의 빌드와 테스트만으로도 꽤 오랜 시간이 걸린다. 그래서 필자는 테스트코드를 작성해야겠다는 생각이 들었고, 테스트주도개발을 도입할 때라고 느꼈다.

 

 

테스트주도개발 시작하기


테스트주도개발은 사실 말로만 들었지 실제로 해본 경험은 없다. 테스트코드를 먼저 작성하고 이후에 구현을 시작하라고 하는데, 당장 구현체가 없으니 어떻게 테스트코드를 작성해야되는지 알 수가 없었다. 그래서 여러 자료도 뒤적거려봤지만, 역시나 왕도는 없었다.

테스트 주도 개발. 독서 큐에 걸려있는데... 큐가 pending되고 있다.

아직 제대로 읽어본 것은 아니지만, 책을 읽어도 '어떻게 해라!' 보다는 보통 테스트코드가 왜 중요한지, 테스트 코드를 클린하게 개발하기 위해선 어떤 것들을 고려해야 하는지, 작성에 도움을 주는 툴은 어떤 것들이 있는지 설명한다. 예를 들어보자. 켄트 벡이 출간한 테스트 주도 개발을 보면 시작부분에 다음과 같은 구절이 있다.

 

내 목표는 여러분이 테스트 주도 개발(TDD)의 리듬을 보도록 하는 것이다. 그 리듬은 다음과 같이 요약할 수 있다.

1. 재빨리 테스트를 하나 추가한다.
2. 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
3. 코드를 조금 바꾼다.
4. 모든 테스트를 실행하고 전부 성공하는지 확인한다.
5. 리팩토링을 통해 중복을 제거한다.
...


자, 그럼 이제 테스트코드를 재빨리 추가해보자.

....아마 테스트코드가 뭔지 알고 있어도, 심지어는 어떻게 작성하는 지 알고 있어도 무작정 시작하기는 쉽지 않다. 개인적으론 테스트주도개발을 시작하기 위한 지침서는 테스트코드에 집중하는 것은 물론, 그 소프트웨어 공학적인 절차에도 초점을 맞춰야 한다고 생각한다. 그래서 필자가 생각하는 테스트코드를 작성하는 방법, 테스트 주도 개발을 시작하는 절차를 논해보고자 한다.

필자가 정말정말 짧은 개발 경험을 거치면서 어렴풋나마 체득한 테스트 코드를 작성하는 과정은 다음과 같다.

 

본격 테스트코드 시작하기

1. 프로젝트 목표에 따른 요구사항, 각 요구사항의 이유를 정의하라.
2. 해당 기능을 어떻게 구현할 지 설계하라.
3. 해당 명세들과 테스트코드 작성 요령에 의거 테스트코드를 작성하라.

 

실력이나 경험이 쌓이면 아주 자연스럽게 2단계까지 머리 속에서 진행되고, 그렇기 때문에 대부분의 TDD 방법론 소스들이 3단계에 대해서만 논하는 것이 아닌가 싶다. 코드를 많이 보다보면, 그리고 개발하면서 알게 되는 여러 프로젝트 히스토리를 통해서 파악할 수 있게되는 것이니 말이다.

필자 또한 DB Migrator 프로젝트의  1번 과정에 대해서는 자연스럽게 파악하고 있다. 반면 아직 짜본 코드나 봐본 코드가 그렇게 많지는 않아 2번부터는 자연스럽게 떠오르진 않는다. 언젠간 자연스레 떠오르겠지만, 지금은 위 4가지 단계가 필요하다. 그렇다면 각 단계는 왜 필요하다는 것일까? 단계 별로 논해보자.

 



1. 프로젝트 목표에 따른 요구사항, 각 요구사항의 이유를 정의하라

첫 번째로 프로젝트에 어떤 기능들이 필요한지 정의해야 한다. 요구사항의 정의라고도 할 수 있는데, 프로젝트가 궁극적으로 달성하고자 하는 목표가 무엇인지, 그것을 이루기 위해서는 어떤 것들이 필요한 지 정의해야 한다. 해당 과정이 필요한 이유는 각 기능이 왜 필요한 지를 아는 것에 따라 구현 방법이 달라질 수 있기 때문이다.

 

예를 들어보자. 간단히 구매하려는 옷과 관련된 옷들을 추천해주는 쇼핑몰을 구현하려고 할 때, 중요한 것은 추천 기능이다. 해당 프로젝트에서 연관된 옷을 보여주는 기능은, 일반적인 쇼핑몰의 기능과는 전혀 다른 구현 방법을 선택해야 한다. 같은 기능을 구현하더라도 그 기능을 구현하려는 이유, 전체 프로젝트의 목적에 의거해 설계가 진행된다.

 

물론 프로젝트를 시작함에 있어 자연스럽게 진행되는 단계이긴 하다. 하지만 요구사항의 정의 없이 프로젝트를 시작한 경우, 그리고 그 프로젝트를 위한 테스트코드를 작성하려는 경우는 불필요한 코드를 작성해 시간을 낭비하는 것보다는 시간이 들고 귀찮더라도 꼭 한 번 정의를 하고 넘어가도록 하자.

 

만약 테스트코드를 작성한 뒤 실제 기능의 명세나 설계를 변경하려고 하면, 너무 많은 것들을 바꿔야 한다. 예를 들어보자.

 


...

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
class MigrationServiceTest {

    @Autowired MigrationService migrationService;
    
    @Test
    void migraitonTest() {
    	//given
        List<BaseLegacyEntity> legacyEntities = new ArrayList<>();
    	for (int i = 0; i< 100; i++)
    		legacyEntities.add(makeDummyLegacyEntity(i));
        postRegisterRepository(legacyEntities);
        
        //when
        migrationService.migrate();
        
        //then
        List<MigrationRepository> mrs = getMigrationRepositories();
        mrs.forEach((migrationRepository) -> assertTrue(migrationRepository));
    }
    
}

...

 

자. 아무런 설계 없이 코드를 작성했고, 그 코드에 맞게 테스트코드를 작성했다. 그리고 해당 테스트를 통과시키기 위해서 본 서비스 코드를 작성해보자.

 

...

@RequiredArgsConstructor
@Service
public class MigratorService {

	public void migrate() {
    		taskQueue.entrySet()
                    .forEach(entry -> {
                        MongoRepository legacyRepository = entry.getKey();
                        JpaRepository migrationRepository = entry.getValue();
                        List<BaseLegacyEntity> legacyEntities = legacyRepository.findAll();
                        List<BaseMigrationEntity> migrationEntities = legacyEntities.stream()
                                .map(BaseLegacyEntity::convert).toList();
                        migrationRepository.saveAll(migrationEntities);
                    });
    }
}

...

 

migrationService의 migrate라는 함수 내부에서 Migration에 대한 모든 결정권을 가지고 일이 처리되니 밖에서 어떤 일이 어떻게 진행되는지 확인할 방법이 없다. 또한 무엇보다 코드가 상당히 많은 일을 처리하며 확장성도 없다. 완벽히 캡슐화가 된 것처럼 보이지만, 저렇게 만드는 것은 좋지 않다는 생각이 든다.

 

그래서 메소드를 변경해 특정한 Entity를 받아서, 해당 Entity에 대해서 코드를 수정하려고 해보자... 다시 테스트코드부터 수정하고, migrate 서비스까지도 수정해야 한다. 만약 이번 수정에서도 제대로된 설계를 생각하지 않고 막무가내로 수정하면 또 다시 이처럼 되돌아올 확률이 높아진다.

 

지금까지 설명한 이유들 때문에 프로젝트의 목표와 이유를 제대로 설계하는 것이 코드 작성보다 먼저 와야 한다. 애자일에서도 최소한의 문서화가 필요하다. 애자일의 일종인 TDD에서도 당연히 이정도의 최소한의 문서화는 필수적이다. 물론 경력과 경험이 쌓이면 그것으로 커버할 수 있을 지도 모른다. 하지만 팀에 경력 개발자만 있는 것은 분명 아닐 것이다. 때문에 TDD에서도 요구사항의 명세나 프로젝트 목표 등에 대한 문서화 단계는 필수다.


필자가 혼자서 혹은 여러 사람들과 함께 프로젝트를 진행할 때마다 느끼는 거지만, 가장 중요한 것은 최소한 '어떤 것을 어떤 이유로 개발하는지 비전. 앞으로 어떤 것들을 개발할 것인지 로드맵. 지금 당장 개발 중인 기능에 대한 구체적인 명세' 이다. 애자일이라고 문서를 작성하지 않으면, 개발을 한창 하다가도 길을 잃기 쉽상이다.

 



2. 해당 기능을 어떻게 구현할 지 설계하라.

두 번째는 테스트코드를 작성하고자 하는 기능을 어떻게 구현할 지 구체적으로 생각하는 단계이다. 첫 번째 단계에서 필요한 기능들을 생각했다면, 그것을 토대로 어떤 순서로 구현할 지도 떠오를 것이다. 그렇다면 해당 기능을 어떻게 구현할 지 구체적으로 설계해보자. 설계의 중요성에 대해서는 위 단계에서 설파했으니 이번에는 필자의 예시를 들어가며 어떤 것들을 구현하려 하는지 들어보자.

 

예를 들어 필자는 현재 DB Migrator 프로젝트에서 실제 Migration이 진행되는 부분을 약간 고도화하려고 한다. 기존의 구현은 Thread의 구현체인 Callable을 사용해서 구현했지만, 크게 복잡하거나 무거운 작업도 아니거니와 배치적으로 돌아가야하는 작업도 아니다. 

 

이에 따라 Stream을 사용해서 구현하려고 한다. 또한, 각 Migration 작업은 상당히 오랜 시간이 필요한 일이기 때문에 작업이 얼만큼 진행됐는지도 사용자에게 보여주는 것이 필요하다. (그렇지 않으면 제대로 동작 중인지, 혹은 에러가 발생했는지 알 길이 없다.) 이런 요구사항에 의거 Migrate 메소드를 다음과 같이 간략하게 구성했다.

 

...
// Good

@RequiredArgsConstructor
@Service
public class MigratorService {

	public Progress migrate(String entityName, LegacyRepository lr, MigrationRepository mr) {
    		List<BaseLegacyEntity> legacyEntities = lr.findAll();
            
            List<BaseMigrationEntity> migrationEntities = legacyEntities.stream()
            		.map(BaseLegacyEntity::convert)
                    	.toList();
                    
            mr.saveAll(migrationEntities);
            
            return new Progress(entityName);
    }
}

...

 

이렇게 구현한다면, 나중에 Progress에 어떤 변형을 주어도 migrate 메소드 자체는 크게 수정하지 않아도 된다. 또한, migrate 메소드의 input과 output이 확실히 정해졌으니 migrate 메소드에 대한 테스트코드를 작성하는 것도 훨씬 더 구체화됐다.

 

처음부터 구체적으로 설계하는 것은 쉽지 않다. 때문에 객체지향 원칙에 따라 확장에는 열려있는 구조를 만드는 것이 중요하다. 아래 코드를 보자. (로직은 동일하다)

 

...
// Bad

@RequiredArgsConstructor
@Service
public class MigratorService {

	public void migrate() {
    		taskQueue.entrySet()
                    .forEach(entry -> {
                        MongoRepository legacyRepository = entry.getKey();
                        JpaRepository migrationRepository = entry.getValue();
                        List<BaseLegacyEntity> legacyEntities = legacyRepository.findAll();
                        List<BaseMigrationEntity> migrationEntities = legacyEntities.stream()
                                .map(BaseLegacyEntity::convert).toList();
                        migrationRepository.saveAll(migrationEntities);
                    });
	}
}

...

 

좋은 예시와 나쁜 예시 각각에 결과가 진행되고 있음을 수정해야된다고 해보자. 좋은 예시의 경우 migrate를 호출한 메소드에서 Progress를 처리하는 방식을 수정하면 그만이다. 반면, 나쁜 예시의 경우 어디서부터 손대야 할 지 감이 잘 오질 않는다. 좋은 예시에서는 하나의 메서드에서 하나의 역할만 맡게 하고, 확장에는 열려있는 구조로 코드를 작성함으로써 훨씬 더 수정하기 용이한 코드가 됐다.

 

테스트주도개발의 방법론을 논하는 자리에서 뜬금없이 객체지향 원칙을 설파하니 주제와 조금 멀어진 기분이 들지만, 어쨌든 말하고자 하는 바는 구현하고자 하는 기능을 어떻게 구현할 지 설계하되, SOLID 원칙에 의거해 설계하라는 것이다.

 

이제 마지막 단계로 이동하자.

 


 

3. 해당 명세들과 테스트코드 작성 요령에 의거 테스트코드를 작성하라.

마지막 단계는 직접 테스트코드를 작성하는 단계이다. 지금까지 어떤 기능을 왜 구현하는 지 파악했으며, 해당 기능을 구현하는 메소드의 파라미터와 출력값을 알게 됐다. 바로 테스트코드를 작성하는 것은 쉽지 않으니, given-when-then으로 나누어 먼저 어떻게 작성할지 생각해보자.

 

...

class MigratorServiceTest {

    @Test
    public void lightMigrationTest() throws Exception {
        //given
        // Entity 생성 (10 ~ 20 종류)
        // EntityManager에 등록하기
        // Entity 당 1000개씩 더미 데이터 생성
        // Repository를 사용해 저장하기

        //when
        // migrate 호출
        // 받아온 Progress 객체를 result로 저장하기

        //then
        // assertEqual(result, new Progress(Entity));
        // Repository로 전체 Entity가 migration 됐는지 확인하기
    }

    @Test
    public void heavyMigrationTest() throws Exception {
        //given
        // Entity 생성 (100 ~ 120 종류)
        // EntityManager에 등록하기
        // Entity 당 1만개씩 더미 데이터 생성
        // Repository를 사용해 저장하기

        //when
        // migrate 호출
        // 받아온 Progress 객체를 result로 저장하기

        //then
        // assertEqual(result, new Progress(Entity));
        // Repository로 전체 Entity가 migration 됐는지 확인하기
    }


}

...

 

테스트코드를 작성할 때도 이렇게 오래 걸리는 테스트는 가벼운 테스트를 따로 분리해서 생성하는 것이 좋다. 테스트에 너무 오랜 시간이 걸리면 개발자는 테스트 과정을 넘기기 쉽상이다.

 

주어진 상황에 대해서, 어떻게 행동했을 때 어떤 결과가 나와야되는지. 그것을 먼저 정의하는 것이 given-when-then이다. 이에 대해서 자세히 아는 것은 아니지만, 테스트코드를 처음 작성하는 개발자들은 해당 패턴을 채워나가는 식으로 작성하는 것이 조금 더 편할 것이다.

 

어쨌든 저렇게 수도코드를 작성했다면, 구현하면 된다. 테스트코드의 구현 과정에 대해서는 더 자세한 소스가 훨씬 많으며, 책 한 권 분량은 족히 나올만한 주제이기에 해당 글에서 다루는 것은 적절하지 못하다고 생각해 여기서 마친다.

 

 

마무리


테스트주도개발을 시작해보는 것은 처음이라 어떤 식으로 시작해야 할 지 먼저 생각해봤는데, 해당 생각이 공유해볼만 한 가치가 있다는 생각에서 출발한 글이 이렇게 길어져버렸다. 사실 경력도 없고 그냥 조그만 구멍가게 프로젝트 하나 진행하고 있는 개발자로써 어찌 이토록 당당하게 '테스트주도개발은 이렇게 시작해야한다!' 라고 설파하고 있는지는 모르겠지만, 필자가 말한 과정이 개인적으로 TDD를 시작하는 과정이었다. 

 

아직 테스트코드를 작성한 것은 아니다. 테스트코드를 작성하기 위해서 BaseLegacyRepository 등을 구현해 이를 상속받아 생성해 ApplicationContext에 등록시키는 메서드를 구현해야 하는 등 추가적으로 필요한 조치가 있다. 하지만, 필자는 이렇게 테스트코드를 어떻게 구현할 지 확실히 설계를 끝냈기 때문에 어떤 유틸 메소드들을 어떤 목적으로 구현할 지 확실히 알고 있다. 이를 알고 개발하는 것과 모르고 개발하는 것의 차이는 단순 생산성의 차이에서 끝나지는 않을 것이라 생각한다.

 

 

Reference


Given-when-then에 대해서: https://brunch.co.kr/@springboot/292

 

Given-When-Then Pattern

테스트 코드 작성 표현 방법 (스프링 부트 환경에서) | 이번 글에서는, 테스트 코드 작성 시 자주 사용하는 Given-When-Then Pattern에 대해서 간략하게 소개하겠다. 별 내용 없는 글이므로, 아주 편한

brunch.co.kr

테스트주도개발 성서: https://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788966261024 

 

테스트 주도 개발 - 교보문고

Test-Driven Development: By Example아름다운 코드와 즐거운 개발을 위한 테스트 주도 개발테스트 주도 개발은 학계와 업계에서 많은 주목을 받아온 프로그래밍 방법으로, 여러 연구 논문과 실례를 통해

www.kyobobook.co.kr

댓글