안녕하세요. 교육코스개발팀 류성현입니다.

우아한테크코스(이하, 우테코)가 시작된 지 1년이 조금 넘었습니다. 그동안 100명에 가까운 크루(수강생)들이 우테코와 함께 생활했고 지금도 함께 하고 있어요. 크루들 대부분은 우테코에서의 경험을 다양한 형태의 컨텐츠로 만들어 공유하고 있는데, 단순히 컨텐츠를 소비하는 소비자의 역할에 머무르는 게 아니라 스스로가 생산자가 되어 학습한 지식을 재공유하고 있는 셈이죠.

techcourse_contents

코드리뷰 피드백 모음 서비스 javable을 소개합니다.

우테코는 강의보단 미션과 코드 리뷰를 중심으로 교육을 진행합니다. 수행한 미션에 대해 리뷰어들이 코드 리뷰를 하면, 크루들은 리뷰를 참고하여 미션을 완수하는데요. 때론 리뷰의 내용이 잘 이해가 가지 않거나, 납득되지 않을 경우 의견을 주고받으며 다양하고 의미 있는 피드백이 쌓입니다. 그래서 크루들은 본인의 피드백이 아니더라도 참고하여 학습하기도 합니다. 좋은 습관을 들이는 데 도움을 주거나, 놓치기 쉬운 부분을 다루고 있어 범용적으로 활용할 수 있기 때문입니다.

javable은 이러한 피드백 중 중요도가 높고 자주 언급되는 주제를 선정하여 읽기 좋은 글로 정리합니다. 개발을 이제 막 시작한 크루들의 시선에서 정리한 글이라 어려운 용어가 많지 않고, 하나의 주제를 무겁지 않게 다루어 가볍게 읽어볼 수 있는 형태로 만들어지고 있어요.

javable은 java와 able의 합성어로, 자바를 할 수 있게 도와준다는 의미. 크루들이 투표로 직접 지은 이름 https://woowacourse.github.io/javable

javable

많은 분과 함께 만들어 가고 싶어요

처음부터 완벽한 글을 작성하는 것보다 점진적으로 발전하는 글을 작성하는 것을 지향합니다. 글을 읽다가 부정확한 내용이 있거나 조금 더 보완할 부분이 있다면 언제든지 댓글과 PR을 통해 의견을 주세요 :) 추가로 다루었으면 하는 주제에 대해서도 의견을 주세요. 우테코를 넘어 많은 개발자와 함께 지식을 공유하고, 더 좋은 학습 환경을 만드는데 기여하실 수 있어요. 경험이 많은 선배 개발자부터 이제 막 공부를 시작한 초보 개발자까지 많은 사람의 참여를 기다리고 있습니다. 언제나 열려있는 javable과 함께 더 좋은 학습 환경을 만들어 가길 희망합니다.

https://github.com/woowacourse/javable


미션과 근로를 병행하며 고생(?)하고 있을 크루들에게 감사의 인사를 보내며 등록된 몇 편의 글을 소개합니다.



일급 컬렉션을 사용하는 이유

일급 컬렉션이란?

본 글은 일급 컬렉션 (First Class Collection)의 소개와 써야할 이유를 참고 했다.

일급 컬렉션이란 단어는 소트웍스 앤솔로지의 객체지향 생활체조 규칙 8. 일급 콜렉션 사용에서 언급되었다.

Collection을 Wrapping하면서, Wrapping한 Collection 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 한다.

이게 무슨 말일까?

먼저 Collection을 Wrapping한다의 의미는 아래 코드를

public class Person {
    private String name;
    private List<Car> cars;
    // ...
}

public class Car {
    private String name;
    private String oil;
    // ...
}

다음과 같이 바꾸는 것을 말한다.

public class Person {
    private String name;
    private Cars cars;
    // ...
}

// List<Car> cars를 Wrapping
// 일급 컬렉션
public class Cars {
    // 멤버변수가 하나 밖에 없다!!
    private List<Car> cars;
    // ...
}

public class Car {
    private String name;
    private String oil;
    // ...
}

위의 코드를 보면 눈치챘겠지만 일급 컬렉션은 그(List<Car> cars) 외 다른 멤버 변수가 없다.

이것이 일급 컬렉션!!

왜 사용하지?

필자가 느낀 일급 컬렉션의 이점을 말해보겠다.

GS편의점에 아이스크림을 팔고 있다.

// GSConvenienceStore.class
public class GSConvenienceStore {
    // 편의점에는 여러 개의 아이스크림을 팔고 있을 것이다.
    private List<IceCream> iceCreams;
    
    public GSConvenienceStore(List<IceCream> iceCreams) {
        this.iceCreams = iceCreams;
    }
    ...
}

// IceCream.class
public class IceCream {
    private String name;
    ...
}

특이하게도 해당 편의점은 아이스크림의 종류를 10가지 이상 팔지 못한다고 한다.

그러면 우리는 List<IceCream> iceCreams의 size가 10이 넘으면 안되는 검증이 필요할 것이다.

// GSConvenienceStore.class
public class GSConvenienceStore {
    private List<IceCream> iceCreams;
    
    public GSConvenienceStore(List<IceCream> iceCreams) {
        validateSize(iceCreams)
        this.iceCreams = iceCreams;
    }
    
    private void validateSize(List<IceCream> iceCreams) {
    	if (iceCreams.size() >= 10) {
            new throw IllegalArgumentException("아이스크림은 10개 이상의 종류를 팔지 않습니다.")
        }
    }
    // ...
}

흠…그래서..? 뭐가 문제지?

말해주겠다!!

  1. 만약 아이스크림뿐만 아니라 과자, 라면 등 여러 가지가 있다고 가정해보자.

    • 모든 검증을 GSConvenienceStore class에서 할 것인가?

      validate아이스크림(아이스크림);
      validate과자(과자);
      validate라면(라면);
      // ...
      
    • 만약 CUConvenienceStore class에서도 동일한 것을 판다면 GSConvenienceStore class에서 했던 검증을 또 사용할 것인가?

      // GSConvenienceStore.class
      public class GSConvenienceStore {
          private List<IceCream> iceCreams;
          private List<Snack> snacks;
          private List<Noodle> Noobles;
               
          public GSConvenienceStore(List<IceCream> iceCreams ...) {
              validate아이스크림(아이스크림);
              validate과자(과자);
              validate라면(라면);
              // ...
          }
          // ...
      }
           
      // CUConvenienceStore.class
      public class CUConvenienceStore {
          private List<IceCream> iceCreams;
          private List<Snack> snacks;
          private List<Noodle> Noobles;
               
          public CUConvenienceStore(List<IceCream> iceCreams ...) {
              validate아이스크림(아이스크림);
              validate과자(과자);
              validate라면(라면);
              // ...
          }
          // ...
      }
      
  2. List<IceCream> iceCreams의 원소 중에서 하나를 find하는 메서드를 만든다고 가정해보자.

    • GSConvenienceStore classCUConvenienceStore class 같은 메서드(find)를 두번 구현할 것인가?

      // GSConvenienceStore.class
      public class GSConvenienceStore {
          private List<IceCream> iceCreams;
          // ...
          public IceCream find(String name) {
              return iceCreams.stream()
                  .filter(iceCream::isSameName)
                  .findFirst()
                  .orElseThrow(RuntimeException::new)
          }
          // ...
      }
           
      // CUConvenienceStore.class
      public class CUConvenienceStore {
          private List<IceCream> iceCreams;
          // ...
          public IceCream find(String name) {
              return iceCreams.stream()
                  .filter(iceCream::isSameName)
                  .findFirst()
                  .orElseThrow(RuntimeException::new)
          }
          // ...
      }
      

이럴 경우 편의점 class의 역할이 무거워 지고, 중복코드가 많아진다.

이것을 해결해주는 것이 일.급.컬.렉.션이다.

상태와 행위을 각각 관리할 수 있다.

아이스크림을 일급 컬렉션으로 만들어 보자.

// IceCream.class
public class IceCreams {
    private List<IceCream> iceCreams;
    
    public IceCreams(List<IceCream> iceCreams) {
        validateSize(iceCreams)
        this.iceCreams = iceCreams
    }
    
    private void validateSize(List<IceCream> iceCreams) {
    	if (iceCreams.size() >= 10) {
            new throw IllegalArgumentException("아이스크림은 10개 이상의 종류를 팔지않습니다.")
        }
    }
    
    public IceCream find(String name) {
        return iceCreams.stream()
            .filter(iceCream::isSameName)
            .findFirst()
            .orElseThrow(RuntimeException::new)
    }
    // ...
}

그럼 편의점 class는 어떻게 달라질까?

// GSConvenienceStore.class
public class GSConvenienceStore {
    private IceCreams iceCreams;
    
    public GSConvenienceStore(IceCreams iceCreams) {
        this.iceCreams = iceCreams;
    }
    
    public IceCream find(String name) {
        return iceCreams.find(name);
    }
    // ...
}

// CUConvenienceStore.class
public class CUConvenienceStore {
    private IceCreams iceCreams;
    
    public CUConvenienceStore(IceCreams iceCreams) {
        this.iceCreams = iceCreams;
    }
    
    public IceCream find(String name) {
        return iceCreams.find(name);
    }
    // ...
}

// 만약 find메서드 중복되는 것이 신경쓰인다면 부모 클래스를 만들어 상속을 사용하세용:)

어떠한가!

느낌이 오는가?

과자랑 라면 등이 생겨도 검증과자의 일급 컬렉션라면의 일급 컬렉션이 해줄 것이다.

그리고 편의점 class가 했던 역할을 아이스크림, 과자, 라면 등 각각에게 위임하여 상태와 로직을 관리할 것이다.

정리한다!!

일급 컬렉션을 사용하면 상태과 로직을 따로 관리할 수 있기 때문에 로직이 사용되는 클래스의 부담을 줄일 수 있고, 중복코드를 줄일 수 있다.


컬렉션의 불변성을 보장

일급 컬렉션을 검색할 때 제일 많이 보는 글은 일급 컬렉션 (First Class Collection)의 소개와 써야 할 이유 일 것이다. 이점 중 하나인 컬렉션의 값을 변경할 수 있는 메소드가 없어 불변성을 보장 해준다는 글을 볼 수 있다.

하지만 필자는 일급컬렉션불변성을 보장하지 않으며, 보장하도록 구현해야 할 필요는 없다는 메시지를 전하고 싶다. 아랫글에서 왜 불변성을 보장할 필요가 없는지, 왜 불변이 아닌지, 만약 불변으로 만들고 싶다면 어떻게 해야하는지 설명하겠다.

왜 불변성을 보장할 필요가 없는지Object Calisthenics의 내용의 일부를 가져와 설명하겠다.

The ThoughtWorks Anthology의 Chapter 6 - Object Calisthenics by Jeff Bay, Technology Principal

Rule 8: Use First-Class Collections

The application of this rule is simple: any class that contains a collection should contain no other member variables. Each collection gets wrapped in its own class, so now behaviors related to the collection have a home. You may find that filters become part of this new class. Filters may also become function objects in their own right. Also, your new class can handle activities such as joining two groups together or applying a rule to each element of the group. This is an obvious extension of the rule about instance variables but is important for its own sake as well. A collection is really a type of very useful primitive. It has many behaviors but little semantic intent or clues for either the next programmer or the maintainer

일급 컬렉션 사용의 Rule을 보면 여기에서 설명한 내용과 동일하다. 또한, 에서 설명한 이점을 위해 사용하는 것이지 “일급컬렉션은 불변으로 만들어야 한다.”, “일급컬렉션의 이점은 불변이다.”라는 내용을 언급하고 있지 않다. 다시 말해 일급 컬렉션이 주는 기능의 핵심은 불변이 아니다.

이번에는 일급컬렉션이 왜 불변이 아닌지만약 불변으로 만들고 싶다면 어떻게 해야하는지를 알아보자.

public class Lotto {
    private final List<LottoNumber> lotto;
    // ...
    public List<LottoNumber> getLotto() {
        return lotto;
    }
}

위와 같이 setter를 구현하지 않으면 불변 컬렉션이 된다. (라는 글을 많이 보았을 것이다.)

하.지.만!

setter를 사용하지 않았어도 Lotto안에 있는 lotto 변수에 변화를 줄 수 있다.

public class Lotto {
    private final List<LottoNumber> lotto;

    public Lotto(List<LottoNumber> lotto) {
        this.lotto = lotto;
    }

    public List<LottoNumber> getLotto() {
        return lotto;
    }
}

public class LottoNumber {
    private final int lottoNumber;

    public LottoNumber(int lottoNumber) {
        this.lottoNumber = lottoNumber;
    }
    
    // toString()은 로그를 찍기 위함이다.
    @Override
    public String toString() {
        return "LottoNumber{" +
                "lottoNumber=" + lottoNumber +
                '}';
    }
}

위와 같은 코드가 있다고 가정하자.

@Test
public void lotto_변화_테스트() {
    List<LottoNumber> lottoNumbers = new ArrayList<>();
    lottoNumbers.add(new LottoNumber(1));
    Lotto lotto = new Lotto(lottoNumbers);
    lottoNumbers.add(new LottoNumber(2));
}

이런 상황이면 lotto를 get했을때 어떤 값을 가지고 있을까?

정답은 [LottoNumber{lottoNumber=1}, LottoNumber{lottoNumber=2}]이다.

lottoNumberslotto class의 멤버변수와 주소값이 같기 때문에 영향을 받는다.

Lotto class의 맴버변수인 lotto가 파라미터로 받은 lottoNumbers의 영향을 받지 않기 위해서는 다음과 같이 수정하면 된다.

public class Lotto {
    private final List<LottoNumber> lotto;

    public Lotto(List<LottoNumber> lotto) {
        this.lotto = new ArrayList<>(lotto);
    }

    public List<LottoNumber> getLotto() {
        return lotto;
    }
}

이렇게 수정하면 멤버변수에 저장되는 주소값을 재할당하기 때문에 영향을 받지 않는다.

하.지.만! (…또 있어…)

@Test
public void lotto_변화_테스트() {
    List<LottoNumber> lottoNumbers = new ArrayList<>();
    lottoNumbers.add(new LottoNumber(1));
    Lotto lotto = new Lotto(lottoNumbers);
    lotto.getLotto().add(new LottoNumber(2));
}

이러한 상황에도 [LottoNumber{lottoNumber=1}, LottoNumber{lottoNumber=2}]가 나온다.

이를 해결하는 방법으로 unmodifiableList 사용한다.

public class Lotto {
    private final List<LottoNumber> lotto;

    public Lotto(List<LottoNumber> lotto) {
        this.lotto = new ArrayList<>(lotto);
    }

    public List<LottoNumber> getLotto() {
        return Collections.unmodifiableList(lotto);
    }
}

unmodifiableList를 사용하면 lotto는 불변이 되고, getter로 return해서 사용될 때 변경이 불가능하다.


참고

일급 컬렉션 (First Class Collection)의 소개와 써야할 이유

The Thoughtworks Anthology



getter를 사용하는 대신 객체에 메시지를 보내자

getter는 멤버변수의 값을 호출하는 메소드이고, setter는 멤버변수의 값을 변경시키는 메소드이다.

자바 빈 설계 규약에 따르면 자바 빈 클래스 설계 시, 클래스의 멤버변수의 접근제어자는 private이며, 모든 멤버변수에 대해 get메소드와 set메소드가 존재해야 한다. get메소드는 매개변수가 없어야 하며 set메소드는 하나 이상의 매개변수가 있어야 한다. 좋은 코드를 위한 자바 메서드 네이밍 에도 간단히 설명되어 있다.

상태값을 갖는 객체에서는 상태값을 외부에서 직접 접근해 변경하지 못하도록 메소드만 노출시킨다. 이때, 멤버변수(상태값)는 접근 제한자를 private으로 설정해 직접적인 접근을 막고, getter와 setter를 이용해서만 변수에 접근이 가능하도록 한다.

private String name;

//setter name의 값을 변경시킨다.
public void setName(String name){
    this.name = name;
}

//getter name의 값을 호출한다.
public String getName(){
    return this.name;
}

상태를 갖는 객체는 getter를 통해 가져온 상태값을 통해 로직을 수행하는 경우가 있다. 그러나 무조건적으로 모든 멤버변수를 호출하는 getter메소드를 생성하는 것이 맞을까? 이 글에서는 getter에 관한 이야기를 해보려고 한다.

무분별한 getter? 객체에 메시지를 보내 객체가 로직을 수행하도록 하자

객체는 캡슐화된 상태와 외부에 노출되어 있는 행동을 갖고 있으며, 다른 객체와 메시지를 주고 받으면서 협력한다. 객체는 메시지를 받으면 객체 그에 따른 로직(행동)을 수행하게 되고, 필요하다면 객체 스스로 내부의 상태값도 변경한다. 간단히 말해서 객체지향 프로그래밍은 객체가 스스로 일을 하도록 하는 프로그래밍이다.

모든 멤버변수에 getter를 생성해 놓고 상태값을 꺼내 그 값으로 객체 외부에서 로직을 수행한다면, 객체가 로직(행동)을 갖고 있는 형태가 아니고 메시지를 주고 받는 형태도 아니게 된다. 또한, 객체 스스로 상태값을 변경하는 것이 아니고, 외부에서 상태값을 변경할 수 있는 위험성도 생길 수 있다.

따라서 이는 객체가 객체스럽지 못한 것이다.

또한, getter를 남용하게 되면, 디미터의 법칙을 위반할 가능성도 생기고, 가독성이 떨어지는 문제도 생길 수 있다. getter 남용으로 인한 디미터 법칙을 위반한 예를 참고하면 좋을 것이다.

자동차 경주 게임의 예시 코드를 보자. Cars 클래스는 여러 자동차의 역할을 한다.

public class Cars {
     public static final String DELIMITER = ",";
     public static final int MINIMUM_TEAM = 2;
     private List<Car> cars;

     public Cars(String inputNames) {
         String[] names = inputNames.split(DELIMITER, -1);
         cars = Arrays.stream(names)
                 .map(name -> new Car(name.trim()))
                 .collect(Collectors.toList());
         validateCarNames();
     }
         ...

    public List<String> findWinners() {
        final int maximum = cars.stream()
                  .map(car -> car.getPosition())	
                  .max(Integer::compareTo)
                  .get();
           
        return cars.stream()
                .filter(car -> car.getPosition() == maximum)
                .map(Car::getName)
                .collect(Collectors.toList());
    } 
         ...
}

여러 자동차들 중 position값이 제일 큰 우승 자동차(들)를 구하는 findWinners() 메소드를 살펴보자.

public List<String> findWinners() {
    final int maximum = cars.stream()
              .map(car -> car.getPosition())		// Car객체의 position = 자동차가 움직인 거리
              .max(Integer::compareTo)
              .get();
           
    return cars.stream()
            .filter(car -> car.getPosition() == maximum)
            .map(Car::getName)
            .collect(Collectors.toList());
} 

Car 객체에서 getPosition() 을 사용해 position 상태값을 직접 꺼내 비교한다. 그러나, Cars에서 position 값을 비교하는 로직을 수행하는 게 맞을까?

Car의 접근 제한자가 private인 멤버변수 position 값 끼리 비교하는 로직이다. 따라서 Car 객체에게 position 값을 비교할 또 다른 Car 객체를 넘겨주고 Car끼리 position을 비교해야 한다. Cars가 아니라 Car에서 해야 하는 일인 것이다.

Car 객체 내에서 같은 자동차끼리 position 값을 비교하고, Car 객체 내에서 maximum 과 일치하는지 비교하도록 Cars의 로직을 Car 안으로 옮기도록 하자.

즉, Car 객체에게 position 값을 비교할 수 있도록 메시지를 보내고, Car 객체에게 maximum 값과 자신의 position 값이 같은지 물어보는 메시지를 보내 getPosition() 을 사용하지 않도록 리팩토링 해 보자.

public class Car implements Comparable<Car> {
         ...
    public boolean isSamePosition(Car other) {
        return other.position == this.position;
 	}
 	
    @Override
    public int compareTo(Car other) {
        return this.position - other.position;
    }
         ...
}

public class Cars {
         ...
    public List<String> findWinners() {
        final Car maxPositionCar = getMaxPositionCar();
        return getSameCars(maxPositionCar);
    }
    
    private Car findMaxPositionCar() {
        Car maxPositionCar = cars.stream()
            .max(Car::compareTo)
            .orElseThrow(() -> new IllegalArgumentException("차량 리스트가 비었습니다."));
    }

    private List<String> findSamePositionCar(Car maxPositionCar) {
        return cars.stream()
            .filter(car -> car.isSamePosition(maxPositionCar))
            .map(Car::getName)
            .collect(Collectors.toList());
    }
}

getPosition() 을 없애는 방향으로 리팩토링 한 코드이다. Car에서 Comparable을 상속받아 compareTo() 를 구현해 Car내에서 자동차끼리 비교를 해준다. max를 통해 cars 중, 최대 길이의 position을 가진 Car를 찾을 수 있다. 또, isSamePosition() 을 구현해 Car 내에서 직접 position 값을 비교할 수 있게 된다.

이에 관해 포비 캡틴(박재성님)은 이런 말을 하셨다.

상태를 가지는 객체를 추가했다면 객체가 제대로 된 역할을 하도록 구현해야 한다.
객체가 로직을 구현하도록 해야한다.
상태 데이터를 꺼내 로직을 처리하도록 구현하지 말고 객체에 메시지를 보내 일을 하도록 리팩토링한다.

getter를 무조건 사용하지 말라는 말은 아니다.

당연히 getter를 무조건 사용하지 않고는 기능을 구현하기 힘들것이다. 출력을 위한 값 등 순수 값 프로퍼티를 가져오기 위해서라면 어느정도 getter는 허용된다. 그러나, Collection 인터페이스를 사용하는 경우 외부에서 getter메서드로 얻은 값을 통해 상태값을 변경할 수 있다.

public List<Car> getCars() {
		return cars;
	} (x)

public List<Car> getCars() {
		return Collections.unmodifiableList(cars);
	} (o)

이처럼 Collections.unmodifiableList() 와 같은 Unmodifiable Collecion 을 사용해 외부에서 변경하지 못하도록 하는 게 좋다.


참고 링크



메소드 시그니처 변경없이 테스트 가능한 구조 만들기

메소드 시그니처를 변경하지 않고 테스트하기

메소드 시그니처란 메소드의 이름, 파라미터, 반환값의 데이터 타입을 통칭하는 말이다. 프로그래밍을 하다 보면 기존의 메소드 시그니처를 변경하지 않으면서 테스트 가능한 구조로 변경해야 하는 경우가 종종 있다. 예를 들어 난수에 의해서 어떤 메소드의 행동이 결정된다고 가정해보자. 이 경우 난수가 메소드에 포함되어 있기 때문에 이 메소드가 정상 작동하는지 판단하기 어렵다. 아래의 예를 보자.

public class Car {
    private int position;
    
    public Car(int position) {
	    this.position = position;   
    }
        
    public void move() {
    	if ((int)(Math.random() * 10) > 4) {
    		this.position++;
    	}   
    }
    
    public boolean isSamePosition(int position) {
        return this.position == position;
    }
}

위의 자동차 클래스에서 move 메소드는 난수에 의해서 현재 자신의 position 이 변경된다. 문제는 메소드의 로직에 난수 (int)(Math.random() *10 이 포함되어 있어 메소드가 정상 작동하는지 테스트하기 어렵다. 이 경우 어떻게 테스트 가능한 구조로 만들 수 있을까?

  • 파라미터를 추가한다.
  • 전략 패턴을 사용한다.
  • 상속을 사용한다.

나는 이러한 문제를 해결하기 위해 외부에서 파라미터를 통해 값을 주입하는 방식, 혹은 전략 패턴을 사용했었다. 하지만 현업에서 move 라는 메소드를 사용하는 곳이 매우 많고 사용하는 곳과의 관계를 명확하게 모르는 경우에 메서드 시그니처를 변경하는 것은 큰 위험이 따른다. 이 메소드를 사용하는 모든 부분을 수정해 줘야 하며 수정과 동시에 다른 버그가 발생할 수 있다. 따라서 이번에는 상속을 활용하여 메서드 시그니처를 변경하지 않는 형태로 진행하는 방식으로 리팩토링하고자 한다.

상속을 활용하여 메소드 재정의 하기.

기존의 코드에서 문제가 되는 부분은 move 내에 존재하는 난수를 생성하는 부분이다. 난수는 우리가 예측할 수 없기 때문에 메서드를 정상적으로 테스트할 수 없는 것이다. 따라서 난수 생성 부분을 메소드로 분리하고 이 메소드의 값을 재정의하는 방식으로 해결할 수 있다. 아래의 코드를 보자.

public void move() {
    if (randomInt() > 4) {
        this.position++;
    }
}

protected int randomInt() { // 메소드 분리
    return (int)(Math.random() * 10);
}

일차적으로 랜덤한 수를 생성하는 부분을 재정의할 수 있는 형태로 분리한다. 그리고 아래와 같이 테스트 코드를 작성하는 부분에서 randomInt() 메서드를 재정의하면 move 라는 메서드가 4라는 기준에 의해서 움직이는 것인지를 정상적으로 테스트할 수 있다.

class CarTest {
    
    @Test
    void moveTest() {
    	Car car = new Car(3) {
    	    @Override
    	    protected int randomInt() { // 랜덤한 난수를 생성하는 부분 재정의(4보다 큰 경우)
    	    	return 4;
            }
    	};
    	car.move();
    	assertThat(car.isSamePosition(4)).isTrue();     
    }
   
    @Test
    void notMoveTest() {
    	final Car car = new Car(3) {
    	    @Override
    	    protected int randomInt() { // 랜덤한 난수를 생성하는 부분 재정의(4보다 작은 경우)
    	    	return 3;
            }
    	};
    	car.move();
    	assertThat(car.isSamePosition(3)).isTrue();     
    }
}

결론

위에서 언급했듯 전략 패턴이나 메소드 파라미터를 추가하는 방식 또한 좋은 해결책이 된다. 하지만 이번 포스팅의 목표는 메소드 시그니처를 변경하지 않고 테스트 가능한 구조를 만드는 것이었다. ‘위와 같이 상속을 사용하는 경우 Production code의 메소드 시그니처를 변경하지 않고 단위 테스트가 가능하게 된다.’

테스트하기 어려운 부분을 테스트하는 것은 어렵지만 중요한 일이다. 위와 같이 상속을 활용하는 방법도 하나의 방법이 될 수 있다. 정도로 알아두면 좋을 것 같다.