이펙티브 자바 4장-2) 클래스와 인터페이스

동동주·2025년 11월 12일

이펙티브 자바

목록 보기
4/13

💡 아이템 20 - 추상 클래스보다 인터페이스를 우선하라

자바의 다중 구현 메커니즘

  • 자바는 인터페이스추상 클래스 둘 다 제공함
  • 자바 8부터 인터페이스도 디폴트 메서드(default method) 지원함 → 인스턴스 메서드 구현 가능
  • 즉, 인터페이스도 일정 수준의 구현 제공 가능해짐

🔸 추상 클래스 vs 인터페이스 차이

구분인터페이스추상 클래스
상속 구조다중 구현 가능단일 상속만 가능
확장성기존 클래스에 implements만 추가하면 됨기존 계층구조 변경 어려움
필드인스턴스 필드 X인스턴스 필드 O
접근제한자모든 메서드는 public제한자 자유로움
정적 멤버public static만 가능제한 없음

→ 따라서 새로운 타입 정의나 기능 확장엔 인터페이스가 훨씬 유연함

인터페이스의 장점

  • 기존 클래스에도 쉽게 구현 추가 가능 (implements만 붙이면 끝)
  • 믹스인(Mixin) 정의에 적합
    → 기존 타입에 선택적 기능을 섞는 개념
    → ex) Comparable = “정렬 가능한 객체” 기능을 섞는 믹스인 인터페이스

인터페이스 조합 (디폴트 메서드 활용)

  • 자바 8 이후, 여러 인터페이스를 조합해서 다양한 기능 클래스 구현 가능
public class Monster implements Rotatable, Moveable, Resizable { ... }
public class Sun implements Moveable, Rotatable { ... }

→ 추상 클래스로 하면 “조합 폭발(Combinatorial Explosion)” 일어남
→ n개의 속성이 있으면 2^n 조합 필요해짐
→ 인터페이스가 훨씬 효율적

계층 없는 타입 프레임워크

  • 인터페이스는 “계층 구조가 없는 타입 시스템” 만들 수 있음
public interface Singer { ... }
public interface Songwriter { ... }
public interface SingerSongwriter extends Singer, Songwriter { ... }

→ 클래스로 만들면 조합 폭발
→ 인터페이스는 조합만으로 간단히 해결

디폴트 메서드 주의점

  • equals, hashCode, toString 같은 Object 메서드는 디폴트로 정의 금지
  • @implSpec 자바독 태그로 구현 규약 문서화해야 함
  • static 메서드 (Java 8+), private 메서드 (Java 9+) 가능
  • 이미 존재하는 인터페이스에는 디폴트 메서드 추가 불가

⚖️ 디폴트 메서드 충돌 시 규칙

  1. 클래스가 항상 이김
    → 클래스나 슈퍼클래스의 메서드가 디폴트보다 우선
  2. 서브인터페이스가 이김
    → 상속 관계 있는 인터페이스끼리라면 더 구체적인 쪽이 우선
  3. 여전히 충돌이면 → 직접 오버라이드해야 함

인터페이스 + 골격 구현(skeletal implementation)

  • 인터페이스: 타입 정의 + 디폴트 메서드 일부 제공
  • 골격 구현 클래스(AbstractXXX): 나머지 메서드 구현
  • 이렇게 하면 구현 클래스는 확장만으로 완성 가능
static List<Integer> intArrayAsList(int[] a) {
    return new AbstractList<>() {
        public Integer get(int i) { return a[i]; }
        public Integer set(int i, Integer val) { int old=a[i]; a[i]=val; return old; }
        public int size() { return a.length; }
    };
}

→ 인터페이스 유연성 + 추상 클래스 편의성 둘 다 챙김
→ 대표 예: AbstractList, AbstractSet, AbstractMap

골격 구현 작성 순서

  1. 기반(primitives) 메서드 선정
  2. 기반 메서드로 구현 가능한 건 디폴트 메서드로 제공
  3. 남은 건 골격 구현 클래스(AbstractXXX)로 구현
  4. 필요 시 private 필드나 헬퍼 메서드 추가 가능

단순 구현(Simple Implementation)

  • 골격 구현의 가벼운 버전
  • 추상 클래스 아님 (상속 안해도 됨)
  • 예: AbstractMap.SimpleEntry

🚀 결론

  • 새로운 타입 설계 시 인터페이스를 먼저 고려할 것
  • 추상 클래스는 공통 코드 재사용용 보조 수단
  • 인터페이스 + 디폴트 메서드 + 골격 구현 조합 → 가장 유연하고 강력한 설계 방식

💡 아이템 21 - 인터페이스는 구현하는 쪽을 생각해 설계하라

🔹 자바 8 이전

  • 인터페이스에 새 메서드 추가 불가
  • 추가하면 기존 구현체들 전부 컴파일 오류
  • 기존 클래스에 우연히 그 이름의 메서드가 있을 확률도 거의 0이라 호환 불가능

🔹 자바 8 이후: 디폴트 메서드 등장

  • default 키워드 덕분에 인터페이스에도 구현 코드 작성 가능
  • 기존 코드 깨뜨리지 않고 새 메서드 추가 가능
  • 하위 호환성 유지 장점 👍
default boolean removeIf(Predicate<? super E> filter) {
  Objects.requireNonNull(filter);
  boolean result = false;
  for (Iterator<E> it = iterator(); it.hasNext();) {
    if (filter.test(it.next())) {
      it.remove();
      result = true;
    }
  }
  return result;
}
  • ex) 자바 8의 Collection.removeIf()
  • 반복자 이용해서 조건 맞는 원소 제거하는 기본 구현 제공
  • 대부분의 컬렉션엔 잘 작동함

하지만 완전 안전하진 않음

  • 디폴트 메서드는 기존 구현체에 대해 아무것도 모름
  • 모든 클래스와 잘 어울린다는 보장 ❌

문제 예시: SynchronizedCollection

  • removeIf는 동기화 고려 안 함
  • 해당 컬렉션은 락(lock) 객체로 동기화해야 함
  • 하지만 removeIf는 그 사실을 모르므로 락 안 걸고 접근함
    → 다중 스레드에서 ConcurrentModificationException 터짐 💥

🧩 해결 방법

  • 디폴트 메서드 재정의해서 필요한 작업(락 등) 선행
  • 자바 기본 Collections.synchronizedCollection()은 이 방식 사용함
  • 하지만 Apache Commons 등 제3자 라이브러리는 이런 수정 못함 → 여전히 버그 남아있음

🧱 결론

  • 디폴트 메서드 추가는 “하위호환 유지용 응급조치”일 뿐
  • 기존 구현체가 많을수록 리스크 커짐
  • “새로운 인터페이스”를 설계할 때는 유용하지만,
    “기존 인터페이스”에 추가하는 건 최후의 수단으로만 써야 함

💡 아이템 22 - 인터페이스는 타입을 정의하는 용도로만 사용하라

인터페이스의 본질

  • 인터페이스는 타입(type) 정의용임
  • 즉, “이 인터페이스를 구현한 객체가 어떤 동작을 할 수 있는가?”를 클라이언트에게 알려주는 역할임
  • 클래스가 어떤 인터페이스를 implements 하는 순간 → “이 인스턴스는 이런 기능을 제공합니다” 라고 약속하는 것임

상수 인터페이스 안티패턴 (Constant Interface Anti-Pattern)

public interface PhysicalConstants {
    static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    static final double ELECTRON_MASS = 9.109_383_56e-31;
}
  • 이런 식으로 상수만 담은 인터페이스 = 상수 인터페이스 패턴, 매우 나쁜 설계

  • 이유 👇

    1. 타입의 의미가 없음 — 동작 정의가 없고 단지 값만 있음
    2. 내부 구현 노출 — 상수를 인터페이스로 공개하면 그걸 구현한 클래스의 API 일부처럼 보여버림
    3. 클라이언트 코드에 불필요한 의존성 생김 (인터페이스 이름에 종속됨)

상수를 공개하고 싶다면?

1️⃣ 관련 클래스/인터페이스 내부에 넣기

  • 상수가 특정 클래스와 밀접하면 그 클래스 안에 넣기
  • 예시 → Integer.MIN_VALUE, Double.MAX_VALUE

2️⃣ 열거 타입(enum)으로 정의

  • 값이 한정된 상수 집합이면 enum이 훨씬 적합함
  • ex) 요일, 방향, 단위 등

3️⃣ 유틸리티 클래스에 모아두기

  • 공통 상수를 제공하려면 인스턴스화 불가능한 유틸리티 클래스로 정의
public class PhysicalConstants {
    private PhysicalConstants() {} // 인스턴스화 방지
    
    public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

정적 임포트로 깔끔하게 사용 가능

  • 유틸리티 클래스 상수 자주 쓸 때는 static import로 간결하게 사용 가능
import static effective_java.item22.PhysicalConstants.*;

public class Test {
    double atoms(double mols) {
        return AVOGADROS_NUMBER * mols;
    }
}

→ 이렇게 하면 클래스 이름 없이 바로 상수 접근 가능

🚀 결론

  • 인터페이스 = 타입 정의용 도구
  • 상수만 넣는 용도로 쓰면 인터페이스의 역할을 완전히 오해한 것임
  • 상수를 노출해야 한다면
    → 관련 클래스 내부
    → 열거 타입
    → 유틸리티 클래스
    중 하나로 해결할 것
  • 즉, “인터페이스는 행동을 정의하고, 상수는 유틸리티로 분리하라.”

💡 아이템 23 — 태그 달린 클래스보다는 클래스 계층구조를 활용하라

문제 상황: 태그 달린 클래스 (Tag Class)

class Figure {
    enum Shape { RECTANGLE, CIRCLE }

    // 태그 필드
    final Shape shape;

    // 사각형일 때만 사용하는 필드
    double length;
    double width;

    // 원일 때만 사용하는 필드
    double radius;

    // 생성자 2개
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    double area() {
        switch (shape) {
            case RECTANGLE: return length * width;
            case CIRCLE: return Math.PI * (radius * radius);
            default: throw new AssertionError(shape);
        }
    }
}

단점

  • 쓸데없는 필드가 생김 (radius or length, width 중 일부는 항상 무의미)
  • switch문으로 동작 분기 → 새로운 도형 추가 시 모든 switch를 수정해야 함 (OCP 위반)
  • 가독성, 유지보수성 저하
  • 추가 타입이 늘어날수록 코드 복잡도 폭증

즉, 클래스 설계에 "if/switch 기반 타입 구분"이 들어가면, 객체지향 원칙을 위반한 신호 ⚠️

올바른 해결책: 클래스 계층구조로 리팩터링

abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;

    Circle(double radius) { this.radius = radius; }

    @Override
    double area() { return Math.PI * radius * radius; }
}

class Rectangle extends Figure {
    final double length;
    final double width;

    Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    @Override
    double area() { return length * width; }
}

이렇게 바꾸면 좋은 이유

항목태그 클래스계층구조 활용
코드 구조여러 타입을 한 클래스에 몰아넣음타입별로 독립적인 클래스로 분리
확장성새로운 타입 추가 시 switch 수정 필요새로운 서브클래스 추가만 하면 됨
가독성/유지보수성복잡하고 조건문 많음명확한 클래스 구조
안정성런타임 오류 가능성 (잘못된 태그 처리 등)컴파일 타임에 오류 잡힘

💡 아이템 24 - 멤버 클래스는 되도록 static으로 만들라

중첩 클래스(Nested Class)란?

  • 다른 클래스 안에 선언된 클래스.
  • 캡슐화 강화코드 논리적 그룹화를 위해 사용한다.
  • 오직 바깥 클래스와 함께 쓰일 때만 정의해야 한다.

중첩 클래스의 네 가지 종류

종류static 여부바깥 인스턴스 참조주요 용도
정적 멤버 클래스 (static member class)✅ static❌ X바깥 클래스와 밀접하지만 인스턴스 참조 불필요할 때
비정적 멤버 클래스 (non-static member class)✅ O바깥 인스턴스의 메서드/필드에 접근할 필요 있을 때
익명 클래스 (anonymous class)문맥 따라 다름비정적 문맥이면 O간단한 콜백, 함수 객체 구현 시
지역 클래스 (local class)비정적 문맥이면 O메서드 내부에서 한정적으로 사용할 때

정적 멤버 클래스

  • static으로 선언되며, 바깥 클래스의 인스턴스와 독립적이다.
  • 바깥 클래스의 private 멤버에는 접근 가능하다 (정적 문맥이므로 직접 참조 필요).
  • 바깥 클래스와 함께 쓰이지만 인스턴스 참조가 필요 없는 경우 유용.
  • 예시: Map.Entry, Enum 내부 구조 등
public class Calculator {
    public static enum Operation {
        PLUS("+", (x, y) -> x + y),
        MINUS("-", (x, y) -> x - y);
    }
}

비정적 멤버 클래스

  • 바깥 인스턴스와 암묵적으로 연결되어 있다.
  • 반드시 바깥 클래스의 인스턴스가 존재해야 생성 가능.
  • 바깥 클래스의 인스턴스에 접근해야 하는 경우 사용.
public class Outer {
    private String name = "Outer";

    public class Inner {
        void print() {
            System.out.println(Outer.this.name);
        }
    }
}

사용 예시:
컬렉션 클래스의 Iterator 구현체 (ListItr 등)는 종종 바깥 인스턴스를 참조해야 하므로 non-static으로 정의된다.

비정적 멤버 클래스의 단점

  • 바깥 인스턴스에 대한 숨은 참조(hidden reference) 를 갖는다.
  • 이로 인해 메모리 누수GC 지연이 발생할 수 있다.
  • 바깥 인스턴스 참조가 불필요하다면 반드시 static을 붙여야 한다.

익명 클래스

  • 이름이 없는 일회성 클래스.
  • 선언과 인스턴스 생성을 동시에 한다.
  • 작은 함수 객체나 이벤트 리스너에 자주 사용된다.
  • static 문맥에서는 static 필드만 가질 수 있다.
static List<Integer> intArrayAsList(int[] a) {
    return new AbstractList<>() {
        public Integer get(int i) { return a[i]; }
        public Integer set(int i, Integer val) { int old = a[i]; a[i] = val; return old; }
        public int size() { return a.length; }
    };
}

지역 클래스

  • 메서드 내에서 선언된 클래스.
  • 지역 변수처럼 동작하고 메서드의 스코프를 벗어나면 접근 불가.
  • 비정적 문맥에서만 바깥 인스턴스 참조 가능.
void foo() {
    class Local {
        void print() { System.out.println("Local class"); }
    }
    new Local().print();
}

정리 (핵심 원칙)

  1. 바깥 클래스의 인스턴스를 참조할 필요가 없다면 반드시 static으로 선언하라.
  2. 불필요한 외부 참조를 줄이면 성능, 메모리 효율, 가독성 모두 개선된다.
  3. 메서드 밖에서도 쓰거나 길이가 길다면 멤버 클래스,
    메서드 안에서만 간단히 쓰면 지역/익명 클래스로 구현하라.

💡 아이템 25 — 톱레벨 클래스는 한 파일에 하나만 담아라

배경

자바에서는 소스 파일 하나에 여러 개의 톱레벨 클래스(Top-level class) 를 선언할 수 있다.
컴파일러 입장에선 문법적으로 문제가 없지만,
이 방식은 예측 불가능한 동작심각한 유지보수 문제를 일으킬 수 있다.

문제점: 컴파일 순서에 따라 동작이 달라진다

// Utensil.java
class Utensil {
    static final String NAME = "PAN";
}
class Dessert {
    static final String NAME = "CAKE";
}
// Dessert.java
class Utensil {
    static final String NAME = "POT";
}
class Dessert {
    static final String NAME = "PIE";
}
// Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }
}

실행 결과는 어떻게 될까?

컴파일 명령결과
javac Main.java Utensil.javaPANCAKE
javac Main.java Dessert.javaPOTPIE
javac Main.javaPANCAKE
javac Dessert.java Main.javaPOTPIE

즉, 어느 파일이 먼저 컴파일되느냐에 따라 결과가 달라짐 ⚠️
이건 클래스의 정의가 충돌하기 때문.

왜 이런 일이 발생할까?

  • 자바는 같은 패키지 내에서 동일한 이름의 톱레벨 클래스가 있으면
    어떤 버전을 사용할지 컴파일러가 보장하지 않는다.
  • 즉, 컴파일러가 먼저 읽어들인 소스 파일에 따라
    동일한 이름의 클래스가 덮어쓰여질 수 있다.
  • 이 문제는 프로젝트 규모가 커질수록 디버깅이 거의 불가능한 버그로 이어질 수 있다.

✅ 해결 방법

톱레벨 클래스는 무조건 한 파일에 하나만!

만약 논리적으로 한 곳에 묶어야 한다면
👉 정적 멤버 클래스(static nested class) 를 사용하자.

// Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }

    private static class Utensil {
        static final String NAME = "PAN";
    }

    private static class Dessert {
        static final String NAME = "CAKE";
    }
}

이 방식은

  • 한 파일에 모아두되
  • 외부 노출을 막고
  • 이름 충돌도 방지함 ✅

참고 글: https://github.com/back-end-study/effective-java/tree/main/4%EC%9E%A5_%ED%81%B4%EB%9E%98%EC%8A%A4%EC%99%80_%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4

0개의 댓글