자바 공부 기록 2회독(9) - 2024.1.27

동준·2024년 1월 27일
0

개인공부(자바)

목록 보기
11/16

8. 제네릭

타입스크립트를 공부할 때 참 어렵다 생각하던 게 제네릭이었는데, 자바 역시 제네릭이 있었다는 사실에 1회독 때는 좌절했었다. 그떄는 코드의 본질에 대해 충분히 고민하지 않고 다급하게 공부하던 때라서 더 좌절했던 것 같기도?(아님 말고)

자바 같은 강타입 언어를 공부하면서 타입에 대해 고민이라 적고 예외의 향연에 대한 고통이라 말한다... 아닌 고민을 많이 하게 됐다. 타입을 지정함으로써 생기는 안정성이 어렴풋이 느껴지는 건, 그래도 공부를 좀 깊게 했다는 반증이겠지? 라고 자뻑을 하면서 타입 지정과 밀접하게 연관된 제네릭을 자바에서 다시 한 번 더 만나게 됐다.

1) 들어가기에 앞서, 타입이란

앞서 말했듯이 타입 지정과 밀접한 키워드로써 의미를 가지는 제네릭을 공부하려면 타입에 대한 고민을 충분히 해야만 했다. 그렇다고 String이니 하는 참조 타입이니... intchar니 하는 원시 타입이니... 그런 고민이 아니라 프로그램에 있어서 타입이 어떤 의의를 가지기에 제네릭이 등장한 것인지에 대한 내 나름의 생각을 정리하고 제네릭을 공부해야집.

(1) 타입? 굳이 지정을 해야 될까?

우선, 타입을 먼저 지정해야 되는 정적 타입 언어와 타입을 지정하지 않아도 되는 동적 타입 언어를 먼저 분류할 필요가 있다. 둘을 간단하게 비교하면...

  • 정적 타입 : 변수 타입이 컴파일 시기(즉, 읽어낼 때)에 결정된다
  • 동적 타입 : 변수 타입이 런타임 시기(즉, 실행할 때)에 결정된다

프로그램이 동작하는 과정은 컴파일을 행한 후, 런타임 환경으로 진입한다. 정적 타입은 컴파일 과정에서 타입이 먼저 정해진 다음에 런타임 시기에 메모리가 할당되는 반면, 동적 타입은 런타임 시기에 타입이 결정되는 시기적인 차이점이 존재한다. 이 점은 추후 타입 지정이 가지는 강점인 메모리 할당의 최적화와 크게 연관된다.

처음 프론트엔드를 공부할 때 접한 자바스크립트는 타입이란 것을 생각하지 않아도 되는 대표적인 동적 타입 언어다. 간단한 예시를 들어보자면...

let a = 1;
console.log('첫번째 변수 : ' + a);

a = '점심 뭐 먹지';
console.log('두번째 변수 : ' + a);

a = [1, 2, 3, 4, 5];
console.log('세번째 변수 : ', a); // toString() 호출 방지를 위한 컴마 구분

a = { key1: '1', key2: '2', key3: '3' };
console.log('네번째 변수 : ', a); // toString() 호출 방지를 위한 컴마 구분

위의 코드는 자바스크립트로 간단하게 변수 a를 선언하고, Number, string, array, object 타입의 값들을 변수에 차례대로 할당하며 그 값을 출력하게 한다. 해당 코드의 콘솔 출력창 결과는 다음과 같다.

보이는 것처럼 아무런 에러 없이 코드 실행이 잘 되는(즉, 출력되는) 모습이다. 여기서, 현재 시점이 아닌 전체적으로 봤을 때, 변수 a의 타입은 하나로 정할 수 없다. 왜냐면 런타임 때 위에서부터 코드의 줄을 읽어오면서 그때그때 타입을 a에 할당주는 것이다.


반대로, 자바정적 타입 언어에 속한다. 아래 코드는 자바로 간단하게 작성하고 실행시킨 모습이다.

변수 aint로 선언됐으나, 6번째 라인에서 String 타입의 값을 할당하면서 실행할 때 컴파일 오류가 발생하는 모습을 보인다. 왜냐하면 코드 작성 과정에서 이미 변수 aint로 선언했기 때문에, 다른 타입의 값을 할당하지 못하도록 막은 셈이기 때문.

(2) 타입 지정의 의의

귀찮게 타입을 왜 지정해야 되나 싶지만, 타입을 지정함으로써 꽤 큰 이점을 얻을 수 있는데 바로 앞서 말했던 메모리 할당의 최적화 부분이다.

좀 더 크게 보자면, 타입을 지정하면서 얻게 되는 컴파일에서의 이점런타임에서의 이점이 있다.

컴파일에서의 이점

  1. 타입 체킹을 함으로써 코드 예측 가능
  2. 타입 사전 할당을 통한 메모리 성능 최적화

런타임에서의 이점

  1. 예외 처리의 용이화
  2. 개발 과정에서의 리팩토링 효율 향상

물론 꽃밭처럼 타입 지정이 장점만을 가지는 것은 아니다. 가장 큰 단점은 매우매우 귀찮다는 것이다.사실 익숙해지면 타입을 지정하지 않으면 몸이 근질근질해지는 진정한 강타입 최적화 인간이 되어감

뿐만 아니라, 변수에 할당된 타입을 예측하는 것이 힘들어지고 반복 작업이 증가하게 된다. 간단하게 아래 예시를 보자.

public class Box {
	public ? content;
}

Box 클래스 안에 있는 content라는 필드의 타입을 String, int, char... 등등 여러 가지 내용물을 하나씩 담는 여러 개의 Box 클래스 객체 참조변수를 만들고 싶다. 만약 제네릭을 쓰지 않는다면, 이렇게 된다.

public class Box {
	public String content;
}

public class Box {
	public int content;
}

public class Box {
	public char content;
}

반복 작업이 너무 싫고, 같은 프레임의 클래스를 중복해서 작업하는 것이 너무 귀찮아서 아예 최상위 클래스인 Object로 타입을 정해버렸다.

public class Box {
	public Object content;
}

이렇게 작성하면 모든 게 해결될 것 같...지만 큰 문제가 생긴다. 바로 객체 참조변수를 통해서 content 필드를 조회해서 새로운 변수에 할당하려면...

Box box = new Box();

String newContent = box.content; // 오류!

이렇게 생각이 들지만, 형변환 때 익혔던 중요한 공식인 '우변은 좌변의 범위를 넘어설 수 없다'가 여기서 적용된다. 좌변의 newContent 변수가 할당받을 수 있는 범위는 String인데, 우변의 box.content 필드 조회 값은 Object로 내용물의 범위가 그릇의 범위를 아득하게 초과한다.

그래서 이렇게 늘 작성해야 된다.

String newContent = (String) box.content;
// 어쩔 수 없는 강제 명시적 형변환...

이렇게 작성했다고 해도, 어떤 내용물이 저장됐는지 모르면 instanceof 연산자가 강제되거나 등등... 여간 귀찮음을 회피하려다가 귀찮음을 더 덮어쓰게 되는 불상사(?)가 발생하게 된다.

(3) 그래서 등장한 게 제네릭

코드를 작성하는 개발자의 관점에서 봤을 때, 우리는 저 Box라는 클래스 내부에 이미 어떤 타입의 필드가 들어갈 지 알고 있는 상태다. 그렇지만 지속적으로 변하는 내용물의 타입을 시기적절한 타이밍에 바로바로 담기 위해서 Object 클래스로 대충 뭉뚱그려버리면 앞서 봤던 귀찮은 상황들이 계속 나온다.

즉, 클래스를 작성하는 시점에서 결정되지 않은 타입을 파라미터로 처리하고 실제로 사용할 때, 타입을 지정해서 대체시켜주는 기능이 제네릭이다.

2) 제네릭

public class Product<K, M> {
    private K kind;
    private M model;

    public K getKind() {
        return kind;
    }

    public M getModel() {
        return model;
    }

    public void setKind(K kind) {
        this.kind = kind;
    }

    public void setModel(M model) {
        this.model = model;
    }
}

보통은 이런 식으로 다이아몬드 괄호 안에 타입 파라미터를 지정해 준다. 위의 클래스는 두 개의 결정되지 않은 타입을 KM라는 파라미터로 정한다.

public class GenericExample {
    public static void main(String[] args) {
        Product<Tv, String> product1 = new Product<>();

        product1.setKind(new Tv());
        product1.setModel("스마트 티비");

        Tv tv = product1.getKind();
        String tvModel = product1.getModel();
    }
}

그리고 사용하는 시점에서 파라미터를 구체적인 타입으로 대체(대입)하게 된다. 위의 클래스 객체 참조변수에는 Tv라는 클래스와 String이라는 참조 타입으로 대체되어 있다.

(1) 제네릭 타입? 제네릭 메소드?

간단하게 말해서, 제네릭 기능을 클래스(혹은 인터페이스)에 쓰냐 메소드에 쓰냐의 차이다.

  • 제네릭 타입 : 타입 파라미터를 가지는 클래스(혹은 인터페이스)
  • 제네릭 메소드 : 타입 파라미터를 가지는 메소드
// 제네릭 타입
public class Box<T> {
    public T content;

    public void setT(T content) {
    	this.content = content;
    }
}
// 제네릭 메소드
public static <T> Box<T> boxing(T t) {
// 접근제한자, 정적 여부, 타입 파라미터, 반환 타입, 메소드명...
    Box<T> box = new Box<>();
    box.setT(t);
    return box;
}

타입 추론과 관련해서 제네릭 메소드에서 적용되는 중요한 내용이 있다. 외부에서 메소드를 호출할 때 타입을 직접 지정하지 않아도 되는 것이다. 약간 제네릭의 유무에 따른 형변환 필요 상황과도 유사하다.

타입 파라미터를 통해 컴파일러가 메소드 호출 시, 타입 추론이 가능해지면서 저 메소드를 쓸 때 리턴값의 타입을 직접적으로 명시하지 않아도 된다.

public class GenericExample {

    // 제네릭 메소드
    public static <T> void printClassName(T value) {
        System.out.println("Class name: " + value.getClass().getSimpleName());
    }

    public static void main(String[] args) {
        // 다양한 타입을 사용하여 제네릭 메소드 호출
        printClassName("Hello");      // T는 String으로 추론됨
        printClassName(42);           // T는 Integer로 추론됨
        printClassName(3.14);         // T는 Double로 추론됨
        printClassName(new Person()); // T는 Person으로 추론됨
    }
}

물론, 명시적으로 리턴 타입을 지정하는 것도 가능

(2) 제네릭 타입의 타입 파라미터와 형변환

public class Box<T> { // Box 옆에 쓰인 <T>가 타입 파라미터
    public T content;

    public boolean compare(Box<T> otherContent) {
        boolean result = content.equals(otherContent.content);
        return result;
    }
}

사실 클래스에서의 타입 파라미터는 쉽다. 다만 타입 파라미터가 뭔지도 모르고 써왔으니 문제지.... 복습하라고 좀
다만, 이걸 외부 클래스에서 사용할 때 고려할 부분이 있는데...

  • 1번 케이스
public static void main(String[] args) {
    Box box1 = new Box();
    box1.content = "100";
	// ...
  • 2번 케이스
public static void main(String[] args) {
    Box<String> box1 = new Box<>();
    box1.content = "100";
	// ...

둘 다 사실 문법적으로 틀린 부분은 없다. 다만, 인텔리제이에서 작성을 하면 1번 케이스는 노란색 밑줄이 쭉 그인다. 문법적인 에러는 없음에도 경고를 주는 표식인데...

보면 경고가 있는데, Raw use of parameterized class 'Box' 라는 경고 문구는 Java에서 제네릭을 사용하는 클래스에 대해 타입을 명시하지 않고 사용했을 때 나타나는 경고다. 다만 프로그램을 돌리면 잘 작동되는 이유는, 타입 추론 때문에 그러하다.

public 필드인 content"100" 이라는 String으로 대입시키면서 제네릭 타입 클래스인 Box 에서 '아, 이 객체의 타입은 String` 이구나 하면서 추론을 하는 것이다.

다만, 타입 파라미터 대입을 사용하지 않고, 후에 특정 타입의 데이터로 지정하면서 객체의 타입을 추론하는 과정은 문법적으로는 에러가 없지만 컴파일러가 해당 클래스의 제네릭 타입 안전성을 확인할 수 없게 되며, 이는 코드에서 잠재적인 문제를 발생할 수 있음을 알려주는 사전 경고라고 볼 수 있겠다.

이런 식으로 타입을 명시하면 경고 라인이 사라지게 된다. 여담으로,

Box<String> box1 = new Box<String>();

이렇게 작성해도 똑같긴 하지만 Java 7 부터는 다이아몬드 연산자 <> 내부의 타입 인자를 생략할 수 있게 돼서 안 써도 되는데(인텔리제이에서도 무시시킴) 이를 다이아몬드 연산자 생략이라고 한다더라.

(3) 제네릭 메소드의 타입 파라미터와 형변환

또 다른 패키지에 Box 클래스를 제네릭 타입으로 선언했다.

public class Box<T> {
    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
}

간단한 gettersetter 가 있는 클래스인데, 외부에서 해당 클래스의 setter 를 직접 쓰는 게 귀찮(?)아서 아예 메소드를 만들어 활용해보기로 했다.

public class GenericExample {
	// 얘도 Box<T> 앞에 쓰인 <T>가 타입 파라미터
    public static <T> Box<T> boxing(T t) { 
        Box<T> box = new Box<>(); // 아까 위에서 봤던 타입 명시
        box.setT(t);
        return box;
    }

    public static void main(String[] args) {
		// ...

static인지 여부는 중요한 게 아니고, 이제 이 메소드를 써서 밑의 메인 메소드에서 활용해보자

  • 1번 케이스
public static void main(String[] args) {
	Box intBox = boxing(100);
    int intBoxValue = (int) intBox.getT(); 
	// 타입 추론 없이 Object 타입으로 지정돼서 강제 타입 캐스팅 필요
  • 2번 케이스
public static void main(String[] args) {
	Box<Integer> intBox = boxing(100);
    int intBoxValue = intBox.getT();

얘네도 둘 다 틀린 문법은 아니다. 다만 클래스의 케이스에서처럼 1번 케이스에서도 노란색 밑줄이 그어지며 경고가 뜬다.

역시나 제네릭을 사용하는 클래스에 대해 타입을 명시하지 않고 사용했을 때 나타나는 경고가 발생하게 된다. 또한 intBox는 Box intBox = boxing(100); 부분에서 타입 파라미터의 구체적 타입이 정해지지 않으면서 intBox 객체의 필드의 T 가 원시 타입인 Object 로 정해지게 되고 그 필드를 가져오는 getter 로 가져온 값을 (int)강제 캐스팅을 하면서, 선언된 int intBoxValue 에 담을 수 있게 되는 것이다.

여담으로 클래스 1번 케이스에서의 box1.content = "100"; 는 좌변은 원시 타입인 Object 이고, 우변은 String 인데, 묵시적 형변환이 이뤄지기 때문에, 제네릭과 관련된 경고와 별개로 문제가 없다. 메소드 1번 케이스에서는 좌변(int)이 우변(Object)보다 범위가 작기 때문에 명시적 형변환이 필요한 것이다.

다시 메소드의 2번 케이스로 돌아가서...

역시나 타입을 명시하면 강제 캐스팅을 할 필요도 없어지고, 노란색 줄이 없어지면서 보기에도 깔끔해진다(중요)

타입 파라미터 <T> 를 기재하지 않는 것은 해당 클래스 혹은 메소드에서 T가 뭔데...? 라는 반문을 받을 수 있는 상황인 것이다. 타입 파라미터를 기재해야 내가 해당 클래스 혹은 메소드에서 제네릭으로 타입을 선언해서 범용성을 넓히겠다는 최소한의 필수불가결 의도를 나타내는 셈이다.

3) 제네릭의 확장

(1) 타입 파라미터 제한

아무리 제네릭 기능을 활용한다고 한들, 개발할 때의 준수 규칙이나 고려 사항은 늘 다르기 마련이다. 제네릭 기능을 사용했다고 해도, 경우에 따라서는 타입 파라미터를 특정 범위 내로 구체적 타입 제한이 필요할 수도 있다.

예를 들어서, 숫자를 연산한다고 한들 자바에는 Byte, Short, Integer,Long, Double 등의 다양한 클래스가 존재한다. 이들만을 타입 파라미터에 대입해야 하는 상황일 경우, 위의 클래스들이 Number 클래스의 자식 클래스라는 점을 활용할 수 있다.

public <T extends 상위타입> 반환 타입 메소드명...

물론 상위 타입이라고 해서 클래스만 가능한 것은 아니고 인터페이스 역시 가능하며, 인터페이스를 썼다고 해서 implements를 사용하는 것이 아닌 extends 키워드를 유지한다.

public class GenericExample {
    // 타입 파라미터 T에 Number 클래스 또는 Number의 하위 클래스만 사용 가능
    public static <T extends Number> boolean compare(T t1, T t2) {
        System.out.println(
                "compare(" + t1.getClass().getSimpleName() + ", " +
                t2.getClass().getSimpleName() + ")");

        double v1 = t1.doubleValue();
        double v2 = t2.doubleValue();

        return (v1 == v2);
    }

    public static void main(String[] args) {
        boolean result1 = compare(10, 20);
        System.out.println(result1); // false

        System.out.println();

        boolean result2 = compare(4.5, 4.5);
        System.out.println(result2); // true
    }
}

(2) 와일드카드 타입 파라미터

제네릭 타입을 매개값이나 리턴 타입으로 사용할 때, 타입 파라미터로 ?(와일드카드)를 사용할 수 있다. ?의 의미는 범위 내에 있는 모든 타입으로 대체할 수 있다는 표시가 된다.

정의할 수 있는 방법은 세 가지가 있다.

// 상위 타입 및 그것을 상속받는 자손 타입들이 타입 파라미터에 들어간다
...반환 타입 메소드명(제네릭 타입<? extends 상위 타입> 변수) { ... }

// 하위 타입 및 그것에게 상속하는 조상 타입들이 타입 파라미터에 들어간다
...반환 타입 메소드명(제네릭 타입<? super 하위 타입> 변수) { ... }

// 어떤 타입이든 타입 파라미터에 들어간다
...반환 타입 메소드명(제네릭 타입<?> 변수) { ... }

간단하게 예시를 들어보면 아래에 이런 상속 관계를 지닌 다섯 개의 클래스가 있다.

Person
	│
	├── Worker
    │    
    └── Student
    		│
    		├── HighStudent
    		│    
    		└── MiddleStudent

Worker 클래스 및 상위 클래스만 타입 파라미터에 대입하고 싶으면 다음과 같이 작성한다.

...반환 타입 메소드명(제네릭 타입<? super Worker> 변수) { ... }

Student 클래스 및 하위 클래스만 타입 파라미터에 대입하고 싶으면 다음과 같이 작성한다.

...반환 타입 메소드명(제네릭 타입<? extends Student> 변수) { ... }
profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글