Tuesday, July 4, 2023

Dart 객체 비교: 참조부터 값까지 심층 분석

객체 지향 프로그래밍의 핵심 개념 중 하나는 '객체' 그 자체입니다. 우리는 데이터를 담고 행위를 정의하는 객체를 만들고, 이들을 서로 비교하며 프로그램의 논리를 전개합니다. Dart 언어 역시 모든 것이 객체로 이루어져 있으며, 두 객체가 '같은지'를 판단하는 것은 매우 흔하고 중요한 작업입니다. 하지만 '같다'는 말에는 두 가지 의미가 숨어있습니다. 하나는 두 변수가 메모리상의 완전히 동일한 객체를 가리키는 경우(참조 동등성)이고, 다른 하나는 두 변수가 서로 다른 객체일지라도 내부에 담고 있는 값이 같은 경우(값 동등성)입니다.

Dart에서 이 두 가지 동등성을 이해하고 올바르게 다루는 것은, 특히 Flutter와 같이 상태 변화에 따라 UI를 갱신하는 프레임워크에서 코드의 안정성, 예측 가능성, 그리고 성능에 지대한 영향을 미칩니다. 많은 개발자들이 == 연산자의 동작 방식을 오해하여 예상치 못한 버그를 마주하곤 합니다. 왜 Person('John', 30) == Person('John', 30)이 기본적으로 false를 반환하는지, 그리고 이를 어떻게 true로 만들 수 있는지, 그 과정에서 hashCode는 왜 반드시 함께 고려되어야 하는지 명확히 알아야 합니다. 이 글에서는 Dart의 동등성 비교 원리를 기초부터 심층적으로 분석하고, Flutter 애플리케이션에서의 실제 활용 사례와 보일러플레이트를 줄여주는 효율적인 방법들까지 체계적으로 살펴보겠습니다.

1장: 동등성의 두 얼굴 - 참조(Identity)와 값(Equality)

Dart에서 동등성을 논하기 전에, 가장 근본적인 두 가지 개념인 '참조 동등성'과 '값 동등성'을 명확히 구분해야 합니다. 이 둘의 차이를 이해하는 것이 모든 논의의 출발점입니다.

참조 동등성 (Identity): 동일한 메모리 주소

참조 동등성은 두 변수가 메모리 위에서 정확히 같은 인스턴스를 참조하고 있는지를 확인합니다. 이를 '식별성' 또는 '동일성'이라고도 부릅니다. Dart에서는 이를 확인하기 위한 최상위 함수 identical()을 제공합니다.


void main() {
  var p1 = Person('Alice', 25);
  var p2 = Person('Alice', 25);
  var p3 = p1; // p1의 메모리 주소를 p3에 복사

  print(identical(p1, p2)); // false: p1과 p2는 내용이 같지만, 서로 다른 메모리 공간에 생성된 별개의 객체입니다.
  print(identical(p1, p3)); // true: p3는 p1과 완전히 동일한 객체를 가리키고 있습니다.
}

class Person {
  final String name;
  final int age;

  Person(this.name, this.age);
}

위 코드에서 p1p2Person 클래스의 인스턴스를 생성하는 코드를 통해 각각 독립적으로 메모리에 할당됩니다. 비록 그 안에 담긴 nameage 값이 같더라도, 그들이 차지하는 메모리 주소는 다릅니다. 따라서 identical(p1, p2)false입니다. 반면, p3 = p1은 새로운 객체를 만드는 것이 아니라, p1이 가리키고 있는 객체의 메모리 주소 자체를 p3에 복사하는 것입니다. 결과적으로 p1p3는 같은 대상을 가리키는 두 개의 이름표와 같으므로, identical(p1, p3)true를 반환합니다.

사용자 정의 클래스의 기본 `==` 연산자는 바로 이 참조 동등성을 확인합니다. 이것이 바로 많은 개발자들이 처음 겪는 혼란의 원인입니다. 별도로 재정의(override)하지 않는 한, `p1 == p2`는 내부적으로 `identical(p1, p2)`와 동일하게 동작합니다.


void main() {
  var p1 = Person('Alice', 25);
  var p2 = Person('Alice', 25);

  // Person 클래스에서 == 연산자를 재정의하지 않았으므로,
  // 참조 동등성을 비교합니다.
  print(p1 == p2); // false
}

값 동등성 (Equality): 동일한 내용

값 동등성은 두 객체가 메모리상 다른 위치에 존재하더라도, 그 객체들이 논리적으로 '같은 값'을 표현하는지를 확인하는 것입니다. 예를 들어, 'Alice'라는 이름과 25살이라는 나이를 가진 사람 객체는 몇 개를 만들든 논리적으로는 동일한 사람을 나타낸다고 볼 수 있습니다. 이러한 값 기반 비교를 위해서는 개발자가 직접 == 연산자를 재정의하여 '같다'의 기준을 명시해주어야 합니다.

기본 자료형들(int, double, String, bool)은 이미 값 동등성을 기준으로 동작하도록 구현되어 있습니다. 우리가 직관적으로 기대하는 바와 같습니다.


void main() {
  print(5 == 5); // true
  print('hello' == 'hello'); // true

  var s1 = 'world';
  var s2 = 'wo' + 'rld'; // 컴파일 시점에 'world'로 결정됨
  print(s1 == s2); // true
}

우리가 직접 만든 클래스에서도 이러한 직관적인 비교가 가능하게 하려면, 이제부터 다룰 == 연산자 재정의와 `hashCode`의 계약을 이해해야 합니다.

2장: 값 동등성 구현 - `==`와 `hashCode`의 계약

사용자 정의 클래스가 값 동등성을 갖도록 하려면, Object 클래스로부터 상속받은 == 연산자와 hashCode getter를 함께 재정의해야 합니다. 이 둘은 마치 동전의 양면과 같아서, 하나를 재정의하면 반드시 다른 하나도 일관성 있게 재정의해야만 합니다. 이를 '==와 hashCode의 계약'이라고 부릅니다.

`==` 연산자 재정의하기

== 연산자는 Object 타입의 매개변수 하나를 받고 bool 값을 반환하는 형태를 가집니다. 값 비교를 위한 재정의는 보통 다음의 단계를 따릅니다.

  1. 참조 확인 (최적화): 비교 대상(other)이 자기 자신(this)과 동일한 인스턴스인지 identical()로 먼저 확인합니다. 만약 같다면, 더 비교할 필요 없이 즉시 true를 반환할 수 있습니다. 이는 비용이 큰 필드 비교를 건너뛰는 효과적인 최적화입니다.
  2. 타입 확인: 비교 대상이 현재 클래스와 호환되는 타입인지 확인합니다. other is Person 과 같은 타입 체크를 통해, 런타임에 엉뚱한 타입의 객체와 비교하여 오류가 발생하는 것을 방지합니다.
  3. 필드 비교: 타입이 일치한다면, 객체의 '값'을 결정하는 핵심 필드들이 모두 동일한지 하나씩 비교합니다. 모든 필드가 같다면 true, 하나라도 다르면 false를 반환합니다.

앞서 사용한 Person 클래스에 == 연산자를 재정의해 보겠습니다.


class Person {
  final String name;
  final int age;

  Person(this.name, this.age);

  @override
  bool operator ==(Object other) {
    // 1. 참조 확인 (최적화)
    if (identical(this, other)) return true;

    // 2. 타입 확인 및 3. 필드 비교
    return other is Person &&
           other.name == name &&
           other.age == age;
  }
}

void main() {
  var p1 = Person('Alice', 25);
  var p2 = Person('Alice', 25);
  var p3 = Person('Bob', 30);

  print(p1 == p2); // true: 이제 값 동등성 비교를 하므로 true가 출력됩니다.
  print(p1 == p3); // false
}

이제 p1p2는 서로 다른 인스턴스임에도 불구하고, 재정의된 == 연산자에 따라 내부 값(nameage)이 동일하므로 true를 반환합니다.

`hashCode`의 중요한 역할과 계약

hashCode는 객체를 정수 값으로 표현한 것입니다. 이 값은 주로 해시 기반의 자료구조, 즉 HashMap, HashSet (Dart에서는 Map, Set)에서 객체를 효율적으로 저장하고 검색하는 데 사용됩니다. Map의 키나 Set의 원소로 객체를 사용할 때, Dart는 먼저 `hashCode`를 비교하여 검색 범위를 좁힌 후, `hashCode`가 같은 객체들 사이에서만 == 연산자로 실제 동등성을 확인합니다.

이 때문에 `==`와 `hashCode` 사이에는 반드시 지켜야 할 엄격한 계약이 존재합니다.

`==`와 `hashCode`의 계약
1. a == btrue이면, a.hashCodeb.hashCode는 반드시 같아야 한다.
2. a.hashCodeb.hashCode가 다르면, a == b는 반드시 false여야 한다.
3. a.hashCodeb.hashCode가 같더라도, a == btrue가 아닐 수도 있다 (이를 해시 충돌(hash collision)이라 한다).

만약 ==만 재정의하고 hashCode를 재정의하지 않으면 어떻게 될까요? 기본 hashCode는 객체의 메모리 주소를 기반으로 생성되므로, 내용이 같은 서로 다른 객체는 다른 `hashCode`를 갖게 됩니다. 이는 계약의 1번 규칙을 위반하며, Set이나 Map에서 객체를 제대로 찾지 못하는 심각한 문제를 야기합니다.


// hashCode를 재정의하지 않은 Person 클래스
class Person {
  final String name;
  final int age;
  Person(this.name, this.age);

  @override
  bool operator ==(Object other) {
    return other is Person && other.name == name && other.age == age;
  }

  // hashCode는 재정의하지 않음 (기본 동작 사용)
}

void main() {
  var p1 = Person('Alice', 25);
  var p2 = Person('Alice', 25);

  print(p1 == p2); // true (==는 올바르게 동작)

  var people = <Person>{}; // Set 생성
  people.add(p1);

  // p2는 p1과 값은 같지만 hashCode가 다르므로 Set 안에서 찾을 수 없습니다.
  print(people.contains(p2)); // false (치명적인 버그!)
}

이 문제를 해결하려면 `hashCode`를 `==` 비교에 사용된 필드들을 조합하여 생성해야 합니다. 이렇게 하면 내용이 같은 객체는 항상 같은 `hashCode`를 반환하게 되어 계약을 만족시킬 수 있습니다.

`hashCode` 구현 방법

여러 필드의 해시 코드를 조합하는 간단한 방법은 비트 연산자인 XOR(^)를 사용하는 것입니다. 하지만 더 안정적이고 권장되는 방법은 Object.hash() 헬퍼 함수를 사용하는 것입니다.


import 'package:meta/meta.dart'; // @immutable 어노테이션을 위해

@immutable
class Person {
  final String name;
  final int age;

  const Person(this.name, this.age); // 불변 객체는 const 생성자를 가질 수 있습니다.

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    
    // 상속 관계에서 대칭성을 보장하기 위해 runtimeType을 비교하는 것이 더 안전합니다.
    return other is Person &&
           other.runtimeType == runtimeType &&
           other.name == name &&
           other.age == age;
  }

  @override
  int get hashCode {
    // == 비교에 사용된 모든 필드를 Object.hash()에 전달합니다.
    return Object.hash(name, age);
  }
}

void main() {
  var p1 = Person('Alice', 25);
  var p2 = Person('Alice', 25);

  var people = {p1}; // Set에 p1 추가
  print(people.contains(p2)); // true (이제 올바르게 동작합니다!)
}

또한, 객체의 `hashCode`는 객체가 살아있는 동안 변하지 않아야 합니다. 따라서 `hashCode`를 구현하는 객체의 필드는 가급적 final로 선언하여 불변(immutable) 객체로 만드는 것이 좋습니다. @immutable 어노테이션은 이를 강제하는 데 도움을 줍니다.

3장: Flutter에서의 실용적 활용과 성능 최적화

Flutter는 선언적 UI 프레임워크로, 상태(state)가 변경되면 UI를 다시 빌드(build)하는 방식으로 동작합니다. 이때 Flutter 프레임워크는 이전 위젯 트리와 새 위젯 트리를 비교하여 변경된 부분만 효율적으로 갱신하려고 시도합니다. 이 비교 과정에서 객체의 동등성 개념이 핵심적인 역할을 합니다.

상태 관리와 위젯 리빌드

Provider, BLoC, Riverpod과 같은 대부분의 상태 관리 솔루션은 상태 객체의 변화를 감지하여 UI에 알립니다. 상태 변화 감지는 주로 이전 상태 객체와 새 상태 객체를 == 연산자로 비교하여 이루어집니다.

만약 상태를 담는 클래스가 값 동등성을 제대로 구현하지 않았다면 어떤 일이 벌어질까요?


// UserState 클래스 (==, hashCode 미구현)
class UserState {
  final String name;
  final bool isLoading;
  UserState(this.name, this.isLoading);
}

// ... BLoC 또는 Provider 로직 ...
void login() {
  // 로그인 시작
  emit(UserState('Guest', true)); 
  // ... 로그인 성공 후 ...
  // 로딩 상태만 false로 변경했지만, 새로운 UserState 인스턴스가 생성됨
  emit(UserState('Guest', false)); 
}

위 코드에서 emit 함수는 새로운 상태를 UI에 전달합니다. UI 레이어에서는 BlocBuilderConsumer 같은 위젯을 사용하여 상태 변화를 수신합니다. 이때 위젯은 `oldState == newState`를 확인하여 리빌드 여부를 결정할 수 있습니다. 만약 UserState==를 재정의하지 않았다면, 로그인 전후의 상태 객체는 항상 다른 것(false)으로 간주되어 불필요한 리빌드를 유발할 수 있습니다. 반대로, 상태 객체를 불변으로 만들지 않고 내부 필드만 변경한다면(`userState.isLoading = false;`), 상태 객체의 참조는 그대로이므로 `oldState == newState`가 `true`가 되어 정작 필요할 때 UI가 갱신되지 않는 버그가 발생합니다.

따라서 Flutter 상태 객체는 불변(immutable)으로 만들고, 값 동등성(`==`과 `hashCode`)을 반드시 구현하는 것이 모범 사례입니다. 이를 통해 상태 변화를 명확하고 예측 가능하게 만들고, 프레임워크가 변화를 정확히 감지하여 최적의 성능으로 UI를 갱신하도록 도울 수 있습니다.

`const` 생성자와 성능

위젯의 생성자 앞에 const 키워드를 붙이면, 컴파일 시점에 해당 위젯의 인스턴스가 생성됩니다. 같은 const 생성자를 같은 인자들로 여러 번 호출하더라도, Dart는 매번 새로운 객체를 만드는 대신 이전에 만들어 둔 동일한 인스턴스(canonical instance)를 재사용합니다.


// const 생성자를 사용
var padding1 = const EdgeInsets.all(8.0);
var padding2 = const EdgeInsets.all(8.0);

print(identical(padding1, padding2)); // true!

// const를 사용하지 않음
var padding3 = EdgeInsets.all(8.0);
var padding4 = EdgeInsets.all(8.0);

print(identical(padding3, padding4)); // false

Flutter는 위젯 트리를 비교할 때, 두 위젯이 identical() 하다면 더 이상 깊이 비교하지 않고 해당 서브트리의 리빌드를 건너뛰는 최적화를 수행합니다. 따라서 정적인 위젯에 const를 적극적으로 사용하면, 불필요한 위젯 인스턴스 생성을 막고 리빌드 성능을 크게 향상시킬 수 있습니다. 이는 동등성 비교의 가장 강력한 형태인 참조 동등성을 활용한 최고의 최적화 기법 중 하나입니다.

4장: 동등성 구현 간소화 - 패키지와 코드 생성

지금까지 살펴본 것처럼 ==와 `hashCode`를 직접 구현하는 것은 가능하지만, 필드가 많아질수록 코드가 길어지고 실수가 발생하기 쉽습니다. 다행히 이 지루하고 반복적인 작업을 자동화해주는 훌륭한 도구들이 있습니다.

1. `equatable` 패키지

equatable은 간단한 방법으로 값 동등성을 구현할 수 있도록 도와주는 패키지입니다. `Equatable` 클래스를 상속받고, 동등성 비교에 사용할 필드들을 `props` 리스트에 포함시키기만 하면 됩니다.


import 'package:equatable/equatable.dart';

class Person extends Equatable {
  final String name;
  final int age;

  const Person(this.name, this.age);

  @override
  List<Object> get props => [name, age]; // 비교할 필드들을 리스트에 담아 반환
}

void main() {
  var p1 = const Person('Alice', 25);
  var p2 = const Person('Alice', 25);

  print(p1 == p2); // true
  print(p1.hashCode == p2.hashCode); // true
}

equatable은 내부적으로 `props` 리스트의 항목들을 기반으로 ==와 `hashCode`를 동적으로 구현해줍니다. 코드가 매우 간결해지고 개발자의 실수를 줄여준다는 큰 장점이 있습니다.

2. 코드 생성: `freezed`

freezed는 한 단계 더 나아가, 동등성 구현뿐만 아니라 불변 객체에 필요한 거의 모든 보일러플레이트 코드를 컴파일 시점에 자동으로 생성해주는 코드 제너레이션 패키지입니다. ==와 `hashCode`는 물론, toString(), 객체 복사를 위한 copyWith() 메서드, JSON 직렬화를 위한 fromJson/toJson까지 생성할 수 있습니다.


// person.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'person.freezed.dart'; // freezed가 생성할 파일
part 'person.g.dart';      // json_serializable이 생성할 파일

@freezed
class Person with _$Person {
  const factory Person({
    required String name,
    required int age,
  }) = _Person;

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
}

// 터미널에서 `flutter pub run build_runner build` 실행

위 코드와 빌드 명령을 실행하면, person.freezed.dart 파일에 `==`, `hashCode` 등이 완벽하게 구현된 클래스가 자동으로 생성됩니다. freezed는 타입 안정성이 높고, Sum-Types(봉인 클래스)와 같은 고급 패턴도 지원하여 복잡한 상태 모델을 표현하는 데 매우 강력합니다. 초기 설정이 `equatable`보다 복잡하지만, 대규모 애플리케이션에서는 생산성과 안정성을 크게 높여줍니다.

3. Dart 3의 레코드(Records)

Dart 3부터 도입된 레코드는 익명의 불변 복합 데이터를 생성하는 새로운 방법입니다. 레코드는 별도의 클래스 선언 없이 여러 값을 하나로 묶을 수 있으며, 기본적으로 값 동등성을 지원합니다.


void main() {
  var record1 = ('Alice', age: 25);
  var record2 = ('Alice', age: 25);

  print(record1 == record2); // true

  var record3 = (name: 'Alice', age: 25); // 필드 순서와 이름이 모두 타입의 일부
  // print(record1 == record3); // 컴파일 에러: 타입이 다름
  
  var set = {record1};
  print(set.contains(record2)); // true
}

레코드는 메서드를 가질 수 없고 이름도 없기 때문에, 클래스를 대체하는 용도는 아닙니다. 하지만 함수에서 여러 값을 반환하거나, 간단한 데이터 묶음을 임시로 사용해야 할 때 클래스를 정의하는 번거로움 없이 값 동등성이 보장되는 데이터를 편리하게 사용할 수 있습니다.

5장: 고급 시나리오와 주의점

컬렉션 비교

List, Map, Set과 같은 Dart의 기본 컬렉션들도 클래스이므로, 기본 `==` 연산자는 참조 동등성을 비교합니다.


var list1 = [1, 2, 3];
var list2 = [1, 2, 3];
print(list1 == list2); // false

컬렉션의 내용물을 비교하려면, `package:collection` 패키지에서 제공하는 `DeepCollectionEquality`를 사용할 수 있습니다.


import 'package:collection/collection.dart';

void main() {
  var list1 = [1, 2, 3];
  var list2 = [1, 2, 3];
  
  print(const ListEquality().equals(list1, list2)); // true

  var map1 = {'a': 1, 'b': [4, 5]};
  var map2 = {'a': 1, 'b': [4, 5]};
  
  // 중첩된 컬렉션까지 재귀적으로 비교
  print(const DeepCollectionEquality().equals(map1, map2)); // true
}

부동 소수점 비교

double 타입을 ==로 직접 비교하는 것은 부동 소수점 연산의 정밀도 한계 때문에 위험할 수 있습니다. 눈에는 보이지 않는 아주 작은 오차가 발생하여 예상과 다른 결과를 낳을 수 있습니다. 따라서 두 double 값을 비교할 때는 아주 작은 허용 오차(epsilon) 범위 내에 있는지 확인하는 것이 안전합니다.


void main() {
  double a = 0.1 + 0.2; // 0.30000000000000004
  double b = 0.3;       // 0.3
  
  print(a == b); // false
  
  const epsilon = 0.00001;
  print((a - b).abs() < epsilon); // true
}

결론

Dart에서의 동등성 처리는 단순한 연산자 하나에 대한 이야기가 아닙니다. 그것은 객체의 본질을 어떻게 정의하고, 프로그램이 상태 변화를 어떻게 인지하며, 프레임워크가 어떻게 최적화를 수행하는지에 대한 근본적인 이해와 맞닿아 있습니다. 참조 동등성(`identical`)값 동등성(`==` 재정의)의 차이를 명확히 인지하고, `==`와 `hashCode`의 계약을 철저히 지키는 것은 견고하고 예측 가능한 Dart 및 Flutter 애플리케이션을 만드는 데 필수적인 기본기입니다.

수동 구현을 통해 그 원리를 이해하는 것도 중요하지만, 실제 프로젝트에서는 `equatable`이나 `freezed`와 같은 검증된 도구를 활용하여 보일러플레이트를 줄이고 인간의 실수를 방지하는 것이 현명한 선택입니다. 이러한 도구들은 단순히 코드를 줄여주는 것을 넘어, 불변성과 값 동등성이라는 중요한 설계 원칙을 코드에 자연스럽게 녹여낼 수 있도록 도와줍니다. 오늘 다룬 원칙들을 바탕으로 여러분의 코드베이스를 다시 한번 점검하고, 객체들이 올바르게 '자신을 말하고' 있는지 확인해 보시기 바랍니다.


0 개의 댓글:

Post a Comment