언어/C#

Tuple과 foreach문의 동작 원리: 값 타입 vs 참조 타입, 인스턴스와 원본

안녕도라 2025. 5. 21. 16:34


프로세스란? 실행 중인 프로그램이다.

 

운영 체제는 이러한 프로그램의 실행, 즉 프로세스를 관리하기 위해, 적절하게 메모리를 사용한다.

 

메모리를 논리적 개념으로 두 가지 영역으로 구분할 수 있는데,

운영 체제가 직접 자원을 제어하는 영역인 커널 영역, 사용자가 직접 제어할 수 있는 사용자 영역이 있다.

 

예를 들어 내가 지금 알고리즘 문제를 풀기 위해 VS Code를 실행해 C# 스크립트 하나를 생성했다고 하자.

 

VS Code라는 프로그램은 사용자 영역에서 실행되는 프로세스가 되며,

C# 스크립트에서 *을 찍는 코드를 작성하고 제대로 작동하는지 콘솔창에 띄워보고자 한다면,

C#스크립트는 컴파일 되어 .exe 파일 형식으로 변환되고, 또 다른 실행 프로세스가 되는 개념이다.

 

사용자 메모리 영역은 대표적으로 스택 영역, 힙 영역, 데이터 영역, 코드 영역으로 구분할 수 있는데,

좀 더 잘 이해하기 위해 C#스크립트의 내부 코드들이 각 메모리 영역을 어떻게 사용하는지 직접 예시 코드와 함께 살펴보자.

 

 

 


데이터 영역

전역 변수나, 정적 변수를 선언하면 데이터 영역에 저장된다.

데이터 영역은 프로세스가 시작될 때 메모리에 할당되고, 프로그램이 종료될 때 해제된다.

아래 코드에서는 static으로 선언한 greeting 변수가 데이터 영역에 저장된다.

using System;

namespace MyCompiler {
    class Program {  
    	static string greeting = "Hello World!";	// 전역 변수
        
         public static void Main(string[] args) {
                Console.WriteLine(greeting);
            }
        }
    }
}

스택(Stack)영역과 힙 영역(Heap)

그리고 지역 변수들은 스택과 힙 영역에 저장되는데,

Struct와 같은 값 타입일 경우, 대부분 스택 영역에 실제 데이터를 직접 저장한다.

Class와 같은 참조 타입일 경우, 힙 영역에 객체가 생성되며, 스택 영역에는 이 힙 객체를 가리키는 참조값(포인터)가 저장된다.

 

따라서 , 변수에 접근한다는 것은 기본적으로 스택 메모리 영역에서 그 변수의 데이터를 읽거나 쓰는 행위를 의미하는데

 

  • 값 타입은 스택에서 직접 데이터를 다루고,
  • 참조 타입은 스택에 저장된 포인터를 따라가서 힙에서 데이터를 다루는 구조인 것이다.

 

using System;

namespace MyCompiler {

    struct MyStruct {
        public int Value;
    }

    class MyClass {
        public int Value;
    }

    class Program {
        static void Main(string[] args) {
            // Struct 예시
            MyStruct structA = new MyStruct { Value = 5 };
            MyStruct structB = structA;    // structA를 복사해서 structB 생성
            structB.Value = 10;            // structB 값 변경

            Console.WriteLine($"structA.Value: {structA.Value}"); // 5 (원본은 변하지 않음)
            Console.WriteLine($"structB.Value: {structB.Value}"); // 10 (복사본만 변경됨)

            // Class 예시
            MyClass classA = new MyClass { Value = 5 };
            MyClass classB = classA;       // classA 참조를 classB에 복사 (동일 객체 참조)
            classB.Value = 10;             // classB를 통해 값 변경

            Console.WriteLine($"classA.Value: {classA.Value}");   // 10 (원본도 변경됨)
            Console.WriteLine($"classB.Value: {classB.Value}");   // 10 (같은 객체 참조)
        }
    }
}

값 타입 vs 참조 타입 , 원본 vs 인스턴스

이러한 스택 메모리와 힙 메모리의 관계성을 이해한 상태로,

tuple형식의 변수에 담긴 값을 바꾸려고 했을 때 문제가 발생한 이유를 알아보자.

 

아래 코드에서는 tuple이라는 이름의 변수를 선언하고, 값을 저장한 뒤, 그 값을 변경하는 작업을 하고 있다.

 

(int, int) 형태의 튜플은 값타입(Value type)으로 즉, 값이 직접 스택(stack)영역에 저장되었고, 

그 값에 직접 접근해 값을 증감시켜준 것이므로, 수정이 가능하다.

using System;
using System.Collections.Generic;

namespace MyCompiler {
    class Program {

        static (int, int) tuple = (1,2);
        
        public static void Main(string[] args) {
            Console.WriteLine($"original: {tuple.Item1}, {tuple.Item2}");

            tuple.Item1++;
            tuple.Item2++;

            Console.WriteLine($"direct after: {tuple.Item1}, {tuple.Item2}");

            var temp = tuple;
            
            temp.Item1++;
            temp.Item2++;

            tuple = temp;

            Console.WriteLine($"temp after: {tuple.Item1}, {tuple.Item2}");
        }
    }
}

 

하지만, 값 타입을 리스트와 같은 참조 컬렉션 형태로 품은 상태에서, 튜플의 값을 변경하려고 시도하면 상황이 달라진다.

  • List의 내부 요소가 값 타입이면, List는 복사본을 반환한다.
  • 그래서, List 내부를 순회하며 튜플의 값을 변경하면, 리스트 내부의 원본 데이터에 영향을 주지 않는다.
using System;
using System.Collections.Generic;

namespace MyCompiler {
    class Program {

        static List<(int, int)> list = new List<(int,int)>{ (1, 2), (3, 4) };
        
        public static void Main(string[] args) {
            Console.WriteLine($"origin: {list[0].Item1}, {list[0].Item2}");

            for (int i = 0; i < list.Count; i++)
            {     
                list[i].Item1++;            
                list[i].Item2++;            
            }

            Console.WriteLine($"direct after: {list[0].Item1}, {list[0].Item2}");
            
            for (int i = 0; i < list.Count; i++)
            {
                var tuple = list[i];      
                tuple.Item1++;            
                tuple.Item2++;            
                list[i] = tuple;   
            }

            Console.WriteLine($"temp after: {list[0].Item1}, {list[0].Item2}");
        }
    }
}

 

즉 정리하자면, 

  • 스택에 직접 저장된 tuple 변수는 내부 값을 직접 변경할 수 있지만,
  • 힙에 저장된 리스트 내부 데이터는 복사본을 통해 접근되므로 직접 수정하려면 복사본을 수정 후 다시 할당해야 한다.

오늘 마주한 문제

마찬가지로 foreach문도 원본이 아니라 복사본을 순회하기 떄문에 원본 값을 바꿀 수 없는 것이다.