IT개발

C# LINQ와 Lambda 표현식 심층 활용하기

HyochulLab 2025. 3. 17. 13:37

 

LINQ (Language Integrated Query)는 컬렉션 데이터를 조회하고 변환하는 강력한 도구이며, 람다 표현식과 함께 사용하면 더욱 간결하고 유연한 코드를 작성할 수 있습니다.

이 글에서는 LINQ의 고급 메서드 활용, 성능 최적화 기법, 실제 개발 사례, 그리고 쿼리 문법 vs 메서드 문법에 대해 심층적으로 분석합니다.

코드 예제와 함께 단계별로 설명하고, 실무에서 고려해야 할 사항들도 함께 정리합니다.

1. LINQ의 고급 메서드

LINQ에는 기본적인 Where, Select 외에도 보다 복잡한 작업을 위한 고급 메서드들이 있습니다.

여기서는 Join, GroupBy, Aggregate 메서드의 사용법을 살펴보겠습니다.

Join: 여러 컬렉션 조인하기

Join 메서드는 두 개의 컬렉션을 키를 기준으로 합쳐 새로운 결과를 만드는 데 사용됩니다. SQL의 **내부 조인(inner join)**과 유사하게, 두 데이터 집합에서 키 값이 일치하는 요소들만 결과에 포함됩니다 (Join Operations - C# | Microsoft Learn). 만약 첫 번째 시퀀스의 모든 항목을 유지하면서 대응되는 항목이 없을 경우에도 결과에 포함하고 싶다면 GroupJoin을 사용하여 **왼쪽 외부 조인(left outer join)**을 구현할 수 있습니다 (Join Operations - C# | Microsoft Learn).

  • 사용법: Join 메서드는 두 시퀀스를 입력받아 키 선택자와 결과 셀렉터를 람다로 지정합니다.
  • 예제: 학생 리스트와 부서 리스트를 부서 ID로 조인하여 학생의 이름과 소속 부서명을 가져오는 코드입니다.
class Student { public string Name; public int DeptId; }
class Department { public int DeptId; public string DeptName; }

var students = new List<Student> {
    new Student { Name = "Alice", DeptId = 1 },
    new Student { Name = "Bob", DeptId = 2 }
};
var departments = new List<Department> {
    new Department { DeptId = 1, DeptName = "컴퓨터공학과" },
    new Department { DeptId = 2, DeptName = "수학과" }
};

// Join을 사용하여 학생과 부서를 조인
var studentDept = students.Join(
    departments,
    student => student.DeptId,        // 학생의 조인 키
    dept => dept.DeptId,              // 부서의 조인 키
    (student, dept) => new { StudentName = student.Name, Dept = dept.DeptName }
);

foreach (var item in studentDept)
{
    Console.WriteLine($"{item.StudentName} - {item.Dept}");
}
// 출력: Alice - 컴퓨터공학과
//       Bob - 수학과

위 예제에서 students.Join(departments, ...)는 students 컬렉션의 DeptId와 departments 컬렉션의 DeptId가 같은 요소를 묶어서 새로운 익명 객체를 생성합니다. Join의 결과에는 매칭되는 요소가 없는 학생은 포함되지 않습니다 (내부 조인). 만약 모든 학생을 포함하면서 부서 정보가 없는 경우 기본 값을 채우고 싶다면, GroupJoin과 DefaultIfEmpty를 조합하여 처리할 수 있습니다 (즉, 왼쪽 외부 조인 패턴).

GroupBy: 데이터 그룹화하는 방법

GroupBy 메서드는 컬렉션의 요소들을 지정된 키에 따라 그룹으로 묶어줍니다. 각 그룹은 IGrouping<TKey, TElement> 타입으로 표현되며, 공통 키를 가진 요소들을 내부에 포함합니다 (Advanced LINQ you must know in C# .NET :: Articles :: Sergey Drozdov). 이 메서드를 활용하면 데이터를 특정 기준으로 분류하고, 그룹별로 집계 연산을 수행하기 쉬워집니다.

  • 사용법: GroupBy는 키 선택자 함수를 인수로 받아 요소들을 그룹화합니다. 필요에 따라 그룹화 후 결과를 투영(select)하거나 집계할 수도 있습니다.
  • 예제: 상품 리스트를 카테고리별로 그룹화하고, 각 카테고리별 상품 수를 출력하는 코드입니다.
class Product { public string Name; public string Category; public decimal Price; }

var products = new List<Product> {
    new Product { Name = "Laptop", Category = "Electronics", Price = 1500m },
    new Product { Name = "Smartphone", Category = "Electronics", Price = 800m },
    new Product { Name = "Bread", Category = "Food", Price = 2m },
    new Product { Name = "Milk", Category = "Food", Price = 3m }
};

// Category 기준으로 그룹화
var productsByCategory = products.GroupBy(p => p.Category);

foreach (var group in productsByCategory)
{
    Console.WriteLine($"Category: {group.Key}, Count: {group.Count()}");
    foreach (var product in group)
    {
        Console.WriteLine($"  - {product.Name}: {product.Price}");
    }
}
/* 출력:
Category: Electronics, Count: 2
  - Laptop: 1500
  - Smartphone: 800
Category: Food, Count: 2
  - Bread: 2
  - Milk: 3
*/

위 코드에서 products.GroupBy(p => p.Category)는 products 리스트를 Category 값별로 묶어서 그룹 컬렉션을 생성합니다. 각 그룹에는 공통된 Category를 가진 Product 객체들이 포함되며, group.Key를 통해 해당 그룹의 키(여기서는 Category 명)를 얻을 수 있습니다. 또한 group.Count()처럼 LINQ 메서드를 추가로 사용하여 그룹 내 요소 개수, 합계 등의 집계를 구할 수도 있습니다.

참고: GroupBy 연산은 non-streaming 연산에 속합니다. 즉, 그룹화를 수행하려면 전체 소스를 한 번 훑어 모든 데이터를 메모리에 모은 뒤 그룹을 만들어야 합니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn). 따라서 대용량 데이터에 대해 GroupBy를 사용할 때는 메모리 사용량과 성능을 고려해야 합니다.

Aggregate: 데이터를 축약(누적)하는 방법

Aggregate 메서드는 시퀀스의 요소들을 누적 처리하여 단일 결과를 산출합니다. 다른 언어의 "reduce" 기능과 유사하며, 누적 함수와 초기 값을 인수로 받아 순차적으로 요소에 함수를 적용해 나갑니다. Aggregate를 사용하면 일반적인 합계나 평균 뿐만 아니라 사용자 정의 누적 계산도 구현할 수 있습니다.

  • 사용법: Aggregate의 기본 형태는 두 개의 인수를 받습니다: 초기 시드 값누적 함수. 누적 함수는 (누적값, 현재요소) -> 새로운 누적값 형태의 람다입니다. 오버로드를 사용하면 최종 결과를 별도로 선택하는 함수도 지정할 수 있습니다.
  • 예제 1: 정수 리스트의 합계를 Aggregate로 구하는 코드 (단순 합계는 Sum() 메서드로도 구할 수 있지만 Aggregate로 직접 구현해보는 예시입니다).
int[] numbers = { 1, 2, 3, 4 };

// 시드 0에서 시작하여 각 요소를 누적하여 합산
int sum = numbers.Aggregate(0, (acc, n) => acc + n);
Console.WriteLine(sum);  // 출력: 10

여기서 acc는 누적값(Accumulator)을, n은 현재 요소를 나타냅니다. 초기 acc 값을 0으로 설정하고 배열의 앞에서부터 차례로 (acc + n) 연산을 수행하여 최종 합을 구했습니다.

  • 예제 2: 문자열 배열의 요소들을 하나의 문장으로 합치는 코드.
string[] words = { "LINQ", "와", "람다", "표현식" };

// 시드 없이 Aggregate 사용 (첫 번째 요소를 초기값으로 사용)
string sentence = words.Aggregate((acc, w) => acc + " " + w);
Console.WriteLine(sentence);  // 출력: "LINQ 와 람다 표현식"

이 경우 Aggregate에 시드 값을 제공하지 않으면 첫 번째 요소를 초기 누적값으로 삼고 두 번째 요소부터 함수를 적용합니다. 결과적으로 "LINQ 와 람다 표현식"처럼 배열의 단어들이 하나의 문자열로 합쳐졌습니다.

Aggregate는 이처럼 범용적인 축약 연산에 사용되며, Sum, Min, Max, Count 등 LINQ에서 제공하는 전용 집계 함수로도 수행할 수 없는 복잡한 누적 로직이 있을 때 유용합니다. (예: 리스트의 객체들을 한 번에 문자열로 직렬화하기, 여러 필드를 조합해서 계산하기 등).

2. LINQ와 Lambda 표현식의 성능 최적화

LINQ를 활용할 때는 편리함만큼이나 성능에도 신경 써야 합니다.

특히 람다 표현식과 LINQ 쿼리는 내부적으로 **지연 실행(Deferred Execution)**되는 경우가 많기 때문에, 실행 시점과 빈도를 제어하는 것이 중요합니다.

여기서는 LINQ의 실행 방식, 성능 저하 방지 기법, 그리고 대량 데이터 처리 시 주의점을 알아보겠습니다.

실행 방식: 지연 실행 vs 즉시 실행

LINQ 쿼리의 기본 동작은 지연 실행입니다.

즉, 쿼리를 선언해도 즉시 데이터 처리가 일어나지 않고, 그 쿼리를 **열거(enumerate)**하는 순간에 비로소 연산이 수행됩니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn).

예를 들어, var query = numbers.Where(x => x > 0);와 같이 쿼리를 정의하는 코드만으로는 실제로 numbers 컬렉션을 탐색하지 않으며, 이후 query를 foreach나 ToList() 등으로 사용할 때 필터링이 진행됩니다.

지연 실행의 장점은 필요할 때까지 계산을 미루므로 불필요한 연산을 줄일 수 있다는 것입니다 (Advanced LINQ you must know in C# .NET :: Articles :: Sergey Drozdov).

또한 데이터 소스가 변경되었을 경우, 실행 시점에 최신 데이터를 반영할 수도 있습니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn).

반면, 즉시 실행은 쿼리 정의와 동시에 즉각적으로 연산이 수행되는 방식입니다.

LINQ 연산 중 결과를 단일 값(스칼라)으로 반환하는 연산들은 즉시 실행되며, 예를 들어 Count, Max, Min, First 등은 호출하는 그 순간 컬렉션을 열거하여 결과를 산출합니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn) (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn).

또한 ToList(), ToArray() 같은 메서드를 호출하면 결과를 컬렉션으로 즉시 물질화(materialize)하기 때문에, 이 역시 쿼리를 바로 실행시킵니다.

지연 실행 예시:

var numbers = Enumerable.Range(1, 10);
var query = numbers.Where(x => x % 2 == 0);  // 이 순간에는 실행되지 않음

Console.WriteLine("쿼리 정의 완료");       // 이 줄이 먼저 출력됨
foreach (var n in query)
{
    Console.WriteLine(n);                 // 이 시점에 필터링 연산이 수행됨
}
// 출력 순서:
// "쿼리 정의 완료"
// 2
// 4
// 6
// 8
// 10

위 코드는 Where으로 짝수를 걸러내는 쿼리를 정의했지만, 실제 필터링은 foreach 루프를 돌기 시작할 때 이뤄집니다.

이러한 지연 실행 덕분에, 필요하지 않은 경우엔 아예 쿼리가 실행되지 않을 수도 있습니다.

즉시 실행 예시:

var numbers = Enumerable.Range(1, 10);

// Count는 즉시 전체 컬렉션을 열거하여 결과를 반환
int count = numbers.Count(n => n % 2 == 0);
Console.WriteLine("짝수 개수: " + count);  // 이 라인에서 이미 계산 완료

Count(predicate)의 경우 컬렉션을 즉시 열거하면서 조건에 맞는 요소를 카운트합니다 (지연되지 않음).

즉시 실행은 결과를 빨리 얻을 수 있다는 장점이 있지만, 경우에 따라서는 불필요하게 일찍 모든 데이터를 가져와버려 효율이 떨어질 수 있습니다.

성능 저하를 방지하는 방법

LINQ를 사용할 때 성능상의 함정을 피하려면 쓸데없는 반복 열거를 피하고, 적절한 시점에 실행을 강제하는 것이 중요합니다.

  • 한 쿼리를 여러 번 열거하지 않기: 지연 실행되는 IEnumerable 쿼리는 열거할 때마다 매번 새로 평가됩니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn). 따라서 동일한 결과를 여러 번 사용해야 한다면 한 번 ToList()로 물질화하여 메모리에 담아두고 재사용하는 편이 좋습니다. 예를 들어 다음 코드를 보겠습니다:위처럼 evenNumbers 쿼리를 두 번 Count()하면 필터링을 두 번 수행하여 비효율적입니다. 이를 개선하려면 다음처럼 한 번 리스트로 materialize합니다:ToList()를 호출하면 그 순간 쿼리가 실행되어 결과를 리스트에 담습니다. 이후에는 원본 쿼리가 아닌 리스트 (ICollection)에 대해 연산하므로, 같은 결과를 반복해서 사용할 때 원래 쿼리를 다시 계산하지 않습니다.
  • var evenList = numbers.Where(x => x % 2 == 0).ToList(); Console.WriteLine(evenList.Count()); // 리스트는 이미 결과가 계산되어 있으므로 즉시 Count 조회 Console.WriteLine(evenList.Count()); // 재사용, 중복 계산 없음
  • var evenNumbers = numbers.Where(x => x % 2 == 0); Console.WriteLine(evenNumbers.Count()); // 첫 번째 열거: 짝수 개수 계산 Console.WriteLine(evenNumbers.Count()); // 두 번째 열거: 다시 짝수 개수 계산 (중복 작업)
  • 원본 데이터가 변경될 때 주의: 지연 실행의 특성 때문에, 만약 쿼리 정의 후에 원본 컬렉션이 변경되면 실행 시 다른 결과가 나올 수 있습니다 (Classification of Standard Query Operators by Manner of Execution - Visual Basic | Microsoft Learn). 의도하지 않은 동작을 막으려면, 데이터 변경 전후에 쿼리를 분리하거나, 변경 전에 미리 ToList()로 값을 확보해두는 것이 좋습니다.
  • LINQ 쿼리 결과를 재사용할 때: 앞서 언급한 대로, 한 번 계산한 결과를 재사용해야 할 때는 ToList(), ToArray() 등으로 캐싱하세요. 단, EF Core와 같은 IQueryable 소스의 경우 ToList()를 호출하면 데이터베이스 쿼리가 즉시 수행되어 결과를 모두 메모리에 들고옵니다 (c# - LINQ - IEnumerable.ToList() and Deferred Execution confusion - Stack Overflow). 따라서 이후 추가적인 필터링이 필요하다면 처음부터 DB 쿼리에 포함시키는 게 더 낫습니다. (아래 EF Core 부분에서 추가 설명)
  • AsEnumerable()의 활용: LINQ 쿼리를 작성하다 보면, IQueryable(예: DB 쿼리)에서 시작해 IEnumerable(메모리 내 쿼리)로 전환하고 싶은 경우가 있습니다. AsEnumerable()는 IQueryable 소스를 IEnumerable로 취급하도록 만들어주는 메서드입니다. 이를 활용하면 앞부분까지는 DB 등 원본에서 처리하고, 남은 부분은 메모리에서 처리하도록 쿼리를 나눌 수 있습니다. 예를 들어 EF Core에서 지원되지 않는 .NET 함수를 써야 한다면, 지원되는 부분까지는 DB에서 수행하고 .AsEnumerable() 이후 그 함수를 적용하면 됩니다. 또한 대용량 데이터를 다룰 때 ToList()와 달리 AsEnumerable()는 결과를 바로 리스트로 만들지 않고 스트리밍 방식으로 처리하므로, 불필요한 메모리 버퍼링을 피할 수 있습니다 (Two notes on Querying performance are lacking background information · Issue #3420 · dotnet/EntityFramework.Docs · GitHub). EF Core 성능 가이드에서도 "추가적인 LINQ 연산을 이어서 할 계획이라면 ToList()로 모든 결과를 메모리에 담지 말고 AsEnumerable()를 사용하라"고 권장하고 있습니다 (Two notes on Querying performance are lacking background information · Issue #3420 · dotnet/EntityFramework.Docs · GitHub).
  • 다만 AsEnumerable()를 썼다고 해서 아예 메모리를 쓰지 않는 것은 아닙니다. 결국 IEnumerable로 전환한 이후 그 데이터를 열거하면 데이터는 읽혀지기 마련이므로, 메모리 사용을 완전히 피할 수는 없습니다. 차이는 ToList()는 전체 결과를 한꺼번에 리스트화하지만, AsEnumerable() 이후 필요한 만큼씩 처리할 수 있다는 점입니다. 상황에 따라 적절히 선택하세요.
  • 불필요한 ToList() 남용하지 않기: 반대로, 습관적으로 모든 쿼리 끝에 ToList()를 붙이는 것도 성능상 해로울 수 있습니다. 쿼리 결과를 한 번만 사용할 거라면 굳이 리스트로 만들 필요 없이 IEnumerable 그대로 두는 편이 좋습니다. 특히 LINQ 연산을 메서드 체인으로 이어나가는 중간에 ToList()를 호출하면 쿼리 파이프라인이 끊기고 그 지점까지의 결과를 메모리에 적재합니다. 이로 인해 DB 쿼리를 쪼개서 여러 번 실행하거나, 메모리 소비를 증가시키는 결과를 낳을 수 있습니다. 따라서, **"한 번만 열거하고 버릴 데이터"**라면 지연 실행을 그대로 활용하고, **"여러 번 참조해야 할 데이터"**만 즉시 실행으로 가져오는 것이 바람직합니다.
  • 람다 캡처 주의: 가끔 LINQ 람다 내에서 외부 변수를 캡처하여 사용할 때 의도치 않은 메모리 할당이나 클로저 생성으로 성능이 떨어질 수 있습니다. 간단한 값 형식 변수 캡처는 무난하지만, 큰 컬렉션이나 객체를 캡처하여 람다에서 사용할 경우 해당 람다가 실행될 때마다 참조를 유지하므로 유의해야 합니다.

대량 데이터 처리 시 주의할 점

만건, 십만건 이상의 대량 데이터를 LINQ로 처리할 때는 다음 사항들을 고려해야 합니다:

  • LINQ 자체 오버헤드: LINQ to Objects는 편의성을 위해 반복자(iterator)와 여러 추상화 계층을 사용하므로, 전통적인 for 또는 foreach 루프에 비해 약간의 성능 손해가 있습니다 (Should I avoid LINQ for performance reasons?). 마이크로소프트 공식 문서도 "LINQ 문법은 일반적인 foreach 루프보다 대체로 효율이 덜하다"고 언급하고 있으며 (Should I avoid LINQ for performance reasons?), 실제로 간단한 연산을 매우 많은 요소에 반복 적용할 경우 루프가 LINQ보다 빠른 것이 일반적입니다. 예를 들어 1000만 개의 숫자에 대한 단순 합계 연산을 벤치마크하면 LINQ보다 루프가 수십 퍼센트 가량 빠를 수 있습니다 (Should I avoid LINQ for performance reasons?). 그러나 대부분의 일반적인 시나리오에서 이 정도의 차이는 치명적이지 않으며, 코드의 가독성과 개발 생산성을 고려하면 LINQ를 사용하는 이점이 더 큽니다. 성능이 의심되는 매우 큰 데이터셋에는 직접 루프를 사용하거나 Span 등의 구조를 고려해볼 수 있습니다.
  • 단일 컬렉션 여러 번 처리 피하기: 앞서 설명한 것처럼, 대량 데이터를 다룰 때 동일한 쿼리를 두 번 실행하면 그만큼 중복으로 시간이 소요됩니다. 예를 들어 100만 개짜리 리스트를 Where로 필터링한 다음 그 결과를 또 다른 Where나 OrderBy로 처리하면, 가능하면 하나의 LINQ 연산 체인으로 합쳐 한 번만 열거하도록 쿼리를 구성하세요. LINQ 쿼리는 지연 실행으로 인해 체인에 연결된 연산들을 한 번의 열거로 처리할 수 있으므로, 필터를 따로따로 하면 두 바퀴 돌 것을 한 바퀴로 줄일 수 있습니다.
  • 메모리 사용: LINQ의 일부 연산 (GroupBy, OrderBy, Reverse 등)은 내부적으로 전체 데이터를 담거나 정렬하기 위해 추가 메모리를 사용합니다. 대량 데이터에 이러한 연산을 적용하면 메모리 부하가 커질 수 있으므로, 메모리 용량을 고려하거나 필요하면 스트리밍 알고리즘을 직접 구현하는 것이 좋습니다. 예를 들어 매우 큰 시퀀스를 그룹핑해야 한다면, 메모리에 다 올리지 않고 부분처리하는 방법을 고민해야 합니다 (LINQ의 기본 GroupBy는 한 번에 다 모으므로 메모리 부담).
  • PLINQ (Parallel LINQ) 활용: 계산량이 많은 대용량 데이터 처리는 병렬화를 고려할 수 있습니다. .NET의 PLINQ (AsParallel())를 사용하면 여러 CPU 코어를 활용하여 LINQ 쿼리를 병렬로 실행할 수 있습니다. 단, 병렬 실행은 context switching 오버헤드와 병렬처리 비용이 있기 때문에, 데이터량이 충분히 크거나 계산이 충분히 복잡한 경우에만 이득을 보며, 그렇지 않으면 오히려 더 느려질 수도 있습니다. 또한 병렬 실행 시 결과의 순서보장이 안 되거나 별도의 처리가 필요하므로 (예: AsOrdered()), 사용하는데 주의가 필요합니다.
  • IQueryable vs IEnumerable 구분: 대용량 데이터를 데이터베이스에서 가져오는 경우, 가능하면 LINQ to SQL/Entities의 IQueryable 기능을 최대한 활용하는 것이 좋습니다. 즉, 서버 측 필터링/집계를 활용하고 필요한 데이터만 가져오도록 하며, 클라이언트 측에서는 최소한의 처리만 수행하는 것입니다. 다음 실무 예제에서 더 자세히 다루겠습니다.

요약하면, LINQ는 일반적으로 충분히 빠르지만 아주 큰 데이터에 연산을 반복 적용하는 상황에서는 그 동작 방식을 이해하고 최적화해야 합니다. 지연 실행을 활용하여 필요한 순간까지 계산을 늦추되, 중복 계산은 피하고, 필요할 때 적절히 즉시 실행으로 전환하면서, 가능하면 한 번의 패스로 처리하도록 쿼리를 구성하는 것이 성능 최적화의 핵심입니다.

3. 실제 개발 사례 및 실무 적용 예제

LINQ와 람다 표현식은 실무 개발에서 다양한 시나리오에 활용됩니다. 이번에는 데이터를 필터링하고 변환하는 예제, **데이터베이스 (EF Core)**와 함께 사용하는 경우, 그리고 API 응답 데이터 처리 사례를 살펴보겠습니다.

데이터 필터링과 변환: 컬렉션 처리 예제

가장 기본적이면서도 빈번한 LINQ 활용 예는 컬렉션에서 특정 조건의 데이터를 필터링하고 원하는 형태로 **변환(투영)**하는 것입니다. 가령, 다음과 같은 시나리오를 가정해봅시다:

예시 시나리오: 여러 개의 사용자 객체 중 활성 상태인 사용자만 추려서, 각 사용자의 이름과 이메일 주소로 이루어진 간단한 DTO 리스트를 만들고 싶다.

이 요구사항을 LINQ 없이 구현한다면 보통 반복문을 돌며 if로 거른 후 새 리스트에 추가하는 코드를 작성할 것입니다. LINQ를 사용하면 이를 훨씬 간결하게 표현할 수 있습니다:

// 가정: User 클래스에는 IsActive, Name, Email 등의 속성이 있다.
List<User> users = GetAllUsers();

// LINQ를 사용한 필터링(Where)과 변환(Select)
var activeUserDtos = users
    .Where(u => u.IsActive)                        // 활성 사용자만 필터링
    .Select(u => new { Name = u.Name, Email = u.Email })  // 새로운 익명 객체로 변환
    .ToList();  // 즉시 실행하여 List로 materialize

// 결과 확인
foreach (var user in activeUserDtos)
{
    Console.WriteLine($"{user.Name} - {user.Email}");
}

Where 절로 IsActive가 true인 사용자만 걸러내고, Select 절로 필요한 속성만 가진 익명 타입을 생성했습니다.

마지막에 ToList()를 호출하여 즉시 실행 및 리스트화를 했는데, 이는 예를 들어 이 결과를 뷰에 바인딩하거나 여러 번 사용할 때 유용합니다.

한편, 단순히 한 번 출력하고 버릴 거라면 ToList()를 생략하고 IEnumerable 상태로 둘 수도 있습니다.

이처럼 LINQ의 메서드 체인을 사용하면 읽기 쉽고 직관적인 방식으로 데이터 필터링 로직을 표현할 수 있습니다.

또한 필요에 따라 중간에 OrderBy, Take, Skip 등을 추가하여 정렬이나 페이징 처리도 연계할 수 있습니다.

데이터베이스와 함께 LINQ 사용: EF Core 연계

Entity Framework Core와 같은 ORM은 LINQ를 사용하여 데이터베이스 질의를 작성할 수 있도록 합니다.

LINQ로 작성한 람다 기반 쿼리가 DB 플랫폼에 맞는 SQL로 번역되어 실행되므로, 익숙한 C# 문법으로 데이터베이스를 액세스할 수 있는 장점이 있습니다.

다만, LINQ to Entities에서는 지원되는 구문과 함수에 제한이 있습니다.

LINQ 쿼리는 표현식 트리(Expression Tree)로 변환되어 EF Core의 IQueryable 프로바이더가 해석하게 되는데, 이 표현식 트리에서 허용되는 C# 구문에 제약이 있을 수 있고, 각 DB 공급자마다 추가 제약을 둘 수 있습니다 (Join Operations - C# | Microsoft Learn). (예를 들어, .NET상의 복잡한 메서드 호출이나 특정 DateTime 연산 등은 SQL로 변환되지 않을 수 있습니다.)

 

예제: EF Core에서 LINQ 사용
간단한 온라인 상점 데이터베이스가 있고, Orders와 Customers 테이블을 EF Core로 불러온 Order와 Customer 엔터티로 매핑했다고 가정하겠습니다.

다음 코드는 특정 상태의 주문과 관련 고객 이름을 조인하여 가져오는 LINQ 쿼리입니다:

using(var context = new AppDbContext())
{
    var pendingOrders = context.Orders
        .Where(o => o.Status == "Pending")
        .Join(context.Customers,
              order => order.CustomerId,
              customer => customer.Id,
              (order, customer) => new { OrderId = order.Id, CustomerName = customer.Name })
        .ToList();

    // 결과 사용
    foreach (var item in pendingOrders)
    {
        Console.WriteLine($"Order {item.OrderId} by {item.CustomerName}");
    }
}

위 쿼리는 SQL로 변환되어 실행될 때, WHERE와 JOIN이 포함된 하나의 SQL문으로 DB에서 처리됩니다.

즉, 애플리케이션 메모리로 가져오는 데이터는 조건에 맞는 주문 + 고객 매칭 결과뿐이므로 효율적입니다.

 

예제: EF Core에서 GroupBy와 집계
EF Core 3.x부터는 대부분의 GroupBy도 데이터베이스 측에서 번역이 가능합니다. 예를 들어, 각 제품 카테고리별 총 매출액을 구하는 쿼리는 다음과 같이 작성할 수 있습니다:

using(var context = new AppDbContext())
{
    var salesByCategory = context.Products
        .GroupBy(p => p.Category)
        .Select(g => new 
        { 
            Category = g.Key, 
            TotalSales = g.Sum(p => p.Price) 
        })
        .ToList();

    foreach(var group in salesByCategory)
    {
        Console.WriteLine($"{group.Category}: {group.TotalSales}");
    }
}

이 쿼리도 DB 측에서 SELECT Category, SUM(Price) ... GROUP BY Category와 같이 실행되어 각 카테고리별 합계를 반환합니다.

LINQ를 통해 복잡한 SQL 작성 없이도 손쉽게 집계 쿼리를 만들 수 있음을 보여줍니다.

 

실무 팁 (EF Core 연계):

  • 가능하면 데이터베이스가 할 수 있는 일은 데이터베이스에게 맡기는 것이 좋습니다. 즉, Where, Join, GroupBy, Sum 등은 최대한 LINQ 식으로 작성하여 하나의 SQL로 만들고, .ToList() 등의 호출은 최종 결과가 비교적 작아졌을 때 하는 것이 바람직합니다. 불필요하게 .AsEnumerable()이나 .ToList()를 너무 일찍 호출하면 데이터베이스에서 가져올 데이터 양이 많아지고 성능이 저하될 수 있습니다.
  • 위 상황과 반대로, DB에서 처리하지 못하는 작업은 적절히 쿼리를 두 단계로 나누는 것이 필요합니다. 예를 들어 SQL로 변환 불가능한 C# 함수를 써서 필터링해야 한다면, 우선 DB에서 기본 데이터만 .ToList() 또는 .AsEnumerable()로 가져온 후, 메모리 내에서 해당 함수를 적용해 2차 필터링을 합니다. 이렇게 하면 전체 데이터를 다 가져오지 않으면서도 필요한 부분만 클라이언트에서 처리할 수 있습니다.
  • EF Core 쿼리는 지연 실행되므로, DB 지연 로드(Lazy Loading)와 착각하지 않도록 합니다. 즉, LINQ 쿼리를 만든 뒤 .ToList()를 호출하거나 열거할 때 DB에 질의가 날아갑니다. 쿼리를 여러 번 열거하면 매번 DB 질의가 반복되므로, 동일 쿼리를 두 번 이상 사용해야 하면 한 번 결과를 리스트로 받아 재사용해야 합니다 (c# - LINQ - IEnumerable.ToList() and Deferred Execution confusion - Stack Overflow) (이는 앞서 일반 LINQ 성능 최적화와 같은 맥락입니다).
  • 트랜잭션 범위DB 연결 수명에 유의하세요. 지연 실행인 LINQ 쿼리를 나중에 평가할 때, 이미 DbContext가 dispose되었다면 예외가 발생합니다. 따라서 필요한 경우 .ToList()로 미리 로드하거나, Context 수명을 해당 쿼리 사용 시점까지 유지해야 합니다.

API 응답 데이터 처리 예제

현대적인 애플리케이션에서는 REST API 호출 등의 외부 데이터 소스를 다루는 경우가 많습니다.

보통 JSON 형태로 데이터를 받고 .NET의 객체나 컬렉션으로 디시리얼라이즈(deserialize)한 다음, 필요한 처리를 하게 됩니다.

LINQ는 이러한 API 응답 데이터 처리에도 유용합니다.

 

예제 시나리오: 어떤 외부 API로부터 제품 리스트를 받아왔다고 가정해봅시다.

응답에는 다양한 제품 정보가 들어있는데, 우리 애플리케이션에서는 이 중 재고가 있는 제품들만 선별하여 가격 정보만 담긴 별도의 리스트를 만들려고 합니다.

// API 호출 결과를 가정한 제품 리스트
List<ProductDto> apiProducts = FetchProductsFromApi();  
// ProductDto에는 Name, Price, InStock 등의 프로퍼티가 있다고 가정

// 재고가 있는 제품의 이름과 가격을 추출
var availableProducts = apiProducts
    .Where(p => p.InStock)                       // 재고 있는 제품만 필터
    .Select(p => new { p.Name, p.Price })        // 이름과 가격만 선택
    .ToList();

// 결과 활용 (예: 콘솔 출력)
foreach (var item in availableProducts)
{
    Console.WriteLine($"{item.Name}: ${item.Price}");
}

위 코드에서는 API로부터 받아온 apiProducts 리스트를 즉시 메모리에 가지고 있으므로 LINQ의 Where와 Select를 사용해 간단히 필터링 및 변환을 수행했습니다.

이렇게 하면 별도의 루프를 작성하지 않고도 원하는 데이터를 추출할 수 있어 코드가 깔끔해집니다.

또 다른 예로, 두 개의 서로 다른 API에서 받은 데이터를 합치는 경우에도 LINQ를 활용할 수 있습니다.

예를 들어, 사용자 정보 리스트와 주문 리스트를 각각 API로부터 받아온 뒤, 이를 조인하여 사용자별 주문 목록을 만든다거나, 여러 소스의 데이터를 하나로 Zip(병합)하는 경우에도 LINQ 연산 (Join, GroupJoin, Zip 등)을 사용할 수 있습니다. LINQ는 데이터 소스가 무엇이든 (IEnumerable 형태만 된다면) 일관된 방법으로 다룰 수 있기 때문에, 파일에서 읽은 데이터이든, API 응답이든, 데이터베이스에서 가져온 컬렉션이든 동일한 패턴으로 처리할 수 있다는 장점이 있습니다.

 

실무 팁 (API 데이터 처리):

  • 대량의 API 응답 데이터를 처리할 때도, 메모리상의 리스트에 LINQ를 남발하면 성능에 영향이 있을 수 있습니다. 예를 들어 10,000건 이상의 항목에 대해서 복잡한 LINQ 연산을 여러 차례 적용한다면, 필요에 따라 병렬 처리나 Batch 처리를 고려하세요. 다만, 대부분의 API 응답은 그 정도로 크지 않은 경우가 많습니다.
  • LINQ 연산은 불변(immutable) 데이터를 다루는 경향이 있습니다. 즉, LINQ로 얻은 결과는 새로운 시퀀스나 객체로 생성되고 원본 데이터를 변경하지 않습니다. API로 받은 객체를 직접 변경해야 하는 경우 (예: 응답 객체를 바로 수정) LINQ 대신 foreach로 처리하는 편이 더 명확할 수 있습니다. 예를 들어 모든 항목의 값을 업데이트하는 작업은 products.ForEach(p => p.Price *= 1.1m);처럼 리스트의 ForEach 메서드를 사용하거나 그냥 반복문을 쓰는 것이 LINQ보다 나을 수 있습니다. LINQ는 주로 조회용 또는 변환용으로 사용하는 것이 좋습니다.
  • LINQ 결과를 다시 API로 보내야 한다면, 즉시 실행 (ToList, ToArray)을 통해 현실적인 자료구조(List 등)로 바꿔주는 것이 안전합니다. IEnumerable 상태로 두면 혹시 모를 지연 실행으로 나중에 문제가 생기지 않도록, API 응답에 사용될 객체 리스트는 완전히 materialize해두는 것이 좋습니다.

4. 쿼리 문법과 메서드 문법의 성능 비교

C# LINQ에는 두 가지 스타일의 쿼리 작성 방식이 있습니다: **쿼리 식 구문(Query Syntax)**과 메서드 체인 구문(Method Syntax). 예를 들어 동일한 동작을 하는 LINQ 쿼리를 아래와 같이 두 가지 방식으로 쓸 수 있습니다.

// Query Syntax 예시 (SQL-like한 문법)
var queryResult = from s in students
                 join d in departments on s.DeptId equals d.DeptId
                 where s.Name.StartsWith("A")
                 select new { s.Name, d.DeptName };

// Method Syntax 예시 (메서드 체인 문법)
var methodResult = students
    .Join(departments, s => s.DeptId, d => d.DeptId, (s,d) => new { s.Name, d.DeptName })
    .Where(x => x.Name.StartsWith("A"));

두 방식 모두 students와 departments 컬렉션을 조인하고 이름이 "A"로 시작하는 학생만 걸러내는 동일한 작업을 수행합니다.

쿼리 문법은 SQL 질의와 유사한 형태로 가독성이 좋고, 특히 여러 조인이나 그룹핑이 있을 때 코드의 구조를 한눈에 파악하기 쉽습니다.

메서드 문법은 메서드 호출을 연결하는 방식으로, 익숙해지면 간결하고 연쇄 호출을 통한 함수형 프로그래밍 스타일에 가깝습니다.

개발 스타일에 따라 선호가 갈릴 수 있지만, 성능 면에서 두 문법은 거의 차이가 없습니다.

C# 컴파일러는 쿼리 식 구문을 메서드 호출 형태로 변환하여 컴파일하기 때문입니다 (c# - LINQ Lambda vs Query Syntax Performance - Stack Overflow).

다시 말해, 우리가 쿼리 문법으로 쓴 코드는 결국 람다를 사용하는 메서드 체인으로 동일하게 바뀌어 실행됩니다.

따라서 동일한 작업을 한다면 쿼리 방식이든 메서드 방식이든 실행 효율은 동일하다고 볼 수 있습니다 (c# - LINQ Lambda vs Query Syntax Performance - Stack Overflow).

그러나 문법의 차이로 인해 코드 구조가 달라지면서 생기는 미세한 성능 차이가 존재할 수는 있습니다.

예를 들어, 쿼리 식에서는 지원되지 않아 메서드로 처리하는 부분이나, 혹은 메서드 체인을 잘못 구성하여 중간에 불필요한 반복이 생기는 경우 등입니다.

한 가지 흔한 예로, Count를 계산하는 두 가지 방식을 들 수 있습니다:

  • 방식 A: var countA = sequence.Where(x => 조건).Count();
  • 방식 B: var countB = sequence.Count(x => 조건);

겉보기엔 둘 다 동일한 결과를 내지만, A방식은 Where로 필터링한 결과를 만들고 나서 Count()를 호출하므로 내부적으로 두 번 열거를 수행할 수 있습니다. 반면 B방식은 조건에 맞는 요소를 세는 동작을 한 번의 열거로 끝냅니다. 따라서 B방식이 약간 더 효율적입니다.

실제로 벤치마크를 해보면 미세한 차이가 있고, 이는 Where().Count() 호출이 Count(predicate)로 최적화되지 않았기 때문입니다.

이런 차이는 쿼리 문법 vs 메서드 문법의 문제라기보다는 LINQ 연산 구성의 차이입니다. (참고로 쿼리 식에서 동일한 작업을 하려면 별도의 into나 let 등을 써야 해서 번거롭기 때문에, 이런 경우 메서드 체인이 선호되기도 합니다.)

 

성능 테스트 및 벤치마크:
일반적으로 단순한 LINQ 쿼리에 대해서는 문법의 형태가 성능에 영향을 주지 않습니다.

즉, from x in collection where ... select ...으로 쓰나 collection.Where(...).Select(...)로 쓰나 결과도 같고 실행 시간도 같습니다.

실제로 간단한 필터+변환 쿼리를 각각 두 문법으로 수백만 번 반복 실행해봐도 통계적으로 유의미한 차이가 나지 않았습니다.

다만, 앞서 언급한 특정 케이스 (예: Count 최적화나, query syntax로 복잡하게 표현해서 중간에 임시 객체가 생기는 경우 등)를 제외하면 거의 동일하다고 봐도 무방합니다.

 

코드 유지보수성과 가독성 비교:

  • 쿼리 문법의 장점: SQL에 익숙한 개발자나 복잡한 조인/그룹이 필요한 경우 쿼리 식이 더 읽기 쉬울 수 있습니다. 예를 들어 2개 이상의 컬렉션을 조인하고 그룹화한 뒤 필터링하는 복잡한 로직도 쿼리 식으로 쓰면 마치 SQL 쿼리를 보듯 직관적으로 파악할 수 있습니다. 또한 쿼리 문법 내에서는 into 키워드를 사용하여 중간 결과에 이름을 붙이거나 계속 이어서 사용할 수 있고, let을 사용해 서브쿼리 결과를 변수에 담아 재사용하는 등 복잡한 질의를 구성하는 데 유용합니다. 이러한 특징 때문에 비즈니스 로직상 질의의도가 명확하게 드러나야 하는 코드에서는 쿼리 문법이 가독성에 유리합니다.
  • 메서드 문법의 장점: 모든 LINQ 연산을 표현할 수 있고 (일부 복잡한 연산은 쿼리 문법으로 표현하기 어려울 때가 있지만 메서드 체인은 언제나 가능합니다), 익숙해지면 오히려 간결합니다. 또한 디버깅 시에 각 메서드 호출별로 중간 결과를 쉽게 살펴볼 수 있고, 메서드 체인을 끊어서 조건부로 연산을 추가하기도 수월합니다. 예를 들어, 조건에 따라 .Where(...)를 체인에 붙일지 말지 결정할 때 메서드 문법은 if문으로 추가하기 쉽지만, 쿼리 문법은 이를 표현하기 까다롭습니다. 이런 유연성 때문에 동적으로 쿼리를 구성하거나 조건부로 연산을 적용할 때는 주로 메서드 체인을 사용합니다.

결론적으로, 성능 측면에서는 쿼리 문법과 메서드 문법에 큰 차이가 없으므로, 프로젝트와 팀의 성향, 그리고 쿼리의 복잡도에 따라 가독성이 높은 방식을 선택하면 됩니다. 일부 복잡한 시나리오에서는 두 방식을 혼용할 수도 있습니다. 가령 기본 구조는 쿼리 식으로 짠 뒤, 쿼리 식으로 표현하기 어려운 부분(예: 메서드 체인 전용 함수 호출)은 중간에 메서드로 삽입하는 방식입니다. 중요한 것은 최종적으로 동일한 동작을 한다면 둘 중 편한 방식을 쓰되, LINQ 쿼리 자체를 효율적으로 구성하는 것이지 문법 형태에 얽매일 필요는 없다는 점입니다.


정리하며:
C#의 LINQ와 람다 표현식은 데이터를 다루는 데 있어 강력한 추상화 도구를 제공합니다.

Join, GroupBy, Aggregate와 같은 고급 메서드를 사용하면 복잡한 데이터 조작도 간결하게 구현할 수 있으며, 지연 실행 등의 특성을 이해하고 활용하면 성능을 최적화할 수 있습니다.

실무에서 LINQ를 사용할 때는 쿼리의 실행 시점을 항상 염두에 두고, 필요한 경우 즉시 실행으로 전환하여 중복 작업을 피하는 것이 중요합니다.

또한 LINQ to Entities (EF Core 등)를 사용할 때는 서버와 클라이언트 작업의 균형을 맞추고, 지원되지 않는 시나리오에 대비해 적절히 쿼리를 분리해야 합니다 (Join Operations - C# | Microsoft Learn).

마지막으로, LINQ 쿼리 작성 스타일은 성능보다 가독성과 유지보수성의 문제로 접근하는 것이 좋습니다.

팀 내에서 일관된 스타일을 사용하는 한편, 각자의 장단점을 이해하고 상황에 맞게 적용하면 LINQ와 람다 표현식이 주는 생산성을 최대한 활용할 수 있을 것입니다.

 

참고 자료:

LINQ 표준 연산자와 구현 방식에 대한 마이크로소프트 문서 (Join Operations - C# | Microsoft Learn) (Join Operations - C# | Microsoft Learn),

성능 고려사항 (Should I avoid LINQ for performance reasons?), 그리고 여러 실무 팁은 Stack Overflow 답변 (c# - LINQ Lambda vs Query Syntax Performance - Stack Overflow)과 EF Core 성능 가이드 (Two notes on Querying performance are lacking background information · Issue #3420 · dotnet/EntityFramework.Docs · GitHub)를 참조하였습니다.