← Kursa Dön
📄 Text · 18 min

@EntityGraph & JOIN FETCH — Fetch Stratejileri

Giriş — Neden Bu Konu Önemli?

Önceki derste N+1 problemini öğrendik ve çözüm olarak JOIN FETCH, @EntityGraph, batch fetching gibi stratejilerden bahsettik. Şimdi bu stratejilerin her birini derinlemesine inceleyeceğiz — ne zaman hangisini kullanmalı, trade-off'ları neler, gerçek projelerde nasıl uygulanır?

Doğru fetch stratejisi seçimi, uygulamanızın performansını 10x-100x iyileştirebilir. 1000 sorguyu 2 sorguya düşürmek mümkündür. Ancak her stratejinin kendi tuzakları vardır — kartezyen çarpım, bellek tüketimi, MultipleBagFetchException gibi sorunları bilmeden kullanmak başka problemler yaratır.

Gerçek Hayat Analojisi: Bir market alışverişi düşünün. JOIN FETCH, alışveriş listenizdeki tüm ürünleri tek seferde toplayıp kasaya gitmektir. @BatchSize, her koridordan 20'şer ürün almaktır. SUBSELECT, "hangi ürünlere ihtiyacınız var?" diye sorup hepsini tek sepette getirmektir. Her stratejinin kendine göre avantajı var — kasayı kaç kez ziyaret edeceğiniz (sorgu sayısı) vs sepetinizin büyüklüğü (bellek kullanımı).


JOIN FETCH ile JPQL

JOIN FETCH, JPQL'de ilişkili entity'leri tek bir SQL sorgusunda yüklemek için kullanılır. Normal JOIN'den farklı olarak, JOIN FETCH ilişkili entity'leri persistence context'e yükler:

public interface AuthorRepository extends JpaRepository<Author, Long> {

    // ❌ Normal JOIN — sadece filtreleme amaçlı, kitaplar YÜKLENMEZ
    @Query("SELECT a FROM Author a JOIN a.books b WHERE b.title LIKE %:keyword%")
    List<Author> findByBookTitle(@Param("keyword") String keyword);
    // → author.getBooks() → LazyInitializationException veya N+1!

    // ✅ JOIN FETCH — kitaplar da yüklenir (N+1 çözümü)
    @Query("SELECT a FROM Author a JOIN FETCH a.books")
    List<Author> findAllWithBooks();
    // → SELECT a.*, b.* FROM author a INNER JOIN book b ON a.id = b.author_id

    // ✅ LEFT JOIN FETCH — kitabı olmayan author'lar da gelir
    @Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> findAllWithBooksIncludingEmpty();
    // → SELECT DISTINCT a.*, b.* FROM author a LEFT JOIN book b ON a.id = b.author_id
}

JOIN vs LEFT JOIN FETCH

TürKitabı olmayan authorKullanım
JOIN FETCH❌ GelmezSadece ilişkisi olan entity'ler gerektiğinde
LEFT JOIN FETCH✅ Gelir (books boş liste)Tüm entity'ler gerektiğinde (yaygın)

DISTINCT Kullanımı

JOIN FETCH ile Kartezyen çarpım oluşabilir. Bir author'ın 3 kitabı varsa, o author sonuç kümesinde 3 kez görünür:

-- DISTINCT olmadan:
| author_id | author_name | book_id | book_title |
|-----------|------------|---------|------------|
| 1         | Orhan      | 10      | Kar        |
| 1         | Orhan      | 11      | İstanbul   |
| 1         | Orhan      | 12      | Masumiyet  |
-- → 3 Author nesnesi oluşur (aynı author!)

-- DISTINCT ile:
-- → 1 Author nesnesi, 3 kitaplı liste

💡 Hibernate 6+ ile DISTINCT, SQL'e yansımaz — Hibernate bunu in-memory yapar. Eski sürümlerde @QueryHints(@QueryHint(name = HINT_PASS_DISTINCT_THROUGH, value = "false")) gerekebilirdi.

Çoklu JOIN FETCH ve MultipleBagFetchException

Birden fazla List koleksiyonu aynı anda JOIN FETCH etmek hatasına neden olur:

// ❌ İki List koleksiyonu — MultipleBagFetchException!
@Query("SELECT a FROM Author a JOIN FETCH a.books JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();

Çözüm 1: Koleksiyonlardan birini Set yapın:

@OneToMany(mappedBy = "author")
private Set<Book> books;    // Set

@OneToMany(mappedBy = "author")
private List<Article> articles;  // List

// Artık çalışır!
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books LEFT JOIN FETCH a.articles")
List<Author> findAllWithBooksAndArticles();

Çözüm 2: İki ayrı sorgu yapın:

// 1. sorgu: author + books
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
List<Author> findAllWithBooks();

// 2. sorgu: author + articles (Hibernate cache'den author'ları bulur)
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.articles WHERE a IN :authors")
List<Author> findAllWithArticles(@Param("authors") List<Author> authors);

// Service'te birleştirme
@Transactional(readOnly = true)
public List<Author> findAllWithAllRelations() {
    List<Author> authors = authorRepo.findAllWithBooks();
    authorRepo.findAllWithArticles(authors);  // Same session → cache merge
    return authors;  // Her author'ın books VE articles'ı dolu
}

@EntityGraph

@EntityGraph, fetch planını annotation ile tanımlamanızı sağlar. JPQL yazmadan fetch stratejisini değiştirebilirsiniz:

public interface AuthorRepository extends JpaRepository<Author, Long> {

    // attributePaths ile inline tanımlama
    @EntityGraph(attributePaths = {"books"})
    List<Author> findAll();

    // Birden fazla ilişki
    @EntityGraph(attributePaths = {"books", "publisher"})
    List<Author> findByNameContaining(String name);

    // Nested ilişkiler — kitapların yayınevlerini de çek
    @EntityGraph(attributePaths = {"books", "books.publisher"})
    Optional<Author> findById(Long id);
}

@EntityGraph'ın Avantajları

  1. JPQL yazmaya gerek yok — Spring Data'nın method name query'leriyle çalışır

  2. Daha okunabilir — hangi ilişkilerin yükleneceği açıkça bellidir

  3. Dinamik — aynı repository metodu farklı EntityGraph'larla kullanılabilir

@NamedEntityGraph

Entity sınıfında tanımlanan, yeniden kullanılabilir fetch planları:

@Entity
@NamedEntityGraph(
    name = "Author.withBooksAndPublisher",
    attributeNodes = {
        @NamedAttributeNode(value = "books", subgraph = "books-subgraph")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "books-subgraph",
            attributeNodes = {
                @NamedAttributeNode("publisher"),
                @NamedAttributeNode("reviews")
            }
        )
    }
)
public class Author {
    @Id
    private Long id;
    private String name;

    @OneToMany(mappedBy = "author")
    private List<Book> books;
}

// Repository'de referans
@EntityGraph(value = "Author.withBooksAndPublisher", type = EntityGraph.EntityGraphType.LOAD)
Optional<Author> findById(Long id);

EntityGraphType: FETCH vs LOAD

@EntityGraph'ın iki tipi vardır:

// FETCH (default):
// - attributePaths'te belirtilen ilişkiler → EAGER
// - Belirtilmeyen TÜM ilişkiler → LAZY (entity annotation'ı yok sayılır)
@EntityGraph(attributePaths = {"books"}, type = EntityGraph.EntityGraphType.FETCH)
List<Author> findAll();

// LOAD:
// - attributePaths'te belirtilen ilişkiler → EAGER
// - Belirtilmeyenler → entity'deki FetchType annotation'ına göre
@EntityGraph(attributePaths = {"books"}, type = EntityGraph.EntityGraphType.LOAD)
List<Author> findAll();
TipBelirtilen ilişkilerBelirtilmeyen ilişkiler
FETCHEAGERLAZY (her zaman)
LOADEAGEREntity annotation'a göre

💡 Hangisi? Çoğu durumda FETCH (default) tercih edilir — sadece belirttiğiniz ilişkiler yüklenir, diğer her şey lazy kalır. Bu, beklenmeyen eager loading'i önler.


Hibernate Batch Fetching

@BatchSize annotation'ı, lazy koleksiyonları gruplar halinde yükler. N+1'i tamamen çözmez ama sorgu sayısını dramatik şekilde azaltır:

@Entity
public class Author {
    @OneToMany(mappedBy = "author")
    @BatchSize(size = 25)
    private List<Book> books;
}

100 author varsa:

  • Batch olmadan: 1 + 100 = 101 sorgu

  • @BatchSize(size = 25): 1 + 4 = 5 sorgu (ceil(100/25) = 4 batch)

Üretilen SQL:

-- İlk author'ın books'una erişildiğinde:
SELECT * FROM book WHERE author_id IN (?, ?, ?, ..., ?)
-- 25 author_id parametresi ile tek sorgu

Global Batch Fetching

Her entity'ye ayrı ayrı @BatchSize eklemek yerine global ayar kullanabilirsiniz:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

Bu ayar, tüm lazy koleksiyonlar ve @ManyToOne lazy ilişkiler için geçerlidir. Her projede aktif olmalıdır — "ücretsiz" performans iyileştirmesidir.

💡 Batch size seçimi: 10-50 arası idealdir. Çok küçük batch verimsiz (çok sorgu), çok büyük batch IN clause limitlerini aşabilir (bazı veritabanlarında 1000 parametre limiti). 16 veya 25 iyi başlangıç değerleridir.


Subselect Fetching

@Fetch(FetchMode.SUBSELECT) stratejisi, tüm lazy koleksiyonları tek bir subselect sorgusu ile yükler:

@Entity
public class Author {
    @OneToMany(mappedBy = "author")
    @Fetch(FetchMode.SUBSELECT)
    private List<Book> books;
}

Üretilen SQL:

-- 1. sorgu: Author'ları getir
SELECT * FROM author WHERE active = true

-- 2. sorgu: Herhangi bir author'ın books'una erişildiğinde
-- TÜM author'ların kitapları tek seferde yüklenir
SELECT * FROM book
WHERE author_id IN (SELECT id FROM author WHERE active = true)

Toplam 2 sorgu! Orijinal sorgu subselect olarak tekrar kullanılır.

Subselect vs BatchSize

Özellik@BatchSize@Fetch(SUBSELECT)
Sorgu sayısı1 + ceil(N/size)2 (her zaman)
BellekDüşük (parçalı yükleme)Yüksek (tümünü yükler)
EsneklikSize ayarlanabilirAyar yok
Kısmi erişimSadece erişilen batch yüklenirTüm veri yüklenir

Programmatic EntityGraph

Runtime'da dinamik fetch planı oluşturmak için:

@Service
@Transactional(readOnly = true)
public class AuthorService {

    @PersistenceContext
    private EntityManager em;

    public Author findWithDynamicGraph(Long id, String... relations) {
        EntityGraph<Author> graph = em.createEntityGraph(Author.class);
        for (String relation : relations) {
            if (relation.contains(".")) {
                // Nested: "books.publisher" → subgraph
                String[] parts = relation.split("\\.");
                Subgraph<?> subgraph = graph.addSubgraph(parts[0]);
                subgraph.addAttributeNodes(parts[1]);
            } else {
                graph.addAttributeNodes(relation);
            }
        }

        Map<String, Object> hints = Map.of("jakarta.persistence.fetchgraph", graph);
        return em.find(Author.class, id, hints);
    }
}

// Kullanım
Author author = authorService.findWithDynamicGraph(1L, "books", "books.publisher");

Gerçek Dünya Örneği: Blog Platformu

Farklı sayfalarda farklı veri ihtiyaçları olan bir blog platformu:

public interface PostRepository extends JpaRepository<Post, Long> {

    // Ana sayfa — sadece post + author adı
    @EntityGraph(attributePaths = {"author"})
    Page<Post> findByPublishedTrue(Pageable pageable);

    // Post detay — post + author + comments + tags
    @Query("""
        SELECT DISTINCT p FROM Post p
        LEFT JOIN FETCH p.author
        LEFT JOIN FETCH p.tags
        WHERE p.id = :id
    """)
    Optional<Post> findByIdWithAuthorAndTags(@Param("id") Long id);

    // Admin paneli — post + author + comment count (DTO projection)
    @Query("""
        SELECT new com.example.dto.PostAdminDto(
            p.id, p.title, p.status, a.name, SIZE(p.comments)
        )
        FROM Post p JOIN p.author a
        ORDER BY p.createdAt DESC
    """)
    Page<PostAdminDto> findPostsForAdmin(Pageable pageable);
}

@Service
@Transactional(readOnly = true)
public class PostService {

    // Post detay — comments ayrı yüklenir (sayfalama ile)
    public PostDetailDto getPostDetail(Long postId) {
        Post post = postRepo.findByIdWithAuthorAndTags(postId)
            .orElseThrow(() -> new ResourceNotFoundException("Post not found"));

        // Comments ayrı sorguda, sayfalama ile
        Page<Comment> comments = commentRepo.findByPostId(
            postId, PageRequest.of(0, 20, Sort.by("createdAt").descending()));

        return PostDetailDto.from(post, comments);
    }
}

Hangi Stratejiyi Ne Zaman Kullanmalı?

SenaryoÖnerilen Strateji
Tek entity + ilişkili verilerJOIN FETCH veya @EntityGraph
Liste + ilişkili veriler (sayfalama yok)JOIN FETCH
Liste + ilişkili veriler (sayfalama var)İki adımlı (ID page + JOIN FETCH)
Sadece belirli alanlar gerekliDTO Projection
Genel performans iyileştirmesiGlobal @BatchSize (16-25)
Tüm koleksiyonlar aynı anda gerekliSUBSELECT
Birden fazla koleksiyonSet kullanın veya iki ayrı sorgu
API yanıtıDTO Projection (en iyi)

JOIN FETCH ile Sayfalama Problemi

JOIN FETCH ve Pageable birlikte kullanıldığında Hibernate in-memory sayfalama yapar:

// ⚠️ Bu çalışır AMA performans sorunu!
@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
Page<Author> findAllWithBooks(Pageable pageable);
// Hibernate UYARISI:
// HHH90003004: firstResult/maxResults specified with collection fetch;
//              applying in memory!

Hibernate, tüm veriyi çeker (100.000 satır bile olsa), bellekte sayfalama yapar. Bu, büyük veri setlerinde OutOfMemoryError riski taşır.

Çözüm: İki Adımlı Yaklaşım

public interface AuthorRepository extends JpaRepository<Author, Long> {

    // 1. Adım: Sayfalama ile sadece ID'leri çek (hafif sorgu)
    @Query("SELECT a.id FROM Author a WHERE a.active = true ORDER BY a.name")
    Page<Long> findActiveAuthorIds(Pageable pageable);

    // 2. Adım: ID'lere göre detaylı veriyi çek (JOIN FETCH, sayfalama yok)
    @Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books WHERE a.id IN :ids")
    List<Author> findWithBooksByIds(@Param("ids") List<Long> ids);
}

@Service
@Transactional(readOnly = true)
public class AuthorService {

    public Page<AuthorDto> findActiveAuthors(Pageable pageable) {
        // Adım 1: ID'leri sayfalama ile çek
        Page<Long> idPage = authorRepo.findActiveAuthorIds(pageable);

        if (idPage.isEmpty()) {
            return Page.empty(pageable);
        }

        // Adım 2: Detaylı veriyi çek
        List<Author> authors = authorRepo.findWithBooksByIds(idPage.getContent());

        // Sıralamayı koru (ID sırası pageable ile aynı olmalı)
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, a -> a));

        List<AuthorDto> dtos = idPage.getContent().stream()
            .map(id -> AuthorDto.from(authorMap.get(id)))
            .toList();

        return new PageImpl<>(dtos, pageable, idPage.getTotalElements());
    }
}

Bu yaklaşımda:

  • 1. sorgu: SELECT id FROM author WHERE active = true ORDER BY name LIMIT 20 OFFSET 40 — hafif, sayfalama veritabanında

  • 2. sorgu: SELECT a.*, b.* FROM author a LEFT JOIN book b ON ... WHERE a.id IN (?, ?, ..., ?) — 20 ID ile detaylı veri

Toplam 2 sorgu, sayfalama doğru ve bellek güvenli.


@EntityGraph ile Spring Data Specification Entegrasyonu

public interface ProductRepository extends
    JpaRepository<Product, Long>,
    JpaSpecificationExecutor<Product> {

    // @EntityGraph + Specification birlikte kullanılabilir
    @EntityGraph(attributePaths = {"category", "brand"})
    Page<Product> findAll(Specification<Product> spec, Pageable pageable);
}

// Kullanım
Specification<Product> spec = ProductSpecs.hasCategory("elektronik")
    .and(ProductSpecs.priceBetween(100, 500));

Page<Product> products = productRepo.findAll(spec, pageable);
// Tek sorgu: category ve brand LEFT JOIN FETCH ile gelir

Fetch Profilleri — Farklı Senaryolar İçin Farklı Planlar

Aynı entity'yi farklı ekranlar için farklı şekillerde yüklemek isteyebilirsiniz:

@Entity
@NamedEntityGraphs({
    @NamedEntityGraph(
        name = "Author.summary",
        attributeNodes = {}  // Sadece author alanları — ilişki yok
    ),
    @NamedEntityGraph(
        name = "Author.withBooks",
        attributeNodes = @NamedAttributeNode("books")
    ),
    @NamedEntityGraph(
        name = "Author.full",
        attributeNodes = {
            @NamedAttributeNode(value = "books", subgraph = "books-sub"),
            @NamedAttributeNode("awards")
        },
        subgraphs = @NamedSubgraph(
            name = "books-sub",
            attributeNodes = {
                @NamedAttributeNode("publisher"),
                @NamedAttributeNode("reviews")
            }
        )
    )
})
public class Author { ... }

// Repository'de farklı ekranlar için farklı graph
public interface AuthorRepository extends JpaRepository<Author, Long> {

    // Liste görünümü — sadece author
    @EntityGraph("Author.summary")
    Page<Author> findByActiveTrue(Pageable pageable);

    // Detay sayfası — author + books
    @EntityGraph("Author.withBooks")
    Optional<Author> findDetailById(Long id);

    // Admin paneli — her şey
    @EntityGraph("Author.full")
    Optional<Author> findFullById(Long id);
}

Performans Ölçme ve Monitoring

Hangi stratejinin daha iyi çalıştığını ölçmek için:

@Component
@Slf4j
public class QueryCountInterceptor implements HandlerInterceptor {

    private static final ThreadLocal<Long> queryCount = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) {
        queryCount.set(0L);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        Long count = queryCount.get();
        if (count != null && count > 10) {
            log.warn("⚠️ High query count: {} queries for {} {}",
                count, request.getMethod(), request.getRequestURI());
        }
        queryCount.remove();
    }

    public static void increment() {
        Long current = queryCount.get();
        if (current != null) {
            queryCount.set(current + 1);
        }
    }
}
# p6spy ile tüm SQL'leri loglama (test/development)
# build.gradle: implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
spring:
  datasource:
    url: jdbc:p6spy:postgresql://localhost:5432/mydb
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver

Özet

  • JOIN FETCH en yaygın N+1 çözümüdür — tek sorgu ile ilişkili veriler yüklenir

  • @EntityGraph JPQL yazmadan fetch planı tanımlar — Spring Data query method'larıyla uyumludur

  • DISTINCT JOIN FETCH ile kartezyen çarpımı önlemek için gereklidir

  • MultipleBagFetchException birden fazla List fetch edildiğinde oluşur — Set kullanın veya ayrı sorgular yazın

  • @BatchSize global olarak aktif olmalıdır (default_batch_fetch_size: 16) — "ücretsiz" optimizasyon

  • SUBSELECT tüm veriyi 2 sorguda çeker ama büyük veri setlerinde bellek tüketimi yüksektir

  • İlk tercihiniz DTO projection olsun — sadece ihtiyacınız olan alanları çekin, entity yönetim maliyeti yoktur