비효율적인 예
public String repeat(String str, int count) {
String result = "";
for (int i = 0; i < count; i++) {
result += str; // 매 반복마다 새로운 String 객체 생성
}
return result;
}
효율적인 예
public String repeat(String str, int count) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < count; i++) {
result.append(str); // 동일한 StringBuilder 객체를 재사용
}
return result.toString();
}
자바에서 String 객체는 불변이다. 불변이란 한 번 생성된 String 객체의 내용이 변경될 수 없다는뜻이다. 비 효율적인 예에서 문자열을 반복적으로 연결할 때마다 새로운 String 객체가 생성되어 메모리 사용량이 증가하고 성능이 저하된다.
예시 : 불변 객체의 재사용
비효율적인 예
Boolean trueValue = new Boolean(true); // 불필요한 Boolean 객체 생성
Boolean falseValue = new Boolean(false);
효율적인 예
Boolean trueValue = Boolean.valueOf(true); // Boolean 객체 재사용
Boolean falseValue = Boolean.valueOf(false);
불변 객체는 내부 상태가 변경되지 않으므로 같은 값을 가지는 인스턴스를 여러번 생성할 필요가 없다.
예를 들어, Boolean.valueOf(boolean) 메소드는 Boolean.TRUE 또는 Boolean.FALSE를 반환하여, 같은 Boolean 객체를 재사용할 수 있게 한다.
사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식을 사용하는 경우가 자주 있는데 이는 적합하지 않다.
인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식인 의존 객채 주입 패턴이 좋다.
예시 :
// 주문 처리를 담당하는 클래스
public class OrderProcessor {
// 결제 서비스에 대한 의존성
private final PaymentService paymentService;
// 재고 서비스에 대한 의존성
private final InventoryService inventoryService;
// 생성자를 통한 의존 객체 주입
// OrderProcessor는 PaymentService와 InventoryService의 구체적인 구현에 의존하지 않습니다.
// 대신, 이 서비스들은 외부(예: 애플리케이션의 설정 부분)에서 주입됩니다.
public OrderProcessor(PaymentService paymentService, InventoryService inventoryService) {
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
// 주문 처리 메서드
public void processOrder(Order order) {
// 결제 처리 시도
if (paymentService.processPayment(order.getTotalPrice())) {
// 결제 성공 시, 재고 업데이트
inventoryService.updateInventory(order.getItems());
System.out.println("Order processed successfully.");
} else {
// 결제 실패 시
System.out.println("Payment failed.");
}
}
}
// 결제 처리를 위한 인터페이스
public interface PaymentService {
boolean processPayment(double amount); // 결제 처리 메서드
}
// 재고 관리를 위한 인터페이스
public interface InventoryService {
void updateInventory(List<Item> items); // 재고 업데이트 메서드
}
// 애플리케이션 실행 클래스
public class App {
public static void main(String[] args) {
// 결제 서비스와 재고 서비스의 구현체를 생성
PaymentService paymentService = new CreditCardPaymentService(); // 실제 결제 서비스 구현체
InventoryService inventoryService = new WarehouseInventoryService(); // 실제 재고 관리 서비스 구현체
// OrderProcessor 인스턴스 생성 시, 의존 객체를 주입
OrderProcessor orderProcessor = new OrderProcessor(paymentService, inventoryService);
// 주문 처리
orderProcessor.processOrder(new Order(/* 주문 정보 */));
}
}
예시의 OrderProcessor 클래스를보면 OrderProecessor는 paymentService 와 inventoryService 인터페이스에 의존한다. 하지만 구체적인 구현에는 의존하지 않는다. 앱 실행 클래스를 보면 이 두 서비스의 구체적인 구현은 생성자를 통해 주입된다.
결론 : 클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면 필요한 자원(혹은 그 자원을 만들어주는 팩토리를) 생성자에(혹은 정적 팩토리나 빌더에) 넘겨주자. = 의존 객체 주입 기법을 사용해 클래스의 유연성, 재사용성, 테스트 용이성을 개선해주자.
"인스턴스화를 막으려거든 private 생성자를 사용해라"는 조언은 유틸리타 클래스나 도우미 클래스와 같이 상태를 가지지 않고 단순 메서드만을 제공하는 클래스에서 주로 사용된다.
예시 :
public class UtilityClass {
// private 생성자를 추가하여 인스턴스화를 방지함
private UtilityClass() {
throw new AssertionError("UtilityClass should not be instantiated");
}
// 유틸리티 메서드 예시
public static void utilityMethod() {
// 메서드 구현
}
}
예시에 나온 UtilityClass 같이 오직 메서드만을 제공할 경우 private 생성자를 추가해, 외부에서 인스턴스 생성을 막을 수 있다. 생성자 내부에서 AssertionError를 던지는 것은 내부에서 인스턴스를 생성하려는 시도를 방지하기 위함이다.
이렇게 하면 클래스 설계 의도가 명확해지고, 불필요한 인스턴스 생성으로 인한 리소스 낭비를 방지할 수 있다.
모든 생성자를 private 으로 선언하지않았다면 Java에서 자동으로 기본생성자 public 을 추가해 어디서나 인스턴스를 생성할 수 있게된다.
싱글턴 패턴 : 특정 클래스의 인스턴스가 오직 하나만 생성되어서 전역적으로 접근 가능하도록 보장하는 패턴이다.
이 패턴은 전역 상태를 관리하거나, 자원 관리, 로깅, 드라이버 객체 등 한 번의 인스턴스 생성으로 충분한 경우에 주로 사용된다.
싱글턴을 만드는 방식은 크게 2가지가 있다.
1. Private 생성자를 이용한 방식
2. Enum(열거형)을 이용한 방식
열거 타입을 이용한 방식의 예시 :
public enum Singleton {
INSTANCE;
public void doSomething() {
// 싱글턴 인스턴스가 할 작업 구현
}
}
더 자세한 예시 :
public enum DatabaseConnection {
INSTANCE; // Enum 싱글턴의 인스턴스 선언. JVM은 이 인스턴스를 유일하게 관리합니다.
private Connection connection; // 데이터베이스 연결 객체를 저장할 변수.
// Enum의 생성자. Enum 인스턴스가 생성될 때 (즉, JVM이 시작할 때) 한 번만 호출됩니다.
DatabaseConnection() {
// 여기에서 데이터베이스 연결을 설정합니다. 실제 환경에서는 이 부분에
// 데이터베이스 연결을 위한 JDBC URL, 사용자 이름 및 비밀번호 등이 포함됩니다.
try {
// DriverManager.getConnection을 사용하여 데이터베이스 연결을 초기화합니다.
// 이 예제에서는 연결 정보를 가정하고 있으며, 실제 정보로 대체해야 합니다.
this.connection = DriverManager.getConnection("jdbc:yourDatabaseUrl", "username", "password");
} catch (SQLException e) {
// 데이터베이스 연결 설정 중 예외가 발생한 경우, 예외를 던집니다.
// 이를 통해 초기화 실패를 알리고, 애플리케이션 시작을 중단할 수 있습니다.
throw new RuntimeException("Database connection setup failed", e);
}
}
// 데이터베이스 연결 객체를 반환하는 메서드.
// 이 메서드를 통해 어디서든 데이터베이스 연결을 사용할 수 있습니다.
public Connection getConnection() {
return this.connection;
}
// 데이터베이스 연결을 안전하게 닫는 메서드.
// 애플리케이션 종료 시 또는 더 이상 데이터베이스 연결이 필요 없을 때 호출합니다.
public void closeConnection() {
if (this.connection != null) {
try {
this.connection.close();
} catch (SQLException e) {
// 연결 닫기 시도 중 예외 처리.
// 예외 처리 로직에 따라 로깅 또는 다른 조치를 취할 수 있습니다.
}
}
}
}
결론 : 대부분의 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.
정적 팩토리 메서드와 public 생성자 모두 제약이 있다. 바로 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 점이다.
이 때 개발자들은 점층적 생성자 패턴을 사용했었다. 하지만 이것도 단점이 존재한다.
점층적 생성자 패턴도 쓸 수는 있지만, 배개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.
점층적 생성자 패턴의 예시 :
public class Pizza {
private int size; // 필수
private boolean cheese; // 선택적
private boolean pepperoni; // 선택적
private boolean bacon; // 선택적
public Pizza(int size) {
this(size, false); // 기본값 사용
}
public Pizza(int size, boolean cheese) {
this(size, cheese, false); // 기본값 사용
}
public Pizza(int size, boolean cheese, boolean pepperoni) {
this(size, cheese, pepperoni, false); // 기본값 사용
}
public Pizza(int size, boolean cheese, boolean pepperoni, boolean bacon) {
this.size = size;
this.cheese = cheese;
this.pepperoni = pepperoni;
this.bacon = bacon;
}
// Pizza 객체 사용 예
Pizza pizza = new Pizza(12, true, true); // 크기 12, 치즈와 페페로니 추가
Pizza cheesePizza = new Pizza(12, true); // 크기 12, 치즈 추가
Pizza everythingPizza = new Pizza(16, true, true, true); // 크기 16, 모두 추가
}
지금은 4개 뿐이라서 이게 뭐가 단점이야? 라고 생각할 수 있지만 지금은 '고작' 4개인것 뿐이다.
이를 보완하기 위해 빌더 패턴을 사용한다.
빌더 패턴(Builder Pattern)은 복잡한 객체의 생성 과정을 단순화하는 디자인 패턴이다. 특히, 생성자나 정적 팩토리가 처리해야 할 매개변수가 많을 때 유용하다. 빌더 패턴은 필수 매개변수만으로 생성자를 호출하여 빌더 객체를 얻고, 빌더 객체가 제공하는 설정 메서드를 호출하여 선택적 매개변수를 설정한 다음, 마지막에는 build() 메서드를 호출하여 필요한 객체를 얻는다.
빌더패턴의 장점이 여러가지 있다.
1. 가독성 향상
2. 사용의 유연성
3. 불변성 유지
4. 객채 생성 코드의 재사용성
빌더 패턴의 예시 :
public class Pizza {
private final int size; // 불변 필드 선언으로 객체의 불변성을 유지
private final boolean cheese;
private final boolean pepperoni;
private final boolean bacon;
private Pizza(Builder builder) {
this.size = builder.size; // Builder에서 설정된 값으로 초기화
this.cheese = builder.cheese;
this.pepperoni = builder.pepperoni;
this.bacon = builder.bacon;
}
public static class Builder {
private final int size; // 필수 매개변수
private boolean cheese = false; // 선택적 매개변수는 기본값으로 초기화
private boolean pepperoni = false;
private boolean bacon = false;
public Builder(int size) {
this.size = size; // 필수 매개변수 설정
}
// 선택적 매개변수 설정 메서드는 체이닝을 통해 가독성과 사용의 유연성을 향상
public Builder cheese(boolean value) {
this.cheese = value;
return this; // 메서드 체이닝을 가능하게 함
}
public Builder pepperoni(boolean value) {
this.pepperoni = value;
return this;
}
public Builder bacon(boolean value) {
this.bacon = value;
return this;
}
public Pizza build() {
return new Pizza(this); // 최종 객체 생성 후 반환
}
}
// Pizza 객체 사용 예
public static void main(String[] args) {
// 첫 번째 예시: 크기가 12인 치즈 피자
// 가독성 향상: 각 설정 메서드의 이름을 통해 어떤 매개변수를 설정하는지 명확히 알 수 있음
Pizza cheesePizza = new Pizza.Builder(12).cheese(true).build();
// 두 번째 예시: 크기가 16인, 치즈, 페페로니, 베이컨이 모두 추가된 피자
// 객체 생성 코드의 재사용성: Builder 객체를 통해 비슷한 객체를 생성할 때 설정 코드를 재사용할 수 있음
Pizza everythingPizza = new Pizza.Builder(16)
.cheese(true)
.pepperoni(true)
.bacon(true)
.build();
// 여기에서 cheesePizza와 everythingPizza 객체를 사용할 수 있습니다.
}
}
빌더패턴은 계층적으로 설계된 클래스 구조(상속 구조) 에서 특히 유용하다.
상속 주고에서 사용되는 빌더 패턴의 예 :
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();
}
// 최종적으로 Pizza 객체를 생성하는 메서드, 하위 클래스에서 구현해야 함
abstract Pizza build();
// 하위 클래스에서 이 메서드를 오버라이드하여 "this"를 반환하도록 함. 체이닝을 위해 사용됨.
protected abstract T self();
}
// Builder를 사용하여 Pizza 객체를 생성하는 생성자
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // Item 50 참고, toppings 복제
}
}
// 마르게리타 피자 클래스, Pizza 클래스를 상속받음
class MargheritaPizza extends Pizza {
MargheritaPizza(Builder builder) {
super(builder); // 상위 클래스의 생성자 호출
}
// 마르게리타 피자를 위한 Builder 정적 내부 클래스, Pizza.Builder를 상속받음
static class Builder extends Pizza.Builder<Builder> {
@Override
MargheritaPizza build() {
return new MargheritaPizza(this); // MargheritaPizza 객체 생성
}
@Override
protected Builder self() {
return this; // 체이닝을 위해 Builder 인스턴스 자신을 반환
}
}
}
결론 : 생성자나 정적 팩토리 메서드에서 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는게 낫다. API는 시간이 지날수록 매개변수가 많아지는 경향이 있기 때문에 매개변수가 적어도 처음부터 빌더 패턴으로 시작하자.