스프링

JPA에서 처리하는 1 : N 관계

석우진 2024. 9. 11. 18:27

이 글을 작성하기에 앞서 인프런의 

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의 | 김영한 - 인프런

김영한 | 스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길

www.inflearn.com

다음 강의를 수강하고 작성하는 것임을 알려드립니다.

 

 

 

관계형 데이터베이스의 핵심 중 하나는 테이블 간의 관계를 어떻게 정의할지에 있습니다. 이러한 관계를 명확히 설계하는 것은 데이터 모델링의 중요한 부분인데요.

스프링 JPA는 테이블 간의 관계를 객체 간의 관계로 자연스럽게 변환해주는 도구입니다.

 

이번 글에서는 1:N 관계를 중심으로 JPA에서 이를 다루는 4개의 방법을 구체적인 예시와 함께 살펴보겠습니다.

 

 

 

기본 세팅

Gradle 의존성 및 DB 연결 부분은 패스하도록 하겠습니다.

 

Entity

 

@Entity
@Table(name = "users")
class User (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    val username: String?=null,

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
    val orderList : MutableList<Order> = mutableListOf()
)

@Entity
@Table(name = "orders")
class Order (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var orderName : String,

    @ManyToOne
    @JoinColumn(name = "user_id")
    @JsonIgnore
    var user:User? = null,
){
    fun saveOrder(user: User){
        this.user = user
        user.orderList.add(this)
    }
}

 

Repository

interface UserRepository : JpaRepository<User, Long> 

interface OrderRepository : JpaRepository<Order, Long>

 

Controller

@RestController
@RequestMapping("user")
class OrderController(
    private val userRepository: UserRepository,
    private val orderRepository: OrderRepository
) {
    @GetMapping("/v1")
    fun userV1(): List<User?>? {
        val result = userRepository.findAll()
        println("user all found")
        return result
    }
}

 

InitDb

@Component
class initDb(
    private val orderRepository: OrderRepository,
    private val userRepository: UserRepository
) {

    @PostConstruct
    fun init(){
        this.init1()
    }

    @Transactional
    fun init1(){
        for(i in 1..100){
            val user = User(username = "user:${i}")
            val order = Order(
                orderName = "order:${i}"
            )
            val order2 = Order(
                orderName = "order2:${i}"
            )
            order.saveOrder(user)
            order2.saveOrder(user)
            userRepository.save(user)
            orderRepository.saveAll(mutableListOf(order,order2))
        }
    }
}

 

 

기본적인 스프링의 1:N (User : Order) 관계입니다. 

 

initDb에선 Dummy user 1개당 2개의 order를 만들어 총 100명의 유저와 200개의 오더를 Init 했습니다.

 

 

테스트 환경이니, DTO로 따로 변환하지 않고 엔티티를 넘겨주고 무한 참조를 피하기 위해 JsonIgnore를 달아줬습니다.

또한 json 데이터로 List를 바로 넘기는 것보단 { data : { [ L1, L2 ] } }와 같이 묶어주는 것이 좋습니다. (일단 스킵!)

 

 

 

 

V1 ) JPA 기본 Fetch 전략

 

1+N Problem

 

기본적으로 JPA는 Lazy한 Fetch 타입을 채택하기에, user를 findAll 하는 과정에서 orderList는 비워둔 채로 옵니다.

 

그 과정에서 만약 요청에서 order 정보를 필요로 하지 않은 요청이라면 아주 효율적으로 리소스를 사용한 셈이 되겠죠 (럭키비키)

 

하지만 만약 그 유저들의 모든 OrderList를 보고 싶다고 한다면 그 데이터를 찾아서 객체에 주입 해줘야겠죠?

이 V1 요청에서도 json 데이터에 orderList를 요구했기에 findAll이 끝나고 user All Found라는 로그가 찍힌 이후에 부랴부랴 OrderList를 가져오는 모습을 볼 수 있습니다.

 

그럼 이때 발생하는 게 흔히 말하는 JPA의 1+N 문제입니다.

1번의 User 검색과, N명에 대한 N번의 Order 탐색이라고 생각하면 편할 것 같습니다.

 

DB에 접속하는 I/O는 시간을 많이 잡아먹습니다. DB가 외부 서버에 있다면 이 시간이 더욱 늘어날 것입니다.

 

 

결론적으로 좋은 개발자가 되려면 이 DB로의 I/O 횟수를 줄여야 합니다.

( 꼭 DB I/O가 아니더라도, 속도적인 측면에서 I/O는 최소화 하는 게 맞다고 생각합니다 )

 

 

 

V2 ) JPA FetchType.Eager 전략

 

그럼 1:N에서 기본적인 FetchType.Lazy를 Eager로 변경하면 어떨까요?

 

@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
val orderList : MutableList<Order> = mutableListOf()

 

이런 식으로 OneToMany Column에 FetchType만 정해주면 됩니다.

 

안타깝지만 이 방법은 유효한 해결책이 되지 못합니다. 

그냥 성실한 바보일 뿐이죠. 

처음 가져올 때부터 Entity 객체의 모든 정보를 꾹꾹 담아옵니다. 1+N문제를 미리 수행한 것과 다름없는 결과입니다.

 

이 방법은 오히려 기피해야 하는데 User 정보만 필요한 요청이어도 불필요한 데이터를 요청합니다. 

더해서 더 복잡한 객체 관계에선 이 Eager가 여러 부분에서의 성능 하락의 원인으로 자리잡을 수 있습니다.

 

 

 

V3 ) JPA Join Fetch 전략

interface UserRepository : JpaRepository<User, Long> {
    @Query("select m from User m join fetch m.orderList")
    fun findAllUsersWithOrderList(): MutableList<User>
}

	
    // Controller

    @GetMapping("/v2")
    fun user(): MutableList<User> {
        val result = userRepository.findAllUsersWithOrderList()
        println("user all found")
        return result
    }

 

이 1:N 에서 발생하는 문제를 해결하기 위해 JPA에선 fetch join 이라는 전략을 제공합니다. 

SQL의 Join 문을 활용, 처음부터 유저의 정보와 OrderList 모두 가져오는 전략입니다.

 

 

DB 접속 1회

 

 

실제로 단 한번의 DB 요청으로 유저의 모든 정보를 받아왔습니다. 

 

실제로 8~90% 이상의 상황에서 효과를 발휘하는 Fetch Join 전략이라고 합니다. 

 

 

 

앞선 1+N 요청과의 시간을 한번 비교해 보겠습니다.

 

 

유저가 100명일 때

평균 30ms

 

평균 7ms

 

 

 

유저가 1000명일 때

 

평균 300 후반 ms

 

평균 30ms 내외

 

 

보시다싶이 모수 N이 커질수록 두 요청의 응답 시간 차이가 지수적으로 벌어지는 것을 볼 수 있습니다. 

 

대부분의 실제 서비스에서 lazy한 요청 처리는 유저를 만족시킬 수 없을 것같네요.

 

 

 

하지만 완벽해 보이는 Fetch join도 몇 가지 단점이 있습니다.

 

먼저 Join 과정에서의 데이터 뻥튀기와 그로 인한 페이징 불가능입니다. 

 

기본적으로 User 테이블 데이터만 가져오는 기존 요청에서는 원하는 방식으로 페이징을 진행할 수 있었으나, fetch Join을 사용하면 SQL에서 페이징 하는 것이 어렵습니다.

 

또 User에 Order 뿐 아니라, 만약 유저와 1:M의 형태로 FavoriteStores라는 컬럼이 추가되고 Fetch Type을 작성한다면

데이터가 N*M개 만큼 뻥튀기 되는 문제가 생겨 전송 데이터량이 너무 커지거나 DB에 부하를 줄 수 있습니다. 

 

 

 

 

V4 ) JPA Batch Size 전략

spring:
    properties:
      hibernate:
        default_batch_fetch_size: 50


마지막 방법입니다. 

join을 사용하지 않고 Lazy를 사용하되, DB접속을 줄일 수 있는 Batch Size 전략입니다.

 

Lazy 전략에서 Order를 가져올 때, 유저마다 DB에 요청하지 않고, 한번에 Size 만큼 묶어서 DB에서 가져오는 전략입니다. 

 

평균 8ms

 

 

Lazy하게 Order를 가져오는 과정에서 기존처럼 한 명씩 가져오지 않고,

binding parameter를 보면 1~50, 51~100 정직하게 SQL 요청이 총 두 번으로 줄은 것을 볼 수 있습니다.

DB 접속횟수는 1 + N//BatchSize 라고 보면 될 것 같습니다.

 

이를 통해 V3 fetch join의 단점이었던 Paging이나 데이터 뻥튀기의 문제를 약간의 비용을 지불하고 해결한 것을 볼 수 있습니다. 

 

Batch Size의 설정은 Data의 크기에 맞춰 유동적으로 조정하며, 보통은 100~1000 사이의 값을 설정하는 것을 추천한다고 합니다.  ( 적절한 Size에 대한 레퍼런스는 인터넷을 찾아보면 될 것 같습니다 )

 

 

 

 

정리

제가 생각하는 결론은 운영하는 서비스의 크기에 맞는 적절한 Batch Size를 설정하고

 

속도가 매우 중요하거나, 페이징이 필요하지 않거나, 연관된 엔티티가 많지 않은 경우와 같은 특수한 경우에서 Fetch Join을 채택하는 것이 좋을 것으로 보입니다. 

 

 

또한 다음 전략들은 1:N 관계뿐 아닌 N:M 관계에서도 비슷하게 적용이 가능할 것으로 보입니다. 가능하다면 N:M 관계 최적화도 남겨보도록 하겠습니다.