Kotlin JDSL 마이그레이션과 Fetch Join을 통한 N+1 문제 해결
Kotlin JDSL 마이그레이션과 Fetch Join을 통한 N+1 문제 해결
들어가며
Native Query를 Kotlin JDSL로 마이그레이션하는 과정에서 일반 join과 fetch join의 차이를 발견하고, 이를 해결했습니다. 이번 글에서는 확장 함수 패턴을 활용한 Kotlin JDSL 마이그레이션 과정과, 일반 join을 fetch join으로 변경하여 N+1 쿼리 문제를 해결한 경험을 공유합니다.
작업 배경
1. Native Query의 문제점
기존 코드에서는 @Query 어노테이션과 Native Query를 사용하고 있었습니다:
@Query( value = """ SELECT DISTINCT s FROM StudentEntity s LEFT JOIN FETCH s.courses WHERE s.organizationId = :organizationId """, nativeQuery = false ) fun findAllWithCourses(...): List<StudentEntity>
문제점:
- 타입 안전성 부족: 컴파일 타임에 쿼리 오류를 발견하기 어려움
- 리팩토링 어려움: Entity 필드명 변경 시 쿼리도 수동으로 수정 필요
- 가독성 저하: 복잡한 쿼리는 문자열로 작성되어 이해하기 어려움
2. Kotlin JDSL이란?
**Kotlin JDSL (Kotlin Jakarta Persistence Query Language DSL)**은 Kotlin에서 타입 안전한 JPQL 쿼리를 작성할 수 있게 해주는 라이브러리입니다. LINE에서 개발한 오픈소스 프로젝트로, JPA의 @Query 어노테이션과 문자열 기반 쿼리 작성의 한계를 해결합니다.
Kotlin JDSL의 특징
1. 타입 안전성
// ❌ 기존 방식: 컴파일 타임에 오류 발견 불가 @Query("SELECT s FROM StudentEntity s WHERE s.organizationId = :orgId") fun findByOrganizationId(orgId: String): List<StudentEntity> // 만약 organizationId 필드명이 변경되면 런타임 에러 발생 // ✅ Kotlin JDSL: 컴파일 타임에 오류 발견 fun findByOrganizationId(orgId: String): List<StudentEntity> { return findAll { val s = entity(StudentEntity::class, "s") select(s) .from(s) .where(s(StudentEntity::organizationId).eq(value(orgId))) } } // 필드명이 변경되면 컴파일 에러로 즉시 발견
2. 리팩토링 지원
- Entity 필드명 변경 시 IDE의 리팩토링 기능이 쿼리까지 자동으로 업데이트
- 문자열 기반 쿼리는 수동으로 찾아서 수정해야 함
3. 가독성 향상
// 복잡한 조건도 DSL로 명확하게 표현 val whereClause = and( s(StudentEntity::organizationId).eq(value(organizationId)), or( s(StudentEntity::status).eq(value(StudentStatus.ACTIVE)), s(StudentEntity::status).isNull(), ), s(StudentEntity::createdAt).greaterThanOrEqualTo(value(startDate)), )
4. 컴파일 타임 검증
- 존재하지 않는 필드 참조 시 컴파일 에러
- 타입 불일치 시 컴파일 에러
- 잘못된 쿼리 구조 시 컴파일 에러
Kotlin JDSL 도입
프로젝트에 다음 의존성을 추가했습니다:
// build.gradle.kts implementation("com.linecorp.kotlin-jdsl:jpql-dsl:3.7.2") implementation("com.linecorp.kotlin-jdsl:jpql-render:3.7.2") implementation("com.linecorp.kotlin-jdsl:spring-data-jpa-support:3.7.2")
의존성 설명:
jpql-dsl: JPQL 쿼리를 작성하기 위한 DSL 제공jpql-render: DSL을 JPQL 문자열로 변환spring-data-jpa-support: Spring Data JPA와 통합 지원
Kotlin JDSL vs 기존 방식 비교
| 항목 | @Query (JPQL) | Kotlin JDSL |
|---|---|---|
| 타입 안전성 | ❌ 런타임 검증 | ✅ 컴파일 타임 검증 |
| 리팩토링 | ❌ 수동 수정 필요 | ✅ 자동 업데이트 |
| 가독성 | ⚠️ 문자열 기반 | ✅ DSL 기반 |
| IDE 지원 | ⚠️ 제한적 | ✅ 완전한 지원 |
| 학습 곡선 | 낮음 | 중간 |
마이그레이션 과정
1단계: 확장 함수 패턴 도입
모든 Repository에 확장 함수를 추가하여 Kotlin JDSL 쿼리를 작성했습니다. 확장 함수 패턴을 사용하면 Repository 인터페이스를 수정하지 않고도 복잡한 쿼리를 추가할 수 있습니다.
확장 함수 패턴의 구조
// 1. Repository 인터페이스 정의 interface SpringDataStudentRepository : JpaRepository<StudentEntity, String>, KotlinJdslJpqlExecutor { // 단순한 JPA 메서드만 정의 fun findByIdAndOrganizationId(id: String, organizationId: String): StudentEntity? } // 2. 별도 파일에 확장 함수 정의 // SpringDataStudentRepositoryExtensions.kt fun SpringDataStudentRepository.findAllWithFilters( organizationId: String, teacherId: String?, grade: Grade?, status: StudentStatus?, graduationDateFrom: LocalDate?, graduationDateTo: LocalDate?, search: String?, pageable: Pageable, ): Page<StudentEntity> { // KotlinJdslJpqlExecutor의 findPage 메서드 사용 val result = findPage(pageable) { val s = entity(StudentEntity::class, "s") // 조건 구성 val baseConditions = listOf( s(StudentEntity::organizationId).eq(value(organizationId)), ) val teacherIdCondition = teacherId?.let { s(StudentEntity::teacherId).eq(value(it)) } val gradeCondition = grade?.let { s(StudentEntity::grade).eq(value(it)) } // ... 기타 조건들 val allConditions = baseConditions + listOfNotNull( teacherIdCondition, gradeCondition, // ... ) val whereClause = if (allConditions.size == 1) { allConditions[0] } else { and(*allConditions.toTypedArray()) } select(s) .from(s) .where(whereClause) .orderBy(s(StudentEntity::createdAt).desc()) } return PageImpl( result.content.filterNotNull(), result.pageable, result.totalElements, ) }
확장 함수 패턴의 장점
1. Repository 인터페이스 분리
- 단순한 JPA 메서드는 인터페이스에 정의
- 복잡한 쿼리는 확장 함수로 분리
- 인터페이스가 깔끔하게 유지됨
2. 타입 안전성
// 컴파일 타임에 필드명 검증 s(StudentEntity::organizationId).eq(value(organizationId)) // 만약 organizationId 필드명이 변경되면 컴파일 에러 발생
3. 모듈화와 재사용성
// 조건 재사용을 위한 헬퍼 함수 private fun buildConditions( s: EntitySpec<StudentEntity, String>, organizationId: String, // ... 기타 파라미터 ): List<Predicate> { val baseConditions = listOf( s(StudentEntity::organizationId).eq(value(organizationId)), ) // ... 조건 구성 return allConditions }
4. 테스트 용이성
- 확장 함수는 독립적으로 테스트 가능
- Mock Repository를 사용하여 단위 테스트 작성 용이
KotlinJdslJpqlExecutor의 역할
KotlinJdslJpqlExecutor는 Kotlin JDSL 쿼리를 실행하기 위한 인터페이스입니다:
interface KotlinJdslJpqlExecutor { fun <T> findAll(statement: StatementProvider<SelectQuery<T>>): List<T> fun <T> findPage( pageable: Pageable, statement: StatementProvider<SelectQuery<T>> ): Page<T> // ... 기타 메서드들 }
확장 함수 내에서 findPage()나 findAll()을 호출하면, Kotlin JDSL이 JPQL로 변환하여 실행합니다.
2단계: 일반 Join의 문제 발견
마이그레이션 과정에서 일반 join을 사용한 코드를 발견했습니다:
문제가 있던 코드:
fun SpringDataStudentRepository.findAllWithCourses( organizationId: String, // ... 기타 파라미터 ): List<StudentEntity> { return findAll { val s = entity(StudentEntity::class, "s") val c = entity(CourseEntity::class, "c") select(s) .from( s, join(CourseEntity::class) .on(s(StudentEntity::id).eq(c(CourseEntity::studentId))), ) .where(whereClause) .orderBy(s(StudentEntity::createdAt).desc()) }.filterNotNull().distinctBy { it.id } }
문제점:
- 일반
join()은 WHERE 조건을 위한 조인만 수행 courses컬렉션이 실제로 로딩되지 않음- 이후
student.courses에 접근할 때 N+1 쿼리 발생
실행되는 SQL:
SELECT s.* FROM students s LEFT JOIN courses c ON s.id = c.student_id WHERE s.organization_id = 'org123'; -- courses 컬럼이 SELECT에 포함되지 않음!
3단계: Fetch Join으로 수정
Kotlin JDSL에서는 fetchJoin()을 사용하여 연관관계를 함께 로딩할 수 있습니다:
fun SpringDataStudentRepository.findAllWithCourses( // ... 파라미터 ): List<StudentEntity> { return findAll { val s = entity(StudentEntity::class, "s") select(s) .from( s, fetchJoin(StudentEntity::courses), // ✅ Fetch Join 사용 ) .where(whereClause) .orderBy(s(StudentEntity::createdAt).desc()) }.filterNotNull().distinctBy { it.id } }
개선점:
fetchJoin(StudentEntity::courses)를 사용하여 한 번의 쿼리로 모든 데이터 조회- N+1 쿼리 문제 해결
- 기존 JPQL의
LEFT JOIN FETCH와 동일한 동작
실행되는 SQL:
SELECT s.*, c.* FROM students s LEFT JOIN courses c ON s.id = c.student_id WHERE s.organization_id = 'org123'; -- courses 컬럼이 SELECT에 포함됨!
일반 Join vs Fetch Join
일반 Join의 동작
from( s, join(CourseEntity::class) .on(s(StudentEntity::id).eq(c(CourseEntity::studentId))), )
실행되는 SQL:
SELECT s.* FROM students s LEFT JOIN courses c ON s.id = c.student_id WHERE s.organization_id = 'org123';
결과:
StudentEntity만 반환courses컬렉션은 로딩되지 않음- 이후
student.courses접근 시 별도 쿼리 실행 (N+1 문제)
Fetch Join의 동작
from( s, fetchJoin(StudentEntity::courses), )
실행되는 SQL:
SELECT s.*, c.* FROM students s LEFT JOIN courses c ON s.id = c.student_id WHERE s.organization_id = 'org123';
결과:
StudentEntity와 연관된CourseEntity컬렉션을 함께 로딩- 한 번의 쿼리로 모든 데이터 조회
- N+1 쿼리 문제 해결
추가 최적화: 데이터베이스 레벨 집계
보수정산 통계 쿼리 개선
정산 통계를 조회하는 쿼리도 개선했습니다:
Before: 애플리케이션 레벨에서 집계
// 여러 번의 쿼리 실행 val total = repository.countByOrganizationId(organizationId) val pending = repository.countByStatusAndOrganizationId( SettlementStatus.PENDING, organizationId ) val completed = repository.countByStatusAndOrganizationId( SettlementStatus.COMPLETED, organizationId )
After: 데이터베이스 레벨에서 직접 집계
fun SpringDataTeacherSettlementRepository.getStatistics( organizationId: String, periodStart: LocalDate?, periodEnd: LocalDate?, ): SettlementStatisticsDto { // 각 집계를 별도 쿼리로 실행 (타입 안전성 확보) val total = findAll { val s = entity(TeacherSettlementEntity::class, "s") val conditions = buildConditions(s, organizationId, periodStart, periodEnd) select(count(s)) .from(s) .where(conditions) }.firstOrNull() ?: 0L val pending = findAll { val s = entity(TeacherSettlementEntity::class, "s") val conditions = buildConditions( s, organizationId, periodStart, periodEnd ) + listOf( s(TeacherSettlementEntity::status).eq(value(SettlementStatus.PENDING)) ) select(count(s)) .from(s) .where(and(*conditions.toTypedArray())) }.firstOrNull() ?: 0L // ... 기타 집계 return SettlementStatisticsDto( total = total, pending = pending, // ... ) }
장점:
- 데이터베이스 레벨에서 집계하여 성능 향상
- 타입 안전한 쿼리 작성
- 조건 재사용을 위한 헬퍼 함수 활용
마이그레이션 결과
변경 통계
- 총 19개 파일 변경
- 419줄 추가, 301줄 삭제
- 8개 Repository 마이그레이션 완료
주요 개선 사항
-
타입 안전성 확보
- 컴파일 타임에 쿼리 오류 발견
- Entity 필드명 변경 시 자동으로 쿼리 업데이트
-
N+1 쿼리 문제 해결
fetchJoin()을 사용하여 연관관계 즉시 로딩- 쿼리 횟수 감소로 성능 향상
-
코드 가독성 향상
- DSL을 통한 선언적 쿼리 작성
- 확장 함수로 모듈화
-
유지보수성 개선
- 복잡한 쿼리를 더 쉽게 관리
- 조건 재사용을 위한 헬퍼 함수 활용
인덱스 활용과 성능 최적화
인덱스 사용 여부
Kotlin JDSL로 작성한 쿼리는 최종적으로 JPQL로 변환되어 실행되므로, 데이터베이스의 인덱스를 정상적으로 활용합니다.
실제 생성되는 SQL:
-- findAllWithFilters 쿼리 SELECT s.* FROM students s WHERE s.organization_id = ? AND s.teacher_id = ? AND s.grade = ? ORDER BY s.created_at DESC LIMIT ? OFFSET ?; -- 인덱스 활용: -- - organization_id: 외래키 인덱스 (자동 생성) -- - teacher_id: 외래키 인덱스 (자동 생성) -- - created_at: 정렬을 위한 인덱스 (필요시 추가)
인덱스 최적화 팁:
- WHERE 조건에 사용되는 컬럼에 인덱스 추가
-- organization_id는 멀티 테넌트 환경에서 필수 CREATE INDEX idx_students_organization_id ON students(organization_id); -- 복합 인덱스로 성능 향상 CREATE INDEX idx_students_org_teacher ON students(organization_id, teacher_id);
- ORDER BY에 사용되는 컬럼에 인덱스 추가
-- 정렬 성능 향상 CREATE INDEX idx_students_created_at ON students(created_at DESC);
- JOIN 조건에 사용되는 컬럼은 자동으로 인덱스 생성
-- 외래키는 자동으로 인덱스 생성됨 -- courses.student_id는 자동으로 인덱스가 있음
확장 함수에서의 인덱스 활용
확장 함수로 작성한 쿼리도 동일하게 인덱스를 활용합니다:
fun SpringDataStudentRepository.findAllWithFilters( organizationId: String, teacherId: String?, // ... ): Page<StudentEntity> { val result = findPage(pageable) { val s = entity(StudentEntity::class, "s") // organizationId는 인덱스를 활용 val baseConditions = listOf( s(StudentEntity::organizationId).eq(value(organizationId)), ) // teacherId도 인덱스를 활용 val teacherIdCondition = teacherId?.let { s(StudentEntity::teacherId).eq(value(it)) } // ... } }
실제 실행 계획 확인:
EXPLAIN ANALYZE SELECT s.* FROM students s WHERE s.organization_id = 'org123' AND s.teacher_id = 'teacher456' ORDER BY s.created_at DESC LIMIT 20; -- Index Scan using idx_students_org_teacher on students -- 실제로 인덱스를 사용하는지 확인 가능
주의사항
1. Fetch Join 사용 시기
Fetch Join을 사용해야 하는 경우:
- 항상 함께 사용되는 연관 엔티티
- N+1 문제가 발생하는 경우
- 트랜잭션이 짧고 성능이 중요한 경우
일반 Join을 사용해야 하는 경우:
- WHERE 조건을 위한 조인만 필요한 경우
- 연관 엔티티를 실제로 로딩할 필요가 없는 경우
2. 카테시안 곱 문제
여러 컬렉션에 fetch join을 사용하면 카테시안 곱이 발생할 수 있습니다:
// 주의: 카테시안 곱 발생 가능 from( s, fetchJoin(StudentEntity::courses), fetchJoin(StudentEntity::activities), // ⚠️ 주의 )
해결 방법:
@BatchSize사용- 별도 쿼리로 분리
- 필요한 경우에만 fetch join 사용
3. Kotlin JDSL 문법
Kotlin JDSL에서 fetch join을 사용할 때는 연관관계를 직접 지정합니다:
// ✅ 올바른 사용법 fetchJoin(StudentEntity::courses) // ❌ 잘못된 사용법 join(CourseEntity::class) .on(s(StudentEntity::id).eq(c(CourseEntity::studentId)))
결론
Kotlin JDSL로 마이그레이션하면서 다음과 같은 개선을 달성했습니다:
- 타입 안전성: 컴파일 타임에 쿼리 오류 발견
- 성능 개선: Fetch Join을 통한 N+1 쿼리 문제 해결
- 코드 품질: DSL을 통한 가독성 향상
- 유지보수성: 확장 함수 패턴으로 모듈화
확장 함수 패턴을 활용하여 Kotlin JDSL로 마이그레이션하면서, 일반 join과 fetch join의 차이를 명확히 이해할 수 있었습니다. Kotlin JDSL의 타입 안전성과 확장 함수 패턴을 함께 사용하면 성능과 코드 품질을 모두 개선할 수 있습니다.
확장 함수 패턴의 실전 활용
확장 함수 패턴을 사용하면 다음과 같은 이점을 얻을 수 있습니다:
- 관심사 분리: Repository 인터페이스는 단순하게 유지하고, 복잡한 쿼리는 확장 함수로 분리
- 타입 안전성: 컴파일 타임에 쿼리 오류 발견
- 재사용성: 조건 구성 로직을 헬퍼 함수로 추출하여 재사용
- 테스트 용이성: 확장 함수를 독립적으로 테스트 가능
- 인덱스 활용: 생성된 SQL이 데이터베이스 인덱스를 정상적으로 활용