우리 앱의 식단 기록 기능을 구현하던 중, 깊은 복사의 중요성을 간만에 느끼게 되어 작성하게 되었습니다. List, Map 또는 클래스 안에 다른 인스턴스들을 넣어 사용할 때 리마인드 할 수 있다면 좋을 것 같습니다.
원본은 바꾸고 싶지 않다고
우리 앱의 식단 기록 기능은 다른 기록 기능들보다, 1회 기록 할 때의 데이터 크기가 큽니다. 또한, 사용자가 원하는 대로 그 데이터를 수정할 수도 있죠. 실제 식단 기록 과정을 예로 들면, 1회 식단 기록 안에는 다수의 음식 정보, 끼니 타입, 시간 등이 존재하고, 음식 정보 안에는 섭취 용량, 단위 정보 등이 존재합니다. 간식의 경우에는 이 식단 정보가 다수로 존재할 수도 있고, 기록 전에 사용자는 대부분의 정보를 조작할 수 있습니다. 사진이나 새로운 음식을 추가할 수도 있고, 음식들의 수량을 수정할 수 있으며, 사용자만 아는 음식을 커스텀하게 만들어 추가할 수도 있습니다. 여기에 더해 갑자기 기록하기 싫은 마음이 들면 기록하던 정보들을 모두 날려버릴 수도 있죠.
따라서 기존 식단 정보를 그대로 복사하고 그 복사본에 대해 사용자가 수정할 수 있도록 한 뒤, 사용자가 원하면 그 복사본을 기록, 그렇지 않으면 통째로 날려버리는 식으로 작성했습니다. 기존에 이미 존재하는 식단 정보에 직접적인 수정을 가하기에는 버그 발생 가능성도 높고 코드 자체도 복잡해 질 테니까요. 여기서 중요한 것은, 복사본과 원본은 완벽하게 분리되어야 합니다. 복사본을 통해 접근한 데이터를 어떻게 수정하든, 원본은 변경되면 안된다는 말이지요.
CODE 1
class Food {
...
int quantity;
}
class Diet {
...
List<Food> foods;
}
class DailyDiet {
...
List<Diet> diets;
DailyDiet copy() => DailyDiet(
...
diets: List.from(diets),
);
}
식단 쪽 데이터 구조를 간략화 하면 위와 같습니다. DailyDiet는 하루 전체 식단 기록, Diet는 개별 식단 기록입니다. 처음 구현 시, 제일 바깥쪽인 DailyDiet 내부에 현재 인스턴스를 복사해 새로운 인스턴스를 만들어 줄 copy 함수를 만들어 두었습니다.
CODE 2
final recordedDailyDiet = await getDietDataFromServer();
...
final copiedData = recordedDailyDiet.copy();
copiedData.diets[0].foods[0].quantity = 2; // 1 -> 2
그리고 위 코드는 서버측 데이터를 copy 함수를 사용해 복사하고, 그 복사본 데이터 중 특정 음식의 수량을 변경하는 코드입니다. 예상 하셨겠지만, 위 코드는 복사본 뿐만 아니라 원본 데이터까지 수정하는 코드입니다.
다트의 변수는 오로지 레퍼런스 타입
다트의 모든 변수는 레퍼런스 타입입니다. 레퍼런스 타입의 변수에는 실제 값이 저장된 메모리 영역의 주소값이 담겨있습니다. 따라서 흔히 보듯이, 아래와 같은 결과를 볼 수 있죠.
CODE 3
class Data {
int intData;
Data(this.intData);
}
void main() {
final data1 = Data(1);
final data2 = data1;
data2.intData = 2;
print(data1.intData); // 2
print(data2.intData); // 2
}
다트의 List 또한 레퍼런스 타입입니다. 이에 따라 다트는, 기존 List 내용물을 그대로 복사해 새로운 List 인스턴스를 생성할 수 있는 함수를 미리 만들어 두었습니다. List.from() 함수가 대표적인 예입니다. 하지만 당연하게도, List 에 정의된 함수가 내용물까지 완벽히 복사할 수는 없습니다. List.from() 함수는 List 인스턴스만 새로운 것으로 생성할 뿐, 내부 내용물은 CODE 3 에서 변수에 다른 인스턴스를 할당하듯 레퍼런스 기반의 복사입니다. CODE 1 의 List.from(diets) 의 결과물 List 는 결국, 원본의 Diet 인스턴스들과 그 안에 포함된 Food 인스턴스들을 그대로 참조합니다. 따라서, CODE 1 을 사용할 경우, 사용자가 식단을 기록하다가 도중에 취소하더라도 그대로 원본 데이터에 반영됩니다.
결국 깊은 복사는 일일이 구현
타 언어에서는 주로 '복사 생성자'나 '복사 대입 연산자'와 같은 요소와 함께 깊은 복사를 설명합니다. 프로그래머는 이 요소들을 이용해 '복사' 연산 과정을 서술합니다. 그 과정을 단순화하면 CODE 4 와 유사합니다.
CODE 4
class InnerData {
...
int data;
InnerData(InnerData other) {
data = other.data;
}
}
class OuterData {
...
InnerData innerData;
OuterData(OuterData other) {
// 얕은 복사
// innerData = other.innerData;
// 깊은 복사
innerData = new InnerData(other.innerData);
}
}
다트에서도 위와 같은 복사를 구현할 수 있습니다. CODE 1 을 깊은 복사로 구현하면 CODE 5 와 같습니다. 리스트에 담을 타입들도 깊은 복사에 해당하는 함수를 호출해 주는 것이지요. 결국 깊은 복사가 완벽히 이루어지려면 가장 깊은 곳의 데이터 타입까지 깊은 복사가 구현되어 있어야 합니다.
CODE 5
class Food {
...
int quantity;
Food deepCopy() => Food(
...
quantity: quantity,
);
}
class Diet {
...
List<Food> foods;
Diet deepCopy() => Diet(
...
foods: foods.map((food) => food.deepCopy()).toList(),
);
}
class DailyDiet {
...
List<Diet> diets;
DailyDiet deepCopy() => DailyDiet(
...
diets: diets.map((diet) => diet.deepCopy()).toList(),
);
}
참고로, 일반적으로 다트에서 사용하는 copyWith 방식의 복사는 얕은 복사에 해당합니다.
다트는 깊은 복사가 딱히 중요하지 않은 걸까?
CODE 4 와 같이 클래스를 구현하던 때가 그리운 이유가 있습니다. 해당 언어들에서는 복사 생성자, 복사 대입 연산자를 클래스 구현 시 한 번만 정의하면, 복사가 발생하는 모든 곳, 그곳이 리스트 외부든 내부든, 알아서, 미리 정의해 둔 복사 생성자와 복사 대입 연산자를 호출하여 깊은 복사를 수행해 줍니다. CODE 5 처럼, 각 원소에 대해 일일이 deepCopy 함수를 호출해 줄 필요가 없다는 뜻입니다.
그렇다면, 왜 다트는 깊은 복사를 기본으로 두지 않은 것일까요? 다트는 불변성을 지향하는 언어입니다. 아래는 불변성과 복사 생성자의 연관성에 대해 ChatGPT 에게 질문한 결과입니다.
불변성을 지향하는 객체는 복사 생성자와 복사 대입 연산자를 제공하지 않는 경우가 많습니다.
복사 생성자는 객체를 복사하여 새로운 객체를 생성합니다. 이는 객체의 상태를 변경할 수 있으며, 이러한 변경은 객체의 불변성을 깨뜨릴 수 있습니다. 반면, 불변성을 지향하는 객체는 상태를 변경할 수 없으므로 복사 생성자가 필요하지 않습니다. 객체의 상태를 변경하려면 항상 새로운 객체를 생성하여 새로운 상태를 할당해야 합니다.
다트는, 한 객체의 복사가 발생했을 때 복사 대상과 복사 결과물의 상태는 동일해야 함을 추구합니다. 또한, 상태가 동일해야 한다면, 레퍼런스 타입 기반의 언어인 다트에게 복사 생성자는 불필요합니다. 인스턴스를 복사할 때 레퍼런스만 복사한다면 상태는 복사 전 후의 상태는 무조건 동일할 테니까요. 이에 반해, 복사 생성자가 있는 언어는 CODE 6 과 같은 결과가 발생할 수 있습니다.
CODE 6
class Data {
int number;
setNumber(int newNumber) {
number = newNumber;
}
Data(Data other) {
number = other.number;
// 상태 변경 가능성 존재
setNumber(0);
}
}
이에 따라 다트는 깊은 복사의 책임을 프로그래머에게 맡기고, 불변성을 더 강하게 추구합니다.
우리의 선택지
Immutable Object
가장 좋은 방법은, 가능하면 Immutable Object 로 구현하는 것입니다. 인스턴스 내부를 수정할 수 없다면 참조하는 변수가 아무리 많아도 예상과 다른 결과를 낳는 일은 없겠죠.
깊은 복사 직접 구현
불변성을 가질 수 없는 클래스라면 Code 5 처럼 깊은 복사를 직접 구현하는 것이 방법이겠죠.
Serialize & Deserialize
CODE 5 보다 간단한 깊은 복사 방법으로는, 아래와 같이 Json 인코딩 후 곧바로 디코딩하여 인스턴스를 생성하는 방법이 있습니다. 다만 이 방법은 성능 저하의 위험이 있고, 타입 또한 잃게 된다는 단점이 있겠죠.
final data = Data();
final copied = jsonDecode(jsonEncode(data));
결론
인스턴스를 새로 생성해야 한다면, 해당 클래스가 Immutable 한 지, 얕은 복사로 인한 버그 발생 가능성은 없는 지 꼭 따져 보는 습관을 가집시다.
'Weekly Flutter' 카테고리의 다른 글
backgroundservice를 사용해보자. (0) | 2023.06.29 |
---|---|
[Flog Favorite] go_router 패키지 (0) | 2023.03.22 |
[Platform integration] 2. Event Channel 사용방법 (0) | 2023.03.08 |
dart의 EventLoop는 어떻게 동작할까? (0) | 2023.03.08 |
다트의 가비지 컬렉터 어떻게 동작할까? (1) | 2023.03.02 |