Pingu
영차영차! Backend

Kotlin JDSL 마이그레이션과 Fetch Join을 통한 N+1 문제 해결

2026년 2월 2일
8개 태그
Kotlin JDSL
JPA
N+1 쿼리
Fetch Join
성능 최적화
Spring Boot
Kotlin
확장 함수

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 마이그레이션 완료

주요 개선 사항

  1. 타입 안전성 확보

    • 컴파일 타임에 쿼리 오류 발견
    • Entity 필드명 변경 시 자동으로 쿼리 업데이트
  2. N+1 쿼리 문제 해결

    • fetchJoin()을 사용하여 연관관계 즉시 로딩
    • 쿼리 횟수 감소로 성능 향상
  3. 코드 가독성 향상

    • DSL을 통한 선언적 쿼리 작성
    • 확장 함수로 모듈화
  4. 유지보수성 개선

    • 복잡한 쿼리를 더 쉽게 관리
    • 조건 재사용을 위한 헬퍼 함수 활용

인덱스 활용과 성능 최적화

인덱스 사용 여부

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: 정렬을 위한 인덱스 (필요시 추가)

인덱스 최적화 팁:

  1. 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);
  1. ORDER BY에 사용되는 컬럼에 인덱스 추가
-- 정렬 성능 향상
CREATE INDEX idx_students_created_at ON students(created_at DESC);
  1. 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로 마이그레이션하면서 다음과 같은 개선을 달성했습니다:

  1. 타입 안전성: 컴파일 타임에 쿼리 오류 발견
  2. 성능 개선: Fetch Join을 통한 N+1 쿼리 문제 해결
  3. 코드 품질: DSL을 통한 가독성 향상
  4. 유지보수성: 확장 함수 패턴으로 모듈화

확장 함수 패턴을 활용하여 Kotlin JDSL로 마이그레이션하면서, 일반 join과 fetch join의 차이를 명확히 이해할 수 있었습니다. Kotlin JDSL의 타입 안전성과 확장 함수 패턴을 함께 사용하면 성능과 코드 품질을 모두 개선할 수 있습니다.

확장 함수 패턴의 실전 활용

확장 함수 패턴을 사용하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 관심사 분리: Repository 인터페이스는 단순하게 유지하고, 복잡한 쿼리는 확장 함수로 분리
  2. 타입 안전성: 컴파일 타임에 쿼리 오류 발견
  3. 재사용성: 조건 구성 로직을 헬퍼 함수로 추출하여 재사용
  4. 테스트 용이성: 확장 함수를 독립적으로 테스트 가능
  5. 인덱스 활용: 생성된 SQL이 데이터베이스 인덱스를 정상적으로 활용

참고 자료

댓글

?