Devin.KR

record 와 struct - 값처럼 다루는 타입

개발자KR 조회 8

이 장에서 배우는 것

앞 장에서 클래스와 객체를 사용해 카페 메뉴의 데이터와 동작을 묶었다. 메뉴 이름과 가격을 하나의 객체에 담으면 여러 값을 따로 전달할 때보다 의도가 분명해진다. 그런데 모든 데이터를 계속 바뀌는 객체로 다룰 필요는 없다. 주문을 접수한 시점의 메뉴 이름과 가격처럼, 한 번 정한 내용을 그대로 보관해야 하는 데이터도 있다.

이 장에서는 데이터의 내용에 의미를 두는 record와 값을 복사해서 전달하는 struct를 살펴본다. 두 문법은 함께 소개되지만 같은 문제를 해결하지는 않는다. record는 주로 데이터의 동등성을 어떻게 정의할지에 관여하고, struct는 값이 저장되고 복사되는 방식을 결정한다. 이 차이를 기준으로 카페 앱의 메뉴 가격표와 재고 수량을 표현한다.

  • 참조 형식인 record를 선언하고, 값 기반 동등성이 필요한 상황을 설명한다.
  • 불변 데이터를 구성하고 with 식으로 일부 값만 다른 새 데이터를 만든다.
  • 얕은 복사의 의미와 불변성을 깨뜨리는 내부 객체를 구별한다.
  • struct의 복사 동작을 확인하고, 선택 기준표로 알맞은 타입을 고른다.

문제 상황

동네 카페에서 아메리카노 가격을 4,000원에서 4,500원으로 올리기로 했다. 매장 화면에는 새 가격을 보여 주어야 하지만, 변경 전에 접수한 주문에는 당시 가격이 남아 있어야 한다. 주문과 메뉴판이 수정 가능한 메뉴 객체 하나를 함께 참조한다면, 메뉴판의 가격을 바꾸는 작업이 과거 주문의 표시 금액까지 바꾸는 원인이 될 수 있다.

이를 막는 방법 중 하나는 주문 시점에 필요한 이름과 가격을 별도 데이터로 보관하는 것이다. 이 데이터는 현실의 메뉴 자체를 계속 추적하는 객체라기보다 특정 시점의 기록에 가깝다. 같은 이름과 같은 가격을 담았다면 내용이 같은 기록으로 판단하는 편이 자연스럽다. 반면 주문 번호로 구별하는 주문 객체는 이름과 금액이 우연히 같더라도 서로 다른 주문일 수 있다.

재고 수량에는 다른 요구가 있다. 담당자가 우유 재고를 12개로 확인한 뒤, 판매 후 수량을 9개로 계산한다고 하자. 계산을 위한 복사본의 값을 바꿨다는 이유로 원래 확인한 수량까지 달라지면 곤란하다. 이때 수량은 독립적으로 복사되는 작은 값으로 다루는 편이 이해하기 쉽다.

이 장의 예제에서는 가격 기록을 불변 record로, 재고 수량을 readonly struct로 표현한다. 주문 저장이나 재고 목록 전체를 구현하지 않고, 이후 앱에서 사용할 데이터 한 개의 동작을 먼저 확인한다. 타입을 고르는 출발점은 문법의 길이가 아니라 데이터가 무엇을 의미하며 어떻게 바뀌어야 하는지다.

내용이 같은 데이터를 표현하는 record

record는 기본적으로 참조 형식이다

record를 단독으로 사용해 선언하면 참조 형식인 record class가 된다. record라고 해서 값 형식이 되는 것은 아니다. 변수에 다른 변수를 대입하면 일반적인 클래스처럼 객체를 가리키는 참조가 복사된다. 따라서 두 변수가 같은 객체를 가리킬 수도 있다.

public sealed record MenuPrice(string Name, int PriceWon);

이 선언은 메뉴 이름과 원 단위 가격을 가진 타입을 만든다. 괄호에 적은 매개변수는 생성할 때 받을 값을 정하며, 같은 이름의 공개 속성도 생성한다. 이와 같은 위치 기반 record의 속성에는 get과 init 접근자가 생긴다. get은 값을 읽게 하고, init은 객체를 초기화하는 과정에서 값을 지정할 수 있게 한다.

sealed는 이 타입을 상속으로 확장하지 않겠다는 표시다. 여기서는 하나의 구체적인 가격 기록으로 범위를 한정하는 데 사용한다. 상속 구조의 설계는 이 장에서 다루지 않는다. 또한 PriceWon이라는 이름은 금액의 단위가 원이라는 사실을 드러낸다. 예제는 정수 원 단위 가격만 사용한다.

같은 객체와 같은 내용은 다른 질문이다

일반 클래스가 동등성 동작을 따로 정의하지 않았다면 ==는 보통 같은 객체를 가리키는지 확인한다. record는 데이터 비교에 필요한 동작을 컴파일러가 생성한다. 이 예제에서는 같은 MenuPrice 타입의 Name과 PriceWon이 각각 같으면 두 객체를 같은 값으로 비교한다. 별도로 생성한 객체라도 비교 결과가 true가 될 수 있다.

var first = new MenuPrice("아메리카노", 4000);
var second = new MenuPrice("아메리카노", 4000);

Console.WriteLine(first == second);
Console.WriteLine(object.ReferenceEquals(first, second));

첫 번째 출력은 True이고 두 번째 출력은 False다. ==는 여기서 record가 제공하는 값 기반 동등성을 검사한다. object.ReferenceEquals는 두 변수가 동일한 객체를 가리키는지 검사한다. 서로 다른 질문에 답하므로 결과가 다른 것이 정상이다.

값 기반 동등성이란 사람이 보기에 비슷한 데이터면 알아서 같다고 판단한다는 뜻은 아니다. 이름이 같더라도 가격이 다르면 이 타입에서는 다른 값이다. 문자열의 대소문자나 공백도 자동으로 정리하지 않는다. 가격 비교에서 메뉴 이름을 제외해야 한다면, 그 요구에 맞게 데이터 구성과 비교 규칙을 다시 생각해야 한다.

불변성은 record라는 이름만으로 완성되지 않는다

불변성(immutability)은 생성된 데이터의 상태가 이후에 바뀌지 않는 성질이다. 예제의 속성은 init으로 제한되므로 생성이 끝난 객체에 current.PriceWon = 4500처럼 다시 대입할 수 없다. 이름은 수정할 수 없는 문자열이고 가격은 정수이므로, 이 두 값만 가진 MenuPrice는 불변 데이터로 다루기 적합하다.

하지만 record 안에 set 접근자가 있는 속성을 직접 선언할 수도 있다. 수정 가능한 다른 객체를 속성으로 담는 것도 가능하다. 따라서 record는 자동으로 모든 내부 상태를 고정하는 문법이 아니다. 속성을 다시 지정할 수 있는지와 속성이 가리키는 객체의 상태를 바꿀 수 있는지를 함께 확인해야 한다.

또한 불변성과 유효성은 별개의 문제다. 위 선언만으로는 음수 가격이나 빈 메뉴 이름을 막지 못한다. 이 장은 복사와 비교에 집중하므로 미리 정한 유효한 값만 사용한다. 실제 앱에서 입력을 받게 되면 어떤 값까지 허용할지 별도 규칙을 두어야 한다.

with 식으로 변경된 기록 만들기

원본을 수정하지 않고 새 객체를 만든다

불변 데이터에서도 가격 인상처럼 새로운 상태를 표현해야 한다. 이때 with 식(with expression)을 사용하면 기존 객체를 복사한 다음, 지정한 속성만 다른 값으로 초기화한 결과를 얻는다. 나머지 속성은 기존 값을 이어받는다.

var current = new MenuPrice("아메리카노", 4000);
var revised = current with { PriceWon = 4500 };

current의 가격은 여전히 4,000원이다. revised는 이름이 같고 가격은 4,500원인 새 객체를 가리킨다. with는 원본에 변경 요청을 보내는 명령이 아니라 결과값을 만드는 식이다. 결과를 사용할 변수나 전달할 위치가 있어야 한다.

이 동작은 주문 시점의 데이터를 보관할 때 유용하다. 주문이 current를 참조한 상태에서 메뉴판이 revised를 사용하도록 바뀌더라도, current 자체를 수정하지 않았으므로 주문이 보던 가격은 유지된다. 실제 앱에서는 어떤 기록을 주문에 남길지 명확히 정해야 하지만, 불변 데이터는 이러한 분리를 표현하기 쉽게 만든다.

with 식은 기존 가격 기록을 유지하면서 변경한 가격을 담은 새 기록을 만든다

속성을 하나도 바꾸지 않는 current with { }도 새 record 객체를 만든다. 이때 원본과 결과는 내용이 같으므로 ==로 비교하면 true지만, 같은 객체는 아니므로 ReferenceEquals는 false다. 복사된 객체의 존재와 복사된 데이터의 동등성을 나누어 생각하면 혼동을 줄일 수 있다.

참조 대상까지 새로 만드는 것은 아니다

with가 기본적으로 수행하는 복사는 얕은 복사(shallow copy)다. 정수 같은 값 형식의 필드는 값이 복사되고, 참조 형식의 필드는 참조가 복사된다. 참조가 복사되면 원본과 새 객체가 같은 내부 객체를 가리킬 수 있다. 객체가 연결된 모든 단계까지 새 객체를 만드는 동작은 아니다.

MenuPrice의 Name도 참조 형식인 문자열이다. 그러나 문자열의 내용은 생성 후 수정되지 않으므로 문자열 참조를 공유해도 이 예제의 불변성이 깨지지 않는다. 문제가 되는 것은 참조의 공유 그 자체보다, 공유한 대상에 변경 가능한 상태가 있다는 점이다.

예를 들어 가격 기록에 수정 가능한 메모 객체를 추가했다면, with로 만든 두 가격 기록이 하나의 메모를 함께 볼 수 있다. 한쪽을 통해 메모 내용을 고치면 다른 쪽에서도 바뀐 내용이 보인다. 바깥 record의 속성을 다시 대입하지 않았어도 내부 상태는 달라진 셈이다.

내부 데이터도 불변으로 만들거나, 변경이 필요한 단계에서 내부 객체까지 새로 구성하면 이 문제를 줄일 수 있다. 어느 수준까지 복사할지는 데이터 구조의 계약이다. record와 with를 사용했다는 이유만으로 독립적인 내부 객체가 모두 마련되었다고 가정해서는 안 된다.

struct는 독립적으로 복사할 작은 값에 쓴다

대입하면 값이 복사된다

struct는 값 형식이다. 일반적인 변수 대입이나 값 매개변수 전달에서는 구조체의 값이 복사된다. 두 변수에 같은 값이 들어 있더라도 각 변수는 별도의 값을 보유한다. 이 장에서는 재고 수량을 담는 StockQuantity를 만들어 이 동작을 확인한다.

public readonly struct StockQuantity
{
    public int Units { get; }

    public StockQuantity(int units)
    {
        Units = units;
    }
}

readonly struct는 구조체의 인스턴스 상태를 변경하지 않는 설계를 표현한다. Units는 읽기 전용 속성이며 생성자에서 값을 정한다. 수량이 달라졌다면 기존 구조체의 속성을 수정하는 대신 새 StockQuantity 값을 만든다.

타입에 붙은 readonly가 그 타입의 모든 지역 변수를 읽기 전용으로 만드는 것은 아니다. 지역 변수 afterSale에는 나중에 새 StockQuantity 값을 다시 대입할 수 있다. 이는 기존 값의 내부를 수정하는 일과 다르다. 변수에 담긴 값 전체를 교체하는 것이다.

구조체를 복사한 변수에 새 값을 대입해도 원래 변수의 값은 유지된다

구조체가 참조 형식의 필드를 포함하면 그 필드에서는 참조가 복사된다. 따라서 struct라고 해서 내부에 있는 객체까지 자동으로 독립되는 것은 아니다. readonly struct 또한 참조 대상의 변경까지 막지는 않는다. 예제는 정수 하나만 저장하므로 이런 공유가 발생하지 않는다.

값의 의미와 복사 비용을 함께 판단한다

struct를 선택할 때는 해당 데이터가 하나의 값처럼 느껴지는지 먼저 확인한다. 수량, 좌표, 범위처럼 작은 데이터는 좋은 후보가 된다. 반면 여러 곳에서 하나의 대상을 공유하고 그 변화를 함께 관찰해야 한다면 클래스가 더 자연스럽다. 속성이 적다는 이유만으로 주문이나 고객을 구조체로 바꿀 필요는 없다.

복사 비용도 고려해야 한다. 큰 구조체를 자주 전달하면 많은 필드가 반복해서 복사될 수 있다. 그렇다고 클래스가 언제나 빠르거나 구조체가 언제나 느리다는 뜻은 아니다. 객체 생성과 참조 공유의 비용까지 사용 방식에 따라 달라지므로, 먼저 의미에 맞게 선택하고 성능이 중요한 경로는 실제로 측정한다.

데이터의 의미에 따른 struct 선택 기준
판단 기준struct가 어울리는 경우클래스 계열을 고려할 경우
데이터의 정체성수량처럼 내용 자체가 의미다같은 내용이어도 별도 주문으로 구별한다
공유 방식복사한 값을 독립적으로 사용한다여러 곳에서 같은 대상의 변화를 본다
크기와 전달 빈도필드가 적고 복사 부담이 작다데이터가 크고 자주 전달된다
변경 방식새 값으로 교체하는 방식이 자연스럽다장기간 유지되는 객체의 상태가 바뀐다
기본값의 의미모든 필드의 기본값도 해석할 수 있다생성 과정을 거쳐야만 의미가 생긴다

기본값도 놓치기 쉬운 기준이다. default(StockQuantity)의 Units는 0이며, 이 값은 int 매개변수를 받는 생성자를 거치지 않고 만들어진다. 재고 0개는 자연스러운 의미이므로 예제 타입과 잘 맞는다. 생성자에 규칙을 넣더라도 구조체의 기본값이 존재한다는 점은 따로 고려해야 한다.

구조체를 선택하는 근거를 “스택에 저장되므로 빠르다”로 요약하지 않는다. 저장 위치는 값이 놓인 문맥에 따라 달라진다. 예를 들어 클래스의 필드인 구조체 값은 그 객체의 일부로 저장될 수 있다. 이 장에서 필요한 핵심은 저장 장소의 단정이 아니라 값 복사와 참조 공유의 차이다.

record struct도 사용할 수 있다. 이는 값 형식에 record의 동등성 생성 기능을 결합한 형태다. 위치 기반 record struct의 속성은 기본적으로 수정 가능하므로, 이 경우에도 불변으로 사용하려면 readonly record struct를 고려한다. 여기서는 record와 값 형식을 구별하기 위해 완성 코드에 일반 readonly struct를 사용한다.

완성 코드

다음 코드는 Program.cs 전체다. .NET 10 콘솔 프로젝트의 Program.cs를 이 내용으로 교체한다. 상단에서는 메뉴 가격을 비교하고 복사하며, 하단에서는 재고 수량의 복사를 확인한다. 최상위 실행문을 먼저 두고 타입 선언을 뒤에 배치했다.

using System;

var current = new MenuPrice("아메리카노", 4000);
var sameContent = new MenuPrice("아메리카노", 4000);
var alias = current;
var revised = current with { PriceWon = 4500 };

Console.WriteLine($"내용 비교: {current == sameContent}");
Console.WriteLine($"같은 객체 비교: {object.ReferenceEquals(current, sameContent)}");
Console.WriteLine($"별칭의 객체 비교: {object.ReferenceEquals(current, alias)}");
Console.WriteLine($"기존 가격: {current.Name} {current.PriceWon}원");
Console.WriteLine($"변경 가격: {revised.Name} {revised.PriceWon}원");
Console.WriteLine($"변경 후 내용 비교: {current == revised}");

var stock = new StockQuantity(12);
var afterSale = stock;
afterSale = new StockQuantity(afterSale.Units - 3);
var empty = default(StockQuantity);

Console.WriteLine($"기존 재고: {stock.Units}개");
Console.WriteLine($"판매 후 재고: {afterSale.Units}개");
Console.WriteLine($"기본 재고: {empty.Units}개");

public sealed record MenuPrice(string Name, int PriceWon);

public readonly struct StockQuantity
{
    public int Units { get; }

    public StockQuantity(int units)
    {
        Units = units;
    }
}

이 프로그램은 사용자 입력, 현재 시각, 외부 파일을 사용하지 않는다. 가격과 수량도 구분 기호 없는 정수로 출력한다. 따라서 실행할 때마다 같은 내용을 확인할 수 있다. 뒤에 나오는 잘못된 코드 조각과 연습용 코드는 이 파일에 포함하지 않는다.

줄별 해설

using System;은 Console과 같은 타입을 짧은 이름으로 사용하기 위한 선언이다. 이후의 실행문은 별도의 Main 메서드 선언 없이 최상위 문으로 작성되어 있다.

var current = new MenuPrice(...);는 기존 가격 기록을 만든다. 생성 시 전달한 문자열과 정수가 Name과 PriceWon 속성에 들어간다. var는 타입을 생략해 추론하게 하는 표기이며, current의 타입은 MenuPrice로 정해진다.

var sameContent = new MenuPrice(...);는 내용이 같은 객체를 한 번 더 만든다. current를 대입한 것이 아니라 new를 다시 실행했으므로 두 변수는 서로 다른 객체를 가리킨다. 이 차이가 동등성 비교 예제의 출발점이다.

var alias = current;는 기존 참조를 복사한다. MenuPrice는 참조 형식이므로 alias와 current는 같은 객체를 가리킨다. 불변 데이터도 여러 변수가 함께 참조할 수 있다. 공유한다고 해서 곧바로 문제가 되는 것은 아니다.

var revised = current with { PriceWon = 4500 };는 기존 속성값을 바탕으로 새 객체를 만들고, 새 객체의 PriceWon만 지정한 값으로 초기화한다. Name은 아메리카노로 유지된다. current와 alias가 가리키던 객체는 그대로 남는다.

처음 세 Console.WriteLine은 비교 기준을 나누어 보여 준다. 내용이 같은 두 record의 == 결과는 True다. 따로 만든 객체의 참조 비교는 False이며, 참조를 대입한 alias와의 비교는 True다. 논리값은 이 출력 방식에서 True와 False라는 문자열로 표시된다.

이어지는 세 출력문은 원본과 변경 결과를 확인한다. 원본 가격은 4,000원이고 변경된 기록의 가격은 4,500원이다. PriceWon이 다르므로 current == revised는 False가 된다. 이름이 같다는 사실만으로 전체 record가 같아지지는 않는다.

var stock = new StockQuantity(12);는 Units가 12인 구조체 값을 만든다. var afterSale = stock;에서는 그 값이 복사된다. 같은 재고 객체를 가리키는 별칭이 생기는 동작이 아니다.

afterSale = new StockQuantity(afterSale.Units - 3);는 복사해 둔 수량에서 3을 빼고, 계산 결과를 가진 새 값으로 afterSale을 교체한다. stock에는 여전히 12가 들어 있다. 이 코드는 판매로 인한 변경 결과와 변경 전 수량을 동시에 보여 준다.

var empty = default(StockQuantity);는 구조체의 기본값을 만든다. Units를 뒷받침하는 정수 필드의 기본값이 0이므로 마지막 출력은 0개다. StockQuantity(int units) 생성자를 호출해 0을 전달한 코드는 아니라는 점을 구별한다.

MenuPrice 선언은 생성자, 속성, 동등성에 필요한 동작 등을 간결하게 표현한다. StockQuantity 선언은 정수 하나를 읽기 전용 속성으로 공개하며, 생성자로 수량을 받는다. 두 선언 모두 데이터의 표현을 담당하지만 대입할 때의 동작은 서로 다르다.

실행 결과

macOS나 Linux 터미널에서 다음 명령으로 프로젝트를 만든다. .NET 10 SDK가 설치된 환경을 전제로 한다.

dotnet new console --framework net10.0 -n CafeValues
cd CafeValues

Program.cs를 완성 코드로 교체한 뒤 프로젝트 폴더에서 실행한다.

dotnet run

프로그램의 예상 출력은 다음과 같다.

내용 비교: True
같은 객체 비교: False
별칭의 객체 비교: True
기존 가격: 아메리카노 4000원
변경 가격: 아메리카노 4500원
변경 후 내용 비교: False
기존 재고: 12개
판매 후 재고: 9개
기본 재고: 0개

첫 두 줄은 내용의 동등성과 객체의 동일성을 구별한다. 가격 출력은 with가 원본을 보존했음을 보여 준다. 마지막 세 줄은 구조체 값을 복사한 뒤 다른 값으로 교체해도 원래 변수에는 영향이 없다는 사실과 기본값이 0이라는 사실을 확인한다.

실무에서 자주 틀리는 것

아래 코드는 각 실수를 설명하는 독립적인 조각이다. 잘못된 코드는 의도적으로 컴파일 오류나 요구와 다른 동작을 포함한다. 완성 코드에 이어 붙이는 코드가 아니다.

init 속성을 나중에 다시 대입한다

가격을 변경해야 한다는 이유로 기존 record의 속성에 값을 넣으면 컴파일 오류가 발생한다. 예제의 위치 기반 record가 생성한 속성은 일반적인 set 속성이 아니다. 객체 생성 이후의 대입을 허용하지 않는 것이 설계의 일부다.

잘못된 코드다.

var price = new MenuPrice("아메리카노", 4000);
price.PriceWon = 4500;

변경한 내용을 가진 새 객체를 만든다.

var price = new MenuPrice("아메리카노", 4000);
var updated = price with { PriceWon = 4500 };

원본이 필요 없다면 price = price with { PriceWon = 4500 };처럼 변수 자체에 새 참조를 대입할 수도 있다. 이 경우에도 기존 객체의 가격을 수정하는 것은 아니다. 기존 객체를 참조하던 다른 변수에서는 계속 4,000원이 보인다. 객체의 불변성과 변수의 재대입 가능성을 구별해야 한다.

바깥 record만 불변이면 충분하다고 생각한다

다음 코드의 목적은 복사본의 메모만 바꾸는 것이다. 그러나 두 record는 같은 MutableMemo를 참조하므로 original을 통해 읽은 메모도 변경된다. 이 코드는 컴파일되지만 원하는 독립성을 제공하지 않는다.

잘못된 코드다.

var original = new PriceNote(new MutableMemo("오전 가격"));
var copied = original with { };
copied.Memo.Text = "오후 가격";

Console.WriteLine(original.Memo.Text);

public sealed class MutableMemo
{
    public string Text { get; set; }

    public MutableMemo(string text)
    {
        Text = text;
    }
}

public sealed record PriceNote(MutableMemo Memo);

출력은 오후 가격이다. PriceNote의 Memo 속성을 다시 지정하지 않았어도, 그 속성이 가리키는 객체의 Text는 수정할 수 있었다. get과 init만 있다는 사실이 내부 객체까지 읽기 전용으로 만든 것은 아니다.

내부 메모도 불변 record로 만들고, 변경되는 단계마다 새 객체를 구성한다.

var original = new PriceNote(new MemoText("오전 가격"));
var copied = original with
{
    Memo = original.Memo with { Text = "오후 가격" }
};

Console.WriteLine(original.Memo.Text);
Console.WriteLine(copied.Memo.Text);

public sealed record MemoText(string Text);
public sealed record PriceNote(MemoText Memo);

이제 원본은 오전 가격, 복사본은 오후 가격을 출력한다. 내부 with는 새 메모를 만들고, 바깥 with는 그 메모를 가진 새 PriceNote를 만든다. 원본이 가리키던 메모에는 변경이 없다. 이 방식에서도 변경할 필요가 없는 불변 데이터는 함께 참조해도 된다.

구조체 매개변수에 대입하면 호출한 쪽도 바뀐다고 생각한다

앞 장에서 메서드에 값을 전달하는 방법을 배웠다. 구조체를 일반적인 값 매개변수로 받으면 매개변수에는 복사본이 들어간다. 메서드 내부에서 그 매개변수에 새 값을 대입해도 호출한 쪽의 변수는 바뀌지 않는다.

재고 감소를 기대하지만 원래 재고가 유지되는 잘못된 사용이다. StockQuantity는 완성 코드의 선언을 사용한다.

static void SellThree(StockQuantity quantity)
{
    quantity = new StockQuantity(quantity.Units - 3);
}

var stock = new StockQuantity(12);
SellThree(stock);
Console.WriteLine(stock.Units);

출력은 12다. SellThree 안의 quantity만 새 값으로 교체되었다. 원본 변수를 자동으로 찾아가 수정하는 과정은 없다. 불변 값에는 변경 결과를 반환하는 방식이 명확하다.

static StockQuantity SellThree(StockQuantity quantity)
{
    return new StockQuantity(quantity.Units - 3);
}

var stock = new StockQuantity(12);
stock = SellThree(stock);
Console.WriteLine(stock.Units);

고친 코드는 9를 출력한다. 반환값을 stock에 대입하는 문장에서 호출한 쪽의 변경이 드러난다. 값을 반환하도록 바꾸었더라도 그 결과를 받지 않으면 원래 변수는 그대로라는 점도 함께 기억한다.

한눈에 보기

record와 struct의 복사 및 변경 특성
선언 형태일반 대입동등성불변성의 조건
일반 class참조를 복사한다별도 정의가 없으면 참조 중심이다속성과 내부 상태를 설계한다
record 또는 record class참조를 복사한다값 기반 동등성을 생성한다변경 가능한 속성과 내부 객체를 확인한다
readonly struct값을 복사한다기본 Equals를 제공하지만 ==는 자동 생성하지 않는다자체 상태는 읽기 전용이며 참조 대상은 별도다
readonly record struct값을 복사한다값 기반 동등성과 ==를 생성한다참조 대상까지 불변인지 확인한다

표의 동등성에서 “값 기반”은 내부 객체를 끝까지 순회하며 내용을 비교한다는 뜻이 아니다. record의 기본 비교는 포함된 필드의 타입이 제공하는 동등성 규칙을 따른다. 내부에 일반 클래스가 있다면 그 객체의 비교 방식이 결과에 영향을 준다.

완성 코드의 StockQuantity에는 == 연산자를 직접 선언하지 않았다. 따라서 두 StockQuantity 변수를 ==로 비교하는 코드는 사용할 수 없다. 이 예제에서 수량만 비교하려면 left.Units == right.Units라고 명시하면 된다. 값 형식이라는 분류와 == 지원 여부는 별개의 사항이다.

메뉴 가격처럼 내용 비교가 필요하고 변경 전 기록을 보존해야 한다면 불변 record가 유용하다. 수량처럼 작고 독립적으로 복사되는 값에는 readonly struct가 어울린다. 구조체에도 record의 비교 기능이 필요하면 readonly record struct를 검토할 수 있다. 선택한 문법보다 중요한 것은 호출하는 코드가 기대할 복사와 변경 규칙을 일정하게 유지하는 일이다.

언어 규칙을 더 확인하려면 record 참조, with 식 참조, 구조체 참조를 참고한다.

연습 문제

코드 문제는 완성 코드의 타입 선언을 사용한다고 가정한다. 실행문을 추가한다면 타입 선언보다 앞에 둔다.

  1. MenuPrice로 카페라테 4,800원 기록을 만들고, with를 사용해 이름은 그대로 두고 가격만 5,000원인 기록을 만든다. 원본과 변경 결과의 가격을 한 줄씩 출력하는 코드를 작성한다.
  2. a가 MenuPrice 객체를 가리킬 때 b = a와 c = a with { }를 각각 실행했다고 하자. a == b, a == c, object.ReferenceEquals(a, b), object.ReferenceEquals(a, c)의 결과와 이유를 설명한다.
  3. StockQuantity를 받아 2개 증가한 새 값을 반환하는 RestockTwo 메서드를 작성한다. 재고 5개를 전달하고 반환값을 별도 변수에 저장한 뒤, 원본과 결과의 수량을 각각 출력한다.
  4. 주문 번호로 구별하며 처리 상태가 계속 바뀌는 주문, 접수 당시 메뉴 이름과 가격을 보관하는 기록, 정수 하나로 나타내는 컵 수량이 있다. 각각 어떤 타입 형태를 우선 검토할지 고르고, 컵 수량을 구조체로 만들 때 기본값의 의미도 설명한다.

정답과 해설

1. 가격만 다른 기록 만들기

var original = new MenuPrice("카페라테", 4800);
var changed = original with { PriceWon = 5000 };

Console.WriteLine(original.PriceWon);
Console.WriteLine(changed.PriceWon);

출력은 차례로 4800과 5000이다. changed.Name은 카페라테로 유지된다. with에서 지정하지 않은 값은 기존 기록으로부터 복사되며, 기존 객체는 수정되지 않는다. 이름까지 바꾸려면 같은 초기화 부분에 Name도 지정할 수 있지만 이 문제에서는 가격만 지정한다.

2. 내용 비교와 참조 비교 구별하기

결과는 질문에 제시된 순서대로 True, True, True, False다. b는 a의 참조를 복사했으므로 같은 객체를 가리킨다. c는 새 객체이지만 바꾼 속성이 없으므로 내용은 a와 같다. 따라서 두 == 비교는 모두 True이고, c와의 참조 비교만 False다.

record를 사용하면 같은 내용의 서로 다른 객체가 생기는 상황을 자연스럽게 표현할 수 있다. 반대로 같은 객체인지 확인해야 하는 요구가 있다면 ==의 결과만으로 판단하지 않는다. 어떤 비교를 선택할지는 데이터에서 묻고 싶은 질문에 달려 있다.

3. 증가한 수량을 반환하기

static StockQuantity RestockTwo(StockQuantity quantity)
{
    return new StockQuantity(quantity.Units + 2);
}

var original = new StockQuantity(5);
var restocked = RestockTwo(original);

Console.WriteLine(original.Units);
Console.WriteLine(restocked.Units);

출력은 5와 7이다. 메서드는 전달받은 수량을 읽어 새 값을 반환한다. original은 수정되지 않으며, 반환값을 restocked에 저장했으므로 두 시점의 수량을 함께 사용할 수 있다. 원본 변수를 갱신하려는 상황이라면 반환값을 original에 대입하는 방식으로 의도를 드러낸다.

4. 데이터의 의미로 타입 고르기

주문은 번호로 구별되는 대상이고 처리 상태가 바뀌므로 일반 클래스를 우선 검토한다. 같은 메뉴와 금액을 가진 주문이 두 건 생겨도 서로 다른 주문이다. 따라서 모든 속성의 내용이 같다는 이유로 같은 주문이라고 판단하는 설계는 요구와 맞지 않을 수 있다.

접수 당시의 메뉴 이름과 가격은 불변 record를 우선 검토한다. 내용이 같은 기록끼리 비교하기 쉽고, 변경된 가격이 필요하면 새 기록을 만들 수 있다. 다만 주문 시점의 기록을 보존하려면 그 record 안에 담는 데이터도 변경 가능성을 점검해야 한다.

컵 수량은 정수 하나로 이루어진 작은 값이므로 readonly struct를 검토한다. 수량 0은 컵이 없는 상태로 해석할 수 있어 기본값도 자연스럽다. 이처럼 기본값을 실제로 설명할 수 있는지까지 확인해야 구조체의 생성 방식과 데이터의 의미가 어긋나지 않는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.