디자인 패턴 : 빌더 & 싱글톤 패턴 구현해보기 (with. 이펙티브 자바)

Spring Boot로 개발하다 보면 @Builder, @Service같은 어노테이션을 너무나 자연스럽게 사용하게 됩니다. 하지만 문득 이런 생각이 들었습니다.

Builder 사용 사진

"만약 Lombok이나 Spring이 없다면 어떻게 구현해야 할까?"

 

그래서 이번에는 빌더 패턴싱글톤 패턴을 직접 구현하면서 이전까지 이론적으로만 학습해왔던 디자인 패턴들에 대해 자세하게 공부해 보며 직접 적용할 때는 어떤 점을 주의해야하는지 학습해 보았습니다.

 

1. 빌더 패턴

 

왜 필요할까?

객체를 생성할 때 이런 코드를 본 적 있으신가요?

Pizza pizza = new Pizza(12, "thin", "mozzarella", "tomato", "olive", "pepperoni", true, false);

Pizza 사진

 

매개변수가 8개나 되는데, 순서도 헷갈리고 어떤 값이 무엇을 의미하는지 전혀 알 수 없습니다. (물론 인텔리제이 IDE가 친절하게 매개변수 명을 보여주긴 합니다.) 게다가 일부 매개변수는 선택적이어서 필요 없는 값까지 null이나 false로 채워야 하는 상황이 발생합니다.

 

빌더 패턴은 바로 이런 문제를 해결합니다.

Pizza pizza = Pizza.builder()
    .size(12)
    .crust("thin")
    .cheese("mozzarella")
    .build();

 

훨씬 읽기 쉽고, 필요한 옵션만 선택할 수 있습니다. 이것이 바로 클라이언트 코드의 가독성을 보호하는 빌더 패턴의 핵심입니다.

 

직접 구현해보기

항상 빌더 패턴을 Lombok을 사용해 작성해왔기 때문에 어떤점을 고려해서 작성해야하는지 이펙티브 자바의 item 2을 참고했습니다.

 

 

`1. 정적 멤버 클래스로 구현하기`

 

처음에는 "왜 굳이 static을 붙여야 하지?"라는 의문이 들었습니다. 하지만 일반 내부 클래스로 구현하면 아래와 같은 상황이 발생합니다

// 일반 내부 클래스의 문제점
PizzaV2 pizza = new PizzaV2(12, "thin"); // 먼저 Pizza를 생성해야 함
PizzaV2.Builder builder = pizza.new Builder(12, "thin"); // Builder를 사용하기 위해 Pizza가 필요한 상황

 

Pizza를 만들려고 Builder를 쓰는데, Builder를 쓰려면 Pizza가 필요한 순환 논리에 빠지게 됩니다.

 

이를 정적 멤버 클래스를 사용하면 외부 클래스의 인스턴스 없이도 Builder에 접근할 수 있고 이를 통해 아래와 같이 Pizza를 생성할 수 있습니다.

Builder 사용 사진

 

`2. 필수 필드와 선택 필드 구분하기`

 

직접 Builder를 구현하게 되는 경우 해당 클래스에서 필수적으로 전달받아야하는 필드가 있고 선택사항이 되는 필드가 존재합니다. 아래는 제가 직접 실습을 진행할 때 작성해보았던 간단한 예제 클래스입니다.

public class Pizza {
    private final int size;
    private final String dough;
    private final boolean cheese;
    private final boolean peperoni;
    private final boolean mushroom;
    private final boolean onion;
    private final boolean olive;

    private Pizza(int size, String dough, boolean cheese, boolean peperoni, boolean mushroom, boolean onion, boolean olive) {
        this.size = size;
        this.dough = dough;
        this.cheese = cheese;
        this.peperoni = peperoni;
        this.mushroom = mushroom;
        this.onion = onion;
        this.olive = olive;
    }

    @Override
    public String toString() {
        return "practice_builder.Pizza{" +
                "size=" + size +
                ", dough='" + dough + '\'' +
                ", cheese=" + cheese +
                ", peperoni=" + peperoni +
                ", mushroom=" + mushroom +
                ", onion=" + onion +
                ", olive=" + olive +
                '}';
    }

    public static class Builder{
        private final int size;
        private final String dough;
        private boolean cheese = false;
        private boolean peperoni = false;
        private boolean mushroom = false;
        private boolean onion = false;
        private boolean olive = false;

        public Builder(int size, String dough) {
            this.size = size;
            this.dough = dough;
        }

        public Builder cheese() {
            this.cheese = true;
            return this;
        }

        public Builder peperoni() {
            this.peperoni = true;
            return this;
        }

        public Builder mushroom() {
            this.mushroom = true;
            return this;
        }

        public Builder onion() {
            this.onion = true;
            return this;
        }

        public Builder olive() {
            this.olive = true;
            return this;
        }

        public Pizza build() {
            return new Pizza(size, dough, cheese, peperoni, mushroom, onion, olive);
        }
    }
}

 

필수 필드의 경우 Builder의 생성자에서 매개변수로 받아 final로 선언하며, 선택필드의 경우 메서드 체이닝으로 기본값을 수정하는 방식으로 진행합니다. 이를 통해 실제 Client에서는 메서드 체이닝을 통해 가독성있게 Pizza 객체를 생성해 사용할 수 있습니다.

 

느낀점

직접 구현해보니... 빌더 패턴의 경우 보일러 플레이트 코드의 끝판왕이라고 느꼈습니다. Lombok의 `@Builder`가 얼마나 고마운 존재인지 깨달을 수 있는 시간이었습니다. 또, 이렇게 내부 구조를 이해하고 나니 빌더 패턴을 언제 사용해야 하는지, 어떤 방식으로 설계해야 하는지 명확히 알게 되었습니다.

Lombok 최고

 

2. 싱글턴 패턴

 

왜 필요할까?

Spring Boot를 사용하다 보면 Service나 Repository를 주입받아 사용합니다. 이때 Spring은 기본적으로 싱글턴 방식으로 빈(Bean)을 관리합니다. 이때, 만약 매번 새로운 객체를 생성한다면?

// 비효율적인 방식
@GetMapping("/users")
public List<User> getUsers() {
    UserService userService = new UserService(); // 매번 생성
    return userService.findAll();
}

인스턴스 N개 생성

 

API 호출마다 새로운 Service 객체를 생성하고, Service는 또 새로운 Repository를 생성하고... 자원 낭비가 발생합니다. 각각의 Service들은 모두 동일한 로직을 갖고 있어 이렇게 매번 새로 생성해서 사용하는 것은 아무리 봐도 비효율적이라고 생각합니다.

 

싱글턴 패턴은 애플리케이션 전체에서 단 하나의 인스턴스만 생성하여 자원을 효율적으로 관리합니다. (자원을 보호해주는 디자인 패턴)

 

직접 구현해보기

싱글톤의 경우에도 이펙티브 자바의 item 3를 참고해 구현을 진행해보았습니다. 

 

`1.public static final 방식`

 첫 번째 방식은 매우 간단한 방식입니다. 그냥 private으로 생성자를 막고 `Ingredient.INSTANCE`를 이용해 사용하는 방법입니다.

public class Ingredient {
    public static final Ingredient INSTANCE = new Ingredient();
    
    private Ingredient() {
        // private 생성자로 외부 생성 차단
    }
}

// 사용
Ingredient ingredient = Ingredient.INSTANCE;

매우 간단하고 실제 API를 통해 싱글턴임을 명확히 드러낼 수 있다는 장점이 있습니다. 하지만, 기획의 변경에 따라 더 이상 싱글턴을 유지하지 않아도 되는 상황이 발생하면 Ingredient 코드 뿐만 아니라 `Ingredient.INSTANCE`를 사용하는 클라이언트 코드도 변경해야 한다는 단점이 존재합니다.

아래 처럼 여러 클래스에서 싱글턴 인스턴스를 사용하고 있는 상황이었다면, 해당 클래스 모두 코드 수정을 해야한다는 단점이 존재합니다.

변경에 취약

 

`2. 정적 팩토리 메서드 방식`

두 번째 방식은 인스턴스 필드를 private으로 감추고 정적 팩토리 메서드로 해당 인스턴스에 대한 접근 API를 제공해주는 방식입니다.

public class IngredientV2 {
    private static final IngredientV2 INSTANCE = new IngredientV2();
    
    private IngredientV2() {}
    
    public static IngredientV2 getInstance() {
        return INSTANCE;
    }
}

// 사용
IngredientV2 ingredient = IngredientV2.getInstance();

 

이 방식의 장점은 API 변경 없이 싱글턴이 아니게 변경 가능하다는 점입니다. 필드를 그대로 가져다 사용하는 경우와 비교해보면 아래와 같이 코드를 작성해 정적 메서드를 가져다 사용하는 다른 클래스 코드에는 영향을 주지않고 변경 가능합니다.

변경에 유연

 

이 장점 이외에도 제네릭 싱글턴 팩터리, Supplier로 사용 가능하다는데... 저는 아직 한번도 이렇게 사용해 본적이 없어 잘 와닿지 않았습니다. 그래서 일단은 다른 객체와 결합도를 낮출 수 있다는 장점만 챙겨가려고 합니다.

 

`3. Enum 방식`

마지막 방법은 Enum을 사용하는 방식입니다.

public enum IngredientV3 {
    INSTANCE;
    
    public void doSomething() {
        System.out.println("Doing something...");
    }
}

// 사용
IngredientV3.INSTANCE.doSomething();

 이펙티브 자바에서도 Enum을 이용해 싱글턴을 구현하는 것을 적극 추천하는데 그 이유는 다음과 같습니다.

  • 가장 간결하고 추가 코드 없이 싱글톤이 보장됩니다.
  • 직렬화 자동처리로 직렬화 -> 역직렬화 이후에도 싱글톤이 보장됩니다.

한가지 단점이 존재하는데, 바로 상속이 필요한 경우 사용할 수 없다는 점입니다. 하지만 엄청 간단하고 역직렬화시에도 싱글턴을 자동으로 보장해준다는 점에서 확실히 매력적인 방법이라고 생각이 들었습니다.

 

직렬화와 싱글턴

그럼 1,2번 방식은 직렬화 시에 어떻게 동작할까요?

객체를 직렬화했다가 역직렬화를 진행한다면 역직렬화를 진행하는 실제 동작에서는 이 객체가 싱글턴으로 사용되고 있다는 사실을 모릅니다.

따라서, 아래와 같이 코드를 작성해 비교하면 싱글턴이 깨지는 상황을 확인해 볼 수 있습니다.

Elvis elvis1 = Elvis.getInstance()
System.out.println("elvis1: " + elvis1);
        
// 직렬화 (객체를 바이트로 변환)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(elvis1);
oos.close();

byte[] serializedData = baos.toByteArray();


// 역직렬화 (바이트를 객체로 복원)
ByteArrayInputStream bais = new ByteArrayInputStream(serializedData);
ObjectInputStream ois = new ObjectInputStream(bais);
Elvis elvis2 = (Elvis) ois.readObject();
ois.close();


System.out.println("같은 인스턴스? " + (elvis1 == elvis2));  // false!

역직렬화 문제

 

이를 해결하는 방법은 readResolve 메서드를 구현하는 것입니다.

public class Ingredient implements Serializable {
    public static final Ingredient INSTANCE = new Ingredient();
    
    private Ingredient() {}
    
    // 역직렬화 시 호출되어 싱글톤 보장
    private Object readResolve() {
        return INSTANCE;
    }
}

 

그럼, 역직렬화의 동작과정중 아래와 같이 메서드를 불러와 실행시키는 부분이 존재하고 이를  통해 싱글턴 객체를 보장할 수 있게됩니다.

Method readResolve = Elvis.class.getDeclaredMethod("readResolve");
if (readResolve != null) {
    readResolve.setAccessible(true);

    // readResolve() 호출
    Object result = readResolve.invoke(tempInstance);  // INSTANCE 반환

    return (Elvis) result;
}

 

싱글턴 정리

싱글턴의 경우 직접 구현할 일이 있다면 최우선적으로 Enum을 고려하고, 만약 해당 클래스를 상속해야하는 일이 생긴다면 public 필드나 정적 팩토리 메서드를 사용해야겠다. 결합도를 생각한다면 코드 몇줄만 추가하면 되는 정적 팩토리 메서드를 아마 선택하는게 현명하지 않을까?

그리고 1번과 2번 방법을 사용하기로 결정하였다면 역직렬화시 발생할 수 있는 싱글턴이 깨지는 상황을 고려해 readResolve() 메서드를 작성해 놓아야 겠다.

 

 

3. 마치며..

Enum 싱글턴은 아직 실제로 사용해본 적이 없어 조금 낯설게 느껴지긴 했는데, 나중에 직접 사용할 일이 생긴다면 무조건 Enum을 이용해 싱글턴 패턴을 사용해봐야겠다.

매번 개념 학습에만 초점을 두었던 디자인 패턴 공부였고, 이번에 처음으로 직접 구현하며 주의해야할 문제점들을 다루어보았는데, 생각보다 유익한 시간이었습니다. 다른 디자인 패턴들도 한번 이렇게 직접 구현해보며 학습하고, 최종적으로는 진행하는 프로젝트에 적용해볼 수 있으면 좋을 것 같습니다.