SQL을 직접 짜본 게 언제였더라: AI의 리팩토링 계획을 뒤집은 날

편리함의 청구서

오늘 하루 짠 코드를 돌아보자. repository.save()를 몇 번 호출했고, findBy로 시작하는 메서드 이름을 몇 개 지었는가. 그리고 SQL은 몇 줄 직접 썼는가.

나는 마지막 질문 앞에서 오래 멈췄던 적이 있다. 메서드 이름만 지으면 쿼리가 알아서 만들어진다. 복잡한 조회는 이미 있는 XML을 복사해 조건 하나만 바꾼다. 막히면 AI에게 물어본다. 요즘 백엔드의 데이터 접근은 대개 이 세 가지로 굴러가고, 나도 몇 년을 그렇게 일했다. 불편한 적은 없었다. 오히려 편했다. 문제는 편리함이 공짜였던 적이 없다는 것이다. 청구서는 늘 한참 뒤에, 예상 못 한 이름표를 달고 도착한다.

이 글은 내게 그 청구서가 도착한 날의 기록이고, 그날 이후 SQL을 처음부터 다시 잡기로 한 재활의 첫 페이지다. 사건으로 들어가기 전에 그림을 하나 그려두자. 이 그림 하나면 오늘 이야기는 끝까지 따라올 수 있다.


창고와 두 개의 문

데이터베이스는 창고다. 서비스의 모든 데이터가 여기 쌓여 있다. 그리고 창고에서 일어나는 일은 딱 두 종류다. 물건을 꺼내 보기만 하는 것, 그리고 물건을 넣거나 바꾸거나 버리는 것. SQL로는 앞쪽이 SELECT(조회), 뒤쪽이 INSERT/UPDATE/DELETE(쓰기)다. 그러니까 창고에는 문이 두 개 있는 셈이다. '읽기 문'과 '쓰기 문'.

우리는 보통 이 창고에 직접 들어가지 않는다. 직원을 시킨다.

  • JPA는 통역 직원이다. ORM(Object-Relational Mapping) — 자바 객체를 건네며 “저장해줘”라고 말하면 알아서 SQL로 통역해 창고에 다녀온다. 나는 SQL을 볼 일이 없다.
  • MyBatis는 심부름 직원이다. SQL Mapper — 내가 써준 SQL 쪽지를 토씨 하나 안 바꾸고 그대로 들고 창고에 다녀온다. SQL은 내가 쓴다.
읽기 문과 쓰기 문이 나란히 달린 창고 앞에 두 직원이 서 있는 일러스트. 한 명은 통역용 헤드셋을, 한 명은 SQL 쪽지를 들고 있다.
데이터베이스라는 창고, 읽기와 쓰기라는 두 개의 문, 그리고 문을 대신 드나드는 두 직원.

여기서 오늘의 핵심 질문이 나온다. '어느 직원이 어느 문을 쓰는가.' 두 직원이 두 문을 모두 드나드는 창고와, 문을 하나씩 나눠 맡은 창고는 완전히 다른 건물이다. 그리고 이 차이를 확인했는지 여부에 따라 계획 하나의 운명이 갈릴 수 있다. 실제로 갈렸다. 이제 그 사건으로 들어간다.


그럴듯한 진단 — “직원부터 한 명으로 줄이자”

내가 운영에 참여하는 시스템은 농산물 생산 이력을 추적하는 규제 산업 시스템이다(Spring Boot + JPA/Hibernate + MyBatis + PostgreSQL). 직원이 두 명인 창고다. 규제 산업이다 보니 어느 날 감사(audit) 로그 — '누가 언제 어떤 데이터를 바꿨는가'를 남기는 기록 — 요구가 내려왔다. 창고 비유로 옮기면, 쓰기 문에 CCTV를 달라는 요구다.

첫 제안서는 AI 어시스턴트가 작성했다. 진단 요지는 이랬다.

ORM으로 Hibernate와 MyBatis가 혼재되어 있어 데이터 접근을 가로챌 단일 창구가 없다. 따라서 데이터 접근 계층을 JPA로 통일하는 리팩토링을 먼저 해야 한다.

어디가 이상한지 보이는가? 보인다면 이 글은 복습이고, 안 보인다면 잘 찾아왔다. 나는 이 진단을 6개월 동안 의심하지 못했다 — 그 코드베이스로 매일 일하는 사람이 말이다.

비유로 번역하면 이렇다. “직원이 두 명이라 물건이 어느 손을 거쳐 바뀌는지 한자리에서 볼 수 없다. CCTV를 달기 전에 직원부터 JPA 한 명으로 줄이자.”

그럴듯함은 힘이 세다. 실제로 이 전제 위에서 '선(先) 리팩토링, 후(後) 기능 구현'이라는 2단계 계획이 세워졌고, PoC(Proof of Concept — 실제로 되는지 작게 만들어보는 사전 검증)까지 진행됐다. 대규모 코드 변경을 각오하고 있었다는 뜻이다.

그런데 이 진단에는 확인되지 않은 전제가 하나 깔려 있다. '두 직원이 정말 둘 다 쓰기 문을 쓰고 있는가?'


6개월 뒤, 문 앞에 서서 세어봤다

6개월 뒤 재검토 시점이 왔다. 이번에는 제안서의 결론이 아니라 전제를 물었다. 잊지 말아야 할 사실 하나 — 감사 로그가 추적할 대상은 '바꾸는' 쪽뿐이다. SELECT는 천 번을 실행해도 데이터가 변하지 않는다. 그러니 '단일 창구가 없다'는 진단이 성립하려면, 두 직원이 모두 쓰기 문을 드나들고 있어야 한다.

이건 토론할 문제가 아니라 세어볼 문제다. 가설: 'MyBatis도 쓰기를 한다.' 검증: 창고 문 앞에 서서 출입 기록을 세듯, MyBatis의 SQL이 담긴 XML 매퍼 파일을 ripgrep(rg — 코드 전체에서 패턴을 검색하는 도구)으로 뒤진다.

# MyBatis XML 매퍼의 구문 분포 확인
rg -n '<select\s' **/mapper/*.xml | wc -l          # → 245건
rg -in '<(insert|update|delete)' **/mapper/*.xml    # → 0건
rg -n '@Insert\(|@Update\(|@Delete\('               # → 0건 (어노테이션 방식도 없음)
rg -n 'sqlSession\.(insert|update|delete)'          # → 0건 (직접 호출도 없음)

MyBatis 직원의 출입 기록 245건은 전부 SELECT, 즉 읽기 문 통과였다. 쓰기 문을 지난 기록은 XML에도, 어노테이션에도, 직접 호출에도 '0건'. 용의자에게는 완벽한 알리바이가 있었다. 이 문은 애초에 구경꾼만 드나드는 문이었던 것이다.

그럼 쓰기 문은 누가 쓰고 있었나. JPA 쪽을 같은 방식으로 셌다. repository.save/delete 호출이 309건. entityManager.persist/merge/remove가 32건 — 영속성 컨텍스트(JPA가 객체의 변경을 감시하는 작업대)를 직접 다루는 저수준 호출이다. 여기에 @Modifying 벌크 쿼리(여러 행을 SQL 한 방으로 UPDATE/DELETE하는 방식) 49건이 리포지토리 31개에 흩어져 있었다. 쓰기는 100% JPA 경로였다.

두 직원이 뒤엉켜 있다던 창고는, 사실 처음부터 문을 하나씩 나눠 맡은 창고였다. 그리고 이 사실 하나가 계획 전체를 뒤집는다.


반전: CCTV는 쓰기 문 하나에만 달면 됐다

전제가 무너지자 결론도 함께 무너졌다.

감사 로그의 목적은 '변경'의 추적이다. 그런데 변경이 전부 JPA/Hibernate라는 한 직원을 지나간다면, 그 직원이 드나드는 문에만 카메라를 달면 끝이다. 실제 기술로는 Hibernate Interceptor — Hibernate가 INSERT/UPDATE/DELETE를 실행하기 직전에 끼어들 수 있도록 열어둔 훅 — 1개면 된다. 남는 사각지대는 영속성 컨텍스트를 우회하는 @Modifying 벌크 쿼리 49건뿐인데, 이건 Spring AOP(특정 메서드 호출의 앞뒤에 공통 동작을 끼워 넣는 스프링 기능) 1개로 보완된다.

쓰기 문 위에만 CCTV가 한 대 달린 창고 일러스트. 읽기 문은 카메라 없이 열려 있다.
쓰기가 전부 한 문으로만 지나간다면, 카메라는 그 문 위 한 대면 충분하다.

기존 비즈니스 코드 수정은 0줄. '대규모 선행 리팩토링' 계획은 그 자리에서 폐기됐다. 검증에 든 비용은 grep 몇 줄이었고, 그 몇 줄이 아낀 비용은 MyBatis 조회 245건을 JPA로 옮겨야 했을 이관 작업 전체였다.

교훈을 “AI 말을 믿지 말자”로 줄이면 반쪽이다. 그럴듯한 진단은 — AI가 썼든 시니어가 썼든 — 전제가 아니라 검증 대상이고, 검증이란 대단한 게 아니라 문 앞에 서서 세어보는 일이라는 것. 그런데 이 실측은 계획 폐기 말고도 두 가지를 더 남겼다. 하나는 이 창고에 대한 발견이고, 하나는 나에 대한 고백이다.


혼돈이 아니라 분업이었다

먼저 발견. “JPA와 MyBatis가 섞여 있으면 나쁜 프로젝트다”라는, 나도 어렴풋이 갖고 있던 통념이 적어도 이 창고에서는 틀렸다. 숫자가 보여준 건 혼돈이 아니라, 의도했든 아니든 자연스럽게 자리 잡은 분업이었다.

작업 유형담당 직원이유
저장/수정/삭제 — 쓰기 문 전부JPA더티 체킹(객체 변경을 감지해 UPDATE를 만들어주는 기능), 연관관계 관리 — 그리고 이제는 감사 로그의 수집 창구
단건/목록 단순 조회JPA — 쿼리 메서드(메서드 이름이 곧 쿼리가 되는 기능)와 JPQL(엔티티 기준의 JPA 쿼리 언어)타입 안전, 빠른 개발
다중 조인 + 집계 + 동적 조건MyBatisSQL 전체를 눈으로 보고 통제
통계/보고서/피벗MyBatisJPQL로는 표현이 안 되는 영역

혼용 자체는 죄가 아니다. '누가 어느 문을 맡는지'의 경계가 흐린 것이 죄다. 이 프로젝트의 기술 선택 이야기는 예전에 MyBatis vs JPA: 세상에 나쁜 기술은 없다에서 다뤘는데, 이번 실측은 그 글의 결론에 숫자를 달아준 셈이 됐다.

여기까지가 사건의 공식 기록이다. 그런데 이 조사에는 공식 기록에 없는 후일담이 하나 있다. 사실 이 시리즈를 시작한 진짜 이유다.


진짜 범인은 따로 있었다

처음에는 AI를 탓하려 했다. 그럴듯한 문장을 확신에 찬 어조로 쓰는 건 AI가 가장 잘하는 일이니까. 하지만 조사 중에 마주친 장면 하나가 그 변명을 가로막았다.

출입 기록을 세겠다고 MyBatis XML 245건을 열었을 때, 나는 그 쿼리들을 제대로 읽어내지 못했다. 매일 이 시스템을 운영하는 내가, 남의 코드처럼 더듬거렸다.

돌아보면 당연한 결과였다. 몇 년 동안 쿼리는 메서드 이름으로 만들었고, 복잡한 조회는 복사했고, 막히면 AI에게 물었다. 매일 창고에 물건을 넣고 꺼내면서, 정작 창고 안이 어떻게 생겼는지는 전부 직원들에게 맡겨둔 셈이다. AI의 진단이 6개월 동안 그럴듯해 보였던 진짜 이유도 여기에 있다. 데이터 접근 계층을 스스로 읽고 의심할 감각이 없으면, 그럴듯함과 사실을 구분할 방법도 없다.

그래서 SQL을 처음부터 다시 잡기로 했다. 이 시리즈는 그 재활의 기록이다. 이번 사건이 보여줬듯 SQL을 읽는 감각은 쿼리를 짤 때만 쓰이는 게 아니다. 그럴듯한 제안서 앞에서 “그 전제, 세어봤는가?”라고 물을 수 있는 힘이 되고, 대규모 리팩토링 하나를 grep 몇 줄로 멈춰 세우는 힘이 된다. 공부하며 만드는 노트는 저장소로 공개한다 — SQLD 같은 시험을 준비하는 사람도 쓸 수 있게 주제별 시험 가중치 표기를 달아뒀다.

SQL 공부 노트 저장소의 README — 실습 환경부터 조인, 집계, 계층형 질의까지 주제별로 정리된 목차
재활 훈련용 노트. 시험 가중치(⭐)와 함정(⚠️) 표기를 달아뒀다.

재활의 시작으로, 저 245건부터 하나씩 다시 열어볼 생각이다. 읽지 못해 지나쳤던 쿼리 한 건 한 건이 이제는 공짜 교과서다. 그 안에 내가 아직 읽지 못하는 패턴은 몇 개나 숨어 있을까.

당신의 창고는 어떤가. 어느 직원이 어느 문을 드나드는지, 세어본 적 있는가.

“SQL을 직접 짜본 게 언제였더라: AI의 리팩토링 계획을 뒤집은 날”에 대한 3개의 생각

  1. 데이터를 변경하는 등록/수정/삭제, 즉 CUD 서비스 메서드에 Spring AOP 기반 감사 로그를 적용하면, MyBatis나 Spring Data JPA 같은 데이터 접근 구현체를 각각 따로 분석하지 않고도 공통적으로 감사 로그를 남길 수 있지 않을까요??

    응답
    • 좋은 질문입니다. 실제로 서비스 계층 AOP 방식도 검토했었고, 최종 구현에도 AOP가 두 군데 들어갔습니다. 다만 ‘무엇이 어떻게 바뀌었는가’의 기록만은 AOP에 맡기지 못했는데, 이유는 두 가지였습니다.

      첫째, 이 시스템의 감사 요구가 필드 단위 before/after였습니다. 서비스 메서드 경계에서 보이는 건 파라미터(DTO)까지이고, 실제로 어떤 컬럼이 어떤 값에서 어떤 값으로 바뀌는지는 flush 시점에야 확정됩니다. AOP에서 diff를 만들려면 변경 전 상태를 다시 조회해서 비교해야 하는데, Hibernate Interceptor의 onFlushDirty는 이전/이후 상태를 그 시점에 그대로 넘겨줘서 diff가 사실상 공짜로 나옵니다.

      둘째, JPA에서는 ‘메서드 호출 = 데이터 변경’이 아니었습니다. 더티 체킹 때문에 save() 호출 없이 setter만 불러도 flush 때 UPDATE가 나가고, 반대로 CUD처럼 생긴 메서드가 조건에 따라 아무것도 안 바꾸기도 합니다. CUD 메서드를 포인트컷으로 잡으려면 결국 네이밍 규약에 기대야 해서, 규약 밖에서 일어나는 변경이 사각지대로 남습니다.

      그래서 역할을 나눴습니다. ‘누가, 어떤 요청으로’는 컨트롤러 AOP(trace_id 발급), ‘무엇이 바뀌었나’는 Hibernate Interceptor, 인터셉터가 못 보는 @Modifying 벌크 쿼리만 별도 AOP로 보완하는 구성입니다. 말씀하신 접근이 틀렸다기보다 — 실측에서 쓰기가 MyBatis에도 흩어져 있었다면 서비스 AOP가 유력한 답이었을 겁니다. 이 시스템은 쓰기가 JPA 단일 경로로 확인된 덕에, diff까지 공짜로 얻을 수 있는 더 낮은 층이 열려 있었던 경우고요.

      덧붙이면 AOP 포인트컷을 넓게 잡았다가 호되게 당한 사고담도 있는데, 이건 나중에 따로 글로 정리해보겠습니다. 좋은 질문 감사합니다!

      응답
  2. Если вас интересуют уколы для похудения, посмотрите этот ресурс: [url=https://tirsebioh.com/]таблетки для похудения[/url].
    Перед стартом терапии выполняют обследование с анализами крови и оценкой наличия сопутствующих заболеваний.

    응답

댓글 남기기