C# list 를 사용할때는 값 가져오는 방법이 여러가지가 있습니다. 본문에서는 가장 일반적으로 사용하는 방법들과 편리하게 함수형태로 list 의 값을 가져오는 방법들 등을 설명해드리고자 합니다. 

 

C# LIST 기본적인 값 가져오기

기본적으로 Add함수를 통해 값을 하나씩 list의 맨뒤에 추가할 수 있으며 배열 인덱스 접근 연산자를 통해 각 인덱스에 접근해서 값을 가져올 수 있습니다. 아래 소스코드 확인 해보시죠.

 

List<int> list = new List<int>();
list.Add(10);
list.Add(20);

int a = list[0]; //20
int b = list[1]; //20

 

Add()함수를 통해서 값을 하나씩 추가하고 있으며 [인덱스] 연산자를 통해서 0번 및 1번인덱스에 접근하여 값을 가져오고 있습니다.

 

 

C# LIST 반복문 값 가져오기

IndexOf(int 인덱스) 함수를 통해서도 동일하게 값을 가져올 수 있습니다. 값을 찾을 때는 for문을 통해서 []연산자 혹은 IndexOf() 함수를 list처음부터 list.Count 번째 까지 순회하면서 호출하며 if문으로 비교해서 값을 찾는 방법을 많이 사용합니다.

int findIndex = -1;
for (int i = 0; i < list.Count; ++i)
{
    if(list[i] == 20)
    {
        findIndex = i;
        break;
    }
}
if (findIndex == -1)
{
    /* 못찾았을때 처리 */
}
else
    b = list[findIndex];​

 

위의 형태가 자주 사용되는경우 다음처럼 함수(메서드)로 만들어 놓고 해당 함수를 호출하는 법도 좋습니다. 아래는 함수를 통해서 C# 에서 LIST 값 을 가져오는 함수 예시입니다.

 

 

 

c# list값 가져오는 함수 예시

다음 함수는 리스트내에서 특정 값이 저장된 인덱스를 반환해주는 함수입니다. 

static int FindIntListIndex(List<int> list, int val)
{
    if (list == null)
        return -1;
    for (int i = 0; i < list.Count; ++i)
    {
        if (list[i] == val)
            return i;
    }
    return -1;
}

값을 찾을 시 인덱스를 반환하게 되고, LIST 가 NULL 이거나 값을 못찾게 되면 -1 을 반환합니다.

 

해당 함수는 본인의 LIST 타입에 맞게 변경하시거나 혹은 제네릭을 익히셨다면 제네릭을 사용하셔서 T타입 값을 가져오시면 됩니다. 

 

 

C# list값 제거

list에서 값을 제거할때는 RemoveAt(int 인덱스) 함수를 통해 특정 인덱스의 값을 삭제할 수 있습니다.

 

list.RemoveAt(0); //0번인덱스를 삭제합니다
 

 

list값 제거시 가장 많이 하게되는 실수중 하나는 for문을 돌면서 RemoveAt()을 호출하는데, 잘못된 인덱스에 접근하거나 for문을 그보다 더 많이 돌게되어 list의 길이 범위를 넘어가게 되어 IndexOutbound 예외가 뜨는 경우 입니다.

 

기본적으로 list에서 값을 제거하는 순간, 제거된 인덱스보다 뒤의 인덱스부터 한칸씩 인덱스 순서가 당겨지게됨에

주의하셔야 합니다.

 

C#에서는 문자열을 처리하기 위한 여러가지 방법이 있습니다.

흔히 수행속도측면의 성능과 관련해서 이야기를 할때는 문자열끼리 붙이는(Concat) 작업으로 비교를 많이합니다.

문자열끼리 붙이는 작업은 실제로 문자열 처리에서 굉장히 흔하게 일어나는 작업입니다.

 

 

 

1. 문자열을 이어붙일때는 string 보다는 StringBuilder 를 선호하세요.

많은 분들이 아시듯 단순 string 데이터에 직접적인 + 연산은 겉보기엔 가장 직관적이지만 성능적으로는 좋은편이 아닙니다.

 

string str = "hello my world";

for(int i = 0; i < 1000; ++i)

    str += "ok";    //str = str + "ok"

 

string인 str변수에 문자열데이터를 최초에 집어넣은 이후 for문을 1000번 돌고있는 코드입니다.

c#의 string의 + 연산 (operator+)의 구현부를 보면 string타입을 매개변수로 받고 있습니다.

( += 연산은 실제론 자기자신과 피연산대상 과의 + 연산과 같습니다.  따라서 += 연산구현부는 + 연산을 보아야합니다.)

 

또한 짚고 넘어가야 할것은 string은 일반적으론 불변성을 띄고 있다는 점 입니다. (Reflection을 사용하지 않는이상...)

따라서 string클래스 내부에서는 직접적으로 자신이 데이터를 유동적으로 추가 삭제하며 관리할 문자열 버퍼를 가지고 있지는 않습니다. 다만 대상 문자열을 레퍼런스로 가리키고 있습니다. (참조만 하고있습니다.)

 

따라서 + 연산을 담당하는 string의 operator+(string) 함수 또한 string 자기 자신과 인자로 받은 문자열을 서로 더하여 새로운 string인스턴스를 생성해서 반환을 합니다.

즉, 위의 루프문은 매번 문자열 이어붙이기 연산을 위해서 새로운 string객체를 만들고 있습니다.

 

 

이는 string 인스턴스를 매번 생성하기 때문에 힙영역의 할당시 일어나는 속도측면의 부담도 있지만 더 무서운 점은

이를 통해 지속적으로 가비지(Garbage)가 발생한다는 점입니다.  

 

 

string 클래스에 만약 내부적으로 자기자신이 문자열을 관리하기위한 문자열버퍼 char[] 가 있다면 + 연산이 일어날때

다음과 같이 처리될 수 있을 것입니다.

str.operator+("ok") 가 일어나더라도 내부적으로 "ok" 의 데이터를 자신 내부에서 관리하는 char[] 버퍼뒤에 붙여서 복사한다.

 

 

 

C#에서는 이러한 처리를 위해서 표준적으로 StringBuilder 클래스를 제공합니다.

StringBuilder클래스는 내부적으로 문자열을 관리하는 char[] 버퍼를 가지고 있으며 이는 문자열을 이어 붙이는 처리를 할때

매번 새로운 string인스턴스를 만들어서 반환하지 않고 자신 내부에서 관리하는 문자열 버퍼에 이어붙일 문자열 데이터를 복사가 가능하게 합니다.

 

//기본적인 사용법 (Append()를 통해 문자열 + 처리를 합니다.)
StringBuilder stringBuilder1 = new StringBuilder();
stringBuilder1.Append("hello");
stringBuilder1.Append("world");
stringBuilder1.Append("is");
stringBuilder1.Append("mine");

//모두 이어붙인 문자열을 한번에 string으로 만들어서 반환할 수도있으며
//이어붙이는 처리가 많을수록 훨씬 더 효율적입니다.
string str = stringBuilder1.ToString();

 

 

//미리 내부의 문자열 버퍼에 데이터를 생성과 동시에 담아둘 수 있습니다.

//미리 담아서 생성하는 편이 안담은 후 Append 하는 것보다 미세하게나마 빠릅니다.
StringBuilder stringBuilder2 = new StringBuilder("hello");
stringBuilder2.Append("world");

 


//미리 내부의 문자열 버퍼의 공간을 할당해놓을 수 있습니다. (바이트 단위) 
StringBuilder stringBuilder3 = new StringBuilder(200);

//미리 여유롭게 공간을 할당했으므로 그 크기만큼의 데이터가 들어오기전엔 Append 할때마다
//추가 버퍼공간 확보가 일어나지 않아 빠릅니다.
stringBuilder3.Append("hello");
stringBuilder3.Append("world");

 

 

 

2. string클래스가 불변성(Immutable)을 가진 이유

 

string클래스는 c#의 기본 타입입니다. 굳이 StringBuilder를 만들 필요없이 string자체가 StringBuilder와 같이

유동성있게 동작했다면 직관적인 + 연산을 마음대로 사용하면서 편하게 문자열 이어붙이기 처리를 할 수 있어보이기도 합니다.

string클래스가 불변성을 가지는 이유는 여러가지가 있으나 2가지만 소개하겠습니다.

 

 

a. 멀티 스레드 환경에서 스레드 세이프 합니다.

 

멀티스레드 환경에서는 여러개의 스레드가 하나의 자원(공유메모리)에 접근할 수 있습니다.

멀티스레드 환경은 기본적으로 시간을 서로 분할하여 각 스레드마다 지정된 아주짧은 시간만큼 제어권을 받아 명령을 수행합니다. 이 과정에서 스레드A의 특정 처리가 모두 끝나지 않았는데도 불구하고 스레드B로 제어권이 넘겨가면서 스레드B는 스레드A가 조작하던 메모리를 조작하여 문제가 발생하는 경우가 생길 수 있습니다.   

 

만약 string이 StringBuilder처럼 동작했다면 다음과 같은 문제가 생길 확률이 있습니다.

 

(1) 스레드A에서는 string a 에 "hello" 라는 데이터를 + 합니다.

(2) "ok" 라는 데이터를 받아서 자신 내부의 버퍼에 데이터를 복사합니다.

(3) 다만 이과정 끝나기도 전에 스레드B로 제어권이 넘어갔습니다. string a에는 현재 "hel" 까지만 들어갔습니다.

(4) 스레드B에서는 "world" 란 데이터를 string a 에 + 합니다.

(5) 이번엔 운이 좋아서 스레드B가 "world"를 모두 넣고나서 스레드A로 제어권이 넘어갔습니다.

(6) 스레드A에서는 자신이 하던 작업을 이어서 진행합니다. 아직 넣지 못한 "lo"를 이어서 붙여 넣습니다.

(7) a 에는 "helloworld" 란 데이터가 넣어지길 바랬으나 실제로 넣어진 데이터는 "helworldlo" 입니다.

 

 

스레드의 최소 작업단위는 기본적으로 하나의 어셈블리 명령단위 입니다.

(최소단위의 명령 하나가 시작되면 끝나기전까지 제어권이 다른스레드로 넘어가지 않습니다.)

 

단순 참조의 경우 기본적으로 레퍼런스(주소)의 대입연산 이므로 이는 어셈블리 명령단위상 하나의 명령입니다.

따라서 string 의 단순 대상 참조명령은 최소단위(Atomic)이므로 도중 스레드제어권이 넘어가지 않습니다. 

(Atomic 합니다.)

 

기본 타입인 만큼 string은 이를 고려하여서 동작되도록 설계되었을 것이며 실제로 StringBuilder는 멀티스레드 환경에서 안전하지 않습니다. 따라서 상황에 맞게 사용되기 위하여 두가지를 제공한 것이라고 보여집니다.

 

 

 

-p.s -

여기서 "어? 그냥 StringBuilder를 스레드세이프하게 만들면 다 끝나는것 아닌가?" 라는 의문을 가질 수 있습니다.

이를 위해 Java로 이야기를 잠시만 새보면...Java또한 string과 StringBuilder가 동일하게 존재합니다. 추가로 Java에서는 멀티스레드 환경에서 안전하지 않게 동작하는 StringBuilder를 대신하여  내부적으로 스레드 세이프 하게 처리하는 StringBuffer 클래스도 있습니다. 다만 Atomic하지 않은 처리를 Atomic하게 처리하기위해 내부에서 스레드락을 이용합니다. 멀티스레드 환경이 아닌경우나 굳이 고려될 필요가 없는 경우에도 불필요하게 스레드락 처리를 하는것은 오히려 성능의 낭비를 야기할 수 있습니다. 직접 스레드 세이프를 위해 StringBuffer 클래스를 제작해서 사용할때 이 논리는 C#에서도 적용할 수 있습니다. 

 

 

 

 

b. 중복적으로 사용되는 문자열의 경우 재사용성이 증가합니다.

 

문자열을 내부적으로 버퍼를 만들어 관리하게 되면 동일한 문자열을 사용하더라도 매번 그 문자열을 담을 버퍼 공간만큼 메모리를 할당을 해서 관리하고 있어야 할 것입니다. 하지만 이미 만들어진 문자열을 참조만 하고 있는 것이라면, 이문자열이 중복되서 사용되는 경우에는 각 string들이 버퍼 공간을 매번 만들 필요없이 만들어진 문자열 레퍼런스(주소)를 단순히 참조만 하면되기 때문에 재사용성에서 오히려 이득이 될 수 있습니다. 

 

특히 상수 문자열(Literal String)의 경우 이 이점이 두드러지게 드러나게 됩니다.

프로그램이 시작할때 코드에 박힌 상수 문자열들은 이미 메모리 어딘가에 존재하는 상수 테이블이란 공간에 올라가게 됩니다. (PE에 대해 아시는 분들은 상수 문자열이 이미 PE내에 그대로 저장됨도 알고계실 것입니다.)

 

string 변수 여러개를 선언하고 이들에게 동일한 상수 문자열을 대입할경우, 프로그램 시작 시 해당 상수 문자열이 있는 공간의 주소(레퍼런스)를 string 변수가 가리키고 있게 됩니다. 만약 StringBuilder처럼 동작했다면 해당 상수 문자열을 가리키지 않고(참조하지 않고) 매번 자신의 내부 버퍼에다가 복사하였을 것입니다. 이런 경우에는 오히려 메모리 공간적으로나 시간적으로 낭비가 될 수 있습니다.

 

 

 

 

 

 

 

3. 결론

 

결론적으로 빈번한 문자열 이어붙이기 처리가 일어날경우 StringBuilder를 사용하는 것이 성능향상에 도움이 될 수 있습니다.

다만 그렇지 않은경우는 오히려 불리해지는 경우도 있으니 상황에 맞게 잘 고려해서 사용해야 합니다.

 

C++에서는 struct 와 class 의 차이가 멤버들의 기본 접근 제어 지시자(private, public, protect)를 제외하고는 없습니다.

 

다만 c#에는 struct이냐? 혹은 class이냐? 에 따라서 값 타입과 참조타입의 개념의 차이가 존재합니다.

이미 존재하는 인스턴스를 참조하지 않는이상 참조타입으로 선언된 변수를 사용하기 위해선 반드시 할당의 과정을 거쳐야합니다.

 

ClassTest obj = new ClassTest(10,20,30);

 

일반적으로 이렇게 new 를 통해 운영체제에 의해 공간을 할당받아 생성되는 인스턴스는 힙(Heap)영역에 올라가게 됩니다.

 

반대로 struct 타입으로 선언된 변수는 일반 변수처럼 선언하되, 멤버변수들을 초기화 하기만 하면 new 를 통한 할당없이도 사용할 수 있습니다.

 

다만 struct를 쓰기에는 c++에 비해서는 의외로 까다로운 점이 있습니다. 기본 생성자를 사용할 수 없으며 선언 후 이를 사용할려면 멤버를 모두 초기화 해야하며 클래스로 부터의 상속을 사용할 수 없으며(인터페이스는 가능) 불변성을 띄는경우가 있다 등등의 몇가지 제약사항이 있습니다.

 

//아래와 같이 사용할 경우 멤버를 모두 초기화해주기 전에는 사용할 수 없습니다. 사용측면에선 귀찮고 까다롭습니다.

StructTest obj;

obj.mem1 = 10;

obj.mem2 = 20;

obj.mem3 = 30;

obj.Test();

 

 

따라서 이점이 까다롭다는 이유로 일반적으로 struct 에도 new로 생성자를 호출해서 사용하시는 분들도 많이 보았습니다. 

 

//3가지 멤버를 한번에 초기화 해주는 생성자를 호출해버립니다. 편해졌습니다.

StructTest obj2 = new StructTest(10,20,30);

 

당연히 c++의 개념대로 라면 new를 하지않은 StructTestobj는 스택(Stack)영역에 있어야 할 것이고 new를 한 obj2는

힙영역에 있어야 할 것입니다.

 

그리고 아래의 코드는 동적할당을 했기때문에 올라가는 영역에 대해서는 별 다른 차이점이 없다고 생각할 수 있습니다.

 

//Class 든 Struct든 모두 new를 했기때문에 Heap영역에 올라갈 것이다?

ClassTest obj = new ClassTest(10,20,30);

StructTest obj2 = new StructTest(10,20,30);

 

 

다만 실상은 그렇지 않습니다.

MS공식 레퍼런스에서 언급하듯 new키워드를 사용했더라도 Struct타입의 데이터는 일반적으로 Stack영역에 할당됩니다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace CSharpTest
{
    
    struct StructTest
    {
        public int mem1;
        public int mem2;
        public int mem3;
 
        public StructTest(int m1, int m2, int m3)
        {
            mem1 = m1;
            mem2 = m2;
            mem3 = m3;
        }
    }
    class ClassTest
    {
        int mem1;
        StructTest structMem; //Stack? Heap?
    }
 
    class Program
    {
 
        static void Main(string[] args)
        {
            ClassTest test = new ClassTest();
        }
 
    }
 
}
cs

 

그리고 위의 코드를 IL (중간언어)로 디스어셈블 한 결과는 다음과 같습니다.

 

 

분명히 new를 사용하였는데도 불구하고 Struct 타입에 대해서는 단순히 생성자만 call하는 것을 볼 수 있으며 

똑같이 new를 사용한 Class타입은 newobj (관리되는 힙 영역 할당 후 생성자 call) 하는 것을 볼 수 있습니다.

 

다만 C#이 익숙하지 않으신 분들의 경우 Struct타입은 무조건 스택(Stack)영역에 올라간다는 생각만 하여선 안됩니다.

다음의 경우가 예외적인 사항입니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace CSharpTest
{
    
    struct StructTest
    {
        public int mem1;
        public int mem2;
        public int mem3;
 
        public StructTest(int m1, int m2, int m3)
        {
            mem1 = m1;
            mem2 = m2;
            mem3 = m3;
        }
    }
    class ClassTest
    {
        int mem1;
        StructTest structMem; //Stack? Heap?
    }
 
    class Program
    {
 
        static void Main(string[] args)
        {
            ClassTest test = new ClassTest();
        }
 
    }
 
}
cs

 

 

 

 

ClassTest 클래스의 StructTest 타입의 변수 structMem은 현재 ClassTest타입의 멤버변수(필드) 로써 포함되어 있습니다.

 

ClassTest 객체를 하나 생성하였으며 이 인스턴스는 현재 힙 영역에 존재할 것입니다.

따라서 StructTest 타입의 멤버인 structMem 역시 해당 인스턴스가 존재하는 곳에 동일하게 올라가 있습니다.

(마치 지역성을 띄고 있습니다. 저 structMem은 자신의 지역(?)인 test 오브젝트가 소멸할 때 같이 소멸될 것입니다.)

 

 

 

따라서 이 개념을 혹여나 모르시고 계셨던 분들의 경우 위와 같은 혼동을 하지않도록 주의 하여야 합니다.

 

 

 

 

 

 

P.S 

 

 

여담으로 필자의 경우 C++로 개발 할때는 둘의 차이점은 별로 없었기에 일반적으로 Struct는 단순히 데이터 덩어리 집합 (패킷 프로토콜 정의, 객체 표현이 필요없는 데이터 덩어리 모음용 등등)으로 사용하고 이외에 객체 자체를 표현해야 하는경우는 Class를 사용하곤 했습니다. 

 

Struct의 사용은 일반적으로는 힙 메모리의 동적 할당을 하지 않기때문에 속도면에서 빠르다고 볼 수 있습니다.

다만 대입이나 값의 전달과 관련된 부분에 대해서는 다시 생각해볼 필요가 있습니다.

 

C#에서 Class 타입은 경우 참조 타입(Reference Type) 이기때문에 서로 단순 대입을 할때는 레퍼런스 값만 복사(참조 복사)가 일어납니다.  이는 내부적으로 포인터로 구현되어 있을 것이며 4바이트 값의 복사만 일어납니다. (한번에 복사가 가능합니다.)  

 

다만 값 타입(Value Type)인 Struct 타입의 경우 일반적으로 서로 단순 대입을 할때는 메모리 값 자체의 복사가 일어납니다. 이것이 한번에 일어나면 좋겠으나 한번 복사할 수 있는 양은 정해져 있습니다. 따라서 일정 바이트크기를 넘어간 데이터의 메모리 값 복사가 일어날때는 반복적으로 복사가 일어나기 때문에 여기에 시간이 소모될 수 있습니다.

(하필 이런 값타입끼리의 단순 대입이나 값전달 코드들은 개발하다 보면 매 루프마다 호출된다던지 등등 굉장히 흔하게 호출되곤 합니다.)

 

.Net에서는 이전버전에서는 16바이트 현 버전에서는 약 24~26바이트 정도 까지 한번에 복사가 가능 하므로 구조체의 크기가 이보다 훨씬 더 커지는 경우 메모리 값 복사에 다소 신경 쓸 필요가 있습니다. 

C++의 경우 이런 거대한 데이터를 전달해야할 상황일때 포인터로 전달하는 방법이 있겠으나 C#은 일반적으론 거대한 Struct를 전달 하는 경우에 대한 마땅한 해결책이 없습니다. 따라서 가급적 Struct는 크지않은 데이터에 대해서 사용하는 편이 좋습니다.

+ Recent posts