private 필드에 public setter를 붙이면 캡슐화가 아니다 — 클래스 설계의 함정
많은 Java 튜토리얼이 "캡슐화 = 필드를 private으로 하고 getter/setter를 만드는 것"이라고 가르친다. 이 설명은 틀렸다.
생각해 보자: private int balance;에 public void setBalance(int b) { this.balance = b; }를 붙였다. 외부에서 balance = -1000000을 직접 못 할 뿐, setBalance(-1000000)으로 똑같이 음수 잔액을 만들 수 있다. 비밀번호를 자물쇠로 걸어두고 열쇠를 현관에 두는 것과 같다.
진짜 캡슐화는 "객체가 자신의 상태를 스스로 보호하는 것"이다. 이 글은 클래스와 객체의 생성, 접근 제어, 생성자 설계를 다루며 — 왜 setter 없는 설계가 더 강력한지를 보여준다.
클래스와 객체 — 설계도와 인스턴스
클래스(class)는 객체의 설계도다. 필드(상태)와 메서드(동작)를 정의한다. 객체(object)는 그 설계도로 만들어진 실체(instance)다. (JLS §8.1)
// Java 25
public class BankAccount {
// 필드 (상태)
private String owner;
private long balance;
// 생성자
public BankAccount(String owner, long initialBalance) {
this.owner = owner;
this.balance = initialBalance;
}
// 메서드 (동작) — 캡슐화된 상태를 안전하게 변경
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 양수여야 합니다: " + amount);
}
balance += amount;
}
public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출금액은 양수여야 합니다: " + amount);
}
if (amount > balance) {
throw new IllegalStateException("잔액 부족: 현재 " + balance + "원");
}
balance -= amount;
}
public long getBalance() {
return balance;
}
}
이 클래스가 "getter/setter 자동 생성"과 다른 점은 — balance를 직접 수정할 방법(setBalance())이 없다. 잔액 변경은 반드시 deposit()이나 withdraw()를 거쳐야 하며, 이 메서드들은 비즈니스 규칙(양수 검사, 잔액 확인)을 강제한다. 이것이 캡슐화다.
왜 캡슐화가 중요한가 — 내부 메커니즘: 캡슐화는 "객체의 상태를 직접 바꾸지 못하게 하고, 반드시 메서드를 통해서만 바꾸게 하는 것"이다. balance가 private이므로 외부에서 account.balance = -1000을 시도하면 컴파일 에러가 난다. 이 에러가 런타임 에러보다 백배 낫다 — 컴파일 에러는 빌드할 때 즉시 발견되지만, 런타임 에러는 사용자가 결제할 때 발견된다.
비유: 캡슐화는 은행 창구다. 고객이 금고에 직접 들어가서 돈을 빼가면 안 된다 — 반드시 창구원(메서드)을 통해 "5만 원 출금합니다"라고 요청해야 한다. 창구원은 잔액을 확인하고 규칙을 검사한 후 출금한다. 금고(balance)를 private으로 잠그고, 창구원(deposit/withdraw)만 public으로 열어둔다.
this — 자기 참조
this는 현재 객체 자신을 가리키는 참조다. (JLS §15.8.3)
// Java 25
public class Person {
private String name;
public Person(String name) {
this.name = name; // this.name = 필드, name = 매개변수
}
// this로 같은 클래스의 다른 생성자 호출 (생성자 체이닝)
public Person() {
this("Unknown"); // Person("Unknown") 호출 — 첫 번째 줄이어야 함
}
}
생성자에서
this(...)로 다른 생성자를 호출할 때, 반드시 생성자의 첫 번째 줄이어야 한다. (JLS §8.8.7.1)
접근 제어자 — 4단계 보호
Java는 4단계의 접근 제어자를 제공한다. (JLS §6.6)
| 접근 제어자 | 같은 클래스 | 같은 패키지 | 하위 클래스 | 전체 |
|---|---|---|---|---|
public |
O | O | O | O |
protected |
O | O | O* | X |
| (package-private, 생략) | O | O | X | X |
private |
O | X | X | X |
protected는 같은 패키지에서도 접근 가능하다. 하위 클래스가 다른 패키지에 있어도 접근 가능하지만, "같은 패키지" 접근 권한이 더 넓다는 점을 간과하기 쉽다.protected를 "상속 전용"으로 생각하면 함정이다.
접근 제어의 원칙 — 최소 노출
필드는 기본적으로 private으로 한다. 메서드는 외부에서 호출해야 하는 것만 public으로 한다. 내부 구현 메서드는 private으로 숨긴다. 이 원칙을 최소 노출 원칙(principle of least privilege)이라 한다.
// Java 25 — 접근 제어 설계 예
public class TemperatureSensor {
private double currentTemp; // private: 외부 직접 접근 차단
private double offset; // private: 보정값
public double read() { // public: 외부 인터페이스
return currentTemp + calibrate();
}
private double calibrate() { // private: 내부 구현
return offset * 0.95;
}
}
패키지 — 네임스페이스와 접근 단위
패키지는 관련 클래스를 묶는 네임스페이스다. (JLS §7)
// Java 25
package com.example.banking;
public class Account { ... }
- 패키지명은 보통 역도메인(
com.example.project) 형식을 따른다. - 같은 패키지의 클래스는 import 없이 사용 가능.
- package-private(접근 제어자 생략) 멤버는 같은 패키지에서만 접근 가능.
static — 인스턴스가 아닌 클래스에 속하는 것
static 키워드는 필드나 메서드가 인스턴스가 아닌 클래스에 속한다는 것을 나타낸다. (JLS §8.3.1.1, §8.4.3.2)
// Java 25
public class Counter {
private static int total = 0; // 모든 인스턴스가 공유
private int count; // 인스턴스마다 별도
public Counter() {
count = 0;
total++;
}
public void increment() {
count++;
}
public static int getTotal() { // 인스턴스 없이 호출 가능: Counter.getTotal()
return total;
}
}
// Java 25
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment();
c1.increment();
c2.increment();
System.out.println(c1.count); // 2 (인스턴스별)
System.out.println(c2.count); // 1
System.out.println(Counter.getTotal()); // 2 (생성된 인스턴스 수)
static 사용 시 주의
// Java 25 — 위험: static 필드와 인스턴스 메서드의 관계
public class BadExample {
static int shared = 0;
int unique = 0;
// static 메서드에서는 인스턴스 필드에 접근 불가
public static void method() {
// System.out.println(unique); // 컴파일 에러: non-static 필드 접근
System.out.println(shared); // OK: static 필드 접근
}
}
static필드는 클래스당 하나만 존재하므로, 동시성 환경에서 경쟁 조건(race condition)이 발생하기 쉽다. mutablestatic필드는 가급적 피한다. 상수(static final)만static으로 두는 것이 안전하다.
final — 세 가지 의미
final 키워드는 위치에 따라 의미가 다르다. (JLS §4.12.4, §8.1.1.2, §8.3.1.2)
| 위치 | 의미 |
|---|---|
final 변수 |
재할당 불가 (값 고정). 단, 객체의 내부 상태는 변경 가능 |
final 필드 |
한 번만 할당 가능 (생성자에서). 불변성의 기초 |
final 메서드 |
하위 클래스에서 override 불가 |
final 클래스 |
상속 불가 (예: String, Integer) |
// Java 25 — final의 세 가지
final int x = 42;
// x = 99; // 컴파일 에러: final 변수 재할당 불가
final StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // OK: 객체 내부는 변경 가능 (참조가 final일 뿐)
// sb = new StringBuilder("New"); // 컴파일 에러: 참조 자체는 변경 불가
final필드는 참조를 고정할 뿐, 객체의 내용을 불변으로 만들지 않는다.final List<String> list = new ArrayList<>();에서list.add("x")가 가능한 이유다. 완전한 불변 객체를 만들려면record나 수동 설계가 필요하다.
record — 불변 데이터 객체의 한 줄 표현 (Java 16+, JEP-395)
데이터만 담는 불변 객체를 간결하게 정의할 수 있다. (JEP-395)
// Java 25 — 전통적 불변 클래스 (장황함)
public final class Point {
private final double x;
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double x() { return x; }
public double y() { return y; }
@Override
public boolean equals(Object o) { ... }
@Override
public int hashCode() { ... }
@Override
public String toString() { ... }
}
// Java 25 — record (한 줄)
public record Point(double x, double y) {}
record는 컴파일러가 자동으로 생성자, 접근자(x(), y()), equals(), hashCode(), toString()을 만들어 준다. 자세한 내용은 08-record-sealed-enum.md에서 풀어간다.
생성자 — 객체의 초기 상태를 보장하는 계약
생성자는 객체가 사용 가능한 상태로 생성되도록 보장한다. (JLS §8.8)
// Java 25 — 생성자로 불변식(invariant) 보장
public class EmailAddress {
private final String address;
public EmailAddress(String address) {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일: " + address);
}
this.address = address;
}
public String getAddress() {
return address;
}
}
이 클래스의 객체는 @가 포함된 유효한 이메일 주소만 가질 수 있다 — 생성자에서 검증하기 때문이다. 이것이 생성자의 핵심 역할: "이 객체가 존재한다면, 이 객체는 유효하다"라는 계약을 보장하는 것.
생성자에서
public메서드를 호출하면 안 된다 — 하위 클래스가 그 메서드를 override했을 때, 아직 초기화되지 않은 필드에 접근할 수 있다. 생성자에서는private이나final메서드만 호출한다. (JLS §12.5)
실습 — 캡슐화 vs getter/setter 자동 생성
// Java 25 — EncapsulationDemo.java
public class EncapsulationDemo {
// 잘못된 캡슐화: setter가 내부 규칙을 우회
static class BadAccount {
private long balance;
public long getBalance() { return balance; }
public void setBalance(long balance) { this.balance = balance; }
// 누구나 balance를 음수로 만들 수 있음 → 비즈니스 규칙 없음
}
// 올바른 캡슐화: 상태 변경은 메서드를 통해서만, 규칙 강제
static class GoodAccount {
private long balance;
public GoodAccount(long initial) {
if (initial < 0) throw new IllegalArgumentException("초기 잔액은 음수 불가");
this.balance = initial;
}
public long getBalance() { return balance; }
// setBalance() 없음 → 외부에서 직접 수정 불가
public void transfer(GoodAccount target, long amount) {
if (amount <= 0) throw new IllegalArgumentException("이체액은 양수");
if (amount > balance) throw new IllegalStateException("잔액 부족");
this.balance -= amount;
target.balance += amount;
}
}
public static void main(String[] args) {
BadAccount bad = new BadAccount();
bad.setBalance(-1000000); // "성공" — 비즈니스 규칙 위반!
System.out.println("Bad balance: " + bad.getBalance());
GoodAccount g1 = new GoodAccount(10000);
GoodAccount g2 = new GoodAccount(5000);
g1.transfer(g2, 3000);
System.out.println("g1: " + g1.getBalance() + ", g2: " + g2.getBalance());
// g1: 7000, g2: 8000
try {
g1.transfer(g2, -1000); // 예외 발생
} catch (IllegalArgumentException e) {
System.out.println("거부: " + e.getMessage());
}
}
}
java EncapsulationDemo.java
Bad balance: -1000000
g1: 7000, g2: 8000
거부: 이체액은 양수
확인할 것: BadAccount는 비즈니스 규칙 없이 잔액이 음수가 되는 것을 허용한다. GoodAccount는 setBalance()가 없고, 상태 변경은 transfer()를 통해서만 가능하며 규칙을 강제한다. 이것이 캡슐화의 본질이다.
요약 — 이 글의 결론
- 캡슐화는 "private + getter/setter"가 아니다. 객체의 상태를 외부에서 직접 변경하지 못하게 하고, 변경은 비즈니스 규칙을 강제하는 메서드를 통해서만 허용하는 것이다. 무분별한 setter는 캡슐화를 깨뜨린다.
- 접근 제어는 최소 노출 원칙을 따른다. 필드는
private, 공개 인터페이스만public. 내부 구현은 숨긴다. static은 클래스 수준, 인스턴스는 객체 수준.static필드는 모든 인스턴스가 공유하므로 동시성에 주의한다. 상수(static final)만static으로 두는 것이 안전하다.final은 세 가지 의미를 갖는다. 변수(재할당 금지), 메서드(override 금지), 클래스(상속 금지).final필드는 참조를 고정할 뿐 객체 내용까지 불변으로 만들지는 않는다.- 생성자는 불변식 계약이다. "이 객체가 존재한다 = 이 객체는 유효하다"를 보장해야 한다. 생성자에서 검증하지 않으면, 무효한 상태의 객체가 시스템 전체에 퍼진다.
생각해 볼 문제
BankAccount클래스에서balance필드를public으로 변경하면 어떤 일이 벌어질 수 있는가? 구체적인 시나리오를 3가지 이상 생각해 보자.static필드를 mutable로 유지해야 하는 상황은 언제인가? 그때 동시성을 어떻게 보장할 수 있는가?final List<String> list = new ArrayList<>();에서list.add("x")가 가능한 이유를 설명하라. 완전히 불변인 리스트를 만들려면?- record의 accessor 메서드가
getX()가 아니라x()인 이유는 무엇인가? 이 설계가 JavaBeans 규약과 어떻게 다른가? - 생성자에서 virtual method call(override 가능한 메서드 호출)이 위험한 이유를 구체적인 코드 예로 설명하라.
참고
- JLS §8 - Classes - 접근 2026-07-10
- JLS §6.6 - Access Control - 접근 2026-07-10
- JLS §8.8 - Constructors - 접근 2026-07-10
- JLS §4.12.4 - final Variables - 접근 2026-07-10
- JEP-395: Records - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 07. interface and abstract (0) | 2026.07.12 |
|---|---|
| Java - 06. inheritance and polymorphism (0) | 2026.07.12 |
| Java - 04. array and string (0) | 2026.07.12 |
| Java - 03. operator control (0) | 2026.07.12 |
| Java - 02. types (0) | 2026.07.10 |
