본문 바로가기

Book/Effective Java

[이펙티브 자바] 2장. 객체 생성과 파괴 (아이템 1, 2, 3) 정리

해당 내용은 책 내용을 그대로 정리하기보다는 책을 읽고 제가 이해한 내용을 바탕으로 작성했습니다. 틀린 내용이 있다면 댓글로 제보해주세요.

 

아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하라

장점 1) 이름을 가질 수 있다

 

생성자를 이용한 객체 생성

public class Member {
    private String name;
    private boolean isAdmin;

    // 생성자
    public Member(String name, boolean isAdmin) {
        this.name = name;
        this.isAdmin = isAdmin;
    }

    // 다른 메서드들...
}

// 객체 생성
Member regularMember = new Member("Jisu", false);
Member adminMember = new Member("Jisu", true);

 

만약, 생성자를 이용하여 객체 생성을 한다면 `Member("Jisu", false)`처럼 코드를 작성할 것이다. 이때 false가 일반 사용자인지 관리자인지 외부에서 바로 이해하기가 어렵다.

 

public class Member {
    private String name;
    private boolean isAdmin;

    // private 생성자
    private Member(String name, boolean isAdmin) {
        this.name = name;
        this.isAdmin = isAdmin;
    }

    // 정적 팩터리 메서드
    public static Member createRegularMember(String name) {
        return new Member(name, false);
    }

    public static Member createAdminMember(String name) {
        return new Member(name, true);
    }

    // 다른 메서드들...
}

// 객체 생성
Member regularMember = Member.createRegularMember("Jisu");
Member adminMember = Member.createAdminMember("Jisu");

만약, 위의 코드처럼 `createRegularMember`, `createAdminMember`와 같은 정적 팩터리 메서드를 사용한다면 외부에서 해당 코드가 어떤 권한을 가진 사용자를 만들 수 있는지 쉽게 알아챌 수 있다.

 

 

장점 2) 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다 

당연한 얘기지만, 정적 팩토리 메서드는 static 함수이므로 호출될 때마다 인스턴스가 새로 생성되는 것을 방지할 수 있다.

이렇게 되면 개발자가 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제할 수 있어 같은 객체가 자주 요청되는 상황이라면 성능을 상당히 끌어올릴 수 있다. 스프링 빈의 기본 스코프인 싱글톤을 생각하면 될 거 같다.

 

 

장점 3) 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다

public interface Animal {
    void speak();
}

public class Dog implements Animal {
    @Override
    public void speak() {
        System.out.println("Woof!");
    }
}

public class Cat implements Animal {
    @Override
    public void speak() {
        System.out.println("Meow!");
    }
}

public class AnimalFactory {

    public static Animal createAnimal(String type) {
        if (type.equals("dog")) {
            return new Dog();
        } else if (type.equals("cat")) {
            return new Cat();
        } else {
            throw new IllegalArgumentException("Unknown animal type.");
        }
    }
}

 

정적 팩터리 메서드를 사용하면 메서드가 선언된 반환 타입의 하위 클래스 혹은 인터페이스 구현체를 반환할 수 있다.

`AnimalFactory.createAnimal()` 메서드는 `Animal` 인터페이스를 반환 타입으로 선언하고 있다. 하지만, 실제로는 `Dog`나 `Cat` 클래스의 인스턴스를 반환한다.

즉, 반환 타입은 `Animal`이지만, 반환되는 객체는 `Animal`의 하위 타입인 `Dog`나 `Cat`이다.

 

 

이렇게 코드를 작성한다면 코드의 유연성이 높아진다. 클라이언트는 구체적인 하위 클래스에 대해 알 필요 없어, 반환된 객체가 `Animal` 타입이라는 것만 알고 있으면 된다.

public class Bird implements Animal {
    @Override
    public void speak() {
        System.out.println("Tweet!");
    }
}

// AnimalFactory에 bird 추가
public static Animal createAnimal(String type) {
    if (type.equals("dog")) {
        return new Dog();
    } else if (type.equals("cat")) {
        return new Cat();
    } else if (type.equals("bird")) {
        return new Bird();
    } else {
        throw new IllegalArgumentException("Unknown animal type.");
    }
}

 

`AnimalFactory`에서 새로운 `Bird` 클래스를 추가하고 싶다면, 위의 코드처럼 `createAnimal` 메서드의 내부 로직만 수정하면 된다. 결국 코드의 의존성은 낮춰지고, 객체 생성 로직을 변경하더라도 클라이언트 코드에 영향을 주지 않는 장점이 따라온다.

 

 

장점 4) 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다

같은 메서드 호출이지만 전달된 인자 값에 따라 서로 다른 클래스의 인스턴스를 생성하고 반환할 수 있다.

 

public interface Shape {
    void draw();
}

public class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a Circle");
    }
}

public class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a Square");
    }
}

public class ShapeFactory {
    
    public static Shape createShape(String shapeType) {
        if (shapeType.equalsIgnoreCase("circle")) {
            return new Circle();
        } else if (shapeType.equalsIgnoreCase("square")) {
            return new Square();
        } else {
            throw new IllegalArgumentException("Unknown shape type.");
        }
    }
}

 

위의 코드를 보면 `ShapeFactory.createShape()` 메서드의 `shapeType` 매개변수에 어떤 값을 넣느냐에 따라 Circle 이나 Square 객체를 반환하게 된다. 즉, 같은 메서드를 호출하지만 입력값에 따라 반환되는 객체의 타입이 달라진다.

 

 

장점 5) 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다

이 부분이 이해하기가 어려웠는데 일단 내가 이해한 내용은 아래와 같다.

 

위에서 이미 언급한 장점처럼 정적 팩터리 메서드는 반환 타입만 명시하면 되므로, 그 메서드가 반환할 구체적인 클래스가 나중에 추가될 수 있다. 이를 통해 개발자는 새 기능을 추가하거나 요구사항이 변경될 때, 기존 코드를 크게 수정하지 않고도 새 클래스/기능을 지원할 수 있다.

 

책에서는 Java의 서비스 제공자 프레임워크인 JDBC를 예시로 들었다.

JDBC는 다양한 데이터베이스 시스템에 접속하기 위한 표준 API를 제공하는데, 우리는 제공되는 JDBC를 그대로 사용한다. 하지만 실제로는 각 데이터베이스 벤더마다 다른 JDBC 드라이버를 제공한다. JDBC 프레임워크는 이러한 드라이버들을 유연하게 관리하고, 클라이언트가 사용하는 드라이버를 동적으로 선택할 수 있도록 한다.

 

Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");

JDBC를 사용해 DB에 연결할 때, `DriverManager` 클래스의 정적 메서드를 사용해 데이터베이스 연결을 얻는다. 이때 `getConnection()` 메서드는 내부적으로 URL에 맞는 적절한 드라이버 (위의 코드에서는 MySQL)를 찾아 `Connection` 객체를 반환하게 된다.

 

즉, 개발자가 특정 구현체를 명시하지 않아도 정적 메서드인 `getConnection()`가 동적으로 적절한 드라이브로 선택해준다. 이는 새로운 데이터베이스 드라이버가 추가되어도 기존 코드를 변경하지 않고 사용할 수 있는 유연함을 제공할 수 있음을 의미한다.

 

위의 내용을 한 번 더 정리하면, 정적 팩터리 메서드는 클라이언트에게 구현체를 숨기고 특정 조건에 따라 적절한 구현체를 동적으로 반환할 수 있는 유연성을 제공한다. 이는 서비스 제공자 프레임워크의 핵심적인 동작 방식이며 JDBC가 이러한 개념을 잘 구현한 대표적인 예시이다.

 

 

단점 1) 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다

정적 팩터리 메서드만 제공하는 클래스는 일반적으로 private으로 선언한다. 이는 외부에서 직접 객체를 생성하지 못하고, 정적 팩터리 메서드를 통해서만 객체를 생성하도록 강제한다.

그러므로 정적 팩터리 메서드만 제공하면 extends를 이용해서 하위 클래스를 못만든다는 의미이다.

 

하지만 이러한 점이 오히려 클래스의 인스턴스 생성 방식을 통제하고, 상속을 통한 클래스 오용을 방지하는 데 중요한 역할이 될 수 있어 장점으로 받아들일 수도 있다.

 

 

단점 2) 정적 팩터리 메서드는 프로그래머가 찾기 어렵다

생성자와 달리 정적 팩터리 메서드는 사용자가 직접 만든 함수이므로 찾기 어렵다는 의미이다. 그렇기에 메서드 이름도 널리 알려진 규약을 따라 지어줘야 한다. 아래는 책에서 소개된 명명 방식 중 몇가지 예시이다.

 

  • from: 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
    • Date d = Date.from(instant);
  • of: 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
    • Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf: from과 of의 더 자세한 버전
    • BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);

 

 

 

아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라

정적 팩터리나 생성자에는 똑같은 제약이 하나 있는데, 바로 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 점이다. 서버 개발을 해봤다면 다들 한 번쯤은 이런 불편함을 겪었을 것이다.

 

점층적 생성자 패턴도 쓸 수 있지만, 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다. 그렇기에 이 책에서는 빌더 객체를 통해 선택 매개변수들을 설정하여 객체를 생성하는 빌더 패턴을 제시했다.

 

이 책에서는 Builder Class를 따로 만들어 예시를 보여주었는데 나는 스프링에서 제공되는 Lombok 라이브러리를 활용했다.

 

import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

@Getter
@ToString
@Builder
public class Member {
    // 필수 매개변수
    private final String name;

    // 선택적 매개변수
    private final int age;
    private final String email;
    private final String phoneNumber;
}


// 빌더 사용
Member member = Member.builder()
	    .name("Jisu")
            .age(24)
            .email("dlawotn321@gmail.com")
            .phoneNumber("010-1111-1111")
            .build();

Lombok의 `@Builder`를 클래스에 추가하면, 자동으로 빌더 클래스를 생성해준다. 개발자는 위의 코드처럼 바로 `Member.builder()` 메서드를 사용해 빌더를 생성하고, 필드 값을 설정해 객체를 생성할 수 있다.

 

Builder 패턴을 사용하면 필드의 이름을 사용해 객체를 생성하므로, 어떤 값이 어떤 필드에 할당되는지 명확히 알 수 있어 가독성이 좋다. 또한 선택적으로 필요한 필드만 설정할 수 있으므로 객체 생성 시의 유연성이 높아진다.

 

하지만 코드가 세로로 길어진다는 단점때문에 개인적으로 Builder 패턴을 선호하는 편이 아니다.
평소에는 생성자를 이용하여 객체를 생성하고 불변성이 강조되는 객체에 한해서만 Builder 패턴을 사용하는 편인데 이게 좋은 방법인지는 모르겠지만 상황에 따라 적절히 사용하면 좋을 거 같다.

 

 

 

 

아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보장하라

싱글턴은 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다. 그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.

테스트 환경에서 싱글턴의 한계
싱글턴 클래스는 애플리케이션 내 어디서든지 동일한 인스턴스를 공유하기 때문에, 테스트 간에 상태가 공유될 위험이 있다. 
이로 인해 테스트 간의 독립성이 깨질 수 있다.
또한, 단위 테스트에서는 특정 객체를 모킹하여 테스트하고자 하는 대상 클래스의 동작을 독립적으로 검증하는데 싱글턴 클래스는 하나의 인스턴스만 존재하므로, 이를 대체하는 것이 어려워진다.

 

 

싱글턴을 만드는 방식은 크게 두 가지가 있으며, 두 방식 모두 생성자는 private으로 감추되, 인스턴스에 접근할 수 있는 public static 멤버를 하나 마련해둔다.

 

1) public static 멤버가 final 필드인 방식

 

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }
    
    public void leaveTheBuilding() { ... }
}

 

생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번만 호출된다. public이나 protected 생성자가 없으니 전체 시스템에서 하나뿐임을 보장해준다.

 

  • 장점
    • public static 필드가 final이므로 싱글턴임이 API에 명백히 드러난다
    • 간결하다

 

2) 정적 팩터리 방식의 싱글턴

public class Elvis {
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }
    public static Elvis getInstance() { return INSTANCE; }
    
    public void leaveTheBuilding() { ... }
}

 

이 방식은 정적 팩터리 메서드를 public static 멤버로 제공한다. `Elvis.getInstance`는 항상 같은 객체의 참조를 반환하기에 싱글턴을 보장해준다.

 

  • 장점
    • API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다
      • getInstance 내부 로직에서 새 인스턴스를 생성하게 하면 해당 클래스는 싱글턴이 아니게 된다
    • 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다
    • 정적 팩터리의 메서드 참조를 공급자로 사용할 수 있다

 

* 역직렬화 과정에서의 문제

하지만 위의 두 방식으로 만든 싱글턴 클래스는 직렬화하려면 Serializable을 구현하는 것만으로는 부족하다. 역직렬화 과정에서 새로운 객체가 생성될 수 있기 때문이다.

 

이 부분을 이해하려면 직렬화와 역직렬화 과정에서 일어나는 문제를 알아야 한다.

  • 직렬화: 객체 데이터를 바이트 스트림으로 변환하여 외부 파일로 내보낼 수 있게 하는 기술
  • 역직렬화: 외부로 내보낸 직렬화 데이터를 다시 읽어들여 자바 객체로 재변환하느 기술

어떤 클래스를 직렬화하여 다른 컴퓨터에 전송하려는데, 이 클래스를 싱글톤으로 구성하려 한다. 하지만 송신자가 파일을 받고 이 싱글톤 클래스를 역직렬화하게 되면 깨지게 되어 더이상 싱글톤이 아니게 된다.

import java.io.*;

public class Main {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton singleton1 = Singleton.getInstance();

        String fileName = "singleton.obj";

        // 직렬화
        ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName)));
        out.writeObject(singleton1);
        out.close();

        // 역직렬화
        ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(fileName)));
        Singleton singleton2 = (Singleton) in.readObject();
        in.close();

        System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
        System.out.println(singleton1);
        System.out.println(singleton2);
    }
}


// 싱글톤 + 직렬화
class Singleton implements Serializable {

    private Singleton() {}

    private static class SettingsHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

 

제대로 깨져버린 싱글톤

 

이러한 현상이 생기는 이유는 역직렬화 자체가 보이지 않은 생성자로서 역할을 수행하기 때문이다. 역직렬화 과정에서 인스턴스를 또다시 만들어, 직렬화에 사용한 인스턴스와는 전혀 다른 인스턴스가 되어 결국 싱글톤이 아니게 된다.

 

 

이러한 문제를 해결하기 위해 `readResolve()`를 정의할 수 있다.

이 메서드를 통해 역직렬화 과정에서 readObject를 통해 만들어진 인스턴스 대신 readResolve에서 반환되는 인스턴스를 내가 원하는 바꿀 수 있다. 그리고 역직렬화를 통해 새로 생성된 객체는 알아서 GC의 대상이 된다.

 

// 싱글턴임을 보장해주는 readResolve 메서드
private Object readResolve() {
    // 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
    return INSTANCE;
}

 

이에 대한 자세한 내용은 https://inpa.tistory.com/entry/JAVA-☕-싱글톤-객체-깨뜨리는-방법-역직렬화-리플렉션에 잘 정리되어 있다.

 

 

3) 열거 타입 방식의 싱글턴

public enum Elvis {
    INSTANCE;
    
    public void leaveTheBuilding() { ... }
}

 

원소가 하나인 열거 타입을 선언하여 싱글턴을 만들 수 있다. public 필드 방식과 비슷하면서도, 편하게 직렬화/역직렬화가 가능하다. 이 책에서는 원소가 하나뿐인 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이라 설명한다.

 

 

 

단, 만들려는 싱글턴이 Enum외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다. (열거 타입은 이미 Enum 클래스를 상속하고 있기 때문이다.)

 

 

개인적으로 3번 형태는 처음 보는 방식인데 이 부분은 실제로 많이 쓰이는 방식인지 개인 공부가 더 필요해보인다...