/ TYPESCRIPT

TypeScript 강좌(6) - Interface

TypeScript 강좌는 여러 절로 구성되어 있습니다.


TypeScript Interface

interface는 우리가 알고 있는 Java의 interface와 문법적으로 유사합니다. 하지만 객체지향에서 말하는 interface와는 의미적으로 좀 차이가 있습니다. 그 부분은 예제 중간중간에 살펴보기로 하고 지금은 TypeScript의 interface 에 대해서만 살펴보도록 하겠습니다.

TypeScript에서 interface는 새로운 데이터 타입을 만드는 추상 데이터 타입(abstract data type)으로 사용이 되며 일반 변수, 함수, 클래스의 type check를 위해 사용됩니다. interface 이용하여 타입을 선언하면 interface안에 명시된 property의 선언과 method의 구현이 강제되기 때문에 프로그래밍의 일관성을 확보할 수 있습니다.

참고로 ES6는 interface를 지원하지 않습니다. TypeScript만 지원합니다. 그렇기 때문에 interface를 컴파일 한 결과물을 보면 interface의 내용은 나타나지 않게됩니다.


Basic Exam

그럼 간단한 예를 통해 알아보도록 하겠습니다.

interface IBook {
    bookName: string;
    bookAuthor: string;
}

let myBook: IBook;

myBook = {
    bookName: "젊은 베르테르의 슬픔",
    bookAuthor: "괴테"
};

console.log(myBook);

해당 .ts 파일을 컴파일 한 결과 .js 파일을 살펴보면 다음과 같습니다.

"use strict";
var myBook;
myBook = {
    bookName: "젊은 베르테르의 슬픔",
    bookAuthor: "괴테"
};
console.log(myBook);

위에서 언급한 대로 interface에 대한 내용은 포함되어 있지 않습니다. TypeScript의 interface는 type check를 위한 용도로 사용되게 됩니다.


Parameter Type Check

비슷한 용도로 함수의 인자를 넘길 때 인자의 type으로 interface를 이용할 수 있습니다. 아래의 예를 한번 살펴보죠.

interface IBook {
    bookName: string;
    bookAuthor: string;
}

function printBookInfo(paramBook: IBook) : void {
    console.log(paramBook.bookName);
}

let myBook: IBook = {
    bookName: "젊은 베르테르의 슬픔",
    bookAuthor: "괴테"
};

printBookInfo(myBook);

굳이 설명할 것도 없는거 같습니다. printBookInfo()의 인자로 interface type의 객체가 전달되어 사용된 것이죠.

여기까지는 별 문제 없이 이해할 수 있습니다. 이제 그 다음이 살짝 이상합니다.


Duck Typing

아래의 코드에서 문법 오류가 일어날까요?

interface IBook {
    bookName: string;
    bookAuthor: string;
}

function printBookInfo(paramBook: IBook) : void {
    console.log(paramBook.bookName);
}

let myBook = {
    bookName: "젊은 베르테르의 슬픔",
    bookAuthor: "괴테",
    bookPrice: 3000
};

printBookInfo(myBook);

원래 예상대로라면 이 코드는 문제가 있는 것이 맞습니다. myBook이라는 객체는 IBook interface type으로 지정되지 않고 parameter로 전달이 되었거든요. 하지만 이 코드는 에러없이 컴파일이 진행됩니다.

왜 이런 현상이 발생할까요?

TypeScript의 중요한 원칙 중 하나는 가지고 있는 값들의 형태를 가지고 type-checking을 수행한다는 것입니다. 말이 좀 어렵네요. 풀어서 설명하자면 TypeScript에서 type-checking은 선언된 타입만을 비교하는것이 아니라 실제 이용될 수 있는 형태의 값들을 가지고 있느냐 그렇지 않느냐를 가지고 한다는 의미입니다.

위의 예제에서 myBook 객체는 비록 IBook interface type은 아니지만 IBook interface type을 모두 커버할 수 있는 값들의 형태를 가지고 있습니다. 이런 경우 myBook 객체는 IBook interface 타입으로 간주한다는 말이지요.

이런걸 동적 typing의 한 종류인 duck typing이라고 합니다. TypeScript는 duck typing을 지원하는 언어입니다. 일반적으로 말하자면 class의 상속이나 interface의 구현으로 타입을 구분하는 것이 아니라 객체가 특정 타입에 걸맞는 property와 method를 가지고 있으면 해당 type으로 간주한다는 의미로 받아들이시면 됩니다.

해서 저 위의 코드는 에러없이 사용할 수 있습니다.


Optional Properties

TypeScript interface의 모든 property와 method는 구현하는 클래스 혹은 구현 객체에서 기본적으로 모두 재정의되어야 합니다. 하지만 Optional Property( property 중 ? 가 붙어있는 property를 의미합니다. )를 이용하면 해당 property는 재정의하지 않아도 상관없습니다. 즉, 선택적으로 구현여부를 결정할 수 있는 property가 optional property입니다.

아래의 예를 가지고 살펴보겠습니다.

interface IBook {
    bookName: string;
    bookAuthor: string;
    bookISBN?: string;   // Optional Property

    getName(): string;
}

function printBookInfo(paramBook: IBook) : void {
    console.log(paramBook.bookName);
    console.log(paramBook.getName());
}

let myBook: IBook = {
    bookName: "젊은 베르테르의 슬픔",
    bookAuthor: "괴테",

    getName: function() {
       return this.bookName;
    }
};

printBookInfo(myBook);

Readonly Properties

readonly keyword를 이용해 객체가 처음 생성되는 시점에만 property들을 수정가능하도록 설정할 수 있습니다.
한번 값이 세팅되면 그 후에는 수정할 수 없게됩니다.

interface Point {
    readonly x: number;
    y: number;
}

let p1: Point = { x: 10, y: 20 };
p1.x = 100;    // 오류 발생

TypeScript는 또 ReadonlyArray<T> 형태의 Array를 지원합니다. 이름에서 의미하는 것처럼 생성된 후에는 Array를 변경할 수 없습니다.

let arr: number[] = [1, 2, 3, 4];

let roArray: ReadonlyArray<number> = arr;

roArray[0] = 100;   // 코드 에러
roArray.push(100);  // 코드 에러

arr = roArray;             // 코드 에러
arr = roArray as number[]; // 가능

위의 코드도 쉽게 이해할 수 있습니다. 마지막 코드 정도만 주의하면 될 듯 합니다.

이렇게 readonly property는 const와 비슷한 역할을 하게됩니다. 단 const는 변수의 선언에 사용되며 readonly는 property 지정에 사용된다는 점만 기억하시면 됩니다.


Function Types

interface는 function의 type을 지정하는데 사용할 수 있습니다. 이 경우 parameter의 리스트와 리턴타입만을 가지고 있는 함수의 선언과 비슷한 형태를 가지게 됩니다. 아래의 예를 보죠.

interface myInterface {
    (myName: string, myAge: number): void;
}

let myFunc: myInterface = function(myName:string, myAge:number): void {
    console.log(`이름 : ${myName}, 나이 : ${myAge}`);
};

myFunc("홍길동",30);

Indexable Types

JavaScript의 객체를 사용하기 위해서는 일반적으로 "." operator를 이용합니다. 일반적인 객체지향언어에서 객체를 사용하는 방식이죠. JavaScript는 추가적으로 객체를 사용하는 방법으로 배열방식을 이용할 수 있습니다. 다음과 같이 사용합니다.

아래의 코드는 JavaScript 코드입니다.

let obj = {
    myName: '홍길동',
    myAddress: '서울'
};

console.log(obj.myName);           // "." operator 이용

let keys = Object.keys(obj);       // 객체의 key값들에 대한 배열 획득

for(let i = 0; i< keys.length; i++) {
    console.log(obj[keys[i]]);     // 배열형식을 이용
}

위의 코드는 문법에러가 발생하지 않고 결과도 잘 출력이 됩니다. obj 객체에 접근할 때 [ ] 형태를 이용해서 key값에 접근해서 value값을 출력하는 간단한 예제입니다.

하지만 이 코드를 TypeScript로 작성하면 코드에러가 발생합니다. 해당 파일에 대한 확장자만 .js에서 .ts로 변경해보면 obj[keys[i]에서 다음과 같은 에러가 발생하는 것을 볼 수 있습니다.

Element implicitly has an 'any' type because type 
'{ myName: string; myAddress: string; }' has no index signature.

쉽게 말하면 index signature를 이용하지 않았기 때문에 property에 접근할 때 어떤 타입인지를 확인할 수 없어서 묵시적으로 any 타입을 이용하게 된다는 의미입니다. 하지만 우리는 TypeScript compiler 옵션 중 noImplicitAny 속성을 true로 해 놓았기 때문에 문제가 발생하는 것입니다. (noImplicitAny: true가 default로 설정됩니다.)

해결하기 위해서는 컴파일러 옵션을 수정하던지 아니면 interface를 이용하여 index signature를 설정해 type을 명시적으로 알려주면 됩니다.

컴파일러 옵션을 수정하는 방법은 tsconfig.json 설정에서 "noImplicitAny": false 옵션을 추가하시면 됩니다. 하지만 좋지 않습니다. any 타입은 특별한 이유가 있지 않는 한 가능한 사용하지 않는게 좋고 명시적으로 타입을 지정해서 사용하셔야 합니다.

좀 더 좋은 해결책은 interface로 index signature를 설정해서 사용하는 것입니다. 이걸 Indexable Type이라고 합니다. 아래의 코드처럼 interface를 이용해 index signature를 설정합니다.

interface IObj {
    [idx: string]: string;
}

let obj: IObj = {
    myName: '홍길동',
    myAddress: '서울'
};

console.log(obj.myName);           // "." operator 이용

let keys = Object.keys(obj);       // 객체의 key값들에 대한 배열 획득

for(let i = 0; i< keys.length; i++) {
    console.log(obj[keys[i]]);     // 배열형식을 이용
}

추가적으로 union type을 이용한 다음의 코드도 살펴보시면 됩니다.

interface IObj {
    [idx: string]: string | number;
    [index: number]: string | number;
    myName: string;
    myAddress: string;
    myAge: number
}

let obj: IObj = {
    myName: '홍길동',
    myAddress: '서울',
    myAge: 30
};

console.log(obj.myName);           // "." operator 이용

let keys = Object.keys(obj);       // 객체의 key값들에 대한 배열 획득

for(let i = 0; i< keys.length; i++) {
    console.log(obj[keys[i]]);     // 배열형식을 이용
}

마지막으로 앞에서 나온 readonly property를 이용하면 ReadonlyArray처럼 사용할 수 있습니다.

interface ReadonlyStringArray {
    readonly [index: number]: string;
}
let myArr: ReadonlyStringArray = ["홍길동", "강감찬"];
myArr[2] = "이순신"; // 코드 오류( readonly )

Class Types

interface의 가장 일반적인 사용법은 JavaC#언어처럼 class의 구현을 명시적으로 강제하는 것입니다. 아직 class에 대해서는 배우지 않았지만 Java같은 객체지향 언어를 해 보신 분이라면 쉽게 이해하실거라 생각됩니다.

interface IPerson {
    [idx: string]: string | number | Function;
    myName: string;
    myAddress: string;
    myAge: number;

    printInfo(obj:IPerson): void;
}

class Person implements IPerson {
    [index: string]: string | number | Function;
    myName: string;
    myAddress: string;
    myAge: number;

    constructor(name:string, address:string, age:number) {
        this.myName = name;
        this.myAddress = address;
        this.myAge = age;
    }
    
    printInfo(obj: IPerson): void {
        Object.keys(obj).forEach(t => console.log(obj[t]));
    }
}

const obj = new Person("홍길동", "서울", 30);
obj.printInfo(obj);

위와 같은 일반적인 경우가 사실은 대부분입니다. ^^; 여기에 추가적인 몇가지만 살펴보겠습니다. 이전 강좌에서 일급함수(first class function)개념에 대해서 언급했었는데 기억하시나요? 한마디로 요약하자면 “JavaScript는 함수를 값으로 취급한다” 입니다. 그래서 함수를 변수에 저장하거나 함수를 다른 함수의 인자로 넘기거나 함수의 리턴값으로 함수를 활용할 수 있는 것이지요.

그래서 다음의 JavaScript코드는 정상적으로 동작합니다.

const PersonFactory = {
    getInstance: function(construct,name,age) {
        return new construct(name,age);
    }
};

class Person {

    constructor(name, age) {
        this.myName = name;
        this.myAge = age;
    }

    printInfo() {
        console.log("이름:" + this.myName + ", 나이:" + this.myAge);
    }
}


let obj = PersonFactory.getInstance(Person, "홍길동", 30);
obj.printInfo();

위의 코드에서 Person 생성자를 PersonFactory.getInstance 함수의 인자로 넘겨서 사용했습니다. JavaScript에서는 문제없이 잘 동작합니다. 하지만 이 코드를 그대로 TypeScript에서 작성하면 코드에러가 발생합니다.

일단 타입지정부터 엉망이니 좀 수정해서 보면 TypeScript코드는 다음과 같게 됩니다.

const PersonFactory = {
    getInstance: function(construct:any,name:string,age:number) {
        return new construct(name,age);
    }
};

class Person {
    myName: string;
    myAge: number;

    constructor(name:string, age:number) {
        this.myName = name;
        this.myAge = age;
    }

    printInfo() {
        console.log("이름:" + this.myName + ", 나이:" + this.myAge);
    }
}

let obj = PersonFactory.getInstance(Person, "홍길동", 30);
obj.printInfo();

데이터 타입을 적절하게 지정해서 일단 코드 오류는 제거했습니다. 그런데 문제가 하나 있습니다.

const PersonFactory = {
    getInstance: function(construct:any,name:string,age:number) {
        return new construct(name,age);
    }
};

위의 코드에서 생성자를 인자로 받아올 때 타입을 어떻게 지정해야 할지 몰라 일단 모든 타입에 대응되는 any로 설정했습니다. any로 설정하면 문제없지만 우리는 any를 사용하지 않습니다. 좀 심하게 표현하자면 any를 사용할꺼면 굳이 TypeScript를 할 필요가 없습니다. 여하간 이 any를 없애고 정확한 타입을 명시하려 합니다. 그런데 어떤 타입을 써야하나요? any대신 만만한 Function을 이용해 보면 다음과 같은 오류를 보실 수 있습니다.

Cannot use 'new' with an expression whose type lacks a call or construct signature

construct signature 없이 new를 사용할 수 없답니다. 그럼 이 construct signature를 어떻게 만들어야 할까요? 저 위에서는 index signature라는 걸 interface를 이용해서 선언하고 사용했습니다. 기억하시죠? 이와 비슷합니다. interface를 이용해 생성자의 signature를 지정해 줄 수 있습니다. 다음과 같이 사용합니다.

interface IPersonConstructor {
    new (n:string, a:number): Person;
}

const PersonFactory = {
    getInstance: function(construct:IPersonConstructor,
                          name:string,
                          age:number) {
        return new construct(name,age);
    }
};

class Person {
    myName: string;
    myAge: number;

    constructor(name:string, age:number) {
        this.myName = name;
        this.myAge = age;
    }

    printInfo() {
        console.log("이름:" + this.myName + ", 나이:" + this.myAge);
    }
}


let obj = PersonFactory.getInstance(Person, "홍길동", 30);
obj.printInfo();

이렇게 사용하는 interface를 constructor interface 라고 표현하기도 합니다. 중요한 것은 interface로 constructor의 타입을 지정해 줄 수 있다는 것이고 construct signature라는 표현으로 interface내에 정의해서 사용합니다.


interface의 확장

하나의 interface는 다른 interface로 부터 상속받아서 확장될 수 있습니다. Java와 유사합니다. 다음의 코드로 이해하시면 됩니다.

interface Shape {
    color: string;
}

interface Square extends Shape {
    sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;

let square = <Square>{}; 에서 <Square> 부분이 어떤것인지 기억하시나요? 그렇습니다. Type assertions이죠. 위의 코드는 크게 어렵지 않을 듯 보입니다. 하지만 Java와 다른점이 있습니다. 동시에 여러 interface로 부터 상속을 받을 수 있다는 것이죠. 하지만 여기서 상속이라는 표현은 맞지 않습니다. 상속은 객체지향 언어에서 나오는 특성입니다. 여기서는 단지 interface의 확장일 뿐입니다.

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

interface Square extends Shape, PenStroke {
    sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

interface는 type check를 위해 사용되기 때문에 interface로는 객체를 생성할 수 없습니다.

일단 이 정도만 알아두어도 될 듯 보입니다. 내용이 생각보다 많은데 생각보다 많이 어렵지는 않습니다. 단지 헷갈릴 뿐이죠 ^^ 여러번 정독하면서 쓰임새를 알아두고 실제 코드에서 활용하면서 익히셔야 합니다.

End.


이 포스트의 내용은 아래의 사이트를 참조했습니다. 조금 더 자세한 사항을 알고 싶으시면 해당 사이트를 방문하세요!!