TypeScript #5 클래스와 인터페이스 Part2
Prog. Langs & Tools/TypeScript

TypeScript #5 클래스와 인터페이스 Part2

지난번 포스팅에 이어서 타입스크립트의 클래스와 인터페이스에 대해서 조금 더 정리해 보려고 한다. ㅎㅎ

오버라이딩(Overriding)

오버라이딩은 부모 클래스에 정의된 메서드를 자식 클래스에서 새로 구현하는 것을 일컫는 개념이다. 부모 클래스에서 오버라이딩을 당하는(?) 메서드를 오버라이든 메서드 라고 하고, 자식 클래스에서 오버라이딩된 메서드를 오버라이딩 메서드라고 했을 때, 오버라이딩이 이루어지기 위해서는 다음 두 가지의 조건을 만족해야 한다. 

조건 1. 오버라이든 메서드의 매개변수 타입은 오버라이딩 메서드의 매개변수 타입과 같거나 상위 타입이어야 한다. (단, 오버라이딩 메서드의 매개변수 타입이 Any 이면 예외)

조건2. 오버라이든 메서드의 매개변수 갯수가 오버라이딩 메서드의 매개변수 갯수와 같거나 많아야 한다. (단, 조건 1이 성립하는 전제가 있어야 함)

아래는 조건1과 조건2를 모두 만족하는 예제 코드이다.

class Bird {
    constructor() { }
    flight(kmDistance: number = 0, kmSpeed: number = 0) {
    	console.log(`새가 ${kmDistance}km를 ${kmSpeed}km의 속도로 비행했습니다.`);
    }
}

class Eagle extends Bird {
    constructor() {
    	super();
    }
    
    flight(kmDistance2: number = 0) {
    	console.log(`독수리가 ${kmDistance}km를 비행했습니다.`)
    }
}

let bird = new Bird();
bird.flight(1000, 100); // 새가 1000km를 100km의 속도로 비행했습니다.

let eagle = new Eagle();
eagle.flight(); // 새가 0km를 비행했습니다.
eagle.flight(1000); // 새가 1000km를 비행했습니다.

 


 

오버로딩(Overloading)

메서드 오버로딩은 메서드의 이름이 같지만 매개변수의 타입과 개수를 다르게 정의하는 방법이다. 클래스의 상속을 고려해 오버로딩을 구현하려면 부모 클래스에 상위 타입을 가지는 오버라이든 메서드를 선언해 두고 파생 클래스에서 오버라이딩 메서드를 선언해 구현할 수 있다. 이 때 오버라이딩 메서드가 오버로딩을 수행하려면 오버라이딩 메서드 위에 오버로드를 추가하면 된다. 예제 코드를 통해 확인해 본다.

class SingleTypeChecker {
    constructor() { }
    typeCheck(value: string): void {
    	console.log(`${typeof value} : ${value}`);
    }
}

class UnionTypeChecker extends SingleTypeChecker {
    constructor() { super(); }
    
    typeCheck(value: number): void;
    typeCheck(value: string): void;
    typeCheck(value: any): void {
    	if (typeof value === "number") {
        	console.log("숫자 : ", value);
        } else if (typeof value === "string") {
        	console.log("문자열 : ", value);
        } else {
        	console.log("기타 : ", value);
        }
    }
}

let unionTypeChecker = new UnionTypeChecker();
unionTypeChecker.typeCheck(123); // 숫자 : 123
unionTypeChecker.typeCheck("happy"); // 문자열 : happy
// 에러
// unionTypeChecker.typeCheck(true);

오버로드는 함수 이름은 같지만, 매개변수 선언 형태가 다른 특성을 가지고 있다. 위 예제에서는 any 타입에 제약을 가해 number와 string만 받을 수 있도록 typeCheck 메서드를 재정의했다.

인터페이스를 이용해서 오버로딩을 구현할 수도 있다. 인터페이스를 이용해 오버로딩을 하려면 인터페이스에 오버로딩할 기본 메서드를 선언해 준다. 그리고 인터페이스를 구현할 클래스에서 기본 메서드를 구현해 준다. 아래의 예제를 보자.

interface IPoint {
    getX(x: any): any;
}

class Point implements IPoint {
    getX(x?: number | string): any {
    	if (typeof x === "number") {
        	return x;
        } else if (typeof x === "string") {
        	return x;
        }
    }
}

let p = new Point();
console.log(p.getX()); // undefined
console.log(p.getX("hello")); // hello
console.log(p.getX(123)); // 123

인터페이스를 사용하면 선언과 구현을 분리할 수 있고 구현부의 구조를 강제할 수 있다. 이 점에서 로직과 구조가 섞여 있는 클래스를 상속해 오버로딩하는 것 보다 구조만을 포함하고 있는 인터페이스를 이용하는 것이 복잡도가 낮다.

 


 

다형성(polymorphism)

다형성은 '여러 모양'을 의미하는 그리스 단어이고, 다형성에서 형은 타입을 의미한다. 프로그래밍 언어에서 다형성이란, 여러 타입을 받아들임으로써 여러 형태를 가지는 것을 의미한다. 타입스크립트에서 살펴볼 수 있는 다형성의 예로는 다음 세 가지가 있다.

클래스의 다형성

부모 클래스 A를 자식 클래스 B가 상속할 때 부모 클래스 A가 변수의 타입으로 지정되면 자식 클래스의 객체에 할당될 수 있다. 이 때 부모 클래스 A는 부모 클래스 A를 상속하는 어떤 자식 클래스의 타입이라도 받아들일 수 있는 다형 타입이 되고, 다형성을 띠게 한다.

class Planet {
    public diameter: number;
    protected isTransduction: boolean = true;
    
    getIsTransduction(): boolean {
    	return this.isTransduction;
    }
    
    stopTransduction(): void {
    	console.log("stop1");
        this.isTransduction = false;
    }
}

class Earth extends Planet {
    public features: string[] = ["soil", "water", "oxyzen"];
    stopTransduction(): void {
    	console.log("stop2");
        this.isTransduction = false;
    }
}

let earth: Planet = new Earth();
earth.diameter = 12656.2;
console.log("1번 : " + earth.diameter); // 1번 : 12656.2
console.log("2번 : " + earth.getIsTransduction()); // 2번 : true
earth.stopTransduction(); // stop2
console.log("3번 : " + earth.getIsTransduction()); // 3번 : false
// 에러
// console.log(earth.features);

예제를 보면 부모 클래스인 Planet을 자식 클래스인 Earth가 상속하고 있다. 이러한 경우 실제 동작은 부모 클래스를 기준으로 실행이 된다. 따라서 객체 참조변수 earth는 getIsTransduction()에는 접근할 수 있지만, features에는 접근할 수 없다.

여기서 유의해서 볼 부분은 stopTransduction() 메서드가 자식 클래스에 오버라이딩 되어 있다는 점이다. 이 경우는 자식 클래스의 메서드가 실행된다. 왜냐하면 오버라이든 메서드보다 오버라이딩 메서드가 우선으로 호출되기 때문이다. 이처럼 런타임 시에 호출될 메서드가 결정되는 특성을 런타임 다형성이라고 한다. 런타임 다형성의 대표적인 예로 덕 타이핑(duck typing)이 있다.

인터페이스의 다형성

인터페이스 A가 있고 인터페이스 A를 구현한 클래스 B가 있을 때 클래스 B가 인터페이스 A 타입으로 지정된 변수에 할당될 때 생기는 다형성을 의미한다.

이 경우도 1번에서 살펴본 클래스의 다형성과 비슷하다. 인터페이스를 구현한 클래스를 가지고 객체를 생성하면, 해당 객체 참조변수는 인터페이스에 정의된 멤버 변수, 메서드 등에 접근할 수는 있지만, 구현 클래스에 추가된 멤버 변수나 클래스에는 접근할 수가 없다.

매개변수의 다형성

메서드의 매개변수가 여러 타입을 받아들이면서(유니언 타입, 인터페이스 타입 등) 생기는 다형성을 말한다. 매개변수의 타입이 여러 서브 타입을 받아들아면 해당 매개변수의 타입이 서브 타입 다형성이 된다. 반대로 자바스크립트의 매개변수처럼 타입을 지정하지 않고 여러 타입을 받아들이면 매개변수 다형성이 된다.

 


 

Getter와 Setter

자바스크립트에서는 객체의 멤버에 접근할 수 있는 방법으로 ES6의 getter와 setter를 지원한다. getter는 일반적으로 접근자(accessor)라 하고, setter는 설정자(mutation)라 한다. 타입스크립트에서는 클래스 내에 get과 set 키워드를 통해 getter와 setter를 선언할 수 있다. 값을 설정하거나 읽을 때 로직을 추가하고 싶다면 get/set 키워드로 접근자와 설정자를 추가해 줄 수 있다. 다음 예제 코드를 확인해 보자.

class Student {
    private studentName: string;
    private studentBirthYear: number;
    
    get name(): string {
    	return this.studentName;
    }
    
    set name(name: string) {
    	// 포함되면 0, 포함되지 않으면 -1
        if (name.indexOf("happy") !== 0) {
            this.studentName = name;
        }
    }
    
    get birthYear(): number {
    	return this.studentBirthYear;
    }
    
    set birthYear(year: number) {
    	if (year <= 2000) {
            this.studentBirthYear = year;
        }
    }
}

let student = new Student();

student.birthYear = 2001; // (set)
console.log(student.birthYear); // undefined (get)
student.birthYear = 2000; // (set)
console.log(student.birthYear); // 2000 (get)

student.name = "happy"; // (set)
console.log(student.name); // undefined (get)
student.name = "lucky"; // (set)
console.log(student.name); // lucky (get)

예제에서 클래스 멤버 변수는 객체를 통해 접근할 수 없도록 private으로 선언되어 있다. 이들 멤버 변수에 접근하려면 Get 접근자와 Set 접근자를 이용해야 한다. Get 접근자는 name과 birthYear가 있다. 

 


 

정적 변수와 정적 메서드

타입스크립트에서는 static 키워드를 지원한다. static 키워드는 클래스에 정적 멤버 변수나 정적 메서드 등을 선언할 때 사용할 수 있는데 객체 생성 없이 바로 접근이 가능하므로 메모리 절약 효과가 있다. 아래 예제를 보자.

class Circle {
    private static pi: number = 3.14;
    static circleArea: number = 0;
    
    static getArea(radius: number) {
        this.circleArea = radius * radius * Circle.pi;
        return this.circleArea;
    }
    
    static set area(pArea: number) {
    	Circle.circleArea = pArea;
    }
    
    get area(): number {
        return Circle.circleArea;
    }
}

// 에러
// console.log(Circle.pi);
console.log(Circle.getArea(3)); // 28.26

// 정적 멤버 변수인 Circle에 값 설정
Circle.area = 100;

let circle = new Circle();
// 정적 멤버 변수인 circle을 통해 클래스와 객체 간의 값을 공유함
console.log(circle.area); // 100

첫 번째 출력 결과는 정적 멤버 메서드 getArea()의 반환값이다. getArea() 메서드 내부는 원의 넓이 계산 후 결괏값을 정적 멤버 변수인 circleArea에 할당하고 있다. 두 번째 출력 결과는 정적 멤버 변수인 circleArea를 통해 클래스와 객체 간에 값을 공유할 수 있음을 보여준다. 클래스와 객체 간에 공통으로 사용되어야 할 멤버가 있다면 static으로 선언할 수 있다.

static 키워드는 클래스에 선언된 멤버 변수를 객체 생성 없이 접근할 수 있게 해주는 장점이 있다. 이 경우 단일 상태를 관리하지만 외부에 변수를 둘 수 없는 문제점이 있다. 반드시 클래스를 통해 정적 멤버에 접근해야 하기 때문이다. 외부에 변수를 두면서 프로그램 단위에서 유일한 객체를 유지할 수 있게 하려면 싱글턴 패턴(Singleton Pattern)을 도입해야 한다. 싱글턴 패턴은 유일한 객체를 생성해 공유해서 사용하는 방식이다. 싱글턴 패턴에는 크게 두 가지가 있다. 부지런한 초기화(eager initialization)와 게으른 초기화(lazy initialization).

부지런한 초기화

부지런한 초기화는 프로그램이 구동될 때 초기화가 일어나고 공개된 정적 메서드를 통해 생성된 객체를 얻는다. 싱글턴 객체는 사용자가 정의한 임의의 변수에 할당돼 접근할 수 있다.

class EagerLogger {
    
    // #1 부지런한 초기화
    private static uniqueObject: EagerLogger = new EagerLogger();
    
    // #2 private을 붙여 객체로 생성되지 않도록 함
    private EagerLogger() { }
    
    // #3 static을 붙여 외부 접근을 가능케 함
    public static getLogger(): EagerLogger {
        return this.uniqueObject;
    }
    
    // #4 정보 로그를 출력
    public info(message: string) {
        console.log(`[info] ${message}`);
    }
    
    // #5 경고 로그를 출력
    public warning(message: string) {
        console.log(`[warn] ${message}`);
    }
}

// #6 유일한 객체를 얻고 메서드(info, warning)를 사용함
let eagerLogger: EagerLogger = EagerLogger.getLogger();
let eagerLogger2: EagerLogger = EagerLogger.getLogger();

eagerLogger.info("1번 : 정보 log"); // [info] 1번 : 정보 log
eagerLogger.warning("2번 : 경고 log"); // [warn] 2번 : 경고 log
eagerLogger.info(`3번 : ${eagerLogger === eagerLogger2}`); // [info] 3번 : true

게으른 초기화

게으른 초기화는 프로그램이 구동될 때 초기화되지 않지만 공개된 정적 메서드를 호출하는 시점에 객체를 생성한다. 싱글턴 객체는 변수에 할당될 수 있다.

class LazeLogger {
    
    // #1 싱글턴 객체를 담는 정적 멤버 변수를 선언함
    private static uniqueObject: LazeLogger;
    
    // #2 private을 붙여 객체로 생성되지 않도록 함
    private LazeLogger() { }
    
    // #3 게으른 초기화를 진행
    public static getLogger(): LazeLogger {
        // #3-1 생성된 객체가 없으면 초기화
        if (this.uniqueObject == null) {
            this.uniqueObject = new LazeObject();
        }
        return this.uniqueObject;
        
    }
    
    // #4 정보 로그를 출력
    public info(message: string) {
        console.log(`[info] ${message}`);
    }
    
    // #5 경고 로그를 출력
    public warning(message: string) {
        console.log(`[warn] ${message}`);
    }
}

// #6 싱글턴 객체를 얻고 메서드(info, warning)를 사용함
let lazeLogger: LazeLogger = LazeLogger.getLogger();
let lazeLogger2: LazeLogger = LazeLogger.getLogger();

lazeLogger.info("1번 : 정보 log"); // [info] 1번 : 정보 log
lazeLogger.warning("2번 : 경고 log"); // [warn] 2번 : 경고 log
lazeLogger.info(`3번 : ${lazeLogger === lazeLogger}`); // [info] 3번 : true

 


 

readonly 제한자

readonly는 타입스크립트 2.0부터 지원하는 제한자이다. readonly가 선언된 변수는 초기화되면 재할당이 불가능하다. const와 readonly의 공통점은 상수 선언이 가능하다는 점이다. 차이점은 다음과 같다.

  1. const는 초기화가 필수이지만, readonly는 초기화가 선택이다.
  2. const는 값 재할당이 불가능하지만, readonly는 가능하다.
  3. const는 선언 가능한 대상이 전역 변수, 클래스 메서드의 변수, 함수의 변수 등이 있고 readonly는 선언 가능한 대상이 인터페이스의 멤버 변수, 클래스의 멤버 변수, 객체 리터럴의 속성, 새롭게 정의하는 타입 등이 있다.
  4. const는 상수로 사용하고, readonly는 읽기 전용 속성을 가지고 있다.
  5. const는 ES6인 경우 컴파일 후 선언이 유지되고, readonly는 컴파일 후 사라진다.

readonly의 사용 예제를 살펴보도록 하자.

interface ICount {
    readonly count: number; // readonly는 인터페이스 멤버를 선언할 수 있음
}

class TestReadonly implements ICount {
    readonly count: number; // readonly는 클래스의 멤버 변수에 선언할 수 있음
    static readonly count2: number; // readonly 앞에 static 지정 가능
    private readonly count3: number; // readonly 앞에 접근 제한자 지정 가능
    readonly count4: number = 0; // readonly로 선언되면 초기화 가능
    getCount() {
        // this.count4 = 0; // readonly로 선언된 멤버 변수는 재할당 불가
        // readonly count5: number = 0; // readonly는 메서드에 선언할 수 없음
    }
}

function getCount() {
    // readonly count: number; // readonly는 함수에 선언할 수 없음
}

// readonly는 객체 리터럴의 속성 앞에 지정 가능
let literalObj: { readonly alias: string } = { alias: "happy" };
// literalObj.name = "happy"; // readonly로 지정된 타입으로 인해 할당 불가
// literalObj = "test" // readonly로 지정된 타입으로 인해 할당 불가

 


 

참고자료

1. <퀵스타트 타입스크립트> (2018, 정진욱 저)

2. 타입스크립트 한국어 공식 문서