CHAPTER9 - 리팩터링, 테스팅 (1)

2025. 3. 25. 00:39Book/모던 자바 인 액션

목차.

1. 람다 표현식으로 코드 리팩토링하기

2. 람다 표현식이 객체지향 설계 패턴에 미치는 영향

 

1. 가독성과 유연성을 개선하는 리팩토링

 

 

이 절에서는 람다, 메서드 참조, 스트림 등의 기능을 이용해서 더 가독성이 좋고 유연한 코드로 리팩터링하는 방법에 대해서 살펴볼 것이다.

 

 

1.1 코드 가독성 개선

 

코드 가독성이란 일반적으로 '어떤 코드를 다른 사람도 쉽게 이해할 수 있음'을 의미한다. 코드 가독성을 높이려면 코드의 문서화를 잘하고, 표준 코딩 규칙을 준수하는 등의 노력이 필요하다.

 

자바 8의 새 기능을 이용해 코드의 가독성을 높일 수 있다. 또한 메서드 참조와 스트림 API를 이용해 코드의 의도를 명확하게 보여줄 수 있다.

 

9장에서는 람다, 메서드 참조, 스트림을 활용해서 코드 가독성을 개선할 수 있는 간단한 세 가지 리팩터링 예제를 살펴볼 것이다.

 

- 익명 클래스를 람다 표현식으로 리팩터링 하기

- 람다 표현식을 메서드 참조로 리팩터링하기

- 명령형 데이터 처리를 스트림으로 리팩터링하기

 

 

1.2 익명 클래스를 람다 표현식으로 리팩터링하기

 

하나의 추상 메서드를 구현하는 익명 클래스는 람다 표현식으로 리팩터링할 수 있다.

// anonymous class
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello World!");
    }
};

// lambda expression
Runnable r2 = () -> System.out.println("Hello World!");

 

모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.(인터페이스의 추상 메서드가 여러 개인 경우는 안됨) 또한 익명 클래스와 람다 표현식에는 차이점이 있다.

 첫째, 익명 클래스에서 사용한 this.와 super는 람다 표현식에서 다른 의미를 갖는다. 익명 클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.

(익명 클래스는 실제 인스턴스를 생성하고, 람다는 내부적으로 invokeDynamic을 사용하기 때문)

public class RunnableLambdaWrapper {
    private final Runnable runnable = () -> System.out.println(this);

    public Runnable getRunnable() {
        return runnable;
    }

    @Override
    public String toString() {
        return "RunnableLambdaWrapper{" + "LambdaWrapper" + "}";
    }
}


public class RunnableAnonymousWrapper {

    private final Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println(this);
        }

        @Override
        public String toString() {
            return "RunnableAnonymous{" + "anonymous" + "}";
        }
    };

    public Runnable getRunnable() {
        return runnable;
    }

    @Override
    public String toString() {
        return "RunnableAnonymousWrapper{" + "anonymousWrapper" + "}";
    }
}

public static void main(String[] args) {
    RunnableAnonymousWrapper anonymousWrapper = new RunnableAnonymousWrapper();
    RunnableLambdaWrapper lambdaWrapper = new RunnableLambdaWrapper();

    anonymousWrapper.getRunnable().run();
    lambdaWrapper.getRunnable().run();
}

=>
RunnableAnonymous{anonymous}
RunnableLambdaWrapper{LambdaWrapper}

 

둘째, 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다. (섀도 변수). 하지만 람다 표현식으로는 변수를 가릴 수 없다.

int a = 10;
Runnable lambdaRunnable = () -> {
    //컴파일 에러
    int a = 2;
    System.out.println(a);
};

Runnable anonymousRunnable = new Runnable() {
    @Override
    public void run() {
        int a = 2;
        System.out.println(a);
    }
};

 

셋째 익명클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있다. 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문이다.

doSomething(new Task() {
    @Override
    public void execute() {
        System.out.println("Task");
    }
});

//Runnalbe 인터페이스와 Task 인터페이스의 시그니쳐가 같아 컴파일 에러
doSomething(() -> System.out.println( "Task"));
}

private static void doSomething(Runnable runnable) {
    runnable.run();
}

private static void doSomething(Task task) {
    task.execute();
}

 

Runnable과 Task의 대상 형식이 될 수 있으므로 doSomething 메서드에 동작 파라미터화 넘겨준 람다식은 컴파일 에러를 일으킨다.

 

명시적 형변환을 이용하면 모호함을 제거할 수 있다.

doSomething((Task) () -> System.out.println( "Task"));

 

 

 

1.3 람다 표현식을 메서드 참조로 리팩터링하기

 

람다 표현식은 쉽게 전달할 수 있는 짧은 코드다. 메서드 참조는 람다 표현식보다 가독성 있고 더욱 짧게 표현할 수 있다.

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = DishExample.menu.stream().collect(
        groupingBy((dish) ->{
            if (dish.getCalories() < 400){ return CaloricLevel.DIET; } 
            else if (400 < dish.getCalories() && dish.getCalories() < 700){ return CaloricLevel.NORMAL; }
            else { return CaloricLevel.FAT; }
        })
);

 

람다 표현식을 별도의 메서드로 추출한 다음에 groupingBy에 인수로 전달할 수 있다. 아래 코드는 코드가 간결하고 의도도 명확하다.

public abstract class DishExample {
    public static List<Dish> menu = Arrays.asList(
            new Dish("season fruits", true, 120, Dish.Type.OTHER),
            new Dish("prawns", false, 300, Dish.Type.FISH),
            new Dish("rice", true, 350, Dish.Type.OTHER),
            new Dish("chicken", false, 400, Dish.Type.MEAT),
            new Dish("salmon", false, 250, Dish.Type.FISH),
            new Dish("french fries", true, 530, Dish.Type.OTHER),
            new Dish("pizza", true, 550, Dish.Type.OTHER),
            new Dish("beef", false, 700, Dish.Type.MEAT),
            new Dish("pork", false, 800, Dish.Type.MEAT)
    );
    
    public static CaloricLevel getCaloricLevel(Dish dish) {
        if (dish.getCalories() < 400){ return CaloricLevel.DIET; }
        else if (400 < dish.getCalories() && dish.getCalories() < 700){ return CaloricLevel.NORMAL; }
        else { return CaloricLevel.FAT; }
    }
}

public static void main(String[] args) {
    Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = DishExample.menu.stream().collect(
            groupingBy(DishExample::getCaloricLevel)
    );
}

 

또한 comparing과 maxBy 같은 정적 헬퍼 메서드를 활용하는 것도 좋다. 이들은 메서드 참조와 조화를 이루도록 설계되었다.

public static void main(String[] args) {
    inventory.sort((Apple a1, Apple a2) ->
            a1.getWeight().compareTo(a2.getWeight()));

    inventory.sort(comparing(Apple::getWeight));
}

 

sum, maximum 등 자주 사용하는 리듀싱 연산은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공한다.

int totalCalories =
        DishExample.menu.stream().map(Dish::getCalories)
                                 .reduce(0, (c1, c2) -> c1 + c2);

int totalCaloriesWithMethodReference =
        DishExample.menu.stream().collect(summingInt(Dish::getCalories));

 

 

 

1.4 명령형 데이터 처리를 스트림으로 리팩터링하기

 

반복자를 이용한 컬렉션 처리 코드를 스트림 API로 바꾸면 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다. 스트림은 쇼트서킷과 게으름이라는 강력한 최적화뿐 아니라 멀티코어 아키텍처를 활용할 수 있는 방법을 제공한다.

List<String> dishNames = new ArrayList<>();
for (Dish dish : dishes) {
    if (dish.getCalories() < 400) {
        dishNames.add(dish.getName());
    }
}

 

스트림 API를 이용하면 문제를 더 직접적으로 기술할 수 있을 뿐 아니라 쉽게 병렬화할 수 있다.

dishes.parallelStream()
      .filter(dish -> dish.getCalories() < 400)
      .map(Dish::getName)
      .toList();

 

 

 

1.5 코드 유연성 개선

 

2장과 3장에서 동작 파라미터화를 살펴봤다. 즉, 다양한 람다를 전달해서 다양한 동작을 표현할 수 있다. 이는 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있게 해준다.

 

함수형 인터페이스 적용

먼저 람다 표현식을 이용하려면 함수형 인터페이스가 필요하다. 이번에는 자주 사용하는 두 가지 패턴을 살펴볼 것이다.

- 조건부 연기 실행

- 실행 어라운드

 

조건부 연기 실행

실제 작업을 처리하는 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 쉽게 볼 수 있다. 보안 검사나 로깅 관련 코드가 이처럼 사용된다.

if (logger.isLoggable(Level.FINER)) {
    logger.finer("Problem");
}

 

위 코드는 다음과 같은 사항에 문제가 있다.

- logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출된다.

- 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인한다.

 

다음처럼 메시지를 로깅하기 전에 logger 객체가 적절한 수준으로 설정되었는지 내부적으로 확인하는 log 메서드를 사용하는 것이 바람직하다.

public void log(Level level, String msg) {
    if (!isLoggable(level)) {
        return;
    }
    LogRecord lr = new LogRecord(level, msg);
    doLog(lr);
}

logger.log(Level.FINER, "Problem");

 

덕분에 불필요한 if문을 제거할 수 있으며 logger의 상태를 노출할 필요도 없으므로 위 코드가 더 바람직하다. 하지만 인수로 전달된 메시지 수준에서 logger가 활성화되어 있지 않더라도 항상 로깅 메시지를 평가하게 된다.

 

** 위 예제로는 직관적으로 이해할 수 없으므로 아래와 같은 예제를 보자**

private static final Logger logger = Logger.getLogger(ConditionalDeferredExecution.class.getName());
public static void main(String[] args) {
    logger.log(Level.FINER, "Problem: " + someExpensiveMethod());
}

private static String someExpensiveMethod() {
    System.out.println("expensiveMethod is running!!");
    return "ExpensiveMethod!!!";
}

=>
expensiveMethod is running!!

 

람다를 이용하면 위 문제를 쉽게 해결할 수 있다. 특정 조건에서만 메시지가 생성될 수 있도록 메시지 생성 과정을 연기 할 수 있어야 한다. 자바 8 API 는 이와 같은 문제를 해결할 수 있도록 Supplier 를 인수로 갖는 오버로드된 log 메서드를 제공한다.

public void log(Level level, Supplier<String> msgSupplier) {
    if (!isLoggable(level)) {
        return;
    }
    LogRecord lr = new LogRecord(level, msgSupplier.get());
    doLog(lr);
}

logger.log(Level.FINER, () -> "Problem: " + someExpensiveMethod());

 

log 메서드는 logger의 수준이 적절하게 설정되어 있을 때만 인수로 넘겨진 람다를 내부적으로 실행한다.

public void log(Level level, Supplier<String> msgSupplier) {
    if (!isLoggable(level)) {
        return;
    }
    LogRecord lr = new LogRecord(level, msgSupplier.get()); // 이 부분
    doLog(lr);
}

 

 

실행 어라운드

3장에서 한 번 실행 어라운드 패턴을 살펴봤다. 매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드가 있다면 이를 람다로 변환할 수 있다. 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 줄일 수 있다.

 

public static void main(String[] args) throws IOException {
    String oneLine = processFile(BufferedReader::readLine);
    String twoLine = processFile(b -> b.readLine() + "\n" + b.readLine());
}

public static String processFile(BufferedReaderProcess p) throws IOException {
    try(BufferedReader br =
                new BufferedReader(new FileReader("data.txt"))) {
        return p.process(br);
    }
}

@FunctionalInterface
public interface BufferedReaderProcess {

    String process(BufferedReader br) throws IOException;
}

 

 

 

2. 람다로 객체지향 디자인 패턴 리팩터링하기

 

디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있다. 기존에 디자인 패턴으로 해결하던 문제를 더 쉽고 간단하게 해결할 수 있다. 이 절에서 아래 다섯 가지 패턴을 살펴보자

 

- 전략

- 템플릿 메서드

- 옵저버

- 의무 체인

- 팩토리

 

 

2.1 전략

 

전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다. 다양한 기준을 갖는 입력값을 검증하거나, 다양한 파싱 방법을 사용하거나, 입력 형식을 설정하는 등 다양한 시나리오에 전략 패턴을 활용 할 수 있다.

위 그림처럼 전략 패턴은 세 부분으로 구성된다.

 

1. 알고리즘을 나타내는 인터페이스(Strategy)

2. 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreteStrategyA~B)

3. 전략 객체를 사용하는 한 개 이상의 클라이언트

 

예를 들어 오직 소문자 또는 숫자로 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷되어 있는지 검증한다고 가정하자.

// 인터페이스
public interface ValidationStrategy {
    boolean execute(String s);
}

// 구현체
public class IsNumeric implements ValidationStrategy {

    @Override
    public boolean execute(String s) {
        return s.matches("\\d+");
    }
}

public class IsAllLowerCase implements ValidationStrategy {

    @Override
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

// 클라이언트
public class Validator {

    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy strategy) {
        this.strategy = strategy;
    }

    public boolean validate(String s) {
        return strategy.execute(s);
    }
}

// 실행문
public class Main {

    public static void main(String[] args) {
        Validator numericValidator = new Validator(new IsNumeric());
        Validator lowerCaseValidator = new Validator(new IsAllLowerCase());

        boolean b1 = numericValidator.validate("data");
        boolean b2 = lowerCaseValidator.validate("babel");

        System.out.println(b1);
        System.out.println(b2);
    }
}

=>
false
true

 

 

람다 표현식 사용

ValidationStrategy는 함수형 인터페이스며 Predicate<String>과 같은 함수 디스크립터를 갖고 있다(함수형 인터페이스의 추상 메서드 시그니처). 따라서 다양한 전략을 구현하는 새로운 클래스를 구현할 필요 없이 람다 표현식을 직접 전달하면 코드가 간결해진다.

Validator numericValidator = new Validator((s) -> s.matches("[0-9]+"));
Validator lowerCaseValidator = new Validator((s) -> s.matches("[a-z]+"));

boolean b1 = numericValidator.validate("data");
boolean b2 = lowerCaseValidator.validate("babel");

System.out.println(b1);
System.out.println(b2);

=>
false
true

 

위 코드에서 람다 표현식을 이용해 전략 패턴에서 발생하는 자잘한 코드를 제거할 수 있다.

 

 

2.2 템플릿 메서드

 

알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다. 쉽게 말하면 이 알고리즘을 사용하고 싶은데 그대로는 안되고 조금 고쳐야 하는 상황에 적합하다.

템플릿 메서드가 어떻게 작동하는지 예제를 보자. 간단한 온라인 뱅킹 애플리케이션을 구현한다고 가정하자. 사용자가 고객 ID를 애플리케이션에 입력하면 은행 데이터베이스에서 고객 정보를 가져오고 고객이 원하는 서비스를 제공할 수 있다. 예를 들어 고객 계좌에 보너스를 입금한다고 가정하자. 은행마다 다양한 온라인 뱅킹 애플리케이션을 사용하며 동작 방법도 다르다.

public abstract class OnlineBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }
    
    abstract void makeCustomerHappy(Customer c);
}

 

processCustomer 메서드는 온라인 뱅킹 알고리즘이 해야 할 일을 보여준다. 우선 주어진 고객 ID를 이용해서 고객을 만족시켜야 한다. 각각의 지점은 OnlineBanking 클래스를 상속받아 makeCustomerHappy 메서드가 원하는 동작을 수행하도록 구현할 수 있다.

 

람다 표현식 사용

람다나 메서드 참조로 알고리즘에 추가할 다양한 컴포넌트를 구현해보자

이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 Consumer<Customer> 형식을 갖는 두 번째 인수를 processCustomer에 추가한다.

public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
    Customer c = Database.getCustomerWithId(id);
    makeCustomerHappy.accept(c);
}

new OnlineBankingLambda().processCustomer(1, (customer) ->
                System.out.println("Hello " + customer.getName()));

 

 

2.3 옵저버

 

어떤 이벤트가 발생했을 때 한 객체(subject)가 다른 개체 리스트(옵저버)에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패턴을 사용한다.

GUI 애플리케이션에서 옵저버 패턴이 자주 등장한다. 버튼 같은 GUI 컴포넌트에 옵저버를 설정할 수 있다. 그리고 사용자가 버튼을 클릭하면 옵저버에 알림이 전달되고 정해진 동작이 수행된다.

 

옵저버 패턴으로 커스터마이즈된 알림 시스템을 설계하고 구현해보자

 

우선 다양한 옵저버를 그룹화할 Observer 인터페이스가 필요하다.

public interface Observer {

    void notify(String message);
}

 

이제 알림에 포함된 다양한 키워드에 다른 동작을 수행할 수 있는 여러 옵저버를 정의할 수 있다.

public class NYTimes implements Observer {

    @Override
    public void notify(String message) {
        if (message != null && message.contains("money")) {
            System.out.println("Breaking news in NY! " + message);
        }
    }
}

 

그리고 subject 또한 구현하자

public interface Subject {

    void registerObserver(Observer observer);
    void notifyObservers(String message);
}

 

subject는 registerObserver 메서드로 새로운 옵저버를 등록한 다음에 notifyObservers 메서드로 구독한 옵저버에 이를 알린다.

public class Subscribe implements Subject {

    private final List<Observer> observers = new ArrayList<>();

    @Override
    public void registerObserver(Observer observer) {
        this.observers.add(observer);
    }

    @Override
    public void notifyObservers(String message) {
        observers.forEach(observer -> observer.notify(message));
    }
}

 

구현은 간단하다. Subscribe

public static void main(String[] args) {
    Subscribe subscribe = new Subscribe();
    subscribe.registerObserver(new NYTimes());
    subscribe.registerObserver(new Guardian());
    subscribe.notifyObservers("money is back!");
}

=>
Breaking news in NY! money is back!

 

 

람다 표현식 사용하기

여기서 Observer 인터페이스를 구현하는 모든 클래스는 하나의 메서드 notify를 구현했다.

subscribe.registerObserver((String message) -> {
    if (message.contains("money")) {
        System.out.println("Breaking news in NY! " + message);
    }
});

 

항상 람다 표현식이 정답인 것은 아니다. 옵저버가 상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수도 있다.

 

 

2.4 의무 체인

 

작업 처리 객체의 체인을 만들 때는 의무 체인 패턴을 사용한다. 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달한다.

 

일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성한다. 작업 처리 객체가 자신의 작업을 끝냈으면 다음 작업 처리 객체로 결과를 전달한다.

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;
    public void setSuccessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }
    public T handle(T input) {
        T r =  handleWork(input);
        if (successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    abstract protected T handleWork(T input);
}

 

그림을 보면 템플릿 메서드 디자인 패턴이 사용되었음을 알 수 있다.

handleRequest 메서드는 일부 작업을 어떻게 처리 할지 전체적으로 기술한다.

 

Handler 클래스를 상속받아 handle 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있다.

public class HeaderTextProcessing extends ProcessingObject<String> {
    @Override
    protected String handleWork(String input) {
        return "From Raoul, Mario and Alan: " + input;
    }
}

public class SpellCheckerProcessing extends ProcessingObject<String> {
    @Override
    protected String handleWork(String input) {
        return input.replaceAll("labda", "lambda");
    }
}

 

두 작첩 처리 객체를 연결해서 작업 체인을 만들 수 있다.

ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2);
String result = p1.handle("Are not labdas really cool?");
System.out.println(result);

=>
From Raoul, Mario and Alan: Are not lambdas really cool?

 

람다 표현식 사용

UnaryOperator<String> headerProcessing =
        text -> "From Raoul, Mario and Alan: " + text;

UnaryOperator<String> spellCheckerProcessing =
        text -> text.replace("labda", "lambda");

Function<String, String> pipeline =
        headerProcessing.andThen(spellCheckerProcessing);

String result = pipeline.apply("Are not labdas really cool?");
System.out.println(result);

=>
From Raoul, Mario and Alan: Are not lambdas really cool?

 

 

2.5 팩토리

인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다. 예를 들어 은행에서 취급하는 대출, 채권, 주식 등 다양한 상팜을 만들어야 한다고 가정하자.

public class ProductFactory {
    public static Product createProduct(String name) {
        return switch (name) {
            case "loan" -> new Loan();
            case "bond" -> new Bond();
            case "stock" -> new Stock();
            default -> throw new RuntimeException("No such product: " + name);
        };
    }
}

 

여기서 Loan, Stock, Bond는 모두 Product의 서브형식이다. createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 있다. 이 코드의 진짜 장점은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 생산할 수 있다는 것이다.

Product p = ProductFactory.createProduct("loan");

 

 

람다 표현식 사용

생성자도 메서드 참조처럼 접근할 수 있다.

final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
    map.put("loan", Loan::new);
    map.put("bond", Bond::new);
    map.put("stock", Stock::new);
}

public static Product createProduct(String name) {
    Supplier<Product> supplier = map.get(name);
    if (supplier != null) return supplier.get();
    throw new IllegalArgumentException("No such product: " + name);
}

 

하지만 위 팩토리 메서드 createProduct 메서드는 상품 생성자로 여러 인수를 전달하는 상황에서는 이 기법을 적용하기 어렵다. (Supplier는 인수를 하나만 받는다.)

 

예를 들어 세 인수를 받는 상품의 생성자가 있다고 가정하면 TriFunction이라는 특별한 함수형 인터페이스를 만들어야 한다.

public interface TriFunction<T, U, V, R> {
    R apply(T t, U u, V v);
}

final static Map<String, TriFunction<Integer, Integer, String, Product>> map
	= new HashMap<>();