많은 플러터 프로젝트들이 모델 선언을 할 때 Freezed 패키지를 사용한다. 왜 다들 Freezed 를 사용할는 걸까? 꼭 패키지를 써야하는걸까? 등등 몇가지 의문점이 들게되었고 공부한 기록을 블로그 포스트로 남겨보려한다.
먼저 모델 선언에 있어서 전제조건은 모델을 통해 만들어진 인스턴스가 같은 녀석인지 다른 녀석인지 비교할 수 있어야한다. 이를 동등성 비교라고 부르는데 왜 모델 선언에 있어서 동등성 비교가 중요할까? 이유는 3가지로 요약할 수 있다.
1. 데이터 무결성 확인
데이터 무결성이란 모든 데이터 값이 정확한 상태(state)임을 보장할 수 있음을 의미한다. 즉 데이터의 중복이 생겨선 안된다는 것이다. 예를 들어 강아지라는 모델을 만들고 두마리 강아지 인스턴스를 만들었다고 하자. 만약 두마리 강아지 인스턴스의 속성 (이름, 나이, 성별, 유전자) 이 같으면 두강아지는 한 마리의 강아지로 취급되어야한다. 만약 서로 다른 강아지로 취급되는 상황을 가정해보자. 나는 시츄야! 이렇게 부르면 한 마리 강아지만 달려오는 것을 기대했는데 두마리 쌍둥이 강아지가 달려오게 되는 상황을 목격할 것이다. 즉 데이터 무결성이 보장되지 못하면 해당 모델을 사용한 실행 로직의 동작 예측을 보장할 수 없게되어 찾기 매우힘든 에러를 발생시킬 수 있다.
2. 컬렉션에서의 중복제거
앞선 무결성과 이어지는 문제로 언어에 기본으로 구현되어있는 컬렉션 타입들 (Set, List, Map) 의 기본 동작은 이러한 객체의 동등성 비교로 동작한다. 다시말해 똑같은 강아지 value를 가진 인스턴스가 같은 강아지로 인식이 되지못하면 Set 에서는 서로 다른 강아지로 취급해 여전히 두마리 강아지가 남게되고, Map 에서도 두마리의 강아지가 서로 다르다고 취급해 두개의 중복된 key값이 만들어질 수 있는 것이다.
3. UI 업데이트
SwiftUI, Flutter, Android Compose는 모두 트리를 이용한 반응형 프레임워크이다. 이 세개의 UI프레임워크를 크게 보면 같은 구조인데 Single Source of Truth 를 통해 상태들을 한 곳에 모아두고 불변속성을 지닌 트리를 만들어 상태를 주입하게 된다. 만약 상태에 변경점이 있을 경우 해당되는 불변 UI를 새롭게 그려낸다. 이 때 상태가 바뀌면 전체 UI 트리를 다시 그리는 것이 아닌 상태의 동등성 비교를 통해 변경점이 있는 UI만 업데이트하게 되는데 만약 동등성 비교가 제대로 동작하지 않는 모델이 있다면 의도치 않은 UI가 계속해서 다시 그려질 것이다.
위 3가지의 이유를 통해 왜 모델 선언에 있어서 동등성 비교가 꼭 필요한지 알아보았다. 그렇다면 다음으로 생기는 의문, 동등성 비교를 어떤걸 기준으로 하는걸까?
Swift, Kotlin 등등 많은 언어들은 타입을 크게 두가지 참조타입과 값타입을 나눌 수 있다. 참조타입은 힙 메모리 영역에 저장되며 값타입은 스택 메모리 영역에 머무르게 된다. 이때 참조타입의 값을 확인할 때는 메모리 주소를 따라가 주소에 맞는 메모리 서랍을 열어야지 확인할 수 있고 값타입의 값은 메모리 서랍에 따로 저장되지 않고 값 그 자체를 바로 확인하게 된다.
여기서 포인트는 참조타입은 "메모리 주소"를 통해 값을 확인하고, 값타입은 "값" 자체를 통해 값을 확인한다. 따라서 동등성 비교를 할 때도 참조타입은 메모리 주소를 비교하고 값타입은 값을 비교하게 된다. 만약 클래스로 모델을 선언한 뒤 인스턴스를 비교하게 된다면 클래스는 참조타입이기 때문에 "메모리 주소"를 통해 비교하게 된다. 그렇기 때문에 같은 강아지더라도 이 강아지가 서로 다른 메모리 주소에 저장되어있다면 다른 강아지로 인식되는 것이다. 따라서 참조타입 모델을 선언해줄 때는 동등성 비교(==) 재정의를 통해 값이 같으면 같은 녀석이라고 수정해주어야한다.
이와같이 클래스를 이용해 모델을 선언한다면 적어도 두가지 "=="연산자와 "hashCode" 를 재정의해 주어야한다. 그럼 여기서 두 가지 의문이 들 수 있다.
1. HashCode는 왜 재정의하는거야?
다트에서 모든 object는 고유한 hashcode를 가지고 있다. 앞서 컬렉션에서도 동등성 비교를 한다고 했는데 이때 Set이나 Map은 객체의 hashcode를 기반으로 동등성 비교를 하게된다. 따라서 hashcode를 통해 동등성 비교를 하는 친구들을 위해 클래스 모델 선언시 hashcode도 재정의해주어야한다.
2. 애초에 값타입으로 모델을 정의하면 되잖아
맞다. 값타입으로 모델을 정의하면 귀찮게 재정의할 필요도 없고 무엇보다 모델 자체가 불변성이 보장된다. 따라서 동시성 환경의 race condition으로부터 자유롭게된다. 서로 다른 메서드에서 주소를 통해 힙메모리에 있는 상태를 바꾸게 되면 의도치않게 경쟁상황이 발생하는데 값타입은 "유일 상태"를 보장함으로써 상태를 안전하게 관리하게 되는 것이다. 하지만 애석하게도 Dart에는 구조체가 없다(..) 이를 보완하기 위해 다트에는 특별한 기능이 있는데 바로 @immutable 어노테이션이다.
클래스에 @immutable 어노테이션이 붙게되면 해당 클래스의 메모리는 말그대로 "불변"이 된다. 클래스이기 때문에 메모리 주소를 참조하는 것은 같지만 값을 수정하게 되면 같은 메모리 주소의 값을 수정하는 것이 아닌 해당 메모리의 주소의 값은 고정되고 새로운 메모리 주소에 변경된 값이 들어가게 된다. 그리고 수정 이전의 메모리주소에 있는 값은 가비지컬렉터에 의해 정리된다. 물론 immutable 을 붙인다고 해서 모든 것이 장점인 것은 아니다. 위 사진처럼 객체를 수정할 때마다 새로운 메모리 공간을 사용하게 되어 메모리 이슈가 있을 수 있고 변경 이전의 메모리는 가비지컬렉터에 의해 메모리를 없애주는 과정을 계속해서 해야하다보니 성능상 단점도 있을 수 있다. 하지만 최근 스마트폰의 성능이 워낙 좋아져서 메모리 부족보다는 동시성 환경 이슈에 집중하다보니 @immutable 어노테이션 선언이 사실상 표준이 되어가고 있다.
그래서 지금까지 언급한 모델 선언 조건을 고려해서 작성한 예시코드를 보자면 위와 같이 선언해줄 수 있다. 여기서 copywith 메서드는 어떤 메서드야? 라고 물을 수 있는데 copywith 는 대표적인 얕은복사(shallow copy) 메서드이다.
얕은 복사(shallow copy) vs 깊은 복사(deep copy)
얕은 복사는 참조를 통해 데이터를 복사해오고 깊은 복사는 새로운 메모리 주소를 만들어서 데이터를 복사해오는 개념이다. 앞서 살펴보았듯이 @immutable 이 붙은 객체는 값을 수정할 때 깊은 복사 개념을 이용하게 된다. 하지만 분명 얕은 복사를 통해 값을 수정하고 싶은 경우도 있을 것이다. 이러한 경우를 위해 얕은 복사 개념을 이용하는 copywith() 메서드를 함께 선언하게 된다.
즉 지금같이 고작 속성 3개뿐인 모델을 선언하는데도 이렇게 많은 코드를 필요로 하게된다. 심지어 위 코드는 모델선언에 "기본"이 되는 코드이고 만약 json직렬화 같은 메서드기능이나 toString같은 기능이 추가될 때마다 모델 선언시 보일러플레이트가 늘어날 것이다.
이러한 보일러플레이트 코드를 줄이기 위해서는 Equtable을 확장하는 방법이 있다. Equatable을 확장하게 되면 기존 "==" 연산자와 hashcode 재정의 연산자를 단순히 props 게터로 줄일 수 있다. 하지만 Equtable을 확장했을 때도 copywith 메서드나 json 메서드 등 부가적인 기능이 필요할 경우 보일러플레이트가 여전히 존재하게 된다.
freezed 패키지는 이러한 보일러플레이트 코드를 획기적으로 줄여준다. freezed를 이용하면 개발자가 위와 같은 코드만 작성해도 코드젠을 통해 immutable한 모델과 재정의, 각종 기능을 지닌 메서드를 자동으로 만들어준다.
특히 내가 사용하는 Freezed의 주요기능은 Union(Pattern matching) 기능인데 (Swift의 Result 타입과 유사하게) 각종 에러처리, 블록상태관리에서 패턴매칭을 이용한 간결하고 가독성 좋은 코드작성을 가능하게 해주는등 너무나 손쉽게 다양한 기능을 이용할 수 있다
추가 지식
공부하던 도중 모델 선언 관련해서 애플 공식문서(!!)가 있다는 것을 알게되었다. 물론 플러터에는 프로토콜과 구조체같은 언어적 기능은 없지만 공통되어 고려해야할 부분이 잘 정리되어있었다. 그리고 무엇보다 애플 공식문서라 신뢰도 5만 퍼센트였다.
먼저 1줄 요약으로 모델은 구조체로 선언하는 것을 디폴트로 하라는 것이 애플 권고사항이였다. 이를 플러터에 대응해보자면 완전히 같은 개념은 아니지만 사실상 @immutable 어노테이션을 디폴트로 선언하라는 것과 같았다.
As a result, you can look at a section of code and be more confident that changes to instances in that section will be made explicitly, rather than being made invisibly from a tangentially related function call.
이유는 사용하는 데이터 모델에 대한 확신을 가질 수 있기 때문이라고 적혀있다. 참조를 통해 데이터를 바꾸면 내가 지금 조작하는 데이터 값이 이게 맞는지 어디서 바꾸고 있는건지 확신하기가 힘든데 구조체로 선언하면 바뀌지 않는 값이니 확신을 가질 수 있다.
특히 이부분이 인상적이었는데 I/O 작업을 통해 불러오는 데이터의 모델은 구조체(불변)로 선언하라는 부분이었다. 외부 시스템에서 데이터를 불러올 때 데이터의 아이덴티티(내용)는 어차피 외부 시스템에서 관리되므로, 애플리케이션 내에서 해당 데이터를 구조체로 표현하여 불변성을 유지하고, 값 타입의 이점을 활용하라고 권고하고 있었다.
이부분을 거꾸로 생각한다면 모델의 identity를 컨트롤해야할 경우에는 "반드시" 클래스(mutable)로 선언해야한다는 것과 같다. 예를 들어, 로컬 데이터베이스 연결을 나타내는 타입을 생각해보면 반드시 클래스로 선언해야함을 알 수 있는데 구조체(immutable)로 선언된다면 서로 다른 로컬데이터베이스 연결통로를 이용하게 될 것이다. 그래서 그런지 로컬 데이터 저장 (Core Data, RealmDB) 모델은 mutable로 선언되는 것을 알 수 있었다 (물론 옵젝-C 영향도 있겠지만..) -> 이를 플러터로 변환해서 생각하면 Hive 데이터베이스도 mutable한 데이터 모델링을 지원한다는 것을 알 수 있다.
폭풍 구글링 결과 hive는 mutable한 모델링이고 freezed는 immutable한 모델링이라 함께쓰면 에러가 발생한다는 이슈도 찾아볼 수 있었다.
'Flutter' 카테고리의 다른 글
이벤트에 반응하여 Stateless 위젯 아이콘 이미지 색 바꾸기 (0) | 2024.08.13 |
---|---|
Flutter 3.24 업데이트! 이제 shared preference 사용해도 됩니다! (0) | 2024.08.07 |
Don't use 'BuildContext's across async gaps 이슈 (0) | 2024.08.01 |
Flutter로 SwiftUI 처럼 모서리 둥글게하기 (feat. Squircle) (0) | 2024.07.27 |
Shared Preference 패키지를 쓰지말아야하는 이유 (0) | 2024.07.25 |