Devin.KR

컬렉션 - List·Dictionary·HashSet

개발자KR 조회 4

이 장에서 배우는 것

카페 주문이 한 건일 때는 변수 하나로도 주문 이름을 보관할 수 있다. 주문이 늘어나면 주문마다 변수를 추가하는 방식으로는 관리하기 어렵다. 주문은 접수한 순서대로 보관해야 하고, 재고는 메뉴 이름으로 찾아야 하며, 오늘 판매한 메뉴는 중복 없이 모아야 한다. 모두 여러 값을 보관하는 일이지만, 필요한 동작은 서로 다르다.

이 장에서는 컬렉션(collection)을 이용해 이 세 가지 요구를 구분한다. 앞 장에서 살펴본 인터페이스와 다형성은 여러 구현을 공통된 방식으로 다루는 기반이 된다. 여기서는 그 기반 위에서 제공되는 구체적인 컬렉션을 선택하고 사용하는 데 집중한다. 조회식을 만드는 기능은 다음 장에서 다룬다.

  • 제네릭 컬렉션이 요소의 타입을 제한하는 방식과 초기화 문법을 이해한다.
  • 순서와 중복이 필요한 주문에는 List<T>를 사용한다.
  • 메뉴 이름으로 찾는 재고에는 Dictionary<TKey, TValue>를 사용한다.
  • 중복 없는 메뉴 집합에는 HashSet<T>를 사용한다.
  • 순회 중 컬렉션을 변경할 때 발생하는 문제를 설명하고, 안전한 수정 방법을 적용한다.

문제 상황

동네 카페에서 오전 주문을 한 번에 정리한다고 하자. 접수된 주문은 라테, 아메리카노, 라테, 차 순서다. 라테 두 잔은 서로 다른 주문이므로 하나로 합치면 안 된다. 먼저 들어온 주문을 먼저 처리해야 하므로 접수 순서도 보존해야 한다.

같은 시각 재고에는 아메리카노 세 잔, 라테 두 잔, 차 한 잔을 만들 수 있는 수량이 기록되어 있다. 직원이 라테를 만들 때 필요한 질문은 “두 번째 재고가 무엇인가”가 아니라 “라테의 남은 수량이 얼마인가”다. 재고는 위치보다 메뉴 이름으로 찾는 편이 자연스럽다.

영업 기록에는 오늘 실제로 제조한 메뉴의 종류도 남긴다. 라테를 두 번 제조해도 이 기록에는 라테가 한 번만 들어가야 한다. 주문의 중복은 보존하고 판매 메뉴의 중복은 제거해야 하는 셈이다.

여기에 취소 요청이 들어온다. 이번 예제에서는 취소 대상으로 지정한 메뉴의 대기 주문을 모두 제거한다. 따라서 차가 취소 대상이면 차 주문을 목록에서 지운다. 실제 서비스에서는 주문 번호로 개별 주문을 취소하는 경우가 많지만, 여기서는 컬렉션의 차이를 드러내기 위해 메뉴 이름을 기준으로 삼는다.

이 요구를 하나의 자료구조로 해결하려고 하면 불필요한 검색이나 중복 검사 코드가 늘어난다. 먼저 어떤 값으로 찾을 것인지, 순서가 의미 있는지, 중복을 허용할 것인지를 정하면 컬렉션을 고르기 쉽다.

보관 규칙에 따라 컬렉션 고르기

제네릭으로 요소의 타입 정하기

제네릭(generic)은 타입을 매개변수처럼 지정하는 방식이다. List<T>의 T에 string을 넣으면 문자열 목록이 되고, int를 넣으면 정수 목록이 된다. 컬렉션을 사용할 때마다 요소의 타입을 추측하는 대신, 선언에서 허용할 타입을 정한다.

List<string> orders = new List<string>();
orders.Add("라테");

// 다음 문장은 문자열 목록에 정수를 넣으므로 컴파일되지 않는다.
// orders.Add(2);

타입 이름 안의 꺾쇠는 요소의 개수를 뜻하지 않는다. List<string>은 문자열 한 개만 저장한다는 뜻도, 문자열을 일정 개수만 저장한다는 뜻도 아니다. 문자열을 요소로 사용하는 목록이라는 뜻이다. 요소를 추가하거나 제거하면 개수는 달라진다.

Dictionary<string, int>에는 타입이 두 개 들어간다. 앞의 string은 검색 기준인 키(key)의 타입이고, 뒤의 int는 키에 연결된 값의 타입이다. 이 장에서는 메뉴 이름을 키로 사용하고 남은 수량을 값으로 사용한다.

카페 데이터의 요구와 컬렉션 선택
컬렉션찾는 기준중복 규칙카페에서의 용도
List<T>위치를 나타내는 인덱스같은 요소를 여러 번 보관접수 순서가 있는 주문
Dictionary<TKey, TValue>고유한 키키는 중복 불가, 값은 중복 가능메뉴별 재고 수량
HashSet<T>요소의 포함 여부같다고 판단한 요소는 한 번만 보관취소 대상과 판매한 메뉴

List는 위치와 중복을 보존한다

List<T>는 요소를 순서대로 보관한다. Add는 끝에 요소를 붙이고, 인덱스를 사용하면 특정 위치의 요소를 읽거나 바꿀 수 있다. 인덱스는 0부터 시작하므로 요소가 네 개라면 사용할 수 있는 인덱스는 0, 1, 2, 3이다. Count는 마지막 인덱스가 아니라 현재 요소의 개수다.

List<string> orders = new List<string>
{
    "라테",
    "아메리카노",
    "라테"
};

string firstOrder = orders[0];
orders.Add("차");

중괄호 안에 초기 요소를 나열하는 문법을 컬렉션 이니셜라이저(collection initializer)라고 한다. 위 코드는 목록을 만든 뒤 나열한 순서대로 요소를 추가한다. 같은 문자열인 라테가 두 번 등장해도 그대로 두 요소가 된다.

Remove는 전달한 값과 같은 첫 번째 요소를 찾아 제거하고 성공 여부를 반환한다. RemoveAt은 지정한 인덱스의 요소를 제거한다. 주문이 중복될 수 있을 때 이 차이가 중요하다. Remove("라테") 한 번으로 모든 라테 주문이 없어지지는 않는다.

Dictionary는 키로 값을 찾는다

Dictionary<TKey, TValue>는 키와 값을 한 쌍으로 보관한다. 아래 초기화에서 대괄호 안의 문자열은 인덱스 번호가 아니라 키다. 키로 사용하는 메뉴 이름과 재고 수량의 관계를 그대로 표현한다.

Dictionary<string, int> stock =
    new Dictionary<string, int>(StringComparer.Ordinal)
    {
        ["아메리카노"] = 3,
        ["라테"] = 2,
        ["차"] = 1
    };

stock["라테"] = 1;

stock["라테"] = 1은 라테가 이미 있으면 수량을 바꾸고, 없으면 새 항목을 추가한다. 반면 Add("라테", 1)은 같은 키가 이미 있을 때 실패한다. 중괄호 안의 대괄호 초기화도 키에 값을 대입하는 형태이므로, 같은 키를 반복하면 뒤의 값이 앞의 값을 덮어쓴다. 초기 데이터를 작성할 때도 키 중복을 확인해야 한다.

값을 읽을 때는 규칙이 다르다. 없는 키를 대괄호로 읽으면 예외가 발생한다. 키가 있는지 확실하지 않다면 TryGetValue로 존재 여부와 값을 함께 확인한다. 이름에 Try가 들어간 이 메서드는 조회 성공 여부를 bool로 반환한다.

if (stock.TryGetValue("라테", out int quantity))
{
    Console.WriteLine($"라테 재고: {quantity}");
}
else
{
    Console.WriteLine("등록되지 않은 메뉴다.");
}

조회에 성공하면 quantity에 저장된 수량이 들어간다. 실패하면 int의 기본값인 0이 들어가지만, 이를 “재고가 0이다”로 해석하면 안 된다. 등록되지 않은 메뉴와 등록되었지만 품절된 메뉴는 다르므로 반환된 bool을 먼저 확인한다.

HashSet은 포함 여부와 중복 제거에 맞는다

HashSet<T>는 집합(set)을 표현한다. 같은 요소를 여러 번 추가해도 하나만 남긴다. Add는 새 요소가 들어갔으면 true를, 이미 있어서 추가되지 않았으면 false를 반환한다. Contains는 해당 요소가 포함되어 있는지를 확인한다.

HashSet<string> soldMenus =
    new HashSet<string>(StringComparer.Ordinal);

bool firstAdded = soldMenus.Add("라테");
bool secondAdded = soldMenus.Add("라테");

위 코드에서 firstAdded는 true이고 secondAdded는 false다. 요소 개수는 1이다. 다만 집합은 판매 횟수를 기록하지 않는다. 두 잔을 팔았다는 사실이 필요하다면 별도의 수량을 보관해야 한다. 중복을 없애는 것은 정보를 요약하는 일이므로, 원래 데이터에서 무엇을 잃어도 되는지 먼저 정해야 한다.

문자열 키나 요소를 다루는 예제에는 StringComparer.Ordinal을 지정한다. 이는 언어별 정렬 관습 대신 문자열의 코드 단위를 기준으로 비교하는 규칙이다. 대소문자도 구분한다. 따라서 "Latte"와 "latte"는 서로 다른 이름이다. 비교 규칙은 무엇을 같은 메뉴로 볼 것인지에 영향을 주므로 컬렉션을 만들 때 정한다.

주문 목록은 중복과 순서를 보존하고 재고 사전은 키로 수량을 연결하며 판매 집합은 중복을 제거한다

초기화와 순회에서 구분할 것

초기화 문법이 비슷해 보여도 각 컬렉션의 추가 규칙은 유지된다. List의 이니셜라이저에 같은 값을 두 번 쓰면 두 요소가 된다. HashSet에 같은 값을 두 번 쓰면 하나만 남는다. Dictionary의 대괄호 초기화에 같은 키를 두 번 쓰면 마지막으로 대입한 값이 남는다.

세 컬렉션 모두 Count로 현재 요소 수를 읽고 foreach로 순회할 수 있다. 그렇다고 모두 같은 방식으로 위치에 접근할 수 있는 것은 아니다. HashSet에는 목록처럼 사용할 정수 인덱스가 없다. Dictionary의 대괄호는 키를 받으므로, 키 타입이 int인 경우에도 그 숫자는 순번이 아니라 키다.

Dictionary를 foreach로 순회하면 각 항목에서 Key와 Value를 읽는다. 다음 코드는 사용법을 설명하는 조각이다. 이 순회 순서를 화면 출력의 계약으로 삼지는 않는다.

foreach (KeyValuePair<string, int> item in stock)
{
    string menu = item.Key;
    int quantity = item.Value;
}

List는 현재 목록 순서대로 순회한다. Dictionary와 HashSet은 출력 순서를 보장하는 용도로 선택하지 않는다. 실행해 보았을 때 추가한 순서처럼 보여도 그 관찰을 프로그램의 요구사항으로 삼으면 안 된다. 보고서나 예상 출력처럼 순서가 중요하면 별도의 목록으로 복사한 뒤 정렬한다.

List<string> menuNames = new List<string>(stock.Keys);
menuNames.Sort(StringComparer.Ordinal);

stock.Keys는 현재 키들을 볼 수 있게 제공하는 컬렉션이다. 이를 List 생성자에 넘기면 그 시점의 키들로 새 목록이 만들어진다. 새 목록을 정렬해도 원래 사전의 키와 값 관계는 바뀌지 않는다. 정렬 기준까지 지정하면 실행 환경의 언어 설정에 기대지 않고 같은 순서로 출력할 수 있다.

새 컬렉션을 만드는 것과 변수만 대입하는 것도 구분해야 한다. List<string> copied = orders;는 목록을 복사하지 않는다. 두 변수가 같은 목록을 가리킨다. 독립적인 목록이 필요하면 new List<string>(orders)처럼 새 인스턴스를 만든다. 단, 요소가 변경 가능한 객체라면 그 객체까지 새로 만드는 것은 아니다.

순회 중 수정은 따로 설계한다

foreach는 컬렉션에서 다음 요소를 차례로 얻는다. 이때 순회 상태를 관리하는 열거자(enumerator)가 사용된다. List를 foreach로 읽는 도중 Add나 Remove로 요소를 변경하면, 진행 중인 열거자가 더 이상 유효하지 않게 된다. 일반적으로 다음 요소를 얻으려는 시점에 InvalidOperationException이 발생한다.

여기서 문제는 스레드를 여러 개 사용했기 때문이 아니다. 하나의 실행 흐름에서도 순회와 수정이 겹치면 발생한다. Count가 달라지는 경우만 문제인 것도 아니다. List의 인덱서로 요소를 교체하는 작업도 진행 중인 열거자를 무효화한다.

다만 모든 컬렉션의 모든 변경이 같은 방식으로 동작한다고 일반화해서는 안 된다. 예를 들어 .NET 10의 Dictionary는 Remove와 Clear가 기존 열거자를 무효화하지 않는 예외를 갖는다. 이 장의 취소 처리는 List를 수정하는 작업이므로, List의 동작을 기준으로 안전한 방법을 선택한다.

취소된 주문을 제거할 때는 목록 끝에서 앞으로 이동하는 for를 사용한다. 앞쪽에서 제거하면 뒤에 있던 요소들이 한 칸씩 당겨진다. 그 상태에서 인덱스를 증가시키면 당겨진 요소를 확인하지 않고 넘어갈 수 있다. 반대로 뒤에서 제거하면 아직 확인하지 않은 앞쪽 요소의 인덱스는 변하지 않는다.

for (int i = orders.Count - 1; i >= 0; i--)
{
    if (cancelledMenus.Contains(orders[i]))
    {
        orders.RemoveAt(i);
    }
}

빈 목록에서도 이 반복문은 동작한다. 시작값이 -1이므로 조건이 처음부터 거짓이 되어 본문에 들어가지 않는다. 요소를 지우더라도 남은 주문들의 상대적인 순서는 유지된다. 이 방식은 목록에서 조건에 맞는 요소를 제거할 때 유용하지만, 순회 중 임의로 요소를 추가하는 모든 문제를 해결하는 규칙은 아니다.

목록 뒤쪽의 주문을 먼저 제거하면 아직 확인하지 않은 앞쪽 주문의 인덱스는 유지된다

또 다른 방법은 순회할 목록을 복사하고 원본을 수정하는 것이다. 복사본을 만드는 데 추가 메모리가 들지만, 순회 대상과 수정 대상이 분리되어 의도가 명확해진다. 어떤 방법을 택하든 “무엇을 순회하고 무엇을 수정하는가”를 코드에서 구분할 수 있어야 한다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 내용으로 바꾼다. 주문을 취소한 뒤 재고가 있는 주문만 제조하고, 마지막에 재고와 판매 메뉴를 출력한다. 문자열 비교와 출력용 정렬에 같은 비교 규칙을 적용한다.

using System;
using System.Collections.Generic;

List<string> orders = new List<string>
{
    "라테",
    "아메리카노",
    "라테",
    "차"
};

Dictionary<string, int> stock =
    new Dictionary<string, int>(StringComparer.Ordinal)
    {
        ["아메리카노"] = 3,
        ["라테"] = 2,
        ["차"] = 1
    };

HashSet<string> cancelledMenus =
    new HashSet<string>(StringComparer.Ordinal)
    {
        "차",
        "차"
    };

HashSet<string> soldMenus =
    new HashSet<string>(StringComparer.Ordinal);

Console.WriteLine($"접수 주문: {orders.Count}건");
Console.WriteLine($"취소 대상 메뉴: {cancelledMenus.Count}종");

int cancelledCount = 0;

for (int i = orders.Count - 1; i >= 0; i--)
{
    if (cancelledMenus.Contains(orders[i]))
    {
        orders.RemoveAt(i);
        cancelledCount++;
    }
}

Console.WriteLine($"취소 주문: {cancelledCount}건");

foreach (string menu in orders)
{
    if (!stock.TryGetValue(menu, out int quantity))
    {
        Console.WriteLine($"처리 불가: {menu} / 미등록");
        continue;
    }

    if (quantity <= 0)
    {
        Console.WriteLine($"처리 불가: {menu} / 재고 없음");
        continue;
    }

    stock[menu] = quantity - 1;
    soldMenus.Add(menu);
    Console.WriteLine($"제조: {menu}");
}

List<string> stockMenus = new List<string>(stock.Keys);
stockMenus.Sort(StringComparer.Ordinal);

Console.WriteLine("[남은 재고]");

foreach (string menu in stockMenus)
{
    Console.WriteLine($"{menu}: {stock[menu]}");
}

List<string> soldMenuList = new List<string>(soldMenus);
soldMenuList.Sort(StringComparer.Ordinal);

Console.WriteLine($"[판매 메뉴 {soldMenus.Count}종]");

foreach (string menu in soldMenuList)
{
    Console.WriteLine(menu);
}

줄별 해설

컬렉션 선언과 초기 데이터

using System;은 Console과 StringComparer를 이름만으로 사용할 수 있게 한다. using System.Collections.Generic;은 이 예제의 제네릭 컬렉션이 들어 있는 네임스페이스를 가져온다. 프로젝트의 암시적 using 설정에 기대지 않도록 둘 다 명시했다.

List<string> orders는 접수 순서를 보존한다. 초기 데이터의 라테 두 개는 같은 이름이지만 별도의 주문이다. 이후 제조 반복문은 이 목록을 앞에서부터 읽으므로 취소되지 않은 주문의 순서가 그대로 사용된다.

Dictionary<string, int> stock은 이름과 수량을 연결한다. ["라테"] = 2는 라테라는 키에 수량 2를 저장한다. 이 초기화에 적힌 순서는 이후 재고 출력 순서를 결정하는 근거로 사용하지 않는다.

cancelledMenus에는 차를 두 번 적었지만 집합에는 하나만 남는다. 따라서 취소 대상 메뉴는 1종이다. 이는 취소된 주문이 한 건이라는 뜻과 다르다. 목록에 차 주문이 세 건 있었다면 같은 취소 대상 하나로 세 주문이 제거된다.

soldMenus는 빈 집합으로 시작한다. 주문을 받았다는 사실만으로 판매 메뉴에 넣지 않고, 재고 확인을 통과해 실제 제조한 메뉴만 추가한다.

취소 주문 제거

처음 두 WriteLine은 원래 주문 수와 취소 대상의 종류 수를 출력한다. 이어지는 cancelledCount는 실제로 목록에서 제거한 주문의 개수를 센다. 집합의 Count와 별도의 변수를 사용하는 이유는 메뉴 종류 수와 주문 건수가 서로 다른 값이기 때문이다.

orders.Count - 1은 마지막 요소의 인덱스다. i >= 0은 첫 요소까지 검사하게 하고, i--는 뒤에서 앞으로 이동하게 한다. Contains가 true일 때만 RemoveAt으로 현재 위치를 제거하고 취소 건수를 늘린다.

원래 마지막 주문인 차를 제거하면 목록에는 라테, 아메리카노, 라테가 남는다. 앞쪽 세 요소의 위치는 바뀌지 않는다. 제거가 끝난 뒤에 제조용 foreach를 시작하므로, 두 작업의 순회 상태가 섞이지 않는다.

재고 확인과 제조

foreach (string menu in orders)는 남은 주문을 접수 순서대로 꺼낸다. TryGetValue 앞의 !는 조회 결과를 반대로 바꾼다. 따라서 해당 조건문은 메뉴가 재고 사전에 없을 때 실행된다. continue는 현재 주문의 처리를 끝내고 다음 주문으로 이동한다.

조회에 성공했더라도 수량이 0 이하라면 제조하지 않는다. 등록 여부와 수량 검사를 나누었으므로 미등록 메뉴와 품절 메뉴에 서로 다른 메시지를 출력할 수 있다. 현재 초기 데이터에서는 두 실패 경로가 실행되지 않는다.

stock[menu] = quantity - 1;은 조회했던 수량보다 하나 적은 값을 저장한다. quantity는 사전의 저장 공간 자체가 아니라 읽어 온 int 값이다. quantity만 감소시키면 사전에 자동으로 반영되지 않으므로 키를 통해 다시 대입한다.

soldMenus.Add(menu)는 제조한 메뉴를 기록한다. 두 번째 라테에서 Add는 false를 반환하지만, 여기서는 새로 추가되었는지에 따라 별도 동작을 하지 않으므로 반환값을 사용하지 않는다. 제조 메시지는 두 번 모두 출력한다.

이 foreach가 읽는 대상은 orders이고 변경하는 대상은 stock과 soldMenus다. 순회 중 다른 컬렉션을 수정하는 행위 자체가 금지된 것은 아니다. 제조 단계에서는 orders의 요소를 추가하거나 제거하지 않는다.

출력 순서 확정

new List<string>(stock.Keys)는 재고 키를 새 목록으로 복사한다. Sort에 StringComparer.Ordinal을 전달하면 라테, 아메리카노, 차 순서가 된다. 그 목록을 순회하며 원래 사전에서 수량을 읽는다.

판매 메뉴도 집합을 바로 출력하지 않고 목록으로 복사해 정렬한다. 따라서 같은 입력에서는 재고와 판매 메뉴가 매번 같은 순서로 표시된다. 판매 메뉴는 두 종류이고, 라테를 두 번 제조한 사실은 앞서 출력한 제조 메시지에 나타난다.

실행 결과

터미널에서 프로젝트를 만들고 해당 디렉터리로 이동한다. 생성된 Program.cs를 완성 코드로 교체한 다음 실행한다.

dotnet new console --framework net10.0 --name CafeCollections
cd CafeCollections
dotnet run

프로그램의 예상 출력은 다음과 같다.

접수 주문: 4건
취소 대상 메뉴: 1종
취소 주문: 1건
제조: 라테
제조: 아메리카노
제조: 라테
[남은 재고]
라테: 0
아메리카노: 2
차: 1
[판매 메뉴 2종]
라테
아메리카노

라테는 두 번 제조되어 재고가 0이 되고, 아메리카노는 한 번 제조되어 2가 남는다. 차는 제조 전에 취소했으므로 재고가 줄지 않는다. 취소 대상에 같은 이름을 두 번 넣은 것은 집합의 크기나 취소 건수를 늘리지 않는다.

실무에서 자주 틀리는 것

다음 예제는 잘못된 사용과 수정 방법을 비교하는 독립적인 코드 조각이다. 완성 코드에 순서대로 이어 붙이는 용도가 아니다. 틀린 코드 중에는 의도적으로 실행 중 예외를 일으키는 코드가 포함되어 있다.

Count를 마지막 인덱스로 사용한다

다음 코드는 요소가 두 개일 때 인덱스 2까지 접근한다. 마지막 반복에서 유효 범위를 벗어난다.

List<string> orders = new List<string> { "라테", "차" };

for (int i = 0; i <= orders.Count; i++)
{
    Console.WriteLine(orders[i]);
}

읽기만 하는 인덱스 순회라면 조건을 Count 미만으로 작성한다. 인덱스가 필요하지 않으면 foreach가 의도를 더 직접적으로 표현한다.

List<string> orders = new List<string> { "라테", "차" };

for (int i = 0; i < orders.Count; i++)
{
    Console.WriteLine(orders[i]);
}

없는 키를 읽거나 기존 키를 다시 추가한다

다음 사전에는 차가 없으므로 읽기에서 KeyNotFoundException이 발생한다. 마지막 줄도 기존 라테 키를 Add로 다시 추가하므로, 따로 실행하면 ArgumentException이 발생한다.

Dictionary<string, int> stock =
    new Dictionary<string, int>(StringComparer.Ordinal)
    {
        ["라테"] = 2
    };

Console.WriteLine(stock["차"]);
stock.Add("라테", 5);

조회할 때는 성공 여부를 확인하고, 수량을 바꾸려는 의도라면 인덱서로 대입한다. 기존 키를 실수로 덮어쓰면 안 되는 등록 작업이라면 TryAdd의 반환값을 확인하는 방법도 있다.

Dictionary<string, int> stock =
    new Dictionary<string, int>(StringComparer.Ordinal)
    {
        ["라테"] = 2
    };

if (stock.TryGetValue("차", out int quantity))
{
    Console.WriteLine(quantity);
}
else
{
    Console.WriteLine("차는 등록되지 않았다.");
}

stock["라테"] = 5;

foreach로 읽는 목록에서 바로 제거한다

다음 코드는 차를 제거한 뒤 foreach가 다음 단계로 진행할 때 InvalidOperationException이 발생한다. 첫 번째 삭제가 수행되었다고 해서 반복문 전체가 유효한 것은 아니다.

List<string> orders = new List<string>
{
    "차", "차", "라테"
};

foreach (string menu in orders)
{
    if (menu == "차")
    {
        orders.Remove(menu);
    }
}

완성 코드에서는 역방향 for를 사용했다. 여기서는 다른 대안으로 복사본을 순회한다. 복사본에는 차가 두 번 있으므로 원본에서도 첫 번째 차를 두 번 제거한다. 결과적으로 원본에는 라테만 남는다.

List<string> orders = new List<string>
{
    "차", "차", "라테"
};

List<string> snapshot = new List<string>(orders);

foreach (string menu in snapshot)
{
    if (menu == "차")
    {
        orders.Remove(menu);
    }
}

이 방법에서 snapshot 대신 orders를 그대로 대입하면 같은 목록을 가리키므로 문제가 해결되지 않는다. 또한 복사와 반복 삭제에는 비용이 든다. 단순한 목록 필터링이라면 역방향 제거처럼 복사 없이 표현할 수 있는지도 함께 검토한다.

HashSet이 횟수와 출력 순서를 보관한다고 생각한다

다음 코드는 라테 두 잔을 판매한 뒤 판매 건수를 1건으로 출력한다. 컴파일이나 실행의 문제가 아니라, 보관하려는 정보와 자료구조가 맞지 않는 문제다.

HashSet<string> soldMenus =
    new HashSet<string>(StringComparer.Ordinal);

soldMenus.Add("라테");
soldMenus.Add("라테");

Console.WriteLine($"판매 건수: {soldMenus.Count}건");

건수와 종류 수를 모두 원하면 중복을 유지하는 목록과 중복을 제거한 집합을 각각 사용한다. 집합을 출력할 때 순서까지 필요하면 정렬용 목록을 별도로 만든다.

List<string> sales = new List<string>
{
    "라테", "라테"
};

HashSet<string> soldMenus =
    new HashSet<string>(sales, StringComparer.Ordinal);

Console.WriteLine($"판매 건수: {sales.Count}건");
Console.WriteLine($"판매 종류: {soldMenus.Count}종");

List<string> sortedMenus = new List<string>(soldMenus);
sortedMenus.Sort(StringComparer.Ordinal);

foreach (string menu in sortedMenus)
{
    Console.WriteLine(menu);
}

한눈에 보기

컬렉션 작업을 선택할 때 확인할 규칙
하려는 작업사용할 기능확인할 점
주문을 접수 순서대로 추가List.Add중복 주문도 각각 보관한다.
특정 위치의 주문 읽기List의 인덱서0 이상 Count 미만이어야 한다.
특정 위치의 주문 제거List.RemoveAt뒤 요소들의 인덱스가 당겨진다.
메뉴별 수량 조회Dictionary.TryGetValue반환값으로 등록 여부를 확인한다.
메뉴별 수량 저장Dictionary의 인덱서 대입기존 키는 갱신하고 새 키는 추가한다.
메뉴의 포함 여부 확인HashSet.Contains생성할 때 정한 동등성 규칙을 따른다.
메뉴를 중복 없이 추가HashSet.Add이미 있으면 false를 반환한다.
목록에서 여러 요소 제거역방향 for와 RemoveAt아직 검사하지 않은 앞쪽 위치를 보존한다.
출력 순서 고정새 List와 Sort문자열 비교 규칙도 명시한다.

컬렉션 선택은 자료의 모양보다 작업의 의미에서 출발한다. 순번으로 다루면 목록, 이름으로 연결된 값을 찾으면 사전, 포함 여부와 중복 제거가 중심이면 집합이 적합하다. 같은 메뉴 이름을 사용하더라도 주문과 재고와 판매 기록은 서로 다른 규칙을 갖는다.

연습 문제

  1. 완성 코드의 주문 목록 끝에 라테를 한 건 더 추가하라. 다른 초기 데이터는 유지한다. 접수 건수, 제조 메시지, 처리 불가 메시지, 최종 라테 재고, 판매 메뉴 종류 수가 어떻게 달라지는지 설명하라.
  2. 재고 사전에 시럽을 처음 등록할 때만 수량 4를 저장하라. 이미 등록되어 있으면 기존 수량을 유지하고 “시럽은 이미 등록되어 있다.”를 출력하라. 같은 등록 코드를 두 번 실행해 결과를 확인할 수 있도록 작성하라.
  3. 주문이 차, 차, 라테, 차 순서인 목록에서 모든 차 주문을 제거하라. 역방향 for를 사용하고 제거 후 개수와 남은 메뉴를 출력하라. 앞에서부터 제거하며 인덱스를 증가시키면 무엇을 놓칠 수 있는지도 설명하라.
  4. 판매 기록이 라테, 차, 라테, 아메리카노, 차 순서로 주어졌다. 판매 건수와 메뉴 종류 수를 각각 구하고, 메뉴 이름을 StringComparer.Ordinal 기준으로 정렬해 한 줄씩 출력하라.

정답과 해설

1. 재고보다 많은 주문

주문 이니셜라이저의 차 다음에 라테를 추가한다. 접수 주문은 5건이 되고 차 한 건이 취소되어 네 건이 제조 단계에 들어간다. 첫 라테와 아메리카노, 두 번째 라테까지는 제조되고, 마지막 라테는 남은 수량이 0이므로 처리 불가 메시지가 출력된다.

접수 주문: 5건
취소 대상 메뉴: 1종
취소 주문: 1건
제조: 라테
제조: 아메리카노
제조: 라테
처리 불가: 라테 / 재고 없음
[남은 재고]
라테: 0
아메리카노: 2
차: 1
[판매 메뉴 2종]
라테
아메리카노

실패한 주문은 재고 차감과 판매 집합 추가에 도달하지 않는다. 따라서 라테 재고는 음수가 되지 않고 판매 메뉴는 여전히 2종이다. 주문 수와 실제 제조 수를 같은 값으로 취급하지 않는 것도 중요하다.

2. 기존 수량을 보존하는 등록

TryAdd는 키가 없을 때 추가하고 true를 반환한다. 같은 키가 있으면 값을 바꾸지 않고 false를 반환한다. 다음 코드는 완성 코드의 stock 선언 이후에 넣어 확인할 수 있는 조각이다.

for (int attempt = 0; attempt < 2; attempt++)
{
    if (stock.TryAdd("시럽", 4))
    {
        Console.WriteLine("시럽을 등록했다.");
    }
    else
    {
        Console.WriteLine("시럽은 이미 등록되어 있다.");
    }
}

첫 시도에서는 등록 메시지, 두 번째 시도에서는 이미 등록되었다는 메시지가 나온다. 시럽 수량은 4다. 인덱서 대입도 예외 없이 동작하지만 기존 수량을 덮어쓸 수 있으므로 “처음 등록할 때만”이라는 요구에는 TryAdd가 맞는다.

3. 연속된 취소 주문 제거

다음은 별도 콘솔 프로젝트의 Program.cs로 실행할 수 있는 정답이다.

using System;
using System.Collections.Generic;

List<string> orders = new List<string>
{
    "차", "차", "라테", "차"
};

for (int i = orders.Count - 1; i >= 0; i--)
{
    if (orders[i] == "차")
    {
        orders.RemoveAt(i);
    }
}

Console.WriteLine($"남은 주문: {orders.Count}건");

foreach (string menu in orders)
{
    Console.WriteLine(menu);
}

출력은 다음과 같다.

남은 주문: 1건
라테

앞에서 첫 차를 제거하면 두 번째 차가 인덱스 0으로 이동한다. 이어서 i를 1로 증가시키면 이동한 차를 확인하지 않고 넘어간다. 역방향에서는 제거한 위치보다 앞에 있는 요소가 그대로 남으므로 연속된 취소 주문도 빠짐없이 검사한다.

4. 판매 건수와 메뉴 종류 분리

판매 목록의 Count는 건수이고, 그 목록으로 만든 집합의 Count는 종류 수다. 다음은 독립적으로 실행할 수 있는 정답이다.

using System;
using System.Collections.Generic;

List<string> sales = new List<string>
{
    "라테", "차", "라테", "아메리카노", "차"
};

HashSet<string> uniqueMenus =
    new HashSet<string>(sales, StringComparer.Ordinal);

List<string> menuNames = new List<string>(uniqueMenus);
menuNames.Sort(StringComparer.Ordinal);

Console.WriteLine($"판매 건수: {sales.Count}건");
Console.WriteLine($"메뉴 종류: {uniqueMenus.Count}종");

foreach (string menu in menuNames)
{
    Console.WriteLine(menu);
}

출력은 다음과 같다.

판매 건수: 5건
메뉴 종류: 3종
라테
아메리카노
차

원래 판매 목록은 중복과 순서를 유지한다. 집합은 메뉴 종류를 얻는 데 사용하고, 정렬된 목록은 표시 순서를 결정하는 데 사용한다. 하나의 컬렉션에 모든 역할을 맡기지 않고 필요한 정보를 단계별로 표현한 결과다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.