JPA 지연로딩과 joinFetch
JPA를 사용하다 보면 연관된 엔티티를 조회할 때 두가지 접근 방식을 고민하게 됩니다. 바로 지연로딩(Lazy Loading) 과 JOIN FETCH 입니다.
실제 프로젝트에서 태스크 취소 기능을 구현하면서 이 두가지 방식의 차이를 명확히 경험할 수 있었습니다. 이번 글에서는 실제코드를 바탕으로 두 방식의 차이점과 성능 특성을 분석하고, 언제 어떤 방식을 선택해야하는지 알아보겠습니다.
문제 상황
과제 취소 기능을 구현할 때 다음과 같은 데이터가 필요합니다.
- Task: 과제 정보
- TaskMatch: 과제와 서포터의 매칭 정보
- CreditTransaction: 크레딧 거래 내역 (환불 금액 계산용) 처음에는 지연로딩 방식으로 구현했지만, 성능 개선을 위해 JOIN FETCH로 변경하였습니다.
지연 로딩(Lazy Loading)이란?
지연 로딩은 연관된 엔티티를 실제로 필요할 때까지 로딩을 미루는 기법입니다. JPA는 이를 위해 프록시 객체 를 사용합니다.
지연 로딩의 동작 원리
// 1번째 쿼리: Task와 TaskMatch만 조회 val taskWithMatch = taskRepository.findByIdWithMatch(taskId) // 2번째 쿼리: CreditTransaction을 별도로 조회 val creditTransaction = creditTransactionRepository.findById( taskWithMatch.task.creditTransactionId )
- 실제 실행되는 SQL
-- 1번째 쿼리 SELECT t.*, tm.* FROM tasks t LEFT JOIN task_matches tm ON t.id = tm.task_id WHERE t.id = 'task123'; -- 2번째 쿼리 (나중에 실행) SELECT * FROM credit_transactions WHERE id = 'transaction123';
지연 로딩의 내부 매커니즘
JPA는 ByteBuddy나 Javassist를 사용하여 동적으로 프록시 클래스를 생성합니다.
// Hibernate가 생성하는 프록시 클래스 (의사 코드) public class TaskEntity$HibernateProxy extends TaskEntity implements HibernateProxy { private LazyInitializer lazyInitializer; @Override public CreditTransaction getCreditTransaction() { // 프록시가 초기화되지 않았다면 if (lazyInitializer.isUninitialized()) { lazyInitializer.initialize(); // 실제 데이터 로딩 } return super.getCreditTransaction(); } }
프록시 객체의 메서드가 호출되면:
- LazyInitializer.inUninitialized() 체크
- 초기화 되지 않았다면 Session.immediateLoad() 호출
- SQL 쿼리 실행하여 실제 엔티티 로딩
- 프록시의 target 필드에 실제 엔티티 할당
지연 로딩 구현 예시
// TaskRepositoryAdapter.kt (지연 로딩 방식) override fun findByIdWithMatchAndTransaction(taskId: String): TaskWithMatchAndTransaction? { // 1. Task와 TaskMatch를 함께 조회 val taskWithMatch = findByIdWithMatch(taskId) ?: return null // 2. CreditTransaction을 별도 조회 (2번째 쿼리) val creditTransactionEntity = creditTransactionRepository .findById(taskWithMatch.task.creditTransactionId) .orElse(null) ?: return null return TaskWithMatchAndTransaction( task = taskWithMatch.task, taskMatch = taskWithMatch.taskMatch, creditTransaction = creditTransactionEntity.toDomain(), ) }
JOIN FETCH란?
JOIN FETCH는 연관된 엔티티를 한번의 쿼리로 함께 조회하는 기법입니다. FETCH 키워드를 사용하면 연괸된 엔티티도 즉시 로딩됩니다.
// 1번의 쿼리로 모든 데이터 조회 val query = entityManager.createQuery( """ SELECT t AS task, ct AS transaction FROM TaskEntity t LEFT JOIN FETCH t.taskMatch tm JOIN CreditTransactionEntity ct ON t.creditTransactionId = ct.id WHERE t.id = :taskId """, Tuple::class.java, )
실제 실행되는 SQL
SELECT t.*, tm.*, ct.* FROM tasks t LEFT JOIN task_matches tm ON t.id = tm.task_id JOIN credit_transactions ct ON t.credit_transaction_id = ct.id WHERE t.id = 'task123';
JOIN FETCH 구현 예시
override fun findByIdWithMatchAndTransaction(taskId: String): TaskWithMatchAndTransaction? { // JOIN FETCH로 한 번에 조회 (1번의 쿼리, 타입 안전) val query = entityManager.createQuery( """ SELECT t AS task, ct AS transaction FROM TaskEntity t LEFT JOIN FETCH t.taskMatch tm JOIN CreditTransactionEntity ct ON t.creditTransactionId = ct.id WHERE t.id = :taskId """, Tuple::class.java, ) query.setParameter("taskId", taskId) val result = query.resultList.firstOrNull() ?: return null // Tuple을 사용하여 타입 안전하게 접근 val taskEntity = result.get("task") as TaskEntity val creditTransactionEntity = result.get("transaction") as CreditTransactionEntity return TaskWithMatchAndTransaction( task = taskEntity.toDomain(), taskMatch = taskEntity.taskMatch?.toDomain(), creditTransaction = creditTransactionEntity.toDomain(), ) }
성능 비교
쿼리 횟수 비교
| 방식 | 쿼리 횟수 | 네트워크 왕복 | 총 지연 시간 |
|---|---|---|---|
| 지연 로딩 | 2번 | 2회 | ~4-20ms |
| JOIN FETCH | 1번 | 1회 | ~2-10ms |
실제 측정 결과
지연 로딩: - 1번째 쿼리: 3.2ms - 2번째 쿼리: 2.8ms - 총 시간: 6.0ms JOIN FETCH: - 단일 쿼리: 4.1ms - 총 시간: 4.1ms
약 32% 성능 향상을 확인할 수 있었습니다.
네트워크 지연 고려
원격 데이터 베이스를 사용하는 경우, 네트워크 왕복시간이 더 중요해집니다.
네트워크 지연: 5ms 가정 지연 로딩: - 쿼리 실행: 6.0ms - 네트워크 왕복 (2회): 10.0ms - 총 시간: 16.0ms JOIN FETCH: - 쿼리 실행: 4.1ms - 네트워크 왕복 (1회): 5.0ms - 총 시간: 9.1ms
언제 무엇을 사용할까?
JOIN FETCH를 사용해야 하는 경우
1. 항상 함께 사용되는 연관 엔티티
// 과제 취소 시 항상 CreditTransaction이 필요 fun cancelTask(taskId: String) { val taskDetail = getTaskWithMatchAndTransaction(taskId) // 항상 함께 사용되므로 JOIN FETCH가 적합 }
2. N+1 문제가 발생하는 경우
// 나쁜 예: N+1 문제 발생 val tasks = taskRepository.findAll() tasks.forEach { task -> val transaction = creditTransactionRepository .findById(task.creditTransactionId) // N번의 쿼리 } // 좋은 예: JOIN FETCH 사용 @Query("SELECT t FROM TaskEntity t JOIN FETCH t.creditTransaction") fun findAllWithTransaction(): List<TaskEntity>
3. 트랜잭션이 짧고 성능이 중요한 경우
- API 응답 시간이 중요한 경우
- 대용량 트래픽을 처리해야 하는 경우
지연 로딩을 사용해야 하는 경우
1. 조건부로 사용되는 연관 엔티티
fun getTask(taskId: String, includeMatch: Boolean): Task { val task = taskRepository.findById(taskId) // 조건에 따라만 로딩 if (includeMatch) { val match = taskMatchRepository.findByTaskId(taskId) } return task }
2. 컬렉션 연관 관계
@OneToMany(fetch = FetchType.LAZY) val comments: List<Comment> // 필요할 때만 로딩
3. 메모리 사용량이 중요한 경우
- 대량의 데이터를 조회해야 하는 경우
- 일부 데이터만 필요한 경우
실제 적용 사례
Before: 지연 로딩 방식
override fun cancelTask(taskId: String, studentId: String): Task { // 1번째 쿼리 val taskWithMatch = getTaskWithMatch(taskId) ?: throw ExceptionMessages.taskNotFound(taskId) // 2번째 쿼리 val creditTransaction = creditTransactionRepository .findById(taskWithMatch.task.creditTransactionId) .orElseThrow { IllegalStateException("Transaction not found") } // 비즈니스 로직... }
문제점:
- 2번의 쿼리 실행
- 네트워크 왕복 2회
- 트랜잭션 시간 증가
After: JOIN FETCH 방식
override fun cancelTask(taskId: String, studentId: String): Task { // 1번의 쿼리로 모든 데이터 조회 val taskDetail = getTaskWithMatchAndTransaction(taskId) ?: throw ExceptionMessages.taskNotFound(taskId) val task = taskDetail.task val creditTransaction = taskDetail.creditTransaction // 비즈니스 로직... }
개선점:
- 1번의 쿼리로 모든 데이터 조회
- 네트워크 왕복 1회
- 약 32-43% 성능 향상
주의사항
1. LazyInitializationException
지연 로딩을 사용할 때 주의해야 할 점:
@Transactional // 트랜잭션이 있어야 함 fun getTask(taskId: String): Task { val task = taskRepository.findById(taskId) return task // 트랜잭션 종료 } // 트랜잭션 밖에서 접근 시 val task = taskService.getTask("task123") val transaction = task.creditTransaction // ❌ LazyInitializationException!
해결 방법:
@Transactional유지- JOIN FETCH 사용
Hibernate.initialize()사용
2. 카테시안 곱 문제
JOIN FETCH를 여러 컬렉션에 사용하면 카테시안 곱이 발생할 수 있습니다:
// 나쁜 예: 카테시안 곱 발생 @Query(""" SELECT t FROM TaskEntity t JOIN FETCH t.comments JOIN FETCH t.attachments """) fun findWithAll(): List<TaskEntity>
해결 방법:
@BatchSize사용- 별도 쿼리로 분리
EntityGraph사용
3. 타입 안전성
JPQL에서 여러 엔티티를 조회할 때는 Tuple을 사용:
val query = entityManager.createQuery( "SELECT t AS task, ct AS transaction FROM ...", Tuple::class.java ) val task = result.get("task") as TaskEntity val transaction = result.get("transaction") as CreditTransactionEntity
결론
지연 로딩과 JOIN FETCH는 각각의 장단점이 있습니다:
- 지연 로딩: 메모리 효율적, 조건부 로딩에 유리
- JOIN FETCH: 성능 우수, 항상 함께 사용되는 경우에 적합 실제 프로젝트에서는 항상 함께 사용되는 연관 엔티티는 JOIN FETCH를 사용하고, 조건부로 사용되는 경우에만 지연 로딩을 사용하는 것이 좋습니다.
과제 취소 기능의 경우, Task, TaskMatch, CreditTransaction이 항상 함께 사용되므로 JOIN FETCH를 사용하여 약 32-43%의 성능 향상을 달성할 수 있었습니다.
참고 자료
댓글 (0)
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!