SearchBookUseCase
같은 경우 HttpClient, Persistence 모두 사용할 수 있을 것 같았으나 각각 변경의 관심사가 달라 분리하게 되었습니다. xxxFromClientUsecase
, xxxFromPersistenceUseCase
먼저 이전 과제의 코드를 보여줄 필요성이 있을것 같네요.
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 처리 및 파싱)위의 두 문제를 해결하기 위해서는 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);
...
}
apache:HttpClient5
의존성을 추가하였고 그에 따라 이름을 HttpClientsApiFactory
로 지었습니다.TypeToken
을 사용하게 되었습니다.위의 과정 덕분에 강결합된 ApiClient 기술과 비즈니스 로직을 가지고 있는 MapAdater이 이제 ApiClient 스펙을 골라서 사용할 수 있게 되었습니다.
마지막으로 실제 서비스 로직은 단 2줄만 있어도 충분하게 됩니다.
public BookApiResponses searchBooksByTitle(SearchBooksQuery query) {
Map<String, String> paramMap = ObjectToMapParser.convertToMap(query);
return apiFactory.requestGetForEntity(paramMap, new TypeToken<BookApiResponses>() {});
}
사실 계층을 너무 나눈건가 생각이 되기는 합니다. 개발기간도 생각보다 오래걸렸고요. 학습 중심이었기 때문에 이렇게 설계를 했기는 하지만 실제 프로젝트에 들어가면 여러개의 확장이 필요한것이 아닌 이상 지양해야 할 것 같습니다.
DAO 코드 역시 몇몇 보일러플레이트가 보여 리팩토링을 하기로 했습니다.
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;
}
}
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);
}
}