간단히 말해 LINQ는 형식이 안전한(type-safe) 방식으로 데이터 쿼리를 지원하는 일련의 언어 확장입니다. LINQ는 Visual Studio의 다음 버전인 코드 이름 "Orcas"와 함께 릴리스될 예정입니다. 쿼리할 데이터는 XML(LINQ to XML), 데이터베이스(LINQ to SQL, LINQ to Dataset 및 LINQ to Entities를 포함하는 LINQ 지원 ADO.NET), 개체(LINQ to Objects) 등의 형식을 취할 수 있습니다. LINQ 아키텍처는 그림 1에 나와 있습니다.
몇 가지 코드를 살펴보겠습니다. 다음은 출시 예정인 "Orcas" 버전의 C#으로 작성한 LINQ 쿼리 예입니다.
var overdrawnQuery = from account in db.Accounts
where account.Balance < 0
select new { account.Name, account.Address };
foreach를 사용하여 이 쿼리의 결과를 반복하면 반환되는 각 요소는 잔고가 0보다 작은 계정의 이름과 주소로 구성됩니다.
위 예를 보면 구문이 SQL과 비슷하다는 점을 쉽게 알 수 있습니다. 몇 년 전 Anders Hejlsberg(C# 수석 디자이너)와 Peter Golde는 데이터 쿼리 통합을 개선하기 위한 C# 확장에 대해 고려했습니다. 당시 C# 컴파일러 개발을 이끌던 Peter는 C# 컴파일러를 확장 가능하도록 만들어 특히 SQL과 같은 도메인별 언어의 구문을 검사하는 추가 기능을 지원할 수 있는지에 대한 가능성을 조사했습니다. 반면 Anders는 보다 심층적이고 세부적인 수준의 통합을 고려했습니다. 그는 IEnumerable을 구현한 모든 컬렉션에 대해 작동하는 "시퀀스 연산자", 그리고 IQueryable을 구현한 형식에 대한 원격 쿼리를 검토했습니다. 결국 시퀀스 연산자 개념이 더 많은 호응을 얻어 2004년 초 Anders는 이 아이디어에 대한 자료를 Bill Gates의 Thinkweek에 제출했습니다. 반응은 놀라울 정도로 긍정적이었습니다. 설계 초기 단계에서 간단한 쿼리 구문은 다음과 같았습니다.
sequence<Customer> locals = customers.where(ZipCode == 98112);
여기에서 sequence는 IEnumerable<T>에 대한 별칭이며 "where"는 컴파일러에서 인식하는 특수 연산자였습니다. where 연산자 구현은 조건자 대리자, 즉 bool Pred<T>(T 항목) 형식의 대리자를 받는 일반 C# 정적 메서드였습니다. 핵심적인 개념은 컴파일러가 연산자에 대한 특수한 정보를 갖는다는 것입니다. 이렇게 하면 컴파일러가 정적 메서드를 올바르게 호출하고 대리자를 식에 연결하는 코드를 만들 수 있습니다.
위의 예가 C#에서 쿼리를 위한 최적의 구문이라고 가정해 보겠습니다. 언어 확장이 없다면 C# 2.0에서 이 쿼리는 어떤 형태가 될까요?
IEnumerable<Customer> locals = EnumerableExtensions.Where(customers,
delegate(Customer c)
{
return c.ZipCode == 98112;
});
이 코드는 지나치게 복잡할 뿐만 아니라 관련 필터(ZipCode == 98112)를 찾기도 무척 힘듭니다. 게다가 이 예는 단순한 편입니다. 여러 가지 필터와 예측 등을 추가할 경우 얼마나 더 알아보기 어렵게 될지 상상해 보십시오. 이러한 복잡성의 근본적인 원인은 무명 메서드에 필요한 구문에 있습니다. 이상적인 쿼리의 경우 식에는 계산할 식만 있으면 됩니다. 그러면 컴파일러는 ZipCode가 정말로 Customer에 정의된 ZipCode를 참조했는지와 같은 정황을 추론합니다. 이 문제는 어떻게 해결할까요? 언어 설계 팀은 특정 연산자에 대한 정보를 언어에 하드코딩하는 방법은 적절치 않다고 판단하고 무명 메서드에 대한 대체 구문을 찾기 시작했습니다. 언어 설계 팀은 대체 구문이 극히 간결하면서 현재 무명 메서드에 대해 필요한 컴파일러 이상의 정보는 요구하지 않기를 원했습니다. 결국 이들은 람다 식을 고안했습니다.
람다 식
람다 식은 여러 가지 측면에서 무명 메서드와 비슷한 언어 기능입니다. 사실 람다 식이 언어에 먼저 추가되었다면 무명 메서드는 필요하지 않았을 것입니다. 람다 식의 기본 개념은 코드를 데이터처럼 처리할 수 있다는 것입니다. C# 1.0에서는 흔히 문자열, 정수, 참조 형식 등을 메서드에 전달하여 메서드가 이러한 값을 이용해 작업하도록 합니다. 무명 메서드와 람다 식은 코드 블록까지 포함하도록 이러한 값의 범위를 확장합니다. 이 개념은 함수형 프로그래밍에서는 일반적인 것입니다.
위의 예에서 무명 메서드를 람다 식으로 대체해 보겠습니다.
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
여기에는 몇 가지 주목할 만한 점이 있습니다. 우선 람다 식의 간결함을 가능하게 하는 몇 가지 요인에 대해 살펴보겠습니다. 첫째, 구문을 삽입할 때 delegate 키워드를 사용하지 않습니다. 대신 컴파일러에 일반 식이 아님을 알려 주는 새로운 연산자인 =>를 사용합니다. 둘째, 사용법에 따라 Customer 형식을 추론합니다. 이 경우에 Where 메서드의 시그니처는 다음과 비슷할 것입니다.
public static IEnumerable<T> Where<T>(
IEnumerable<T> items, Func<T, bool> predicate)
Where 메서드의 첫 번째 매개 변수가 IEnumerable<Customer>이며, 따라서 T는 Customer이므로 컴파일러는 "c"가 고객을 지칭한다는 사실을 추론할 수 있습니다. 컴파일러는 이 정보를 사용하여 Customer에 ZipCode 멤버가 있다는 사실도 확인합니다. 마지막으로, return 키워드가 지정되지 않습니다. 구문 형식에서 return 멤버가 생략되지만 이는 단지 구문상의 편의일 뿐입니다. 식의 결과는 여전히 반환 값으로 간주됩니다.
람다 식 역시 무명 메서드와 같이 변수 캡처를 지원합니다. 예를 들어 람다 식 본문 내에서 람다 식을 포함하는 메서드의 매개 변수 또는 지역 변수를 참조하는 것이 가능합니다.
public IEnumerable<Customer> LocalCusts(
IEnumerable<Customer> customers, int zipCode)
{
return EnumerableExtensions.Where(customers,
c => c.ZipCode == zipCode);
}
마지막으로 람다 식은 형식을 명시적으로 지정하고 여러 개의 문을 실행할 수 있는 보다 세부적인 구문을 지원합니다. 예를 들면 다음과 같습니다.
return EnumerableExtensions.Where(customers,
(Customer c) => { int zip = zipCode; return c.ZipCode == zip; });
긍정적인 사실은 원래 자료에서 제시된 이상적인 구문에 훨씬 더 가까워졌으며, 일반적으로 쿼리 연산자 범위 이상으로 유용한 언어 기능으로 이를 구현할 수 있었다는 점입니다. 현재 어디까지 진행되었는지 다시 한 번 보겠습니다.
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
여기에는 분명한 문제가 있습니다. 현재 사용자는 Customer에 수행할 수 있는 연산에 대해 생각하는 대신 이 EnumerableExtensions 클래스에 대해 알아야 합니다. 또한 연산자가 여러 개인 경우 사용자는 올바른 구문을 작성하려면 생각을 전환해야 합니다. 예를 들면 다음과 같습니다.
IEnumerable<string> locals =
EnumerableExtensions.Select(
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822),
c => c.Name);
Select는 Where 메서드의 결과를 기준으로 연산하지만 외부 메서드입니다. 이상적인 구문은 다음과 같은 형태에 가까울 것입니다.
sequence<Customer> locals =
customers.where(ZipCode == 98112).select(Name);
그렇다면 다른 언어 기능을 통해 이상적인 구문에 더 가까워질 수 있을까요?
확장 메서드
확장 메서드라고 하는 언어 기능을 사용하면 구문을 크게 개선할 수 있습니다. 확장 메서드는 기본적으로 인스턴스 구문을 사용하여 호출할 수 있는 정적 메서드입니다. 위에 있는 쿼리에서 문제의 원인은 IEnumerable<T>에 메서드를 추가하려고 한다는 데 있습니다. Where, Select 등의 연산자를 추가한다면 모든 기존 및 향후 구현자는 이러한 메서드를 구현해야 합니다. 그러나 이러한 구현의 대부분은 동일합니다. C#에서 "인터페이스 구현"을 공유하는 유일한 방법은 앞서 사용된 EnumerableExtensions 클래스에서 수행한 것처럼 정적 메서드를 사용하는 것입니다.
그 대신 Where 메서드를 확장 메서드로 작성한다고 가정해 보겠습니다. 쿼리는 다음과 같이 다시 작성할 수 있습니다.
IEnumerable<Customer> locals =
customers.Where(c => c.ZipCode == 91822);
이 간단한 쿼리의 경우 구문이 이상적 형태에 상당히 근접합니다. 그러나 Where 메서드를 확장 메서드로 작성하는 것이 의미하는 바는 정확히 무엇일까요? 사실 그 의미는 매우 간단합니다. 기본적으로 정적 메서드의 시그니처가 변경되어 다음과 같이 첫 번째 매개 변수에 "this" 한정자가 추가됩니다.
public static IEnumerable<T> Where<T>(
this IEnumerable<T> items, Func<T, bool> predicate)
또한 메서드는 정적 클래스 내에서 선언되어야 합니다. 정적 클래스는 정적 멤버만 포함할 수 있으며 클래스 선언에서 static 한정자를 사용하여 나타냅니다. 이것이 전부입니다. 이 선언에 따라 컴파일러는 IEnumerable<T>를 구현하는 모든 형식의 인스턴스 메서드와 동일한 구문으로 Where를 호출하도록 허용합니다. 한편 Where 메서드는 현재 범위 내에서 액세스가 가능해야 합니다. 포함하는 형식이 범위 내에 있으면 메서드도 범위 내에 있습니다. 따라서 Using 지시문을 통해 확장 메서드를 범위 내로 가져오는 것이 가능합니다. 자세한 내용은 "확장 메서드" 보충 기사를 참조하십시오.
확장 메서드는 이 기사의 예로 사용한 쿼리를 간소화하는 데 분명히 도움이 되지만, 이러한 시나리오를 벗어나 일반적으로도 유용한 언어 기능일까요? 확장 메서드를 사용할 수 있는 곳은 많습니다. 가장 일반적인 경우는 공유 인터페이스 구현을 제공할 때입니다. 예를 들어 다음과 같은 인터페이스가 있다고 가정해 보겠습니다.
interface IDog
{
// Barks for 2 seconds
void Bark();
void Bark(int seconds);
}
이 인터페이스의 경우 모든 구현자는 두 가지 오버로드에 대해 구현을 작성해야 합니다. C# "Orcas" 버전에서는 다음과 같이 간단하게 인터페이스를 작성할 수 있습니다.
interface IDog
{
void Bark(int seconds);
}
다음과 같이 다른 클래스에 확장 메서드를 추가할 수 있습니다.
static class DogExtensions
{
// Barks for 2 seconds
public static void Bark(this IDog dog)
{
dog.Bark(2);
}
}
이제 인터페이스 구현자는 단일 메서드만 구현하면 되며, 인터페이스 클라이언트는 자유롭게 두 가지 오버로드 중 하나를 호출할 수 있습니다.
이제 필터 절에 대해서는 이상적인 구문에 매우 근접했습니다. 그러나 이것이 C# "Orcas" 버전의 전부일까요? 그렇지 않습니다. 예를 약간 확장하여 고객 개체 전체가 아니라 고객의 이름만 예측해 보겠습니다. 앞에서 언급했듯이 이상적인 구문은 다음과 같은 형태를 취합니다.
sequence<string> locals =
customers.where(ZipCode == 98112).select(Name);
지금까지 설명한 언어 확장인 람다 식과 확장 메서드를 사용하면 다음과 같이 다시 작성할 수 있습니다.
IEnumerable<string> locals =
customers.Where(c => c.ZipCode == 91822).Select(c => c.Name);
이 쿼리의 경우 반환 형식이 IEnumerable<Customer>가 아니라 IEnumerable<string>인데, 이는 select 문에서 고객 이름만 반환하기 때문입니다.
이 방식은 예측이 단일 필드인 경우 매우 훌륭하게 작동합니다. 고객의 이름뿐만 아니라 주소까지 반환하려는 경우를 가정해 봅시다. 이상적인 구문은 다음과 같습니다.
locals = customers.where(ZipCode == 98112).select(Name, Address);
익명 형식
기존 구문을 계속 사용하여 이름과 주소를 반환하려는 경우에는 Name과 Address만 포함하는 형식은 없다는 문제에 직면하게 됩니다. 그러나 다음과 같은 형식을 통해 쿼리를 작성할 수 있습니다.
class CustomerTuple
{
public string Name;
public string Address;
public CustomerTuple(string name, string address)
{
this.Name = name;
this.Address = address;
}
}
그러면 이 형식(여기에서는 CustomerTuple)을 사용하여 쿼리의 결과를 구성할 수 있습니다.
IEnumerable<CustomerTuple> locals =
customers.Where(c => c.ZipCode == 91822)
.Select(c => new CustomerTuple(c.Name, c.Address));
확실히 이 코드는 필드의 하위 집합을 예측하기 위한 일상적인 코드로 보입니다. 이러한 형식의 이름을 어떻게 지정할지 확실하지 않은 경우가 많습니다. CustomerTuple이 정말 좋은 이름일까요? Name과 Age를 예측해야 하는 경우에는 어떻게 할까요? 이 경우에도 CustomerTuple이 될 수 있습니다. 즉, 문제는 일상적인 코드는 있지만 만드는 형식에 맞는 이름이 없다는 것입니다. 또한 다양한 형식이 필요할 수 있는데, 이러한 경우 형식을 관리하기가 금방 어려워집니다.
익명 형식은 바로 이러한 문제를 해결하기 위한 것입니다. 기본적으로 이 기능을 사용하면 이름을 지정하지 않고 구조적 형식을 만들 수 있습니다. 익명 형식을 사용하여 위의 쿼리를 다시 작성하면 다음과 같습니다.
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { c.Name, c.Address });
이 코드는 Name 및 Address 필드가 있는 형식을 암시적으로 만듭니다.
class
{
public string Name;
public string Address;
}
이 형식은 이름이 없으므로 이름으로 참조할 수 없습니다. 필드의 이름은 익명 형식 생성에서 명시적으로 선언할 수 있습니다. 예를 들어 생성되는 필드가 복잡한 식에서 파생되거나 이름이 적절하지 않은 경우 이름을 변경할 수 있습니다.
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + " " + c.LastName,
HomeAddress = c.Address });
여기에서 생성되는 형식에는 FullName 및 HomeAddress라는 필드가 있습니다.
이를 통해 이상적인 구문에 더 접근했지만 문제가 있습니다. 익명 형식을 사용한 곳에서는 계획적으로 지역 변수의 형식을 생략했음을 알 수 있을 것입니다. 익명 형식의 이름을 나타낼 수 없다면 어떻게 이를 사용할 수 있을까요?
암시적으로 형식이 지정된 지역 변수
암시적으로 형식이 지정된 지역 변수(또는 줄여서 var)라고 하는 또 다른 언어 기능이 있습니다. 이 기능은 컴파일러가 지역 변수의 형식을 추론하도록 합니다. 예를 들면 다음과 같습니다.
var integer = 1;
여기에서 integer의 형식은 int입니다. 이는 여전히 강력한 형식이라는 점이 중요합니다. 동적 언어에서는 정수의 형식을 나중에 변경할 수 있습니다. 예를 들어 다음 코드는 컴파일되지 않습니다.
var integer = 1; integer = "hello";
C# 컴파일러는 string을 int로 암시적으로 변환할 수 없다는 정보와 함께 두 번째 줄에서 오류를 보고합니다.
위 쿼리의 경우 이제 다음과 같이 완전한 할당을 작성할 수 있습니다.
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + " " + c.LastName,
HomeAddress = c.Address });
지역 변수의 형식은 IEnumerable<?>가 되며 여기에서 "?"는 작성할 수 없는(익명이므로) 형식의 이름입니다.
암시적으로 형식이 지정된 지역 변수는 메서드 내의 지역 변수입니다. 형식을 명시적으로 나타낼 수 없고 "var"은 필드나 매개 변수 형식으로 유효하지 않으므로 이러한 지역 변수는 메서드의 경계를 벗어날 수 없습니다.
암시적으로 형식이 지정된 지역 변수는 쿼리 이외의 분야에서도 유용합니다. 예를 들어 다음과 같이 복잡한 제네릭 인스턴스화를 간소화하는 데 도움이 됩니다.
var customerListLookup = new Dictionary<string, List<Customer>>();
이제 쿼리가 상당한 수준에 이르렀습니다. 이상적인 구문에 가까워졌으며, 범용 언어 기능을 사용하여 여기까지 도달했습니다.
흥미롭게도 많은 사람들이 이 구문을 사용하게 되면서 예측이 메서드 경계를 벗어나도록 허용해야 하는 경우가 많다는 사실을 발견했습니다. 앞에서 살펴보았듯이 이를 위해서는 Select 내에서 개체의 생성자를 호출하여 개체를 구성하면 됩니다. 하지만 설정해야 하는 값을 정확히 받는 생성자가 없는 경우에는 어떻게 해야 할까요?
개체 이니셜라이저
이 경우에는 향후 출시될 "Orcas" 버전에서 제공되는 개체 이니셜라이저라고 하는 C# 언어 기능을 사용할 수 있습니다. 개체 이니셜라이저를 사용하면 기본적으로 단일 식에 여러 속성이나 필드를 할당할 수 있습니다. 예를 들어 개체를 생성하는 일반적인 패턴은 다음과 같습니다.
Customer customer = new Customer(); customer.Name = "Roger"; customer.Address = "1 Wilco Way";
이 경우 이름과 주소를 받는 Customer 생성자는 없지만 인스턴스가 생성될 때 한 번 설정할 수 있는 Name과 Address라는 두 가지 속성이 있습니다. 개체 이니셜라이저를 사용하면 다음과 같은 구문으로 동일한 결과를 만들 수 있습니다.
Customer customer = new Customer()
{ Name = "Roger", Address = "1 Wilco Way" };
이전 CustomerTuple 예에서는 생성자를 호출하여 CustomerTuple 클래스를 만들었습니다. 이제 개체 이니셜라이저를 사용하여 동일한 결과를 얻을 수 있습니다.
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c =>
new CustomerTuple { Name = c.Name, Address = c.Address });
개체 이니셜라이저를 사용하면 생성자의 괄호를 생략할 수 있습니다. 또한 필드와 설정할 수 있는 속성을 개체 이니셜라이저의 본문 내에 할당할 수 있습니다.
이제 C#에서 쿼리를 만드는 간결한 구문을 완성했습니다. 또한 확장 메서드를 통해 새 연산자(Distinct, OrderBy, Sum 등)를 추가할 수 있는 확장 가능한 방법과, 각기 유용한 용도가 있는 별도의 언어 기능 집합도 있습니다.
언어 설계 팀은 사용자 의견 수집을 위한 여러 프로토타입을 준비했고 우리는 C#과 SQL에 대한 경험을 모두 갖춘 여러 참가자들로 구성된 사용 연구팀을 구성했습니다. 사용자 의견은 대부분 긍정적이었지만 부족한 부분도 확실히 있었습니다. 특히 개발자들이 SQL에 대한 지식을 적용하기가 어려웠는데, 이는 우리가 이상적이라고 생각한 구문이 개발자들의 분야별 전문 지식과 그다지 잘 부합되지 않았기 때문입니다.
쿼리 식
이후 언어 설계 팀은 쿼리 식이라고 하는, SQL과 유사한 구문을 설계했습니다. 예를 들어 지금까지 살펴본 예에 대한 쿼리 식은 다음과 같은 형태를 취할 수 있습니다.
var locals = from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + " " +
c.LastName, HomeAddress = c.Address };
쿼리 식은 앞서 설명한 언어 기능에 기반을 두고 있으며 이미 살펴본 기본 구문으로 정확히 변환됩니다. 예를 들어 위의 쿼리는 다음과 같이 변환됩니다.
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + " " + c.LastName,
HomeAddress = c.Address });
쿼리 식은 from, where, select, orderby, group by, let, join과 같은 여러 가지 "절"을 지원합니다. 이러한 절은 해당하는 연산자 호출로 변환된 다음 확장 메서드를 통해 구현됩니다. 쿼리 절과 연산자를 구현하는 확장 메서드 간의 관계는 매우 밀접하므로 쿼리 구문이 필요한 연산자를 지원하지 않을 경우 손쉽게 이 둘을 조합할 수 있습니다. 예를 들면 다음과 같습니다.
var locals = (from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + " " +
c.LastName, HomeAddress = c.Address})
.Count();
이 경우 쿼리는 91822 우편 번호 지역에 거주하는 고객의 수를 반환합니다.
이로써 어느 정도 만족스럽게 처음 시작점과 거의 같은 위치에서 마칠 수 있었습니다. C# 다음 버전의 구문은 여러 가지 새로운 언어 기능의 추가를 통해 지난 몇 년 동안 발전했는데, 결국 이는 2004년 겨울에 제안된 최초의 구문에 매우 근접한 형태가 되었습니다. 추가되는 쿼리 식은 차기 C# 버전의 다른 언어 기능에 바탕을 두고 있으며 SQL 지식이 있는 개발자가 다양한 쿼리 시나리오를 쉽게 읽고 이해할 수 있게 해줍니다.




Prev





