JdbcTemplate에서는 왜 Optional을 반환하지 않을까? (JdbcClient의 탄생)

,

JdbcTemplate을 직접 구현하는 미션 덕에 이것저것 공부하던 도중, 다른 크루의 미션 제출 현황을 보고 궁금한 게 생겼다.

흥미로운 주제네요 🤔

우선 옵셔널이 무엇인지는 조이썬의 테코톡을 한 번 보고 오자 🤙🏻, Optional이 등장하게 된 배경과 주의할 점을 잘 설명해 주었다! 희선이 최고 🙌🏻

📦 Optional

🤦🏻‍♂️

Java 8과 함께 등장한 Optional. 값을 감싸는 Wrapper class의 역할을 한다. 추가적인 클래스이므로 orElse(), orElseGet()와 같은 편의 메서드 또한 제공한다. 존재 여부를 null로 처리하지 않고, 한 번 더 추상화해 코드를 사용하는 사람에게 “정말 없어”라는 점을 환기한다.

그렇다고 모든 nullOptional 로 치환해도 되는가? 라고 한다면.. 나는 괜찮을 것이라고 생각하지만 이에 대한 논쟁은 끊이지 않고 있다. Optional을 써서 발생하는 가독성을 우려하는 목소리도 크다. 단순하게 null 검증으로 끝날 수 있는 것을 굳이 Optional로 감싸야 해? 와 같은 이야기다. 실제 성능이 많이 뒤쳐진다는 벤치마킹 결과도 있다. 아래가 결과인데, 자세한 코드는 링크를 참고하자.

상당한 차이를 보이네요..

🤔 JdbcTemplate은 왜 null을 반환할까?

Spring Jdbc Core의 JdbcTemplate은 여러 메서드를 통해 SQL 쿼리를 실행하고, 그 결과를 반환한다. 다만 우리가 흔히 알고 있는 Spring JPA의 JPA Repository들과는 다르게, Optional을 찾아볼 수 없었다.

아니 Optional 줘도 될 것 같은데 왜 안 하지? 라는 생각이 들어서 좀 찾아봤다. 유서깊은 토론이 이미 오고 간 흔적이 있었다. 2015년 1월에 열린 두 이슈 #724, #17262에 아래와 같은 내용이 적혀 있었다.


문제 상황 1: 값이 존재하지 않음을 확인하려면 JdbcTemplate.queryForObject에서 EmptyResultDataAccessException을 잡아야 한다. 이는 리턴 값이 DataAccessUtils.nullableSingleResult 함수를 호출하기 때문이며, 함수의 문서에도 명시돼 있다.

문제 상황 2: 다른 방법으로 query, queryForList로 결과를 받아 리스트의 크기를 측정해야 한다. 이 또한 매우 번거롭다.

제안: 아래와 같은 규칙을 따르는 queryForOptional 메서드를 새로 만들 것을 제안했다.

  • 결과 없음 → Optional.Empty()
  • 결과 하나 → Optional.ofNullable(result)
  • NULL → Optional.empty()
  • 결과 2개 이상 → IncorrectResultSizeDataAccessException

암만 봐도 괜찮아 보이는데… 왜 도입되지 않았을까? 스프링의 입장은 1) ‘Template이 할 일이 아니다’와 , 2) ‘많은 오버로드가 누적돼 변형에 취약하다’는 점이다.. 실제 코드를 들여다보면 서로 다른 메서드를 불러 리턴하는 경우가 너무 많다보니, 특정 메서드 하나를 Optional로 만들기가 까다로워진 것이다. 또한 마무리로 아래와 같은 말을 덧붙였는데,

The same can be achieved with an independent interface/class that delegates to a JdbcTemplate internally.

JdbcTemplate을 활용하는 다른 클래스 / 인터페이스를 두면 (추상화) Optional을 충분히 반환할 수 있다는 말이다. 라이브러리를 사용하는 사람 입장에서는 뭔가 꺼림칙하긴 하다. 편하게 해준대서 썼더니만.. 내가 다시 추상화를 해야 한다고?

🚀 JdbcClient의 등장

Spring Framework 6.1.0-M3 부터 DataAccessUtils.optionalResult 메서드가 추가됐다 (PR#27735). 이를 기점으로 다시 재조명된 게 JdbcTemplate에도 Optional 지원해달라~ 였는데, 이미 논의된 사항이었으므로 duplicated로 처리되었다 (#30927).

Optional이 Template이 아닌 DataAccessUtils에 추가된 건 의도되었다. Template 내부에서 값을 강제할 때에는 내부 메서드를 호출하지 않는다. Template은 정말 SQL 자체에만 집중한다. 쿼리의 결과값에 제한을 걸어 두는 것은 DataAccessUtils에게 맡긴다. 따라서 단 하나의 결과를 다루는 queryForObject의 값 검증은 DataAccessUtils에서 진행한다. 이 관점에서 본다면 Optional 또한 DataAccessUtil에 존재하는 것이 조금 더 알맞을지도 모른다. 완벽하게 객체 책임의 관점에서 바라보았다고 생각했다. 🥹

JdbcTemplate에 수정을 가하기는 어려우니, WebClient와 같은 추상화 방식을 Jdbc에도 적용하면 어떨까? 라는 이야기를 유겐 휠러가 2017년 코멘트에서 언급했다. 이는 #30931에서 구체화되었고, Spring Framework 6.1.0-M4부터 지원한다. 2023년 8월 즈음에 커밋을 한 이력이 있으니, 1년 좀 더 된 따끈따끈한 친구다.

실제로 JdbcClient를 쓰면 굉장히 편하다. 추상화를 더 해버리니 사용하는 사람 입장에서는 정말 실수하기 어렵게 설계되었음을 느꼈다. JPQL을 쓰는 것처럼 : 파라미터 연산자를 사용할 수도 있고, query 인자로 대상 클래스를 삽입해 Stream, Set, List, Optional, T 타입으로 변환해올 수도 있다. 아래 예시를 이해하는 데 어려움이 있는가?!

record Fruit(int id, String name) {}

private final JdbcClient jdbcClient = JdbcClient.create(dataSource);

@Test
void selectOptional() {
    Optional<Fruit> actual = jdbcClient.sql("SELECT * FROM fruit WHERE id = :id")
            .param("id", 1)
            .query(Fruit.class)
            .optional();

    assertThat(actual).isPresent();
}

(JPA 왜 쓸까..?)

💬 미션으로 돌아와서

라이브러리나 프레임워크를 개발한다면, 무엇보다 중요한 건 일관적으로 작동하게끔 설계하는 것이다. 사용자가 코드를 작성할 때 추가 인지부하 없이 빠르게 기대하고자 하는 것을 찾게 돕는 것도 라이브러리/프레임워크가 할 일이다. 스프링은 정말 논리적으로 자신들이 설계한 목적을 잘 드러냈고, 다른 개발자들의 염원에 힘입어 새로운 JdbcClient까지 선보였다.

바닥부터 만드는 JdbcTemplate이니 null을 리턴하거나 Optional로 감싸거나는 순전히 나의 책임이다. 의도를 잘 설명하고 일관된 명령 집합을 가지게끔 설계하는 것도 나의 책임이 되겠다. 이렇게 정리해두고 보니 나는 null 리턴에 조금 더 마음이 기울기도 하지만, Optional을 리턴하자는 주장에도 충분히 설득력이 있다고 느낀다. 저번에도 이야기한 것 같은데, 코드는 문학이라는 말이 참 잘 어울린다. 모든 사람을 끄덕이게 만들기는 쉽지 않지만, 7-8명 정도가 나의 코드에 수긍해준다면 그걸로 만족할 수 있겠다. 참 어렵다 미션 🥴

Categories