TYPESCRIPT · 심층 가이드
TypeScript 객체지향 완전 정리
클래스와 생성자, 인터페이스, 캡슐화, 합성, 값 객체, 저장소·서비스 객체까지 TypeScript 객체지향 20가지 주제를 실무 기준으로 정리했습니다.
TypeScript의 타입 시스템은 구조적입니다. 어떤 인터페이스를 구현한다고 선언하지 않아도, 모양만 맞으면 그 자리에 들어갈 수 있습니다. 이름으로 호환을 판단하는 언어에서 넘어오면 이 점이 가장 낯설고, 동시에 가장 유용합니다. 인터페이스를 미리 상속 계층으로 설계하기보다 필요한 모양을 그때그때 기술하는 쪽이 잘 맞습니다.
기본 클래스 또는 구조체와 생성자와 초기화로 골격을 잡고, 인터페이스 또는 trait에서 구조적 타이핑을 확인하십시오. 상속 또는 확장과 합성으로 기능 묶기는 반드시 이어서 보는 편이 좋습니다. 대부분의 경우 상속보다 합성이 나은 이유가 두 항목을 비교할 때 드러납니다. 뒤쪽의 값 객체 비교와 불변 데이터 객체, DTO 매핑은 실무에서 가장 자주 쓰이는 조합입니다.
캡슐화에서 꼭 알아 둘 것이 있습니다. private 키워드는 컴파일 시점에만 작동하고 결과물에는 남지 않습니다. 즉 실행 중에는 외부에서 그 필드에 접근할 수 있습니다. 진짜로 숨겨야 하는 값이라면 #으로 시작하는 비공개 필드를 쓰십시오. 이쪽은 JavaScript 차원에서 강제되어 런타임에도 접근이 차단됩니다.
01기본 클래스 또는 구조체
생성자 매개변수에 public을 붙여 name과 level 필드를 별도 선언 없이 만들고, summary()가 둘을 콜론으로 이어 붙인 문자열을 돌려줍니다. 데이터와 그 데이터를 다루는 동작을 한 덩어리로 묶을 때 쓰는 형태입니다. 이 매개변수 프로퍼티 문법은 TypeScript 전용 확장이라 같은 코드를 순수 JS 파일에 그대로 옮기면 동작하지 않습니다.
TypeScript code
// 기본 클래스 또는 구조체
class User {
constructor(public name: string, public level: number) {}
summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 1).summary());constructor(public readonly name: string)처럼 접근 제어자와 readonly를 함께 붙이면 선언과 대입을 한 줄로 끝낼 수 있습니다.
매개변수 프로퍼티는 TypeScript 전용 문법이라, 타입만 지우는 변환 설정에서는 필드가 만들어지지 않아 값이 undefined가 됩니다.
02생성자와 초기화
Connection은 mode만 받는 생성자를 두고, static local()이 "local"을 채운 인스턴스를 만들어 반환합니다. 초기화 조합이 몇 가지로 정해져 있을 때 이름 붙은 진입점을 제공하는 방식입니다. 정적 메서드는 인스턴스가 아니라 클래스 객체에 붙기 때문에 this가 인스턴스가 아닌 클래스를 가리킵니다.
TypeScript code
// 생성자와 초기화
class Connection {
constructor(public mode: string) {}
static local(): Connection { return new Connection("local"); }
}
console.log(Connection.local());정적 팩터리의 반환 타입을 this로 적어 두면 상속했을 때 결과가 하위 클래스 타입으로 좁혀져 형변환이 필요 없어집니다.
정적 메서드 안의 this는 클래스를 가리키므로, 메서드를 변수에 담아 따로 호출하면 this가 끊겨 예외가 납니다.
03인스턴스 메서드
Logger에는 인자를 그대로 출력하는 log가 있고, Service는 생성자로 받은 로거를 run() 안에서 호출합니다. 호출 대상 객체를 밖에서 넣어 주므로 로거 구현을 바꿔 끼우기 쉬워집니다. 인스턴스 메서드는 프로토타입에 한 번만 만들어져 인스턴스마다 복제되지 않는다는 점이 필드에 함수를 대입하는 방식과 다릅니다.
TypeScript code
// 인스턴스 메서드
class Logger { log(message: string): void { console.log(message); } }
class Service { constructor(private logger: Logger) {} run(): void { this.logger.log("start"); } }
new Service(new Logger()).run();메서드는 프로토타입에 한 번만 만들어져 인스턴스가 늘어도 메모리가 늘지 않지만, 필드에 화살표 함수를 대입하면 인스턴스마다 복제됩니다.
메서드를 콜백 자리에 참조만 넘기면 this가 끊겨 필드 접근이 실패하므로, 화살표로 감싸거나 bind해야 합니다.
04정적 메서드
여기서는 클래스 대신 Readonly<{ name: string; tags: string[] }>로 Profile 타입을 정의하고 객체 리터럴 하나를 그 타입으로 고정했습니다. 값 자체를 바꾸지 못하게 표시해 두고 싶을 때 쓰는 최소 형태입니다. Readonly는 한 겹만 적용되므로 tags 재대입은 막히지만 tags.push(...)는 타입 검사를 통과합니다.
TypeScript code
// 정적 메서드
type Profile = Readonly<{ name: string; tags: string[] }>;
const profile: Profile = { name: "Ada", tags: ["ts"] };
console.log(profile);안쪽까지 잠그려면 readonly string[]처럼 배열 자체를 읽기 전용으로 선언해야 하며, 그러면 push 호출이 컴파일에서 막힙니다.
Readonly는 한 겹만 적용되므로 profile.tags.push('x')가 통과해, 읽기 전용이라 믿은 값이 실행 중에 바뀝니다.
05상속 또는 확장
제목과 달리 코드에는 extends가 없고, name과 level을 갖는 User 클래스 하나가 summary()로 요약 문자열을 만들 뿐입니다. 계층이 한 단계면 이렇게 단일 클래스로 두는 편이 읽기 쉽습니다. 실제로 상속을 넣는 순간 파생 클래스 생성자는 this를 건드리기 전에 반드시 super()를 먼저 호출해야 합니다.
TypeScript code
// 상속 또는 확장
class User {
constructor(public name: string, public level: number) {}
summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 5).summary());파생 클래스에서는 super() 이후에만 this를 쓸 수 있고, 필드 초기화는 부모 쪽이 항상 먼저 끝납니다.
부모 생성자가 오버라이드된 메서드를 부르면 자식 필드가 아직 초기화되기 전이라 undefined를 보게 됩니다.
06인터페이스 또는 trait
interface 선언은 등장하지 않고, mode 필드와 정적 팩터리 local()을 가진 구체 클래스만 있습니다. 규약이 하나뿐일 때는 이 정도로 충분합니다. TypeScript는 구조적 타입 시스템이라 필드와 메서드 모양만 맞으면 implements를 적지 않아도 인터페이스 자리에 그대로 대입됩니다.
TypeScript code
// 인터페이스 또는 trait
class Connection {
constructor(public mode: string) {}
static local(): Connection { return new Connection("local"); }
}
console.log(Connection.local());implements는 검사 목적일 뿐 타입을 만들지 않으므로, 붙이지 않아도 모양만 맞으면 인터페이스 자리에 그대로 대입됩니다.
구조만 맞으면 통과하기 때문에 의미가 다른 두 타입이 서로 대입되며, 이를 막으려면 브랜드 필드를 넣어 모양을 일부러 다르게 해야 합니다.
07캡슐화
private logger로 선언해 Service 바깥에서는 로거 필드에 접근할 수 없고, 노출되는 통로는 run() 하나뿐입니다. 내부 협력 객체를 감추고 공개 표면을 좁힐 때 이렇게 씁니다. 다만 private는 컴파일 시점 검사일 뿐이라 트랜스파일된 JS에서는 obj.logger가 그대로 보이며, 런타임까지 막으려면 #logger 형태를 써야 합니다.
TypeScript code
// 캡슐화
class Logger { log(message: string): void { console.log(message); } }
class Service { constructor(private logger: Logger) {} run(): void { this.logger.log("start"); } }
new Service(new Logger()).run();#logger로 선언한 필드는 실행 시점에도 클래스 밖 접근이 막히고, #x in obj로 같은 클래스의 인스턴스인지 확인할 수도 있습니다.
private는 컴파일 시점 검사라 (service as any).logger로 그대로 읽히고, 변환된 JS에는 필드 이름이 고스란히 남습니다.
08합성으로 기능 묶기
타입 조합만으로 name과 tags를 한 형태로 묶은 뒤 Readonly를 씌워 Profile을 만들었습니다. 상속 계층을 세우지 않고 필요한 조각을 붙여 형태를 정의하고 싶을 때 적합합니다. 타입 별칭은 컴파일 후 완전히 사라지므로 런타임에는 Profile이라는 이름이 존재하지 않고 평범한 객체만 남습니다.
TypeScript code
// 합성으로 기능 묶기
type Profile = Readonly<{ name: string; tags: string[] }>;
const profile: Profile = { name: "Ada", tags: ["ts"] };
console.log(profile);타입 별칭은 교차 타입으로 조각을 합칠 수 있어, 공통 필드를 재사용하면서도 상속 계층을 만들지 않아도 됩니다.
타입은 컴파일 후 사라지므로 instanceof Profile 같은 검사를 쓸 수 없고, 값 확인이 필요하면 함수를 직접 만들어야 합니다.
09팩터리 메서드
코드에는 팩터리 함수가 없고 new User("Ada", 9)로 직접 인스턴스를 만든 뒤 summary() 결과를 출력합니다. 생성 규칙이 단순하면 굳이 감싸지 않는 편이 낫습니다. 팩터리를 도입해 이득을 보는 시점은 생성 시 검증이나 분기가 붙거나, 반환 타입을 구체 클래스 대신 인터페이스로 좁히고 싶을 때입니다.
TypeScript code
// 팩터리 메서드
class User {
constructor(public name: string, public level: number) {}
summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 9).summary());생성 시 검증이 필요해지면 생성자를 private로 막고 팩터리만 공개해, 검증을 거치지 않은 인스턴스를 원천적으로 없앨 수 있습니다.
팩터리를 만들면서 생성자를 그대로 열어 두면 두 경로가 공존해, 검증을 건너뛴 인스턴스가 섞여도 알아채기 어렵습니다.
10열거형 상태 표현
상태 값 mode는 enum이 아니라 그냥 string으로 선언되어 있고, local()이 그중 하나를 미리 채워 줍니다. 값이 몇 개로 한정된 상태를 표현할 때 흔히 마주치는 출발점입니다. string을 "local" | "remote" 같은 리터럴 유니온으로 좁히면 오타가 컴파일에서 걸리고, enum과 달리 런타임 객체도 생기지 않습니다.
TypeScript code
// 열거형 상태 표현
class Connection {
constructor(public mode: string) {}
static local(): Connection { return new Connection("local"); }
}
console.log(Connection.local());리터럴 유니온은 컴파일 후 아무 코드도 남기지 않지만, enum은 실제 객체를 만들어 결과물에 그대로 포함됩니다.
mode: string으로 두면 'loacl' 같은 오타가 통과해, 어느 분기에도 걸리지 않는 상태가 조용히 만들어집니다.
11불변 데이터 객체
불변 데이터 대신 서비스 조립이 보입니다. Service는 로거 참조를 생성자에서 한 번 받아 필드에 저장하고 이후 run()에서 재사용합니다. 참조를 생성 시점에 고정하면 실행 도중 대상이 바뀌지 않아 추적이 쉬워집니다. 필드에 readonly를 붙여도 막히는 것은 필드 재대입뿐이고, 가리키는 로거 객체 내부는 여전히 변경 가능합니다.
TypeScript code
// 불변 데이터 객체
class Logger { log(message: string): void { console.log(message); } }
class Service { constructor(private logger: Logger) {} run(): void { this.logger.log("start"); } }
new Service(new Logger()).run();생성자에서 받은 참조를 readonly로 고정하면 필드 교체가 막혀, 인스턴스가 사는 동안 협력 대상이 바뀌지 않음을 보장할 수 있습니다.
readonly는 가리키는 객체 내부까지 잠그지 않으므로, 넘겨받은 로거의 설정을 밖에서 바꾸면 이 서비스의 동작도 함께 달라집니다.
12빌더 패턴
단계별로 값을 채워 가는 빌더 대신, 완성된 객체 리터럴을 Readonly 타입에 한 번에 대입합니다. 필드 수가 적고 모두 필수라면 이 직접 생성이 가장 짧습니다. 빌더가 필요해지는 지점은 선택 필드가 많아 생성 도중 부분 상태를 허용해야 할 때인데, Readonly는 그 부분 상태 대입 자체를 컴파일에서 막습니다.
TypeScript code
// 빌더 패턴
type Profile = Readonly<{ name: string; tags: string[] }>;
const profile: Profile = { name: "Ada", tags: ["ts"] };
console.log(profile);선택 필드가 많더라도 빌더를 세우기 전에, 부분 객체를 받아 기본값과 합치는 함수 하나로 충분한지 먼저 따져 보는 편이 좋습니다.
Readonly 타입에 부분 상태를 담아 조립하려 하면 필수 필드가 빠진 대입이 컴파일에서 막혀, 빌더 중간 단계를 표현할 수 없습니다.
13다형성
출력되는 것은 User 인스턴스의 summary() 호출 결과 하나이고, 오버라이드하는 하위 타입은 없습니다. 동작이 한 종류일 때의 기본형입니다. TypeScript에서는 summary(): string이라는 모양만 같으면 상속 관계가 없는 별개 클래스도 같은 자리에 넣을 수 있어, 다형성을 위해 공통 부모가 반드시 필요하지는 않습니다.
TypeScript code
// 다형성
class User {
constructor(public name: string, public level: number) {}
summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 13).summary());구조가 같으면 상속 없이도 같은 자리에 넣을 수 있으므로, 공통 동작만 인터페이스로 뽑아 두면 클래스 계층 자체가 필요 없습니다.
이름과 인자는 같은데 의미가 다른 클래스가 섞이면 타입 검사는 통과하고 동작만 어긋나, 실행해 봐야 문제가 드러납니다.
14의존성 주입
local()은 "local"이라는 값을 메서드 안에 직접 적어 넣고 인스턴스를 만듭니다. 설정이 고정된 개발용 진입점으로는 편리한 방식입니다. 반대로 이 문자열을 호출자가 넘기도록 바꾸면 테스트에서 다른 모드를 주입할 수 있는데, 지금 형태는 값이 메서드 본문에 묶여 있어 바깥에서 교체할 수 없습니다.
TypeScript code
// 의존성 주입
class Connection {
constructor(public mode: string) {}
static local(): Connection { return new Connection("local"); }
}
console.log(Connection.local());값을 생성자 인자로 빼 두면 테스트에서 다른 모드를 넣어 볼 수 있고, 기본값을 지정해 두면 기존 호출부는 고치지 않아도 됩니다.
메서드 본문에 연결 문자열을 직접 적어 두면 바깥에서 교체할 방법이 없어, 테스트가 실제 연결을 그대로 사용하게 됩니다.
15값 객체 비교
비교 코드는 없고, Logger 인스턴스를 Service에 넘겨 run()을 호출하는 흐름만 있습니다. 협력 객체를 다룰 때는 동일성보다 동작이 관심사입니다. 참고로 클래스 인스턴스는 ===가 참조를 비교하므로, 필드 값이 완전히 같은 두 객체도 서로 다른 것으로 판정되고 값 비교는 직접 구현해야 합니다.
TypeScript code
// 값 객체 비교
class Logger { log(message: string): void { console.log(message); } }
class Service { constructor(private logger: Logger) {} run(): void { this.logger.log("start"); } }
new Service(new Logger()).run();값이 같으면 같다고 봐야 하는 타입에는 비교 메서드를 두거나, 비교용 키 문자열을 만들어 Map에 담는 방법이 있습니다.
클래스 인스턴스를 Set이나 Map의 키로 쓰면 참조로 비교되어, 필드가 완전히 같은 객체가 중복으로 쌓입니다.
16저장소 객체
저장소 대신 조회용 데이터 형태가 나옵니다. Profile은 Readonly로 감싼 객체 타입이고 profile 상수가 그 타입을 따릅니다. 읽기 전용으로 넘겨 줄 결과 모델을 정의할 때 이런 형태를 씁니다. const는 변수 재대입만 막고 속성 변경은 막지 않기 때문에, 속성까지 잠그려면 지금처럼 타입 쪽에서 readonly를 걸어야 합니다.
TypeScript code
// 저장소 객체
type Profile = Readonly<{ name: string; tags: string[] }>;
const profile: Profile = { name: "Ada", tags: ["ts"] };
console.log(profile);읽기 전용 결과 모델을 타입으로 표시해 두면, 호출부가 값을 고쳐 저장소 상태를 흔드는 실수를 컴파일 단계에서 막을 수 있습니다.
const는 재대입만 막으므로 profile.name = 'x'는 타입 표기가 없으면 통과하고, 공유된 객체가 조용히 바뀝니다.
17서비스 객체
User는 상태를 들고 있으면서 summary()로 자기 데이터를 가공합니다. 상태 없는 서비스 객체와 달리, 데이터에 가까운 로직은 이렇게 모델 안에 두는 편이 호출부가 단순해집니다. 반대로 외부 I/O가 섞이기 시작하면 모델에서 떼어 내야 테스트에서 대역으로 바꿔 끼우기 쉬워집니다.
TypeScript code
// 서비스 객체
class User {
constructor(public name: string, public level: number) {}
summary(): string { return `${this.name}:${this.level}`; }
}
console.log(new User("Ada", 17).summary());데이터에 가까운 로직은 모델 안에 두고, 외부 호출이 섞이는 순간 서비스로 떼어 내면 테스트 대역을 붙일 지점이 분명해집니다.
모델 메서드 안에서 네트워크나 파일 접근을 하면 단순한 문자열 조립 검사에도 준비 코드가 필요해져, 실행이 느려지고 불안정해집니다.
18DTO 매핑
매핑 함수 없이, mode 하나만 가진 Connection을 local()로 생성합니다. 전달 대상 필드가 하나라면 별도 DTO 없이 이렇게 두어도 충분합니다. 다만 클래스 인스턴스를 그대로 JSON.stringify에 넘기면 메서드는 빠지고 필드만 남으므로, 응답 모양을 고정하려면 평범한 객체 타입으로 옮기는 편이 안전합니다.
TypeScript code
// DTO 매핑
class Connection {
constructor(public mode: string) {}
static local(): Connection { return new Connection("local"); }
}
console.log(Connection.local());응답 모양을 고정하려면 클래스 대신 평범한 객체 타입으로 옮기고, 필요한 필드만 골라 담는 매핑 함수를 하나 두면 됩니다.
클래스 인스턴스를 그대로 직렬화하면 메서드는 사라지고 필드만 남아, 밖에 보이면 안 되는 내부 필드까지 응답에 실립니다.
19변경 불가능 업데이트
값을 갱신하는 부분은 없고, 생성자에서 받은 로거를 그대로 유지한 채 run()이 호출됩니다. 상태 변경 없이 동작만 위임하는 구조입니다. 객체를 갱신할 때 흔히 쓰는 스프레드 복사는 한 겹만 새로 만들기 때문에, 중첩된 객체나 배열은 원본과 같은 참조를 계속 공유한다는 점을 기억해야 합니다.
TypeScript code
// 변경 불가능 업데이트
class Logger { log(message: string): void { console.log(message); } }
class Service { constructor(private logger: Logger) {} run(): void { this.logger.log("start"); } }
new Service(new Logger()).run();중첩 구조를 갱신할 때는 바뀌는 경로에 있는 객체만 새로 만들고 나머지는 참조를 재사용하면, 비용과 안전성을 함께 챙길 수 있습니다.
스프레드 한 번으로 깊은 복사가 됐다고 믿으면, 안쪽 객체를 수정했을 때 이전 상태로 보관해 둔 값까지 함께 바뀝니다.
20확장 스타일 동작
확장 메서드를 붙이는 대신, Readonly 유틸리티 타입으로 기존 객체 형태를 감싸 새 타입 Profile을 파생시켰습니다. 원본 정의를 건드리지 않고 성질만 바꾸고 싶을 때의 방법입니다. TypeScript에는 다른 언어의 확장 메서드가 없어서, 동작을 더하려면 별도 함수나 래퍼를 만들거나 인터페이스 병합을 써야 합니다.
TypeScript code
// 확장 스타일 동작
type Profile = Readonly<{ name: string; tags: string[] }>;
const profile: Profile = { name: "Ada", tags: ["ts"] };
console.log(profile);동작을 더하고 싶다면 인스턴스를 첫 인자로 받는 함수를 만들면 되고, 이 방식은 쓰지 않는 기능을 결과물에서 걷어내기도 쉽습니다.
내장 타입의 프로토타입에 메서드를 추가하면 다른 라이브러리와 이름이 충돌하거나 순회 코드에 섞여, 원인 찾기 어려운 오류를 만듭니다.
정리하며
- 구조적 타이핑이라 선언 없이 모양만 맞으면 인터페이스를 만족합니다
- 상속보다 합성을 기본값으로 두고 필요한 경우에만 계층을 만듭니다
- private는 컴파일 시점 표시일 뿐 런타임에는 접근이 막히지 않습니다
- 실제 은닉이 필요하면 # 접두사 비공개 필드를 사용합니다
더 깊이 들어가고 싶다면 TypeScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.