[Spring] R2DBC DatabaseClient 잘 사용하기
소개
Webflux에서 DB 조회를 할 때 선택지가 많지만 개인적으로 가장 접근이 쉬운 방법에 대한 효율적인 공통화 클래스를 소개하고자 합니다.
(물론 제 기준..)
일단 조회 기능을 다 설명해보려고 합니다.
DB 조회 종류
ReactiveCrudRepository
Mono<User> findById(ID id);
간단한 조회를 할 때 좋은 선택이 될 수 있습니다.
Criteria
public Flux<T> findAll(Criteria criteria, Class<T> domainType) {
return r2dbcTemplate.select(domainType)
.matching(query(criteria))
.all();
}
동적 쿼리의 유연성이 떨어진고 가독성이 떨어집니다.
JPA에서도 Criteria로 쿼리하지 않듯이 이것도 복잡성으로 인해 사용하지 않습니다.
QueryDSL
QueryDSL처럼 사용할 수 있는 오픈소스가 있지만 안정성의 문제로 사용하기 꺼려집니다.
사용성에 관한 문제는 다른 블로그에서도 비슷한 글들이 있고 아래에 개인적인 사견도 담았으니 참고 바랍니다.
DatabaseClient
R2DBC를 사용하는 개발자들이 가장 많이 선택하는 것이 DatabaseClient를 사용해 쿼리를 그대로 하는 것이라 직관적이며 그나마 제일 효율적인 방식입니다.
databaseCleint.sql("""
SELECT T1.id as id,
T1.product_id as product_id,
T1.name as name,
T2.name as category_name,
FROM TABLE_1 T1
LEFT JOIN TABLE_2 T2
ON T1.category_id = T2.id
""")
DatabaseClient 효율적으로 사용하기(핵심)
비효율적인 코드
default Flux<Product> findBy(String name, Pageable pageable, DatabaseClient databaseClient) {
StringBuilder sql = new StringBuilder("""
SELECT T1.id as id,
T1.product_id as product_id,
T1.name as name,
T2.name as category_name,
FROM TABLE_1 T1
LEFT JOIN TABLE_2 T2
ON T1.category_id = T2.id
""");
if (name != null && !name.isEmpty()) {
sql.append("WHERE T1.name LIKE :name ");
}
String sortOrder = getSort(pageable);
if (!sortOrder.isEmpty()) {
sql.append("ORDER BY ").append(sortOrder).append(" ");
}
sql.append("LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec executeSpec = databaseClient.sql(sql.toString())
.bind("limit", pageable.getPageSize())
.bind("offset", pageable.getOffset());
if (name != null && !name.isEmpty()) {
executeSpec = executeSpec.bind("name", "%" + name + "%");
}
return executeSpec
.map((row, rowMetadata) -> toEntity(row))
.all();
위의 코드 공통 함수 없이 DatabaseClient만을 이용해 쿼리하는 방식입니다.
굉장히 복잡합니다.
name에 대한 String Append를 해주기 위해 조건식을 사용했는데,
executeSpec에 bind를 하기 위해 또 조건식을 중복으로 사용한 것을 볼 수 있습니다.
바로 String을 Append하면 될 수도 있지만 DatabaseClient가 이렇게 개발된 이유는 SQL Injection을 방지하기 위함입니다.
:을 이용한 콜론 방식의 바인딩을 이용한 쿼리는 데이터베이스가 쿼리를 먼저 파싱하고 그 후에 값을 바인딩 합니다.
바인딩하고 나서 이스케이프 처리를 하기 때문에 특수문자가 들어갈 수 없습니다.
결과적으로 내부에선 'data;with;semicolons' 이런식으로 구문처리가 아닌 String으로 처리합니다.
다만 이러한 방식은 코드의 중복이 많고 알아보기 힘들다는 단점이 있습니다.
이걸 어떻게 효율적으로 쓸 수 있을까 고민해보다가 처음에는 일부분만 QueryDSL처럼 빌더 패턴을 만들어볼까 했지만 이게 꽤나 한계점이 보여서 패스했습니다.
그래서 단순히 매핑 부분만 공통화하는 방향으로 바꿨습니다.
효율적인 코드
default Flux<Product> findBy(String name, Pageable pageable, DatabaseClient databaseClient) {
SqlBuilder sqlBuilder = new SqlBuilder().append("""
SELECT T1.id as id,
T1.product_id as product_id,
T1.name as name,
T2.name as category_name,
FROM TABLE_1 T1
LEFT JOIN TABLE_2 T2
ON T1.category_id = T2.id
""");
sqlBuilder.appendIfPresent("WHERE SP.name LIKE %:name%", name);
sqlBuilder.appendIfPresent("ORDER BY " + getSort(pageable));
sqlBuilder.append("LIMIT :limit ", pageable.getPageSize());
sqlBuilder.append("OFFSET :offset ", pageable.getOffset());
return sqlBuilder.execute(databaseClient)
.map((row, rowMetadata) -> toEntity(row))
.all();
두둥.
굉장히 한눈에 들어오는 형태로 바뀌었습니다. 뭐 거의 배울 필요도 없을 정도죠.
참고로 메소드가 default로 시작하는데 Repository 인터페이스에 담았기 때문에 그렇습니다.
물론 JPA에서 Spring Data JPA와 QueryDSL를 하나의 파일에서 관리하는 방법을 참고하면 됩니다.
저는 아직 비즈니스 로직 규모가 작아서 패스..
여튼 위의 방식으로 사용하기 위해 SqlBuilder 클래스를 아래 적겠습니다.
import lombok.Getter;
import org.springframework.r2dbc.core.DatabaseClient;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SqlBuilder {
private final StringBuilder sql = new StringBuilder();
@Getter
private final Map<String, Object> params = new HashMap<>();
public SqlBuilder append(String sqlPart) {
sql.append(sqlPart).append("\n");
return this;
}
public SqlBuilder append(String sqlPart, Object paramValue) {
sql.append(sqlPart).append("\n");
extractParams(sqlPart).forEach(paramName -> {
if (paramValue != null) {
params.put(paramName, paramValue);
}
});
return this;
}
public void appendIfPresent(String sqlPart, Object paramValue) {
if (paramValue != null) {
this.append(sqlPart, paramValue);
}
}
private List<String> extractParams(String sqlPart) {
List<String> paramNames = new ArrayList<>();
Matcher matcher = Pattern.compile(":([\\w_]+)").matcher(sqlPart);
while (matcher.find()) {
paramNames.add(matcher.group(1));
}
return paramNames;
}
public DatabaseClient.GenericExecuteSpec execute(DatabaseClient databaseClient) {
DatabaseClient.GenericExecuteSpec executeSpec = databaseClient.sql(sql.toString());
for (Map.Entry<String, Object> entry : getParams().entrySet()) {
executeSpec = executeSpec.bind(entry.getKey(), entry.getValue());
}
return executeSpec;
}
}
하지만 getSort 부분만 +로 연결한 방식을 이용했는데, 이는 ORDER BY는 콜론으로 데이터 바인딩이 불가능하기 떄문입니다.
그래서 +로 연결하되 SQL Injection을 방지하는 코드도 개발했는데요.
그 코드는 아래와 같습니다.
public class DatabaseClientUtils {
public static String getSort(Pageable pageable) {
String sort = pageable.getSort().isSorted() ?
pageable.getSort().stream()
.map(order -> String.format("%s %s", StringUtils.camelToSnake(order.getProperty()), order.getDirection().name()))
.collect(Collectors.joining(", "))
: "id ASC"; // 정렬 조건이 없을 때 기본값 설정
if (!isValidInput(sort)) {
throw new CommonExceptions.BadRequestException("Invalid sort field: " + sort);
}
return sort;
}
public static boolean isValidInput(String input) { // Prevent SQL Injection
return input != null && input.matches("^[a-zA-Z0-9_ ]+$") && !containsSQLKeywords(input);
}
private static final List<String> FORBIDDEN_KEYWORDS = Arrays.asList(
"select", "drop", "delete", "insert", "update", "alter", "--", ";", "exec", "execute", "union"
);
public static boolean containsSQLKeywords(String input) {
String lowerInput = input.toLowerCase();
for (String keyword : FORBIDDEN_KEYWORDS) {
if (lowerInput.contains(keyword)) {
return true;
}
}
return false;
}
public static <T> T getValue(Row row, String columnName, Class<T> type) {
try {
return row.get(columnName, type);
} catch (NoSuchElementException e) {
return null;
}
}
}
다 적고나니까 이걸 누가쓰겠냐는 상상이..