MyBatis에서 JPA로: 현대적 영속성 레이어 전환기

기존 프로젝트는 JSP + MyBatis 기반이었다.
쿼리는 XML Mapper에 SQL을 직접 작성했고, 어떤 쿼리가 나가는지 늘 명확했다. 조인이 어떻게 걸리고 조건이 어떻게 붙는지도 SQL 한 줄만 보면 바로 알 수 있었다.
그런데 신규 프로젝트를 JPA 기반으로 시작하면서, 생각보다 빨리 이런 질문에 부딪혔다.
JPA에서는 쿼리를 대체 어떻게 쓰는 게 맞는 걸까?
처음엔 아무 생각 없이 Query Method를 썼다
JPA를 처음 쓰기 시작했을 때는 Spring Data JPA가 기본으로 제공하는 메서드 이름 기반 쿼리를 주로 사용했다.
findByEmail
findByStatus
findByStatusAndRole이 정도까지는 정말 편했다. 쿼리를 따로 작성하지 않아도 되고, 메서드 이름만 봐도 무슨 조회인지 바로 알 수 있었다.
"이 정도면 MyBatis보다 훨씬 생산성 좋은데?" 라는 생각도 들었다.
문제는 프로젝트가 조금만 커져도 바로 나타났다.
조건이 늘어나는 순간, 메서드 이름이 감당이 안 됐다
검색 조건이 하나둘 늘어나기 시작했다. 상태, 권한, 기간, 키워드, 선택적 조건들… 그러다 보니 자연스럽게 이런 메서드가 등장했다.
findByStatusAndRoleAndCreatedAtBetween(...)이쯤 되면 무슨 조회인지 한 번에 읽히지 않는다. 게다가 조건이 선택적(optional)인 경우에는 메서드 이름만으로는 도저히 해결되지 않았다.
이때 처음 든 생각은 이거였다.
"아… 이건 뭔가 다른 방법이 필요하겠는데?"
JPQL을 쓰면 해결될 줄 알았다
다음으로 선택한 건 JPQL + @Query 였다. SQL과 문법이 비슷하고, MyBatis에서 SQL을 직접 작성하던 경험도 있어서 가장 익숙하게 느껴졌다.
@Query("""
SELECT u
FROM User u
WHERE u.status = :status
AND u.createdAt BETWEEN :start AND :end
""")엔티티 기준으로 쿼리를 작성한다는 점도 좋았다. 조인 조건을 직접 쓰지 않아도 연관관계만 잘 매핑해 두면 자연스럽게 JOIN이 걸리는 것도 마음에 들었다.
"아, 이게 JPA구나" 싶은 순간이었다. 하지만 이것도 오래가진 않았다.
동적 조건이 들어오자 JPQL도 지저분해졌다
문제는 동적 조건이었다. 어떤 조건은 있고 어떤 조건은 없고, 검색 화면은 계속 바뀌었다.
JPQL은 결국 문자열이라서, 조건이 늘어날수록 선택지가 두 가지밖에 없었다.
- 쿼리를 여러 개로 쪼개거나
- 문자열을 직접 조합하거나
어느 쪽이든 깔끔하지 않았다. 게다가 문자열 기반이라 컴파일 타임에 오타를 잡을 수 없다는 치명적인 단점도 컸다. 엔티티 필드명이 바뀌어도 JPQL 문자열은 그대로라, 런타임 에러가 터져야만 알 수 있었다.
그때 구조적으로 동적 쿼리를 다루는 방법이 필요하다는 생각이 들었다.
그래서 Specification을 도입했다
결국 Specification을 쓰기 시작했다. 처음 봤을 때는 솔직히 마음에 들지 않았다. Criteria API 특유의, 한눈에 안 들어오는 그 느낌.
하지만 막상 써 보니 의외로 괜찮았다.
- 조건을 작은 단위로 나눌 수 있고
- 필요한 것만 조합할 수 있고
- 동적 조건 처리도 자연스럽다.
Specification<User> spec = Specification
.where(hasStatus(status))
.and(emailContains(keyword));"아, 이게 동적 검색 화면용이구나" 라는 감각은 확실히 왔다. 다만 동시에 이런 생각도 들었다. 이게 과연 최선일까, 아니면 그냥 익숙해진 것뿐일까?
성능이 문제일까? → 아니었다
Specification을 쓰면서 "이거 느린 거 아니야?" 같은 고민도 했다. 하지만 조금 더 파 보니 거의 의미 없는 걱정이었다.
Query Method, JPQL, Specification, QueryDSL… 결국 모두 JPQL → SQL로 변환되어 실행된다. 같은 조건, 같은 인덱스라면 실행되는 SQL도 거의 같다.
즉, "Specification이 느려서 문제다", "QueryDSL이 빨라서 선택해야 한다" 는 말은 틀린 말에 가깝다.
진짜 차이는 성능이 아니라 '사람'이었다
지금 와서 보면 이 모든 고민의 핵심은 성능이 아니었다. 진짜 차이는 항상 여기서 갈렸다.
- 조건이 하나 추가됐을 때 얼마나 고치기 쉬운가
- 쿼리를 처음 보는 사람이 이해할 수 있는가
- 실수할 여지가 얼마나 되는가
- 팀원들에게 익숙한 방식인가
| 방식 | 특징 |
|---|---|
| Query Method | 초반에는 최고지만, 나중에 유지보수 비용이 폭증한다 |
| JPQL | 고정된 조회에는 좋지만, 동적 조건에는 금방 한계가 온다 |
| Specification | 구조적으로는 괜찮지만, 팀이 익숙하지 않으면 가독성이 떨어질 수 있다 |
| QueryDSL | 초기 세팅 비용은 있지만, 장기적으로 가장 안정적인 선택지에 가깝다 |
특히 리팩터링 안전성(Refactoring Safety) 에서 차이가 극명했다. 만약 User 엔티티의 status 필드명을 userStatus로 바꾼다면?
- Query Method — 애플리케이션 로딩 시점이나 런타임에 에러가 난다. IDE 리팩터링 기능도 완벽히 지원되지 않는다.
- JPQL — 문자열 안에 숨어 있어 찾기 힘들다. "Find in Files"로 전수 조사가 필요하다.
- QueryDSL — 컴파일 자체가 안 된다. 빨간 줄이 그어진 곳만 고치면 된다. 가장 안전하다.
Native SQL은 여전히 '마지막 카드'다
JPA를 쓰면서도 Native SQL을 완전히 안 쓰게 되지는 않았다. 다만 기준은 확실해졌다.
JPQL로 표현이 안 되는 경우, DB 특화 기능이 필요한 경우, 통계·리포트·배치성 쿼리가 필요할 때만 쓴다. 성능 때문이라며 습관처럼 Native SQL을 쓰기 시작하면 JPA를 쓰는 의미가 없어진다.
JPA로 넘어오면서 가장 크게 바뀐 건 '사고방식'이었다
MyBatis 시절에는 늘 이렇게 생각했다.
어떤 테이블을 조회하지? 어떤 컬럼을 가져오지? 조인은 어떻게 걸지?
JPA에서는 질문이 완전히 바뀌었다.
어떤 엔티티를 조회하지? 어떤 연관관계를 타고 갈까? 객체 그래프 탐색으로 해결할 수 있을까?
그리고 무엇보다, "쿼리를 어떻게 쓰지?" → "이 조회는 어떤 방식을 선택하는 게 맞지?" 라는 질문을 먼저 하게 됐다.
실전 가이드: 언제 무엇을 쓸까?
여러 시행착오 끝에, 우리 팀은 아래와 같은 기준을 세웠다.
| 상황 | 추천 방식 | 이유 |
|---|---|---|
| 단순 조회 (ID, Unique Key) | Query Method | findById, existsByEmail처럼 명확하고 짧을 때 가장 생산성이 좋다. |
| 복잡한 정적 쿼리 | JPQL (@Query) | 조인은 많지만 조건이 고정일 때. SQL과 비슷해 읽기 쉽다. |
| 동적 검색 / 필터링 | QueryDSL | 컴파일 타임 안전성, 쉬운 동적 쿼리 작성, 코드 자동완성. |
| 대량 데이터 처리 | Native SQL / JDBC | JPA의 1차 캐시나 더티 체킹이 오히려 성능을 떨어뜨릴 때. |
| 통계 / 집계 | Native SQL | DB 함수나 윈도우 함수처럼 표준 JPQL이 지원하지 않는 기능이 필요할 때. |
지금 시점에서의 결론
JPA에는 쿼리 작성 방식이 정말 많다. 하지만 그걸 다 잘할 필요는 없다. 중요한 건 항상 이것이다.
- 이 조회는 얼마나 자주 바뀔까?
- 조건은 고정인가, 유동적인가?
- 이 코드를 누가, 얼마나 오래 유지보수할까?
이걸 기준으로 Query Method, JPQL, Specification, QueryDSL을 섞어 쓰는 것. 그게 지금 내가 내린 결론이다.
JPA로 전환하면서 가장 크게 느낀 변화는 이거였다.
쿼리를 "어떻게 쓰느냐"보다 "언제 어떤 방식을 선택하느냐"가 훨씬 중요해졌다.
직접 만든 JPA 패턴 분석 도구
JPA 쿼리 방식을 고민하면서, 각 패턴이 실제로 어떤 특성을 가지는지 눈으로 비교해 보고 싶어졌다. 그래서 Java 코드의 AST(Abstract Syntax Tree)를 분석해 JPA 쿼리 패턴을 감지하고, 성능 지표를 시뮬레이션하는 도구를 직접 만들었다.
실제 DB를 실행하지 않아도, 코드를 붙여 넣기만 하면 복잡도와 패턴별 이론적 성능을 바로 분석해 준다.
새 탭에서 분석 도구를 열어 내 코드를 직접 분석해 보세요.
주요 기능
- 코드 분석 — Tree-sitter로 Java 코드를 파싱해 AST 구조를 분석
- 패턴 감지 — Query Method, JPQL, Native SQL, Specification, QueryDSL 등을 자동 식별
- 성능 시뮬레이션 — 코드 복잡도와 패턴 특성을 바탕으로 컴파일·실행 시간과 메모리 사용량 예측
- 비교 차트 — 현재 코드와 표준 참조 값을 시각적으로 비교해 최적화 포인트 제안
이 프로젝트는 학습 목적으로 만들었고, 실제 DB 성능이 아니라 코드 레벨의 복잡도와 패턴 특성을 분석하는 데 초점을 맞췄다. JPA를 처음 접했거나 "내 코드가 어떤 쿼리 스타일에 가까운지" 객관적인 지표로 확인해 보고 싶은 분들에게 도움이 되길 바란다.