개발

Flutter/Dart 클래스와 생성자 완벽 가이드

실무에서 바로 써먹는 클래스 설계부터 생성자 패턴까지

2025년 9월 18일
25분 읽기
Flutter/Dart 클래스와 생성자 완벽 가이드

왜 클래스를 제대로 알아야 할까?

Flutter 개발에서 클래스는 모든 것의 기본입니다. Widget도 클래스, Repository도 클래스, 심지어 앱 전체도 클래스입니다.

클래스를 제대로 이해하지 못하면:
• 코드가 복잡해져도 정리할 수 없습니다
• 다른 사람 코드를 읽기 어렵습니다
• 버그가 생겨도 원인을 찾기 힘듭니다
• 확장이나 수정이 어려워집니다

이 가이드에서는 실무에서 자주 사용하는 패턴들을 중심으로 설명합니다.

기본 클래스 문법

가장 기본적인 클래스부터 시작해봅시다.

1. 간단한 클래스

Dart
// 사용자 정보를 담는 클래스
class User {
  String name;
  int age;
  
  // 기본 생성자
  User(this.name, this.age);
  
  // 메소드
  void sayHello() {
    print('안녕하세요, 저는 $name이고 ${age}살입니다.');
  }
}

// 사용법
void main() {
  final user = User('김철수', 25);
  user.sayHello(); // 안녕하세요, 저는 김철수이고 25살입니다.
}

2. 필드 초기화의 다양한 방법

Dart
class Product {
  String name;
  double price;
  bool isAvailable;
  
  // 방법 1: 기본 생성자 (권장)
  Product(this.name, this.price, this.isAvailable);
  
  // 방법 2: 전통적인 방식
  Product.traditional(String name, double price, bool isAvailable) {
    this.name = name;
    this.price = price;
    this.isAvailable = isAvailable;
  }
  
  // 방법 3: 초기화 리스트 사용
  Product.withDefaults(this.name, this.price) 
    : isAvailable = true;  // 기본값 설정
}

생성자의 종류와 활용

Dart는 다양한 생성자 패턴을 지원합니다. 각각의 특징과 언제 사용하는지 알아봅시다.

1. 기본 생성자 (Default Constructor)

Dart
class Rectangle {
  double width;
  double height;
  
  // 가장 기본적인 형태
  Rectangle(this.width, this.height);
  
  double get area => width * height;
}

// 사용
final rect = Rectangle(10.0, 5.0);
print(rect.area); // 50.0

2. 명명된 생성자 (Named Constructor)

Dart
class User {
  String name;
  String email;
  int age;
  
  // 기본 생성자
  User(this.name, this.email, this.age);
  
  // 명명된 생성자 - 관리자 계정용
  User.admin(String name, String email) 
    : this.name = name,
      this.email = email,
      this.age = 0;  // 관리자는 나이 비공개
  
  // 명명된 생성자 - 게스트 계정용  
  User.guest() 
    : this.name = 'Guest',
      this.email = 'guest@example.com',
      this.age = 0;
  
  // 명명된 생성자 - JSON에서 생성
  User.fromJson(Map<String, dynamic> json)
    : name = json['name'],
      email = json['email'],
      age = json['age'];
      
  @override
  String toString() => 'User(name: $name, email: $email, age: $age)';
}

// 사용법 비교
void main() {
  // 일반 사용자
  final user1 = User('김철수', 'kim@example.com', 25);
  
  // 관리자
  final admin = User.admin('관리자', 'admin@example.com');
  
  // 게스트
  final guest = User.guest();
  
  // JSON에서 생성
  final user2 = User.fromJson({
    'name': '이영희',
    'email': 'lee@example.com',
    'age': 30,
  });
  
  print(user1);  // User(name: 김철수, email: kim@example.com, age: 25)
  print(admin);  // User(name: 관리자, email: admin@example.com, age: 0)
  print(guest);  // User(name: Guest, email: guest@example.com, age: 0)
  print(user2);  // User(name: 이영희, email: lee@example.com, age: 30)
}

3. 팩토리 생성자 (Factory Constructor)

팩토리 생성자는 특별한 생성자입니다. 항상 새 인스턴스를 만들지 않고, 기존 인스턴스를 반환하거나 조건에 따라 다른 타입을 반환할 수 있습니다.

싱글톤 패턴 구현

Dart
class DatabaseConnection {
  static DatabaseConnection? _instance;
  
  // private 생성자
  DatabaseConnection._internal();
  
  // 팩토리 생성자 - 싱글톤 보장
  factory DatabaseConnection() {
    _instance ??= DatabaseConnection._internal();
    return _instance!;
  }
  
  void connect() {
    print('데이터베이스에 연결됨');
  }
}

// 사용
void main() {
  final db1 = DatabaseConnection();
  final db2 = DatabaseConnection();
  
  print(identical(db1, db2)); // true - 같은 인스턴스!
  
  db1.connect(); // 데이터베이스에 연결됨
}

조건부 인스턴스 생성

Dart
abstract class Animal {
  String name;
  Animal(this.name);
  void makeSound();
}

class Dog extends Animal {
  Dog(String name) : super(name);
  
  @override
  void makeSound() => print('$name: 멍멍!');
}

class Cat extends Animal {
  Cat(String name) : super(name);
  
  @override
  void makeSound() => print('$name: 야옹!');
}

class AnimalFactory {
  // 팩토리 메소드 - 타입에 따라 다른 인스턴스 반환
  static Animal create(String type, String name) {
    switch (type.toLowerCase()) {
      case 'dog':
        return Dog(name);
      case 'cat':
        return Cat(name);
      default:
        throw ArgumentError('알 수 없는 동물 타입: $type');
    }
  }
}

// 사용
void main() {
  final animals = [
    AnimalFactory.create('dog', '바둑이'),
    AnimalFactory.create('cat', '나비'),
  ];
  
  for (final animal in animals) {
    animal.makeSound();
  }
  // 출력:
  // 바둑이: 멍멍!
  // 나비: 야옹!
}

4. 선택적 매개변수와 기본값

Dart에서는 매개변수를 선택적으로 만들고 기본값을 설정할 수 있습니다.

위치 기반 선택적 매개변수

Dart
class Coffee {
  String type;
  String size;
  bool hasWhip;
  
  // 대괄호 [] 안은 선택적 매개변수
  Coffee(this.type, [this.size = 'medium', this.hasWhip = false]);
  
  @override
  String toString() => '$size $type' + (hasWhip ? ' with whip' : '');
}

// 사용법
void main() {
  final coffee1 = Coffee('라떼');                    // medium 라떼
  final coffee2 = Coffee('아메리카노', 'large');        // large 아메리카노  
  final coffee3 = Coffee('카푸치노', 'small', true);   // small 카푸치노 with whip
  
  print(coffee1); // medium 라떼
  print(coffee2); // large 아메리카노
  print(coffee3); // small 카푸치노 with whip
}

명명된 매개변수 (가장 권장하는 방법)

Dart
class ApiRequest {
  String url;
  String method;
  Map<String, String>? headers;
  Duration? timeout;
  int? retryCount;
  
  ApiRequest({
    required this.url,                    // 필수 매개변수
    this.method = 'GET',                 // 기본값 있는 선택적 매개변수
    this.headers,                         // null 허용 선택적 매개변수
    this.timeout = const Duration(seconds: 30),
    this.retryCount = 3,
  });
  
  @override
  String toString() {
    return 'ApiRequest(url: $url, method: $method, timeout: $timeout)';
  }
}

// 사용법 - 매개변수 이름을 명시하므로 가독성이 좋음
void main() {
  // 최소한의 매개변수만
  final request1 = ApiRequest(url: 'https://api.example.com/users');
  
  // 일부 매개변수 지정
  final request2 = ApiRequest(
    url: 'https://api.example.com/posts',
    method: 'POST',
    timeout: Duration(seconds: 60),
  );
  
  // 순서는 상관없음!
  final request3 = ApiRequest(
    timeout: Duration(minutes: 5),
    url: 'https://api.example.com/data',
    retryCount: 1,
    method: 'PUT',
  );
  
  print(request1);
  print(request2);
  print(request3);
}

초기화 리스트 (Initializer List)

초기화 리스트는 생성자 본문이 실행되기 전에 필드를 초기화하는 방법입니다. 특히 final 필드나 복잡한 초기화가 필요할 때 유용합니다.

기본 사용법

Dart
class Circle {
  final double radius;
  final double area;
  final double circumference;
  
  // 초기화 리스트에서 계산된 값으로 final 필드 초기화
  Circle(double radius)
    : this.radius = radius,
      area = 3.14159 * radius * radius,
      circumference = 2 * 3.14159 * radius;
  
  @override
  String toString() {
    return 'Circle(radius: $radius, area: ${area.toStringAsFixed(2)}, circumference: ${circumference.toStringAsFixed(2)})';
  }
}

// 사용
void main() {
  final circle = Circle(5.0);
  print(circle); // Circle(radius: 5.0, area: 78.54, circumference: 31.42)
}

복잡한 초기화 로직

Dart
class BankAccount {
  final String accountNumber;
  final String ownerName;
  final double initialBalance;
  final DateTime createdAt;
  final String accountType;
  
  BankAccount({
    required this.ownerName,
    required this.initialBalance,
  }) : accountNumber = _generateAccountNumber(),
       createdAt = DateTime.now(),
       accountType = _determineAccountType(initialBalance) {
    // 생성자 본문은 초기화 리스트 다음에 실행됨
    print('계좌가 생성되었습니다: $accountNumber');
    _validateAccount();
  }
  
  // private static 메소드들
  static String _generateAccountNumber() {
    return 'ACC-${DateTime.now().millisecondsSinceEpoch}';
  }
  
  static String _determineAccountType(double balance) {
    if (balance >= 1000000) return 'Premium';
    if (balance >= 100000) return 'Standard';
    return 'Basic';
  }
  
  void _validateAccount() {
    if (initialBalance < 0) {
      throw ArgumentError('초기 잔액은 음수일 수 없습니다');
    }
    print('계좌 검증 완료');
  }
  
  @override
  String toString() {
    return 'BankAccount(number: $accountNumber, owner: $ownerName, type: $accountType, balance: $initialBalance)';
  }
}

// 사용
void main() {
  final account = BankAccount(
    ownerName: '김철수',
    initialBalance: 150000,
  );
  print(account);
  
  // 출력:
  // 계좌가 생성되었습니다: ACC-1703123456789
  // 계좌 검증 완료
  // BankAccount(number: ACC-1703123456789, owner: 김철수, type: Standard, balance: 150000.0)
}

실무에서 자주 사용하는 패턴들

실제 Flutter 개발에서 자주 만나는 클래스 패턴들을 알아봅시다.

1. 상태 클래스 (State Class)

Dart
// 로딩, 성공, 에러 상태를 나타내는 클래스
abstract class NetworkState {}

class LoadingState extends NetworkState {
  final String message;
  LoadingState([this.message = '로딩 중...']);
}

class SuccessState<T> extends NetworkState {
  final T data;
  SuccessState(this.data);
}

class ErrorState extends NetworkState {
  final String message;
  final Exception? exception;
  
  ErrorState(this.message, [this.exception]);
}

// 사용법
class UserRepository {
  Future<NetworkState> getUser(String id) async {
    try {
      // 로딩 상태 반환
      yield LoadingState('사용자 정보 조회 중...');
      
      // API 호출 시뮬레이션
      await Future.delayed(Duration(seconds: 2));
      
      // 성공 상태 반환
      final user = {'id': id, 'name': '김철수', 'email': 'kim@example.com'};
      return SuccessState(user);
      
    } catch (e) {
      // 에러 상태 반환
      return ErrorState('사용자 조회 실패', e as Exception);
    }
  }
}

2. 빌더 패턴 (Builder Pattern)

Dart
class HttpRequestBuilder {
  String? _url;
  String _method = 'GET';
  Map<String, String> _headers = {};
  String? _body;
  Duration _timeout = Duration(seconds: 30);
  
  HttpRequestBuilder url(String url) {
    _url = url;
    return this;
  }
  
  HttpRequestBuilder method(String method) {
    _method = method;
    return this;
  }
  
  HttpRequestBuilder header(String key, String value) {
    _headers[key] = value;
    return this;
  }
  
  HttpRequestBuilder body(String body) {
    _body = body;
    return this;
  }
  
  HttpRequestBuilder timeout(Duration timeout) {
    _timeout = timeout;
    return this;
  }
  
  HttpRequest build() {
    if (_url == null) {
      throw StateError('URL은 필수입니다');
    }
    
    return HttpRequest._(
      url: _url!,
      method: _method,
      headers: Map.unmodifiable(_headers),
      body: _body,
      timeout: _timeout,
    );
  }
}

class HttpRequest {
  final String url;
  final String method;
  final Map<String, String> headers;
  final String? body;
  final Duration timeout;
  
  // private 생성자
  HttpRequest._({
    required this.url,
    required this.method,
    required this.headers,
    this.body,
    required this.timeout,
  });
  
  // 빌더 인스턴스 반환
  static HttpRequestBuilder builder() => HttpRequestBuilder();
  
  @override
  String toString() {
    return 'HttpRequest(method: $method, url: $url, headers: $headers)';
  }
}

// 사용법 - 메소드 체이닝으로 깔끔하게
void main() {
  final request = HttpRequest.builder()
      .url('https://api.example.com/users')
      .method('POST')
      .header('Content-Type', 'application/json')
      .header('Authorization', 'Bearer token123')
      .body('{"name": "김철수", "email": "kim@example.com"}')
      .timeout(Duration(minutes: 1))
      .build();
      
  print(request);
}

3. 데이터 클래스 (Data Class) with Freezed

실무에서는 보통 freezed 패키지를 사용하지만, 수동으로 만드는 방법도 알아두면 좋습니다.
Dart
// 수동으로 만든 데이터 클래스
class Product {
  final String id;
  final String name;
  final double price;
  final List<String> tags;
  
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.tags,
  });
  
  // copyWith 메소드 - 일부 필드만 변경한 새 인스턴스 생성
  Product copyWith({
    String? id,
    String? name,
    double? price,
    List<String>? tags,
  }) {
    return Product(
      id: id ?? this.id,
      name: name ?? this.name,
      price: price ?? this.price,
      tags: tags ?? this.tags,
    );
  }
  
  // JSON 변환
  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id: json['id'],
      name: json['name'],
      price: (json['price'] as num).toDouble(),
      tags: List<String>.from(json['tags']),
    );
  }
  
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'price': price,
      'tags': tags,
    };
  }
  
  // 동등성 비교
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other is! Product) return false;
    
    return other.id == id &&
           other.name == name &&
           other.price == price &&
           _listEquals(other.tags, tags);
  }
  
  @override
  int get hashCode {
    return id.hashCode ^
           name.hashCode ^
           price.hashCode ^
           tags.hashCode;
  }
  
  @override
  String toString() {
    return 'Product(id: $id, name: $name, price: $price, tags: $tags)';
  }
  
  // 리스트 비교 헬퍼
  bool _listEquals(List a, List b) {
    if (a.length != b.length) return false;
    for (int i = 0; i < a.length; i++) {
      if (a[i] != b[i]) return false;
    }
    return true;
  }
}

// 사용법
void main() {
  final product1 = Product(
    id: '1',
    name: '맥북 프로',
    price: 2000000,
    tags: ['laptop', 'apple', 'premium'],
  );
  
  // 가격만 변경한 새 인스턴스
  final product2 = product1.copyWith(price: 1800000);
  
  print(product1); // Product(id: 1, name: 맥북 프로, price: 2000000.0, tags: [laptop, apple, premium])
  print(product2); // Product(id: 1, name: 맥북 프로, price: 1800000.0, tags: [laptop, apple, premium])
  
  // JSON 변환
  final json = product1.toJson();
  final product3 = Product.fromJson(json);
  
  print(product1 == product3); // true
}

4. 싱글톤 패턴 (Singleton Pattern)

Dart
class Logger {
  static Logger? _instance;
  static const String _logFile = 'app.log';
  
  // private 생성자
  Logger._internal();
  
  // 싱글톤 인스턴스 반환
  static Logger get instance {
    _instance ??= Logger._internal();
    return _instance!;
  }
  
  // 팩토리 생성자로도 구현 가능
  factory Logger() => instance;
  
  void log(String message) {
    final timestamp = DateTime.now().toIso8601String();
    print('[$timestamp] $message');
    // 실제로는 파일에 저장
  }
  
  void error(String message) {
    log('ERROR: $message');
  }
  
  void info(String message) {
    log('INFO: $message');
  }
}

// 사용법 - 어디서든 같은 인스턴스
void main() {
  final logger1 = Logger.instance;
  final logger2 = Logger();
  final logger3 = Logger.instance;
  
  print(identical(logger1, logger2)); // true
  print(identical(logger2, logger3)); // true
  
  logger1.info('앱이 시작되었습니다');
  logger2.error('오류가 발생했습니다');
  
  // 출력:
  // [2023-12-01T10:30:45.123] INFO: 앱이 시작되었습니다
  // [2023-12-01T10:30:45.124] ERROR: 오류가 발생했습니다
}

실무 팁과 모범 사례

1. 생성자 선택 가이드

언제 어떤 생성자를 사용할까?

기본 생성자: 간단한 클래스, 모든 필드가 필수일 때
명명된 매개변수: 매개변수가 3개 이상이거나 의미가 명확해야 할 때 (권장)
명명된 생성자: 같은 클래스의 다른 형태 인스턴스가 필요할 때
팩토리 생성자: 싱글톤, 캐싱, 조건부 인스턴스 생성이 필요할 때

2. 필드 초기화 모범 사례

Dart
class ApiService {
  // ✅ final 필드 - 불변성 보장
  final String baseUrl;
  final Duration timeout;
  
  // ✅ late 필드 - 나중에 초기화 (주의해서 사용)
  late final HttpClient _client;
  
  // ✅ nullable 필드 - 선택적 데이터
  String? authToken;
  
  ApiService({
    required this.baseUrl,
    this.timeout = const Duration(seconds: 30),
    this.authToken,
  }) {
    // 생성자 본문에서 late 필드 초기화
    _client = HttpClient()..connectionTimeout = timeout;
  }
  
  // ✅ getter로 계산된 값 제공
  String get isAuthenticated => authToken != null;
  
  // ✅ 메소드는 명확한 이름으로
  Future<Map<String, dynamic>> get(String endpoint) async {
    final url = '$baseUrl/$endpoint';
    // API 호출 로직
    return {};
  }
}

3. 성능 최적화 팁

Dart
class OptimizedClass {
  // ✅ const 생성자 - 컴파일 타임 상수
  const OptimizedClass.constant({
    required this.name,
    required this.value,
  });
  
  // ✅ 캐시된 계산 값
  static final Map<String, ExpensiveCalculation> _cache = {};
  
  factory OptimizedClass.withExpensiveCalculation(String key) {
    return _cache.putIfAbsent(key, () {
      // 비싼 계산을 한 번만 수행
      return ExpensiveCalculation._internal(key);
    });
  }
  
  final String name;
  final int value;
  
  // ✅ lazy getter - 필요할 때만 계산
  String? _formattedName;
  String get formattedName {
    _formattedName ??= _formatName(name);
    return _formattedName!;
  }
  
  String _formatName(String name) {
    // 복잡한 포맷팅 로직
    return name.toUpperCase();
  }
}

class ExpensiveCalculation {
  final String key;
  ExpensiveCalculation._internal(this.key);
}

4. 에러 처리와 검증

Dart
class BankTransfer {
  final String fromAccount;
  final String toAccount;
  final double amount;
  final DateTime timestamp;
  
  BankTransfer({
    required this.fromAccount,
    required this.toAccount,
    required this.amount,
  }) : timestamp = DateTime.now() {
    // 생성자에서 검증 수행
    _validate();
  }
  
  void _validate() {
    if (fromAccount.isEmpty) {
      throw ArgumentError('출금 계좌가 비어있습니다');
    }
    
    if (toAccount.isEmpty) {
      throw ArgumentError('입금 계좌가 비어있습니다');
    }
    
    if (fromAccount == toAccount) {
      throw ArgumentError('출금 계좌와 입금 계좌가 같을 수 없습니다');
    }
    
    if (amount <= 0) {
      throw ArgumentError('이체 금액은 0보다 커야 합니다');
    }
    
    if (amount > 10000000) {
      throw ArgumentError('이체 금액이 한도를 초과했습니다');
    }
  }
  
  @override
  String toString() {
    return 'BankTransfer(from: $fromAccount, to: $toAccount, amount: $amount, time: $timestamp)';
  }
}

// 사용법 - 검증 실패 시 예외 발생
void main() {
  try {
    final transfer = BankTransfer(
      fromAccount: '123-456-789',
      toAccount: '987-654-321',
      amount: 50000,
    );
    print(transfer);
  } catch (e) {
    print('이체 생성 실패: $e');
  }
}

정리

Dart의 클래스와 생성자를 제대로 활용하면 견고하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

핵심 포인트:

명명된 매개변수를 적극 활용하세요 (가독성 향상)
final 필드로 불변성을 보장하세요
팩토리 생성자로 복잡한 인스턴스 생성을 캡슐화하세요
초기화 리스트로 final 필드를 효율적으로 초기화하세요
생성자에서 검증을 수행해 잘못된 상태 방지하세요

실무에서 기억할 점:

• 코드는 다른 사람이 읽기 쉽게 작성하세요
• 에러는 가능한 한 빨리 발견하고 명확한 메시지를 제공하세요
• 성능이 중요한 부분에서는 캐싱과 lazy loading을 활용하세요
• 불변 객체를 선호하고, 필요시에만 가변 객체를 사용하세요

이런 패턴들을 익혀두면 Flutter 개발뿐만 아니라 다른 객체지향 언어에서도 응용할 수 있습니다.
#Flutter
#Dart
#Class
#Constructor
#OOP