Visual Basic · 기본
Visual Basic과 .NET 입문
LINQ 쿼리 식 - From…Where…Select
쿼리 식과 메서드 구문, Group By·Order By, 익명 형식, 지연 실행
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 여러 비품을 List(Of T)에 담고, 필요한 항목을 찾아 사용하는 방법을 살펴보았다. 목록이 늘어나면 전체 목록을 보여 주는 일보다 조건에 맞는 일부를 골라 보여 주는 일이 많아진다. 지금 빌릴 수 있는 비품만 보고 싶을 수도 있고, 종류별로 몇 개가 있는지 알고 싶을 수도 있다. 이런 요구를 반복문으로 처리할 수 있지만, 조회 조건과 결과 모양을 한곳에 표현하면 코드를 읽기 쉬워진다.
LINQ(Language Integrated Query)는 컬렉션 등의 데이터를 조회하는 기능을 언어 안에서 사용할 수 있게 하는 기술이다. 이 장에서는 이미 만들어진 비품 목록을 대상으로 검색, 정렬, 묶음 집계를 수행한다. 데이터를 어디에 저장할지는 다루지 않는다. 조회할 목록이 준비되어 있다는 전제에서 출발하며, 파일에 저장하고 다시 읽는 일은 다음 장에서 다룬다.
- From, Where, Select로 조회 대상, 조건, 결과를 표현한다.
- 같은 조회를 쿼리 식과 메서드 구문으로 작성하고 비교한다.
- Order By로 출력 순서를 정하고 Group By로 종류별 현황을 만든다.
- 익명 형식으로 화면에 필요한 값만 묶어서 반환한다.
- 지연 실행과 ToList의 차이를 원본 목록의 변화로 확인한다.
문제 상황
작은 사무실에서 노트북 두 대와 프로젝터 두 대를 관리하고 있다. 각 비품에는 고유 번호, 이름, 종류, 대여 가능 여부가 있다. 담당자는 오전에 대여 가능한 비품을 확인하고, 종류별 전체 수량과 대여 가능한 수량을 보고하려 한다. 같은 날 새 노트북이 등록되므로, 등록 전 보고 내용과 등록 후 현황도 구분해야 한다.
모든 항목을 출력한 다음 사람이 대여 가능 여부를 읽어 구분하는 방식은 목록이 커질수록 번거롭다. 보고서에서 이름과 번호만 필요해도 전체 비품 정보를 넘겨주는 코드가 생긴다. 종류별 집계를 별도의 반복문으로 작성하면 검색 코드와 집계 코드가 함께 길어진다. 이번 프로그램은 이러한 조회 요구를 서로 독립된 식으로 표현한다.
조회에는 세 가지 질문이 들어 있다. 어떤 목록을 볼 것인지, 어떤 항목을 남길 것인지, 남긴 항목에서 무엇을 보여 줄 것인지다. 여기에 출력 순서와 묶음 기준을 더하면 업무에서 흔히 요구하는 목록과 현황표를 만들 수 있다. 프로그램은 조회 결과를 출력할 뿐, 조회 과정에서 비품의 대여 상태를 바꾸지 않는다.
| 요구 | 사용할 기능 | 결과에 담을 값 |
|---|---|---|
| 대여 가능한 비품 목록 | Where, Order By, Select | 번호와 이름 |
| 종류별 보유 현황 | Group By와 집계 | 종류, 전체 수, 가능 수 |
| 등록 전후 비교 | 지연 실행, ToList | 조회 시점별 항목 수 |
조회 조건과 결과 모양을 표현하기
From, Where, Select의 역할
쿼리 식(query expression)은 From, Where, Select 같은 언어 요소로 조회를 적는 문법이다. From은 조회할 데이터와 각 항목을 부를 이름을 정한다. 아래 코드에서 assets는 비품 목록이고, item은 목록을 살펴보는 동안 비품 하나를 가리키는 이름이다. item은 목록 전체를 뜻하지 않으며, 결과를 읽을 때 항목마다 차례로 사용된다.
Dim readyQuery =
From item In assets
Where item.IsAvailable
Order By item.Id
Select New With {
.Id = item.Id,
.Name = item.Name
}
Where는 항목을 남길 조건이다. IsAvailable은 Boolean 속성이므로 그 값 자체를 조건으로 쓸 수 있다. 값이 True인 비품만 결과에 남고, False인 비품은 제외된다. Where는 원본 목록에서 비품을 지우는 기능이 아니다. 원본은 그대로 두고, 결과에서 어떤 항목을 볼지 정한다.
Select는 결과에 담을 값을 정한다. Select item이라고 쓰면 조건에 맞는 비품 객체 자체를 얻는다. Select item.Name이라고 쓰면 이름 문자열만 얻는다. 위 코드는 번호와 이름을 함께 담는 작은 결과 객체를 만든다. 조건은 같더라도 Select를 어떻게 쓰는지에 따라 결과의 데이터 형식이 달라진다.
Order By는 결과를 정렬한다. 위 코드는 번호가 작은 비품부터 보여 준다. 내림차순이 필요하면 Order By item.Id Descending이라고 쓴다. 출력 순서가 업무 요구에 포함되어 있다면 정렬 기준을 코드에 명시한다. 이 장에서는 번호와 종류 코드처럼 숫자로 된 기준을 사용하여 실행 환경의 문자열 정렬 규칙에 기대지 않는다.
조회 식을 읽을 때는 왼쪽에서 오른쪽으로 단어를 외우기보다 각 절의 역할을 연결하는 편이 좋다. assets에서 비품 하나씩을 대상으로 삼고, 대여 가능한 것만 남긴 뒤, 번호순으로 정렬하여 번호와 이름을 내보낸다고 읽는다. 이렇게 읽으면 반복문의 세부 절차보다 조회의 목적이 먼저 드러난다.
메서드 구문으로 같은 조회 쓰기
메서드 구문(method syntax)은 Where, OrderBy, Select 같은 메서드를 이어 호출하는 방식이다. 쿼리 식과 별개의 조회 기술은 아니다. Visual Basic의 쿼리 식은 컴파일 과정에서 대응하는 메서드 호출로 변환된다. 같은 조건과 결과 모양을 사용하면 두 방식으로 같은 조회 결과를 만들 수 있다.
Dim readyMethods = assets.
Where(Function(item) item.IsAvailable).
OrderBy(Function(item) item.Id).
Select(Function(item) New With {
.Id = item.Id,
.Name = item.Name
})
Function(item) item.IsAvailable은 이름을 따로 붙이지 않은 짧은 함수다. 이런 식을 람다 식(lambda expression)이라고 한다. 괄호 안 item은 함수가 받을 항목이고, 그 뒤의 식은 반환할 값이다. Where에는 조건을 판단하는 함수가 필요하므로 Boolean 값을 돌려준다. OrderBy에는 정렬 기준을 선택하는 함수가 필요하므로 번호를 돌려준다.
Select에 전달한 함수는 번호와 이름을 담은 객체를 돌려준다. 점은 앞 단계에서 얻은 결과에 다음 메서드를 연결한다. 줄 끝의 점을 통해 식이 다음 줄로 이어지므로, 각 줄이 별개의 명령이 되는 것은 아니다. 괄호의 짝과 점의 위치를 함께 확인하면 식의 연결 관계를 읽을 수 있다.
짧은 필터와 집계는 메서드 구문이 간결할 수 있고, 여러 조회 절이 이어지는 코드는 쿼리 식이 읽기 편할 수 있다. 어느 쪽이 항상 더 빠르다고 판단해서 고를 필요는 없다. 먼저 같은 요구를 표현하는지 확인하고, 함께 작업하는 사람이 이해하기 쉬운 모양을 선택한다. 이 장에서는 두 구문을 나란히 사용하여 대응 관계를 확인한다.
익명 형식과 형식 추론
New With로 만든 결과 객체는 익명 형식(anonymous type)을 사용한다. 익명 형식은 작성자가 클래스 이름을 따로 선언하지 않아도 컴파일러가 속성들을 묶는 형식을 만들어 주는 기능이다. 위 결과에는 Id와 Name 속성이 있으며, 각 속성의 형식은 대입한 값으로 정해진다. Id에는 Integer 값이, Name에는 String 값이 들어간다.
형식 추론(type inference)은 오른쪽 식을 보고 변수의 형식을 정하는 기능이다. Dim readyQuery처럼 As를 생략했더라도 Option Infer On이면 컴파일러가 조회 결과의 형식을 알아낸다. 익명 형식에는 직접 적을 클래스 이름이 없으므로 이런 방식이 자연스럽다. 완성 코드에서는 Option Strict On과 Option Infer On을 함께 선언한다.
형식을 추론한다는 말은 형식 검사를 생략한다는 뜻이 아니다. 결과에는 Number라는 속성이 없으므로 row.Number를 쓰면 컴파일 오류가 난다. Option Strict On을 유지한 채로도 추론한 속성과 형식을 검사한다. 조회 결과를 담으려고 변수를 Object로 선언할 필요가 없다.
익명 형식은 한 메서드 안에서 잠깐 사용할 조회 결과에 알맞다. 여러 프로시저 사이에 일정한 자료 구조를 전달해야 한다면 이름이 있는 형식을 사용하는 편이 의도를 표현하기 쉽다. 이번 프로그램의 결과는 Main 안에서 곧바로 출력하므로 익명 형식으로 충분하다.
정렬과 묶음 집계
개별 비품 목록만으로는 노트북이 모두 몇 대인지 바로 알기 어렵다. Group By는 같은 기준값을 가진 항목들을 하나의 그룹으로 묶는다. 그룹은 조건에 맞는 항목들을 담은 작은 목록처럼 생각할 수 있다. 묶음의 기준이 되는 값을 키라고 부른다. 이 프로그램은 종류 코드와 종류 이름을 함께 키로 사용한다.
Dim categoryQuery =
From item In assets
Group By categoryCode = item.CategoryCode,
categoryName = item.Category Into Members = Group
Order By categoryCode
Select New With {
.Category = categoryName,
.Total = Members.Count(),
.Available = Members.Count(Function(member) member.IsAvailable)
}
categoryCode와 categoryName은 그룹의 키에 붙인 이름이다. Into Members = Group은 각 그룹에 속한 비품들을 Members라는 이름으로 사용하겠다는 뜻이다. Group By 앞에서 item은 개별 비품을 나타냈지만, 그 뒤에서는 그룹의 키와 Members를 이용해 결과를 만든다. 묶은 뒤에도 이전의 item을 그대로 사용하려고 하면 조회의 범위를 혼동하게 된다.
Members.Count()는 해당 그룹의 전체 항목 수를 구한다. 조건을 전달한 Count는 조건을 만족하는 항목 수만 구한다. 따라서 Members.Count(Function(member) member.IsAvailable)은 그 종류 안에서 대여 가능한 비품의 수다. 이 결과는 개별 비품이 아니라 종류별 요약 한 줄을 나타낸다.
전체 수량과 대여 가능 수량을 함께 보고하려면 그룹을 만들기 전에 대여 중인 비품을 제외하지 않아야 한다. 먼저 Where item.IsAvailable을 쓰면 대여 중인 비품은 그룹에 들어오지 않는다. 그 상태에서 계산한 전체 수량은 보유 수량이 아니라 대여 가능한 수량이 된다. 집계에서는 무엇을 분모에 포함하는지 먼저 결정한다.
두 개의 키를 사용하면 종류 코드와 종류 이름이 모두 같은 항목끼리 묶인다. 같은 코드에 서로 다른 이름이 입력되어 있으면 서로 다른 그룹이 된다. 이번 목록에서는 노트북은 코드 1, 프로젝터는 코드 2로 통일했다. 실제 목록에서도 같은 종류에 같은 값이 들어가도록 관리해야 보고 결과가 갈라지지 않는다.
그룹의 출력 순서는 Order By categoryCode로 지정한다. 코드 1인 노트북 그룹이 먼저, 코드 2인 프로젝터 그룹이 다음에 나온다. 여러 정렬 기준이 필요하면 Order By 뒤에 쉼표로 기준을 더 적을 수 있다. 예를 들어 개별 비품을 종류 코드순으로 정렬한 뒤 같은 종류 안에서 번호순으로 정렬할 수 있다.
Dim orderedAssets =
From item In assets
Order By item.CategoryCode, item.Id
Select item
조회하는 시점과 지연 실행
지연 실행(deferred execution)은 조회 식을 만들 때 곧바로 모든 결과를 계산하지 않고, 결과를 실제로 읽을 때 작업을 수행하는 방식이다. List(Of Asset)에 Where와 Select를 적용한 조회는 대표적인 예다. Dim readyQuery = ...가 실행되면 조회 방법을 나타내는 값이 생기지만, 그 순간의 결과 목록이 별도로 저장되는 것은 아니다.
이런 결과는 보통 IEnumerable(Of T)로 다룬다. 이는 결과 항목을 차례로 읽을 수 있다는 약속을 나타내는 인터페이스다. List(Of T)처럼 모든 항목을 이미 보관한 목록과는 구분해야 한다. For Each로 조회 결과를 순회하면 그때 원본 목록을 읽으면서 조건을 적용한다.
같은 조회 변수를 다시 순회하면 원본을 다시 살펴본다. 첫 순회가 끝난 뒤 원본 목록에 대여 가능한 비품을 추가하면, 다음 순회에는 새 비품도 들어간다. 조회 변수가 원본의 변화와 무관한 보고서 사본이라고 생각하면 수량이 예상과 달라질 수 있다.
ToList는 조회 결과를 즉시 끝까지 읽어 새 List(Of T)에 담는다. 이처럼 결과를 실제 저장된 컬렉션으로 만드는 일을 구체화라고 한다. 이번 프로그램의 readySnapshot은 ToList를 호출했을 때 얻은 결과를 보관한다. 이후 원본에 항목을 추가해도 이미 만들어진 결과 목록의 항목 수는 바뀌지 않는다.
ToList가 모든 객체의 내부까지 복제하는 것은 아니다. Select item으로 비품 객체를 그대로 선택해 목록을 만들었다면, 새 목록도 같은 비품 객체를 참조한다. 원본 목록에 항목을 추가하는 것과 기존 객체의 속성을 바꾸는 것은 다른 문제다. 이 장의 readySnapshot은 번호와 이름 값으로 만든 익명 객체들을 담으므로, 원본 목록의 추가와 기존 비품 속성 변경을 함께 보관하는 설계가 아니다.
지연 실행이라고 해서 모든 처리가 항목 하나씩 즉시 끝나는 것도 아니다. Order By는 정렬을 위해 입력 항목을 모아야 하고, 이 장에서 사용하는 Group By도 그룹을 만들기 위해 입력을 읽는다. 이들은 조회를 정의한 순간에는 실행되지 않지만, 결과를 읽기 시작하면 전체 입력을 살펴볼 수 있다. 실행 시작 시점과 내부에서 필요한 작업량을 구분해야 한다.
완성 코드
다음 내용을 새 콘솔 프로젝트의 Program.vb 전체에 넣는다. Asset 클래스는 앞 장까지 사용한 비품 데이터의 최소 형태다. 조회에 필요한 속성만 유지하여 한 파일에서 실행할 수 있도록 했다. Imports System.Linq는 조회 메서드들을 사용할 수 있게 한다. 이번 프로그램은 사용자 입력을 기다리지 않으며, 모든 출력이 끝나면 종료된다.
Option Strict On
Option Explicit On
Option Infer On
Imports System
Imports System.Collections.Generic
Imports System.Linq
Module Program
Sub Main()
Dim assets As New List(Of Asset) From {
New Asset With {
.Id = 101,
.Name = "노트북 A",
.CategoryCode = 1,
.Category = "노트북",
.IsAvailable = True
},
New Asset With {
.Id = 102,
.Name = "노트북 B",
.CategoryCode = 1,
.Category = "노트북",
.IsAvailable = False
},
New Asset With {
.Id = 201,
.Name = "프로젝터 A",
.CategoryCode = 2,
.Category = "프로젝터",
.IsAvailable = True
},
New Asset With {
.Id = 202,
.Name = "프로젝터 B",
.CategoryCode = 2,
.Category = "프로젝터",
.IsAvailable = False
}
}
Dim readyQuery =
From item In assets
Where item.IsAvailable
Order By item.Id
Select New With {
.Id = item.Id,
.Name = item.Name
}
Dim readyMethods = assets.
Where(Function(item) item.IsAvailable).
OrderBy(Function(item) item.Id).
Select(Function(item) New With {
.Id = item.Id,
.Name = item.Name
})
Console.WriteLine("대여 가능한 비품")
For Each row In readyQuery
Console.WriteLine($"{row.Id}: {row.Name}")
Next
Console.WriteLine("메서드 구문으로 조회한 번호")
For Each row In readyMethods
Console.WriteLine(row.Id)
Next
Dim categoryQuery =
From item In assets
Group By categoryCode = item.CategoryCode,
categoryName = item.Category Into Members = Group
Order By categoryCode
Select New With {
.Category = categoryName,
.Total = Members.Count(),
.Available = Members.Count(Function(member) member.IsAvailable)
}
Console.WriteLine("종류별 현황")
For Each row In categoryQuery
Console.WriteLine(
$"{row.Category}: 전체 {row.Total}, 대여 가능 {row.Available}")
Next
Dim readySnapshot = readyQuery.ToList()
Console.WriteLine("새 비품 등록 전")
Console.WriteLine($"조회 결과 수: {readyQuery.Count()}")
Console.WriteLine($"보관한 결과 수: {readySnapshot.Count}")
assets.Add(New Asset With {
.Id = 103,
.Name = "노트북 C",
.CategoryCode = 1,
.Category = "노트북",
.IsAvailable = True
})
Console.WriteLine("새 비품 등록 후")
Console.WriteLine($"조회 결과 수: {readyQuery.Count()}")
Console.WriteLine($"보관한 결과 수: {readySnapshot.Count}")
Console.WriteLine("등록 후 대여 가능한 비품")
For Each row In readyQuery
Console.WriteLine($"{row.Id}: {row.Name}")
Next
End Sub
End Module
Public Class Asset
Public Property Id As Integer
Public Property Name As String = ""
Public Property CategoryCode As Integer
Public Property Category As String = ""
Public Property IsAvailable As Boolean
End Class
줄별 해설
Option으로 시작하는 세 줄은 파일의 형식 검사와 변수 사용 규칙을 정한다. Option Strict On은 암시적인 축소 변환과 늦은 바인딩 등을 제한한다. 늦은 바인딩은 사용할 멤버를 실행 중에 결정하는 방식이다. 여기서는 조회 결과의 속성을 컴파일할 때 확인한다. Option Explicit On은 선언하지 않은 변수를 사용하지 못하게 하고, Option Infer On은 초기화 식에서 변수 형식을 추론하게 한다.
Imports로 시작하는 세 줄은 Console, List(Of T), LINQ 관련 기능의 이름을 짧게 쓸 수 있도록 한다. Imports는 다른 파일의 내용을 복사해 넣는 명령이 아니다. 사용할 형식과 기능이 들어 있는 이름 공간을 지정한다. 새 프로젝트에 같은 가져오기가 기본 설정으로 있어도 이 파일은 자체적으로 필요한 이름 공간을 드러낸다.
Dim assets As New List(Of Asset) From은 비품 목록을 만들고 초기 항목을 넣는다. 이 줄의 From은 컬렉션 초기화 구문이다. 뒤에서 사용하는 From item In assets는 조회 구문이다. 같은 단어라도 New List 뒤에서 중괄호로 항목을 나열하는지, 항목 이름과 In을 함께 쓰는지에 따라 역할을 구분할 수 있다.
네 개의 New Asset With 블록은 시작 시점의 비품들을 만든다. 노트북 A와 프로젝터 A는 대여 가능하고 나머지 두 비품은 대여 중이다. 종류 코드는 정렬 기준에 사용한다. Id는 개별 비품을 구별하고, CategoryCode는 여러 비품이 공유하는 종류를 구별한다.
Dim readyQuery부터 첫 Select의 닫는 중괄호까지는 대여 가능 목록의 정의다. item의 형식은 원본 목록을 통해 Asset으로 정해진다. 결과에는 Id와 Name만 들어간다. 이 부분에서 Console.WriteLine을 호출하지 않으므로 조회 정의와 화면 출력의 역할도 나뉜다.
Dim readyMethods부터 Select의 닫는 괄호까지는 같은 요구를 메서드 호출로 적은 부분이다. Where에는 대여 가능 조건, OrderBy에는 번호, Select에는 결과 객체를 만드는 함수를 전달한다. 쿼리 식의 Order By는 두 단어이고 메서드 이름 OrderBy는 한 단어라는 점도 확인한다.
첫 두 개의 For Each 블록은 각 조회를 실제로 읽는다. 첫 블록은 번호와 이름을 함께 출력하고, 두 번째 블록은 비교하기 쉽도록 번호만 출력한다. 두 출력에 등장하는 번호는 101과 201로 같다. 원본 목록의 항목 수는 여전히 네 개다.
Dim categoryQuery부터 그룹 결과의 중괄호까지는 비품을 종류별로 묶어 요약한다. Members는 한 종류에 속한 비품들이고, Count의 조건에서 쓰는 member는 그 그룹 안의 비품 하나다. item, Members, member의 역할을 구분하면 전체 목록을 다루는지, 그룹을 다루는지, 항목 하나를 다루는지 알 수 있다.
종류별 현황의 For Each는 그룹 결과를 읽는 시점이다. 이 시점에는 새 노트북을 추가하지 않았으므로 각 종류의 전체 수는 2이고, 대여 가능 수는 1이다. 결과 한 줄에 비품 이름 대신 종류 이름이 나오는 이유는 Select가 요약 객체를 만들었기 때문이다.
Dim readySnapshot = readyQuery.ToList()는 현재 조회 결과를 새 목록에 보관한다. 앞에서 한 번 출력했다고 결과가 자동으로 저장된 것은 아니므로 ToList는 조회를 다시 읽는다. 이 줄이 끝나면 readySnapshot에는 두 개의 결과 객체가 들어 있다.
등록 전의 두 수량 출력에서 readyQuery.Count()는 조회를 읽어 항목 수를 계산하는 메서드다. readySnapshot.Count는 이미 저장된 List의 항목 수를 나타내는 속성이다. 괄호의 유무는 두 변수의 역할 차이를 보여 준다. 이 시점에는 두 값 모두 2다.
assets.Add 블록은 번호 103인 노트북 C를 원본에 추가한다. 추가는 어떤 조회의 For Each가 실행 중인 때가 아니라, 모든 앞선 순회가 끝난 뒤에 수행한다. 목록을 읽는 중에 같은 목록의 구조를 변경하는 문제를 만들지 않도록 위치를 정했다.
등록 후 수량 출력과 마지막 For Each는 원본 변경의 영향을 보여 준다. readyQuery는 현재 원본을 다시 읽으므로 수량이 3이 된다. readySnapshot은 기존 두 결과를 보관한다. 마지막 출력에서는 정렬 기준에 따라 101, 103, 201 순서로 나타난다.
파일 끝의 Asset 클래스는 조회 대상이 가진 값을 정의한다. 문자열 속성을 빈 문자열로 초기화하여 객체를 만들 때 사용할 시작값을 명시했다. 이 장에서는 속성에 값을 넣는 방법보다 그 속성을 조회 조건과 결과에 사용하는 방법에 집중한다.
실행 결과
.NET 10 SDK가 준비된 macOS 또는 Linux 터미널에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 뒤 실행한다. dotnet new가 보여 주는 프로젝트 생성 메시지는 아래에 포함하지 않았다. 마지막 명령 뒤에 제시한 내용은 프로그램의 예상 출력이다.
dotnet new console -lang VB -f net10.0 -o OfficeLinq
cd OfficeLinq
dotnet run
대여 가능한 비품
101: 노트북 A
201: 프로젝터 A
메서드 구문으로 조회한 번호
101
201
종류별 현황
노트북: 전체 2, 대여 가능 1
프로젝터: 전체 2, 대여 가능 1
새 비품 등록 전
조회 결과 수: 2
보관한 결과 수: 2
새 비품 등록 후
조회 결과 수: 3
보관한 결과 수: 2
등록 후 대여 가능한 비품
101: 노트북 A
103: 노트북 C
201: 프로젝터 A
새 비품을 목록 끝에 추가했지만 마지막 출력의 끝에 나오지는 않는다. Order By item.Id가 결과를 번호순으로 정렬하기 때문이다. 종류별 현황은 등록 전에 출력했으므로 노트북의 전체 수가 2로 표시된다. 등록 후 현황이 필요하다면 추가가 끝난 뒤 categoryQuery를 다시 순회해야 한다.
실무에서 자주 틀리는 것
조회 변수에 과거 결과가 저장되었다고 생각한다
다음 코드는 등록 전 이름을 보관하려는 의도와 다르게 동작한다. beforeNames에는 이름 목록의 사본이 아니라 조회 방법이 들어 있다. 원본에 새 비품을 추가한 뒤 읽으면 새 이름도 나타난다. 컴파일 오류가 없어도 조회 시점을 잘못 이해한 코드다.
' 잘못된 예: 등록 전 이름을 보관하려는 코드
Dim beforeNames =
From item In assets
Where item.IsAvailable
Select item.Name
assets.Add(New Asset With {
.Id = 103,
.Name = "노트북 C",
.CategoryCode = 1,
.Category = "노트북",
.IsAvailable = True
})
For Each name In beforeNames
Console.WriteLine(name)
Next
등록 전 결과를 유지하려면 추가 전에 ToList를 호출한다. 괄호는 전체 쿼리 식에 ToList를 적용한다는 범위를 표시한다. 고친 코드는 이름 문자열을 저장하므로 이후 원본 비품의 Name을 바꿔도 이미 저장된 문자열 값은 바뀌지 않는다.
' 고친 예: 이 줄에서 등록 전 이름을 저장한다
Dim beforeNames = (
From item In assets
Where item.IsAvailable
Select item.Name
).ToList()
Select로 바뀐 결과 형식을 놓친다
Select item.Name은 비품 객체가 아니라 문자열을 반환한다. 따라서 반복 변수 name에서 IsAvailable을 읽을 수 없다. 조건을 검사하려면 비품 객체를 사용할 수 있는 Select 이전에 Where를 배치한다.
' 잘못된 예: name은 String이므로 IsAvailable 속성이 없다
Dim names =
From item In assets
Select item.Name
For Each name In names
If name.IsAvailable Then
Console.WriteLine(name)
End If
Next
고친 코드에서는 Where가 Asset의 속성을 읽고, Select가 출력할 문자열을 만든다. 각 단계의 결과 형식을 따라가면 어느 위치에서 어떤 속성을 쓸 수 있는지 판단할 수 있다.
' 고친 예
Dim names =
From item In assets
Where item.IsAvailable
Select item.Name
For Each name In names
Console.WriteLine(name)
Next
필터를 먼저 적용하고 전체 보유 수량이라고 부른다
다음 코드는 대여 가능한 비품만 남긴 뒤 묶는다. Total이라는 이름을 붙였지만 값은 전체 보유 수량이 아니다. 조회 결과의 이름이 업무 의미와 일치하는지 확인해야 한다.
' 잘못된 예: 전체 보유 수량을 구하려는 코드
Dim totals =
From item In assets
Where item.IsAvailable
Group By category = item.Category Into Members = Group
Select New With {
.Category = category,
.Total = Members.Count()
}
전체 목록을 먼저 묶고, 그룹 안에서 서로 다른 기준으로 수를 계산하면 전체와 대여 가능 수량을 함께 얻는다. 아래 코드는 집계에 초점을 맞춘 예이며, 출력 순서가 필요하면 완성 코드처럼 정렬 절도 추가한다.
' 고친 예
Dim totals =
From item In assets
Group By category = item.Category Into Members = Group
Select New With {
.Category = category,
.Total = Members.Count(),
.Available = Members.Count(Function(member) member.IsAvailable)
}
조회 결과를 읽는 도중 원본 목록에서 삭제한다
List를 기반으로 한 다음 조회를 순회하면서 같은 List에서 항목을 제거하면 순회가 정상적으로 계속되지 않을 수 있다. 조회 식이 원본 목록의 사용 규칙을 없애 주지는 않는다. 이 예에서는 삭제 대상을 먼저 별도 목록으로 확정한 뒤 삭제한다.
' 잘못된 예
Dim targets =
From item In assets
Where Not item.IsAvailable
Select item
For Each item In targets
assets.Remove(item)
Next
ToList를 호출한 뒤 targets를 순회하면 순회 대상은 별도 목록이다. 이 목록의 요소는 원본 비품 객체이므로 원본에서 해당 객체를 제거할 수 있다. 이는 목록 구조를 분리하는 사용 예이며, 대여 중인 비품을 삭제할 수 있는지는 실제 업무 규칙으로 따로 정해야 한다.
' 고친 예
Dim targets = (
From item In assets
Where Not item.IsAvailable
Select item
).ToList()
For Each item In targets
assets.Remove(item)
Next
한눈에 보기
| 표현 | 역할 | 확인할 점 |
|---|---|---|
| From item In assets | 대상 목록과 항목 이름 지정 | item은 개별 항목이다. |
| Where 조건 | 조건에 맞는 항목 선택 | 원본에서 항목을 삭제하지 않는다. |
| Order By 기준 | 결과 순서 지정 | Descending은 내림차순이다. |
| Select 식 | 결과에 담을 값 지정 | 결과 형식이 바뀔 수 있다. |
| Group By … Into Members = Group | 같은 키의 항목 묶기 | 묶은 뒤에는 키와 그룹을 사용한다. |
| New With | 이름 없는 결과 형식 만들기 | 필요한 속성을 명시한다. |
| For Each, Count() | 조회 결과 읽기 | 다시 읽으면 원본을 다시 조회할 수 있다. |
| ToList() | 현재 결과를 새 목록에 저장 | 객체 내부까지 복제하지는 않는다. |
조회 코드를 작성할 때는 조건, 결과 모양, 순서, 실행 시점을 차례로 확인한다. 특히 화면에 한 번 출력하는 조회와 특정 시점의 보고 결과를 보관하는 조회는 요구가 다르다. 같은 조회 식이라도 언제 읽고 어디에 저장하는지에 따라 프로그램의 의미가 달라진다.
LINQ는 비품 등록이나 반납 처리 자체를 대신하지 않는다. 이미 가진 목록에서 필요한 정보를 얻는 역할을 맡는다. 기존의 등록·대여·반납 코드와 조회 코드를 분리하면, 상태를 바꾸는 작업과 상태를 보여 주는 작업을 각각 이해하기 쉬워진다.
연습 문제
각 문제는 완성 코드의 assets를 사용한다. 별도 언급이 없으면 노트북 C를 추가하기 전의 네 항목을 기준으로 삼는다. 작성한 조회를 Main 안에서 출력하여 예상 결과와 비교한다.
- 대여 가능한 비품 중 종류 코드가 1인 비품만 선택한다. 번호 내림차순으로 정렬하고 이름만 반환하는 쿼리 식을 작성한다. 등록 전과 노트북 C 등록 후에 각각 어떤 순서로 이름이 나오는지도 적는다.
- 대여 중인 비품을 번호순으로 정렬하여 번호와 이름을 반환하는 메서드 구문을 작성한다. Where에 전달하는 함수가 어떤 값을 반환하는지 설명한다.
- 종류별로 전체 수량과 대여 중인 수량을 구하는 쿼리 식을 작성한다. 종류 코드순으로 정렬하며, 결과에는 종류 이름, 전체 수량, 대여 중인 수량을 담는다.
- readyQuery.ToList()로 저장한 뒤, 새 항목을 추가하는 대신 번호 102의 비품을 대여 가능으로 바꾼다고 가정한다. readyQuery.Count()와 readySnapshot.Count의 값은 각각 무엇인가. 이어서 Select item으로 만든 목록에서도 원본 비품의 속성 변경이 보이는지 설명한다.
정답과 해설
1. 노트북만 선택하고 이름을 반환한다
Dim laptopNames =
From item In assets
Where item.IsAvailable AndAlso item.CategoryCode = 1
Order By item.Id Descending
Select item.Name
For Each name In laptopNames
Console.WriteLine(name)
Next
AndAlso는 두 조건이 모두 참일 때 참이 된다. 첫 조건이 거짓이면 두 번째 조건은 평가하지 않는다. 등록 전에는 노트북 A만 나온다. 노트북 C를 추가한 뒤 같은 조회를 다시 읽으면 번호 103이 101보다 크므로 노트북 C, 노트북 A 순서로 나온다. Select가 이름만 반환하므로 반복 변수 name은 String이다.
2. 대여 중인 비품을 메서드 구문으로 조회한다
Dim borrowed = assets.
Where(Function(item) Not item.IsAvailable).
OrderBy(Function(item) item.Id).
Select(Function(item) New With {
.Id = item.Id,
.Name = item.Name
})
For Each row In borrowed
Console.WriteLine($"{row.Id}: {row.Name}")
Next
IsAvailable이 False인 비품에서 Not item.IsAvailable은 True가 된다. Where에 전달한 함수는 비품을 받으면 Boolean 값을 반환한다. 출력은 102: 노트북 B와 202: 프로젝터 B다. 조건의 결과가 True인 항목을 남긴다는 Where의 규칙은 대여 가능 여부를 조회할 때와 같다.
3. 전체 수량과 대여 중인 수량을 함께 센다
Dim borrowedSummary =
From item In assets
Group By categoryCode = item.CategoryCode,
categoryName = item.Category Into Members = Group
Order By categoryCode
Select New With {
.Category = categoryName,
.Total = Members.Count(),
.Borrowed = Members.Count(Function(member) Not member.IsAvailable)
}
For Each row In borrowedSummary
Console.WriteLine(
$"{row.Category}: 전체 {row.Total}, 대여 중 {row.Borrowed}")
Next
노트북과 프로젝터 모두 전체 수량은 2, 대여 중인 수량은 1이다. 완성 코드의 집계에서 조건만 반대로 바꾸었다. 그룹을 만들기 전에 필터를 적용하지 않았으므로 전체 수량에는 대여 가능 비품과 대여 중인 비품이 모두 포함된다.
4. 목록의 변화와 객체의 변화를 구분한다
처음 저장한 readySnapshot.Count는 2다. 번호 102의 비품을 대여 가능으로 바꾼 뒤 readyQuery.Count()를 호출하면 3이 된다. 조회는 현재 IsAvailable을 검사하지만, 저장한 목록에는 처음 선택한 두 결과만 들어 있으므로 readySnapshot.Count는 계속 2다.
Dim objectSnapshot = (
From item In assets
Select item
).ToList()
assets(1).IsAvailable = True
Console.WriteLine(objectSnapshot(1).IsAvailable)
이 별도 예제의 출력은 True다. objectSnapshot은 새 목록이지만 그 안에는 기존 Asset 객체에 대한 참조가 들어 있다. assets(1)과 objectSnapshot(1)은 같은 비품 객체를 가리킨다. ToList가 원본과 분리하는 것은 결과 목록의 구성이다. 객체 속성까지 독립된 기록으로 남기려면 필요한 값을 별도의 결과 객체로 옮기는 방식 등을 검토해야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.