■ LINQ(Language Integrated Query)
LINQ는 Language INtegrated Query의 약어로, C# 언어에 통합된 데이터 질의 기능을 말한다.
여기서 “질의(Query)”란 말 그대로 무언가를 묻는 행위를 뜻하며, “데이터 질의”는 데이터에 대해 물어보고 필요한 값을 찾는 과정을 말한다.
이러한 질의에는 다음과 같은 요소가 포함된다.
- From : 어떤 데이터 집합에서 찾을 것인가?
- Where : 어떤 값의 데이터를 찾을 것인가?
- Select : 어떤 항목을 추출할 것인가?
LINQ는 원래 데이터베이스(SQL)에서 데이터를 조회할 때 사용하던 쿼리 형식에서 영감을 받아 설계된 C# 전용 문법이다.
→ SQL처럼 간결한 방식으로 배열, 리스트, 컬렉션 등 다양한 데이터 소스를 간단하고 읽기 쉽게 다를 수 있도록 해준다.
public class Profile
{
public string Name { get; set; }
public float Height { get; set; }
public Profile(string name, float height)
{
Name = name;
Height = height;
}
}
만약, 다음과 같은 데이터에서 키가 165 이상인 요소만 골라낸 뒤, 키를 기준으로 오름차순 정렬하는 코드는 아래와 같이 작성할 수 있을 것이다.
public void LinqBasicTest()
{
// foreach를 사용하여 키가 165 이상인 데이터 찾기
var selected = new List<Profile>();
foreach (var profile in profiles)
{
if (profile.Height > 165f)
{
selected.Add(profile);
}
}
selected.Sort((a,b) => a.Height.CompareTo(b.Height));
PrintProfileData(selected);
}
이 코드 역시 잘못된 부분은 없다. 하지만 LINQ를 사용하면 아래와 같이 더 직관적이고 간략하게 정리가 가능해진다.
1. 쿼리 식(Query Expression) 방식
// Linq를 사용해 키가 165이상인 사람을 찾고 오름차순 정렬
var data1 = from profile in profiles
where profile.Height > 165
orderby profile.Height
select profile;
- from : profiles 안에 있는 각 데이터(profile)로 부터,
- where : Height가 165 이상인 객체만 골라,
- orderby : 키순(profile.Height)으로 정렬하여,
- select : profile 객체를 추출한다.
SQL과 비슷한 선언형 문법이다. 읽기 쉽고 직관적이기 때문에 초보자나 SQL 배경이 있는 사람에게 익숙하다.
→ C# 컴파일러는 쿼리 식을 내부적으로 Where, OrderBy, Select 등의 메서드 체이닝으로 변환한다.
2. 메서드 체이닝(Method Chain) 방식
var data2 = profiles.Where(x => x.Height > 165f).OrderBy(x => x.Height);
람다 식과 확장 메서드 기반의 함수형 스타일이다. 더 유연하고 강력한 표현이 가능하며 각 연산을 체이닝 방식으로 이어서 표현하기 때문에, 복잡한 데이터 가공도 한 줄로 표현할 수 있다.
■ LINQ의 기본
1. from
모든 쿼리식(Query Expression)은 반드시 from절로 시작해야 한다.
from 절에서는 쿼리의 대상이 되는 원본 데이터(Data Source)와, 그 안의 각 요소 데이터를 나타내는 범위 변수(Range Variable)를 함께 지정한다.
foreach(var n in m) { }
// 쿼리식
var data = from n in m;
- from n in m은 본질적으로 foreach(var n in m)과 같은 의미이다.
- 즉, 원본 데이터 m의 각 요소를 순차적으로 꺼내서 n이라는 이름으로 접근한다.
참고로 메서드 체이닝 방식에선 m.Select(x ⇒ _)와 같이 원본 데이터는 m으로 직접 지정하고, 람다 식의 매개변수 x가 각 요소를 나타내는 범위 변수 역할을 하기 때문에 별도의 from절을 사용할 필요가 없다.
🛠️질의 가능한 객체
자료구조라고 해서 다 쿼리식을 사용할 수 있는 것은 아니고, IEnumerable<T> 인터페이스를 상속하는 형식이어야 한다.
- 즉, foreach문에 사용할 수 있는 컬렉션이어야 한다는 뜻이다.
이렇게 from절을 이용해서 데이터를 원본으로부터 뽑아낸 후, LINQ가 제공하는 다양한 연산자를 이용해 데이터를 가공 및 추출한다.
2. where
where은 한마디로 필터 역할을 하는 연산자이다.
from 절에서 꺼낸 범위 변수가 만족해야 하는 조건직을 where에 작성하면, 해당 조건에 부합하는 데이터만 걸러낸다.
// profiles 에서 키가 170 이상인 객체들만 선택
var data = from p in profiles
where p.Height > 170
select p;
// 메서드 체이닝 방식
var data = profiles.where(x => x.Height > 170);
3.orderby
orderby는 데이터의 정렬을 수행하는 연산자이다. orderby에 적어준 값을 기준으로 정렬할지 지정 가능하며, 계산식/파생식도 가능하다.
// profiles 에서 키가 170 이상인 객체들만 선택한 뒤, 오름차순 정렬
var data = from p in profiles
where p.Height > 170f
orderby p.Height
select p;
// profiles 에서 키가 170 이상인 객체들만 선택한 뒤, "내림차순"정렬
var data = from p in profiles
where p.Height > 170f
orderby p.Height descending
select p;
// 키가 같다면, 몸무게를 비교하여 오름차순 정렬.(다단계 정렬)
var data = from p in profiles
where p.Height > 170f
orderby p.Height, p.Weight
select p;
// 혹은 다음과 같이 orderby 가능
orderby p.Weight / (p.Height * p.Height) // BMI 기준 정렬
orderby p.Name.Length // 이름의 길이 기준으로 정렬
- 따로 정렬을 명시해주지 않으면 orderby는 오름차순(ascending) 정렬한다.
// profiles 에서 키가 170 이상인 객체들만 선택한 뒤, 오름차순 정렬
var data = profiles.Where(x => x.Height > 170f).OrderBy(x => x.Height);
// profiles 에서 키가 170 이상인 객체들만 선택한 뒤, "내림차순" 정렬
var data3 = profiles.Where(x => x.Height > 170f).OrderByDescending(x => x.Height);
// 키가 같다면, 몸무게를 비교하여 오름차순 정렬.(다단계 정렬)
var data = profiles.Where(x => x.Height > 170)
.OrderBy(x => x.Height)
.ThenBy(x => x.Weight);
메서드 체이닝 방식에선 Orderby() 외에 Order()도 존재한다.
- Order()는 따로 정렬 기준을 지정할 수 있는 파라미터를 받지 않기 때문에 OrderBy()처럼 정렬 기준을 명시할 수 없다.
따라서 복합 데이터 형식을 담고 있는 컬렉션에 대해 Order()을 사용하면, 컴파일러는 무엇을 기준으로 정렬할지 알 수 없어, InvalidOperationException이 발생한다.
// IComparable가 구현현 기본 타입 사용
var list = new List<int> { 6, 2, 4, 3, 1, 5 };
var sorted = list.Order();
foreach (var i in sorted)
{
Console.WriteLine(i);
}
// 혹은 IComparable<>을 직접 구현
public class Profile : IComparable<Profile>
{
public string Name { get; set; }
public float Height { get; set; }
public float Weight { get; set; }
public int CompareTo(Profile other)
{
return Height.CompareTo(other.Height);
}
}
var date5 = profiles.Order();
- order()을 사용할 땐 기본 타입 또는 IComparable 구현 타입에만 사용해야 한다.
- 사용자 정의 타입에 적용하라면 ICompareable<>을 직접 구현해야 한다.
4. select
select 절은 쿼리식에서 최종 결과를 결정하는, 쿼리식의 마침표 같은 역할을 한다.
// 모든 프로필에서 이름만 추출.(쿼리식)
var data = from p in profiles
where p.Height > 170f
orderby p.Height
select p;
// 모든 프로필에서 이름만 추출.(메서드 체이닝)
var data = profiles.Select(x => x.Height);
- 쿼리식에서는 select를 반드시 작성해야 하며, 생략할 경우 컴파일 오류가 발생한다.
// select로 무명 형식 생성. (쿼리식)
var data6 = from profile in profiles
where profile.Height > 165
orderby profile.Height
select new { Name = profile.Name,
BMI = profile.Weight / (profile.Height * profile.Height) };
// Select로 무명 형식 생성. (메서드 체이닝)
var data7 = profiles.Select(x => new
{
Name = x.Name,
BMI = x.Weight / (x.Height * x.Height)
});
select 또는 Select()를 통해 이름 없는 새로운 데이터 형식(무명 형식)을 즉석에서 만들어낼 수 있다.
- 이처럼 무명 형식은 결과 데이터를 필요한 속성만 조합하거나 계산된 값을 담아낼 때 유용하다.
- 반환 타입은 var로 받으며, 외부에서는 해당 타입에 직접 접근할 수 있다.
■ LINQ의 반환 결과
// 반환 형식 : IEnumerable<Profiles>
var data = from profile in profiles
where profile.Height > 165
select profile;
// 반환 형식 : IEnumerable<string> 로 결정.
var data = from profile in profiles
where profile.Height > 165
select profile.Name;
LINQ 쿼리는 List<Profiles>과 같은 컬렉션에 질의를 하더라도, 결과는 IEnumerable<T>형식으로 반환한다.
- 이때, 형식 매개변수 T는 select절에서 무엇을 반환하느냐에 따라 결정된다.
메서드 체이닝의 경우 Select()를 사용하지 않으면 IEnumerable의 T는 입력 컬렉션과 동일한 타입으로 유지된다.
반대로 Select()를 사용하면, 람다 식의 반환 타입이 곧 T가 된다.
1. 지연평가와 즉시평가
그럼 왜 기존 형식으로 반환하는 것이 아닌 IEnumerable<T>형식으로 반환할까?
이유는 쿼리 결과를 즉시 계산하지 않고, 필요할 때마다 하나씩 계산해서 반환하는 구조이기 때문이다.
- 이러한 방식의 실행을 지연 평가(Deferred Execution)라 한다.
// 지연 평가 테스트
Console.WriteLine("지연 평가 테스트... 1");
var data8 = profiles.Where(x =>
{
Console.WriteLine(x.Name);
return x.Height > 170;
});
Console.WriteLine("지연 평가 테스트... 2");
Where문 안에 Console 함수를 호출하여 Name을 출력하는 코드를 작성하고 실행시켜 보면 Where안의 Consol은 출력되지 않은 것을 확인할 수 있다.
지연 평가가 가능한 이유 : yield return과 반복자
LINQ는 IEnumerable<T> 인터페이스를 기반으로 작동하며, 이 IEnumerable<T>는 내부적으로 반복자(iterator)를 사용한다. 이 반복자는 C#의 yield return을 통해 쉽게 구현할 수 있다.
IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
- yield return은 값을 하나 반환하고 상태를 보존한 채 함수 실행을 멈춘다.
- 이 멈춘 상태를 IEnumerator(반복자 객체)가 기억하고 있다가,
- 다음 반복이 요청될 때 중단된 위치부터 다시 실행을 재개한다.
이러한 구조 덕에 모든 값을 한 번에 계산하거나 메모리에 올릴 필요가 없다.
일반 메서드 | return으로 즉시 종료하고 결과 하나만 반환 |
yield return 메서드 | 값을 하나씩 “지연해서”반환. 반복이 끝날 때까지 함수 내부 상태 유지 |
LINQ와의 관계 | LINQ 연산자 대부분은 내부적으로 yield return 기반 반복자를 사용하여 결과를 "필요할 때마다" 생성함 |
// 즉시 평가 테스트
Console.WriteLine("지연 평가 테스트... 1");
var data = profiles.Where(x =>
{
Console.WriteLine(x.Name);
return x.Height > 170;
}).ToList();
// 쿼리식인경우
var data = (from profile in profiles
where profile.Height > 165
orderby profile.Height
select profile).ToList();
Console.WriteLine("지연 평가 테스트... 2");
만약, 쿼리식이나 메서드 체이닝에서. ToList(),. ToArray()등을 붙이게 되면 즉시 평가가 진행된다.
ToList(), ToArray()등의 경우 IEnumerable<T>의 모든 요소를 한 번에 순회하여 결과를 메모리에 즉시 저장하고, 실제 컬렉션으로 만들어 반환한다.
- 즉, 결과를 완전히 계산해야 하기 때문에 내부적으로 foreach처럼 MoveNext()를 끝까지 호출하여 전부 평가한다.
이 외에도 Count(), First(), Last(), Max(), Min()등 즉시 결과를 반환하는 메서드나, foreach, for, ToDictionary() 등도 열거(iterator)를 강제로 실행하기 때문에 즉시 평가가 발생한다.
■ 중첩된 컬렉션 탐색
public class UserData
{
public string Name { get; set; }
// 배열
public int[] Lv { get; set; }
}
UserData의 각 인스턴스는 여러 개의 레벨(Lv)을 가지고 있고, 이 레벨들 중 특정 조건에 해당하는 값만 골라낸다 해보자. 이때는 중첩된 컬렉션(이중 From절)을 사용해 탐색해야 한다.
쿼리식(Query Syntax)
// 쿼리식
var data = from d in userData
from l in d.Lv
where l < 60
select new { Name = d.Name, Lowest = l };
중첩된 from절을 사용하면 각 사용자의 모든 레벨을 순회하면서 조건에 맞는 값을 추출할 수 있다.
- 첫 번째 from d in userData는 사용자 하나하나를 반복한다.
- 두 번째 from l in d.Lv는 해당 사용자의 모든 레벨을 반복한다.
두 번째 from절의 범위 변수 l을 where절을 사용하여 조건을 걸러내서 무명 형식으로 저장한다.
메서드 체이닝(Method Syntax)
// 메서드 체이닝
var data2 = userData
.SelectMany(d => d.Lv.Select(l => new { Name = d.Name, Lowest = l }))
.Where(x => x.Lowest < 60);
중첩된 컬렉션을 대상으로 데이터를 조회할 땐 SeletMany()를 사용하게 된다. SeletMany()는 중첩 컬렉션을 한 줄로 펼칠(faltten) 때 사용하는 메서드이다.
- userData는 사용자들의 배열이다.
- 각 사용자 d는 또 하나의 배열 d.Lv를 가지고 있다.
- 단순한 Select를 사용하면 배열 속 배열이 되는 반면,
- SelectMany는 Lv 배열들을 전부 하나로 펼친다.
이 후 .Where(x ⇒ x.Lowest < 60)으로 60 미만의 레벨만 필터링한다.
■ 데이터 분류하기
데이터를 질의할 때, 특정 기준에 따라 데이터를 분류하고 싶을 때가 있다. 이럴 때 사용하는 키워드가 바로 group by이다.
group by를 사용하면 원본 데이터를 “기준 값(Key)”에 따라 여러 개의 그룹으로 나눌 수 있으며, 각 그룹은 해당 기준을 만족하는 항목들의 컬렉션으로 구성된다.
// 키와 몸무게를 가진 Profile 리스트
var profiles = new List<Profile>
{
new ("홍길동", 174.3f, 68.2f),
new ("김연아", 162.5f, 45.3f),
new ("카리나", 160.3f, 47.2f),
new ("대상혁", 180.2f, 65.5f),
new ("콩쥐", 152.5f, 32.2f)
};
// 키가 170cm를 기준으로 true/false 그룹으로 분류
var data3 = from p in profiles
group p by p.Height > 170f into g
select new
{
GroupKey = g.Key, // true 또는 false
Profiles = g // 해당 조건에 속하는 Profile 컬렉션
};
// 메서드 체이닝 방식
var data4 = profiles.GroupBy(p => p.Height > 170f)
.Select(g => new
{
GroupKey = g.Key,
Profile = g
});
// 출력
foreach (var group in data3)
{
Console.WriteLine($"키 170 이상인가? → {group.GroupKey}");
foreach (var p in group.Profiles)
{
Console.WriteLine($"- {p.Name} : {p.Height}cm");
}
Console.WriteLine();
}
위 코드는 저장된 키를 기준으로 170 이상인 그룹과 이하인 그룹으로 나누는 코드이다.
여기서 중요한 점이 그룹 변수(g)는 단순한 리스트가 아닌, 하나의 기준 값(Key)과 해당 값을 만족하는 데이터의 그룹으로 구성된 구조이며, 이는 IGrouping<TKey, TElement>인터페이스로 표현한다.
Key | IEnumerable<TElement> |
그룹의 기준값 (예 : p.Height > 170 → true혹은 false) | 그 키에 속하는 요소들의 컬렉션 |
group by는 “묶는다”는 개념이기 때문에 단순한 리스트가 아니라 (기준 값 + 묶인 리스트) 구조가 필요하기 때문에 LINQ는 결과를 IGrouping<TKey, TElement>형식으로 반환한다.
■ join
join은 두 데이터 원본을 연결하는 연산자이다. 각 데이터 원본에서 특정 필드의 값을 비교하여 일치하는 데이터끼리 연산을 수행한다.
1. 내부 조인(Inner Join)
내부 조인은 교집합과 비슷한데, 두 데이터의 원본 사이에서 일치하는 데이터들만 연결한 후 반환한다.
- 첫 번째 데이터 원본과 두 번째 데이터 원본의 특정 필드를 비교하여 일치하는 데이터를 반환.
var starData = new List<Star>
{
new (){Name = "정우성", Height = 186f},
new (){Name = "김태희", Height = 158f},
new (){Name = "고현정", Height = 172f},
new (){Name = "이문세", Height = 178f},
new (){Name = "하하", Height = 171f},
};
var product = new List<Info>
{
new (){Name = "정우성", Product = "비트"},
new (){Name = "김태희", Product = "CF 다수"},
new (){Name = "김태희", Product = "아이리스"},
new (){Name = "고현정", Product = "모래시계"},
new (){Name = "이문세", Product = "Solo"},
};
var listProfile =
from d in starData
join p in product on d.Name equals p.Name
select new
{
Name = p.Name,
Product = p.Product,
Height = d.Height
};
// 내부 조인 (메서드 체이닝)
var listProfiles = starData.Join
(
product, // inner: product 테이블
d => d.Name, // starData에서 Join 기준 키
p => p.Name, // product 에서 Join 기준 키
(d, p) => new // join 결과의 구성 형식
{
Name = p.Name,
Product = p.Product,
Height = d.Height
}
);
foreach (var p in listProfile)
{
Console.WriteLine($"{p.Name} / {p.Height} = {p.Product}");
}
내부 조인은 join절을 통해 수행하며, 기준 데이터 a는 from 절에서 뽑은 범위 변수이고, 연결 대상 데이터 b는 join에서 뽑아낸 변수이다.
- on 키워드는 조건을 수반하며, 내부 조인에서 조건은 “동등(Equality)”만 허용된다.(==연산자는 사용하지 않음)
이렇게 되면 on 결과에 해당하는 데이터만 선택되어 새로운 컬렉션을 만들게 된다.
2. 외부 조인(Outer Join)
내부 조인은 기준 데이터에는 존재하지만 연결할 데이터에는 존재하지 않는 데이터는 조인 결과에 포함되지 않았다. 하지만 외부 조인은 조인 결과에 기준이 되는 데이터 원본이 모두 포함된다.
// 외부 조인
var listProfileOuter =
from d in starData
join p in product on d.Name equals p.Name into dp
from p in dp.DefaultIfEmpty(new Info() { Product = "없음" })
select new
{
Name = p.Name,
Product = p.Product,
Height = d.Height
};
join절을 이용해서 조인을 수행한 후, 그 결과를 임시 컬렉션(into dp)에 저장한다.
- 이후 임시 컬렉션에 대해 DefaultIfEmpty 연산을 거친 임시 컬렉션에서 from절을 통해 범위 변수를 뽑아낸다.
- 범위 변수와 기준 데이터 원본에서 뽑아낸 범위 변수를 이용해 결과를 추출한다.
'Unity,C# > Unity 정보' 카테고리의 다른 글
[Unity] Coroutine(코루틴) 파헤치기 (4) | 2025.05.30 |
---|