본문 바로가기
개발 일지/나눔비타민 (인턴)

JPA 없이 통계 시스템 구축하기: QueryDSL + Flyway로 데이터 접근하기

by Hoya324 2025. 2. 28.

들어가면서..

인턴십 기간 동안 저는 기존 DB에 쌓여 있던 데이터를 활용해 통계 서비스(대시보드 형태)를 구축하는 과제를 맡았습니다.

 

이를 구현하면서 자연스럽게 JPA(Java Persistence API) 기반의 ORM(Object-Relational Mapping)을 사용했는데, 개발을 진행할수록 몇 가지 불편함이 드러났습니다.

 

예를 들어, 기존에 JPA로 짜여진 데이터를 사용하기 위해서 기존에 존재하던 Entity 클래스에 의존해야하는 문제가 있었습니다.

(저의 역량 부족일 확률이 높지만..)

 

또한 서비스 도메인 모델과 DB 엔티티 클래스들을 1:1로 매핑하여 설계했는데, 실제 통계 로직을 작성하다 보니 이러한 도메인-엔티티 1:1 매핑 방식에 의문이 생겼습니다.

 

이런 고민을 겪으면서 기존 JPA 중심으로 코드를 작성하면서 반복적으로 겪은 불편함이 있었고, 이는 “과연 모든 경우에 JPA를 고집하는 것이 맞을까?”라는 고민으로 이어졌습니다.

 

그래서 이 글에서는 이러한 JPA 의존성을 줄이려는 시도와 도메인-엔티티 분리에 대한 고민 과정을 공유하고, 제가 적용하고자 했던 대안을 기록하려 합니다.

 

결론부터 말하자면 Flyway에서 DB 스키마를 변경하면 그 내용을 통계 서비스가 트래킹하여 내부 로직에 반영해야하기에, 또다른 의존성을 가져오는 문제가 있었고, 적용하지는 못한 방법입니다.

이를 해결하기 위한 방법으로 JPA는 그대로 활용하되 통계 대시보드에 맞는 데이터만 Entity로 구성하여 적용했고, JPA를 활용하면서도 가장 간단하게 적용할 수 있던 방법이었습니다.

다만 이 글에서는 JPA의 의존성을 없애고 작업할 수도 있구나를 깨닫는 과정을 기록하고자 했습니다.
이전에 했던 DB와 Applicaiton 사이의 역할 고민에 대해 보시려면 아래의 link를 클릭해주세요!

DB와 Application 코드의 역할과 범위를 어떻게 나눌까?
 

DB와 Application 코드의 역할과 범위를 어떻게 나눌까?

들어가기 전..인턴십에서 통계 관련 작업을 진행하며 DB와 Application 코드 중 어디에서 로직을 처리할지 고민해야 하는 상황이 있었습니다. 특히, 주차에 맞는 날짜를 반환하는 로직을 작성할 때

hoya324.tistory.com


JPA 의존성 문제를 인식하다

처음에는 JPA 엔티티를 만들어 기존 DB 테이블들과 매핑을 맞추며 개발을 시작했습니다.

 

하지만 시간이 지나며 DB 스키마(테이블 구조)가 변경될 때마다 일일이 해당 JPA 엔티티 클래스를 수정하고 동기화해야 하는 부담이 점점 커졌습니다.(참고로 기존의 프로젝트에서 분리해서 작업했습니다.)

특히 기존 Entity를 위해 사수분의 시간을 뺏어야했기에 업무 효율성이 최악이라고 생각하게 되었습니다.

 

예컨대 새로운 컬럼이 추가되거나 타입이 바뀌면 엔티티 클래스에 동일한 변경을 해줘야 했고, 누락 시 애플리케이션 실행 시점에 오류가 발생할 수 있었습니다.

 

JPA가 자동으로 SQL을 생성해주는 이점이 있지만, 스키마 변경에 민감하게 따라다니는 엔티티 관리는 생각보다 번거로웠습니다.

 


 

게다가 통계 서비스 특성상 복잡한 SQL 쿼리를 다룰 일이 많았습니다.

 

여러 금액을 집계하기 위해 다수의 테이블을 조인(join)하거나 DB 고유의 함수를 사용해 집계하는 등의 작업은 JPA의 추상화 뒤에서 처리하기가 쉽지 않았습니다. 간단하게 예를 들면 기프트카드가 발급된 후 추가 지급된 급액까지 집계하려면 5개의 테이블을 함께 알아야하는 문제 등이 있었습니다.

 

복잡한 SQL 쿼리에 대해서는 결국 QueryDSL 같은 도구를 이용하게 되었습니다.

 

이렇게 JPA를 우회하는 빈도가 늘어날수록 “이렇게까지 할 거면 처음부터 JPA를 안 쓰는 게 낫지 않을까?” 하는 생각이 들었습니다.

 

정말 JPA를 우회하는게 맞는지 확인하기 위해 서칭하던 중에 실제로 한 외국 기술 블로그에서도 “JPA 같은 추상화 계층을 쓰면 대가를 치러야 한다. 특정 DB 함수나 재귀 쿼리가 필요한 경우 차라리 JPA를 포기하고 native SQL을 직접 쓰는 편이 가장 쉬운 방법” 이라는 조언을 접했고, 이때 JPA를 분리해보자고 생각하게 되었습니다.

(Using native queries with JPA? Here’s the catch – Arnold Galovics).

 

사실 JPA가 모든 것을 자동으로 해줄 것 같았지만, 복잡한 통계 쿼리 앞에서는 오히려 추상화 계층이 걸림돌이 되는 상황을 마주한 것입니다.

 

마지막에 더 자세히 적겠지만, 이러한 경험들은 자연스럽게 “내가 맡은 통계 서비스의 도메인 모델과 엔티티를 동일시하는 접근이 과연 옳은가?”라는 근본적인 고민으로 이어졌습니다.


엔티티에 의존하지 않는 방법 모색

JPA를 쓰면서 겪은 불편함 때문에, 저는 엔티티에 의존하지 않는 데이터 접근 방법을 본격적으로 찾아보기 시작했습니다.

 

흔히 ORM이 객체지향 개발에 많은 이점을 준다고 하지만, 항상 사수분이나 선배 개발자분들이 얘기해주시던대로 Case By Case..

모든 경우에 ORM이 최선은 아닐 수도 있다는 점을 깨닫게 되었습니다.

 

특히 저의 상황처럼 데이터 중심의 통계 서비스에서는 기존 메인 서비스 환경에 따라 DB 스키마에 자주 변경이 발생할 수 있고, 그때마다 엔티티 클래스를 수정해주는 비용이 무시하기 어렵습니다. 전혀 상태관리가 되지 않는다고 생각했습니다.

 

또한 만약 이 통계 서비스가 방대한 데이터 조회와 복잡한 집계를 주로 수행한다면, JPA의 이점(예: 지연로딩, 변경 감지 등)이 크지 않고 오히려 직접 SQL을 다루는 편이 더 효율적일 수도 있다고 생각했습니다.

 

ORM을 전제하지 않고도 안정적으로 데이터를 다룰 수 있는 방법을 생각하게 되었습니다.

 

(현재 Flyway를 외부 서버에서 따로 관리하고 있었기에 Entity 역시 분리해보는건 어떨지 제안해보았으나, 업무 스케줄 상 불가능한 상황이었고, 예측할 수 없는 사이드이펙트가 관건이었습니다.)

 


 

이때 눈에 들어온 것이 QueryDSL이라는 라이브러리였습니다.

 

QueryDSL은 자바 코드로 SQL과 유사한 형태의 질의를 작성하게 해주는 타입이 안전한 쿼리 DSL입니다.

원래는 JPA와 함께 사용해서 복잡한 조건의 쿼리를 깔끔하게 만들 때 많이 쓰지만, JPA 없이도 순수 SQL에 QueryDSL을 적용할 수 있다는 점이 매력적이었습니다.

 

즉, QueryDSL은 JPA, JDBC 등 여러 데이터 접근 방식에 공통으로 활용할 수 있는 저의 상황에 유연한 쿼리 도구인 것이죠.

 

덕분에 JPA의 엔티티가 없어도 QueryDSL이 생성하는 Q클래스만 있다면 컴파일 시점에 컬럼 이름이나 타입 검증이 이루어진 안전한 SQL 작성이 가능했습니다.

 

저는 서칭을 통해 Flyway에서 제공하는 스키마를 활용해서 JPA에 의존하지 않고 DB의 스키마에 유연하게 적용할 수 있는 통계 시스템을 구축할 수 있다는 희망이 생겼습니다.

 

이제 남은 과제는 DB 스키마 관리QueryDSL 설정이었고, 이를 어떻게 해결할지 구체화하기 시작했습니다.


JPA 없이 QueryDSL + Flyway 적용

고민 끝에 제가 선택한 방법은 JPA를 아예 배제하고 QueryDSL + Flyway 조합으로 가는 것이었습니다.

 

기존에 Flyway를 외부 서버에서 따로 관리하고 있었기에 QClass를 만드는 시점에 Flyway의 스키마를 확인해서 일치시키도록 계획했습니다.

 

다행히 QueryDSL에는`MetaDataExporter`라는 유틸리티가 있어서, JDBC 커넥션 메타데이터를 읽어 자동으로 Q클래스 소스 파일들을 생성해줄 수 있었습니다.

(querydsl/querydsl-sql-codegen/src/main/java/com/querydsl/sql/codegen/MetaDataExporter.java at master · querydsl/querydsl · GitHub).

관련 의존성

 

 

간단히 말해 DB 테이블당 하나씩 대응하는 Q클래스가 코드로 만들어지는 것입니다.

 

예를 들어 DB에 user라는 테이블이 있으면 QUser라는 클래스가 생성되고, 이 클래스에는 id, name 같은 컬럼에 대응하는 필드가 정의됩니다.

 

이 작업은 프로젝트 빌드 시 플러그인으로 수행할 수도 있고, 별도 실행 프로그램을 만들어 수동으로 돌릴 수도 있는데, 저는 인턴 프로젝트 규모가 크지 않아서 간단한 Java 실행 클래스(main 메서드)를 작성해 `MetaDataExporter`를 실행했습니다.

 

아래는 `MetaDataExporter`를 사용하는 예시 코드입니다:

// MetaDataExporter를 사용해 Q클래스 생성
Connection conn = dataSource.getConnection();
MetaDataExporter exporter = new MetaDataExporter();
exporter.setPackageName("com.example.querydsl");  // 생성될 Q클래스 패키지명
exporter.setTargetFolder(new File("src/main/java"));  // 생성 파일 경로
exporter.export(conn.getMetaData());  // DB 메타데이터로부터 Q타입 생성

적용했던 코드 핵심(Kotlin)

 

이렇게 해서 DB 테이블 구조에 대응하는 QClass들이 지정한 폴더에 한꺼번에 생성되었습니다.

 

이제 이를 활용하기 위해 Spring 환경에서는 `SQLQueryFactory`를 빈(bean)으로 등록하고 DataSource를 주입 받아 사용하도록 설정했습니다. (QueryDSL 전용 Configuration에 DB dialect에 맞는 SQLTemplates 설정 포함)

 

그런 다음부터는 마치 JPA의 EntityManager를 쓰듯이 SQLQueryFactory를 가지고 쿼리를 생성하고 실행하면 됐습니다. 설정하는 부분에서 애를 먹었지만, 그 후에는 기존의 jpa 기반 queryDsl과 크게 다르지 않았습니다.

 

예를 들어 사용자별 주문 건수를 집계하는 통계 쿼리를 QueryDSL로 작성하면 아래처럼 표현할 수 있습니다:

SQLQueryFactory queryFactory = ...; // 주입 받은 SQLQueryFactory

QUser user = QUser.user;       // QueryDSL이 생성한 QUser 클래스
QOrder order = QOrder.order;   // QueryDSL이 생성한 QOrder 클래스

// 사용자 이름별 주문 수 집계 쿼리
List<Tuple> results = queryFactory.select(user.name, order.id.count())
    .from(user)
    .join(order).on(order.userId.eq(user.id))
    .groupBy(user.name)
    .fetch();

 

위 예시 코드에서는 user 테이블과 order 테이블을 조인하여 사용자 이름별로 주문 건수를 집계하고 있습니다. QueryDSL의 Q클래스를 활용하니 `.join().on(), .groupBy()`  같은 메서드 체인을 통해 SQL과 유사한 구문을 자바 코드로 직관적으로 작성할 수 있었습니다.

 

결과는 QueryDsl에서 익숙하게 사용한 것처럼 Tuple 리스트로 받아서 사용하였고, 필요한 경우 DTO로 매핑하거나 별도 도메인 객체에 담는 방식으로 처리했습니다.

 

이렇게 QueryDSL을 사용하면서 복잡한 쿼리도 가독성 있게 표현할 수 있었고, JPA 없이도 원하는 통계 로직 구현에 크게 문제가 없었습니다.

 

작업한 내용을 요약하면, 기존 메인 서비스의 Entity에 의존성에서 벗어나서 Flyway로 관리 중인 스키마를 통해 작업의 독립성을 보장했고, 이를 QueryDsl의 MetaDataExporter를 통해 적용했습니다.


결론 및 배운 점

이 작업은 모두 완료하고 적용까지 가능했으나, 회사 일정상 Flyway의 변경을 트래킹해 통계 대시보드에 적용까지 하지는 못했습니다. 결론적으로 해결한 방법은 글의 초반에 작성해두었습니다.

 

그러나 이번 경험을 통해 몇 가지 중요한 교훈을 얻었기에 눈물을 한방울 흘리고 보람을 느꼈다고 생각했습니다.

 

JPA는 분명 강력한 도구이지만, 모든 상황에서 정답은 아닐 수 있다는 것을 느꼈고, 때로는 단순한 JDBC 접근이나 QueryDSL 같은 도구를 활용한 직접 쿼리가 더 적합한 경우도 있다는 것을 느꼈습니다.

 

특히, 복잡한 읽기 위주의 서비스 다양한 데이터 소스를 활용하는 경우에는 ORM의 추상화가 오히려 개발 속도를 늦추거나 제약을 늘릴 수 있습니다. 그럴 때는 과감하게 ORM 없이도 구현을 고려해볼 수 있다는 자신감(?)을 얻게 되었습니다.

 

마지막으로, 도메인과 엔티티의 분리 여부도 상황에 따라 유연하게 결정해야 함을 깨달았습니다. 처음에는 엔티티와 도메인을 1:1로 유지하지 않으면 뭔가 잘못된 설계가 아닐까 걱정했지만, 실제로 분리해보니 오히려 각 층의 책임이 명확해지고 의도가 분명해졌습니다.

 

반면 단순한 CRUD 위주의 서비스라면 JPA 엔티티를 곧바로 도메인으로 써도 개발 생산성이 높아질 수 있겠죠. 이렇듯 정해진 틀에 얽매이기보다는 상황에 따른 유연한 판단이 중요하다는 것을 배웠습니다.

 

제가 이번 작업을 하면서 배운 점을 정리하면 다음과 같습니다:

  • ORM 도구는 만능이 아니다: JPA 같은 ORM을 무조건 사용하는 것이 언제나 옳은 것은 아닐 수 있으며, 경우에 따라서는 직접 SQL을 다루는 편이 더 효율적일 수 있다. 주요 요구사항이 복잡한 조회나 통계 처리라면, 추상화를 줄이고 데이터베이스에 가까운 접근을 고려해보는 것도 방법이다..
  • 형상 관리와 타입 안전성 확보: JPA를 쓰지 않더라도 Flyway와 QueryDSL 조합으로 스키마 변경 관리와 타입 안전성을 확보할 수 있다. 
  • 도메인-엔티티 매핑은 유연하게: 도메인 모델과 DB 엔티티를 꼭 1대1로 대응시켜야 한다는 강박에서 벗어날 필요가 있었다. 복잡한 도메인이라면 별도의 퍼시스턴스 모델을 두어도 되고, 단순한 경우 하나로 합쳐도 됩니다. 궁극적으로는 애플리케이션의 복잡도와 팀의 목표에 맞게 선택하는 것이 바람직하다고 느꼈습니다.

인턴십 동안 시행착오를 거치며 이런 대안을 적용해본 것은 큰 공부가 되었습니다.

 

새로운 기술 스택을 도입하는 건 늘 고민되지만, 이번 경험으로 문제에 맞는 도구를 선택하는 용기를 얻은 것 같습니다.

 

앞으로도 필요에 따라 다양한 접근방식을 시도하면서, 더 효율적이고 유연한 설계를 추구해 나가야겠습니다. 

 

 

Reference

-  Querydsl for AI Infrastructure | Restackio

- (Having the domain model separated from the persistence model · Enterprise Craftsmanship)

- Spring Data JPA Tutorial: Creating Database Queries With Querydsl - Petri Kainulainen),

- Flyway를 통한 데이터베이스 버전 관리 소개 (Flyway Migrations: Simplifying Database Version Control - DEV Community)

- https://www.linkedin.com/posts/tobyilee_개발자들에게-ddd가-어려운-이유가-뭘까-요즘-많이-하는-생각은-우리-activity-7295220028218511360-NQbk

- https://happycloud-lee.tistory.com/94

- 정리본: https://hoya324.notion.site/ddd

- http://querydsl.com/static/querydsl/5.0.0/apidocs/com/querydsl/sql/spring/SpringConnectionProvider.html#SpringConnectionProvider-javax.sql.DataSource-

- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/DataSourceUtils.html