🤔 왜 필요하지?

자바는 접근 제어자(Access Modifier)라는 것을 제공한다. 이걸 사용하면 해당 클래스 외부에서 특정 필드나 메서드에 접근하는 것을 허용하거나 제한할 수 있다. 대충 느낌은 뭔지 알겠다. 궁극적으로 접근 제어자가 왜 필요한지 아래 예제를 통해 알아보자.

만약 스피커 소프트웨어를 개발하려고 하는데, 스피커의 음량은 절대 100을 넘으면 안 된다는 제한사항이 있다고 할 때, 스피커 객체를 설계해보자.

package access;

public class Speaker {
    
    int volumn;

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

    void volumnUp() {
        if (volumn < 100) {
            System.out.println("볼륨이 10만큼 증가되었습니다.");
            volumn += 10;
        } else {
            System.out.println("볼륨은 100을 넘을 수 없습니다!");
        }

    }

    void volumnDown() {
        if (volumn > 0) {
            System.out.println("볼륨이 10만큼 감소되었습니다.");
            volumn -= 10;
        } else {
            System.out.println("볼륨을 0 아래로 내릴 수 없습니다!");
        }
    }

    void showVolumn() {
        System.out.println("현재 볼륨은 " + volumn + "입니다.");
    }
}
package access;

public class SpeakerMain {
    public static void main(String[] args) {
        Speaker speaker = new Speaker(90);

        speaker.showVolumn();

        speaker.volumnUp();
        speaker.volumnUp();

        speaker.volumnDown();
        speaker.volumnDown();

        speaker.showVolumn();

    }
}

/*
현재 볼륨은 90입니다.
볼륨이 10만큼 증가되었습니다.
볼륨은 100을 넘을 수 없습니다!
볼륨이 10만큼 감소되었습니다.
볼륨이 10만큼 감소되었습니다.
현재 볼륨은 80입니다.
*/

이런 식으로 스피커의 음량이 100을 넘지 않도록 개발에 성공했다.

 

근데 나중에 다른 개발자가 더 좋은 스피커로 만들기 위해 음량을 100을 넘을 수 있도록 만든다고 한다면? Speaker 클래스에 volumn 멤버 변수가 선언되어 있는 걸 보고 직접 접근해서 음량을 설정하려고 한다고 가정해보자.

package access;

public class SpeakerMain {
    public static void main(String[] args) {
        Speaker speaker = new Speaker(90);

        speaker.showVolumn();

        speaker.volumnUp();
        speaker.volumnUp();

        speaker.volumnDown();
        speaker.volumnDown();

        speaker.showVolumn();
        
        // volumn 필드에 직접 접근
        System.out.println("필드에 직접 접근해서 수정!");
        speaker.volumn = 150;
        speaker.showVolumn();

    }
}

/*
현재 볼륨은 90입니다.
볼륨이 10만큼 증가되었습니다.
볼륨은 100을 넘을 수 없습니다!
볼륨이 10만큼 감소되었습니다.
볼륨이 10만큼 감소되었습니다.
현재 볼륨은 80입니다.
필드에 직접 접근해서 수정!
현재 볼륨은 150입니다.
*/

Speaker 클래스에서의 제한 사항은 그대로 유지되는데, 보다시피 volumn 필드를 직접 수정하니 볼륨이 아주 쉽게 100을 돌파했다. 왜 이런 일이 일어난 걸까?

Speaker 객체를 사용하는 사용자는 보다시피, 클래스의 멤버 변수와 메서드에 모두 접근할 수 있다. volumnUp() 메서드로 제한 사항을 걸어놔도 필드에 직접 접근해서 원하는 값으로 바꾸면 그만이다. 이런 문제가 발생하기 때문에, 외부에서 필드로의 접근을 막을 방법이 필요한데 이때 “접근 제어자” 가 필요한 것이다.

 

private int volumn;

위와 같이, 멤버 변수 앞에 private 접근 제어자를 붙여주자. 이러면 같은 클래스 안에서는 필드에 접근이 가능하지만, 바깥 클래스에서는 접근이 불가능하다. 실행해보면 컴파일 오류가 나는 것을 확인할 수 있다.

/Users/byeonguk/Desktop/java-basic/src/access/SpeakerMain.java:19:16
java: volumn has private access in access.Speaker

 

그림을 통해 비교해보자.

private 접근 제어자를 통해 volumn 필드를 클래스 내부에 숨겨서 클래스 내부에서만 접근할 수 있도록 한 것이다. 애초에 내가 처음 스피커를 개발했을 때, volumn 필드의 외부 접근을 제한했다면, 다른 개발자는 해당 필드에 접근할 수 없었을 것이고, 메서드를 통해서만 데이터를 통제해 스피커가 고장나는 일은 없었을 것이다. 이처럼 좋은 프로그램이라는 건, 무한한 자유도를 주어지는 것이 아니라 적절한 제약을 제공하는 프로그램이다.


🗂️ 접근 제어자의 종류

자바는 아래와 같이 4가지를 제공한다.

  • private: 모든 외부 호출을 막는다.
  • default(package-private): 같은 패키지 안에서 호출은 허용된다.
  • protected: 같은 패키지 안에서 호출이 허용되고, 패키지가 달라도 상속 관계의 호출은 허용된다.
  • public: 모든 외부 호출을 허용한다.

 

접근 제어자는 필드, 메서드, 생성자에 사용된다. 생성자도 접근 제어자 관점에서 메서드와 같다. 클래스 레벨에도 일부 접근 제어자를 사용할 수도 있고, 지역 변수에는 사용할 수 없다.

public class Speaker {
		
	private int volumn;
		
	public Speaker(int volumn) {}
		
	public void volumnUp() {}
	public void volumnDown() {}
	public void showVolumn() {}
}

접근 제어자의 핵심은 속성과 기능을 어디까지 오픈하고, 어디까지 닫을까를 결정하는 것이다.


🧀 사용 예제 - 필드, 메서드

패키지 위치에 유의하면서 다양한 상황에 따른 접근 제어자 예제를 살펴보자.

 

<필드, 메서드 레벨의 접근 제어자>

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;
        
        publicMethod();
        defaultMethod();
        privateMethod();
    }
}
package access.a;

public class AccessInnerMain {
    public static void main(String[] args) {
        
        AccessData data = new AccessData();
        
        // public은 아무 곳에서나 호출 가능
        data.publicField = 1;
        data.publicMethod();

        // 같은 패키지의 default 호출 가능
        data.defaultField = 2;
        data.defaultMethod();

        // private은 호출 불가
        // data.privateField = 3;
        // data.privateMethod();

        data.innerAccess();
    }
}

/*
publicMethod 호출 1
defaultMethod 호출 2
내부 호출
publicMethod 호출 100
defaultMethod 호출 200
privateMethod 호출 300
*/

현재 패키지 위치는 둘 다 package access.a이다. 이때, public은 패키지 위치와 상관없이 필드와 메서드 모두 접근이 가능하고, default도 같은 패키지에 접근할 수 있는 것을 볼 수 있다. private 접근 제어자는 현재 클래스가 다르기 때문에 AccessInnerMain 클래스에서는 호출이 불가한 것을 확인할 수 있다. 그리고 innerAccess() 메서드는 public으로 되어 있지만, 메서드 안에서 privateMethod()를 호출한다. 하지만, 다행히도 호출된 privateMethod()는 같은 클래스에 존재하므로 컴파일 오류가 발생하지 않는다.

 

package access.b 패키지에 다른 코드를 만들어보자.

package access.b;

import access.a.AccessData;

public class AccessOuterMain {
    public static void main(String[] args) {

        AccessData data = new AccessData();
        // public 호출 가능
        data.publicField = 1;
        data.publicMethod();

        // 다른 패키지 default 호출 불가
        // data.defaultField = 2;
        // data.defaultMethod();

        // private 호출 불가
        // data.privateField = 3;
        // data.privateMethod();

        data.innerAccess();
    }
}

현재 AccessData 클래스가 속한 패키지와 다르기 때문에 default, private 접근 제어자가 붙은 필드와 메서드에는 접근할 수 없는 것을 볼 수 있다.


🍔 사용 예제 - 클래스 레벨

지금까지는 항상 클래스를 정의할 때, 앞에 public을 붙였다. 이게 무슨 의미인지 한번 살펴보자.

일단, 클래스 레벨의 접근 제어자에는 규칙이 있다.

  • public, default만 사용할 수 있다.
  • public 클래스는 반드시 파일명과 이름이 같아야 한다.
    • 하나의 자바 파일에는 하나의 public 클래스만 존재할 수 있다.
    • default 접근 제어자를 사용하는 클래스는 하나의 자바 파일에 무한정 만들 수 있다.
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 {}

class DefaultClass2 {}

위와 같이 PublicClass라는 이름의 클래스를 만들고, public 접근 제어자를 붙여줬다. 이 클래스는 외부에서 접근할 수 있는 것이다. 그 아래에 DefaultClass1, DefaultClass2default 접근 제어자다. 그렇기 때문에 같은 패키지 내부에서만 접근할 수 있다. main() 메서드를 보면, public은 당연하고, 같은 패키지 내부이기 때문에 default도 사용 가능하다.

 

이제 다른 예제를 살펴보자.

package access.a;

public class PublicClassInnerMain {
    public static void main(String[] args) {
        
        PublicClass publicClass = new PublicClass();
        DefaultClass1 class1 = new DefaultClass1();
        DefaultClass2 class2 = new DefaultClass2();
    }
}

당연하게도, 패키지가 같기 때문에 default 접근 제어자가 붙은 클래스를 통해 인스턴스를 생성해도 문제가 없다. 그럼 아래 예제와도 비교해보자.

package access.b;

import access.a.PublicClass;

public class PublicClassOuterMain {
    public static void main(String[] args) {
        
        PublicClass publicClass = new PublicClass();

		// 이거 접근 가능할까?
        DefaultClass1 class1 = new DefaultClass1();
        DefaultClass2 class2 = new DefaultClass2();
    }
}
/Users/byeonguk/Desktop/java-basic/src/access/b/PublicClassOuterMain.java:4:16
java: access.a.DefaultClass1 is not public in access.a; cannot be accessed from outside package

 

보다시피, 패키지가 다르기 때문에 default 접근 제어자가 붙은 클래스(DefaultClass1, DefaultClass2)에는 접근이 불가능하다.


💊 캡슐화

캡슐화는 알다시피 객체 지향 프로그래밍에서 가장 중요한 개념 중 하나다. 데이터와 해당 데이터를 처리하는 메서드를 하나로 묶어서 외부에서의 접근을 제한하는 것을 말한다. 쉽게 말해, 속성과 기능을 하나로 묶고, 외부에 꼭 필요한 기능만 노출하고 나머지는 모두 내부로 숨기는 것이라고 할 수 있다. 여기서 더 안전하게 캡슐화를 완성할 수 있도록 도와주는 장치가 바로 “접근 제어자” 다.

 

그럼 어떤 것을 노출하고, 어떤 것을 숨겨야 할까?

“먼저 데이터를 숨겨야 한다.”

객체에는 데이터와 메서드가 있다고 했다. 일단 캡슐화로 가상 필수로 숨겨야 하는 것은 데이터다. 맨앞에서 살펴봤던 스피커 예제를 생각하면 바로 왜인지 알 것 같다. 객체 내부의 데이터를 외부에서 함부로 접근할 수 있게 내버려두면, 클래스 안에 정의된 메서드를 깡그리 무시하고 데이터를 직접 변경할 수도 있다. 이러면 캡슐화를 한 의미가 없는 것이다. 객체의 데이터는 객체가 제공하는 메서드를 통해서만 접근해야 한다.

 

“기능을 숨겨라”

방금 기능을 열어두라고 하지 않았나? 전부 다 숨기라는 말이 아니고, 기능 중에 객체 내부에서만 사용하는 기능들이 있다. 이런 기능들을 숨기라는 의미다. 자동차를 예로 들면, 자동차 내부에는 여러 가지 기능들이 있을 것이다. 그 중에 엔진을 조절하는 기능, 냉각 기능, 배기 기능 등 내가 알 필요 없는 기능들도 많다. 결국 내가 사용하는 기능은 엑셀, 브레이크, 핸들 조절 정도 밖에 없을 것이다.

 

데이터는 모두 숨기고, 기능은 꼭 필요한 기능만 노출하는 것이 바로 좋은 캡슐화라고 할 수 있다. 아래 잘 정돈된 캡슐화 예제를 뜯어보자.

package access;

public class BankAccount {

    private int balance;

    public BankAccount() {
        balance = 0;
    }

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

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

    // public 메서드: getBalance
    public int getBalance() {
        return balance;
    }

    private boolean isAmountValid(int amount) {
        // 금액이 0보다 커야 한다.
        return amount > 0;
    }
}
package access;

public class BankAccountMain {

    public static void main(String[] args) {
        BankAccount account = new BankAccount();

        account.deposit(10000);
        account.withdraw(3000);

        System.out.println("balance = " + account.getBalance());
    }
}

/*
balance = 7000
*/

여기서 private 접근 제어자로 막은 것은,

  • balance(잔액): BankAccount가 제공하는 메서드를 통해서만 접근할 수 있도록 했다.
  • isAmountValid(): 입력 금액을 검증하는 기능은 내부에서만 필요한 기능이므로 private으로 외부에서의 접근을 막아준다.

public으로 노출시켜서 실제 사용자가 사용할 항목들은,

  • deposit(): 입금 기능
  • withdraw(): 출금 기능
  • getBalance(): 잔고 확인 기능

이런 식으로, BankAccount를 사용하는 사용자 입장에서는 단 3가지 메서드만 알면 된다. 나머지 속성이나 기타 메서드 등은 클래스 내부에 숨겨둔다.

 

만약, isAmountValid() 메서드를 외부에 노출시키면 어떻게 될까? 아마 사용하는 입장에서는, 입금과 출금을 하기 전에 금액 검증을 해야 하는건지 헷갈릴 것이다. balance는 괜찮을까? 잠깐만 생각해봐도 아찔하다. 필드에 직접 접근해서 잔고를 강제로 늘리거나 줄일 수 있다.

이처럼 접근 제어자를 적절히 사용해서 캡슐화를 완성한다면, 데이터를 안전하게 보호하는 것은 물론이고, 클래스를 사용하는 개발자 입장에서 기능을 사용하는 복잡도도 낮출 수 있다.


🧮 문제 - 최대 카운터와 캡슐화

MaxCounter 클래스를 만들어야 하는데, 아래와 같은 속성과 기능을 가지고 있다.

  • int count: 내부에서 사용하는 숫자로, 초기값은 0이다.
  • int max: 최댓값이다. 생성자를 통해 입력한다.
  • increment(): 숫자를 하나 증가시킨다.
  • getCount(): 지금까지 증가한 값을 반환한다.

추가적으로, 접근 제어자를 통해 데이터를 캡슐화해야 하고, 해당 클래스는 다른 패키지에서도 사용할 수 있어야 한다.

// 내가 푼 풀이
package access.ex;

public class MaxCounter {

    private int count;
    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;
    }
}
package access.ex;

public class CounterMain {
    public static void main(String[] args) {

        MaxCounter mc = new MaxCounter(3);
        mc.increment();
        mc.increment();
        mc.increment();
        mc.increment();

        System.out.println("지금까지 증가한 값은 " + mc.getCount() + "입니다.");
    }
}

/*
값을 더 이상 증가시킬 수 없습니다!
지금까지 증가한 값은 3입니다.
*/

🛒 문제 - 쇼핑 카트

ShoppingCartMain 코드가 작동하도록 Item, ShoppingCart 클래스를 완성해야 한다.

  • 접근 제어자를 사용해서 데이터를 캡슐화해야 한다.
  • 해당 클래스는 다른 패키지에서도 사용할 수 있게 해야 한다.
  • 장바구니에는 상품을 최대 10개만 담을 수 있도록 한다.
    • 10개 초과 등록 시 : 오류 메시지 출력
// 내가 푼 풀이
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;
    }

}
package access.ex;

public class ShoppingCart {

    private Item[] items = new Item[10];
    private int count;

    public void addItem(Item item) {
        if (count >= items.length) {
            System.out.println("장바구니 항목들은 10개를 초과할 수 없습니다!");
        } else {
            items[count] = item;
            count++;
        }
    }

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

        System.out.println("전체 가격 합:" + calculateTotalPrice());
    }

    private int calculateTotalPrice() {
        int totalPrice = 0;
        for (int i = 0; i < count; i++) {
            Item item = items[i];
            totalPrice += item.getTotalPrice();
        }

        return totalPrice;
    }
}
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();
    }
}

/*
장바구니 상품 출력
상품명:마늘, 합계:4000
상품명:상추, 합계:12000
전체 가격 합:16000
*/
profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글