java, spring 환경에서 객체를 어떻게 생성할 것인가
객체 생성 방식에 대한 고민
팀 내 객체 생성 방식이 제각각인 상황을 목격하게 되었다. 프로젝트의 복잡성이 날로 증가하면서 어떠한 변경이 추가될 떄, 일관되지 않은 객체 생성 방식 때문에 수정이 번거로워 진다. 이를 어떻게 하면 일관된 방식으로 통일할 수 있을지에 대한 고민에서 이 글을 작성하게 되었다.
단순하지만 근본적인 질문에서 시작해보자. Java로 개발할 때 객체를 어떻게 생성해야 할까? 여기서 말하는 객체가 모호하게 들린다면, 클래스의 인스턴스를 어떻게 생성할 것인가로 바꿔 생각해도 좋다.
논의의 범위를 좀 더 구체적으로 좁혀보자면, 스프링 부트 기반의 웹 애플리케이션을 개발할 때의 객체 생성 방식에 대해 이야기해보고자 한다.
객체를 생성하게 되는 시점들
스프링으로 개발하면서 객체를 생성하게 되는 시점 두 가지
- 개발자가 명시적으로 의식하면서 처리하는 경우로, 주로 레이어 간 개념적인 클래스를 생성할 때이다.
// 서비스 레이어에서 엔티티를 DTO로 변환하는 경우 OrderDto orderDto = new OrderDto(order.getId(), order.getCustomerName()); // 레포지토리에서 조회한 결과를 도메인 객체로 변환하는 경우 Order order = Order.createFrom(orderEntity); - 잘 의식하지 않고 자동화 되는 부분도 있다. 보통 프레임워크나 라이브러리에 의해 자동화되어 처리되는 경우다. 주로 외부에서 애플리케이션으로 데이터가 유입될 때 발생한다.
// HTTP 요청 바디를 객체로 변환하는 경우 @PostMapping("/orders") public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) { // Jackson이 자동으로 OrderRequest 객체 생성 } // MyBatis resultType 매핑 @Select("SELECT id, name, price FROM products") List<Product> findAll(); // MyBatis가 자동으로 Product 객체 생성 // JPA 엔티티 조회 @Entity public class User { @Id private Long id; private String name; // JPA는 기본 생성자 필요 protected User() {} } - 이때 리플렉션의 도움을 받을 때도 있고, 생성자를 사용할 수도, 팩토리 메서드를 사용할 수도 있다.
- 이러나 저러나 자바 문법 아래서 근본적으로는 생성자가 객체를 생성하는 역할을 수행한다. 따라서 생성자에 따른 생성 방식을 잘 확인해야 한다.
문제는 의식하지 않는 곳에서 발생
생성자를 건드리기 시작하면서 문제가 발생한다
- 예를 들어 클래스 레벨로
@Builder를 선언하면 무슨 문제가 생길까. 기본 설정에서 대분 JSON 역직렬화를 위해jackson이 사용된다.- 이 경우 객체 생성은 리플렉션을 위해 기본 생성자를 필요로 한다.
- 빌더를 클래스 레벨로 생성하는 순간 생성자가 생기므로 기본 생성자가 사라진다.
- 이러면 요청을 파싱하지 못하는 오류가 발생한다.
mybatis도 유사한 제약이 있다.resultType으로 선언된 클래스가 생성자가 없다면 쿼리의 결과 행과 멤버 변수를 꼭 일치 시켜야만 한다. 변수를 일치시키지 않게 하기 위해서는 리플렉션을 사용할 수 있도록 기본 생성자를 재공해야 한다.- JPA 또한
@Entity로 선언된 객체는 매핑을 위해 반드시 기본 생성자가 필요하다. - 내가 자주 사용하는 의존성을 기준으로 생성자의 필요 여부를 간단히 정리해 보았다.
| Name | Constructor | Detail |
|---|---|---|
| MyBatis | 기본 생성자 (no-args) | ResultType 및 ParameterType으로 객체 전달 시 필요 |
| JPA | 기본 생성자 (no-args), 필요한 필드를 포함한 생성자 (선택) | @Entity 클래스는 반드시 기본 생성자 필요 (protected 이상의 접근 제어자), 다른 생성자가 있더라도 기본 생성자 필수, 영속성 컨텍스트가 프록시 객체 생성 시 필요 |
| Jackson | 기본 생성자 (no-args) 또는 @JsonCreator로 지정된 생성자 | JSON deserialize 시 기본 생성자 필요, 또는 @JsonCreator + @JsonProperty로 지정된 생성자 사용 가능 |
그럼 기본 생성자를 다 생성하면 되지 않나.
- 그렇다고 기본 생성자를
public으로 죄다 열어 주게 되면 계속해서 객체를 생성하는 방식이 왜곡되기 시작한다.- 모든 필드가
null인 객체에 값을 주입하기 위해setter로 값을 지정하는 로직을 많이 보았을 것이다. - 이때
setter는 아무 의미가 없고, 단지 개발자의 편의를 위해 생성되었을 뿐이다. 필드 갯수만큼 나열되는setter메서드에서는 에서는 뭐가 맞고 틀린 것인지, 값을 setter에 넘기는 것이 어떤 의미를 가지는 것인지 알기도 어렵다.Order order = new Order(); // 모든 필드 null order.setId(1L); order.setCustomerName("John"); order.setStatus(OrderStatus.PENDING); order.setItems(items); // 어떤 필드가 필수이고 어떤 필드가 선택적인지 알 수 없음
- 모든 필드가
협업을 위한 규칙을 마련해 보자면
깊게 생각하기 어렵다면 / 빌더 쓴다면 private으로 써라
- 이펙티브 자바에서는 가장 먼저 객체 생성에 관련한 item들이 등장한다. 여기서 제안하는 두 가지 규칙을 맹목적으로 따르다 보면 대다수의 상황에서는 해결이 된다고 본다. 물론 하다보면 분명히 예외가 필요한 지점은 생기겠지만, 일단 펼쳐놓은 것을 막는 것보다 막아둔 것을 펼치는 것이 더 쉽다.
public class Product {
private final String name;
private final BigDecimal price;
private final ProductCategory category;
@Builder(access = AccessLevel.PRIVATE)
private Product(String name, BigDecimal price, ProductCategory category) {
this.name = Objects.requireNonNull(name, "상품명은 필수입니다");
this.price = Objects.requireNonNull(price, "가격은 필수입니다");
this.category = Objects.requireNonNull(category, "카테고리는 필수입니다");
}
// 의미있는 이름의 팩토리 메서드
public static Product createNewProduct(String name, BigDecimal price, ProductCategory category) {
return Product.builder()
.name(name)
.price(price)
.category(category)
.build();
}
// 특수한 상황을 위한 팩토리 메서드
public static Product createGiftProduct(String name, ProductCategory category) {
return Product.builder()
.name(name)
.price(BigDecimal.ZERO)
.category(category)
.build();
}
}
빌드를 막는 규칙이 필요
여러 개발자가 협업하여 지속 가능한 소프트웨어를 만들기 위해서는 명확한 컨벤션이 필요하다. 그리고 이 컨벤션은 빌드 실패라는 강제성을 가질 때만 실효성 있게 지켜질 수 있다.
따라서 빌드 전 단계에서 수행되는 각종 검사 도구들을 통해 검증 가능한 규칙들을 마련해야 한다.
클래스 생성은 누구나 쉽게 할 수 있고, 그에 따른 객체 생성 방식도 매우 다양하게 구현될 수 있다. 경험상 이러한 다양성에 일관된 제약을 두는 것은 결코 쉬운 일이 아니다.
현재의 규칙이 완벽하든 그렇지 않든, 통일된 규칙의 존재는 향후 코드 수정을 용이하게 만들어 줄 것이다 (맨먼스 미신에서 언급된 ‘개념적 일관성’과 일맥상통한다). 끊임없는 변경과 싸우는 우리 개발자들이 객체 생성 방식을 깊이 고민하고 제약해야 하는 이유가 바로 여기에 있다.