본문 바로가기

접근 제어자와 정보 은닉

private·package-private·protected·public이 각각 어디까지 접근을 허용하는지 알아보고, 필드는 숨긴 채 규칙을 지키는 메서드만 공개하는 방법을 배웁니다.

객체에 데이터와 행동을 함께 두기에서는 바깥 코드가 공개 필드 email에 null을 넣는 바람에 멀쩡하던 회원 객체가 망가지는 것을 보았습니다. 그 뒤 두 글에 걸쳐 생성자에서 이메일과 나이를 검사해, 처음부터 올바른 회원만 만들어지게 했습니다. 하지만 만들어진 뒤에 아무 코드나 member.age = -5;라고 쓸 수 있다면, 생성자의 검사는 객체가 만들어지는 순간에만 의미가 있을 뿐입니다.

이 글에서는 필드와 메서드에 접근 제어자(access modifier)를 붙여, 누가 그것을 쓸 수 있는지를 클래스가 직접 정하는 방법을 배웁니다. 필드를 숨기고 규칙을 지키는 메서드만 공개하면 객체의 상태를 바꾸는 길이 그 메서드들로 좁혀집니다. 이렇게 내부 구현을 바깥에서 보지도 바꾸지도 못하게 감추는 것을 정보 은닉(information hiding)이라고 하며, 정보 은닉과 캡슐화를 언어와 상관없는 설계 원칙으로 비교한 설명은 소프트웨어공학 교재의 책임을 나누는 모듈에 있습니다. 접근 제어자는 개발자를 귀찮게 하는 자물쇠가 아니라, 어떤 방법으로 객체를 바꿀 수 있는지를 코드에 적어 두는 도구입니다.

다른 클래스에서 private 필드에 접근하면

다음 파일에는 최상위 클래스가 두 개 있습니다. Member는 같은 파일에 있지만 PrivateFieldViolation 안에 중첩되지 않은 별도의 클래스이고, 나이 필드를 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;
    }
}

main이 member.age를 읽으려는 줄에서 컴파일 오류가 납니다. 바로 앞 줄의 생성자 호출은 문제없이 통과하고, 오류는 private 필드에 접근하는 그다음 줄에서만 납니다.

컴파일 오류
error: age has private access in Member

private을 붙인 필드나 메서드는 그것을 선언한 클래스 안에서만 쓸 수 있습니다. 두 클래스는 같은 파일, 같은 패키지에 있지만 PrivateFieldViolation은 Member가 아니므로 age를 읽을 수 없습니다. 읽기만 해도 막히니 member.age = -5; 같은 대입은 말할 것도 없습니다. 이제 Member의 나이를 바꾸거나 읽는 코드는 모두 Member 클래스 안에 있어야 하고, 나이 규칙을 확인하려면 이 클래스 하나만 보면 됩니다.

바깥에서 나이를 읽어야 한다면 Member가 읽기용 메서드를 공개합니다.

public int age() {
    return age;
}

이렇게 필드 값을 돌려주는 메서드를 흔히 getter라고 부릅니다. getter는 간단하지만, 한번 공개하면 바깥 코드가 그 메서드에 기대기 시작하므로 나중에 마음대로 없애거나 바꾸기 어렵습니다. 그래서 공개하기 전에 바깥이 정말 나이라는 숫자 자체를 알아야 하는지 따져 보는 것이 좋습니다. 호출하는 쪽이 결국 '성인인가'를 판단하려는 것이라면 isAdult()를, 화면에 표시하려는 것이라면 describe()를 공개하는 편이 의도를 더 잘 드러냅니다. 판단 기준이 바뀌어도 Member 안만 고치면 되기 때문입니다.

중첩 클래스에서는 private이 main을 막지 않는다

이 장의 예제들은 대부분 Member를 실행용 클래스 안에 private static final class로 중첩해 두었습니다. 이때는 Member의 필드가 private이어도 바깥 클래스의 main에서 접근할 수 있습니다. 자바에서 private 멤버를 쓸 수 있는 범위는 그 멤버를 둘러싼 최상위 클래스의 본문 전체이기 때문입니다. 중첩된 Member의 private 필드는 바깥 클래스 안 어디에서나 보입니다.

그래서 뒤에 나올 ControlledMember처럼 age가 final이 아닌 예제에서는 main에 member.age = -5;라고 써도 컴파일되고, 실제로 나이가 -5로 바뀝니다. 이 교재의 예제가 한 파일 안에서도 필드를 직접 건드리지 않고 메서드를 호출하는 것은 언어가 막아서가 아니라, 클래스를 각자의 파일로 나눴을 때와 같은 방식으로 코드를 쓰기 위해서입니다. 중첩 클래스는 중첩 클래스의 종류와 정적 중첩 클래스에서 자세히 다룹니다.

네 가지 접근 수준

자바의 접근 수준은 네 가지입니다. 접근 제어자 세 개(private, protected, public)와, 아무것도 쓰지 않은 상태가 있습니다. 각각 어디에서 접근할 수 있는지 정리하면 다음과 같습니다.

접근 제어자같은 클래스같은 패키지다른 패키지의 자식 클래스그 밖의 모든 곳
private가능불가불가불가
없음 (package-private)가능가능불가불가
protected가능가능가능 (상속한 멤버로)불가
public가능가능가능가능

표의 위에서 아래로 갈수록 범위가 넓어집니다. private은 앞에서 본 대로 선언한 클래스 안에서만 쓸 수 있습니다. 정확히는 그 클래스를 둘러싼 최상위 클래스 안입니다. 아무 접근 제어자도 쓰지 않으면 같은 패키지의 클래스들이 쓸 수 있는데, 이것을 package-private 접근이라고 하고 흔히 default 접근이라고도 부릅니다. 앞 글의 members.model.Member가 이 수준이어서 console 패키지에서는 쓸 수 없었습니다. public은 어디서든 쓸 수 있습니다.

default 접근이라는 이름 때문에 default int age;처럼 써야 한다고 오해하기 쉽지만, 그런 접근 제어자는 없습니다. 이렇게 쓰면 modifier default not allowed here 오류가 납니다. package-private은 '아무것도 쓰지 않는 것' 그 자체입니다.

protected는 이름 때문에 '자식 클래스만 쓸 수 있다'고 외우기 쉬운데, 표에서 보듯 같은 패키지에서도 쓸 수 있습니다. 즉 protected는 package-private의 범위에 '다른 패키지의 자식 클래스'를 더한 것이라서 package-private보다 넓습니다. 자식 클래스와 상속은 상속과 메서드 오버라이딩에서 배우므로, 지금은 이 순서만 기억해 두면 됩니다.

가장 좁은 범위부터 시작하기

접근 범위가 넓을수록 좋은 것은 아닙니다. public 메서드는 누구나 호출할 수 있으므로, 이름이나 매개변수를 바꾸면 그것을 호출하는 모든 코드가 영향을 받습니다. 반대로 private 메서드는 같은 클래스 안에서만 쓰이니 바꿔도 그 클래스만 고치면 됩니다. 범위가 좁을수록 마음 놓고 고칠 수 있는 셈입니다.

그래서 기본 전략은 가장 좁게 시작하는 것입니다. 내부 구현은 private, 같은 패키지의 클래스끼리 협력하는 데만 필요한 것은 package-private으로 둡니다. 바깥에서 꼭 써야 하는 최소한의 기능만 public으로 엽니다. 나중에 필요해지면 범위를 넓히기는 쉽지만, 한번 public으로 연 것을 다시 좁히면 그것을 쓰던 코드가 모두 깨집니다.

더 깊이: 다른 패키지의 자식 클래스와 protected

다른 패키지의 자식 클래스가 protected 멤버를 쓸 수 있는 것은 '자기가 상속받은 멤버'로 쓸 때뿐입니다. 다음 Account는 members.base 패키지에 있고, 로그인 횟수를 늘리는 recordLogin()을 protected로 선언했습니다.

package members.base;

public class Account {
    protected int loginCount;

    protected void recordLogin() {
        loginCount++;
    }
}

AdminAccount는 다른 패키지 members.ext에서 Account를 상속한 자식 클래스입니다. 앞 글에서 배운 완전한 이름으로 Account를 가리켰으므로 import는 쓰지 않았습니다.

package members.ext;

public class AdminAccount extends members.base.Account {
    void loginTwice() {
        recordLogin();       // 가능: 자기가 상속받은 메서드
        this.recordLogin();  // 가능
    }

    void touch(members.base.Account other) {
        other.recordLogin(); // 컴파일 오류
    }
}

loginTwice()의 두 호출은 컴파일되지만, touch의 호출은 recordLogin() has protected access in Account 오류가 납니다. other는 AdminAccount가 아닌 다른 Account 객체일 수도 있기 때문입니다. 자식 클래스라는 이유로 부모 타입의 모든 객체를 건드릴 수 있게 하면, protected가 사실상 public과 다를 바 없어집니다. 같은 패키지 안이라면 이런 제한 없이 쓸 수 있습니다.

public에도 넘을 수 없는 선이 하나 있습니다. Java 9부터 들어온 모듈을 쓰면, 모듈이 내보내지(export) 않은 패키지의 public 클래스는 다른 모듈에서 쓸 수 없습니다. 모듈은 모듈 시스템과 ServiceLoader에서 다룹니다.

필드를 숨기고 규칙을 지키는 메서드로 바꾸기

필드를 숨겼다면, 바꿔도 되는 경우를 위해 메서드를 마련해야 합니다. 다음 Member는 나이를 private으로 숨기고, 잘못 입력한 나이를 고치는 correctAge 메서드를 공개합니다. 나이가 허용 범위 14부터 120까지를 벗어나면 고치지 않고 false를 돌려줍니다.

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

나이 40으로 만든 같은 회원에게 두 번 정정을 요청했습니다. 첫 요청 correctAge(41)은 범위 안이므로 age를 41로 바꾸고 true를 돌려줍니다. 두 번째 요청 correctAge(121)은 범위를 벗어나므로 대입하기 전에 false를 돌려줍니다. 그래서 age는 첫 요청이 남긴 41 그대로이고, 마지막 줄에 [email protected]=41이 출력됩니다. 거절된 요청이 앞서 성공한 변경을 되돌리거나 망가뜨리지 않았다는 점이 중요합니다.

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

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

  • 실패한 요청이 앞선 성공을 되돌리거나 덮어쓰지 않습니다.

나이를 바꾸는 코드가 correctAge 한 곳뿐이므로, 나이가 14 이상 120 이하라는 규칙은 생성자와 이 메서드만 지키면 됩니다. 나중에 '나이는 한 번에 1살까지만 정정할 수 있다' 같은 규칙이 생겨도 correctAge만 고치면 되고, 회원의 나이를 바꾸는 다른 경로가 있는지 프로그램 전체를 뒤질 필요가 없습니다. 앞에서 본 대로 이 예제의 main은 중첩 클래스의 age에 직접 대입할 수도 있지만, 일부러 correctAge를 거쳐 허용 범위를 지킵니다.

생성자는 잘못된 값을 받으면 예외를 던졌는데 correctAge는 false를 돌려준다는 차이도 있습니다. 잘못된 회원은 아예 만들어지면 안 되지만, 정정 요청이 거절되는 것은 사용자가 숫자를 잘못 입력하면 흔히 일어나는 일입니다. 그래서 호출한 쪽이 결과를 보고 사용자에게 안내할 수 있게 한 것입니다. 실패를 예외로 알릴지 반환값으로 알릴지는 12장에서 예외를 다룰 때 더 자세히 비교합니다.

한 가지 짚어 둘 점은 correctAge와 describe에 붙은 public입니다. 메서드를 public으로 선언해도, 그 메서드가 속한 Member 클래스 자체가 private 중첩 클래스라면 바깥 클래스 밖에서는 Member를 쓸 수 없으므로 메서드에 접근할 방법도 없습니다. 멤버의 접근 범위는 그것을 담은 클래스의 범위를 넘지 못합니다. 이 예제의 public은 'Member를 자기 파일로 옮기면 공개할 메서드'라는 표시로 읽으면 됩니다.

setter를 모든 필드에 만들지 않는 이유

필드를 private으로 바꾼 뒤 습관처럼 모든 필드에 setter를 만드는 경우가 많습니다. setter는 getter와 반대로 필드에 새 값을 넣는 메서드이며, 보통 set 뒤에 필드 이름을 붙여 이름 짓습니다. 다음은 age 필드의 setter입니다.

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

이 메서드가 있으면 바깥 코드는 member.setAge(-5)로 나이를 아무 값으로나 바꿀 수 있습니다. 형식만 메서드일 뿐 member.age = -5;라고 쓰는 것과 다를 바가 없으니, 필드를 숨긴 의미가 사라집니다. setter 안에 검사를 넣을 수도 있지만, '나이를 이 값으로 바꾼다'는 이름만으로는 어떤 상황에서 어떤 변경이 허용되는지 알 수 없습니다.

그보다 celebrateBirthday(), correctAge(int), activate()처럼 변경의 의도를 이름에 담은 메서드가 낫습니다. 생일이 지나면 나이가 1 늘고, 잘못 입력한 나이는 허용 범위 안에서만 고치고, 비활성 회원은 활성으로만 바꿀 수 있다는 식입니다. 이렇게 하면 메서드마다 그 상황에 맞는 규칙을 붙일 수 있고, 메서드 목록만 읽어도 이 객체에 어떤 일이 일어날 수 있는지 알 수 있습니다.

클래스에 붙이는 접근 제어자

접근 제어자는 클래스 자체에도 붙입니다. 다른 클래스 안에 들어 있지 않은 최상위 클래스에는 public을 붙이거나 아무것도 붙이지 않는 두 가지만 쓸 수 있습니다. private class나 protected class를 최상위에 선언하면 modifier private not allowed here 같은 오류가 납니다. 반면 다른 클래스 안에 선언하는 중첩 클래스에는 네 가지 접근 수준을 모두 쓸 수 있고, 이 장의 예제가 쓰는 private static final class가 그런 경우입니다.

public 최상위 클래스에는 규칙이 하나 더 있습니다. 클래스 이름과 파일 이름이 같아야 합니다. public final class SomethingElse를 WrongFileName.java에 저장하면 javac는 class SomethingElse is public, should be declared in a file named SomethingElse.java 오류를 냅니다. 그래서 한 파일에는 public 최상위 클래스를 하나만 둘 수 있습니다. public이 아닌 최상위 클래스는 같은 파일에 여러 개 둘 수 있는데, 맨 앞의 PrivateFieldViolation.java에 Member를 함께 둔 것이 그 예입니다.

public final class MemberRegistry { }
final class MemberValidator { }

이 두 줄이 MemberRegistry.java 파일에 있다면, MemberRegistry는 다른 패키지에서도 쓸 수 있습니다. 반면 MemberValidator는 같은 패키지의 클래스만 쓸 수 있습니다. 회원 정보를 검사하는 도우미 클래스는 저장소를 구현하는 데만 필요하므로 바깥 패키지에 보일 이유가 없습니다.

앞 글에서 본 것처럼 하위 패키지는 별개의 패키지라는 점도 접근 제어에 그대로 적용됩니다. members.model.internal은 members.model 안에 있는 것처럼 보이지만, members.model의 package-private 클래스나 멤버를 쓸 수 없습니다. 예를 들어 members.model.internal의 클래스에서 앞 글의 members.model.Member를 만들려고 하면 Member is not public in members.model; cannot be accessed from outside package 오류가 납니다. 패키지 이름이 길게 이어져 있어도 package-private이 허용되는 범위는 정확히 한 패키지입니다.

MemberRegistry의 공개 메서드와 숨긴 구현

이제 회원 저장소에 같은 원칙을 적용해 보겠습니다. 다음 MemberRegistry는 배열과 회원 수를 private으로 숨기고, 회원 추가, 회원 설명 조회, 나이 합계 세 가지 기능만 공개합니다.

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은 회원 두 명을 추가한 뒤 0번 회원의 설명과 나이 합계를 출력합니다. memberAt(0)은 먼저 추가한 [email protected]=40을, totalAge()는 40 + 50 = 90을 돌려줍니다.

공개한 세 메서드가 돌려주는 값을 보면 이 설계의 의도가 드러납니다. add는 저장했는지를 boolean으로, memberAt은 회원의 설명을 String으로, totalAge는 합계를 int로 돌려줍니다. 어느 메서드도 내부 배열이나 Member 객체의 참조를 내주지 않으므로, 호출한 쪽은 저장소 안의 회원을 직접 바꿀 수 없습니다. 인덱스가 올바른지 확인하는 checkIndex는 저장소 안에서만 쓰는 도우미라서 private으로 두었습니다. Member와 MemberRegistry는 둘 다 AccessControlledMemberRegistry 안에 나란히 선언된 private 중첩 클래스이므로, 앞에서 설명한 대로 이 파일의 main은 members나 size에 직접 접근할 수도 있습니다. 그래도 공개 메서드만 쓴 것은 앞의 예제와 같은 이유입니다.

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가 실패 시 상태를 보존한다고 일반화하지 않습니다.

실패한 추가가 회원 수를 바꾸는 문제

위의 add는 두 회원이 모두 올바른 이 main에서는 잘 동작하지만, 잘못된 회원을 추가하려 하면 문제가 드러납니다. 다음처럼 빈 이메일로 추가를 시도한 뒤 합계를 구해 보겠습니다.

MemberRegistry registry = new MemberRegistry(2);
try {
    registry.add("", 30);
} catch (IllegalArgumentException exception) {
    System.out.println("rejected=" + exception.getMessage());
}
System.out.println("total=" + registry.totalAge());

rejected=invalid member가 출력된 다음, total= 줄 대신 NullPointerException이 발생합니다. 원인은 members[size++] = new Member(email, age); 한 줄의 실행 순서에 있습니다. 자바는 배열에 대입할 때 왼쪽의 배열과 인덱스를 먼저 계산하고, 그다음에 오른쪽 값을 계산합니다. 그래서 size++가 생성자보다 먼저 실행됩니다.

순서일어나는 일sizemembers[0]
1왼쪽 members[size++]를 계산한다. 인덱스는 0으로 정해지고 size가 1 늘어난다1null
2오른쪽 new Member("", 30)을 실행한다. 생성자가 예외를 던진다1null
3대입은 일어나지 않고 예외가 add 밖으로 나간다1null

예외가 나도 이미 늘어난 size는 되돌아가지 않습니다. 이제 저장소는 회원이 한 명 있다고 알고 있지만 0번 칸은 비어 있습니다. 그래서 totalAge가 0번 칸의 age()를 호출하는 순간 NullPointerException이 납니다. 객체가 존재하는 동안 늘 참이어야 하는 조건, 곧 불변식인 '0번부터 size - 1번까지는 모두 회원이 들어 있다'가 깨진 것입니다. 필드를 private으로 숨겨서 바깥 코드가 이 조건을 깨는 것은 막았지만, 클래스 안의 코드가 깨는 것까지 막아 주지는 않습니다.

실패한 추가가 저장소를 바꾸지 않게 하려면 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;
}

회원 만들기, 배열에 저장하기, 회원 수 늘리기를 세 문장으로 나누었습니다. 생성자가 예외를 던지면 첫 문장에서 add를 빠져나가므로 뒤의 두 문장은 실행되지 않고, 저장소는 호출 전 상태 그대로 남습니다. 위의 시험 코드를 이 버전으로 다시 실행하면 rejected=invalid member 다음에 total=0이 출력됩니다. 배열 대입의 계산 순서는 Java 언어 명세의 단순 대입 연산자 절에 정의되어 있습니다. 예외가 이미 일어난 부수 효과를 자동으로 되돌려 주지 않는다는 점은 이 저장소 밖에서도 기억해 둘 만합니다. 다음 글 캡슐화로 회원 저장소 완성하기는 여기서 한 걸음 더 나아가, 생성자의 예외를 false로 바꿔 돌려주는 방식까지 구현합니다.

정리

  • private 멤버는 그것을 선언한 클래스 안에서만 쓸 수 있습니다. 정확한 범위는 둘러싼 최상위 클래스 전체이므로, 바깥 클래스와 그 안에 중첩된 클래스들은 서로의 private 멤버에 접근할 수 있습니다.
  • 아무 접근 제어자도 쓰지 않으면 같은 패키지에서만 쓸 수 있는 package-private이 됩니다. protected는 여기에 다른 패키지의 자식 클래스를 더한 범위이고, public은 어디서든 쓸 수 있습니다.
  • 범위가 넓을수록 바꾸기 어려우므로 가장 좁게 시작하고, 바깥에서 꼭 필요한 기능만 public으로 엽니다.
  • 필드는 숨기고, 모든 필드에 setter를 여는 대신 correctAge처럼 변경의 의도와 규칙을 담은 메서드를 공개합니다.
  • 최상위 클래스에는 public이나 package-private만 쓸 수 있고, public 최상위 클래스의 이름은 파일 이름과 같아야 합니다.
  • 접근 제어는 바깥 코드가 불변식을 깨는 것을 막을 뿐입니다. 클래스 안의 코드는 실패했을 때도 상태가 어긋나지 않도록 문장의 순서를 직접 챙겨야 합니다.

다음 글에서는 필드를 private으로 숨겨도 내부 배열을 그대로 돌려주는 메서드 하나 때문에 캡슐화가 무너지는 경우를 확인하고, 이 장에서 배운 생성자와 접근 제어를 모아 캡슐화된 회원 저장소 CLI를 완성합니다.

연습 문제

문제 1: 최댓값이 있는 카운터

0에서 시작하고 최댓값을 생성자로 받는 Counter를 만드세요. 최댓값은 0 이상이어야 하며, 음수가 들어오면 생성 단계에서 거절합니다. count 필드는 private으로 숨기고, increment()는 최댓값을 넘게 되면 false, 성공하면 true를 돌려주게 하세요. 현재 값은 value()로만 읽습니다. 코드를 쓰기 전에, 최댓값이 0일 때 첫 increment()의 결과와 그 뒤의 값도 예측해 보세요.

해설 보기
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 한 곳뿐이고, 증가하기 전에 count == maximum인지 확인합니다. 그래서 count는 0부터 시작해 maximum을 넘을 수 없습니다. 최댓값 2인 카운터는 두 번 성공해 count가 2가 되고, 세 번째 호출은 증가하기 전에 false를 돌려주므로 값은 2로 남습니다.

최댓값이 0이라면 시작할 때부터 count == maximum이므로 첫 호출부터 false이고 값은 0입니다. 음수 최댓값을 생성자에서 거절한 이유도 여기서 드러납니다. 최댓값이 -1이면 0에서 출발한 count가 -1과 같아질 일이 없으므로, increment()는 최댓값과 상관없이 계속 성공합니다. 한편 이 파일의 main은 중첩 클래스인 Counter의 private 필드에 접근할 수 있지만, 예제에서는 공개한 메서드만 사용합니다.

문제 2: 어느 줄이 컴파일될까

Ledger와 LedgerView는 서로 다른 패키지에 있고, LedgerView는 Ledger를 완전한 이름으로 가리킵니다. LedgerView의 네 줄 중 컴파일 오류가 나는 줄을 모두 고르고 이유를 설명하세요. 그다음 LedgerView를 members.model 패키지로 옮기면 어떻게 달라지는지도 답하세요.

// src/members/model/Ledger.java
package members.model;

public class Ledger {
    private int balance;
    int entryCount;
    protected String owner;

    public String summary() {
        return owner + ":" + balance;
    }
}
// src/members/console/LedgerView.java
package members.console;

public final class LedgerView {
    void show(members.model.Ledger ledger) {
        System.out.println(ledger.summary());   // (1)
        System.out.println(ledger.owner);       // (2)
        System.out.println(ledger.entryCount);  // (3)
        System.out.println(ledger.balance);     // (4)
    }
}
해설 보기

(1)만 컴파일되고 (2), (3), (4)는 모두 오류입니다. summary()는 public이므로 어디서든 호출할 수 있습니다. owner는 protected인데 LedgerView는 다른 패키지에 있고 Ledger의 자식 클래스도 아니므로 owner has protected access in Ledger 오류가 납니다. entryCount는 접근 제어자가 없는 package-private이라 entryCount is not public in Ledger; cannot be accessed from outside package 오류가 나고, balance는 private이라 balance has private access in Ledger 오류가 납니다.

LedgerView를 members.model 패키지로 옮기면 (1), (2), (3)이 컴파일되고 (4)만 오류로 남습니다. 같은 패키지에서는 package-private인 entryCount뿐 아니라 protected인 owner도 쓸 수 있기 때문입니다. protected가 '자식 클래스 전용'이 아니라는 것을 보여 주는 예입니다. private인 balance는 어느 패키지로 옮겨도 Ledger 밖에서는 쓸 수 없고, 잔액이 궁금한 쪽은 summary() 같은 공개 메서드를 통해서만 알 수 있습니다.