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

TypeScript #4 클래스와 인터페이스 Part1

이번에는 타입스크립트의 클래스와 인터페이스에 대해 공부한 내용을 정리해 보고자 한다.

객체지향 프로그래밍과 클래스 기초

객체지향 프로그래밍(Object Oriented Programming, OOP)은 커다란 문제를 클래스 단위로 나누고 클래스 간의 관계를 추가하면서 코드 중복을 최소화 하는 개발방식이다. 클래스 간의 관계를 추가할 때는 상속이나 포함 관계를 고려하여 추가한다. OOP를 통해 어플리케이션을 개발하면 코드 중복을 상당히 줄일 수 있다. 타입스크립트는 자바스크립트(ES6)에 비해서 OOP를 지원하는 부분이 훨씬 더 많다.

타입스크립트에서는 클래스 선언을 다음과 같이 할 수 있다. 더불어 Rectangle 클래스 타입은 그 아래의 인터페이스 타입과 정확하게 일치한다.

class Rectangle {
    x: number;
    y: number;
    
    constructor(x: number, y: number) {
    	this.x = x;
        this.y = y;
    }
    getArea(): number { return this.x * this.y; }
}

interface Rectangle {
    x: number;
    y: number;
    getArea(): number;
}

// Rectangle 객체 생성
let rectangle = new Rectangle(1, 5);
let area: number = rectangle.getArea(); // 5

클래스는 멤버 변수와 멤버 메서드 등으로 구성된 '틀'이며 클래스를 실제로 사용하려면 객체로 생성해야 한다. 생성된 객체는 실제 메모리에 위치하고 객체의 참조가 객체 참조변수에 할당되는 과정을 인스턴스화(instantiate)라고 한다.

Rectangle 클래스가 실제 자바스크립트(ES5)에서 어떻게 표현되는지는, 아래의 컴파일 후 생성된 코드를 통해서 확인할 수 있다.

// JavaScript ES5
var Rectangle = (function () {
    function Rectangle(x, y) {
    	this.x = x;
        this.y = y;
    }
    Rectangle.prototype.getArea() = function() {
    	return this.x * this.y;
    };
    return Rectangle;
}());

컴파일된 코드를 보면 생성자 함수에 모듈 패턴을 적용해 클래스에 대응하는 구조이다. 모듈 패턴은 클로저(Closure)를 이용해 비공개된 내부 메서드를 캡슐화(encapsulation)하는데, 전역 이름 공간을 더럽히지 않는다는 장점이 있다. 변환된 코드에 내부 메서드는 생성자 역할을 하는 Rectangle 함수에 prototype 속성과 연결(chaining)된 형태로 선언된다는 것을 알 수 있다.

 


 

OOP에서 클래스 간의 관계는 크게 두 가지로 나누어 볼 수 있다. 하나는 상속 관계(IS-A)이며, 다른 하나는 포함 관계(HAS-A)이다.

상속 관계 : 상속은 클래스 계층을 만들어 코드 중복을 줄이는 객체지향 프로그래밍 방법이다. 타입스크립트는 클래스에 대해 단일 상속만 지원하므로 자식 클래스는 하나의 부모 클래스만 상속받을 수 있다. 자식 클래스가 부모 클래스를 상속받을 때는 자식 클래스의 생성자에서 super() 메서드를 호출해 부모 클래스의 생성자를 호출해 주어야 한다.

포함 관계 : 포함 관계는 클래스가 다른 클래스를 포함하는 HAS-A 관계이다. 클래스 내부에 다른 클래스를 포함하는 관계는 합성(composition) 관계와 집합(aggregation) 관계로 나뉜다. 합성 관계는 강한 관계인 반면, 집합 관계는 약한 관계이다.

// 합성 관계
class Engine {}
class Car {
    private engine;
    constructor() {
    	this.engine = new Engine();
    }
}
let myCar = new Car(); // engine 객체도 함께 생성
myCar = null; // engine 객체도 함께 제거

// 집합 관계
class Engine {}
class Car {
    private engine: Engine;
    constructor(engine: Engine) {
    	this.engine = engine;
    }
}
let engine = new Engine();
let car = new Car(engine); // 외부에서 생성된 engine 객체를 전달, car 객체에 null이 할당되어도 제거되지 않음 

 


 

추상 클래스(abstract class)는 구현 메서드와 추상 메서드(abstract method)가 동시에 존재할 수 있다. 여기에서 구현 메서드는 실제 구현 내용을 포함한 메서드이고, 추상 메서드는 선언만 된 메서드이다. 이와 같이 추상 클래스는 구현 내용이 없는 추상 메서드를 포함하기 때문에 불완전한 클래스이다. 따라서 추상 클래스는 단독으로 객체를 생성할 수 없고 추상 클래스를 상속하고 구현 내용을 추가하는 자식 클래스를 통해 객체를 생성해야 한다. 

구현하지 않은 추상 메서드가 선언되었으므로 자식 클래스에서는 추상 메서드를 오버라이딩(overriding)하여 반드시 구현해 주어야 한다. 또한 추상 클래스에 추상 멤버 변수가 선언되어 있으면 자식 클래스에서도 선언해야 한다. 추상 클래스를 작성할 때 abstract 키워드는 static 이나 private(public, protected는 가능)과 함께 선언할 수 없음에 주의해야 한다. 아래는 추상 클래스와 구현 클래스 예제이다.

abstract class AbstractBird {
    // 추상 멤버 변수
    abstract birdName: string;
    abstract habitat: string;
    
    // 추상 메서드
    abstract flySound(sound: string);
    
    // 구현 메서드
    fly(): void {
    	this.flySound('파닥파닥');
    }
    
    // 구현 메서드
   	getHabitat(): void {
    	console.log(`<${this.birdName}>의 서식지는 <${this.habitat}> 입니다.`);
    }
}

class WildGoose extends AbstractBird {
    // 추상 멤버 변수를 상속함
    constructor(public birdName: string, public habitat: string) {
    	super();
    }
    
    // 추상 메서드를 오버라이딩
    flySound(sound: String) {
    	console.log(`<${this.birdName}>가 <${sound}> 날아갑니다.`);
    }
}

let wildGoose = new WildGoose('기러기', '순천만 갈대밭');
wildGoose.fly(); // <기러기>가 <파닥파닥> 날아갑니다.
wildGoose.getHabitat(); // 기러기의 서식지는 <순천만 갈대밭> 입니다.

 


 

인터페이스에 대한 이해

인터페이스(interface)는 클래스에서 구현 부분이 빠진 타입으로 이해하면 된다. 인터페이스는 컴파일 후에 사라지게 된다. 인터페이스는 선언만 존재하며, 멤버 변수와 멤버 메서드를 선언할 수 있지만 접근 제한자는 설정할 수 없다.

자식 인터페이스의 경우 부모 인터페이스를 상속해서 확장할 수 있는데, 타입스크립트는 다중 상속이 가능하다. 만약 다중 상속을 받을 때 같은 이름의 메서드를 상속받으면, 상속받는 인터페이스에서 같은 이름의 메서드를 모두 재정의 해야 한다. 아래의 예제에서 DogBird 인터페이스는 Dog 인터페이스와 Bird 인터페이스로부터 둘 다 상속을 받는데, 같은 이름의 getStatus() 메서드를 가지고 있으므로 DogBird 인터페이스에서는 getStatus() 메서드를 재정의 해 주어야 한다. 

interface Dog {
    run(): void;
    getStatus(): { runningSpeed: number; };
}

interface Bird {
    fly(): void;
    getStatus(): { flightSpeed: number; };
}

interface DogBird extends Dog, Bird {
    getStatus(): { runningSpeed: number, flightSpeed: number; }
}

class NewAnimal implements DogBird {
    run(): void { }
    fly(): void { }
    getStatus(): { runningSpeed: number, flightSpeed: number; } {
    	return { runningSpeed: 10, flightSpeed: 20 }
    }
}

인터페이스는 객체 리터럴을 정의하는 타입으로 사용될 수도 있다. 아래의 예제를 통해서 확인할 수 있다. 물론 배열의 요소로 인터페이스를 받아서 타입 선언을 하는 방법도 있다. 인터페이스는 타입 선언이 많아도 컴파일(ES6) 후에는 모두 사라지므로 런타임 성능에 영향을 끼치지 않는다.

interface Person {
    name: string;
    city: string;
}
interface PersonItems extends Array<Person> { }
let person5: PersonItems = [
    { name: 'a', city: 'seoul' },
    { name: 'a', city: 'seoul' },
    { name: 'a', city: 'seoul' }
];

인터페이스에 익명 함수에 대한 함수 타입을 정의해서 사용할 수도 있다. 인터페이스는 클래스의 구조를 정의하기도 하지만, 자바스크립트의 객체 모양을 정의하기도 한다. 관련 예제는 다음과 같다.

interface IFormat {
    (data: string, toUpper?: boolean): string;
}

let format: IFormat = function (str: string, isUpper: boolean) {
    if (isUpper) {
        return str.toUpperCase();
    } else {
        return str.toLowerCase();
    }
};
console.log('Happy'); // happy
console.log('Happy', true); // HAPPY
console.log('Happy', false); // happy