자바와 타입스크립트에는 Generics(지네릭 혹은 제네릭)이 있다. 사실 이 두 언어 뿐만 아니라 C++, C# 등의 언어에도 존재하지만 나는 자바와 타입스크립트를 공부하고 있으므로 두 언어에서의 Generics만 언급하겠다.
TypeScript Generics 타입의 사용법에 대하여 작성한 글이 이미 있다. 이 글에서는 자바의 Generics에 맞춰 TypeScript와 비교해보는 방식으로 글을 진행하고자 한다.
Generics는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능이다. (참조: JAVA의 정석 )
단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 작성할 수 있습니다. 사용자는 제네릭을 통해 여러 타입의 컴포넌트나 자신만의 타입을 사용할 수 있습니다. ( 참조 : TypeScript 공식문서 )
개인적으로 JAVA의 정석 책을 인용한 정의가 쉽게 와닿는다.
쉽게 말하자면 객체 혹은 클래스를 선언할 때가 아닌, 생성할 때 타입이 결정되는 것이다.
다양한 타입의 객체를 다룬다는 것은 예를 들어 다음과 같다.
class GenericEx{
private Object item;
public void setItem(Object item) { this.item = item; }
public Object getItem() { return item; }
}
class GenericEx{
private _item:Object; // any 타입도 마찬가지
contructor(){}
set item(item:Object){ this._item = item };
get item(){ return this._item; }
}
자바와 타입스크립트는 모든 타입이 Object 타입에 상속된다.
정확히 말하자면 자바는 클래스 기반 언어이므로 클래스가 상속(extends
)되는 것이고 타입스크립트는 prototype
체이닝의 마지막에 Object
객체가 있다는 것이다.
위 예시코드의 핵심은 Object라는 타입에는 어떤 타입이든 들어갈 수 있다는 것이다. 이러한 경우 타입안정성이 떨어지고, 일일이 타입체크를 해주거나 형변환을 해주어야할 것이다. 이 귀찮음을 위해 탄생한 것이 바로 Generics이다.
Generics를 사용하면 다음과 같은 장점이 있다.
- 타입 안정성을 제공한다.
- 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
자바의 Generics 타입은 클래스와 메서드에 선언할 수 있다. 하지만 타입스크립트에는 메서드의 개념이 없기 때문에 함수라고 생각하면 되겠다.
위에서 작성한 자바 예시 코드를 Generics 타입으로 수정해보겠다.
class GenericEx<T>{
private T item;
public void setItem(T item) { this.item = item; }
public T getItem() { return item; }
}
public class Test {
public static void main(String[] args) {
GenericEx<String> genericEx = new GenericEx<String>();
genericsEx.setItem("hello"); // ok
genericsEx.setItem(1); // 컴파일 에러! String 타입이 아님
}
}
Object
타입을 모두 <T>
로 바꿔주었다. 바뀌었지만 하는 역할은 똑같다. 어떤 타입이 오든 T
자리에 그 타입을 넣고 객체를 생성해준다.
또한, 인스턴스를 생성할 때 생성자에는 Generics에 들어갈 타입을 생략할 수 있다.
여기서 T는 Type을 의미하며, JAVA API문서를 보면 E (Element)도 자주볼 수 있는데, 이는 얼마든지 커스터마이징할 수 있다. 하지만 JAVA API에서 사용하는 T와 E는 나름의 의미에 따라 다르니 따로 공부해보길 바란다.
GenericEx<String> genericEx = new GenericEx<>();
만약 String
을 넣고 GenericEx
인스턴스를 생성하여 컴파일하면 어떻게 되는 지 보도록 하자.
class GenericEx{
private String item;
public void setItem(String item) { this.item = item; }
public String getItem() { return item; }
}
자바를 컴파일하면 Generics 타입은 생략되고 위와 같은 인스턴스가 생성된다.
타입스크립트 코드도 자바 코드와 비슷하게 생겼다.
class GenericEx<T> {
private _item: T;
contructor() {};
set item(item:T){ this._item = item };
get item(){ return this._item; }
}
const generic = new GenericEx<String>; // String으로 생성
generic.item = "hello"; // ok
generic.item = 1; // 컴파일 에러! String타입만 넣을 수 있음
타입스크립트 generics 클래스도 자바 코드와 매우 흡사하다. 타입스크립트의 getter
와 setter
, 생성자 사용법을 제외하면 Generics
그 자체의 사용법은 사실 상 동일하다.
static은 멤버 변수나 메서드(함수)를 정적으로 만들어 컴파일 시에 메모리에 올라간다. 이 말은 즉슨, Generics로 변수나 메서드(함수)를 static으로 선언한다면 생성 하기 전부터 타입도 모른 채 메모리에 올라간다는 것이다. 말이 안되는 것이다. 자바와 타입스크립트 모두 동일하게 컴파일 에러가 발생한다.
class GenericEx<T>{
private static T item; // 컴파일 에러 발생!!
public void setItem(T item) { this.item = item; }
public T getItem() { return item; }
}
class GenericEx<T> {
private static _item: T; // 컴파일 에러 발생!!
contructor() {};
set item(item:T){ this._item = item };
get item(){ return this._item; }
}
먼저 아래의 예시 코드를 보자.
class GenericEx<T>{
T[] itemArr;
T[] toArray(){
T[] tmpArr = new T[itemArr.length];
return tmpArr;
}
}
class GenericEx<T> {
private itemArr: T[];
contructor() {};
toArray():T[]{
const tmpArr = new T[this.itemArr.length];
return tmpArr
}
}
각각의 코드에서 toArray()
는 T
타입의 배열을 생성한다.
이는 컴파일에러가 발생한다. 이유는 new
연산자 때문인데, new
연산자는 컴파일 시점에 타입 T가 무엇인지 정확히 알아야한다. 위 코드는 컴파일 시점에 어떤 타입이 T가 될 지 알수가 없다. ( 그러려고 Generics를 쓰는 것이므로 ) Generics는 타입으로 참조되는 것이지 값으로 사용하기 위한 타입이 아니다.
class Generics<T> {}
의 경우
Generics
는 원시타입, Generics<T>
는 Generics 클래스라고 부른다.
원시타입에 대해 자바와 타입스크립트에서 다른 점이 있다면
GenericEx genericEx1 = new GenericEx(); // Generics 없음
genericEx1.setItem("hello");
System.out.println(genericEx1.getType()); // java.lang.String
let nonGenerics: GenericEx; // 컴파일 에러!!
일반적으로 Generics 타입 사이의 형변환은 이루어지지 않는다.
GenericEx<Apple> appleEx = new Generics<Apple>(); // ok
GenericEx<Grape> grapeEx = new Generics<Apple>(); // 컴파일 에러!!
이는 두 Apple과 Grape가 상속관계여도 마찬가지이다. 두 클래스 모두 한 클래스에 상속받는 경우도 마찬가지이다.
// Apple 클래스가 Fruit 클래스를 상속받는다고 가정
GenericEx<Fruit> fruitEx = new Generics<Apple>(); // 컴파일 에러!!
let genericApple:GenericEx<Apple>;
let genericGrape:GenericEx<Grape>;
genericApple = new GenericEx<Apple>;// ok
genericGrape = new GenericEx<Apple>;// 컴파일 에러
타입스크립트에서 만일 Apple 클래스와 Grape 클래스 (클래스라고 하지만 사실 객체이다. 자바스크립트는 자바같은 클래스가 없다. 흉내만 냈을 뿐..)
의 메서드의 형태가 동일할 경우 Generics의 사용 목적을 잃는다.
class GenericEx<T> {
private _item: T;
}
class Apple{
print(): String{
return "Apple";
}
}
class Grape{
print(): String{
return "Grape";
}
}
let genericApple:GenericEx<Apple>;
let genericGrape:GenericEx<Grape>;
genericApple = new GenericEx<Apple>; // ok
genericGrape = new GenericEx<Apple>; // ok
가장 마지막 라인을 보면 GenericEx<Grape>
타입으로 선언된 변수에 GenericEx<Apple>
객체를 생성하여 넣어도 컴파일 에러가 발생하지 않는다. 이는 타입스크립트의 컴파일 방식이 이유이다.
타입 스크립트의 컴파일러는 객체(클래스)간의 호환성만 체크한다. 자바스크립트로 컴파일된 코드를 보면 이해가 쉬울 것이다.
var Apple = /** @class */ (function () {
function Apple() { // 생성자
}
Apple.prototype.print = function () { // print()
return "Apple";
};
return Apple;
}());
var Grape = /** @class */ (function () {
function Grape() { // 생성자
}
Grape.prototype.print = function () { // print()
return "Grape";
};
return Grape;
}());
컴파일된 Apple
과 Grape
를 보면 구조적으로 거의 똑같다. 생성자가 있고, print()
가 선언되었다. Apple, Grape의 이름만 다를 뿐 형식은 똑같다. 타입스크립트는 이렇 듯 형식이 동일하면 컴파일 에러를 발생시키지 않는다.
그렇다면 멤버변수를 넣으면 어떨까?
class Apple{
a:string;
b="hello";
constructor(){
this.a = "type";
}
print(): String{
return "Apple";
}
}
class Grape{
print(): String{
return "Grape";
}
}
let genericApple:GenericEx<Apple>;
let genericGrape:GenericEx<Grape>;
genericApple = new GenericEx<Apple>; // ok
genericGrape = new GenericEx<Apple>; // ok
Grape
와 다르게 변수 a,와 b를 선언해주었지만 Generics에 대한 컴파일 에러는 발생하지 않는다. 왜 그럴까? 이유는 아래의 컴파일된 자바스크립트 코드를 보면 알 수 있다.
var Apple = /** @class */ (function () {
function Apple() {
this.b = "hello";
this.a = "type";
}
Apple.prototype.print = function () {
return "Apple";
};
return Apple;
}());
var Grape = /** @class */ (function () {
function Grape() {
}
Grape.prototype.print = function () {
return "Grape";
};
return Grape;
}());
분명 타입스크립트에서 생성자가 아니라 멤버 변수로 a와 b를 선언했지만 컴파일된 코드를 보니 생성자에 들어가있다. 그렇기 때문에 생성자
, print()
의 구조는 변함이 없다.
💡 위와 같은 경우에는 예기치 못한 상황들이 발생할 수 있으므로 최대한 피해서 개발하도록 하자!
만약 Apple
과 Grape
등 Fruit
에 상속된 클래스만을 Generics 인수로 받고 싶을 때가 있다. 이 때는 extends
를 사용하여 Generics를 제한할 수 있다.
// Apple과 Grape가 Fruit에 상속을 받는다고 가정
class GenericsEx<T extends Fruit>{}
상속 뿐만 아니라 interface 구현관계도 extends
를 사용하여 Generics의 인자를 제한할 수 있다.
interface GerericInterface{}
class Apple implements GerericInterface{}
class Grape {}
class GenericEx<T extends GerericInterface> {}
public class Test {
public static void main(String[] args) {
GenericEx<Apple> apple = new GenericEx<>(); // ok -> Apple은 GenericInterface의 구현체이다!
GenericEx<Grape> grape = new GenericEx<>(); // 컴파일 에러!!
}
}
interface GenericInterface{
attr:String;
}
class GenericEx<T extends GenericInterface> {
private _item: T;
}
class Apple implements GenericInterface{
attr:String;
}
class Grape extends Fruit{}
let genericApple:GenericEx<Apple>; // ok
let genericGrape:GenericEx<Grape>; // 컴파일에러!
🙏 중요한 점 !!
여기서GenericInterface
의 속성(변수나 함수)를 클래스가 모두 포함하는 경우 그 클래스가 Interface를 구현한다고 컴파일러가 이해하기 때문에 컴파일 에러가 발생하지 않는다.
interface GenericInterface{
attr:String;
}
class GenericEx<T extends GenericInterface> {
private _item: T;
}
class Apple implements GenericInterface{
attr:String;
}
class Grape {
attr:String;
}
let genericGrape:GenericEx<Grape>; // ok.
위 코드에서 Grape 클래스는 GenericInterface를 구현하고 있지 않지만 속성이 모두 동일하므로 구현된 것 처럼 컴파일에러가 발생하지 않는다.
자바스크립트와 class와 타입스크립트의 interface는 다른 언어에서 사용하는 실제 class와 interface를 도입한 것이 아니다. 문법만 추가되었을 뿐이지 실제로는 prototype을 기반으로 상속이 이루어진다. 이런 점들은 그저 흉내낸 것에 불과하기 때문에 발생하는 한계로 느껴져서 아쉽다. 조심해서 사용할 필요가 있다.
상속은 한 클래스에 하나만 가능하므로 상속관계의 클래스 하나와 여러 인터페이스를 충족하는 클래스를 Generics의 인자로 제한할 수 있다.
class 클래스명<T extends 부모 & 인터페이스>
로 표현한다.
interface GerericInterface{}
class Fruit{ }
class Apple extends Fruit implements GerericInterface{ }
class Grape extends Fruit {}
class GenericEx<T extends Fruit & GerericInterface> {
private T item;
}
public class Test {
public static void main(String[] args) {
GenericEx<Apple> apple = new GenericEx<>(); // ok
GenericEx<Grape> grape = new GenericEx<>(); // 컴파일 에러
}
}
class Fruit{
eat():boolean{return true;}
}
interface GenericInterface{
run():void
}
class GenericEx<T extends Fruit & GenericInterface> {
private _item: T;
}
class Apple extends Fruit implements GenericInterface{
run(): void {}
}
class Grape extends Fruit{}
let genericApple:GenericEx<Apple>; // ok
let genericGrape:GenericEx<Grape>; // 컴파일 에러
타입스크립트는 클래스 기반 언어이기 때문에 자바에서의 메서드와 사용방법이 다르다.
기본적인 사용방법은 다음과 같다.
Generics가 메서드의 타입 앞에 위치한다. 사용할 때도 마찬가지이다.
class MethodEx{
static <T> void method(T item){System.out.println(item+가 들어왔습니다.);}
}
...main method
MethodEx.<String>method("Hello");
// Hello가 들어왔습니다
사용 시 Generics 메서드에 대입된 타입은 대부분의 경우 컴파일러가 추정할 수 있기 때문에 생략할 수 있다.
MethodEx.method("Hello");
class MethodEx{
static void method(GenericEx<Apple> item){}
}
method()
의 매개변수로 GenericEx<Apple>
타입만 올 수 있다. 하지만 나는 Fruit과 상속관계에 있는 Grape 클래스도 올 수 있게 하고 싶다.
하지만 알다시피 Generics는 상속관계에 있다하더라도 형변환이 이루어지지 않는다. Grape 클래스를 Generics로 사용하기 위해서는 다음과 같이 코드를 변경해주어야 한다.
class MethodEx{
static void method(GenericEx<Grape> item){}
}
이렇게 변경하면 이제 Apple
은 사용하지 못한다. Fruit
클래스를 괄호 사이에에 넣으면 될까? 안된다. 다시 말하지만 Generics끼리는 상속에 얽히지 않는다. 그럼 Fruit
에 상속받는 Apple
과 Grape
를 받고싶으면 어떻게 해야할까?
그럴 때 사용하는 것이 와일드 카드이다.
class MethodEx{
static void method(GenericEx<? extends Fruit> item){}
}
위 코드는 와일드카드의 상한을 제한하는 방법이다. 즉 Fruit
과 그 자손들만 가능하다.
class MethodEx{
static void method(GenericEx<? super Fruit> item){}
}
위 코드는 와일드카드의 하한을 제한하는 방법이다. 즉 Fruit
과 그 조상들만가능하다.
class MethodEx{
static void method(GenericEx<?> item){}
}
이는 사실 상 <? extends Object>
와 동일하며 제한없이 모든 타입이 가능하다. 하지만 이 방법은 타입 안정성이 매우 떨어지므로 사용하지 않는 것이 좋다.
자바는 타입 앞에 Generics를 두는 반면 타입스크립트는 함수 이름 뒤에 Generics를 작성한다.
function identity<T>(){
let attr:T;
}
Generics를 통해 매개변수의 타입, 반환타입도 Generics로 지정할 수 있다.
function identity<T>(args:T):T{
let attr:T;
attr = args;
return args;
}
console.log(identity<String>("Hello")); // Hello
💡 주의할 점
만약 T가 배열로 들어와서length
등의 배열 메서드를 사용해야할 때 문제가 발생한다. 물론 다른 타입들과 마찬가지고 타입 혹은 인터페이스를 사용하여 사용하고자 하는 타입의 속성을 미리 선언해주어도 되지만, 배열의 경우 조금 더 간단하다.
function identity<T>(args:T[]):Number{
let attr:T[];
attr = args;
return args.length;
}
이런 식으로 배열로 타입 매개변수를 받아준다면 해당 타입의 배열이 생성되어 컴파일 에러 없이 배열 메서드를 사용할 수 있다.
작성하다보니 생각보다 내용이 많아 글이 매우 길어졌다. 하지만 축약하고 축약한 정보가 이 정도이다. 그 만큼 어렵기도하고 중요한 문법이니 꼭 더 공부하고 숙지하자! ( 나 자신아 )