캡슐화를 설명할 때 흔히 필드(클래스 안에 있는 변수)는 private로 감추고, 접근은 게터(getter, 값을 읽어 오는 메서드)와 세터(setter, 값을 바꾸는 메서드)로 열어 준다고 정리한다. 틀린 말은 아니지만 이 문장만 외우면 캡슐화가 지키려던 진짜 목적을 놓친다. private는 프로그래밍 언어가 제공하는 접근 제어자(다른 코드에서 이 필드를 직접 건드릴 수 없게 막는 키워드)일 뿐이고, 그 옆에 아무 조건 없는 세터를 하나 붙이는 순간 문법으로 막았던 문을 다시 열어 버리는 셈이 된다.
회원 이메일을 예로 든다. 이메일 주소를 바꾸는 절차는 대개 새 주소로 인증 코드를 보내고, 그 코드를 확인한 다음에야 실제로 바뀐다. 그런데 코드는 흔히 이렇게 짜인다.
class User {
email: string;
constructor(email: string) { this.email = email; }
getEmail() { return this.email; }
setEmail(email: string) { this.email = email; }
}
const user = new User("[email protected]");
user.setEmail("아무값이나");
console.log(user.getEmail()); // 아무값이나
필드 email 앞에 private가 없어 이 예시는 은닉조차 하지 않았지만, private를 붙여도 상황은 똑같다. setEmail은 문자열이면 뭐든 받아 그대로 저장한다. 회원가입 화면이든 관리자 도구든 배치 스크립트든 setEmail을 호출하는 곳마다 인증 절차를 매번 직접 작성해야 하고, 한 곳이라도 빠뜨리면 인증 없이 이메일이 바뀌는 코드가 생긴다.
게터와 세터 짝짓기가 캡슐화를 무력화하는 이유
필드마다 게터와 세터를 대칭으로 만드는 습관은 필드를 public(어디서든 접근 가능하게 열어 둔 상태)으로 둔 것과 결과가 같다. 읽고 쓰는 경로가 메서드 이름만 다를 뿐 아무 조건 없이 뚫려 있기 때문이다. 이런 클래스를 데이터만 있고 규칙은 클래스 밖에 흩어진 빈약한 도메인 모델(Anemic Domain Model)이라 부른다. 이메일 인증 로직을 화면마다 따로 작성해야 하고, 한 곳이라도 빠뜨리면 그 구멍으로 규칙이 깨진다.
캡슐화가 지키려는 대상은 필드가 아니라 규칙이다. private 필드에 접근하는 유일한 통로가 검증 없는 세터라면 그 필드는 사실상 공개된 것과 같다. 접근 제어자는 문법적인 방화벽일 뿐이고, 방화벽 안쪽에 구멍이 있는지는 세터가 실제로 무엇을 확인하는지로 판단해야 한다.
조회는 열고 변경은 절차로 감싼다
판단 기준은 단순하다. 값을 화면에 보여 주는 용도라면 게터를 열어도 된다. 이메일을 마이페이지에 표시하려고 값을 읽는 코드에는 규칙이 끼어들 여지가 없다. 문제는 항상 쓰는 쪽에서 생긴다. 값을 바꾸는 통로를 세터 하나로 뭉뚱그리지 않고, 그 값을 바꾸는 실제 절차를 메서드 이름과 매개변수(메서드가 받는 입력값)로 드러내면 규칙을 강제할 자리가 생긴다.
class User {
#email: string;
#pendingEmail: string | null = null;
constructor(email: string) { this.#email = email; }
get email() { return this.#email; }
requestEmailChange(next: string, sendCode: (to: string) => void) {
if (!next.includes("@")) throw new Error("이메일 형식이 아님");
this.#pendingEmail = next;
sendCode(next);
}
confirmEmailChange(code: string, verify: (code: string) => boolean) {
if (!this.#pendingEmail) throw new Error("변경 요청이 없음");
if (!verify(code)) throw new Error("인증 코드 불일치");
this.#email = this.#pendingEmail;
this.#pendingEmail = null;
}
}
const user = new User("[email protected]");
user.requestEmailChange("[email protected]", (to) => console.log(to + "로 코드 발송"));
// [email protected]로 코드 발송
user.confirmEmailChange("000000", (code) => code === "000000");
console.log(user.email); // [email protected]
setEmail 하나가 요청과 확인, 두 메서드로 나뉘었다. #email처럼 이름 앞에 샵(#) 기호를 붙이면 자바스크립트에서 클래스 바깥은 그 필드를 아예 볼 수도 없는 진짜 사적 필드가 된다. 이제 이메일은 인증 코드를 확인해야만 바뀌고, 관리자 도구든 배치 스크립트든 User 객체를 통하는 이상 이 절차를 건너뛸 방법이 없다. 세터 하나를 지웠을 뿐인데 규칙을 어길 수 있는 자리 자체가 사라졌다.
바뀌면 안 되는 값은 세터를 만들지 않는다
모든 필드에 변경 절차가 필요한 것은 아니다. 주문 번호나 생성 시각처럼 한 번 정해지면 그대로여야 하는 필드는 세터를 아예 만들지 않는 편이 정직하다. readonly(생성자에서 값을 넣은 뒤로는 바꿀 수 없다고 언어가 강제하는 키워드)를 붙이면 그 필드는 생성자에서만 값이 들어가고 이후에는 코드만 봐도 바뀌지 않는다는 사실이 드러난다.
class Order {
readonly id: string;
#status: "PLACED" | "SHIPPED" | "CANCELLED" = "PLACED";
constructor(id: string) { this.id = id; }
ship() {
if (this.#status !== "PLACED") throw new Error("배송 처리할 수 없는 상태");
this.#status = "SHIPPED";
}
cancel() {
if (this.#status === "SHIPPED") throw new Error("이미 배송된 주문은 취소 불가");
this.#status = "CANCELLED";
}
get status() { return this.#status; }
}
const order = new Order("A-1001");
order.ship();
console.log(order.status); // SHIPPED
order.cancel(); // Error: 이미 배송된 주문은 취소 불가
status에는 setStatus가 없다. 상태를 바꾸는 통로는 ship과 cancel 두 개뿐이고, 각 메서드는 지금 상태에서 그 전환이 허용되는지부터 확인한다. setStatus(status)를 하나 열어 두면 배송 완료된 주문을 다시 PLACED로 되돌리는 코드도 아무 문제 없이 컴파일된다. 그런 코드가 실제로 짜였는지와 별개로, 짤 수 있다는 사실 자체가 설계의 구멍이다.
세터를 완전히 없애기 애매할 때
프레임워크가 특정 형태의 클래스를 요구하거나 외부 라이브러리가 세터 존재를 전제하는 경우도 있다. 이럴 때는 세터 내부에서 생성자와 똑같은 검증을 반복한다. 나이 필드라면 음수를 막고, 가격 필드라면 값이 0 이상인지 확인한다. 세터를 없앨 수 없다고 검증까지 생략할 이유는 없다. 중요한 것은 메서드 이름이 get이냐 set이냐가 아니라, 그 메서드를 통과할 때 규칙이 지켜지는가이다.
게터와 세터 자체가 나쁜 것은 아니다. 문제는 필드 개수만큼 기계적으로 찍어 내는 습관이다. 클래스를 새로 만들 때 필드마다 get과 set을 자동 생성하기 전에, 이 값을 바깥에서 마음대로 바꿔도 되는지 한 번은 따져 봐야 한다. 안 된다면 절차를 담은 메서드로 바꾸고, 아예 안 바뀌어야 한다면 세터 없이 생성자에만 맡긴다.