Devin.KR

LINQ - 데이터 질의를 코드로

개발자KR 조회 6

이 장에서 배우는 것

카페의 주문 목록이 쌓이면 필요한 주문을 찾고, 화면에 표시할 항목을 뽑고, 메뉴별 판매량을 묶는 일이 반복된다. 앞 장에서 다룬 컬렉션은 이 데이터를 담는 그릇이다. 이번에는 그 안에서 원하는 결과를 얻는 과정을 코드로 표현한다. 이 장의 주제인 ‘LINQ - 데이터 질의를 코드로’는 저장된 목록을 어떤 관점으로 읽을지 정하는 방법에 관한 이야기다.

통합 언어 쿼리(Language Integrated Query, LINQ)는 C# 코드 안에서 데이터 질의를 표현하는 기능이다. 여기서는 List<Order>에 담긴 주문을 대상으로 메서드를 이어 붙이는 방식을 사용한다. 데이터베이스 연결이나 별도의 질의 문법은 다루지 않는다. 이미 배운 메서드, 객체, 컬렉션을 이용해 주문 보고서를 만든다.

  • Where로 조건에 맞는 주문만 고른다.
  • Select로 주문을 보고서에 필요한 형태로 바꾼다.
  • GroupBy로 같은 메뉴의 주문을 묶고 OrderBy로 표시 순서를 정한다.
  • 질의를 만드는 시점과 실행하는 시점을 구분하고, ToList로 결과를 보관한다.

문제 상황

동네 카페의 마감 시간이다. 직원은 오늘 받은 주문을 한 목록에 기록했지만, 아직 결제를 마치지 않은 주문도 섞여 있다. 매니저는 결제가 끝난 주문 가운데 수량이 두 잔 이상인 주문을 확인하고 싶어 한다. 주방에서는 메뉴별로 실제 몇 잔을 판매했는지 알고 싶어 한다. 두 보고서는 같은 주문 목록을 읽지만 선택 조건과 결과 모양이 다르다.

반복문으로도 처리할 수 있다. 주문을 하나씩 꺼내 결제 여부를 검사하고, 수량을 검사하고, 출력 문자열을 만들면 된다. 메뉴별 집계에는 사전을 하나 더 준비할 수 있다. 다만 조건, 변환, 집계가 한 반복문에 섞이면 보고서의 목적을 빠르게 읽기 어려워진다. 조건 하나를 바꾸려다가 합계 계산까지 건드리는 일도 생긴다.

LINQ를 사용하면 ‘결제한 주문을 고른다’, ‘수량을 검사한다’, ‘주문 번호로 정렬한다’, ‘표시할 문자열로 바꾼다’라는 단계를 나누어 적을 수 있다. 각 단계의 입력과 출력을 이해하면 요구 사항이 바뀌었을 때 수정할 위치도 분명해진다. 반복문을 모두 없애는 것이 목적은 아니다. 데이터를 고르는 규칙을 읽기 쉽게 표현하는 것이 목적이다.

이번 프로그램에서는 원 단위 금액을 정수로 저장한다. 소수점, 할인, 세금 계산은 포함하지 않는다. 결제 여부는 IsPaid로 나타내고, 주문 하나는 한 메뉴의 수량과 단가를 가진다. 먼저 다섯 건으로 보고서를 만든 뒤 여섯 번째 주문을 추가하여, 이미 작성한 질의와 저장한 결과가 어떻게 달라지는지 확인한다.

조건을 고르고 결과 모양을 바꾼다

LINQ의 여러 메서드는 IEnumerable<T> 형태의 시퀀스(sequence)를 입력으로 받는다. 시퀀스는 원소를 차례로 꺼내 읽을 수 있는 대상이다. 목록처럼 모든 원소가 이미 저장된 대상도 있고, 원소를 요청할 때마다 계산하는 대상도 있다. 따라서 LINQ 결과를 받았다는 사실만으로 새 목록이 만들어졌다고 생각해서는 안 된다.

Where는 원소마다 조건을 검사하여 참인 원소만 통과시킨다. 다음 코드는 결제한 주문을 고르는 규칙이다. 여기서 orders는 주문 목록이며, 짧은 예제들은 뒤에 나오는 완성 코드의 변수와 타입을 사용한다.

var paidQuery = orders.Where(order => order.IsPaid);

order => order.IsPaid는 람다 식(lambda expression)이다. 화살표 왼쪽의 order는 검사할 주문 하나이고, 오른쪽은 그 주문에 대해 계산할 결과다. 이 경우 결과가 bool이므로 필터 조건으로 사용할 수 있다. 변수 이름은 직접 정한다. x처럼 짧은 이름보다 order가 뜻을 파악하기 쉽다.

Where를 지나도 원소의 타입은 Order다. 조건에 맞지 않는 원소를 제외할 뿐, 통과한 주문의 내용을 고치지 않는다. 조건 두 개를 연결하려면 하나의 람다 식에서 논리 연산자를 사용하거나 Where를 두 번 이어 붙일 수 있다.

var largePaidQuery = orders
    .Where(order => order.IsPaid)
    .Where(order => order.Quantity >= 2);

Select는 각 원소를 다른 값으로 변환한다. 주문 전체가 필요하지 않고 출력할 한 줄만 필요하다면 주문 하나를 문자열 하나로 바꾸면 된다. 이처럼 필요한 항목이나 모양을 만들어 내는 작업을 투영(projection)이라고 한다.

var menuNames = paidQuery.Select(order => order.Menu);

이 결과의 원소는 주문 객체가 아니라 문자열이다. 같은 메뉴를 주문한 기록이 두 개라면 메뉴 이름도 두 번 나온다. Select는 중복을 없애는 기능이 아니며, 단순한 변환에서는 입력 원소 하나마다 출력 원소 하나가 생긴다. 조건에 따라 원소 수를 줄이는 역할은 Where에 맡긴다.

단계를 연결할 때는 현재 원소의 타입을 따라가야 한다. 주문을 메뉴 이름으로 바꾼 다음에는 그 문자열에서 Quantity를 읽을 수 없다. 주문의 수량이나 번호가 필요한 필터와 정렬은 주문 객체를 유지하는 동안 처리하고, 화면용 문자열 변환은 뒤에 두는 편이 읽기 쉽다.

각 연산이 원소와 결과에 주는 변화
연산주요 역할결과의 원소원본 변경
Where조건에 맞는 원소 선택입력과 같은 타입없음
Select각 원소를 다른 값으로 변환변환식이 반환하는 타입없음
OrderBy키를 기준으로 오름차순 정렬입력과 같은 타입없음
GroupBy같은 키를 가진 원소 묶기키와 원소들을 가진 그룹없음

표의 ‘원본 변경 없음’은 해당 연산 자체가 목록을 수정하지 않는다는 뜻이다. 람다 식 안에서 별도의 대입이나 메서드 호출로 객체를 바꾸면 그 코드는 실제로 실행될 수 있다. 이 장에서는 람다 식을 값을 검사하고 계산하는 용도로만 사용한다. 질의 안에 데이터 수정 작업을 섞지 않으면 실행 시점도 설명하기 쉬워진다.

Where는 주문을 고르고 Select는 통과한 주문의 모양을 바꾼다

표시 순서를 정하고 메뉴별로 묶는다

OrderBy에는 정렬 기준이 되는 값을 지정한다. OrderBy(order => order.Id)는 주문 번호가 작은 것부터 나열한다. 이 메서드는 정렬된 결과를 돌려주지만 원래 List<Order>의 저장 순서는 바꾸지 않는다. 따라서 반환값을 받아 사용하거나 다른 연산에 연결해야 한다.

정렬 기준의 타입도 결과에 영향을 준다. 정수 주문 번호를 정렬하면 숫자의 크기로 비교하지만, 문자열로 변환한 번호를 정렬하면 문자열의 비교 규칙을 따른다. 화면에서 숫자로 보인다는 이유로 두 정렬을 같은 것으로 취급하면 안 된다. 어떤 값의 순서가 필요한지 먼저 정하고 그 값을 키로 사용한다.

GroupBy는 키가 같은 원소들을 한 묶음으로 만든다. 다음 코드에서 키는 메뉴 이름이다. 결과를 반복하면 주문이 바로 나오는 대신 메뉴 하나에 해당하는 그룹이 나온다. 각 그룹의 Key는 메뉴 이름이고, 그룹 자체를 반복하면 해당 메뉴의 주문들을 읽을 수 있다.

var menuGroups = paidQuery
    .GroupBy(order => order.Menu)
    .OrderBy(group => group.Key, StringComparer.Ordinal);

GroupBy만 호출한다고 메뉴 이름순으로 정렬되지는 않는다. 이 장에서 사용하는 메모리 컬렉션의 그룹은 원본에서 해당 키가 처음 등장한 순서로 나오며, 그룹 안의 주문은 원본에 나타난 순서를 따른다. 화면에 표시할 그룹 순서가 따로 필요하면 위 코드처럼 그룹을 만든 뒤 OrderBy를 연결한다.

문자열 비교에는 StringComparer.Ordinal을 명시했다. 이 비교 방식은 실행 환경의 문화권에 따른 언어별 정렬 규칙 대신 문자열의 코드 단위를 기준으로 비교한다. 이 예제의 메뉴는 ‘라테’, ‘아메리카노’, ‘차’ 순서로 나온다. 사람에게 익숙한 사전식 정렬이 필요한 서비스에서는 그 요구에 맞는 비교 규칙을 별도로 선택해야 한다.

그룹은 집계의 단위가 된다. 완성 코드에서는 보조 메서드인 Count()와 Sum()도 사용한다. Count()는 그룹에 들어 있는 주문 건수를 세고, Sum(order => order.Quantity)는 수량을 더한다. 한 주문에 세 잔이 들어 있을 수 있으므로 건수와 잔 수는 다른 값이다.

매출은 각 주문의 ‘수량 × 단가’를 더해 계산한다. 먼저 잔 수를 모두 더하고 임의의 주문 단가를 곱하는 방식은 같은 메뉴의 가격이 바뀌었을 때 잘못된 결과를 낼 수 있다. 주문마다 기록된 단가로 금액을 계산한 뒤 합하면 각 주문의 거래 금액을 반영할 수 있다.

지연 실행과 결과를 저장하는 시점

지연 실행(deferred execution)은 질의를 작성할 때가 아니라 결과를 실제로 읽을 때 작업을 수행하는 방식이다. Where나 Select를 호출한 변수는 흔히 결과 목록이 아니라 결과를 얻기 위한 규칙을 가리킨다. var를 썼다고 해서 그 변수가 목록으로 바뀌는 것은 아니다. 실제 타입은 오른쪽 식으로 결정된다.

var paidQuery = orders.Where(order => order.IsPaid);
orders.Add(new Order(106, "아메리카노", 1, 3000, true));

foreach (var order in paidQuery)
{
    Console.WriteLine(order.Id);
}

위 코드에서는 질의를 만든 뒤 추가한 주문도 출력 대상에 들어간다. foreach가 질의를 읽기 시작할 때 원본 목록에 새 주문이 들어 있기 때문이다. 질의 변수의 선언 위치와 목록을 실제로 읽는 위치 사이에 어떤 변경이 있었는지가 결과를 좌우한다.

ToList()는 질의를 그 자리에서 끝까지 읽고 결과를 새 목록에 저장한다. 보고서의 기준 시점을 고정하고 싶다면 이 호출 위치를 의도적으로 정한다. 완성 코드에서는 최초 다섯 건 가운데 결제한 주문 네 건을 먼저 목록으로 저장하고, 상세 목록과 메뉴별 집계를 모두 그 목록에서 만든다.

var paidQuery = orders.Where(order => order.IsPaid);
var paidSnapshot = paidQuery.ToList();

이후 원본 orders에 주문이 추가되어도 paidSnapshot의 원소 수는 늘어나지 않는다. 다만 ToList는 객체 전체를 재귀적으로 복제하지 않는다. 참조 형식의 원소라면 새 목록에도 같은 객체의 참조가 들어간다. 수정 가능한 객체의 속성을 바꾸면 두 목록에서 그 변경이 함께 보일 수 있다. ‘목록 구성의 저장’과 ‘객체 내부 상태의 독립적인 복사’를 구분해야 한다.

이 예제의 Order는 위치 매개변수를 가진 record로 선언하며, 주문을 만든 뒤 속성을 수정하지 않는다. 따라서 이번에 관찰할 변화는 원본 목록에 주문이 한 건 더 들어오는 것뿐이다. 이처럼 변하는 대상을 좁히면 지연 실행의 영향을 확인하기 쉽다.

지연 실행이라고 해서 언제나 원소를 하나씩 바로 전달하는 것은 아니다. Where와 Select는 요청받은 원소를 차례로 처리할 수 있다. 반면 여기서 사용하는 OrderBy는 정렬 결과를 내기 위해 입력을 모아야 하며, GroupBy도 첫 그룹을 반환하기 전에 입력 전체를 읽어 그룹을 구성한다. ‘실행을 늦춘다’와 ‘전체 데이터를 모으지 않는다’는 서로 다른 성질이다.

질의를 실행시키는 행동과 결과 보관 여부
코드 또는 행동입력을 읽는 시점이후 사용할 결과
Where, Select 연결반환된 질의를 읽을 때질의 규칙
OrderBy, GroupBy 연결반환된 질의를 읽을 때정렬 또는 그룹화 질의
foreach 실행반복을 진행할 때별도 목록을 만들지는 않음
ToList() 호출호출한 자리에서결과를 담은 새 목록
같은 질의를 다시 읽으면 새 주문이 반영되지만 앞서 저장한 목록의 건수는 유지된다

같은 질의를 두 번 읽으면 조건 검사나 변환도 다시 수행될 수 있다. 원본이 바뀌지 않으면 같은 값이 나올 수 있지만, 이전 실행 결과가 자동으로 보관되는 것은 아니다. 반대로 결과를 모두 ToList로 바꾸면 메모리 사용이 늘어난다. 한 번만 순회할 작업에는 질의를 그대로 쓰고, 동일한 기준의 보고서를 여러 번 만들 때는 결과를 저장하는 선택이 유용하다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 내용으로 바꾼다. 최상위 문 뒤에 Order 타입을 선언하므로 파일 하나로 구성된다. 입력이나 현재 시간, 무작위 값을 사용하지 않으며, 문자열 정렬 기준도 지정하여 실행 결과를 일정하게 유지한다.

using System;
using System.Collections.Generic;
using System.Linq;

var orders = new List<Order>
{
    new(101, "아메리카노", 2, 3000, true),
    new(102, "라테", 1, 4000, true),
    new(103, "아메리카노", 1, 3000, false),
    new(104, "라테", 3, 4000, true),
    new(105, "차", 2, 2500, true)
};

var paidQuery = orders.Where(order => order.IsPaid);
var paidSnapshot = paidQuery.ToList();

var detailLines = paidSnapshot
    .Where(order => order.Quantity >= 2)
    .OrderBy(order => order.Id)
    .Select(order =>
        $"{order.Id} | {order.Menu} | {order.Quantity}잔 | {order.Quantity * order.UnitPrice}원");

Console.WriteLine("[결제 완료 · 2잔 이상]");
foreach (var line in detailLines)
{
    Console.WriteLine(line);
}

var menuGroups = paidSnapshot
    .GroupBy(order => order.Menu)
    .OrderBy(group => group.Key, StringComparer.Ordinal);

Console.WriteLine();
Console.WriteLine("[메뉴별 결제 집계]");
foreach (var group in menuGroups)
{
    int orderCount = group.Count();
    int cupCount = group.Sum(order => order.Quantity);
    int sales = group.Sum(order => order.Quantity * order.UnitPrice);
    Console.WriteLine($"{group.Key} | {orderCount}건 | {cupCount}잔 | {sales}원");
}

Console.WriteLine();
Console.WriteLine("[주문 추가 전후]");
Console.WriteLine($"추가 전 질의: {paidQuery.Count()}건");
Console.WriteLine($"추가 전 저장 목록: {paidSnapshot.Count}건");

orders.Add(new Order(106, "아메리카노", 1, 3000, true));

Console.WriteLine($"추가 후 질의: {paidQuery.Count()}건");
Console.WriteLine($"추가 후 저장 목록: {paidSnapshot.Count}건");

sealed record Order(
    int Id,
    string Menu,
    int Quantity,
    int UnitPrice,
    bool IsPaid);

줄별 해설

처음 세 줄은 사용할 네임스페이스를 가져온다. System은 콘솔과 문자열 비교에, System.Collections.Generic은 목록에, System.Linq는 질의 메서드에 사용한다. 콘솔 템플릿이 일부를 자동으로 가져오더라도 이 파일에서는 의존성을 직접 보여 준다.

var orders = new List<Order>로 시작하는 부분은 원본 주문 다섯 건을 만든다. 각 생성자의 인수는 번호, 메뉴, 수량, 단가, 결제 여부 순서다. 103번만 결제가 끝나지 않았다. 102번은 결제했지만 한 잔짜리 주문이므로 상세 목록에서는 빠지고 메뉴별 집계에는 들어간다. 이 차이가 두 보고서의 조건을 확인하는 기준이다.

paidQuery를 선언하는 줄은 결제 여부를 검사하는 질의를 만든다. 이 줄에서는 주문 네 건을 별도 목록에 담지 않는다. 바로 다음 paidQuery.ToList()가 질의를 읽어 101, 102, 104, 105번을 paidSnapshot에 담는다. 보고서가 사용할 데이터의 범위는 여기서 정해진다.

detailLines의 첫 단계는 저장한 목록에서 수량이 두 잔 이상인 주문을 고른다. 다음 단계는 주문 번호를 오름차순으로 정렬한다. 마지막 단계는 주문 객체를 문자열로 바꾼다. 오른쪽의 곱셈은 현재 주문 한 건의 금액을 계산한다. 이 질의의 결과 원소는 string이므로 아래 반복문의 line도 문자열이다.

첫 번째 Console.WriteLine은 상세 목록 제목을 출력한다. 이어지는 foreach에서 detailLines를 실제로 읽는다. 지금 데이터는 처음부터 번호순이지만, 정렬 규칙을 코드에 남겼기 때문에 원본의 저장 순서를 바꾸더라도 보고서의 번호순 표시를 유지할 수 있다.

menuGroups는 상세 목록의 결과가 아니라 paidSnapshot에서 출발한다. 따라서 두 잔 이상이라는 상세 목록 조건이 집계에 섞이지 않는다. 보고서 사이에서 변수를 재사용할 때는 이름이 편리하다는 이유만으로 앞선 결과를 가져오지 말고, 그 결과에 이미 적용된 조건을 확인해야 한다.

그룹을 순회하는 반복문에서는 주문 건수, 잔 수, 매출을 각각 계산한다. 라테 그룹에는 102번과 104번이 있으므로 두 건이고, 수량은 1과 3을 더해 네 잔이다. 매출은 4000원과 12000원을 더한 16000원이다. 이미 구성된 그룹을 각각 집계하므로 이 세 줄이 원본 목록 전체를 세 번 그룹화하는 것은 아니다.

인수가 없는 Console.WriteLine()은 보고서 사이에 빈 줄을 만든다. 출력 형식을 담당하는 코드와 질의 코드를 나누면, 데이터 선택 조건을 바꾸지 않고 제목이나 구분 기호를 수정할 수 있다. Select 안에서 출력하지 않고 문자열을 반환하도록 한 이유도 여기에 있다.

마지막 출력 구간의 paidQuery.Count()는 필터 질의를 읽어 현재 결제 주문 수를 구한다. 반면 paidSnapshot.Count는 이미 만들어진 목록의 원소 수를 읽는 속성이다. 괄호 유무가 다르며, 이 예제에서 실제로 하는 일도 다르다. 첫 두 출력은 모두 네 건이다.

orders.Add로 106번을 추가한 뒤 같은 표현을 다시 출력한다. 질의는 바뀐 원본을 읽으므로 다섯 건을 반환한다. 저장 목록에는 자동으로 새 원소가 들어가지 않으므로 네 건을 유지한다. 파일 마지막의 record 선언은 주문에 필요한 다섯 값을 묶으며, 이 예제에서는 별도의 동작을 추가하지 않는다.

실행 결과

터미널에서 프로젝트를 만들고 해당 디렉터리로 이동한다. 생성된 Program.cs를 완성 코드로 바꾼 다음 실행한다.

dotnet new console --framework net10.0 -n CafeLinq
cd CafeLinq
dotnet run

프로그램의 예상 출력은 다음과 같다. 상세 목록에는 102번이 없지만 라테 집계에는 그 주문의 한 잔이 포함된다. 마지막 두 줄에서는 원본을 다시 읽는 질의와 앞서 저장한 목록의 차이를 확인할 수 있다.

[결제 완료 · 2잔 이상]
101 | 아메리카노 | 2잔 | 6000원
104 | 라테 | 3잔 | 12000원
105 | 차 | 2잔 | 5000원

[메뉴별 결제 집계]
라테 | 2건 | 4잔 | 16000원
아메리카노 | 1건 | 2잔 | 6000원
차 | 1건 | 2잔 | 5000원

[주문 추가 전후]
추가 전 질의: 4건
추가 전 저장 목록: 4건
추가 후 질의: 5건
추가 후 저장 목록: 4건

실무에서 자주 틀리는 것

다음 코드는 실수를 설명하기 위한 부분 코드다. 각 예제는 서로 독립적이며, 완성 코드에 모두 이어 붙이는 용도가 아니다.

질의 변수에 이전 결과가 저장되었다고 생각한다

다음 코드는 추가 전 주문 수를 기억하려는 의도와 맞지 않는다. before라는 이름을 붙여도 실행 시점이 고정되지는 않는다. Count()를 호출하는 시점에는 새 주문이 이미 들어 있다.

var before = orders.Where(order => order.IsPaid);
orders.Add(new Order(107, "차", 1, 2500, true));
Console.WriteLine(before.Count());

추가 전 주문 목록이 필요하면 변경 전에 결과를 저장한다. 목록 없이 숫자 하나만 필요하다면 변경 전에 Count()를 호출하여 정수 변수에 보관해도 된다. 어떤 결과를 다시 사용할 것인지에 따라 저장 대상을 선택한다.

var before = orders
    .Where(order => order.IsPaid)
    .ToList();

orders.Add(new Order(107, "차", 1, 2500, true));
Console.WriteLine(before.Count);

정렬 메서드가 원본 목록을 바꾼다고 생각한다

다음 코드는 OrderBy의 반환값을 사용하지 않는다. 뒤의 반복문은 여전히 원본 목록을 읽는다. 현재 자료가 우연히 원하는 순서여도 이 코드가 정렬을 적용한 것은 아니다.

orders.OrderBy(order => order.Id);

foreach (var order in orders)
{
    Console.WriteLine(order.Id);
}

정렬된 결과를 변수로 받고 그 결과를 순회한다. 이 방식은 원본의 저장 순서와 보고서의 표시 순서를 분리할 수 있다. Where나 Select도 반환값을 버리면 그 결과를 활용할 수 없다는 점은 같다.

var sortedOrders = orders.OrderBy(order => order.Id);

foreach (var order in sortedOrders)
{
    Console.WriteLine(order.Id);
}

그룹을 만들면 메뉴 이름순으로 나온다고 생각한다

다음 코드는 메뉴를 묶지만 이름순 정렬은 지정하지 않는다. 입력에서 어떤 메뉴가 먼저 나왔는지에 따라 출력 순서가 달라진다. 데이터 추가 방식이 바뀌면 보고서의 줄 순서도 바뀔 수 있다.

var groups = orders.GroupBy(order => order.Menu);

foreach (var group in groups)
{
    Console.WriteLine(group.Key);
}

그룹을 표시할 순서는 그룹의 키를 이용해 명시한다. 그룹 내부 주문까지 번호순으로 표시해야 한다면 그 내부를 읽을 때도 별도의 정렬이 필요하다. 바깥 그룹을 정렬한 것만으로 내부 주문의 순서까지 바뀌지는 않는다.

var groups = orders
    .GroupBy(order => order.Menu)
    .OrderBy(group => group.Key, StringComparer.Ordinal);

foreach (var group in groups)
{
    Console.WriteLine(group.Key);
}

원본을 읽는 반복 중에 원본 목록을 수정한다

다음 코드는 미결제 주문을 지우려는 의도다. 그러나 질의가 원본 List<Order>를 순회하는 동안 같은 목록에서 원소를 제거하면 이후 순회가 실패할 수 있다. 지연 실행된 필터가 원본과 분리된 목록이라고 오해해서 생기는 문제다.

var unpaidQuery = orders.Where(order => !order.IsPaid);

foreach (var order in unpaidQuery)
{
    orders.Remove(order);
}

제거할 대상을 먼저 별도 목록으로 만든 뒤 원본을 수정한다. 이제 반복문이 읽는 목록은 unpaidOrders이고, 수정하는 목록은 orders다. 원본에서 제거해도 반복 대상 목록의 구성은 유지된다.

var unpaidOrders = orders
    .Where(order => !order.IsPaid)
    .ToList();

foreach (var order in unpaidOrders)
{
    orders.Remove(order);
}

이 예제에서는 주문 번호가 서로 다른 기록을 사용한다. 업무에서 주문을 실제로 취소하거나 삭제할 때는 어떤 주문을 같은 주문으로 판단할지도 별도로 정해야 한다. 여기서 확인할 핵심은 대상 선택을 끝낸 다음 원본 목록을 변경하는 순서다.

한눈에 보기

카페 보고서 요구를 LINQ 연산으로 옮기는 기준
요구 사항사용할 코드확인할 점
결제한 주문만 읽기Where(o => o.IsPaid)조건은 참 또는 거짓을 반환한다.
메뉴 이름만 뽑기Select(o => o.Menu)원소 타입이 문자열로 바뀐다.
주문 번호순 표시OrderBy(o => o.Id)반환된 결과를 사용한다.
메뉴별 주문 묶기GroupBy(o => o.Menu)그룹의 키와 내부 원소를 구분한다.
보고서 대상 보관ToList()호출 시점의 원소들을 새 목록에 담는다.
판매 잔 수 계산Sum(o => o.Quantity)주문 건수와 수량 합계는 다르다.

질의를 읽을 때는 세 가지를 확인한다. 출발점이 어느 컬렉션인지, 각 단계에서 원소의 타입과 범위가 어떻게 바뀌는지, 실제로 읽는 시점이 언제인지다. 이 세 가지가 분명하면 필터 조건이 집계에 잘못 섞이거나, 뒤늦은 원본 변경이 보고서에 반영되는 문제를 찾기 쉬워진다.

사실 확인을 더 하고 싶다면 공식 문서의 지연 실행 설명과 GroupBy API 설명에서 실행 방식과 그룹의 순서 보장을 확인할 수 있다.

연습 문제

모든 문제는 완성 코드의 최초 다섯 주문과 paidSnapshot을 기준으로 한다. 답안 코드는 해당 변수가 만들어진 뒤, 파일 끝의 타입 선언보다 앞에 넣는 부분 코드다.

  1. 상세 목록의 수량 조건을 단가 3000원 이상이라는 조건으로 바꾼다. 주문 번호 오름차순으로 정렬하고, Select로 번호 | 메뉴 형식의 문자열을 만든다. 출력될 세 줄도 적는다.
  2. paidSnapshot을 메뉴별로 묶고 메뉴 이름순으로 정렬한다. 각 메뉴의 판매 잔 수만 메뉴: 수량잔 형식으로 출력한다. 라테의 결과가 주문 건수와 다른 이유를 설명한다.
  3. 미결제 주문 질의와 그 질의를 ToList로 저장한 목록을 만든다. 이후 103번 주문을 원본에서 제거하면 질의의 건수와 저장 목록의 건수는 각각 얼마인가. 제거 시점과 실행 시점을 근거로 설명한다.
  4. 주문을 먼저 Select(order => order.Menu)로 바꾼 뒤 Where(name => name.Quantity >= 2)를 연결하면 왜 컴파일되지 않는지 설명하고, 메뉴 이름을 출력하는 올바른 질의를 작성한다.

정답과 해설

1. 단가 조건으로 상세 목록 만들기

var lines = paidSnapshot
    .Where(order => order.UnitPrice >= 3000)
    .OrderBy(order => order.Id)
    .Select(order => $"{order.Id} | {order.Menu}");

foreach (var line in lines)
{
    Console.WriteLine(line);
}

저장 목록에는 결제한 주문만 들어 있다. 그중 차는 단가가 2500원이므로 제외된다. 102번은 수량이 한 잔이지만 이번 조건은 단가를 검사하므로 포함된다. 출력은 다음과 같다.

101 | 아메리카노
102 | 라테
104 | 라테

2. 메뉴별 잔 수 구하기

var groups = paidSnapshot
    .GroupBy(order => order.Menu)
    .OrderBy(group => group.Key, StringComparer.Ordinal);

foreach (var group in groups)
{
    int cups = group.Sum(order => order.Quantity);
    Console.WriteLine($"{group.Key}: {cups}잔");
}

라테 주문은 두 건이지만 각각 한 잔과 세 잔이므로 판매량은 네 잔이다. Count()는 기록 수를 세고 Sum은 지정한 값을 더한다. 출력은 다음과 같다.

라테: 4잔
아메리카노: 2잔
차: 2잔

3. 원본에서 제거한 뒤 결과 비교하기

var unpaidQuery = orders.Where(order => !order.IsPaid);
var unpaidSnapshot = unpaidQuery.ToList();

orders.RemoveAll(order => order.Id == 103);

Console.WriteLine($"질의: {unpaidQuery.Count()}건");
Console.WriteLine($"저장 목록: {unpaidSnapshot.Count}건");

RemoveAll은 List<Order>에서 조건에 맞는 원소를 제거하는 메서드다. 여기서는 원본을 수정하는 역할로 사용했다. 질의는 제거 이후의 원본을 읽으므로 0건이고, 저장 목록은 제거 전에 담은 103번을 계속 가지고 있으므로 1건이다. 원본 목록에서 원소를 제거하는 작업은 다른 목록에서 같은 원소를 자동으로 제거하지 않는다.

질의: 0건
저장 목록: 1건

4. 변환 전에 수량 검사하기

메뉴 이름으로 변환한 뒤의 원소는 string이다. 문자열에는 주문 수량을 뜻하는 Quantity 속성이 없으므로 해당 조건을 사용할 수 없다. 수량 검사를 먼저 하고 메뉴 이름으로 변환하면 된다.

var names = paidSnapshot
    .Where(order => order.Quantity >= 2)
    .OrderBy(order => order.Id)
    .Select(order => order.Menu);

foreach (var name in names)
{
    Console.WriteLine(name);
}

이 질의는 101, 104, 105번을 고른 뒤 메뉴 이름을 출력한다. 조건을 적용할 때 필요한 데이터가 아직 남아 있는지 확인하는 습관은 더 긴 질의를 작성할 때도 도움이 된다.

아메리카노
라테
차

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.