“이 프로젝트, 도대체 정체가 뭡니까?”
지금 다니는 회사의 프로젝트는 일명 ‘짬짜면’이다. MyBatis와 JPA가 한 프로젝트 안에 공존하고 있다. 문제는 이 코드를 처음 짠 ‘창조주(초기 멤버)’들은 이미 모두 퇴사하고 없다는 점이었다.
남겨진 우리(현 팀원들)는 혼란스러웠다. 도대체 무슨 기준으로 기술을 섞어 쓴 건지 문서 하나 남아있지 않았다. 그러다 보니 상황은 점입가경이 되었다.
- A 개발자: “난 SQL이 편해.” -> 모든 로직을 MyBatis로 짬.
- B 개발자: “난 객체지향이 좋아.” -> 모든 로직을 JPA로 짬.
결국 하나의 서비스 안에서 비슷한 기능을 구현하는데 누구는 XML을 파고 있고, 누구는 엔티티를 설계하는 기이한 ‘기술적 무정부 상태’가 되어버렸다. 프로젝트는 점점 유지보수하기 힘든 괴물이 되어갔고, 결국 회의가 소집됐다.
“우리, 기술 스택 하나로 통일합시다.”

“JPA는 느려서 못 쓰겠던데요?”
회의 분위기는 팽팽했다. 특히 MyBatis에 익숙한 동료들은 JPA로의 통일을 강하게 반대했다. 그들의 핵심 논리는 ‘속도’였다.
“저번에 통계 페이지 JPA로 짰다가 로딩만 20초 걸린 거 기억 안 나요? SQL로 직접 짜면 1초면 나오는데, 우리가 제어할 수 없는 블랙박스(JPA)를 쓰는 건 위험해요.”
확실히 몇몇 로직은 데이터 로딩에만 수십 초가 걸리고 있었다. 하지만 나는 그게 기술의 문제가 아니라 ‘숙련도’의 문제라고 확신했다. 나는 그 자리에서 노트북을 열고, 문제의 그 ’20초 걸리는 코드’를 띄웠다.
코드는 처참했다. List<Entity> 를 조회하는데, 연관된 객체들이 루프를 돌 때마다 하나씩 DB를 찌르고 있었다. 전형적인 N+1 문제였다.
“이건 JPA가 느린 게 아니라, 우리가 JPA에게 비효율적으로 일을 시킨 겁니다. 보세요.”
나는 즉석에서 Fetch Join을 적용해 쿼리를 한 방으로 줄였다. 배포 후, 20초 걸리던 로딩은 1초로 줄어들었다. 동료들의 눈빛이 달라지는 순간이었다.

뜻밖의 발견: 혼종이 아니라 하이브리드
JPA의 오해를 풀었으니 이제 JPA로 천하통일하면 될까? 막상 뜯어보니 그것도 정답은 아니었다.
복잡한 통계 쿼리나 수십만 건의 엑셀 다운로드를 JPA로 처리하려니, 객체 변환 비용이 너무 컸고 동적 쿼리를 짜기도 까다로웠다. 반면 MyBatis는 이런 작업에서 압도적으로 직관적이고 빨랐다. “도대체 다른 회사들은 이 문제를 어떻게 해결할까?” 답답한 마음에 밤새 구글링을 하며 기술 블로그들을 뒤지기 시작했다.
놀랍게도 많은 기술 기업들이 JPA 하나만 고집하지 않고 있었다. 복잡한 조회 성능을 위해 QueryDSL을 쓰거나, 심지어 우리처럼 MyBatis를 함께 쓰는 경우도 많았다.
나는 무릎을 탁 쳤다. “아, 떠나신 분들이 아무 생각 없이 섞어 쓴 게 아니었구나!”
- JPA의 영역: 데이터의 저장, 수정, 삭제 같은 비즈니스 로직. 객체 지향의 장점을 살려 유지보수성을 높인다.
- MyBatis의 영역: 복잡한 화면 출력이나 통계 같은 조회 전용 로직. SQL의 강력함을 이용해 성능을 극대화한다.
그들은 이미 각 도구의 장단점을 알고 의도적으로 기술을 분리했던 것이다. 다만 문서화를 안 해놓고 떠나는 바람에, 남겨진 우리가 그 뜻을 모르고 “왜 통일 안 했지?”라며 싸우고 있었던 것이다.
JPA의 한계를 넘어서: QueryDSL과 Projection
그렇다면 MyBatis를 걷어내고 JPA 진영의 기술만으로 이 문제를 해결할 순 없을까? 여기서 등장하는 것이 바로 QueryDSL과 Projection이다.
- 동적 쿼리의 제왕, QueryDSL MyBatis의 XML 태그 대신, 자바 코드로 쿼리를 짠다. 오타가 나면 컴파일 에러가 잡아주니 안전하고,
where(nameEq(name))처럼 메서드를 조립해 동적 쿼리를 짜는 맛이 일품이다. - 통계 쿼리의 구세주, Interface Projection 통계는 보통
SUM,AVG,COUNT같은 집계 데이터다. 이건Entity가 아니다. JPA로 무거운 엔티티를 다 가져오는 건 낭비다. 이때 Interface Projection을 쓰면, DB에서 딱 필요한 컬럼(스칼라 값)만 쏙 뽑아와서 인터페이스에 꽂아준다. DTO를 만들 필요도 없다. 가볍고, 빠르고, 간편하다.
// 1. 필요한 데이터만 정의한 인터페이스 (DTO 필요 없음!)
public interface DailyStat {
String getDate();
Long getTotalSales();
}
// 2. QueryDSL이나 JPA Repository로 조회
// DB에서 딱 저 두 컬럼만 SELECT 해오므로 MyBatis만큼 빠르다.
List<DailyStat> stats = repository.findDailySales();
이 기술들을 알게 되자, 굳이 XML 지옥(MyBatis)으로 돌아갈 이유가 사라졌다. JPA 생태계 안에서도 충분히 고성능 쿼리를 짤 수 있었기 때문이다.
실무 조언: 싸우지 말고 공존하라
결국 우리 팀은 ‘무조건적인 통일’ 대신 ‘원칙 있는 공존’을 택했다. (그리고 장기적으로는 QueryDSL로의 전환을 목표로 했다.)
- 기본 원칙: 모든 비즈니스 로직(회원가입, 주문, 결제)은 JPA로 개발하여 객체지향의 장점을 살린다.
- 예외 원칙: 통계, 대시보드, 대용량 엑셀 등 복잡한 조회 전용 로직은 MyBatis를 허용한다. (단, 데이터 변경은 금지)
재미있는 건, 우리가 생존을 위해 선택한 이 ‘쓰기(Command)는 JPA, 읽기(Query)는 전용 도구’ 전략이 아키텍처 관점에서는 CQRS(Command and Query Responsibility Segregation) 패턴의 아주 기초적인 형태라는 점이다.
거창하게 DB를 쪼개지 않더라도, 코드 레벨에서 ‘명령과 조회의 책임을 나눈다’는 철학만으로도 프로젝트는 훨씬 깔끔해진다.
마치며: 기술은 죄가 없다
우리는 종종 “JPA가 최고다”, “아니다, MyBatis가 실무에선 짱이다”라며 논쟁을 벌인다. 하지만 이번 경험으로 배운 건, 나쁜 기술은 없다는 것이다. 단지 ‘용도에 맞지 않게 쓰는 상황’이 있을 뿐이다.
JPA는 마법 지팡이지만 주문을 잘못 외우면 폭발하고, MyBatis는 튼튼한 망치지만 모든 것을 못으로 보게 만든다.
혹시 지금 팀에서 레거시 코드 때문에, 혹은 서로 다른 기술 취향 때문에 갈등을 겪고 있다면 잠시 멈춰보자. 어쩌면 그 혼란 속에 선배 개발자들의 깊은 고민이 숨어있을지도 모른다. 그것을 찾아내어 ‘규칙’으로 만드는 것, 그것이 진짜 실력 있는 개발팀이 되는 길이다.