GYUD-TECH

[스터디: 모던 자바] 동적 파라미터화 본문

스터디

[스터디: 모던 자바] 동적 파라미터화

GYUD 2024. 3. 26. 21:51

 

시스템의 요구사항은 항상 바뀐다.

해피에이징 프로젝트를 수행 하면서 계속해서 요구사항이 바뀌어서 코드를 수정하느라 힘들었던 기억이 있다.

 

오브젝트 책을 읽고, 객체지향설계를 통해서 변경에 유연한 설계를 할 수 있다는 것을 알았다.

객체지향설계를 통해 추상화에 의존하여 변경에 유연하게 대처하는 것도 가능하지만, 따로 구현체를 선언할 필요가 없는 간단한 부분은 동적 파라미터화를 통해 코드를 간단하게 작성할 수 있다.

 

이번 장에서는 동적 파라미터화를 통해서 어떻게 코드를 개선할 수 있는지를 중점적으로 살펴보자.

 


동적 파라미터화

동적 파라미터화란 아직은 어떻게 실행할지 결정하지 않은 코드 블럭을 의미한다.

이 코드 블럭의 실행은 메서드 파라미터를 통해서 전달되어 런타임에 결정된다.

 

동적 파라미터화를 적용하면 서로 다른 요구사항에는 다른 파라미터를 전달하여 처리할 수 있기 때문에 코드를 중복하지 않고 재사용하기 쉽다는 장점이 있다.

 

이전 장에서 소개한 사과 필터링 예시를 다시 가져오자.

프로그램은 사용자의 요청에 따라 초록색인 사과만 추출하거나, 무게가 150 이상인 사과만 추출해야한다.

 

Flag 사용

오로지 자바의 기본 개념만으로 이를 구현하기 위해서는 flag를 두고 이에 따라서 다른 구현을 처리하도록 할 것이다.

public static List<Apple> filterApples(List<Apple> inventory, Color color, int weight, 
                                         boolean flag) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple: inventory) {
        if ((flag && apple.getColor().equals(color)) ||
            (!flag && apple.getWeight() > weight)) {
            result.add(apple);
        }
    }
}

결론부터 말하자면 이런 코드는 작성해서는 안된다.

 

코드의 파라미터로 받는 flag는 의미가 명확하지 않기 때문에 구현을 보지 않고서는 어떤 의미인지 알 수 없다.

또한 사과의 크기나 모양으로 필터링하는 요구사항이 추가한다고 가정하면 어떻게 대처할 것인가?

flag를 enumType으로 바꾸어서 이에 따라서 각자 다른 메서드를 적용해주어야 할 것이다.

 

요구사항이 추가되더라도 filterApples 메서드를 수정하지 않고, 추가된 요구사항만 적용할 수 없을까?

 

 

전략 패턴의 사용

이를 위해서는 계속해서 바뀌는 필터링 조건에 해당하는 코드를 파라미터로 전달해야한다.

객체지향의 전략패턴을 적용하면 이를 해결할 수 있다.

public interface ApplePredicate {
    boolean test(Apple apple);
}


public class AppleHeavyWeightPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}


public class AppleGreenColorPredicate implements ApplePredicate {
    pulblic boolean test(Apple apple) {
        return GREEN.equals(apple.getColor());
    }
}

추상화 인터페이스인 ApplePredicate를 선언하고 이를 각자 다른 방식으로 구현한 AppleHeavyWeightPredicate 와 AppleGreenColorPredicate를 선언했다.

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
    List<Apple> result = new ArrayList<>();
    
    for (Apple apple: inventory) {
        if (p.test(apple)) {
            result.add(apple);
        }
    }
    return result;
}

이후 filterApples() 메서드의 파라미터로 ApplePredicate 타입의 인스턴스를 받으면, 주입된 인스턴스의 타입에 따라 유연한 필터링 조건을 적용할 수 있다.

 

만약 빨간 사과를 필터링하는 요구사항이 추가된다 하더라도 AppleRedColorPredicate 구현체를 작성하여 기존 코드의 변경 없이 새로운 기능을 추가할 수 있을 것이다.

 

문제를 해결할 수는 있었지만, 구현하려는 것에 비하여 너무 많은 코드를 작성한 것 같다.

단지 필터링 조건만을 외부에서 주입받으면 되는 것이었는데, 메서드는 이급 시민으로써 파라미터로 전달할 수 없기 때문에 이를 클래스로 감싸고, 추상화된 인터페이스를 정의하여서 일급 값인 인스턴스를 전달해서 문제를 해결한 것이다.

 

 

익명 클래스의 사용

사과 필터링 조건 로직을 filterApples() 메서드에서만 사용한다면, 복잡하게 구현체들을 클래스로 감싸서 선언하는 것이 아니라 익명클래스로 바로 생성해줄 수 있다.

List<Apple> greenApples = filterApples(inventory, new ApplePredicate() {
    public boolean test(Apple apple) {
        return RED.equals(apple.getColor());
    }
});

익명 클래스를 처음 접한 사람이라면 ApplePredicate는 인터페이스인데 어떻게 new 키워드로 구현체를 생성할 수 있는지 의문을 가질 수도 있다.

ApplePredicate는 인스턴스를 생성할 수 없는 인터페이스이긴 하지만, 위 코드에서는 바로 뒤이어서 구현을 작성해 주었기 때문에 이는 ApplePredicate 와 같은 타입으로 여겨질 수 있는 구현체 인스턴스를 생성하는 코드이다.

 

이렇게 작성하면 AppleGreenApplePredicate, AppleHeavyPredicate 클래스를 선언할 필요 없이 바로 구현체를 전달해 줄 수 있다.

 

 

람다식 사용

위와 같이 익명 클래스의 메서드 코드가 간단하다면, 자바 8에서 도입된 람다식을 활용하여 더 간단하게 표현할 수 있다.

List<Apple> result = filterApples(inventory, (Apple apple) -> GREEN.equals(apple.getColor()));

test() 메서드를 선언하는 것이 아니라 람다식으로 간단하게 Apple을 인자로 받아서 boolean을 리턴하는 로직을 작성하는 것이다.

 

처음에 람다식을 공부할 때는 ApplePredicate의 test 추상 메서드와 코드의 람다식이 어떻게 매칭되는지 궁금했다.

만약 추상 메서드가 2개 이상이라면 어떻게 매칭할 것인가?

 

람다식은 추상 메서드 하나만을 가지는 기능적 인터페이스만을 대체할 수 있다.

기능적 인터페이스에 람다식을 전달하면 자바는 해당 식을 기능적 인터페이스의 단일 추상 메서드와 매핑해준다.

만약 추상 메서드가 여러개라면 매핑이 불가능 하기 때문에 전략 패턴 방식을 활용하거나 익명 클래스를 통해서 메서드 이름을 명시해 줘야 한다.

 

 

제네릭 사용

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
    List<Apple> result = new ArrayList<>();
    
    for (Apple apple: inventory) {
        if (p.test(apple)) {
            result.add(apple);
        }
    }
    return result;
}

위 코드는 List<Apple>을 파라미터로 받아서 조건을 만족하는 Apple을 리턴하는 메서드이다.

만약 숫자를 필터링 하고 싶다면 Apple을 Integer로 대체하기만 하면 위 로직을 그대로 사용할 수 있을 것 같다.

 

이를 위해서는 타입을 추상화해야 하기 때문에 제네릭을 사용하여 코드를 아래와 같이 개선할 수 있다.

public interface Predicate<T> {
    boolean test(T t);
}

public static <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> result = new ArrayList<>();
    for (T e: list) {
        if(p.test(e)) {
            result.add(e);
        }
    }
    return result;
}

이제는 어떤 타입의 List에서 조건을 만족하는 인스턴스만을 찾고 싶은 모든 요구사항에서 위 코드를 사용할 수 있다.

List<Apple> redApples = filter(inventory, (Apple apple) -> RED.equals(apple.getColor)));
List<Integer> evenNumbers = filter(numbes, (Integer i) -> i % 2 == 0);

 


실전 사용 예제

전략 패턴에 더하여 동적 파리미터화와 제네릭 까지 적용함으로써 넓은 범위에서 재사용이 가능한 메서드를 작성할 수 있었다.

실제로 자바에서는 어느 부분에서 동적 파라미터화를 사용하고 있는지 살펴보자.

 

Comparator

알고리즘 문제를 풀 때 자주 사용하는 Comparator는 정렬 로직을 추상화 해놓고 어떤 객체가 들어와도 이를 정렬할 수 있도록 설계가 되어 있다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
    ... (기타 default 메서드)
}
default void sort(Comparator<? super E> c) {
    Object[] a = this.toArray();
    Arrays.sort(a, (Comparator) c);
    ListIterator<E> i = this.listIterator();
    for (Object e : a) {
        i.next();
        i.set((E) e);
    }
}

위 코드는 자바의 sort와 Comparator 코드의 일부이다.

 

sort에서는 Comparator라는 기능적 인터페이스를 파라미터로 받아서 정렬 기준을 지정한다.

Comparator에서는 제네릭 타입 T를 사용하여 어떤 타입의 인스턴스가 와도 코드를 재사용 할 수 있다.

 

이렇게 구현되어 있는 덕분에 사과를 정렬하고자 할 때 아래와 같이 람다식으로 간단하게 Comparator를 생성하여 파라미터로 전달하는 것이 가능하다.

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));

 

위 코드를 공부하며 3가지 의문점이 생겼다.

 

@FunctionalInterface의 역할

첫번째 의문점은 @FunctionalInterface 애노테이션의 사용이다.

그냥 interface만 선언해도 될 것 같은데 @FunctionalInterface 애노테이션을 달아 준 이유는 뭘까?

 

그 이유는 의도의 명확화와 컴파일 시간의 오류 확인, 상속 규칙 지정이 있다.

 

의도 명확화

@FunctionalInterface를 달아줌으로써 이 인터페이스가 기능적 인터페이스로 사용된다는 의도를 명확하게 나타낸다.

이는 코드의 가독성 및 유지 관리에 유용하게 작용한다.

 

컴파일 시간의 오류 확인

컴파일러는 @FunctionalInterface를 만나면 해당 인터페이스에 하나의 추상메서드만 존재하는지 확인하고, 존재하지 않는 경우 컴파일 오류를 발생시키기 떄문에 컴파일 시간에 에러를 확인할 수 있다.

 

상속 규칙 지정

@FunctionalInterface가 붙은 인터페이스가 붙은 인터페이스를 확장하는 경우에 하위 인터페이스는 새로운 추상 메서드를 도입할 수 없다는 제약에 걸린다.

 

이러한 장점을 가지기 때문에 확실하게 기능적 인터페이스로 사용하고자 한다면 @FunctionalInterface 애노테이션을 붙여주는 것이 좋다.

 

 

Comparator의 두개의 추상클래스

앞서 @FunctionalInterface를 붙이면 해당 인터페이스에 하나의 추상메서드만 존재하는지 확인하고 그렇지 않다면 컴파일타임에 에러를 발생시킨다고 공부했다.

 

하지만 Comparator의 코드를 보면 2개의 추상 메서드가 존재하는 것을 알 수 있다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
    ... (기타 default 메서드)
}

compare() 와 equals() 라는 2개의 추상메서드가 존재하기 때문에 이는 FunctionalInterface로 동작할 수 없다.

하지만 코드를 실행해보면 전혀 컴파일 오류 없이 잘 동작하는 것을 알 수 있다.

 

그 이유는 equals() 메서드는 최상위 계층의 Object 클래스에 구현되어 있는 메서드이기 때문이다.

Object에서 재정의 되어 있기 때문에 equals() 메서드는 추가적인 추상 메서드로 간주되지 않는다.

 

따라서 compare() 메서드만이 하나의 추상 메서드이고, 람다식을 통해서 compare 로직을 전달하면 자바에서 자동으로 이를 매칭하여 비교 연산을 구현하는 것이다.

 

 

제네릭 제약

sort 메서드를 보면 아래와 같이 제네릭 타입에 super라는 키워드가 들어가 있는 것을 알 수 있다.

default void sort(Comparator<? super E> c) {...}

기존에 제네릭 타입에 대해서 잘 몰랐기 때문에 관련 내용을 찾아본 후에야 super 키워드의 의미를 알 수 있었다.

혹시 궁금한 사람이 있다면 아래 블로그를 참고해서 추가로 학습하면 좋을 것 같다.

 

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0

 

☕ 자바 제네릭(Generics) 개념 & 문법 정복하기

제네릭 (Generics) 이란 자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다. 자바에서 배

inpa.tistory.com

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%AD-%EC%99%80%EC%9D%BC%EB%93%9C-%EC%B9%B4%EB%93%9C-extends-super-T-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4

 

☕ 자바 제네릭의 공변성 & 와일드카드 완벽 이해

자바의 공변성 / 반공변성 제네릭의 와일드카드를 배우기 앞서 선수 지식으로 알고 넘어가야할 개념이 있다. 조금 난이도 있는 프로그래밍 부분을 학습 하다보면 한번쯤은 들어볼수 있는 공변

inpa.tistory.com

 


 

Comparator 외에도 Runnable이나 자바의 GUI Callable에서도 동적 파라미터화를 활용하여 유지보수에 용이한 코드를 설계한다.

자바 8 이전에는 객체로 메서드를 감싸서 전달하거나, 익명클래스를 사용해야만 했지만, 자바 8 이후로는 람다식을 활용하여 직접 메서드를 전달함으로써 더욱 간단하고 명시적으로 구현이 가능해졌다.

 

기존에 알고리즘 문제를 풀면서도 Comparator 클래스를 따로 구현하곤 하였는데, 앞으로는 람다식을 활용해서 더욱 간단하게 표현하며 동적 파라미터화를 활용할 예정이다.