안동민 개발노트

본문 시작

접근 제어와 변경 경계

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처럼 사용 의도와 허용 규칙을 담는 행동이 낫습니다.

나이 정정의 성공과 거절 뒤 남는 값

ControlledMember에서 age 40으로 만든 같은 객체에 correctAge(41), correctAge(121)을 순서대로 호출하면 true와 false를 반환하고 마지막 age는 41이다.

ControlledMember의 같은 회원에 연속 호출합니다. 시작 나이는 40, 허용 범위는 14~120입니다.

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

범위 안 · true

필드 대입 실행

41
correctAge(121)41

상한 초과 · false

필드 대입 전 반환

41 유지
  1. correctAge(41)

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

    호출 후: age=41

  2. correctAge(121)

    호출 전 age=41. 상한을 넘어 필드 대입 전에 false를 반환합니다.

    호출 후: age=41 유지

두 호출 뒤 describe()의 결과는 [email protected]=41입니다. 이 표는 메서드의 검사 결과이며, 같은 바깥 클래스의 main까지 private 접근이 차단된다는 뜻은 아닙니다.


클래스 접근 수준

최상위 클래스에는 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 접근이 언어 차원에서 금지된 것은 아닙니다.

checkIndex는 공개할 이유가 없는 내부 보조 메서드라 private입니다.

Member와 MemberRegistry는 같은 바깥 클래스인 AccessControlledMemberRegistry에 나란히 선언된 private 중첩 타입입니다.

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

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

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() 합산

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를 만드세요.

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라는 한 경로로만 일어납니다.