SQL mapper을 객체지향적으로 동적 쿼리를 작성하는 방법과 예제 (mybatis와 함께)

들어가며

  • SQL mapper는 DB에 제출할 쿼리를 동적으로 생성시키는 기술이다. java-spring 진영에서는 Mybatis와 JDBCTemplate를 동적쿼리를 위한 기술로 주로 사용한다. ORM 기술인 JPA 또한 EntityManager#createQuery와 @Query의 형태로 동적 쿼리를 지원한다.
  • JPA는 객치지향 기술이며 객체지향적인 방향으로 개발을 유도한다. 반대로 SQL Mapper의 경우 DB에서 파싱된 데이터는 단순한 데이터로서의 DTO로 반환하고 절차지향적인 방식으로 다룬다. 비지니스로직을 자바 코드로 구현되는 것이 아닌 쿼리로 구현되는 것을 자주 볼 수 있고, 이로 인하여 길게는 천 줄짜리의 복잡한 쿼리도 쉽게 찾아볼 수 있다.
  • 다만, SQL Mapper를 사용해야 하는 상황에서 작성 방식을 잘 고려한다면 충분하게 좋은 방법으로 개발할 수 있다고 생각한다.
  • 아래는 update 쿼리를 작성할 때 mybatis를 사용하며 작성해왔던 동적 쿼리를 5개의 패턴으로 분류하였다. 그리고 각 방식을 평가하고자 한다. (참고로 아래 코드가 실제로 동작하는지는 확인하지 않았다.)

예제로 사용하는 객체와 insert 쿼리

<insert id="save">
    insert into product(product_name, price, stock, reg_id, reg_dt) 
    values (#{productName}, #{price}, #{stock}, #{regId}, now())
</insert>
public interface ProductMapper{
    void save(Product product);
}

public class Product{
    private Long productId;
    private String productName; 
    private int price; 
    private int stock; 
    private String regId;
    private LocalDateTime regDt;
    private String modId;
    private LocalDateTime modDt;
}
  • 처음의 XML파일은 mybatis의 동적 쿼리이다. 다음의 자바 클래스는 해당 쿼리를 바인딩 받기 위한 클래스이다. 위의 예제를 기반으로 update 쿼리를 어떻게 작성할까?

1. 인자에 필요로한 데이터 타입을 나열 한다.

  • 상품의 재고를 늘린다면 필요로 한 값은 변경될 재고(stock)과 변경의 대상이 되는 레코드의 PK(productId)이다.
  • 해당 쿼리에 필요로 한 데이터 타입을 나열한다.
<insert id="updateStock">
    update product
    set 
        stock = #{stock}
        , modId = #{modId}
        , modDt = now()
    where product_id = #{productId}
</insert>
public interface ProductMapper{
    void updateStock(Long productId, int stock, String modId); 
}
  • 가장 단순하고 의도 또한 명확하다. 하지만 이 방식은 두 가지 문제가 있다. 첫 번째로 아래와 같이 요구사항이 늘어날 수록 복잡해진다. 비슷한 메서드가 많고 인자도 복잡하다. 두 번째로 자신의 인자 자체를 노출하는 것은 변경에 취약해지는 것과 다름없으며 이는 OCP를 위반한다.
public interface ProductMapper{
    void updateStock(Long productId, int stock, String modId); 
    void updatePrice(Long productId, int price, String modId);
    void updateStockAndPrice(Long productId, int price, int stock, String modId);
    void updateDetail(Long productId, String productName, int price, int stock, String modId);
}

2. 인자를 하나의 타입으로 통일한다.

  • 복잡한 인자를 하나의 타입으로 통일하자. 예를 들면 해당 도메인의 엔티티인 Product를 사용하여 아래와 같은 코드를 작성할 수 있다.
public interface ProductMapper{
    void updateStock(Product product); 
    void updatePrice(Product product);
    void updateStockAndPrice(Product product);
    void updateDetail(Product product);
}
  • 다만, 코드를 읽는 클라이언트 개발자 입장에서 동적 쿼리에 의존하는 문제가 발생한다. 왜냐하면 updateStock과 updatePrice는 그것의 의도를 가지지만 정확하게 Product의 어떤 필드가 update 쿼리를 할 때 사용될 지는 모르기 때문이다. 결국 동적쿼리를 읽고 필요로한 필드가 무엇인지 확인해야 한다. 메서드에 대한 신뢰성이 떨어지며 이는 유지보수 비용의 증대로 이어진다.

3. 매서드마다 각자의 타입을 가진다.

  • 하는 김에 장황하더라도 명확하게 할 수 있다. 각 메서드마다 별도의 파라미터 타입을 정의할 수 있다.
public interface ProductMapper{
    void updateStock(ProductUpdateStockRequest req); 
    void updatePrice(ProductUpdatePriceRequest req);
    void updateStockAndPrice(ProductUpdateStockAndPriceRequest req);
    void updateDetail(ProductUpdateDetailRequest req);
}
  • 장점은 분명하다. 해당 타입이 해당 매서드에 헌신적이다. 클래스에 정의된 모든 필드가 해당 쿼리에 사용하는 값임을 보장한다. 파라미터 타입의 모든 메서드는 해당 쿼리의 정상동작을 보장하기 위한 로직으로 채울 수 있따. 불변 객체로 만들기도 용이하다. 헌신적이므로 SRP를 지킨다. 수정 및 유지보수에 유리하고 저렴하다.
  • 다만, 매서드마다 클래스를 작성해야 하므로 장황하다.

4. <if> 태그를 활용하여 좀 더 복잡한 동적인 쿼리를 작성한다.

  • 하나의 테이블을 갱신하는데 매서드 여러 개와 여러 개의 파라미터 타입을 복잡하게 섞는 것에 회의를 가질 수도 있다. 이런 경우 차라리 하나의 매서드에 하나의 인자만 사용하는 것이 나을 수도 있다.
  • 게다가 mybatis는 동적 쿼리를 작성하는데 필요로한 다양한 기능을 제공한다. 그 중 하나는 <if> 태그로서 특정 조건을 만족할 때 해당 부분을 쿼리에 추가한다. 이를 통해 동적 쿼리를 극대화 할 수 있다.
public interface ProductMapper{
    void update(Product product); 
}
public void execute(...args){
    // 나는 stock만 변경한다! 그럼 나머지 값은 null을 삽입한다.
    productMapper.update(new Product(productId, null, null, stock, null, modId)); 
}
<insert id="updateStock">
    update product
    set
        <if test="stock!=null and !stock.equals('')">
        stock = #{stock}
        </if>
        <if test="price!=null and !price.equals('')">
        price = #{price}
        </if>
        <if test="productName!=null and !productName.equals('')">
        product_name = #{productName}
        </if>
        , modId = #{modId}
        , modDt = now()
    where product_id = #{productId}
</insert>
  • 위 코드의 치명적인 문제를 가지는데, null 그 자체를 삽입할 수 없다. 쿼리 또한 다소 장황하고 읽기 어렵다.

5. select 후 update 하기

  • 마지막 방법으로서 수정 가능한 칼럼을 정의하고, update할 때 해당 칼럼을 일괄적으로 수정해버린다. 예를 들면 아래와 같다.
<insert id="update">
    update product
    set <!-- 아래의 칼럼은 변경 가능한 한 칼럼이라고 볼 수 있다. -->
        stock = #{stock}
        , price = #{price}
        , product_name = #{productName} 
        , modId = #{modId}
        , modDt = now()
    where product_id = #{productId}
</insert>
  • select을 먼저 수행하여 필드를 채운다. 그리고 필요로 한 로직을 수행한 후 update를 수행한다.
void updateStock(int updateId, int stock){
    Product product = productMapper.getOne(updateId);
    product.changeStock(stock); 
    productMapper.update(product);
} 

정리 - 서비스 객체와 리포지토리 객체는 결합도를 낮춰야 한다

  • 이번 논의에서 JPA의 객체지향은 “객체지향적”인 개발과 “객체”를 영속화 하는 기술로 구분하여 논의할 필요가 있다. JPA는 객체를 다루는 것처럼 엔티티를 다루더라도, 로직의 종료 직전에 자동으로 DB에 갱신을 한다. 이를 위한 기술이 영속성 컨텍스트, 더티체킹, 쓰기 지연이다. 영속화를 위한 리포지토리로의 방향성이 존재하지 않는다. 이로 인하여 JPA를 사용할 경우 객체지향과 관계 없이 편리하고 빠른 개발이 가능하다.
  • Sql Mapper로 데이터를 영속화할 때는 JPA와 달리 서비스에서 리포지토리의 데이터 전달은 피할 수 없다. 객체가 서비스와 리포지토리 빈으로 분리된 상태이며 더 넓게 보면 서비스 레이어와 리포지토리 레이어로 분리 된 상태이다. 두 개의 빈은 별도의 객체이며 두 객체 간 결합도를 낮춰야 한다. 이런 개념으로 접근하게 될 경우 우리는 2, 4, 5번의 방식을 사용해서는 안된다. 리포지토리는 그 자체로 기능을 가져야 하나, 4와 5번의 방식은 메서드와 인자만으로 해당 기능이 무엇인지 이해할 수 없다. 그저 update 쿼리를 사용한다는 내용 이외에는 어떤 정보도 존재하지 않는다.
  • 이와 달리 3번은 그것의 역할이 명확하고 SRP를 지킨다. ProductUpdateStockRequest 타입을 인자로 하는 updateStock() 메서드는 그 자체로 어떤 기능을 하는지 설명한다. 데이터 타입의 초기화 과정에서 유효성 검토 등 내부 로직을 구현할 수 있다. 더 나아가 장기적으로 유지보수할 때 유리하다. 리포지토리에 사용하는 데이터 타입의 필드는 결과적으로 DB에 삽입할 칼럼으로 변환되는데, 해당 쿼리에 맞춰 정확하게 일치하는 필드를 가질 경우 코드를 이해하는데 매우 유리하다. 하나의 타입을 애매하게 여러 리포지토리 메서드에서 사용할 경우, 정작 필요한 필드가 무엇인지 몰라 동적 쿼리를 번갈아가며 확인해야 하는 불상사가 발생한다. 3번은 이런 문제를 원천적으로 차단한다.
  • 다만, 구현의 편의성 때문에 1번을 사용하기도 한다.
  • 더하여, 엔티티(Product)를 리포지토리에서 꺼내고 이를 수정하는 로직을 수행할 경우 2번과 5번의 중간의 형태를 선택하기도 한다. 예를 들면 아래의 코드처럼 select으로 호출하여 완전한 엔티티 객체를 생성한 후, 비지니스 로직에 따라 객체가 로직을 수행한 후, 구체적인 메서드 명을 가진 update 메서드에 그 결과를 DB에 저장할 때 사용한다.
public void purchase(PurchaseRequest req){
    Product product = productMapper.findByProductId(req.getProductId());
    // 판매에 필요로 한 추가 기능을 수행
    product.sell(req);
    productMapper.updateSoldResult(product);
    // 이하 부가적인 로직 수행
}
  • 결과적으로 Sql Mapper는 JPA가 “객체”를 영속화하는 방식을 구현하기는 어려워 보인다. 이보다는 서비스와 리포지토리 간 객체지향을 지키는 개발을 고려하는 것이 올바르게 보인다.