[Java] Fluent API란? (feat. JDBC에 적용해보기)

 

 

 

Fluent API는 API의 패턴 중 하나이다.

Fluent API는 메서드 체이닝 형태로 설계된 API이며, 그 목표는 도메인별 언어 (DSL)를 생성하여 코드 가독성을 높이는 것이다.

이 용어는 Eric EvansMartin Fowler 가 2005년에 만들어냈다고 한다.

 

Fluent API를 사용하는 대표적인 라이브러리로 AssertJ가 존재한다.

이전에는 Junit을 통해 하나의 메서드로 처리해야 했던 코드를

Fluent API 패턴을 적용한 AssertJ를 통해, 직관적인 형태로 메서드를 풀어내어 테스트 코드를 작성할 수 있도록 구현되어 있다.

 

 

// Junit
Assertions.assertTrue(true);

// assertJ
Assertions.assertThat(true).isTrue();

// ===

// Junit
 Assertions.assertEquals(object1,object2);

 // assertJ
 Assertions.assertThat(object1).isEqaulTo(object2);

 

하나의 메서드를 통해 테스트를 수행하던 이전 Junit이 아닌

Fluent API로 구현된 assertJ를 사용함으로써 테스트 질의를 다양화하는데에 도움을 얻고,

쉽고 빠르게 코드를 이해할 수 있게 되었다.

 

확실히 Junit보다 assertJ를 사용한 코드가 더 직관적임을 확인할 수 있다.

 

Fluent는 ‘유창한’ 이라는 의미처럼 메서드 체이닝 방식을 통해 자신의 행위에 대해 유창하게 표현할 수 있다.

이러한 Fluent API 패턴을 우테코 미션을 수행하는 과정에서 적용해보게 되어 그 기록을 남겨보고자 한다.

 

 


 

적용 배경

 

@Override
    public Long add(Connection conn, GameEntity game) throws SQLException {
        try {
            PreparedStatement preparedStatement = conn.prepareStatement(
                    "INSERT INTO game (turn) VALUES(?)",
                    Statement.RETURN_GENERATED_KEYS
            );
            preparedStatement.setString(1, game.getTurn().now().name());
            preparedStatement.executeUpdate();

            ResultSet generatedKeys = preparedStatement.getGeneratedKeys();
            generatedKeys.next();

            return generatedKeys.getLong(1);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

본인은 Java, JDBC를 사용해 DB 접근을 수행해야하는 우테코 체스 미션을 수행하면서, 위와 같은 DAO 클래스의 로직을 만들게 되었다.

 

JDBC를 사용하는 로직은 위와 같이 연결된 Connection을 통해 PreparedStatement를 얻고

여기에 쿼리를 설정하여 execute 메서드를 통해 DB에 요청을 보내는 구조이다.

 

 

리뷰어 알렉스의 리뷰

이번 미션에서 리뷰어를 맡아주신 알렉스는 이 코드를 보고 비슷한 패턴이 반복되는 DAO 클래스 메서드들의 개선을 제안했다.

이 리뷰에 대해 본인은 “함수형 프로그래밍 등을 사용하면 처리할 수 있지 않을까?” 라고 생각하였지만 그렇지 않았다.

 

패턴은 비슷하지만 완전 중복되는 로직이 아니라 중간중간 다르게 작동되어야하는 부분이 많았다.

예를 들어 PreparedStatement를 사용하면 쿼리를 입력하고 이후에 setString, setLong 등을 통해 쿼리에 사용되는 값들을 주입해주어야 했는데 이러한 처리 하나하나를 분리하는 것이 굉장히 복잡하게 느껴졌다.

 

PreparedStatement 자체를 생성하는 것도 Create 로직은 AUTO_INCREMENT Key 설정을 적용해두었기 때문에 이를 알아내어 사용하기 위해 Statement.RETURN_GENERATED_KEYD 설정을 적용해주어야했다.

 

다른 부분이 굉장히 많은 로직에서 중복을 제거하기란 쉽지 않았다.

이때 문뜩 Query 작성을 메서드 체이닝 방식을 기반으로 편리하게 만들어준 QueryDSL이 떠올랐다.

 

본인은 지금 겪고있는 문제에 대해 메서드 체이닝 방식을 적용하여 중복코드를 최대한 제거하고,

코드의 가독성을 높여보고자 Fluent API 패턴을 JDBC 사용에 적용해보고자 한다.

 

 


 

 

구조

 

  • QueryManager: Connection 연결을 담당
    • ConnectedQueryManager: Connection이 연결된 상태로 insert, select, update, delete 중 하나를 선택하고 쿼리를 입력받음
      • CrudQueryManager: CRUD 각각의 쿼리 매니저의 중복로직을 가지고 있음
        • InsertQueryManager: Create 쿼리 수행을 담당
          • AfterExecuteQueryManager: Create 쿼리 수행 이후, Auto_Increment로 생성된 키값을 리턴하는 로직을 담당
        • SelectQueryManager: Read 쿼리 수행을 담당
        • UpdateQueryManager: Update 쿼리 수행을 담당
        • DeleteQueryManager: Delete 쿼리 수행을 담당

 

 

코드 보기

 

QueryManager

import java.sql.Connection;

public class QueryManager {

    public static ConnectedQueryManager setConnection(final Connection conn) {
        return new ConnectedQueryManager(conn);
    }
}

 

ConnectedQueryManager

import java.sql.Connection;
import java.sql.SQLException;

public class ConnectedQueryManager {
    private Connection conn;

    public ConnectedQueryManager(final Connection conn) {
        this.conn = conn;
    }

    public InsertQueryManager insert(final String query) throws SQLException {
        return new InsertQueryManager(conn, query);
    }

    public SelectQueryManager select(final String query) throws SQLException {
        return new SelectQueryManager(conn, query);
    }

    public UpdateQueryManager update(final String query) throws SQLException {
        return new UpdateQueryManager(conn, query);
    }

    public DeleteQueryManager delete(final String query) throws SQLException {
        return new DeleteQueryManager(conn, query);
    }
}

 

CrudQueryManager

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

sealed class CrudQueryManager permits InsertQueryManager, SelectQueryManager, UpdateQueryManager, DeleteQueryManager {
    private final PreparedStatement pstmt;

    protected CrudQueryManager(final PreparedStatement pstmt) {
        this.pstmt = pstmt;
    }

    protected void setStringParameter(final int paramIndex, final String value) throws SQLException {
        this.pstmt.setString(paramIndex, value);
    }

    protected void setLongParameter(final int paramIndex, final Long value) throws SQLException {
        this.pstmt.setLong(paramIndex, value);
    }

    protected void setIntParameter(final int paramIndex, final int value) throws SQLException {
        this.pstmt.setInt(paramIndex, value);
    }

    protected ResultSet executeQuery() throws SQLException {
        return pstmt.executeQuery();
    }

    protected void executeUpdate() throws SQLException {
        pstmt.executeUpdate();
    }

    public PreparedStatement getPreparedStatement() {
        return pstmt;
    }
}

 

InsertQueryManager

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.regex.Pattern;

public final class InsertQueryManager extends CrudQueryManager {
    private static final Pattern INSERT_PATTERN = Pattern.compile("(?i)^insert\\s.*");

    public InsertQueryManager(final Connection conn, final String query) throws SQLException {
        super(conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS));
        validate(query);
    }

    private void validate(final String query) {
        if (!INSERT_PATTERN.matcher(query).matches()) {
            throw new IllegalArgumentException("삽입 쿼리는 INSERT 구문으로 시작해야합니다.");
        }
    }

    public InsertQueryManager setString(final int paramIndex, final String value) throws SQLException {
        setStringParameter(paramIndex, value);
        return this;
    }

    public InsertQueryManager setLong(final int paramIndex, final Long value) throws SQLException {
        setLongParameter(paramIndex, value);
        return this;
    }

    public AfterExecuteQueryManager execute() throws SQLException {
        super.executeUpdate();
        return new AfterExecuteQueryManager(super.getPreparedStatement());
    }
}

 

AfterExecuteQueryManager

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class AfterExecuteQueryManager {

    private final PreparedStatement pstmt;

    public AfterExecuteQueryManager(final PreparedStatement pstmt) {
        this.pstmt = pstmt;
    }

    public ResultSet getGeneratedKeys() throws SQLException {
        return pstmt.getGeneratedKeys();
    }
}

 

SelectQueryManager

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.regex.Pattern;

public final class SelectQueryManager extends CrudQueryManager {
    private static final Pattern SELECT_PATTERN = Pattern.compile("(?i)^select\\s.*");

    public SelectQueryManager(final Connection conn, final String query) throws SQLException {
        super(conn.prepareStatement(query));
        validate(query);
    }

    private void validate(final String query) {
        if (!SELECT_PATTERN.matcher(query).matches()) {
            throw new IllegalArgumentException("조회 쿼리는 SELECT 구문으로 시작해야합니다.");
        }
    }

    public SelectQueryManager setString(final int paramIndex, final String value) throws SQLException {
        setStringParameter(paramIndex, value);
        return this;
    }

    public SelectQueryManager setLong(final int paramIndex, final Long value) throws SQLException {
        setLongParameter(paramIndex, value);
        return this;
    }

    public ResultSet execute() throws SQLException {
        return super.executeQuery();
    }
}

 

UpdateQueryManager

import java.sql.Connection;
import java.sql.SQLException;
import java.util.regex.Pattern;

public final class UpdateQueryManager extends CrudQueryManager {
    private static final Pattern UPDATE_PATTERN = Pattern.compile("(?i)^update\\s.*");

    public UpdateQueryManager(final Connection conn, final String query) throws SQLException {
        super(conn.prepareStatement(query));
        validate(query);
    }

    private void validate(final String query) {
        if (!UPDATE_PATTERN.matcher(query).matches()) {
            throw new IllegalArgumentException("수정 쿼리는 UPDATE 구문으로 시작해야합니다.");
        }
    }

    public UpdateQueryManager setString(final int paramIndex, final String value) throws SQLException {
        setStringParameter(paramIndex, value);
        return this;
    }

    public UpdateQueryManager setLong(final int paramIndex, final Long value) throws SQLException {
        setLongParameter(paramIndex, value);
        return this;
    }

    public void execute() throws SQLException {
        super.executeUpdate();
    }
}

 

DeleteQueryManager

import java.sql.Connection;
import java.sql.SQLException;
import java.util.regex.Pattern;

public final class DeleteQueryManager extends CrudQueryManager {
    private static final Pattern DELETE_PATTERN = Pattern.compile("(?i)^delete\\s.*");

    public DeleteQueryManager(final Connection conn, final String query) throws SQLException {
        super(conn.prepareStatement(query));
        validate(query);
    }

    private void validate(final String query) {
        if (!DELETE_PATTERN.matcher(query).matches()) {
            System.out.println(query);
            throw new IllegalArgumentException("삭제 쿼리는 DELETE 구문으로 시작해야합니다.");
        }
    }

    public DeleteQueryManager setString(final int paramIndex, final String value) throws SQLException {
        setStringParameter(paramIndex, value);
        return this;
    }

    public DeleteQueryManager setLong(final int paramIndex, final Long value) throws SQLException {
        setLongParameter(paramIndex, value);
        return this;
    }

    public void execute() throws SQLException {
        super.executeUpdate();
    }
}

 

 


 

 

적용 전/후 비교

 

Before

@Override
    public Long add(Connection conn, GameEntity game) throws SQLException {
        try {
            PreparedStatement preparedStatement = conn.prepareStatement(
                    "INSERT INTO game (turn) VALUES(?)",
                    Statement.RETURN_GENERATED_KEYS
            );
            preparedStatement.setString(1, game.getTurn().now().name());
            preparedStatement.executeUpdate();

            ResultSet generatedKeys = preparedStatement.getGeneratedKeys();
            generatedKeys.next();

            return generatedKeys.getLong(1);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

 

 

After

public Long add(Connection conn, GameEntity game) throws SQLException {
        ResultSet generatedKeys = QueryManager
                .setConnection(conn)
                .insert("INSERT INTO game (turn) VALUES(?)")
                .setString(1, game.getTurn().now().name())
                .execute()
                .getGeneratedKeys();

        generatedKeys.next();
        return generatedKeys.getLong(1);
    }

 

확실히 이전보다 JDBC 코드를 다루는 것이 간편해졌으며,
메서드 체이닝 형식을 사용하니, 이전보다 직관적이고 가독성 높은 코드로 변화했다는 생각이 든다.

 

역할 분리 및 구현을 위해 Fluent API 패턴 적용을 위한 클래스가 많이 생성된다는 단점이 존재하지만 그 부분을 제외하고는 중복로직도 상당히 제거되고 가독성이 증가하는 것이 굉장히 맘에 들었다.

 

학습하는 과정에서 배운 내용을 적용해보는 행동은 항상 즐겁고 의미있는 것 같다.

그리고 이렇게 학습한 것들을 모두 시도해보고 도전해볼 수 있는 학습 환경을 제공하고 만들어주는 우테코에게도 굉장히 감사한다!

 


Reference

Fluent interface