Springboot 레거시 마이그레이션: JDBC에서 JPA로 전환하며 겪은 N+1 문제와 성능 최적화

최근 5년 이상 운영된 레거시 시스템의 리팩토링 프로젝트를 맡게 되었습니다. 해당 시스템은 초당 트랜잭션(TPS)이 3,000을 상회하는 이커머스 코어 모듈이었는데, 가장 큰 골칫거리는 수천 라인에 달하는 JDBC(Java Database Connectivity) 기반의 데이터 접근 계층이었습니다. 단순한 CRUD 하나를 추가하려 해도 `Connection` 연결부터 `PreparedStatement` 파라미터 바인딩, `ResultSet` 매핑까지 불필요한 상용구 코드(Boilerplate Code)가 비즈니스 로직을 압도하고 있었습니다. 우리는 생산성 향상을 위해 SpringbootJPA 기반으로 마이그레이션을 결정했으나, 단순히 기술 스택을 교체하는 것만으로는 해결되지 않는 심각한 성능 병목을 마주하게 되었습니다. 이 글은 그 과정에서 겪은 시행착오와 구체적인 해결책에 대한 기록입니다.

객체-관계 불일치와 JDBC의 한계 분석

마이그레이션의 근본적인 원인은 '유지보수 비용'이었습니다. 자바는 객체 지향(Object-Oriented) 언어이지만, 관계형 데이터베이스(RDBMS)는 테이블과 외래 키 기반의 관계형 세계관을 가집니다. 이 두 패러다임 사이의 간극, 즉 객체-관계 임피던스 불일치(Object-Relational Impedance Mismatch)를 개발자가 직접 메우는 것은 매우 고통스러운 작업입니다.

기존 JDBC 코드는 데이터를 객체로 변환하기 위해 다음과 같은 과정을 반복해야 했습니다.

  • SQL 문자열을 자바 코드 내에 하드코딩 (스키마 변경 시 런타임 에러 유발)
  • 상속 관계나 다형성을 DB 스키마로 표현하기 위한 복잡한 분기 처리
  • 객체 그래프 탐색(A.getB().getC()) 불가능
Note: JPA는 특정 프레임워크가 아니라 Jakarta Persistence 기술 명세(Specification)입니다. 우리는 이 명세의 가장 대중적인 구현체인 Hibernate를 사용했습니다.

우리는 Springboot 3.2 환경에서 JPA를 도입하여 이 문제를 해결하고자 했습니다. JPA는 ORM(Object-Relational Mapping) 기술을 통해 개발자가 SQL이 아닌 자바 객체에 집중할 수 있게 해줍니다. 하지만, JDBC에서 JPA로의 전환은 단순한 라이브러리 교체가 아니었습니다. SQL을 직접 제어하던 권한을 프레임워크에 위임하면서 발생하는 '보이지 않는 쿼리'들이 시스템을 위협하기 시작했습니다.

실패 사례: 즉시 로딩(EAGER)의 함정

초기 구현 단계에서 개발팀은 "연관된 데이터는 무조건 같이 쓰인다"는 가정하에 `@ManyToOne`과 `@OneToMany` 관계를 `FetchType.EAGER`(즉시 로딩)로 설정하는 실수를 범했습니다. 로컬 환경의 소량 데이터에서는 문제가 없었으나, 스테이징 환경에서 주문 목록 조회 API를 호출하자마자 CPU 사용률이 치솟았습니다.

Error Log: Hibernate: select ... from order0_ where ...
Hibernate: select ... from user ... (N번 실행)
Hibernate: select ... from order_item ... (N번 실행)

이것이 바로 악명 높은 N+1 문제입니다. 1개의 쿼리로 100개의 주문을 가져왔는데(1), 각 주문에 연관된 회원 정보와 상품 정보를 가져오기 위해 100번의 추가 쿼리가 발생(N)하는 현상입니다. JDBC를 쓸 때는 개발자가 JOIN 쿼리를 직접 작성하여 한 번에 가져왔지만, JPA를 잘못 설정하면 의도치 않은 쿼리 폭탄을 맞게 됩니다.

해결책: 지연 로딩과 Fetch Join 전략

이 문제를 해결하기 위해 두 가지 핵심 원칙을 적용했습니다. 첫째, 모든 연관관계는 지연 로딩(LAZY)으로 설정한다. 둘째, 필요한 경우에만 Fetch Join을 사용하여 한 방 쿼리로 가져온다.

다음은 최적화된 엔티티 설정과 Repository 코드입니다.

// 1. 엔티티 설정: 무조건 LAZY로 설정하여 불필요한 쿼리 방지
@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // EAGER는 예측 불가능한 SQL을 발생시키므로 LAZY가 필수
    @ManyToOne(fetch = FetchType.LAZY) 
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    // ... getter, setter
}

// 2. Repository: JPQL의 join fetch를 사용하여 N+1 해결
public interface OrderRepository extends JpaRepository<Order, Long> {

    // 일반 join과 달리 연관된 엔티티를 함께 SELECT 절에 포함
    @Query("select o from Order o join fetch o.member join fetch o.orderItems where o.status = :status")
    List<Order> findAllByStatus(@Param("status") OrderStatus status);
}

위 코드에서 가장 중요한 부분은 `@ManyToOne(fetch = FetchType.LAZY)` 설정입니다. 이는 JPA에게 "실제 객체 대신 프록시(가짜 객체)를 넣어두고, 실제 데이터가 접근될 때 쿼리를 날려라"라고 지시합니다. 하지만 이것만으로는 N+1 문제가 해결되지 않기 때문에, `join fetch` 구문을 사용하여 JPQL 실행 시점에 연관된 데이터를 강제로 한 번에 로딩(Eager Loading)하도록 명시했습니다. 이는 JDBC의 JOIN 쿼리와 유사한 성능을 내면서도 객체 그래프의 정합성을 유지해줍니다.

추가적으로, 만약 Spring Security와 연동된 사용자 정보 조회 등 복잡한 조건이 필요하다면 QueryDSL 도입을 고려하는 것이 좋습니다.

성능 검증 및 결과

최적화 적용 전후의 성능을 JMeter를 사용하여 비교 분석했습니다. 테스트 환경은 AWS t3.medium 인스턴스이며, 데이터베이스에는 약 100만 건의 더미 데이터가 적재되어 있습니다.

지표 (Metric) JDBC (Legacy) JPA (Naive - EAGER) JPA (Optimized - LAZY + Fetch Join)
평균 응답 시간 120ms 2,400ms 135ms
발생 쿼리 수 (1회 호출) 1회 (Complex Join) 101회 (N+1) 1회 (Fetch Join)
코드 라인 수 150 lines 15 lines 15 lines

결과적으로 최적화된 JPA 코드는 기존 JDBC와 거의 대등한 응답 속도를 보여주었습니다. JDBC가 미세하게 더 빠른 이유는 ORM이 객체를 매핑하는 내부 오버헤드(Dirty Checking, 1차 캐시 관리 등) 때문입니다. 하지만 코드 라인 수가 1/10로 줄어들었고, 비즈니스 로직의 가독성이 비약적으로 상승한 점을 고려하면 이 정도의 오버헤드는 충분히 감수할 만한 트레이드오프입니다.

Spring Data JPA 공식 문서 확인하기

주의사항: JPA를 피해야 할 때

모든 상황에서 JPA가 정답은 아닙니다. 특히 대량의 데이터를 처리하는 배치(Batch) 작업에서는 JPA의 영속성 컨텍스트(Persistence Context) 전략이 오히려 독이 될 수 있습니다.

Performance Warning: JPA의 `saveAll()` 메서드는 내부적으로 엔티티 하나하나에 대해 영속성 상태를 관리합니다. 수만 건의 데이터를 `INSERT` 해야 한다면, JPA 대신 JDBC Template의 `batchUpdate` 기능을 사용하십시오. JPA로 10분이 걸리던 작업이 JDBC Batch로는 10초 내에 끝날 수 있습니다.

또한, 컬렉션이 둘 이상인 경우 `Fetch Join`을 사용하면 MultipleBagFetchException이 발생하거나 데이터가 뻥튀기(Cartesian Product) 될 수 있습니다. 이 경우 Hibernate 설정에서 `default_batch_fetch_size`를 지정하여 `IN` 쿼리로 최적화하는 방식을 권장합니다.

결론

JDBC에서 Springboot JPA로의 전환은 단순히 코드를 줄이는 것을 넘어, 데이터 중심의 사고에서 객체 중심의 사고로 전환하는 과정입니다. 초기에는 N+1 문제나 프록시 객체 이해 부족으로 성능 저하를 겪을 수 있지만, `FetchType.LAZY`와 `Fetch Join` 같은 전략을 적절히 구사하면 유지보수성과 성능이라는 두 마리 토끼를 모두 잡을 수 있습니다. JPA는 표준 명세이므로, 이를 깊이 이해하면 Hibernate뿐만 아니라 다른 ORM 기술을 습득하는 데에도 큰 도움이 됩니다.

Post a Comment