자동차 경주 게임 리팩토링 중, 전략 패턴 을 사용해서 랜덤값에 대한 테스트 코드를 작성하는 부분을 보았다. 요구사항은 아래와 같다
...
4.전진하는 조건은 0에서 9 사이에서 random 값을 구한 후 random 값이 4이상일 경우이다.
...
random 한 값을 어떻게 테스트 해야 할까? 에 대해서 여러 대안이 있었지만 (ex : 시행 횟수를 늘려서 평균값을 측정한다, 매서드의 인자로 시행 결과를 받는다 등등...) 그 중 전략 패턴을 사용한 부분을 한번 살펴보자.
public class Car {
private final String name;
private final int position;
public Car(String name, int position) {
this.name = name;
this.position = position;
}
public Car move(MoveStrategy moveStrategy) {
if (moveStrategy.isMovable()) {
return new Car(name, position + 1);
}
return this;
}
...
}
public interface MoveStrategy {
boolean isMovable();
}
위에서 보는 바와 같이, MoveStrategy라는 인터페이스를 매서드의 패러미터로 설정하고 인터페이스의 isMovable() 이라는 boolean 매서드가 true 일때만 position을 +1 한다.
그렇다면 이 부분은 테스트 코드에서 어떻게 활용 될까?
...
@Test
public void 이동() {
Car car = new Car("pobi", 0);
Car actual = car.move(new MoveStrategy() {
@Override
public boolean isMovable() {
return true;
}
});
assertThat(actual).isEqualTo(new Car("pobi", 1));
}
@Test
public void 정지() {
Car car = new Car("pobi", 0);
Car actual = car.move(new MoveStrategy() {
@Override
public boolean isMovable() {
return false;
}
});
assertThat(actual).isEqualTo(new Car("pobi", 0));
}
...
}
위와 같이 new MoveStrategy(){ 구현내용 }; 으로 매서드 스코프에서 해당 객체(인터페이스)의 추상 매서드를 구현하여 사용 가능하다. 얘를 익명 클래스라고 한다.
실제 비즈니스 로직에서의 Movable 은 랜덤으로 값이 5 이상인지를 판별하지만
테스트 코드에서는 "랜덤"의 요소를 없애서 반복해서 테스트해도 안전한 코드로 재사용 가능한 것이다. 심지어 인터페이스를 @Override 하는 구현체 클래스를 만들 필요 없이!
따라서 익명 클래스의 장점은 아래와 같다
클래스 내의 클래스, 내부 클래스 및 익명 클래스는 아래와 같이 디컴파일 된다.
CarTest 는 분명 하나의 클래스 파일로 디컴파일 되었는데,(CarTest.class) $1 $2 처럼 다른 녀석들이 보인다. 이 녀석들의 정체가 바로 익명 클래스이다.
익명 클래스는 상위 클래스(위 예제에서는 CarTest 클래스)에 $1 $2 와 같이 순서를 붙인 채로 디컴파일 된다.
익명 클래스는 인터페이스, 추상 클래스 등 추상에도 선언이 가능하고, 구상 클래스를 @Override 할 수도 있다.
따라서 개발자는 구상 클래스에 존재하는 기존 매서드를 그대로 사용하는 것이 아닌 즉석에서 고쳐서 "특정" 매서드에서만 일회용으로 사용이 가능하게 된다.
그런데, 위의 익명 클래스를 이용한 테스트 클래스를 선언 한 후 살펴보면, IDE에서 다음과 같이 알려준다.
"익명 'new MoveStrategy()' 는 lambda로 대체 될 수 있다"
그렇다면 한번 해보자.
오! 훨씬 모습이 간결해졌다. 람다는 어떤 녀석일까?
한번 그냥 써보자. 리팩토링과 함께
public static int sumAll(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
total += number;
}
return total;
}
public static int sumAllEven(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number % 2 == 0) {
total += number;
}
}
return total;
}
public static int sumAllOverThree(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number > 3) {
total += number;
}
}
return total;
}
이게 내 오늘 과제이다. 위 매서드를 어떻게 리팩토링하는 것이 좋을까?
당장 보이는 문제점은
중복되는 부분이 많다
: numbers 를 패러미터로 하는 매서드들이 total에 어떤 계산을 거쳐서, 리턴한다는 점이 중복된다
유일한 창이는 조건문이다
: sumAll 은 조건문 없이 total에 추가하고, 다른 녀석들은 조건에 따라서 추가한다
그렇다면, 조건만 따로 분리하고 나머지는 동일하게 공통화(일반화) 해도 되겠다. 매서드를 하나로 빼보자
public static int sumByCondition(List<Integer> numbers){
int total = 0;
for (Integer number : numbers) {
if (무슨무슨조건(number)){
total += number;
}
}
return total;
}
이놈은 위 놈들이랑 완전히 같은 모양이다. "무슨무슨조건"에 어떤 녀석이 들어올지만 가변적으로 변신 시켜주면 될 것 같다.
오? 가변적으로 변경이면...익명 클래스가 생각난다. 받는 쪽에서
new 조건 (){ 이렇게 저렇게 하자} 라고 재정의하면 될 것 같은데?
그렇다면 인터페이스를 하나 파보자
public interface SumWithCondition {
boolean sumCondition(int number);
}
조건에 따라 더해주는 인터페이스를 선언했다.
넘버를 받아서, 해당 넘버가 적절하면 true 아니면 false를 리턴 해 줄 것이다.
이제 적용시켜보자
public static int sumByCondition(List<Integer> numbers){
int total = 0;
SumWithCondition sumWithCondition = new SumWithCondition() {
@Override
public boolean sumCondition(int number) {
return true;
}
};
for (Integer number : numbers) {
if (sumWithCondition.sumCondition(number)){
total += number;
}
}
return total;
}
위 형태는 sumWithAll 과 완전히 동일한 역할을 한다.
public static int sumByCondition(List<Integer> numbers){
int total = 0;
SumWithCondition sumWithCondition = number -> true;
for (Integer number : numbers) {
if (sumWithCondition.sumCondition(number)){
total += number;
}
}
return total;
}
위 코드는 훨씬 보기 편해졌다.
그런데, 우리가 원하는 건 각각의 함수에 람다로 뭔가를 하는 게 아니라, 공통 기능을 하나의 함수로 뽑고 나머진 그 함수를 이용하는 것이다.
그렇다면, 람다의 특징 중 하나인 "일급 객체"(혹은 일급 시민) 으로 활용된다는 점을 이용해보자
일급 객체의 조건은 다음과 같다.
변수나 데이타에 할당 할 수 있어야 한다.
객체의 인자로 넘길 수 있어야 한다.
객체의 리턴값으로 리턴 할수 있어야 한다..
우리는 여기서 "객체의 인자로 넘길 수 있어야 한다" 를 주목해보자.
만약 우리가 만든 매서드 내에서 인터페이스를 구현하는 어떤 함수가 들어오는지를 "모를 수" 있다면?
위와 같이 모르게 해주기 위해서, 인자로 한번 넣어주자
public static int sumByCondition(List<Integer> numbers, SumWithCondition sumWithCondition){
int total = 0;
for (Integer number : numbers) {
if (sumWithCondition.sumCondition(number)){
total += number;
}
}
return total;
}
위와 같이. 그리고 사용하는 측에서는 이렇게 사용해보자
public static int sumAll(List<Integer> numbers) {
return sumByCondition(numbers, new SumWithCondition() {
@Override
public boolean sumCondition(int number) {
return true;
}
});
}
public static int sumAllEven(List<Integer> numbers) {
return sumByCondition(numbers, new SumWithCondition() {
@Override
public boolean sumCondition(int number) {
return number % 2 == 0;
}
});
}
public static int sumAllOverThree(List<Integer> numbers) {
return sumByCondition(numbers, new SumWithCondition() {
@Override
public boolean sumCondition(int number) {
return number > 3;
}
});
}
익명 클래스를 통해서 매서드단마다 인터페이스의 매서드를 구현해준다.
조건절은 오버라이드 한다.
위를 통해서, 인터페이스의 구현체가 필요 할 때마다 재정의(@Override)를 쉽고 편하게 할 수 있다.
그런데, 조금 보기 불편한데?
public static int sumAll(List<Integer> numbers) {
return sumByCondition(numbers, number -> true);
}
public static int sumAllEven(List<Integer> numbers) {
return sumByCondition(numbers, number -> number % 2 == 0);
}
public static int sumAllOverThree(List<Integer> numbers) {
return sumByCondition(numbers, number -> number > 3);
}
위와 같이 람다식으로 변환 가능하다.
위의 케이스를 보면서 람다의 사용법을 정리해보자
: 컴파일러 측에서 타입을 추론하여 넣어준다.
우리가
위와 같이 인터페이스의 타입을 정의했으므로 (리턴은 booelan, 인자는 int로 받아라)
위 람다식에서도 함수의 타입을 명시하지 않고 사용이 가능하다. 물론, 명시적으로 써 줄 수도 있다.
: 다시 한 번,일급 객체의 조건은 다음과 같다.
변수나 데이타에 할당 할 수 있어야 한다.
객체의 인자로 넘길 수 있어야 한다.
객체의 리턴값으로 리턴 할수 있어야 한다..
람다는 기본적으로 익명 클래스이다. 그리고 익명클래스는 위의 조건을 만족하는 일급 객체이다.
위 어노테이션은 해당 인터페이스가 함수형 인터페이스임을 알려준다.
함수형 인터페이스는
오직 하나의 추상 매서드를 가진다.
따라서 컴파일러가 하나 이상의 추상 매서드를 감지하면, 컴파일 에러를 내주게 하는 어노테이션이다. (Default 매서드 등 구상 매서드는 가질 수 있다)