2026-06-23
KotlinSpring

Spring Data JPA에서 JDBC로 마이그레이션

영속성 모델 단순화 과정에서 연관관계와 변경 감지를 사용하지 않는 구조를 Spring Data JDBC로 옮기기

서비스의 초기 영속성 계층은 Spring Data JPA로 구현했습니다.
Spring Data의 쿼리 메서드와 트랜잭션 추상화 덕분에 빠르게 기능을 만들 수 있었습니다.

그러나 기능이 늘면서 실제 사용 방식과 Spring Data JPA가 제공하는 모델 사이에 간격이 생겼습니다.

  • 팀 컨벤션상 엔티티 연관관계를 사용하지 않고 논리적인 식별자로 애그리거트(Aggregate)를 참조했습니다.
  • 시간 범위 집계와 통계 조회가 많아 네이티브 쿼리 사용 비중이 높았습니다.
  • 엔티티 변경 감지보다 명시적인 save()를 선호했습니다.

JPA를 사용하면서도 영속성 컨텍스트, 지연 로딩, 연관관계 매핑 같은 핵심 기능은 거의 사용하지 않았습니다.
반면 flush 시점과 엔티티 상태 관리는 계속 이해해야 했습니다.

문제가 된 쓰기 지연

웹사이트 도메인에는 유니크 제약조건이 있습니다.
동시에 같은 웹사이트를 저장하면 예외를 잡아 기존 행을 사용하는 로직이 필요했습니다.

try {
    return websiteRepository.save(Website(domain = domain))
} catch (exception: DataIntegrityViolationException) {
    return websiteRepository.findByDomain(domain)
        ?: throw exception
}

코드만 보면 save()에서 제약조건 예외가 발생할 것처럼 보입니다.
하지만 JPA의 save()는 엔티티를 영속성 컨텍스트에 등록하고, 실제 INSERT는 flush 또는 트랜잭션 커밋 시점까지 지연될 수 있습니다.

DBPersistence ContextServiceDBPersistence ContextServicetry 블록 정상 종료repository.save(entity)entity 반환트랜잭션 커밋INSERTUNIQUE 예외

예외가 try-catch를 벗어난 트랜잭션 종료 시점에 발생하면 의도한 복구 로직이 실행되지 않습니다.
saveAndFlush()로 시점을 강제할 수 있지만, 즉시 SQL 실행이 필요한 코드마다 JPA의 flush 동작을 의식해야 했습니다.

이 문제 하나만으로 JPA를 제거한 것은 아닙니다.
오히려 이 사례는 애플리케이션이 원하는 모델이 “변경 내용을 모아 flush하는 단위 작업”보다 “repository 호출 시 SQL을 명시적으로 실행하는 단순한 애그리거트 저장”에 가깝다는 신호였습니다.

JDBC가 더 잘 맞았던 부분

Spring Data JDBC는 repository와 쿼리 메서드라는 익숙한 인터페이스를 유지하면서 영속성 컨텍스트를 사용하지 않습니다.

JdbcRepository.kt
interface JdbcRepository<T, ID> : ListCrudRepository<T, ID>
WebsiteRepository.kt
interface WebsiteRepository :
    JdbcRepository<Website, UUID>,
    CustomWebsiteRepository {
    fun findByDomain(domain: String): Website?
}

단순 조회는 기존처럼 파생 쿼리 메서드를 사용하고, 실행 계획을 직접 제어해야 하는 쿼리는 custom repository와 jOOQ로 구현했습니다.

CustomHistoryRepositoryImpl.kt
return dsl
    .select(
        HISTORY.WEBSITE_ID,
        WEBSITE.DOMAIN,
        WEBSITE.CATEGORY,
        visitedAt,
        closedAt
    )
    .from(HISTORY)
    .join(WEBSITE)
    .on(WEBSITE.ID.equal(HISTORY.WEBSITE_ID))
    .where(
        HISTORY.USER_ID.equal(userId)
            .and(HISTORY.VISITED_AT.lessThan(endedAt))
            .and(HISTORY.CLOSED_AT.greaterThan(startedAt))
    )
    .fetchInto()

이 구조에서는 역할이 명확합니다.

  • CRUD와 간단한 조건 조회: Spring Data JDBC repository
  • 복잡한 조인, 집계, 업서트, 락: jOOQ custom repository
  • 원자성 경계: Spring의 @Transactional

JPA에서도 같은 구조를 만들 수 있습니다.
마이그레이션의 근거는 JDBC가 더 우수한 프레임워크라서가 아니라, 현재 서비스가 JPA의 추가 기능을 사용하지 않으면서 그 상태 모델의 비용은 부담하고 있었기 때문입니다.

엔티티 모델 변경

JPA 엔티티에는 프록시 생성을 위한 기본 생성자, 변경 가능한 프로퍼티, @Entity와 같은 메타데이터가 필요했습니다.
JDBC로 옮기면서 엔티티를 일반 Kotlin 데이터 클래스에 가깝게 단순화했습니다.

History.kt
@Table("history")
data class History(
    @Id
    val id: UUID? = null,
    val userId: UUID,
    val websiteId: UUID,
    val pageId: UUID,
    val visitedAt: Instant,
    val closedAt: Instant,
    val isClosed: Boolean,
    val scrollDepth: Int?
)

연관 애그리거트는 객체 참조로 연결하지 않고 UUID만 보관합니다.
필요한 조합은 조회 쿼리에서 명시적으로 수행합니다.

  • History.websiteIdWebsite
  • History.pageIdPage

이 선택은 N+1을 자동으로 해결해 주지는 않습니다.
대신 어떤 조회에서 JOIN할지 코드와 SQL에 드러나며, 영속성 컨텍스트가 암묵적으로 추가 쿼리를 실행하지 않습니다.

마이그레이션 과정

한 번에 저장소 전체를 바꾸기보다 다음 순서로 진행했습니다.

  1. JPA 연관관계와 프록시에 의존하는 코드가 없는지 확인했습니다.
  2. 엔티티를 @Table, @Id 기반의 JDBC 매핑으로 변경했습니다.
  3. JpaRepositoryListCrudRepository 기반 공통 repository로 교체했습니다.
  4. 네이티브 쿼리를 custom repository의 jOOQ 구현으로 이동했습니다.
  5. UUID, Instant, enum 변환기를 JDBC와 jOOQ 양쪽에서 일관되게 설정했습니다.
  6. repository 통합 테스트로 실제 SQL 결과를 검증했습니다.

특히 저장 동작의 차이를 주의했습니다.
Spring Data JDBC는 애그리거트의 기존 상태를 영속성 컨텍스트에서 추적하지 않습니다.
자식 컬렉션이 있는 애그리거트를 save()하면 기존 자식을 삭제하고 다시 저장하는 방식이 될 수 있습니다.

현재 도메인은 연관 엔티티를 한 애그리거트 객체 안에 컬렉션으로 매핑하지 않고 각 애그리거트 루트를 별도 repository로 관리합니다.
따라서 이 제약이 현재 모델에는 큰 문제가 아니었지만, JDBC를 선택할 때 반드시 확인해야 할 부분이었습니다.

즉시 실행만을 기대하지 않기

JDBC로 바꾸면 repository 저장 SQL이 호출 흐름 안에서 실행되므로 제약조건 예외의 발생 지점이 명확해집니다.
하지만 이것이 동시성 문제를 자동으로 해결한다는 뜻은 아닙니다.

같은 키에 대한 조회 후 INSERT 경쟁은 JDBC에서도 발생합니다.
웹사이트 저장은 별도로 ON DUPLICATE KEY UPDATE를 사용했고, 아웃박스 선점은 FOR UPDATE SKIP LOCKED를 사용했습니다.

  • JDBC 마이그레이션: SQL 실행 시점과 상태 모델을 단순화
  • 업서트/락 설계: 동시성 정합성을 해결

두 문제를 구분하지 않으면 “JPA를 JDBC로 바꿔 동시성 문제가 해결됐다”는 과도한 결론이 됩니다.

회귀 테스트

마이그레이션의 목표는 프레임워크 벤치마크에서 승리하는 것이 아니었습니다.
기존 기능의 정합성과 처리 성능이 나빠지지 않는지가 우선이었습니다.

동일한 로컬 MySQL, JVM 옵션, 데이터셋에서 카테고리 분석에 사용되는 핵심 repository 호출을 각각 1,000회 실행했습니다.
워밍업 이후 P95를 비교한 결과는 다음과 같았습니다.

구현P95 실행 시간
Spring Data JPA10.0ms
Spring Data JDBC4.0ms

JDBC 구현이 이 테스트에서는 더 빨랐지만, 이를 모든 CRUD에서 2.5배 빠르다는 의미로 해석하지 않았습니다.
쿼리 형태, 드라이버, 매핑 대상, 캐시와 영속성 컨텍스트 사용 여부에 따라 결과는 달라집니다.

측정의 결론은 다음 정도로 제한했습니다.

  1. 선택한 repository 호출에서 성능 회귀가 없었습니다.
  2. 현재처럼 단순 엔티티와 Projection 중심인 경로에서는 JDBC 매핑 비용이 충분히 작았습니다.
  3. 마이그레이션의 주된 이점은 속도보다 영속성 동작을 코드와 가깝게 만든 것이었습니다.

포기한 기능과 기준

JDBC로 옮기며 JPA의 다음 기능을 포기했습니다.

  • 변경 감지와 쓰기 지연
  • 1차 캐시와 동일성 보장
  • 지연 로딩
  • JPQL을 통한 객체 모델 중심 조회
  • 연관관계 cascade

이 기능이 필요 없는 것이 아니라, 현재 서비스에서 사용하지 않기로 한 것입니다.
향후 복잡한 애그리거트 내부의 변경 추적이나 동일 트랜잭션 내 반복 조회 최적화가 중요해진다면 JPA가 다시 더 적합할 수 있습니다.

정리

이번 마이그레이션은 기술 스택 교체 자체가 목적이 아니었습니다.
실제 코드가 이미 연관관계 없는 애그리거트, 명시적 저장, SQL 중심 조회로 작성되어 있었고, 이에 맞는 영속성 도구로 모델을 정렬한 작업이었습니다.

Spring Data JDBC를 사용해 쿼리 메서드와 Spring 트랜잭션은 유지하면서 영속성 컨텍스트 의존성을 제거했습니다.
그 결과 SQL 실행과 예외 발생 시점이 명확해졌고, custom repository의 복잡한 쿼리와 기본 CRUD의 경계도 분명해졌습니다.

그리고 1,000회 회귀 테스트에서 P95가 10ms에서 4ms로 측정되어, 구조를 단순화하면서도 핵심 조회 성능이 나빠지지 않았음을 확인했습니다.