다음으로 살펴볼 개념은 바로 제네릭이다.
이 제네릭이라는 개념은 TypeScript에서도 자주 사용되기 때문에 생소하거나 어려운 개념은 아니었다.
다만, TypeScript는 타입을 정의하기 위한 방법이 주를 이뤘다면 자바는 제네릭을 좀 더 광범위하게 사용하고 있다는 느낌을 받았다.
제네릭에 대해서 간단하게 개념을 정리해보자 👀
제네릭은 컴파일 타임에 타입을 지정하여 타입 안정성을 보장하기 위한 방법이다.
또한, 코드 재사용성을 높이는 기능 중 하나이기도 하다.
클래스, 인터페이스, 메서드에서 사용이 가능한데, 주로 타입을 명시하지 않고도 다양한 데이터 타입을 처리하는 데 사용된다.
제네릭의 필요성을 보여주는 간단한 예제를 통해 살펴보자!
우선 예제 코드를 하나 살펴보자
Sample Code
class ManageApple { private Apple apple = new Apple(); public Apple get() { return this.apple; } public void set(Apple apple) { this.apple = apple; } } class ManagePencil { private Pencil pencil = new Pencil(); public Pencil get() { return this.pencil; } public void set(Pencil pencil) { this.pencil = pencil; } }
위 코드에서 ManageApple, ManagePencil은 각각 Apple, Pencil의 인스턴스를 관리하는 Manage역할을 수행한다.
하지만 이러한 구조의 문제점은 새로운 상품이 추가될 때마다 새로운 관리 클래스를 만들어야 하는 문제가 발생한다.
우선 타입 캐스팅을 사용하여 이 문제를 한번 해결해보자
가장 직관적인 방법으로는 바로 최상위 클래스인 Object를 활용한 방법이다.
코드를 통해 살펴보자
Sample Code
class Manage { private Object object = new Object(); public Object get() { return this.object; } public void set(Object object) { this.object = object; } }
Manage apple = new Manage(); apple.set(new Apple()); Apple newApple = (Apple) apple.get(); Manage pencil = new Manage(); pencil.set(new Pencil()); Pencil newPencil = (Pencil) pencil.get();
위처럼 Manage라는 공통 클래스를 정의하고, 인스턴스를 외부에서 주입하는 방식으로 사용할 수 있다.
하지만 이렇게 코드를 작성하게되면 명시적으로 타입 캐스팅을 수행해야 한다.
또한, 컴파일 시점에 개발자가 오류를 확인할 수 없다.
오직, 런타임에서 오류를 확인할 수 있게 된다.
이러한 불편함을 최소화시킨 방법이 바로 제네릭이다.
제네릭 타입 변수명은 개발자가 마음대로 지정할 수 있지만, 관례적으로 다음과 같이 사용한다
제네릭 타입 변수 관례
T: type
K: key
V: value
N: number
E: element
이제 이전 코드를 제네릭을 사용해서 개선해보자
제네릭을 사용하여 이전에 구현했던 Manage 클래스를 리팩토링하면 다음과 같다.
Sample Code
class Manage<T> { private T t; public T get() { return this.t; } public void set(T t) { this.t = t; } }
필자는 맨 처음 제네릭을 접했을 때 이게 무슨 문법인가 싶었는데, 간단하게 생각하면 이해하기 편했다.
외부에서 Manage 클래스를 사용할 때 <>을 사용해서 타입을 지정해준다.
이때, 외부에서 정의된 타입이 T라는 변수에 대입된다고 생각하면 간단하다.
따라서, 사용 예시는 다음 코드와 같다.
Sample Code
Manage<Apple> apple = new Manage<>(); apple.set(new Apple()); Apple newApple = apple.get(); Manage<Pencil> pencil = new Manage<>(); pencil.set(new Pencil()); Pencil newPencil = pencil.get();
참고로 맨 첫줄의 코드는 Manage<Apple> apple = new Manage<Apple>()
과 동일하다.
(new 생성자를 사용하는 부분에서는 자동으로 타입추론이 가능하기 때문에 생략이 가능하다!)
보다시피 불필요한 타입 캐스팅이 필요없다.
왜냐하면 인스턴스를 생성하는 시점에 그에 맞는 타입을 지정해서 T에 넘겨주기 때문이다.
마찬가지로 제네릭은 클래스 뿐만 아니라 메서드에서도 사용할 수 있다.
간단한 예제를 살펴보자
Sample Code
class GenericMethod { public <T> T method1(T t) { return t; } public <T> boolean method2(T t1, T t2) { return t1 == t2; } public <K, V> void method3(K k, V v) { System.out.println(k + " : " + v); } }
// 제네릭 메서드 사용 GenericMethod gm = new GenericMethod(); gm.<String>method1("안녕하세요"); gm.<Integer>method2(1, 2); gm.<String, Integer>method3("첫번째", 1);
외부에서 지정해주는 타입 T
, V
가 대입된다고 생각하자
기본적으로 타입 추론이 되기 때문에 실제 사용예시는 다음과 같다.
Sample Code
GenericMethod gm = new GenericMethod(); gm.method1("안녕하세요"); // String으로 추론 gm.method2(1, 2); // Integer로 추론 gm.method3("첫번째", 1); // String, Integer로 추론 gm.method3(100, "숫자") // Integer, String으로 추론
즉, method3의 사용 예시를 보면 알다시피, 자동으로 타입이 추론되기 때문에 개발자는 자유롭게 사용할 수 있다.
Java를 이용한 개발을 많이 해본 것은 아니지만, 지금까지 해보니까 아마 제네릭은 타입을 제한하는 경우 많이 사용되는 것 같다.
예제 코드를 살펴보자
Sample Code
class Fruit { public void print() { System.out.println("과일"); } } class Apple extends Fruit {} class Pencil {} class Goods<T extends Fruit> { private T t; public void set(T t) { t.print(); // Fruit의 메서드 사용 가능 this.t = t; } }
// 사용 예시 Goods<Apple> goods1 = new Goods<>(); Goods<Pencil> goods2 = new Goods<>(); // 컴파일 에러 발생
여기서 중요한 점은 타입을 제한함으로써 얻는 이점이다.
Goods 클래스에서 t.print()를 호출할 수 있는 이유는 T가 반드시 Fruit의 하위 타입이라는 것을 컴파일러가 알고 있기 때문이다.
즉, 타입 제한을 통해 해당 타입이 가진 메서드들을 안전하게 사용할 수 있게 된다!
이번 기회에 자바로 조금 개발해보니, 이런 타입 제한을 통해 특정 기능을 가진 객체들만 처리하도록 하는 경우가 많은 것 같다.
이것이 제네릭 타입 제한의 실용적인 활용법이라고 할 수 있을 것 같다.
제네릭 클래스도 상속이 가능하다.
상속은 이전 포스팅에서 살펴봤으니 예제 코드만 첨부하고 넘어가려고 한다.
이전 포스팅에서 작성한 클래스가 제네릭으로 대치되었다고 생각해도 무방하다!
Sample Code
class Parent<T> { private T t; public T getT() { return t; } public void setT(T t) { this.t = t; } } class Child1<T> extends Parent<T> {} class Child2<T, V> extends Parent<T> { private V v; public V getV() { return v; } public void setV(V v) { this.v = v; } }
// 사용 예시 Parent<String> p = new Parent<>(); p.setT("부모 제네릭 클래스"); System.out.println(p.getT()); // "부모 제네릭 클래스" 출력 Child1<String> c1 = new Child1<>(); c1.setT("자식 클래스 1"); System.out.println(c1.getT()); // "자식 클래스 1" 출력 // Child2 사용 Child2<String, Integer> c2 = new Child2<>(); c2.setT("자식 클래스 2"); c2.setV(100); System.out.println(c2.getT()); // "자식 클래스 2" 출력 System.out.println(c2.getV()); // 100 출력
Child2를 보면 setT()
, setV
로 필드를 세팅하고 있다.
여기서 setT()
는 Parent의 메서드인데, Child2 인스턴스를 생성할 때, <String, Integer>
로 선언한다.
여기서 T에는 String이 대입되어 Parent의 타입(T)도 String이 된다!
이번 포스팅에서는 자바의 제네릭에 대해 알아봤다.
제네릭은 타입 안정성을 보장하면서도 코드의 재사용성을 높여주는 중요한 기능이다.
요즘 자바를 다시 배우면서 이전에 대충 넘어갔던 부분을 강제로 공부하는 느낌이다..
자주 사용되고 기본적인 내용이니 알아두자 👊