IEnumerable vs IQueryable di C#: Perbedaan Eksekusi Query

Foto oleh anshul kumar via Pexels

IEnumerable vs IQueryable: Bedanya di Mana?

Ketika berhadapan dengan data di C#, terutama saat menggunakan LINQ, Anda pasti akan sering menemui interface IEnumerable<T> dan IQueryable<T>. Keduanya memang sama-sama memungkinkan kita untuk melakukan iterasi atau query terhadap koleksi data. Namun, di balik kemiripan fungsionalitasnya, terdapat perbedaan fundamental dalam cara kerja yang sangat krusial, terutama bagi performa dan skalabilitas aplikasi Anda.

Seringkali, developer baru (atau bahkan yang berpengalaman) salah kaprah dalam penggunaannya, menyebabkan aplikasi memuat seluruh data dari database ke memori hanya untuk memfilter sebagian kecil di antaranya. Ini adalah skenario yang ingin kita hindari.

IEnumerable<T>: Iterasi Lokal, Pemrosesan In-Memory

IEnumerable<T> adalah interface paling dasar untuk iterasi koleksi. Ia mendefinisikan metode GetEnumerator() yang memungkinkan Anda untuk melintasi elemen-elemen dalam sebuah koleksi, satu per satu. Banyak koleksi umum di C# seperti List<T>, Array, dan bahkan interface seperti ICollection<T> serta IList<T> (yang telah kita bahas sebelumnya) mengimplementasikan IEnumerable<T>.

Karakteristik utama dari IEnumerable<T> adalah eksekusi query yang bersifat lokal (in-memory). Artinya, ketika Anda menerapkan operasi LINQ (seperti Where(), Select(), OrderBy()) pada sebuah objek IEnumerable<T>, operasi tersebut akan dieksekusi pada memori aplikasi Anda. Jika data berasal dari sumber eksternal (misalnya database), seluruh data yang relevan akan ditarik terlebih dahulu ke memori aplikasi, baru kemudian diolah.

Perhatikan contoh berikut:

Contoh penggunaan IEnumerable untuk koleksi in-memory

using System; using System.Collections.Generic; using System.Linq; public class Student { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } } public class Program { public static void Main(string[] args) { IEnumerable<Student> students = new List<Student> { new Student { Id = 1, Name = "Budi", Age = 20 }, new Student { Id = 2, Name = "Ani", Age = 22 }, new Student { Id = 3, Name = "Candra", Age = 20 }, new Student { Id = 4, Name = "Dewi", Age = 21 } }; // Operasi LINQ pada IEnumerable IEnumerable<Student> youngStudents = students.Where(s => s.Age < 21); Console.WriteLine("Mahasiswa Muda:"); foreach (var student in youngStudents) { Console.WriteLine($"- {student.Name}, {student.Age} tahun"); } } } /* Output: Mahasiswa Muda: - Budi, 20 tahun - Candra, 20 tahun */

Dalam contoh di atas, list students sudah ada di memori. Ketika .Where() dipanggil, LINQ to Objects akan mengiterasi setiap elemen Student dalam list dan mengembalikan yang memenuhi kriteria. Ini adalah perilaku yang diharapkan untuk koleksi in-memory.

IQueryable<T>: Delegasi Query ke Sumber Data Eksternal

IQueryable<T> adalah interface yang lebih canggih, yang memperluas IEnumerable<T>. Namun, perbedaan kuncinya ada pada properti Provider (bertipe IQueryProvider) dan Expression (bertipe Expression).

Ketika Anda menggunakan IQueryable<T>, operasi LINQ yang Anda tulis (misalnya Where(), Select()) tidak langsung dieksekusi. Sebaliknya, operasi tersebut diterjemahkan menjadi sebuah Expression Tree. Expression Tree ini kemudian diteruskan ke IQueryProvider (misalnya Entity Framework Core, LINQ to SQL) yang bertanggung jawab untuk menerjemahkannya ke dalam bahasa query yang dimengerti oleh sumber data eksternal (seperti SQL untuk database, atau API call untuk layanan web).

Hasilnya, operasi filtering, sorting, atau proyeksi data akan dieksekusi langsung di sumber data, dan hanya data yang benar-benar dibutuhkan yang akan ditarik ke aplikasi.

Berikut adalah ilustrasi penggunaannya, seringkali ditemukan pada konteks database:

Ilustrasi penggunaan IQueryable dengan DbContext (Entity Framework)

using System.Linq; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System; public class Student { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } } // Simulasikan DbContext public class ApplicationDbContext : DbContext { public DbSet<Student> Students { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // Gunakan In-Memory database untuk contoh optionsBuilder.UseInMemoryDatabase("TestDb"); } public void SeedData() { Students.Add(new Student { Id = 1, Name = "Budi", Age = 20 }); Students.Add(new Student { Id = 2, Name = "Ani", Age = 22 }); Students.Add(new Student { Id = 3, Name = "Candra", Age = 20 }); Students.Add(new Student { Id = 4, Name = "Dewi", Age = 21 }); SaveChanges(); } } public class Program { public static void Main(string[] args) { using (var _dbContext = new ApplicationDbContext()) { _dbContext.Database.EnsureDeleted(); // Pastikan bersih _dbContext.Database.EnsureCreated(); // Buat DB _dbContext.SeedData(); // Isi data // _dbContext.Students mengembalikan IQueryable<Student> IQueryable<Student> query = _dbContext.Students .Where(s => s.Age < 21) .OrderBy(s => s.Name); Console.WriteLine("Query IQueryable telah dibuat. Belum ada eksekusi ke DB."); // Eksekusi terjadi saat ToList() dipanggil List<Student> youngStudents = query.ToList(); Console.WriteLine("Mahasiswa Muda (dari IQueryable):"); foreach (var student in youngStudents) { Console.WriteLine($"- {student.Name}, {student.Age} tahun"); } } } } /* Output: Query IQueryable telah dibuat. Belum ada eksekusi ke DB. Mahasiswa Muda (dari IQueryable): - Budi, 20 tahun - Candra, 20 tahun */

Pada contoh IQueryable di atas, ketika Where() dipanggil pada _dbContext.Students (yang mengembalikan IQueryable<Student>), tidak ada data yang ditarik dari database saat itu juga. Entity Framework akan membangun sebuah Expression Tree dari ekspresi lambda Anda. Hanya ketika metode seperti .ToList(), .FirstOrDefault(), atau .Count() dipanggil, Expression Tree ini baru akan dikompilasi menjadi sebuah query SQL yang efisien, dan dikirimkan ke database.

Perbedaan Kritis: Eksekusi Query dan Lokasi Filtering

Inilah inti dari perbedaan antara keduanya. Mari kita simulasikan dengan sebuah skenario database:

Skenario 1: Menggunakan IEnumerable<T> secara tidak tepat

Bayangkan Anda memiliki tabel Products di database dengan jutaan baris data. Anda ingin mendapatkan produk dengan harga di atas 100 dan mengurutkannya.

Contoh penggunaan IEnumerable secara tidak tepat (menarik semua data ke memori)

using System.Linq; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System; public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } } // Simulasikan DbContext public class ProductDbContext : DbContext { public DbSet<Product> Products { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("ProductDb"); } public void SeedData(int count) { for (int i = 1; i <= count; i++) { Products.Add(new Product { Id = i, Name = $"Product {i}", Price = i * 10M }); } SaveChanges(); } } public class Program { public static void Main(string[] args) { using (var _dbContext = new ProductDbContext()) { _dbContext.Database.EnsureDeleted(); _dbContext.Database.EnsureCreated(); _dbContext.SeedData(1000000); // Sejuta produk Console.WriteLine("--- Menggunakan IEnumerable (salah) ---"); Console.WriteLine("Memuat SEMUA produk ke memori lalu memfilter..."); // EKSEKUSI DATABASE TERJADI DI SINI: SEMUA DATA DITARIK IEnumerable<Product> allProductsInMemory = _dbContext.Products.ToList(); Console.WriteLine($"Total produk ditarik ke memori: {allProductsInMemory.Count()} (dari {allProductsInMemory.Count()} baris)"); // FILTERING DAN SORTING DILAKUKAN DI MEMORI APLIKASI var filteredAndSortedProducts = allProductsInMemory .Where(p => p.Price > 1000000) .OrderBy(p => p.Name) .Take(10); Console.WriteLine($"Jumlah produk setelah filter in-memory: {filteredAndSortedProducts.Count()}"); foreach (var product in filteredAndSortedProducts) { Console.WriteLine($"- {product.Name}, Price: {product.Price:C}"); } } } } /* Output: --- Menggunakan IEnumerable (salah) --- Memuat SEMUA produk ke memori lalu memfilter... Total produk ditarik ke memori: 1000000 (dari 1000000 baris) Jumlah produk setelah filter in-memory: 0 (Memuat 1 juta objek ke memori akan sangat lambat dan boros) */

Apa yang terjadi?

  1. _dbContext.Products.ToList(): Seluruh jutaan baris data produk ditarik dari database ke memori aplikasi Anda. Ini membutuhkan bandwidth jaringan yang besar dan penggunaan memori aplikasi yang masif.
  2. .Where(p => p.Price > 100): Filter harga dieksekusi secara in-memory, di aplikasi Anda.
  3. .OrderBy(p => p.Name): Pengurutan nama juga dilakukan secara in-memory.
Ini adalah kesalahan umum yang dapat menyebabkan masalah performa serius, terutama pada data skala besar.

Skenario 2: Menggunakan IQueryable<T> dengan benar

Dengan IQueryable<T>, kita melakukan filtering dan sorting di sisi database:

Contoh penggunaan IQueryable secara tepat (filtering di database)

using System.Linq; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System; public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } } // Simulasikan DbContext (sama seperti contoh sebelumnya) public class ProductDbContext : DbContext { public DbSet<Product> Products { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("ProductDb"); } public void SeedData(int count) { for (int i = 1; i <= count; i++) { Products.Add(new Product { Id = i, Name = $"Product {i}", Price = i * 10M }); } SaveChanges(); } } public class Program { public static void Main(string[] args) { using (var _dbContext = new ProductDbContext()) { _dbContext.Database.EnsureDeleted(); _dbContext.Database.EnsureCreated(); _dbContext.SeedData(1000000); // Sejuta produk Console.WriteLine("--- Menggunakan IQueryable (benar) ---"); Console.WriteLine("Membangun query Expression Tree..."); // _dbContext.Products adalah IQueryable<Product> IQueryable<Product> query = _dbContext.Products .Where(p => p.Price > 9999900) // Harga lebih besar dari 9999900 (misal: Product 999991 hingga 1000000) .OrderBy(p => p.Name) .Take(10); Console.WriteLine("Expression Tree selesai dibangun. Belum ada eksekusi ke database."); // EKSEKUSI DATABASE TERJADI DI SINI: HANYA DATA TERFILTER YANG DITARIK List<Product> filteredAndSortedProducts = query.ToList(); Console.WriteLine($"Jumlah produk ditarik dari DB setelah filter: {filteredAndSortedProducts.Count()}"); foreach (var product in filteredAndSortedProducts) { Console.WriteLine($"- {product.Name}, Price: {product.Price:C}"); } } } } /* Output: --- Menggunakan IQueryable (benar) --- Membangun query Expression Tree... Expression Tree selesai dibangun. Belum ada eksekusi ke database. Jumlah produk ditarik dari DB setelah filter: 10 - Product 999991, Price: Rp 9.999.910,00 - Product 999992, Price: Rp 9.999.920,00 - Product 999993, Price: Rp 9.999.930,00 - Product 999994, Price: Rp 9.999.940,00 - Product 999995, Price: Rp 9.999.950,00 - Product 999996, Price: Rp 9.999.960,00 - Product 999997, Price: Rp 9.999.970,00 - Product 999998, Price: Rp 9.999.980,00 - Product 999999, Price: Rp 9.999.990,00 - Product 1000000, Price: Rp 10.000.000,00 */

Apa yang terjadi?

  1. _dbContext.Products: Mengembalikan objek IQueryable<Product>. Tidak ada query yang dieksekusi ke database.
  2. .Where(p => p.Price > 100): Ekspresi filter ditambahkan ke Expression Tree. Masih belum ada query ke database.
  3. .OrderBy(p => p.Name): Ekspresi pengurutan ditambahkan ke Expression Tree. Masih belum ada query ke database.
  4. .ToList(): Di sinilah query dieksekusi. Entity Framework menerjemahkan Expression Tree menjadi query SQL yang spesifik dan dikirimkan ke database. Database akan memproses filter dan sorting, dan hanya mengirimkan hasil yang sudah terfilter dan terurut ke aplikasi Anda.
Contoh query SQL yang mungkin dihasilkan oleh Skenario 2:

Contoh query SQL yang mungkin dihasilkan oleh Entity Framework (dari IQueryable)

SELECT TOP(10) [p].[Id], [p].[Name], [p].[Price] FROM [Products] AS [p] WHERE [p].[Price] > 9999900 ORDER BY [p].[Name]

Anda bisa melihat perbedaan yang signifikan: hanya data yang relevan yang ditarik, menghemat memori, CPU, dan bandwidth jaringan aplikasi Anda.

Dampak Performa dan Sumber Daya

  • Memori: IEnumerable dapat menghabiskan lebih banyak memori karena harus memuat seluruh dataset ke dalam memori aplikasi sebelum diproses. IQueryable jauh lebih efisien karena hanya memuat data yang benar-benar diperlukan setelah filtering dan sorting dilakukan di sumber data.
  • CPU: Pemrosesan data (filtering, sorting) oleh IEnumerable dilakukan di CPU server aplikasi Anda, yang bisa jadi sumber bottleneck. IQueryable mendelegasikan pemrosesan ke sumber data (misalnya database server), memanfaatkan sumber daya yang mungkin lebih optimal untuk tugas tersebut.
  • Jaringan: IEnumerable dapat menyebabkan transfer data yang masif melalui jaringan. IQueryable meminimalkan transfer data, hanya mengambil hasil akhir yang sudah diproses.

Transformasi dari IQueryable ke IEnumerable

Penting untuk memahami bahwa setiap IQueryable<T> secara implisit adalah IEnumerable<T> karena IQueryable<T> mewarisi IEnumerable<T>. Ini berarti Anda bisa mengubah sebuah IQueryable menjadi IEnumerable. Transformasi ini terjadi secara otomatis ketika:

  • Anda memanggil metode seperti .ToList(), .ToArray(), .AsEnumerable().
  • Anda menggunakan metode LINQ yang tidak dapat diterjemahkan oleh IQueryProvider menjadi query database. Misalnya, sebuah fungsi C# kustom yang kompleks.

Setelah sebuah IQueryable diubah menjadi IEnumerable, semua operasi LINQ berikutnya akan dieksekusi secara in-memory. Hati-hati jangan sampai mengubahnya terlalu cepat!

Contoh penggunaan .AsEnumerable() secara eksplisit:

Contoh konversi IQueryable ke IEnumerable dengan .AsEnumerable()

using System.Linq; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System; public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } } public class ProductDbContext : DbContext { public DbSet<Product> Products { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("ProductDb"); } public void SeedData(int count) { for (int i = 1; i <= count; i++) { Products.Add(new Product { Id = i, Name = $"Product {i}", Price = i * 10M }); } SaveChanges(); } } public class Program { public static void Main(string[] args) { using (var _dbContext = new ProductDbContext()) { _dbContext.Database.EnsureDeleted(); _dbContext.Database.EnsureCreated(); _dbContext.SeedData(100); // 100 produk untuk contoh ini Console.WriteLine("--- Konversi IQueryable ke IEnumerable terlalu dini ---"); // Database query executes here. All 100 products are loaded into memory. IEnumerable<Product> allProductsInMem = _dbContext.Products.AsEnumerable(); Console.WriteLine("Semua 100 produk telah ditarik ke memori."); // Filtering now happens in memory var expensiveProducts = allProductsInMem.Where(p => p.Price > 500).ToList(); Console.WriteLine($"Produk mahal (difilter in-memory): {expensiveProducts.Count()}"); foreach (var product in expensiveProducts) { Console.WriteLine($"- {product.Name}, Price: {product.Price:C}"); } } } } /* Output: --- Konversi IQueryable ke IEnumerable terlalu dini --- Semua 100 produk telah ditarik ke memori. Produk mahal (difilter in-memory): 50 - Product 51, Price: Rp 510,00 ... - Product 100, Price: Rp 1.000,00 */

Dalam contoh ini, .AsEnumerable() akan menarik semua produk dari database ke memori terlebih dahulu, kemudian filtering .Where() akan dilakukan secara in-memory. Ini adalah contoh penggunaan yang kurang optimal jika filter bisa dilakukan di database.

Kesalahan Umum dan Cara Menghindarinya

Kesalahan paling umum adalah memanggil .ToList() atau .ToArray() terlalu dini saat bekerja dengan IQueryable, terutama di awal rantai query.

Kesalahan umum: Memanggil .ToList() terlalu awal

using System.Linq; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System; public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } } public class ProductDbContext : DbContext { public DbSet<Product> Products { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("ProductDb"); } public void SeedData(int count) { for (int i = 1; i <= count; i++) { Products.Add(new Product { Id = i, Name = $"Product {i}", Price = i * 10M }); } SaveChanges(); } } public class Program { public static void Main(string[] args) { using (var _dbContext = new ProductDbContext()) { _dbContext.Database.EnsureDeleted(); _dbContext.Database.EnsureCreated(); _dbContext.SeedData(100); // 100 produk Console.WriteLine("--- Kesalahan Umum: ToList() terlalu awal ---"); // SALAH: Memanggil ToList() di sini akan menarik SEMUA 100 produk ke memori List<Product> allProducts = _dbContext.Products.ToList(); Console.WriteLine($"Jumlah produk ditarik ke memori: {allProducts.Count}"); // Filtering berikutnya dilakukan di memori, BUKAN di database var filteredProducts = allProducts.Where(p => p.Price > 500).ToList(); Console.WriteLine($"Jumlah produk setelah filter in-memory: {filteredProducts.Count}"); foreach (var product in filteredProducts) { Console.WriteLine($"- {product.Name}, Price: {product.Price:C}"); } } } } /* Output: --- Kesalahan Umum: ToList() terlalu awal --- Jumlah produk ditarik ke memori: 100 Jumlah produk setelah filter in-memory: 50 (Meskipun output akhir benar, prosesnya tidak efisien untuk data besar) */

Di baris var allProducts = _dbContext.Products.ToList();, semua produk ditarik ke memori. Filter .Where(p => p.Price > 100) setelahnya tidak akan lagi memengaruhi query database dan hanya akan memfilter koleksi di memori. Selalu usahakan untuk menunda eksekusi query (dengan menunda panggilan .ToList() atau .ToArray()) sampai Anda benar-benar membutuhkan data untuk diolah di aplikasi.

Kapan Menggunakan yang Mana (Trade-off)

  • Gunakan IEnumerable<T> ketika:

    • Anda berurusan dengan koleksi data yang sudah ada di memori aplikasi (misalnya List<T>, Array).
    • Data set yang Anda olah relatif kecil, sehingga memuat seluruhnya ke memori tidak menjadi masalah performa.
    • Anda perlu melakukan operasi LINQ yang kompleks atau menggunakan fungsi C# kustom yang tidak dapat diterjemahkan oleh IQueryProvider ke query sumber data (misalnya, sebuah algoritma pencarian string yang sangat spesifik).
  • Gunakan IQueryable<T> ketika:

    • Anda berinteraksi dengan sumber data eksternal seperti database (melalui ORM seperti Entity Framework), layanan web, atau API.
    • Anda ingin memanfaatkan kemampuan filtering, sorting, dan proyeksi data yang dilakukan langsung di sumber data untuk efisiensi.
    • Anda bekerja dengan dataset yang berpotensi besar, di mana menarik seluruh data ke memori aplikasi akan menyebabkan masalah performa atau penggunaan sumber daya yang tinggi.

Ringkasan: Pilihlah dengan Tepat

Memahami perbedaan antara IEnumerable<T> dan IQueryable<T> adalah fundamental bagi setiap developer C# yang berinteraksi dengan data. IEnumerable<T> cocok untuk koleksi in-memory atau dataset kecil, di mana semua pemrosesan dilakukan secara lokal. Sementara itu, IQueryable<T> adalah pilihan yang jauh lebih efisien dan skalabel untuk berinteraksi dengan sumber data eksternal seperti database, karena ia mendelegasikan eksekusi query ke sumber data itu sendiri.

Selalu prioritaskan penggunaan IQueryable<T> sejauh mungkin dalam rantai query Anda saat berinteraksi dengan database, dan tunda konversi ke IEnumerable<T> (misalnya dengan .ToList()) sampai Anda benar-benar siap untuk mengolah data di dalam memori aplikasi. Ini akan menjadi kunci untuk membangun aplikasi .NET yang responsif dan berkinerja tinggi.

Seri Belajar C# Fundamental:

← Sebelumnya: IEnumerable vs ICollection vs IList di C#

→ Selanjutnya: var vs dynamic vs object di C# (segera terbit)