-
Notifications
You must be signed in to change notification settings - Fork 1
JPA QueryDSL
기술을 선택한 결론이 아니라, 각 선택지에서 무엇을 포기하고 무엇을 얻었는지의 판단 과정입니다.
SQL을 직접 작성하는 방식의 근본적인 문제는 SQL과 Java 객체 사이의 간극을 개발자가 매번 직접 메워야 한다는 점입니다.
// Raw SQL + JDBC 방식이라면
String sql = "SELECT r.id, r.reservation_date, r.reservation_price, " +
"b.business_name, m.service_name " +
"FROM reservation r " +
"JOIN business b ON r.business_id = b.id " +
"JOIN menu m ON r.menu_id = m.id " +
"WHERE r.customer_id = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setObject(1, customerId);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
// 각 컬럼을 하나씩 꺼내 Java 타입으로 변환
UUID id = UUID.fromString(rs.getString("id"));
LocalDate date = rs.getDate("reservation_date").toLocalDate();
// ...
}이 반복 작업은 단순 노동이 아닙니다. reservation_price 컬럼 이름이 바뀌면 Java 코드도 함께 찾아서 바꿔야 합니다. 컴파일 타임에 잡을 수 없습니다. Timefit은 Reservation 단독이 아니라 Business, Menu, BookingSlot, Review 등 여러 엔티티가 관계를 맺고 함께 조회되는 구조입니다. 이 관계를 매번 SQL JOIN으로 직접 풀고 ResultSet에서 매핑하는 코드는 비즈니스 로직보다 배관(plumbing) 작업이 많아집니다.
ORM을 선택한 이유는 편의성이 아닙니다. 도메인 모델을 DB 구조와 분리해서 설계하고, 그 사이의 매핑을 프레임워크에 위임함으로써 비즈니스 로직에 집중할 수 있기 때문입니다. Reservation.getMenu().getPrice() 같은 객체 탐색이 가능해지면, 예약 생성 시 스냅샷 패턴 같은 도메인 로직이 코드에서 자연스럽게 표현됩니다.
JDBC와 MyBatis는 SQL을 중심에 놓는 기술입니다. JPA는 객체를 중심에 놓습니다.
MyBatis라면 모든 쿼리를 XML이나 어노테이션으로 직접 작성합니다. SQL 제어권은 완전하지만, 객체 관계(연관관계 탐색, 지연 로딩, 변경 감지)를 직접 구현해야 합니다. Timefit처럼 도메인 로직이 엔티티 간 관계에 의존하는 설계에서는 이 비용이 큽니다.
JPA를 선택한 핵심 근거는 세 가지입니다.
영속성 컨텍스트(Persistence Context)가 변경 감지를 담당합니다. 예약 상태 변경은 SQL UPDATE를 직접 실행하지 않습니다.
// JPA — 도메인 메서드 호출만으로 상태 변경
public void confirm(Reservation reservation) {
reservation.confirm(); // 엔티티 내부에서 status 변경
// @Transactional이 끝날 때 영속성 컨텍스트가 변경 감지
// → UPDATE SQL 자동 실행
}
// MyBatis라면
public void confirm(UUID reservationId) {
reservationMapper.updateStatus(reservationId, "CONFIRMED"); // SQL 직접 실행
}JPA 방식에서는 비즈니스 로직이 "상태를 CONFIRMED로 바꾼다"는 의도를 표현하고, 어떻게 DB에 반영할지는 JPA가 처리합니다.
연관관계 탐색이 자연스럽습니다. 예약 생성 시 스냅샷 패턴에서 menu.getPrice(), menu.getDurationMinutes()를 자연스럽게 호출합니다. MyBatis라면 Menu를 별도로 조회하거나 JOIN 쿼리를 작성해야 합니다.
엔티티가 단일 진실의 원천(SSOT)이 됩니다. DB 스키마와 Java 클래스가 @Entity, @Column으로 선언적으로 연결되면, 컬럼 이름 변경이 컴파일 타임에 반영됩니다.
트레이드오프도 있습니다. JPA는 개발자가 내부 동작(영속성 컨텍스트, 지연 로딩, 1차 캐시)을 이해하지 못하면 N+1 같은 문제가 보이지 않는 곳에서 발생합니다. MyBatis는 SQL이 눈에 보여서 "어떤 쿼리가 실행되는지"가 명확합니다. 이 트레이드오프를 수용한 이유는, 도메인 복잡도가 높아질수록 JPA가 얻는 이점이 커지고 N+1 같은 문제는 도구(WAS 로그, QueryDSL fetch join)로 해결 가능하기 때문입니다.
Spring Data JPA의 메서드 이름 쿼리는 단순한 경우에 강합니다.
// 이 정도까지는 메서드 이름으로 충분
List<BookingSlot> findByBusinessIdAndSlotDateOrderByStartTimeAsc(
UUID businessId, LocalDate slotDate
);
boolean existsByMenuIdAndStatusIn(UUID menuId, List<ReservationStatus> statuses);그러나 Timefit의 실제 요구사항에는 이것으로 처리할 수 없는 경우가 있었습니다.
// 요구사항: 업체 예약 목록 조회
// - status 필터 (선택)
// - 고객명 검색 (선택)
// - 날짜 범위 (선택)
// - 페이징
// 조건이 모두 optional → 모든 조합을 메서드로 만들면 2^4 = 16가지Spring Data JPA @Query 어노테이션으로 해결하면 이렇게 됩니다.
@Query("""
SELECT r FROM Reservation r
JOIN FETCH r.business b
JOIN FETCH r.menu m
WHERE r.business.id = :businessId
AND (:status IS NULL OR r.status = :status)
AND (:customerName IS NULL OR r.customerName LIKE %:customerName%)
AND (:startDate IS NULL OR r.reservationDate >= :startDate)
AND (:endDate IS NULL OR r.reservationDate <= :endDate)
""")
Page<Reservation> findWithFilters(
UUID businessId, ReservationStatus status,
String customerName, LocalDate startDate, LocalDate endDate,
Pageable pageable
);두 가지 문제가 있습니다. 첫째, (:status IS NULL OR r.status = :status) 패턴은 DB Planner가 항상 조건을 평가하기 때문에 인덱스 활용이 불완전할 수 있습니다. 둘째, 문자열이기 때문에 컴파일 타임에 오류를 잡을 수 없습니다. r.customerNaem처럼 오타를 내도 런타임에야 알 수 있습니다.
QueryDSL은 엔티티에서 Q타입을 자동 생성하고, 이를 기반으로 Java 코드로 쿼리를 작성합니다.
// QReservation은 빌드 시 자동 생성 — reservation.customerName은 실제 필드와 연결됨
private final QReservation reservation = QReservation.reservation;
public Page<Reservation> findBusinessReservationsWithFilters(
UUID businessId, ReservationStatus status, String customerName,
LocalDate startDate, LocalDate endDate, Pageable pageable) {
List<Reservation> reservations = queryFactory
.selectFrom(reservation)
.join(reservation.business, business).fetchJoin()
.join(reservation.menu, menu).fetchJoin()
.join(menu.businessCategory, businessCategory).fetchJoin()
.where(
businessIdEq(businessId),
statusEq(status), // null이면 조건 자체가 사라짐
customerNameContains(customerName),
reservationDateGoe(startDate),
reservationDateLoe(endDate)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(reservation.reservationDate.desc(),
reservation.reservationTime.desc())
.fetch();
// ...
}
// 조건 메서드: null 반환 시 where 절에서 자동 제외
private BooleanExpression statusEq(ReservationStatus status) {
return status != null ? reservation.status.eq(status) : null;
}
private BooleanExpression customerNameContains(String customerName) {
return customerName != null ?
reservation.customerName.containsIgnoreCase(customerName) : null;
}BooleanExpression이 null을 반환하면 QueryDSL이 해당 조건을 WHERE 절에서 제외합니다. 이것이 @Query의 IS NULL OR 패턴과 다른 점입니다. 각 조건이 독립적인 Java 메서드이므로 재사용도 됩니다. 같은 businessIdEq() 메서드를 데이터 조회 쿼리와 COUNT 쿼리에 동시에 쓸 수 있습니다.
컴파일 타임 안전성이 가장 큰 이점입니다. reservation.customerName은 Q타입의 실제 필드입니다. 엔티티에서 customerName 필드명을 바꾸면 Q타입이 재생성되고, QueryDSL 코드가 컴파일 에러를 냅니다. 문자열 JPQL에서는 런타임 에러로만 알 수 있는 것을 빌드 시점에 잡습니다.
QueryDSL에는 DTO를 직접 반환하는 Projection 기능이 있습니다.
// Projections 방식 — DTO를 DB에서 직접 생성
List<ReservationSummaryDto> result = queryFactory
.select(Projections.constructor(ReservationSummaryDto.class,
reservation.id,
reservation.reservationDate,
reservation.reservationPrice,
business.businessName,
menu.serviceName
))
.from(reservation)
.join(reservation.business, business)
.join(reservation.menu, menu)
.where(reservation.customer.id.eq(customerId))
.fetch();이 방식의 이점은 필요한 컬럼만 SELECT해서 DB에서 클라이언트로 오는 데이터를 줄일 수 있다는 것입니다.
Timefit에서 이 방식을 선택하지 않은 이유는 두 가지입니다.
첫 번째: 엔티티를 기반으로 한 비즈니스 로직이 서비스 레이어에 있습니다. Reservation 엔티티에는 confirm(), cancel(), isModifiable() 같은 상태 전이 메서드가 있습니다. 이 메서드들은 엔티티가 영속성 컨텍스트에 있을 때 의미가 있습니다. Projection으로만 받아온다면 상태 변경이 필요한 시점에 엔티티를 다시 조회해야 합니다.
// 현재 방식: 엔티티로 받아서 영속성 컨텍스트 안에서 처리
Reservation reservation = reservationRepository.findById(id);
reservation.confirm(); // 엔티티 메서드 → 변경 감지 → UPDATE 자동 실행
// Projection 방식이었다면: DTO 조회 후 별도 엔티티 조회 필요
ReservationDto dto = queryFactory.select(...).fetchOne();
Reservation reservation = reservationRepository.findById(dto.getId()); // 추가 쿼리
reservation.confirm();두 번째: 응답 DTO가 엔티티의 거의 모든 필드를 사용합니다. 목록 조회 DTO도 reservationId, status, reservationDate, reservationTime, reservationPrice, reservationDuration, customerName, customerPhone, businessName 등 많은 필드를 담습니다. Projection으로 아낄 수 있는 컬럼이 실제로 많지 않습니다.
대신 엔티티를 받아서 DTO static factory 메서드로 변환하는 패턴을 채택했습니다. 집계 쿼리에서만 예외적으로 Tuple Projection을 사용합니다. 엔티티로 표현할 수 없는 AVG, COUNT GROUP BY 같은 경우입니다.
// 평균 평점 — 스칼라 projection
Double avg = queryFactory
.select(review.rating.avg())
.from(review)
.where(businessIdEq(businessId), deletedAtIsNull())
.fetchOne();
// 평점 분포 — Tuple projection
List<RatingCount> results = queryFactory
.select(review.rating, review.count())
.from(review)
.where(businessIdEq(businessId), deletedAtIsNull())
.groupBy(review.rating)
.fetch()
.stream()
.map(tuple -> new RatingCount(
tuple.get(review.rating),
tuple.get(review.count())
))
.toList();QueryDSL 없이 연관 엔티티를 함께 로딩하려면 두 가지 선택지가 있었습니다.
@EntityGraph는 메서드 단위로 fetch join 경로를 선언합니다.
@EntityGraph(attributePaths = {"user", "invitedBy"})
List<UserBusinessRole> findByBusinessIdAndIsActiveOrderByJoinedAtAsc(
UUID businessId, Boolean isActive
);조건이 고정된 단순 쿼리에서는 @EntityGraph가 간결합니다. Timefit에서 UserBusinessRole 조회처럼 조건이 변하지 않는 경우에 사용합니다.
그러나 동적 조건이 추가되면 @EntityGraph만으로는 부족합니다. QueryDSL로 fetchJoin을 직접 제어합니다.
// 예약 생성을 위한 BookingSlot 조회 — Business, Menu를 한 번에 로딩
public Optional<BookingSlot> findBookingSlotWithBusinessAndMenu(UUID slotId) {
BookingSlot result = queryFactory
.selectFrom(bookingSlot)
.join(bookingSlot.business).fetchJoin() // Business 즉시 로딩
.join(bookingSlot.menu).fetchJoin() // Menu 즉시 로딩
.where(bookingSlot.id.eq(slotId))
.fetchOne();
return Optional.ofNullable(result);
}
// 업체 예약 목록 — Business, Menu, BusinessCategory를 한 번에 로딩
List<Reservation> reservations = queryFactory
.selectFrom(reservation)
.join(reservation.business, business).fetchJoin()
.join(reservation.menu, menu).fetchJoin()
.join(menu.businessCategory, businessCategory).fetchJoin()
.where(...)
.fetch();이 쿼리는 1번으로 Reservation + Business + Menu + BusinessCategory를 모두 가져옵니다. 목록이 100건이어도 추가 SELECT 없이 데이터를 사용할 수 있습니다.
@EntityGraph vs QueryDSL fetchJoin 판단 기준:
| @EntityGraph | QueryDSL fetchJoin | |
|---|---|---|
| 사용 조건 | 고정 조건 쿼리 | 동적 조건 쿼리 |
| 코드 위치 | Repository 인터페이스 | QueryRepository 구현체 |
| 동적 조합 | 불가 | BooleanExpression 조합 |
| 복잡한 조인 | 한계 있음 | 다단계 fetchJoin 가능 |
Raw SQL / JDBC
└─ SQL 제어권 최대 / 객체-관계 매핑을 직접 구현
→ 비즈니스 로직보다 배관 코드 비율이 높아짐
MyBatis
└─ SQL은 XML/어노테이션으로 관리 / 결과 매핑 지원
→ 객체 관계 탐색, 변경 감지는 직접 구현
→ SQL이 눈에 보여 "어떤 쿼리가 실행되는지"가 명확함
JPA (Spring Data JPA)
└─ 객체 중심, 영속성 컨텍스트가 변경 감지·지연 로딩 담당
→ 단순 쿼리: 메서드 이름으로 해결
→ 복잡한 동적 조건: @Query 문자열 JPQL (런타임 오류, 조건 조합 어려움)
JPA + QueryDSL ← 최종 선택
└─ JPA: 영속성 컨텍스트, 엔티티 생명주기, 변경 감지
QueryDSL: 타입 안전 동적 쿼리, 조건 재사용, fetchJoin 제어
→ 일반 조회: 엔티티 → DTO 변환 (엔티티 메서드가 서비스 레이어에서 작동해야 하고,
응답 DTO가 엔티티 대부분 필드를 사용해 Projection 이점이 작음)
→ 집계 쿼리: Tuple Projection (AVG, COUNT GROUP BY 등 엔티티로 표현 불가한 경우만)
선택 과정에서 "좋은 기술"을 고른 게 아닙니다. Timefit의 도메인 복잡도(다중 엔티티 관계, 동적 필터, 상태 머신 패턴)에 비용 대비 가장 적합한 조합을 고른 것입니다. SQL이 단순하거나 성능 요구가 극단적인 서비스라면 MyBatis 또는 JDBC를 다시 고려할 것입니다.
시스템
백엔드 포트폴리오