본문으로 건너뛰기
Forward Engineering
Go back

[JPA + Spring Mastery 09] 어디서 무엇을 쓸까 — JPA / JPQL / Native SQL 결정 트리, 측정값 4종으로 갈리는 컷

- views

Table of contents

Open Table of contents

들어가며

이 시리즈를 1 편부터 8 편까지 끌고 오면서 가장 자주 받은 질문이 하나 있습니다 — “그래서 결국 언제 JPA 를 쓰고 언제 안 써야 하나요?”. 1 편의 PersistenceContext 와 Dirty Checking, 4 편의 N+1 entity graph, 5 편의 saveAll IDENTITY 함정, 7 편의 self-invocation, 8 편의 Saga / Outbox — 각 편이 측정값으로 한 함정을 풀었지만, 그 측정값들이 결정 트리의 어디에 위치하는지는 따로 정리하지 않았습니다.

이 마무리편은 그 정리입니다. 8 편의 evidence 를 한 트리 위에 올리고, 4 개의 측정값 컷 — Dirty Checking 132×, saveAll IDENTITY 약 1000×, OFFSET 1M 570×, bulk JPQL vs Dirty Checking 84× — 으로 어느 컷에서 도구를 바꿔야 하는지 를 그어둡니다. 면접 자리에서 “JPA 어떻게 다뤄봤어요?” 라는 질문에 URL 한 줄로 답하기 위한 entry point 글입니다.

JPA 가 만능이라는 주장과 JPA 는 ORM 이라 느리다는 주장 모두 반쪽 답입니다. 정확한 질문은 지금 이 작업이 도메인 단위인가 데이터 단위인가 — 이 한 질문이 JPA / JPQL / Native SQL 의 갈림길의 첫 분기입니다. Eric Evans 가 Domain-Driven Design (2003) 에서 정의한 Aggregate Root 의 일관성 경계 안의 일은 JPA 의 Dirty Checking 이 가장 빛나는 자리고, Aggregate 경계를 넘는 데이터 처리 — bulk UPDATE, 통계, 리포팅, 1만 건 INSERT — 는 JPA 가 적자를 보는 자리입니다. 이 둘 사이의 회색지대를 JPQL 이 메우고, JPQL 도 못 가는 곳에 Native SQL / JdbcTemplate / MyBatis 가 있습니다.

  1. 첫 갈림길 — 도메인 단위인가 데이터 단위인가 (Eric Evans Aggregate / Fowler Identity Map)
  2. JPA Dirty Checking 이 빛나는 곳 5 가지 — Aggregate 의 atomic 변경, 1차 캐시 hit, Optimistic Lock, Rich Domain, Cascade
  3. JPA Dirty Checking 이 적자인 곳 5 가지 — bulk UPDATE (132× / 84×), saveAll IDENTITY (~1000×), 통계·집계, 복잡한 join + N+1, DB 고유 기능
  4. JPQL 의 중간 자리@Modifying bulk, DTO Projection, QueryDSL, 한계
  5. Native / JdbcTemplate / MyBatis 의 자리 — entity 매핑 안 함, Spring Batch reader 4 종
  6. Command / Query 분리 — Greg Young CQRS Documents (2010) 의 가벼운 적용
  7. 결정 트리 + 측정값 4 종 컷
  8. 면접 답변 — 1 분 / 5 분 / 30 분 꼬리질문 방어

결론부터 말하면:

머릿속의 “JPA 가 좋아 / 나빠” 라는 이분법이 어떻게 어디서 무엇을 써야 하는가 의 결정 트리로 바뀌는지 측정값과 함께 풀어봅니다.


1. 첫 갈림길 — 도메인 단위 vs 데이터 단위

1.1 Eric Evans 의 Aggregate Root — 일관성 경계

Eric Evans, Domain-Driven Design (Addison-Wesley, 2003)Aggregate 정의가 이 글의 출발점입니다.

“An AGGREGATE is a cluster of associated objects that we treat as a unit for the purpose of data changes. Each AGGREGATE has a root and a boundary. The boundary defines what is inside the AGGREGATE. The root is a single, specific ENTITY contained in the AGGREGATE. The root is the only member of the AGGREGATE that outside objects are allowed to hold references to.” — Eric Evans, Domain-Driven Design, 2003, Chapter 6

핵심 정의:

  1. Aggregate = 데이터 변경 단위로 함께 다루는 객체 cluster
  2. 경계 (boundary) = 이 안에 있는 것 / 밖에 있는 것의 명확한 선
  3. Root = Aggregate 의 단일 진입점 entity. 외부는 root 만 참조 가능
  4. 일관성 = Aggregate 안의 invariant 는 한 트랜잭션 안에서 보장

이 정의가 JPA Dirty Checking 의 적합 영역을 직접 가리킵니다. Aggregate 경계 안의 변경 = Dirty Checking 이 자동으로 추적해주는 변경. 경계 밖의 변경 = JPA 의 1 차 캐시 / Unit of Work 의 가치가 떨어지는 영역.

1.2 Fowler 의 Identity Map / Unit of Work — JPA 가 구현한 패턴

Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) 가 JPA 의 구현 패턴의 원전입니다.

패턴정의JPA 의 구현
Identity Map한 비즈니스 트랜잭션 안에서 같은 ID = 같은 Java 객체1 차 캐시 (PersistenceContext)
Unit of Work트랜잭션 안의 모든 변경 추적 → commit 시점에 한 번에 flushActionQueue + Dirty Checking
Repository도메인 객체의 컬렉션처럼 보이는 추상화JpaRepository / EntityManager
Domain Model도메인 로직이 entity 메서드에 들어감@Entity + 도메인 메서드

이 4 패턴의 조합이 JPA 가 도메인 단위 작업에 빛나는 이유입니다. 그리고 이 4 패턴이 데이터 단위 작업에는 오히려 비용이 되는 이유 — Identity Map 은 1 만 entity 메모리 등록을 강제, Unit of Work 는 1 만 snapshot 비교를 강제, Repository 추상화는 raw SQL 의 자유도를 잃습니다.

이 비용을 실제 측정값으로 본 게 시리즈 1 편의 PersistenceContext 글 — 같은 SELECT 를 raw JDBC 와 JPA 로 비교하면 raw 0.74ms vs JPA 0.99ms 의 +0.4ms baseline 비용이 반드시 발생합니다. 1 row 당 0.4ms — 1 만 row 면 4 초의 baseline 비용.

1.3 도메인 단위 vs 데이터 단위의 정의

이 글에서 두 개념을 다음과 같이 정의합니다.

도메인 단위 작업 — Aggregate 경계 안의 invariant 변경. 사용자 행동 1 회가 만드는 변경. 보통 row 수 1 ~ 수십.

// 도메인 단위 — 결제 webhook 한 번이 5 entity 변경
@Transactional
public void confirmPayment(PaymentWebhook webhook) {
    Subscription sub = repo.findById(webhook.subId());
    sub.activate(webhook.confirmedAt());      // 변경 1
    sub.getBilling().markPaid(webhook);       // 변경 2
    sub.getCoupon().consume();                // 변경 3
    sub.scheduleNextBilling();                // 변경 4
    auditRepo.save(new ConfirmAudit(webhook));// 변경 5
}

데이터 단위 작업 — Aggregate 경계를 명시적으로 넘어서 처리하는 row 단위 작업. 통계 / 리포팅 / bulk 변경 / 마이그레이션. 보통 row 수 수천 ~ 수억.

// 데이터 단위 — 만료된 쿠폰 1 만 건 일괄 expire
@Modifying(clearAutomatically = true)
@Query("UPDATE Coupon c SET c.status = 'EXPIRED' WHERE c.expiresAt < :now AND c.status = 'ACTIVE'")
int expireOldCoupons(@Param("now") LocalDateTime now);

이 두 작업의 근본적 차이 — 도메인 단위는 invariant 추적이 가치, 데이터 단위는 invariant 추적이 비용. 이 한 줄이 결정 트리의 첫 분기입니다.

1.4 첫 결정 — 이 작업이 도메인 단위인가 데이터 단위인가

PR 리뷰 / 설계 회의에서 제일 먼저 묻는 질문:

질문 1: 이 작업이 만들어내는 변경이 어느 Aggregate 의 invariant 를 건드리나?
  - 명확한 단일 Aggregate → 도메인 단위 → JPA Dirty Checking
  - 여러 Aggregate / Aggregate 경계 없음 → 데이터 단위 → 질문 2

질문 2: row 수가 예측 가능한 수십 ~ 수백인가, 아니면 수천 이상으로 폭증 가능한가?
  - 수십 ~ 수백 → JPA + DTO Projection 도 OK
  - 수천 ~ 수만 → JPQL bulk UPDATE / DELETE
  - 수만 ~ → raw JDBC batch / native SQL

이 결정 트리의 각 분기에 측정값을 다음 절부터 채워 넣습니다.


2. JPA Dirty Checking 이 빛나는 곳 5 가지

2.1 Aggregate Root + 자식 entity 의 atomic 변경

도메인 작업 1 회가 여러 entity 를 동시 변경할 때, JPA Dirty Checking 이 코드의 의도와 DB 의 상태를 일치시켜줍니다. 결제 webhook 처리가 대표 사례.

@Service
public class PaymentConfirmService {

    @Transactional
    public void confirm(PaymentWebhook webhook) {
        Subscription sub = repo.findById(webhook.subId())
            .orElseThrow();

        // 5 entity 의 atomic 변경 — 도메인 메서드로 의도 표현
        sub.activate(webhook.confirmedAt());
        sub.getBilling().markPaid(webhook.amount());
        sub.getCoupon().ifPresent(Coupon::consume);
        sub.scheduleNext(webhook.confirmedAt());
        auditRepo.save(ConfirmAudit.of(webhook));

        // commit 시점에 flush — UPDATE / INSERT 자동 발사
    }
}

이 코드가 raw SQL 이라면 5 개의 UPDATE / INSERT 를 순서까지 직접 작성해야 합니다. 한 entity 만 잊어도 부분 업데이트 사고. JPA 의 가치는 — 변경 의도만 표현하면 SQL 발행은 Hibernate 가 책임진다는 것.

8 편의 Saga 구현이 이 패턴 위에 있습니다. Saga 의 각 step (Tx1 reserve / Tx2 confirm / Tx3 cancel) 안은 단일 Aggregate 의 atomic 변경 — JPA Dirty Checking 이 그 step 안의 일관성을 책임지고, Saga 의 보상 로직이 step 간의 일관성을 책임집니다. Aggregate = JPA / 분산 단위 = Saga 의 2 층 구조 — 시리즈 8 편 트랜잭션 분리 글 의 핵심이기도 합니다.

외부 사례: 토스 SLASH24 — SAGA 분산 트랜잭션 보상 에서도 Saga 의 각 step 은 단일 Aggregate 의 JPA 트랜잭션으로 구현됨을 확인할 수 있습니다.

2.2 1 차 캐시 hit — 같은 entity 반복 접근

같은 트랜잭션 안에서 같은 ID 의 entity 를 여러 번 읽으면 — 2 회차부터 SQL 0 건. Identity Map 패턴의 직접적 가치.

@Transactional
public void processOrder(Long orderId) {
    Order o1 = repo.findById(orderId).orElseThrow();    // SELECT 1 회
    validateInventory(o1);                              // 메서드 안에서 다시 조회
    calculateDiscount(o1);                              // 또 조회
    sendNotification(o1);                               // 또 조회
}

private void validateInventory(Order o) {
    Order again = repo.findById(o.getId()).orElseThrow();// 1 차 캐시 hit — SQL 0
    // ...
}

raw JDBC 였다면 각 조회마다 SELECT 1 건 — 총 4 SELECT. 같은 트랜잭션 안에서 같은 row 를 반복 조회하는 도메인 코드에서 1 차 캐시 hit 의 가치가 누적됩니다.

단 반복 접근이 없는 호출 1 회 path 에서는 1 편에서 본 +0.4ms baseline 비용 (raw 0.74ms vs JPA 0.99ms) 이 순손실. 반복 접근의 빈도 × baseline 비용의 trade-off — 반복이 많으면 1 차 캐시가 이긴다.

2.3 Optimistic Lock + @Version

Aggregate 의 atomic 변경 + 동시성 제어가 결합되는 자리. JPA 의 @Version 한 줄이 SQL 의 WHERE version = ? 절 자동 생성.

@Entity
public class Account {
    @Id Long id;
    long balance;
    @Version Long version;  // ← 한 줄

    public void deduct(long amount) {
        if (balance < amount) throw new InsufficientBalance();
        this.balance -= amount;
        // commit 시점에 자동: UPDATE Account SET balance=?, version=version+1 WHERE id=? AND version=?
    }
}

시리즈 7 편 self-invocation 글 의 락 4 종 측정 [실측, EXP-02] 을 보면 — 동시성 100 worker, 잔액 100 차감 시나리오에서:

시간정확성
비관락 (FOR UPDATE)180ms100% 정확
낙관락 (@Version)549ms100% 정확 (단, contention 시 N² 재시도)
MySQL GET_LOCK5,015ms91% (advisory lock 비용)
Redisson단일 인스턴스 환경 부적합53%

낙관락의 549ms 가 비관락 180ms 의 3 배 느림이지만 — 이건 100 worker 가 같은 row 를 노리는 high-contention 환경 의 측정. low-contention (서로 다른 Aggregate) 에서는 낙관락이 lock 부하 없이 더 빠른 경우가 많습니다. 어떤 도메인의 contention 패턴인가에 따라 답이 갈립니다.

이 측정의 진짜 학습은 측정값이 아니라 측정 도중 발견한 self-invocation 함정입니다. successes=100 인데 잔액이 그대로 — 같은 클래스 내부 호출이 Spring AOP 프록시를 우회. 7 편이 그 함정을 라인 단위로 분해했습니다.

2.4 도메인 메서드로 비즈니스 로직 표현 — Rich Domain

Fowler PoEAA 의 Domain Model 패턴 이 정의한 Rich Domain Model vs Anemic Domain Model.

// Anemic — getter/setter 만 있고 로직은 service 에
public class Order {
    private OrderStatus status;
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus s) { this.status = s; }
}

@Service
public class OrderService {
    public void cancel(Long id) {
        Order o = repo.findById(id);
        if (o.getStatus() == OrderStatus.SHIPPED) throw new IllegalState();
        o.setStatus(OrderStatus.CANCELLED);  // 로직이 service 에 흩어짐
    }
}

// Rich — 로직이 entity 안에
public class Order {
    private OrderStatus status;

    public void cancel() {
        if (status == OrderStatus.SHIPPED)
            throw new CannotCancelShippedOrder();
        this.status = OrderStatus.CANCELLED;
    }
}

@Service
public class OrderService {
    @Transactional
    public void cancel(Long id) {
        repo.findById(id).orElseThrow().cancel();  // 의도가 명확
    }
}

Rich Domain 은 service 비대화를 막고 비즈니스 invariant 가 entity 안에 응집 됩니다. JPA Dirty Checking 이 이 패턴을 코드로 표현 가능하게 만든 핵심 — 변경 후 save() 호출 불필요. 도메인 메서드만 호출하면 commit 시점에 자동 flush.

raw JDBC 라면 도메인 메서드 호출 후 반드시 repo.update(o) 를 명시 — 잊으면 silent miss. JPA 는 이 호출을 암묵적 으로 처리합니다.

2.5 Cascade + Orphan Removal

부모-자식 관계의 일관성. raw SQL 의 삭제 동기화 누락 사고를 JPA 가 막아줍니다.

@Entity
public class Order {
    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<OrderItem> items;

    public void removeItem(OrderItem item) {
        items.remove(item);  // commit 시점에 DELETE FROM order_items 자동
    }
}

raw JDBC 였다면 — Order.items 의 메모리 상태와 DB 상태를 수동 동기화. 변경 다섯 번 하면 다섯 번 모두 일관성 책임. JPA 는 컬렉션 변경 을 자동 감지해서 INSERT / DELETE 발행.

단 함정도 있습니다. @OneToMany 가 2 개 이상 일 때 fetch join 시 MultipleBagFetchException, paging 시 OOM (Hibernate 가 메모리에서 paging) — N+1 entity graph deep dive 글 의 4 함정. Cascade 의 가치는 경계가 명확한 1:N 관계에 한정.


3. JPA Dirty Checking 이 적자인 곳 5 가지

3.1 bulk UPDATE — 132× / 84× 측정값

수만 row UPDATE 에서 Dirty Checking 의 비용이 dominant 가 됩니다. Dirty Checking snapshot cost 글 (EXP-13) 의 6 시나리오 측정 [실측 — Java/Spring]:

#시나리오시간 (ms)의미
S1일반 entity (@Transactional, readOnly 없음)3,450Dirty Checking dominant
S2readOnly SELECT 만26변경 없음 baseline
S3일반 entity 변경 (@DynamicUpdate 없음)3,117S1 과 비슷
S4@DynamicUpdate 적용2,123SET 절 축소만
S5bulk JPQL @Modifying41PersistenceContext 우회
S6raw JDBC31baseline

비교 기준 분리:

  • S1 vs S2 ≈ 132× (3,450 / 26) — readOnly 가 빠진 메서드가 부담하는 비용. 단, S2 는 SELECT only 이므로 쓰기 비용 비교 가 아니라 Dirty Checking 부재 시 1 만 row 로드 + flush 한계 비용 비교
  • S4 vs S6 ≈ 68× (2,123 / 31) — @DynamicUpdate 만으로는 Dirty Checking dominant 비용을 못 줄임
  • S5 vs S6 ≈ 1.32× (41 / 31) — JPA 추상화 오버헤드는 30% 수준
  • S1 vs S5 ≈ 84× (3,450 / 41) — bulk JPQL 로 모델 자체를 바꿨을 때 의 효과

이 측정의 의미 — bulk 작업에서 도구를 바꾸지 않으면 84× 손해. 1 분 작업이 84 분.

(심도) Hibernate 6 의 loadedState 가 어떻게 만들어지나 — Dirty Checking 비용의 정체 (펼치기)

Vlad Mihalcea, High-Performance Java Persistence (2016, Manning), Chapter 5 Persistence Context 가 가장 정확하게 설명한 부분.

Hibernate 가 entity 를 hydrate 할 때:

SELECT 결과 row → ResultSet → Hibernate hydrator
  → Object[] (= loadedState, snapshot 1)
  → 이 Object[] 를 복사 해서 entity 필드에 set
  → EntityEntry 가 (entity, loadedState) 둘 다 보관

핵심 비용:

  1. Object[] 복사 — 1 만 entity × N 컬럼 = N 만 객체 참조 메모리
  2. flush 시 비교 loop — 모든 managed entity × 모든 컬럼 = O(N × M) reflection 호출 (또는 bytecode enhancement 인터셉터)
  3. WHERE 절은 PK 만@DynamicUpdate 없으면 모든 컬럼 을 SET 절에 (변경 안 된 컬럼도 포함)

S4 의 @DynamicUpdate 가 (3) 만 해결. (1)(2) 는 그대로 — 그래서 S4 가 여전히 raw JDBC 대비 68× 느림. 근본 해결 은 모델 자체를 bulk JPQL 로 바꾸는 것 (S5, 41ms).

Hibernate 6 부터는 bytecode enhancement 옵션 으로 dirty tracking 을 interceptor 패턴 으로 교체 가능 — 단 빌드 시 enhancement task 추가 + 디버깅 복잡도 증가. 측정 시 30 ~ 40% 개선 보고가 있지만 bulk 작업의 84× 차이 를 메우진 못합니다.

3.2 saveAll(IDENTITY) — 약 1000× 측정값

saveAll IDENTITY bulk insert trap 글 (EXP-14) 의 측정 [실측 — Java/Spring]:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50    # 설정해도 IDENTITY 면 무시됨
@Entity
public class BulkRow {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)  // ← 함정
    private Long id;
    // ...
}

repo.saveAll(rows);  // 1 만 row

기대: 1 만 row / batch 50 = 200 SQL. 실제: 1 만 SQL (Hibernate Statistics prepareStatementCount=10000).

근본 원인: GenerationType.IDENTITY 는 매 INSERT 마다 LAST_INSERT_ID() 를 즉시 받아야 합니다 — Statement.RETURN_GENERATED_KEYS 가 batch 와 호환 안 됨. 그래서 Hibernate 가 batch 자체를 비활성화.

raw JDBC batchUpdate + rewriteBatchedStatements=true 면 1 만 row → multi-value INSERT 약 10 SQL → 약 1000× 의 SQL 수 차이 (그리고 비례적인 시간 차이).

해결 4 가지:

방법동작trade-off
UUID애플리케이션이 UUID 생성 후 set → batch 가능UUID 16 byte, 인덱스 random IO
@TableGenerator + pooled-lo (allocationSize=1000)시퀀스 테이블에서 1000 ID 한 번에 발급시퀀스 테이블 관리 + MySQL 환경 PostgreSQL SEQUENCE 의 효과 못 봄
Snowflake / TSID분산 ID 생성기라이브러리 의존 추가
raw JDBC batchUpdateJPA 우회entity 매핑 안 함

MySQL 환경의 진짜 답 — 위 4 가지 중 도메인 특성에 따라 선택. PostgreSQL 의 SEQUENCE 가 없는 MySQL 에서는 IDENTITY → SEQUENCE 100 배 같은 dzone 글의 결론을 그대로 옮겨오면 안 됩니다 (dzone 글의 PostgreSQL 가정).

3.3 통계 / 집계 / 리포팅

도메인 의미가 아닌 데이터 처리 가 목적인 작업. JPQL 도 표현이 어렵고 — Window function, recursive CTE, JSON 함수, FULLTEXT 모두 JPQL 표준 spec 에 없습니다.

-- 사장님 대시보드: 최근 30 일의 일별 매출 + 7 일 이동평균
SELECT
    DATE(created_at) AS day,
    SUM(amount) AS daily,
    AVG(SUM(amount)) OVER (
        ORDER BY DATE(created_at)
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS rolling_7d
FROM orders
WHERE merchant_id = ?
  AND created_at >= NOW() - INTERVAL 30 DAY
GROUP BY DATE(created_at);

이 SQL 을 JPQL 로 표현 시도 → window function 미지원 → 실패. native query 또는 JdbcTemplate 으로 바로 가야 합니다.

DTO Projection 의 비용도 다릅니다.

방식비용1 만 row
Entity 로딩 (findAll())1 만 entity 메모리 + snapshot + 1 차 캐시4,000ms (1 편의 +0.4ms × 1 만)
DTO Projection (SELECT new com.X.SalesDto(...))DTO 만 — 1 차 캐시 안 차임약 30ms
JdbcTemplate RowMapperDTO 만 — 추상화 0약 25ms

리포팅은 entity 가 필요 없는 작업 — DTO Projection 또는 JdbcTemplate.

3.4 복잡한 join + N+1

Aggregate 경계를 넘는 join. JPA 의 fetch join / @EntityGraph 의 4 함정이 N+1 entity graph deep dive 글 에서 풀렸습니다 — MultipleBagFetchException, Pagination OOM, OneToOne LAZY 가 EAGER 처럼 작동, @BatchSize 의 양면성.

복잡한 join 의 정통 패턴은 DTO Projection 으로 fetch join 자체를 회피.

// Anti-pattern: entity fetch join
@Query("SELECT DISTINCT o FROM Order o " +
       "LEFT JOIN FETCH o.items " +
       "LEFT JOIN FETCH o.payments " +  // ← MultipleBagFetchException
       "WHERE o.merchantId = :id")
List<Order> findWithItemsAndPayments(@Param("id") Long id);

// Right way: DTO Projection
@Query("SELECT new com.X.OrderListDto(o.id, o.amount, o.createdAt, m.name) " +
       "FROM Order o JOIN o.merchant m " +
       "WHERE m.id = :id")
List<OrderListDto> findOrderList(@Param("id") Long id);

DTO Projection 은 entity 의 atomic 변경 이 필요 없는 조회 전용 path 의 정통. 다음 6 절의 Command / Query 분리의 핵심 도구이기도 합니다.

3.5 DB 고유 기능

JPA 의 추상화 는 DB-portability 를 위한 것 — 그 대가로 DB 고유 기능 을 잃습니다.

기능JPA / JPQL 표현 가능?권장 도구
Window function (OVER)Native SQL / JdbcTemplate
Recursive CTE (WITH RECURSIVE)Native SQL
JSON 함수 (JSON_EXTRACT)⚠️ MySQL Dialect 한정, 표준 spec 아님Native SQL
Upsert (INSERT ... ON DUPLICATE KEY)Native SQL
Partition pruning hintNative SQL
FULLTEXT (MATCH ... AGAINST)Native SQL
SELECT FOR UPDATE SKIP LOCKED⚠️ 일부 DialectNative SQL
Bulk INSERT multi-valueraw JDBC batchUpdate

이 매트릭스의 의미 — DB 고유 기능이 필요한 순간 = JPA 를 부분적으로 우회. 같은 코드베이스 안에서 JPA 와 Native SQL 을 공존 시키는 게 정상 운영 패턴.


4. JPQL 의 중간 자리

JPQL 은 JPA 와 Native SQL 의 사이 — entity 매핑은 유지 하지만 PersistenceContext 의 Dirty Checking 을 우회. 이 우회의 의미를 정확히 알아야 함정에 안 빠집니다.

4.1 bulk UPDATE / DELETE — @Modifying

public interface CouponRepository extends JpaRepository<Coupon, Long> {

    @Modifying(clearAutomatically = true)
    @Query("UPDATE Coupon c SET c.status = 'EXPIRED' " +
           "WHERE c.expiresAt < :now AND c.status = 'ACTIVE'")
    int expireOldCoupons(@Param("now") LocalDateTime now);
}

핵심 동작:

  1. PersistenceContext 우회 — Dirty Checking 도, 1 차 캐시도 거치지 않음
  2. DB 에 직접 UPDATE 발행 — bulk 1 SQL
  3. 1 차 캐시는 stale — 같은 트랜잭션에서 이미 로드한 entity 는 변경 전 상태

stale 함정:

@Transactional
public void scenario() {
    Coupon c = repo.findById(1L).orElseThrow();  // ACTIVE 로 로드, 1 차 캐시 등록
    repo.expireOldCoupons(LocalDateTime.now());   // bulk UPDATE — DB 는 EXPIRED
    System.out.println(c.getStatus());            // ⚠️ 여전히 ACTIVE — 1 차 캐시 stale
}

처방: @Modifying(clearAutomatically = true) 로 1 차 캐시 자동 비움. 또는 @Modifying(flushAutomatically = true) 로 발행 전 미flush 변경 먼저 flush.

측정값: 1 만 row UPDATE 시 bulk JPQL = 41ms (S5), Dirty Checking = 3,450ms (S1). 84× 차이. 데이터 단위 작업의 1 차 처방.

4.2 DTO Projection — readOnly + 1 차 캐시 안 차기

조회 전용 path 의 정통.

@Query("SELECT new com.X.OrderSummaryDto(o.id, o.amount, m.name, o.createdAt) " +
       "FROM Order o JOIN o.merchant m " +
       "WHERE m.id = :merchantId " +
       "ORDER BY o.createdAt DESC")
List<OrderSummaryDto> findOrderSummaries(@Param("merchantId") Long merchantId);

DTO Projection + @Transactional(readOnly = true) 의 결합 — 시리즈 1 편에서 분해한 readOnly 의 3 단 효과:

  1. Hibernate flush mode = MANUAL → flush 발생 안 함 → Dirty Checking 안 함
  2. Spring Tx readOnly 마커 → 일부 connection / driver 가 read-only 최적화
  3. MySQL Com_set_option 감소외부 사례: 카카오페이 — JPA Transactional 잘 알고 쓰고 계신가요? 의 QPS 58% 감소 회고. read-only 트랜잭션의 set autocommit = 0/1 round-trip 이 줄어드는 효과

조회 path 의 기본값 이 readOnly = true + DTO Projection. 변경 path 만 명시적으로 readOnly = false 또는 @Transactional 만.

4.3 동적 query — QueryDSL / Criteria

검색 조건이 런타임 결정 되는 경우. JPQL 의 문자열 쿼리로는 동적 조건 결합이 위태로움.

public List<Order> search(OrderSearchCriteria c) {
    return queryFactory
        .selectFrom(order)
        .where(
            c.merchantId() != null ? order.merchantId.eq(c.merchantId()) : null,
            c.from() != null ? order.createdAt.goe(c.from()) : null,
            c.to() != null ? order.createdAt.lt(c.to()) : null,
            c.status() != null ? order.status.eq(c.status()) : null
        )
        .orderBy(order.createdAt.desc())
        .limit(20)
        .fetch();
}

QueryDSL 의 가치는 컴파일 시 type 안전. 단 빌드 설정이 추가됨 (Q-class 생성). Criteria API 는 type 안전이지만 가독성 매우 낮음 — 보통 QueryDSL 을 권장.

4.4 JPQL 의 한계 — native function 못 씀

// ❌ JPQL — MySQL JSON_EXTRACT 표준 spec 에 없음
@Query("SELECT o FROM Order o WHERE JSON_EXTRACT(o.metadata, '$.coupon') = :code")

// ✅ Native SQL — MySQL Dialect 직접 사용
@Query(value = "SELECT * FROM orders WHERE JSON_EXTRACT(metadata, '$.coupon') = :code",
       nativeQuery = true)

native function (window, JSON, FULLTEXT, recursive CTE) 이 필요한 순간 → JPQL 한계 → Native query 또는 JdbcTemplate 으로.


5. Native SQL / JdbcTemplate / MyBatis 의 자리

5.1 Native @Query(nativeQuery = true)

entity 매핑 유지 + DB 고유 기능. 트랜잭션 / 1 차 캐시 / 격리수준은 JPA 와 함께.

@Query(value = """
    SELECT * FROM orders
    WHERE merchant_id = :id
      AND MATCH(description) AGAINST (:keyword IN BOOLEAN MODE)
    ORDER BY created_at DESC
    LIMIT 20
    """, nativeQuery = true)
List<Order> searchByFulltext(@Param("id") Long id, @Param("keyword") String keyword);

적합한 자리:

부적합한 자리:

5.2 JdbcTemplate

entity 매핑 전혀 안 함. 통계 / 리포팅 / bulk / 마이그레이션의 정통.

@Service
public class SalesReportService {
    private final JdbcTemplate jdbc;

    public List<DailySalesDto> dailySales(Long merchantId, int days) {
        return jdbc.query("""
            SELECT
                DATE(created_at) AS day,
                SUM(amount) AS daily,
                AVG(SUM(amount)) OVER (
                    ORDER BY DATE(created_at)
                    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
                ) AS rolling_7d
            FROM orders
            WHERE merchant_id = ?
              AND created_at >= NOW() - INTERVAL ? DAY
            GROUP BY DATE(created_at)
            """,
            (rs, n) -> new DailySalesDto(
                rs.getDate("day").toLocalDate(),
                rs.getLong("daily"),
                rs.getDouble("rolling_7d")
            ),
            merchantId, days);
    }
}

Spring Batch 의 reader 와 조합 — JdbcCursorItemReader, JdbcPagingItemReader. 대용량 backfill / 마이그레이션의 정석.

외부 사례: 우아콘 — Spring Batch 와 함께 하는 대용량 데이터 처리 에서도 JpaItemReader 가 아닌 JdbcCursorItemReader 권장. JPA 의 entity 매핑 비용이 데이터 단위 처리 에서는 손해.

5.3 MyBatis

SQL 100% 직접 + 결과 매핑 자동. 한국 금융권 / 보험 / 일부 enterprise 가 여전히 정통으로 사용. JPA 와 동시 사용 도 가능 — 같은 DataSource 공유, 같은 트랜잭션 참여.

<!-- MyBatis mapper -->
<select id="findOrderList" resultType="OrderListDto">
    SELECT
        o.id, o.amount, o.created_at,
        m.name AS merchant_name
    FROM orders o
    JOIN merchants m ON m.id = o.merchant_id
    WHERE m.id = #{merchantId}
    ORDER BY o.created_at DESC
    LIMIT 20
</select>
// JPA 와 MyBatis 공존
@Service
public class OrderQueryService {

    @Transactional(readOnly = true)
    public Page<OrderListDto> list(Long merchantId, Pageable p) {
        return mybatisMapper.findOrderList(merchantId, p);  // MyBatis 조회
    }

    @Transactional
    public void cancel(Long orderId) {
        Order o = jpaRepo.findById(orderId).orElseThrow();  // JPA 도메인 변경
        o.cancel();
    }
}

MyBatis 의 자리: SQL 의 세밀한 제어 가 비즈니스 가치인 도메인 (보험 정산, 회계 마감) — JPA 의 추상화가 오히려 디버깅 비용 증가. legacy 시스템 흡수 도 흔한 경우.

대용량 처리는 Spring Batch 의 reader 선택이 결정적.

Reader특징OFFSET 사용 여부측정값
JpaPagingItemReaderOFFSET 페이지네이션 + entity hydrationOFFSET ✓OFFSET 1M → 171ms (570× 손해)
JdbcCursorItemReaderDB cursor 기반, entity 매핑 없음OFFSET ✗cursor 0.30ms
JpaCursorItemReaderJPA + cursor (Hibernate ScrollableResults)OFFSET ✗entity hydration 비용 남음
QuerydslZeroOffset (수제)OFFSET 안 쓰는 keyset paginationOFFSET ✗cursor 0.30ms

OFFSET vs No-Offset cursor 글 (EXP-07) 의 측정 [실측 — Java/Spring]:

OFFSET 위치Latencyrow scanned
1,0000.443ms1,020
10,000~5ms10,020
100,000~50ms100,020
1,000,000171ms1,000,020
No-Offset cursor0.30ms20

약 570× 차이 (171 / 0.30). 사장님 대시보드의 깊은 페이지 사고가 이 측정값으로 설명됩니다 — OFFSET 5 만 페이지 클릭 한 번이 P99 를 무너뜨림.

함정 하나 더 — No-Offset 코드를 어떻게 쓰느냐 에 따라 또 한 번 500× 가 갈라집니다. ANSI SQL row constructor (a,b) < (?,?) 는 의미상 OR 분리 형태와 같지만 MySQL 옵티마이저가 index range 로 push down 못 함. 154ms (OFFSET 과 거의 동일). OR 분리 형태로 써야 0.30ms.

이 측정의 의미 — 데이터 단위 처리 = JPA 추상화의 적자 영역. JpaPagingItemReader 의 OFFSET 함정은 reader 종류만 바꿔도 해결.


6. Command / Query 분리 — Greg Young CQRS 의 가벼운 적용

6.1 Greg Young 의 CQRS Documents (2010)

Greg Young, CQRS Documents (2010) 가 Command Query Responsibility Segregation 의 정의를 정리한 문서.

“CQRS is simply the creation of two objects where there was previously only one. The separation occurs based upon whether the methods are a command or a query (the same definition that is used by Meyer in Command and Query Separation, but, CQRS uses a separate object).” — Greg Young, CQRS Documents, 2010

핵심 정의:

  1. Command — 시스템 상태를 변경 하는 작업. 반환값 없음 (의도적)
  2. Query — 시스템 상태를 읽기만 하는 작업. 변경 없음
  3. 분리 — Command 와 Query 를 서로 다른 객체 로 (또는 서로 다른 모델)

Full CQRS 는 Command 측에 Event Sourcing, Query 측에 별도 read model + 별도 DB / 인덱스 — 이 글이 추천하는 건 그게 아닙니다. Greg Young 자신도 같은 문서에서 full CQRS 의 비용 을 언급하며 대부분의 시스템은 그 비용을 정당화 못 함 이라고 적었습니다.

6.2 가벼운 분리 — 같은 도메인 안의 두 갈래

Full CQRS 가 아닌 논리적 분리 만으로도 80% 의 효과:

                    ┌─────────────────────────────────┐
                    │      Order Domain               │
                    └─────────────────────────────────┘

                ┌────────────┴─────────────┐
                ▼                          ▼
        ┌──────────────┐          ┌──────────────┐
        │   Command    │          │     Query    │
        │              │          │              │
        │  JPA         │          │  JPQL DTO    │
        │  Aggregate   │          │  Projection  │
        │  Dirty Check │          │  readOnly=T  │
        └──────────────┘          └──────────────┘
                │                          │
                ▼                          ▼
        ┌──────────────┐          ┌──────────────┐
        │ OrderService │          │ OrderQuery-  │
        │ .place()     │          │ Service      │
        │ .cancel()    │          │ .list()      │
        │ .refund()    │          │ .summary()   │
        └──────────────┘          └──────────────┘

같은 도메인 (Order) 인데 변경 path 와 조회 path 가 다른 코드 / 다른 모델:

// Command path — JPA Aggregate
@Service
public class OrderCommandService {
    @Transactional
    public void cancel(Long orderId) {
        Order o = repo.findById(orderId).orElseThrow();
        o.cancel();  // 도메인 메서드
    }
}

// Query path — DTO Projection
@Service
public class OrderQueryService {
    @Transactional(readOnly = true)
    public Page<OrderListDto> list(Long merchantId, Pageable p) {
        return repo.findOrderList(merchantId, p);
    }

    @Transactional(readOnly = true)
    public OrderDetailDto detail(Long orderId) {
        return repo.findOrderDetail(orderId);
    }
}

이 분리의 가치:

  1. Query path 는 entity 안 차임 — 1 차 캐시 / Dirty Checking / snapshot 비용 없음
  2. Command path 는 도메인 메서드 응집 — Rich Domain
  3. 읽기 부하 분산이 쉬움 — Query path 만 read replica 로 분리 가능
  4. 변경 / 조회 모델이 다를 수 있음 — Query 가 join / aggregation 를 자유롭게

6.3 외부 사례: 우아콘 — Spring Batch + DTO Projection

우아콘의 대용량 처리 회고에서 — 조회는 JdbcCursorItemReader 또는 DTO Projection, 변경은 JPA Aggregate 의 분리 패턴이 명시적으로 등장합니다. full CQRS 까지 가지 않고 코드베이스 한 곳에서 두 갈래로 분리.

6.4 분리의 안티패턴

// ❌ Command 와 Query 가 섞임
@Service
public class OrderService {
    @Transactional
    public OrderDto getAndCancel(Long id) {  // 의도 불명
        Order o = repo.findById(id).orElseThrow();
        o.cancel();
        return new OrderDto(o);  // Query 인지 Command 인지
    }
}

이런 메서드는 호출자가 의도를 못 읽음. getAndCancel 같은 이름이 함수 이름의 burning sign. Greg Young 의 Meyer Command-Query Separation 원칙 — 반환값이 있는 변경 메서드 금지.


7. 결정 트리 — 손에 잡히는 시나리오 매핑

7.1 결정 트리 (ASCII)

[ 작업이 들어옴 ]


질문 1: 변경인가 조회인가
   ├─ 변경 → 질문 2
   └─ 조회 → 질문 5

질문 2: row 수가 예측 가능한 수십 ~ 수백인가
   ├─ YES → 질문 3
   └─ NO (수천 이상) → 질문 4

질문 3: 단일 Aggregate 의 invariant 변경인가
   ├─ YES → JPA Dirty Checking + Rich Domain
   │        (1 차 캐시 + Unit of Work + Optimistic Lock)
   └─ NO (여러 Aggregate / 외부 시스템) → Saga (시리즈 8)

질문 4: row 수
   ├─ 수천 → JPQL @Modifying bulk UPDATE / DELETE
   │         (PersistenceContext 우회, clearAutomatically=true)
   ├─ 수만 → raw JDBC batchUpdate
   │         (Dirty Checking 84× 차이 회피)
   └─ INSERT 수만 → raw JDBC + rewriteBatchedStatements=true
                    (saveAll IDENTITY ~1000× 회피)

질문 5: 조회 — 단순 / 복잡 / 통계
   ├─ 단순 (Aggregate 단일 ID) → JPA findById + readOnly
   ├─ 복잡 (join + paging) → JPQL DTO Projection + readOnly
   │                          (paging 깊으면 No-Offset cursor — 570×)
   └─ 통계 / 리포팅 / 집계 → JdbcTemplate + Native SQL
                              (window function / CTE / JSON)

7.2 시나리오 매핑 표

시나리오row 수Aggregate?도구
결제 webhook 처리1단일 (Subscription)JPA Dirty Checking
주문 생성 + 외부 PG1 + 외부1 + 분산JPA + Saga
잔액 차감 (high-contention)1단일JPA + 비관락 (180ms)
잔액 차감 (low-contention)1단일JPA + 낙관락 (@Version)
만료 쿠폰 일괄 expire수천 ~ 수만데이터 단위JPQL @Modifying
1 만 row 마이그레이션1 만데이터 단위raw JDBC batchUpdate
사장님 일별 매출 리포트30데이터 단위JdbcTemplate + window function
검색 (FULLTEXT)20단일 조회Native @Query + MATCH
사장님 대시보드 주문 목록20 (paged)데이터 단위JPQL DTO + No-Offset cursor
알림 발행 (eventually consistent)1 + 외부분산Outbox (시리즈 8)
통계 집계 (월말 마감)수십만데이터 단위Spring Batch + JdbcCursorItemReader

7.3 결정 트리의 변동 요인

이 트리의 cut 은 이 환경의 측정값 에 기반합니다. 다른 환경에선 cut 이 달라질 수 있습니다:

변동 요인영향
Hibernate 6 bytecode enhancementDirty Checking 비용 30~40% 감소 (S1 측정값 변동)
MySQL Connector/J 버전rewriteBatchedStatements 효과 차이
InnoDB buffer pool 크기OFFSET 비용 변동 (cache 효과)
동시성 / contention 패턴락 종류 선택 cut 변동
네트워크 latency (DB - app)1 차 캐시 hit 의 가치 증가

학술적 정직성 — 이 컷은 이 환경 / 이 데이터 의 측정값. 다른 환경에선 다른 컷. 운영 도입 전 직접 측정 권장.


8. 측정값 4 종으로 갈리는 컷

이 글의 허브 가 되는 4 측정값.

8.1 측정값 표

측정비율의미출처
1 만 row UPDATE: Dirty Checking vs raw JDBC132× (3,450ms / 26ms)readOnly 가 빠진 메서드의 부담Dirty Checking snapshot cost (EXP-13)
1 만 row UPDATE: Dirty Checking vs bulk JPQL84× (3,450ms / 41ms)모델 자체를 bulk JPQL 로 바꿨을 때동일
@DynamicUpdate 단독 한계 vs raw JDBC68× (2,123ms / 31ms)@DynamicUpdate 만으론 부족동일
bulk JPQL vs raw JDBC1.32× (41ms / 31ms)JPA 추상화 30% 오버헤드동일
saveAll(IDENTITY) vs raw JDBC batch약 1000× (1 만 SQL vs 약 10 SQL)IDENTITY = batch 비활성화saveAll IDENTITY trap (EXP-14)
OFFSET 1M vs No-Offset cursor570× (171ms vs 0.30ms)OFFSET 깊은 페이지의 비용No-Offset cursor (EXP-07)
비관락 vs 낙관락 (high-contention)3× (180ms vs 549ms)contention 패턴별 락 선택락 4 종 비교 (EXP-02)
raw JDBC vs JPA (warm)1.35× (0.74ms vs 0.99ms)JPA baseline +0.4msPersistenceContext flush (시리즈 1)

8.2 컷의 위치

              row 수 / 작업 규모

   1 ───── 100 ──── 1,000 ───── 10,000 ──── 100,000 ───>
                  │              │              │
                  │              │              │
   JPA Dirty C    │              │              │
   ←───── 적합 ──→│              │              │
                  │              │              │
                  │  JPQL bulk   │              │
                  │←─── 적합 ───→│              │
                  │              │              │
                  │              │  raw JDBC    │
                  │              │←──── 적합 ──→│
                  │              │              │
   84× cut ────────────────────→│              │
                  │              │              │
   1000× cut ────────────────────────────────→ │ (IDENTITY)
                  │              │              │
   570× cut (OFFSET) — 페이지 깊이 5만 부근

cut 의 근사 값 — 환경 / 데이터 / 동시성에 따라 변동

8.3 cut 이 다른 환경에서 변동하는 예

환경Dirty Checking 84× cut 변동사례
bytecode enhancement30~40% 감소 → 수만 row 까진 JPA 도 가능Hibernate 6 + 빌드 enhancement
batch_size + non-IDENTITYINSERT cut 사라짐UUID / TableGenerator pooled-lo
읽기 전용 pathDirty Checking 비용 0readOnly = true (1 편 카카오페이 회고)
반복 조회 path1 차 캐시 hit 의 가치 ↑같은 entity 4~5 회 접근

이 cut 들은 고정값이 아닌 권장 시작점. 운영 도입 전 직접 측정.


9. 면접 답변 — 1 분 / 5 분 / 30 분

9.1 1 분 후크

“JPA 는 작은 단위 도메인의 일관성을 코드로 표현하는 도구이지, 모든 SQL 의 대체재가 아닙니다. Aggregate 경계 안의 atomic 변경 — 결제 webhook 5 entity 동시 변경 — 에서는 Dirty Checking 이 가장 빛나고, bulk UPDATE 1 만 row 같은 데이터 단위 작업에서는 84 배 적자입니다. 도구 선택의 첫 갈림길은 도메인 단위인가 데이터 단위인가 라는 한 질문입니다.”

9.2 5 분 — 5 단계

1단계 — 첫 갈림길: 도메인 단위 (Aggregate 경계 안의 invariant 변경) vs 데이터 단위 (통계 / bulk / 리포팅). Eric Evans DDD 의 Aggregate Root 정의가 이 분기의 학술 anchor.

2단계 — JPA 가 빛나는 곳: Aggregate atomic 변경, 1 차 캐시 hit (Identity Map 패턴), Optimistic Lock + @Version, Rich Domain 의 도메인 메서드, Cascade + Orphan Removal. Fowler PoEAA 의 Identity Map / Unit of Work 패턴 위에 구현됨.

3단계 — JPA 가 적자인 곳 + 측정값:

4단계 — JPQL 의 중간 자리: @Modifying(clearAutomatically = true) bulk UPDATE, DTO Projection, QueryDSL 동적 query. PersistenceContext 우회의 의미 + 1 차 캐시 stale 함정.

5단계 — Command / Query 분리: Greg Young CQRS Documents (2010) 의 가벼운 적용. Full CQRS 까지 안 가도 같은 도메인 안에서 두 갈래 (Command = JPA Aggregate / Query = DTO Projection + readOnly) 만으로도 80% 효과. 외부 사례: 카카오페이 readOnly QPS 58% 감소 가 그 효과의 운영 회고.

9.3 30 분 — 꼬리질문 5 개 방어

Q1. Dirty Checking 비용의 정체는 무엇인가?

Vlad Mihalcea 의 High-Performance Java Persistence (2016, Chapter 5) 의 분해 — Hibernate hydrator 가 SELECT 결과를 entity 로 만들 때 Object[] (loadedState) 를 복사 해서 보관. flush 시점에 모든 managed entity × 모든 컬럼 reflection 비교. O(N × M) 의 비용. 1 만 entity × 10 컬럼 = 10 만 비교 + 1 만 UPDATE 발사. 해결은 모델 자체 를 bulk 로 바꾸는 것 (S5 측정값 41ms = 84× 개선). @DynamicUpdate 만 켜면 SET 절 축소 효과만 (S4 = 2,123ms = 여전히 raw JDBC 대비 68× 느림).

Q2. JPA bulk = 84× 느린 근본 이유?

세 가지: (1) entity hydration — 1 만 row 가 1 만 Java 객체 + Object[] snapshot. (2) Dirty Checking — flush 시 전수 비교. (3) Statement.RETURN_GENERATED_KEYS 가 batch 와 비호환 (IDENTITY 의 경우 추가). bulk JPQL 은 (1)(2) 우회 — DB 에 직접 UPDATE 1 SQL. raw JDBC 는 (3) 까지 우회 — multi-value INSERT 또는 batchUpdate.

Q3. JPQL bulk 가 PersistenceContext stale 만드는 메커니즘 + 해결?

@Modifying bulk UPDATE 는 PersistenceContext 우회. DB 에 직접 UPDATE 발사. 그런데 같은 트랜잭션에서 이미 로드한 entity 는 변경 전 상태 — 1 차 캐시의 객체와 DB 의 row 가 불일치. 처방: @Modifying(clearAutomatically = true) 로 1 차 캐시 자동 비움. 또는 발행 후 해당 entity 를 더 이상 안 쓰게 흐름 설계. 그리고 flushAutomatically = true — bulk UPDATE 직전에 미flush 변경 을 먼저 flush (Dirty Checking 결과를 DB 에 먼저 반영).

Q4. CQRS 가 아닌데 왜 Command / Query 분리가 의미 있나?

Greg Young 자신이 같은 문서에서 full CQRS 의 비용 을 인정. Event Sourcing + 별도 read model 까지 안 가도 — Meyer Command-Query Separation 의 원칙만 지키면 80%. 가치: (1) Query path 가 readOnly + DTO Projection 이면 1 차 캐시 / Dirty Checking / snapshot 비용 0. (2) Command path 의 도메인 메서드가 응집 (Rich Domain). (3) 읽기 부하 분산이 쉬움 (read replica 분리). (4) 변경 / 조회 모델이 다를 수 있음. 카카오페이의 readOnly QPS 58% 감소 회고가 (1) 의 운영 측정값.

Q5. JPA / MyBatis 혼용 시 트랜잭션 동기화는?

같은 DataSource 공유 시 — Spring 의 DataSourceTransactionManager 가 같은 connection 위에 둘을 묶음. JPA 의 EntityManager 와 MyBatis 의 SqlSession 이 같은 트랜잭션 에 참여. 단 함정: (1) JPA 의 지연 flush 와 MyBatis 의 직접 SQL 이 순서 어긋남 — JPA 의 변경이 flush 안 된 상태에서 MyBatis 가 SELECT 하면 변경 전 row 를 봄. 처방: MyBatis 호출 전 entityManager.flush() 명시. (2) 1 차 캐시 stale — MyBatis 의 UPDATE 후 같은 트랜잭션에서 JPA 로 entity 조회 시 변경 전 객체 반환. 처방: entityManager.clear() 또는 refresh(). (3) 격리 수준 — 한 connection 위 같은 격리수준이지만 MyBatis 가 MVCC 가시성을 명시적으로 알아야 디버깅 가능.


10. 결론 — 시리즈를 닫으며

10.1 한 문장 요약

JPA 는 작은 단위 도메인의 일관성 을 코드로 표현하는 도구이지, 모든 SQL 의 대체재가 아닙니다. Aggregate 경계 안의 atomic 변경에서는 Dirty Checking 이 빛나고, 데이터 단위 작업 (bulk UPDATE / saveAll IDENTITY / 통계) 에서는 84× ~ 1000× 적자를 봅니다. 도구 선택의 첫 갈림길은 도메인 단위인가 데이터 단위인가 라는 한 질문이고, 그 답을 측정값 4 종 (132× / 84× / ~1000× / 570×) 으로 컷합니다.

10.2 시리즈 1 ~ 9 의 evidence chain

측정값 / 학술 anchor결정 트리에서의 자리
01 PersistenceContext / Flushraw 0.74ms vs JPA 0.99ms = 1.35× / Fowler Identity Map / 카카오페이 readOnly QPS 58%JPA baseline 비용 + readOnly 효과
02 N+1 / Entity Graph (deep dive)MultipleBagFetchException / Pagination OOM / OneToOne LAZY EAGER 문제 / @BatchSize복잡한 join 의 4 함정
03 Optimistic Lock (lost update)비관락 180ms / 낙관락 549ms / GET_LOCK 5,015ms동시성 패턴 별 락 선택
04 Dirty Checking 비용 (snapshot cost)132× / 68× / 84× / Vlad Mihalcea Hibernate 6 loadedStatebulk 의 84× 컷
05 saveAll IDENTITY (trap)약 1000× / RETURN_GENERATED_KEYS 비호환INSERT 의 1000× 컷
06 (예정) QueryDSL 동적 querytype-safe 동적 조건 결합JPQL 의 중간 자리
07 self-invocation (AOP proxy)successes=100 + 잔액 그대로의 모순 / 6 어노테이션 / Spring AOP 프록시운영 함정 진단법
08 트랜잭션 분리 (Saga / Outbox)9 시나리오 매트릭스 / Garcia-Molina 1987 / Helland CIDR 2005Aggregate 너머의 분산
09 (이 글)4 측정값 컷 / 결정 트리 / Greg Young CQRS / Eric Evans Aggregate시리즈의 허브

10.3 운영 점검 체크리스트

10.4 시리즈를 닫으며

이 시리즈는 측정값 으로 시작해서 학술 원전 으로 깊이를 채우고 한국 빅테크 회고 로 운영 reality 를 검증하는 3 박자였습니다. Eric Evans 의 Aggregate, Fowler 의 Identity Map / Unit of Work, Greg Young 의 CQRS, Vlad Mihalcea 의 Hibernate 내부 구조, Pat Helland 의 Idempotence — 이 학술 anchor 들이 언제 / 어디서 / 어떤 환경 의 측정값과 결합될 때 기억에 남는다고 믿습니다.

면접 자리에서 “JPA 어떻게 다뤄봤어요?” 라는 질문에 URL 한 줄로 답할 수 있는 entry point 를 만들고 싶었습니다. 1 편의 PersistenceContext 부터 9 편의 결정 트리까지 — 8 편의 evidence chain 이 하나의 결정 트리 위에 정리되었습니다.

다음 도구가 무엇이 됐든 — 측정값 4 종으로 갈리는 컷의 원리 는 같습니다. 도메인 단위인가 데이터 단위인가. 이 한 질문이 도구 선택의 첫 갈림길이고, 그 답을 측정값과 학술 anchor 로 검증하는 게 글로벌 시니어가 도구를 다루는 방식 입니다.


11. 참고자료

학술 자료 (L5)

공식 문서 (1 순위)

한국 빅테크 회고

Canonical 작가

시리즈 자매글 + 측정값 출처


Share this post on:

Previous Post
32스레드 테스트는 통과했지만 명세가 틀렸다 — Lincheck가 동시성 계약을 세 번 기각한 과정
Next Post
@Transactional(readOnly = true) 한 줄이 응답시간을 132배 줄인 이야기 — JPA Dirty Checking 의 정체 해부