PUBLISHED
shallow encapsulation
작성일: 2025.01.28

자바스크립트는 객체 지향 프로그래밍을 지원하기 위해 지속적으로 언어 기능을 확장해왔다. class 문법의 도입, private field(#)의 추가는 그 대표적인 예다. 특히 #을 이용한 프라이빗 멤버는 기존의 관례적 약속(예: _private)과 달리, 언어 차원에서 접근을 차단한다는 점에서 의미가 크다. 이로 인해 자바스크립트는 더 이상 단순한 스크립팅 언어가 아니라, 대규모 애플리케이션에서도 일정 수준의 객체 모델링을 감당할 수 있는 언어가 되었다.
그러나 문제는 여기서 시작된다. 많은 개발자가 #를 사용했다는 사실만으로 “캡슐화가 완료되었다”고 착각한다. 하지만 캡슐화(encapsulation)는 단순히 접근을 막는 것(access control) 이 아니라, 상태 변경의 통로를 통제하는 것에 가깝다. 다시 말해, 외부에서 내부 상태를 어떤 방식으로든 변경할 수 있다면, 그것은 이미 캡슐화가 깨진 상태다.
다음과 같은 클래스는 겉보기에는 캡슐화가 잘 이루어진 것처럼 보인다. 내부 상태는 프라이빗 필드로 숨겨져 있고, 외부에서는 getter를 통해서만 값을 조회할 수 있다. setter가 없기 때문에 외부에서 값을 수정할 수 없는 구조처럼 보이기도 한다.
class Test {
#array = [{ value: 1000 }];
get array() {
return [...this.#array];
}
}그러나 실제로는 외부 코드에서 내부 상태를 문제없이 변경할 수 있다. getter를 통해 반환된 배열에 접근해 내부 객체의 값을 수정하면, 그 변경 사항은 그대로 객체 내부에 반영된다.
const test = new Test();
test.array[0].value = 2000;
console.log(test.array); // [{ value: 2000 }]이 현상은 자바스크립트의 참조 타입 특성에서 비롯된다. 스프레드 연산자를 사용하면 배열 자체는 새로 생성되지만, 배열 안에 포함된 객체까지 복사되지는 않는다. 결국 외부와 내부가 동일한 객체 참조를 공유하게 되고, 외부에서의 수정이 내부 상태 변경으로 이어진다. 이처럼 접근은 차단되어 있지만 참조를 통해 상태가 변경될 수 있는 상태를 나는 얕은 캡슐화, 즉 shallow encapsulation이라고 부른다.
얕은 캡슐화가 문제가 되는 이유는 상태 변경의 통제권이 객체 외부로 흘러나가기 때문이다. 객체가 스스로 유지해야 할 불변 조건이 외부 코드에 의해 쉽게 깨질 수 있고, 상태가 언제 어디서 변경되었는지 추적하기도 어려워진다. 이는 유지보수성과 디버깅 난이도를 크게 높인다.
이 문제를 해결하기 위해 흔히 떠올리는 방법이 Object.freeze를 사용하는 것이다. 반환되는 값을 얼려버리면 외부에서 수정할 수 없을 것처럼 보인다.
class Test {
#array = [{ value: 1000 }];
get array() {
return Object.freeze([...this.#array]);
}
}그러나 이 역시 근본적인 해결책은 아니다. Object.freeze 또한 얕은 동결만 수행하기 때문이다. 배열 자체는 수정할 수 없게 되지만, 배열 내부에 포함된 객체는 여전히 변경 가능하다. 결과적으로 참조 타입이 중첩된 구조에서는 동일한 문제가 반복된다.
이러한 한계를 보완하기 위해 나는 deepFreeze라는 유틸리티 함수를 사용한다. 이 함수는 객체의 모든 중첩된 속성을 재귀적으로 순회하며 동결한다. 즉, 객체 그래프 전체를 불변 상태로 만든다.
function deepFreeze(obj) {
Object.freeze(obj);
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (
typeof value === "object" &&
value !== null &&
!Object.isFrozen(value)
) {
deepFreeze(value);
}
});
return obj;
}이제 getter에서 반환하는 값을 deepFreeze로 감싸면, 외부에서는 어떤 방식으로도 내부 상태를 변경할 수 없다.
class Test {
#array = [{ value: 1000 }];
get array() {
return deepFreeze([...this.#array]);
}
}다만 deepFreeze는 어디까지나 방어적 수단이라는 점을 인식해야 한다. 깊은 객체 구조일수록 성능 비용이 증가하고, 불변성을 강하게 전제한 설계를 요구한다. 경우에 따라서는 값을 그대로 반환하는 대신, 필요한 정보만 제공하는 쿼리 메서드를 설계하는 편이 더 나은 선택일 수 있다.
예를 들어 내부 배열 전체를 노출하는 대신, 특정 값을 조회하는 메서드를 제공하면 참조 자체를 외부로 내보내지 않아도 된다. 이러한 설계는 애초에 얕은 캡슐화 문제가 발생할 여지를 제거한다.
결국 캡슐화의 완성도는 #를 사용했는지 여부가 아니라, 외부에 어떤 형태의 값을 노출했는지에 달려 있다. 참조 타입을 그대로 반환하는 순간, 프라이빗 필드의 의미는 크게 퇴색된다. 캡슐화는 문법의 문제가 아니라 설계의 문제이며, shallow encapsulation을 인식하지 못하면 “캡슐화된 것처럼 보이는 코드”를 계속해서 만들어내게 된다.