Queue(큐)에 이어 이번 글에서는 배열 기반으로 동작하는 List 구조를 살펴보겠습니다. List는 내부적으로 배열을 사용하지만, 필요할 때 자동으로 크기를 늘려 가는 동적 배열(dynamic array)입니다. C#에서 가장 많이 사용하는 List<T> 컬렉션과 거의 동일한 방식으로 동작합니다.
이 글에서는 List의 개념과 특징을 정리하고, C# 배열을 이용해 직접 List를 구현하는 방법과 Unity에서 테스트하는 예제를 함께 살펴보겠습니다.
1. List 구조와 동작 방식
List는 내부적으로 하나의 연속된 배열을 사용합니다. 배열과 마찬가지로 인덱스를 이용한 임의 접근(random access)이 O(1)에 가깝게 매우 빠릅니다. 하지만 일반적인 배열과 달리, List는 다음과 같은 특징을 가집니다.
- 처음에 정해진 용량(
Capacity)만큼 내부 배열을 생성합니다. - 요소를
Add하다가 배열이 가득 차면, 더 큰 새 배열을 만들고 기존 요소들을 복사한 뒤 교체합니다. - 이 과정을 통해, 개발자는 배열 크기를 직접 신경 쓰지 않고도 마음대로 데이터를 추가할 수 있습니다.
정리하면, List는:
- 내부적으로는 배열이지만
- 겉으로는 크기가 자동으로 늘어나는 것처럼 보이는 동적 배열입니다.
2. List가 활용되는 대표적인 사례
List는 “순서가 있는 여러 데이터를 관리”하는 거의 모든 상황에서 사용할 수 있습니다. 특히 다음과 같은 경우에 자주 등장합니다.
- 게임 오브젝트 목록 관리
예: 현재 활성화된 몬스터 리스트, 파티원 리스트, 인벤토리 아이템 리스트 등 - 범위 내 대상 수집 및 처리
스킬 범위나 시야(FOV) 안에 들어온 대상들을 리스트에 모아 한 번에 처리합니다. - 동적 UI 요소 관리
인벤토리 슬롯, 파티 UI, 퀘스트 목록처럼 중간 삽입/삭제가 발생하는 UI 구성 요소를 리스트로 관리합니다.
※ 실제 개발에서는 “여러 개의 데이터를 순서대로 담고, 빠르게 접근하거나 처리하고 싶다”라고 생각되면 대부분 List를 사용합니다. List는 연속된 메모리 구조를 기반으로 하므로 인덱스를 통한 빠른 조회(O(1))와 순차 처리에 매우 효율적입니다.
3. List가 제공하는 기능 (구현 기준)
배열 기반 List는 일반적으로 다음과 같은 기능을 제공합니다.
- Add(T item) : 리스트의 맨 끝에 요소를 하나 추가
- Insert(int index, T item) : 중간에 요소를 끼워 넣고 뒤 요소들을 한 칸씩 뒤로 밀기
- Remove(T item) : 지정한 값을 처음으로 찾은 뒤 제거
- RemoveAt(int index) : 지정한 인덱스의 요소 제거
- Clear() : 모든 요소 제거 (용량은 유지)
- Contains(T item) : 리스트 안에 해당 값이 포함되어 있는지 여부 확인
- IndexOf(T item) : 처음 등장하는 인덱스 반환, 없으면 -1
- this[int index] 인덱서 :
list[i]형태로 읽기/쓰기 - Count : 현재 저장된 요소 개수
- Capacity : 내부 배열의 현재 용량 (필요시 확장)
이 글에서 구현하는 List는 위 기능을 모두 포함하며, 내부 배열의 용량이 부족해질 경우 자동으로 크기를 늘리는 구조를 가지고 있습니다.
4. List의 내부 동작 방식
이제 List가 내부적으로 배열을 어떻게 관리하며 요소를 추가·삭제하는지 살펴보겠습니다.
✔ 초기 상태
- 배열은 비어 있음
- count = 0

✔ A 추가 (Add)
- 현재 count 위치에 A 추가, count 1 증가
- count = 1

✔ B, C 추가 (Add)
- 같은 방식으로 B, C 추가
- count = 3

✔ B 제거 (Remove)
- B가 있는 인덱스 찾기 -> 1 (IndexOf 사용)
- 1 뒤에 있는 데이터를 앞으로 당기기(복사), count - 1 자리 비우기, count 1 감소
- count = 2

✔ D를 1번 인덱스에 추가 (A, C 사이) (Insert)
- 1과 그 뒤에 있는 데이터들을 한 칸씩 뒤로 밀기(복사)
- 1 자리에 D 설정
- count = 3

이런 방식 덕분에, List는 배열의 장점(빠른 인덱스 접근)을 유지하면서도 동적으로 크기를 늘리고, 중간 삽입/삭제를 지원할 수 있습니다.
5. C# 배열로 List 직접 구현하기
아래 코드는 배열을 기반으로 한 제네릭 List<T> 구현 예제입니다. Daebak.Common.Collections 네임스페이스 아래에 두고 사용했습니다.
using System;
using System.Collections;
using BCL = System.Collections.Generic; // BCL - Base Class Library
namespace Daebak.Common.Collections
{
/// <summary>
/// 배열을 내부적으로 사용하여 동적으로 크기를 늘려 가는 리스트 구현입니다.
/// .NET의 List<T>와 비슷한 방식으로 동작합니다.
/// </summary>
public class List<T> : BCL.IEnumerable<T>
{
private T[] _items; // 실제 데이터를 저장하는 내부 배열
private int _count; // 현재 리스트에 들어있는 요소 개수
public int Capacity => _items.Length; // 현재 내부 배열이 한 번에 담을 수 있는 최대 개수(용량)
public int Count => _count; // 현재 리스트에 저장된 요소의 개수
private const int DEFAULT_CAPACITY = 4; // 기본 생성 시 사용할 초기 용량
/// <summary>
/// 기본 생성자. 내부 배열을 DEFAULT_CAPACITY 크기로 초기화합니다.
/// </summary>
public List()
{
_items = new T[DEFAULT_CAPACITY];
}
/// <summary>
/// 지정한 용량(capacity)으로 내부 배열을 초기화하는 생성자입니다.
/// </summary>
/// <param name="capacity">초기 용량 (0 이상)</param>
public List(int capacity)
{
if (capacity < 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_items = new T[capacity];
}
/// <summary>
/// 리스트의 용량(Capacity)을 강제로 지정합니다.
/// 현재 요소 개수(Count)보다 작게 줄일 수는 없습니다.
/// </summary>
/// <param name="capacity">새로 설정할 용량</param>
public void SetCapacity(int capacity)
{
// 현재 들어있는 개수보다 작은 용량으로는 줄일 수 없음
if (capacity < _count)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
// 용량에 변화가 없으면 아무 것도 하지 않음
if (capacity == _items.Length) return;
// 새로운 배열 생성
T[] newArr = new T[capacity];
// 기존 요소들을 새 배열로 복사
if (_count > 0)
{
Array.Copy(_items, newArr, _count);
}
// 내부 배열 교체
_items = newArr;
}
/// <summary>
/// 리스트의 맨 뒤에 요소를 하나 추가합니다.
/// 필요하다면 내부 배열의 용량을 자동으로 확장합니다.
/// </summary>
public void Add(T item)
{
// 최소한 _count + 1개를 담을 수 있도록 용량 확인/확장
EnsureCapacity(_count + 1);
_items[_count] = item;
++_count;
}
/// <summary>
/// 지정한 인덱스 위치에 요소를 삽입합니다.
/// 뒤에 있는 요소들은 한 칸씩 뒤로 밀립니다.
/// </summary>
/// <param name="index">삽입할 위치 (0 ~ Count)</param>
/// <param name="item">삽입할 값</param>
public void Insert(int index, T item)
{
// index는 0 이상, Count 이하(맨 뒤에 삽입하는 경우)만 허용
if (index < 0 || index > _count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
// 새로운 요소를 담을 수 있도록 용량 확인/확장
EnsureCapacity(_count + 1);
// 중간에 끼워 넣는 경우, 뒤에 있던 요소들을 한 칸씩 뒤로 복사
if (index < _count)
{
Array.Copy(_items, index, _items, index + 1, _count - index);
}
// index 위치에 새 값 대입
_items[index] = item;
++_count;
}
/// <summary>
/// 리스트에서 지정한 값을 처음으로 찾은 뒤 제거합니다.
/// </summary>
/// <returns>성공적으로 제거하면 true, 해당 값이 없으면 false</returns>
public bool Remove(T item)
{
// 먼저 값의 인덱스를 찾고
int idx = IndexOf(item);
if (idx < 0)
{
return false;
}
// 해당 위치를 제거
RemoveAt(idx);
return true;
}
/// <summary>
/// 지정한 인덱스에 있는 요소를 제거합니다.
/// 뒤에 있는 요소들을 한 칸씩 앞으로 당깁니다.
/// </summary>
/// <param name="index">제거할 위치 (0 ~ Count-1)</param>
public void RemoveAt(int index)
{
if (index < 0 || index >= _count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
// 마지막 요소가 아닌 경우, 뒤에 있는 것들을 한 칸씩 앞으로 복사
if (index < _count - 1)
{
Array.Copy(_items, index + 1, _items, index, _count - 1 - index);
}
// 마지막 자리는 기본값으로 지워서 GC가 참조를 잡고 있지 않도록 함
_items[_count - 1] = default;
--_count;
}
/// <summary>
/// 리스트에 들어있는 모든 요소를 제거합니다.
/// (Capacity는 그대로 유지됩니다.)
/// </summary>
public void Clear()
{
// 참조 타입인 경우 GC가 수거할 수 있도록 null(기본값)로 초기화
for (int i = 0; i < _count; ++i)
{
_items[i] = default;
}
_count = 0;
}
/// <summary>
/// 리스트에 지정한 값이 들어있는지 확인합니다.
/// </summary>
public bool Contains(T item)
{
return IndexOf(item) >= 0;
}
/// <summary>
/// 리스트에서 지정한 값이 처음으로 나타나는 인덱스를 찾습니다.
/// 없으면 -1을 반환합니다.
/// </summary>
public int IndexOf(T item)
{
// EqualityComparer<T>.Default를 사용하면
// 값 타입/참조 타입에 맞게 적절한 비교를 해 줍니다.
BCL.EqualityComparer<T> cmp = BCL.EqualityComparer<T>.Default;
for (int i = 0; i < _count; ++i)
{
if (cmp.Equals(_items[i], item))
{
return i;
}
}
return -1;
}
/// <summary>
/// 인덱서를 통해 리스트에 랜덤 접근할 수 있게 해 줍니다.
/// list[i] 형태로 읽기/쓰기가 가능합니다.
/// </summary>
public T this[int index]
{
get
{
if (index < 0 || index >= _count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
return _items[index];
}
set
{
if (index < 0 || index >= _count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
_items[index] = value;
}
}
/// <summary>
/// 최소 min 개수의 요소를 담을 수 있도록 내부 배열의 용량을 늘려 줍니다.
/// 용량이 부족할 때만 배열을 새로 할당합니다.
/// </summary>
/// <param name="min">필요한 최소 용량</param>
private void EnsureCapacity(int min)
{
// 이미 충분한 용량이라면 아무 것도 하지 않음
if (_items.Length >= min)
{
return;
}
// 현재 용량이 0이면 기본 값, 아니면 두 배로 늘리기
int newCap = _items.Length == 0 ? DEFAULT_CAPACITY : _items.Length * 2;
// 그래도 min보다 작으면, 최소한 min까지는 맞춰 줌
if (newCap < min)
{
newCap = min;
}
// 새 배열을 만들고 기존 요소들을 복사한 뒤 교체
T[] newArr = new T[newCap];
Array.Copy(_items, newArr, _count);
_items = newArr;
}
/// <summary>
/// 제네릭 열거자를 반환합니다. (foreach 지원)
/// </summary>
public BCL.IEnumerator<T> GetEnumerator()
{
for (int i = 0; i < _count; ++i)
{
yield return _items[i];
}
}
/// <summary>
/// 비제네릭 열거자를 반환합니다. (IEnumerable 구현)
/// </summary>
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
}
6. 유니티(Unity) 환경에서 테스트해 보기
Unity에서 위 List 구현이 제대로 동작하는지 확인하기 위해 간단한 테스트 스크립트를 작성해 보겠습니다.
using UnityEngine;
using Daebak.Common.Collections;
public class Test_List : MonoBehaviour
{
void Start()
{
List<int> list = new List<int>();
Debug.Log("=== Add 3개 ===");
list.Add(10);
list.Add(20);
list.Add(30);
Debug.Log($"Count: {list.Count}, Capacity: {list.Capacity}"); // 3, 4
Debug.Log("\n=== Insert(1, 15) ===");
list.Insert(1, 15); // 10, 15, 20, 30
Dump(list);
Debug.Log("\n=== Remove(20) ===");
list.Remove(20); // 10, 15, 30
Dump(list);
Debug.Log("\n=== IndexOf / Contains ===");
Debug.Log($"IndexOf(15): {list.IndexOf(15)}"); // 1
Debug.Log($"Contains(30): {list.Contains(30)}"); // True
Debug.Log("\n=== Capacity 확장 테스트 ===");
for (int i = 0; i < 10; ++i)
{
list.Add(100 + i);
}
Debug.Log($"Count: {list.Count}, Capacity: {list.Capacity}"); // 13, 16
Dump(list);
Debug.Log("\n=== Clear ===");
list.Clear();
Debug.Log($"Count after Clear: {list.Count}, Capacity: {list.Capacity}"); // 0, 16
}
private void Dump(List<int> list)
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("[");
for (int i = 0; i < list.Count; ++i)
{
if (i > 0) sb.Append(", ");
sb.Append(list[i]);
}
sb.Append("]");
Debug.Log(sb.ToString());
}
}
실행 결과
=== Add 3개 ===
Count: 3, Capacity: 4
=== Insert(1, 15) ===
[10, 15, 20, 30]
=== Remove(20) ===
[10, 15, 30]
=== IndexOf / Contains ===
IndexOf(15): 1
Contains(30): True
=== Capacity 확장 테스트 ===
Count: 13, Capacity: 16
[10, 15, 30, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
=== Clear ===
Count after Clear: 0, Capacity: 16
마무리
이 글에서는 배열을 사용해 직접 List를 구현하는 방법을 살펴보았습니다.
- 내부 배열과
Count,Capacity의 관계 Add시 EnsureCapacity를 통한 자동 용량 확장 구조Insert,RemoveAt에서의 데이터 이동 방식IndexOf,Contains, 인덱서 등을 통한 편리한 접근 방식
직접 구현해 보면, 평소에 아무 생각 없이 사용하던 List<T>가 내부에서 어떻게 동작하는지 훨씬 명확하게 이해할 수 있습니다.이후 LinkedList, Deque 같은 다른 컨테이너들과 비교할 때도 큰 도움이 됩니다.
※ 다음 글에서는 LinkedList(연결 리스트) 구조를 살펴보며, 배열 기반 리스트와 노드 기반 리스트의 구조적 차이와 성능 특성을 비교합니다.
'자료 구조' 카테고리의 다른 글
| Unity 개발자를 위한 C# HashSet(해시셋) 구현 (0) | 2025.12.07 |
|---|---|
| Unity 개발자를 위한 C# Deque(덱) 구현 (0) | 2025.12.06 |
| Unity 개발자를 위한 C# LinkedList(링크드 리스트) 구현 (0) | 2025.12.05 |
| Unity 개발자를 위한 C# Queue(큐) 구현 (0) | 2025.11.24 |
| Unity 개발자를 위한 C# Stack(스택) 구현 (0) | 2025.11.24 |