{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[소프트웨어공학] Should we test private methods?

우리는 비공개 메소드들도 시험해야 하나요?

  우리는 테스트를 어떻게 해야 하나요? 소프트웨어 공학은 이에 대한 해답을 제시합니다. 단위(이를테면 클래스)부터 통합, 배포, 인수 등 다양한 측면에서의 시험을 수행해야 합니다. 중요한 것은 자동화된 테스트여야 한다는 것이죠. 세상에는 자동화된 테스트를 위한 다양한 테스트 프레임워크가 존재합니다. 대표적으로 XUnit이 있죠. 자바의 경우, JUnit입니다.

JUnit

  JUnit이 제공하는 클래스와 메소드들은 테스트 드라이버로서 작동하는 것들입니다. 즉, 우리가 만든 대상 클래스들의 객체를 생성하고, 그들의 메소드들을 호출하여 예측 값과 실제 값을 맞추어보는 것이죠. 다음 코드는 JUnit의 사용 예제입니다. Multiplier 객체를 생성해, multiply()의 인자에 3과 5를 주면 15를 반환하는지를 확인하죠.

public class Multiplier {
    
    public int multiply(int a, int b) {
	int result = 0;
	for(int i = 0; i < b; i++) {
	    result += a;
	}
	return result;
    }
    
}

public class MultiplierTest {
    
    @Test
    public void testMultiply() {
	Multiplier multiplier = new Multiplier();
	assertEquals(15, multiplier.multiply(3, 5));
    }
    
}


복잡한 클래스의 테스트

 복잡한 메소드의 경우, 수많은 비공개 메소드들을 호출합니다. 이들 역시 단위로 나누어 테스트하면 좋지 않겠습니까? 하지만 객체지향 언어의 특성상 다른 클래스는 해당 메소드들을 호출할 수 없습니다. 따라서 이들을 테스트하기 위해 다양한 편법들을 활용합니다:

  • 접근 지시자를 protected 혹은 package로 선언하여 우회적(상속하거나 같은 패키지 안에 테스트 클래스를 만듭니다)으로 호출합니다.
  • 테스트 메소드 혹은 클래스를 대상 클래스의 안에 선언합니다(inner class).
  • 언어에서 제공하는 Reflection 메커니즘을 이용해 메소드를 호출합니다.

  그리고, 마지막으로 우리에게 주어진 선택지가 있죠.

  • 비공개 메소드를 "직접 호출"해서 테스트하지 않습니다.


  과연 어느 선택이 타당할까요? 사실 이는 논란의 여지가 많은 주제입니다. 그래도 이에 대해 논할 가치는 충분하죠. 한 번 제 생각을 들어보실래요?


철학적인 질문으로부터 시작해봅시다.

  먼저 앞서서 예시로 나왔던 multiply() 메소드와 이를 테스트하는 testMultiply() 메소드를 봅시다. 이는 충분한 테스트라고 볼 수 있나요? 사실 메소드가 단순해서 그렇지, 충분한 테스트라고 보기는 어렵습니다. 다음과 같이 테스트를 작성하면, 꽤 괜찮은 테스트라고 볼 수 있겠죠:

public class MultiplierTest {
    
    private static Multiplier multiplier;
    
    @BeforeClass
    public static void setUp() {
	multiplier = new Multiplier();
    }
    
    @Test
    public void testMultiply1() {
	assertEquals(15, multiplier.multiply(3, 5));
    }

    @Test
    public void testMultiply2() {
	assertEquals(0, multiplier.multiply(0, 5));
    }
    
    @Test
    public void testMultiply3() {
	assertEquals(0, multiplier.multiply(5, 0));
    }
    
    @Test
    public void testMultiply4() {
	assertEquals(1000000000, multiplier.multiply(10000, 100000));
    }
    
    @Test
    public void testMultiply5() {
	assertEquals(1000000000, multiplier.multiply(-10000, -100000));
    }
    
    @Test
    public void testMultiply6() {
	assertEquals(-1000000000, multiplier.multiply(-10000, 100000));
    }
    
    @Test
    public void testMultiply7() {
	assertEquals(-1000000000, multiplier.multiply(10000, -100000));
    }

    ...
    
}

  실제로, 보다 까다로운 위 테스트는 실패합니다. 음수에 대해 제대로 곱하지 않는 결함이 있었기 때문이죠.

  하지만 제가 정말로 말씀드리려고 했던 것은 다양한 테스트 케이스의 중요성이 아닙니다. 한 번 다음처럼 제가 Multiplier 클래스의 코드를 리팩토링했다고 생각해보죠:

public class Multiplier {
    
    public int multiply(int a, int b) {
	int result = 0;
	for(int i = 0; i < b; i++) {
	    result = add(result, a); // Refactored: calls add() instead.
	}
	return result;
    }
    
    private int add(int a, int b) {
	return a + b;
    }
    
}

  비공개 메소드인 add() 메소드를 만들어, result에 a를 더하는 연산을 반복적으로 대신 수행하도록 했습니다. 그 결과는 동일하죠.

  이제, 우리가 작성한 테스트 클래스를 다시 한 번 봐보죠. 엄격한 기준을 이용해서 작성한 테스트 케이스들이 갑자기 다시 미흡해보이나요? 단순히 비공개 메소드를 테스트하지 않았다는 이유만으로요? 그렇지는 않습니다. 어차피 간접적으로 add() 메소드가 호출되기 때문이죠.

  물론 이는 단순한 예제입니다. add()는 단 하나의 메소드에서 호출되므로, 단순히 가독성을 올리기 위한 리팩토링에 불과합니다. 반면 우리가 일반적으로 작성하는 비공개 메소드의 경우 다양한 호출구조를 지녀 보다 복잡하며 코드 중복을 줄이기 위한 경우가 많습니다. 하지만 이런 예시는 분명 우리에게 고민을 던져주죠. 비공개 메소드이냐 아니냐는 사실 코드의 관점에서 별로 중요한 것이 아닙니다. 우리는 질문을 바꾸어보아야 할 것입니다: 

"우리는 테스트를 어느 수준까지 해야 하나요?"

  즉, 이것은 명료하게 가를 수 있는 문제가 아닙니다. 작은 단위, 가령 코드 라인의 한 줄 단위로 시험을 해야 한다는 사람들은 비공개 메소드들 역시 테스트를 해야 한다고 말할 것입니다. 큰 단위, 가령 API의 입력과 출력 수준으로 시험을 해야 한다는 사람들은 비공개 메소드들의 테스트가 필요 없다고 말할 것입니다. 그리고 이곳에 정답은 없죠.


작은 단위의 테스트에 대한 이점

  우리가 테스트를 배우면서 가장 처음으로 배우는 것은 테스트의 정의입니다. 소프트웨어 공학에서 테스트는 "대상 소프트웨어가 정상적으로 동작한다"는 것을 검증하는 작업이 아닙니다. 반대로, "대상 소프트웨어가 결함이 있다"는 것을 증명하는 작업이죠. 결함이 없는 소프트웨어는 세상에 존재하지 않는다는 것이 테스트의 철학입니다.

  그러한 측면에서, 작은 단위의 테스트는 분명 이점이 있습니다. 코드를 작은 단위로 나누어서 테스트를 작성하면, 실패가 발생했을 때 그 실패의 원인을 특정하기 쉬워지죠. 엄격한 테스트를 통과하는 조금 더 복잡한 Multiplier 클래스를 상상해보죠:

public class Multiplier {
    
    public int multiply(int a, int b) {
	int result = 0;
	if(a >= 0)
	    result = multiplyByAdding(a, b);
	else
	    result = multiplyBySubtracting(a, b);
	return result;
    }

    private int multiplyByAdding(int a, int b) {
	int result = 0;
	for(int i = 0; i < Math.abs(a); i++) {
	    result = add(result, b);
	}
	return result;
    }
    
    private int multiplyBySubtracting(int a, int b) {
	int result = 0;
	for(int i = 0; i < Math.abs(a); i++) {
	    result = subtract(result, b);
	}
	return result;
    }
    
    private int subtract(int a, int b) {
	return add(a, -b);
    }
    
    private int add(int a, int b) {
	return a + b;
    }
    
}

  이 개선된 Multiplier는 a의 부호가 음수냐 양수냐에 따라서 b의 값을 빼면서 누적할지 더하면서 누적할지를 결정합니다. 이는 음수에 대한 곱셈 역시 잘 처리하는 코드죠.


Fault Localization

  어느 날, multiply()에서 실패가 발생했습니다. 만약 우리가 공개 메소드 multiply()에 대한 테스트만을 작성해왔다면, 이것의 결함이 어디로부터 기인하는 것인지 특정하기 어렵습니다. 수많은 결함 원인의 후보가 내부에 존재하죠:

  • multiply()의 분기 조건
  • multiplyByAdding(), multiplyBySubtracting()의 반복문의 반복 횟수
  • subtract()의 add() 호출의 매개변수 입력
  • add()의 결과 반환

  즉, multiply() 이하는 전부 블랙 박스가 되는 것이죠. 이는 유지보수를 어렵게 합니다. 반면, 이러한 각각의 비공개 메소드들에 대한 테스트들이 작성되어 있었다면 실패한 테스트들로부터 결함의 위치를 파악할 수 있죠. 이는 분명 작은 단위의 테스트가 가지는 이점입니다.


작은 단위의 테스트에 대한 단점

  이번엔 다른 상황을 생각해보죠. 두 정수의 곱셈을 수행하는 굉장한 수학적 알고리즘이 개발되면서, 그러한 구현을 우리 역시 적용해보기로 했습니다. 마치 다음처럼요:

public class Multiplier {
    
    public int multiply(int a, int b) {
	return a * b;
    }
    
}


  아뿔싸, 우리는 결함의 지역화를 위해 비공개 메소드들에 대한 테스트를 일일히 수행하고 있었습니다. multiplyByAdding(), multiplyBySubtracting(), subtract(), add() 등에 대한 수많은 테스트 케이스들이 남아있었죠. 이들은 전부 쓸모 없는 테스트 케이스가 되었습니다. 공개 메소드에 대한 테스트 케이스만 동작할 뿐이죠. 우리들의 노력은 어디로 가버린 것이죠?

    @Test
    public void testMultiply1~10() {
	// I'm Survived!
    }
    
    @Test
    public void testMultiplyByAdding1~10() {
	// But
    }
    
    @Test
    public void testMultiplyBySubtracting1~10() {
	// We
    }
    
    @Test
    public void testSubtract1~10() {
	// Are
    }
    
    @Test
    public void testAdd1~10() {
	// Dead!
    }

  그래도 팀장은 우리를 다독이며 그나마 다행이라고 이야기합니다. 단순히 기존의 테스트 케이스를 삭제하는 것에서 그쳤기 때문이죠. 만약 새로운 곱셈의 알고리즘이 약간이라도 복잡해서 비슷한 분량의 코드로 이를 해결했어야 했다면, 그에 대한 새로운 비공개 메소드들의 테스트 케이스 역시 작성했어야 했을 것이라고 말합니다. 


우리는 깨닫습니다. 세상에 공짜는 없다는 것을 말이죠.

  • 작은 단위의 테스트는 지역적인 결함을 찾기 쉽습니다. 
  • 작은 단위의 테스트는 세부 구현의 변화에 취약합니다.


제가 타협안을 제시해보도록 하죠.

  우리는 객체지향 패러다임 언어를 사용하는 프로그래머들입니다. 프로그램의 모든 것들을 설명하는 데 있어 객체가 그 근본이 되죠. 테스트 코드라는 것은 무엇인가요? 결국 이 역시 객체입니다. 테스트 드라이버로서 대상 클래스의 객체를 활용하는 테스트 객체인 것이죠.

  결국 테스트 역시 객체 간의 상호작용이라고 볼 수 있습니다. 우리는 객체 간의 응집성과 결합도 관리를 위해 캡슐화를 하고, 인터페이스 및 상속 등을 활용합니다. 테스트도 그러한 원칙을 따라야 하지 않겠습니까? 

"우리는 공개된 메소드들에 의존하는 테스트를 작성해야 합니다. 세부 구현에 의존하는 테스트는 소프트웨어의 유지보수성을 해칩니다."


그렇다면, 결함 추적은요?

  결함을 손쉽게 찾아내는 능력 역시 우리는 포기할 수 없습니다. 저는 현재의 프로그래밍 환경이 이를 어느정도 감수할 수 있는 환경이라고 생각합니다. 가령 언어 차원에서 지원하는 assert문이나, 디버깅 도구와 같은 것들이죠. 그러므로 첫번째 원칙은, 비공개 메소드의 테스트는 공개 메소드의 테스트를 이용해서 우회적으로만 한다는 것입니다.

  그럼에도 불구하고 공개된 메소드의 테스트들만으로는 도저히 해결할 수 없는 상황이 있습니다. 우리는 또다시 비공개 메소드 테스트의 늪으로 들어가야 하나요? 아닙니다. 그러한 원인은 테스트에 있지 않습니다. 바로 우리의 클래스 설계에 있다고 생각해야 합니다.

  거대한(massive) 클래스는 치명적인 코드 스멜 중 하나입니다. 우리가 공개된 메소드들만으로 그것들을 시험하기 어렵다는 것은, 이미 그 클래스가 부풀대로 부풀었다는 증거입니다. 이를 해결하기 위해서 내부를 들여다보는 것은 장기적인 유지보수성을 해칩니다. 대신 수술대에 올라 매스를 들고 재모듈화를 수행해야 하죠.

public class Multiplier {
    
    public int multiply(int a, int b) {
	int result = 0;
	if(a >= 0)
	    result = multiplyByAdding(a, b);
	else
	    result = multiplyBySubtracting(a, b);
	return result;
    }

    private int multiplyByAdding(int a, int b) {
	int result = 0;
	for(int i = 0; i < Math.abs(a); i++) {
	    result = Adder.add(result, b); // Refactored: delegates to Adder.
	}
	return result;
    }
    
    private int multiplyBySubtracting(int a, int b) {
	int result = 0;
	for(int i = 0; i < Math.abs(a); i++) {
	    result = Subtractor.subtract(result, b); // Refactored: delegates to Subtractor.
	}
	return result;
    }
    
}

  수술을 마친 뒤 Multiplier는 Adder와 Subtractor라는 새로운 클래스에 의존합니다. Adder와 Subtractor는 완전히 캡슐화된 새로운 모듈로 재탄생했으므로, 이들의 공개된 메소드들에 대한 테스트는 기존과 다르게 유지보수성 역시 잡을 수 있습니다.


따라서 제가 제안하는 테스트 원칙은 다음과 같습니다:

  • 첫째, 우리는 공개된 메소드들에 의존하는 테스트를 작성합니다.
  • 둘째, 공개된 메소드들만으로는 테스트가 약간 어려우나, 그 규모가 여전히 감당가능한 경우에는 언어 차원의 assert문 등을 적극 활용해 간단하면서도 유지가능한 지역적 테스트를 수행합니다. (가령, 실패가 발생한 공개 메소드 이하 비공개 메소드들에서만 assert문들을 추가하여 결함을 지역화하고 추적합니다)
  • 셋째, 테스트의 어려움이 감당하기 어려운 경우에는 클래스를 나누고 나누어진 클래스들의 명세를 정의하며 그것들의 공개된 메소드들에 대한 단위 테스트를 작성합니다.


  하지만 현실은 언제나 녹록지 않습니다. 소프트웨어 공학에 절대라는 것은 없습니다. 결국 그러한 선을 결정하는 것은 언제나 개발자 개인의 몫이죠. 중요한 것은, 테스트를 하기 전 이것이 올바른 설계인지 고민하는 과정입니다. 자신만의 신념을 갖고, 그것을 유하게 다룰 줄 아는 개발자야말로 훌륭한 개발자이지 않겠어요?


끝.

댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기