C#

[C#] LINQ

sunlight-dby 2025. 5. 12. 21:28

 

'이것이 C#이다 개정판'의 chapter 15를 정리한 글입니다.

 


 

LINQ : Linguage INtegrated Query

LINQ는 C# 언어에 통합된 데이터 질의 기능을 말합니다.

데이터 질의라고 하면, 데이터에 대해 물어본다는 의미이고 기본적인 질문은 다음과 같습니다.

  • From : 어떤 데이터 집합에서 찾을 것인가?
  • Where : 어떤 값의 데이터를 찾을 것인가?
  • Select : 어떤 항목을 추출할 것인가?

LINQ의 사용 예시

class Profile
{
    public string Name { get; set; }
    public int Height { get; set; }
}

Profile[] arrProfile =
{
    new Profile() {Name = "AAA", Height = 186 },
    new Profile() {Name = "BBB", Height = 158 },
    new Profile() {Name = "CCC", Height = 172 },
    new Profile() {Name = "DDD", Height = 178 },
    new Profile() {Name = "EEE", Height = 171 }
};

 

다음과 같은 코드가 있을 때, arrProfile에서 Height 프로퍼티가 175 미만인 데이터만 골라 새 컬렉션으로 추출해야 한다면 아래와 같은 코드를 작성해야 합니다.

List<Profile> profiles = new List<Profile>();
foreach (Profile profile in arrProfile)  // arrProfile 안에 있는 각 데이터로부터
{
    if (profile.Height < 175)            // Height가 175 미만인 객체만 골라 profiles에 넣은 후
        profiles.Add(profile);
}

profiles.Sort(                           // 키의 오름차순으로 정렬
    (profile1, profile2) =>
    {
        return profile1.Height - profile2.Height;
    });

 

LINQ를 이용하면 위의 작업을 보다 논리정연하면서도 간결하게 작성이 가능합니다.

var profiles = from    profile in arrProfile  // arrProfile 안에 있는 각 데이터로부터
               where   profile.Height < 175   // Height가 175 미만인 객체만 골라
               orderby profile.Height         // 키순으로 정렬하여
               select  profile;               // profile 객체를 추출

 


from

모든 LINQ 쿼리식은 반드시 from 절로 시작합니다.

쿼리식의 대상이 될 데이터 원본(Data Source)과, 데이터 원본 안에 들어 있는 각 요소 데이터를 나타내는 범위 변수(Range Variable)를 from 절에서 지정해줘야 합니다.

from 범위_변수 in 데이터_원본

 

이때 from의 데이터 원본은 아무 형식이나 사용할 수 없고, IEnumerable<T> 인터페이스를 상속하는 형식이어야만 합니다.

배열이나 컬렉션 객체들은 IEnumerable<T>를 상속하기 때문에 이들은 모두 from 절의 데이터 원본으로 사용할 수 있습니다.

 

범위 변수는 쿼리 변수(Query Variable)라고도 하는데,

foreach 문의 반복 변수를 생각하면 이해하기 쉽지만, 분명한 차이점이 존재합니다.

 

foreach 문의 반복 변수는 데이터 원본으로부터 데이터를 담아내지만, 범위 변수는 실제로 데이터를 담지는 않습니다.

그래서 쿼리식 외부에서 선언된 변수에 범위 변수의 데이터를 복사해 넣는 일은 할 수 없습니다.


where

where는 필터 역할을 해주는 연산자입니다.from 절이 데이터 원본으로부터 뽑아낸 범위 변수가 가져야 하는 조건을 where 연산자에 인수로 입력하면 LINQ는 해당 조건에 부합하는 데이터만을 걸러냅니다.


orderby

orderby는 데이터의 정렬을 수행하는 연산자입니다.

orderby profile.Height
orderby profile.Height ascending
orderby profile.Height descending

 

orderby 연산자는 기본적으로 오름차순으로 데이터를 정렬하지만, 명시적으로 ascending 키워드를 사용할 수 있습니다.

내림차순으로 정렬할 시에는 orderby ~ descending을 이용할 수 있습니다.


select

select 절은 최종 결과를 추출하는 쿼리식의 마침표 같은 존재입니다. 따라서 select 절로 LINQ 쿼리 구문이 끝나야만 합니다.

 

※ LINQ 쿼리 구문은 select 절로 끝나야 하지만, group by into 절로도 끝날 수 있습니다.
    즉, LINQ 쿼리 구문은 반드시 select 절 또는 group by into 절로 끝나야 합니다.

 

from 절에서 데이터 원본으로부터 범위 변수를 뽑아내고 where 절에서 이 범위 변수의 조건을 검사한 후, 그 결과를 orderby 절에서 정렬하고 select 문을 이용하여 최종 결과를 추출해내는 것입니다.

var profiles = from    profile in arrProfile 
               where   profile.Height < 175
               orderby profile.Height     
               select  profile;

 

[반환 형식]

이렇게 var 형식으로 선언된 profiles은 C# 컴파일러가 var 형식을 LINQ 쿼리식이 반환할 결과 형식에 맞춰 컴파일을 해줍니다.

 

LINQ 질의 결과는 IEnumerable<T>로 반환되는데, 이때 형식 매개변수 T는 select 문에 의해 결정됩니다.

 

위의 선언된 profiles의 LINQ 쿼리식은 IEnumberable<Profile> 형식으로 반환됩니다.

 

만약 다음과 같이 select 문에서 Profile 객체 전체가 아닌 string 형식의 Name 프로퍼티만 추출하면 profiles는 IEnumerable<string> 형식으로 컴파일됩니다.

var profiles = from    profile in arrProfile 
               where   profile.Height < 175
               orderby profile.Height     
               select  profile.Name;

 

[무명 형식의 반환]

select 문은 무명 형식을 이용해서 새로운 형식을 즉석에서 만들어낼 수도 있습니다.

var profiles = from    profile in arrProfile 
               where   profile.Height < 175
               orderby profile.Height     
               select  new { Name = profile.Name, InchHeight = profile.Height * 0.393 };

 


여러 개의 데이터 원본에 질의하기

LINQ 쿼리식은 데이터 원본에 접근하기 위해 from 절을 사용합니다. 여러 개의 데이터 원본에 접근하려면 이 from 문을 중첩해서 사용하면 됩니다.

from 첫_번째_범위_변수 in 첫_번째_데이터_원본
from 두_번째_범위_변수 in 두_번째_데이터_원본(첫 번째 범위 변수의 요소를 기반으로 결정됨)

 

[예제]

using System;
using System.Linq;

namespace FromFrom
{ 
    class Class
    {
        public string Name { get; set; }
        public int[] Score { get; set; }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Class[] arrClass =
            {
                new Class(){Name = "연두반", Score = new int[]{ 99, 80, 70, 24 } },
                new Class(){Name = "분홍반", Score = new int[]{ 60, 45, 87, 72 } },
                new Class(){Name = "파랑반", Score = new int[]{ 92, 30, 85, 94 } },
                new Class(){Name = "노랑반", Score = new int[]{ 90, 88, 0,  17 } }
            };

            var classes = from c in arrClass
                          from s in c.Score
                          where s < 60
                          orderby s
                          select new { c.Name, Lowest = s };

            foreach (var c in classes)
                Console.WriteLine($"낙제 : {c.Name} ({c.Lowest})");
        }
    }
}

실행 결과


group by

LINQ 질의식은 group by 절을 통해 데이터를 분류합니다.

group A by B

 

A에는 from 절에서 뽑아낸 범위 변수를, B에는 분류 기준을 위치시킵니다.

 

group by 절은 분류 기준으로 데이터 원본의 요소들을 그룹화하며, 그 결과는 IGrouping<TKey, TElement> 타입으로 이루어진, 키와 해당 키에 속하는 요소들의 하위 컬렉션이 됩니다.

 

group by 절을 사용하면 LINQ 질의식이 종료되며, 이 뒤의 다른 표준 쿼리 절을 연결할 수 없습니다.

이어서 연결하고 싶을 경우에는 쿼리의 결과를 변수에 담은 후에야 해당 변수에 대해 추가적인 LINQ 메서드를 적용할 수 있습니다.

 

[예시]

int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var groupedNumbers = from num in numbers
                     group num by num % 2;
                     
foreach (var group in groupedNumbers) // groupedNumbers는 IEnumerable<IGrouping<int, int>> 타입
{
    Console.WriteLine($"Key: {group.Key} (0=짝수, 1=홀수)");
    foreach (var number in group)
    {
        Console.Write($"{number} ");
    }
    Console.WriteLine();
}

 

해당 예시에서 num은 0(짝수 그룹) 또는 1(홀수 그룹)로 그룹화가 적용됩니다.

groupedNumers를 출력하면, 아래의 실행 결과를 얻을 수 있습니다.

실행 결과

 

[주의할 점]

다음 예시에서 결과를 얻을 수는 있지만, group by 절의 결과는 중간 결과로서 그룹화된 컬렉션의 형태입니다.

group by 절 자체의 결과는 쿼리의 최종 결과 형태가 아니라는 점을 명심해야 하며, 이와 같은 이유로 group by는 종료 절로 사용될 수 없습니다.


group by into

group by into 절은 group by와 기본적으로 동일하지만, 추가적인 표준 쿼리 절을 연결하는 것이 가능합니다.

group A by B into C

 

A에는 from 절에서 뽑아낸 범위 변수를, B에는 분류 기준을, C에는 그룹 변수를 위치시킵니다.

 

group by into 절은 쿼리의 최종 결과 형태로 반환되며, 최종 종료 절로 사용하는 것이 가능합니다.

 

 

[예제]

using System;
using System.Linq;

namespace GroupBy
{ 
    class Profile
    {
        public string Name { get; set; }
        public int Height { get; set; }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Profile[] arrProfile =
            {
                new Profile() {Name = "AAA", Height = 186 },
                new Profile() {Name = "BBB", Height = 158 },
                new Profile() {Name = "CCC", Height = 172 },
                new Profile() {Name = "DDD", Height = 178 },
                new Profile() {Name = "EEE", Height = 171 }
            };

            var listprofiles = from profile in arrProfile
                               orderby profile.Height
                               group profile by profile.Height < 175 into g
                               select new { GroupKey = g.Key, Profiles = g };

            foreach(var Group in listprofiles)
            {
                Console.WriteLine($"- 175cm 미만? : {Group.GroupKey}");

                foreach(var Profile in Group.Profiles)
                {
                    Console.WriteLine($">>> {Profile.Name}, {Profile.Height}");
                }
            }
        }
    }
}

실행 결과


join

join은 각 데이터 원본에서 특정 필드의 값을 비교하여 일치하는 데이터끼리의 연결을 수행합니다.

join에는 내부 조인(Inner Join)과 외부 조인(Outer Join)이 존재합니다.


내부 조인 (Inner Join)

내부 조인은 두 데이터 원본 사이에서 서로 일치하는 데이터들만 연결한 후 반환합니다.

첫 번째 데이터 원본과 두 번째 데이터 원본의 특정 필드를 비교해 일치하는 데이터를 반환하는데, 이때 기준은 첫 번째 원본 데이터입니다.

 

내부 조인을 수행할 때 기준 데이터 원본에는 존재하지만 연결할 데이터 원본에는 존재하지 않는 데이터는 조인 결과에 포함되지 않습니다.

 

내부 조인은 다음과 같은 join 절을 통해 수행합니다.

from a in A
join b in B on a.XXXX equals b.YYYY

 

기준 데이터 a는 from 절에서 뽑아낸 범위 변수이고, 연결 대상 데이터 b는 join 절에서 뽑아낸 변수입니다.

join 절의 on 키워드는 조인 조건을 수반하며, 이때 on 절의 조인 조건은 동등만 허용됩니다.

 

[예제]

using System;
using System.Linq;

namespace InnerJoin
{ 
    class Category
    {
        public int ID { get; set; }
        public string Name { get; set; }
    }

    class Product
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public int CategoryID { get; set; }
    }


    class MainApp
    {
        static void Main(string[] args)
        {
            Category[] arrCategory =
            {
                new Category(){ ID = 1, Name = "전자제품" },
                new Category(){ ID = 2, Name = "도서" },
                new Category(){ ID = 3, Name = "의류" }
            };

            Product[] arrProduct =
            {
                new Product(){ ID = 101, Name = "노트북", CategoryID = 1 },
                new Product(){ ID = 102, Name = "키보드", CategoryID = 1 },
                new Product(){ ID = 103, Name = "C# 프로그래밍", CategoryID = 2 },
                new Product(){ ID = 104, Name = "C++ 프로그래밍", CategoryID = 2 },
                new Product(){ ID = 105, Name = "청바지", CategoryID = 3 },
                new Product(){ ID = 106, Name = "티셔츠", CategoryID = 3 },
                new Product(){ ID = 107, Name = "알 수 없는 상품", CategoryID = 99 }
            };

            var listCategory = from category in arrCategory
                               join product in arrProduct on category.ID equals product.CategoryID
                               select new
                               {
                                   Name = category.Name,
                                   ProductName = product.Name,
                                   Id = product.ID
                               };

            foreach (var category in listCategory)
            {
                Console.WriteLine("카테고리 분류 : {0} / 제품 이름 : {1} / 제품 ID : {2}",
                                   category.Name, category.ProductName, category.Id);
            }
        }
    }
}

실행 결과


외부 조인 (Outer Join)

외부 조인은 기본적으로 내부 조인과 비슷하지만, 조인 결과에 기준이 되는 데이터 원본이 모두 포함된다는 점이 다릅니다.

 

내부 조인을 수행할 때는 기준 데이터 원본에는 존재하지만 연결할 데이터 원본에는 존재하지 않는 데이터는 조인 결과에 포함되지 않지만, 외부 조인의 결과에서는 기준 데이터 원본의 모든 데이터를 조인 결과에 반드시 포함시킵니다.

만약 연결할 데이터 원본에 기준 데이터 원본의 데이터와 일치하는 데이터가 없다면 그 부분은 빈 값으로 결과를 채우게 됩니다.

 

외부 조인을 사용하는 방법은 내부 조인과 크게 다르지 않습니다.

join 절을 이용해서 조인을 수행한 후 그 결과를 임시 컬렉션에 저장하고, 이 임시 컬렉션에 대해 DefaultIfEmpty 연산을 수행해서 비어 있는 조인 결과에 빈 값을 채워 넣습니다.

 

이 과정을 거친 임시 컬렉션에서 from 절을 통해 범위 변수를 뽑아내고, 이 범위 변수와 기준 데이터 원본에서 뽑아낸 범위 변수를 이용해서 결과를 추출해냅니다.

 

※ DefaultIfEmpty 연산

 

[예제]

using System;
using System.Linq;

namespace OuterJoin
{ 
    class Category
    {
        public int ID { get; set; }
        public string Name { get; set; }
    }

    class Product
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public int CategoryID { get; set; }
    }


    class MainApp
    {
        static void Main(string[] args)
        {
            Category[] arrCategory =
            {
                new Category(){ ID = 1, Name = "전자제품" },
                new Category(){ ID = 2, Name = "도서" },
                new Category(){ ID = 3, Name = "의류" },
                new Category(){ ID = 4, Name = "생필품" }
            };

            Product[] arrProduct =
            {
                new Product(){ ID = 101, Name = "노트북", CategoryID = 1 },
                new Product(){ ID = 102, Name = "키보드", CategoryID = 1 },
                new Product(){ ID = 103, Name = "C# 프로그래밍", CategoryID = 2 },
                new Product(){ ID = 104, Name = "C++ 프로그래밍", CategoryID = 2 },
                new Product(){ ID = 105, Name = "청바지", CategoryID = 3 },
                new Product(){ ID = 106, Name = "티셔츠", CategoryID = 3 },
                new Product(){ ID = 107, Name = "알 수 없는 상품", CategoryID = 99 }
            };

            var listCategory = from category in arrCategory
                               join product in arrProduct on category.ID equals product.CategoryID into ps
                               from product in ps.DefaultIfEmpty(new Product() 
                                                                { Name = "해당 없음" , ID = 999 })
                               select new
                               {
                                   Name = category.Name,
                                   ProductName = product.Name,
                                   Id = product.ID
                               };

            foreach (var category in listCategory)
            {
                Console.WriteLine("카테고리 분류 : {0} / 제품 이름 : {1} / 제품 ID : {2}",
                                   category.Name, category.ProductName, category.Id);
            }
        }
    }
}

실행 결과


LINQ 쿼리식의 메서드 호출 코드

C#에서는 LINQ를 사용할 수 있지만, 다른 .NET 언어는 대부분 LINQ를 사용할 수 없습니다.

수많은 .NET 언어 중에서도 C#과 VB에서만 사용이 가능합니다.

 

마이크로소프트는 LINQ 쿼리식이 실행될 수 있도록 CLR을 개선하는 대신, C# 컴파일러와 VB 컴파일러를 업그레이드 했습니다. 이들 컴파일러가 각각 LINQ 쿼리식을 CLR이 이해할 수 있는 코드로 번역해주도록 말입니다.

 

컴파일러가 LINQ 쿼리식을 CLR이 이해할 수 있게 하기 위해서, LINQ 쿼리식을 분석해서 일반적인 메서드 호출 코드로 만들어냅니다.

 

다음 예제는 LINQ 쿼리식을 메서드 호출 코드로 바꾼 버전입니다.

 

[예제]

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

namespace MethodLINQ
{
    class Profile
    {
        public string Name { get; set; }
        public int Height { get; set; }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Profile[] arrProfile =
            {
                new Profile() {Name = "AAA", Height = 186 },
                new Profile() {Name = "BBB", Height = 158 },
                new Profile() {Name = "CCC", Height = 172 },
                new Profile() {Name = "DDD", Height = 178 },
                new Profile() {Name = "EEE", Height = 171 }
            };

            var profiles = arrProfile
                                .Where(profile => profile.Height < 175)
                                .OrderBy(profile => profile.Height)
                                .Select(profile =>
                                        new
                                        {
                                            Name = profile.Name,
                                            InchHeight = profile.Height * 0.393
                                        });

            foreach (var profile in profiles)
                Console.WriteLine($"{profile.Name}, {profile.InchHeight}");
        }
    }
}

실행 결과

 

'C#' 카테고리의 다른 글

[C#] dynamic  (0) 2025.05.16
[C#] 리플렉션과 애트리뷰트  (0) 2025.05.13
[C#] 람다식, 식 트리  (0) 2025.05.12
[C#] 대리자와 이벤트  (0) 2025.05.09
[C#] 예외 처리  (0) 2025.05.06