Backend 20

Spring Data JPA 쿼리 메서드 이름 규칙 해독하기

findByTitleContaining 같은 이름이 어떻게 WHERE와 LIKE 조건으로 바뀌는지 모르겠다.

근거 · 교안 p128-p132

Spring Data JPA 쿼리 메서드 이름 규칙 해독하기 대표 이미지

1장: “자동 생성”의 시작: findBy와 WHERE 절의 연결

솔라의 손가락이 노트북 터치패드 위에서 멈췄다. 화면에는 자바 코드가 환하게 빛나고 있었다. 몇 줄 안 되는 인터페이스 정의였지만, 솔라의 미간에는 깊은 골이 패였다.

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByTitleContaining(String keyword);
}

“메서드 이름만으로 SQL을 만든다니… 마법도 아니고.”

혼잣말처럼 중얼거리는 목소리에는 감탄보다 불신이 더 많이 섞여 있었다. Spring Data JPA가 쿼리 메서드 이름으로 SQL을 ‘자동 생성’해준다는 것은 분명 편리한 기능이다. 하지만 findByTitleContaining이라는 이름이 어떻게 데이터베이스에 ‘제목에 특정 키워드가 포함된 책을 찾아달라’는 SQL 명령으로 전달되는지, 그 과정이 통째로 검은 상자 안에 들어있는 것 같았다. ‘자동’이라는 단어의 편리함 이면에 숨겨진 막연함이 솔라를 답답하게 만들었다.

그때, 조용히 다가온 루나가 솔라의 모니터를 들여다봤다.

“그 라인이 계속 마음에 걸리는구나.”

깜짝 놀란 솔라가 의자를 돌렸다. “언니, 언제부터 거기 있었어?”

“네가 ‘마법’ 타령을 시작할 때부터.” 루나는 장난스럽게 웃으며 솔라의 화면을 가리켰다. “findByTitleContaining. 저 이름이 SQL로 바뀌는 규칙이 궁금한 거지?”

솔라가 고개를 세차게 끄덕였다. “응. findBy는 ‘찾는다’는 뜻 같고, Title은 책 제목이겠지. Containing은 ‘포함한다’는 거고. 단어 뜻은 알겠는데, 이게 합쳐져서 WHERE title LIKE '%keyword%' 같은 SQL이 된다는 게 상상이 안 가. 그냥 ‘자동으로 된다’고만 하니까 믿어야 할지, 외워야 할지 감도 안 잡혀.”

루나는 잠시 생각하더니, 빈 노트와 펜을 가져왔다. 하지만 무언가를 적는 대신, 펜으로 솔라의 코드 한가운데를 찔렀다. findByTitleContaining 이라는 긴 이름의 중간, TitleContaining 사이였다.

“이 긴 이름을 한 번에 해독하려고 하니까 막막한 거야. 아주 작은 조각으로 잘라서, 우리가 확실히 아는 부분부터 확인해보면 어떨까?”

“조각으로 자르자고?”

“응. 저 이름에서 가장 핵심적인 부분, 검색 조건의 시작을 알리는 부분부터 말이야.”

루나는 솔라의 코드 아래에 새로운 메서드 한 줄을 추가해보라고 제안했다.

“만약 그냥 책 제목으로 정확히 일치하는 책을 찾는다고 해보자. 다른 복잡한 조건 없이. 그럼 메서드 이름은 어떻게 될까?”

솔라는 잠시 고민했다. “‘제목으로 찾는다’니까… findByTitle?”

“좋아. 그럼 직접 한 번 해보는 거야. findByTitle이라는 메서드를 레포지토리에 추가하고, 실제로 실행했을 때 어떤 SQL이 나가는지 로그를 확인해보는 거지.”

솔라는 반신반의하며 키보드를 잡았다. 익숙하게 BookRepository 인터페이스에 새로운 메서드를 추가하고, 간단한 테스트 코드를 작성했다. bookRepository.findByTitle("JPA 프로그래밍")을 호출하는 코드였다. 테스트를 실행하자, 콘솔 창에 여러 줄의 로그가 빠르게 스쳐 지나갔다. 솔라는 스크롤을 올려 Hibernate가 생성한 SQL 로그를 찾아냈다.

select book0_.id as id1_0_, book0_.price as price2_0_, book0_.title as title3_0_ 
from book book0_ 
where book0_.title=?

“아…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 로그에 찍힌 SQL은 명확했다. WHERE 절이 있었고, 그 조건은 title 컬럼이었다. 자신이 메서드 이름으로 사용했던 Title과 정확히 일치했다.

findBy가… WHERE 절을 만드는 거였구나. 그리고 그 뒤에 붙은 TitleWHERE 절의 조건이 되는 거고.”

솔라는 방금 전까지 마법처럼 느껴졌던 코드 라인을 다시 바라봤다. findByTitle. 이제 이 이름은 더 이상 막연한 약속이 아니었다. findBy는 SQL의 WHERE를 여는 열쇠였고, Title은 조건을 지정하는 명확한 규칙이었다.

루나는 아무 말 없이 고개만 끄덕였다.

솔라는 처음의 고민으로 돌아갔다. findByTitleContaining. 이제 이 긴 이름의 첫 번째 단계를 해독할 수 있게 되었다.

“그렇구나. ‘자동 생성’이라는 게 그냥 되는 게 아니라, findBy라는 약속으로 검색 조건의 시작을 알리는 거였어. 일단 WHERE 절이 만들어진다는 건 확실히 알겠어.”

스스로 내린 결론에 만족한 듯 솔라의 표정이 한결 밝아졌다. 하지만 기쁨도 잠시, 그녀의 시선은 다시 이름의 뒷부분으로 향했다.

“좋아, findByTitle까지는 해결됐어. WHERE title… 그런데 언니, 뒤에 붙은 Containing은 대체 뭐야? 이건 또 LIKE랑 어떻게 연결되는 거지?”

검은 상자의 첫 번째 뚜껑을 열자, 그 안에서 더 작은 상자가 모습을 드러낸 순간이었다.

2장: Containing 키워드: LIKE 연산자와의 숨겨진 연결

솔라의 노트북 화면에는 두 개의 창이 나란히 떠 있었다. 왼쪽에는 findByTitle 메서드가 추가된 자바 코드가, 오른쪽에는 WHERE book0_.title=? 라는 명확한 결과가 찍힌 SQL 로그가 보였다. 성공의 증거였다. 이제 findByTitle이 만드는 WHERE title이라는 조합은 더 이상 미지의 영역이 아니었다.

하지만 솔라의 시선은 다시 코드의 첫 줄, 모든 혼란의 시작이었던 findByTitleContaining으로 돌아갔다. findByTitle은 해결했지만, 그 뒤에 유령처럼 붙어있는 Containing이라는 단어가 여전히 수수께끼처럼 남아 있었다.

루나는 솔라의 머뭇거림을 지켜보다가, 이전과 똑같이 펜으로 화면을 가리켰다. 이번에는 findByTitle을 지우고, 원래의 findByTitleContaining을 다시 한번 꾹 눌렀다.

“자, 이제 두 번째 조각을 맞춰볼 시간이야. findByTitleWHERE title = ?을 만든다는 걸 확인했으니, findByTitleContaining은 뭘 만들어낼 것 같아?”

“음…” 솔라는 잠시 생각에 잠겼다. “단어 뜻 그대로 ‘포함하는 것’을 찾는 거니까… 그냥 문자열 안에 그 키워드가 있는지 확인하는 SQL이 아닐까? 근데 그런 명령어가 있나?” 막연한 추측이었다. SQL의 LIKE 연산자가 떠오르기는 했지만, 그게 % 같은 와일드카드와 어떻게 연결되는지, 프레임워크가 알아서 해준다는 게 여전히 아리송했다.

“가장 확실한 방법이 뭐였지?” 루나가 되물었다.

그 말에 솔라는 아차 싶었다. 망설일 필요가 없었다. 방금 전 findByTitle의 비밀을 파헤쳤던 것처럼, 직접 부딪혀보면 될 일이었다.

솔라는 재빨리 테스트 코드를 수정했다. findByTitle을 호출하던 부분을 주석 처리하고, 원래의 findByTitleContaining을 호출하도록 코드를 바꿨다. 검색어로는 ‘JPA’를 넣었다. 데이터베이스에는 ‘스프링 부트와 JPA’, ‘JPA 프로그래밍’ 같은 책들이 들어 있었다.

// List<Book> books = bookRepository.findByTitle("JPA 프로그래밍");
List<Book> books = bookRepository.findByTitleContaining("JPA");

심호흡 한번 하고 테스트를 실행했다. 결과는 성공. 이제 가장 중요한 순간이 남았다. 솔라는 떨리는 마음으로 콘솔 로그를 위로 스크롤했다. Hibernate가 뱉어낸 SQL 쿼리를 찾는 그녀의 눈이 가늘어졌다.

select book0_.id as id1_0_, book0_.price as price2_0_, book0_.title as title3_0_ 
from book book0_ 
where book0_.title like ?

LIKE!やっぱり LIKE였어!”

솔라는 저도 모르게 외쳤다. findByWHERE를 만들었듯, ContainingLIKE를 만드는 열쇠였다. 그런데 진짜 놀라운 것은 바로 다음 줄에 있었다. 파라미터 바인딩 로그가 그 비밀의 마지막 조각을 보여주고 있었다.

binding parameter [1] as [VARCHAR] - [%JPA%]

“세상에, %까지 알아서 붙여주네!”

솔라의 눈이 동그래졌다. Containing이라는 키워드가 단순히 LIKE로 변환되는 것뿐만 아니라, 인자로 받은 값의 앞뒤에 와일드카드(%)를 붙여 LIKE '%값%' 형태의 구문을 완성시켜주는 규칙이었던 것이다. 그냥 ‘포함’이라는 애매한 개념이 아니었다. 이것은 명백하고 예측 가능한 약속이었다.

솔라는 의자에 등을 깊게 기댔다. 검은 상자 같았던 findByTitleContaining의 구조가 머릿속에서 완벽하게 해독되는 기분이었다.

find By Title Containing

  • find...By: SELECT ... FROM ... WHERE 절을 생성한다.
  • Title: WHERE 절의 검색 대상이 될 속성(컬럼)을 지정한다.
  • Containing: LIKE 연산자를 사용하고, 파라미터 양쪽에 %를 붙인다.

“언니, 이거 완전 레고 블록 조립하는 것 같아. Containing은 앞뒤가 탁 트인 LIKE 블록이었어.”

스스로 발견한 규칙에 신이 난 솔라가 말했다. 이제 findByTitleContaining은 더 이상 마법의 주문이 아니었다. 명확한 문법을 가진 한 문장이었다.

“하나의 조건을 처리하는 건 이제 알겠어. findBy로 시작하고, 속성 이름 붙이고, Containing 같은 키워드로 조건을 정하고… 그럼 만약에 말이야,”

솔라는 새로운 질문이 떠오르자 몸을 앞으로 기울였다.

“‘JPA’라는 단어를 포함하면서, 가격이 2만 원보다 싼 책을 찾고 싶으면 어떡하지? 조건이 두 개가 되는데, 이것도 메서드 이름으로 만들 수 있어?”

3장: And/Or 키워드: 복합 검색 조건 만들기

솔라의 화면에는 BookRepository 인터페이스 파일이 다시 열려 있었다. 하지만 이전과 달리, 파일 끝에 두 줄의 새로운 메서드 시그니처가 추가되어 있었다. 솔라는 그 두 줄을 보며 끙, 하고 앓는 소리를 냈다.

// 이런 식으로 해야 하나?
List<Book> findByTitleContaining(String title);
List<Book> findByPriceLessThan(int price);

‘JPA’를 포함하는 책 목록과 2만 원보다 싼 책 목록. 각각을 가져오는 건 이제 쿼리 메서드로 만들 수 있었다. 문제는 이 두 목록을 합치는 방법이었다. 솔라는 두 개의 리스트를 받은 뒤 자바 코드로 직접 교집합을 찾아야 하는 건가 싶어 막막해졌다. 이건 전혀 ‘자동’도 아니고, 우아하지도 않았다.

“두 개의 강을 하나로 합치려는 모양이네.”

어느새 다가온 루나가 솔라의 화면에 떠 있는 두 개의 메서드를 보고 말했다. “각각의 강에서 물을 떠 온 다음에, 섞어서 원하는 물고기만 골라내려는 것 같아 보여.”

솔라는 루나의 비유에 얼굴을 붉혔다. “어쩔 수 없잖아. ‘제목에 JPA가 포함’되면서 ‘가격이 2만원 미만’인 책을 찾아야 하는데, 조건을 두 개나 거는 방법을 모르니까. 일단 각각 찾아서 어떻게든 해보려고 했지… 역시 이건 아닌가?”

“틀린 건 아니야. 하지만 더 좋은 길이 있을 뿐.”

루나는 손가락으로 두 메서드의 공통된 시작 부분, findBy를 차례로 가리켰다. “우리는 이미 findByWHERE를, Titletitle 컬럼을, ContainingLIKE '%...%'를 만든다는 걸 알아냈어. 마치 블록처럼.”

루나는 솔라가 앞서 발견했던 규칙들을 상기시켰다. 그러고는 솔라의 주석 바로 아래에 커서를 옮겨놓고, 키보드를 톡톡 두드렸다.

“그럼 이 블록들 사이에 다른 블록을 끼워 넣을 수는 없을까? 두 조건을 ‘그리고(AND)’로 연결해주는 블록 말이야.”

“연결 블록?” 솔라의 눈이 반짝였다. 가장 직관적인 단어가 머릿속을 스쳐 지나갔다. “まさか… 그냥 And?”

솔라는 자신의 추측이 너무 단순해서 어이없다는 듯 피식 웃었다. 하지만 ContainingLIKE로 변환되는 걸 직접 목격한 후였다. 더 이상 ‘설마’ 하는 마음으로 가능성을 무시할 수는 없었다.

솔라는 홀린 듯 키보드에 손을 올렸다. 먼저 기존에 적어두었던 두 줄의 임시 코드를 지웠다. 그리고 스스로 떠올린 ‘연결 블록’을 사용해 새로운 메서드 이름을 조립하기 시작했다.

find By Title Containing … 그리고 AndPrice LessThan.

각각의 조각들이 머릿속에서 하나의 긴 단어로 합쳐졌다.

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByTitleContainingAndPriceLessThan(String title, int price);
}

메서드 이름이 길어졌고, 파라미터도 Stringint 두 개를 받도록 바뀌었다. 솔라는 약간의 불안감과 큰 기대감을 안고 테스트 코드를 작성했다. "JPA"라는 키워드와 20000이라는 숫자를 인자로 넘겨 새로 만든 메서드를 호출했다.

테스트 실행. 콘솔 창에 로그가 쏟아져 내렸다. 솔라는 침을 꿀꺽 삼키며 스크롤을 올려 Hibernate가 생성한 SQL을 확인했다.

select book0_.id as id1_0_, book0_.price as price2_0_, book0_.title as title3_0_ 
from book book0_ 
where book0_.title like ? and book0_.price < ?

“됐다! and가 생겼어!”

솔라는 자신도 모르게 작은 환호성을 질렀다. SQL 로그의 WHERE 절에는 title에 대한 like 조건과 price에 대한 < 조건이 명확하게 and 연산자로 연결되어 있었다. 파라미터 바인딩 로그 역시 [%JPA%]20000을 각각 정확히 처리하고 있었다.

메서드 이름에 넣었던 And 키워드가 SQL의 AND로 변환되는 마법. 아니, 이제는 마법이 아니었다. 이것 또한 명백한 규칙이었다. ‘두 조건을 따로 조회해서 합친다’는 복잡한 생각은 완전히 잘못된 접근이었다. 그저 두 조건을 나란히 나열하고 그사이에 And라는 다리만 놓아주면 되는 일이었다.

솔라는 의자에 등을 기댔다. 머릿속의 레고 블록 상자에 새로운 블록이 추가되는 느낌이었다.

“이제 알겠어. AndOr 같은 키워드로 그냥 조건을 계속 이어 붙이면 되는 거였구나.”

스스로 내린 결론에 만족하며, 솔라는 테스트 결과를 바라봤다. 화면에는 조건에 맞는 책 목록이 출력되어 있었다.

“‘스프링 부트와 JPA’, 19000원. ‘JPA 프로그래밍’, 18000원. 둘 다 잘 찾아왔네.”

결과를 확인하던 솔라의 미간이 살짝 찌푸려졌다.

“그런데 언니, 검색 결과가 좀 뒤죽박죽인 것 같아. 이왕이면 가격이 비싼 순서대로 정렬해서 보여주면 더 좋을 것 같은데. 이것도 메서드 이름으로 어떻게 안 될까?”

4장: OrderBy 키워드: 검색 결과 정렬하기

솔라의 노트북 화면에는 테스트 결과가 선명하게 떠 있었다. findByTitleContainingAndPriceLessThan 메서드는 ‘JPA’라는 단어를 포함하고 가격이 20,000원 미만인 책 두 권을 정확히 찾아냈다. 성공이었다. 하지만 솔라의 표정은 어딘가 개운치 않았다. 그녀의 손가락이 화면에 출력된 가격을 톡, 톡 건드렸다. 19,000원, 그리고 그 아래 18,000원.

컴퓨터 입장에서는 그저 조건에 맞는 데이터를 순서 없이 가져왔을 뿐이지만, 사용자 입장에서는 아니었다. 이왕이면 비싼 책부터, 혹은 싼 책부터 보고 싶을 수 있었다. 이런 뒤죽박죽인 결과는 정돈되지 않은 방처럼 솔라의 마음을 불편하게 만들었다. ‘결과를 받은 다음에 자바 코드로 다시 정렬해야 하나? List.sort()를 쓰면 되긴 하는데… 그럼 쿼리 메서드의 우아함이 반감되잖아.’

“마지막 한 조각이 덜 맞춰진 표정이네.”

언제 다가왔는지, 루나가 솔라의 화면을 들여다보며 말했다.

솔라는 고개를 저었다. “결과는 맞는데, 순서가 엉망이야. 가격이 비싼 순서대로 나왔으면 좋겠어. SQL이라면 ORDER BY를 쓰면 간단한데…まさか 이것까지 메서드 이름으로 되진 않겠지?”

솔라의 목소리에는 기대 반, 체념 반이 섞여 있었다. findByWHERE로, ContainingLIKE로, Andand로 변하는 기적을 여러 번 목격했지만, 정렬까지 이름으로 제어한다는 건 너무 과한 욕심처럼 느껴졌다. 어쩌면 이건 프레임워크의 마법이 닿지 않는 영역일지도 모른다고 생각했다.

루나는 아무 말 없이 키보드를 끌어당겼다. 그리고는 BookRepository 인터페이스에 새로운 메서드를 위한 공간을 만들었다. 텅 빈 줄 하나가 커서를 깜빡이며 솔라의 다음 행동을 기다리고 있었다.

“SQL의 SELECT 문은 보통 어떻게 끝나지?”

루나의 질문은 간단했다. 하지만 그 안에는 명확한 힌트가 담겨 있었다. 솔라는 잠시 생각에 잠겼다. SELECT, FROM, WHERE, 그리고 마지막은…

ORDER BY!”

솔라는 외쳤다. 그러고는 자신의 입에서 나온 단어에 스스로 놀랐다. 혹시? 설마?

“그럼… 메서드 이름에 OrderBy를 붙이면 되는 거야?”

솔라의 손이 키보드 위로 향했다. 의심은 어느새 실험해보고 싶은 강한 호기심으로 바뀌어 있었다. 그녀는 And로 조건을 붙였던 것처럼, 이번에는 정렬 규칙을 이름 뒤에 이어 붙여보기로 했다.

‘가격을 기준으로, 내림차순으로.’ 머릿속으로 필요한 조각들을 떠올렸다. ‘Order By Price Desc’. 각 단어의 첫 글자를 대문자로 바꾸어 하나의 긴 이름으로 조립했다.

public interface BookRepository extends JpaRepository<Book, Long> {
    // ... 이전 메서드들
    List<Book> findByTitleContainingOrderByPriceDesc(String title);
}

복잡했던 And 조건은 잠시 빼고, ContainingOrderBy의 조합만으로 실험해보기로 했다. 새로운 규칙을 확인할 때는 변수를 최소화하는 것이 좋았다. 메서드 이름이 제법 길어졌지만, 이제는 그 구조가 낯설지 않았다.

솔라는 테스트 코드를 수정해 findByTitleContainingOrderByPriceDesc("JPA")를 호출하도록 바꿨다. 실행 버튼을 누르는 그녀의 손가락에 힘이 들어갔다. 제발, 마지막 퍼즐 조각이 맞아떨어지기를.

결과는 성공. 이제 남은 건 SQL 로그 확인뿐이었다. 솔라는 마른침을 삼키며 콘솔 창의 스크롤을 올렸다. 이내 Hibernate가 생성한 SQL 쿼리가 눈에 들어왔다.

select book0_.id as id1_0_, book0_.price as price2_0_, book0_.title as title3_0_ 
from book book0_ 
where book0_.title like ? 
order by book0_.price desc

“됐어! order by가… desc까지 완벽하게 들어갔어!”

솔라의 얼굴에 환한 미소가 번졌다. OrderBy 키워드는 SQL의 ORDER BY 절을 만들었고, 뒤따라온 Price는 정렬 기준 컬럼을, Desc는 내림차순(descending)을 지정하는 명확한 규칙이었다. 만약 오름차순으로 하고 싶다면 Asc를 쓰거나 그냥 생략하면 될 터였다. 정렬조차도 그저 약속된 단어를 조합하는 것으로 해결되는 문제였다.

솔라는 의자에 등을 깊게 기댔다. 머릿속에 흩어져 있던 레고 블록들이 마침내 제자리를 찾은 기분이었다.

  • findBy...: WHERE 절의 시작
  • [속성 이름]: 검색할 컬럼
  • [조건 키워드]: Containing, LessThan
  • And/Or: 여러 조건 연결
  • OrderBy[속성 이름]Desc/Asc: 정렬

“언니, 이제 알겠어. 이건 그냥 마법이 아니라, 하나의 언어였어. 단어를 순서대로 조합해서 문장을 만드는 것과 똑같아.”

솔라는 자신이 작성했던 긴 메서드 이름들을 자랑스럽게 바라봤다. findByTitle, findByTitleContaining, findByTitleContainingAndPriceLessThan, 그리고 마침내 findByTitleContainingOrderByPriceDesc까지. 막연한 두려움의 대상이었던 검은 상자는 이제 속이 훤히 들여다보이는 투명한 상자가 되었다.

그녀는 문득 처음의 고민으로 돌아갔다. findByTitleContaining. 이제 이 이름은 너무나도 간단해 보였다. 솔라는 조금 더 복잡한 조합을 상상해 보았다. 만약 findByNameStartingWithAndAgeGreaterThanOrderByAgeDesc 같은, 더 길고 낯선 메서드 이름을 만난다면?

“좋아, 규칙은 다 모았어. 그럼 이제 어떤 복잡한 이름이든, 보면 바로 SQL로 해석할 수 있을까?”

솔라의 눈이 새로운 도전을 앞둔 사람처럼 빛나기 시작했다.

5장: 복합 쿼리 메서드 이름, SQL로 변환하는 마스터

솔라의 노트북 화면에는 그녀가 지금까지 작성하고 검증했던 쿼리 메서드들의 목록이 자랑스럽게 나열되어 있었다. 마치 전투에서 승리하고 얻은 전리품 같았다. 각 이름들은 이제 더 이상 암호가 아니라, 명확한 규칙으로 해독 가능한 문장이었다. 막연했던 ‘자동 생성’의 세계에 질서가 잡히는 느낌이었다.

그때 루나가 조용히 다가와 솔라의 옆에 앉았다. 루나는 아무 말 없이 새 User 엔티티와 UserRepository 인터페이스 파일을 화면에 띄웠다. 그러고는 주석으로 긴 메서드 이름 하나를 툭 던져놓았다. 그 도전적인 한 줄에, 솔라의 자신감 넘치던 표정이 살짝 굳었다.

// findByNameStartingWithAndAgeGreaterThanOrderByAgeDesc

이름이 예상보다 훨씬 길었다. Book이 아닌 User라는 낯선 도메인이었고, StartingWithGreaterThan처럼 처음 보는 키워드도 섞여 있었다. 각 규칙을 따로따로 아는 것과, 이렇게 복잡하게 얽힌 전체를 마주하는 것은 다른 문제였다. 머릿속에 쌓아 올렸던 레고 블록들이 와르르 무너질 것 같은 아찔함이 스쳤다.

솔라는 심호흡을 한번 하고, 화면에 떠 있는 긴 이름을 노려보았다. 혼란스러웠지만, 이전처럼 막막하지는 않았다. 그녀에게는 스스로 발견한 규칙들이 있었다.

“어디 보자…”

솔라는 펜을 들고 노트에 긴 이름을 옮겨 적었다. 그리고는 처음 쿼리 메서드를 분석했을 때처럼, 이름에 구분선을 그으며 조각내기 시작했다. find...By가 문장의 틀을 잡고, OrderBy...가 끝을 맺는다. 그 사이가 진짜 문제였다.

그녀는 첫 번째 조건 덩어리인 NameStartingWith에 동그라미를 쳤다. ‘Containing이 LIKE ‘%값%’이었으니까, ‘~로 시작하는’이라는 뜻의 StartingWith는 아마 LIKE ‘값%’일 거야.’ 추론은 자연스러웠다. And는 단순한 연결부. 그 다음 덩어리는 AgeGreaterThan이었다. ‘~보다 크다’. 이건 더 명확하게 > 연산자로 이어졌다. 마지막으로 OrderByAgeDesc는 이미 익숙한 ORDER BY age DESC 구문으로 귀결되었다.

솔라는 각 조각에 대한 해석을 조합하며, 노트 한쪽에 예상 SQL 쿼리 전체를 써 내려갔다. 개별 규칙을 나열하는 것이 아니라, 문법에 맞춰 하나의 완결된 문장을 만드는 과정이었다.

SELECT u FROM User u WHERE u.name LIKE ?1 AND u.age > ?2 ORDER BY u.age DESC

(JPQL 형태지만, 실제 SQL도 거의 동일할 것이다)

자신이 쓴 SQL을 본 순간, 솔라의 마음속에서 무언가 ‘탁’ 하고 맞아 들어가는 느낌이 들었다. 이건 더 이상 추측이 아니었다. 논리적인 추론의 결과였다.

“언니, 나 이거 알 것 같아.”

솔라는 자신감을 되찾은 목소리로 말했다. 그녀는 주석 처리된 라인을 지우고, 실제 메서드 시그니처를 UserRepository에 작성했다. 파라미터로 String name, int age를 추가하는 것도 잊지 않았다.

List<User> findByNameStartingWithAndAgeGreaterThanOrderByAgeDesc(String name, int age);

곧바로 테스트 코드를 작성하고 실행했다. 데이터베이스에는 다양한 이름과 나이의 사용자들이 있었다. 그녀는 ‘Kim’으로 시작하고 30세보다 많은 사용자를 나이순으로 정렬해달라고 요청했다. 잠시 후, 콘솔 창에 SQL 로그가 나타났다.

select user0_.id as id1_1_, user0_.age as age2_1_, user0_.name as name3_1_ 
from user user0_ 
where user0_.name like ? and user0_.age>? 
order by user0_.age desc

파라미터 바인딩 로그에는 Kim%30이 정확히 찍혀 있었다.

자신이 노트에 적었던 예상 SQL과 거의 완벽하게 일치하는 결과였다. 솔라는 환호성 대신 가만히 화면을 응시했다. ‘자동 생성’이라는 말의 무게가 다르게 느껴졌다. 그것은 편리함을 뜻하는 마법의 단어가 아니라, ‘명확한 규칙의 조합이 만들어내는 예측 가능한 결과’를 의미하는 공학적인 용어였다.

규칙 몇 개를 아는 것과, 그 규칙들로 이루어진 언어의 문법을 이해하는 것은 차원이 다른 문제였다. 솔라는 이제 그 문법을 깨우친 것이다.

솔라는 테스트 파일을 닫았다. 그리고 잠시 망설이다, 자신이 진행하던 실제 프로젝트 폴더를 열었다. 복잡한 비즈니스 로직 때문에 어쩔 수 없이 JPQL 쿼리를 직접 문자열로 작성했던 @Query 어노테이션이 눈에 들어왔다. 전에는 그저 당연하게 여겼던 코드였다.

하지만 지금은 달랐다. 솔라는 그 복잡한 JPQL 쿼리를 한참 동안 바라보다가, 그 아래 빈 줄에 커서를 옮겼다. 입가에 작은 미소가 번졌다. 이 복잡한 문자열도, 이제는 더 우아한 이름으로 대체할 수 있을 것 같았다. 마법을 부리는 개발자가 아니라, 언어를 구사하는 개발자가 된 순간이었다.