우테코 회고록/프리코스 회고록

[프리코스 4주차] 미션 수행 기록

kim chan jin 2023. 11. 11. 12:35

3주차 미션 중에 사용자가 올바른 값을 입력하지 않았다면

예외를 터트리고 잡은 이후에 올바른 값을 입력할 때까지 입력을 강제하는 로직이 있었다.

 

이 로직을 구현하기 위해

boolean valid = true 변수를 세우고

만약 사용자가 올바른 값을 입력하지 않는다면 valid 를 그대로 냅두고

만약 사용자가 올바른 값을 입력한다면 valid 를 false 로 바꿔서 while 문을 탈출하도록 구현했다.

 

하지만 사용자로부터 입력값을 받는 메서드마다 while try catch 를 반복해야 했다.

다른 지원자 분들의 코드를 둘러보다 Supplier 를 사용한 것을 봤다.

코드를 적용하는 건 쉬웠지만, 그게 왜 작동하는건지는 이해하지 못했다.

 

이해하지 못하는 이유는 지네릭, 함수형 인터페이스 때문이었다...!

완벽히 이해하지 못한 개념들을 복습한 이후 Supplier 를 어떻게 활용할지 고민해보자!

 

 

 

 

 


메서드 참조,  람다 표현식,  함수형 인터페이스,  익명객체

 


 

 

메서드 참조, 람다 표현식은

해당 메서드에 따라 적절한 익명객체를 만들고, 그 익명객체의 단 하나의 추상 메서드와 대응시켜,

그 추상 메서드를 호출했을 때 해당 메서드를 호출하기 위한 방법이다!

 

Runnable, Supplier, Consumer, Function, Predicate 예시 코드를 보며 확인해보자

우선 Runnable 예시 코드이다!

// Runnable 예시 코드

public class RTest {

    public static void runnableMethod() {
        System.out.println("runnableMethod");
    }
    
    public static void methodUsingRunnable(Runnable runnable) {
        runnable.run();
    }
   
    public static void main(String[] args) {
    
        // (0) 에러. Required type : Runnable, Provided : void
        // methodUsingRunnable(runnableMethod());
        
        // (1) 메서드 참조 사용
        methodUsingRunnable(RTest::runnableMethod); // runnableMethod
        
        // (2) 람다표현식 사용
        methodUsingRunnable(() -> RTest.runnableMethod()); // runnableMethod
        
        // (3) Runnable 함수형 인터페이스 익명객체 직접 생성
        methodUsingRunnable(() -> new Runnable() {
            @Override
            public void run() {
                System.out.println("runnableMethod");
            }
        }.run()); // runnableMethod
    }
}

 

 

Runnable 타입

매개변수가 없고, 반환값이 없는 A 라는 메서드를 가진 Runnable 함수형 인터페이스 타입의 익명객체를 말한다.

 

복습 차원에서 상기하자면

함수형 인터페이스의 목적은 익명객체를 만들기 위함이다.

즉 메서드 참조와 람다 표현식은 마치 메서드를 주고받는 것처럼 보이지만 사실 익명객체를 주고받는 것이다.

(1), (2), (3) 를 보면 확인할 수 있다.

 

다시 본론으로 돌아와서 생각해보자

(0) 은 왜 안되고 (1), (2), (3) 은 왜 될까?

그 이유는 methodUsingRunnable() 은 매개변수가 Runnable 함수형 인터페이스 타입이기 때문이다.

public static void runnableMethod() {
    System.out.println("runnableMethod");
}
public static void methodUsingRunnable(Runnable runnable) { // 매개변수는 void 가 아니라 Runnable
    runnable.run();
}

 

(0) 에 작성된 methodUsingRunnable(runnableMethod()) 는 매개변수로 void 가 들어간 셈이라 컴파일 에러다.

 

(1) 에 작성된 methodUsingRunnable(FunctionalInterface::runnableMethod) 과

(2) 에 작성된 methodUsingRunnable(() -> FunctionalInterface.runnableMethod()) 과

(3) 에 작성된 methodUsingRunnable(() -> new Runnable() { @Override public void run() {...생략...}}).run() 는

매개변수로 Runnable 함수형 인터페이스 타입이 들어간 것이라 정상작동한다.

 

그럼 또 의문이 생긴다.

runnableMethod() 를 매개변수가 없고 반환값이 없는 메서드로 선언 및 구현했을 뿐인데 (Runnable 를 상속한 적도 없는데)

(1) 메서드 참조, (2) 람다 표현식, (3) 익명객체 직접 생성 라는 3가지 방법을 사용했다는 이유만으로 

어떻게 methodUsingRunnable( 이 자리! ) 의 매개변수로 들어갈 수 있는거지?

 

그 이유는 다음과 같다.

1. Runnable 함수형 인터페이스에는 단 하나의 추상 메서드 run() 이 존재한다.

2. (1) 메서드 참조 (2) 람다 표현식을 사용하면 컴파일러가 해당 메서드의 특성(매개변수의 유무, 반환값의 유무)에 따라 Runnable 함수형 인터페이스의 익명객체를 만들고, 해당 메서드를 익명객체의 run() 메서드와 대응시킨다.

3. methodUsingRunnable() 메서드는 Runnable 함수형 인터페이스의 익명객체를 파라미터로 받고 해당 익명객체를 점(.)연산자를 사용하여 run() 을 호출하면 대응된 runnableMethod() 가 호출된다.

 

다른 함수형 인터페이스도 동일한 원리이다. 일반화하여 정리한다면 다음과 같다.

1. 함수형 인터페이스에는 각각 단 하나의 추상 메서드가 존재한다.

2. 메서드 참조 또는 람다 표현식을 사용하면 컴파일러가 해당 메서드의 특성(매개변수의 유무, 반환값의 유무)에 따라 적절한 함수형 인터페이스의 익명객체가 만들어지고, 해당 메서드를 익명객체의 단 하나의 추상 메서드와 대응시킨다.

3. 이후 로직에서 단 하나의 추상 메서드를 호출하면 해당 메서드가 호출된다.

 

Supplier 예시 코드도 살펴보자!

// Supplier 예시 코드

public class STest {

    public static String supplierMethod() {
        return "supplierMethod";
    }

    public static <T> T methodUsingSupplier(Supplier<T> supplier) {
        return supplier.get();
    }

    public static void main(String[] args) {

        // (1) 메서드 참조 사용
        String line1 = methodUsingSupplier(STest::supplierMethod);
        System.out.println(line1); // supplierMethod

        // (2) 람다 표현식 사용
        String line2 = methodUsingSupplier(() -> STest.supplierMethod());
        System.out.println(line2); // supplierMethod

        // (3) Supplier 함수형 인터페이스 익명객체 직접 생성
        String line3 = methodUsingSupplier(() -> new Supplier<>() {
            @Override
            public String get() {
                return "supplierMethod";
            }
        }.get());
        System.out.println(line3); // supplierMethod
    }
}

 

자... 이해해보려고 하는데 지네릭이 나를 또 붙잡는다...!

갑자기 지네릭이 나온 이유는 Supplier 함수형 인터페이스가 특정 타입을 반환하는 추상 메서드를 가지기 때문이다!

지네릭을 복습하자!

도움을 받은 레퍼런스는 여기! https://devlog-wjdrbs96.tistory.com/201

 

public static <T> T methodUsingSupplier(Supplier<T> supplier) {...}

 

T 가 3번 나온다.

 

첫번째 <T> 는 methodUsingSupplier()의 지네릭 메서드이다.

해당 메서드 내에서 사용될 반환 타입, 매개변수의 타입을 제한하기 위한 용도이다.

지네릭 메서드로 사용되는 지네릭은 메서드 레벨에서 선언되고 메서드 내에서만 사용된다. (지네릭 메서드는 지네릭 클래스와 독립적이다!)

 

지네릭 메서드로 선언했기 때문에

methodUsingSupplier() 메서드의 반환타입을 T 로 통일해야 하고

methodUsingSupplier() 메서드의 파라미터의 타입 Supplier 함수형 인터페이스의 타입매개변수를 T 로 통일해야 한다.

(물론 와일드카드로 제한을 걸기 전까지는 T 는 어떠한 참조타입이 되어도 상관없다.)

지금까지 말만 들으면 지네릭 메서드에 따라 반환타입, 타입 매개변수가 결정될 것 같지만 오히려 그 반대이다.

 

(1)의 예시를 보자!

// (1) 메서드 참조 사용
String line1 = methodUsingSupplier(STest::supplierMethod);
System.out.println(line1); // supplierMethod

 

메서드 참조를 통해 methodUsingSupplier() 의 매개변수로 Supplier 함수형 인터페이스의 익명객체를 넣었다.

STest::supplierMethod 가 아니라 왜 Supplier 함수형 인터페이스의 익명객체라고 말했는지 이제는 이해해야 한다!

 

그 이유는 supplierMethod() 는 매개변수가 없고, 반환값이 있는 메서드이다.

그 메서드를 메서드 참조로 호출한다면

해당 메서드는 Supplier 함수형 인터페이스의 get() 와 메서드 시그니처가 동일하기 때문에

Supplier 익명객체를 만들고 get() 메서드를 supplierMethod() 을 대응시킨다.

이후 methodUsingSupplier 에서는 Supplier 익명객체를 매개변수로 받고 그 익명객체에 점연산자를 사용하여 get() 을 호출한다.

결과적으로 supplierMethod() 가 호출되어 String 값을 반환하게 되는 것이다!

 

본론으로 돌아와서

public static <T> T methodUsingSupplier(Supplier<T> supplier) {...}

get() 을 호출하면 결과적으로 supplierMethod() 가 호출되어 String 값을 반환할 때 비로소 T 가 정해지는 것이다.

그럼 지네릭 메서드반환타입도 그에 따라야 한다는 것이다!

 

이제 지네릭도 이해되었는가?

Supplier 예시 코드가 이해가 되었는가??!!

 

이제 살짝 다른 Consumer 예시 코드를 살펴보자!

// Consumer 예시 코드

public class CTest {

    public static void consumerMethod(String str) {
        System.out.println(str);
    }

    public static <T> void methodUsingConsumer(Consumer<T> consumer, T value) {
        consumer.accept(value);
    }

    public static void main(String[] args) {
    
        // (1) 메서드 참조 사용
        methodUsingConsumer(CTest::consumerMethod, "consumerMethod"); // consumerMethod
        
        // (2) 람다 표현식 사용
        methodUsingConsumer((str) -> CTest.consumerMethod(str), "consumerMethod"); // consumerMethod

        // (3) Consumer 함수형 인터페이스 익명객체 직접 생성
        methodUsingConsumer((str) -> new Consumer<String>() {
            @Override
            public void accept(String str) {
                System.out.println(str);
            }
        }.accept(str), "consumerMethod"); // consumerMethod
    }
}

 

Consumer 함수형 인터페이스는 Supplier 와 다르게 매개변수가 있고, 반환값이 없다.

즉, Supplier 와 다르게 Consumer 는 인자를 받아야 한다는 것이다.

그것 말고는 원리는 똑같다.

 

(1) 메서드 참조를 기준으로 순서를 정리하자면 ((2),(3)도 동일한 원리이다.)

CTest::consumerMethod() 메서드 참조를 했기 때문에

Consumer 함수형 인터페이스의 익명객체가 만들어지고 consumerMethod() 는 accept() 에 대응되었다.

이 때 Consumer 함수형 인터페이스의 단 하나의 추상 메서드 accept(T t) 는 매개변수가 하나 있어야 하므로 

methodUsingConsumer() 2번째 매개변수에 String 을 넣어준다. (이번 예시에서 내가 consumerMethod() 가 두번째 매개변수로 String 을 받도록 구현했다.)

이후 methodUsingConsumer() 에서 믹명객체를 통해 accept() 을 호출하면 결과적으로 consumerMethod() 가 호출된다.

호출 결과 consumerMethod() 는 void를 반환한다.

Consumer 함수형 인터페이스의 타입 매개변수가 void 로 결정되었으니 지네릭 메서드도 void, 반환타입 도 void 가 되어야 한다.

 

Function, Predicate 예시 코드를 살펴보자

설명이 없어도 이해할 수 있을 것이다!

// Function 예시 코드

public class FTest {

    public static String functionMethod(String str) {
        return "functionMethod" + str;
    }

    public static <T, R> R methodUsingFunction(Function<T, R> function, T value) {
        return function.apply(value);
    }

    public static void main(String[] args) {

        // (1) 메서드 참조 사용
        String result1 = methodUsingFunction(FTest::functionMethod, "XXX");
        System.out.println(result1); // functionalMethodXXX

        // (2) 람다 표현식 사용
        String result2 = methodUsingFunction((str) -> FTest.functionMethod(str), "XXX");
        System.out.println(result2); // functionMethodXXX
        
        // (3) Function 함수형 인터페이스 익명객체 직접 생성
        String result3 = methodUsingFunction(new Function<String, String>() {
            @Override
            public String apply(String str) {
                return "functionMethod" + str;
            }
        }, "XXX"); // functionMethodXXX
        System.out.println(result3); // functionMethodXXX
    }
}

 

// Predicate 예시 코드

public class PTest {
    
    public static boolean predicateMethod(boolean bool) {
        return bool;
    }

    public static <T> boolean methodUsingPredicate(Predicate<T> predicate, T value) {
        return predicate.test(value);
    }

    public static void main(String[] args) {

        // (1) 메서드 참조 사용
        boolean bool1 = methodUsingPredicate(PTest::predicateMethod, true);
        System.out.println(bool1); // true

        // (2) 람다 표현식 사용
        boolean bool2 = methodUsingPredicate((bool) -> PTest.predicateMethod(bool), true);
        System.out.println(bool2); // true

        // (3) Predicate 함수형 인터페이스 익명객체 직접 생성
        boolean bool3 = methodUsingPredicate(new Predicate<Boolean>() {
            @Override
            public boolean test(Boolean bool) {
                return bool;
            }
        }, true);
        System.out.println(bool3); // true
    }
}

 

 

메서드 참조, 람다 표현식, 함수형 인터페이스, 익명객체에 대해 이해했으니

이제 Supplier 를 활용하여 사용자가 올바른 입력을 할 때까지 무한 반복하는 코드를 작성해보자

// InputView
public int enterDate() {
    String line = Console.readLine().trim();

    validateDateNumeric(line);
    validateDateRange(line);

    return Integer.parseInt(line);
}
// Controller
private int askDate() {
    return retryUntilGoodInput(inputView::enterDate);
}
    
...

private <T> T retryUntilGoodInput(Supplier<T> supplier) {
    while (true) {
        try {
            return supplier.get();
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

 

InputView 클래스의 enterDate() 는 매개변수가 없고 int를 반환한다.

즉 Supplier 익명객체로 만들어져서 enterDate() 메서드와 get() 메서드가 대응될 수 있다는 것이다.

 

retryUntilGoodInput() 은 Supplier 함수형 인터페이스 타입을 매개변수로 받는다.

해당 메서드의 로직은 IllegalArgumentException 이 잡히지 않을 때까지 (즉, 올바른 입력을 할 때까지)

익명객체 supplier 의 get() 메서드를 호출한다는 것이다.

만약 올바른 입력을 하지 않는다면 올바른 입력을 할 때까지 계속 해당 예외의 메시지를 출력한다.

 

즉 retryUntilGoodInput() 메서드의 파라미터에

메서드 참조로 Supplier 함수형 인터페이스의 익명객체를 넘겨준다면 

get() 을 통해 enterDate() 를 호출할 수 있다!

 

그럼 무한반복 while 문 속의 try - catch 구문 속에 get() 메서드 자리에

enterDate() 를 위치시켜 호출할 수 있다는 것이다.

 

이것이 가능했던 이유는 

[1] 내가 만든 메서드가 Runnable, Supplier, Consumer, Function, Predicate 함수형 인터페이스 중 하나에 속하기 때문이고

[2] 컴파일러가 내가 만든 메서드를 보고 적절한 함수형 인터페이스의 익명객체를 만들어주고

[3] 그 익명객체의 유일한 추상메서드 Runnable 이라면 run(), Supplier 라면 get(), Consumer 라면 accept(), Function 이라면 apply(), Predicate 라면 test() 를 내가 만든 메서드와 연동시켜주기 때문이다!

[4] 그래서 익명객체를 통해 유일한 추상메서드를 호출하면 결과적으로 내가 만든 메서드를 호출할 수 있게 된다!

 

이제 이해했다!

 

 

 

 

 

 

 

 


flatMap(),  entrySet(),  allMatch(),  mapToInt(),  anyMatch()

 


 

스트림과 스트림 메서드들을 쓰긴 쓰는데 완벽히 이해하고 쓰진 않았다...

그래서 이번 기회에 확실히 공부해봤다!!

 

현재 상황은 다음과 같다.

Menu 인터페이스가 있다.

Menu 인터페이스를 상속하는 AppetizerMenu, MainMenu, DessertMenu, BeverageMenu 열거형들이 있다.

이렇게 추상화를 한 이유는 Menu 의 구현체들(열거형들)을 Menus 라는 상위 열거형으로 묶기 위함이다.

상위 열거형 Menus 가 Menu[] menus 를 필드로 갖는데, Menu[] 는 인터페이스 타입이기 때문에 AppetizerMenu.values(), MainMenu.values(), DessertMenu.values(), BeverageMenu.values() 가 Menus 타입 정적 필드의 필드로 존재가능한 것이다!

 

여기까지가 상황 설명이고, 이제 flatMap() 에 대해 알아보자!

위에서 여러 열거형들을 상위 열거형으로 묶은 이유는 메뉴를 제대로 입력했는지 판단할 때 모든 메뉴가 들어가있는 열거형으로써 사용하기 위함이었다.

이때 flatMap() 을 써야 한다는 것이다! 왜 써야 하는지 정리해보겠다!

 

(1) Menu[] menus = Menus.values()

: Menus 의 모든 정적 필드를 가져온다. 즉, Menus[] 형태를 반환한다.

 

(2) Stream<Menus> StreamMenus = Arrays.stream(Menus.values())

: Menus.values()새로운 하나의 스트림으로 만든다. 배열로 뭉쳐있던 것을 떼내어 스트림으로 다룬다는 것이다.

 

(3) Stream<Menu> StreamMenu = Arrays.stream(Menus.values()).flatMap(menus -> Arrays.stream(menus.getMenus()));

: Menus.values() 를 새로운 하나의 스트림으로 만들고, 그 스트림의 각 요소를 menus로 칭하는데menus 에 대해 getMenus() 를 호출 및 적용하여 Menu[] 를 반환하고 새로운 하나의 스트림으로 만든다.(배열로 뭉쳐있던 것을 떼네어 스트림으로 다룬다는 것이다.) 하지만 그러면 중첩 스트림이 되기 때문에 풀어헤쳐서 단일한 스트림으로 만들어준다.

 

여기서 핵심은 상위 열거형인 Menus 의 getMenus() 메서드가 하위 열거형의 배열 Menu[], 정확히 말하면 하위 열거형들의 추상화된 인터페이스 타입의 배열 Menu[] 를 반환하기 때문에 그 배열을 풀어헤치지 않으면 중첩 스트림이 된다는 것이다!!!

 

 

 

WeekdayDiscount 예시 코드

이번 미션을 수행하며 사용했던 코드들을 예시로 복습해보자!

// WeekdayDiscount

@Override
public int calculateDiscount(Consumer consumer) {
    Map<String, Integer> order = consumer.order();

    if (canDiscountByOrder(consumer) && canDiscountByDate(consumer)) {
        return order.entrySet().stream()
                .filter(entry -> Arrays.stream(DessertMenu.values()).anyMatch(dessertMenu -> entry.getKey().contains(dessertMenu.getName())))
                .mapToInt(entry -> entry.getValue())
                .sum() * WEEKDAY_DISCOUNT;
    }
    return 0;
}

 

이해하기 쉽게 그림으로 표현했다. 간단한 정리해보자!

map 의 각 요소(한쌍)에 접근하기 위해 entrySet() 사용

반환타입은 Set<Map.Entry<String, Integer>>

 

Set<Map.Entry<String, Integer>> 을 스트림으로 만들면

Stream<Map.Entry<String, Integer>>

Stream<Map.Entry<String, Integer>> 의 각 요소는 entry 로 접근한다.

 

filter로 해당 조건에 맞지 않은 스트림의 각 요소(entry)들은 제거한다.

이 때 스트림과 스트림간의 연산이 필요하므로 DessertMenu.values() 도 스트림으로 만든다.

조건의 내용은

entry의 각 Key 가

Arrays.stream(DessertMenu.values()) 의 각 요소(dessertMenu)의 name 필드와

일치해야 한다는 것이다.

 

그래서 남겨진 entry 의 Value 는 정수형이므로 연산을 위해 IntStream 을 새롭게 만든다.

 

남겨진 스트림의 요소의 합을 총합하여 결과를 구한다.

 

 

Consumer 예시 코드

하나만 더 복습해보자!

이번엔 그림말고 글로만 해보자!

// Consumer

private void validateInvalidOrder(Map<String, Integer> order) {
    if (!(order.entrySet().stream()
            .allMatch(entry -> Arrays.stream(Menus.values())
                    .flatMap(menus -> Arrays.stream(menus.getMenus()))
                    .anyMatch(menu -> menu.getName().equals(entry.getKey()))))) {
        throw new InvalidOrderException();
    }
}

 

order 는 Map 이기 때문에 entrySet() 으로 바꿔 한쌍에 접근할 수 있도록 만들고

이를 stream() 으로 만든다.

 

조건에 모두 만족해야 하는데

스트림과 스트림간의 연산을 해야 하기 때문에

Menus.values() 또한 스트림으로 만든다.

근데 Menus.values() 는 필드로 AppetizerMenu.values(), MainMenu.values() 등 Menu[] 타입을 지니기 때문에

map() 을 사용하면 AppetizerMenu[], MainMenu[] 처럼 각 타입의 배열을 따로따로 스트림으로 만들어버린다.

내가 원하는건 모든 메뉴를 하나의 스트림으로 다 흩뜨러놓아 사용하는 것이기 때문에 flatMap() 을 사용해야 한다.

 

flatMap() 으로 모든 메뉴들을 하나의 스트림으로 담았기 때문에 이제 스트림과 스트림간의 연산을 의도한대로 이뤄질 수 있다.

 

만약 메뉴들이 담긴 스트림을 순회하며 각 요소의 이름 menu.getName() 이

쌍(Key-Value)이 담긴 스트림의 요소의 키값 entry.getKey() 와 하나라도 일치하면

내부조건은 만족,

 

그리고

만약 쌍이 담긴 스트림을 순회하며

내부조건이 모두 만족한다면

외부조건은 만족,

 

내부조건과 외부조건이 하나로 묶인 것이 하나의 조건이다.

하지만 우리가 필요한 건 손님이 입력한 메뉴가 메뉴판에 없는 경우를 찾아내는 것이므로 괄호로 묶어서 반대로(!) 만들어준다

즉, 손님이 입력한 메뉴가 하나라도 메뉴판에 존재하는 것이 아니라면 예외를 던져야 한다는 것이다!

 

 

 

[마무리 소감문]

1,2,3주차까지는 설계하고 구현하기, 의존도 줄이기,  MVC 구조, SOLID, 검증, record, enum

뭔가 구조적이고 철학적인(?) 고민들을 많이 하는 공부였다면

 

이번 4주차는 기술적인 공부를 많이 했다.

함수형 인터페이스, 메서드 참조, 람다 표현식, 익명객체 생성, 함수형 인터페이스의 추상 메서드

지네릭, 지네릭 메서드, 지네릭 클래스

스트림과 스트림 메서드에 대해 공부했다.

 

우테코 덕분에 폭발적으로 공부하고 고민할 수 있었다.

그냥 단순암기 주입식 공부가 아니라 원리를 이해하는 공부여서 더 좋았다.

 

이제 4주차 프리코스가 끝났다.

4주가 어떻게 흘러갔는지 모르겠다. 

눈 감았다 뜨면 과제 제출날이었고

눈 감았다 뜨면 주차가 바뀌어져 있었다.

근데 정말 재밌었다. 그리고 유익했다.

 

결과가 어떻게 될지 모르겠지만

지금까지 그래왔던 것처럼 앞으로도 행복하게 개발 공부를 해나가자!

이제 밀린 과제들과 프로젝트들을 하러 가보자!