빌더 패턴(Builder Pattern)

반응형

객체를 생성할 때 빌더 패턴을 추천하는 사람들이 많았습니다.

대략적으로는 알고 있지만 좀 더 자세히 그 이유까지 알고싶어서 정리를 해보게 되었습니다.


빌더 패턴 탄생 배경

우선은 탄생 배경을 알게 되면 기술에 대해서 좀 더 이해하기 쉽다고 생각합니다.


1) 점층적 생성자 패턴

말 그대로 매개변수를 받아서 생성자를 통해 객체를 생성하는 방법으로, 다양한 매개변수에 따라 생성자를 오버로딩해서 사용하는 방법입니다.

가장 간편하게 생각해낼 수 있는 방법입니다.

 

게임에서 우주선 유닛을 만든다고 생각해보겠습니다.

우주선 유닛의 이름과 유닛 코드는 필수적으로 넣고 나머지 필드들은 정해지면 입력할 예정이라고 가정하겠습니다.

이러한 상황에서 생성자 패턴으로 객체를 만들게 된다면 아래와 같은 코드가 필요할 것입니다.

public class SpaceShip {
    // 필수 매개변수
    private String name;
    private int code;

    // 선택 매개변수
    private int attack;
    private int speed;
    private int defense;

    public SpaceShip(String name, int code, int attack, int speed, int defense) {
        this.name = name;
        this.code = code;
        this.attack = attack;
        this.speed = speed;
        this.defense = defense;
    }

    public SpaceShip(String name, int code, int attack, int speed) {
        this.name = name;
        this.code = code;
        this.attack = attack;
        this.speed = speed;
    }

    public SpaceShip(String name, int code, int attack) {
        this.name = name;
        this.code = code;
        this.attack = attack;
    }

    ...

}

그리고 사용을 한다면 아래와 같이 사용하게 됩니다.

public class Main {
    public static void main(String[] args) {
        // 모든 요소가 결정된 우주선 유닛
        SpaceShip spaceShip1 = new SpaceShip("밀레니엄 팔콘",1,100,100,100);
        
        // 이름, 코드, 공격력까지만 결정된 우주선 유닛
        SpaceShip spaceShip2 = new SpaceShip("레이스",2,100);
        
        // 이름, 코드, 속도만 결정된 우주선 유닛
        SpaceShip spaceShip3 = new SpaceShip("발키리",3,0,70,0);
    }
}

지금 제가 단순히 예제를 만드는데도 몇 번째 인자가 어떤 필드인지가 헷갈릴 지경입니다.

 

또한 속도만 넣어서 만드는 생성자가 없기 때문에 굳이 다른 필드에 0을 넣어서 속도만 결정된 우주선 유닛을 생성할 수 밖에 없었습니다.

 

지금 고작 5개의 필드만으로도 이런 상황이 벌어지는데 조금만 더 많은 필드가 생겨도 그걸 감당하기가 어려울 것 같습니다.


2) 자바 빈(Java Beans) 패턴

위에서 나온 생성자 패턴의 단점을 보완하기 위해 나온 것이 Setter, Getter를 활용하는 자바 빈 패턴이라고 합니다.

매개변수가 없는 기본 생성자로 우선 객체를 생성하고 Setter를 통해 필드의 값을 넣어주는 방식입니다.

public class SpaceShip {
    // 필수 매개변수
    private String name;
    private int code;

    // 선택 매개변수
    private int attack;
    private int speed;
    private int defense;

    public void setName(String name) {
        this.name = name;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public void setAttack(int attack) {
        this.attack = attack;
    }

    public void setSpeed(int speed) {
        this.speed = speed;
    }

    public void setDefense(int defense) {
        this.defense = defense;
    }
}
public class Main {
    public static void main(String[] args) {
        // 모든 요소가 결정된 우주선 유닛
//        SpaceShip spaceShip1 = new SpaceShip("밀레니엄 팔콘",1,100,100,100);
        SpaceShip spaceShip1 = new SpaceShip();
        spaceShip1.setName("밀레니엄 팔콘");
        spaceShip1.setCode(1);
        spaceShip1.setAttack(100);
        spaceShip1.setSpeed(100);
        spaceShip1.setDefense(100);

        // 이름, 코드, 공격력까지만 결정된 우주선 유닛
//        SpaceShip spaceShip2 = new SpaceShip("레이스",2,100);
        SpaceShip spaceShip2 = new SpaceShip();
        spaceShip2.setName("레이스");
        spaceShip2.setCode(2);
        spaceShip2.setAttack(100);

        // 이름, 코드, 속도만 결정된 우주선 유닛
//        SpaceShip spaceShip3 = new SpaceShip("발키리",3,0,70,0);
        SpaceShip spaceShip3 = new SpaceShip();
        spaceShip3.setName("발키리");
        spaceShip3.setCode(3);
        spaceShip3.setSpeed(70);
    }
}

이렇게 자바 빈 패턴을 사용함으로서 몇 번째에 어떤 필드를 넣어야 하는지 헷갈리던 가독성 문제가 사라지고, 원하는 필드에 대해서만 setter를 선언함으로서 유연하게 객체를 생성하는게 가능해졌습니다.

 

하지만 여기서도 일관성(Consistency)문제와 불변성(immutable) 문제가 발생하게 됩니다.

이 2가지 문제로 인해 자바 빈 패턴도 지양하게 되었습니다.

일관성 문제
객체가 초기화될 때 반드시 설정되어야 하는 필수 매개변수에 대해서 개발자의 실수록 setter를 호출하지 않았다면, 반드시 가지고 있어야 할 필드를 가지고 있지 못한 '일관성이 무너진' 객체가 생성되게 됩니다. 즉 유효하지 않은 객체가 생성되는 것입니다.

불변성 문제
setter 메서드를 통해 생성된 객체에 필드값을 넣어줄 수 있게 되었습니다. 하지만 이 setter를 통해 누가 어디서 객체의 필드값을 조작할지 모르게 되었습니다. 이렇게 필드값이 언제든 변경될 수 있게 된 상태를 불변성을 보장할 수 없는 상태, 즉 불변성 문제가 나타났다고 합니다.

3) 빌더(Builder) 패턴

위의 문제들을 해결하기 위해 빌더 패턴이 나오게 되었습니다.

 

빌더 패턴은 별도의 Builder 클래스를 만들어 그 안에 메서드를 통해 단계적으로 값을 입력 받은 후에 마지막에 build() 메서드로 객체를 생성하여 리턴해주는 방식입니다.

public class Main {
    public static void main(String[] args) {
        // 모든 요소가 결정된 우주선 유닛
        // 생성자 방식
//        SpaceShip spaceShip1 = new SpaceShip("밀레니엄 팔콘",1,100,100,100);

        // 자바 빈 방식
//        SpaceShip spaceShip1 = new SpaceShip();
//        spaceShip1.setName("밀레니엄 팔콘");
//        spaceShip1.setCode(1);
//        spaceShip1.setAttack(100);
//        spaceShip1.setSpeed(100);
//        spaceShip1.setDefense(100);

        SpaceShip spaceShip1 = new SpaceShip().Builder("밀레니엄 팔콘", 1)
                .attack(100)
                .speed(100)
                .defense(100)
                .build();
    }

이런식으로 빌더 패턴은 각각의 메서드를 체이닝(Chaining) 형태로 호출함으로서 인스턴스를 구성하고 마지막에 build() 메서드를 통해 객체를 생성합니다.

 

이제는 생성자를 오버로딩하여 사용하지 않아도 되고, 데이터의 순서에 상관없이 필드값을 넣어 객체를 생성할 수도 있으며, 잘못된 값을 넣을 확률도 현저히 낮아졌습니다.


빌더 패턴 구현

저는 지금까지 단순하게 Lombok을 통해서 빌더 패턴을 사용해왔는데, 이번 기회에 직접 구현하는 법을 정리해보기로 했습니다.

 


아래의 SpaceShip 클래스에 대해 객체 생성을 해주는 빌더 클래스를 만들어보겠습니다.

public class SpaceShip {
    // 필수 매개변수
    private String name;
    private int code;

    // 선택 매개변수
    private int attack;
    private int speed;
    private int defense;
    
    public SpaceShip(String name, int code, int attack, int speed, int defense) {
        this.name = name;
        this.code = code;
        this.attack = attack;
        this.speed = speed;
        this.defense = defense;
    }
}

이 SpaceShip 클래스에 대해 빌더 패턴을 구현하려면 SpaecShipBuilder라는 빌더 클래스를 만들어야 합니다.

(Lombok만 써왔던 저는 이것조차 처음 알게 된 사실입니다....)

public class SpaceShipBuilder {
    private String name;
    private int code;
    private int attack;
    private int speed;
    private int defense;

    public SpaceShipBuilder name(String name){
        this.name = name;
        return this;
    }

    public SpaceShipBuilder code(int code){
        this.code = code;
        return this;
    }

    public SpaceShipBuilder attack(int attack){
        this.attack = attack;
        return this;
    }

    public SpaceShipBuilder speed(int speed){
        this.speed = speed;
        return this;
    }

    public SpaceShipBuilder defense(int defense){
        this.defense = defense;
        return this;
    }

    public SpaceShip build(){
        return new SpaceShip(name, code, attack, speed, defense);
    }
}

이 빌더 클래스를 만들 때 유의해서 봐야할 점이 몇 가지 있습니다.

  1. 각 멤버에 대한 Setter를 구현할 때, 자바 빈 패턴에서의 Setter와 헷갈리지 않게 set 단어는 빼고 멤버 이름만으로 메서드 명을 작성합니다.
  2. 각 Setter 함수 마지막 반환 구문을 return this로 합니다. 여기서 this는 SpaceShipBuilder 객체 자신을 의미하는데, 이렇게 하는 이유는 빌더 객체 자신을 리턴함으로서 메서드 호출 후 연속적으로 메서드들을 체이닝하여 호출하기 위함입니다.
  3. 빌드하려고 했던 SpaceShip 객체를 만들기 위한 마지막 build 메서드에서는 지금까지 입력받은 필드들을 SpaceShip 생성자의 인자로 넣어줌으로서 완성된 객체를 리턴해줍니다.
public class Main {
    public static void main(String[] args) {
    SpaceShip spaceShip1 = new SpaceShipBuilder()
            .name("밀레니엄 팔콘")
            .code(1)
            .attack(100)
            .speed(100)
            .defense(100)
            .build();
    }
}

 


빌더 패턴의 장단점

어떤 기술이 왜 많이 사용되는지 이유를 알기 위해서는 그 기술의 장단점을 정확하게 알아야합니다.


빌더 패턴의 장점

1) 코드가 간단해지고 유연성이 확보된다

처음에 얘기했던것처럼 생성자 패턴에서 자바 빈 패턴 그리고 빌드 패턴이 되면서 점점 코드가 간소화되고 있습니다.  

 

또한 추가적인 멤버 변수가 생겼을 때 생성자는 그에 따른 생성자를 다시 만들어주어야하고, 경우에 따라서는 이미 있는 생성자를 또 모두 수정해야하는 상황이 발생할 수 있습니다.

그렇게 되면 객체를 생성하고 있는 실제 코드도 같이 변경이 되어야겠죠...

상상만해도 너무 번거롭습니다.

 

그에 반해 빌더 패턴은 그 추가적인 멤버 변수에 대한 메서드만 생성해주고 필요한 부분에 대해서만 그 메서드를 통해 추가적인 멤버 변수를 넣어주면 됩니다.

이로써 객체 생성 및 수정에 대해서 유연성이 확보되게 됩니다.


2) 가독성이 향상된다

위에서도 나왔듯이 생성자 방식으로 객체를 생성하게되면 매개변수가 많아졌을 때, 가독성이 급격하게 떨어지게 됩니다.

변수가 5개만 되어도 몇번째 변수가 어떤 변수였는지 생각해서 값을 넣기가 까다로워집니다.

        // 생성자 방식
        SpaceShip spaceShip1 = new SpaceShip("밀레니엄 팔콘",1,100,100,100);

        // 자바 빈 방식
        SpaceShip spaceShip1 = new SpaceShip();
        spaceShip1.setName("밀레니엄 팔콘");
        spaceShip1.setCode(1);
        spaceShip1.setAttack(100);
        spaceShip1.setSpeed(100);
        spaceShip1.setDefense(100);

	// 빌더 방식
        SpaceShip spaceShip1 = new SpaceShip().Builder("밀레니엄 팔콘", 1)
                .attack(100)
                .speed(100)
                .defense(100)
                .build();

하지만 위에처럼 빌더 방식을 사용하게 되면 메서드의 이름에 따라 어떤 값이 설정되는지 직관적으로 볼 수 있기 때문에 가독성이 향상되게 됩니다.


3) 디폴트 매개변수 기능이 간접적으로 지원된다

디폴트 매개변수는 인자 값을 설정해도 되고 생략해도 되는 매개변수를 말합니다.

# 출처: https://wikidocs.net/85321
# 파이썬 디폴트 매개변수

def print_hello(to1, to2='analysis'): 
    print('hello', to1, to2)

print_hello('data') #hello data analysis
print_hello('data', 'info') #hello data info

위에 코드처럼 파이썬이나 자바스크립트는 디폴트 매개변수를 지원해주지만 자바에서는 지원해주지 않습니다.

생성자를 통해 집어넣어줘도 되지만, 그러면 미리 디폴트 값을 넣고 싶은 필드 변수에 초기값을 넣고, 나머지 필드를 세팅하는 생성자를 따로 만들어야합니다.

결국 생성자를 사용할 때의 단점이 그대로 다시 나오게 되는 것입니다.

public class SpaceShip {
    // 필수 매개변수
    private String name = "밀레니엄 팔콘";    // 디폴트 매개변수 설정
    private int code;

    // 선택 매개변수
    private int attack;
    private int speed;
    private int defense;
    
    public SpaceShip(String name, int code, int attack, int speed, int defense) {
        this.name = name;
        this.code = code;
        this.attack = attack;
        this.speed = speed;
        this.defense = defense;
    }

    // 디폴트 매개변수를 제외한 생성자
    public SpaceShip(int code, int attack, int speed, int defense) {
        this.code = code;
        this.attack = attack;
        this.speed = speed;
        this.defense = defense;
    }
}

빌더 패턴에서도 디폴트 매개변수를 구현하는 방법은 같습니다.

하지만 빌더라는 객체 생성 클래스를 통해서 구현하여, 디폴트 매개변수가 설정된 필드를 설정하는 메서드를 호출하지 않는 방식으로 객체를 생성하여 언뜻 보기에는 디폴트 매개변수를 생략하고 생성하는 것처럼 구현할 수 있게 됩니다.

public class SpaceShipBuilder {
    private String name;
    private int code;
    private int attack = 100; // 디폴트 매개변수처럼 미리 설정
    private int speed;
    private int defense;
    
    ...
}
SpaceShip spaceShip1 = new SpaceShipBuilder()
                .name("밀레니엄 팔콘")
                .code(1)
                .speed(100)
                .defense(100)
                .build();


4) 필수 멤버를 반드시 입력하게 만들 수 있다

객체를 생성할 때 반드시 필요한 멤버 변수가 있을 때가 있습니다.

이럴 때 생성자를 통해서 구현하면 2가지 방법을 사용할 수 있습니다.

  1. 초기화가 필수인 멤버 변수만을 위한 생성자를 정의하고 나머지 멤버 변수들은 오버로딩을 통해 경우의 수에 따라 생성자를 만든다
  2. 멤버 전체를 인자로 받는 생성자를 정의하고 필수가 아닌 멤버 변수들의 자리에는 null을 받는다.

2번째 방법은 null을 넣어야 하는 것부터 이미 좋은 방법은 아닌거 같습니다.

 

또한 1번째 방법은 경우의 수가 상당히 많아질 우려가 있습니다.

예를 들어 우주선의 code가 필수 멤버고 나머지는 선택적이라고 가정해보겠습니다.

그러면 나오게 되는 생성자의 경우의 수는

(code, name, attack, speed, defense)

(code, name, attack, speed)

(code, name, attack, defense)

...

상당히 많아짐을 알 수 있죠.

 

이럴 때 빌더 패턴을 이용하면 초기화가 필수인 멤버는 빌더의 생성자로 받게해서 필수 멤버를 설정해주어야만 빌더 객체가 생성되도록 유도하고, 나머지 멤버는 메서드로 받아서 객체 생성을 하면 됩니다.

public class SpaceShipBuilder {
    private String name;
    private int code;   // 초기화 필수 멤버 변수
    private int attack;
    private int speed;
    private int defense;

    // 필수 멤버는 빌더의 생성자를 통해 설정
    public SpaceShipBuilder(int code) {
        this.code = code;
    }

    // 선택적 멤버들은 메서드로 설정
    public SpaceShipBuilder name(String name){
        this.name = name;
        return this;
    }

    public SpaceShipBuilder attack(int attack){
        this.attack = attack;
        return this;
    }

    public SpaceShipBuilder speed(int speed){
        this.speed = speed;
        return this;
    }

    public SpaceShipBuilder defense(int defense){
        this.defense = defense;
        return this;
    }

    public SpaceShip build(){
        return new SpaceShip(name, attack, speed, defense);
    }
}

5) 초기화 검증 코드를 각 멤버마다 분리해서 작성할 수 있다

생성자로 초기화 검증 코드를 넣으려면, 각 생성자 매개변수에 따라 해당하는 검증 코드를 모두 넣어주어야 합니다.

    public SpaceShip(String name, int code, int attack, int speed, int defense){
        // naem, code, attack, speed, defense 검증코드
    }
    
    public SpaceShip(int code, int attack, int speed, int defense){
        // code, attack, speed, defense 검증코드
    }

    public SpaceShip(int attack, int speed, int defense){
        // attack, speed, defense 검증코드
        
   ...     
   
    }

하지만 빌더를 통해 검증을 하게되면 각 멤버 변수 설정 메서드마다 해당하는 검증 코드를 한번씩만 넣어주면 됩니다.

이렇게 검증 과정이 나눠어져있는 것이 나중에 유지 보수에도 훨씬 용이합니다.

    public SpaceShipBuilder(int code) {
        // code 검증코드
        this.code = code;
        return this;
    }
    
    public SpaceShipBuilder name(String name){
        // name 검증코드
        this.name = name;
        return this;
    }

    public SpaceShipBuilder attack(int attack){
        // attack 검증코드
        this.attack = attack;
        return this;
    }

    public SpaceShipBuilder speed(int speed){
        // speed 검증코드
        this.speed = speed;
        return this;
    }

    public SpaceShipBuilder defense(int defense){
        // defense 검증코드
        this.defense = defense;
        return this;
    }

6) 멤버에 대한 변경 가능성을 최소화한다.

위에서 빌더의 여러가지 장점들을 보면서 '이거는 자바 빈 패턴의 setter를 사용해도 충분히 가능하지 않나?'라는 의문이 생기는 것들이 있었을겁니다.

물론 그런것들이 있는 것은 사실이지만 바로 이 특징으로 인해 자바 빈 패턴도 지양되어야 합니다.

 

협업을 진행하는데 있어서 가장 중요한 점 중에 하나는 바로 불변 객체입니다.

불변 객체는 생성 이후에는 상태가 변하지 않는 객체이며, 읽기(get) 메서드만 제공하고 쓰기(set)는 애초에 제공하지 않습니다.

자바 키워드로는 final을 붙인 변수를 불변 객체라고 할 수 있죠.

 

불변 객체를 이용해야하는 이유로는 크게 3가지가 있습니다.

  1. 불변 객체는 Thread-Safe하여 동기화를 고려하지 않아도 된다. (동기화 이슈는 매우 복잡합니다...)
  2. 객체에 다른 값이 쓰여지는 중간에 어디선가 예외가 발생하면 그 객체안에 어떤 값이 들어있을지 예측이 불가능해진다.
  3. 불변 객체라는 것을 알면 내가 잘 모르는 메서드를 이용하더라도 객체의 값을 변하지 않은 것임을 알기 때문에 좀 더 안전하게 코드를 짜는 것이 가능하다.

이러한 이유들로 인해 불변 객체를 반드시 이용하고, 정말 불가피한 상황에는 변경 가능성을 최소화해야합니다.

final 키워드를 붙일 수 없는 상황이라면 적어도 setter 메서드를 구현하지 않는 식으로 진행해야 합니다.

 

결국 그러면 자바 빈 패턴의 setter가 아닌 생성자를 이용해야하는데, 그럼 위에서 얘기한 단점들이 또 다시 나오게 됩니다.

그렇게 최종적으로 빌더 패턴이 나오게 된 것입니다.

즉, 빌더 패턴은 생성자 없이 객체에 대한 변경 가능성을 최소화하기 위한 패턴입니다.

빌더 패턴의 단점

그렇다면 빌더 패턴의 단점은 무엇이 있을까요

1) 코드 복잡성이 증가한다

사실 생성자를 사용하면, 복잡하게 생각하지 않는다는 가정하에 하나의 생성자로 모든 멤버 변수를 정의하여 객체를 생성할 수 있습니다.

하지만 빌더 패턴은 결국 각 멤버 변수에 대한 메서드를 작성해주어야하기 때문에, 코드 복잡성이 증가하게 됩니다.

하지만 이런 복잡성의 증가는 대다수의 디자인 패턴이 가지는 단점입니다.


2) 생성자보다 성능이 떨어진다

결국 모든 멤버 변수에 대해 메서드를 하나씩 호출해주어야하기 때문에 프로그램의 성능은 떨어지게 됩니다.

성능을 가장 우선시하는 프로그램에는 적절하지 않은 방법일 수도 있습니다.


3) 빌더를 지나치게 남용해서는 안된다

객체의 멤버가 4개보다 적고, 변경 가능성이 별로 없다면 차라리 생성자난 정적 팩토리 메서드를 이용하는 것이 좋을 수 있습니다.

위에서 얘기했듯이 빌더 패턴은 코드 복잡성을 증가시키고 성능도 떨어트릴 수 있기 때문입니다.


롬복을 통한 빌더 패턴 사용

위에서 본것처럼 빌더 패턴을 작성하는 것은 생각보다 많은 코드가 필요합니다.

그래서 이것을 편하게 하기 위해 Lombok의 @Builder 어노테이션을 사용할 수 있습니다.

@Builder    // 빌더 패턴을 생성해준다
@AllArgsConstructor // 모든 멤버 변수를 인자로 갖는 생성자를 생성해준다
public class SpaceShip {
    // 필수 매개변수
    private String name;    // 디폴트 매개변수 설정
    private int code;
    private int attack;
    private int speed;
    private int defense;
}

하지만 단순하게 위에 처럼만 작성하면, 필수 멤버 변수 지정이나, 검증 로직 추가 등을 할 수가 없습니다.

@Builder    // 빌더 패턴을 생성해준다
@AllArgsConstructor // 모든 멤버 변수를 인자로 갖는 생성자를 생성해준다
public class SpaceShip {
    // 필수 매개변수
    private final String name;    // 디폴트 매개변수 설정
    private final int code;
    private final int attack;
    private final int speed;
    private final int defense;

    public static SpaceShipBuilder builder(String name, String code){
        // 멤버 변수 검증
        if(name == null || code == null){
            throw new IllegalArgumentException("필수 멤버 변수가 없습니다");
        }
        return new SpaceShipBuilder().name(name).code(code);
    }
    public static void main(String[] args) {
        SpaceShip spaceShip1 = SpaceShip.builder("밀레니엄 팔콘","1234")  // 필수 멤버변수
                .attack(100)
                .speed(100)
                .defense(100)
                .build();

위와 같이 하여 필수 멤버 변수 지정과, 검증 로직 추가를 모두 할 수 있습니다.


참고링크1

참고링크2 이 링크가 정말 많은 도움이 되었습니다. 거의 이걸 보고 공부한걸 제 나름의 코드를 다시 만들어보면서 복습한 글 입니다.

반응형

'방구석 컴퓨터 > 방구석 자바' 카테고리의 다른 글

equals와 hashCode  (0) 2024.11.14
제네릭(Generic)  (0) 2023.09.08
StringUtils  (0) 2023.08.17
메이븐(Maven)  (0) 2023.08.16
자바 빌드 툴  (0) 2023.08.13
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유