본문 바로가기

접근 제어와 변경 경계

private·default·protected·public의 허용 범위를 구분하고 회원 저장소의 필드 대신 안전한 행동만 공개합니다.

객체가 불변식을 책임지려면 외부 코드가 내부 상태를 우회해 바꾸지 못하게 해야 합니다.

접근 제어자는 개발자를 불편하게 만드는 잠금이 아니라 허용된 변경 경로를 분명하게 만드는 도구입니다.

가장 좁게 숨기고 협력에 필요한 기능만 공개하는 것이 기본 전략입니다.


private 접근 제한

lab/PrivateFieldViolation.java
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 Member

PrivateFieldViolation과 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으로 둡니다.


필드 은닉과 상태 변경

src/ControlledMember.java
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입니다.

correctAge 호출 전후의 나이와 반환값
호출 순서호출 전 age검사·반환호출 후 age
correctAge(41)40

범위 안 · true

필드 대입 실행

41
correctAge(121)41

상한 초과 · false

필드 대입 전 반환

41 유지
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 동일Kim객체age40→correctAge41범위통과true/41→correctAge121대입전false/41유지→[email protected]=41. 서로독립요청아닌순차두호출. 처음 상태 Kim · age=40 correctAge(41) 14~120 안 → true 대입 후 age=41 다음 요청 correctAge(121) 기존 age=41 범위 밖 → false 대입 전 반환 age=41 유지 describe() 결과 [email protected]=41 두 요청은 같은 객체에 순차 적용 · 첫 성공의 41이 두 번째 거절 뒤에도 남음
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 동일Kim객체age40→correctAge41범위통과true/41→correctAge121대입전false/41유지→[email protected]=41. 서로독립요청아닌순차두호출. 처음 상태 Kim · age=40 correctAge(41) 14~120 안 → true 대입 후 age=41 다음 요청 correctAge(121) 기존 age=41 범위 밖 → false 대입 전 반환 age=41 유지 describe() 결과 [email protected]=41 같은 객체에 순서대로 두 요청 거절은 직전의 41을 보존
  1. correctAge(41)

    호출 전 age=40. 허용 범위 안이므로 필드에 41을 대입하고 true를 반환합니다.

    호출 후: age=41

  2. correctAge(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입니다.

  • 요청: correctAge(41)
  • 조건: 14–120 안
  • 허용 범위 확인 뒤에만 대입합니다.
성공 변경

41을 대입하고 true를 반환합니다.

  • age: 41
  • 다음 요청: correctAge(121)
  • 첫 성공 값이 다음 거절의 기존 상태가 됩니다.
거절 보존

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입니다.

correctAge 호출 전후의 나이와 반환값
호출 순서호출 전 age검사·반환호출 후 age
correctAge(41)40

범위 안 · true

필드 대입 실행

41
correctAge(121)41

상한 초과 · false

필드 대입 전 반환

41 유지
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 동일Kim객체age40→correctAge41범위통과true/41→correctAge121대입전false/41유지→[email protected]=41. 서로독립요청아닌순차두호출. 처음 상태 Kim · age=40 correctAge(41) 14~120 안 → true 대입 후 age=41 다음 요청 correctAge(121) 기존 age=41 범위 밖 → false 대입 전 반환 age=41 유지 describe() 결과 [email protected]=41 두 요청은 같은 객체에 순차 적용 · 첫 성공의 41이 두 번째 거절 뒤에도 남음
허용한 변경만 반영하고 거절 뒤에도 age를 보존한다 동일Kim객체age40→correctAge41범위통과true/41→correctAge121대입전false/41유지→[email protected]=41. 서로독립요청아닌순차두호출. 처음 상태 Kim · age=40 correctAge(41) 14~120 안 → true 대입 후 age=41 다음 요청 correctAge(121) 기존 age=41 범위 밖 → false 대입 전 반환 age=41 유지 describe() 결과 [email protected]=41 같은 객체에 순서대로 두 요청 거절은 직전의 41을 보존
  1. correctAge(41)

    호출 전 age=40. 허용 범위 안이므로 필드에 41을 대입하고 true를 반환합니다.

    호출 후: age=41

  2. correctAge(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입니다.

  • 요청: correctAge(41)
  • 조건: 14–120 안
  • 허용 범위 확인 뒤에만 대입합니다.
성공 변경

41을 대입하고 true를 반환합니다.

  • age: 41
  • 다음 요청: correctAge(121)
  • 첫 성공 값이 다음 거절의 기존 상태가 됩니다.
거절 보존

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

src/AccessControlledMemberRegistry.java
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 중첩 타입입니다.

MemberRegistry의 공개 행동과 반환하지 않는 구현

AccessControlledMemberRegistry의 add, memberAt, totalAge를 반환 계약과 내부 작업으로 비교하고, 같은 바깥 클래스의 두 private 중첩 타입이라는 범위를 명시한다.

호출자는 행동을 요청하고 설명이나 합계만 받는다

내부 배열이나 Member 참조를 반환하지 않는 공개 메서드의 계약을 비교합니다.

AccessControlledMemberRegistry의 중첩 MemberRegistry가 선언한 public 행동입니다. 중첩 타입 자체가 모든 패키지에 공개된 것은 아닙니다.

공개 행동의 반환 계약과 내부 구현
public 행동호출자가 받는 것내부에서 하는 일
add(email, age)

성공 true

용량이 꽉 차면 false

용량 검사 후

members[size++] = new Member(email, age)

memberAt(index)

회원의 설명 String

Member 참조가 아님

checkIndex(index)로 0 ≤ index < size 검사 후

members[index].describe()

totalAge()

나이 합계 int

내부 배열이 아님

0 ≤ index < size를 순회하며

members[index].age() 합산

호출자는 행동을 요청하고 설명이나 합계만 받는다 main공개행동요청. addboolean/memberAtString/totalAgeint반환,private members/size/checkIndex는구현내사용. 참조/배열반환없음. 원add size++선평가실패보존안됨명시/본문보완과구분. 호출자 main 행동 요청·결과 사용 MemberRegistry 공개 메서드 경계 add(email,age) → boolean memberAt(index) → String totalAge() → int 내부 데이터 읽기·갱신 private 구현 members / size / checkIndex 검사·순회·저장 로직 반환받는 값 boolean / String int · 내부 참조 없음 원 add는 size++가 생성보다 먼저 평가됨 · 생성 실패 계약은 본문 보완 참조
호출자는 행동을 요청하고 설명이나 합계만 받는다 main공개행동요청. addboolean/memberAtString/totalAgeint반환,private members/size/checkIndex는구현내사용. 참조/배열반환없음. 원add size++선평가실패보존안됨명시/본문보완과구분. 호출자 main 행동을 요청 공개 메서드 호출 MemberRegistry의 공개 경계 add(email,age) boolean 저장 여부 memberAt(index) String 회원 설명 totalAge() int 합계 내부 데이터 읽기·갱신 private 구현 경계 members / size checkIndex(index) 호출자에게 반환 boolean/String/int Member 참조·배열 아님 상태를 직접 꺼내지 않음 원 add: size++를 먼저 평가 생성 실패 상태는 본문 보완 참조
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()로만 읽습니다.

해설 보기
src/LimitedCounter.java
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 필드에 접근할 수 있지만 예제에서는 행동 메서드만 사용합니다.