oguri's garage

Java 객체 생성 패턴 비교 및 선택 가이드 본문

개발하다/Java

Java 객체 생성 패턴 비교 및 선택 가이드

oguri 2025. 9. 28. 14:43

1. 세 가지 주요 객체 생성 패턴

1.1 생성자 패턴

개념

  • Java의 기본적인 객체 생성 방식
  • 오버로딩을 통해 여러 생성자 제공
  • 컴파일 시점에 타입 안전성 보장

예시

public class User {
    private String name;
    private String email;
    private Integer age;

    // 기본 생성자
    public User() {}

    // 필수 필드만 포함
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // 모든 필드 포함
    public User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }
}

// 사용법
User user1 = new User("John", "john@example.com");
User user2 = new User("Jane", "jane@example.com", 25);

 

장점

  • 간단하고 직관적
  • 컴파일 시점 타입 안전성 보장
  • IDE 지원 완벽 (자동완성, 리팩토링 등)
  • 성능 최적화

단점

  • 매개변수가 많아지면 가독성 저하
  • 선택적 매개변수 처리 시 생성자 오버로딩 과도해짐
  • 매개변수 순서 혼동 위험
  • 같은 타입 매개변수가 많으면 실수 발생

 

1.2 빌더 패턴

개념

  • 객체 생성 과정과 표현 방법을 분리
  • 단계별로 객체 생성하며 선택적 매개변수 설정
  • 최종적으로 build() 메소드로 불변 객체 생성

예시

@Builder
public class User {
    private String name;
    private String email;
    private Integer age;
    private String address;
    private String phone;
}

// 사용법
User user = User.builder()
    .name("John")
    .email("john@example.com")
    .age(25)
    .address("Seoul")
    .build();

 

장점

  • 매개변수가 많거나 선택적인 경우 유연하게 대응
  • 가독성이 좋고 사용하기 쉬움
  • 불변 객체를 쉽게 만들 수 있음
  • 매개변수 순서에 상관없이 설정
  • 필드명이 명시되어 의미 명확

단점

  • 별도의 빌더 클래스 필요로 코드량 증가
  • 간단한 객체 생성에는 과도한 설정
  • 약간의 성능 오버헤드
  • 빌더 객체 생성 비용

 

1.3 정적 팩토리 메소드

개념

  • 객체 생성을 캡슐화하는 static 메소드 제공
  • 메소드 이름을 통해 객체 생성 의도를 명확히 표현

예시

public class User {
    private String name;
    private String email;
    private Integer age;

    private User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    // 정적 팩토리 메소드들
    public static User createGuest() {
        return new User("Guest", "guest@system.com", null);
    }

    public static User createUser(String name, String email) {
        return new User(name, email, null);
    }

    public static User createAdult(String name, String email) {
        return new User(name, email, 18);
    }

    // 캐싱된 인스턴스 반환
    private static final User ADMIN_USER = new User("Admin", "admin@system.com", null);

    public static User admin() {
        return ADMIN_USER;
    }

    // 조건부 객체 생성
    public static User fromAge(String name, String email, int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
        return new User(name, email, age);
    }
}

// 사용법
User guest = User.createGuest();
User adult = User.createAdult("John", "john@example.com");
User admin = User.admin(); // 캐싱된 인스턴스

 

장점

  • 메소드 이름으로 생성 의도를 명확히 전달
  • 호출할 때마다 새 객체를 생성할 필요 없음 (캐싱 가능)
  • 반환 타입의 하위 타입 객체 반환 가능 (유연성)
  • 입력 매개변수에 따라 다른 클래스 객체 반환 가능
  • 인스턴스 생성 비용이 높은 객체에서 성능 이점

단점

  • 상속을 통한 확장이 어려울 수 있음 (생성자가 private인 경우)
  • 다른 static 메소드와 구분이 어려울 수 있음
  • API 문서화가 중요함

2. 현대적 패턴: of() 메소드 + 람다

2.1 전통적 빌더 vs of() 메소드

전통적 빌더

HttpClient client = HttpClient.builder()
    .url("https://api.example.com")
    .method("POST")
    .timeout(Duration.ofSeconds(30))
    .header("Content-Type", "application/json")
    .build();

of() 메소드 + 람다

HttpClient client = HttpClient.of(builder -> {
    builder.url("https://api.example.com");
    builder.method("POST");
    builder.timeout(Duration.ofSeconds(30));
    builder.header("Content-Type", "application/json");
});

 

2.2 of() 메소드의 장점

 

1. 메모리 효율성

  • 기존 빌더: 각 체이닝 단계에서 중간 빌더 객체 생성
  • of() 방식: 빌더 객체를 한 번만 생성하고 람다 내에서 즉시 설정

2. JIT 컴파일러 최적화

  • 람다 표현식은 JIT 컴파일러의 인라인 최적화에 유리
  • 메소드 호출 오버헤드 감소

3. 안전한 객체 생성

  • build() 호출 누락 위험 없음
  • 람다 내부에서 모든 필드 설정 후 바로 객체 반환

4. 코드 간결성

  • 중복된 build() 호출 제거
  • 명확한 컨텍스트 유지

 

2.3 구현 예시

public class HttpClient {
    private String url;
    private String method;
    private Duration timeout;
    private Map<String, String> headers;

    private HttpClient(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.timeout = builder.timeout;
        this.headers = builder.headers;
    }

    // 현대적 of() 메소드
    public static HttpClient of(Consumer<Builder> builderConsumer) {
        Builder builder = new Builder();
        builderConsumer.accept(builder);
        return new HttpClient(builder);
    }

    // 전통적 builder() 메소드도 함께 제공
    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private String url;
        private String method = "GET";
        private Duration timeout = Duration.ofSeconds(30);
        private Map<String, String> headers = new HashMap<>();

        public Builder url(String url) { this.url = url; return this; }
        public Builder method(String method) { this.method = method; return this; }
        public Builder timeout(Duration timeout) { this.timeout = timeout; return this; }
        public Builder header(String key, String value) { 
            this.headers.put(key, value); 
            return this; 
        }

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

3. 패턴 선택 기준

3.1 객체 복잡성 기준

필드 수 권장 패턴 이유
1-2개 생성자 간단하고 직관적
3-5개 생성자 또는 빌더 선택적 매개변수 여부에 따라
6개 이상 빌더 패턴 가독성과 유지보수성

3.2 상황별 선택 가이드

// 🎯 간단한 값 객체 → 생성자
public class Point {
    private final int x, y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

// 🎯 선택적 매개변수가 많음 → 빌더 패턴
@Builder
public class HttpRequest {
    private String url;           // 필수
    private String method;        // 선택 (기본: GET)
    private Map<String, String> headers; // 선택
    private String body;          // 선택
    private Duration timeout;     // 선택
    private boolean followRedirects; // 선택
}

// 🎯 생성 의도를 명확히 해야 함 → 정적 팩토리
public class LocalDateTime {
    public static LocalDateTime now() { ... }
    public static LocalDateTime of(int year, int month, int day) { ... }
    public static LocalDateTime parse(String text) { ... }
}

// 🎯 인스턴스 재사용 필요 → 정적 팩토리
public enum Direction {
    NORTH, SOUTH, EAST, WEST;

    public static Direction fromAngle(double angle) {
        // 각도에 따라 적절한 enum 반환
    }
}

3.3 성능 고려사항

패턴 메모리 사용량 실행 속도 컴파일 시간
생성자 최적 최고속 최단
정적 팩토리 캐싱 시 최적 캐싱 시 최고속 짧음
빌더 추가 객체 생성 약간 느림 보통
of() + 람다 최적화됨 JIT 최적화 혜택 보통

4. 실무 권장 패턴

4.1 레이어별 권장사항

Entity Layer

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
    @Id private Long id;
    private String name;

    // 정적 팩토리 메소드 조합
    public static User createUser(String name) {
        return User.builder().name(name).build();
    }

    @Builder
    private User(String name) {
        this.name = name;
    }
}

 

DTO Layer

@Builder
public class UserResponse {
    private Long id;
    private String name;
    private String email;
}

 

Configuration Layer

// 복잡한 설정이 필요한 경우 of() 메소드 활용
DataSource dataSource = DataSourceConfig.of(config -> {
    config.url("jdbc:mysql://localhost/db");
    config.username("user");
    config.password("password");
    config.maxPoolSize(20);
    config.connectionTimeout(Duration.ofSeconds(30));
});

 

4.2 팀 컨벤션 예시

/**
 * 객체 생성 패턴 선택 기준:
 * 
 * 1. 필드 3개 이하 + 필수 매개변수만 → 생성자
 * 2. 필드 4개 이상 또는 선택적 매개변수 → 빌더 패턴
 * 3. 생성 의도 명확화 필요 → 정적 팩토리 메소드
 * 4. 인스턴스 캐싱/재사용 → 정적 팩토리 메소드
 * 5. 복잡한 설정 API → of() + 람다 패턴
 */

// ✅ 간단한 값 객체
public class Coordinate {
    public Coordinate(double x, double y) { ... }
}

// ✅ 선택적 매개변수가 있는 복잡한 객체
@Builder
public class EmailMessage {
    private String to;      // 필수
    private String subject; // 필수  
    private String body;    // 선택
    private List<String> cc; // 선택
    private Priority priority; // 선택
}

// ✅ 특별한 생성 로직
public class User {
    public static User guest() { ... }
    public static User admin() { ... }
    public static User fromEmail(String email) { ... }
}

5. 결론

5.1 기본 원칙

  1. 단순함을 우선하라: 복잡하지 않다면 생성자 사용
  2. 가독성을 고려하라: 매개변수가 많으면 빌더 패턴
  3. 의도를 명확히 하라: 특별한 생성 로직은 정적 팩토리
  4. 성능을 고려하라: 자주 사용되는 객체는 캐싱 검토
  5. 일관성을 유지하라: 팀 내에서 일관된 패턴 사용

5.2 최종 선택 가이드

객체가 간단한가? 
├─ YES → 생성자 패턴
└─ NO → 선택적 매개변수가 많은가?
    ├─ YES → 빌더 패턴
    └─ NO → 특별한 생성 로직이 필요한가?
        ├─ YES → 정적 팩토리 메소드
        └─ NO → 생성자 또는 빌더 선택

 

각 패턴은 고유한 장단점이 있으므로, 상황에 맞는 적절한 선택이 중요합니다.