Backend 21

페이징, 정렬, save, @Query: 더 이상 findAll에 의존하지 않는 데이터 처리

findAll로 다 가져오면 쉬운데 Pageable, Sort, @Query가 왜 따로 필요한지 모르겠다.

근거 · 교안 p133-p141

페이징, 정렬, save, @Query: 더 이상 findAll에 의존하지 않는 데이터 처리 대표 이미지

1장: findAll의 그림자: 숨겨진 비용

솔라는 모니터 구석에 띄워 둔 스터디 그룹의 채팅 기록을 무심코 쳐다봤다. 여러 줄의 코드와 링크들 사이로, 유독 한 문장이 눈에 들어와 생각을 멈추게 했다.

전체 조회는 부담스러워 한 번에 N개씩 잘라서 조회한다.

솔라는 고개를 갸웃거렸다. 개발을 처음 배울 때, 데이터베이스의 모든 내용을 한 번에 가져오는 findAll() 메서드는 마법과도 같았다. 이름만으로 모든 걸 처리해 주는 편리한 기능. 그런데 ‘부담스럽다’니. 마치 잘 닦인 고속도로를 보고 굳이 좁은 국도로 돌아가라고 말하는 것처럼 들렸다.

“언니.”

솔라의 부름에, 방 한쪽에서 조용히 책을 읽던 루나가 고개를 들었다.

“데이터를 조회할 때 말이야, findAll()을 쓰면 테이블에 있는 걸 전부 가져오잖아. 그게 왜 부담스럽다는 거야? 그냥 제일 편한 방법 아니야?”

솔라는 질문을 던지며 방금 본 문장을 가리켰다. findAll의 편리함에 익숙해진 솔라에게는 진심으로 이해가 되지 않는 말이었다. 데이터가 많아도 컴퓨터가 알아서 다 처리해 줄 텐데, 미리 걱정할 필요가 있을까?

루나는 솔라의 질문에 바로 답하는 대신, 잠시 생각에 잠기더니 자리에서 일어났다. 그녀는 방 한구석에 쌓여 있는, 아직 정리하지 못한 상자 더미로 걸어갔다. 이사 온 지 한참 지났지만, 언젠가 들여다보겠다며 미뤄둔 잡동사니들이었다.

“솔라, 저기 맨 아래 상자에 작년에 우리가 쓰던 노란색 머그컵이 들어있거든. 좀 가져다줄래?”

“응? 갑자기 그건 왜?”

“그냥. 저 상자들 전부 네 책상 위로 옮긴 다음에 찾아봐.”

솔라는 황당한 표정을 지었다. 책상 위는 노트북과 책들로 이미 비좁았다. 스무 개는 족히 넘어 보이는 상자들을 전부 책상 위로 옮기라는 건 말도 안 되는 소리였다.

“말도 안 돼. 저걸 다 어떻게 책상에 올려놔. 자리가 없는 건 둘째치고, 저걸 다 옮기려면 하루 종일 걸리겠어. 그냥 맨 아래 상자만 열어서 찾으면 되잖아.”

솔라가 투덜거리며 대답하자, 루나는 빙그레 웃으며 원래 자리로 돌아왔다.

“바로 그거야.”

루나가 말했다.

“지금 네가 한 말이 findAll()의 문제를 정확히 짚고 있어. 데이터가 몇 개 없을 땐, 네가 말한 것처럼 findAll()이 가장 편하고 빨라. 상자가 한두 개뿐이라면 전부 책상에 올려놓고 찾는 게 더 빠를 수도 있지.”

루나는 솔라의 책상 위 펜꽂이를 가리켰다.

“펜꽂이에서 빨간 펜 하나 찾는 거랑 비슷해. 펜이 몇 자루 없으니, 전부 꺼내서 한번에 보고 찾는 게 효율적이지.”

“그런데,” 그녀는 다시 상자 더미로 시선을 돌렸다. “만약 저 상자가 스무 개가 아니라, 수백만 개가 쌓인 거대한 창고라면 어떨까? 그리고 그 창고에 있는 모든 상자를 네 작은 책상으로 가져와야만 머그컵을 찾을 수 있다면?”

솔라는 거대한 창고와 자기 책상을 상상했다. 상상만으로도 어깨가 뻐근해지는 기분이었다. 책상은 진작에 무너져 내렸을 거고, 수많은 상자에 파묻혀 머그컵은커녕 자기 몸 하나 가누기 힘들 것이다.

“창고가 데이터베이스(DB)고, 상자들이 데이터 행(row)이야. 그리고 네 책상은 우리 컴퓨터의 메모리(RAM)지. 상자를 옮기는 수고는 네트워크와 CPU가 쓰는 시간과 에너지야.”

루나의 차분한 설명에, 솔라의 머릿속에서 비로소 그림이 맞춰지기 시작했다. findAll()은 ‘창고의 모든 상자를 책상으로 가져와’라는 명령과 같았다. 데이터가 수십, 수백만 건이 되면, 한정된 메모리는 순식간에 가득 차고 시스템 전체가 마비될 수 있었다. 네트워크는 끊임없이 데이터를 전송하느라 지칠 것이고, 애플리케이션은 응답을 멈출 것이다. findAll은 마법이 아니라, 잠재적인 재앙을 부르는 주문일 수도 있었다.

솔라는 다시 모니터 속 문장을 바라봤다.

전체 조회는 부담스러워 한 번에 N개씩 잘라서 조회한다.

이제야 그 ‘부담’의 무게가 구체적으로 느껴졌다. 그것은 단순히 몇 줄의 코드가 아니라, 시스템의 안정성과 직결된 현실적인 고민이었다.

“아… 그래서 부담스럽다고 한 거구나. 데이터가 적을 땐 전혀 문제가 안되지만, 많아지면 시스템 전체가 멈출 수도 있으니까. 마치 도서관의 모든 책을 한 번에 옮기려는 것처럼.”

솔라는 고개를 끄덕이며 혼잣말처럼 중얼거렸다.

“그럼 ‘N개씩 잘라서 조회한다’는 건… 창고에서 상자를 전부 가져오는 대신, ‘일단 첫 번째 줄에 있는 상자 10개만 가져다주세요’라고 요청하는 거랑 비슷한 거겠네.”

“맞아. 필요한 만큼만, 감당할 수 있는 만큼만 가져오는 거지.”

솔라는 이제 findAll을 만능 해결책으로 생각했던 자신의 시야가 얼마나 좁았는지 깨달았다. 문제를 해결하는 것만큼이나, 문제를 일으키지 않는 방법을 고민하는 것이 중요했다. 이제 왜 전체 조회를 피해야 하는지는 명확히 이해했다. 하지만 새로운 질문이 곧바로 고개를 들었다.

“언니, 근데… 알겠어. 왜 그래야 하는지는. 그런데 그걸 어떻게 하는 거야? 컴퓨터한테 ‘10개만 잘라서 줘’ 라거나, ‘그다음 10개를 줘’ 같은 요청은 코드로 어떻게 보내?”

솔라의 눈에는 findAll의 편리함을 넘어선, 더 정교한 도구에 대한 호기심이 반짝이고 있었다. 전체를 옮기는 무모함 대신, 필요한 만큼만 현명하게 가져오는 방법에 대한 궁금증이었다.

2장: 필요한 만큼만, Pageable로 조회하기

솔라의 손가락이 키보드 위에서 잠시 멈췄다. 모니터에는 findAll() 메서드가 선언된 리포지토리 인터페이스가 띄워져 있었다. 어제까지만 해도 마법의 지팡이처럼 보였던 이 메서드는 이제 시한폭탄의 스위치처럼 느껴졌다. 어마어마한 상자 더미를 작은 책상 위로 옮기려 했던 무모함. 그 이미지가 머릿속을 떠나지 않았다.

‘전체를 가져오면 안 된다는 건 알겠어. 그럼 딱 10개만 가져오라고 하려면…’

솔라는 findAll 뒤에 괄호를 열고 (10)이라고 입력했다가 바로 지웠다. 말도 안 되는 코드였다. 메서드 이름 자체를 바꿔야 하나? findTop10()? 가능할 것 같았지만, ‘그다음 10개’는 어떻게 가져올까? find11to20()? 이건 더 아니었다. 길은 보였지만, 그 길을 걷는 법을 모르는 답답함이 밀려왔다.

그때, 방에 들어온 루나가 솔라의 굳은 표정을 보고 조용히 다가왔다.

“어제 말했던 ‘요청서’를 어떻게 써야 할지 막혔구나.”

솔라의 화면을 흘끗 본 루나는 책상 한쪽의 빈 포스트잇 한 장을 가져왔다. 그리고 그 위에 간단한 양식을 그렸다.


데이터 요청서 (Request Form)

  1. 원하는 페이지: [___] 번째 페이지
  2. 페이지당 개수: [___]

“컴퓨터에게 ‘10개만 줘’라고 모호하게 말하는 대신, 이런 요청서를 보낸다고 생각해 봐. 아주 명확하고 구체적이지.”

솔라는 포스트잇을 들여다봤다. 첫 번째 페이지에 10개. 두 번째 페이지에 10개. 어제 막연하게 상상했던 ‘첫 번째 줄 상자 10개만’이라는 요청을 정확하게 표현하는 방법이었다.

“이 요청서가 바로 Pageable이야.”

루나의 말에 솔라의 눈이 반짝였다. 막연했던 개념에 이름이 붙는 순간이었다. 솔라는 재빨리 코드를 수정하기 시작했다. findAll 메서드의 인자로 Pageable 객체를 넘겨주면 되었다. ‘0번째 페이지부터 시작해서, 10개씩 가져와주세요.’라는 의미를 담아 PageRequest.of(0, 10) 코드를 작성했다. findAll()findAll(Pageable pageable)로 바뀌는 순간이었다.

// 솔라's thought process in code
// 이전: List<Post> findAll();
// 변경: Page<Post> findAll(Pageable pageable);

// ...

// 서비스 코드 어딘가에서
Pageable pageRequest = PageRequest.of(0, 10); // 0번째 페이지, 10개 사이즈
Page<Post> postPage = postRepository.findAll(pageRequest);

실행 버튼을 누르자, 거짓말처럼 10개의 데이터만 깔끔하게 조회되었다. 터질 것 같았던 창고에서 딱 필요한 만큼만 안전하게 꺼내 온 기분이었다.

“와! 된다! 언니, 진짜 10개만 가져왔어. 이제 findAll 때문에 시스템이 터질 걱정은 안 해도 되겠네!”

솔라는 환호했다. findAll의 그림자에서 벗어나 데이터를 효율적으로 제어할 수 있는 새로운 도구를 손에 넣은 것이다. 이것만 있으면 게시판의 어떤 페이지든, 상품 목록의 어떤 구간이든 자유롭게 보여줄 수 있을 것 같았다.

“한 번 더 요청해 봐. 0번째 페이지 말고, 1번째 페이지로.”

루나가 차분하게 말했다. 솔라는 신이 나서 코드를 PageRequest.of(1, 10)으로 바꾸고 다시 실행했다. 역시나, 처음과는 다른 10개의 데이터가 조회되었다. 성공이었다.

“자, 그럼 다시 0번째 페이지를 조회해 볼래? 그리고 방금 가져온 10개랑 비교해 봐.”

솔라는 루나의 말에 고개를 갸웃했지만, 시키는 대로 다시 0번째 페이지를 조회했다. 결과는 아까와 같았다. 10개의 데이터가 나왔다. 그런데 결과를 자세히 들여다보던 솔라의 얼굴에 미묘한 변화가 스쳤다.

“어라…?”

분명 10개의 데이터가 나왔지만, 그 순서가 이상했다. 최신순도, 오래된 순도, 알파벳순도 아니었다. 데이터베이스에 저장된 순서 그대로, 혹은 데이터베이스 시스템이 가장 꺼내기 편한 순서대로 무질서하게 뽑혀 나온 결과였다. 마치 도서관 사서에게 “아무 책이나 10권 주세요”라고 말한 것과 같았다.

“이상하다. 10개를 가져오는 건 성공했는데… 첫 페이지라면 당연히 가장 최근에 쓴 글 10개가 나와야 하는 거 아니야? 이건 순서가 완전히 뒤죽박죽이네.”

솔라는 자신이 반쯤만 성공했다는 사실을 깨달았다. ‘얼마나 가져올 것인가’라는 양의 문제는 해결했지만, ‘어떤 순서로 가져올 것인가’라는 질의 문제가 새롭게 등장한 것이다. Pageable은 그저 데이터를 N개씩 잘라주는 칼일 뿐, 그 내용물을 정렬해 주는 도구는 아니었다.

솔라는 자신이 작성한 PageRequest.of(0, 10) 코드를 망연히 바라봤다. ‘몇 개’는 지정했지만, ‘어떻게’는 말해주지 않았던 것이다.

“언니, 그럼… 페이지를 나눌 때 순서도 정해줄 수 있어? ‘최신순으로 정렬해서 첫 페이지 줘’ 같은 식으로 말이야.”

솔라의 질문은 findAll의 문제를 해결한 기쁨에서, 더 정교한 제어를 향한 새로운 궁금증으로 넘어가고 있었다. 양을 제어하는 법을 배웠으니, 이제 질서를 부여할 차례였다.

3장: 순서를 정하다: Sort와 save의 양면성

솔라의 모니터에는 어제와 같은 10개의 데이터가 떠 있었지만, 이제는 무질서한 데이터 덩어리가 아닌 실패의 증거처럼 보였다. ‘10개씩 가져오기’는 성공했지만, 가장 중요한 ‘어떻게’가 빠져 있었다. 최신 글을 보여주고 싶은데, 조회할 때마다 뒤죽박죽인 목록은 아무 쓸모가 없었다.

솔라가 멍하니 코드를 바라보고 있을 때, 루나가 다가와 솔라의 책상에 놓여있던 포스트잇을 조용히 집어 들었다. 어제 Pageable을 설명하며 그렸던 ‘데이터 요청서’였다.


데이터 요청서 (Request Form)

  1. 원하는 페이지: [ 0 ] 번째 페이지
  2. 페이지당 개수: [ 10 ]

루나는 펜을 들어 그 아래에 새로운 항목을 추가했다.


데이터 요청서 (Request Form)

  1. 원하는 페이지: [ 0 ] 번째 페이지
  2. 페이지당 개수: [ 10 ]
  3. 정렬 기준: [___] (으)로, [___] 순으로

새롭게 추가된 3번 항목. 솔라는 그 빈칸을 보자 어젯밤 자신이 던졌던 질문이 떠올랐다. ‘최신순으로 정렬해서 첫 페이지 줘’ 같은 요청. 바로 그 요청을 담을 수 있는 칸이었다. 막연했던 요구사항에 구체적인 자리가 생긴 것이다.

“요청서에 한 줄이 더 필요한 거였어.”

루나의 말에 솔라는 고개를 끄덕이며 다시 코드를 들여다봤다. PageRequest.of(0, 10). ‘0번째 페이지, 10개’라는 요청만으로는 부족했다. ‘정렬 기준’이라는 세 번째 인수가 필요했다.

“그럼 여기에 정렬 규칙을 알려주는 Sort라는 걸 같이 보내면 되겠네.”

솔라는 직감적으로 깨닫고 코드를 수정하기 시작했다. ‘생성일시(createdAt)를 기준으로, 내림차순(descending)으로 정렬해 주세요.’라는 요청은 Sort.by("createdAt").descending() 이라는 명확한 코드로 번역되었다. Pageable 요청서에 정렬 기준을 명시한 새로운 Sort 객체를 함께 담아 보내는 것이다.

// 솔라의 머릿속 코드 조립 과정
Pageable pageRequest = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Post> postPage = postRepository.findAll(pageRequest);

다시 실행 버튼을 누르자, 화면에 나타난 10개의 게시글은 방금 전과 완전히 달랐다. 가장 최근에 작성된 글부터 순서대로, 완벽하게 정렬된 목록이 눈앞에 펼쳐졌다. 무질서하게 널려 있던 상자들이 드디어 작성일 순서대로 가지런히 정돈된 느낌이었다.

“됐다! 이제 진짜 원하는 대로 데이터를 가져올 수 있게 됐어!”

솔라는 기쁨에 겨워 소리쳤다. Pageable로 양을 제어하고, Sort로 순서를 바로잡는다. 이제 findAll은 더 이상 두려운 존재가 아니었다. 필요한 만큼만, 원하는 순서대로 데이터를 가져올 수 있는 강력한 도구를 두 개나 손에 넣은 것이다.

자신감이 붙은 솔라는 한 걸음 더 나아가 보기로 했다. 데이터를 읽는 것뿐만 아니라, 쓰는 것도 간단할 거라 생각했다. 예를 들어, 이렇게 조회한 최신 글의 제목을 수정하고 싶다면?

솔라는 방금 조회한 첫 번째 게시글 객체를 가져와 제목을 바꾸고, 주저 없이 postRepository.save() 메서드를 호출하는 코드를 상상했다.

// 솔라의 다음 생각
Post latestPost = postPage.getContent().get(0);
latestPost.changeTitle("수정된 제목!"); // 제목 변경
postRepository.save(latestPost); // 저장!

“데이터 조회는 Pageable이랑 Sort로 하고, 저장이나 수정은 그냥 save() 쓰면 다 해결되는 거 맞지, 언니?”

솔라는 자신의 완벽한 시나리오에 만족하며 물었다. 조회, 수정, 등록. 모든 데이터 처리가 이 간단한 메서드들로 우아하게 끝나는 그림을 그리고 있었다. 마치 findAll을 처음 만났을 때처럼, save 역시 모든 것을 해결해 주는 만능 키처럼 보였다.

루나는 대답 대신, 솔라의 코드를 물끄러미 보다가 새로운 시나리오를 던졌다.

“방금 그건 있던 글을 수정한 거고. 그럼 아직 데이터베이스에 없는, 완전히 새로운 글을 저장할 때도 같은 save() 메서드를 쓸까?”

“어… 당연히 그렇겠지? 저장하는 거니까 save잖아.”

솔라는 당연하다는 듯 대답했지만, 루나의 질문 속에는 미묘한 함정이 숨어있는 것 같았다.

“정말? 하나는 데이터베이스의 기존 줄을 바꾸는 UPDATE 명령이고, 다른 하나는 새로운 줄을 추가하는 INSERT 명령이야. 전혀 다른 작업인데, 어떻게 똑같은 save() 메서드 하나로 두 가지 일을 다 처리하는 걸까?”

루나의 질문에 솔라는 말문이 막혔다. 생각해보니 그랬다. save()를 호출했을 때, 이 객체가 원래 있던 놈인지, 아니면 새로 온 놈인지 어떻게 알고 알아서 UPDATEINSERT를 구별해서 실행하는 걸까?

솔라는 자신이 작성한 postRepository.save(latestPost) 코드를 다시 바라보았다. 방금 전까지 데이터 처리의 종착역처럼 보였던 save() 메서드가, 이제는 속을 알 수 없는 미스터리한 상자처럼 보이기 시작했다. 그 안에서 어떤 기준으로, 어떤 일이 벌어지고 있는 걸까. 순서의 문제를 해결하자, 이번에는 ‘존재’의 문제가 고개를 들었다.

4장: 똑똑한 save(): ID로 구분하는 영속화 전략

솔라의 손가락은 postRepository.save(latestPost) 코드 위에서 미동도 하지 않았다. 수정할 때도, 새로 등록할 때도 똑같은 이름의 메서드라니. 마치 하나의 열쇠로 현관문과 자동차 문을 모두 여는 것처럼 편리했지만, 그 원리를 알 수 없어 답답했다. UPDATEINSERT. 분명히 데이터베이스에는 다른 명령으로 전달될 텐데, 이 save라는 메서드는 어떻게 그 둘을 구분하는 걸까?

그때, 방으로 들어온 루나가 솔라의 고민을 읽은 듯, 책상 위 키보드 옆에 작은 카드 두 장을 나란히 놓았다. 직접 만든 듯한 조악한 회원 카드였다. 첫 번째 카드에는 이름 칸만 덩그러니 비어 있었고, 두 번째 카드에는 ‘회원 번호: 15번’이라고 선명하게 적혀 있었다.

[ 카드 1 ]
이름: [      ]
회원 번호: (발급 전)
--------------------

[ 카드 2 ]
이름: [ 루나 ]
회원 번호: 15
--------------------

루나는 아무 말 없이 그 카드들을 솔라 쪽으로 밀어주었다. 어제 Pageable을 설명하던 포스트잇처럼, 이 카드들에도 설명이 담겨 있을 터였다. 솔라는 골똘히 생각에 잠긴 채 두 카드를 번갈아 바라보았다. 하나는 아직 번호가 없는 신규 회원용 신청서 같았고, 다른 하나는 이미 발급된 기존 회원의 신분증 같았다.

“언니, 이게 save가 하는 일을 설명하는 거야?”

솔라의 물음에 루나는 고개를 끄덕였다.

“데이터베이스를 깐깐한 클럽 문지기라고 생각해 봐. 네가 저 카드들을 한 장씩 들고 가서 문지기에게 건네는 거야. 첫 번째 카드를 받은 문지기는 뭘 할까?”

솔라는 잠시 상상에 잠겼다. 회원 번호가 없는 카드를 받은 문지기.

“음… 신규 회원이구나, 하고 생각하겠지? 그래서 회원 명부에 새로운 줄을 하나 추가하고, 비어있는 회원 번호 칸에 ‘16번’ 같은 새로운 번호를 발급해서 적어줄 거야. 그리고 명부에 이름을 기록하겠지.”

“맞아. 그게 INSERT야. 새로운 레코드를 만드는 거지. 그럼 두 번째 카드를 건네면?”

“두 번째 카드는 ‘회원 번호: 15번’이라고 이미 적혀있으니까, 문지기는 명부에서 15번 회원을 찾을 거야. 그리고는 ‘아, 15번 루나 회원이시군요’ 하고 확인하겠지. 만약 내가 카드에 적힌 이름을 ‘루나’에서 ‘루나-L’로 바꿔서 건넸다면, 문지기는 기존 명부의 15번 회원 이름을 ‘루나-L’로 고쳐줄 거고.”

“새로운 회원을 등록하지 않고?”

“응. 이미 있는 회원이니까. 그게 UPDATE구나.”

순간, 솔라의 머릿속에 흩어져 있던 퍼즐 조각들이 맞춰졌다. save 메서드가 똑똑한 게 아니었다. 문지기처럼 그저 원칙에 따라 움직이는 것뿐이었다. 그리고 그 원칙은 바로 ‘회원 번호’, 즉 엔티티의 ID 존재 유무였다.

솔라는 재빨리 자신의 코드로 눈을 돌렸다.

// 시나리오 1: 신규 게시글 저장
Post newPost = new Post("새로운 글입니다."); // 아직 ID가 없음 (null 상태)
postRepository.save(newPost); // ID가 없네? -> INSERT 실행

// 시나리오 2: 기존 게시글 수정
Post existingPost = postRepository.findById(15L).get(); // DB에서 ID 15번 게시글 조회
existingPost.changeTitle("제목을 수정했어요."); // 내용 변경
postRepository.save(existingPost); // ID(15)가 있네? -> UPDATE 실행

“아! save 메서드에 전달된 객체에 ID 값이 있느냐 없느냐로 결정되는 거였어. ID가 null이면 새로운 데이터라고 판단해서 INSERT 쿼리를 만들고, ID 값이 있으면 기존 데이터라고 판단해서 UPDATE 쿼리를 만드는 거구나.”

신분증에 번호가 있나 없나를 확인하는 문지기처럼, save 메서드는 엔티티의 @Id 필드를 확인하고 있었다. 미스터리 상자 같았던 save의 내부가 투명하게 들여다보이는 순간이었다. 솔라는 이제 어떤 객체를 save에 넘겨주면 어떤 쿼리가 실행될지 예측할 수 있게 되었다.

“이제 findAll의 부담은 PageableSort로 덜고, 데이터 저장은 save의 ID 규칙을 이용하면 되겠네. 웬만한 건 이걸로 다 할 수 있겠는데?”

솔라는 자신감이 넘쳤다. 데이터를 가져오는 방법, 정렬하는 방법, 그리고 저장하고 수정하는 방법까지. Spring Data JPA가 제공하는 강력하고 편리한 도구들로 대부분의 데이터 처리 문제를 해결할 수 있을 것 같았다.

“그래? 그럼 이런 건 어때?”

루나가 새로운 문제를 툭 던졌다.

“게시글 중에서 ‘추천 수’가 10개 이상이고, 제목에 ‘JPA’라는 단어가 포함된 글들만 찾아서, 글의 전체 내용 말고 ‘제목’과 ‘작성자’만 뽑아서 보고 싶다면? 메서드 이름으로 어떻게 만들래? findByLikesGreaterThanAndTitleContaining...? 너무 길지 않아?”

솔라는 입을 려다 말문이 막혔다. 메서드 이름이 끝도 없이 길어질 것 같았다. 게다가 특정 필드만 뽑아오는 건 어떻게 해야 할지 감도 잡히지 않았다.

“하나 더. 작년에 작성된 모든 게시글 제목 앞에 ‘[오래된 글]‘이라는 문구를 한 번에 추가하고 싶다면? findAll로 모든 글을 가져와서 하나씩 save를 호출하는 건, 우리가 첫날에 피하기로 했던 ‘창고의 모든 상자를 옮기는’ 일과 똑같잖아.”

루나의 질문은 솔라의 자신감에 찬물을 끼얹었다. Pageable, Sort, save. 이 편리한 도구들만으로는 해결하기 어려운, 더 복잡하고 미묘한 요구사항들이 분명히 존재했다. 기본 제공되는 메서드들은 정해진 길을 가는 데는 훌륭했지만, 길이 없는 곳을 가거나 지름길을 만드는 데는 한계가 있었다.

솔라는 다시 고민에 빠졌다. save가 만능이 아니었듯, 기본 리포지토리 메서드들도 만능이 아니었다. 그렇다면 이런 복잡한 요구사항들은 대체 어떻게 처리해야 하는 걸까. 마치 잘 닦인 고속도로를 벗어나, 지도에도 없는 험한 산길을 마주한 기분이었다. 길을 직접 만들어야 할 때가 온 것이다.

5장: @Query: 복잡한 요구사항을 위한 맞춤형 쿼리

솔라의 커서가 깜박였다. 텅 빈 리포지토리 인터페이스 안에서, 그녀는 어제 루나가 던졌던 두 가지 어려운 질문을 구현해 보려 애쓰고 있었다. ‘추천 수 10개 이상, 제목에 JPA 포함.’ 간단한 조건 같았지만, 이걸 Spring Data JPA의 메서드 이름 규칙으로 만들려니 끝이 보이지 않았다.

List<Post> findByLikesGreaterThanAndTitleContaining...

메서드 이름만으로도 숨이 찼다. 더 큰 문제는, ‘제목과 작성자만 뽑아서 보고 싶다’는 요구사항이었다. 메서드 이름으로는 도저히 표현할 방법이 없었다. Post 엔티티 전체를 가져와서 애플리케이션에서 일일이 원하는 데이터만 골라내야 한단 말인가? 그건 첫날에 피하기로 했던 비효율과 다를 바 없었다.

결국 솔라는 키보드에서 손을 떼고 의자 깊숙이 몸을 묻었다.

“메서드 이름으로 소설을 쓰겠네… 이건 아닌 것 같아.”

자신이 배운 Pageable, Sort, save는 정해진 길을 갈 때 쓰는 훌륭한 자동차 같았다. 하지만 지금 마주한 문제는, 자동차가 갈 수 없는 험한 산길이나 지도에 없는 지름길을 개척해야 하는 상황이었다.

“언니, findBy 같은 걸로는 안 되는 복잡한 조건은… 결국 SQL을 직접 써야 해? 그건 너무 어렵고 실수하기도 쉬운데…”

솔라는 고개를 저었다. SQL을 직접 다루는 것은 피하고 싶었다. 편리한 메서드들을 쓰다가 다시 날것의 쿼리문으로 돌아가는 건 퇴보처럼 느껴졌다. @Query라는 어노테이션이 어렴풋이 떠올랐지만, 왠지 모를 거부감이 들었다. 그건 아주 특별하고 어려운 경우에만 쓰는 전문가들의 도구일 거라 지레짐작했다.

그때, 솔라의 혼잣말을 들은 루나가 다가왔다. 그녀는 솔라의 모니터에 적힌 길고 어색한 메서드 이름을 잠시 바라보더니, 책상 한편에 있던 빈 스케치북과 펜을 가져왔다.

“솔라, 우리가 지금까지 한 건 ‘메서드 이름’이라는 약속된 형식으로 JPA에게 우리가 원하는 걸 에둘러 설명하는 방식이었어. ‘Likes가 크고(GreaterThan)’, ‘Title이 포함된(Containing)’ 이런 식으로.”

루나는 스케치북에 findBy...(...) 라고 적었다.

“그런데 만약 우리가 직접 주문서를 작성해서 건네줄 수 있다면 어떨까? 에둘러 말하는 대신, 데이터베이스가 쓰는 언어로 명확하게.”

루나는 findBy... 라고 적은 글자 위에 커다랗게 덧그렸다.

@Query("여기에 직접 명령을 적으세요")
findComplexPosts(...);

새롭게 그려진 그림. 메서드 이름은 findComplexPosts처럼 단순하고 명료해졌다. 대신 그 위에 @Query라는 이름표와 함께, 직접 명령을 적을 수 있는 빈칸이 생겼다. 약속된 이름으로 힌트를 주던 방식에서, 쿼리 그 자체를 직접 써서 전달하는 방식으로의 전환이었다.

“아…”

솔라는 그제야 깨달았다. @Query는 어려운 SQL을 쓰기 위한 도구가 아니었다. 오히려 메서드 이름으로 표현하기 힘든 복잡하고 세밀한 요구사항을 ‘정확하게’ 전달하기 위한, 더 직접적인 소통 창구였다.

“그럼 여기에 아까 그 조건을 그대로 쓰면 되는 거야? ‘추천 수 10개 이상, 제목에 JPA 포함’ 같은 거.”

솔라는 다시 키보드를 잡았다. 루나의 도움을 받아, 그녀는 비어있던 @Query의 빈칸을 채워나갔다. 데이터베이스의 엔티티를 기준으로 작성하는 JPQL(Java Persistence Query Language) 문법은 순수 SQL과 조금 달랐지만, 그 의도는 훨씬 명확하게 읽혔다.

@Query("SELECT p FROM Post p WHERE p.likes >= :likes AND p.title LIKE %:keyword%")
List<Post> findComplexPosts(@Param("likes") int likes, @Param("keyword") String keyword);

메서드 이름은 간결해졌고, 복잡한 로직은 모두 @Query 안으로 들어갔다. 이제 findComplexPosts(10, "JPA")를 호출하면, JPA는 메서드 이름을 해석하는 대신 @Query에 적힌 명령서를 데이터베이스에 그대로 전달할 것이다.

“그럼 제목이랑 작성자만 가져오는 것도…?”

“그것도 이 명령서에 적으면 돼. SELECT p.title, p.author FROM Post p ... 처럼. 아니면 아예 그 두 필드만 담는 새로운 DTO(Data Transfer Object) 객체를 만들어서 반환할 수도 있고.”

@Query("SELECT new com.example.PostSummaryDto(p.title, p.author) ...")

루나의 설명에 솔라는 눈을 빛냈다. 엔티티 전체를 가져와 메모리를 낭비할 필요 없이, 처음부터 딱 필요한 조각만 가져올 수 있었다. 작년에 작성된 모든 글의 제목을 한 번에 바꾸는 것 같은 대량 수정 작업도, findAll로 모든 것을 불러오는 대신 @Modifying 어노테이션과 함께 @Query를 사용해 단 한 번의 UPDATE 쿼리로 끝낼 수 있었다.

솔라는 자신이 @Query를 오해했다는 것을 인정했다. 그것은 어려운 길로 돌아가는 것이 아니라, 가장 빠르고 정확한 지름길을 직접 만드는 설계도와 같았다.

그녀는 책상 위에 흩어져 있던 지난 며칠의 흔적들을 둘러보았다. findAll의 위험성을 알려주던 상자 더미, Pageable을 설명하던 데이터 요청서, save의 비밀을 담고 있던 회원 카드들. 그리고 이제, 복잡한 요구사항을 해결할 @Query라는 맞춤형 주문서까지.

솔라는 잠시 생각에 잠기더니, 스터디 그룹의 새로운 과제를 해결하기 위해 코드를 작성하기 시작했다. 과제는 복합적이었다.

"최근 일주일간 작성된 게시글 중, '좋아요'를 가장 많이 받은 상위 5개를 찾아 제목과 '좋아요' 수를 표시해 주세요. 또한, 사용자가 새 글을 등록하거나 기존 글의 제목을 수정하는 기능도 함께 구현해야 합니다."

이전의 솔라였다면 어디서부터 시작해야 할지 막막했을 것이다. 하지만 이제는 달랐다. 그녀는 문제를 차분히 분해하고, 각 문제에 가장 적합한 도구를 꺼내 들었다.

“‘최근 일주일, 좋아요 상위 5개, 특정 필드만 조회’… 이건 조건이 복잡하니까 findBy로는 무리야. @Query를 써서 직접 쿼리를 짜야겠어. 그리고 상위 5개만 필요하니까 Pageable을 같이 넘겨주면 되겠네.”

그녀는 리포지토리 인터페이스에 @QueryPageable을 사용하는 메서드를 망설임 없이 정의했다.

“‘새 글 등록’은 ID가 없는 새 객체를 save하면 INSERT가 될 거고, ‘기존 글 수정’은 ID가 있는 객체를 save하면 UPDATE가 되겠지. 이건 save의 규칙을 믿으면 돼.”

솔라의 손가락이 키보드 위에서 자신감 있게 움직였다. findAll이라는 하나의 만능 열쇠에 의존하던 모습은 온데간데없었다. 이제 그녀의 손에는 Pageable, Sort, save, 그리고 @Query라는, 각기 다른 모양과 쓰임새를 가진 여러 개의 정교한 도구들이 들려 있었다. 그리고 그녀는 언제 어떤 문을 열어야 할지 정확히 알고 있었다.