[Project] Kotlin + Spring Boot + Querydsl을 이용한 주소 검색 API

하게 된 계기

  • 회사 서비스에서 외부 주소 API가 자주 삑난다고 주소 검색 내재화 API를 만든다는 말이 있었다.
  • 스프링 부트도 못하고 JPA도 잘 모르는 나로썬 좀 걱정이 되어 혼자 토이 프로젝트를 진행해 보았다.

Stack

Server : Spring Boot, Spring Data Jpa, Querydsl
Database : MySQL

과정

1. Querydsl 연동

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "2.6.6"
	id("io.spring.dependency-management") version "1.0.11.RELEASE"
	kotlin("jvm") version "1.6.10"
	kotlin("plugin.spring") version "1.6.10"
	kotlin("plugin.jpa") version "1.6.10"
	kotlin("kapt")  version "1.6.10" // kapt 등록

}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

// Q파일 생성 경로
sourceSets["main"].withConvention(org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet::class) {
	kotlin.srcDir("$buildDir/generated/source/kapt/main")
}

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("com.h2database:h2")
	testImplementation("org.springframework.boot:spring-boot-starter-test")

	runtimeOnly("mysql:mysql-connector-java")

	// querydsl
	api("com.querydsl:querydsl-jpa:")
	kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa") 
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "11"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

application.yaml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/[dbname]?serverTimezone=UTC&characterEncoding=UTF-8
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: [userid]
    password: [userpassword]

  jpa:
    hibernate:
#      ddl-auto: create
    properties:
      hibernate:
        dialect: com.example.practice_qdsl.Dialect.CustomDialect # 커스텀한 Dialect 경로
#        show_sql: true
        format_sql: true

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type:
          descriptor:
            sql: trace

querydslConfig

package com.example.practice_qdsl.Configuration
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import javax.persistence.EntityManager
import javax.persistence.PersistenceContext

@Configuration
class QuerydslConfig(
  @PersistenceContext
  private val entityManager: EntityManager
) {

  @Bean
  fun jpaQueryFactory(): JPAQueryFactory {
    return JPAQueryFactory(this.entityManager)
  }
}

2. JPA Dialect를 이용한 match-against 기능 등록

  • querydsl은 일종의 JPQL 빌더다. (결국엔 JPQL로 변환됨)
  • 순수 JPQL에는 match-against 기능을 지원하지 않는다. 이를 사용하기 위해 JPA의 Dialect를 알아야 한다.
  • Dialect?


  • JPA는 보통 직접 SQL을 작성하고 실행한다. 그런데 DBMS 종류마다 사용하는 SQL이 다르다. JPA는 해당 DBMS에 맞춰 SQL을 생성해야 하는데 DBMS의 정보를 모르면 문제가 발생할 수 있다.
  • 그래서 JPA에 어떤 DBMS를 사용하는 알려주는 방법이 Dialect를 설정하는 방법이 된다. JPA에 Dialect를 설정할 수 있는 추상화 Dialect Class를 제공하고 설정된 방언으로 각 DBMS에 맞는 구현체를 제공한다.
package com.example.practice_qdsl.Dialect

import org.hibernate.dialect.MySQL57Dialect
import org.hibernate.dialect.function.SQLFunctionTemplate
import org.hibernate.type.StandardBasicTypes


class CustomDialect: MySQL57Dialect() {

  init{
    registerFunction("match", SQLFunctionTemplate(StandardBasicTypes.INTEGER, "match(?1) against (?2 in boolean mode)"))
  }
}
  • MySQL57Dialect를 상속 받은 뒤, Fulltext Search를 사용하기 위해 생성자에서 regsiterFunction로 match - against 함수를 등록한다.

  • 검색 모드는 NATURAL LANGUAGE MODE와 BOOLEAN MODE 2가지가 있다. 자연어 검색 모드에서는 전체 테이블의 50% 이상의 레코드가 검색된 키워드를 가지고 있다면, 그 키워드는 검색어로서 의미가 없다고 판단하고 검색 결과에서 배제 시킨다. 주소 데이터를 배제 시키면 당연히 안되므로 각 키워드의 포함과 불포함을 판단하는 BOOLEAN MODE로 선택한다.

  • type이 String이 아니라 INTEGER로 받은 이유는 Expressions.stringTemplate
    은 Select 절에서만 사용이 가능. (이유는 잘 모르겠다.. 누군가 알면 댓글로 남겨주시면 감사하겠습니다 ㅎ)

3. Entity와 Repository

Entity

package com.example.practice_qdsl.Entity

import javax.persistence.*

@Entity(name = "ADDRESS_INFO")
data class AddressInfo(
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  var id: Long = 0,

  @Column(name = "address")
  val address: String,
)

AddressInfoRepositoryCustom

package com.example.practice_qdsl.Repository

import com.example.practice_qdsl.Entity.AddressInfo

interface AddressInfoRepositoryCustom {
  fun getAddressList(keyword : String) :List<AddressInfo>
  fun getLists():List<AddressInfo>
}

AddressInfoRepositoryCustomImpl

package com.example.practice_qdsl.Repository.Impl

import com.example.practice_qdsl.Entity.AddressInfo
import com.example.practice_qdsl.Entity.QAddressInfo
import com.example.practice_qdsl.Repository.AddressInfoRepositoryCustom
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.core.types.dsl.NumberTemplate
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.stereotype.Repository



@Repository
class AddressInfoRepositoryCustomImpl(
  val jpaQueryFactory: JPAQueryFactory,
) : AddressInfoRepositoryCustom {

  override fun getAddressList(keyword: String): List<AddressInfo> {
    val booleanTemplate: NumberTemplate<*> = Expressions.numberTemplate(Integer::class.java,
      "function('match',{0},{1})",
        QAddressInfo.addressInfo.address, keyword)


    return jpaQueryFactory.select(QAddressInfo.addressInfo).from(QAddressInfo.addressInfo).where(booleanTemplate.gt(0)).fetch()
  }

  override fun getLists(): List<AddressInfo> {
    return jpaQueryFactory.selectFrom(QAddressInfo.addressInfo).fetch()
  }
}
  • match against을 where 절에서 사용하려고 Expression.numberTamplate 자료형인 booleanTemplate를 만들었다.
  • return에 있는 쿼리문을 보면 greather than > 0 이라는 조건을 주어 booleanTemplate에 맞게 쿼리를 날리게 했다.

4. Perfomance 비교

  • 주소 데이터 개수 : 357294

1) %Like%

  • SELECT * FROM mydb.ADDRESS_INFO where address like("%서울특별시 서대문구 세무서길%") : 0.157 sec

2) match-against

  • SELECT FROM mydb.ADDRESS_INFO where match(address) against("서울특별시+서대문구+세무서길" in boolean mode) > 0 : 0.029 sec

약 5배 차이가 난다. 단 검색어가 "서울특별시" 같이 짦은 것들은 %Like%가 더 빨랐다.
즉, 검색어가 길어질 수록 Perfomance는 match-against >>>>>>>> %Like%이 된다.

참고 : https://idlecomputer.tistory.com/337

좋은 웹페이지 즐겨찾기