Kotlin JDSL 마이그레이션과 Fetch Join을 통한 N+1 문제 해결
Native Query를 Kotlin JDSL로 마이그레이션하면서 확장 함수 패턴을 도입했습니다. 일반 join을 fetchJoin으로 변경하여 N+1 쿼리 문제를 해결하고, 데이터베이스 레벨에서 직접 집계 연산을 수행하여 쿼리 성능을 개선했습니다. Kotlin JDSL의 타입 안전성과 확장 함수 패턴을 활용하여 유지보수성을 크게 향상시켰습니다.
Sample code들어가며
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이 데이터베이스 인덱스를 정상적으로 활용