minwoorich / 2024-spring-jpa-study

4 stars 5 forks source link

제네릭 그리고 재귀적 타입 한정 #54

Open minwoorich opened 3 months ago

minwoorich commented 3 months ago

⚠️ 이번 포스트는 제네릭에 대한 이해가 어느정도 필요합니다.

➡️ 제네릭에 대해 참고 할 만한 블로그

1. 아니 도대체 이게 뭐야!?!?!

때는 바야흐로 본인이 스프링 시큐리티를 열심히 디버거를 돌려가면서 학습을 하던 때였다. HttpSecurity 가 도대체 어떻게 생겨먹었는지 궁금해서 디버거로 타고타고 들어가다가 사진 속 고대의 암호문과 같은 코드를 발견하였다.

박스친 부분이 보이는가?

본인은 분명 제네릭을 꽤나 이해했다고 자부했었는데 오만한 착각이였다,,, 이 암호문 같은 제네릭 코드는 도저히 해석이 불가능했다.

그래서 바로 교수님께 콜을 때렸다.

🤔 재귀적 타입 한정 ?

코드를 다시 한번 보자.

AbstractHttpConfigurer<T extends AbstractHttpConfigurer<T, B>, B extends HttpSecurityBuilder<B>>

엇, 그러고보니 뭔가 재귀적인 패턴이 눈에 띄는것 같다.

AbstractHttpConfigurer 의 제네릭 파라미터가 상한 경계를 띄고 있는데 다름이 아니라 자기 자신을 상한 경계로 가지고 있다. 그리고 그 타입이 또 마찬가지로 타입 파라미터를 가지고 있는 말 그대로 재귀적으로 타입이 한정되고 있는 셈이다.

🧐 좋아 재귀적으로 타입을 한정짓고 있네,,, 그럼 왜 이렇게 한거지?

2. 재귀적 타입 한정 정복하기

교수님께서 친절히 정의와 사용 예시까지 설명을 해주셨지만 사실 그렇게 와 닿는 설명은 아니다. 그래서 본인이 직접(구글링해서 얻은) 예시를 통해 steb-by-step 차근차근 조져나가보겠다.

📌 요구사항

Money 라는 인터페이스가 존재하며 이를 구현하는 자식객체들 (Won, Dollar) 을 만들고 각 화폐별로 값(amount) 을 대소 비교하는 코드를 작성하시오.

또한 Won과 Dollar 객체를 서로 비교하려고 하는 경우 에러를 발생시키시오. (1달러랑 1원은 다르지 않은가?)

☹️ Ver.1

Money.java

interface Money {
    Integer getAmount();
}

Won.java

class Won implements Money, Comparable<Won> {

    private final Integer amount; // int 대신 Integer 를 사용한 이유 : compareTo() 를 사용하려고

    public Won(Integer amount) {
        this.amount = amount;
    }

    @Override
    public int compareTo(Won o) {
        return amount.compareTo(o.amount);
    }

    @Override
    public Integer getAmount() {
        return amount;
    }
}

Dollar.java

class Dollar implements Money, Comparable<Dollar> {

    private final Integer amount;

    public Dollar(Integer amount) {
        this.amount = amount;
    }

    @Override
    public int compareTo(Dollar o) {
        return amount.compareTo(o.amount);
    }

    @Override
    public Integer getAmount() {
        return amount;
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Won won1 = new Won(2000);
        Won won2 = new Won(2000);
        printForWon(won1, won2);

        Dollar dollar1 = new Dollar(1);
        Dollar dollar2 = new Dollar(2);
        printForDollar(dollar1, dollar2);

//        won1.compareTo(dollar1); <-- 에러 발생 (에러 발생하는게 좋은거임)
    }

    private static void printForWon(Won won1, Won won2) {
        if (won1.compareTo(won2) > 0) {
            System.out.println("won1 > won2 입니다");
        } else if (won1.compareTo(won2) == 0) {
            System.out.println("won1 == won2 입니다");
        }else {
            System.out.println("won1 < won2 입니다");
        }
    }

    private static void printForDollar(Dollar dollar1, Dollar dollar2) {
        if (dollar1.compareTo(dollar2) > 0) {
            System.out.println("dollar1 > dollar2 입니다");
        } else if (dollar1.compareTo(dollar2) == 0) {
            System.out.println("dollar1 == dollar2 입니다");
        }else {
            System.out.println("dollar1 < dollar2 입니다");
        }
    }
}

출력

⚠️ Ver.1 의 문제점

자바에선 객체간의 비교를 위해 Comparable 인터페이스를 제공해주기 때문에 이를 사용해서 객체간의 amount 비교를 구현하였다. 다행히도 출력은 기대한 값이 정상적으로 나온다. 하지만 문제는 중복 코드가 발생 한다는 것이다. 현재 ComparableWonDollar 가 직접 구현을 하고 있기 때문인데 만일 엔 화, 유로화, 바트 화 등이 계속 추가될 경우 계속해서 중복 코드가 발생할 것이다.

이를 해결해보자!

😑 Ver.2

Money.java

class Money implements Comparable<Money> {

    private final Integer amount;

    public Money(Integer amount) {
        this.amount = amount;
    }

    public Integer getAmount() {
        return amount;
    }

    @Override
    public int compareTo(Money o) {
        return amount.compareTo(o.getAmount());
    }
}

Won.java

class Won extends Money {
    public Won(Integer amount) {
        super(amount);
    }
}

Dollar.java

class Dollar extends Money {
    public Dollar(Integer amount) {
        super(amount);
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Won won1 = new Won(2000);
        Won won2 = new Won(2000);
        print(won1, won2);

        Dollar dollar1 = new Dollar(1);
        Dollar dollar2 = new Dollar(2);
        print(dollar1, dollar2);

        won1.compareTo(dollar1); // <-- 에러 발생 X
    }

    private static void print(Money money1, Money money2) {
        if (money1.compareTo(money2) > 0) {
            System.out.println("money1 > money2 입니다");
        } else if (money1.compareTo(money2) == 0) {
            System.out.println("money1 == money2 입니다");
        }else {
            System.out.println("money1 < money2 입니다");
        }
    }
}

⚠️ Ver.2 의 문제점

코드 중복을 말끔히 해결했다! 이제 더 이상 Won, Dollar 가 아닌 부모인 MoneyComparable 을 구현 하므로 자>식들은 compareTo() 를 그저 상속 받아서 사용하면된다.

하지만 문제가 발생했다.

won1.compareTo(dollar1);

요구사항대로라면 이 코드에서 에러가 발생을 해야하는데 그렇지 않고 그대로 실행이된다. 왜냐하면 Comparable 을 구현 할 때 제네릭 타입으로 부모타입인 Money 를 지정해줬기 때문이다. 그래서 다형성이 적용되는 바람에 위 코드가 그대로 실행 될 수 있는것이다.

다음 버전을 통해 또 한번 개선해보자!

😐 Ver.3

Money.java

class Money<T> implements Comparable<T> {

    private final Integer amount;

    public Money(Integer amount) {
        this.amount = amount;
    }

    public Integer getAmount() {
        return amount;
    }

    @Override
    public int compareTo(T o) {
        return amount.compareTo(o.getAmount()); // 에러 발생
    }
}

Won.java

class Won extends Money<Won>{
    public Won(Integer amount) {
        super(amount);
    }
}

Dollar.java

class Dollar extends Money<Dollar>{
    public Dollar(Integer amount) {
        super(amount);
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Won won1 = new Won(2000);
        Won won2 = new Won(2000);
        print(won1, won2);

        Dollar dollar1 = new Dollar(1);
        Dollar dollar2 = new Dollar(2);
        print(dollar1, dollar2);

        won1.compareTo(dollar1); // <-- 에러 발생 
    }

    // print() 생략
}

⚠️ Ver.3 의 문제점

이제 진짜 제네릭의 기능을 십분 사용하여 동적으로 타입을 지정할 수 있도록 설계를 하였다.

덕분에 WonDollar 를 비교하는 것을 컴파일 에러를 발생시켜 막을 수 있었다.

하지만 이 코드에는 치명적인 문제가 있다. 이 사실 이 코드에는 에러가 다른곳에서도 발생을 하는데 바로

@Override
  public int compareTo(T o) {
       return amount.compareTo(o.getAmount()); // 에러 발생
   }

이부분이다.

현재 T는 어떤 클래스 타입도 지정되지 않은 상태다. T가 실제 클래스 타입으로 바꿔치기 되는 경우는 런타임에 수행된다. 그렇기 때문에 o 는 그 어떤 메서드도 가지고 있지 않은 상태인데 getAmount() 를 호출하려고 하니 에러가 발생하는 것이다.

Comparable<T> -> Comparable<T extends Money> 로 바꾸기만 하면 되는거 아닌가?

라고 생각할 수 있으나 아쉽게도 그것은 불가능하다. 왜냐하면 너무나 간단한 이유인데 Comparable<T extends 클래스명> 를 지원하지 않는다. JAVA 에는 오직 Comparable<T> 밖에 존재하지 않는다.

이제 이 문제를 해결하러 가보자! (거의 다 왔으니 좀 만 더 힘 내자!! 🔥 )

😄 Ver.4 (Final)

Money.java


// 재귀적 타입 한정
class Money<T extends Money<T>> implements Comparable<T> {
    private final Integer amount;

    public Money(Integer amount) {
        this.amount = amount;
    }

    public Integer getAmount() {
        return amount;
    }

    @Override
    public int compareTo(T o) {
        return amount.compareTo(o.getAmount());
    }
}

Won.java

class Won extends Money<Won> {
    public Won(Integer amount) {
        super(amount);
    }
}

Dollar.java

class Dollar extends Money<Dollar> {
    public Dollar(Integer amount) {
        super(amount);
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Money won1 = new Won(2000);
        Money won2 = new Won(2000);
        print(won1, won2);

        Money dollar1 = new Dollar(1);
        Money dollar2 = new Dollar(2);
        print(dollar1, dollar2);

//        won1.compareTo(dollar1); // <-- 에러 발생
    }

    // print() 생략

}

출력


🎉 재귀적 타입 한정 사용

여기까지 오느라 수고 정말 많았다. 이걸 보여주기위해 무려 4단계에 걸쳐 빌드업을 쌓아온 것이다.

정리 해보자면 다음과 같다.

Ver.1 에서는 중복코드가 발생하였다. Ver.2 에서는 제네릭에 다형성이 적용되는 바람에 WonDollar 의 비교를 막을 수 없었다. Ver.3 에서는 제네릭을 본격적으로 사용하여 동적 타입 지정은 가능했지만, 그러는 바람에 오히려 Money 에 정의 되어있는 메서드 getAmount() 를 사용하지 못했다.

하지만 Ver.4 에서 재귀적 타입 한정을 사용함으로써 Ver.2와 3 의 문제점을 전부 해결 할 수 있었다.

Money<T extends Money<T>> implements Comparable<T>

즉, 위 코드는 다음과 같이 해석될 수 있다.

Money의 제네릭 타입은 자기 자신과 그 하위 타입(상속) 만으로 제한하며 하위 타입들은 부모로부터 유용한 메서드들을 상속 받아 사용 할 수 있다.

🌫️ Type Erasure

제네릭 매개변수들은 컴파일러가 읽어들이면서 전부 제거 해버린다. 즉, 바이트 코드상에는 제네릭 타입들이 존재하지 않게 되는데 제거하는 이유는 제네릭이 뒤늦게 나온 기술이기 때문에 이전의 바이트 코드에는 제네릭이 전혀 존재하지 않는다. 그래서 이전 버전의 JVM에서도 동작하도록 하위호환을 위해 제네릭을 제거해버리는 것이다.

재귀적 타입 한정을 사용한 경우에도 마찬가지로 타입제거가 수행될건데 뇌피셜을 좀 가미해본다면

아마 자바 컴파일러가

class Money<T extends Money<T>> implements Comparable<T> {
    private final Integer amount;

    // ...생략...

    @Override
    public int compareTo(T o) {
        return amount.compareTo(o.getAmount());
    }
}

를 읽어들이며 Type Erasure 를 수행할 것이고 이것을

class Money implements Comparable {
    private final Integer amount;

    // ...생략...

    @Override
    public int compareTo(Money o) {
        return amount.compareTo(o.getAmount());
    }
}

대략 이런식으로 코드를 읽어들이지 않을까 추측해본다. 한번 확인해볼까?

Money.class (바이트코드)

솔직히 바이트코드를 읽을줄 몰라서 뭐가 뭔지 모르겠지만 제네릭 타입이 모두 지워진것은 확실히 확인 가능하다.

3. 마무리

이제 어떤 경우에 재귀적 타입 한정을 사용하는지 대충 감이 오는가?

한번 맨 처음에 봤던 암호문 같았던 시큐리티 코드를 다시 한번 살펴보자!

이 코드에서 T 는 재귀적 타입 한정이 적용 됨으로 AbstractHttpConfigurer 혹은 이것의 하위 타입만이 들어갈 수 있게된다.

즉, withObjectPostProcessor() 메서드의 경우 반환 타입이 T 이므로 다음과 같이 사용할 수 있다.

public class MySecurityConfigurer extends AbstractHttpConfigurer<MySecurityConfigurer, HttpSecurity> {

    public MySecurityConfigurer withObjectPostProcessor(ObjectPostProcessor<?> objectPostProcessor) {
        addObjectPostProcessor(objectPostProcessor);
        return this;
    }

    public void configure(HttpSecurity http) throws Exception {

        // 추가적인 커스터마이징을 위해 ObjectPostProcessor를 사용하여 UsernamePasswordAuthenticationFilter를 커스터마이즈
        withObjectPostProcessor(new ObjectPostProcessor<UsernamePasswordAuthenticationFilter>())
        .disable() // 다른 빌더 메서드를 연속적으로 호출하여 체이닝을 구현할 수 있음
        .다른빌더메서드1()
        .다른빌더메서드2()
        .다른빌더메서드3(); 
    }
}

코드는 이해 못해도 상관없다. 이렇게 재귀적 타입 한정을 사용함으로써 체이닝 메서드를 사용할 수 있는 이점 이 있다는 것을 보여주기 위해 작성한 것이다. 물론 제네릭을 사용했기 때문에 타입 안정성도 동시에 지킬 수 있게 된다. 그래서 재귀적 타입 한정 은 빌더 패턴과 궁합이 잘 맞는다. 실제로 스프링 시큐리티에는 수 많은 Builder 객체들이 존재하는데 자세히보면 재귀적 타입 한정 패턴이 적용되어있는것을 심심찮게 볼 수 있다. ➡️ 빌더패턴이란?

😎 정리

❤️ 재귀적 타입 한정은 제네릭 타입을 자기 자신과 그 하위 타입 만으로 제한 할 수 있다. 💚 자신과 자신의 자식만을 강제로 지정함으로써 타입 안정성 을 얻을 수 있다. 💙 타입 안정성을 지킴과 동시에 제네릭을 이용해 체이닝 메서드를 구현 할 수 있어서 빌더 패턴과 궁합이 잘 맞는다.



📚 레퍼런스

예제 만들때 참고한 스택 오버플로글

https://stackoverflow.com/questions/7385949/what-does-recursive-type-bound-in-generics-mean

재귀적 타입 한정 정의 참고

http://www.angelikalanger.com/GenericsFAQ/FAQSections/TypeParameters.html#FAQ106

제네릭 이해 (인파블로그)

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#%EC%A0%9C%EB%84%A4%EB%A6%AD_%ED%83%80%EC%9E%85_%EC%86%8C%EA%B1%B0

TypeErasure 동작 원리 (인파블로그)

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%AD-%ED%83%80%EC%9E%85-%EC%86%8C%EA%B1%B0-%EC%BB%B4%ED%8C%8C%EC%9D%BC-%EA%B3%BC%EC%A0%95-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0