The Bill for Convenience
Think back over the code you wrote today. How many times did you call repository.save()? How many method names did you come up with that start with findBy? And how many lines of SQL did you write yourself?
I once got stuck on that last question for a long time. Name a method and the query writes itself. For a complex lookup, copy an existing XML mapper and change one condition. When you get stuck, ask an AI. That’s how most backend data access runs these days, and that’s how I worked for years. It never felt inconvenient. If anything, it was comfortable. The problem is that convenience has never been free. The bill always arrives much later, under a name you didn’t expect.
This post is the record of the day that bill arrived for me, and the first page of my rehab: the day I decided to relearn SQL from scratch. Before we get to the incident, let me sketch one picture. With this one picture, you can follow the whole story.
A Warehouse with Two Doors
A database is a warehouse. All of a service’s data is stored here. And only two kinds of things happen in a warehouse: you take items out just to look at them, or you put items in, change them, or throw them away. In SQL, the first is SELECT (reads) and the second is INSERT/UPDATE/DELETE (writes). So the warehouse effectively has two doors: a ‘read door’ and a ‘write door’.
We usually don’t walk into this warehouse ourselves. We send staff.
- JPA is the interpreter. It’s an ORM (Object-Relational Mapping): hand it a Java object and say “save this,” and it translates the request into SQL and makes the trip to the warehouse for you. I never have to look at the SQL.
- MyBatis is the errand runner. It’s a SQL mapper: it takes the SQL note I wrote and carries it to the warehouse exactly as written, without changing a word. I write the SQL.

This brings us to today’s key question: ‘Which staff member uses which door?’ A warehouse where both staff members go through both doors and a warehouse where each door belongs to one of them are completely different buildings. And whether you check that difference can decide the fate of an entire plan. In my case, it did. Now, the incident.
A Plausible Diagnosis: “Cut the Staff Down to One First”
The system I help run is a regulated-industry system that tracks agricultural production history (Spring Boot + JPA/Hibernate + MyBatis + PostgreSQL). It’s a warehouse with two staff members. Because it’s a regulated industry, one day a requirement came down for an audit log, a record of ‘who changed what data, and when.’ In warehouse terms, it was a request to put a CCTV camera on the write door.
The first proposal was drafted by an AI assistant. Its diagnosis, in short:
Because Hibernate and MyBatis are mixed as ORMs, there is no single point where data access can be intercepted. Therefore, a refactoring that unifies the data access layer on JPA must come first.
Do you see what’s wrong? If you do, this post is a review. If you don’t, you’re in the right place. I failed to question this diagnosis for six months, and I work in that codebase every day.
Translated into our analogy: “There are two staff members, so you can’t see from one place whose hands an item passed through when it changed. Before installing a CCTV camera, cut the staff down to one: JPA.”
Plausibility is powerful. A two-phase plan was actually built on this premise, ‘refactor first, build the feature later,’ and it went as far as a PoC (Proof of Concept: a small build to check up front whether something actually works). In other words, we were bracing for a large-scale code change.
But this diagnosis rests on one unverified premise: ‘Are both staff members really using the write door?’
Six Months Later, I Stood at the Door and Counted
Six months later, it was time to revisit the plan. This time I questioned not the proposal’s conclusion but its premise. One fact to keep in mind: an audit log only needs to track the side that ‘changes’ things. You can run a SELECT a thousand times and the data won’t change. So for the diagnosis ‘there is no single point of interception’ to hold, both staff members would have to be going through the write door.
That’s not something to debate. It’s something to count. Hypothesis: ‘MyBatis writes too.’ Test: like standing at the warehouse door and tallying the entry log, search the XML mapper files that hold MyBatis’s SQL with ripgrep (rg, a tool that searches an entire codebase for a pattern).
# Check how statements are distributed across the MyBatis XML mappers
rg -n '<select\s' **/mapper/*.xml | wc -l # → 245
rg -in '<(insert|update|delete)' **/mapper/*.xml # → 0
rg -n '@Insert\(|@Update\(|@Delete\(' # → 0 (no annotation-based statements either)
rg -n 'sqlSession\.(insert|update|delete)' # → 0 (no direct calls either)
All 245 entries in the MyBatis staffer’s log were SELECTs, trips through the read door. Trips through the write door: zero, whether in XML, in annotations, or in direct calls. The suspect had a perfect alibi. Only people who came to look had ever used this door.
So who was using the write door? I counted the JPA side the same way. 309 calls to repository.save/delete. 32 calls to entityManager.persist/merge/remove, low-level calls that work directly with the persistence context (the workbench where JPA watches objects for changes). On top of that, 49 @Modifying bulk queries (which UPDATE or DELETE many rows with a single SQL statement) were scattered across 31 repositories. Writes went through JPA 100% of the time.
The warehouse where two staff members were supposedly tangled up had, in fact, split the doors between them from the start. And that one fact turns the whole plan upside down.
The Twist: One Camera on the Write Door Was Enough
Once the premise collapsed, the conclusion collapsed with it.
The purpose of an audit log is to track ‘changes.’ If every change goes through a single staff member, JPA/Hibernate, you only need a camera on the door that staff member uses. In real technical terms, that’s one Hibernate Interceptor, a hook Hibernate exposes so you can step in right before it executes an INSERT/UPDATE/DELETE. The only blind spot left is the 49 @Modifying bulk queries that bypass the persistence context, and a single Spring AOP aspect (a Spring feature that wraps shared behavior around specific method calls) covers those.

Changes to existing business code: zero lines. The ‘large-scale refactoring first’ plan was scrapped on the spot. The check cost a few lines of grep, and those few lines saved the entire migration it would have taken to move 245 MyBatis queries to JPA.
If you shrink the lesson to “don’t trust what AI tells you,” you only get half of it. A plausible diagnosis, whether an AI or a senior engineer wrote it, is something to verify, not a premise to build on. And verifying isn’t a big deal: it’s standing at the door and counting. But this measurement left two more things besides a scrapped plan. One is a discovery about this warehouse. The other is a confession about me.
Not Chaos, but a Division of Labor
First, the discovery. The conventional wisdom, one I vaguely believed myself, that “a project mixing JPA and MyBatis is a bad project” turned out to be wrong, at least in this warehouse. What the numbers showed wasn’t chaos but a division of labor that had settled in naturally, whether anyone intended it or not.
| Type of work | Staff in charge | Why |
|---|---|---|
| Create/update/delete: all write-door traffic | JPA | Dirty checking (detects object changes and generates the UPDATE for you) and relationship management, and now the collection point for the audit log |
| Simple single-row and list lookups | JPA: query methods (the method name becomes the query) and JPQL (JPA’s entity-based query language) | Type safety, fast development |
| Multi-table joins + aggregation + dynamic conditions | MyBatis | See and control the entire SQL |
| Statistics/reports/pivots | MyBatis | Territory JPQL can’t express |
Mixing the two isn’t the sin. A blurry boundary over ‘who handles which door’ is. I wrote about this project’s technology choices earlier in MyBatis vs JPA: There Is No Such Thing as a Bad Technology, and this measurement ended up putting numbers behind that post’s conclusion.
That’s the official record of the incident. But this investigation has a postscript that isn’t in the official record. It’s actually the real reason I started this series.
The Real Culprit Was Someone Else
At first, I wanted to blame the AI. Writing plausible sentences in a confident tone is, after all, what AI does best. But one moment during the investigation blocked that excuse.
When I opened those 245 MyBatis XML queries to count the entries, I couldn’t properly read them. I run this system every day, and I was fumbling through it like it was someone else’s code.
Looking back, it was the natural result. For years, I built queries out of method names, copied complex lookups, and asked an AI when I got stuck. I was putting things into the warehouse and taking them out every day, while leaving everything about what the inside looked like to the staff. That’s also the real reason the AI’s diagnosis looked plausible for six months. If you can’t read and question your own data access layer, you have no way to tell plausibility from fact.
So I decided to relearn SQL from the ground up. This series is the record of that rehab. As this incident showed, being able to read SQL isn’t only useful when you write queries. It gives you the nerve to ask “Did you count that premise?” when you face a plausible proposal, and the power to stop a large-scale refactoring with a few lines of grep. I’m publishing my study notes in a repository (written in Korean). Each topic is tagged with its exam weight, so people preparing for SQL certifications such as SQLD (a Korean SQL certification) can use them too.

To kick off the rehab, I plan to reopen those 245 queries one at a time. Every query I skimmed past because I couldn’t read it is now a free textbook. I wonder how many patterns I still can’t read are hiding in there.
What about your warehouse? Have you ever counted which staff member goes through which door?


