코드의 여백

[이펙티브 자바] Interface와 Class 설계 원칙

by rowing0328

※ 책 내용을 바탕으로 제 관점에서 풀어 쓴 글입니다. 일부 내용이 다를 수 있습니다.

 

아이템 20 - Abstract class 보다는 interface를 우선하라

 

Extends vs Implements

  • extends
    단일 상속만 가능하다.
    한 클래스는 하나의 상위 클래스를 상속받을 수 있다.
  • implements
    다중구현이 가능하다.
    클래스는 여러 개의 인터페이스를 구현할 수 있다.

 

예제 코드

public class Sub extends Super implements Serializable, Cloneable {}

public interface Sub extends Serializable, Cloneable {}

 

Default Method와 Interface

Java 8부터 default 메서드가 추가되어 기본 동작을 제공하는 메서드를 인터페이스 내에 정의할 수 있다.

 

예제 코드

public interface Packable {

    default void packOrder() {
        System.out.println("포장 주문이 들어왔습니다.");
    }
    
}

public class Restaurant implements Delibariable, Packable {
    // Packable의 default 메서드 활용 가능
}

 

Default Method의 Diamond 문제

인터페이스에 동일한 이름과 시그니처를 가진 default 메서드가 존재하면 다이아몬드 문제가 발생할 수 있다.

이를 방지하기 위해 클래스에서 명시적으로 해당 메서드를 재정의해야 한다.

 

예제 코드

public class Restaurant implements Delibariable, Packable {
    
    @Override
    public void order() {
        // 명시적으로 메서드를 구현해야 충돌 방지
    }
    
}

 

설명

Delibariable과 Packable 인터페이스에 동일한 이름의 default 메서드가 정의된 경우,
클래스에서 해당 메서드를 명시적으로 구현하지 않으면 컴파일 오류가 발생한다.

 

Skeletal Implementation (추상 골격 구현)

추상 클래스와 인터페이스를 조합하여 설계한다.

 

Interface 설계의 뼈대를 만든다.

인터페이스는 설계의 뼈대를 정의하며, 기본적으로 메서드의 시그니처만 제공한다.
필요에 따라 default 메서드를 추가하여 기본 구현을 제공할 수도 있다.

 

예제 코드

public interface Shape {
    
    double calculateArea(); // 면적 계산
    double calculatePerimeter(); // 둘레 계산

    default void displayType() {
        System.out.println("This is a shape.");
    }
    
}

 

Abstract Class로 공통 로직 구현

추상 클래스는 인터페이스를 구현하며, 공통으로 사용할 수 있는 메서드를 정의하거나 일부 메서드를 미완성 상태로 유지한다.

 

예제 코드

public abstract class AbstractShape implements Shape {
    
    protected String name;

    public AbstractShape(String name) {
        this.name = name;
    }

    @Override
    public void displayType() {
        System.out.println("This is a " + name + ".");
    }

    public abstract double calculateArea();
    public abstract double calculatePerimeter();
    
}

 

Sub Class 세부 구현

구체적인 클래스를 작성하여 추상 클래스를 상속받고 세부적인 로직을 완성한다.

 

예제 코드

public class Circle extends AbstractShape {

    private double radius;

    public Circle(String name, double radius) {
        super(name);
        this.radius = radius;
    }

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

    @Override
    public double calculatePerimeter() {
        return 2 * Math.PI * radius;
    }
    
}

public class Rectangle extends AbstractShape {

    private double width;
    private double height;

    public Rectangle(String name, double width, double height) {
        super(name);
        this.width = width;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }

    @Override
    public double calculatePerimeter() {
        return 2 * (width + height);
    }
    
}

 

사용 예시

public class Main {
    
    public static void main(String[] args) {
        Shape circle = new Circle("Circle", 5);
        circle.displayType();
        System.out.println("Area: " + circle.calculateArea());
        System.out.println("Perimeter: " + circle.calculatePerimeter());

        Shape rectangle = new Rectangle("Rectangle", 4, 6);
        rectangle.displayType();
        System.out.println("Area: " + rectangle.calculateArea());
        System.out.println("Perimeter: " + rectangle.calculatePerimeter());
    }
    
}

 

주의사항

Object 클래스의 메서드(equals, toString 등)는 default 메서드로 제공하지 않는 게 좋다.

Object 클래스는 모든 클래스의 상위 클래스라서

인터페이스에서 default 메서드로 제공하면 클래스 계층 구조에서 충돌이 발생할 수 있다.

 

예를 들어, equals 메서드를 default로 정의하면

하위 클래스에서 이를 재정의하거나 사용할 때

예상치 못한 문제가 생길 수 있다.

 

 

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

default 메서드는 인터페이스에 메서드 구현을 포함시켜

이를 구현한 클래스에서 재정의하지 않으면 기본 구현이 사용되도록 하는 기능이다.

 

하지만 모든 구현체와 매끄럽게 연동된다는 보장은 없어서

주의해서 사용해야 한다.

 

디폴트 메서드의 장점과 한계

 

장점

  • 기존 인터페이스에 새로운 메서드를 추가할 수 있어, 하위 호환성을 유지하면서도 기능을 확장할 수 있다.
  • 새로운 인터페이스를 설계할 때 표준적인 메서드 구현을 제공하여 인터페이스를 쉽게 구현할 수 있다.

한계

  • 모든 구현체의 상황을 고려해 불변식을 해치지 않는 디폴트 메서드를 작성하는 것은 어렵다.
  • 구현체의 특수한 요구 사항과 충돌하거나, 예상치 못한 런타임 오류가 발생할 수 있다.

 

사용 예시

List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
numbers.removeIf(n -> n % 2 == 0); // 짝수를 제거
System.out.println(numbers); // 출력: [1, 3, 5]

 

설명

Java 8에서는 Collection 인터페이스에 removeIf 디폴트 메서드가 추가되었다.

이 메서드는 조건(Predicate)에 맞는 요소를 컬렉션에서 제거하는 데 사용된다.

 

SynchronizedCollection과의 문제

SynchronizedCollection은 스레드 안전을 보장하기 위해

모든 메서드 호출을 락 객체로 동기화한 뒤, 내부 컬렉션에 작업을 위임하는 래퍼 클래스다.

 

하지만 removeIf는 default 메서드로 제공되므로

동기화를 고려하지 않고 작성되었다.

 

이로 인해 removeIf를 사용하면 동기화 요구 사항을 충족하지 못하고,

멀티스레드 환경에서 데이터 손상이 발생할 수 있다.

 

예제 코드

@Override
public boolean removeIf(Predicate<? super E> filter) {
    
    synchronized (lock) {
        return collection.removeIf(filter);
    }
    
}

 

설명

Java는 이러한 문제를 해결하기 위해, 구현체에서 default 메서드를 재정의할 수 있는 기능을 제공한다.

예를 들어, SynchronizedCollection에서는 removeIf를 재정의하여 동기화를 추가한다.

 

 

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

 

Constant static final은 Anti-Pattern

클래스 내부에서 사용하는 static final 상수는 내부 구현에 해당한다.

이 상수를 외부에서 접근 가능하게 만들면 혼란을 초래할 수 있다.

 

만약 상수를 여러 곳에서 사용해야 한다면 Util Class를 활용하는 것이 더 적합하다.

 

코드 예제

// 잘못된 사용 예시
public class OrderService {
	public static final double SECOND_TO_MIN = 60;
}

// 올바른 사용 예시
public class TimeConvertUtil {

	public static final double SECOND_TO_MIN = 60;
    
    public static double secondToMin(double second) {
    	return second / SECOND_TO_MIN;
    }
    
}

 

Static Import 사용 시 주의사항

static import를 사용하면 클래스 이름 없이 상수를 바로 사용할 수 있다.

하지만 잘못 사용하면 혼란을 초래할 수 있으니 신중하게 사용하는 것이 좋다.

 

코드 예제

// 일반 import
import example.chapter4.TimeConvertUtil;
TimeConvertUtil.SECOND_TO_MIN;

// static import
import static example.chapter4.TimeConvertUtil.SECOND_TO_MIN;
SECOND_TO_MIN;

 

권장 사항

일반 import 방식을 사용하는 게 더 좋다.

이 방식은 비슷한 이름의 상수(enum 포함)와 충돌할 위험을 줄여준다.

 

상수를 외부에서 관리해야 한다면 Properties에 값을 선언하고

의존성 주입(Dependency Injection)을 활용하는 것도 좋은 방법이다.

 

 

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

 

태그 달린 클래스와 클래스 계층 구조 활용

 

태그 달린 클래스의 문제점

태그 필드(enum Type)와 다양한 필드를 섞어서 설계한 클래스이다.

태그 필드를 기반으로 조건문(switch)을 사용해 로직을 처리한다.

 

단점

  • 여러 구현이 한 클래스에 섞여 있어 각 타입별로 필요한 필드와 로직이 혼재되어 코드가 복잡해진다.
  • 특정 타입에서만 사용하는 필드가 클래스 전체에 존재해 메모리 낭비와 관리가 어렵다.
  • 새로운 타입이 추가되면 switch 문과 관련된 로직을 전부 수정해야 해 유지보수가 어렵다.

 

클래스 계층 구조로 리팩토링

태그 필드를 제거하고, 클래스 계층 구조를 활용해 각 타입별 클래스를 분리한다.

추상 클래스나 인터페이스를 활용해 공통 로직과 메서드를 정의하고, 각 타입에 맞는 세부 구현을 제공한다.

 

예제 코드

// 추상 클래스 정의
abstract class User {
    abstract boolean order(String info);
}

// 세부 구현 : Customer
class Customer extends User {
    
    @Override
    boolean order(String info) {
        // 고객 전용 로직
        return false;
    }
    
}

// 세부 구현 : DeliveryPerson
class DeliveryPerson extends User {
    
    @Override
    boolean order(String info) {
        // 배달원 전용 로직
        return false;
    }
    
}

 

장점

  • 각 타입별로 로직이 분리되어 코드가 더 명확해진다.
  • 새로운 타입을 추가할 때 기존 코드를 수정하지 않고 클래스를 추가하면 되므로 확장성이 높아진다.
  • 각 클래스에 필요한 필드만 정의하므로 불필요한 필드가 제거된다.

 

 

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

 

Nested Class - Static Member Class

Static Member Class는 외부 클래스에 종속되지만, 독립적으로 존재할 수 있다.

Static으로 선언된 클래스는 외부 클래스의 인스턴스 없이 사용할 수 있다.

 

예제 코드

public class Customer {
    
    private int age;
    private Address address;

    public String printBarCode() {
        return address.fullAddress + address.zipcode;
    }

    private static class Address {
        private String fullAddress;
        private String zipcode;
    }
    
}

 

Nested Class - Member Class

Member Class는 외부 클래스의 인스턴스와 연결되어야만 생성이 가능하다.

독립적으로 사용할 수 없으며, 항상 외부 클래스의 인스턴스를 통해 접근해야 한다.

 

예제 코드

public class User {
    
    private String name;
    private Address address;

    public class Address {
        private String zipcode;
    }

    public String getUserName() {
        return name;
    }
    
}

// 사용법
User user = new User();
User.Address address = user.new Address();

 

Nested Class - Anonymous Class

Anonymous Class는 이름이 없는 클래스로, 주로 즉석에서 일회성 객체를 생성할 때 사용된다.

인터페이스 또는 추상 클래스를 구현하여 사용하는 경우가 많다.

 

예제 코드

public interface MyName {
    int getAge();
}

// 익명 클래스 사용
MyName myName = new MyName() {

    private int age = 25;

    @Override
    public int getAge() {
        return age;
    }
    
};

 

Nested Class - Local Class

Local Class는 특정 메서드 내에서 정의된 클래스이다.

메서드 내부에서만 사용되며, 해당 메서드가 실행될 때만 접근 가능하다.

 

예제 코드

public void getName() {
    
    class Name {
        public int age;
    }

    Name name = new Name();
    name.age = 25;
    
}

 

 

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

소스 파일 하나에 여러 개의 톱 레벨 클래스를 선언하는 것은 득이 없고 위험하다.

이 방식은 클래스 정의가 중복될 수 있는 위험을 감수하게 하고, 컴파일 순서에 따라 동작이 달라질 수 있다.

 

컴파일 순서에 따라 결과가 달라지는 경우

 

예제 코드

// 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.java → pancake
  • javac Dessert.java Main.javapotpie

이처럼 컴파일 순서에 따라 예상치 못한 결과를 초래한다.

 

정적 멤버 클래스로 변환

톱레벨 클래스를 유지할 필요가 없고, 부차적인 관계라면 정적 멤버 클래스를 사용하는 것이 더 적합하다.

 

예제 코드

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";
    }
    
}

 

 

마무리

이번 아이템을 통해,

작은 설계 실수가 예상치 못한 큰 문제를 초래할 수 있다는 점을

다시 한번 깨달았다. 

 

코드를 작성할 때는

구조적 명확성과 확장 가능성을

항상 염두에 두어야 한다는 것도 다시금 느꼈다.

블로그의 정보

코드의 여백

rowing0328

활동하기