빌더 패턴 적용하기 (게시판 검색 동적 쿼리)

junto·2024년 11월 16일
0

design-patterns

목록 보기
3/3
post-thumbnail

빌더 패턴

  • 빌더 패턴이란 객체의 생성 과정을 별도의 빌더 객체로 분리하여 유연하게, 단계별로 객체를 생성할 수 있게 하는 패턴이다.

필요한 이유

  • 객체를 생성할 때 초기화할 필드가 많으면 생성자 매개변수가 많아져 가독성이 떨어진다. 심지어 같은 타입의 필드를 초기화할 때 다른 생성자를 실수로 호출할 위험도 있다.
public User(String name, int age, String email, String phoneNumber, String address ...)

public User(String name, int age, String referer, String email, String PhoneNumber, String Address ...)
  • 생성자 매개변수에 의존성이 생기는 문제, 가독성이 떨어지는 문제를 해결하기 위해 빌더를 사용할 수 있다.

전체 코드: https://github.com/ji-jjang/Learning/commit/b0c39e36b8f339079d5539d0d3399489ef9dbfec

public class User {

	private String name;
    ...
    
    private class User() {} // private으로 제한할 수도 있음
    
    public static class Builder {
    	private String name;
        ...
        
        public Builder(String name) {
    		this.name = name;
        }
    
    	public Builder(int age) {
    		this.age = age;
        }
    	...
        public User build() {
            return new User(this);
        }
    }
}

public static void main(String[] args) {
    User user = new Builder("홍길동")
      .age(20)
      .email("juny@gmail.com")
      .address("seoul")
      .build();
}
  • Setter를 열어두지 않기 위해 Builder 클래스를 내부 클래스로 선언한다.

빌더 패턴 구조

  • 위와 같이 빌더를 구성해도 되지만, Director를 두어 좀 더 유연하게 사용할 수도 있다. 유키 히로시 디자인 패턴 책에 나온 구성을 참고해 보자.

1. Builder 인터페이스

public interface Builder {
  public void makeTitle(String title);
  public void makeString(String string);
  public void makeItems(String[] items);
  public void close();
}

2. Director (객체 생성 과정 관리)

public class Director {
  private Builder builder;
  public Director(Builder builder) {
    this.builder = builder;
  }
  public void construct() {
    builder.makeTitle("Greeting");
    builder.makeString("일반적인 인사");
    builder.makeItems(new String[]{
      "How are you?",
      "Hello.",
      "Hi."
    });
    builder.makeString("시간대별 인사");
    builder.makeItems(new String[]{
      "Good morning.",
      "Good afternoon.",
      "Good evening.",
    });
    builder.close();
  }
}

3. TextBuilder (Builder Interface 구현)

public class TextBuilder implements Builder {
  private StringBuilder sb = new StringBuilder();
  @Override
  public void makeTitle(String title) {
    sb.append("===============================\n");
    sb.append("[");
    sb.append(title);
    sb.append("]\n\n");
  }
  @Override
  public void makeString(String str) {
    sb.append("*");
    sb.append(str);
    sb.append("\n\n");
  }
  @Override
  public void makeItems(String[] items) {
    for (String s : items) {
      sb.append(".");
      sb.append(s);
      sb.append("\n");
    }
    sb.append("\n");
  }
  @Override
  public void close() {
    sb.append("================================\n");
  }
  public String getTextResult() {
    return sb.toString();
  }
}

4. HTML Builder(Builder Interface 구현)

public class HTMLBuilder implements Builder {
  private String filename = "untitled.html";
  private StringBuilder sb = new StringBuilder();
  @Override
  public void makeTitle(String title) {
    filename = title + ".html";
    sb.append("<!DOCTYPE html>\n");
    sb.append("<html>\n");
    sb.append("<head>\n\t<title>");
    sb.append(title);
    sb.append("</title>\n");
    sb.append("\t<meta charset=\"UTF-8\">\n");
    sb.append("</head>\n");
    sb.append("<body>\n");
    sb.append("<h1>");
    sb.append(title);
    sb.append("</h1>\n\n");
  }
  @Override
  public void makeString(String str) {
    sb.append("<p>");
    sb.append(str);
    sb.append("</p>\n\n");
  }
  @Override
  public void makeItems(String[] items) {
    sb.append("<ul>\n");
    for (String s: items) {
      sb.append("<li>");
      sb.append(s);
      sb.append("</li>\n");
    }
    sb.append("</ul>\n\n");
  }
  @Override
  public void close() {
    sb.append("</body>");
    sb.append("</html>\n");
    try {
      Writer writer = new FileWriter(filename);
      writer.write(sb.toString());
      writer.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
  public String getHTMLResult() {
    return filename;
  }
}

5. Main 함수

public class Main {
  public static void main(String[] args){
    
    TextBuilder textbuilder = new TextBuilder();
    Director director = new Director(textbuilder);
    director.construct();
    String result = textbuilder.getTextResult();
    System.out.println(result);
    
    HTMLBuilder htmlbuilder = new HTMLBuilder();
    director = new Director(htmlbuilder);
    director.construct();
    String filename = htmlbuilder.getHTMLResult();
    System.out.println("HTML파일 " + filename + "이 작성되었습니다.");
  }
}
  • Director는 빌더를 사용하여 객체 생성 과정을 관리하고, Builder 인터페이스를 구현한 빌더들은 자신의 목적에 맞는 객체를 생성하는 것을 볼 수 있다.

순수 JDBC에서 게시글 검색

  • 등록일과 제목, 내용, 작성자를 입력하면 게시글을 검색해서 게시글을 가져오는 게시판을 생각해보자. jdbc를 사용한다면 아래와 같은 코드를 작성할 수 있다.

전체 코드: https://github.com/ji-jjang/ebrainsoft/tree/main/JspBoard

  @Override
  public List<Board> getBoardSearchList(int page, Map<String, String> searchConditions) {

    List<Board> boards = new ArrayList<>();

    StringBuilder sql =
        new StringBuilder(
            """
        SELECT
          b.id, b.title, b.view_count, b.created_at, b.updated_at
        FROM
          boards b
        LEFT JOIN
          categories c
        ON
          b.category_id = c.id
        """);

    int limit = Constants.BOARD_LIST_PAGE_SIZE;
    int offset = (page - 1) * limit;

    boolean hasWhere = false;

    if (searchConditions.containsKey(Constants.START_DATE)
        && searchConditions.containsKey(Constants.END_DATE)) {
      sql.append("WHERE b.created_at BETWEEN ? AND ? ");
      hasWhere = true;
    }

    if (searchConditions.containsKey(Constants.CATEGORY)) {
      String connector = hasWhere ? "AND" : "WHERE";
      sql.append(connector).append(" c.name = ? ");
    }

    if (searchConditions.containsKey(Constants.KEYWORD)) {
      String connector = hasWhere ? "AND" : "WHERE";
      sql.append(connector).append(" (b.title LIKE ? OR b.created_by LIKE ? OR b.content LIKE ?) ");
    }
    sql.append(" ORDER BY b.created_at DESC ");
    sql.append(" LIMIT ? OFFSET ?");

    try (Connection conn = DriverManagerUtils.getConnection();
        PreparedStatement pstmt = conn.prepareStatement(sql.toString())) {

      int index = 0;

      if (searchConditions.containsKey(Constants.START_DATE)
          && searchConditions.containsKey(Constants.END_DATE)) {
        pstmt.setString(
            ++index, searchConditions.get(Constants.START_DATE) + Constants.START_DATE_START_TIME);
        pstmt.setString(
            ++index, searchConditions.get(Constants.END_DATE) + Constants.END_DATE_END_TIME);
      }

      if (searchConditions.containsKey(Constants.CATEGORY)) {
        pstmt.setString(++index, searchConditions.get(Constants.CATEGORY));
      }

      if (searchConditions.containsKey(Constants.KEYWORD)) {
        String keyword =
            Constants.PERSENT_SIGN
                + searchConditions.get(Constants.KEYWORD)
                + Constants.PERSENT_SIGN;
        pstmt.setString(++index, keyword);
        pstmt.setString(++index, keyword);
        pstmt.setString(++index, keyword);
      }

      pstmt.setInt(++index, limit);
      pstmt.setInt(++index, offset);

      try (ResultSet rs = pstmt.executeQuery()) {
        while (rs.next()) {
          boards.add(
              new Board(
                  rs.getLong(Constants.ID_COLUMN),
                  rs.getString(Constants.TITLE_COLUMN),
                  rs.getString(Constants.CONTENT_COLUMN),
                  rs.getString(Constants.PASSWORD_COLUMN),
                  rs.getInt(Constants.VIEW_COUNT_COLUMN),
                  rs.getTimestamp(Constants.CREATED_AT_COLUMN).toLocalDateTime(),
                  rs.getString(Constants.CREATED_BY_COLUMN),
                  rs.getTimestamp(Constants.UPDATED_AT_COLUMN) != null
                      ? rs.getTimestamp(Constants.UPDATED_AT_COLUMN).toLocalDateTime()
                      : null,
                  rs.getLong(Constants.CATEGORY_ID_COLUMN)));
        }
      }
    } catch (SQLException | ClassNotFoundException e) {
      throw new RuntimeException(e);
    }
    return boards;
  }
  • 복잡해보일 수 있지만, 조건에 따라 쿼리를 붙이는 부분만 이해하면 된다. QueryBuilder를 만들어 빌드 패턴을 적용해 볼 수 있지 않을까? 여기서 BoardDAO는 위 구조에서 Director 역할을 하며, 객체 생성 책임(쿼리)을 지니는 QueryBuilder를 만들어보자.
public class QueryBuilder {

  private final StringBuilder sql;
  private final List<Object> parameters;

  private QueryBuilder(String baseQuery) {
    this.sql = new StringBuilder(baseQuery);
    this.parameters = new ArrayList<>();
  }

  public static QueryBuilder create(String baseQuery) {
    return new QueryBuilder(baseQuery);
  }

  public QueryBuilder addCondition(String condition, Object... params) {
    if (!Objects.isNull(condition)) {
      sql.append(parameters.isEmpty() ? " WHERE " : " AND ").append(condition);
      if (!Objects.isNull(params)) {
        parameters.addAll(Arrays.asList(params));
      }
    }
    return this;
  }

  public QueryBuilder orderBy(String orderBy) {
    if (!Objects.isNull(orderBy)) {
      sql.append(" ORDER BY ").append(orderBy);
    }
    return this;
  }

  public QueryBuilder limitOffset(int limit, int offset) {
    sql.append(" LIMIT ? OFFSET ?");
    parameters.add(limit);
    parameters.add(offset);
    return this;
  }

  public String buildQuery() {
    return sql.toString();
  }

  public List<Object> getParameters() {
    return parameters;
  }
}
  @Override
  public List<Board> getBoardList(int page, Map<String, String> searchConditions) {

    List<Board> boards = new ArrayList<>();

    String baseQuery = """
        SELECT
          b.id, b.title, b.content, b.view_count, b.created_at, b.created_by, b.updated_at, b.category_id
        FROM
          boards b
        LEFT JOIN
          categories c ON b.category_id = c.id
        """;

    int limit = Constants.BOARD_LIST_PAGE_SIZE;
    int offset = (page - 1) * limit;

    QueryBuilder queryBuilder = QueryBuilder.create(baseQuery);

    String startDate = searchConditions.getOrDefault(Constants.START_DATE, TimeFormatterUtils.getDefaultStartDate()) + Constants.START_DATE_START_TIME;
    String endDate = searchConditions.getOrDefault(Constants.END_DATE, TimeFormatterUtils.getDefaultEndDate()) + Constants.END_DATE_END_TIME;
    queryBuilder.addCondition("b.created_at BETWEEN ? AND ?", startDate, endDate);

    if (searchConditions.containsKey(Constants.CATEGORY)) {
      queryBuilder.addCondition("c.name = ?", searchConditions.get(Constants.CATEGORY));
    }

    if (searchConditions.containsKey(Constants.KEYWORD)) {
      String keyword = Constants.PERSENT_SIGN + searchConditions.get(Constants.KEYWORD) + Constants.PERSENT_SIGN;
      queryBuilder.addCondition("(b.title LIKE ? OR b.created_by LIKE ? OR b.content LIKE ?)",
        keyword, keyword, keyword);
    }

    queryBuilder.orderBy("b.created_at DESC").limitOffset(limit, offset);

    String sql = queryBuilder.buildQuery();
    List<Object> parameters = queryBuilder.getParameters();

    try (Connection conn = DriverManagerUtils.getConnection();
      PreparedStatement pstmt = conn.prepareStatement(sql)) {

      for (int i = 0; i < parameters.size(); i++) {
        pstmt.setObject(i + 1, parameters.get(i));
      }

      try (ResultSet rs = pstmt.executeQuery()) {
        while (rs.next()) {
          boards.add(
            new Board(
              rs.getLong(Constants.ID_COLUMN),
              rs.getString(Constants.TITLE_COLUMN),
              rs.getString(Constants.CONTENT_COLUMN),
              null,
              rs.getInt(Constants.VIEW_COUNT_COLUMN),
              rs.getTimestamp(Constants.CREATED_AT_COLUMN).toLocalDateTime(),
              rs.getString(Constants.CREATED_BY_COLUMN),
              rs.getTimestamp(Constants.UPDATED_AT_COLUMN) != null
                ? rs.getTimestamp(Constants.UPDATED_AT_COLUMN).toLocalDateTime()
                : null,
              rs.getLong(Constants.CATEGORY_ID_COLUMN)));
        }
      }
    } catch (SQLException | ClassNotFoundException e) {
      throw new RuntimeException(e);
    }
    return boards;
  }
  • 예외 처리를 빼놓고 보면, 큰 차이가 없어 보이지만 조건을 처리하는 로직을 Builder가 담당하니 단순해진다. 이렇게 QueryBuilder를 한 번 만들어두면 검색 조건이 있는 다른 쿼리에 대해서 적용할 수 있다. 전체적으로 쿼리를 붙이는 로직이 QueryBuilder에 집중되니 DAO나 Service 계층은 단순해진다.
  • 만약에 어떤 시스템에선 JDBC가 아니라 QueryDsl을 사용한다면? 위의 예시에서 나온 구조로 확장하여 사용할 수도 있을 것이다.
public class SQLQueryBuilder implements QueryBuilder {
}

public class QueryDSLQueryBuilder implements QueryBuilder {
}
  • 물론 QueryDSl 자체가 타입 안전한 쿼리 빌더이기 때문에 실제로 사용 가능한 구조는 아니지만 이렇게 생각해볼 수도 있다.
profile
꾸준하게

0개의 댓글