본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : 객체 책임과 캡슐화

접근 제어와 변경 경계

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

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

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

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


private 접근 제한

lab/PrivateFieldViolation.java
public final class PrivateFieldViolation {
    public static void main(String[] args) {
        Member member = new Member("kim@example.com", 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("kim@example.com", 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
kim@example.com=41

외부는 age = 121을 직접 만들 수 없습니다.

correctAge가 정정할 값을 검사하고 성공한 변경만 반영합니다.

필드가 private이므로 새 변경 경로가 필요할 때 클래스 작성자가 규칙을 선택합니다.

무조건 모든 필드에 setter를 만들면 이름만 메서드일 뿐 직접 대입과 같은 자유를 다시 엽니다.

public void setAge(int age) {
    this.age = age;
}

이보다 celebrateBirthday, correctAge, activate처럼 사용 의도와 허용 규칙을 담는 행동이 낫습니다.


클래스 접근 수준

최상위 클래스에는 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("kim@example.com", 40);
        registry.add("lee@example.com", 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; }
    }
}
kim@example.com=40
total=90

외부는 memberssize를 볼 수 없고 add, memberAt, totalAge만 호출합니다.

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

Member 자체도 이 축약 예제에서는 MemberRegistry 구현 안에 숨겨져 있습니다.


연습 문제

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