Java 접근 제어자 범위와 캡슐화의 목적을 실무 관점에서 정리하고, getter/setter 남용을 피하는 방법과 불변 객체 기본 패턴을 예제로 설명합니다.
클래스를 만들다 보면 “일단 public으로 열어두고 나중에 정리할까?”라는 유혹이 자주 생깁니다. 그런데 시간이 지나면 필드가 여기저기서 직접 수정되고, 원인을 찾기 어려운 버그가 늘어나요. 접근 제어자와 캡슐화는 이런 상황을 초기에 막아주는 가장 값싼 안전장치입니다.
핵심 개념 (Java 접근 제어자와 캡슐화가 중요한 이유)

캡슐화의 핵심은 “데이터(상태)를 숨기고, 의미 있는 동작(행위)만 공개한다”는 겁니다. 비유하자면 자동차 엔진룸을 운전자가 직접 만지게 두는 대신, 페달/핸들처럼 안전한 인터페이스만 제공하는 것과 비슷해요. 접근 제어자는 그 인터페이스의 “출입문”을 어디까지 열지 결정합니다.
Java 접근 제어자 4종: 어디까지 보이게 할까?
Java(17+)에서 접근 제어자는 public / protected / default(package-private) / private 네 가지입니다. 특히 default는 키워드가 없다는 점 때문에 놓치기 쉬운데, 실무에서는 “패키지 내부 구현을 숨기기”에 매우 유용합니다.
| 접근 제어자 | 같은 클래스 | 같은 패키지 | 다른 패키지(상속 X) | 다른 패키지(상속 O) | 주로 쓰는 곳 |
|---|---|---|---|---|---|
private |
✅ | ❌ | ❌ | ❌ | 필드, 내부 헬퍼 메서드 |
default(없음) |
✅ | ✅ | ❌ | ❌ | 패키지 내부 구현(모듈 경계) |
protected |
✅ | ✅ | ❌ | ✅ | 상속 확장 포인트(신중히) |
public |
✅ | ✅ | ✅ | ✅ | 외부에 공개하는 API |
여기서 중요한 포인트는 이거예요.
- **public은 “돌이키기 어려운 약속”**입니다. 한 번 외부에 공개되면, 나중에 바꾸기가 매우 어려워요(호환성 문제).
- protected는 상속을 전제로 한 공개라서, 의도치 않은 결합을 만들기 쉽습니다. “확장 포인트”로 설계한 게 아니라면 과감히 피하는 편이 안전해요.
- default(package-private)는 실무에서 가장 과소평가된 무기입니다. 패키지를 하나의 “작은 모듈”로 보고, 외부에는 최소만 공개할 수 있어요.
getter/setter 남용이 캡슐화를 망치는 패턴
필드를 private으로 바꿔놓고, 모든 필드에 대해 public getter/setter를 자동 생성하면 겉보기엔 캡슐화처럼 보이지만 실제로는 **“public 필드와 거의 같은 효과”**가 납니다.
- 어디서든 상태를 마음대로 바꿀 수 있음 → 불변식(invariant) 유지가 어려움
- 변경 시점이 분산됨 → 디버깅이 어려움
- 도메인 로직이 객체 밖으로 새어 나감 → “빈 껍데기(Anemic Domain Model)”가 되기 쉬움
대신, 의미 있는 메서드로 상태 변경을 제한해 보세요. 예를 들어 setStatus()가 아니라 activate(), suspend(reason)처럼 “왜 바꾸는지”가 드러나는 API가 캡슐화에 더 잘 맞습니다.
불변 객체(Immutable) 맛보기: 변경 대신 “새로 만들기”
불변 객체는 생성 이후 상태가 바뀌지 않는 객체입니다. 실무에서 불변 객체는 다음 장점이 큽니다.
- 상태 변경 버그가 줄어듦(특히 멀티스레드/캐시/공유 객체에서 강력)
- 객체의 유효성(불변식)을 생성 시점에 강제하기 쉬움
- 테스트가 단순해짐
Java 17+에서는 record가 불변 데이터 모델에 특히 잘 맞습니다(단, 참조 필드가 가변이면 “얕은 불변”이 될 수 있다는 점은 주의가 필요합니다).
flowchart LR
A[""public setter로 상태 변경""] --> B[""여러 곳에서 변경 발생""]
B --> C[""불변식 깨짐/원인 추적 어려움""]
D[""의미 있는 메서드/불변 객체""] --> E[""변경 경로 제한""]
E --> F[""안전한 상태 관리""]
접근 제어와 불변 설계는 “변경 경로를 줄여서” 버그 표면적을 낮춥니다.
코드 예제 (접근 제어자 + setter 남용 방지 + 불변 객체)
아래 코드는 한 파일로 바로 실행 가능합니다. setter로 모든 걸 열어두는 대신, 의미 있는 메서드로 변경을 제한하고, 불변 객체(record) 도 함께 보여줍니다.
public class AccessModifiersDemo {
public static void main(String[] args) {
Account account = new Account("A-100", Money.wons(10_000));
// account.balance = Money.wons(0); // 컴파일 에러: private
// account.setBalance(...); // 애초에 setter를 제공하지 않음
account.deposit(Money.wons(5_000));
account.withdraw(Money.wons(3_000));
System.out.println("id=" + account.getId());
System.out.println("balance=" + account.getBalance().amount());
// 불변 객체 예시: Money는 record로 만들고, 연산 결과는 새 인스턴스를 반환
Money a = Money.wons(1_000);
Money b = a.plus(Money.wons(500));
System.out.println("a=" + a.amount() + ", b=" + b.amount()); // a는 그대로
}
// 외부에 공개되는 API만 public
static final class Account {
private final String id;
private Money balance;
public Account(String id, Money initialBalance) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("id is required");
if (initialBalance == null) throw new IllegalArgumentException("initialBalance is required");
if (initialBalance.amount() < 0) throw new IllegalArgumentException("initialBalance must be >= 0");
this.id = id;
this.balance = initialBalance;
}
public String getId() {
return id;
}
// getter는 “조회” 목적일 때만 최소로 제공합니다.
public Money getBalance() {
return balance;
}
// setter 대신 의미 있는 메서드로 변경을 제한
public void deposit(Money money) {
requirePositive(money);
this.balance = this.balance.plus(money);
}
public void withdraw(Money money) {
requirePositive(money);
if (this.balance.amount() < money.amount()) {
throw new IllegalStateException("insufficient balance");
}
this.balance = this.balance.minus(money);
}
private static void requirePositive(Money money) {
if (money == null) throw new IllegalArgumentException("money is required");
if (money.amount() <= 0) throw new IllegalArgumentException("money must be > 0");
}
}
// Java 17 record: 불변 데이터에 적합 (단, 내부 참조가 가변이면 얕은 불변)
record Money(long amount) {
public Money {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
}
public static Money wons(long amount) {
return new Money(amount);
}
public Money plus(Money other) {
if (other == null) throw new IllegalArgumentException("other is required");
return new Money(this.amount + other.amount);
}
public Money minus(Money other) {
if (other == null) throw new IllegalArgumentException("other is required");
long result = this.amount - other.amount;
if (result < 0) throw new IllegalArgumentException("result must be >= 0");
return new Money(result);
}
}
}
실무 팁 (접근 제어자/캡슐화 베스트 프랙티스)
💡 실무에서는: “public은 최소, default는 적극적으로”를 기본값으로 두세요
- 클래스/메서드는 기본적으로 package-private(default) 로 시작하고, 정말 외부 계약(API)일 때만
public을 여는 편이 유지보수에 유리합니다. - 패키지를 기능 단위로 잘 나누면, default 접근 제어자만으로도 “모듈 경계”가 생겨서 캡슐화가 훨씬 쉬워집니다.
💡 실무에서는: setter 대신 “의도를 드러내는 메서드” + “검증 위치 고정”을 추천합니다
setEmail(),setStatus()처럼 범용 setter는 도메인 규칙을 객체 밖으로 밀어내기 쉽습니다.- 검증 로직이 여기저기 흩어지지 않게, 생성자/팩토리/상태 변경 메서드 내부로 모아두면 버그가 줄어듭니다.
- 불변이 가능한 값 객체(금액, 기간, 좌표, 비율 등)는
record로 먼저 검토해 보세요.
핵심 요약: 접근 제어자는 “변경/사용 가능한 범위”를 줄여 버그를 예방합니다.
getter/setter를 습관적으로 열기보다, 의미 있는 메서드로 상태 변경을 제한해 보세요.
불변 객체(record)는 상태 관리 비용을 크게 낮춰주는 좋은 출발점입니다.
다음 글: #08 상속 vs 조합 — 실무에서의 선택
'JAVA' 카테고리의 다른 글
| Java 인터페이스 vs 추상 클래스 실전 구분법 (default 메서드, 다중 구현 패턴까지) (0) | 2026.02.16 |
|---|---|
| Java 상속 vs 조합(Composition) — 실무에서의 선택 가이드 (0) | 2026.02.16 |
| Java 메서드 잘 만드는 법: 파라미터 설계부터 반환 타입, 오버로딩, 길이 원칙까지 (0) | 2026.02.14 |
| Java 클래스와 객체 — 왜 나눠야 할까? (설계 기초부터 생성자·this·네이밍까지) (0) | 2026.02.14 |
| Java 연산자와 제어문 핵심 정리 — if/switch, 반복문, 비교 연산 실수 방지 (0) | 2026.02.13 |