월간 지앤선

'TDD 잘알못을 위한 돌직구 세미나' 참관기 - 그 남자와 그 여자의 사정

여자 편

글 - May Lee 님


안녕하세요. 지난 2018/6/21 TDD 잘알못을 위한 돌직구 세미나를 다녀온 후기를 작성하게 된, TDD를 적극적으로 실천 하고 있지는 못하지만 테스트 코드 작성에 많은 시간을 할애 하려고 노력하는 흔한 프로그래머입니다.

세미나에 대한 전반적이면서도 세세한 후기는 같은 회사의 동료인 이동욱님의 블로그(http://jojoldu.tistory.com/306)에 적혀 있고 함께 참석하신 다른 프로그래머이신 강대명님의 후기(링크)에서도 보실 수 있으실테니 저는 개인적인 느낌 위주로 후기를 적어보려 합니다.


먼저, 해당 세미나에 참석하게 된 계기는 아래와 같습니다. 평소에 테스트 코드를 작성하면서 느꼈던 어려움입니다. (아래 내용들은 세미나 참석 전 사전 질문 취합 시에 제출 했던 내용과 일부 일치하기도 합니다.)

    • 나는 API 위주의 사용자 테스트만큼 유닛 테스트가 주는 가치를 잘 모르겠다.

    • 나는 미리 응답값을 정의해두고 특정 입력을 집어 넣어 메서드가 잘 동작하는지 확인하는 행위가 어떤 가치가 있는지 잘 모르겠다(게다가 Java와 같은 강력한 타입 제한 언어를 사용하는 입장에서는 입력값 또한 제한적일 수 밖에 없지 않은가?)

    • 나는 유닛 테스트에서도 자꾸 DB에 다녀오는 CRUD를 테스트 하려고 한다.

    • 위의 이유로 내가 작성하는 유닛테스트는 인티그레이션 테스트에 더 가깝다.

    • 위의 이유로 내가 작성하는 유닛테스트는 동작을 수행하는 데 있어 빠르거나 가볍지 못하다.

    • 테스트 코드 작성하는 것이 고역이다. (잘 못짜니까 더 짜기 싫다.)

    • 테스트 코드를 먼저 작성해서 컴파일 오류부터 시작하는 TDD는 엄두도 내지 못한다. (반감마저 가지고 있다. 도대체 왜 그렇게 하는 걸까?)


아래는 세미나를 다녀온 후 해소된 어려움(혹은 강박) 입니다.

    • 테스트 할 수 있는 만큼만 하지 뭐.

    • 서비스 인터페이스에 기능이 추가되면 동일한 입출력을 가진 테스트 메서드를 작성하려고 노력한다.

    • 이것도 길이고 저것도 길이면 일단 갈 수 있는만큼 아무 데나 가보자.

    • 테스트에 사용되는 리소스를 최대한 분리해서 가볍게 만든다.

    • 위의 이유로 미리 응답 값을 정의해두는 행위는 큰 의미가 있다고 느꼈다.

    • 남들도 어려운가보다.

어떤 느낌인지 짐작이 가신다면 이야기가 쉬워질 것 같습니다. 

저는 테스트 코드, 아니 엄밀히 말하면 유닛 테스트를 작성하는 것에 큰 스트레스를 받고 있었습니다. 유닛 테스트에서 말하는 작은 단위가 어디까지인지 구분하는 것이 너무 어렵고(이건 세미나를 다녀온 이후인 지금도 여전합니다. 제가 생각하는 '작은 단위'는 매번 API 응답값을 제공하는 service logic 단위) 테스트 코드 동작을 보장하기 위해 주입 해야되는 Dependency의 규모가 컸으며, 그로 인해 테스트 하나 돌리는데 길게는 수 초간 기다려야 하는 스트레스까지 있었습니다. 그런 스트레스에도 불고하고 서비스 로직을 테스트 하기 위해 코드를 작성해야 될 것 같다는 강박에 시달리기도 하고요.

세미나 후에는 그런 스트레스로부터 약간이나마 벗어난 상태입니다. 그래서인지 이번 세미나에서 얻은 가장 큰 보람(?)은 심리적인 위안인 것 같습니다. 무엇을 테스트할 것인지 정의하고 얼만큼 테스트할지 범주를 가늠하는 일은 숙련도 높은 개발자들도 어렵고 부담스러워 한다는 사실만으로도 위안을 받는 느낌이었달까요? (약간 정신승리 같기도 합니다만.)


첫 번째 발표 - 자성의 시간

특히 첫 번째 발표인 박재성님의 말씀에서는 걷지도 못하는데 뛰려고 하는 나를 반성하게 됐는데요. 제가 테스트 하려는 대상 자체가 복잡하거나 정교하게 엮여 있는 비즈니스 코드들이기 때문에 테스트 코드를 작성하는데 공수가 더 많이 드는 것이 자명했던 것입니다. 그리고 지속적인 리팩토링에 많은 시간을 할애하지 않은 실질적인 문제로 인식한 것은 개인적으로 매우 큰 수확이라 말하고 싶습니다.

박재성님이 제안하신 TDD 실천 방법은 아래와 같습니다.

    1. xUnit 테스트하기: 테스트 코드 작성이 용이한 유틸성 작은 메서드로 시작한다.

    2. TDD 연습하기: 요구사항이 명확한 프로그램 위주로 프로젝트를 진행한다. UI/DB 등 다른 레이어의 의존이 없는 예제로 연습하면 좋다.

    3. 리팩토링과 객체지향 설계, 제약조건 추가: 같은 요구사항도 다시 구현해보고, 객체 지향적 설계로 변경해보며, 의식적으로 제약조건을 추가해 난이도를 증가시킨다.

    4. 웹이나 모바일 프로젝트에 적용하기: 3단계와 병행

    5. ATDD & CI 적용

해당 내용을 들으며 재성님이 말 하고자 하는 것은, 작은 가치로부터 시작해 꾸준 하고 점진적으로 발전시키라는 것으로 이해됐습니다. 어떤 계기로든 TDD를 실천하고자 뛰어드는 개발자가 있다면, TDD를 흉내내고 있지만 아직 체화하지 못했다면, 아직 별다른 학습 계획이 없다면, 위에 안내된 길을 따라보는 것을 제안하고 싶습니다. 저도 세미나를 들은 후 로버드 C.마틴의 클린소프트웨어를 읽으며 볼링게임을 만들기 시작했습니다.


두 번째 발표 - 지루했다. 하지만 그것은 내가 못알아들어서였어...

발표를 듣는 동안은 다소 지루했습니다. 발표에서 다루는 내용이 너무나 간단하다고 느꼈기 때문입니다. 너무 추상적이어서인지, 심지어 말장난 같다고 느끼기도 했습니다. 테스트 하기 어려운 이유는 코드가 테스트하기 어렵게 짜여졌기 때문이므로 테스트하기 쉬운 코드를 작성하면 된다니요. 아니 이 무슨 역/이/대우가 동치에 반박 마저도 불가한 연역법이오. 여보시오, 의사양반. 하지만 열띈 질문과 토론 시간을 거치고, 거기에서 얻은 팁들을 실제로 코딩할 때 적용 하며 요 며칠 감탄하고 있습니다. 유닛 테스트에 대한 스트레스도 많이 덜어졌습니다.

두 번째 발표자이신 정진욱님께서 짚어주신 테스트의 장애물은 크게 다음과 같았습니다.

  1. 불확실한 것: LocalDate.now() 처럼 상황에 따라 다른 것
  2. 부수효과(side-effect)가 발생하는 것: 외부에 대한 I/O가 발생하는 것

특히 제가 테스트코드를 작성할 때 가장 어려워 하는 것은 바로 2번 부수효과와 관련된 것들이었습니다. DB를 이용해 데이터를 저장하고 읽는 것이 제가 하는 개발의 가장 큰 비중을 차지 하니 그것(DB Read/Write) 위주로 테스트를 하게 되고, 그러니 테스트의 스코프가 넓을 수 밖에요. 그래서 그런 부수효과를 일으키는 코드들을 테스트 코드로 커버할 때는 격리시켜야 하는 것이었습니다.

예제 코드를 보실까요? 아래는 평소처럼 작성하던 유닛 테스트 코드입니다.

테스트 코드를 만들고요.

@RunWith(SpringRunner.class)
public class ServiceTest {
    @Autowired
    private SomeService someService;
    
    @Test
    public void dummy() {
        boolean doSome = someService.doSome(SomeObjectOne.of("test", 1L), SomeObjectTwo.of("test", SomeEnum.TEST));
        assertTrue(doSome);
    }
}

필요한 이런저런 클래스들을 만들어 줍니다.

@Getter
@AllArgsConstructor(staticName = "of")
public class SomeObjectOne {
    private String name;
    private Long num;
}

@Getter
@AllArgsConstructor(staticName = "of")
public class SomeObjectTwo {
    private String name;
    private SomeEnum someEnum;
}

public enum SomeEnum {
    TEST("test"), MAY("may");
    private String desc;

    SomeEnum(String desc) {
        this.desc = desc;
    }

    public String getDesc() { return desc; }
}

이런 서비스가 있다고 해보죠. 인자로 받은 두 개의 객체에서 필요한 값들을 꺼내어 가공하거나 이용합니다. SomeRepository를 이용해 valid 하는 부분은 DB에 I/O를 발생시킵니다.

public class SomeService {
    @Autowired
    private SomeRepository someRepository;

    public boolean doSome(SomeObjectOne one, SomeObjectTwo two) {
        // do something.. 
        someRepository.valid(one.getNum(), two.getSomeEnum(), ...);
        return false;
    }
}

다음은 진욱님이 말씀해주셨던 I/O를 발생시킬 수 있는 대한 부분을 격리시킨 모습입니다. SomeService에 세터를 만들어 SomeRepository를 주입받을 수 있게 만들고

public class SomeService {
    private SomeRepository someRepository;

    @Autowired
    public void setRepository(SomeRepository repository) {
        this.someRepository = repository;
    }

    boolean doSome(SomeObjectOne one, SomeObjectTwo two) {
        // do something..
        return someRepository.valid(one.getNum(), two.getSomeEnum());
    }
}
그리고 기존의 코드를 고치는 겁니다!!
@RunWith(MockitoJUnitRunner.class)
public class ServiceTest {
    @Mock
    private SomeRepository someRepository;

    @InjectMocks
    private SomeService someService = new SomeService();
}

테스트 코드에서는 stub 처리를 합니다.

@Test
public void dummy() {
    SomeObjectOne one = SomeObjectOne.of("test", 1L);
    SomeObjectTwo two = SomeObjectTwo.of("test", SomeEnum.TEST);

    // 짜잔!
    Mockito.when(someRepository.valid(one.getNum(), two.getSomeEnum())).thenReturn(false);

    boolean doSome = someService.doSome(one, two);
    assertTrue(doSome);
}

위의 예는 세미나를 들은 뒤 실천하고 있는 부분 중의 하나입니다. (시간이 넉넉했다면 훨씬 적절한 코드를 예로 들 수 있을 것 같은데 아쉽네요.) 기존의 테스트에서 사용되는 자원 중 일부분을(데이터를 읽고 쓰는 부분)을 단순히 목으로 처리하는 것만으로도 테스트 코드를 수행하는데 엄청난 시간 절약 효과가 있었습니다. Mockito를 이용한 when().then() 전략도 훨씬 사용하기 유용해졌고요.

예제 코드에서의 service 코드는 매우 단순하지만 실제로 작성하는 코드들은 훨씬 복잡하기에, 특정 자원이나 행위를 spy 혹은 stub 하는 것 만으로도 데이터 입출력 핸들링과 동작 제어가 간편 해졌습니다.

두 분이 발표하신 내용들을 곰곰히 복기할 수록 감탄하고 있습니다. 지난 시간 오랫동안 테스트 코드를 작성 하며 고통받고(!), 체득하고, 고민하고, 결론 낸 내용을 짧은 시간동안 엄청나게 전수해주신 것 같아요. 


다음 세미나는 실제 코드 레벨에서 더 풍부한 예제와 함께 진행됐으면 하는 바람입니다. 오랜 시간동안 수련하여 얻은 insight와 know how를 나누어 주셔서 감사합니다.