티스토리 뷰

목차

    배경

    최근 코드리뷰를 받는 중에 '빌더 패턴의 단점이 딱 드러나는 케이스인 것 같아요' 라는 의견을 받았다. 사실 Builder 패턴으로 객체를 생성해야하는 이유와 그 장점에 대해서는 다양한 블로그 포스트들과 예시 코드를 통해 자주 접해서 익히 잘 알고있었고 습관적으로 객체 생성시 필요할때 Builder 패턴을 따르고 있음에도 (부끄럽지만) 그 단점과 사용시 유의사항에 대해서는 평소 생각해보지 못했다. 이러한 이유를 계기로 이번 기회에 관련 내용을 스스로 정리하고 공유해두면 좋을 것 같아 글로써 정리해두었다.

    내용

    Builder 패턴으로 객체 생성시 유의사항에 대해 설명하기 전에, 먼저 일반적인 생성자를 통한 객체 생성setter를 통한 객체 생성과 비교해보며 Builder로 객체를 생성해야하는 이유와 그 장점을 짚고 넘어가면 좋을 것 같다.

    일반 생성자 방식과 수정자(Setter)를 통한 객체 생성과 Builder 패턴 비교

    먼저 다음과 같은 상품 정보를 나타내는 객체가 있다고 가정해보자면,
    (여기서 producetId는 상품의 ID, productName은 상품의 이름, category는 상품의 분류 정보, price는 상품의 가격정보, discount는 할인되는 금액이라고 가정하였다.)

    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
    }

     
    기본적인 생성자를 통한 객체 생성 방식은 다음과 같을 것이다.

    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
        
        // Product 생성자
        public Product(String productId, String productName, String category, long price, long discount){
            this.productId = productId;
            this.prodcutName = productName;
            this.category = category;
            this.price = price;
            this.discount = discount;
        }
    }
    
    // Product 생성자 호출
    Product product = new Product("1", "사과", "과일", 1200, 100);

     
     
    이러한 방식으로 객체를 생성할때, 각 매개변수들은 단순히 그 값만 드러나기 때문에 어떤 데이터를 의미하는지 쉽게 유추할 수 없다. 또한 만일 할인금액이 없는 상품이 존재한다면 우리는 discount에 dummy값을 넣어주거나 discount는 파라미터로 받지 않는 생성자를 추가로 구현해주어야할 것이다.

    // dummy 값을 넣어서 객체 생성
    Product product = new Product("2", "공책", "문구", 1500, 0);
    
    // 혹은 추가로 구현한 Product 생성자 호출
    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
        
        public Product(String productId, String productName, String category, long price, long discount){
            this.productId = productId;
            this.prodcutName = productName;
            this.category = category;
            this.price = price;
            this.discount = discount;
        }
        
        // 추가로 구현한 Product 생성자
        public Product(String productId, String productName, String category, long price){
            this.productId = productId;
            this.prodcutName = productName;
            this.category = category;
            this.price = price;
        }
    }
    
    Product product = new Product("2", "공책", "문구", 1500);

     
     
    discount라는 필드 하나가 없는 생성자가 필요하여 6줄짜리의 생성자를 추가로 구현했고, 이런 상황이 반복된다면 코드는 금방 복잡하고 길어질 것이다.
     
    혹은 정책이 바뀌어서 Product에 cost(원가) 필드를 추가하기로 했다면, Product 객체의 생성자를 일일이 돌아보며 cost 필드를 신규로 추가해줘야 한다. (얼마나 귀찮은 일인지 굳이 상상해볼 필요도 없을 것이다)

    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
        private long cost; // 신규로 추가된 cost 필드
        
        public Product(String productId, String productName, String category, long price, long discount, long cost){
            this.productId = productId;
            this.prodcutName = productName;
            this.category = category;
            this.price = price;
            this.discount = discount;
            this.cost = cost; // 신규로 추가한 cost 값 할당 로직
        }
        
        public Product(String productId, String productName, String category, long price, long cost){
            this.productId = productId;
            this.prodcutName = productName;
            this.category = category;
            this.price = price;
            this.cost = cost; // 신규로 추가한 cost 값 할당 로직
        }
    }

     
     
    위 예시까지만 봤을때는 추가 생성자가 필요하거나 필드수정이 요구될때, 다음과 같이 setter(수정자)를 통해서도 충분히 대처할 수 있을거란 생각이 들 것이다.

    @NoArgsConstructor
    @Setter
    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
    }
    
    public class UsingProductClass {
        Product product = new Product();
        
        product.setProductId("1");
        product.setProductName("사과");
        product.setCategory("과일");
        product.setPrice("1200");
        product.setDiscount("100");
        
        ... 후략   
    }

     
    하지만 setter를 통한 객체 수정은 객체의 불변성을 해친다는 큰 위험이 있다. 불변성을 잃어버린 객체는 다른 개발자(혹은 몇개월 뒤 내가 짠 코드를 다시 보게될 미래의 나)가 해당 객체를 사용하는 코드를 작업할때 프로그램의 동작을 쉽게 예측할 수 없고 예상치 못한 사이드 이펙트를 유발할 수 있다. 그리고 이는 결국 장애로 이어질 수 있다.
    (이와 같은 이유로 되도록이면 @Setter 어노테이션 사용을 지양하고, 수정이 꼭 필요한 필드에 대해서만 수정 메서드를 구현해서 필드의 변경 가능성을 열어주는 것이 좋다. 수정 메서드를 구현할때는 상품 이름을 수정하는 메서드라면 단순 setProductName()보다는 rename() 과 같이 도메인을 드러내는 이름으로 명명해준다면 더욱 좋을 것이다.)
     

    Builder 패턴의 장점

    Builder 패턴을 사용한다면 단순히 객체에 @Builder 어노테이션을 달아주고,

    객체명.Builder().필드명(필드 값).build();

    와 같은 패턴으로 간편하게 객체를 생성할 수 있다.
     
    이러한 방식으로 어떤 필드에 어떤 값이 들어가는지 구체적으로 명시할 수 있다. 그리고 위 예시와 같이 필드 수정이나 추가 생성자가 필요할때마다 매번 생성자를 추가 구현(혹은 수정)하는것이 아니라 단지 코드 한 줄 추가만으로 유연하게 대처할 수 있으며, 객체의 불변성 또한 확보할 수 있다. 

    @Builder
    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
    }
    
    public class UsingProductClass {
        Product apple = Product.Builder()
            .productId("1")
            .productName("사과")
            .category("과일")
            .price("1200")
            .discount("100")
            .build();
        
        // 할인 금액이 없는 Product 객체를 생성하는 경우
        Product noteBook = Product.Builder()
            .productId("2")
            .productName("공책")
            .category("문구")
            .price("1500")
            .build();
        
        // cost 필드가 추가되는 경우
        Product lamp = Product.Builder()
            .productId("3")
            .productName("탁상 조명")
            .category("일상")
            .price("32000")
            .cost("30000")
            .build();
    }

    Builder 패턴의 단점

    여기까지 살펴봤을때 객체 생성은 무조건 Builder가 가장 좋은 거아니야? 라는 생각이 스쳐지나갈 수 있다. 하지만 (언제나 그렇듯) 개발 세계에서 모든 상황에 완벽하게 틀어맞는 방법은 없다. 다음은 글을 작성하게된 이유인 Builder 패턴의 단점과 유의사항이다.
     
    첫번째 단점으로 Builder 패턴은 객체를 생성하는 의도가 명확히 드러나지 않는다. 예를 들어 다음과 같이 Builder를 사용해서 Product 객체를 생성할때, 다른 개발자가 볼때 product1과 product2를 생성한 이유를 유추할 수 없다.

    Product product1 = Obkect.builder().build();
    Product product2 = Obkect.builder().productId("1").build();

     
    이처럼 다른사람이 이해할 수 없는 객체 생성은 잘못된 혹은 무분별한 객체 생성을 초래할 수 있다.
     
    두번째 단점은 위에서 Builder 패턴의 장점으로 설명했던 부분인데, 단순한 패턴으로 객체를 생성할 수 있다는 간편함이다.
    앞서 설명했듯이 Builder 패턴은 단순히 필드명만 추가함으로써 객체 생성을 추가/변경할 수 있다. 하지만 업무를 이해한 다른 개발자가 잘못된 필드를 채우면서 객체를 생성한다면 프로그램의 흐름이 잘못될 수 있다. 그리고 이 역시 장애로 이어질 가능성이 있다.

    구체적인 예시

    다음과 같은 코드는 Builder 패턴의 단점을 명확히 드러낸다.
    첫번째 예시는 상품이름이 "사과" 인 상품을 조회하는 경우이고 두번째는 할인 상품을 조회하는 경우다. 각 예시에 대해 첫번째 조회는 내역이 없으면 "deafult" 상품을, 두번째는 아무것도 없는 빈 객체를 반환한다.

    // 상품 이름이 "사과" 상품 조회
    Product product = productRepository.findByProductName("사과")
      .orElse(Product.builder().productId("default").build());
    
    // 할인 상품 조회
    Product product2 = productRepository.findByDiscountGreaterThenZero()
      .orElse(Product.builder().build());

     
     
    orElse구문 내에 있는 두 객체 생성 로직은 객체 생성 의도가 명확하게 드러나지 않으며 단순히 필드명만 드러나기 때문에 다른 개발자가 잘못 이해한 채로 수정한다면 처음 의도와 다른 객체 생성이 발생할 수 있다.
     
    이런 상황을 개선하기 위해 Null Object Pattern을 응용해보면 좋을 것이다. Null Object Pattern의 주요 개념에 대해 간단하게 정리해보자면 다음과 같다.
     

    • 객체 생성 메서드를 추가하여 어떤 상태를 나타내기 위한 객체 생성인지 명확히 드러낸다.
    • 객체 생성 부분을 숨김으로써, 잘못된 필드를 추가할 가능성을 줄인다.

    개선 방안

    Product 클래스 내부에 위와같은 특정 케이스를 핸들링하는(특정 상황을 나타내는) 객체 생성자 메서드를 추가해주면 이와 같은 단점을 방지할 수 있을 것이다.

    @Builder
    public class Product {
        private String productId;
        private String productName;
        private String category;
        private long price;
        private long discount;
    
        public static Product notFound() {
    	    return Product.builder().productName("default").build();
    	}
    
    	public static Product noDiscount() {
    	    return Product.builder().build();
    	}
    }

     
    개선한 코드

    // 개선 코드 1 - 상품 이름이 "사과" 상품 조회
    Product product = productRepository.findByProductName("사과")
      .orElse(Product.notFound());
    
    // 개선 코드 2 - 할인 상품 조회
    Product product2 = productRepository.findByDiscountGreaterThenZero()
      .orElse(Product.noDiscount());

     
     

    결론

    객체 생성 방법을 간단하게 소개하고, 그 중 객체 생성시 발생할 수 있는 다양한 케이스를 유연하게 대처할 수 있고 간편하게 객체 생성을 할 수 있는 Builder 패턴에 대해 살펴보며 그의 장점과 사용시 유의사항에 대해 살펴보았다.
    평소 Builder 패턴으로 객체를 생성해야하는 이유와 그 장점을 알고 있다고 생각했고 무의식적으로 자주 사용하던 패턴임에도 어떤 유의사항이 있을지는 미처 생각해보지 못했는데 항상 어떤 장단점이 있는지, 지금 상황에 적절한지 고민해보며 사용하는게 중요한 것 같다. 내용을 정리해보며 다시 한 번 느꼈지만 사용하기 간편한 기술들은 그만큼 잘못 사용할 가능성을 염두에 두고 주의깊게 사용하는게 필요하다고 생각한다.

    Comments