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

우노구나·2025년 8월 12일

조슈아 블로크의 Effective Java3의 15번째 아이템은 정보 은닉(Information Hiding)에 관한 내용을 다룬다.

핵심 주제

외부에서 알 필요 없는 내부 구현을 숨기고, 오직 필요한 API만 노출해라


은닉화의 장점

기본적으로 은닉화는 무결성, 안정성, 보안적인 측면에서 이점이 있다.

이 책에서는 조금 더 자세한 장점들을 나열하였다.

  1. 개발 속도를 높인다.
    • 여러 컴포넌트를 병렬로 개발할 수 있기 때문
      (컴포넌트들이 독립적이라 서로 신경을 쓰지 않고 개발이 가능해서)
  2. 관리 비용을 낮춘다.
    • 각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담이 적음
  3. 성능 최적화에 도움을 준다.
    • 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화가 가능해짐
  4. 소프트웨어 재사용성을 높인다.
    • 외부에 의존하지 않고 동작하는 컴포넌트는 다른 환경에서 유용하게 쓰일 가능성이 높음
  5. 큰 시스템을 제작하는 난이도를 낮춰준다.
    • 전체 시스템이 완성되지 않아도 개별 컴포넌트의 동작을 검증할 수 있기 때문

이처럼 여러가지 장점이 있지만 정리하자면 은닉화로 인한 각각의 개별 컴포넌트가 독립적으로 이루어져 생기는 장점들로 이해하면 될 것 같다.


은닉화 적용 방법

기본 원칙은 간단하다. 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다. 즉, 소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 한다.


1. 최상위 클래스와 인터페이스는 패키지 외부에서 쓸 이유가 없다면 package-private으로 선언하자

최상위 클래스와 인터페이스는 public 또는 package-private으로 접근수준을 부여할 수 있는데, public으로 선언하면 공개 API가 되어 외부에서 사용되기 때문에 계속 관리해줘야 하고 마음대로 수정하기가 어렵다. 반면, package-private으로 선언하면 API가 아닌 내부 구현이 되어 언제든 수정이 가능하다.


2. 한 클래스에서만 사용하는 package-private 최상위 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static으로 중첩시켜보자(아이템 24)

  • 어떤 헬퍼 클래스나 작은 인터페이스가 오직 한 클래스에서만 사용됨

  • 그런데 그것을 package-private 최상위로 만들어 두면, 패키지 내의 다른 클래스들도 접근할 수 있게 됨

// 파일: MyHelper.java
class MyHelper {  // package-private 최상위 클래스
    void help() { ... }
}

// 파일: MyMain.java
public class MyMain {
    private MyHelper helper = new MyHelper();
}
  • 여기서 MyHelper는 사실 MyMain에서만 쓰는데, 같은 패키지 안의 다른 클래스도 접근 가능

  • 즉, 접근 범위가 필요 이상으로 넓음

해결 방법 — private static 중첩 클래스

  • 사용 범위를 한 클래스 내부로 제한

  • private static 으로 중첩시켜 외부에서 접근 불가능하게 함

public class MyMain {
    private final MyHelper helper = new MyHelper();

    // 🔒 오직 MyMain 안에서만 쓰이는 내부 클래스
    private static class MyHelper {
        void help() {
            System.out.println("Helping...");
        }
    }
}

3. 권한을 풀어주는 일을 자주 하게 된다면 시스템에서 컴포넌트를 더 분해해야 하는 것은 아닌지 고민해보자.

접근제한자 설정 과정 :
공개 API 설계 => 그 외의 모든 멤버를 private으로 설정 => 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private으로 풀어줌 => 이 때 권한을 풀어주는 일을 자주 하게 된다면 시스템에서 컴포넌트를 더 분해해야 하는 것은 아닌지 고민해보자.


4. protected 멤버의 수는 적을 수록 좋다.

public 클래스의 protected 멤버는 공개 API이므로 영원히 지원돼야 한다. 또한 내부 동작 방식을 API 문서에 적어 사용자에게 공개해야 할 수도 있어서 protected 멤버의 수는 적을수록 좋다.

// 라이브러리 코드
package lib;

public class Animal {
    protected void eat() {
        System.out.println("Animal eats");
    }
}
// 외부 사용자 코드
package user;

import lib.Animal;

public class Dog extends Animal {
    @Override
    protected void eat() { // 상속받아 재정의 가능
        System.out.println("Dog eats");
    }
}

여기서 Animal.eat()은 외부에서 상속 경로를 통해 접근 가능하므로,
이미 공개 API 역할을 하게 된다.


4-1. 리스코프 치환 원칙(아이템 10)

멤버 접근성을 좁히지 못하게 방해하는 제약.
상위 클래스의 메서드를 재정의할 때는 그 접근 수준을 상위 클래스에서보다 좁게 설정 할 수 없다.


5. 테스트 목적으로 접근 범위를 넓히더라도 적당한 수준까지만 넓히자.

테스트만을 위해 클래스, 인터페이스, 멤버를 공개 API로 만들어서는 안 된다.

  • 테스트 코드를 테스트 대상과 같은 패키지에 두면 package-private 요소에 접근할 수 있어서 굳이 접근 범위를 넓힐 필요가 없다.

예:

// src/main/java/com/example/MyService.java
package com.example;

class MyService {
    int calculate(int x) { // package-private
        return x * 2;
    }
}
// src/test/java/com/example/MyServiceTest.java
package com.example;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class MyServiceTest {
    @Test
    void testCalculate() {
        MyService service = new MyService();
        assertEquals(4, service.calculate(2)); // 접근 가능
    }
}
  • 이렇게 하면 굳이 public으로 바꾸지 않아도 테스트 가능

  • 내부 구현은 여전히 패키지 외부에는 감춰짐


6. public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다.(아이템 16)

public 클래스의 필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 그 필드에 담을 수 있는 값을 제한할 힘을 잃게 된다.

public class Person {
    public String name;          // final 아님, public
    public List<String> hobbies; // 가변 객체 참조, public
}

Person p = new Person();
p.name = "철수"; // 외부에서 변경 가능
p.hobbies = new ArrayList<>();  // 아예 새로운 리스트로 변경 가능

여기서 문제는:

  1. name이 final이 아님 + public
    → 외부에서 마음대로 변경 가능 (p.name = "영희";)

  2. hobbies가 List(가변 객체) + public
    → 외부에서 리스트 내용을 마음대로 수정 가능 (p.hobbies.add("축구"))

이렇게 되면 클래스 안에서 아무리 값 검증 로직을 넣어도 우회 가능

"값을 제한할 힘을 잃는다"의 의미

원래는 필드를 private으로 감추고 setter나 메서드를 통해 값을 변경하게 하면 검증 로직을 넣을 수 있다.

public void setName(String name) {
    if (name == null || name.isEmpty()) {
        throw new IllegalArgumentException("이름은 비어있을 수 없습니다.");
    }
    this.name = name;
}

하지만 public 필드로 열어버리면:

  • 누가 언제 어떤 값으로 바꿀지 모름

  • 잘못된 값이 들어가도 막을 수 없음

  • 가변 객체일 경우 내부 구조까지 무단 변경 가능

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

스레드 안전하게 필드를 변경하려면, 보통 다음처럼 락을 걸거나 동기화를 해야 한다.

  • increment() 메서드 안에서만 값을 변경하도록 하고, 락(synchronized)을 걸어 동시성 문제를 방지함.
public class Counter {
    private int count;

    public synchronized void increment() {
        count++;
    }
}

하지만 필드가 public이면?

Counter c = new Counter();
c.count++; // 외부에서 직접 변경 → 락 없음 → 경쟁 조건 발생
  • 락을 강제할 방법이 없음
  • 누군가가 동기화 없이 필드를 수정하면 스레드 안전성이 깨짐
  • 내부적으로 어떤 보호 장치를 해놔도 무용지물

6-1. 예외적으로 클래스가 표현하는 추상 개념을 완성하는 데 꼭 필요한 구성요소로써의 상수라면 public static final 필드로 공개해도 좋다.

public static final 필드는 Java에서 흔히 상수(constant) 를 정의할 때 쓰는 형태이다.

1. 각 키워드의 의미

  • public

    • 어디서나 접근 가능

    • 다른 패키지나 클래스에서도 이 필드 사용 가능

  • static

    • 클래스 변수

    • 인스턴스(객체)마다 따로 생기는 게 아니라, 클래스에 하나만 존재

    • 객체를 만들지 않고 클래스명.필드명 형태로 접근 가능

  • final

    • 한 번 값이 정해지면 변경 불가

    • 초기화 시 반드시 값을 지정해야 하고, 이후 재할당 불가능

2. 결합해서 의미
public static final = "어디서든 접근 가능한, 클래스에 하나만 존재하며, 변경할 수 없는 값"

즉, 모든 곳에서 공유하는 상수를 만드는 데 쓰임.

관례상 이런 상수의 이름은 대문자 알파벳으로 쓰며, 각 단어 사이에 밑줄(_)을 넣는다.

public class MathConstants {
    public static final double PI = 3.141592653589793;
    public static final int MAX_USERS = 100;
}

3. 주의사항

클래스에서 배열로 상수를 사용할 때가 있다.

public class Colors {
    public static final String[] BASIC_COLORS = {"RED", "GREEN", "BLUE"};
}

이런 경우 final이라 배열 변경이 안될 거라고 생각을 할 수 있지만, 배열의 주소값이 변경이 되지 않는거지 배열의 내용 자체는 변경이 가능하여 배열의 내용을 제한할 힘을 잃게 된다.

해결방법은 배열을 private으로 우선 바꾼 후 public 불변 리스트를 추가하거나 그 배열의 복사본을 반환하는 public 메서드를 추가하는 방법(방어적 복사)방법이 있다.

  • 불변 리스트
  • 리스트를 수정하려 하면 UnsupportedOperationException 발생
public class Example {
    private static final String[] NAMES = {"철수", "영희"};

    public static final List<String> NAMES_LIST =
        Collections.unmodifiableList(Arrays.asList(NAMES));
}
  • 방어적 복사
public class Example {
    private static final String[] NAMES = {"철수", "영희"};

    public static String[] getNames() {
        return NAMES.clone(); // 새로운 배열 반환 → 원본 보호
    }
}

7. 모듈과 두 가지 암묵적 접근 수준

모듈 시스템
Java 9 이후 모듈(패키지들의 묶음) 개념 추가

.java 소스에서 module-info.java 파일로 정의

모듈 간에 어떤 패키지를 외부에 공개(export) 할지 선택 가능

module com.example.myapp {
    exports com.example.service; // 이 패키지만 외부 모듈에서 사용 가능
}

두 가지 암묵적 접근 수준

  1. public이더라도, 그 클래스가 속한 패키지가 모듈에서 exports 되지 않았다면 외부 모듈에서는 접근 불가, 같은 모듈 안에서는 exports 여부와 상관없이 접근 가능 (일종의 protected)

  2. export한 경우 외부 모듈에서 사용 가능 (일종의 public)


마무리

은닉화를 개념적으로는 이해했었는데 이렇게 디테일한 부분이 있는줄 처음 알았다. 여러 내용이 나왔지만 결론은 최대한 접근수준을 최소화하는게 포인트 인 것 같다. 추후 프로젝트 설계할 때 적용해보고싶다.

profile
기술 블로그

0개의 댓글