들어가기 전
이 글은 인프런 백기선님의 이펙티브 자바를 복습하며 작성한 글입니다.
아이템 15. 클래스와 멤버의 접근 권한을 최소화하라.
아이템 15의 핵심정리는 다음과 같다. 아래에서는 필요한 내용들을 잘 정리해보고자 한다.
- 구현과 API를 분리하는 '정보 은닉'의 장점
- 클래스와 인터페이스의 접근 제한자 사용 원칙
- 멤버(필드, 메서드, 중첩 클래스/인터페이스)의 접근 제한자 원칙
핵심 정리1: 구현과 API를 분리하는 "정보 은닉"의 장점
- 시스템 개발 속도를 높인다.
- 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다. API를 기준으로 개발하기 때문에 API가 다 개발될 때까지 기다리지 않고 나머지를 구현하면 된다.
- 시스템 관리 비용을 낮춘다.
- 컴포넌트를 더욱 빨리 파악할 수 있기 때문이다. 공개된 API만으로 컴포넌트를 파악하는데 충분하기 때문이다.
- 성능 최적화에 도움을 준다
- 프로파일링을 통해 최적화 할 컴포넌트를 찾고 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 개선할 수 있기 때문이다.
- 소프트웨어 재사용성을 높인다.
- 시스템 개발 난이도를 낮춘다.
- 전체를 만들기 전에 개별 컴포넌트를 검증할 수 있기 때문에
public, private 접근 지시자를 잘 이용하면 캡슐화 / 정보은닉을 잘할 수 있다. 캡슐화 + 정보 은닉을 잘하면 어떤 장점이 있을까?
정보은닉을 잘 하려고 하면 자연스럽게 인터페이스를 설계하게 된다. 인터페이스 설계가 완료되면 인터페이스를 구현하는 사람 / 인터페이스를 사용하는 사람이 동시에 개발을 할 수 있게 된다. 따라서 좋은 정보은닉 / 캡슐화라면 개발속도가 빨라진다.
- 인터페이스를 사용하는 사람은 인터페이스에서 제공해주는 메서드의 인풋/아웃풋 명세에 따라 개발하면 된다.
- 인터페이스를 구현하는 사람은 인터페이스 정의된 대로 클래스를 구현하면 된다.
또한 인터페이스를 기준으로 캡슐화가 잘 되어있다면 시스템 관리 비용을 낮출 수 있다. 인터페이스를 기준으로 캡슐화가 잘 되어있다면, 인터페이스에 명세된 메서드를 보는 것만으로 이 컴포넌트가 하는 역할을 이해할 수 있다. 반면 적절한 인터페이스가 없다면 기능 개발을 위해 어떤 클래스부터 살펴 봐야할지 모르게 된다.
성능 최적화에 도움을 준다. 정보 은닉이 잘 되어있을 때, 성능의 병목 지점을 찾아내는 것이 더 용이하다. 가장 성능을 많이 먹는 모듈을 찾아내서 개선한다던지 할 수 있다.
소프트웨어 재사용성을 높일 수 있고, 시스템 개발 난이도를 낮춘다. 정보은닉 / 캡슐화로 잘 모듈화가 되었다고 가정해보자. 그럼 이런 클래스가 모여서 모듈이 되고, 모듈이 모여서 어플리케이션된다. 분할 & 정복의 좋은 예시가 된다.
핵심 정리 2 : 클래스와 인터페이스의 접근 제한자 사용 원칙
이 내용은 반드시 지켜야 하는 것은 아니다. 이 내용을 지킨다면 코드를 조금 더 객체지향에 가깝게 작성할 수 있는 것을 의미한다.
- 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.
- 톱레벨 클래스/인터페이스에는 package-private 또는 public을 쓸 수 있다.
- public으로 선언하면 API가 되므로 하위 호환성을 유지하려면 영원히 관리해야한다.
- 패키지 외부에서 쓰지 않을 클래스나 인터페이스라면 package-private으로 선언한다.
- 한 클래스에서만 사용하는 package-private 클래스/인터페이스는 해당 클래스에 private static class로 중첩 시키자. (아이템 24)
아래에서 이 내용에 대해서 조금 더 자세히 공부해보고자 한다.
package com.example.effectivejava1.chapter15.member;
// TopLevel Interface --> public, package-private만 가능.
public interface MemberService {
}
톱레벨 클래스/인터페이스는 어떤 파일의 최상단에 선언되는 녀석을 의미한다. 이곳에 붙일 수 있는 접근 지시자는 package-private(default 접근 지시자), public을 사용할 수 있다. 어떤 지시자를 사용하느냐에 따라 클래스가 가지는 의미가 달라진다.
- package-private 지시자 사용 → 내부 구현체
- public 지시자 사용 → API
API가 되는 순간부터 하위 호환성을 유지하려면 영원히 해당 클래스를 관리해야한다. 여기서 하위 호환성의 개념을 살펴보고가자. 하위호환성을 지킨다는 것은 새로운 버전이 예전 버전의 API 역시 지원한다는 것을 의미한다.
우리가 만드는 컴포넌트의 버전이 올라간다. 컴포넌트의 버전이 올라가서 jar 파일이 생기지만, 이전 버전의 jar 파일 역시 누군가가 쓰고 있을 것이다. 그런데 한번 public으로 공개한 클래스는 다른 사람들이 어떻게 사용하고 있을지 모른다. 이걸 인지하지 않고 API를 바꾼다면, 버전 업된 jar 파일을 사용하게 된 사용자들은 모두 코드를 다 수정해야한다. 이런 것을 하위호환성이라고 한다.
예를 들어 각 클래스의 접근 지시자를 선언할 때, 다음을 생각해서 선택한다고 가정해보자.
- MemberService 인터페이스 : 외부에서 사용할 비즈니스 로직을 제공함. Item 컴포넌트는 MemberService 인터페이스만 가져다가 비즈니스 로직을 처리하길 바람. 따라서 public으로 선언.
- DefaultMemberService 클래스 : MemberService 인터페이스의 구현체. 세부 구현이고, 세부 구현은 외부에서 알 필요가 없다. 따라서 패키지 내부에서만 사용할 수 있으면 되기 때문에 package-private로 구현함.
- Member 클래스 : Member는 Package 밖으로 공개되어도 괜찮은 정보. 도메인 클래스로 생각할 수 있음. Item 클래스도 Member 클래스를 그대로 참조하면 좋겠음. 따라서 public으로 작성.
위와 같은 패키지 구조를 가지고, 목적을 가졌다면 각 접근 지시자는 다음과 같이 사용하면 된다.
// 외부 공개. 인터페이스를 통해서만 비즈니스 로직 사용
public interface MemberService {
}
// 세부 구현 사항. 패키지 내부에서만 사용.
class DefaultMemberService implements MemberService{
}
// 도메인 클래스. 다른 곳에서도 이대로 사용하면 좋겠음.
public class Member {
}
한 클래스에서만 사용하는 package-private 클래스/인터페이스는 해당 클래스에 private static으로 중첩 시키자. (아이템 24)
예를 들어 MemberRepository 인터페이스가 존재하고, 패키지 내부에서만 존재하기 때문에 package-private 형태로 존재한다고 가정해보자. 아래와 같은 구성이 될 것이다.
이 때, MemberRepository를 참조하는 곳이 DefaultMemberService만 있다고 가정해보자. 이 경우에는 굳이 MemberRepository 인터페이스를 DefaultMemberService와 분리할 필요가 없다. 이 때는 MemberRepository를 private static class로 DefaultMemberService 내부에 중첩 시키는 것이 좋다. 코드로 표현하면 아래처럼 볼 수 있다.
// 세부 구현 사항. 패키지 내부에서만 사용.
class DefaultMemberService implements MemberService{
// 내부 중첩 클래스 + private static으로 사용
private static interface MemberRepository{
}
}
그렇다면 왜 private class가 아닌 private static class로 내부 중첩 클래스를 생성해야할까? 이것은 묵시적 참조와 관련되어있다.
- 중첩 클래스로 private class를 선언하면, 내부 클래스는 항상 외부 클래스의 참조를 묵시적으로 가진다.
- 내부 클래스는 외부 클래스의 내부로 항상 접근할 수 있게 된다.
- 묵시적 참조는 Cleaner의 GC 관점에서 문제가 될 수 있다. 내부 클래스는 외부 클래스를 항상 Strong Reference 하기 때문이다.
- static class는 내부 클래스지만, 남과 다름없다. 외부 클래스의 인스턴스를 참조하지 않는다.
예를 들어 private class로 내부 중첩 클래스를 다음과 같이 선언해보자.
// 세부 구현 사항. 패키지 내부에서만 사용.
class DefaultMemberServiceWithJustPrivate implements MemberService{
private int n1;
public DefaultMemberServiceWithJustPrivate() {
}
public void printData() {
Field[] declaredFields = MemberRepositoryWithPrivate.class.getDeclaredFields();
for (Field field : declaredFields) {
System.out.println("field Type = " + field.getType());
System.out.println("field Name = " + field.getName());
}
}
// 내부 중첩 클래스 + private static으로 사용
private class MemberRepositoryWithPrivate{
}
public static void main(String[] args) {
DefaultMemberServiceWithJustPrivate defaultMemberServiceWithJustPrivate = new DefaultMemberServiceWithJustPrivate();
defaultMemberServiceWithJustPrivate.printData();
}
}
그리고 이 결과를 확인할 수 있도록 main() 메서드를 실행해보자. 결과는 아래에서 볼 수 있듯이, private 내부 중첩 클래스로 선언된 MemberRepositoryWithPrivate은 부모 클래스의 참조를 가진다는 것을 볼 수 있다. 만약 private static class로 내부 클래스를 생성한다면, 이 내부 클래스는 외부 클래스의 인스턴스와는 상관없이 생성되기 때문에 외부 인스턴스에 대한 참조를 가지지 않는다.
Repository 클래스의 성격은 독립적인 클래스에 가깝다. 이런 관점에서 생각해본다면, private static class를 사용하는 것이 가장 좋다. 만약 private class를 사용하면, repository는 묵시적으로 service를 참조한다. 이것은 두 가지를 의미한다.
- repository가 service에 대한 의존성이 증가했다.
- repository, service는 양방향 참조를 한다.
이 관점도 고려해본다면, 단방향 참조(헥사고날 아키텍쳐) + 의존성을 줄일 수 있기 때문에 private static class로 내부 클래스를 선언해서 이것을 줄이는 것이 더 좋다.
핵심 정리 3 : 멤버(필드, 메서드, 중첩 클래스/인터페이스)의 접근 제한자 원칙
- 각 접근 지시자는 서로 다른 의미를 가진다.
- private / package-private : 내부 구현
- public / protected 공개 API
- 코드를 테스트 하는 목적으로 private을 pakcage-private으로 풀어주는 것은 허용할 수 있다. 하지만 테스트만을 위해서 멤버를 공개 API(public/protected)로 만들어서는 안된다. (테스트를 같은 패키지에 만든다면 그럴 필요도 없다.)
- public 클래스의 필드는 되도록 public이 아니어야 한다. (아이템 16에서 다룸)
- 클래스에서 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안 된다.
앞선 핵심 정리에서는 클래스의 접근 권한을 최소화하는 관점을 살펴봤다. 이번 핵심정리에는 클래스의 멤버에 대한 접근 권한을 최소화 하는 관점을 살펴본다.
클래스의 멤버는 필드 / 메서드 / 중첩 클래스 / 중첩 인터페이스를 의미한다. 이 멤버들에게 사용할 수 있는 접근 제한자는 private, package-private, protected, public이 있다. 가장 기본적인 원칙은 다음과 같다.
- 기본적인 공개 API를 제외하고는 모든 멤버를 private로 만든다.
- 필요하다면 package-private 정도로 하향한다. 만약 이런 작업이 많아진다면 component 구성이 잘못되었을 수도 있으니, component를 나누자.
private, package-private은 내부구현이다. 정보은닉에 해당하는 것이고, 정보은닉을 위해서는 이 접근 지시자를 이용해서 감춰주면 된다. 만약 밖(클라이언트)가 사용해야 할 메서드 / 필드가 있다면 public으로 선언한다. 필드라면 상수인 경우에만 public static final로 공개하는 것을 권장한다.
public class ItemService {
// 만약 상수를 공개해야 한다면, public static final로 공개
public static final String NAME = "whiteship";
// 기본적으로 모두 private으로 설정. 필요하면 getter 같은 메서드로 접근하도록 함.
private MemberService memberService;
boolean onSale;
protected int saleRate;
public ItemService(MemberService memberService) {
this.memberService = memberService;
}
public MemberService getMemberService() {
return memberService;
}
}
코드를 테스트 하는 목적으로 private → pakcage-private으로 풀어주는 것은 허용할 수 있다. 하지만 테스트만을 위해서 멤버를 공개 API로 만들어서는 안된다.
예를 들어서 위 코드에서 MemberService는 외부 패키지에서 참조 할 수 있지만, DefaultMemberService는 외부 패키지에서 참조할 수 없다. 따라서 다른 패키지에 생성한 테스트 코드는 DefaultMemberService에 대한 테스트코드를 작성할 수 없게 된다.
DefaultMemberService는 private-package로 되어있어서 다른 패키지에서 테스트 코드를 작성할 수 없다. 그렇다면 테스트 코드를 어떻게 작성하지? 이럴 때를 위해서 Mocking을 사용하는 것이다. 예를 들어 ItemService를 테스트 하는 코드를 작성한다고 가정해보자.
ItemService의 코드는 다음과 같다. 이 때 MemberService가 package-private 필드라는 것에 주목하자.
public class ItemService {
public static final String NAME = "whiteship";
// private -> package-private
MemberService memberService;
boolean onSale;
protected int saleRate;
public ItemService(MemberService memberService) {
this.memberService = memberService;
}
public MemberService getMemberService() {
return memberService;
}
}
이 녀석에 대한 테스트 코드를 작성하면 어떨까? Mocking을 사용하면 ItemService / MemberService에 대한 테스트 코드를 작성할 수 있다. 이 때 ItemService가 가지고 있는 내부 변수인 memberService에 대한 테스트도 확인하고 싶다고 가정해보자.
그런데 memberService는 private 이기 때문에 테스트 코드에서 작성할 수 없다. 그렇다면 이 때는 어떤 방법이 있을까?
@ExtendWith(MockitoExtension.class)
class ItemServiceTest {
@Mock
MemberService memberService;
@Test
void test1() {
ItemService itemService = new ItemService(memberService);
Assertions.assertThat(itemService).isNotNull();
Assertions.assertThat(itemService.memberService).isNotNull();
}
}
다음 두 가지 방법을 고려해볼 수 있다.
- private → package-private로 하향한다. 이것은 충분히 가능한 일이다.
- 테스트를 위해 public api인 getter()를 생성한다. 이것은 하면 안된다.
public으로 getter()를 만든다면 공개 API가 되고, 하위 호환성을 고려해야하는 코드가 된다. 그렇지만 memberService는 내부구현이고, 내부 구현끼리 확인한다면 packge-private으로 바꾼 후 테스트 코드를 작성하는 것이 의미상 더욱 맞다. 만약 필요하다면 getter()를 package-private으로 구현해서 부가 기능까지 추가해 볼 수도 있다.
정리하면 만약 테스트를 위해서 멤버의 공개가 필요하다면 private → package-private 수준까지 낮추는 것은 가능하지만, 테스트만을 위해서 공개 API를 만드는 것은 바람직 하지 않다는 것이다.
public static final 상수 사용 시, Collection 계열은 절대로 사용하면 안된다.
일반적으로 클래스의 상수는 public static final로 공개한다. 그런데 이 때 Collection 계열은 절대로 사용하면 안된다. Collection 계열은 상수의 의미를 제공하지 못하기 때문이다.
public static final List<String> MY_COLLECTIONS = List.of("A", "B", "C");
이렇게 선언한 것의 의미는 MY_COLLECTIONS가 가지는 객체가 저 List로 유지된다는 것이다. List 안에 어떠한 값이 추가되고, 삭제되는 것은 막지 못한다. 따라서 상수의 의미로 사용했는데, 상수처럼 사용되지 않게 된다.
요약
- private, package-private는 내부 정보. protected + public은 공개 API임.
- protected부터는 상속받으면, 상속받는 클래스부터 이 멤버를 사용할 수 있게 된다. 즉, 공개 API가 되는데, 공개 API가 되는 순간부터 하위 호환성을 유지하려면 유지보수 비용이 증가한다.
- 따라서 public / protected는 가급적이면 사용하지 않는 것을 권장한다.
- private으로 모두 만든 다음에 테스트 코드를 작성하다가, 필요한 경우에는 private을 package-private으로 변경해서 사용할 수 있다. 그렇지만 테스트 코드 때문에 public한 코드 (공개 API)를 만드는 것은 금물이다.
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
Effective Java : 아이템9. 완벽공략 (0) | 2023.04.05 |
---|---|
Effective Java : 아이템16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라. (0) | 2023.04.05 |
Effective Java : 아이템8. 완벽공략 (0) | 2023.04.03 |
Effective Java : 아이템8. finalizer와 cleaner 사용을 피하라 (0) | 2023.04.03 |
Effective Java : 아이템9. try-finally 보다 try-with-resources를 사용하라 (0) | 2023.04.02 |