반응형

0. 서론


NEXT STEP TDD 수업을 들으며 기능을 구현하던 와중 private method를 테스트하고 싶다는 생각이 들었는데요. private method를 테스트하면 좋지 않다고 들어왔기 때문에 멈칫하게 되었고, 이에 정말 private method 는 테스트하면 안좋은걸까라는 의문이 생겨서 이 글로 제 개인적인 의견을 정리해봅니다.

 

1. private method를 테스트하는 것이 왜 좋지 않은가?


여러 자료를 살펴보면 private method를 테스트하는 것이 변경에 취약한 구조를 만든다고 합니다.

 

이에 대한 이해를 위해 public method와 private method의 변경 가능성에 대한 전제 하나를 만들고 가야하는데요.

 

public method는 클래스 외부에 공개된 계약으로 간주됩니다. 따라서 다른 클래스나 모듈에서 이를 의존하고 있을 가능성이 높습니다. 이러한 계약은 상대적으로 안정적이어야하는데요. 즉, public method는 요구사항 변경에 따라 동작 자체가 바뀌는 경우에만 변경되며, 이러한 변경은 최소화된다는 전제를 두고 있습니다.

 

반면 private method는 클래스 내부 동작을 지원하는 구현 세부사항입니다. 즉, private method는 특정 로직을 재사용하거나, 복잡한 로직을 분리해 가독성을 높이는 역할을 하죠. 따라서 로직 재구성, 코드 리팩터링, 성능 최적화 등의 이유로 언제든지 자유롭게 변경될 수 있어야 하며, 그 변경이 외부에 영향을 주면 안됩니다.

 

즉, public method는 변경이 적어야하며, private method는 변경에 자유로워야한다는 것입니다.

 

이런 전제 하에 다시 private method를 테스트하는 것이 왜 변경에 취약한 구조를 만드는지 알아봅시다.

 

보통 클라이언트 코드는 안정적인 것에 의존해야지 변화가 잦은 것에 의존하게 되면 변경에 취약해집니다.

 

테스트 코드 역시 우리가 만든 코드를 사용하는 클라이언트라고 볼 수 있는데요. 이 과정에서 구체적인 내부 구현 방식(변경이 잦은)인 private 메서드를 테스트하는 것은 변경에 취약해지는 결과를 초래합니다.

 

즉, 클래스가 외부에 안정적이라고 공개한 계약(public method)에 대해서는 테스트 코드를 작성함으로써 코드의 동작(입력과 출력)을 검증하는 것이 바람직하지만.. 코드 리팩터링, 최적화 등 변경이 잦은 부분에 대해 테스트 코드를 작성하면 깨지기 쉬운 테스트가 됩니다.

 

// A, B, C는 private method로 내부 구현이다.
public f(입력): 출력 {
	A()
	B()
	C()
}

// 내부 구현 방식 변경
public f(입력): 출력 {
	A()
	B2()
	C2()
}

 

위와 같은 코드를 작성하고, private method인 B, C에 대한 테스트를 꼼꼼히 작성해두었다고 생각해봅시다. 하지만 공개된 계약인 f의 입출력은 그대로 둔 채 내부 구현 로직을 바꾸는 것은 빈번히 일어나는 일입니다. 이 때 B → B2, C → C2으로 변경하면 기껏 만들어둔 테스트가 다 깨지게 됩니다.

 

이러한 이유로 변경이 잦은(세부 구현 사항)에 대한 테스트는 깨지기 쉽고, 테스트를 유지하는데 필요한 비용이 많이 듭니다.

 

결론적으로 변경이 잦은 내부 구현사항인 private method를 테스트하는 것은 깨지기 쉬워 유지보수가 많이 필요한 코드를 작성하는 것이라고 볼 수 있습니다. 바람직하지 않은 것이죠.

 

2. 그럼 나는 왜 private method를 테스트하고 싶었는가?


class Lotto(
    private val numbers: Set<Int>,
) {
    
    fun match(winningNumbers: Set<Int>, bonusNumber: Int): Int {
        val matchCount = numbers.intersect(winningNumbers).size
        val isBonusMatched = numbers.contains(bonusNumber)
        return getRank(matchCount, isBonusMatched)
    }

    private fun getRank(matchCount: Int, bonusMatched: Boolean): Int {
        return when (matchCount) {
            6 -> 1
            5 -> if (bonusMatched) 2 else 3
            4 -> 4
            3 -> 5
            else -> 0
        }
    }
}

 

기능을 구현하다보니 초기에 저런 코드가 만들어졌는데 이 때 가장 테스트하고 싶었던 것은 match 보다는 getRank 메서드였습니다.

 

match 메서드의 나머지 부분은 간단한 로직인 반면 getRank 메서드는 비교적 검증이 필요한 부분이라고 생각했었죠.

 

또한 match 메서드를 테스트하기 위해서는 다음과 같은 과정이 필요했는데요.

  1. winningNumbers, bonusNumber 를 입력으로 만들어주기
  2. numbers를 테스트하고자하는 로또 등수에 맞게 적절히 만들어주기

반면에 getRank 메서드를 테스트하기 위해서는 비교적 간단한 입력인 Int, Boolean 두개만 만들면 됩니다.

 

이러한 이유로 인해 getRank만 테스트하고 싶다는 욕구가 치솟았습니다.

 

private method를 테스트하고 싶다는 마음이 생기면 구조에 문제가 있을 수 있다는 내용을 보기도 했는데, 어떤게 정확히 문제점인지 파악이 잘 되지 않아 관련 내용을 찾아보고, 주변 개발자분들에게도 도움을 요청했습니다. 이 중 와닿았던 내용들을 정리하면 다음과 같습니다.

 

3. 정리 및 결론


1) 테스트 해야하는 대상을 잘 생각해보자

match 메서드를 테스트하는 작업이 번거로운건 맞을 수 있지만 우리가 테스트 하고자하는 대상을 잘 생각해봐야 합니다. 특정 클래스에 대한 테스트를 한다는 것이 무엇인지를 생각해보면 객체가 제공해주는 기능을 테스트하는 것이지 각 라인별로 어떻게 동작하는지를 테스트해보고 싶은 것은 아닐 것 입니다.

 

Lotto 클래스를 예로 들면 “주어진 Lotto 인스턴스를 대상으로 winningNumbers와 bonusNumber 정보를 통해 몇 등인지 확인하는 것”이 Lotto 클래스가 제공해주는 기능입니다. 따라서 테스트를 할 대상을 선정한다고 하면 getRank 보다는 match가 더 적합하다고 할 수 있습니다.

 

또한 getRank가 제일 중요한 로직이라고 생각해서 그 부분을 중점적으로 테스트하면 된다고 생각했는데, matchCount를 구하는 부분, isBonusMatched를 판단하는 부분 역시 간단하지만 중요한 부분입니다.

 

이 부분이 잘못된다면 클라이언트와 계약했던 내용과 다르게 동작할 수 있습니다. 또한 public method 내부 구현을 변경했을 때 이 부분을 포함한 테스트가 있어야 안정적으로 리팩토링이 가능하기 때문이죠.

 

따라서 입출력을 만드는 것이 조금 귀찮더라도 클래스가 제공해주는 기능인 public method를 테스트하는 것이 옳으며, private method는 public method를 통해 간접적으로 테스트하는 것이 더 좋은 방향인 것 같습니다.

 

2) 클래스를 분리하라는 신호이다.

마이클 C. 페더스가 쓴 “레거시 코드 활용 전략

나는 단위 테스트를 처음으로 해보는 사람들로부터 “private 메서드는 어떻게 테스트해야 하나요?”라는 질문을 수없이 많이 듣는다. 많은 사람들이 이 문제를 회피하는 방법을 찾기 위해 많은 시간을 들였다.
...
이 질문에 대한 답은 “어떻게든 private 메서드를 테스트하고 싶다면, 그 메서드는 private이면 안된다"다. 메서드를 public으로 바꿔도 될지 마음에 걸린다면, 이 메서드는 별도의 책임의 일부로서 원래는 다른 클래스에 들어있어야 했던 것이다.

 

위 내용을 보면 private method를 테스트하고싶다는 생각이 들면 클래스를 분리하라는 신호라고도 생각할 수 있을 것 같습니다. 그래서 클래스를 분리하면 다음과 같은데요.

 

class Rank {
    fun toInt(matchCount: Int, bonusMatched: Boolean): Int {
        return when (matchCount) {
            6 -> 1
            5 -> if (bonusMatched) 2 else 3
            4 -> 4
            3 -> 5
            else -> 0
        }
    }
}

class Lotto(
    private val numbers: Set<Int>,
    private val rankCalculator: LottoRankCalculator = LottoRankCalculator()
) {

    fun match(winningNumbers: Set<Int>, bonusNumber: Int): Int {
        val matchCount = numbers.intersect(winningNumbers).size
        val isBonusMatched = numbers.contains(bonusNumber)
        return rankCalculator.toInt(matchCount, isBonusMatched)
    }
}

 

이렇게 하니 코드가 좀 더 깔끔해지고 rank를 구하는 메서드도 테스트 가능해졌습니다.

 

하지만 이렇게하면 생기는 문제점이 있는 것 같습니다.

  1. 파일을 하나 더 만들어야하고 파일이 늘어나면 코드를 이해하는데 추가적인 오버헤드가 든다.
  2. Lotto 클래스의 의존성이 추가되었다. 의존성이 추가되는건 코드 복잡성을 늘리는 일이라 좋은건 아닌 것 같다.

 

따라서 클래스 분리 신호라고 느끼는 것은 상황에 따라 다를 것 같습니다. 클래스가 너무 많은 책임을 가지고 있는가?를 잘 따져보면서 분리가 적합한지 아닌지를 잘 판단해봐야할 것 같습니다.

 

3) private method도 필요하면 테스트해도 된다.

위에서 두 가지 내용을 정리했습니다.

  1. private method(getRank) 메서드 대신 public method(match) 를 테스트하는 것이 더 좋다.
  2. private method를 테스트하고 싶다면 클래스를 분리하라는 신호이다.

1번의 경우에도 getRank를 너무 테스트하고싶다면 딱히 도움이 되지 않는 이야기이고, 2번의 경우도 테스트는 가능하지만 클래스를 만드는 오버헤드로 생기는 문제점이 있습니다.

 

public method의 일부를 private method로 분리해서 네이밍을 통해 가독성을 늘리는 작업은 흔히 하는 리팩토링 방식이며, 이렇게 분리된 private method를 분리해서 세부적으로 검증하고 싶은 일 또한 있게 되는 것 같습니다.

 

이런 경우는 어떻게 해야할까 고민해보았을 때 제 결론은 public method 테스트를 먼저 고려해보고, 클래스 분리까지 고려해본 뒤 그래도 필요하면 private method를 테스트하자 입니다.

 

private method를 테스트하는 방법

private method를 테스트하는 방법은 뭐 리플렉션 같은 것을 이용해도 되고.. 다양한데 저는 두 가지로 정리했습니다.

  1. private method의 접근제어자를 변경하기
    • private 접근 제어자를 package-private 으로 변경하여 캡슐화를 약간 위반하되 테스트 용이성을 챙기는 방식입니다.
    • private method를 테스트 하지 않아야 하는 이유는 자주 변경되는 세부 구현에 의존하게 되면 유지보수성이 떨어지기 때문이라고 했는데요. 그러면 변경이 잦지 않을 것으로 예상되는 private method는 테스트 해도 되지 않을까 싶습니다.
    • 즉, 테스트가 깨질 일이 많이 없어 보일때는 접근 제어자를 조절하여 테스트를 진행해봐도 괜찮을 것 같습니다.
  2. 나만 테스트해보기
    • private 메소드를 로컬에서 개발할 때만 public으로 열어서 테스트한 뒤, 기능이 잘 동작하는 것을 확인하고 다시 메소드 접근 제어자를 private 으로 바꾸고 테스트를 삭제하는 방식입니다.
    • 제가 생각하는 테스트가 필요한 이유는 코드 변경시 안정성 확보, 동료 개발자를 위한 문서화의 역할인 것 같습니다.
    • 근데 이 두가지 모두 public 메소드를 같이 테스트했다면 private 메서드 테스트는 없어져도 무방한 것 같습니다.
    • 따라서 로컬에서만 잘 동작하는지 확인해보고 접근제어자를 다시 private으로 바꾼뒤 테스트를 날려버리는 것도 방법 중 하나라고 생각합니다.
복사했습니다!