접근 제어와 변경 경계
private·default·protected·public의 허용 범위를 구분하고 회원 저장소의 필드 대신 안전한 행동만 공개합니다.
객체가 불변식을 책임지려면 외부 코드가 내부 상태를 우회해 바꾸지 못하게 해야 합니다.
접근 제어자는 개발자를 불편하게 만드는 잠금이 아니라 허용된 변경 경로를 분명하게 만드는 도구입니다.
가장 좁게 숨기고 협력에 필요한 기능만 공개하는 것이 기본 전략입니다.
private 접근 제한
public final class PrivateFieldViolation {
public static void main(String[] args) {
Member member = new Member("[email protected]", 40);
System.out.println(member.age);
}
}
final class Member {
private final String email;
private final int age;
Member(String email, int age) {
this.email = email;
this.age = age;
}
}error: age has private access in MemberPrivateFieldViolation과 Member가 같은 파일과 같은 패키지에 있어도 private 멤버는 선언한 클래스 밖에서 접근할 수 없습니다.
필요한 읽기 행동을 Member가 공개해야 합니다.
public int age() {
return age;
}단순 getter도 공개 계약입니다.
외부가 정말 원시 값을 알아야 하는지, isAdult()이나 describe()처럼 의도 중심 행동이 더 적합한지 판단합니다.
네 가지 접근 수준
| 접근 제어자 | 같은 클래스 | 같은 패키지 | 다른 패키지 자식 | 그 밖의 외부 |
|---|---|---|---|---|
private | 허용 | 차단 | 차단 | 차단 |
생략(default) | 허용 | 허용 | 패키지가 다르면 차단 | 차단 |
protected | 허용 | 허용 | 상속 경로에서 허용 | 차단 |
public | 허용 | 허용 | 허용 | 허용 |
소스에 default라고 쓰는 접근 제어자는 없습니다.
아무 접근 제어자도 적지 않은 상태를 package-private 또는 default 접근이라고 부릅니다.
protected는 같은 패키지 접근과 다른 패키지의 상속 접근을 결합하므로 단순히 “자식만”이라고 외우면 틀립니다.
접근 범위가 넓을수록 좋은 API가 아닙니다.
public은 모든 호출자가 의존할 수 있어 변경 비용이 가장 큽니다.
내부 구현은 private, 패키지 협력만 필요한 타입과 메서드는 package-private, 외부가 사용해야 하는 최소 행동만 public으로 둡니다.
필드 은닉과 상태 변경
public final class ControlledMember {
public static void main(String[] args) {
Member member = new Member("[email protected]", 40);
System.out.println(member.correctAge(41));
System.out.println(member.correctAge(121));
System.out.println(member.describe());
}
private static final class Member {
private final String email;
private int age;
Member(String email, int age) {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
this.email = email;
this.age = age;
}
public boolean correctAge(int correctedAge) {
if (correctedAge < 14 || correctedAge > 120) {
return false;
}
age = correctedAge;
return true;
}
public String describe() {
return email + "=" + age;
}
}
}true
false
[email protected]=41이 축약 예제의 ControlledMember.main은 같은 바깥 클래스 안의 중첩 Member에 접근할 수 있습니다. 여기서는 직접 대입 대신 correctAge를 호출해 허용 범위를 지킵니다.
correctAge가 정정할 값을 검사하고 성공한 변경만 반영합니다.
필드가 private이므로 새 변경 경로가 필요할 때 클래스 작성자가 규칙을 선택합니다.
무조건 모든 필드에 setter를 만들면 이름만 메서드일 뿐 직접 대입과 같은 자유를 다시 엽니다.
public void setAge(int age) {
this.age = age;
}이보다 celebrateBirthday, correctAge, activate처럼 사용 의도와 허용 규칙을 담는 행동이 낫습니다.
나이 정정의 성공과 거절 뒤 남는 값
같은 Member에 correctAge(41) 성공 뒤 correctAge(121) 거절이 이어져 41이 남습니다. 실제 두 요청의 검사 전·대입 후·거절 후 상태를 비교합니다.
ControlledMember에서 age 40으로 만든 같은 객체에 correctAge(41), correctAge(121)을 순서대로 호출하면 true와 false를 반환하고 마지막 age는 41이다.
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다
같은 회원의 나이는 40→41로 바뀌며 121로의 정정은 거절됩니다.
ControlledMember의 같은 회원에 연속 호출합니다. 시작 나이는 40, 허용 범위는 14~120입니다.
| 호출 순서 | 호출 전 age | 검사·반환 | 호출 후 age |
|---|---|---|---|
correctAge(41) | 40 | 범위 안 · 필드 대입 실행 | 41 |
correctAge(121) | 41 | 상한 초과 · 필드 대입 전 반환 | 41 유지 |
correctAge(41)호출 전
age=40. 허용 범위 안이므로 필드에41을 대입하고true를 반환합니다.호출 후:
age=41correctAge(121)호출 전
age=41. 상한을 넘어 필드 대입 전에false를 반환합니다.호출 후:
age=41유지
두 호출 뒤 describe()의 결과는 [email protected]=41입니다. 이 표는 메서드의 검사 결과이며, 같은 바깥 클래스의 main까지 private 접근이 차단된다는 뜻은 아닙니다.
이 예의 조건
- 같은 Kim 객체에 두 요청을 순차 적용합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 Kim 객체의 age 40→correctAge 41 범위 통과 true/41→correctAge 121 대입 전 false/41 유지→[email protected]=41이다. 서로 독립된 요청이 아니라 순차적으로 수행하는 두 호출이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 Kim 객체의 age 40→correctAge 41 범위 통과 true/41→correctAge 121 대입 전 false/41 유지→[email protected]=41이다. 서로 독립된 요청이 아니라 순차적으로 수행하는 두 호출이다. |
| 전체 읽기 정보 | 나이 정정의 성공과 거절 뒤 남는 값 나이 정정의 성공과 거절 뒤 남는 값 ControlledMember에서 age 40으로 만든 같은 객체에 correctAge(41), correctAge(121)을 순서대로 호출하면 true와 false를 반환하고 마지막 age는 41이다. 허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 같은 회원의 나이는 40→41로 바뀌며 121로의 정정은 거절됩니다. ControlledMember 의 같은 회원에 연속 호출합니다. 시작 나이는 40 , 허용 범위는 14~120 입니다. correctAge 호출 전후의 나이와 반환값 호출 순서 호출 전 age 검사·반환 호출 후 age correctAge(41) 40 범위 안 · true 필드 대입 실행 41 correctAge(121) 41 상한 초과 · false 필드 대입 전 반환 41 유지 correctAge(41) 호출 전 age=40 . 허용 범위 안이므로 필드에 41 을 대입하고 true 를 반환합니다. 호출 후: age=41 correctAge(121) 호출 전 age=41 . 상한을 넘어 필드 대입 전에 false 를 반환합니다. 호출 후: age=41 유지 두 호출 뒤 describe() 의 결과는 [email protected]=41 입니다. 이 표는 메서드의 검사 결과이며, 같은 바깥 클래스의 main 까지 private 접근이 차단된다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 처음 상태 Kim의 age는 40입니다. |
|
|
| 성공 변경 41을 대입하고 true를 반환합니다. |
|
|
| 거절 보존 121은 범위 밖이므로 대입 전 false입니다. |
|
|
나이 정정의 성공과 거절 뒤 남는 값
같은 Member에 correctAge(41) 성공 뒤 correctAge(121) 거절이 이어져 41이 남습니다. 실제 두 요청의 검사 전·대입 후·거절 후 상태를 비교합니다.
ControlledMember에서 age 40으로 만든 같은 객체에 correctAge(41), correctAge(121)을 순서대로 호출하면 true와 false를 반환하고 마지막 age는 41이다.
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다
같은 회원의 나이는 40→41로 바뀌며 121로의 정정은 거절됩니다.
ControlledMember의 같은 회원에 연속 호출합니다. 시작 나이는 40, 허용 범위는 14~120입니다.
| 호출 순서 | 호출 전 age | 검사·반환 | 호출 후 age |
|---|---|---|---|
correctAge(41) | 40 | 범위 안 · 필드 대입 실행 | 41 |
correctAge(121) | 41 | 상한 초과 · 필드 대입 전 반환 | 41 유지 |
correctAge(41)호출 전
age=40. 허용 범위 안이므로 필드에41을 대입하고true를 반환합니다.호출 후:
age=41correctAge(121)호출 전
age=41. 상한을 넘어 필드 대입 전에false를 반환합니다.호출 후:
age=41유지
두 호출 뒤 describe()의 결과는 [email protected]=41입니다. 이 표는 메서드의 검사 결과이며, 같은 바깥 클래스의 main까지 private 접근이 차단된다는 뜻은 아닙니다.
이 예의 조건
- 같은 Kim 객체에 두 요청을 순차 적용합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 동일한 Kim 객체의 age 40→correctAge 41 범위 통과 true/41→correctAge 121 대입 전 false/41 유지→[email protected]=41이다. 서로 독립된 요청이 아니라 순차적으로 수행하는 두 호출이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 동일한 Kim 객체의 age 40→correctAge 41 범위 통과 true/41→correctAge 121 대입 전 false/41 유지→[email protected]=41이다. 서로 독립된 요청이 아니라 순차적으로 수행하는 두 호출이다. |
| 전체 읽기 정보 | 나이 정정의 성공과 거절 뒤 남는 값 나이 정정의 성공과 거절 뒤 남는 값 ControlledMember에서 age 40으로 만든 같은 객체에 correctAge(41), correctAge(121)을 순서대로 호출하면 true와 false를 반환하고 마지막 age는 41이다. 허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 같은 회원의 나이는 40→41로 바뀌며 121로의 정정은 거절됩니다. ControlledMember 의 같은 회원에 연속 호출합니다. 시작 나이는 40 , 허용 범위는 14~120 입니다. correctAge 호출 전후의 나이와 반환값 호출 순서 호출 전 age 검사·반환 호출 후 age correctAge(41) 40 범위 안 · true 필드 대입 실행 41 correctAge(121) 41 상한 초과 · false 필드 대입 전 반환 41 유지 correctAge(41) 호출 전 age=40 . 허용 범위 안이므로 필드에 41 을 대입하고 true 를 반환합니다. 호출 후: age=41 correctAge(121) 호출 전 age=41 . 상한을 넘어 필드 대입 전에 false 를 반환합니다. 호출 후: age=41 유지 두 호출 뒤 describe() 의 결과는 [email protected]=41 입니다. 이 표는 메서드의 검사 결과이며, 같은 바깥 클래스의 main 까지 private 접근이 차단된다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 처음 상태 Kim의 age는 40입니다. |
|
|
| 성공 변경 41을 대입하고 true를 반환합니다. |
|
|
| 거절 보존 121은 범위 밖이므로 대입 전 false입니다. |
|
|
클래스 접근 수준
최상위 클래스에는 public 또는 package-private만 사용할 수 있습니다.
public 최상위 클래스 이름은 파일 이름과 같아야 합니다.
한 소스 파일에 public 최상위 클래스를 여러 개 선언할 수 없습니다.
public final class MemberRegistry { }
final class MemberValidator { }MemberRegistry는 다른 패키지가 사용할 수 있고 MemberValidator는 같은 패키지 구현만 사용합니다.
private이나 protected 최상위 클래스는 허용되지 않지만, 중첩 클래스에는 네 접근 수준을 모두 적용할 수 있습니다.
패키지가 하위 경로처럼 보여도 자동 포함 관계가 없다는 점을 다시 확인합니다.
members.model
members.model.internal두 패키지는 서로 다른 package-private 경계입니다.
internal에서 model의 package-private 멤버에 접근할 수 없습니다.
MemberRegistry 공개 API
public final class AccessControlledMemberRegistry {
public static void main(String[] args) {
MemberRegistry registry = new MemberRegistry(2);
registry.add("[email protected]", 40);
registry.add("[email protected]", 50);
System.out.println(registry.memberAt(0));
System.out.println("total=" + registry.totalAge());
}
private static final class MemberRegistry {
private final Member[] members;
private int size;
public MemberRegistry(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity");
members = new Member[capacity];
}
public boolean add(String email, int age) {
if (size == members.length) return false;
members[size++] = new Member(email, age);
return true;
}
public String memberAt(int index) {
checkIndex(index);
return members[index].describe();
}
public int totalAge() {
int total = 0;
for (int index = 0; index < size; index++) {
total += members[index].age();
}
return total;
}
private void checkIndex(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
}
}
private static final class Member {
private final String email;
private final int age;
private Member(String email, int age) {
if (email == null || email.isBlank() || age < 14 || age > 120) {
throw new IllegalArgumentException("invalid member");
}
this.email = email;
this.age = age;
}
private String describe() {
return email + "=" + age;
}
private int age() { return age; }
}
}[email protected]=40
total=90이 예제의 main은 members와 size를 직접 다루지 않고 add, memberAt, totalAge를 호출합니다. 같은 바깥 클래스 안이므로 private 접근이 언어 차원에서 금지된 것은 아닙니다.
원문 main의 두 정상 입력은 위 결과를 만듭니다. 다만 이 버전의 추가 실패 경로에는 아직 문제가 있습니다. members[size++] = new Member(email, age)는 왼쪽 인덱스의 size++를 먼저 평가한 뒤 생성자를 실행합니다. 빈 이메일로 생성자가 예외를 던지면 배열 저장은 일어나지 않아도 증가한 size는 되돌아가지 않습니다. 처음 size=0인 저장소라면 0번 칸은 null인데 size=1이 되어 이후 집계가 실패할 수 있습니다.
실패한 추가가 상태를 보존하게 하려면 위 add만 다음처럼 바꿉니다. 용량이 가득 찬 경우의 false는 유지하고, 잘못된 회원의 생성 예외는 호출자에게 전달합니다.
public boolean add(String email, int age) {
if (size == members.length) return false;
Member candidate = new Member(email, age);
members[size] = candidate;
size++;
return true;
}생성 성공 → 배열 저장 → 개수 증가로 문장을 나누면 생성 예외에서 뒤 두 문장을 실행하지 않습니다. 배열 대입의 평가 순서에 따라 예외가 앞의 부수 효과까지 자동으로 취소하지 않는다는 점도 함께 확인하세요. 캡슐화된 회원 저장소는 생성 예외를 false로 바꾸는 정책까지 구현합니다.
checkIndex는 공개할 이유가 없는 내부 보조 메서드라 private입니다.
Member와 MemberRegistry는 같은 바깥 클래스인 AccessControlledMemberRegistry에 나란히 선언된 private 중첩 타입입니다.
AccessControlledMemberRegistry의 add, memberAt, totalAge를 반환 계약과 내부 작업으로 비교하고, 같은 바깥 클래스의 두 private 중첩 타입이라는 범위를 명시한다.
호출자는 행동을 요청하고 설명이나 합계만 받는다
내부 배열이나 Member 참조를 반환하지 않는 공개 메서드의 계약을 비교합니다.
AccessControlledMemberRegistry의 중첩 MemberRegistry가 선언한 public 행동입니다. 중첩 타입 자체가 모든 패키지에 공개된 것은 아닙니다.
| public 행동 | 호출자가 받는 것 | 내부에서 하는 일 |
|---|---|---|
add(email, age) | 성공 용량이 꽉 차면 | 용량 검사 후
|
memberAt(index) | 회원의 설명
|
|
totalAge() | 나이 합계 내부 배열이 아님 |
|
add(email, age)- 성공하면
true, 용량이 꽉 차면false입니다. 용량 검사 뒤 내부에서members[size++] = new Member(email, age)를 평가합니다. memberAt(index)checkIndex(index)가0 ≤ index < size를 검사한 뒤members[index].describe()를 반환합니다. 결과는String이며Member참조가 아닙니다.totalAge()0 ≤ index < size를 순회하며members[index].age()를 합산합니다. 결과는int이며 내부 배열을 반환하지 않습니다.
members, size, checkIndex는 private입니다. 다만 두 타입은 같은 바깥 클래스의 중첩 타입이므로 바깥 main의 접근까지 차단되지는 않습니다.
생성 실패는 별도입니다. new Member(...)가 예외를 던지면 먼저 평가한 size++는 되돌아가지 않습니다. 이 add가 실패 시 상태를 보존한다고 일반화하지 않습니다.
연습 문제
0에서 시작하고 최댓값을 생성자로 받는 Counter를 만드세요. 최댓값은 0 이상이며 음수 설정은 생성 단계에서 거절합니다. 최댓값이 0인 경우 첫 증가 결과와 남는 값도 먼저 예측하세요.
count 필드는 private으로 숨기고 increment()가 최댓값을 넘으면 false, 성공하면 true를 반환하게 하세요.
현재 값은 value()로만 읽습니다.
해설 보기
public final class LimitedCounter {
public static void main(String[] args) {
Counter counter = new Counter(2);
System.out.println(counter.increment());
System.out.println(counter.increment());
System.out.println(counter.increment());
System.out.println("value=" + counter.value());
}
private static final class Counter {
private final int maximum;
private int count;
Counter(int maximum) {
if (maximum < 0) throw new IllegalArgumentException("maximum");
this.maximum = maximum;
}
boolean increment() {
if (count == maximum) return false;
count++;
return true;
}
int value() {
return count;
}
}
}true
true
false
value=2외부는 count를 최댓값보다 크게 직접 대입할 수 없습니다.
상태 변경은 increment라는 한 경로로만 일어납니다. 최댓값 0이면 시작부터 count == maximum이므로 첫 호출도 false이고 값은 0입니다. 최댓값 2에서는 두 성공 뒤 count=2가 되고, 세 번째 호출은 증가 전에 반환해 2를 유지합니다. 이 한 파일의 바깥 main은 중첩 타입의 private 필드에 접근할 수 있지만 예제에서는 행동 메서드만 사용합니다.