티스토리 뷰

 

2024.11.09 - [개발/스프링] - JPA에 관한 단상(1) - N+1 문제의 본질

 

JPA에 관한 단상(1) - N+1 문제의 본질

spring 에서 JPA으로 개발하다보면 누구나 N+1 을 만나게 되어 있다. 사실 Spring에만 있는 것은 아니고 대부분의 ORM에 다 있는 문제이다. 관련해서 키워드로 나오는게 1. JOIN FETCH2. Batch Size3. Eager Load

jongqui.tistory.com

 

이전 포스팅에선 N+1 문제의 본질에 대해 다루었다.

RDBMS와 OOP의 사물을 표현하는 철학이 다르다는 점에 대해서 설명하면서,실제로 문제가 되는 사례를 하나 들어서 설명헀다.

 

이번 포스팅에서는 백오피스에서 OneToMany 이슈를 어떻게 다뤄야 할지에 대한 내용을 담았다.

 

JPA에서는 일대다 관계 JOIN시 발생하는 레코드 개수가 꼬이는 이슈에 대응하기 위해 N+1이라는 꼼수를 사용했다. 

(SQL Alchemy 같은 것들도 마찬가지이다)

그리고, Hibernate에서는 쿼리를 N개만큼 날렸을 때 발생하는 네트워크 I/O 이슈를 극복하기 위해 in 절을 활용하는 코드를 짜냈다.

쿼리는 아래처럼 나오게 된다. 

 

SELECT 
    c.id AS customer_id,
    c.name AS customer_name,
    c.email AS customer_email,
    c.phone AS customer_phone,
    c.address AS customer_address
FROM 
    Customer c
LIMIT 10 OFFSET 0;  -- 예시: 첫 페이지의 10명의 고객을 가져옴

SELECT 
    o.id AS order_id,
    o.order_date,
    o.status,
    o.total_amount,
    o.customer_id
FROM 
    Order o
WHERE 
    o.customer_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

 

 

이 방식에서는 페이징 처리나 카운트 처리가 정상적으로 동작하게 된다

이 쿼리에서는 제약조건이 발생하는데, 바로 WHERE 절에 Many쪽 데이터를 넣을 수가 없다는 것이다.

어떻게 보면 당연한 것인데, Order 테이블에 where 절을 걸려면 결국 다시 JOIN을 해야하게 되고, 이전 포스팅에서 만난 문제를 맞딱드리게 된다.

 

결국 이 경우엔 아래처럼 전체 레코드 개수를 보여주고, 한 테이블에 보여줄 개수를 고정시키는 구현이 성립되지 않는다.

백오피스에서 주로 사용하는 테이블 예제

 

물론 안된다고 하고 넘어가면 정말 좋겠지만 경험상 기획자들은 계속 검색기능을 원할 것이다.

숙련된 기획자라면 검색이 어렵다는 내용을 들으면 그 수준에서 적절하게 타협하는 것이 일반적이지만,

그 기능이 꼭 필요하다고 고집을 피우는 경우도 더러 있다. 

이렇게 되면 적정선에서 타협안을 제시할 수 밖에 없다. 

 

어떤 대안이 있을까?

 

1. 신규 메뉴 추가

 

백오피스에선 Customer 조회라는 메뉴가 있었을 것이고,

현재 이슈가 되는 부분은 Customer의 목록을 조회할 때, Order 테이블에 검색조건을 넣고 검색하는 기능이 문제가 되고 있다. 

근본적으로 이 문제를 지원하려면, 백오피스의 메뉴에 "Order"에 대응하는 메뉴를 추가하는 수 밖에 없다. 

이건 JPA 코드를 보는 것이 이슈를 명확하게 보여줄 수 있다. 

Customer 기준으로 조회를 한다고 했을 때, 검색조건을 Order 쪽에 걸면 아래처럼 나온다. 

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface CustomerRepository : JpaRepository<Customer, Long> {
    // 이메일로 고객 검색
    fun findByEmail(email: String): Customer?

    // 특정 주문 상태를 가진 고객을 페이징으로 검색
    fun findByOrders_Status(status: String, pageable: Pageable): Page<Customer>
}

 

이 구조에서는 주문 상태에 조건을 걸어, Customer 을 검색하게 된다.

이렇게 되면 JOIN 후 레코드 개수가 늘어나기 때문에 무슨짓을 해도 데이터 개수는 꼬인다. 

 

아래처럼 검색하면 페이징이 꼬이지 않는다.

import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface OrderRepository : JpaRepository<Order, Long> {
    // 특정 상태를 가진 주문을 페이징하여 검색
    fun findByStatus(status: String, pageable: Pageable): Page<Order>
}

 

즉, 검색하고자 하는 대상을 바꿔치기 하는 트릭을 사용한다.

OOP로 표현해보면 페이징이 왜 정상화되는지 좀 더 명확해진다. 

 

아래처럼 Customer 기준으로 데이터를 조회하게 되면, Customer 가 둘, Order가 셋일 때, 리스트의 크기는 2개가 된다. 

따라서, RDBMS로 조인했을 때 나오는 개수인 3개와 맞지 않는다. 

fun main() {
    // Customer 객체 생성
    val customer1 = Customer(1, "Alice", "alice@example.com", "123-456-7890", "123 Maple Street")
    val customer2 = Customer(2, "Bob", "bob@example.com", "098-765-4321", "456 Oak Avenue")

    // Customer에 Order 추가
    customer1.addOrder(Order(1, LocalDate.of(2024, 11, 1), "Completed", BigDecimal("150.50")))
    customer1.addOrder(Order(2, LocalDate.of(2024, 11, 2), "Pending", BigDecimal("75.25")))

    customer2.addOrder(Order(3, LocalDate.of(2024, 11, 3), "Completed", BigDecimal("200.00")))

    // Customer를 중심으로 출력
    val customers = listOf(customer1, customer2)
    customers.forEach { println(it) }
}

 

아래와 같이 Order 중심으로 변경해보자. 방향이 명백하게 바뀌었고,  같은 상황에서 목록의 크기가 3개가 된다.

fun main() {
    // Customer 객체 생성
    val customer1 = Customer(1, "Alice", "alice@example.com", "123-456-7890", "123 Maple Street")
    val customer2 = Customer(2, "Bob", "bob@example.com", "098-765-4321", "456 Oak Avenue")

    // Order 객체 생성 (Order가 Customer를 참조)
    val order1 = Order(1, LocalDate.of(2024, 11, 1), "Completed", BigDecimal("150.50"), customer1)
    val order2 = Order(2, LocalDate.of(2024, 11, 2), "Pending", BigDecimal("75.25"), customer1)
    val order3 = Order(3, LocalDate.of(2024, 11, 3), "Completed", BigDecimal("200.00"), customer2)

    // Order를 중심으로 출력
    val orders = listOf(order1, order2, order3)
    orders.forEach { println(it) }
}

 

이제 OneToMany가 ManyToOne으로 관계가 변경되면서, 페이징 처리가 정상적으로 동작하게 된다.

 

결국 "주문" 메뉴와 "손님" 메뉴가 둘 다 있어야 하고, 

주문메뉴에선 Order 테이블의 칼럼 기준으로 검색을,

고객 메뉴에서는 Customer 테이블의 칼럼 기준으로 검색하도록 구현하게 된다.

이렇게 되면 기존 테이블 구조를 변경하지 않고도 BatchSize, FETCH JOIN 등을 활용해 적절하게 처리가 가능해진다. 

페이지를 이동하는게 조금 불편하긴 해도 검색과 페이징 처리 모두 정상 동작하니, 무난한 타협안이 될 수 있다. 

사실 백오피스 기획에 능숙한 기획자라면 이정도는 다 감안하고 기획을 한다.

 

2. UI 테이블 구조의 변경

UI 테이블 구조를 변경하는 방법도 있다. 제약조건이 많다는 문제가 있지만 아주 극히 드문 상황에서 타협안이 될 수도 있다. 

 

먼저 HTML의 테이블 UI에 대해 약간 설명을 하자면, 기본적으로 테이블의 데이터 처리는 백엔드에서 담당한다. 

 

백오피스에서 목록을 보여줄 때, 자주 사용하는 datatable.js 의 경우 기본적으로 전체 페이지 개수 한 페이지에 보여줄 크기와 페이지 번호, 검색조건을 요청에 넣어서 보내고, 리턴으로 , 전체 데이터 개수, 페이지 개수, 요청에서 넣은 페이지번호와 크기에 맞는 데이터를 리턴한다. 

이런 구현을 오프셋 기반 pagination이라고 부른다. 이런 테이블 구조에서는 코드가 정상화될 수 없다. 

 

따라서 커서 기반 pagination으로 교체한다.

커서기반 페이징처리는 이전 페이지의 마지막 id를 기억해놨다가, 다음 페이지를 렌더링할 때 같이 넣어서 보낸다. 

 

고객정보를 검색하는 테이블에서 주문 상태를 WHERE 절로 넣어서 검색한다고 가정해보면 쿼리가 아래처럼 나온다.

 

SELECT 
    c.id AS customer_id,
    c.name AS customer_name,
    c.email AS customer_email,
    o.id AS order_id,
    o.order_date,
    o.status,
    o.total_amount
FROM 
    Customer c
LEFT JOIN 
    Order o ON c.id = o.customer_id
WHERE 
    (o.status = 'Completed' OR o.status IS NULL)
    AND c.id > :last_customer_id  -- 마지막으로 조회한 Customer의 id를 커서로 사용
ORDER BY 
    c.id ASC
LIMIT 10;

 

장점으로는 커서기반 페이징 처리를 사용하기 때문에 성능이 그렇게 나쁘지만은 않다는 것이다.

 

그 장점을 커버쳐 줄 수 없는 무수한 단점이 있는게 문제다.

 

첫째로, UI에 전체 레코드 개수와 페이지크기를 더이상 보여줄 수 없고, 매 페이지마다 보여주는 데이터의 크기가 일정하지 않을 수도 있다는 것에 있다. 

둘째로,  이 문제를 완화하기 위해서 페이징을 포기하고 스크롤다운으로 데이터를 렌더링하도록 하면 이 문제는 좀 해소는 되는데,  문제는 백오피스 구현에서 스크롤다운 방식 구현을 선호하지 않는다는 것에 있다.

마지막으로, 약간의 커스터마이징을 하면 부분적으로 페이징 처리를 할 수 있게 구현 가능한데, 이경우에도 각 페이지 별로 데이터 개수가 일정하지 않다는 문제는 남아 있다. 

 

경험상 99% 이상은 대안으로 1번을 선호한다.

2번의 스크롤다운으로 UI를 구성하는게 더 적절한 상황이라면 그럭저럭 괜찮은 타협안이지만, 스크롤다운 자체가 백오피스에서 선호되는 형태의 UI가 아니기 때문에 사실 선택이 잘 안된다.

 

사실 이 두 대안을 제시했을 때도 싫다고하면 최후의 대안으로 하나 더 있긴 하다.

리스트 전체를 불러서, 백엔드 서버에서 페이징 처리를 한 후 리턴 하는 방법이다.

이 방법은 이직이 예정되어 있는 경우에 만 추천한다. 

 

요약

 

RDBMS의 일대다 관계인 테이블을 백오피스에서 조회하는 경우에 검색조건을 多쪽 테이블의 칼럼에 걸려면 UI 상에서의 적절한 테이블 구현이 불가능해진다.

多쪽에 해당하는 테이블에 검색조건을 넣게 되면 pagination을 사용할 수 없다.

따라서 pagination의 기준을 多쪽으로 바꾸거나,

pagination 을 포기하고 스크롤다운방식으로 변경해야 한다. 

 

 

 

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함