김코딩

[ Effective Java ] 2. 생성자에 매개변수가 많다면 빌더를 고려하라 본문

개발 서적/Effective Java

[ Effective Java ] 2. 생성자에 매개변수가 많다면 빌더를 고려하라

김코딩딩 2025. 9. 26. 00:29

빌더(Builder)란?

빌더(Builder)는 복잡한 객체를 단계별로 생성할 수 있게 해주는 생성패턴이다. 처리하기 어려운 많은 매개변수를 가진 객체를 깔끔하고 안전하게 만들 수 있도록 도와준다.
// 일반적인 생성자 방식
Person person = new Person("김철수", 30, "서울", "010-1234-5678", "kim@email.com", true, false);

// 빌더 방식  
Person person = Person.builder()
    .name("김철수")
    .age(30)
    .address("서울")
    .phoneNumber("010-1234-5678")
    .email("kim@email.com")
    .isMarried(true)
    .hasChildren(false)
    .build();

기존 방식들의 한계

1. 점층적 생성자 패턴 (Telescoping Constructor Pattern)

- 매개변수가 많은 클래스에서 전통적으로 사용하던 방식이다.

- 매개변수 1개 ~ 전부다 받는 생성자까지 늘려가는 방식이다.

- 매개변수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.

public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }

    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize = servingSize;
        this.servings = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

 

문제점들 : 

 

- 매개변수 순서 혼동

// 이런 실수가 자주 발생
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35);
// 매개변수 순서를 바꿔서 잘못 입력할 가능성
NutritionFacts pepsi = new NutritionFacts(240, 100, 8, 0, 35);  //calories와 servings 순서 바뀜

 

- 불필요한 매개변수 설정

// sodium만 설정하고 싶어도 fat까지 0으로 설정해야 함
NutritionFacts juice = new NutritionFacts(200, 1, 80, 0, 15);  // fat=0을 억지로 설정

 

- 생성자 개수가 너무 많아짐

필드가 10개면 생성자가 몇 개나 필요할까? 2^10 = 1024개
실제로는 합리적인 조합만 만들어도 수십 개가 필요

 

2. 자바빈즈 패턴 (JavaBeans Pattern)

- 매개변수가 없는 생성자로 객체를 만든 후, setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식

public class NutritionFacts {
    private int servingSize = -1;    // 필수: 기본값 없음
    private int servings = -1;       // 필수: 기본값 없음  
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;

    public NutritionFacts() { }

    // setter 메서드들
    public void setServingSize(int val) { servingSize = val; }
    public void setServings(int val) { servings = val; }
    public void setCalories(int val) { calories = val; }
    public void setFat(int val) { fat = val; }
    public void setSodium(int val) { sodium = val; }
    public void setCarbohydrate(int val) { carbohydrate = val; }
}

 

사용 예시: 

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

 

장점 : 

  • 점층적 생성자 패턴의 단점들이 사라짐
  • 각 매개변수의 의미가 명확함
  • 원하는 매개변수만 설정 가능

단점 : 

- 객체 일관성(consistency) 파괴

NutritionFacts facts = new NutritionFacts();
// 이 시점에서 객체는 불완전한 상태 (servingSize = -1, servings = -1)

facts.setServingSize(240);
// 여전히 불완전 (servings = -1)

// 만약 여기서 다른 스레드가 이 객체를 사용한다면?
// 또는 setter 호출을 깜빡한다면?

facts.setServings(8);
// 이제야 완전한 상태

 

- 불변 클래스로 만들 수 없음

// setter가 있으면 불변 객체가 될 수 없음
public final class ImmutableNutritionFacts {
    private final int servingSize;
    private final int servings;
    
    // setter 메서드를 만들 수 없음 (final 필드이므로)
    // public void setServingSize(int val) { servingSize = val; }  //컴파일 에러
}

 

- 런타임 에러 가능성

public class NutritionFacts {
    private int servingSize = -1;
    
    public int calculateTotalCalories() {
        if (servingSize <= 0) {
            throw new IllegalStateException("servingSize가 설정되지 않았습니다!");
        }
        return servings * calories;
    }
}

NutritionFacts facts = new NutritionFacts();
facts.setServings(8);
facts.setCalories(100);
// servingSize 설정을 깜빡함

facts.calculateTotalCalories();  //RuntimeException 발생

빌더 패턴의 등장

- 점층적 생성자 패턴의 안정성 자바 빈즈 패턴의 가독성을 겸비한 빌더 패턴이 등장하였다.

- 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자(혹은 정적 팩터리)를 호출해 빌더 객체를 얻는다.

 

빌더 패턴의 동작 방식

1. 빌더 객체 획득 : 필수 매개변수로 빌더 생성

2. 선택적 매개변수 설정 : 체이닝 방식으로 값 설정

3. 최종 객체 생성 : build() 메서드 호출

public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;
    
    // private 생성자 - 빌더를 통해서만 생성 가능
    private NutritionFacts(Builder builder) {
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
        fat = builder.fat;
        sodium = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
    
    public static class Builder {
        // 필수 매개변수
        private final int servingSize;
        private final int servings;
        
        // 선택 매개변수 - 기본값으로 초기화
        private int calories = 0;
        private int fat = 0;
        private int sodium = 0;
        private int carbohydrate = 0;
        
        // 필수 매개변수만 받는 빌더 생성자
        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }
        
        // 각 선택적 매개변수를 설정하는 메서드들
        // 빌더 자신을 반환하여 체이닝 가능
        public Builder calories(int val) {
            calories = val;
            return this;
        }
        
        public Builder fat(int val) {
            fat = val;
            return this;
        }
        
        public Builder sodium(int val) {
            sodium = val;
            return this;
        }
        
        public Builder carbohydrate(int val) {
            carbohydrate = val;
            return this;
        }
        
        // 최종 NutritionFacts 객체 생성
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }
}

 

사용 예시:

// 깔끔하고 읽기 쉬운 코드
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)    // 필수 매개변수
    .calories(100)           // 선택 매개변수들을 원하는 순서로
    .sodium(35)
    .carbohydrate(27)
    .build();                // 최종 객체 생성

// 필요한 것만 설정 가능
NutritionFacts water = new NutritionFacts.Builder(240, 1)
    .build();  // calories, fat, sodium, carbohydrate는 모두 기본값 0

// 순서는 상관없음
NutritionFacts juice = new NutritionFacts.Builder(200, 1)
    .carbohydrate(20)        // 순서를 바꿔도 됨
    .calories(80)
    .sodium(5)
    .build();

 

빌더 패턴의 장점

 

- 가독성과 명확성

// 기존 생성자 방식 - 매개변수의 의미가 불분명
NutritionFacts pepsi = new NutritionFacts(240, 8, 100, 3, 35, 27);

// 빌더 방식 - 각 값의 의미가 명확
NutritionFacts pepsi = new NutritionFacts.Builder(240, 8)
    .calories(100)
    .fat(3)
    .sodium(35)
    .carbohydrate(27)
    .build();

 

- 불변 객체 생성

public class NutritionFacts {
    private final int servingSize;  // final 필드 - 불변성 보장
    private final int servings;
    // ... 다른 final 필드들
    
    // setter 메서드 없음 - 생성 후 변경 불가
}

 

- 유연한 객체 생성

// 필수값만 설정
NutritionFacts simple = new NutritionFacts.Builder(240, 1).build();

// 필요한 것만 선택적으로 설정
NutritionFacts partial = new NutritionFacts.Builder(240, 8)
    .calories(100)
    .sodium(35)
    .build();  // fat과 carbohydrate는 기본값 사용

 

- 매개변수 유효성 검사

public static class Builder {
    public Builder(int servingSize, int servings) {
        if (servingSize <= 0) {
            throw new IllegalArgumentException("servingSize는 양수여야 합니다: " + servingSize);
        }
        if (servings <= 0) {
            throw new IllegalArgumentException("servings는 양수여야 합니다: " + servings);
        }
        this.servingSize = servingSize;
        this.servings = servings;
    }
    
    public Builder calories(int val) {
        if (val < 0) {
            throw new IllegalArgumentException("calories는 음수가 될 수 없습니다: " + val);
        }
        calories = val;
        return this;
    }
    
    public NutritionFacts build() {
        // 최종 검증도 가능
        if (calories > 1000 && fat == 0) {
            throw new IllegalStateException("고칼로리 식품인데 지방이 0인 것은 이상합니다");
        }
        return new NutritionFacts(this);
    }
}

 

빌더 패턴의 단점

 

- 코드량 증가와 복잡성

// 간단한 클래스도 빌더 때문에 코드가 2-3배 길어짐
public class Person {
    private final String name;
    private final int age;
    
    // 생성자만 있다면 이것으로 끝
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
// 빌더 패턴 적용 시 - 코드가 훨씬 길어짐
public class Person {
    private final String name;
    private final int age;
    
    private Person(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
    }
    
    public static class Builder {
        private final String name;
        private int age;
        
        public Builder(String name) {
            this.name = name;
        }
        
        public Builder age(int age) {
            this.age = age;
            return this;
        }
        
        public Person build() {
            return new Person(this);
        }
    }
}

 

- 성능 오버헤드

// 일반 생성자: 객체 1개 생성
Person person = new Person("김철수", 25);

// 빌더 패턴: 객체 2개 생성 (Builder + Person)
Person person = new Person.Builder("김철수")
    .age(25)
    .build();

 

- 매개변수가 적을 때는 오히려 복잡

// 매개변수 2-3개일 때는 생성자가 더 간단하고 명확
Point point1 = new Point(10, 20);  // 간단하고 직관적

// 빌더는 오히려 과도함
Point point2 = new Point.Builder()
    .x(10)
    .y(20)
    .build();  // 너무 복잡

Effective Java에서

생성자나 정적 팩터리 메서드가 처리해야 할 매개변수가 많다면 빌더패턴을 선택하는게 낫다. 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다. 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.

Effective Java에서는 빌더패턴은 매개변수가 4개 이상은 되어야 값어치를 한다고 한다.