왜 클래스를 제대로 알아야 할까?
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개 이상이거나 의미가 명확해야 할 때 (권장)
• 명명된 생성자: 같은 클래스의 다른 형태 인스턴스가 필요할 때
• 팩토리 생성자: 싱글톤, 캐싱, 조건부 인스턴스 생성이 필요할 때
• 기본 생성자: 간단한 클래스, 모든 필드가 필수일 때
• 명명된 매개변수: 매개변수가 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 개발뿐만 아니라 다른 객체지향 언어에서도 응용할 수 있습니다.
핵심 포인트:
• 명명된 매개변수를 적극 활용하세요 (가독성 향상)
• final 필드로 불변성을 보장하세요
• 팩토리 생성자로 복잡한 인스턴스 생성을 캡슐화하세요
• 초기화 리스트로 final 필드를 효율적으로 초기화하세요
• 생성자에서 검증을 수행해 잘못된 상태 방지하세요
실무에서 기억할 점:
• 코드는 다른 사람이 읽기 쉽게 작성하세요
• 에러는 가능한 한 빨리 발견하고 명확한 메시지를 제공하세요
• 성능이 중요한 부분에서는 캐싱과 lazy loading을 활용하세요
• 불변 객체를 선호하고, 필요시에만 가변 객체를 사용하세요
이런 패턴들을 익혀두면 Flutter 개발뿐만 아니라 다른 객체지향 언어에서도 응용할 수 있습니다.