본문 바로가기

개발 & IT/프론트엔드

JavaScript 개발자가 Flutter의 copyWith를 이해하기까지

시작하며

Flutter를 배우면서 가장 궁금했던 것 중 하나가 바로 copyWith 패턴이었습니다.

// 이게 왜 안 되는 거지?
deck.keyword = "커피";  // 컴파일 에러!

// 왜 이렇게 해야 하지?
final newDeck = deck.copyWith(keyword: "커피");

"JavaScript였으면 그냥 바꾸면 되는데..." 라는 생각이 들었습니다. 하지만 실제로 사용해보니 이 패턴이 왜 필요한지 이해하게 되었고, 오늘은 그 내용을 공유하려 합니다.


JavaScript에서는 당연했던 것들

JavaScript(특히 React)로 개발할 때, 객체나 배열을 다루는 방식은 이랬습니다:

// 객체 일부 변경
const user = { name: "김철수", age: 30, email: "kim@example.com" };
const updatedUser = { ...user, age: 31 };

// 배열에 항목 추가
const items = [1, 2, 3];
const newItems = [...items, 4];

// React 상태 업데이트
setState(prev => ({
  ...prev,
  score: prev.score + 10
}));

핵심은: Spread operator(...)를 사용해서 원본을 유지하면서 새 객체를 만든다는 것입니다. React를 써본 분이라면 익숙한 패턴일 것입니다.


Dart에서는 왜 다를까?

Dart에는 JavaScript의 Spread operator 같은 "객체 복사 + 일부 변경" 기능이 언어 차원에서 제공되지 않습니다. 그래서 개발자가 직접 copyWith 메서드를 만들어야 합니다.

class DeckModel {
  final String? keyword;
  final List<String> matchedWords;
  final int deckIndex;

  DeckModel({
    this.keyword,
    required this.matchedWords,
    required this.deckIndex,
  });

  // 이걸 직접 만들어야 함
  DeckModel copyWith({
    String? keyword,
    List<String>? matchedWords,
  }) {
    return DeckModel(
      keyword: keyword ?? this.keyword,
      matchedWords: matchedWords ?? this.matchedWords,
      deckIndex: this.deckIndex,
    );
  }
}

처음에는 "왜 이렇게 번거롭게?" 싶었습니다. JavaScript였으면 한 줄이면 끝날 것을 메서드로 따로 만들어야 한다니 말이죠.


원본 보존이 왜 중요한가?

게임 예제를 들어보겠습니다. 플레이어가 단어 카드를 덱에 배치하는 상황입니다.

❌ 직접 수정 방식 (불가능)

class GameProvider extends ChangeNotifier {
  List<DeckModel> decks = [...];
  
  void placeWord(String word, int deckIndex) {
    // 이렇게 하고 싶지만...
    decks[deckIndex].keyword = word;  // final이라 불가능
    decks[deckIndex].matchedWords.add(word);  // 가능하지만 위험
    
    notifyListeners();  // Flutter가 변경을 감지 못할 수도 있음
  }
}

문제점:

  1. final 필드는 수정 불가능합니다
  2. 리스트를 직접 수정하면 Flutter가 변경을 감지하지 못할 수 있습니다
  3. 원본이 사라져서 Undo 기능 구현이 불가능합니다

✅ copyWith 방식 (권장)

class GameProvider extends ChangeNotifier {
  List<DeckModel> decks = [...];
  
  void placeWord(String word, int deckIndex) {
    // 새 객체를 만들어서 교체
    decks[deckIndex] = decks[deckIndex].copyWith(
      keyword: word,
      matchedWords: [...decks[deckIndex].matchedWords, word]
    );
    
    notifyListeners();  // 확실하게 변경 감지됨
  }
}

장점:

  1. 원본 객체는 그대로 유지됩니다
  2. 새 객체가 생성되므로 Flutter가 확실히 감지합니다
  3. 이전 상태를 보관할 수 있어 Undo가 가능합니다

실전 사례: Undo 기능

게임에서 "뒤로가기" 아이템을 구현할 때 원본 보존의 가치를 절감했습니다.

class GameProvider extends ChangeNotifier {
  List<DeckModel> _currentDecks = [...];
  List<List<DeckModel>> _history = [];  // 이전 상태들
  
  void placeWord(String word, int deckIndex) {
    // 현재 상태를 히스토리에 저장
    _history.add(_currentDecks.map((d) => d).toList());
    
    // 새 상태로 변경
    _currentDecks[deckIndex] = _currentDecks[deckIndex].copyWith(
      keyword: word
    );
    
    notifyListeners();
  }
  
  void undo() {
    if (_history.isEmpty) return;
    
    // 이전 상태로 복원 (원본이 살아있어서 가능!)
    _currentDecks = _history.removeLast();
    notifyListeners();
  }
}

만약 객체를 직접 수정했다면? 원본이 이미 변경되어서 되돌릴 방법이 없었을 것입니다.


JavaScript와 비교하면

실제로 copyWith는 JavaScript의 Spread operator와 정확히 같은 개념입니다.

JavaScript Dart

const newObj = {...obj, key: value} final newObj = obj.copyWith(key: value)
언어 차원 지원 직접 구현 필요
자동으로 작동 메서드 작성 필요

차이점은 단 하나: JavaScript는 언어에서 제공하고, Dart는 직접 만들어야 한다는 것뿐입니다.

// JavaScript
const deck = { keyword: "커피", matched: [], index: 0 };
const updated = { ...deck, keyword: "샐러드" };
// Dart
final deck = DeckModel(keyword: "커피", matchedWords: [], deckIndex: 0);
final updated = deck.copyWith(keyword: "샐러드");

처음엔 번거로웠지만

솔직히 처음에는 copyWith 메서드를 일일이 작성하는 게 번거로웠습니다. 필드가 10개면 10개를 다 나열해야 하니까요.

DeckModel copyWith({
  String? keyword,
  List<String>? matchedWords,
  List<String>? requiredWords,
  int? deckIndex,
  // ... 필드가 많으면 지옥
}) {
  return DeckModel(
    keyword: keyword ?? this.keyword,
    matchedWords: matchedWords ?? this.matchedWords,
    requiredWords: requiredWords ?? this.requiredWords,
    deckIndex: deckIndex ?? this.deckIndex,
    // ...
  );
}

하지만 실제 프로젝트를 진행하면서 이 패턴의 가치를 깨달았습니다:

  1. 타입 안정성: 컴파일 타임에 오류를 잡아줍니다
  2. 명확한 의도: "이건 새 객체다"라는 게 코드로 드러납니다
  3. 디버깅 용이: 상태 변화를 추적하기 쉽습니다
  4. Undo/Redo: 이전 상태 보관이 자연스럽습니다

팁: 코드 생성 도구 활용

매번 copyWith를 손으로 작성하기 귀찮다면, 코드 생성 패키지를 사용할 수 있습니다:

// freezed 패키지 사용
import 'package:freezed_annotation/freezed_annotation.dart';

@freezed
class DeckModel with _$DeckModel {
  factory DeckModel({
    String? keyword,
    required List<String> matchedWords,
    required int deckIndex,
  }) = _DeckModel;
}

// copyWith가 자동 생성됨!

마무리

Flutter를 배우면서 가장 어색했던 부분이 바로 이 불변성 패턴이었습니다. "그냥 바꾸면 되는데 왜 이렇게 복잡하게?"

하지만 실제로 사용해보니, 이 패턴이:

  • 더 안전하고 (예상치 못한 변경 방지)
  • 더 명확하며 (상태 변화가 코드에 드러남)
  • 더 강력합니다 (시간 여행, Undo 등 구현 용이)

결국 copyWith는 JavaScript의 Spread operator와 같은 개념입니다. 단지 문법이 다를 뿐, 지향하는 바는 동일합니다:

원본을 보존하면서 필요한 부분만 변경하기.


참고 자료:

반응형