시작하며
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가 변경을 감지 못할 수도 있음
}
}
문제점:
- final 필드는 수정 불가능합니다
- 리스트를 직접 수정하면 Flutter가 변경을 감지하지 못할 수 있습니다
- 원본이 사라져서 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(); // 확실하게 변경 감지됨
}
}
장점:
- 원본 객체는 그대로 유지됩니다
- 새 객체가 생성되므로 Flutter가 확실히 감지합니다
- 이전 상태를 보관할 수 있어 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,
// ...
);
}
하지만 실제 프로젝트를 진행하면서 이 패턴의 가치를 깨달았습니다:
- 타입 안정성: 컴파일 타임에 오류를 잡아줍니다
- 명확한 의도: "이건 새 객체다"라는 게 코드로 드러납니다
- 디버깅 용이: 상태 변화를 추적하기 쉽습니다
- 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와 같은 개념입니다. 단지 문법이 다를 뿐, 지향하는 바는 동일합니다:
원본을 보존하면서 필요한 부분만 변경하기.
참고 자료:
'개발 & IT > 프론트엔드' 카테고리의 다른 글
| Dart 메서드 vs Getter: 언제 무엇을 사용해야 할까? (0) | 2026.01.06 |
|---|---|
| TypeScript 인터페이스 확장: Declaration Merging과 Module Augmentation (0) | 2025.10.27 |
| 웹 빌드 도구 비교: Webpack vs Vite vs Rollup 그리고 etc (0) | 2025.10.26 |
| Vite: 차세대 프론트엔드 개발 도구 (0) | 2025.10.20 |
| Vue3 Carousel에서 화면 줄어들 때 이전 슬라이드가 겹쳐 보이는 문제 해결하기 (2) | 2025.08.18 |