[☕JAVA] 접근 제어자와 캡슐화

접근 제어자가 왜 필요한지, 캡슐화 2대 원칙이 무엇인지, 그리고 static과 어떻게 조합되는지 코드 레벨로 정리합니다.

[☕JAVA] 접근 제어자와 캡슐화

캡슐화는 OOP의 핵심 원칙이지만, 실제로 어떻게 구현하는지 흐릿한 경우가 많다. 접근 제어자는 단순한 문법이 아니라 최소 권한 원칙을 코드 레벨에서 강제하는 장치다. Speaker 폭발 사례부터 BankAccount, ShoppingCart까지 — 왜 private이 필요한지, static과 어떻게 다른 축인지 완전히 정리해보자.


What this post covers

  • 왜 접근 제어자가 필요한가 (Speaker 폭발 사건)
  • 4가지 접근 제어자의 범위와 차이
  • 클래스 레벨 규칙 (public vs default)
  • 캡슐화 2대 원칙과 실전 예제
  • static은 접근 제어자와 어떻게 다른 축인가

1. 왜 접근 제어자가 필요한가?

1.1 Speaker 폭발 사건 — 문제의 시작

스피커 소프트웨어 개발 시나리오를 생각해보자. 요구사항: 음량은 절대 100을 넘으면 안 된다 (100 초과 시 부품 고장).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package access;

public class Speaker {
    int volume;  // 접근 제어자 없음 → default (package-private)

    Speaker(int volume) {
        this.volume = volume;
    }

    void volumeUp() {
        if (volume >= 100) {
            System.out.println("음량을 증가할 수 없습니다. 최대 음량입니다.");
        } else {
            volume += 10;
            System.out.println("음량을 10 증가합니다.");
        }
    }

    void volumeDown() {
        volume -= 10;
        System.out.println("volumeDown 호출");
    }

    void showVolume() {
        System.out.println("현재 음량:" + volume);
    }
}

volumeUp()에 검증 로직이 있으므로 정상적으로 사용하면 100을 넘지 않는다:

1
2
3
4
5
현재 음량: 90
음량을 10 증가합니다.
현재 음량: 100
음량을 증가할 수 없습니다. 최대 음량입니다.
현재 음량: 100

1.2 문제 발생 — 필드 직접 접근

새 개발자가 기존 요구사항을 모르고 필드에 직접 접근:

1
2
speaker.volume = 200;  // ❌ 검증 로직 완전 우회!
speaker.showVolume();   // 현재 음량: 200 → 스피커 폭발!

volume 필드가 외부에 노출되어 있으면, volumeUp()의 검증이 무용지물이다.

1.3 해결 — private으로 차단

1
2
3
4
public class Speaker {
    private int volume;  // ✅ private 사용!
    // ... 나머지 동일
}

이제 외부에서 speaker.volume = 200 시도 시:

1
volume has private access in access.Speaker  // 컴파일 에러!

“좋은 프로그램은 무한한 자유도가 주어지는 프로그램이 아니라 적절한 제약을 제공하는 프로그램이다.”

1.4 Principle of Least Privilege

OS에서 프로세스 A가 프로세스 B의 메모리 공간에 접근할 수 없는 것처럼, 접근 제어자최소 권한 원칙(Principle of Least Privilege)을 OOP에서 구현한 것이다. 이는 Secure Coding의 Attack Surface 최소화와 직결된다.


2. 접근 제어자 4가지 종류

차단 강도 순서: privatedefaultprotectedpublic

2.1 접근 범위 요약표

ModifierSame ClassSame PackageSubclass (다른 패키지)World
privateOXXX
default (키워드 없음)OOXX
protectedOOOX
publicOOOO

2.2 각 제어자의 의미

private — 나의 클래스 안으로 숨김

  • 모든 외부 호출을 막는다
  • 해당 클래스 내부에서만 접근 가능
  • 캡슐화의 핵심 도구

default (package-private) — 나의 패키지 안으로 숨김

  • 접근 제어자를 명시하지 않으면 적용됨
  • 같은 패키지 안에서만 접근 가능
  • package-private이 더 정확한 표현

protected — 패키지 + 상속 관계로 숨김

  • 같은 패키지 안에서 호출 허용
  • 패키지가 달라도 상속 관계의 호출은 허용

public — 모든 외부 호출 허용

  • 어디서든 접근 가능
  • 한 번 public으로 열면 되돌리기 어려움 (이미 다른 코드가 의존할 수 있음)

3. 접근 제어자 사용 — 필드, 메서드

3.1 접근 제어자 사용 위치

접근 제어자는 필드, 메서드, 생성자에 사용된다. 추가로 클래스 레벨에도 일부 접근 제어자를 사용할 수 있다.

1
2
3
4
5
6
7
public class Speaker {       // 클래스 레벨
    private int volume;      // 필드
    public Speaker(int volume) {} // 생성자
    public void volumeUp() {}    // 메서드
    public void volumeDown() {}
    public void showVolume() {}
}

3.2 AccessData 예제 — 내부 vs 외부 접근

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package access.a;

public class AccessData {
    public int publicField;
    int defaultField;
    private int privateField;

    public void publicMethod() {
        System.out.println("publicMethod 호출 " + publicField);
    }
    void defaultMethod() {
        System.out.println("defaultMethod 호출 " + defaultField);
    }
    private void privateMethod() {
        System.out.println("privateMethod 호출 " + privateField);
    }

    public void innerAccess() {
        System.out.println("내부 호출");
        publicField = 100;
        defaultField = 200;
        privateField = 300;   // ✅ 자기 자신 → private도 접근 가능!
        publicMethod();
        defaultMethod();
        privateMethod();
    }
}

3.3 같은 패키지에서 접근 (access.a)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package access.a;

public class AccessInnerMain {
    public static void main(String[] args) {
        AccessData data = new AccessData();
        data.publicField = 1;      // ✅ public
        data.publicMethod();
        data.defaultField = 2;     // ✅ 같은 패키지이므로 default 접근 가능
        data.defaultMethod();
        // data.privateField = 3;  // ❌ private은 어디서든 직접 접근 불가
        // data.privateMethod();   // ❌
        data.innerAccess();        // ✅ public 메서드를 통해 내부 private에 간접 접근
    }
}

실행 결과:

1
2
3
4
5
6
publicMethod 호출 1
defaultMethod 호출 2
내부 호출
publicMethod 호출 100
defaultMethod 호출 200
privateMethod 호출 300

3.4 다른 패키지에서 접근 (access.b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package access.b;
import access.a.AccessData;

public class AccessOuterMain {
    public static void main(String[] args) {
        AccessData data = new AccessData();
        data.publicField = 1;      // ✅ public은 어디서든 접근 가능
        data.publicMethod();
        // data.defaultField = 2;  // ❌ 다른 패키지이므로 default 접근 불가!
        // data.defaultMethod();   // ❌
        // data.privateField = 3;  // ❌
        // data.privateMethod();   // ❌
        data.innerAccess();        // ✅ public 메서드이므로 외부 호출 가능
    }
}

실행 결과:

1
2
3
4
5
publicMethod 호출 1
내부 호출
publicMethod 호출 100
defaultMethod 호출 200
privateMethod 호출 300

innerAccess()는 외부에서 호출되었지만, 이 메서드는 AccessData 내부에 있으므로 자신의 private 필드와 메서드에 모두 접근할 수 있다. 이것이 캡슐화의 작동 원리다.

참고: 생성자도 접근 제어자 관점에서 메서드와 같다.


4. 접근 제어자 사용 — 클래스 레벨

4.1 클래스 레벨 규칙

  • 클래스 레벨에는 publicdefault만 사용 가능 (private, protected 불가)
  • public 클래스는 반드시 파일명과 이름이 같아야
  • 하나의 .java 파일에 public 클래스는 하나만 가능
  • default 클래스는 같은 파일에 여러 개 가능

4.2 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
// PublicClass.java 파일
package access.a;

public class PublicClass {         // 파일명과 반드시 일치
    public static void main(String[] args) {
        PublicClass publicClass = new PublicClass();
        DefaultClass1 class1 = new DefaultClass1();  // ✅ 같은 패키지
        DefaultClass2 class2 = new DefaultClass2();  // ✅ 같은 패키지
    }
}

class DefaultClass1 { }           // default → 같은 패키지에서만 접근
class DefaultClass2 { }           // 같은 파일에 여러 개 가능

같은 패키지(access.a)에서 접근:

1
2
3
4
5
6
7
8
9
package access.a;

public class PublicClassInnerMain {
    public static void main(String[] args) {
        PublicClass publicClass = new PublicClass();    // ✅ public
        DefaultClass1 class1 = new DefaultClass1();    // ✅ 같은 패키지
        DefaultClass2 class2 = new DefaultClass2();    // ✅ 같은 패키지
    }
}

다른 패키지(access.b)에서 접근:

1
2
3
4
5
6
7
8
9
10
package access.b;
import access.a.PublicClass;

public class PublicClassOuterMain {
    public static void main(String[] args) {
        PublicClass publicClass = new PublicClass();    // ✅ public 클래스
        // DefaultClass1 class1 = new DefaultClass1(); // ❌ 다른 패키지, default 접근 불가
        // DefaultClass2 class2 = new DefaultClass2(); // ❌
    }
}

5. 캡슐화 (Encapsulation)

5.1 정의

캡슐화(Encapsulation) = 데이터와 해당 데이터를 처리하는 메서드를 하나로 묶어서 외부에서의 접근을 제한하는 것.

쉽게: 속성과 기능을 하나로 묶고, 외부에 꼭 필요한 기능만 노출하고 나머지는 모두 내부로 숨기는 것.

접근 제어자는 캡슐화를 안전하게 완성할 수 있게 해주는 장치이다.

5.2 캡슐화 2대 원칙

원칙 1 — 데이터를 숨겨라

객체 내부의 데이터를 외부에서 함부로 접근하게 두면, 모든 검증 로직을 무시하고 데이터를 변경할 수 있다. MusicPlayer 예제에서 volume, isOn을 직접 수정하는 대신 on(), volumeUp() 메서드만 쓰는 것과 같다 — JAVA(3) OOP 도입편 참고.

객체의 데이터는 객체가 제공하는 기능인 메서드를 통해서 접근해야 한다.

원칙 2 — 내부 기능도 숨겨라

외부에서 사용하지 않고 내부에서만 사용하는 기능도 감춘다. MusicPlayer의 내부 상태 관리 로직을 밖에서 알 필요 없듯이, 외부 인터페이스는 꼭 필요한 것만 열어둔다.

사용자 입장에서 꼭 필요한 기능만 외부에 노출하고, 나머지 기능은 모두 내부로 숨기자.

정리: 데이터는 모두 숨기고, 기능은 꼭 필요한 기능만 노출하는 것이 좋은 캡슐화.

5.3 BankAccount — 캡슐화 실전 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package access;

public class BankAccount {
    private int balance;                        // ✅ 원칙 1: 데이터 숨김

    public BankAccount() { balance = 0; }

    public void deposit(int amount) {           // public: 외부에 노출
        if (isAmountValid(amount)) {
            balance += amount;
        } else {
            System.out.println("유효하지 않은 금액입니다.");
        }
    }

    public void withdraw(int amount) {          // public: 외부에 노출
        if (isAmountValid(amount) && balance - amount >= 0) {
            balance -= amount;
        } else {
            System.out.println("유효하지 않은 금액이거나 잔액이 부족합니다.");
        }
    }

    public int getBalance() { return balance; } // public: 외부에 노출

    private boolean isAmountValid(int amount) { // ✅ 원칙 2: 내부 기능 숨김
        return amount > 0;
    }
}

사용자는 deposit(), withdraw(), getBalance() 단 3가지 메서드만 알면 된다.

접근 제어자와 캡슐화를 통해 데이터를 안전하게 보호하는 동시에, 사용자 입장에서 복잡도도 낮출 수 있다.

5.4 Secure Coding 연결

  • private 필드 → Attack Surface 최소화: 외부 노출 인터페이스가 줄어들수록 공격 지점 감소
  • public 메서드의 입력 검증 → Input Validation: getter/setter를 통해 모든 입력에 검증 강제
  • isAmountValid()private으로 숨김 → 필요 이상의 정보를 노출하지 않는 보안 원칙
  • private finalprivate이 접근 차단, final이 변경 차단 담당

6. 문제와 풀이

6.1 MaxCounter — 최대 카운터와 캡슐화

요구사항:

  • 최대값을 지정하고 최대값까지만 값이 증가하는 기능 제공
  • 접근 제어자를 사용해서 데이터를 캡슐화
  • 해당 클래스는 다른 패키지에서도 사용할 수 있어야 함
1
2
3
4
5
6
7
8
9
10
11
12
13
package access.ex;

public class CounterMain {
    public static void main(String[] args) {
        MaxCounter counter = new MaxCounter(3);
        counter.increment();
        counter.increment();
        counter.increment();
        counter.increment();
        int count = counter.getCount();
        System.out.println(count);
    }
}

실행 결과:

1
2
최대값을 초과할 수 없습니다.
3

정답:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package access.ex;

public class MaxCounter {
    private int count = 0;   // ✅ 데이터 숨김
    private int max;          // ✅ 데이터 숨김

    public MaxCounter(int max) {
        this.max = max;
    }

    public void increment() {
        if (count >= max) {
            System.out.println("최대값을 초과할 수 없습니다.");
            return;
        }
        count++;
    }

    public int getCount() {
        return count;
    }
}

6.2 ShoppingCart — 객체 간 캡슐화 협력

1
2
3
4
5
6
7
8
9
10
11
12
package access.ex;

public class ShoppingCartMain {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();
        Item item1 = new Item("마늘", 2000, 2);
        Item item2 = new Item("상추", 3000, 4);
        cart.addItem(item1);
        cart.addItem(item2);
        cart.displayItems();
    }
}

실행 결과:

1
2
3
4
장바구니 상품 출력
상품명:마늘, 합계:4000
상품명:상추, 합계:12000
전체 가격 합:16000

Item 클래스 정답:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package access.ex;

public class Item {
    private String name;
    private int price;
    private int quantity;

    public Item(String name, int price, int quantity) {
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    public String getName() { return name; }

    public int getTotalPrice() {
        return price * quantity;  // ✅ 자기 데이터를 자기가 계산!
    }
}

pricequantity를 외부에 반환하여 외부에서 곱하는 대신, getTotalPrice()에서 자기 데이터를 자기가 직접 계산하여 결과만 제공.

ShoppingCart 클래스 정답:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package access.ex;

public class ShoppingCart {
    private Item[] items = new Item[10];
    private int itemCount;

    public void addItem(Item item) {
        if (itemCount >= items.length) {
            System.out.println("장바구니가 가득 찼습니다.");
            return;
        }
        items[itemCount] = item;
        itemCount++;
    }

    public void displayItems() {
        System.out.println("장바구니 상품 출력");
        for (int i = 0; i < itemCount; i++) {
            Item item = items[i];
            System.out.println("상품명:" + item.getName() +
                ", 합계:" + item.getTotalPrice());
        }
        System.out.println("전체 가격 합:" + calculateTotalPrice());
    }

    private int calculateTotalPrice() {   // ✅ 내부에서만 사용 → private
        int totalPrice = 0;
        for (int i = 0; i < itemCount; i++) {
            totalPrice += items[i].getTotalPrice();
        }
        return totalPrice;
    }
}

calculateTotalPrice()private인 이유: displayItems() 내부에서만 사용되는 유틸리티 메서드이므로 외부에 노출할 필요가 없다.


7. static — 인스턴스가 아닌 클래스에 소속

접근 제어자가 “누가 볼 수 있는가”를 결정한다면, static은 “이 멤버가 어디에 소속되는가“를 결정한다. 이 두 개념은 독립된 축(orthogonal axes)이다.

7.1 메모리 관점

구분저장 위치생명 주기개수
인스턴스 변수Heap객체 생성 ~ GC 수거인스턴스마다 하나
static 변수Method Area (Metaspace)클래스 로딩 ~ 프로그램 종료클래스당 딱 하나
지역 변수Stack메서드 호출 ~ 리턴호출마다 하나

7.2 코드 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Student {
    private String name;             // 인스턴스 변수: 학생마다 다름
    private static int totalCount;   // static 변수: 모든 Student가 공유

    public Student(String name) {
        this.name = name;
        totalCount++;                // 생성될 때마다 카운트 증가
    }

    public static int getTotalCount() {
        return totalCount;
        // this.name ← ❌ 컴파일 에러! static 메서드에서 인스턴스 변수 접근 불가
    }
}

7.3 핵심 규칙

규칙 1: static 메서드에서 인스턴스 멤버 접근 불가

static 메서드는 특정 인스턴스에 바인딩되지 않으므로 this 사용 불가. “누구의 name?” — 대답할 수 없다.

규칙 2: static은 오버라이드되지 않음 (Method Hiding)

1
2
3
4
5
6
7
8
9
class Parent {
    static void greet() { System.out.println("Parent"); }
}
class Child extends Parent {
    static void greet() { System.out.println("Child"); }  // hiding, not overriding
}

Parent p = new Child();
p.greet();  // "Parent" 출력! — 컴파일 타임에 결정 (early binding)

7.4 접근 제어자 + static 조합

조합의미대표 예시
private static클래스 내부에서만 쓰는 공유 데이터싱글톤의 instance 필드
public static어디서든 접근 가능한 클래스 레벨 멤버Math.PI, Integer.MAX_VALUE
public static final상수 (변경 불가 + 어디서든 접근)public static final double PI = 3.14159
private final인스턴스별 불변 데이터불변 객체의 필드

Key takeaways

  • 접근 제어자는 캡슐화를 안전하게 완성하는 장치
    • private: 같은 클래스 내부에서만. 캡슐화의 핵심
    • default: 같은 패키지에서만. 키워드 없이 적용
    • protected: 같은 패키지 + 상속 관계
    • public: 어디서든 접근 가능
  • 캡슐화 2대 원칙
    • 데이터(속성)는 모두 private으로 숨겨라
    • 기능(메서드)도 외부에 필요한 것만 public으로 열어라
    • 내부 유틸리티 메서드는 private으로 숨겨라
  • 클래스 레벨 규칙
    • 클래스에는 publicdefault만 사용 가능
    • public 클래스 = 파일명과 동일, 파일당 하나
  • static은 접근 제어자와 독립된 축
    • 인스턴스가 아닌 클래스에 소속 → Method Area에 딱 하나만 존재
    • static 메서드에서 인스턴스 멤버 접근 불가
  • private final 조합
    • private = 외부 접근 차단 (캡슐화)
    • final = 변경 차단 (불변성)
    • 둘의 조합 = 불변 객체의 핵심 규칙

Reading flow