2차 과제 수행기

Nine-JH·2023년 9월 4일
0
post-custom-banner

전체 아키텍처

  • 이전 과제와 마찬가지로 모듈화를 진행했습니다.
  • 검색 유스케이스인 SearchBookUseCase같은 경우 HttpClient, Persistence 모두 사용할 수 있을 것 같았으나 각각 변경의 관심사가 달라 분리하게 되었습니다. xxxFromClientUsecase, xxxFromPersistenceUseCase


개발 중점사항

1. API 요청 어댑터 캡슐화

1) 수정 전

먼저 이전 과제의 코드를 보여줄 필요성이 있을것 같네요.

KakaoApiAdater.class

public List<KeywordApiResponse> findLocationByKeyword(KeywordApiCondition searchCondition) {
        try {
            URI requestURI = createKakaoLocalApiRequestUri(searchCondition);

            HttpUriRequest httpRequest = createRequest(requestURI);

            try (CloseableHttpResponse httpResponse = HttpClients.createDefault()
                .execute(httpRequest)) {
                return KakaoLocalResponseParser.parse(httpResponse,
                    KakaoApiAdapter::extractKeywordResponseFromApiResponse);
            }
        } catch (IOException | URISyntaxException e) {
            throw new HttpClientException(500, "internalError", e.getMessage());
        }
}

API로 요청하는 KakaoApiAdapter 클래스입니다. 그럭저럭 리팩토링을 진행하기는 했으나, 몇가지 문제점이 보였습니다.

  • HttpClient라는 기술에 의존적이다.
  • Adapter에 부여된 역할이 많다. (Http 요청과 + Response 처리 및 파싱)

2) 리팩토링

위의 두 문제를 해결하기 위해서는 Adapter 클래스에 HttpClient와 같이 RestAPI를 요청하는 역할을 가지는 객체를 만들 필요성이 필요했습니다.

public interface ApiFactory {

    <T> T requestGetForEntity(Map<String, String> queryParameterMap, TypeToken<T> responseType);
}

그래서 위 처럼 Http 통신 기술을 추상화시킨 ApiFactory 인터페이스를 생성해 이를 구현하도록 설정했습니다.


위 인터페이스의 구현체 중 apache.HttpClient 을 사용하는 경우를 보면 다음과 같습니다.

public abstract class HttpClientsApiFactory implements ApiFactory {

   ...

    protected HttpClientsApiFactory(String uriResourcePath) {
        requestUri = PropertiesUtils.getInstance().get(uriResourcePath);
    }

    @Override
    public <T> T requestGetForEntity(Map<String, String> queryParameterMap,
        TypeToken<T> responseType) {

        ClassicHttpRequest httpGetRequest = createGetRequest(requestUri, queryParameterMap);

        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            String entityToString = httpClient.execute(httpGetRequest, response -> {
                final HttpEntity entity = response.getEntity();

                String content = EntityUtils.toString(entity);
                if (isErrorCode(response.getCode())) {
                    throw new BadApiResponseException(content);
                }

                EntityUtils.consume(entity);
                return content;
            });
            return gson.fromJson(entityToString, responseType);
        } catch (IOException e) {
            throw new ParsingFailException(e.getMessage());
        }
    }

    protected abstract ClassicHttpRequest createGetRequest(String uri,
        Map<String, String> queryParameterMap);

    ...
}
  • ApiFactory의 구현체는 기술의존적이어도 상관없기 때문에 apache:HttpClient5 의존성을 추가하였고 그에 따라 이름을 HttpClientsApiFactory로 지었습니다.
  • ResponseType은 제네릭타입도 받을 수 있게 만들기 위해 슈퍼타입토큰을 구현한 GSON의 TypeToken을 사용하게 되었습니다.
  • 요청은 uri와 queryParameter을 커스텀할 수 있게 추상팩토리 패턴을 사용했습니다. 여유가 있으면 body 역시 추가할 예정입니다.

3) 리팩토링 이후

위의 과정 덕분에 강결합된 ApiClient 기술과 비즈니스 로직을 가지고 있는 MapAdater이 이제 ApiClient 스펙을 골라서 사용할 수 있게 되었습니다.

  • 왼쪽이 이전 아키텍처, 오른쪽이 이후 아키텍처 입니다.

이렇게 확장이 가능한 아키텍처로 변경되었기 때문에 추후 이렇게 확장이 될 것으로 예상이 됩니다.

마지막으로 실제 서비스 로직은 단 2줄만 있어도 충분하게 됩니다.

public BookApiResponses searchBooksByTitle(SearchBooksQuery query) {
    Map<String, String> paramMap = ObjectToMapParser.convertToMap(query);
    return apiFactory.requestGetForEntity(paramMap, new TypeToken<BookApiResponses>() {});
}

4) 회고

사실 계층을 너무 나눈건가 생각이 되기는 합니다. 개발기간도 생각보다 오래걸렸고요. 학습 중심이었기 때문에 이렇게 설계를 했기는 하지만 실제 프로젝트에 들어가면 여러개의 확장이 필요한것이 아닌 이상 지양해야 할 것 같습니다.





2. DAO 리팩토링

1) 수정 전

DAO 코드 역시 몇몇 보일러플레이트가 보여 리팩토링을 하기로 했습니다.

2) 리팩토링

public final class JdbcUtils {

    public static <T> void executeCommand(String sql, T entity, PreparedStatementBuilder<T> builder) {
        try (Connection connection = ConnectionUtils.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql)) {
            if (entity != null) {
                builder.prepare(entity, preparedStatement);
            }
            preparedStatement.execute();
        } catch (SQLException e) {
            throw new BadSQLException(e.getMessage());
        }
    }

    public static <T> List<T> executeQuery(String sql, ResultSetParser<T> parser) {
        List<T> entities = new ArrayList<>();

        try (Connection connection = ConnectionUtils.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            ResultSet resultSet = preparedStatement.executeQuery()) {
            while (resultSet.next()) {
                entities.add(parser.parse(resultSet));
            }
        } catch (SQLException e) {
            throw new BadSQLException(e.getMessage());
        }

        return entities;
    }

    @FunctionalInterface
    public interface PreparedStatementBuilder<T> {

        void prepare(T entity, PreparedStatement preparedStatement) throws SQLException;
    }

    @FunctionalInterface
    public interface ResultSetParser<T> {

        T parse(ResultSet resultSet) throws SQLException;
    }
}
  • Custom FunctionInterface을 생성해 Callback으로 구현을 실시했습니다.

3) 리팩토링 이후

public class BookPersistenceAdapter implements BookPersistencePort {

    public void save(BookEntity book) {
        String sql = "INSERT INTO book(title, price, sale_price, author, publisher, isbn) VALUES (?, ?, ?, ?, ?, ?)";
        JdbcUtils.executeCommand(sql, book, this::prepareInsertStatement);
    }

    private void prepareInsertStatement(BookEntity entity, PreparedStatement preparedStatement) throws SQLException {
        preparedStatement.setString(1, entity.title());
        preparedStatement.setInt(2, entity.price());
        preparedStatement.setInt(3, entity.salePrice());
        preparedStatement.setString(4, entity.author());
        preparedStatement.setString(5, entity.publisher());
        preparedStatement.setString(6, entity.isbn());
    }

    public List<BookEntity> findAll() {
        String sql = "SELECT * FROM book ORDER BY title";
        return JdbcUtils.executeQuery(sql, this::parseBookEntity);
    }

    private BookEntity parseBookEntity(ResultSet resultSet) throws SQLException {
        String title = resultSet.getString("title");
        int price = resultSet.getInt("price");
        int salePrice = resultSet.getInt("sale_price");
        String author = resultSet.getString("author");
        String publisher = resultSet.getString("publisher");
        String isbn = resultSet.getString("isbn");
        return new BookEntity(title, price, salePrice, author, publisher, isbn);
    }

    public void deleteAll() {
        String sql = "DELETE FROM book";
        JdbcUtils.executeCommand(sql, null, null);
    }
}
  • 이제 비즈니스 로직에서는 SQL과 파싱 전략만 입력해주면 됩니다.
  • 전체 삭제 기능을 가지는 deleteAll()에서 파라미터로 Null을 넘기는게 신경쓰여 해당 코드를 분리해볼 예정입니다.
post-custom-banner

0개의 댓글