데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      Kotlin과 JPA의 한계 극복: 빌링 시스템 고도화 과정

      hakrae.kim 25.05.14
      4,041 5 2
      DEVOTEE 요약
      Kotlin과 Spring Boot를 기반으로 한 빌링 시스템을 고도화하며 JPA와 QueryDSL의 한계(불변 객체 설계 어려움, 복잡한 쿼리 표현력 부족, 유지보수 이슈)를 경험했고, 이를 해결하기 위해 기술 스택을 업그레이드하고 jOOQ를 도입했습니다. jOOQ 도입을 통해 복잡한 SQL 표현이 가능해지고 성능 최적화와 불변 객체 중심 설계로 안정성 및 유지보수성을 높였으며, 전반적인 개발 생산성을 향상했습니다. 이러한 변화는 기존 시스템의 기술적 부채를 해소하고 더 나은 개발 환경을 구축하기 위한 성공적인 선택으로 평가됩니다.
      DEVOTEE 추천 블로그

      들어가며...

      저희는 Kotlin + Spring Boot 기반으로 개발한 빌링 시스템을 수년간 운영하고 있습니다.

      시스템 개발 착수 당시 기준으로 비교적 최신 버전인 Kotlin, Spring Boot, Spring Data JPA, 그리고 QueryDSL 조합으로 서비스의 주요 기능을 개발해왔습니다.

      그러나 시간이 지나면서 기술적 부채가 쌓이고 시스템의 사용량이 늘어나면서 성능에 대한 문제도 발생하기 시작하여 이를 해결하기 위한 고도화를 진행하게 되었습니다.

      이 글에서는 그 배경과 과정, 그리고 저희가 선택한 기술적 판단들 중에 일부를 공유하고자 합니다.

      발생한 문제는...

      처음에 시스템 개발은 아래와 같은 기술 스택으로 진행했습니다.

      • Kotlin 1.6 + JDK 11

      • Spring Boot 2.6

      • Spring Data JPA + QueryDSL 5

      이러한 조합은 이미 Java 환경에서 널리 사용되는 검증된 방식이었고, Kotlin 환경에서도 큰 문제 없이 사용 가능할 것이라고 생각했습니다.

      하지만, 개발하고 시스템을 운영하는 과정에서 다음과 같은 문제들이 발생했습니다.

      Kotlin 의 data class 와 JPA의 궁합 문제

      Kotlin 의 data class 는 불변성과 copy 메서드, 구조 분해 등의 강력한 기능을 제공합니다.

      그러나 JPA 는 불변성을 고려하여 설계된 기술이 아니기 때문에 data class 를 엔티티 클래스로 사용할 수 없었습니다.

      따라서 아래와 같이 설계하게 되었는데요,

      프로퍼티가 다른 코드에 의해 변경될 수 있고 의도치 않은 데이터 변경이 일어날 수 있어서, 엔티티와 비슷한 모습의 불변 DTO 클래스를 추가로 작성하여 사용해야 했습니다.

      이런 구조는 초기 개발 당시 큰 문제로 느껴지지는 않았지만 시간이 흐르면서 DTO 가 중구난방으로 추가되는 문제가 발생하기 시작했습니다.

      @Entity
      @Table(name = "orders")
      class Order(
          @Id
          var orderId: String,
          var orderStatus: OrderStatus
      )
      
      data class OrderDto(
          val orderId: String,
          val orderStatus: OrderStatus
      ) {
          companion object {
              fun from(entity: OrderEntity): OrderDto {
                  return OrderDto(entity.id, entity.orderStatus)
              }
          }
      }
      
      @Service
      class OrderService(private val orderRepository: OrderRepository) {
          fun doSomething(): Order {
              // ...
              order.orderStatus = REFUNDED // order 내부 변수를 직접 변경
              // ...
              return order // 값이 변경된 order 인스턴스 반환으로 원하지 않는 데이터 변경 위험
          }
          
          fun getOrder(id: String): OrderDto {
              return OrderDto.from(orderRepository.findById(id))
          }
      }

      그래서 이렇게 사용하면 참 좋겠다는 생각을 많이 하게 되었습니다.

      @Table(name = "orders")
      data class OrderEntity(
          @Id
          @JvmField
          val orderId: String,
          val orderStatus: OrderStatus
      )
      
      @Service
      class OrderService(private val orderRepository: OrderRepository) {
          fun doSomething(): OrderEntity {
              // ...
              val modified = order.copy(orderStatus = REFUNDED) // order 와 modified 는 다른 인스턴스
              // ...
              return order // 값이 변경되지 않은 인스턴스 반환
          }
          
          fun getOrder(id: String): OrderEntity {
              return orderRepository.findById(id)
          }
      }

      QueryDSL 의 한계 및 유지보수 이슈

      데이터의 양이 많아지면서 쿼리 성능에 문제가 발생하기 시작했고, SQL 을 새로 작성하거나 튜닝해야 했습니다.

      하지만 QueryDSL 이 지원하는 기능에는 한계가 있어서 원하는대로 작업할 수 없거나 복잡하게 개발을 해야했습니다.

      SQL 하나를 간략하게 예를 들어보자면 아래와 같은 sub query 를 실행하기 위해서

      SELECT order_id,
             GROUP_CONCAT(discount_method, discount_type) AS discount_methods,
             JSON_ARRAYAGG(
                 JSON_OBJECT(
                     'discountTitle', discount_title,
                     // ...
                 )
             )
        FROM order_discount
       GROUP BY order_id

      이런 클래스를 작성해야 했습니다.

      @Entity
      @Subselect("""
      SELECT order_id,
             GROUP_CONCAT(discount_method, discount_type) AS discount_methods,
             JSON_ARRAYAGG(
                 JSON_OBJECT(
                     'discountTitle', discount_title,
                     // ...
                 )
             )
        FROM order_discount
       GROUP BY order_id
      """)
      @Synchronize("order_discount")
      class DiscountMethodsByOrderId {
          // ...
      }

      결국 Native Query 를 사용하게 되면서 QueryDSL 을 통해 얻을 수 있는 타입 안전성이나 DB 의존성 회피 등 JPA 의 장점을 일부 포기하는 타협을 하게 되었습니다.

      게다가 현재 QueryDSL은 더 이상 원작자에 의해 공식적으로 유지보수되지 않고 있으며,

      현재는 OpenFeign 에 의해 fork 되어 이어지고 있는 상태로 장기적인 측면에서 봤을 때 지속성에 의구심이 들었습니다.

      OpenFeign QeuryDSL: https://github.com/OpenFeign/querydsl

      문제 해결은 이렇게 하기로...

      기술 스택 업그레이드 및 변경

      일단 최신 언어의 기능과 성능 개선점을 도입하기 위해 전반적인 기술 스택 업그레이드 및 변경을 진행했습니다.

      • Kotlin 2.0 + Java 21

      • Spring Boot 3.4

      • Spring Data JDBC

      이를 통해 Spring 의 최신 기능들과 Kotlin 의 K2 컴파일러, Java 의 Virtual Thread 등을 사용할 수 있게 되었습니다.

      QueryDSL 대신 jOOQ 으로 동적 쿼리

      jOOQ 는 QueryDSL 과 동일하게 타입에 안전한 코드를 작성할 수 있고, Native SQL 과 100% 호환되어 복잡한 쿼리를 작성하는데 문제가 없어 보였습니다.

      그리고 Kotlin 과도 궁합이 좋아서 동적 쿼리 작성 도구로 선택되었습니다.

      // 메타모델 클래스
      val o = com.example.jooq.tables.references.ORDER.`as`("o")
      val od = com.example.jooq.tables.references.ORDER_DISCOUNT.`as`("od")
      
      val query = dslContext
          .select(
              // ...
              if_(
                  condition = od.ORDER_ID.isNull,
                  ifTrue = inline(null, String::class.java),
                  ifFalse = jsonArrayAgg(
                               jsonObject(
                                  jsonEntry("discountId", od.DISCOUNT_ID),
                                  // ...
                               )
                            )
              ).`as`("order_discounts_results"),
          )
          .from(o).join(od)
          // ...

      jOOQ 도 QueryDSL 과 마찬가지로 메타모델 클래스를 생성해야 한다는 불편함이 있지만, 타입 안전성을 위해서는 피할 수 없는 부분이고

      annotation processor 기반이었던 QueryDSL 과는 달리 jOOQ 는 SQL DSL 코드 생성 기반이라 좀 더 안정적이고 직관적인 개발이 가능했습니다.


      DDL 스크립트를 바탕으로 메타모델 클래스 생성을 위한 build.gradle.kts 예시

      plugins {
          id("nu.studer.jooq") version "9.0"
      }
      
      jooq {
          configurations {
              create("main") {
                  generateSchemaSourceOnCompilation.set(true)
                  jooqConfiguration.apply {
                      generator.apply {
                          name = "org.jooq.codegen.KotlinGenerator"
                          database.apply {
                              name = "org.jooq.meta.extensions.ddl.DDLDatabase"
                              properties.apply {
                                  add(
                                      Property().apply {
                                          key = "scripts"
                                          value = "src/main/resources/database/schema.sql"
                                      }
                                  )
                              }
                          }
                      }
                  }
              }
          }
      }

      솔루션 적용은 이런 순서로...

      빌링 시스템의 특성 상 영속성 프레임워크의 전면 교체는 위험 부담이 크기 때문에 다음과 같은 순서로 고도화하는 전략을 택했습니다:


      검색 및 조회 기능부터 jOOQ 로 전환

      • 복잡한 조회 쿼리를 중심으로 작성된 QueryDSL 기반 코드를 jOOQ 로 우선 전환하고

      • 성능 개선 여부와 유지보수 용이성을 일정 기간동안 검증했습니다.

      JPA 으로 구현한 CRUD 기능을 서서히 변경

      • 변경이 적은 엔티티부터 JPA에서 JDBC 기반 구현으로 서서히 교체하면서

      • 도메인 모델은 불변 객체(immutable object)로 새로 작성했습니다.

      쿼리 실행 결과 프로젝션 방식 변경

      • 데이터를 서비스 기능의 요구에 맞게 제공하기 위해 aggregation 클래스를 도입하여

      • jOOQ 나 JDBC 로 가져온 엔티티를 조합해 응답 객체를 구성하며, 불필요해진 DTO 클래스를 제거했습니다.

      그래서 좋아진 것은...

      복잡한 쿼리에 대한 표현력 향상

      • JPA, QueryDSL 의 한계를 벗어나 순수 SQL 과 같은 수준에서 자유로운 쿼리를 작성 가능해졌습니다.

      • 서브쿼리, 윈도우 함수, DBMS 특화 기능 등을 코드로 명확하게 구현 할 수 있었습니다.

      성능 최적화 용이

      • 코드 수정량은 많았지만... 필요 없는 Lazy 로딩이나 N+1 이슈 등, 원하지 않았던 join 발생 등을 줄일 수 있었습니다.

      • 원하는 대로 SQL 을 작성할 수 있다보니 튜닝으로 응답 시간 개선도 가능했습니다.

      불변 객체 중심의 설계 가능

      • Kotlin 의 장점을 살려 불변 모델로 작성함으로써 도메인의 안정성이 향상되었다고 생각되고

      • Aggregation 클래스를 통해 비즈니스 요구에 맞는 응답 구조를 명확히 분리했습니다.

      • 그리고 불필요한 클래스 파일을 제거하면서 다이어트 효과도 얻을 수 있었습니다.

      빌드 및 운영 환경에서의 안정성 향상

      • QueryDSL 사용으로 인한 annotation processor 이슈, 버전 충돌 이슈 등을 제거함으로써 전체적인 기술 스택 업그레이드가 가능했고 개발 생산성도 향상되었다고 생각합니다.

      마무리하며...

      JPA 는 오랜 시간 널리 사용되어온 강력한 기술이지만, Kotlin 을 본격적으로 도입하면서 그 한계를 깊이 체감하게 되었습니다.

      "잘 동작하는 시스템은 건드리지 말라"는 업계의 속설을 한 번쯤은 접해보셨을 듯 한데요

      저희는 더 나은 개발 환경과 미래의 저희를 위해 손을 대버렸고, 결과적으로는 잘한 선택이라고 생각합니다.

      사실 Kotlin Exposed 으로 도전해볼껄 하는 아쉬움이 조금은 남지만, 너무 급진적인 변화는 바람직하지 않은 것 같아

      이번 고도화 작업에서는 JPA + QueryDSL 에서 해방되는 것으로 만족하려고 합니다.

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      hakrae.kim 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기