티스토리 뷰

Immutable? 불변의 객체 이걸 왜 사용해야 할까?

Dart로 클래스를 만들면 기본적으로 mutable하다.  하지만 이런 객체(특히 Model)를 사용하게 되면 원하지 않는 데이터의 변경이 발생하는 경우가 있다.

아래 코드를 한번 보자

class Document {
    String id;
    String title;
    Document({required this.id, required this.title});  
}

void main() {
    final document = Documet(id:’1234’, title:’개발자놀이!’);
    final isValidTitle = checkTitleValidation(document);
	print(‘${document.title}: ${isValidTitle}’);
}

bool checkTitleValidation(Document document) {
    Document.title =   ’사용불가’;
    return false;
}

위와 같이 유효한 제목인지를 검사하는 함수  checkTitleValidation 이 있다고 가정하자  이 함수의 역할은 title이 유효한 값일 경우 true를 그렇지 않은 경우 false를 반환한다.   

하지만 현재 잘못된 구현으로 함수내부에서 document.title을 다른 값으로 변경해 버렸다. 

물론 조금은 극단적인 상황 이지만 간혹 인자로 전달받은 객체의 내부를 마음대로 변경하고 있는 코드를 볼 때가 있다. (특히 예전 c스타일의 코드를 보면 참조 값 전달 후 전달받은 인자의 내부를 마음대로 변경하는 코드를 아주 쉽게 볼 수 있다. 이건 C개발에서 어쩔 수 없는 요소일 것 같지만 요즘 언어에서는 이렇게 사용하면 안 된다. )

 

이렇게 다른 함수 내부에서 document객체가 변경되어 버리면 main함수를 개발하던 개발자는 checkTitleValidation함수가 내부에서 마음대로 값을 변경했을 거라 생각하지 못한 채 다음 코드를 작성하다 자신의 의도와 다른 결과가 나온 것을 보고 당황하게 될 것이다.

 

기본적으로 함수의 인자로 전달받은 값을 변경해선 안된다. 하지만 우리는 이것을 강제할 수 없고 그렇게 하지 않을 것이라도 믿고 개발을 하는 수밖에 없다. 

 

다행히 dart에는 final 키워드를 이용해서 변수가 생성된 후 더이상 변경할 없도록 강제 할 수 있고, @immutable anotiaion 이용해서 immutable 하다는 것을 개발자에게 알릴 수 도 있다.  

@immutable
class Document {
    final String id;
    final String title;
    Document({required this.id, required this.title})   
}

@immutable / final을 사용하는 것만으로 우리가 원하는 immutable객체를 만들 수 있다. 

하지만 이렇게 immutable객체를 만들게 되면 필연적으로 추가해야 하는 기능이 있는데 바로 copy / copyWith함수이다. 

 

이미 만들어진 docuemnt객체의 title 다른것으로 변경해야 할때는 아래와 같이 기존 document 값을  생성자에 전달해서 새로운 객체를 만들어야 한다. 

final document = Documet(id:’1234’, title:’개발자놀이!’)

// title을 변경하기 위해서는 newDocument객체를 다시 생성해야한다.
final newDocument = Document(id:document.id, title:’수정된 제목’);

위 와 같이 Model내부의 값 하나를 변경하기 위해 새로운 객체를 생성하는 것은 매우 번거로운 일이다. 

특히  Model내부 property가 많으면 코드도 길어져서 불필요한 일을 많이 해야 한다.  그래서 보통은 아래와 같은 copyWith라는 함수를 만들어서 사용한다.  

@immutable
class Document {
    final String id;
    final String title;
    Document({required this.id, required this.title}) ;
   
    Document copyWith({String? id, String? title})  {
        return Document(id : id ?? this.id, name: name ?? this.name);
    }
}

final document = Documet(id:’1234’, title:’개발자놀이!’)

// copyWith함수를 이용해서 쉽게 title이 변경된 새로운 Document를 생성한다.
final newDocument = document.copyWith(title: ‘’수정된 제목);

이제 우리의 inmutable class인 Document가 완성되었다. 

이 Document 클래스만으로도  immutable 객체에게 원했던  것을 얻을 수 있다. 

 

immutable 객체를 사용하지 않으면?

개발을 하다 보면 immutable 하게 만드는 것보다 mutable 한 model를 사용하는 것이 편리하게 느껴질 때가 많다.  번거롭게 새로운 객체를 생성하는 것 보다 그냥 내부의 값만 변경하는 게 더 좋아 보이고 직관적으로 보일 때가 많은 것도 사실이다.

 

immutable 한 객체를 사용하지 않을 경우 우리의 코드가 어떻게 될지 한번 상상해 보도록 하겠다.

 

우리의 Document클래스에 다음과 같은 요구사항이 추가되었다고 가정해보자

level라는 property가 하나 더 추가되었다.  이것의 초기 값은 1인데  유저는 화면에서 그 값을 1에서 10까지 변경할 수 있다 그리고 이 값이 변경되면 DB에 변경된 Document 정보를 저장해야 한다. 

우리는 개발의 편의를 위해 위에서 만들었던 immutable 한 Document를 아래와 같이 mutable 하게  바꿔 버렸다. 

 

아래 코드는 다음과 같은 상황을 가정해서 작성하였다. 

1. 문서 목록 화면 (ListViewModel)에서 openFirstDocument함수를 호출하면 문서 1번의 상세 정보 화면으로 이동한다.  

2. 이때 documents의 0번째 값을 그대로 인자로 넣어서 문서 상세 정보를 볼 때 활용하도록 하였다. 

3. 문서 상세화면 (DetailViewModel)에서는 유저에 의해 level이 변경되고, 이 값은 인자로 전달받은 document에 반영하였다. 

4. 그리고 이 document의 변경사항을 DB에 저장하기 위해  Repository의 updateDocument함수를 통해 DB에 저장하였다. (그리고 이때 불행하게도 DB에 저장하고 난 후 document.level를 10으로 변경하는 코드가 들어가 버렸다. - 역시 좀 극단적인 상황이긴 하지만 이런 실수가 아니더라도 개발된 코드 중 인자로 전달받은 값을 변경하는 경우를 자주 접할 수 있다.)

// level를 변경해야 하기 때문에 @imutable을 제거하고 level은 final로 선언하지 않았다.
class Document  {
    final String id;
    final String title;
    int level;
    Document({required this.id, required this.title, this.level = 1}) ;
}

class ListViewModel {
   final documents = [
       Document(id: '001', title: '문서1번', level: 5),
       Document(id: '002', title: '문서2번', level: 7)   
   ]
   
   void openFirstDocument() {
     ... 
     _goDocuemntDetail(documents[0])
     ...
   }
}

class DetailViewModel {
  document document;
  DocumentRepository _documentRepository;
  
  DocumentViewModel(this.document);
  
  void updateLevel(int level) async {
      document.level = level;
      await _documentRepository.updateDocument(document);
  }
}

class DocumentRepository {
   Future<void> updateDocument(Document document) {
      // Save document ....
      document.level = 10
   }
}

 

위 과정을 한번 보자  어떠한가. 조금 코드도 간결하고 쉬워 보인다.

하지만 document라는 객체 (listViewModel.documents [0] 이였던 객체)는 화면 기준으로 ListViewModel / DetailViewModel 이렇게 두 화면에 걸쳐서 존재하고 있고,  2개의 class ( DetailViewModel / Repository)에서 값 변경이 일어나고 ListViewModel / DetailViewModel 모두에게 영향을 끼치게 된다. 

 

어떠한가  우리는 이제 document 객체의 값을 신뢰할 수 있는가?  document가 내가 진짜 원하던 값을 가지고 있다고 보장할 수 있는가?

예를 들기 위한 조금은 극단적인 상황이기는 하지만, 개인적으로 이와 유사한 코드를 생각보다 꽤 많이 보았던 기억이 있다. 

 

조금 쉽게 개발하기 위해 필드에서 final를 제거하면  편하게 개발하는 것이 가능하다.

하지만 이렇게 되면 그 값을 신뢰할 수 없고,  여러 개발자들이 함께 개발을 한다고 가정했을 때 Document클래스를 어떻게 다루어야 할지 혼란스럽게 만들 수 도 있다.

 

immutable 객체를 사용한다면? 

이제 위와 똑같은 상황을 immutable 한 Document를 이용해서 개발해보자.

@immutable
Class Document  {
    final String id;
    final String title;
    final int level;
    Document({required this.id, required this.title, required this.level = 1}) ;
     
    Document copy() {
        return Document(id: this.id, name: this.name, level: this.level);
    }
    Document copyWith({String? id, String? title, int? level})  {
        return Document(id : id ?? this.id, name: name ?? this.name, level: level ?? this.level);
    }
    
}

class ListViewModel {
   final documents = [
       Document(id: '001', title: '문서1번', level: 5),
       Document(id: '002', title: '문서2번', level: 7)   
   ]
   
   void openFirstDocument() {
     ... 
     // 다른 화면으로 값을 전달할때는 참조값을 그대로 전달하기 보다는 deepCopy한 객체를 전달해야 한다. 
     _goDocuemntDetail(documents[0].copy())
     ...
   }
}

class DetailViewModel {
  document document;
  Repository _repository;
  
  DocumentViewModel(this.document);
  
  void updateLevel(int level) async {
      final newDocument = document.copyWith(level: level)
      await _repository.updateDocument(newDocument);
      document = newDocument;
  }
}

class Repository {
   Future<void> updateDocument(Document document) {
      // Save document ....
      // document.level = 10  // 개발자가 실수로 작성했던 이 코드는 컴파일 오류가 발생했을것이다.
   }
}

 

위 코드는 어떤가? 그냥 보기에는 크게 달라 보이지 않는다 하지만 아래 사항들이 변경되었다. 

1. Document를 immnutable 하게 만듬

2. document [0]을 직접 전달했던 것을 새로운 객체로 만들어 DetailView로 전달

3. repository.updateDocument에 document를 직접 전달했던 것에서 document의 level를 변경한 새로운 객체를 만들어 전달

4. updateDocument함수 내에서 인자로 받은 document를 수정하려 할 때 오류 발생

 

여러 클래스들에서 사용되던 document는 이제 자신이 속해 있는 class내에서만 유효하게 되었고, 다른 클래스들에 의해 영향을 받지 않게 되었다.  이제 document는 자신이 속한 클래스 내에서는 신뢰할 수 있는 값을 가진 객체로 사용할 수 있다.

 

위 예시 상황이 조금은 극단적일 수도 있다 하지만 실무에서는 이보다 더 복잡한 상황이 많고 하나의 객체가 여러 클래스와 화면에 걸쳐 영향을 끼치게 되면 우리가 예상하지 못했던 많은 문제들을 만들게 된다. (명확하게 여러 화면에서 함께 공유하고 수정하기 위해서 사용하는 것이 아니라면 자신이 속한 영역 내에서만 객체가 유효하도록 만들어야 한다.)

 

Freezed

사실 freezed 라이브러리를 소개하려고 작성하던 글인데 immutable객체에 대한 설명이 훨씬 길어진 느낌이다. 

위에서 설명했던 것처럼 immutable객체를 쉽게 만들기 위해서 사용할만한 라이브러리가 freezed이다. 

 

https://pub.dev/packages/freezed

 

freezed | Dart Package

Code generation for immutable classes that has a simple syntax/API without compromising on the features.

pub.dev

 

freezed는 json_serializable + Equatable + Immutable를 하나로 합쳐 놓은 듯한 느낌의 라이브러리이다. 

 

1. toJson / fromJson 함수를 제공해 json으로 쉽게 serialize / deserialize 할 수 있도록 돕는다. 

2. equals (==)와 hashCode를 자동으로 작성해준다.

3. 선언된 필드들의 getter 만들어서 외부에서 값을 변경할 수 없도록 한다. 

4. copy와 copyWith을 자동으로 구현해주고,  종속성을 가지는 하위 클래스들에 대해서도 쉽게 deepCopy 할 수 있도록 도와준다. 

 

위에서 나열한 기능들을 실무에서 사용하는 모든 Model에 직접 코드로 구현한다면 아주 많은 시간이 필요할 것이다.

하지만 freezed는 이것을 아주 간편하게 설정하고 자동으로 코드를 generate 해주기 때문에 아주 고마운 라이브러리라고 할 수 있다. 

라이브러리의 상세한 사용법은 공식문서에서 아주 잘 다루고 있음으로 필요하신 분들은 공식 문서를 참고하세요 

마치며

사실 라이브러리는 상황에 따라 자신에게 필요한 것을 찾아서 사용하면 됩니다.

나중에 freezed보다 더 좋은 라이브러리가 나와서 우리를 편하게 해 줄지도 모릅니다. 

하지만 우리가 왜 immutable객체를 사용해야 하는지 알고서 사용하는 것이  특정 라이브러리를 사용하는 것보다 더 중요할 것입니다. 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함