
불변성 특성이 있는 자료구조
Record에 대해서 알아본다. 그리고 객체의 깊은 복사 방법에 대해서도 알아본다.
기업협약 프로젝트에서 SaveData의 클래스의 객체를 깊은 복사를 통해, 아예 새로운 객체를 만들고자 한다. 이럴 경우 나도 모르게 아래와 같이 쓰게 된다.
public class A
{
public int a = 15;
public S str;
}
public class S
{
public string nameA = "";
public int b = 3;
}
A test1 = new();
A test2 = test1;
당연한 얘기지만 이와 같이 쓰면, test2 값은 얕은 복사가 되어서 같은 객체를 가리키게 된다.
클래스의 객체 필드들을 깊은 복사로 구현하는 데에 방법이 여러가지 있다. 대표적인 Clone() 수동 구현부터 알아본다.
1. Clone 메서드 구현
ICloneable 인터페이스의 Clone() 메서드를 직접 구현하는 방식이다. MemberwiseClone()으로 앝은 복사를 먼저 한 뒤, 참조형의 필드들을 다시 Clone()하는 방식으로 구현한다.추가/변경 될 때마다 Clone()메서드 또한 수정해야 하므로, 유지보수가 어렵다.2. JSON 직렬화/역직렬화
JsonUtility라이브러리를 통해 객체를 한 번 JSON 형태로 직렬화를 한 후, 이를 역직렬화 함과 동시에 객체로 할당하면 새로이 객체가 생성된다.[Serializable] 어트리뷰트가 필요하고, public 프로퍼티만 직렬화 되는 등의 단점이 있다.3. Reflection
GetType(),SetType()과 같은 메서드가 있는 .NET의 라이브러리다.4. Expression Trees
Clone()처럼 매우 빠른 속도를 가지면서 범용적인 사용이 가능한 방법이다. 다만, 표현식 트리를 직접 다루는 것은 매우 난이도가 높은 작업이다. 위의 4가지 방법 모두 마음에 들지 않아, 다른 방법이 없나 찾아봤는데 그 해답이 record 자료구조다.
Data를 표현하는 것을 주 목적으로하는 자료구조이다. 불변성(Immutability)을 가진다는 특성이 있다.
객체가 생성된 후, 그 상태가 변경될 수 없는 것을 불변성이라 한다. string이 그 좋은 예시다.
string a = "test";
a = a+"test";
Console.WriteLine(a); // testtest
위 코드에서 string a는 기존의 값이였던 "test"에 실제로 문자열을 추가한 것이 아니라, "testtest"라는 string 객체를 새로이 생성한 것이다. 즉, 원본 값은 변하지 않는다.
이러한 불변성은 접근자에서도 차이가 난다.
class의 프로퍼티는 get; set;이지만, record는 대신에 init; 프로퍼티를 사용한다.
public record Person(string Name, int Age);
var person1 = new Person("홍길동", 30);
// person1.Name = "김철수";
// 💥 에러 발생! init-only 프로퍼티이므로 변경 불가
이렇게 한번 생성된 record 객체는 init; 프로퍼티 때문에 그 값을 변경 할 수 없다.
그렇다면 값을 변경하고 싶을 때는 어떻게 해야 될까?
불변성을 가지고 있는 객체의 값을 변경하고자 할 때는 with 구문을 사용한다.
var person1 = new Person("홍길동", 30);
// person1을 기반으로 나이만 다른 '새로운' person2를 생성
var person2 = person1 with { Age = 31 };
// 결과 확인
Console.WriteLine(person1);
// Person { Name = 홍길동, Age = 30 } <- 원본은 그대로!
Console.WriteLine(person2);
// Person { Name = 김철수, Age = 31 } <- 복사본이 생성됨
참조형인 컬렉션(List)일 경우, ConvertAll() 메서드를 사용한다.
var person2 = person1 with { address = address.ConvertAll() };
레코드와 클래스간의 차이를 간단하게 표현해본다.
행동과 상태를 모두 가지는 객체를 만들고자 할 때 사용한다. 객체는 생성된 후, 상태가 계속 변할 수 있고(가변, Muttable) 복잡한 로직을 수행하는 메서드를 가진다.
캐릭터로 치자면캐릭터 컨트롤러에 해당한다.
데이터 그 자체를 표현하고자 쓰기 좋다. 한 번 만들어지면 변하지 않는(불변, Immutable) 데이터 묶음을 안전하게 다루기 위해 사용한다.
캐릭터로 치자면, 캐릭터의 기본 값들을 담고 있는캐릭터 모델에 해당한다.
test1 = new("홍길동");
test2 = new("홍길동");
test1 = test2;
Console.WriteLine(test1==test2);
false(참조 객체의 주소값을 비교함)true(컴파일러 단계에서 자동으로 Equals 메서드를 사용한다. 값을 비교함.)with { } 구문을 통해 record는 간단하게 복사 할 수 있다. 클래스를 Clone()으로 구현할 경우, 성능 상 유의미한 차이는 없다.
다만, record의 경우 코드 작성이 매우 간결해진다. with { } 구문과 달리 class에서는 memberwiseClone() 메서드 호출을 모든 필드에서 해줘야 한다.
유지보수성 입장에서도 월등하다. 필드가 추가/제거될 때 with 구문 내에서만 수정하면 되는 record와 달리 class는 해당 필드의 코드를 찾아서 수정해줘야 한다.
누락 시 버그가 발생하므로, 안전성 측면에서도 record가 더 좋다.
class의 경우 특히, Clone() 메서드를 구현하는 코드를 작성해야 한다는 단점도 있다.
1. 데이터의 안전성 및 예측 가능성 향상
멀티쓰레딩 환경에서 안전하다.2. 코드의 간결함
Equals, GetHashCode, ToString 등과 같은 메서드는 작성하지 않아도 된다. 클래스였을 경우, 데이터 비교를 위해 직접 구현해야 했던 코드들의 작성이 필요 없어진다.
var person1 = new Person("홍길동", 30);
var person2 = new Person("홍길동", 30);
Console.WriteLine(person1 == person2);
// record는 true (값이 같으므로)
// class였다면 false (참조하는 객체의 주소값이 다르므로)
Console.WriteLine(person1);
// record는 "Person { Name = 홍길동, Age = 30 }" 출력
// class였다면 "YourNamespace.Person" 출력
3. 데이터 전달 목적에 최적화
상태가 변하지 않는 데이터 묶음을 표현할 때 쓰기 좋다.
응답 데이터(DTO)record 객체의 필드 값을 변경하려면 with{ } 구문을 사용해야 하는데, 이를 사용하면 값의 주소에 접근해서 내용을 변경하는 것이 아닌, 해당 값만 변경 된 새로운 객체를 생성하여 할당한다. 값을 자주 변경하는 객체일 경우 record를 사용하는 것은 바람직 하지 않다.
깊은 복사를 하고자 할 경우에는 with{ } 구문과 함께 사용하고, 컬렉션의 경우 ConvertAll() 메서드를 추가 사용한다. 이 포스트의 맨 앞에서 썼던 예시를 기준으로 작성해보자.
A recordTestA = new();
recordTestA.a = 13;
recordTestA.str.b = 4;
A recordTestB = recordTestA with { };
이를 통해, class 객체의 깊은 복사 문제를 해결한다.
다만, 이는 어디까지나 객체의 내용이 자주 변하지 않는 경우 사용하는 것이다.
동적으로 실시간으로 값이 변하는 캐릭터의 스테이터스(HP,MP)와 같은 경우, record를 쓰면 안된다.
값을 변경하고자 할 때 마다 객체를 새로이 생성하기 때문이다.