캡슐화는 필드에 private를 붙이고 게터와 세터로 통로를 좁히는 데서 끝나지 않는다. 필드를 감춰도 그 필드가 가리키는 객체를 게터로 그대로 내보내면, 호출하는 코드는 그 객체를 통해 내부를 마음대로 주무를 수 있다. 문자열이나 숫자는 값 자체가 복사되지만, 배열이나 객체는 값이 있는 위치를 가리키는 참조(reference)만 넘어간다. 게터가 이 참조를 그대로 돌려주는 습관에서 캡슐화의 구멍이 가장 흔히 생긴다.

게터가 참조를 그대로 넘길 때

장바구니 클래스를 예로 든다. 담긴 물건 목록을 배열로 갖고 있고, 게터로 그 목록을 꺼내 화면에 보여 준다.


class Cart {
#items: string[] = [];
add(item: string) { this.#items.push(item); }
getItems() { return this.#items; }
}

const cart = new Cart();
cart.add("키보드");
const items = cart.getItems();
items.push("마우스");
console.log(cart.getItems()); // ["키보드", "마우스"]


cart.add를 한 번도 부르지 않았는데 마우스가 장바구니에 들어갔다. getItems가 #items 배열의 참조를 그대로 돌려줬기 때문에, 밖에서 그 배열에 push를 호출하면 원본 배열이 그대로 바뀐다. #items 앞의 샵 기호는 필드에 새 값을 직접 대입하는 것만 막을 뿐, 그 필드가 가리키는 배열을 통째로 내주는 것까지는 막지 못한다.

방어적 복사로 참조를 끊는다

해법은 간단하다. 내보낼 때 원본 대신 복사본을 넘겨 외부가 내부를 직접 건드리지 못하게 막는다. 이 방법을 방어적 복사(defensive copy)라 부른다.


class Cart {
#items: string[] = [];
add(item: string) { this.#items.push(item); }
getItems() { return [...this.#items]; }
}

const cart = new Cart();
cart.add("키보드");
const items = cart.getItems();
items.push("마우스");
console.log(cart.getItems()); // ["키보드"]


getItems가 스프레드 문법(..., 배열 항목을 풀어 새 배열에 담는 문법)으로 새 배열을 만들어 돌려주니, 밖에서 그 배열을 건드려도 #items는 그대로다. 장바구니 내용을 바꾸는 유일한 방법은 add뿐이다.


같은 문제가 생성자로 들어오는 값에서도 벌어진다. 회의 시작 시각을 Date 객체로 받는 클래스를 예로 든다.


class Meeting {
#startAt: Date;
constructor(startAt: Date) { this.#startAt = startAt; }
get startAt() { return this.#startAt; }
}

const date = new Date("2026-08-01T10:00:00");
const meeting = new Meeting(date);
date.setMonth(date.getMonth() + 1);
console.log(meeting.startAt); // 2026-09-01T10:00:00


meeting을 만든 뒤 date 변수를 다시 손댈 일이 없다고 여기기 쉽지만, 생성자가 넘겨받은 Date 객체를 참조 그대로 저장했기 때문에 바깥에서 date를 바꾸면 회의 시작 시각도 같이 밀린다. Meeting은 startAt을 바꾸는 메서드가 하나도 없는데도 값이 바뀐다. Meeting 클래스만 봐서는 원인을 찾을 수 없다.


class Meeting {
#startAt: Date;
constructor(startAt: Date) { this.#startAt = new Date(startAt.getTime()); }
get startAt() { return new Date(this.#startAt.getTime()); }
}


들어올 때 한 번, 나갈 때 한 번, 두 군데서 복사한다. 생성자에서 복사하지 않으면 바깥이 원본을 바꿔 내부가 오염되고, 게터에서 복사하지 않으면 내부 값을 받아 간 바깥이 그 참조로 내부를 오염시킨다. 한쪽만 복사하면 절반만 막은 것이다.

아예 못 바꾸게, 불변 객체

방어적 복사는 참조가 나갈 때마다 신경 써야 하는 부담이 남는다. 더 근본적인 해법은 객체 자체를 바꿀 수 없게 만드는 불변 객체(immutable object)다. 필드를 전부 readonly(생성자에서 값을 넣은 뒤로는 다시 대입할 수 없게 언어가 강제하는 키워드)로 선언하고, 값을 바꾸는 메서드는 자신을 고치는 대신 새 인스턴스를 돌려준다.


class Point {
x: number;
y: number;
constructor(x: number, y: number) { this.x = x; this.y = y; }
moveBy(dx: number, dy: number) { this.x += dx; this.y += dy; }
}

const start = new Point(0, 0);
const path = [start];
start.moveBy(10, 0);
path.push(start);
console.log(path[0], path[1]); // Point{x:10,y:0} Point{x:10,y:0}


이동 경로를 기록하려고 배열에 시작점을 넣어 뒀지만, moveBy가 start 자신을 직접 고쳐 버려서 배열 첫 칸과 마지막 칸이 같은 객체를 가리킨다. path[0]도 이동한 뒤의 좌표로 보인다. 서로 다른 시점의 좌표를 남기려던 의도가 무너졌다.


class Point {
readonly x: number;
readonly y: number;
constructor(x: number, y: number) { this.x = x; this.y = y; }
moveBy(dx: number, dy: number) { return new Point(this.x + dx, this.y + dy); }
}

const start = new Point(0, 0);
const path = [start];
const moved = start.moveBy(10, 0);
path.push(moved);
console.log(path[0], path[1]); // Point{x:0,y:0} Point{x:10,y:0}


moveBy가 새 Point를 만들어 돌려주고 원본 start는 그대로 남는다. readonly를 붙인 필드는 생성자 밖에서 대입하려는 순간 컴파일 에러가 나므로, moveBy 안에서 this.x를 고치는 코드 자체를 쓸 수 없다. 방어적 복사를 챙길 필요도 사라진다.

복사를 남발하면 안 되는 지점도 있다

모든 값을 다 이렇게 감쌀 필요는 없다. 클래스 경계를 넘지 않고 그 안에서만 만들어지고 쓰이다 사라지는 값이라면 굳이 복사하거나 불변으로 만들 이유가 없다. 수만 건짜리 목록을 getItems 한 번 부를 때마다 통째로 복사하는 것도 부담이 될 수 있다. 이럴 때는 TypeScript의 ReadonlyArray로 게터 반환 타입을 선언해 컴파일 시점에 push 호출을 막는 방법도 있다. 다만 컴파일러가 잡아 주는 약속일 뿐, 실행 시점에는 여전히 같은 배열이다.


Object.freeze도 흔히 오해한다. 배열이나 객체를 얼리면 최상위 속성 재할당은 막히지만, 안에 들어 있는 또 다른 객체까지 얼지는 않는다. 얕은 방어를 전체 방어로 착각하면 여기서도 구멍이 생긴다.


판단 기준은 하나다. 이 값이 클래스 경계를 넘어가는가. 넘어간다면 복사하거나 처음부터 불변으로 설계한다. 넘지 않는 값이라면 그대로 써도 된다. 캡슐화를 private 하나로만 여기면, 참조가 새는 이런 구멍은 계속 남는다.