반응형

조슈아 블로크의 'Effective Java' 책을 읽고 제멋대로 정리한 내용입니다. :)

 

1. 개요

 잘 설계된 컴포넌트의 판단 기준 중 하나는 클래스 내부 필드와 구현 정보를 외부로부터 얼마나 잘 숨겼는지이다. 구현 정보를 숨기면 외부 컴포넌트에서 이를 사용할때 내부 동작 방식에는 전혀 신경쓰지 않게 된다. 이를 소프트웨어 설계 근간이 되는 원리인 정보은닉 혹은 캡슐화라고 한다.

 


2. 정보은닉의 장점

 

2.1. 시스템 개발 속도를 높인다.

 다른 컴포넌트의 동작 방식에 신경쓰지 않는다는 건, 컴포넌트를 개발하는 시점에서 다른 컴포넌트의 구현에 대해 신경쓰지 않아도 된다는 것이다. 작업자들이 컴포넌트를 병렬적으로 개발할 수 있다.

 

2.2. 시스템 관리 비용을 낮춘다.

 정보은닉을 통해 결합도가 낮아진 컴포넌트는 파악하기 쉽기에 디버깅 / 유지보수에 효율적이다.

 

2.3. 성능 최적화에 도움을 준다.

 성능 최적화는 곧 코드 수정이다. 앞서 말한것처럼 결합도가 낮으므로 어떤 컴포넌트를 최적화하기 위해 코드를 수정한다 한들 다른 컴포넌트에 영향을 미치지 않는다. 온진히 성능 최적화 작업에 집중할 수 있다.

 

2.4. 소프트웨어 재사용성을 높인다.

 외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 다른 어플리케이션에서도 유용하게 재사용될 가능성이 크다.

 

2.5. 큰 시스템을 제작하는 난이도를 낮춰준다.

 단위 컴포넌트의 동작을 테스트를 통해 검증할 수 있다.

 


3. 정보 은닉의 기본 원칙

 정보 은닉 원리를 적용한 컴포넌트 설계의 첫 단추는 당연하게도 정보 은닉의 기본 원칙을 준수하는 것이다.

정보 은닉의 기본 원칙
모든 클래스와 멤버필드의 접근성을 가능한 한 좁혀야 한다.

 그럼 접근성을 좁히는 가장 간단한 방법은 뭐가 있을까?


4. 접근성을 좁히는 가장 간단한 방법! 접근제어자

접근제어자
클래스 및 멤버에 대해 접근할 수 있는 범위를 제어해주는 제어자이다.
private, package-private(default), protected, public이 있다.

 


5. 클래스 접근제어자

 클래스 접근제어자는 public, package-private (default 제어자) 만 사용된다. public 사용 시 모든 클래스에서 접근 가능하며, package-private 사용 시 해당 패키지에 존재하는 클래스에서만 접근 가능하다. 

 


 1) 동일 패키지의 public AClass 클래스

package org.ssk.item16.usecase1.pack1;

public class AClass {
}

 

 2) 동일 패키지의 pacakge-private BClass 클래스

package org.ssk.item16.usecase1.pack1;

class BClass {
}

 

 3) 동일 패키지의 Main 클래스

package org.ssk.item16.usecase1.pack1;

public class Main {
    AClass aClass = new AClass(); // 같은 패키지에서 접근 가능한 AClass
    BClass bClass = new BClass(); // 같은 패키지에서 접근 가능한 BClass
}

 

 Main 클래스는 BClass, AClass 와 동일한 패키지에 위치하기 때문에 각 클래스로 접근이 가능하다.


 4) 하위 패키지의 EClass 클래스

package org.ssk.item16.usecase1.pack1.innerPack;

public class EClass {
    
    AClass aClass = new AClass();
    BClass bClass = new BClass(); // 컴파일 에러 발생
}

 

 하위 패키지에 위치한 EClass는 BClass와 다른 패키지에 위치하기 때문에 접근을 못하며 컴파일 에러가 발생한다.


 5) 상위 패키지의 RootClass

package org.ssk.item16.usecase1;

public class RootClass {

    AClass aClass = new AClass();
    BClass bClass = new BClass(); // 컴파일 에러 발생
}

상위 패키지에 위치한 RootClass도 BClass와 다른 패키지에 위치하기 때문에 컴파일 에러가 발생한다.

 


 중요한 점은 접근 제어자를 통해 정해지는 클래스의 성격이다. public으로 선언한 클래스는 어느 클래스에서나 접근 가능하다는 점에서 '공개 API' 성격을, package-private로 선언한 클래스는 해당 패키지에서만 접근이 가능하다는 점에서 '내부 구현' 성격을 띈다.

 

 일반적으로 내부 구현은 외부에서 접근 불가하므로 클라이언트에 대한 인터페이스로 사용하지 않는다. 클라이언트에 신경 쓸 필요 없이 코드를 수정할 수 있다는 뜻이다. 반면, 공개 API는 그 API를 사용하는 모든 코드가 해당 클래스에 의존하게 된다. 이 클래스에 대한 변경(교체)은 하위 호환성 문제를 일으킬 수 있다.

(변경 가능성이 있는 클래스의 경우 인터페이스가 활용하면 호환성을 지킬 수 있다. )

 

 때문에 public 클래스는 공개 API를 제공하고, package-private 클래스는 내부 구현으로 숨겨 사용한다. 접근 제어자만 설정했을 뿐인데 자연스럽게 정보 은닉 성격을 띄게 되었다.

 

1) Client

APIClass apiClass = new APIClass();
String result = apiClass.sayHi();
System.out.println(result);

 

2) APIClass

package org.ssk.item16.usecase4;

public class APIClass {

    public String sayHi(){
        return ImplementClass.hi();
    }

}

 

3) ImplementClass

package org.ssk.item16.usecase4;

class ImplementClass {

    static String hi(){
        return "hi";
    }
}

 

 ImplementClass는 해당 패키지에 있는 클래스들이라면 자유롭게 접근할 수 있다. 만약 여러 클래스가 아닌 특정 클래스에서만 접근할 수 있도록 하고 싶다면, 클래스 안에 private static class(중첩 클래스) 만들어 사용할 수도 있다.

public class PublicClass {

    public void logic(){
        InnerClass.innerLogic();
    }
    
    //publicClass 클래스에서만 접근 가능한 클래스
    private static class InnerClass{

        static void innerLogic(){

        }
    }
}

6. 멤버 접근 제어자

 멤버 접근 제어자의 멤버는 필드, 메서드, 중첩 클래스, 중첩 인터페이스를 뜻한다. 멤버 접근 제어자는  private, package-private, protected, public 이 사용되며, 접근 범위는 다음과 같다.

접근 제어자 같은 클래스의 멤버 같은 패키지의 멤버 자식 클래스의 멤버 그 외 영역
public O O O O
protected O O O X
default O O X X
private O X X X

출처 : TCP School (https://www.tcpschool.com/java/java_modifier_accessModifier)

 


7. 기본적인 접근 제어자 설계

 

 기본적인 접근 제어자 설계 방법은 다음과 같다.

 

1) 클래스에 대한 공개 API를 먼저 설계한다.

 

2) 클라이언트가 호출할 일이 없는 멤버는 private로 하여 외부 접근을 차단한다.

 

3) 같은 패키지 내 다른 클래스에서 private 멤버에 대해 접근해야한다면 package-private로 만들어준다.

 

4) 작성한 클래스 내에 package-private가 많아질 경우 컴포넌트를 분해하여 pacakge-private 클래스로 관리해야하는 것은 아닌지 고민한다.

 


8. 멤버의 접근성 제어에 대한 제약

 멤머의 접근성을 제어할 때 하나의 제약이 있다. 상위 클래스의 메서드를 재정의할 때 접근 수준을 상위 클래스보다 좁게 설정할 수 없다는 것이다. 이는 상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 한다는 리스코프 치환 원칙을 위배하기 때문이다. 

 

리스코프 치환 원칙(LSP)
하위 타입은 언제나 상위 타입과 호환될 수 있어야 한다는 원칙으로 다형성을 보장하기 위한 원칙이다.

 

어떤 부분에서 이 원칙을 위배하는지 예제를 통해 알아보자.

 


8.1. 리스코프 치환 원칙을 지키는 케이스

UpperClass 클래스의 hello 메서드를 Client 클래스에서 호출하고 있다. hello가 정상적으로 출력된다.

public class UpperClass {

    public void hello(){
        System.out.println("hello");
    }
}
public class Client {
    public static void main(String[] args) {
        UpperClass clazz = new UpperClass();
        clazz.hello(); // hello 출력
    }
}

 

 여기에 서브 클래스를 만들고, UpperClass의 hello 메서드를 재정의하였다. 일단 접근제어자를 수정하지 않고 public으로 동일하게 가져갔다.

public class SubClass extends UpperClass{

    @Override
    public void hello() {
        System.out.println("hello my friend!");
    }
}

 

 그 후 Client에서 상위 타입 대신 하위 타입 인스턴스로 변경하였다. 리스코프 치환 원칙이 지켜진다.

public class Client {

    public static void main(String[] args) {
        // UpperClass clazz = new UpperClass();
        SubClass clazz = new SubClass(); // 기반 타입 대신 하위 타입을 사용한다
        clazz.hello(); // hello my friend! 출력
    }
}

 


8.2. 리스코프 치환 원칙을 위배하는 케이스

 재정의한 메서드의 접근 제어자를 public 에서 protected로 수정하였더니 컴파일 에러가 발생했다. UpperClass의 hello는 어디서든 호출이 가능한데 바뀐 SubClass의 hello는 자식클래스 혹은 같은 클래스에서만 호출이 가능하다. 즉, SubClass가 UpperClass를 대체할 수 없기 때문에 리스코프 교환 원칙 위배하게 되는 것이다.

public class SubClass extends UpperClass{

    @Override
    protected void hello() {
        System.out.println("hello my friend!");
    }
}

컴파일 에러 발생

 

 이에 대한 다른 예로 인터페이스와 구현 클래스가 있는데, 이때 클래스는 인터페이스가 정의한 모든 메서드를 public으로 선언해야 한다. 접근제어자로 인한 리스코프 교환 원칙을 위배하지 않아야 하기 때문이다.


9. public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다.

 

 필드가 가변 객체를 참조하거나 final이 아닌 필드public으로 선언하면 해당 필드를 제한할 수 없게 된다. 어디서든 해당 필드에 접근하여 수정할 수 있으며 이는 상태 값을 공유하는 것이므로 Thread Safe 하지 않다.

 하지만 클래스 내에서 바뀌지 않는 꼭 필요한 상수라면 public static final 필드로 공개해도 좋다. 이런 필드는 불변성을 가져야 하므로 기본 타입 값이나 불변 객체를 참조해야 한다.

 하지만 길이가 0이 아닌 배열은 final이어도 수정이 가능하기 때문에 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안된다.

 

아래의 경우 길이 0인 emptyArr는 값을 추가하려 할 때 arrayIndexOutOfBoundsException이 발생하여 런타임 시 수정이 불가능하지만, arr의 경우 예외가 발생하지 않고 수정됨을 확인할 수 있다.

public class MyClass {
    public static final Thing[] arr = {new Thing(), new Thing()};
    public static final Thing[] emptyArr = {};
}

public class Main {
    public static void main(String[] args) {
        MyClass.arr[0] = new Thing(); // 런타임 에러가 발생하지 않음.
        MyClass.emptyArr[0] = new Thing(); // ArrayIndexOutOfBoundsException 발생
    }
}

 

이에 대한 해결책은 두 가지다. 첫번째 방법은 public 배열을 private로 만들고 public 불변 리스트를 추가하는

것이고, 두번째 방법은 clone을 통한 방어적 복사를 사용하는 것이다.

 전자의 경우 수정은 불가능하지만 PRIVATE_VALUES과 동일함을 보장할 수 있고, 후자의 경우 자유롭게 수정할 수 있다. 상황에 따라 선택하면 된다.

public class MyClass {
    private static final Thing[] PRIVATE_VALUES = {new Thing(), new Thing()};

    public static final List<Thing> VALUES = List.of(PRIVATE_VALUES); // 불변 객체의 List로 변환 후 반환
    
    public static Thing[] values(){
        return PRIVATE_VALUES.clone(); // clone을 통해 방어적 복사
    }
}

10. 정리

정보은닉 기반의 설계를 위해 공개 API는 꼭 필요한 것만 골라 최소한으로 설계해야 한다.

그 외에는 클래스, 인터페이스, 멤버가 의도치 않게 공개 API가 되지 않도로 한다.

public 클래스는 상수용 public static final 필드 외에 어떠한 public 필드도 가져서는 안된다.

반응형

+ Recent posts