본문 바로가기

C#

C# 값 튜플(ValueTuple) vs Tuple 성능 비교

ValueTuple은 구조체, Tuple은 클래스입니다. 이 한 줄이 성능 차이의 대부분을 설명합니다. 구조체인 ValueTuple은 스택 또는 포함하는 객체 내부에 저장되어 할당과 GC 비용이 적고, 클래스인 Tuple은 힙 할당과 GC 압력이 발생합니다. 실용적인 관점에서 둘의 속도, 메모리 차이를 간단한 코드와 함께 정리합니다.

1. 핵심 차이 요약

- ValueTuple: 구조체(struct), 필드가 공개이며(mutability) 이름 지정과 분해 할당 지원, 일반적으로 할당 없음(escape 시 제외), 빠름.

- Tuple: 참조형(class), 불변(immutable), 항상 힙 할당, GC 대상, Item1/Item2 등만 제공, 느림.

- 성능 포인트: 작은 데이터(예: 2~3개 값) 왕복 시 ValueTuple이 유리합니다. 단, 큰 값을 여러 개 담아 빈번히 복사하면 구조체 복사 비용이 생길 수 있습니다.

2. Stopwatch로 빠른 감 잡기

아래 코드는 500만 번 생성/접근을 반복하여 대략적인 경향을 봅니다. 환경에 따라 수치는 달라지지만 ValueTuple이 일반적으로 더 빠르고 GC 할당이 적습니다.

using System; 
using System.Diagnostics;

class Program
{
    const int N = 5_000_000;

    static void Main()
    {
        Warmup();

        var sw = Stopwatch.StartNew();
        long sum1 = 0;
        for (int i = 0; i < N; i++)
        {
            var t = (i, i + 1); // ValueTuple (struct)
            sum1 += t.Item1 + t.Item2;
        }
        sw.Stop();
        Console.WriteLine($"ValueTuple: {sw.ElapsedMilliseconds} ms, sum={sum1}");

        GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();

        sw.Restart();
        long sum2 = 0;
        for (int i = 0; i < N; i++)
        {
            var t = Tuple.Create(i, i + 1); // Tuple (class)
            sum2 += t.Item1 + t.Item2;
        }
        sw.Stop();
        Console.WriteLine($"Tuple: {sw.ElapsedMilliseconds} ms, sum={sum2}");
    }

    static void Warmup()
    {
        for (int i = 0; i < 1_000; i++)
        {
            var a = (i, i + 1);
            var b = Tuple.Create(i, i + 1);
            _ = a.Item1 + a.Item2 + b.Item1 + b.Item2;
        }
    }
}

- 일반적 관찰: ValueTuple 구문은 힙 할당이 없거나 적고, Tuple은 반복마다 객체가 생성되어 GC가 늘어납니다. 긴 루프에서 체감 차이가 큽니다.

3. 제대로 측정하기: BenchmarkDotNet

정확한 비교는 BenchmarkDotNet을 권장합니다. 패키지(BenchmarkDotNet) 추가 후 아래를 실행합니다.

using System;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class TupleBench
{
    private int[] data = Array.Empty<int>();

    [GlobalSetup]
    public void Setup()
    {
        data = Enumerable.Range(0, 1000).ToArray();
    }

    [Benchmark]
    public long ValueTuple_CreateAccess()
    {
        long sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            var t = (data[i], data[i] + 1);
            sum += t.Item1 + t.Item2;
        }
        return sum;
    }

    [Benchmark]
    public long Tuple_CreateAccess()
    {
        long sum = 0;
        for (int i = 0; i < data.Length; i++)
        {
            var t = Tuple.Create(data[i], data[i] + 1);
            sum += t.Item1 + t.Item2;
        }
        return sum;
    }
}

public class Program
{
    public static void Main() => BenchmarkRunner.Run<TupleBench>();
}

- 예상 결과: ValueTuple은 할당(Allocated) 0B 또는 매우 적게, Tuple은 데이터 길이에 비례한 힙 할당이 보고됩니다. 평균 실행 시간도 ValueTuple이 앞서는 경향입니다.

4. 결과 해석과 메모리 관점

- ValueTuple은 구조체라서 지역 사용과 반환에 유리하며, escape 하지 않으면 힙 할당이 없습니다. 반복적인 생성/접근 패턴에서 이점이 큽니다.

- Tuple은 참조형이라 생성 시마다 힙에 객체가 생기고, GC 대상이 됩니다. 짧은 루프나 고빈도 경로에서는 성능 병목이 됩니다.

- 단, ValueTuple은 값 복사가 발생합니다. 포함 필드가 크거나 중첩되어 크기가 커질수록 복사 비용이 커질 수 있습니다.

5. 언제 무엇을 쓸까

- ValueTuple 권장: 여러 값을 간단히 반환/전달, 고빈도 경로(루프/핫패스), 분해 할당과 이름 지정으로 가독성 향상을 원할 때.

- Tuple 고려: 참조 동일성(Identity)이 필요하거나, 딕셔너리 키로 넣은 뒤 값 변경 위험을 피하고 싶을 때(불변성), 매우 큰 페이로드를 자주 전달하여 값 복사 비용이 커질 때(전용 클래스로 모델링도 고려)입니다.

6. 주의사항과 최적화 팁

- ValueTuple은 가변입니다. 딕셔너리 키로 사용 후 필드를 바꾸면 해시가 바뀌어 문제를 일으킬 수 있습니다. 이 경우 불변 타입을 사용하세요.

- 큰 ValueTuple을 빈번히 전달하면 복사 비용이 증가합니다. 필요한 경우 메서드 매개변수에 in 키워드를 사용해 읽기 전용 참조로 전달을 검토합니다.

- 7개를 초과하는 요소로 ValueTuple/Tuple을 구성하면 중첩이 발생해 가독성과 비용이 나빠집니다. 명시적인 레코드/클래스/구조체를 정의하는 것이 좋습니다.

- 인터페이스나 object로 박싱하면(ValueTuple -> object) 힙 할당이 생깁니다. 제네릭을 활용해 박싱을 피하세요.

- async/iterator 캡처 시에도 일반적으로 ValueTuple이 유리하지만, 불필요한 큰 값 복사를 피하도록 구조를 단순화하는 것이 좋습니다.

7. 결론

대부분의 실무 시나리오에서 ValueTuple이 Tuple보다 빠르고 메모리 효율적입니다. 짧고 빈번한 경로라면 ValueTuple을 기본 선택으로, 불변 참조형이 필요한 경우나 큰 데이터 모델링이 필요할 때는 전용 타입(레코드/클래스)로 명확히 표현하는 것을 권장합니다.