MyBatis Unsupported conversion from LONG to java.sql.Timestamp 해결하기 (@Builder 어노테이션 고찰)

Belluga·2021년 8월 4일
3

문제 발생 💥

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderMapper orderMapper;

    public Order getById(long id) {
        return orderMapper.getById(id).orElseThrow(NotFoundOrderException::new);
    }
}

MyBatis Mapper 인터페이스를 통해 SELECT SQL 쿼리 명령을 수행할 때 Error attempting to get column 'id' from result set. Cause: java.sql.SQLDataException: Unsupported conversion from LONG to java.sql.Timestamp 에러가 발생하였습니다.

@Builder
@Getter
public class Order {

    private Long id;
    private OrderStatus status;
    private long ordererId;
    private String address;
    private List<OrderProduct> orderProductList;
    private Timestamp createDate;
}

그러나 데이터베이스 Order 테이블의 id 컬럼은 bigint 타입이었고 Order 클래스의 id 필드는 Long 타입이었기 때문에 Unsupported conversion from LONG to java.sql.Timestamp 에러가 발생할 이유가 없다고 생각하였습니다.

@Builder
@Getter
@NoArgsConstructor
public class Order {

    private Long id;
    private OrderStatus status;
    private long ordererId;
    private String address;
    private List<OrderProduct> orderProductList;
    private Timestamp createDate;
}


구글링을 통해 알아본 결과 문제가 되는 클래스에 기본 생성자를 추가하라는 답변을 발견하였고 위와 같이 기본생성자 추가시 해당 에러를 해결할 수 있었습니다.

왜 기본 생성자를 추가함으로써 문제를 해결할 수 있었을까 알아본 결과
MyBatis는 결과 객체의 인스턴스를 만들기 위해 ObjectFactory 를 사용하는데 이때 대상 클래스의 기본 생성자를 사용하기 때문이었습니다.

일반적으로 명시적인 생성자가 없는 경우 자바 컴파일러가 기본 생성자를 자동으로 추가하는 것으로 알고있는데 왜 기본 생성자가 존재하지 않았을까요?

바로 @Builder 어노테이션 때문이었습니다.

@Builder 어노테이션을 클래스에 적용하는 경우 명시적으로 생성자를 직접 작성하지 않았다면 모든 필드에 대한 package-private 생성자를 생성합니다.

@Builder 어노테이션으로 인해 생성되는 @AllArgsConstructor(access = AccessLevel.PACKAGE)으로 인해 기본 생성자가 존재하지 않았던 것입니다.

따라서 @NoArgsConstructor을 선언함으로써 기본 생성자를 생성하였습니다.

이렇게 문제가 해결되는듯 싶었지만 추가적인 수정이 필요했습니다.

@Builder와 @NoArgsConstructor를 함께 사용할 수 없다.

앞서 설명한 바와 같이 클래스에 @Builder 어노테이션을 적용하는 경우 all-args 생성자를 생성하고 all-args 생성자가 있다고 가정하고 이를 사용하는 코드를 생성합니다.

public static class OrderBuilder {
        private Long id;
        private OrderStatus status;
        private long ordererId;
        private String address;
        private List<OrderProduct> orderProductList;
        private Timestamp createdAt;

        OrderBuilder() {
        }

        public OrderBuilder id(Long id) {
            this.id = id;
            return this;
        }

        public OrderBuilder status(OrderStatus status) {
            this.status = status;
            return this;
        }

        public OrderBuilder ordererId(long ordererId) {
            this.ordererId = ordererId;
            return this;
        }

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

        public OrderBuilder orderProductList(List<OrderProduct> orderProductList) {
            this.orderProductList = orderProductList;
            return this;
        }

        public OrderBuilder createdAt(Timestamp createdAt) {
            this.createdAt = createdAt;
            return this;
        }

        public Order build() {
            return new Order(id, status, ordererId, address, orderProductList, createdAt);
        }

        public String toString() {
            return "Order.OrderBuilder(id=" + this.id + ", status=" + this.status + ", ordererId=" + this.ordererId + ", address=" + this.address + ", orderProductList=" + this.orderProductList + ", createdAt=" + this.createdAt + ")";
        }
}

내부 클래스 TBuilder의 build() 메서드에서 모든 파라미터를 갖는 생성자를 호출합니다.

즉, all-args 생성자가 없으면 컴파일러 오류가 발생합니다.
현 상황에서는 @NoArgsConstructor를 통해 기본 생성자가 존재하기 때문에 all-args 생성자를 생성하지 않아 문제가 됩니다.

이를 해결하기 위해 크게 두가지 해결책이 있습니다.

[1] @AllArgsConstructor 를 통해 all-args 생성자를 생성하기

[2] @Builder 어노테이션을 클래스 대신 생성자에 추가하기

[1]안의 경우 단순히 클래스에 어노테이션을 붙이면 된다는 간편함이 있지만 [2]안을 통해 얻을 수 있는 부수적인 장점이 더욱 크다고 생각하여 [2]안을 선택하였습니다.

@Builder 어노테이션을 클래스 대신 생성자에 추가하자

public Order build() { 
    // 사용자 지정 생성자 호출
    return new Order(status, ordererId, address, orderProductList);
}

사용자 지정 생성자에 @Builder 어노테이션을 선언하면
내부 클래스 TBuilder의 build() 메서드에서 해당 생성자를 호출합니다.

(1) 입력해선 안되는 필드를 제한할 수 있습니다.

Builder AllArgsConstructor의 경우 위 그림처럼 모든 멤버필드에 대한 매개변수 셋팅을 허용하게 됩니다.
이때 id값은 MySQL의 AUTO_INCREMENT를 사용하기 때문에 입력받으면 안되는 값이나 id값을 직접 셋팅할 수 있는 가능성을 열어두게 된다는 문제점이 발생합니다.

@Builder
public Order(OrderStatus status, long ordererId, String address, List<OrderProduct> orderProductList) {
    this.status = status;
    this.ordererId = ordererId;
    this.address = address;
    this.orderProductList = orderProductList;
}

받아야 하는 생성자를 필요조건에 따라 지정한 뒤 @Builder 어노테이션을 선언하면 입력해선 안되는 필드를 제한할 수 있습니다.

(2) 입력값에 대한 추가적인 검증을 진행할 수 있습니다.

위와 같이 빌더패턴을 통해 객체를 생성하는 경우 모든 필드가 null값인 객체가 생성됩니다. 즉 불완전한 객체를 생성할 수 있는 가능성이 있습니다.

@Builder
public Order(OrderStatus status, long ordererId, String address, List<OrderProduct> orderProductList) {
    Assert.notNull(status, "상태가 존재하지 않습니다");
    Assert.isTrue(ordererId > 0, "주문자 정보가 존재하지 않습니다");
    Assert.hasText(address, "주소가 존재하지 않습니다");

    this.status = status;
    this.ordererId = ordererId;
    this.address = address;
    this.orderProductList = orderProductList;
}

org.springframework.util 패키지에서 제공하는 Assert 클래스를 통해
애플리케이션 런타임에 데이터에 대해 입력값의 필수값 및 유효성 검증을 진행할 수 있습니다.

@NoArgsConstructor 접근 권한을 최소화 하자

마지막으로 @NoArgsConstructor 접근 권한을 최소화하여 아래 최종적인 코드를 작성하였습니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {

    private Long id;
    private OrderStatus status;
    private long ordererId;
    private String address;
    private List<OrderProduct> orderProductList;
    private Timestamp createDate;

    @Builder
    public Order(OrderStatus status, long ordererId, String address, List<OrderProduct> orderProductList) {
        Assert.notNull(status, "상태가 존재하지 않습니다");
        Assert.isTrue(ordererId > 0, "주문자 정보가 존재하지 않습니다");

        this.status = status;
        this.ordererId = ordererId;
        this.address = address;
        this.orderProductList = orderProductList;
    }

마지막으로 기본 생성자의 접근 권한을 PRIVATE 으로 제한합니다.
기본 생성자의 존재는 불완전한 객체를 생성할 수 있다는 가능성을 열어두는 것이기 때문에 굳이 외부에서 생성자를 열어둘 필요가 없습니다.

MyBatis에서 기본 생성자를 필요로 하지 않나요?

MyBatis에서 사용되는 Reflection 클래스에서 타깃 클래스들의 생성자 접근 옵션을 접근 가능으로 바꾸기 때문에 private 생성자의 접근이 가능합니다.

constructor.setAccessible(true);

References

https://github.com/spring-projects/spring-boot/issues/21300

https://cheese10yun.github.io/lombok/#null

https://www.popit.kr/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-lombok-%EC%82%AC%EC%9A%A9%EB%B2%95/

https://marobiana.tistory.com/119

1개의 댓글

comment-user-thumbnail
2022년 8월 8일

많은 도움이 되었습니다. 잘보고 갑니다.

답글 달기