JAVA/Effective Java

[아이템 15] 클래스와 멤버의 접근 권한을 최소화하라

꾸준함. 2024. 2. 1. 15:55

클래스가 얼마나 잘 설계되었는지 평가하는 중요한 지표 중 하나는 캡슐화(encapsulation)입니다.

이는 내부 데이터와 구현 세부 정보가 외부 컴포넌트로부터 얼마나 효과적으로 숨겨져 있는지를 나타냅니다.

잘 설계된 컴포넌트의 경우 오직 API를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 영향을 받지 않습니다.

자바는 캡슐화를 위한 다양한 장치를 제공하는데 그중 접근 제어 메커니즘은 클래스, 인터페이스, 멤버의 접근 허용 범위를 명시하며 해당 접근 제한자를 제대로 활용하는 것이 정보 은닉의 핵심입니다.

 

캡슐화 장점

컴포넌트를 캡슐화함으로써 취할 수 있는 장점은 아래와 같이 크게 5가지가 있습니다.

  • 시스템 개발 속도를 높임
  • 시스템 관리 비용을 낮춤
  • 성능 최적화에 도움을 줌
  • 소프트웨어 재사용성을 높임
  • 시스템 개발 난이도를 낮춤

 

1. 시스템 개발 속도를 높임

 

정보 은닉을 적용하면 자연스럽게 인터페이스 설계가 이루어집니다. 

인터페이스를 활용하는 개발자는 단순히 해당 인터페이스에 맞춰 개발하면 되고, 구현 담당자는 인터페이스에 정의된 동작을 따르는 클래스를 정의하면 됩니다. 

인터페이스가 설계되면 사용자 측과 구현 측은 동시에 병렬로 개발할 수 있어, 이는 개발 속도 향상에 큰 도움이 됩니다.

혼자 개발할 때는 인터페이스의 중요성을 적절히 평가하기 어려울 수 있습니다. 

그러나 다수의 팀원이 함께 작업하는 경우, 인터페이스는 협업의 핵심 요소로 작용합니다. 

인터페이스는 모든 팀원 간의 공통된 이해와 협의점을 제공하며, 이는 효율적인 개발 및 일관된 결과물을 도출하는 데 결정적인 역할을 합니다.

 

2. 시스템 관리 비용 낮춤

 

인터페이스를 구현한 클래스들은 인터페이스 설계에 따라 개발되었으므로, 인터페이스를 확인하는 것만으로도 컴포넌트를 신속하게 파악할 수 있습니다. 

변경이 필요한 경우, 인터페이스 규격을 수정하고 구현체들도 변경된 규격에 맞게 수정하면 됩니다. 

이로써 캡슐화를 적용한 시스템은 유지보수 및 관리 비용이 낮아지게 됩니다.

 

3. 성능 최적화에 도움을 줌

 

정보 은닉 자체가 성능을 높여주지는 않지만 성능 최적화에 도움이 됩니다.

완성된 시스템을 프로파일링 해 최적화할 컴포넌트를 선정한 후 다른 컴포넌트에 영향을 주지 않으면서 해당 컴포넌트만 최적화할 수 있기 때문입니다.

내부 구현의 변경에 대한 유연성을 제공하므로, 성능 향상을 위한 내부 로직을 변경하더라도 외부 인터페이스가 변하지 않으면서 최적화를 수행할 수 있습니다.

 

4. 소프트웨어 재사용성 높임

 

외부에 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 해당 컴포넌트와 함께 개발되지 않은 모듈에서도 유용하게 쓰일 확률이 높습니다.

 

5. 시스템 개발 난이도 낮춤

 

규모가 큰 시스템의 경우 멀티 모듈로 개발되면 각 개별 컴포넌트를 단위로 검증할 수 있어 시스템 개발 난이도가 낮아집니다. 

다시 말해, 모듈 단위로 Divide and Conquer 전략을 적용할 수 있다고 볼 수 있습니다.

 

접근 제한자 사용 원칙

접근 제한자의 기본 원칙은 간단합니다.

모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 합니다.

즉, 소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 합니다.

클래스/인터페이스에 대한 사용 원칙 및 멤버에 대한 원칙은 각각 다르기 때문에 간략히 정리해 보겠습니다.

 

클래스/인터페이스에 대한 접근 제한자 사용 원칙

 

top level(가장 바깥에 위치한) 클래스와 인터페이스에는 package-private(default) 혹은 public 접근 제한자를 사용할 수 있습니다.

개인적으로 여태까지 public으로만 사용했었는데 public으로 선언할 경우 공개 API가 되므로 자바가 추구하는 하위 호환성을 유지하기 위해 영원히 관리해야하는 단점이 생깁니다.

  • 부연 설명을 하자면 모듈에는 여러 버전이 존재하고 제공자 입장에서는 클라이언트가 어떤 버전을 사용하고 있는지 알 방도가 없음
  • 따라서 모든 클라이언트 코드가 깨지지 않기 위해서는 하위 버전에 대한 코드 유지가 필요하며 이에 따라 변화가 필요할 때도 코드 수정에 소극적일 수밖에 없음

 

개인적으로는 변화가 필요하다고 판단할 때 finalizer처럼 @Deprecated 어노테이션을 붙인 후 주석으로 몇 버전부터는 지원하지 않는다고 명시하여 클라이언트 코드 수정을 강제하는 것도 나쁘지 않다고 봅니다.

 

이처럼 접근제한자를 public으로 선언했을 때 단점도 존재하기 때문에 클래스나 인터페이스를 패키지 내부에서만 사용할 경우 package-private으로 선언하는 것을 권장합니다.

인터페이스의 경우 패키지 밖에서 사용되는 경우가 많기 때문에 대부분 public으로 선언하겠지만 인터페이스의 구현체는 내부 패키지에서만 알아도 되는 케이스도 종종 있습니다.

위와 같은 케이스에서는 인터페이스를 사용하는 입장에서 구체적인 클래스를 IOC 컨테이너를 통해 제공받는 형식으로 사용하면 되기 때문에 굳이 구현체까지 public일 필요는 없습니다.

아이템 1에서 다루었던 ServiceLoader가 인터페이스만 알면 되는 예 중 하나입니다.

 

 

 

마지막으로 저자는 한 클래스에서만 사용하는 package-private top level 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static으로 중첩시키라고 권장했습니다.

private이 아닌 private static으로 선언하라는 이유는 아래와 같습니다.

  • inner private class는 해당 클래스를 감싸고 있는 외부 인스턴스를 참조하기 때문에 외부 인스턴스를 참조하는 필드가 자동으로 생김
  • 반면 private static class는 외부 inner class이지만 남이나 마찬가지이기 때문에 외부 클래스와 아무런 참조가 없음
    • 애초에 별개 선언하려고 했던 클래스들을 한 클래스에 합치는 것이기 때문에 private static이 어울리고
    • 두 클래스 간의 관계가 간단해진다는 장점 존재

 

아래 예시 코드를 보면 단순히 private으로 inner class를 선언할 경우 외부 클래스를 참조하지만 private static으로 선언한 클래스는 외부를 참조하지 않는 것을 확인할 수 있습니다.

 

 

 

멤버의 접근 제한자 사용 원칙

 

여기서 멤버는 필드, 메서드, 그리고 중첩 클래스 및 인터페이스를 의미합니다.

멤버에 부여할 수 있는 접근 수준은 top level 클래스/인터페이스와 달리 네 가지입니다.

  • private
  • package-private
  • protected
  • public

 

1. private과 package-private 접근제한자를 최대한 활용하자

앞서 접근 제한자 사용 원칙으로 모든 클래스와 멤버의 접근성을 최대한 좁혀야한다고 강조했습니다.

따라서 클래스의 공개 API를 세심히 설계 후 그 외의 모든 멤버는 우선 private으로 만드는 것을 권장합니다.

일단 private으로 만든 후 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private으로 풀어주되 너무 많은 필드를 package-private으로 풀어야 한다면 컴포넌트를 더 분해해야 하는지를 고려해봐야 합니다.

그래도 다행인 점은 후술 할 protected와 public과 달리 private과 package-private은 클래스 내부 구현에 해당하므로 보통은 공개 API에 영향을 주지 않는다는 점입니다.

Serializable로 구현한 클래스에서 private 및 protected 필드가 의도치 않게 공개 API가 될 수가 있는데 이는 Java Serialization 메커니즘이 객체의 상태를 저장하고 복원하는 과정에서 필드에 대한 접근 제한자(visibility modifier)를 무시하기 때문입니다.

만약 클래스가 민감한 정보를 지니고 있을 경우 직렬화/역직렬화를 직접 제어하기 위해 readObject, writeObject 메서드를 재정의하는 것을 권장합니다.

 

2. public 클래스의 protected와 public 접근 제한자는 공개 API

public 클래스에서 멤버를 protected로 선언하는 순간 앞서 소개한 두 접근 제한자와 달리 해당 멤버에 접근할 수 있는 대상 범위가 엄청나게 넓어집니다.

public 클래스의 protected 멤버는 공개 API이므로 하위 호환성을 위해 영원히 지원하게 될 수 있으며 내부 동작 방식을 API 문서에 적어 사용자에게 공개해야 할 수도 있습니다.

이 때문에 protected 멤버의 수는 소소익선이며 이는 public 멤버 또한 마찬가지입니다.

다만 객체지향의 SOLID 원칙 중 하나인 리스코프 치환 원칙에 의거하여 상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 하기 때문에 상위 클래스의 메서드를 재정의할 때 그 접근 수준을 상위 클래스에서보다 좁게 설정할 수 없습니다.

이 때문에 인터페이스 구현체는 인터페이스가 정의하는 모든 메서드를 public으로 선언해야 합니다.

 

3. 테스트 코드 목적으로 멤버를 공개 API로 만드는 것은 지양하자

굳이 getter 메서드가 없는 필드를 테스트 목적으로 public api를 만드는 것은 지양해야 합니다.

필요할 경우 private 접근제한자를 package-private으로 변경 후 같은 패키지 내에서 테스트 코드 작성을 하면 됩니다.

 

4. public 클래스의 인스턴스 필드는 되도록 public이 아니어야 함

필드가 가변 객체를 참조하거나 final이 아닌 인스턴스 필드를 public으로 선언할 경우 모든 코드가 해당 필드에 접근할 수 있기 때문에 불변식을 보장하지 못하고 데이터의 일관성을 깨뜨리고 thread-safe 하지 않은 상태를 유발할 수 있습니다.

위 문제는 정적 필드에서도 마찬가지이지만 딱 한 가지 예외가 존재합니다.

기본 타입 값이나 불변 객체를 참조하는 상수일 경우 public static final 접근 제한자를 붙여 공개해도 무방합니다.

  • 상수가 가변 객체일 경우 final 키워드가 붙어 다른 객체를 참조하지 못하지만 참조된 객체 자체는 수정될 수 있기 때문에 무의미

 

5. 클래스에서 public static final 배열 필드를 두거나 해당 필드를 반환하는 접근 메서드 제공하면 안 됨

길이가 0이 아닌 배열의 경우 언제든지 값을 수정할 수 있기 때문에 public static final 배열 필드를 두거나 해당 필드를 반환하는 접근자 메서드를 제공해서는 안됩니다.

굳이 배열을 인스턴스 필드로 두고 싶을 경우 아래와 같이 코드를 작성해야 합니다.

 

1. 배열을 private으로 생성하고 public 불변 리스트를 추가

 

 

 

2. 배열을 private으로 생성하고 그 복사본을 반환하는 public 메서드 제공

 

 

정리

캡슐화의 장점을 살리기 위해 프로그램 요소의 접근성은 가능한 한 최소화하는 것이 좋습니다.

따라서 꼭 필요한 것만 골라 최소한의 public API를 설계하고 클래스, 인터페이스, 그리고 멤버가 의도치 않게 API로 공개되는 일이 없도록 해야 합니다.

 

비고

java 9+ 버전부터 모듈 시스템이라는 개념이 도입되면서 두 가지 암묵적 접근 수준이 추가되었지만 JDK 외에 모듈 개념을 활용하는 케이스가 거의 없어 "모듈 시스템이라는 개념이 있다" 정도만 알면 될 것 같습니다. 

 

참고

이펙티브 자바
이펙티브 자바 완벽 공략 2부 - 백기선 강사님

반응형