TypeScript에서 객체지향을 다루는 현실적인 방법 – 클래스가 답이 아닌 이유까지
TypeScript OOP를 컴파일 계약 관점에서 다루며 접근 제어, 추상화, 상속과 컴포지션 판단 기준을 설명
Posted
By okorion
TypeScript에서 객체지향을 다루는 현실적인 방법 – 클래스가 답이 아닌 이유까지
결론: TypeScript의 OOP는 “런타임 은닉”이 아니라 “컴파일 타임 계약”을 만드는 도구다
TypeScript의 클래스/OOP는 Java나 C#의 그것과 다르다. 접근 제어·추상화는 런타임 보호가 아니라 컴파일 단계의 사용 제약이다. 따라서 목적 없이 클래스를 쓰면 복잡도만 늘고, 목적이 명확하면 강력한 설계 도구가 된다.
접근 제어자(public/private/protected/readonly)는 “컴파일 규칙”이다
결론: 접근 제어자는 런타임 보안이 아니라 오용을 막는 가이드다.
정의
public: 어디서나 접근 가능(기본값)private: 클래스 내부에서만 접근 가능protected: 클래스 + 서브클래스에서 접근 가능readonly: 생성 이후 재할당 금지
언제 쓰나 (판단 기준)
private: 내부 불변식 유지protected: 상속 확장 포인트readonly: 생성 이후 바뀌면 안 되는 식별자
흔한 실수 / 디버깅 포인트
- “private이니까 런타임에서 숨겨진다”는 오해 → JS로 컴파일되면 그대로 노출
readonly를 불변성 보장으로 착각 → 객체 내부 변경은 막지 못함
예제
1
2
3
4
5
6
7
8
9
10
11
class User {
public name: string;
private password: string;
readonly id: string;
constructor(id: string, name: string, password: string) {
this.id = id;
this.name = name;
this.password = password;
}
}
생성자 축약 초기화는 편하지만, 유지보수 리스크가 있다
결론: 축약은 “안 바뀌는 단순 모델”에서만 써라.
정의
- 생성자 매개변수에 접근 제어자를 붙여 필드 선언+할당을 동시에 수행
언제 쓰나
- DTO, 단순 값 객체
- 필드가 적고 변경 가능성이 낮을 때
흔한 실수
- 비즈니스 로직이 커진 클래스에도 무분별하게 사용
- 필드가 늘어나며 생성자 시그니처가 비대해짐
예제
1
2
3
4
5
6
class Point {
constructor(
public readonly x: number,
public readonly y: number
) {}
}
주의: 생성자 로직이 늘어나는 순간, 축약은 가독성과 확장성을 해친다.
interface vs abstract class는 “상속이냐, 계약이냐”의 선택이다
결론: 구현 강제가 목적이면 interface, 기본 동작 공유가 필요하면 abstract class다.
interface
- 객체의 형태(계약) 만 정의
- 다중 구현 가능
- 런타임 코드 없음
abstract class
- 일부 구현 + 확장 포인트 제공
- 단일 상속
- 런타임 코드 존재
interface로 계약 정의 → class 구현 예제
1
2
3
4
5
6
7
8
9
interface Repository {
save(data: string): void;
}
class ApiRepository implements Repository {
save(data: string) {
console.log("save to api", data);
}
}
abstract class로 템플릿 메서드 유사 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
abstract class Logger {
log(message: string) {
const formatted = this.format(message);
console.log(formatted);
}
protected abstract format(message: string): string;
}
class JsonLogger extends Logger {
protected format(message: string) {
return JSON.stringify({ message });
}
}
흔한 실수
- union/type을 interface로 표현하려다 막힘
- 단순 계약인데 abstract class를 써서 상속 고정
static과 getter/setter는 “경계가 명확할 때”만 가치가 있다
결론: 상태가 섞이는 순간 테스트와 추론이 어려워진다.
static
- 클래스 인스턴스와 무관한 로직/상수
- 전역 상태처럼 동작할 수 있음
1
2
3
4
5
class MathUtil {
static clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
}
getter/setter
- 값 접근 시 검증/계산이 필요한 경우
1
2
3
4
5
6
7
8
9
10
11
12
class User {
private _age = 0;
get age() {
return this._age;
}
set age(value: number) {
if (value < 0) throw new Error("invalid age");
this._age = value;
}
}
흔한 실수
- 모든 필드에 getter/setter 남발 → 추론 비용 증가
- static에 상태를 두어 테스트 격리 실패
Singleton 패턴은 “편리하지만 비용이 큰 선택”이다
결론: 전역 상태가 필요한지부터 의심하라.
잘못 쓴 Singleton 예제 (문제점)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AppState {
private static instance: AppState;
private data = 0;
private constructor() {}
static getInstance() {
if (!this.instance) {
this.instance = new AppState();
}
return this.instance;
}
setData(v: number) {
this.data = v;
}
}
문제점
- 테스트 간 상태 공유
- 의존성 주입 불가
- 숨은 전역 상태
개선안: 의존성으로 주입
1
2
3
4
5
6
7
8
9
10
class AppState {
constructor(private data = 0) {}
setData(v: number) {
this.data = v;
}
}
// 필요한 곳에서 명시적으로 주입
const appState = new AppState();
판단 기준: “전역으로 하나여야 한다”는 요구는 대부분 설계 미비다.
TS에서 OOP가 과한 순간: 함수형/컴포지션이 더 낫다
결론: 상태 공유·상속 깊이가 늘어나면 OOP는 비용이 된다.
OOP가 과한 신호
- 상속 계층이 3단계 이상
protected남발- 클래스보다 헬퍼 함수가 더 많음
대안: 컴포지션
1
2
3
4
5
6
7
type Logger = (msg: string) => void;
const createService = (logger: Logger) => ({
run() {
logger("run");
}
});
효과: 테스트 용이, 의존성 명시, 타입 단순화.
실무 판단 기준: 언제 class가 유리한가?
결론: “상태 + 생명주기 + 계약”이 동시에 필요할 때다.
- 내부 상태를 유지해야 하는가?
- 명확한 생명주기(start/stop 등)가 있는가?
- 여러 구현체를 계약으로 교체해야 하는가?
- 인스턴스 단위 테스트가 필요한가?
위 조건이 2개 이상이면 class를 고려할 가치가 있다.
체크리스트
- 접근 제어자를 런타임 보호로 착각하지 않았는가?
- 생성자 축약을 남용하고 있지 않은가?
- interface와 abstract class를 목적에 맞게 구분했는가?
- static 상태로 테스트를 오염시키지 않는가?
- Singleton이 진짜 필요한지 재검토했는가?
요약 5줄
- TypeScript의 OOP는 컴파일 타임 계약을 만드는 도구다.
- 접근 제어자는 런타임 은닉이 아니라 오용 방지 규칙이다.
- interface는 계약, abstract class는 기본 구현 공유에 쓰인다.
- static·singleton은 편리하지만 테스트와 확장성 비용이 크다.
- 상태와 생명주기가 명확할 때만 class가 합리적이다.
자기점검 질문 5개
- 현재 클래스는 “상태와 생명주기”를 실제로 관리하는가?
- interface 대신 abstract class를 쓴 이유가 명확한가?
- static 필드/메서드가 전역 상태처럼 쓰이고 있지 않은가?
- Singleton이 테스트를 어렵게 만들고 있지 않은가?
- 이 클래스는 함수/컴포지션으로 대체 가능하지 않은가?
실전 미션 3개
- 상속받는 클래스 하나를 interface + 컴포지션 구조로 리팩터링하라.
- Singleton 하나를 제거하고 의존성 주입 방식으로 바꿔 테스트를 추가하라.
- getter/setter를 쓰는 필드를 일반 메서드로 바꿔 가독성을 비교하라.
This post is licensed under CC BY 4.0 by the author.
