이펙티브 자바 완벽 공략 1부

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

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


아이템 15. 핵심 정리 - 구현과 API를 분리하는 “정보 은닉”의 장점

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

캡슐화(Encapsulation)와 정보 은닉(Information Hiding)

이펙티브 자바 2부의 첫 번째 아이템은 “클래스와 멤버의 접근 권한을 최소화하라” 이다.

이번 아이템의 핵심은 다음 두 가지다.

  • 캡슐화(Encapsulation)
  • 정보 은닉(Information Hiding)

자바는 다음과 같은 접근 제어자를 제공한다.

public
protected
package-private
private

접근 권한을 어떻게 설정하느냐에 따라

  • 외부 공개 범위
  • 유지보수성
  • 변경 용이성
  • 결합도

가 결정된다.

즉 이번 아이템은

무엇을 공개하고 무엇을 숨길 것인가

에 대한 설계 철학을 다룬다.


왜 캡슐화가 중요한가?

캡슐화는 단순히 코드를 깔끔하게 만드는 기술이 아니다.

실제로는 다음과 같은 효과를 가져온다.

  • 개발 속도 향상
  • 유지보수 비용 감소
  • 성능 최적화 용이
  • 재사용성 증가
  • 대규모 시스템 관리 용이

개발 속도를 높인다

인터페이스 중심 설계

캡슐화를 잘 하면 자연스럽게 인터페이스 중심 설계가 된다.

예를 들어 다음과 같은 인터페이스가 있다고 가정해보자.

public interface PaymentService {
    void pay();
}

이 경우

  • 사용하는 쪽은 인터페이스 기준으로 개발
  • 구현하는 쪽은 실제 로직 구현

이 가능해진다.

병렬 개발 가능

예를 들어

  • A팀 → 인터페이스 사용
  • B팀 → 구현체 개발

처럼 동시에 작업할 수 있다.

즉 인터페이스는 협업을 위한 약속이 된다.

규모가 커질수록 중요하다

혼자 개발할 때는 체감이 적을 수 있다.

하지만 시스템 규모가 커질수록

명확한 인터페이스

는 필수 요소가 된다.


유지보수 비용을 낮춘다

시스템 구조 파악이 쉬워진다

캡슐화가 잘 되어 있으면

  • 시스템 구조 이해
  • 디버깅
  • 기능 교체

가 쉬워진다.

인터페이스 중심 구조의 장점

프로젝트에 처음 투입되더라도

인터페이스만 보면

  • 핵심 흐름
  • 의존 관계
  • 역할 분리

를 빠르게 이해할 수 있다.

디버깅이 쉬워진다

문제가 발생했을 때

어느 모듈에서 발생했는가

를 빠르게 좁혀갈 수 있다.

구현체 교체가 쉬워진다

예를 들어

MysqlRepository

RedisRepository

로 교체하더라도

인터페이스만 유지하면 된다.

핵심은 의존성 감소

캡슐화의 목적은

구현 세부사항에 대한 의존 제거

이다.


성능 최적화에도 유리하다

병목 지점 파악이 쉽다

캡슐화 자체가 성능을 올려주지는 않는다.

하지만 병목을 찾기 쉽게 만든다.

예를 들어

  • DB 병목
  • 캐시 병목
  • MQ 병목
  • API 병목

등을 빠르게 식별할 수 있다.

최적화 범위를 줄일 수 있다

전체 시스템을 건드리는 것이 아니라

특정 모듈만 집중적으로 개선

할 수 있게 된다.


재사용성이 올라간다

재사용 가능한 모듈 생성

캡슐화가 잘 된 모듈은 다른 프로젝트에서도 쉽게 재사용할 수 있다.

예를 들어

NotificationService

가 있다고 하자.

구현은 숨기고 기능만 공개

내부 구현은

  • Slack
  • Email
  • KakaoTalk

등을 사용하더라도

외부에는

send(message)

만 공개한다.

결과

다른 프로젝트에서도 그대로 사용할 수 있는 재사용 가능한 컴포넌트가 된다.


큰 시스템 개발 난이도를 낮춘다

Divide and Conquer

캡슐화는 결국

Divide and Conquer(분할 정복)

전략과 연결된다.

시스템을 작은 단위로 분리

예를 들어

  • User
  • Payment
  • Notification
  • Settlement
  • Auth

등으로 나누어 개발할 수 있다.

복잡한 문제를 단순하게 만든다

복잡한 시스템도

작은 문제들의 집합

으로 분해할 수 있게 된다.


접근 제어자의 핵심 철학

최소 공개 원칙

이번 아이템의 핵심은

필요한 것만 공개하라

이다.

기본은 private

가능한 한

private

를 사용하고

정말 필요한 경우에만

protected
public

을 사용한다.


public이 위험한 이유

public은 외부 계약(API)이다

public은 단순한 접근 제어자가 아니다.

외부 사용자와 맺는 계약이다.

변경이 어려워진다

한 번 공개되면

외부 코드가 의존하기 시작한다.

따라서 함부로 변경할 수 없다.

라이브러리에서는 더욱 중요하다

public API 변경은

하위 호환성(Backward Compatibility)

문제를 발생시킨다.


private이 중요한 이유

언제든 변경 가능하다

private 멤버는

  • 자료구조 변경
  • 알고리즘 변경
  • 정책 수정
  • 성능 개선

이 자유롭다.

외부 영향이 없다

외부에서 접근할 수 없기 때문에

리팩토링 자유도가 매우 높다.


캡슐화의 진짜 의미

흔한 오해

많은 개발자가 캡슐화를

Getter / Setter 만드는 것

정도로 생각한다.

진짜 의미

캡슐화의 본질은

변경 가능한 것을 숨기는 것

이다.

목표는 변경 격리

변화가 발생하더라도

그 영향이 외부로 전파되지 않도록 만드는 것이 핵심이다.


실무에서 자주 보이는 안 좋은 예시

잘못된 설계

public String name;
public int age;

문제점

누구나

  • 아무 값이나 대입 가능
  • 검증 우회 가능
  • 상태 오염 가능

하다.

즉 캡슐화가 완전히 무너진 상태다.

개선된 방식

private String name;

그리고

changeName()

같은 메서드를 제공한다.

장점

메서드 내부에서

  • 검증
  • 로깅
  • 이벤트 발행
  • 비즈니스 정책 적용

이 가능해진다.


핵심 정리

  • 캡슐화는 정보 은닉이다.
  • 접근 권한 최소화가 핵심이다.
  • 인터페이스는 협업 속도를 높인다.
  • 유지보수 비용을 줄인다.
  • 병목 분석이 쉬워진다.
  • 재사용성을 높인다.
  • 대규모 시스템을 다루기 쉽게 만든다.
  • public은 신중하게 공개해야 한다.
  • private은 리팩토링 자유도를 높인다.

한 줄 정리

캡슐화는 단순히 숨기는 기술이 아니라, 변화의 영향을 통제하여 소프트웨어를 오래 유지 가능하게 만드는 설계 전략이다.

아이템 15. 핵심 정리 - 클래스와 인터페이스의 접근 제한자 사용 원칙

이번 파트에서는 클래스와 인터페이스를 설계할 때 접근 제한자를 어떻게 선택해야 하는지 살펴본다.

접근 제한자는 단순한 문법이 아니다. 무엇을 API로 공개하고, 무엇을 내부 구현으로 숨길 것인지를 결정하는 중요한 설계 도구다.


최상위 클래스와 인터페이스의 접근 수준

최상위 클래스와 인터페이스는 사실상 두 가지 접근 수준만 사용할 수 있다.

public class MemberService {
}
class MemberService {
}

즉 선택지는 다음 두 가지다.

  • public
  • package-private

접근 제한자를 생략한 경우를 흔히 default 접근 제한자라고 부르지만, 정확히는 package-private 이다.

public

public으로 선언하면 외부 패키지에서도 접근할 수 있다.

즉, 외부에 공개된 API가 된다.

package-private

package-private은 같은 패키지 내부에서만 접근할 수 있다.

즉, 외부 공개 대상이 아닌 내부 구현을 의미한다.


public과 package-private의 선택 기준

최상위 클래스나 인터페이스를 설계할 때는 다음 질문을 던지면 된다.

이 타입을 패키지 밖에서도 사용할 가치가 있는가?

패키지 밖에서도 사용해야 한다면 public으로 선언한다.

패키지 내부에서만 사용한다면 package-private으로 둔다.


public은 단순한 접근 제한자가 아니다

많은 개발자는 다음과 같이 생각한다.

외부에서 사용해야 하니까 public

하지만 public은 단순히 “외부 접근 허용”이 아니다.

public은 곧 공개 API 를 의미한다.

public interface PaymentService {
    void pay();
}

이 순간부터 외부 코드는 이 인터페이스에 의존하기 시작한다.


public API는 오래 관리해야 한다

한 번 공개된 API는 쉽게 변경할 수 없다.

예를 들어 다음 인터페이스를 공개했다고 하자.

public interface PaymentService {
    void pay();
}

나중에 메서드를 다음처럼 변경하면 기존 사용자 코드가 깨질 수 있다.

void pay(String currency);

따라서 public API는 다음을 고려해야 한다.

  • 하위 호환성 유지
  • 문서화
  • 장기 유지보수
  • 버전 관리

그래서 public을 선언하기 전에는 반드시 고민해야 한다.

정말 외부에 공개해야 하는가?


자바 생태계는 하위 호환성을 중요하게 생각한다

Spring, Hibernate, JUnit 같은 프레임워크들도 공개 API를 변경할 때 매우 신중하다.

이유는 간단하다. 수많은 사용자 코드가 이미 그 API에 의존하고 있기 때문이다.

물론 하위 호환성을 무조건 영원히 유지하는 것이 항상 정답은 아니다. 하지만 공개 API를 변경한다는 것은 사용자 코드에 직접적인 영향을 줄 수 있으므로 신중해야 한다.


실무에서 추천하는 접근 제한자 선택 순서

접근 제한자를 고를 때는 다음 순서로 생각하는 것이 좋다.

private 가능?
 ↓
package-private 가능?
 ↓
protected 필요?
 ↓
정말 public이 필요한가?

기본 전략은 다음과 같다.

일단 숨기고, 필요할 때만 공개하라.

공개 범위가 좁을수록 변경하기 쉽고, 리팩토링하기 쉽고, 결합도도 낮아진다.


인터페이스는 공개하고 구현체는 숨겨라

객체지향 설계에서 자주 사용하는 방식은 인터페이스는 공개하고 구현체는 숨기는 것이다.

public interface MemberService {
    Member findMember(Long id);
}

구현체는 다음과 같이 package-private으로 둘 수 있다.

class DefaultMemberService implements MemberService {
}

구현체를 숨기는 이유

외부 사용자는 “회원 조회 기능”에 관심이 있다.

DefaultMemberService라는 구체적인 구현체에는 관심이 없다.

즉, 외부는 인터페이스만 알면 된다.

구현체를 숨기면 얻는 장점

나중에 구현체를 바꾸더라도 외부 코드는 수정되지 않는다.

예를 들어 다음 구현체를

DefaultMemberService

다음 구현체로 바꾸더라도

CachingMemberService

외부 코드가 MemberService 인터페이스에만 의존하고 있다면 영향이 없다.

이것이 캡슐화가 제공하는 유연성이다.


인터페이스에 의존하고 구현체에 의존하지 말라

이 방식은 객체지향 설계의 중요한 원칙을 자연스럽게 실천하게 만든다.

인터페이스에 의존하라.
구현체에 의존하지 말라.

구현체를 숨기면 외부 코드는 구체 클래스가 아니라 역할과 계약에 의존하게 된다.

이렇게 하면 구현체 교체, 테스트 대역 사용, 확장 구조 설계가 훨씬 쉬워진다.


한 클래스에서만 사용하는 클래스는 중첩 클래스로 만들자

책에서는 다음과 같은 조언을 한다.

한 클래스에서만 사용하는 package-private 클래스나 인터페이스는 그 클래스 안으로 중첩시키자.

예를 들어 다음 두 클래스가 있다고 하자.

DefaultMemberService
MemberRepository

그런데 실제로는 다음처럼 DefaultMemberServiceMemberRepository를 사용한다면

DefaultMemberService
        ↓
MemberRepository

굳이 MemberRepository를 바깥에 둘 필요가 없다.


private static 중첩 클래스를 고려하라

이런 경우 다음처럼 내부로 넣을 수 있다.

class DefaultMemberService {

    private static class MemberRepository {

    }
}

이렇게 하면 다음 의도가 명확해진다.

이 클래스는 오직 DefaultMemberService 내부 구현이다.

외부에서는 알 필요도 없고, 접근할 수도 없다.


왜 private static인가?

많은 개발자가 단순히 private inner class를 떠올릴 수 있다.

class Outer {

    private class Inner {

    }
}

하지만 일반 inner class는 바깥 객체와 강하게 연결된다.


일반 Inner Class는 바깥 객체를 참조한다

일반 inner class는 내부적으로 바깥 객체 참조를 가진다.

Outer.this

즉, 안쪽 클래스가 바깥 객체와 연결된다.

Outer ↔ Inner

이 구조는 생각보다 강한 결합을 만든다.

일반 Inner Class의 특징

  • 바깥 객체 상태에 접근할 수 있다.
  • 바깥 객체의 생명주기에 영향을 받는다.
  • 숨은 참조가 생긴다.
  • 결합도가 높아진다.

Static Nested Class는 독립적이다

반면 static nested class는 바깥 객체를 자동으로 참조하지 않는다.

class Outer {

    private static class Inner {

    }
}

즉, 이름만 바깥 클래스 안에 있을 뿐 구조적으로는 훨씬 독립적이다.

private static 중첩 클래스의 장점

  • 바깥 인스턴스 참조가 없다.
  • 결합도가 낮다.
  • 구조가 단순하다.
  • 메모리 사용이 줄어든다.
  • 내부 구현이라는 의도가 명확하다.

언제 Inner Class를 사용해야 할까?

안쪽 클래스가 바깥 객체의 상태를 자연스럽게 사용해야 한다면 일반 inner class를 사용할 수 있다.

예를 들어 다음처럼 바깥 객체의 필드에 의존한다면 inner class가 어울릴 수 있다.

Outer.this.name

반대로 안쪽 클래스가 바깥 객체의 상태에 의존하지 않는다면 private static 중첩 클래스가 더 적합하다.


접근 제한자는 설계 도구다

접근 제한자는 단순히 “누가 접근할 수 있는가”를 정하는 문법이 아니다.

접근 제한자는 다음을 결정한다.

  • 무엇을 공개할 것인가
  • 무엇을 숨길 것인가
  • 무엇을 API로 볼 것인가
  • 어떤 클래스가 어떤 클래스에 의존할 것인가
  • 결합도를 어디까지 허용할 것인가

따라서 접근 제한자는 설계의 일부다.


핵심 정리

  • 최상위 클래스와 인터페이스는 public 또는 package-private 중 하나를 선택한다.
  • public은 외부에 공개되는 API다.
  • 공개 API는 하위 호환성을 고려하며 오래 관리해야 한다.
  • 기본적으로는 가능한 좁은 접근 범위를 선택한다.
  • 인터페이스는 공개하고 구현체는 숨기는 것이 좋다.
  • 구현체를 숨기면 교체와 확장이 쉬워진다.
  • 한 클래스에서만 사용하는 클래스는 중첩 클래스로 만드는 것을 고려한다.
  • 바깥 객체 상태에 의존하지 않는다면 private static 중첩 클래스를 사용하자.
  • 일반 inner class는 바깥 객체를 참조하므로 결합도가 높아질 수 있다.
  • 접근 제한자는 단순 문법이 아니라 설계 전략이다.

한 줄 정리

접근 제한자는 “누가 접근할 수 있는가”를 결정하는 문법이 아니라, “무엇을 API로 공개하고 무엇을 내부 구현으로 숨길 것인가”를 결정하는 설계 도구다.

아이템 15. 핵심 정리 - 멤버(필드. 메서드, 중첩 클래스/인터페이스)의 접근 제한자 원칙

이번 파트에서는 클래스 자체가 아니라 클래스 내부의 멤버(필드, 메서드, 중첩 클래스, 중첩 인터페이스) 에 대한 접근 권한을 어떻게 설정해야 하는지 살펴본다.

핵심 원칙은 매우 단순하다.

공개 API를 먼저 결정하고, 나머지는 모두 숨겨라.

즉, 기본값은 공개가 아니라 은닉이다.


멤버 접근 권한의 기본 원칙

클래스 멤버에 사용할 수 있는 접근 제한자는 다음 네 가지다.

  • private
  • package-private
  • protected
  • public

이 중 가장 먼저 고려해야 할 것은 항상 private이다.

private String name;
private int age;

공개할 필요가 없다면 모두 private으로 선언한다.

그리고 정말 필요한 경우에만 범위를 넓힌다.

private
 ↓
package-private
 ↓
protected
 ↓
public

즉,

가능한 가장 좁은 범위를 선택하라.

가 기본 원칙이다.


공개 API가 아닌 것은 모두 숨겨라

클래스를 설계할 때 가장 먼저 해야 할 일은 공개 API를 정하는 것이다.

예를 들어 외부에서 필요한 기능이 다음 하나뿐이라면

public class MemberService {

    public Member findMember(Long id) {
        ...
    }
}

이 메서드만 공개하면 된다.

그 외 구현 세부사항은 모두 숨긴다.

private MemberRepository repository;
private void validateMember() {}
private void loadCache() {}

외부 사용자는 이런 내부 구현을 알 필요가 없다.


private과 package-private은 내부 구현이다

private

private void validate() {
}

해당 클래스 내부에서만 사용할 수 있다.

가장 강력한 캡슐화 수준이다.

package-private

void validate() {
}

같은 패키지 내부에서만 사용할 수 있다.

외부 패키지에서는 접근할 수 없다.


private을 자꾸 풀게 된다면 설계를 의심하라

실무에서는 종종 이런 상황이 발생한다.

private UserRepository repository;

그런데 다른 클래스에서도 접근하고 싶어져서

UserRepository repository;

로 변경한다.

그리고 또 다른 클래스에서도 필요해진다.

이런 일이 반복된다면 단순히 접근 권한 문제가 아닐 수 있다.

오히려

  • 클래스 책임이 너무 많지는 않은가?
  • 컴포넌트 구성이 잘못된 것은 아닌가?
  • 클래스를 분리해야 하는 것은 아닌가?

를 고민해야 한다.


protected와 public은 공개 API다

많은 개발자가 protected를 package-private 정도로 생각한다.

하지만 실제로는 그렇지 않다.

protected void doSomething() {
}

은 상속 관계를 통해 다른 패키지까지 노출된다.

즉 protected도 사실상 공개 API의 일부다.


protected가 위험한 이유

예를 들어

protected void process() {
}

를 공개했다고 가정하자.

외부 개발자가 상속해서 사용하기 시작할 수 있다.

public class CustomService
        extends BaseService {

    @Override
    protected void process() {
        ...
    }
}

이 순간부터

process()

는 더 이상 내부 구현이 아니다.

외부 코드가 의존하는 API가 된다.

따라서 함부로 제거하거나 변경하기 어려워진다.


public 멤버는 더욱 신중해야 한다

public 멤버는 외부 사용자와의 계약이다.

public void save() {
}

한 번 공개되면

  • 하위 호환성
  • 문서화
  • 유지보수

를 모두 고려해야 한다.

따라서 정말 필요한 경우에만 public으로 공개한다.


public 필드는 거의 사용하지 말자

가장 위험한 공개 방식 중 하나가 public 필드다.

예를 들어

public String name;
public int age;

이렇게 설계하면

외부에서 마음대로 상태를 변경할 수 있다.

member.age = -100;
member.name = null;

객체는 자신의 상태를 통제할 수 없게 된다.


필드를 공개하면 안 되는 이유

객체는 자신의 상태를 스스로 관리해야 한다.

하지만 public 필드를 사용하면

  • 검증 불가
  • 로깅 불가
  • 이벤트 처리 불가
  • 정책 적용 불가

상태 변경을 통제할 수 없게 된다.


더 좋은 방법

필드는 숨기고 메서드로 제어한다.

private String name;
public void changeName(String name) {
    validate(name);
    this.name = name;
}

이렇게 하면

  • 검증
  • 로깅
  • 이벤트 발행
  • 비즈니스 규칙 적용

이 가능하다.


public static final 상수는 예외적으로 허용된다

다음과 같은 상수는 공개해도 된다.

public static final int MAX_SIZE = 100;

불변(Immutable) 데이터이기 때문이다.


하지만 배열은 위험하다

많은 개발자가 다음 코드를 안전하다고 생각한다.

public static final String[] VALUES = {
    "A", "B", "C"
};

하지만 실제로는 위험하다.

VALUES[0] = "HACK";

이 가능하기 때문이다.


final은 참조만 고정한다

public static final String[] VALUES

는 배열 참조만 변경 불가능하다.

배열 내부 값은 여전히 수정 가능하다.

불변처럼 보이는 가변 객체

가 된다.


배열 대신 불변 컬렉션을 사용하자

다음 방식이 훨씬 안전하다.

public static final List<String> VALUES =
        List.of("A", "B", "C");

외부에서 수정할 수 없다.


테스트 때문에 접근 권한을 넓히지 말자

실무에서 매우 자주 발생하는 문제다.

예를 들어

class DefaultMemberService
        implements MemberService {
}

가 package-private이라고 가정하자.

테스트 코드에서 직접 접근이 안 된다.

그러면 많은 개발자가

public class DefaultMemberService

로 바꿔버린다.


하지만 먼저 고민해야 한다

질문은 이것이다.

정말 구현체를 직접 테스트해야 하는가?

설계상 숨기기로 한 구현체라면

테스트 코드 역시 그 의도를 존중해야 한다.


인터페이스를 기준으로 테스트하라

예를 들어

public interface MemberService

가 공개 API라면

테스트 역시 인터페이스 기준으로 작성하는 것이 자연스럽다.


Mock 객체를 활용하라

Mockito 같은 프레임워크를 사용하면

@Mock
private MemberRepository repository;

처럼 가짜 구현체를 쉽게 만들 수 있다.

굳이 접근 제한자를 넓힐 필요가 없다.


생성자 주입이 테스트를 쉽게 만든다

좋은 설계는 테스트를 위해 내부 상태를 들여다보지 않아도 된다.

예를 들어

public MemberService(
        MemberRepository repository) {

    if (repository == null) {
        throw new IllegalArgumentException();
    }

    this.repository = repository;
}

처럼 설계하면

객체 생성 시점에 유효성이 보장된다.


테스트를 위해 public을 만들지 말자

가장 안 좋은 경우는

public MemberRepository getRepository()

같은 메서드를

오직 테스트 때문에 만드는 것이다.

이렇게 되면

테스트 코드 때문에 API가 오염

된다.

테스트 때문에 설계를 망가뜨리지 말자.


핵심 정리

  • 공개 API를 먼저 정하고 나머지는 숨긴다.
  • 기본값은 항상 private이다.
  • private → package-private → protected → public 순으로 고민한다.
  • protected도 사실상 공개 API다.
  • public 필드는 사용하지 않는 것이 좋다.
  • 객체 상태는 메서드를 통해 제어해야 한다.
  • public static final 배열은 안전하지 않다.
  • 배열 대신 불변 컬렉션을 사용하자.
  • 테스트 때문에 접근 권한을 넓히지 말자.
  • Mock 객체와 생성자 주입을 활용하자.
  • 접근 제한자는 캡슐화를 위한 핵심 도구다.

한 줄 정리

클래스의 멤버는 기본적으로 모두 숨기고, 정말 필요한 경우에만 공개하라. 공개된 순간 그 멤버는 더 이상 구현이 아니라 API가 된다.

아이템 15. 완벽 공략 요약

정리해서 바로 붙여넣기 좋게 작성하면 아래 형태가 가장 깔끔합니다.

아이템 15. 클래스와 멤버의 접근 권한을 최소화하라 (완벽 공략)

이번 파트에서는 아이템 15에서 짧게 언급된 내용들을 조금 더 깊게 살펴본다.

핵심은 여전히 동일하다.

한 번 공개된 것은 생각보다 훨씬 오래 책임져야 한다.

그래서 접근 권한은 가능한 한 최소화해야 한다.


Serializable 구현 시 private 필드도 공개 API가 될 수 있다

책에서는 다음과 같은 문장을 언급한다.

Serializable을 구현한 클래스에서는 그 필드들도 의도치 않게 공개 API가 될 수 있다.

처음 보면 이해하기 어려울 수 있다.

왜 private 필드가 공개 API가 될까?


직렬화가 내부 구조를 외부에 노출하기 때문이다

직렬화는 객체 상태를 저장하고 나중에 다시 복원하는 기능이다.

예를 들어 다음 객체가 있다고 가정하자.

public class Member implements Serializable {

    private String name;
    private int age;
}

겉으로 보면

name
age

는 private 필드다.

하지만 직렬화를 수행하면 이 정보들이 저장된다.

Member
 ├─ name
 └─ age

그리고 미래의 어느 시점에 역직렬화를 수행할 때도 동일한 구조를 기대하게 된다.


필드 변경이 어려워진다

예를 들어 나중에

private int age;

private LocalDate birthDate;

로 변경했다고 가정해보자.

그러면 과거에 저장된 직렬화 데이터와 호환되지 않을 수 있다.

즉 원래는 내부 구현이었던 필드가

직렬화 포맷의 일부

가 되어 버린 것이다.


결국 공개 API처럼 취급된다

원래는

private = 자유롭게 변경 가능

이어야 한다.

하지만 직렬화가 개입하면

private 필드
 ↓
직렬화 포맷 포함
 ↓
호환성 고려 대상
 ↓
사실상 공개 API

가 된다.

그래서 Effective Java는 Serializable 구현 시 매우 신중할 것을 권장한다.


리스코프 치환 원칙(LSP)과 접근 제한자

책에서는 재정의(Override) 시 접근 수준을 좁힐 수 없다고 설명한다.

예를 들어

class Parent {

    protected void process() {
    }
}

가 있다고 하자.

그러면 하위 클래스에서

class Child extends Parent {

    private void process() {
    }
}

처럼 만들 수 없다.

컴파일 에러가 발생한다.


왜 허용되지 않을까?

이유는 리스코프 치환 원칙 때문이다.

리스코프 치환 원칙(LSP)은 다음을 의미한다.

상위 타입은 언제든 하위 타입으로 대체 가능해야 한다.

예를 들어

Parent parent = new Child();

가 가능해야 한다.

그런데 Parent에서는 사용할 수 있었던 메서드가

process()

Child에서 갑자기 숨겨져 버리면

기존 코드가 동작하지 않게 된다.

Parent 가능
 ↓
Child 불가능

상황이 발생한다.


따라서 접근 수준은 유지하거나 넓혀야 한다

가능한 경우

protected  public

은 가능하다.

반대로

public  protected
protected  private

는 불가능하다.


public 가변 필드는 스레드 안전하지 않다

책에서는

public 가변 필드를 가진 클래스는 일반적으로 스레드 안전하지 않다.

라고 설명한다.


왜 위험할까?

예를 들어

public class Counter {

    public int count;
}

가 있다고 하자.

멀티스레드 환경에서

counter.count++;

를 여러 스레드가 동시에 수행할 수 있다.


Race Condition 발생

예를 들어

Thread A → count 읽음 (0)

Thread B → count 읽음 (0)

Thread A → 1 저장

Thread B → 1 저장

결과는

1

이 된다.

원래는

2

가 되어야 한다.


상태를 통제할 수 없다

public 필드는 외부에서 언제든 변경 가능하다.

즉 객체가

자기 상태를 스스로 관리할 수 없다.

그래서 캡슐화 측면에서도 문제가 되고

스레드 안전성 측면에서도 문제가 된다.


public static final은 불변 객체만 공개하라

많은 개발자가 다음 코드를 안전하다고 생각한다.

public static final String[] VALUES = {
    "A", "B", "C"
};

하지만 사실 위험하다.


final은 참조만 고정한다

많은 사람들이 착각하는 부분이다.

public static final String[] VALUES

에서 final은

배열 참조

만 고정한다.

배열 내부 값은 바뀔 수 있다.


외부에서 수정 가능하다

VALUES[0] = "HACK";

가 가능하다.

상수처럼 보이는 가변 객체

가 된다.


안전한 공개 방법

기본 타입은 안전하다.

public static final int MAX_SIZE = 100;

문자열도 안전하다.

public static final String NAME = "MEMBER";

String은 불변 객체이기 때문이다.


컬렉션은 불변 컬렉션 사용

public static final List<String> VALUES =
        List.of("A", "B", "C");

처럼 사용하는 것이 좋다.

외부에서 수정할 수 없다.


Java 9 모듈 시스템과 캡슐화

Java 9부터는 모듈 시스템이 도입되었다.

기존에는

클래스
 ↓
패키지

수준까지만 캡슐화가 가능했다.


모듈 단위 캡슐화

Java 9 이후에는

클래스
 ↓
패키지
 ↓
모듈

단위로 캡슐화할 수 있다.

예를 들어

module member.module {

    exports com.example.api;
}

처럼 설정하면

api 패키지만 공개

할 수 있다.

나머지 패키지는

모듈 내부 구현

으로 숨길 수 있다.


아이템 15에서 반드시 기억해야 할 것

접근 제한자는 단순 문법이 아니다.

한 번 공개된 것은

  • 하위 호환성
  • 유지보수
  • API 계약

의 대상이 된다.

특히

  • Serializable
  • protected 메서드
  • public 필드
  • public static final 가변 객체

는 생각보다 훨씬 오래 유지해야 하는 API가 될 수 있다.


핵심 정리

  • Serializable은 private 필드까지 공개 API처럼 만들 수 있다.
  • 직렬화 포맷은 장기 호환성을 요구한다.
  • Override 시 접근 수준을 더 좁힐 수 없다.
  • 이는 리스코프 치환 원칙 때문인다.
  • public 가변 필드는 캡슐화와 스레드 안전성을 모두 해친다.
  • public static final은 불변 객체만 공개해야 한다.
  • 배열과 컬렉션 같은 가변 객체는 그대로 공개하면 안 된다.
  • Java 9부터는 모듈 단위 캡슐화도 가능하다.

한 줄 정리

접근 권한은 단순한 문법이 아니라 미래의 변경 비용을 결정하는 설계 도구이며, 한 번 공개된 것은 생각보다 훨씬 오래 책임져야 한다.

아이템 15. 완벽 공략 - 자바 플랫폼 모듈 시스템 1

자바 9 모듈 시스템(JPMS, Java Platform Module System)

이번 완벽 공략에서는 이펙티브 자바 아이템 15에서 잠깐 언급된 자바 9 모듈 시스템(Java Platform Module System, JPMS) 을 살펴본다.

자바 9의 가장 큰 변화 중 하나이며, 과거에는 Project Jigsaw(프로젝트 직소) 라는 이름으로 개발되었던 기능이다.


모듈 시스템이 등장한 이유

자바 8까지의 의존성 관리는 사실상 다음 방식뿐이었다.

ClassPath

예를 들어

  • Spring
  • Hibernate
  • JPA
  • Jackson

같은 라이브러리를 사용하려면 JAR 파일을 클래스패스에 추가해야 했다.

Application
 ├─ spring.jar
 ├─ hibernate.jar
 └─ jackson.jar

기존 ClassPath 방식의 문제점

문제는 클래스패스가 너무 느슨하다는 것이다.

예를 들어 개발 환경에서는 정상 동작하지만

배포 과정에서 특정 라이브러리가 빠졌다고 가정해보자.

hibernate.jar 누락

애플리케이션은 일단 실행된다.

하지만 실제로 Hibernate 클래스를 사용하는 순간

ClassNotFoundException
NoClassDefFoundError

가 발생한다.

즉,

문제를 너무 늦게 발견한다.

는 것이 가장 큰 문제였다.


모듈 시스템이 해결하려는 문제

JPMS는 다음 질문에 답하기 위해 만들어졌다.

어떤 모듈이 필요한가?

Application
 ↓
Spring 필요
 ↓
Hibernate 필요
 ↓
Jackson 필요

무엇을 외부에 공개할 것인가?

API 패키지
 → 공개

내부 구현 패키지
 → 비공개

의존 관계가 올바른가?

A → B 의존

B 없음
 ↓
애플리케이션 시작 실패

즉,

런타임 중간이 아니라
시작 시점에 문제 발견

이 가능해진다.


모듈 시스템의 핵심 장점

1. 의존성을 명시적으로 표현 가능

기존 방식

ClassPath에 넣으면 끝

모듈 방식

requires spring.core;
requires hibernate.core;

처럼 명시적으로 선언한다.


2. 더 강력한 캡슐화

기존 자바에서는

public class MemberService

이면 어디서든 접근 가능했다.


하지만 모듈 시스템에서는 다르다.

public class MemberService

라도

exports com.example.member.api;

가 없다면

다른 모듈에서는 접근할 수 없다.


접근 제어 계층이 하나 더 생김

기존

public
protected
package-private
private

모듈 시스템 추가 후

Module
 ↓
Package
 ↓
Class
 ↓
Member

구조가 된다.

즉,

public보다 더 바깥 단계의 캡슐화

가 생긴 셈이다.


3. 더 빠른 오류 발견

예전 방식

실행
 ↓
몇 시간 후 특정 기능 실행
 ↓
ClassNotFoundException

모듈 방식

애플리케이션 시작
 ↓
필요 모듈 확인
 ↓
없음
 ↓
즉시 실패

문제를 훨씬 빨리 발견할 수 있다.


4. JDK 자체의 모듈화

JPMS의 가장 큰 수혜자는 사실 JDK다.

자바 8 이전

거대한 rt.jar

자바 9 이후

java.base
java.sql
java.xml
java.logging
...

처럼 여러 모듈로 분리되었다.


필요한 기능만 포함 가능

예를 들어

서버 애플리케이션

이라면

GUI 관련 모듈은 필요 없다.


JPMS는

필요한 모듈만 포함한
작은 런타임 생성

을 가능하게 만들었다.

이 기능이 바로

jlink

이다.


모듈 프로젝트의 특징

모듈 여부를 구분하는 가장 쉬운 방법은

module-info.java

파일 존재 여부다.


일반 프로젝트

src
 └─ main
     └─ java
         └─ hello
             └─ HelloService.java

모듈 프로젝트

src
 └─ main
     └─ java
         ├─ module-info.java
         └─ hello
             └─ HelloService.java

module-info.java

모듈의 핵심 설정 파일이다.

예시

module com.example.hello {
}

모듈 이름

일반적으로

역순 도메인

형태를 사용한다.

예시

module com.siksik.member {
}

외부 공개 패키지 지정

module com.siksik.member {

    exports com.siksik.member.api;
}

의미

api 패키지만 공개

내부 구현 패키지는 숨김

com.siksik.member.api
    → 공개

com.siksik.member.internal
    → 비공개

외부 모듈은

internal

패키지에 접근할 수 없다.

설령

public class InternalService

라도 접근 불가능하다.


다른 모듈 사용

module com.siksik.member {

    requires java.sql;
}

의미

이 모듈은 java.sql 사용

모듈 시스템이 실무에서 많이 쓰일까?

흥미롭게도

생각보다 많이 쓰이지는 않는다.

이유

기존 ClassPath 방식이 여전히 동작

자바 9 이후에도

ClassPath

는 계속 지원된다.


라이브러리 호환성 문제

모든 라이브러리가 완벽하게 모듈화된 것은 아니다.


개발 편의성

개발자 입장에서는

내부 구현도 접근하고 싶다

는 유혹이 존재한다.

대표적인 예가 Lombok이다.


하지만 개념 자체는 중요하다

실무에서 직접 JPMS를 사용하지 않더라도

다음 개념은 매우 중요하다.

의존성을 명시하자

공개 범위를 최소화하자

내부 구현을 숨기자

이것이 바로 Effective Java 아이템 15의 핵심 철학과 연결된다.


핵심 정리

JPMS(Java Platform Module System)

  • Java 9에서 도입
  • Project Jigsaw의 결과물
  • module-info.java가 존재하면 모듈
  • 모듈 간 의존성을 명시 가능
  • 패키지 공개 범위를 제어 가능
  • ClassPath보다 강한 캡슐화 제공
  • 애플리케이션 시작 시 의존성 검증 가능
  • JDK 자체도 모듈화됨
  • jlink를 통해 경량 런타임 생성 가능

한 줄 정리

Java 9 모듈 시스템은 “클래스 수준 캡슐화”를 넘어 “모듈 수준 캡슐화”를 제공하여 의존성을 명확하게 관리하고 내부 구현을 더욱 강력하게 숨길 수 있게 만든 기능이다.


© 2020. All rights reserved.

SIKSIK