`with` 식 — 기존 객체를 수정한 복사본(새 객체) 만들기

with 식 — 기존 객체를 수정한 복사본(새 객체) 만들기

with 식은 파괴적 변경(nondestructive mutation) 을 하는 C# 표현식이에요. 원본은 그대로 두고, 지정한 프로퍼티·필드만 바꾼 새 복사본을 만들어 주는 구문이죠. 객체를 통째로 새로 만들지 않고도 같은 구조에서 몇 개 값만 바꾼 객체가 필요할 때 아주 유용해요.

출처: Microsoft Learn

본문

with 식은 피연산자(operand)의 복사본을 만들되, 명시한 프로퍼티와 필드만 수정한 채로 돌려줘요. 어느 멤버를 어떻게 바꿀지는 개체 이니셜라이저(object initializer) 문법을 빌려서 지정해요. 괄호 안에 멤버 = 새 값 형태로 원하는 것만 적으면 되고, 적지 않은 멤버는 원본 값을 그대로 가져와요:

using System;

public class WithExpressionBasicExample
{
    public record NamedPoint(string Name, int X, int Y);

    public static void Main()
    {
        var p1 = new NamedPoint("A", 0, 0);
        Console.WriteLine($"{nameof(p1)}: {p1}");  // output: p1: NamedPoint { Name = A, X = 0, Y = 0 }
        
        var p2 = p1 with { Name = "B", X = 5 };
        Console.WriteLine($"{nameof(p2)}: {p2}");  // output: p2: NamedPoint { Name = B, X = 5, Y = 0 }
        
        var p3 = p1 with 
            { 
                Name = "C", 
                Y = 4 
            };
        Console.WriteLine($"{nameof(p3)}: {p3}");  // output: p3: NamedPoint { Name = C, X = 0, Y = 4 }

        Console.WriteLine($"{nameof(p1)}: {p1}");  // output: p1: NamedPoint { Name = A, X = 0, Y = 0 }

        var apples = new { Item = "Apples", Price = 1.19m };
        Console.WriteLine($"Original: {apples}");  // output: Original: { Item = Apples, Price = 1.19 }
        var saleApples = apples with { Price = 0.79m };
        Console.WriteLine($"Sale: {saleApples}");  // output: Sale: { Item = Apples, Price = 0.79 }
    }
}

이 예시에서 p2NameX만 바뀐 복사본이고, p1은 끝까지 그대로인 걸 출력으로 확인할 수 있어요. 코드 마지막 부분처럼 익명 형식(anonymous type) 에도 with를 쓸 수 있다는 점도 눈여겨볼 만해요 — 원본 { Item = Apples, Price = 1.19 }에서 가격만 내린 saleApples를 새로 만든 거죠.

with 식의 왼쪽 피연산자로 쓸 수 있는 타입은 크게 네 가지예요: 레코드 타입(record), 구조체 타입(struct), 튜플(tuple), 그리고 위 예시에서 본 익명 타입까지요.

[!NOTE] 첫 도입 버전과 관련된 자세한 내용은 링크 에서 확인할 수 있어요.

with 식의 결과 타입은 피연산자의 런타임 타입과 같아요. 피연산자를 어떤 베이스 타입 변수에 담아 두었더라도, 복사본은 실제 객체의 타입을 그대로 유지해요. 상속 관계에서 어떻게 동작하는지 보여주는 예시를 볼게요:

using System;

public class InheritanceExample
{
    public record Point(int X, int Y);
    public record NamedPoint(string Name, int X, int Y) : Point(X, Y);

    public static void Main()
    {
        Point p1 = new NamedPoint("A", 0, 0);
        Point p2 = p1 with { X = 5, Y = 3 };
        Console.WriteLine(p2 is NamedPoint);  // output: True
        Console.WriteLine(p2);  // output: NamedPoint { X = 5, Y = 3, Name = A }
    }
}

p1은 컴파일 타임 타입이 Point지만 실제로는 NamedPoint 인스턴스예요. 여기에 with를 써서 만든 p2여전히 NamedPoint 라서 p2 is NamedPointTrue예요. 복사본이 베이스 타입인 Point로 깎이지 않고, Name까지 유지된 원래 타입 그대로 남는 걸 확인할 수 있어요.

한 가지 꼭 짚고 넘어갈 부분이 있어요. 멤버가 참조 타입(reference type)이라면, 복사는 그 '참조'(주소)만 복사한다는 거예요. 즉 복사본과 원본이 같은 인스턴스를 가리키게 되죠. 이게 실제로 어떤 결과를 만드는지 보여주는 예시를 볼게요:

using System;
using System.Collections.Generic;

public class ExampleWithReferenceType
{
    public record TaggedNumber(int Number, List<string> Tags)
    {
        public string PrintTags() => string.Join(", ", Tags);
    }

    public static void Main()
    {
        var original = new TaggedNumber(1, new List<string> { "A", "B" });

        var copy = original with { Number = 2 };
        Console.WriteLine($"Tags of {nameof(copy)}: {copy.PrintTags()}");
        // output: Tags of copy: A, B

        original.Tags.Add("C");
        Console.WriteLine($"Tags of {nameof(copy)}: {copy.PrintTags()}");
        // output: Tags of copy: A, B, C
    }
}

with로 만든 copy에서 Number는 2로 새 값이 반영돼요. 그런데 TagsList<string>이라는 참조 타입이라 복사본과 원본이 같은 리스트를 가리켜요. 그래서 original.Tags.Add("C")로 원본 리스트에 항목을 추가하자 copy의 출력에도 A, B, C가 나오는 거예요. 참조 타입 멤버는 '값이 복사'되는 게 아니라 '그 값을 가리키는 주소가 공유'된다는 점, 여기서 확실히 기억해 두세요.

사용자 정의 복사 시맨틱 (Custom copy semantics)

모든 레코드 클래스 타입에는 복사 생성자(copy constructor) 가 있어요. 복사 생성자란, 자신(포함하고 있는 레코드 타입)을 단일 매개변수로 받는 생성자를 말해요. 이 생성자가 인자로 받은 레코드의 상태를 새 레코드 인스턴스로 복사하죠. with 식을 평가하면 이 복사 생성자를 호출해서 원본을 바탕으로 새 인스턴스를 만들고, 그 다음 지정한 수정 사항만 새 인스턴스에 반영해요. 기본적으로 컴파일러가 이 복사 생성자를 알아서 만들어 주는데, 원한다면 직접 복사 생성자를 선언해서 복사 방식을 바꿀 수 있어요.

다음 예시는 앞서 본 예시를 명시적 복사 생성자로 바꾼 버전이에요. 레코드를 복사할 때 List<Tags>리스트 참조가 아니라 항목들을 새로 복사하도록 동작을 바꿨어요:

using System;
using System.Collections.Generic;

public class UserDefinedCopyConstructorExample
{
    public record TaggedNumber(int Number, List<string> Tags)
    {
        protected TaggedNumber(TaggedNumber original)
        {
            Number = original.Number;
            Tags = new List<string>(original.Tags);
        }

        public string PrintTags() => string.Join(", ", Tags);
    }

    public static void Main()
    {
        var original = new TaggedNumber(1, new List<string> { "A", "B" });

        var copy = original with { Number = 2 };
        Console.WriteLine($"Tags of {nameof(copy)}: {copy.PrintTags()}");
        // output: Tags of copy: A, B

        original.Tags.Add("C");
        Console.WriteLine($"Tags of {nameof(copy)}: {copy.PrintTags()}");
        // output: Tags of copy: A, B
    }
}

protected TaggedNumber(TaggedNumber original) 생성자가 복사 생성자 역할을 하고, 여기서 Tags = new List<string>(original.Tags)라고 새 리스트를 만들어 줬어요. 이제 original.Tags.Add("C")를 해도 copy.Tags는 그대로 A, B를 출력해요 — 두 객체가 더 이상 같은 리스트를 공유하지 않기 때문이에요. 다만 이렇게 복사 방식을 바꿀 수 있는 건 레코드 타입 한정이에요. 구조체 타입은 복사 시맨틱을 사용자 정의할 수 없어요.

[!IMPORTANT] 위 예시들의 프로퍼티는 전부 서로 독립적이에요. 어떤 프로퍼티도 다른 프로퍼티 값에서 계산되지 않죠. with 식은 먼저 기존 레코드 인스턴스를 복사한 뒤, with 식에 지정된 프로퍼티·필드만 수정한다는 점을 기억하세요. 계산 프로퍼티(computed property)는 인스턴스 생성 시 초기화하지 말고 접근할 때 계산하도록 만들어야 해요. 만약 생성 시점에 값을 고정해 버리면, 수정된 복사본이 아니라 원본을 기준으로 계산된 값을 돌려줄 수 있거든요. 자세한 내용은 언어 참조의 record 타입 문서 에서 확인할 수 있어요.

더 알아보기