비트를 쪼개는 개발자

allen321@naver.com

C#

C# - 이터레이터(Iterator) [반복자]

MozarTnT 2024. 10. 29. 15:18
728x90
반응형

 

 

이터레이터(Iterator)란?

 

 

Iteration라는 단어는 반복이라는 의미를 가진다.

 

C#에서 이를 Iterator를 사용한다는 것은 프로그램안에서 특정 동작이나 행위를 반복시키고 싶을때 사용한다.

 

C#에서는 이 Iterator를 IEnumerator라는 인터페이스를 사용해서 나타내고 이를 열거자 라고 지칭한다.

 

Iterator는 다음과 같은 특징을 가진다.

 

 

Iterator의 특징

  1. 순차적 접근:
    • 이터레이터는 컬렉션 내의 요소를 순서대로 하나씩 접근할 수 있게 도와준다. 첫 번째 요소부터 시작해 마지막 요소까지 순차적으로 요소에 접근할 수 있다.
  2. 상태 관리:
    • IEnumerator 인터페이스는 Current, MoveNext, Reset 메서드를 통해 현재 요소의 상태를 유지하고, 다음 요소로 이동하거나 컬렉션의 시작점으로 되돌릴 수 있는 메커니즘을 제공한다.
  3. 지연 실행 (Lazy Evaluation):
    • yield return을 이용해 필요한 순간에 데이터를 반환하는 방식으로, 모든 요소를 미리 메모리에 로드하지 않고 필요할 때마다 생성하여 반환할 수 있다. 이를 통해 메모리 사용량을 최소화할 수 있어 메모리 사용 측면에서 경제적이다.
  4. 컬렉션의 캡슐화:
    • 이터레이터는 컬렉션의 내부 구조를 노출하지 않고도 데이터를 순차적으로 접근할 수 있게 해준다. 즉, 데이터가 배열이든 리스트든 상관없이, 외부에서는 이터레이터를 통해 같은 방식으로 접근할 수 있다.
  5. 반복 가능한 구조:
    • IEnumerable 인터페이스를 통해 다양한 데이터 구조가 foreach 반복문과 같은 구문으로 접근 가능해지며, 이를 통해 코드의 가독성과 일관성을 높일 수 있다.

 

 

이터레이터(Iterator)를 사용하는 이유

 

1. 메모리 효율성

 

 

아래의 예시 코드를 살펴보자

// Iterator 사용하지 않을 때
public List<int> GetNumbers()
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < 1000000; i++)
    {
        numbers.Add(i);  // 모든 숫자를 메모리에 저장
    }
    return numbers;
}

// Iterator 사용할 때
public IEnumerable<int> GetNumbers()
{
    for (int i = 0; i < 1000000; i++)
    {
        yield return i;  // 필요할 때마다 하나씩 생성
    }
}

 

 

0부터 1백만까지의 숫자를 선언할때 리스트로 선언한다면 선언한 시점에 모든 데이터는 메모리에 로드된다.

 

하지만 이를 Iterator를 사용해 IEnumerable(열거자)로 선언하면 실제로 해당 데이터가 필요한 시점(ex : foreach 루프)에서만 하나씩 생성되는 방식이기 때문에 메모리를 효율적으로 사용할 수 있게 된다.

 

즉 도서관에서 한번에 백만권의 책을 빌려 와서 한권을 읽는것과 필요할때마다 한권씩 빌려오는 것의 효율 차이다.

 

 

이를 실제로 테스트 해보면 다음과 같다.

 

// 방법 1: List - 즉시 실행 (Eager Loading)
public List<int> GetNumbersList()
{
    Console.WriteLine("리스트 생성 시작");
    List<int> numbers = new List<int>();
    
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine($"숫자 {i} 추가중");
        numbers.Add(i);
    }
    return numbers;
}

// 방법 2: IEnumerable - 지연 실행 (Lazy Loading)
public IEnumerable<int> GetNumbersEnum()
{
    Console.WriteLine("열거자 생성 시작");
    
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine($"숫자 {i} 생성중");
        yield return i;
    }
}

// 실행 테스트
void Test()
{
    Console.WriteLine("1. List 방식:");
    List<int> list = GetNumbersList();     // 이 시점에 모든 숫자가 생성됨
    Console.WriteLine("리스트 준비 완료\n");

    Console.WriteLine("2. IEnumerable 방식:");
    IEnumerable<int> enum = GetNumbersEnum();  // 이 시점에는 아무 것도 생성 안됨
    Console.WriteLine("열거자 준비 완료\n");

    Console.WriteLine("열거자 순회 시작:");
    foreach(var num in enum)               // 이 시점에 하나씩 숫자가 생성됨
    {
        Console.WriteLine($"숫자 {num} 사용");
    }
}

 

 

 

 

 

예시로 생성한 코드에서는 선언한 숫자를 가져와서 사용하는 상황이기 때문에 IEnumerable(열거자)를 사용하는 방식이 효과적이다.

 

하지만 리스트를 사용하기에 충분한 메모리가 있는 상황이어서 굳이 지연 처리를 통해 반환할 준비만 하지 않아도 되는 상황이거나, 데이터를 반복적으로 사용하고 데이터를 수정해야 하는 상황이라면 List를 사용하는 것이 유리하다.

 

IEnumerable(열거자)를 사용할때는 다음과 같은 제한 사항이 있기 때문이다.

  • 이터를 차적으로 읽기만 가능
  • ✅ 필터링(Where), 변환(Select) 등은 가능
     
  •  데이터 추가/삭제/수정 불가
  • ❌ 인덱스로 직접 접근 불가

 

간단하게 정리하자면 이렇다.

 

  • 데이터 수정이 필요한 경우 → List 사용
  • 데이터 읽기만 하는 경우 → IEnumerable 사용

 

 

2. 캡슐화

 

 

아래의 예시 코드를 살펴보자

public class Library
{
    private List<Book> books;                // 내부 구현은 숨기고
    private HashSet<Book> reservedBooks;     // 외부에서 직접 접근 불가

    public IEnumerable<Book> GetAvailableBooks()  // 단순한 인터페이스만 제공
    {
        // 내부적으로 어떻게 구현되어있는지 외부에서는 알 필요가 없음
        foreach(var book in books)
        {
            if (!reservedBooks.Contains(book))
            {
                yield return book;
            }
        }
    }
}

// 사용하는 쪽에서는
Library library = new Library();
foreach(var book in library.GetAvailableBooks())  // 단순히 이용만 하면 됨
{
    Console.WriteLine(book.Title);
}

 

 

 

IEnumerable(열거자)를 사용해 다음과 같이 선언된 Library Class가 있다고 가정하자.

 

외부에서 열거자로 선언된 GetAvailableBooks에 접근하고 싶을때 사용하는 쪽에서는 book에 대한 정보를 아무것도 몰라도 해당 데이터를 넘겨 받을 수 있다. 

 

이를 통해 내부적으로는 코드 구현을 숨길 수 있고, 내부적으로 현재 선언된 Books를 List로 선언해도, Array로 수정해도, HashSet을 Dictionary로 변경해도 외부에서는 코드를 한 줄도 수정할 필요가 없다.

 

즉 내부적으로는 코드가 유연하고 안전해지고, 외부적으로는 복잡한 내부 로직을 몰라도 편하게 데이터를 사용할 수 있게 되는것이다.

 

 

이를 정리하면 다음과 같다.

 

 

캡슐화를 통한 장점

  • 구현 변경이 유연하다. (List -> Array, HashSet -> Dictionary)
  • 코드의 안전성이 보장된다. (외부에서 수정 불가)
  • 인터페이스의 단순성 (사용하는 쪽에서의 로직 파악 필요성이 없음)
  • 유지 보수 용이 (내부 구현 변경에 따른 외부 코드 수정이 필요 없음)

 

 


 

 

실제 사용 예시

 

// 무한한 피보나치 수열 생성
public IEnumerable<int> GetFibonacci()
{
    int current = 0, next = 1;
    while (true)  // 무한 반복
    {
        yield return current;
        int temp = current + next;
        current = next;
        next = temp;
    }
}

// 사용: 처음 5개만 가져오기
foreach(var num in GetFibonacci().Take(5))
{
    Console.WriteLine(num);  // 0, 1, 1, 2, 3
}

 

 

위 코드는 열거자를 사용해 피보나치 수열을 생성하는 코드다.

 

피보나치 수열을 List나 배열로 구현하려고 하면 무한한 숫자가 생성되기 때문에 무한 루프 문제가 발생한다.

 

이런 상황에서 List에 모든 값을 배정할 필요 없이 열거자를 사용해 필요한 값을 하나씩 생성하는 방식으로 처리하면 안전하게 컴파일 할 수 있다.

 

이는 Itearator의 지연 응답 기능을 활용한 아주 유용한 방식이다. 

 

 

 

 

 

 

마지막으로 Iterator(반복자)와 Enumerator(열거자)를 정리하면

 

 

 

도서관 Class에서 책을 한 권 대출하고 싶은 상황일때

 

Iterator(반복자)는 "책을 순서대로 봐야 하는 규칙"을 담당하고

 

Enumerator(열거자)는 "다음 책을 하나씩 제공하는 사서"를 담당한다.

 

 

즉 C#에서는

 

 

Iterator(반복자) : 컬렉션의 요소를 순회하는 방법 자체를 정의하는 것

 

Enumerator(열거자) : 실제로 컬렉션을 순회할 때 사용하는 객체

 

 

로 정리할 수 있다.

 

 

 

 

 

 

 

사진 출처 : https://refactoring.guru/ko/design-patterns/iterator

728x90
반응형