Dart 객체지향 프로그래밍

이 문서 주변 탐색

주제 태그, 링크, 시리즈 흐름을 중심으로 옆으로 이동할 수 있습니다.

시리즈 흐름

이 문서는 아직 읽기 시리즈에 연결되지 않았습니다.

관련 문서

같이 읽을 만한 관련 문서가 아직 없습니다.

이 문서를 참조하는 문서

이 문서를 참조하는 다른 문서가 아직 없습니다.

1. 클래스와 인스턴스

클래스란?

클래스는 설계도고, 인스턴스는 그 설계도로 만든 실제 객체

void main() {
  // Person 클래스로 morymory라는 인스턴스 생성
  Person morymory = Person('mory', ['친구1', '친구2']);

  morymory.sayHello();  // Hello~
  morymory.introduce(); // 헬로우 mory
}

class Person {
  String name;
  List<String> friends;

  // 생성자
  Person(String name, List<String> friends)
      : this.name = name,
        this.friends = friends;

  void sayHello() {
    print('Hello~');
  }

  void introduce() {
    print('헬로우 ${this.name}');
  }
}
  • 클래스 내부의 함수는 "메서드"라고 부름 - 함수랑 메서드는 기능적으로 같은데, 메서드는 특정 객체에 속해있어서 this로 자기 자신 데이터에 접근할 수 있음

  • this 키워드는 "이 인스턴스 자기 자신"을 가리킴. 메서드 안에서 this.name은 "이 객체의 name 속성"이라는 뜻

왜 클래스를 쓸까?

// ❌ 클래스 없이 여러 사람 관리
String person1Name = '모리';
int person1Age = 25;
String person2Name = '철수';
int person2Age = 30;
// 사람이 늘어날수록 변수가 기하급수적으로 증가...

// ✅ 클래스로 깔끔하게 관리
class Person {
  String name;
  int age;
  Person(this.name, this.age);
}

List<Person> people = [
  Person('모리', 25),
  Person('철수', 30),
  Person('영희', 28),
];

2. 생성자 완전 정복

Dart의 생성자는 다른 언어보다 훨씬 유연하고 간결

기본 생성자

class Person {
  String name;
  int age;

  // 가장 기본적인 형태
  Person(String name, int age)
      : this.name = name,
        this.age = age;
}

간소화 문법 (Syntactic Sugar) ⭐

class Person {
  String name;
  int age;

  // 위와 완전히 동일! Dart의 강력한 단축 문법
  Person(this.name, this.age);
}
  • this.name을 파라미터로 쓰면 자동으로 해당 필드에 할당

  • 이건 Dart만의 편의 기능이고, Java나 JavaScript에서는 안 됨

  • 코드량이 절반 이하로 줄어들어서 실무에서 거의 항상 이 방식 씀

Named Parameters (명명된 매개변수)

class Person {
  String name;
  int age;
  String? nickname;  // 선택적

  Person({
    required this.name,
    required this.age,
    this.nickname,
  });
}

void main() {
  // 순서 상관없이 이름으로 전달 - 가독성 향상!
  var person = Person(
    age: 25,
    name: '모리',
    nickname: 'mory',
  );
}

Named Constructor (명명된 생성자)

신기하게 생성자를 여러 개 만들 수 있음. 오버로드 같은 느낌

class Person {
  String name;
  List<String> friends;

  // 기본 생성자
  Person(this.name, this.friends);

  // Named Constructor - 리스트에서 생성
  Person.fromList(List values)
      : name = values[0],
        friends = values[1];

  // Named Constructor - 기본값으로 생성
  Person.anonymous()
      : name = '익명',
        friends = [];

  // Named Constructor - Map에서 생성 (JSON 파싱에 유용!)
  Person.fromJson(Map<String, dynamic> json)
      : name = json['name'],
        friends = List<String>.from(json['friends'] ?? []);
}

void main() {
  // 다양한 방법으로 같은 클래스의 인스턴스 생성
  var p1 = Person('모리', ['친구1']);
  var p2 = Person.fromList(['모링', ['친구A', '친구B']]);
  var p3 = Person.anonymous();
  var p4 = Person.fromJson({'name': '철수', 'friends': ['영희']});
}
  • Named Constructor는 함수 오버로딩의 대안임. Dart는 같은 이름의 함수를 여러 개 못 만들어서 이 방식 씀

  • 실무에서 fromJson, fromMap, empty, copy 같은 이름 자주 씀

  • Flutter에서 API 응답을 모델로 변환할 때 fromJson 생성자가 필수적으로 쓰임

Initializer List (초기화 리스트)

class Rectangle {
  final double width;
  final double height;
  final double area;  // 계산된 값

  Rectangle(this.width, this.height)
      : area = width * height;  // 생성자 본문 실행 전에 초기화
}

콜론(:) 뒤에 오는 부분이 초기화 리스트로, 생성자 본문 실행 전에 실행됨

3. 불변성 (Immutability)

final - 런타임 불변

class Person {
  final String name;      // 한 번 설정되면 변경 불가
  final List<String> friends;

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

void main() {
  var person = Person('모리', ['철수', '영희']);

  // person.name = '다른이름';  // ❌ 컴파일 에러!

  // ⚠️ 주의: 리스트 자체는 못 바꾸지만, 내용은 수정 가능!
  person.friends.add('민수');  // ✅ 가능
  print(person.friends);       // [철수, 영희, 민수]

  // person.friends = [];      // ❌ 리스트 자체 교체는 불가
}
  • JavaScript의 const와 비슷한 동작임. 참조(reference)는 고정되지만 내부 값은 변경 가능

  • 진정한 불변 리스트가 필요하면 List.unmodifiable() 쓰면 됨

  • 클래스 필드에 final 붙이는 건 좋은 습관임. 의도치 않은 변경 방지

const 생성자 - 컴파일 타임 상수

class Point {
  final int x;
  final int y;

  // const 생성자 - 모든 필드가 final이어야 함
  const Point(this.x, this.y);
}

void main() {
  // const로 생성하면 컴파일 타임에 결정됨
  const p1 = Point(1, 2);
  const p2 = Point(1, 2);

  // 🎯 같은 값이면 메모리 공유!
  print(identical(p1, p2));  // true - 같은 객체!

  // const 없이 생성하면 매번 새 인스턴스
  var p3 = Point(1, 2);
  var p4 = Point(1, 2);
  print(identical(p3, p4));  // false - 다른 객체
}
  • const 객체는 단 하나만 존재함 (Flyweight 패턴이랑 비슷)

  • Flutter에서 위젯에 const 붙이면 리빌드 최적화에 큰 도움 됨

  • const는 빌드 타임에 값을 알 수 있어야 함. DateTime.now()나 함수 호출 결과는 사용 불가

final vs const 정리

특성

final

const

값 결정 시점

런타임

컴파일 타임

DateTime.now()

✅ 가능

❌ 불가

인스턴스 필드

✅ 가능

❌ 불가 (static만)

메모리 공유

❌ 불가

✅ 동일 값은 공유

4. Getter와 Setter

Getter/Setter는 계산된 속성이나 접근 제어가 필요할 때 씀

Getter

class Person {
  final String firstName;
  final String lastName;
  final List<String> friends;

  Person(this.firstName, this.lastName, this.friends);

  // Getter - 계산된 속성
  String get fullName => '$firstName $lastName';

  // Getter - 리스트의 첫 번째 요소
  String get firstFriend {
    if (friends.isEmpty) return '친구 없음';
    return friends[0];
  }

  // Getter - 친구 수
  int get friendCount => friends.length;
}

void main() {
  var person = Person('김', '모리', ['철수', '영희']);

  // 메서드가 아닌 속성처럼 접근!
  print(person.fullName);     // 김 모리
  print(person.firstFriend);  // 철수
  print(person.friendCount);  // 2
}

Setter

class Temperature {
  double _celsius = 0;  // private 변수

  // Getter
  double get celsius => _celsius;
  double get fahrenheit => _celsius * 9 / 5 + 32;

  // Setter - 값 하나만 받을 수 있음!
  set celsius(double value) {
    if (value < -273.15) {
      throw ArgumentError('절대영도 이하는 불가능');
    }
    _celsius = value;
  }

  // Setter - 화씨로 설정하면 자동 변환
  set fahrenheit(double value) {
    _celsius = (value - 32) * 5 / 9;
  }
}

void main() {
  var temp = Temperature();

  temp.celsius = 25;
  print(temp.fahrenheit);  // 77.0

  temp.fahrenheit = 100;
  print(temp.celsius);     // 37.777...
}
  • Getter/Setter는 필드처럼 보이지만 실제로는 메서드

  • Setter는 무조건 파라미터 하나만 받음 (= 연산자 우측 값)

  • 실무에서 Setter보다 Getter를 훨씬 많이 씀. 값 설정은 명시적인 메서드가 더 명확할 때가 많음

5. 캡슐화와 Private

언더스코어(_)로 Private 선언

오 private라는 개념이 있네. 클래스 이름에 언더스코어 붙이면 됨. 함수도 마찬가지

// person.dart 파일
class Person {
  String name;          // public - 어디서든 접근 가능
  String _secret;       // private - 같은 파일에서만 접근 가능

  Person(this.name, this._secret);

  void _privateMethod() {  // private 메서드
    print('비밀: $_secret');
  }

  void revealSecret() {    // public 메서드
    _privateMethod();       // 내부에서는 호출 가능
  }
}

// ⚠️ 같은 파일에서는 private도 접근 가능!
void sameFileFunction() {
  var p = Person('모리', '비밀정보');
  print(p._secret);  // ✅ 같은 파일이라 가능
}
// main.dart 파일 (다른 파일)
import 'person.dart';

void main() {
  var person = Person('모리', '비밀');

  print(person.name);      // ✅ public
  // print(person._secret);  // ❌ 다른 파일에서 private 접근 불가

  person.revealSecret();   // ✅ public 메서드 통해 간접 접근
}
  • Dart의 private는 파일 단위임. 같은 파일이면 다른 클래스에서도 접근 가능

  • Java/C#의 private 키워드랑 다르게 네이밍 컨벤션으로 구현됨

  • 클래스 자체도 private로 만들 수 있음: class _InternalHelper { ... }

6. 상속 (Inheritance)

기본 상속

class Idol {
  String name;
  int membersCount;

  Idol({required this.name, required this.membersCount});

  void sayName() {
    print('저는 ${this.name}입니다.');
  }

  void sayMembersCount() {
    print('${this.name}은 ${this.membersCount}명의 멤버가 있습니다.');
  }
}

// extends로 상속
class BoyGroup extends Idol {
  // 자식 클래스의 생성자
  BoyGroup(String name, int membersCount)
      : super(name: name, membersCount: membersCount);
      //  ↑ super()로 부모 생성자 호출

  // 자식만의 메서드 추가
  void sayBoyGroup() {
    print('저희는 남성 아이돌입니다.');
  }
}

class GirlGroup extends Idol {
  // 추가 속성
  String company;

  GirlGroup({
    required String name,
    required int membersCount,
    required this.company,
  }) : super(name: name, membersCount: membersCount);

  void sayCompany() {
    print('소속사는 $company입니다.');
  }
}

void main() {
  var bts = BoyGroup('BTS', 7);
  bts.sayName();         // 저는 BTS입니다. (부모 메서드)
  bts.sayBoyGroup();     // 저희는 남성 아이돌입니다. (자식 메서드)

  var blackpink = GirlGroup(
    name: 'BLACKPINK',
    membersCount: 4,
    company: 'YG',
  );
  blackpink.sayMembersCount();  // BLACKPINK은 4명의 멤버가 있습니다.
  blackpink.sayCompany();       // 소속사는 YG입니다.
}
  • extends는 "확장한다"는 의미로, 부모의 모든 것을 물려받음

  • super는 "부모"를 가리킴. 생성자에서 super()로 부모 생성자 호출해야 함

  • Dart는 단일 상속만 지원함. 여러 클래스 상속은 불가능하고, 이걸 보완하기 위해 Mixin이 있음

7. 메서드 오버라이딩

@override 어노테이션

상속하고 있는 메서드를 덮어씌울 수 있음. 완전 똑같은 시그니처여야 함

class Idol {
  String name;
  int membersCount;

  Idol({required this.name, required this.membersCount});

  void introduce() {
    print('안녕하세요, $name입니다.');
  }
}

class BoyGroup extends Idol {
  BoyGroup({required super.name, required super.membersCount});

  // 부모 메서드를 덮어씀
  @override
  void introduce() {
    // super로 부모 메서드 호출 가능
    super.introduce();  // 안녕하세요, BTS입니다.
    print('저희는 남성 아이돌입니다!');
  }
}

void main() {
  var bts = BoyGroup(name: 'BTS', membersCount: 7);
  bts.introduce();
  // 출력:
  // 안녕하세요, BTS입니다.
  // 저희는 남성 아이돌입니다!
}

오버라이딩 규칙

class Parent {
  int calculate(int x) {
    return x * 2;
  }
}

class Child extends Parent {
  // ✅ 시그니처가 완전히 동일해야 함
  @override
  int calculate(int x) {
    return super.calculate(x) + 10;  // 부모 결과에 10 추가
  }

  // ❌ 시그니처가 다르면 오버라이딩이 아닌 새 메서드
  // int calculate(int x, int y) { }  // 이건 다른 메서드
}
  • @override는 선택적이지만 항상 붙이는 게 좋음. 오타로 새 메서드 만드는 실수 방지

  • super.method()로 부모 구현 호출하면서 기능 확장 가능

  • this 써도 부모 필드에 접근 가능하지만, 명확성 위해 super 권장. 근데 this 써도 문제없긴 함

8. Static 멤버

static 변수와 메서드

static 키워드도 있음

class Employee {
  static String company = '네이버';  // 모든 인스턴스가 공유
  static int employeeCount = 0;     // 직원 수 카운터

  String name;

  Employee(this.name) {
    employeeCount++;  // 새 직원 생성될 때마다 증가
  }

  // static 메서드
  static void printCompanyInfo() {
    print('회사: $company, 총 직원 수: $employeeCount');
  }

  void introduce() {
    // 인스턴스 메서드에서 static 접근 가능
    print('저는 $company의 $name입니다.');
  }
}

void main() {
  // static은 클래스 이름으로 접근
  print(Employee.company);  // 네이버

  var emp1 = Employee('모리');
  var emp2 = Employee('철수');

  Employee.printCompanyInfo();  // 회사: 네이버, 총 직원 수: 2

  // 모든 직원의 회사 변경
  Employee.company = '카카오';

  emp1.introduce();  // 저는 카카오의 모리입니다.
  emp2.introduce();  // 저는 카카오의 철수입니다.
}
  • static은 클래스에 속하는 것이지, 인스턴스에 속하지 않음

  • 인스턴스 안 만들어도 ClassName.staticMember로 접근 가능

  • 유틸리티 함수, 상수, 팩토리 메서드 등에 주로 씀

  • static 메서드 안에서는 this 사용 못 함 (인스턴스가 없으니까)

9. 추상 클래스와 인터페이스

추상 클래스 (Abstract Class)

abstract를 넣으면 추상적이라는 의미. 그래서 함수 바디 안 넣어도 됨

// abstract 키워드로 추상 클래스 선언
abstract class Shape {
  // 추상 메서드 - 본문 없음, 자식이 반드시 구현해야 함
  double getArea();
  double getPerimeter();

  // 일반 메서드 - 공통 구현 제공
  void describe() {
    print('넓이: ${getArea()}, 둘레: ${getPerimeter()}');
  }
}

class Rectangle extends Shape {
  final double width;
  final double height;

  Rectangle(this.width, this.height);

  @override
  double getArea() => width * height;

  @override
  double getPerimeter() => 2 * (width + height);
}

class Circle extends Shape {
  final double radius;
  static const double pi = 3.14159;

  Circle(this.radius);

  @override
  double getArea() => pi * radius * radius;

  @override
  double getPerimeter() => 2 * pi * radius;
}

void main() {
  // Shape shape = Shape();  // ❌ 추상 클래스는 인스턴스화 불가

  List<Shape> shapes = [
    Rectangle(10, 5),
    Circle(7),
  ];

  for (var shape in shapes) {
    shape.describe();  // 다형성!
  }
}

인터페이스 (implements)

Dart에는 별도의 interface 키워드가 없음. 모든 클래스가 인터페이스로 사용 가능

implements를 붙여서 사용. 시그니처를 강제화하는 거라고 보면 됨

// 이 클래스는 인터페이스로도 사용 가능
class Flyable {
  void fly() {
    print('날고 있습니다.');
  }
}

class Swimmable {
  void swim() {
    print('수영하고 있습니다.');
  }
}

// implements로 인터페이스 구현
// ⚠️ implements 쓰면 모든 메서드를 직접 구현해야 함!
class Duck implements Flyable, Swimmable {
  @override
  void fly() {
    print('오리가 날고 있습니다.');
  }

  @override
  void swim() {
    print('오리가 수영하고 있습니다.');
  }
}

// extends vs implements 비교
class Bird extends Flyable {
  // fly() 구현 안 해도 됨 - 부모 것 상속받음
}

class Airplane implements Flyable {
  @override
  void fly() {
    // 반드시 직접 구현해야 함!
    print('비행기가 날고 있습니다.');
  }
}
  • extends = "~이다" 관계 (is-a), 부모 구현을 상속받음

  • implements = "~처럼 행동한다" 관계 (can-do), 시그니처만 강제하고 구현은 직접

  • 추상 클래스는 일부 구현 + 일부 강제 (템플릿 메서드 패턴)

  • implements는 여러 개 가능, extends는 하나만 가능

실전 예시: Repository 패턴

// 인터페이스 역할의 추상 클래스
abstract class UserRepository {
  Future<User?> findById(String id);
  Future<List<User>> findAll();
  Future<void> save(User user);
  Future<void> delete(String id);
}

// 실제 구현 - 데이터베이스 버전
class DatabaseUserRepository implements UserRepository {
  @override
  Future<User?> findById(String id) async {
    // DB 조회 로직
    return User(id: id, name: 'from DB');
  }

  // ... 나머지 구현
}

// 테스트용 구현 - 메모리 버전
class InMemoryUserRepository implements UserRepository {
  final Map<String, User> _storage = {};

  @override
  Future<User?> findById(String id) async {
    return _storage[id];
  }

  // ... 나머지 구현
}

10. 제네릭 (Generics)

타입을 외부에서 받을 때 사용함

기본 제네릭

// T는 타입 파라미터 - 외부에서 타입을 주입받음
class Box<T> {
  T content;

  Box(this.content);

  T getContent() => content;

  void setContent(T newContent) {
    content = newContent;
  }
}

void main() {
  // String 타입의 Box
  var stringBox = Box<String>('안녕하세요');
  print(stringBox.getContent());  // 안녕하세요

  // int 타입의 Box
  var intBox = Box<int>(42);
  print(intBox.getContent());  // 42

  // 타입 추론도 가능
  var autoBox = Box('자동 추론');  // Box<String>
}

여러 타입 파라미터

class Pair<K, V> {
  K key;
  V value;

  Pair(this.key, this.value);

  @override
  String toString() => '($key: $value)';
}

void main() {
  var entry = Pair<String, int>('age', 25);
  print(entry);  // (age: 25)

  var coord = Pair<double, double>(37.5, 127.0);
  print(coord);  // (37.5: 127.0)
}

제네릭 제약 (Bounded Generics)

// T는 num을 상속한 타입만 가능 (int, double)
class NumberBox<T extends num> {
  T value;

  NumberBox(this.value);

  // num의 메서드 사용 가능
  bool isPositive() => value > 0;
  T doubled() => (value * 2) as T;
}

void main() {
  var intBox = NumberBox<int>(10);
  print(intBox.doubled());  // 20

  var doubleBox = NumberBox<double>(3.14);
  print(doubleBox.isPositive());  // true

  // var stringBox = NumberBox<String>('hello');  // ❌ 컴파일 에러!
}
  • 제네릭은 타입 안전성 유지하면서 코드 재사용 가능하게 해줌

  • List<T>, Map<K, V>, Future<T> 등 Dart 코어 라이브러리가 제네릭으로 가득함

  • Flutter에서 StatefulWidget<T>, Provider<T> 등 제네릭 많이 씀

  • 관례적으로 T(Type), E(Element), K(Key), V(Value) 사용함

11. Mixin

Mixin이란?

Mixin은 코드를 재사용하면서 다중 상속의 문제를 피하는 방법임

// mixin 정의
mixin Singing {
  void sing() {
    print('노래를 부릅니다 🎵');
  }
}

mixin Dancing {
  void dance() {
    print('춤을 춥니다 💃');
  }
}

mixin Acting {
  void act() {
    print('연기를 합니다 🎭');
  }
}

// 기본 클래스
class Performer {
  String name;
  Performer(this.name);

  void introduce() {
    print('안녕하세요, $name입니다.');
  }
}

// with 키워드로 mixin 사용
class Idol extends Performer with Singing, Dancing {
  Idol(super.name);
}

class Actor extends Performer with Acting {
  Actor(super.name);
}

// 세 가지 모두!
class MultiTalent extends Performer with Singing, Dancing, Acting {
  MultiTalent(super.name);
}

void main() {
  var idol = Idol('BTS');
  idol.introduce();  // 안녕하세요, BTS입니다.
  idol.sing();       // 노래를 부릅니다 🎵
  idol.dance();      // 춤을 춥니다 💃
  // idol.act();     // ❌ Acting mixin 없음

  var talent = MultiTalent('아이유');
  talent.sing();
  talent.dance();
  talent.act();
}

Mixin 제약조건

// 특정 클래스에서만 사용 가능한 mixin
mixin IdolSkills on Performer {
  void fanService() {
    print('$name이(가) 팬서비스를 합니다!');
  }
}

class Idol extends Performer with IdolSkills {
  Idol(super.name);
}

// ❌ Performer를 상속하지 않으면 사용 불가
// class Animal with IdolSkills { }
  • extends는 하나, with는 여러 개 가능함

  • Mixin은 생성자를 가질 수 없음 (인스턴스화 불가)

  • on 키워드로 특정 클래스에서만 사용하도록 제한 가능

  • Flutter에서 SingleTickerProviderStateMixin, WidgetsBindingObserver 등 자주 쓰임

12. OOP 설계 원칙

SOLID 원칙 요약

원칙

설명

Dart 적용

Single Responsibility

클래스는 하나의 책임만

작은 클래스로 분리

Open/Closed

확장에 열림, 수정에 닫힘

상속, Mixin 활용

Liskov Substitution

자식은 부모 대체 가능

올바른 override

Interface Segregation

인터페이스 분리

작은 mixin들

Dependency Inversion

추상화에 의존

추상 클래스, implements

실전 팁

// ❌ 나쁜 예: 너무 많은 책임
class UserManager {
  void createUser() { }
  void deleteUser() { }
  void sendEmail() { }     // 이메일은 별개의 책임
  void generateReport() { } // 리포트도 별개의 책임
}

// ✅ 좋은 예: 책임 분리
class UserRepository {
  void create() { }
  void delete() { }
}

class EmailService {
  void send() { }
}

class ReportGenerator {
  void generate() { }
}

🤖

본 글은 AI의 도움을 받아 작성되었습니다.

읽기 시리즈

현재 위치를 확인하고, 흐름을 따라 바로 다음 읽기로 이어갈 수 있습니다.

Reading Series

Dart
0편

현재 읽는 문서

Dart 객체지향 프로그래밍

Dart 언어에 대해서 알아보기

이전 문서

시리즈의 시작 문서입니다.

다음 문서

시리즈의 마지막 문서입니다.

Dart 객체지향 프로그래밍 | 위키.모리.블로그_