
실제 프로젝트를 하다 보면 데이터가 한곳에만 깔끔하게 모여있는 경우는 드뭅니다.
예를 들어, '플레이어 정보'는 A 리스트에, '길드 정보'는 B 리스트에 저장된 경우죠.
이럴 때 '특정 길드에 속한 모든 플레이어의 이름'을 찾으려면 어떻게 해야 할까요?
흩어져 있는 데이터를 연결해서 의미 있는 정보로 만들어주는 join에 대해 알아보겠습니다!
먼저, 우리가 다룰 데이터를 준비해 볼게요. 플레이어(Player)와
길드(Guild) 정보가 각각 다른 리스트에 담겨 있다고 가정해 봅시다.
Player 클래스는 자신이 어떤 길드에 속해있는지 GuildId를 가지고 있어요.
[코드]
using System;
using System.Collections.Generic;
using System.Linq; // LINQ를 사용하기 위해 필요
// 플레이어 정보
public class Player
{
public int Id { get; set; }
public string Name { get; set; }
public int GuildId { get; set; } // 소속된 길드의 ID
}
// 길드 정보
public class Guild
{
public int Id { get; set; }
public string Name { get; set; }
}
class Program
{
static void Main()
{
// 데이터 준비
var players = new List<Player>
{
new Player { Id = 1, Name = "용기사", GuildId = 101 },
new Player { Id = 2, Name = "마법사", GuildId = 102 },
new Player { Id = 3, Name = "성기사", GuildId = 101 },
new Player { Id = 4, Name = "추적자", GuildId = 999 } // 소속 길드가 없음
};
var guilds = new List<Guild>
{
new Guild { Id = 101, Name = "전사단" },
new Guild { Id = 102, Name = "마법사연합" },
new Guild { Id = 103, Name = "상인연맹" } // 소속 플레이어가 없음
};
// LINQ join 없이 해결하려는 시도 (중첩 반복문)
foreach (var player in players)
{
foreach (var guild in guilds)
{
// 플레이어의 GuildId와 길드의 Id가 일치하는지 확인
if (player.GuildId == guild.Id)
{
Console.WriteLine($"플레이어: {player.Name}, 길드: {guild.Name}");
break; // 일치하는 길드를 찾았으니 내부 반복문 종료
}
}
}
}
}
[실행 결과]
플레이어: 용기사, 길드: 전사단
플레이어: 마법사, 길드: 마법사연합
플레이어: 성기사, 길드: 전사단
데이터가 적을 땐 괜찮지만, 리스트가 수천, 수만 개가 된다면 어떨까요?
이 코드는 매우 비효율적이고, 가독성도 떨어집니다.
'무엇을(What)' 원하는지보다 '어떻게(How)' 찾을지에 대한 코드가 훨씬 길죠.
위에서 작성한 코드를 내부 조인을 사용해 단 하나의 쿼리로 바꿔보겠습니다.
내부 조인은 두 데이터 소스에서 연결고리가 일치하는 데이터들의 집합만을 반환합니다.
[코드]
using System;
using System.Collections.Generic;
using System.Linq; // LINQ를 사용하기 위해 필요
// 플레이어 정보
public class Player
{
public int Id { get; set; }
public string Name { get; set; }
public int GuildId { get; set; } // 소속된 길드의 ID
}
// 길드 정보
public class Guild
{
public int Id { get; set; }
public string Name { get; set; }
}
class Program
{
static void Main()
{
// 데이터 준비
var players = new List<Player>
{
new Player { Id = 1, Name = "용기사", GuildId = 101 },
new Player { Id = 2, Name = "마법사", GuildId = 102 },
new Player { Id = 3, Name = "성기사", GuildId = 101 },
new Player { Id = 4, Name = "추적자", GuildId = 999 } // 소속 길드가 없음
};
var guilds = new List<Guild>
{
new Guild { Id = 101, Name = "전사단" },
new Guild { Id = 102, Name = "마법사연합" },
new Guild { Id = 103, Name = "상인연맹" } // 소속 플레이어가 없음
};
var playerWithGuilds = from player in players
join guild in guilds on player.GuildId equals guild.Id
select new { PcName = player.Name, Guild = guild.Name };
foreach (var item in playerWithGuilds)
{
Console.WriteLine($"플레이어: {item.PcName}, 길드: {item.Guild}");
}
}
}
[실행 결과]
플레이어: 용기사, 길드: 전사단
플레이어: 마법사, 길드: 마법사연합
플레이어: 성기사, 길드: 전사단
코드가 훨씬 간결하고 명확해졌죠? 이제 join구문을 자세히 살펴볼까요?
join [컬렉션 2의 변수명] in [데이터 2 소스] on [Key 1] equals [Key 2]
join guild in guilds: players데이터에 guilds데이터를 연결하겠다고 선언합니다.
on player.GuildId equals guild.Id: 여기가 가장 중요합니다!
두 데이터를 어떤 조건으로 연결할지 명시하는 부분입니다.
"player의 GuildId와 guild의 Id가 같은 것끼리 짝을 지어줘"라는 의미죠.
주의!
조건 비교 시==가 아니라equals키워드를 사용해야 합니다.
LINQ에서join의 고유한 문법이니 꼭 기억해 주세요!
select new { ... }: join을 통해 player와 guild
두 개의 정보를 모두 사용할 수 있게 되었습니다. select구문에서
이 두 정보를 조합하여 새로운 형태의 결과(익명 타입)를 만들어냅니다.
여기서는 플레이어 이름과 길드 이름을 가진 새로운 객체를 만들었죠.
이 쿼리를 실행하면 player.GuildId와 guild.Id가 일치하는 데이터들만
짝지어 결과로 반환됩니다. 이를 내부 조인(Inner Join)이라고 불러요.
(교집합을 구하는 것과 비슷하죠!)
LINQ는 세 가지 외부 조인 방식 중에서 왼쪽 외부 조인만 지원합니다.
왼쪽 외부 조인은 이름 그대로 왼쪽 데이터 소스의 모든 항목을 포함합니다.
그리고 오른쪽 데이터 소스에서 짝이 맞는 데이터를 찾아 연결하죠.
만약 짝이 없다면? 그 부분은 null로 채워집니다.
왼쪽 외부 조인은 group join과 DefaultIfEmpty()메서드를 조합하여 구현합니다.
조금 복잡해 보이지만, 패턴만 익히면 금방 익숙해질 수 있어요!
비유: 모든 학생(왼쪽)의 명단을 펼쳐놓고, 각 학생이 제출한 과제가 있는지
오른쪽에 표시하는 것과 같아요. 과제를 안 낸 학생은 과제 칸만 비어 있게 되죠.
[코드]
using System;
using System.Collections.Generic;
using System.Linq; // LINQ를 사용하기 위해 필요
// 플레이어 정보
public class Player
{
public int Id { get; set; }
public string Name { get; set; }
public int GuildId { get; set; } // 소속된 길드의 ID
}
// 길드 정보
public class Guild
{
public int Id { get; set; }
public string Name { get; set; }
}
class Program
{
static void Main()
{
// 데이터 준비
var players = new List<Player>
{
new Player { Id = 1, Name = "용기사", GuildId = 101 },
new Player { Id = 2, Name = "마법사", GuildId = 102 },
new Player { Id = 3, Name = "성기사", GuildId = 101 },
new Player { Id = 4, Name = "추적자", GuildId = 999 } // 소속 길드가 없음
};
var guilds = new List<Guild>
{
new Guild { Id = 101, Name = "전사단" },
new Guild { Id = 102, Name = "마법사연합" },
new Guild { Id = 103, Name = "상인연맹" } // 소속 플레이어가 없음
};
var leftOuterJoin = from player in players // 왼쪽 기준(Player)을 먼저 선언
join guild in guilds on player.GuildId equals guild.Id into Group // 그룹화
from g in Group.DefaultIfEmpty() // 짝이 없으면 null로 채움
select new
{
PcName = player.Name,
Guild = g?.Name ?? "소속 없음" // null일 경우를 대비
};
Console.WriteLine("--- 왼쪽 외부 조인 (Left Outer Join) 결과 ---");
foreach (var item in leftOuterJoin)
{
Console.WriteLine($"플레이어: {item.PcName}, 길드: {item.Guild}");
}
}
}
[실행 결과]
--- 왼쪽 외부 조인 (Left Outer Join) 결과 ---
플레이어: 용기사, 길드: 전사단
플레이어: 마법사, 길드: 마법사연합
플레이어: 성기사, 길드: 전사단
플레이어: 추적자, 길드: 소속 없음
'추적자'가 결과에 포함되었습니다. join을 시도했지만
짝이 맞는 길드가 없었기 때문에, g는 null이 되었고,
??연산자를 통해 '소속 없음'이라는 기본값을 출력할 수 있었죠.
from player in players: '왼쪽'의 기준이 되는 데이터입니다.join ... into Group: group join 구문입니다. 각 player에 해당하는 guild들을 찾아 Group이라는 임시 그룹으로 묶습니다. '추적자'의 경우 이 그룹은 비어 있게 됩니다.from g in Group.DefaultIfEmpty(): 왼쪽 외부 조인의 핵심입니다!DefaultIfEmpty()는 그룹이 비어있을 경우, null(또는 기본값) 하나만 들어있는 컬렉션으로 바꿔줍니다. 짝이 없는 '추적자'도 쿼리에서 살아남을 수 있게 됩니다.g?.Name ?? "소속 없음": g가 null일 수 있으므로, ?.(null 조건부 연산자)를??(null 병합 연산자)를 사용해null일 경우 "소속 없음"이라는 문자열을 대신 사용합니다.여러 데이터 소스를 하나로 합쳐 풍부한 정보를 만들어내는 join에 대해 알아봤습니다.
내부 조인과 왼쪽 외부 조인은 데이터를 연결하는 강력한 도구이지만,
그 목적과 결과는 명확히 다릅니다. 언제 무엇을 써야 할지 표로 정리해 볼게요.
| 구분 | 내부 조인 (Inner Join) | 왼쪽 외부 조인 (Left Outer Join) |
|---|---|---|
| 핵심 개념 | 두 데이터 소스에 모두 존재하는, 짝이 맞는 데이터만 선택 (교집합) | 왼쪽 데이터는 모두 포함하고, 오른쪽 데이터는 짝이 맞으면 연결, 없으면 null |
| 결과 | 한쪽이라도 짝이 없으면 결과에서 제외됨 | 왼쪽 데이터는 절대 누락되지 않음 |
| 사용 시점 | "길드에 소속된 플레이어 목록"처럼, 관계가 명확하게 성립하는 데이터만 필요할 때 | "모든 플레이어의 길드 정보 (null 포함)"처럼, 한쪽 기준의 전체 목록이 필요할 때 |