📚 학습 기록/Dart & Flutter 기초

Dart의 객체지향 프로그래밍: 자바 개발자를 위한 핵심 가이드

zenjoydev 2025. 4. 24. 21:56

안녕하세요! 오늘은 Flutter 개발의 핵심 언어인 Dart의 객체지향 프로그래밍(OOP) 개념에 대해 알아보겠습니다. 특히 자바 개발자 분들이 Dart로 전환하실 때 알아두면 좋은 차이점과 특징을 중심으로 정리했습니다.

목차

  1. Dart의 객체지향 프로그래밍 개요
  2. 클래스와 생성자
  3. Getter와 Setter
  4. 불변성과 Final/const
  5. 상속과 Super 키워드
  6. 접근 제어: Private
  7. Static 멤버
  8. 제네릭
  9. 자바와의 비교
  10. 실전 코드 예시

Dart의 객체지향 프로그래밍 개요

Dart는 객체지향과 함수형 프로그래밍 패러다임을 모두 지원하는 언어입니다. 클래스 기반의 객체지향 언어로, 인스턴스는 클래스를 통해 만든 결과물입니다. 흥미로운 점은 생성 시 new 키워드 없이도 인스턴스 생성이 가능하다는 것입니다.

// 클래스 정의
class Person {
  String name;
  int age;
  
  Person(this.name, this.age);
}

// 인스턴스 생성 (new 키워드 생략 가능)
Person person = Person('홍길동', 30);

클래스와 생성자

기본 생성자

Dart에서는 생성자를 다양한 방식으로 정의할 수 있습니다:

class Person {
  String name;
  int age;
  
  // 기본 생성자
  Person(this.name, this.age);
  
  // 명시적 생성자
  Person.explicit(String name, int age) {
    this.name = name;
    this.age = age;
  }
}

네임드 컨스트럭터

Dart의 강력한 특징 중 하나는 네임드 컨스트럭터입니다. 한 클래스에 여러 개의 생성자를 정의할 수 있으며, 각각 다양한 목적으로 사용할 수 있습니다.

class Person {
  String name;
  int age;
  String? address;
  
  // 기본 생성자
  Person(this.name, this.age);
  
  // 네임드 컨스트럭터
  Person.withAddress(this.name, this.age, this.address);
  
  // 기본값을 가진 네임드 컨스트럭터
  Person.defaultPerson() 
      : name = '홍길동',
        age = 20;
        
  // 팩토리 생성자
  factory Person.fromMap(Map<String, dynamic> map) {
    return Person(map['name'], map['age']);
  }
}

// 사용 예시
Person p1 = Person('홍길동', 30);
Person p2 = Person.withAddress('김철수', 25, '서울시');
Person p3 = Person.defaultPerson();
Person p4 = Person.fromMap({'name': '이영희', 'age': 28});

네임드 컨스트럭터는 가독성과 명확성을 높이는 데 유용하며, 다양한 방식으로 객체를 초기화할 수 있게 해줍니다.

Getter와 Setter

Dart에서는 Getter와 Setter를 특별한 방식으로 처리합니다. 소괄호 없이 속성처럼 접근할 수 있습니다.

class Rectangle {
  double _width;
  double _height;
  
  Rectangle(this._width, this._height);
  
  // Getter
  double get area => _width * _height;
  
  // Setter
  set width(double value) {
    if (value > 0) {
      _width = value;
    }
  }
}

void main() {
  var rect = Rectangle(10, 20);
  print(rect.area); // 소괄호 없이 속성처럼 접근
  rect.width = 15;  // 소괄호 없이 설정
}

현대 프로그래밍에서는 setter 사용을 지양하는 경향이 있습니다. 이는 불변성(immutability)을 중요시하기 때문인데, 데이터 변경 가능성을 제한함으로써 버그를 줄이고 코드의 예측 가능성을 높일 수 있습니다.

불변성과 Final/const

Dart에서는 final과 const 키워드를 통해 불변성을 구현할 수 있습니다:

final vs const

  • final: 참조 변경이 불가능하지만, 참조된 객체의 내용은 변경 가능
  • const: 참조 변경 불가 + 참조된 객체의 내용도 변경 불가
  •  
// final 예시
final List<int> finalList = [1, 2, 3];
finalList.add(4); // 가능 - 내용 변경
// finalList = [5, 6]; // 오류 - 참조 변경 불가

// const 예시
const List<int> constList = [1, 2, 3];
// constList.add(4); // 오류 - 내용 변경 불가
// constList = [5, 6]; // 오류 - 참조 변경 불가

Const 생성자

불변 객체를 효율적으로 생성하기 위해 Dart는 const 생성자를 지원합니다:

class ImmutablePoint {
  final int x;
  final int y;
  
  // const 생성자 - 모든 필드는 반드시 final로 선언되어야 함
  const ImmutablePoint(this.x, this.y);
}

void main() {
  // 동일한 const 생성자로 만든 객체는 메모리 상에서도 완전히 같은 인스턴스로 취급
  const p1 = ImmutablePoint(1, 2);
  const p2 = ImmutablePoint(1, 2);
  
  print(identical(p1, p2)); // true - 같은 인스턴스
  
  // 다트가 프로그램 전체에서 한 번만 생성하도록 최적화하기 때문
}

const 생성자는 컴파일 타임에 값이 결정되어야 하므로, 매개변수 사용 시 최적화하기 위해 const 키워드를 사용하는 습관을 들이는 것이 좋습니다. 또한 동일성과 값 비교가 모두 true가 됩니다.

상속과 Super 키워드

Dart에서 상속은 자바와 유사하지만 문법이 약간 다릅니다:

class Animal {
  String name;
  
  Animal(this.name);
  
  void makeSound() {
    print('Some sound');
  }
}

class Dog extends Animal {
  String breed;
  
  // super를 사용하여 부모 생성자 호출
  Dog(String name, this.breed) : super(name);
  
  @override
  void makeSound() {
    // super를 사용하여 부모 메서드 호출
    // super.makeSound();
    print('Bark!');
  }
  
  void showInfo() {
    // 상속을 해서 받은 값 역시 본인의 클래스에 있기에 super, this 생략 가능
    print('Name: $name, Breed: $breed');
    // 혹은
    // print('Name: ${super.name}, Breed: $breed');
  }
}

super 키워드는 부모 클래스의 생성자나 메서드를 호출할 때 사용합니다. 변수명이 유일성을 가진다면 this 생략 가능합니다.

접근 제어: Private

Dart에서는 자바와 달리 private 키워드가 없습니다. 대신 변수나 메서드 이름 앞에 밑줄(_)을 붙여 private로 표시합니다:

class BankAccount {
  // private 변수 (같은 파일 내에서만 접근 가능)
  double _balance = 0;
  
  // public 메서드
  void deposit(double amount) {
    if (amount > 0) {
      _balance += amount;
    }
  }
  
  double get balance => _balance;
}

void main() {
  var account = BankAccount();
  account.deposit(100);
  print(account.balance);
  // account._balance = 1000000; // 오류는 아니지만 권장되지 않음
}

Dart에서 private 접근 제어는 클래스 단위가 아닌 파일 단위로 적용됩니다. 같은 파일 내에서는 private 멤버에 접근할 수 있습니다.

Static 멤버

Static 멤버는 클래스에 귀속되며, 인스턴스를 생성하지 않고도 접근할 수 있습니다:

class MathUtils {
  // 인스턴스 귀속
  double calculateArea(double radius) {
    return pi * radius * radius;
  }
  
  // 클래스 귀속 (static)
  static double pi = 3.14159;
  
  static double calculateCircumference(double radius) {
    return 2 * pi * radius;
  }
}

void main() {
  // 인스턴스의 기능을 이용하려면, 기능은 해당 인스턴스의 값을 바탕으로 하기 때문
  var math = MathUtils();
  print(math.calculateArea(5));
  
  // 클래스 자체의 속성에 값을 변경해버리면, 해당 클래스를 사용하는 인스턴스에 반영
  print(MathUtils.pi);
  print(MathUtils.calculateCircumference(5));
}

Dart에서 static은 "공유한다"는 의미를 가집니다. 인스턴스의 기능을 사용할 때는 인스턴스의 값을 바탕으로 하지만, static 멤버는 클래스 자체에 속하므로 모든 인스턴스가 공유합니다.

제네릭

Dart에서 제네릭은 타입을 외부에서 받을 때 사용합니다:

class Box<T> {
  T value;
  
  Box(this.value);
  
  T getValue() {
    return value;
  }
}

void main() {
  // 문자열 상자
  Box<String> stringBox = Box<String>('Hello');
  String strValue = stringBox.getValue();
  
  // 정수 상자
  Box<int> intBox = Box<int>(42);
  int intValue = intBox.getValue();
}

제네릭을 사용하면 동일한 코드로 다양한 타입의 데이터를 처리할 수 있어 타입 안전성과 코드 재사용성을 높일 수 있습니다.

자바와의 비교

Dart와 자바의 주요 차이점을 살펴보겠습니다:

항목DartJava

생성자 네임드, const, 일반 일반
귀속 인스턴스, 클래스 인스턴스
Setter 사용 빈도 매우 낮음 사용 빈도 잦음
상속 Super 생략 가능 Super 기재 필요
Private 선언 이름 앞 _ Private 키워드
Final vs const Final: 참조 변경 불가<br>const: 참조 + 요소 변경 불가 Final: 참조 변경 불가<br>unmodifiableList: 참조 + 요소 변경 불가
인스턴스 생성 New 생략 가능 New 기재 필요

실전 코드 예시

아래는 Dart의 OOP 개념들을 활용한 간단한 예시입니다:

// 불변 좌표 클래스
class Point {
  final double x;
  final double y;
  
  // 일반 생성자
  Point(this.x, this.y);
  
  // 네임드 컨스트럭터
  Point.origin() : x = 0, y = 0;
  
  // const 생성자
  const Point.immutable(this.x, this.y);
  
  // Getter
  double get distanceFromOrigin => 
      sqrt(x * x + y * y);
}

// 도형 추상 클래스
abstract class Shape {
  double get area;
  
  // 팩토리 생성자
  factory Shape.fromType(String type, {double? width, double? height, double? radius}) {
    if (type == 'circle' && radius != null) {
      return Circle(radius);
    } else if (type == 'rectangle' && width != null && height != null) {
      return Rectangle(width, height);
    }
    throw ArgumentError('Invalid shape or missing parameters');
  }
}

// 원 클래스
class Circle implements Shape {
  final double radius;
  static const double pi = 3.14159;
  
  Circle(this.radius);
  
  @override
  double get area => pi * radius * radius;
}

// 사각형 클래스
class Rectangle implements Shape {
  final double width;
  final double height;
  
  Rectangle(this.width, this.height);
  
  @override
  double get area => width * height;
}

// 제네릭 컬렉션 클래스
class ShapeCollection<T extends Shape> {
  final List<T> _shapes = [];
  
  void addShape(T shape) {
    _shapes.add(shape);
  }
  
  double getTotalArea() {
    return _shapes.fold(0, (prev, shape) => prev + shape.area);
  }
}

void main() {
  var point1 = Point(3, 4);
  var point2 = Point.origin();
  const point3 = Point.immutable(5, 12);
  
  print('Distance from origin: ${point1.distanceFromOrigin}');
  
  var circle = Shape.fromType('circle', radius: 5) as Circle;
  var rectangle = Shape.fromType('rectangle', width: 4, height: 6) as Rectangle;
  
  var collection = ShapeCollection<Shape>();
  collection.addShape(circle);
  collection.addShape(rectangle);
  
  print('Total area: ${collection.getTotalArea()}');
}

주의사항 및 팁

  1. 함수 자체에서 자신을 계속 호출하면 무한 루프에 빠진다 - 재귀 함수 사용 시 종료 조건을 명확히 해야 합니다.
  2. 단순 final은 참조 변경만 막을 수 있다 - 객체의 내부 상태 변경을 막으려면 추가 조치가 필요합니다.
  3. getter와 setter는 소괄호 없이 속성처럼 사용합니다 - 이는 Dart의 문법적 특징입니다.
  4. const 키워드로 선언 및 사용하는 습관을 들이자 - 컴파일 타임에 최적화가 가능해집니다.

결론

Dart의 객체지향 프로그래밍은 자바와 유사하면서도 현대적인 언어 특성을 갖추고 있습니다. 특히 네임드 컨스트럭터, 불변성 지원, 그리고 간결한 문법은 Flutter와 같은 UI 개발에 최적화되어 있습니다.

자바 개발자라면 Dart의 OOP 개념을 빠르게 습득할 수 있으며, 몇 가지 문법적 차이와 철학적 접근 방식(불변성 등)을 이해하면 효율적인 Dart 코드를 작성할 수 있습니다.

다음 포스팅에서는 Dart의 함수형 프로그래밍 기능에 대해 알아보겠습니다. 감사합니다!


더 알면 좋은 Dart OOP 팁

  1. 카스케이드 연산자(..) - 동일한 객체에 여러 연산을 연속해서 적용할 수 있습니다:
  2.  
var paint = Paint()
  ..color = Colors.black
  ..strokeCap = StrokeCap.round
  ..strokeWidth = 5.0;

extension 메서드 - 기존 클래스에 새로운 기능을 추가할 수 있습니다:

extension NumberParsing on String {
  int parseInt() {
    return int.parse(this);
  }
}

void main() {
  print('42'.parseInt()); // 42
}

믹스인(with) - 다중 상속의 일부 기능을 제공합니다:

mixin Musical {
  bool canPlayPiano = false;
  void playInstrument() {
    print('Playing piano: $canPlayPiano');
  }
}

class Musician extends Person with Musical {
  // Person의 모든 것 + Musical의 모든 것
}