Devin.KR

LINQ 심화 - 그룹·조인·집계와 지연 실행

개발자KR 조회 0

이 장에서 배우는 것

도서 대출 목록에서 필요한 행을 골라내는 일은 기본서에서 다룬 LINQ 쿼리 식으로 해결할 수 있다. 그러나 운영 보고서를 만들 때는 행을 고르는 것만으로 충분하지 않다. 분야별 대출 건수를 묶어 보고, 대출 기록에 도서 제목을 붙이고, 연체료의 합계와 평균을 계산해야 한다. 같은 쿼리를 화면 표시와 파일 저장에 함께 쓰면 데이터가 언제 읽히는지도 따져야 한다.

앞 장에서 살펴본 람다와 델리게이트는 메서드 구문에서 조건과 계산식을 전달하는 데 쓰인다. 이 장에서는 그 표현을 그룹화, 조인, 집계에 연결한다. 또한 쿼리 변수를 만드는 시점과 결과를 읽는 시점이 다를 수 있음을 확인한다. 예제는 메모리에 저장된 도서와 대출 목록을 대상으로 하며, 데이터베이스 쿼리 공급자의 동작은 범위에 포함하지 않는다.

  • Group By로 같은 키를 가진 행을 묶고 그룹별 통계를 계산한다.
  • Join으로 두 목록을 연결하고 키의 중복과 누락이 결과에 미치는 영향을 설명한다.
  • Aggregate 쿼리 식을 메서드 구문의 집계 연산과 대응시킨다.
  • 지연 실행(deferred execution)과 ToList의 실행 시점을 구분한다.
  • 여러 번 열거할 때 계산과 원본 읽기가 반복되는 상황을 확인한다.

문제 상황

동네 도서관은 도서 목록과 대출 기록을 따로 관리한다. 도서에는 번호, 제목, 분야가 있고 대출 기록에는 기록 번호, 도서 번호, 연체 일수, 연체료가 있다. 대출 목록만 출력하면 담당자는 도서 번호를 보고 제목을 다시 찾아야 한다. 분야별 통계를 계산하려면 대출 기록을 도서 목록과 연결한 뒤 분야가 같은 기록을 모아야 한다.

담당자가 요청한 보고서는 세 부분이다. 첫째는 대출 기록마다 제목과 연체료를 보여 주는 상세 목록이다. 둘째는 분야별 대출 건수와 연체료 합계다. 셋째는 전체 건수, 연체료 합계, 평균, 최솟값, 최댓값이다. 이때 대출 건수는 서로 다른 도서의 수가 아니라 대출 기록의 수다. 같은 도서가 두 번 대출되었다면 두 건으로 센다.

보고서와 함께 연체 대출 목록도 보관하려 한다. 그런데 목록을 표시한 뒤 새로운 대출 기록이 들어오면, 앞서 만든 쿼리의 결과가 달라질 수 있다. 쿼리 변수에 이름을 붙였다는 이유만으로 결과가 저장된 것은 아니다. 보고서를 작성할 때는 집계의 기준뿐 아니라 어떤 시점의 목록을 읽는지도 결정해야 한다.

완성 코드에서는 시작할 때 네 건의 대출을 넣는다. 보고서를 출력한 다음 한 건을 추가하여, 기존 쿼리를 다시 읽은 결과와 ToList로 저장한 결과를 비교한다. 날짜나 외부 파일을 사용하지 않으므로 실행할 때마다 같은 출력이 나온다.

그룹과 조인으로 보고서의 행 만들기

Group By는 키가 같은 행을 묶는다

그룹화(grouping)의 첫 결정은 키다. 분야별 대출 통계라면 분야가 키이고, 회원별 통계라면 회원 번호가 키다. 제목을 키로 쓰면 제목이 같은 서로 다른 도서가 한 그룹에 섞일 수 있다. 보고서가 무엇을 세는지 먼저 정의한 뒤 그 기준을 식으로 옮겨야 한다.

다음 쿼리에서 rows는 도서 정보와 대출 정보를 연결한 행들의 시퀀스라고 가정한다. Group By 뒤의 Category는 결과에 노출할 키 이름이다. Into 뒤에는 그룹을 대상으로 실행할 집계를 적는다. Count는 그룹의 행 수를 세고 Sum은 각 행의 Fee를 더한다.

Dim report =
    From row In rows
    Group By Category = row.Category
    Into LoanCount = Count(), TotalFee = Sum(row.Fee)
    Order By Category

이 결과의 한 행은 원래 대출 한 건이 아니다. 한 분야의 요약이다. 따라서 원래 행에 있던 제목을 요약 행에서 그대로 읽으려 해서는 안 된다. 제목까지 필요하다면 그룹의 구성원을 보존하는 Into Group을 사용하거나, 별도의 상세 목록을 만든다. 분야별 요약과 대출 상세는 서로 다른 결과 형태다.

메서드 구문에서는 GroupBy로 그룹을 만든 뒤 Select로 요약 행을 만든다. 각 그룹의 Key가 분야이고, 그룹 자체를 열거하면 그 분야에 속한 원래 행을 얻는다. 완성 코드에서는 쿼리 식과 메서드 구문으로 만든 보고서가 같은 내용을 담는지 확인한다.

Dim report =
    rows.GroupBy(Function(row) row.Category).
    Select(Function(group) New With {
        .Category = group.Key,
        .LoanCount = group.Count(),
        .TotalFee = group.Sum(Function(row) row.Fee)
    }).
    OrderBy(Function(row) row.Category)

그룹을 만든다고 보고서에 필요한 정렬까지 정해지는 것은 아니다. 화면에 분야 이름순으로 표시하려면 Order By 또는 OrderBy를 별도로 적는다. 이 예제의 완성 코드는 문자열 정렬에 StringComparer.Ordinal을 지정하여 실행 환경의 문화권 설정에 따른 차이를 줄인다.

Join은 같은 키를 가진 행의 조합을 만든다

조인(join)은 두 시퀀스에서 키가 같은 항목을 연결한다. 대출의 BookId와 도서의 Id를 비교하면 대출 행에 제목과 분야를 붙일 수 있다. 쿼리 식에서는 왼쪽 키와 오른쪽 키 사이에 Equals를 쓴다. 일반 조건식의 등호를 대신 넣는 문법이 아니다.

Dim rows =
    From loan In loans
    Join book In books On loan.BookId Equals book.Id
    Select loan.Id, book.Title, book.Category, loan.Fee

메서드 구문의 Join에는 연결할 목록, 바깥쪽 키 선택 함수, 안쪽 키 선택 함수, 결과를 만드는 함수가 순서대로 들어간다. 결과 선택 함수에는 일치한 대출과 도서가 함께 전달된다. 앞 장에서 익힌 두 매개변수 람다가 실제 데이터를 연결하는 위치다.

Dim rows =
    loans.Join(
        books,
        Function(loan) loan.BookId,
        Function(book) book.Id,
        Function(loan, book) New With {
            .Id = loan.Id,
            Key .Title = book.Title,
            Key .Category = book.Category,
            Key .Fee = loan.Fee
        })

이 Join은 내부 조인(inner join)이다. 일치하는 도서가 없는 대출 기록은 결과에서 빠진다. 반대로 도서 목록에 같은 Id가 두 개 있으면 하나의 대출 기록이 두 도서 행과 연결되어 결과가 두 행이 된다. 따라서 조인을 붙인 뒤 합계를 구하려면 키의 유일성과 연결 누락을 먼저 확인해야 한다.

예제의 도서 목록에는 대출 기록이 없는 역사 분야 도서도 있다. 대출을 출발점으로 내부 조인한 결과에는 그 도서가 나타나지 않는다. 그래서 분야별 보고서에도 역사가 없다. 대출이 없는 도서를 0건으로 보여 주는 요구는 Group Join과 기본값 처리 등이 필요한 별도의 설계다. 현재 보고서는 실제 대출 기록이 있는 분야만 요약한다.

대출 기록을 도서와 연결한 뒤 분야로 묶으면 분야별 대출 통계가 된다

그림에서 소설 분야의 세 행은 도서 세 권을 뜻하지 않는다. 같은 도서의 대출 기록이 반복될 수 있기 때문이다. 도서 번호의 종류 수가 필요하다면 그룹 안에서 도서 번호를 선택하고 Distinct로 중복을 제거한 뒤 세어야 한다. 집계 함수가 올바르더라도 집계 대상이 잘못되면 보고서의 의미가 달라진다.

Aggregate 쿼리 식과 집계 메서드

집계(aggregation)는 여러 값을 하나 또는 소수의 요약 값으로 줄이는 작업이다. Group By가 키별로 집계를 나누는 데 비해, Aggregate 쿼리 식은 지정한 시퀀스 전체를 대상으로 통계를 표현할 수 있다. Into에 여러 집계를 쓰면 결과는 그 이름들을 속성으로 가진 하나의 값이 된다.

Dim totals =
    Aggregate loan In loans
    Into LoanCount = Count(),
         TotalFee = Sum(loan.Fee),
         AverageFee = Average(loan.Fee),
         MinimumFee = Min(loan.Fee),
         MaximumFee = Max(loan.Fee)

완성 코드의 Fee는 Decimal이다. 돈의 합계와 평균도 Decimal로 계산한다. 네 건의 연체료는 900, 0, 600, 300원이므로 합계는 1800원이고 평균은 450원이다. 이 평균의 분모는 연체 대출 수가 아니라 전체 대출 수다. 연체 대출만의 평균이 필요하다면 먼저 연체 조건으로 대상을 제한해야 한다.

메서드 구문에서는 Count(), Sum(Function(loan) loan.Fee), Average(Function(loan) loan.Fee)처럼 각각 표현한다. 여러 통계를 하나의 개체로 묶고 싶다면 New With로 속성을 만들 수 있다. 다만 호출을 여러 개 적었다는 사실만으로 원본을 한 번만 읽는다고 해석해서는 안 된다. 원본과 연산에 따라 각각 별도의 작업이 수행될 수 있다.

쿼리 식과 메서드 구문의 주요 대응
목적쿼리 식메서드 구문
키별 그룹Group By … Into GroupGroupBy
키로 연결Join … On … Equals …Join
건수Aggregate … Into Count()Count()
선택한 값의 합Aggregate … Into Sum(식)Sum(선택 함수)
평균과 범위Average(식), Min(식), Max(식)Average, Min, Max

Aggregate라는 이름의 메서드도 있지만, 이것을 Aggregate 쿼리 식의 단순한 동의어로 보면 혼동하기 쉽다. 메서드 Aggregate는 누적값을 다음 항목과 결합하는 함수를 받는다. 예를 들어 초깃값을 0D로 주고 연체료를 차례로 더할 수 있다. 단순 합계에는 Sum이 의도를 더 직접적으로 드러낸다.

Dim totalFee As Decimal =
    loans.Aggregate(
        0D,
        Function(total, loan) total + loan.Fee)

빈 시퀀스의 동작도 구분해야 한다. 이 예제처럼 Nullable이 아닌 Decimal 값을 집계할 때 Sum은 빈 시퀀스에서 0을 반환하지만 Average, Min, Max는 예외를 발생시킨다. 초깃값을 지정한 Aggregate 메서드는 항목이 없으면 그 초깃값을 반환한다. 값이 없다는 상태를 0으로 표시할지, 통계 없음으로 표시할지는 보고서 요구에 맞춰 정해야 한다.

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

Where나 Select로 시퀀스를 만드는 쿼리는 보통 선언 시점에 모든 항목을 읽지 않는다. 변수에는 결과 목록이 아니라 결과를 만들어 낼 연산이 담긴다. For Each, ToList, Count 같은 연산으로 결과가 필요해질 때 원본 읽기가 시작된다. 이것이 지연 실행을 이해하는 출발점이다.

지연 실행과 항목을 조금씩 처리하는 방식은 같은 뜻이 아니다. Where는 조건에 맞는 항목을 찾으면 다음 단계에 넘길 수 있지만, 메모리 시퀀스의 GroupBy와 OrderBy는 결과를 제공하기 전에 원본을 모으는 과정이 필요하다. GroupBy를 변수에 넣었다고 곧바로 실행되지는 않지만, 열거를 시작하면 그룹을 구성하기 위해 원본을 읽는다.

ToList는 그 시점에 시퀀스를 열거하고 결과 항목을 새 List에 담는다. 이후 그 목록을 다시 읽으면 원래 쿼리를 재실행하지 않는다. 따라서 보고서의 행을 여러 번 사용할 때 ToList로 목록을 한 번 만들어 두는 방식이 유용하다. 대신 항목을 보관할 메모리가 필요하며, 뒤늦게 원본에 추가된 항목은 자동으로 반영되지 않는다.

또한 ToList는 개체 내부까지 복사하는 연산이 아니다. Loan이 클래스라면 새 목록에도 같은 Loan 개체의 참조가 들어간다. 원본 목록에 항목을 추가하는 일과 기존 Loan의 Fee를 바꾸는 일은 다르다. 전자는 저장한 목록의 구성에 반영되지 않지만 후자는 같은 개체를 통해 보일 수 있다. 시점별 보고서를 보존하려면 필요한 값을 별도의 결과 행으로 투영하는 방법도 고려해야 한다.

같은 쿼리를 다시 열거하면 원본을 다시 읽지만 ToList로 저장한 목록은 항목 구성을 유지한다

여러 번 열거하는 문제는 결과 차이만이 아니다. 조건 함수가 비싼 계산을 한다면 매번 그 비용을 지불한다. 로그를 남기거나 카운터를 증가시키는 함수라면 그 작업도 반복될 수 있다. 완성 코드의 카운터는 이 사실을 관찰하기 위한 장치다. 실제 보고서 조건은 가능하면 외부 상태를 바꾸지 않는 식으로 작성한다.

어떤 Count 호출은 컬렉션이 이미 가진 개수를 사용할 수 있으므로, 모든 Count가 항상 모든 항목을 순회한다고 일반화해서는 안 된다. 여기서는 호출 횟수를 세는 조건 함수 뒤에 ToList를 붙인다. 목록을 만들려면 조건을 확인해야 하므로 네 항목과 다섯 항목을 읽는 차이를 출력으로 확인할 수 있다.

완성 코드

다음 내용으로 Program.vb를 교체한다. 별도 패키지는 필요하지 않다. 출력 숫자는 문화권을 명시해 형식을 고정한다. 프로그램 끝의 검사는 두 구문으로 만든 보고서의 대응, 집계 결과, 재열거 결과를 확인한다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.Linq

Module Program
    Private Class Book
        Public Property Id As Integer
        Public Property Title As String = String.Empty
        Public Property Category As String = String.Empty
    End Class

    Private Class Loan
        Public Property Id As Integer
        Public Property BookId As Integer
        Public Property OverdueDays As Integer
        Public Property Fee As Decimal
    End Class

    Private Function Number(value As Decimal) As String
        Return value.ToString("0.##", CultureInfo.InvariantCulture)
    End Function

    Private Sub Check(condition As Boolean, message As String)
        If Not condition Then
            Throw New InvalidOperationException(message)
        End If
    End Sub

    Sub Main()
        Dim books As New List(Of Book) From {
            New Book With {
                .Id = 1, .Title = "골목의 봄", .Category = "소설"},
            New Book With {
                .Id = 2, .Title = "작은 별 관찰", .Category = "과학"},
            New Book With {
                .Id = 3, .Title = "강가의 편지", .Category = "소설"},
            New Book With {
                .Id = 4, .Title = "마을의 기록", .Category = "역사"}
        }

        Dim loans As New List(Of Loan) From {
            New Loan With {
                .Id = 101, .BookId = 1, .OverdueDays = 3, .Fee = 900D},
            New Loan With {
                .Id = 102, .BookId = 2, .OverdueDays = 0, .Fee = 0D},
            New Loan With {
                .Id = 103, .BookId = 1, .OverdueDays = 2, .Fee = 600D},
            New Loan With {
                .Id = 104, .BookId = 3, .OverdueDays = 1, .Fee = 300D}
        }

        Dim joinedQuery =
            From loan In loans
            Join book In books On loan.BookId Equals book.Id
            Select New With {
                Key .LoanId = loan.Id,
                Key .BookId = book.Id,
                Key .Title = book.Title,
                Key .Category = book.Category,
                Key .Fee = loan.Fee
            }

        Dim rows = joinedQuery.
            OrderBy(Function(row) row.LoanId).ToList()

        Dim methodRows = loans.Join(
            books,
            Function(loan) loan.BookId,
            Function(book) book.Id,
            Function(loan, book) New With {
                Key .LoanId = loan.Id,
                Key .BookId = book.Id,
                Key .Title = book.Title,
                Key .Category = book.Category,
                Key .Fee = loan.Fee
            }).OrderBy(Function(row) row.LoanId).ToList()

        Check(
            rows.SequenceEqual(methodRows),
            "Join 결과가 일치하지 않는다")

        Console.WriteLine("[대출 상세]")
        For Each row In rows
            Console.WriteLine(
                $"{row.LoanId} | {row.Title} | {Number(row.Fee)}원")
        Next

        Dim groupedQuery =
            From row In rows
            Group By Category = row.Category
            Into LoanCount = Count(), TotalFee = Sum(row.Fee)

        Dim report = groupedQuery.OrderBy(
            Function(row) row.Category,
            StringComparer.Ordinal).ToList()

        Dim methodReport = rows.
            GroupBy(Function(row) row.Category).
            Select(Function(group) New With {
                .Category = group.Key,
                .LoanCount = group.Count(),
                .TotalFee = group.Sum(Function(row) row.Fee)
            }).
            OrderBy(
                Function(row) row.Category,
                StringComparer.Ordinal).ToList()

        Check(
            report.Count = methodReport.Count,
            "그룹 수가 일치하지 않는다")
        For index As Integer = 0 To report.Count - 1
            Check(
                report(index).Category = methodReport(index).Category AndAlso
                report(index).LoanCount = methodReport(index).LoanCount AndAlso
                report(index).TotalFee = methodReport(index).TotalFee,
                "분야별 집계가 일치하지 않는다")
        Next

        Console.WriteLine()
        Console.WriteLine("[분야별 보고서]")
        For Each row In report
            Console.WriteLine(
                $"{row.Category} | {row.LoanCount}건 | " &
                $"{Number(row.TotalFee)}원")
        Next

        Dim totals =
            Aggregate loan In loans
            Into LoanCount = Count(),
                 TotalFee = Sum(loan.Fee),
                 AverageFee = Average(loan.Fee),
                 MinimumFee = Min(loan.Fee),
                 MaximumFee = Max(loan.Fee)

        Dim foldedFee As Decimal = loans.Aggregate(
            0D,
            Function(total, loan) total + loan.Fee)

        Check(totals.LoanCount = 4, "전체 건수 오류")
        Check(totals.TotalFee = 1800D, "합계 오류")
        Check(totals.AverageFee = 450D, "평균 오류")
        Check(totals.MinimumFee = 0D, "최솟값 오류")
        Check(totals.MaximumFee = 900D, "최댓값 오류")
        Check(foldedFee = totals.TotalFee, "누적 합계 오류")

        Console.WriteLine()
        Console.WriteLine("[전체 집계]")
        Console.WriteLine(
            $"건수={totals.LoanCount}, 합계={Number(totals.TotalFee)}원")
        Console.WriteLine(
            $"평균={Number(totals.AverageFee)}원, " &
            $"최소={Number(totals.MinimumFee)}원, " &
            $"최대={Number(totals.MaximumFee)}원")

        Dim predicateCalls As Integer = 0
        Dim overdueQuery = loans.Where(
            Function(loan)
                predicateCalls += 1
                Return loan.OverdueDays > 0
            End Function)

        Console.WriteLine()
        Console.WriteLine("[지연 실행 확인]")
        Console.WriteLine($"선언 직후 조건 호출={predicateCalls}")

        Dim firstRead = overdueQuery.ToList()
        Dim firstCalls As Integer = predicateCalls
        Console.WriteLine(
            $"첫 열거: {firstRead.Count}건, 조건 호출={firstCalls}")

        predicateCalls = 0
        Dim snapshot = overdueQuery.ToList()
        Dim savedCalls As Integer = predicateCalls
        Console.WriteLine(
            $"목록 저장: {snapshot.Count}건, 조건 호출={savedCalls}")

        loans.Add(New Loan With {
            .Id = 105, .BookId = 2, .OverdueDays = 4, .Fee = 1200D})

        predicateCalls = 0
        Dim secondRead = overdueQuery.ToList()
        Dim secondCalls As Integer = predicateCalls
        Console.WriteLine(
            $"추가 후 재열거: {secondRead.Count}건, " &
            $"조건 호출={secondCalls}")

        predicateCalls = 0
        Console.WriteLine(
            $"저장 목록 재사용: {snapshot.Count}건, " &
            $"조건 호출={predicateCalls}")

        Check(firstRead.Count = 3 AndAlso firstCalls = 4,
              "첫 열거 오류")
        Check(snapshot.Count = 3 AndAlso savedCalls = 4,
              "목록 저장 오류")
        Check(secondRead.Count = 4 AndAlso secondCalls = 5,
              "재열거 오류")
        Check(predicateCalls = 0, "저장 목록에서 조건이 실행됨")

        Console.WriteLine()
        Console.WriteLine("검사 통과")
    End Sub
End Module

줄별 해설

조인 결과의 익명 형식은 다섯 속성 모두에 Key를 붙인다. Visual Basic 익명 형식의 값 동등성은 Key 속성을 기준으로 하므로, 두 구문이 만든 서로 다른 객체도 모든 필드의 값이 같으면 SequenceEqual 검사에 통과한다. Key가 없으면 객체 참조를 비교하여 같은 값의 결과도 불일치할 수 있다.

Option과 Imports. Option Strict On은 암시적 축소 변환과 늦은 바인딩을 제한한다. Option Infer On은 LINQ가 만든 익명 형식(anonymous type)의 지역 변수를 추론하도록 한다. System.Linq는 메모리 시퀀스에 대한 LINQ 연산을 제공하고, System.Globalization은 출력 숫자의 형식을 고정하는 데 사용한다.

Book과 Loan. 두 클래스는 조인에 필요한 키와 보고서에 필요한 값만 가진다. 문자열 속성을 String.Empty로 초기화하여 기본 상태를 명시한다. Fee의 Decimal 형식과 900D 같은 리터럴의 D 접미사는 금액 계산에 사용할 형식을 맞춘다. 대출의 Id와 BookId는 역할이 다르다. 전자는 기록 자체의 번호이고 후자는 연결할 도서의 번호다.

Number와 Check. Number는 소수점 이하가 필요할 때만 표시하며 문화권에 따른 숫자 표기 차이를 피한다. Check는 조건이 거짓이면 실행을 중단한다. 일반 예외 처리 방법은 기본서에서 다뤘으므로 여기서는 검사 실패를 숨기지 않고 그대로 드러낸다. 모든 검사가 성공하면 마지막 문장이 출력된다.

books와 loans 초기화. 도서 네 권과 대출 네 건을 메모리에 넣는다. 도서 번호 1은 두 대출에서 사용되고 도서 번호 4는 사용되지 않는다. 이 데이터 배치는 대출 건수와 서로 다른 도서 수의 차이, 내부 조인에서 연결되지 않은 도서가 사라지는 모습을 함께 보여 준다.

joinedQuery와 rows. Join의 키는 loan.BookId와 book.Id다. Select New With는 필요한 값만 가진 결과 행을 만든다. 이 쿼리를 선언한 다음 OrderBy와 ToList로 실행한다. 이후 분야별 통계는 원래 목록을 다시 조인하지 않고 저장한 rows를 읽는다. 상세 목록은 대출 기록 번호순으로 고정된다.

methodRows와 SequenceEqual. 같은 연결을 Join 메서드로 다시 표현한다. 두 결과의 익명 형식은 같은 이름, 순서, 형식의 속성을 사용하므로 같은 형식으로 취급된다. 익명 형식의 기본 값 비교를 이용해 정렬된 두 목록을 SequenceEqual로 확인한다. 이 검사는 예제 데이터에서 두 표현이 같은 행을 만들었는지 확인한다.

groupedQuery와 report. rows의 Category를 키로 삼는다. Into에 적힌 LoanCount와 TotalFee가 요약 행의 속성이 된다. 분야 정렬에는 명시적인 문자열 비교자를 사용한다. 이 데이터에서는 과학이 소설보다 앞에 나온다. 조인에 나타나지 않은 역사 분야는 그룹에도 생기지 않는다.

methodReport와 반복 검사. GroupBy의 각 그룹에서 Count와 Sum을 계산한다. 먼저 그룹 수를 비교하고, 같은 인덱스의 분야, 건수, 합계를 차례로 비교한다. 두 보고서는 동일한 정렬 기준을 사용하므로 위치별 비교가 가능하다. 정렬 기준이 다르면 내용이 같아도 이런 검사는 실패할 수 있다.

totals와 foldedFee. Aggregate 쿼리 식으로 다섯 통계를 계산하고, Aggregate 메서드로 합계를 따로 계산한다. 이 시점은 다섯 번째 대출을 추가하기 전이다. 따라서 화면의 전체 집계는 네 건을 기준으로 한다. 나중에 목록이 바뀌어도 이미 계산된 totals의 숫자가 자동으로 다시 계산되지는 않는다.

overdueQuery. 여러 줄 람다는 조건이 평가될 때마다 predicateCalls를 증가시킨다. 선언만 한 직후에는 조건이 실행되지 않아 0이다. 첫 ToList와 다음 ToList는 각각 원본 네 항목을 검사한다. 각각의 결과는 연체 대출 세 건이지만 계산 과정은 두 번 수행된다.

loans.Add 이후. 원본에 연체 대출 한 건을 추가하고 같은 쿼리를 다시 열거한다. 이번에는 다섯 항목을 검사해 네 건을 얻는다. 반면 snapshot.Count는 저장된 목록의 개수만 읽는다. 원래 조건 함수가 실행되지 않으므로 카운터는 0이다. 목록의 구성과 쿼리의 실행 계획을 구별하는 핵심 부분이다.

실행 결과

.NET 10 SDK가 설치된 macOS 또는 Linux에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 뒤 실행한다.

dotnet new console -lang VB -f net10.0 -n LibraryLinq
cd LibraryLinq
dotnet run

프로그램의 표준 출력은 다음과 같다.

[대출 상세]
101 | 골목의 봄 | 900원
102 | 작은 별 관찰 | 0원
103 | 골목의 봄 | 600원
104 | 강가의 편지 | 300원

[분야별 보고서]
과학 | 1건 | 0원
소설 | 3건 | 1800원

[전체 집계]
건수=4, 합계=1800원
평균=450원, 최소=0원, 최대=900원

[지연 실행 확인]
선언 직후 조건 호출=0
첫 열거: 3건, 조건 호출=4
목록 저장: 3건, 조건 호출=4
추가 후 재열거: 4건, 조건 호출=5
저장 목록 재사용: 3건, 조건 호출=0

검사 통과

첫 열거와 목록 저장은 서로 다른 ToList 호출이다. 결과가 모두 세 건이라는 점보다 조건이 각각 네 번 실행된다는 점을 읽어야 한다. 추가 후의 네 건은 기존 세 건과 새 연체 대출 한 건을 합친 결과다. 앞서 출력한 보고서가 바뀌지 않는 것은 해당 결과를 이미 목록과 숫자로 저장했기 때문이다.

실무에서 자주 틀리는 것

쿼리 변수에 넣으면 결과도 저장된다고 생각한다

다음 코드는 건수 확인과 상세 출력에 같은 쿼리를 사용한다. Where의 조건을 평가하는 작업은 두 사용 지점에서 다시 발생할 수 있다. 두 지점 사이에 원본이 변경되면 표시한 건수와 상세 행 수도 달라질 수 있다.

' 잘못된 예: 같은 시점의 결과라고 가정한다.
Dim query = loans.Where(Function(loan) loan.OverdueDays > 0)
Console.WriteLine(query.Count())
For Each loan In query
    Console.WriteLine(loan.Id)
Next

같은 항목 구성을 재사용하려면 한 번 목록으로 만든 뒤 그 목록의 개수와 행을 사용한다. 한 번만 순회하는 큰 시퀀스라면 목록화가 필요하지 않을 수 있으므로 사용 방식에 맞춰 선택한다.

' 수정: 목록을 만든 시점의 항목 구성을 재사용한다.
Dim items = loans.
    Where(Function(loan) loan.OverdueDays > 0).ToList()
Console.WriteLine(items.Count)
For Each loan In items
    Console.WriteLine(loan.Id)
Next

조인 결과의 합계가 원본 합계와 같다고 가정한다

도서 번호가 중복되면 대출 행이 늘고, 연결할 도서가 없으면 대출 행이 빠진다. 아래 계산은 데이터의 연결 상태를 확인하지 않은 채 원본 연체료 합계와 같은 값이라고 가정한다는 점에서 잘못되었다.

' 잘못된 예: 연결 상태를 확인하지 않고 합산한다.
Dim feeAfterJoin = (
    From loan In loans
    Join book In books On loan.BookId Equals book.Id
    Select loan.Fee).Sum()

보고서의 전제가 도서 번호의 유일성과 모든 대출의 연결이라면 먼저 그 전제를 검사한다. 누락된 기록을 별도 목록으로 보고해야 하는 서비스라면 예외를 던지는 대신 오류 보고서로 분리할 수 있다. 여기서는 전제를 만족하는 데이터만 집계하도록 한다.

' 수정: 키의 중복과 연결 누락을 먼저 확인한다.
Check(
    books.Select(Function(book) book.Id).Distinct().Count() =
    books.Count,
    "도서 번호 중복")

Dim bookIds As New HashSet(Of Integer)(
    books.Select(Function(book) book.Id))
Check(
    loans.All(Function(loan) bookIds.Contains(loan.BookId)),
    "연결할 도서가 없는 대출")

Dim feeAfterJoin = (
    From loan In loans
    Join book In books On loan.BookId Equals book.Id
    Select loan.Fee).Sum()

빈 목록에도 평균이 있다고 생각한다

대출이 없는 날에도 평균 연체료를 계산할 수 있다고 가정하면 Nullable이 아닌 Decimal의 Average에서 예외가 발생한다. 0원은 대출이 존재하면서 연체료가 모두 0인 경우와 구별해야 한다.

' 잘못된 예: 빈 시퀀스를 고려하지 않는다.
Dim emptyLoans As New List(Of Loan)
Dim averageFee = emptyLoans.Average(Function(loan) loan.Fee)

이 보고서에서는 대상이 없으면 평균을 표시하지 않기로 한다. 먼저 계산할 값을 목록으로 만들고 개수를 확인하면, 확인과 계산 사이에 원래 쿼리를 다시 실행하는 일도 피할 수 있다.

' 수정: 대상이 없으면 통계 없음으로 처리한다.
Dim fees = emptyLoans.Select(Function(loan) loan.Fee).ToList()
If fees.Count = 0 Then
    Console.WriteLine("평균 없음")
Else
    Console.WriteLine(Number(fees.Average()) & "원")
End If

ToList가 개체의 모든 값을 복사한다고 생각한다

다음 목록에는 원본과 같은 Loan 개체가 들어 있다. 그래서 원본을 통해 Fee를 바꾸면 저장한 목록에서 읽는 값도 바뀐다. 목록을 저장했으니 금액도 보존되었다고 보는 것이 잘못이다.

' 잘못된 예: saved가 기존 금액까지 보존한다고 가정한다.
Dim saved = loans.ToList()
loans(0).Fee = 0D
Console.WriteLine(saved(0).Fee)

기록 번호와 금액만 보존하면 되는 보고서는 그 값을 새 결과 행으로 투영한다. 여기서는 Integer와 Decimal 값을 담으므로 Loan의 나중 변경에 영향을 받지 않는다. 투영할 값이 또 다른 참조 형식이면 그 내부 상태까지 보존되는지 따로 판단해야 한다.

' 수정: 변경 전에 필요한 값을 새 결과 행에 담는다.
Dim savedValues = loans.Select(
    Function(loan) New With {
        Key .LoanId = loan.Id,
        Key .Fee = loan.Fee
    }).ToList()

loans(0).Fee = 0D
Console.WriteLine(Number(savedValues(0).Fee))

한눈에 보기

보고서를 만들 때 결정해야 할 연산과 실행 시점
연산결과실행 특성확인할 점
Where·Select조건 또는 투영 시퀀스대체로 열거할 때 실행재열거 시 조건과 계산 반복
Join키가 일치한 행의 조합열거할 때 연결 수행키 중복과 연결 누락
GroupBy키별 그룹지연 실행, 그룹 구성에 원본 수집 필요건수의 단위와 결과 정렬
Sum·Average 등계산된 요약 값호출 시 결과 계산빈 시퀀스와 숫자 형식
ToList새 목록호출 시 열거하여 저장메모리 사용과 개체 참조 공유

보고서를 설계할 때는 연결할 키, 묶을 키, 집계할 값, 결과를 확정할 시점을 순서대로 정한다. 문법 선택보다 이 네 가지가 먼저다. 쿼리 식은 데이터 흐름을 읽기 쉽게 보여 주고 메서드 구문은 람다를 통해 각 연산의 입력을 드러낸다. 두 표현을 함께 읽을 수 있으면 기존 코드를 이해하고 필요한 연산을 추가하기가 수월해진다.

이 장에서는 표준 LINQ 연산을 조합하여 결과 시퀀스를 만들었다. 다음 장에서는 확장 메서드와 반복기를 통해 이러한 사용 형태를 직접 제공하는 방법을 살펴본다. 그때도 시퀀스를 만드는 일과 열거하는 일이 구분된다는 관점이 바탕이 된다.

연습 문제

다음 문제는 완성 코드에서 다섯 번째 대출을 추가하기 전의 데이터를 기준으로 한다. 코드 조각은 Sub Main 안에서 해당 변수들이 만들어진 뒤에 넣는다.

  1. rows를 분야별로 묶어 대출 건수와 서로 다른 도서 수를 함께 구하라. 두 수가 다른 분야와 그 이유를 설명하라.
  2. 연체 일수가 0보다 큰 대출만 대상으로 Aggregate 쿼리 식을 작성하라. 건수, 연체료 합계, 평균을 구하고 전체 대출 평균과 비교하라.
  3. overdueQuery 선언 뒤, 최초 열거 전에 loans에 연체 대출 한 건을 추가한다고 가정하라. 최초 결과 건수와 조건 호출 횟수를 예측하라. 먼저 ToList를 호출한 뒤 추가하는 경우와 비교하라.
  4. 도서 번호 2를 가진 도서 행이 books에 하나 더 들어 있다면 내부 조인의 결과 건수와 연체료 합계가 어떻게 바뀌는지 설명하라. 이 예제에서 합계만 검사하는 방식의 한계를 쓰라.

정답과 해설

1. 대출 건수와 도서 수

Dim counts = rows.
    GroupBy(Function(row) row.Category).
    Select(Function(group) New With {
        .Category = group.Key,
        .LoanCount = group.Count(),
        .BookCount = group.
            Select(Function(row) row.BookId).Distinct().Count()
    }).
    OrderBy(Function(row) row.Category, StringComparer.Ordinal).
    ToList()

과학은 대출 1건, 도서 1권이고 소설은 대출 3건, 도서 2권이다. 소설 분야에서 도서 번호 1의 대출 기록이 두 번 나타나기 때문이다. Distinct는 선택한 도서 번호의 중복을 제거한다. 전체 결과 행에 Distinct를 적용하면 대출 번호가 서로 달라 중복 도서 수를 구하는 목적을 달성하지 못한다.

2. 연체 대출만의 집계

Dim overdueLoans = loans.
    Where(Function(loan) loan.OverdueDays > 0).ToList()

Dim overdueTotals =
    Aggregate loan In overdueLoans
    Into LoanCount = Count(),
         TotalFee = Sum(loan.Fee),
         AverageFee = Average(loan.Fee)

연체 대출은 3건이고 합계는 1800원, 평균은 600원이다. 전체 평균 450원에는 연체료가 0원인 대출도 분모에 포함되어 있다. 합계는 같아도 평균의 대상이 다르다. 실제 서비스에서는 연체 대출이 없는 경우를 확인한 뒤 Average를 실행해야 한다. 이 데이터에는 세 건이 있어 바로 계산할 수 있다.

3. 최초 열거 전에 추가한 경우

쿼리 선언만 하고 아직 열거하지 않았다면 추가된 항목도 최초 열거에 포함된다. 원본은 다섯 항목이므로 조건 호출은 5회이고 결과는 연체 대출 4건이다. 선언 시점의 목록이 자동으로 고정되는 것은 아니다.

추가 전에 ToList로 저장하면 저장 목록은 연체 대출 3건이다. 이후 원본에 한 건을 추가해도 그 목록의 구성은 그대로다. 다만 같은 overdueQuery를 다시 열거하면 새 항목을 포함한 4건을 얻는다. 어느 변수를 읽는지에 따라 결과가 달라진다.

4. 중복 키가 만드는 추가 행

도서 번호 2를 참조하는 대출은 102번 한 건이다. 같은 키의 도서가 두 행이면 이 대출이 두 행과 연결되어 전체 조인 결과는 4건에서 5건으로 늘어난다. 그러나 해당 대출의 연체료가 0원이므로 합계는 여전히 1800원이다.

따라서 연체료 합계가 원본과 같다는 검사만으로 조인의 정확성을 확인할 수 없다. 건수, 키의 유일성, 연결 누락을 요구에 맞게 함께 확인해야 한다. 중복 도서의 분야까지 다르면 분야별 건수도 달라질 수 있다. 집계 결과를 검토할 때는 숫자 하나보다 그 숫자를 만든 행의 의미를 먼저 살핀다.

댓글 0

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

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