본문 바로가기

Backend/질문 시리즈

[질문-시리즈] 생성자? 정적 팩토리 메서드? 빌더?

[질문 시리즈]는 주변 개발자 분들께 제가 생각하는 내용을 질문 후 답변받은 내용입니다.
답변엔 여러 사람들의 관점을 바탕으로 최대한 정리하여 작성하였습니다.
해당 포스팅은 질문에 대한 정답을 명확히 하는 것이 목표가 아닙니다.
프로젝트를 바라보는 관점에 따라 의견이 다양해질 수 있고, 이 글 또한 그중 하나의 관점 정도로 생각하고 읽어주세요.

질문

다들 뭐 사용해?

참여한 프로젝트에서 new 키워드, 정적 팩토리 메서드, 빌더 패턴 등 다양한 방법을 활용하여 인스턴스를 생성하는 것을 보았습니다.

곰곰이 지켜보다 문뜩 이런 질문이 떠올랐습니다.

왜 여러가지 방법을 사용해서 인스턴스를 생성하는 거야?

자문자답을 해보려 하니 마땅히 떠오르는 것은 어… 그냥 편해서? 였습니다.

고민을 해본 적이 없었고 스스로 답변할 수 없는 답답함을 해결하고자, 좀 더 파고들어 각 방법의 장단점이 무엇인지 정리해봤습니다.

new 키워드

장점  별도의 패턴이 없으므로 이해하기가 가장 편합니다.
단점  accessLevel을 불필요하게 열어두는 경우를 볼 수도 있습니다.
public class Member {
    private String name;
    private String profileImage;

    private Member() {}
    public Member(final String name, final String profileImage) {
        Assert.notNull(name, "name must not be null");
        this.name = name;
        this.profileImage = profileImage;
    }
}

정적 팩토리 메서드 패턴

장점 1 객체 생성을 메서드 단위로 관리할 수 있습니다. (객체를 생성하는 방법 자체를 관리할 수 있음)  
 - 싱글톤 패턴을 적용하여 미리 생성한 객체를 재활용할 수 있습니다.  
 - Member Class를 상속받은 클래스의 인스턴스를 생성 및 반환할 수 있습니다.
장점 2
Lombok 어노테이션을 적극적으로 활용할 수 있습니다.
단점 1 생성자의 접근제한자가 public이면, 생성과정에 필요한 로직을 피해 인스턴스가 생성될 수 있습니다.
 - 접근제한을 private으로 제한할 수 있으면 좋지만 레거시 코드의 경우 변경이 어렵습니다.
 - 생성자에 로직을 넣어도 되지만 Lombok 어노테이션을 적극적으로 활용할 수 없어집니다.
단점 2 정적 메서드이므로 상속을 통한 확장이 불가능합니다.
단점 3 정적 메서드이므로 객체지향을 목표한 테스트 코드 작성이 어렵습니다.
 - Stub, Spy 형태의 클래스를 구현하지 않으면 테스트가 불가능합니다.
@AllArgsConstructor(access=AccessLevel.PRIVATE)
public class Member {
    private String name;
    private String profileImage;

    public static Member of(final String name, final String profileImage) {
        // 생성과정에 필요한 값 검사입니다.
        // 만약 생성자의 접근제한자가 public이면 값을 검사하는 로직을 피해 인스턴스를 생성할 수 있습니다.
        Assert.notNull(name, "name must not be null");
        // 미리 만들어둔 인스턴스를 반환하거나(싱글톤)
        // Member Class를 상속받은 클래스의 인스턴스를 생성 및 반환할 수 있습니다.
        return new Member(name, profileImage); 
    }
}

빌더 패턴(어노테이션)

장점 인스턴스 생성에 별도의 로직이 필요 없다면 @Builder로 간단하게 작성할 수 있습니다.
 - @NoArgsConstructor를 사용하지 않으면 모든 요소를 포함한 private 생성자가 추가됩니다.
단점 1 인자에 null을 입력할 수 있습니다.
 - 이상적인 개발을 위해선 null 사용을 줄이고, Optional과 정확한 예외처리가 필요합니다.
단점 2 단점1을 없애기 위해 null 검증을 하기 위해선 생성자를 선언 후 로직을 추가해야하는 번거로움이 있습니다.
 - validation 의존성을 추가하여 해결할 수 있지만, 동작 코드, 테스트 코드 등에 추가해야할 내용이 많습니다.
 - 의존성 추가 작업, 테스트 코드에 동작하도록 작업, 예외처리 핸들링 등
// @NoArgsConstructor를 사용하지 않으면 모든 요소를 포함한 private 생성자가 추가됩니다.
@Builder
public class Member {
	private String name;
	private String profileImage;
}

 

답변

프로젝트 상황에 따라 다르겠지만…

답변에 가장 많이 사용하는 방법은 정적 팩토리 메서드였습니다.

장단점을 인지하고 적용한다면 편함과 유지보수 용이성을 어느 정도 챙길 수 있기 때문에 보편적으로 사용하는 것 같습니다.

 

 

물론 객체 생성에 대한 책임을 피하고, 객체지향적인 테스트 코드를 작성하고자 한다면 Provider 패턴을 고려할 수 있습니다.

Provider 클래스에게 객체 생성을 위임하게 되면, 테스트 코드를 작성할 때 Provider의 Stub객체를 생성하여 테스트 상황에 맞게 원하는 객체를 생성하도록 유도할 수 있습니다.

예시

상품의 할인쿠폰이 유효시간이 지났을 때 적용이 되지 않도록 테스트 코드를 구현하고자 합니다.
해당 테스트를 위해선 할인쿠폰이 발급된 시각, 유효 일자 그리고 현재 시각이 필요합니다.

현재 시각은 LocalDateTime.now()를 통해 구할 수 있지만,  테스트 과정에서 요구하는 건 실제 시각이 아닌 만료 처리가 발생할 시각입니다.

유효시간을 최대한 작게 잡아 테스트를 할 수도 있겠지만 테스트 대상의 값을 정책에 맞지 않게 설정할 수 있으므로 논외입니다.

이때 위에서 설명한 Provider 패턴을 적용할 수 있습니다.

 

동작 코드

public class LocalDateTimeProvider {
	public LocalDateTime now() {
		return LocalDateTime.now();		
	}
}
-------------------------------------------------
public class OrderService {
	private final LocalDateTimeProvider localDateTimeProvider;
	public OrderId order(final OrderRequest request) {
		...
		order.payment(localDateTimeProvider.now());
		...
	}
}

테스트 코드

public class StubLocalDateTimeProvider extends LocalDateTimeProvider {
	public LocalDateTime returnValue = LocalDateTime.now();
	@Override
	public LocalDateTime now() {
		return returnValue;
	}
}
-------------------------------------------------
public class OrderServiceTest {
	private LocalDateTimeProvider stubLocalDateTimeProvider = new StubLocalDateTimeProvider();
	private OrderService orderService = new OrderService(stubLocalDateTimeProvider);
	
	public void expiredCOupon() {
		...
		stubLocalDateTimeProvider.returnValue = LocalDateTIme.of(2021, 1, 1, 10, 10, 10);
		orderService.order(givenRequest);
		...
  }
}