접근 제어자
public·private·protected가 클래스 내부·하위 클래스·외부 호출에 허용하는 접근 범위를 구분해 캡슐화를 설계합니다.
클래스는 데이터(속성)와 로직(메서드)을 묶어 객체를 생성하는 틀입니다.
여기서 중요한 객체 지향 원칙이 캡슐화(Encapsulation)입니다.
캡슐화는 내부 구현을 숨기고, 외부에 꼭 필요한 부분만 선택적으로 노출하는 방식입니다.
이 원칙을 지키면 코드 응집도가 높아지고, 외부에서 잘못된 방식으로 상태를 바꾸는 일을 줄여 안정성을 확보할 수 있습니다.
타입스크립트는 클래스의 속성과 메서드에 대한 접근 수준을 제어하기 위해 세 가지 접근 제어자(Access Modifiers)를 제공합니다.
이들은 클래스 멤버가 클래스 외부, 자식 클래스, 또는 오직 클래스 내부에서만 접근 가능하도록 설정하는 역할을 합니다.
접근 제어자는 포함 경계마다 다른 계약을 연다 — 상태 추적
접근 제한 설명 중 실제 BankAccount는 10000→15000→13000과 20000 출금 거절을 포함한다. 계좌/요청/결과의 같은 역할로 메서드 조건이 상태를 바꾸고 거절이 보존하는 것을 추적할 가치가 있다.
TYPE / RUNTIME / CONTRACT
누가 멤버를 직접 사용할 수 있는지 비교합니다
public·protected·private는 허용된 코드 위치가 다릅니다. 아래는 TypeScript의 직접 멤버 접근이며 #private 런타임 은닉과는 별도로 읽습니다.
외부 myCar.speed는 타입 오류이다. TypeScript private·readonly는 컴파일 검사이며 JavaScript #private 런타임 경계와 다르다.
코드·조건·비교표와 보충 설명
PUBLIC
외부 호출에 공개
Product.name·price·getDetails는 외부에서 접근합니다. price는 readonly가 아니므로 1200000에서 1150000으로 변경할 수 있습니다.
PROTECTED
파생 클래스가 활용
Car.drive는 상속한 speed와 accelerate에 접근해 60에서 70으로 올립니다. 외부 myCar.speed 직접 접근은 오류입니다.
PRIVATE
선언 클래스의 구현 안에서 사용
BankAccount는 _balance를 직접 노출하지 않고 입출금 메서드를 제공합니다. 실제 예제는 10000 → 15000 → 13000이고 20000 출금은 거부합니다.
READONLY / RUNTIME
변경 제한과 은닉은 별개
Student.entryYear는 public readonly 매개변수 속성입니다. TypeScript의 readonly·private는 검사 규칙입니다. #private는 별도의 JavaScript 런타임 기능이며 더 안쪽 단계가 아닙니다.
protected에는 파생 클래스의 receiver 규칙도 적용됩니다. 접근 제어자가 금액 검증을 대신하지 않으며, BankAccount의 조건은 작성된 메서드가 검사합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기와 입금 | 잔액: 10000→15000 / 호출: deposit 5000 | 초기 10000에서 5000을 입금해 15000이다 | public 동작이 작성된 조건으로 private 잔액을 바꾼다 |
| 허용 출금 | 잔액: 15000→13000 / 호출: withdraw 2000 | 2000 출금 후 13000이다 | 허용된 업무 조건일 때만 잔액을 차감한다 |
| 거절 출금 | 잔액: 13000 유지 / 요청: 20000 거절 | 20000 출금은 거절되고 13000을 유지한다 | private 접근 제한이 금액 검증을 대신하는 것은 아니다 |
접근 제어자는 포함 경계마다 다른 계약을 연다 — 상태 추적
접근 제한 설명 중 실제 BankAccount는 10000→15000→13000과 20000 출금 거절을 포함한다. 계좌/요청/결과의 같은 역할로 메서드 조건이 상태를 바꾸고 거절이 보존하는 것을 추적할 가치가 있다.
TYPE / RUNTIME / CONTRACT
누가 멤버를 직접 사용할 수 있는지 비교합니다
public·protected·private는 허용된 코드 위치가 다릅니다. 아래는 TypeScript의 직접 멤버 접근이며 #private 런타임 은닉과는 별도로 읽습니다.
외부 myCar.speed는 타입 오류이다. TypeScript private·readonly는 컴파일 검사이며 JavaScript #private 런타임 경계와 다르다.
코드·조건·비교표와 보충 설명
PUBLIC
외부 호출에 공개
Product.name·price·getDetails는 외부에서 접근합니다. price는 readonly가 아니므로 1200000에서 1150000으로 변경할 수 있습니다.
PROTECTED
파생 클래스가 활용
Car.drive는 상속한 speed와 accelerate에 접근해 60에서 70으로 올립니다. 외부 myCar.speed 직접 접근은 오류입니다.
PRIVATE
선언 클래스의 구현 안에서 사용
BankAccount는 _balance를 직접 노출하지 않고 입출금 메서드를 제공합니다. 실제 예제는 10000 → 15000 → 13000이고 20000 출금은 거부합니다.
READONLY / RUNTIME
변경 제한과 은닉은 별개
Student.entryYear는 public readonly 매개변수 속성입니다. TypeScript의 readonly·private는 검사 규칙입니다. #private는 별도의 JavaScript 런타임 기능이며 더 안쪽 단계가 아닙니다.
protected에는 파생 클래스의 receiver 규칙도 적용됩니다. 접근 제어자가 금액 검증을 대신하지 않으며, BankAccount의 조건은 작성된 메서드가 검사합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기와 입금 | 잔액: 10000→15000 / 호출: deposit 5000 | 초기 10000에서 5000을 입금해 15000이다 | public 동작이 작성된 조건으로 private 잔액을 바꾼다 |
| 허용 출금 | 잔액: 15000→13000 / 호출: withdraw 2000 | 2000 출금 후 13000이다 | 허용된 업무 조건일 때만 잔액을 차감한다 |
| 거절 출금 | 잔액: 13000 유지 / 요청: 20000 거절 | 20000 출금은 거절되고 13000을 유지한다 | private 접근 제한이 금액 검증을 대신하는 것은 아니다 |
세 접근 제어자는 어디에서 멤버를 읽고 쓸 수 있는지로 구분하면 더 명확합니다.
클래스 내부, 상속받은 자식 클래스, 인스턴스 외부라는 세 위치를 기준으로 비교해 봅시다.
public (공개)
public은 가장 기본적인 접근 제어자입니다.
public으로 선언된 속성이나 메서드는 클래스 외부의 어디에서든 자유롭게 접근할 수 있습니다.
자바스크립트 클래스의 기본 동작과 동일하며, 타입스크립트에서 접근 제어자를 명시하지 않으면 해당 멤버는 자동으로 public으로 간주됩니다.
class Product {
public name: string; // 'public' 명시
price: number; // 접근 제어자를 명시하지 않으면 기본적으로 'public'
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
public getDetails(): string { // 'public' 명시
return `제품명: ${this.name}, 가격: ${this.price}원`;
}
}
const laptop = new Product("노트북", 1200000);
console.log(laptop.name); // '노트북' - public 속성에 외부에서 직접 접근
console.log(laptop.price); // '1200000' - public 속성에 외부에서 직접 접근
console.log(laptop.getDetails()); // '제품명: 노트북, 가격: 1200000원' - public 메서드 호출
// public 멤버는 외부에서 자유롭게 변경 가능
laptop.price = 1150000;
console.log(laptop.price); // 1150000public 멤버는 객체의 인터페이스, 즉 외부에서 객체를 어떻게 사용해야 하는지를 정의하는 데 사용됩니다.
private (비공개)
private은 클래스 멤버가 오직 해당 클래스 내부에서만 접근 가능하도록 제한합니다.
클래스 외부에서는 물론, 심지어 이 클래스를 상속받은 자식 클래스에서도 private 멤버에 직접 접근할 수 없습니다.
이는 타입 검사를 따르는 코드에서 특정 상태를 직접 조작하지 않고 공개 동작을 사용하도록 안내할 때 유용합니다. 아래 입출금 계산은 유한한 일반 숫자를 전제로 읽고, 접근 제어와 실제 값 검증을 따로 확인합니다.
class BankAccount {
private _balance: number; // 계좌 잔액은 private으로 선언하여 외부에서 직접 변경 불가
constructor(initialBalance: number) {
if (initialBalance < 0) {
throw new Error("초기 잔액은 0보다 작을 수 없습니다.");
}
this._balance = initialBalance;
}
// public 메서드를 통해 private 속성에 간접적으로 접근
public getBalance(): number {
return this._balance;
}
public deposit(amount: number): void {
if (amount > 0) {
this._balance += amount;
console.log(`${amount}원 입금. 현재 잔액: ${this._balance}원`);
} else {
console.log("0원보다 큰 금액을 입금해 주세요.");
}
}
public withdraw(amount: number): void {
if (amount > 0 && this._balance >= amount) {
this._balance -= amount;
console.log(`${amount}원 출금. 현재 잔액: ${this._balance}원`);
} else {
console.log("잔액이 부족하거나 유효하지 않은 금액입니다.");
}
}
}
const myAccount = new BankAccount(10000);
// console.log(myAccount._balance); // Error: '_balance' 속성은 private이며 'BankAccount' 클래스 내에서만 접근할 수 있습니다.
myAccount.deposit(5000); // 5000원 입금. 현재 잔액: 15000원
myAccount.withdraw(2000); // 2000원 출금. 현재 잔액: 13000원
console.log(myAccount.getBalance()); // 13000 - public 메서드를 통해 안전하게 잔액 확인
myAccount.withdraw(20000); // 잔액이 부족하거나 유효하지 않은 금액입니다. (내부 로직에 의해 제어)private 멤버는 클래스의 내부 비밀을 보호하는 역할을 합니다.
타입 검사를 따르는 일반적인 직접 접근은 public 메서드(예: deposit, withdraw)로 모아 _balance를 변경하도록 설계합니다. TypeScript private는 런타임 보안 경계가 아니며, 공개 메서드가 필요한 값 조건을 실제로 검사하는지도 별도로 확인해야 합니다.
참고: 자바스크립트의 최신 문법에는
#을 사용하는 프라이빗 필드(Private Fields)가 있습니다. 타입스크립트의private키워드는 컴파일 시점 타입 검사로 접근을 제한하지만, 자바스크립트로 변환된 뒤에는 접근 가능한 형태로 남을 수 있습니다(물론 접근하지 않는 것이 권장됩니다). 반면#프라이빗 필드는 런타임에서도 완전히 프라이빗하게 동작합니다. 어느 표기를 더 많이 쓰는지보다 요구한 경계를 먼저 정합니다. 타입 검사 중 직접 멤버 접근을 제한할지, JavaScript 런타임에서도 필드를 은닉할지에 따라private와#필드를 구분합니다. 둘 다 잘못된 입출금 값을 자동으로 검증하지는 않습니다.
protected (보호된)
protected는 private보다는 덜 제한적이고 public보다는 더 제한적인 접근 제어자입니다.
protected로 선언된 속성이나 메서드는 해당 클래스 내부와 이 클래스를 상속받은 자식 클래스 내부에서만 접근할 수 있습니다.
클래스 외부에서는 접근이 불가능합니다.
이는 부모 클래스에서 정의된 특정 로직이나 데이터가 자식 클래스에서 재활용되거나 확장되어야 하지만, 외부에는 노출되지 않아야 할 때 유용합니다.
class Vehicle {
protected speed: number; // protected 속성
constructor(initialSpeed: number) {
this.speed = initialSpeed;
}
protected accelerate(amount: number): void { // protected 메서드
this.speed += amount;
console.log(`속도 증가: ${this.speed}`);
}
public getCurrentSpeed(): number {
return this.speed; // public 메서드를 통해 간접 접근
}
}
class Car extends Vehicle {
private gear: number = 1;
constructor(initialSpeed: number, initialGear: number) {
super(initialSpeed); // 부모 클래스(Vehicle)의 생성자 호출
this.gear = initialGear;
}
public drive(): void {
console.log(`현재 기어: ${this.gear}`);
this.accelerate(10); // 자식 클래스에서 부모의 protected 메서드 접근 가능
console.log(`차량이 ${this.speed}km/h로 주행 중입니다.`); // 자식 클래스에서 부모의 protected 속성 접근 가능
}
// public 래퍼를 구현하면 외부 호출을 허용할 수 있습니다.
// public setProtectedSpeed(newSpeed: number) {
// this.speed = newSpeed; // 허용: 파생 클래스 내부에서 자신의 protected 멤버에 접근
// }
}
const myCar = new Car(60, 3);
myCar.drive(); // 현재 기어: 3, 속도 증가: 70, 차량이 70km/h로 주행 중입니다.
console.log(myCar.getCurrentSpeed()); // 70
// console.log(myCar.speed); // Error: 'speed' 속성은 protected이며 'Vehicle' 클래스 및 해당 하위 클래스 내에서만 접근할 수 있습니다.
// myCar.accelerate(20); // Error: 'accelerate' 속성은 protected이며...protected 멤버는 상속 계층 구조 내에서 데이터를 공유하고 메서드를 호출할 수 있도록 하여, 서브클래싱(Subclassing)을 통한 기능 확장을 용이하게 합니다.
생성자의 매개변수에 접근 제어자 사용 (복습 및 강조)
이전 절에서 잠시 언급했지만, 생성자의 매개변수에 접근 제어자를 붙여 속성을 선언하고 초기화하는 것을 간결하게 처리할 수 있습니다.
이는 클래스 선언에서 자주 사용되는 패턴입니다.
class Student {
// public, private, protected 또는 readonly를 매개변수 앞에 붙이면
// 해당 매개변수는 자동으로 클래스의 해당 접근 제어자를 가진 속성으로 선언되고 초기화됩니다.
constructor(
public studentId: string, // public 속성으로 선언
private _studentName: string, // private 속성으로 선언
protected grade: number, // protected 속성으로 선언
readonly entryYear: number // readonly + public 속성으로 선언 (읽기 전용)
) {
// 별도로 this.studentId = studentId; 등을 작성할 필요가 없습니다.
}
public getInfo(): string {
return `ID: ${this.studentId}, 이름: ${this._studentName}, 학년: ${this.grade}`;
}
// private _studentName에 대한 getter
get studentName(): string {
return this._studentName;
}
}
const s1 = new Student("S001", "김민수", 3, 2022);
console.log(s1.studentId); // S001 (public)
console.log(s1.getInfo()); // ID: S001, 이름: 김민수, 학년: 3
// console.log(s1._studentName); // Error: private
// console.log(s1.grade); // Error: protected
s1.studentId = "S002"; // public이므로 변경 가능
// s1.entryYear = 2023; // Error: readonly이므로 변경 불가이 문법은 특히 DTO(Data Transfer Object)나 간단한 모델 클래스를 정의할 때 코드의 양을 크게 줄여줍니다.
접근 제어자는 타입스크립트에서 객체 지향의 캡슐화를 구현하는 핵심적인 도구입니다.
public, private, protected의 차이점을 명확히 이해하고, 각 멤버의 성격에 맞게 적절한 접근 제어자를 사용하는 것은 견고하고 유지보수하기 쉬운 클래스를 설계하는 데 매우 중요합니다.
외부 호출, 내부 상태, 상속 확장 지점을 분리해 보면 접근 제어자를 단순 암기가 아니라 변경 위험을 줄이는 설계 도구로 사용할 수 있습니다.
잔액을 바꾸기 전에 값 검사하기
원 BankAccount의 정상 수치 10000 → 15000 → 13000은 맞습니다. 하지만 number에는 Infinity와 NaN도 포함됩니다. 예를 들어 deposit(Infinity)는 원 amount > 0 조건을 통과합니다. private를 붙였다는 이유로 공개 메서드의 값 조건까지 완성되지는 않습니다.
다음은 원 클래스를 바꾸지 않고, 정수 원 단위·0 이상의 초기 잔액·유한한 양수 입금으로 학습 범위를 정한 별도 예제입니다. 최대 안전 정수를 넘는 합도 거절합니다. 실제 금융 시스템의 통화·동시성·영속성 모델을 구현한 것은 아닙니다.
class WholeWonAccount {
private balance: number;
constructor(initial: number) {
if (!Number.isSafeInteger(initial) || initial < 0) {
throw new RangeError('초기 잔액은 안전한 0 이상의 정수');
}
this.balance = initial;
}
deposit(amount: number): void {
const next = this.balance + amount;
if (!Number.isSafeInteger(amount) || amount <= 0 || !Number.isSafeInteger(next)) {
throw new RangeError('입금과 결과 잔액은 안전한 정수');
}
this.balance = next;
}
getBalance(): number { return this.balance; }
}
const wholeAccount = new WholeWonAccount(10000);
wholeAccount.deposit(5000);
console.log(wholeAccount.getBalance()); // 15000
// wholeAccount.deposit(Infinity); // 값 검사에서 RangeError, 잔액 변경 전 거절amount의 타입은 number라 Infinity 호출도 컴파일됩니다. 실행할 때 값 검사를 통과한 경우에만 balance에 next를 저장합니다. 초기값 검사와 변경 검사도 별도로 수행합니다. 공식 클래스 가시성 설명의 정적 접근 제한과 구분합니다.
혼자 허용 경계 확인하기
원 myAccount 예제의 마지막 잔액 13000에서 withdraw(14000)를 시도하면 잔액은 얼마일까요? WholeWonAccount 예제에서는 현재 15000에서 입금 0과 NaN을 각각 별도 실행하여 거절 여부와 잔액 변경 시점을 확인합니다. Student의 entryYear를 외부에서 2023으로 대입하는 것과 s1.studentName을 getter로 읽는 것은 각각 허용될까요?
동작과 접근 해설
원 withdraw(14000)는 잔액 부족 안내를 출력하고 잔액 13000을 유지합니다. 별도 계좌의 0과 NaN 입금은 각각 RangeError를 던지므로 balance 대입까지 도달하지 않아 기존 15000을 유지합니다. 오류 뒤 잔액을 읽으려면 각 호출을 try/catch로 감싸 실행합니다. entryYear는 readonly라 대입이 타입 오류이고, public getter인 studentName은 김민수를 반환합니다. readonly·private는 타입 검사 규칙이며 객체 전체를 자동으로 동결하지 않습니다.
접근 제어자를 고를 때는 멤버가 누구의 계약인지부터 나누면 좋습니다.
호출부가 의존해야 하는 공개 API인지, 클래스 내부의 불변식을 지키는 상태인지, 상속 클래스만 재사용할 확장 지점인지에 따라 접근 범위가 달라집니다.