본문 바로가기
JAVA/Effective Java

Item2. 빌더는 생성자에 매개변수가 많을 때 고려하라

by 민휘 2023. 5. 14.

이 문서가 다루는 내용

  • 빌더 패턴의 사용법과 장점
  • 정적 팩토리 메소드와 빌더의 비교
  • 정적 내부 클래스를 사용한 빌더
  • 인터페이스를 두어 유연하게 만든 빌더
  • 내부 클래스의 메모리 누수
  • 빌더 패턴 적용이 유효한 경우
  • 스프링부트 자동구성에서 빌더 찾기 : Builder, Consumer, Configurer
  • Builder 사용 예시 : TaskExecutionAutoConfiguration
  • Builder, Consumer 사용 예시 : JacksonObjectMapperConfiguration
  • Builder, Configurer 사용 예시 : RestTemplateAutoConfiguration

 

 

Builder Pattern

 

빌더 객체는 정적 팩토리 메소드와 마찬가지로 인스턴스를 얻는 방법을 추상화하는 책임을 가진다. 정적 팩토리 메소드는 인스턴스를 얻는데 필요한 매개변수가 많아지면 함수 시그니처가 지저분해져서 가독성이 떨어진다.

 

보통 메소드에서 받는 매개변수가 많거나 여러 메소드에서 많이 사용되는 변수는 인스턴스 필드로 분리하는 것이 일반적이다. (이때 필드의 유효 범위를 객체로 확대해도 되는지 검토가 필요하다) 이런 방법을 적용하여 빌더 객체는 인스턴스 반환에 필요한 여러 정보를 필드로 가지는 자료구조이면서 동시에 해당 필드를 사용해 인스턴스를 초기화해 반환하도록 만든다. 가독성도 챙기면서 안정적으로 객체를 만들 수 있다.

 

빌더 패턴은 매개변수 초기화가 복잡해지면 도입을 고려해볼 수 있는 패턴이다. 매개변수 초기화가 복잡해지면 Product 객체 안에서 매개변수 초기화에 필요한 코드가 점점 많아진다. 그러면 Product 객체의 오퍼레이션과 객체의 생성 책임이 섞이게 되므로 클래스의 응집도가 떨어진다. 빌더 패턴은 객체의 행동으로부터 매개변수와 관련된 생성 책임을 별도의 객체로 분리해서 Product의 응집도를 높이는 방법이다.

 

정리하자면 빌더 객체는 다음과 같은 책임을 갖는다.

  • 객체 초기화에 필요한 정보를 필드로 알고 있을 책임
  • 해당 필드를 사용하여 객체를 초기화하고 반환하는 책임

 

빌더 객체를 사용하면 정적 팩토리 메소드와 마찬가지로 인스턴스를 반환하는 방법을 구현할 수 있다. 예를 들어 다음과 같은 추가 기능을 수행할 수 있다. (대부분 매개변수 관련)

  • 매개변수 유효성 검사 : 불변성
  • 필수적인 매개변수와 선택적인 매개변수 초기화의 구분

 

 

정적 내부 클래스를 사용하는 Builder Pattern

(Product 안에 Builder가 포함되어있다고 생각해주세요)

 

생성하려는 객체(Product) 안에 static 빌더 내부 클래스를 둔다. 빌더 내부 클래스는 초기화에 필요한 정보를 필드로 가지며, 클라이언트가 정보를 선택적으로 초기화할 수 있도록 셀프 리턴 기법을 사용한다. 그리고 초기화된 정보를 건내받아 객체를 생성하는 역할을 가진다. 클라이언트는 빌더 내부 클래스를 사용해 초기화 정보를 넘기고 초기화 메시지를 보낸다.

 

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 static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values
        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;
        }

        // initialize fields - method chaining with self return
				// 중복 발생! 매개변수로 받은 정보를 초기화하고 자신을 반환하는 코드가 반복된다
        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; }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    // immutable
    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

    public static void main(String[] args) {
				// method chaining
        NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                .calories(100).sodium(35).carbohydrate(27).build();
    }
}

이 코드에서 정보를 선택적으로 초기화하는 기능을 제공하기 위해, 하나씩 받은 매개변수를 세팅하고 자신을 반환한다. 그런데 이 메소드들을 잘 보면 매개변수로 받은 정보를 초기화하고 자신을 반환하는 코드가 반복된다는 것을 알 수 있다. 여기서 바뀌는 것은 어느 필드를 세팅할 것인지이다. 만약 매개변수를 열거형으로 추상화해도 된다면, 매개변수를 초기화하는 부분을 하나의 메소드로 추상화할 수 있다. (이 코드는 불가능)

 

 

아래 코드는 계층형 클래스에 필드 추상화를 적용한 방법이다. 부모인 Pizza는 열거형인 Topping을 선언했고, 하나의 메소드로 매개변수를 선택적 초기화할 수 있다. 자식인 NyPizza에서는 추가적인 필드인 Size를 선언하고, 자신의 추가적인 빌더에서 Size 필더와 초기화 메소드를 추가했다. 클라이언트는 자식 클래스를 생성할 때 부모 클래스의 빌더로 공통적인 필드는 일관된 api를 사용하여 추가하고, 자식 클래스에서 추가로 선언한 필드는 자식 클래스의 빌더에 메시지를 보낸다.

public abstract class Pizza {

		// 타입을 열거형으로 추상화
    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>> {
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

				// 하나의 메소드로 매개변수를 선택적으로 초기화 가능, 중복 제거
        public T addTopping(Topping topping) {
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

        // Subclasses must override this method to return "this"
        protected abstract T self();
    }

    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone(); // See Item 50
    }
}

public class NyPizza extends Pizza {

		// 추가적인 정보 선언
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

				// builder, self 오버라이딩

    }

    public static void main(String[] args) {
        NyPizza pizza = new NyPizza.Builder(SMALL)
                .addTopping(SAUSAGE).addTopping(ONION).build();

        System.out.println(pizza);
    }
}

 

 

 

빌더 패턴 적용이 유효한 경우

 

빌더 객체는 Product의 필드를 중복해서 가지기 때문에 그만큼 메모리를 더 필요로 한다. 또한 클래스를 새로 만들어야한다는 부담도 있다. 그래서 초기화에 필요한 매개변수가 4개 이상일 때 빌더 패턴 적용을 고려해야 한다.

 

 

왜 빌더 내부 클래스는 정적어야 하는가?

 

잘 설명한 글 : https://velog.io/@maketheworldwise/중첩-내부-클래스-메모리-누수의-위험성

위의 코드에서 내부 클래스는 항상 static으로 선언했다. 그 이유는 내부 클래스를 static으로 선언하지 않으면 Product를 만들 때마다 내부 클래스가 생성되어 메모리 낭비일 뿐만 아니라 메모리 누수가 발생하기 때문이다.

 

내부 클래스를 비정적 클래스로 선언하면 외부 클래스에 대한 참조를 항상 가지고 있게 된다. javap -p 명령어로 Disassembler 결과(바이트코드를 자바코드 비슷하게 만들어줌)를 확인해보면 알 수 있다.

 

 

 

 

반면 static 내부 클래스는 외부 클래스에 대한 참조를 가지지 않는다. 사실 외부 클래스가 로딩되기 전에 static이 로딩되므로 참조를 가질 수 없다.

 

 

 

jc가 메모리 할당을 해제하는 시점은 객체가 더이상 사용되지 않을 때이다. 하지만 내부 클래스는 항상 외부 클래스를 참조하고 있으므로, 외부 클래스가 삭제되더라도 내부 클래스가 남아서 메모리 누수가 발생한다. 반면 static으로 선언하면 외부 클래스와는 독립적으로 생성되므로, 메모리 누수가 발생하지 않는다.

 

 

인터페이스를 사용하는 Builder Pattern

 

Builder를 인터페이스로 분리해서 객체를 반환하라는 메시지를 하나 가지게 한다. 인터페이스를 구현하는 클래스는 내부에 초기화를 위해 필요한 정보를 필드로 가지며, 객체 반환 메소드를 구현해야한다. Product의 생성자를 private으로 막을 것이라면 ConcreteBuilder를 Product 안에 정적 내부 클래스로 포함해야 한다.

이 코드는 Builder의 역할을 인터페이스로 두어서 빌더들의 구현을 바꿀 수 있다는 장점이 있다. (전략 패턴) XML 또는 Json 파일로부터 설정을 읽어오는 빌더, DB로부터 설정을 읽어오는 빌더 등을 만들 수 있다. 또 빌더의 구현을 인터페이스로 바꿀 수 있으므로 유닛 테스트용으로 단순한 빌더를 만들어 사용할 수 있다.

public interface Builder<T> {
    T build();
}

public class Person {
    private final String name;
    private final int age;
    private final String address;

    private Person(String name, int age, String address) {
        this.name = name;
        this.age = age;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public String getAddress() {
        return address;
    }

    public static Builder<Person> builder() {
        return new PersonBuilder();
    }

    private static class PersonBuilder implements Builder<Person> {
        private String name;
        private int age;
        private String address;

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

        public PersonBuilder withAge(int age) {
            this.age = age;
            return this;
        }

        public PersonBuilder withAddress(String address) {
            this.address = address;
            return this;
        }

        @Override
        public Person build() {
            return new Person(name, age, address);
        }
    }
}

 

다만 주의할 것은 Builder를 대체할 때, 초기화되는 값의 집합마다 Builder를 만들어 Product를 생성하는 방법은 사용하지 않는다. Builder는 경우에 따라 많은 필드를 가지는 객체이기 때문에 Builder 자체를 여러개 생성하기보다는, Builder 자체는 Product 당 하나만 만들어두고 설정값을 추상화해서 주입하는 방법이 더 낫다.

 

 

Builder를 재사용하는 방법

 

Product 클래스 하나가 Build 클래스 하나만 사용하게 하고, 설정값은 재사용하는 패턴으로 Consumer 패턴과 Configurer 패턴이 있다.

 

Configurer vs Customizer

 

GPT 왈 :

 

Customizer는 간단한 구성 변경에 유용하며, Configurer는 더 복잡한 구성 변경에 유용합니다. 또한, Configurer는 Customizer보다 높은 우선순위를 가지므로 Configurer가 먼저 적용되고 그 다음에 Customizer가 적용됩니다.

 

Customizer는 builder의 일부 구성 요소를 변경하는 단순한 방법을 제공합니다. 예를 들어, Customizer를 사용하여 Interceptor, MessageConverter, ErrorHandler 등을 추가하거나 제거할 수 있습니다. Customizer는 빌더에 대한 단순한 변경을 수행하므로 일반적으로 빠르게 적용할 수 있습니다.

 

Configurer는 builder의 전체 구성을 변경하는 더욱 강력한 방법입니다. 예를 들어, Configurer를 사용하여 빌더의 연결 시간 초과 또는 기본 요청 헤더와 같은 속성을 구성할 수 있습니다. Configurer는 빌더에 대한 복잡한 구성 변경을 수행하므로 Customizer보다 시간이 더 걸릴 수 있습니다.

 

 

스프링부트의 자동구성에서 빌더 찾기

 

적절한 예제는 스프링 부트의 자동구성 빈에서 찾을 수 있다.

 

Config 클래스로 인프라 스트럭처 빈을 등록할 때, 외부 설정값(application.properties 등)으로 등록될 빈의 일부 필드 값을 바꿀 수 있도록 커스터마이징 기능을 제공한다. 변경 가능한 필드를 모아둔 클래스가 Properties인데, 프로퍼티즈를 빈으로 등록해두고 application.properties와 같은 설정 파일에 적어둔 값을 바인딩하는 작업을 후처리기로 처리한다. 인프라 스트럭처 빈을 등록할 때 값이 바인딩된 Properties 빈을 @Import로 포함시켜서 외부 설정을 적용한다. (@Import는 @EnableConfigurationProperties에 메타 애노테이션으로 포함됨)

 

인프라 스트럭처 빈을 등록할 때 초기화해야하는 필드가 너무 많은 경우, 빌더를 사용한다. 다음은 ThreadPoolTaskExecutor 빈을 등록하기 위해 TaskExecutorBuilder을 등록하는 예이다.

@ConditionalOnClass(ThreadPoolTaskExecutor.class)
@AutoConfiguration
@EnableConfigurationProperties(TaskExecutionProperties.class)
public class TaskExecutionAutoConfiguration {

	/**
	 * Bean name of the application {@link TaskExecutor}.
	 */
	public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";

	// 등록할 인프라 스트럭처 빈
	@Lazy
	@Bean(name = { APPLICATION_TASK_EXECUTOR_BEAN_NAME,
			AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME })
	@ConditionalOnMissingBean(Executor.class)
	public ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder builder) {
		return builder.build();
	}

	// 빌더를 등록하는 빈 (커스터마이저는 나중에..)
	@Bean
	@ConditionalOnMissingBean
	public TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties,
			ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers,
			ObjectProvider<TaskDecorator> taskDecorator) {
		TaskExecutionProperties.Pool pool = properties.getPool();
		TaskExecutorBuilder builder = new TaskExecutorBuilder();
		builder = builder.queueCapacity(pool.getQueueCapacity());
		builder = builder.corePoolSize(pool.getCoreSize());
		builder = builder.maxPoolSize(pool.getMaxSize());
		builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
		builder = builder.keepAlive(pool.getKeepAlive());
		Shutdown shutdown = properties.getShutdown();
		builder = builder.awaitTermination(shutdown.isAwaitTermination());
		builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
		builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
		builder = builder.customizers(taskExecutorCustomizers.orderedStream()::iterator);
		builder = builder.taskDecorator(taskDecorator.getIfUnique());
		return builder;
	}

}

// 빌더 클래스
public class TaskExecutorBuilder {

	private final Integer queueCapacity;
	private final Integer corePoolSize;
	private final Integer maxPoolSize;
	private final Boolean allowCoreThreadTimeOut;
	private final Duration keepAlive;
	private final Boolean awaitTermination;
	private final Duration awaitTerminationPeriod;
	private final String threadNamePrefix;
	private final TaskDecorator taskDecorator;
	private final Set<TaskExecutorCustomizer> customizers;

	public ThreadPoolTaskExecutor build() {
		return configure(new ThreadPoolTaskExecutor());
	}

	public <T extends ThreadPoolTaskExecutor> T build(Class<T> taskExecutorClass) {
		return configure(BeanUtils.instantiateClass(taskExecutorClass));
	}
}

 

 

 

 

Builder, Customizer를 사용해 초기화하는 JacksonObjectMapperConfiguration

 

ObjectMapper는 Jackson 라이브러리에서 제공하는 클래스 중 하나로, JSON 데이터와 Java 객체 간의 변환을 담당하는 클래스이다.

 

JacksonObjectMapperConfiguration는 Jackson 라이브러리가 클래스패스에 있는 경우에만 빈으로 등록된다. 이후에는 Jackson2ObjectMapperBuilder 클래스를 사용하여 기본적인 설정값을 지정하고, 필요에 따라 커스터마이징 할 수 있는 ObjectMapper 빈을 생성한다.

 

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
	static class JacksonObjectMapperConfiguration {

		@Bean
		@Primary
		@ConditionalOnMissingBean
		ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
			return builder.createXmlMapper(false).build();
		}

	}
}

 

 

Jackson2ObjectMapperBuilder를 빈으로 등록하는 컨피그이다. 빌더에는 Jackson2ObjectMapper를 구성하는 필드로 List<Jackson2ObjectMapperBuilderCustomizer>를 받는다. 이 OpjectMapper는 다양한 설정단위를 허용하여, 우선순위에 따라 최종으로 적용할 설정값을 정한다. 따라서 매개변수로 Cusomizer List을 주입 받아서 최종 적용할 설정값을 정한다.

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
	static class JacksonObjectMapperBuilderConfiguration {

		@Bean
		@Scope("prototype")
		@ConditionalOnMissingBean
		Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(ApplicationContext applicationContext,
				List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
			Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
			builder.applicationContext(applicationContext);
			customize(builder, customizers);
			return builder;
		}
	}
}

 

 

Customizer에 설정값들이 주입되므로, 커스터마이저를 등록하는 빈에 Properties가 달리는 것을 확인할 수 있다.

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
	@EnableConfigurationProperties(JacksonProperties.class)
	static class Jackson2ObjectMapperBuilderCustomizerConfiguration {}

}

@ConfigurationProperties(prefix = "spring.jackson")
public class JacksonProperties {

	private String dateFormat;
	private String propertyNamingStrategy;
	private final Map<PropertyAccessor, JsonAutoDetect.Visibility> visibility = new EnumMap<>(PropertyAccessor.class);
	private final Map<SerializationFeature, Boolean> serialization = new EnumMap<>(SerializationFeature.class);
	private final Map<DeserializationFeature, Boolean> deserialization = new EnumMap<>(DeserializationFeature.class);
	// ..

}

 

 

SerializationFeature.WRITE_DATES_AS_TIMESTAMPS 옵션을 활성화하려면 커스터마이저 구현 클래스를 만들어 빈으로 등록하면 된다.

@Component // 유저 구성정보
public class CustomJackson2ObjectMapperBuilderCustomizer implements Jackson2ObjectMapperBuilderCustomizer {
    @Override
    public void customize(Jackson2ObjectMapperBuilder builder) {
        builder.featuresToEnable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

 

 

 

 

Builder, Configurer을 사용해 초기화하는 RestTemplateAutoConfiguration

 

RestTemplate은 HTTP 요청을 수행하기 위한 클라이언트이다. RestTemplateBuilder는 불변 객체인 RestTemplate을 구성하기 위한 빌더 패턴의 구현체이다.

@AutoConfiguration(after = HttpMessageConvertersAutoConfiguration.class)
@ConditionalOnClass(RestTemplate.class)
@Conditional(NotReactiveWebApplicationCondition.class)
public class RestTemplateAutoConfiguration {

	@Bean
	@Lazy
	@ConditionalOnMissingBean
	public RestTemplateBuilderConfigurer restTemplateBuilderConfigurer(
			ObjectProvider<HttpMessageConverters> messageConverters,
			ObjectProvider<RestTemplateCustomizer> restTemplateCustomizers,
			ObjectProvider<RestTemplateRequestCustomizer<?>> restTemplateRequestCustomizers) {
		RestTemplateBuilderConfigurer configurer = new RestTemplateBuilderConfigurer();
		configurer.setHttpMessageConverters(messageConverters.getIfUnique());
		configurer.setRestTemplateCustomizers(restTemplateCustomizers.orderedStream().collect(Collectors.toList()));
		configurer.setRestTemplateRequestCustomizers(
				restTemplateRequestCustomizers.orderedStream().collect(Collectors.toList()));
		return configurer;
	}

	@Bean
	@Lazy
	@ConditionalOnMissingBean
	public RestTemplateBuilder restTemplateBuilder(RestTemplateBuilderConfigurer restTemplateBuilderConfigurer) {
		RestTemplateBuilder builder = new RestTemplateBuilder();
		return restTemplateBuilderConfigurer.configure(builder);
	}

 

 

이때 커스텀하게 설정하여 RestTemplate을 구성하려면 RestTemplateBuilderConfigurer을 커스텀하게 만들어 RestTemplateBuilder의 프로퍼티를 수정해서 RestTemplate을 커스텀하게 만들 수 있다.

// RestTemplate 커스톰 빈 등록
@Configuration
public class MyConfiguration {
    @Bean
    public RestTemplate restTemplate() {
        // 커스터마이징된 RestTemplate을 생성합니다.
        RestTemplate restTemplate = new RestTemplate();
        // 커스터마이징 작업을 수행합니다.
        // ...
        return restTemplate;
    }
}

// RestTemplateBuilder 커스톰 빈 등록
@Configuration
public class MyConfiguration {
    @Bean
    public RestTemplateBuilder restTemplateBuilder() {
        return new RestTemplateBuilder();
    }
}