Dart 언어 핵심 정리

Dart는 Google에서 개발한 범용 프로그래밍 언어다. 현재는 Flutter 애플리케이션 개발에서 가장 많이 사용되며, 모바일뿐만 아니라 웹과 데스크톱 애플리케이션까지 하나의 코드베이스로 개발할 수 있도록 설계되어 있다.

Dart의 문법은 Java, C#, JavaScript, Kotlin 등의 영향을 받은 형태라 객체지향 개발자에게 비교적 익숙하다. 동시에 async/await, Future, Stream, Null Safety 같은 기능을 언어 차원에서 제공한다.

Dart 코드는 실행 환경에 따라 컴파일 방식이 달라진다.

Dart Source -> Dart Compiler -> Native / JavaScript / WebAssembly -> Runtime

특히 Flutter에서는 Dart 코드가 네이티브 코드로 컴파일되어 실행된다.

Dart를 이해할 때는 단순히 문법을 외우기보다 객체 모델, 타입 시스템, Null Safety, 비동기 처리, 컬렉션, 클래스 구조를 중심으로 보는 편이 좋다.


변수와 타입

Dart는 정적 타입 언어다.

int health = 100;
double speed = 3.5;
String name = 'Player';
bool alive = true;

타입을 직접 적지 않고 var를 사용할 수도 있다.

var health = 100;
var name = 'Player';

이 경우 Dart가 초기값을 보고 타입을 추론한다.

health -> int
name -> String

var를 사용했다고 해서 동적 타입이 되는 것은 아니다.

var health = 100;

// health = '100'; // 오류

health는 여전히 int로 추론된다.


final과 const

Dart에서 변수의 변경 가능성을 제어할 때 finalconst를 사용한다.

final name = 'Player';

final 변수는 한 번 값을 할당한 뒤 다시 할당할 수 없다.

const maxHealth = 100;

const는 컴파일 타임 상수다.

둘의 차이는 다음과 같이 이해하면 된다.

final -> 런타임에 한 번 결정 -> 이후 변경 불가
const -> 컴파일 타임에 결정 -> 상수 객체

예를 들어:

final now = DateTime.now();

이것은 실행할 때 값이 결정되므로 const가 될 수 없다.

반면:

const maxHealth = 100;

처럼 컴파일 시점에 값을 알 수 있는 경우 const를 사용할 수 있다.

Flutter에서는 const가 위젯을 비롯한 객체를 불변으로 구성하는 데 자주 사용된다.


기본 타입

Dart의 대표적인 기본 타입은 다음과 같다.

int / double / num / String / bool / List / Set / Map / Record

모든 타입은 객체 모델에 속한다.

int value = 10;

print(value.isEven);

정수 역시 메서드와 프로퍼티를 가진 객체처럼 사용할 수 있다.


num

intdouble의 상위 개념으로 num이 있다.

num value = 10;

value = 10.5;

num은 정수와 실수를 모두 표현할 수 있다.

num -> int / double

정확히 정수만 필요한 경우 int, 실수만 필요한 경우 double을 사용하는 것이 타입 의도를 명확하게 표현한다.


String

문자열은 String으로 표현한다.

String name = 'Player';

문자열 보간을 지원한다.

int level = 10;

print('Level: $level');

표현식도 사용할 수 있다.

print('Next Level: ${level + 1}');

문자열을 조합할 때 별도의 포맷 함수를 남발하지 않아도 되는 것이 편하다.


Nullable Type과 Null Safety

Dart에서 상당히 중요한 부분이다.

일반적인 변수는 기본적으로 null을 가질 수 없다.

String name = 'Player';

// name = null; // 오류

null을 허용하려면 ?를 붙인다.

String? name;

name = null;
name = 'Player';

타입 구조가 다음처럼 달라진다.

String -> 문자열만 가능
String? -> 문자열 또는 null

이것을 Null Safety라고 한다.

Null 관련 오류를 런타임이 아니라 가능한 많은 경우 컴파일 단계에서 잡도록 만든 구조다.


Null 처리

Nullable 값을 사용할 때는 null일 가능성을 고려해야 한다.

String? name;

if (name != null) {
  print(name.length);
}

Dart는 조건문을 확인한 뒤 해당 범위에서 name을 non-null로 판단할 수 있다.

null-aware operator도 제공한다.

String? name;

print(name?.length);

namenull이면 length에 접근하지 않고 null을 반환한다.

기본값이 필요하다면 ??를 사용할 수 있다.

String? name;

final displayName = name ?? 'Unknown';

Null Assertion

!를 사용하면 개발자가 해당 값이 null이 아니라고 명시할 수 있다.

String? name;

print(name!.length);

문제는 실제로 name이 null이면 런타임 오류가 발생한다는 것이다.

따라서 !는 null이 아니라는 것을 확실히 알고 있을 때 사용하는 편이 좋다.

? -> null 가능
! -> 개발자가 null이 아니라고 보장

!를 습관적으로 붙이는 것은 Null Safety의 장점을 없애는 방향이다.


List

Dart의 배열에 해당하는 자료구조는 List다.

List<int> scores = [10, 20, 30];

타입 추론도 가능하다.

var scores = [10, 20, 30];

값을 추가할 수 있다.

scores.add(40);

인덱스로 접근한다.

print(scores[0]);

다른 언어의 동적 배열과 비슷한 역할을 한다.


Set

중복을 허용하지 않는 컬렉션은 Set이다.

Set<int> ids = {1, 2, 3};

중복된 값을 넣어도 하나만 유지된다.

var ids = {1, 2, 3, 3};

집합 연산도 제공한다.

final a = {1, 2, 3};
final b = {2, 3, 4};

print(a.intersection(b));

중복 제거가 필요하거나 집합 기반의 처리가 필요할 때 사용할 수 있다.


Map

Key와 Value를 저장하는 자료구조다.

Map<String, int> stats = {
  'health': 100,
  'mana': 50,
};

값을 가져올 수 있다.

print(stats['health']);

새로운 값을 추가할 수도 있다.

stats['level'] = 10;

JSON 데이터를 다룰 때도 Map이 자주 등장한다.


Collection If

Dart는 컬렉션 내부에서 조건문을 사용할 수 있다.

final items = [
  'Sword',
  'Shield',
  if (hasPotion) 'Potion',
];

조건에 따라 리스트의 항목을 추가할 수 있다.

Flutter에서 UI 위젯 목록을 구성할 때 특히 자주 사용한다.


Collection For

반복문을 컬렉션 내부에 사용할 수도 있다.

final numbers = [
  for (var i = 0; i < 5; i++) i,
];

기존 방식으로 작성하면 별도의 리스트를 만들고 add()를 반복해야 하지만 컬렉션 문법을 이용하면 데이터 생성 자체를 표현할 수 있다.


함수

Dart의 함수 문법은 비교적 단순하다.

int add(int a, int b) {
  return a + b;
}

반환 타입을 생략할 수도 있지만 명확한 코드에서는 필요한 경우 타입을 명시하는 편이 좋다.

var result = add(10, 20);

Dart에서 함수 역시 객체다.

따라서 함수를 변수에 저장하거나 다른 함수에 전달할 수 있다.

int add(int a, int b) {
  return a + b;
}

final operation = add;

print(operation(10, 20));

Arrow Function

함수 본문이 하나의 표현식으로 끝난다면 => 문법을 사용할 수 있다.

int add(int a, int b) => a + b;

Flutter에서도 콜백을 작성할 때 자주 사용한다.

items.map((item) => item.name);

단순한 함수에서는 코드가 짧아지지만 복잡한 로직까지 한 줄에 넣는 것은 가독성이 좋지 않다.


Named Parameter

Dart는 named parameter를 지원한다.

void createPlayer({
  required String name,
  int level = 1,
}) {
  print(name);
}

호출할 때:

createPlayer(
  name: 'Knight',
  level: 10,
);

매개변수의 의미가 호출부에서 명확하게 드러난다.

Flutter 위젯 생성자가 다음과 같은 형태를 많이 사용하는 이유도 이것이다.

Widget(
  width: 100,
  height: 200,
);

required

Named parameter를 필수로 만들고 싶다면 required를 사용한다.

void createPlayer({
  required String name,
}) {
}

이제 다음 호출은 오류가 발생한다.

createPlayer();

다음과 같이 호출해야 한다.

createPlayer(name: 'Player');

객체 생성이나 함수 호출에서 필요한 값과 선택 가능한 값을 명확하게 구분할 수 있다.


Class

Dart는 객체지향 언어이며 클래스가 기본적인 코드 구조다.

class Player {
  String name;
  int health;

  Player(this.name, this.health);

  void attack() {
    print('Attack');
  }
}

객체를 생성한다.

final player = Player('Knight', 100);

player.attack();

생성자와 메서드, 필드를 하나의 객체로 묶을 수 있다.


Constructor

Dart에서는 생성자 문법이 상당히 간결하다.

class Player {
  final String name;
  final int health;

  Player(this.name, this.health);
}

이 코드는 생성자 매개변수를 받아 필드에 할당한다.

Named parameter를 이용하면 다음처럼 작성할 수도 있다.

class Player {
  final String name;
  final int health;

  Player({
    required this.name,
    required this.health,
  });
}

Flutter 코드에서 매우 자주 보게 되는 패턴이다.


Getter와 Setter

Dart에서는 getter와 setter를 정의할 수 있다.

class Player {
  int health = 100;

  bool get isAlive => health > 0;
}

사용할 때는 메서드처럼 ()를 붙이지 않는다.

if (player.isAlive) {
}

계산된 값을 프로퍼티처럼 노출하고 싶을 때 유용하다.


상속

Dart는 extends를 이용해 클래스를 상속한다.

class Character {
  void attack() {
    print('Attack');
  }
}

class Player extends Character {
  @override
  void attack() {
    print('Player Attack');
  }
}

부모 클래스의 메서드를 재정의할 때 @override를 사용한다.


Abstract Class

추상 클래스는 직접 객체를 생성하기보다 공통 인터페이스나 기본 구현을 제공하는 용도로 사용한다.

abstract class Character {
  void attack();
}

상속받은 클래스에서 구현한다.

class Player extends Character {
  @override
  void attack() {
    print('Player Attack');
  }
}

Interface

Dart에서는 별도의 interface 키워드를 사용하지 않아도 모든 클래스가 암묵적으로 인터페이스 역할을 할 수 있다.

class Character {
  void attack() {}
}

다른 클래스에서:

class Player implements Character {
  @override
  void attack() {
    print('Player Attack');
  }
}

extendsimplements의 차이는 중요하다.

extends -> 부모 클래스의 구현을 상속
implements -> 인터페이스가 요구하는 구조를 구현

Mixin

Dart의 특징적인 기능 중 하나가 mixin이다.

상속 관계가 아닌 기능을 여러 클래스에 재사용하고 싶을 때 사용할 수 있다.

mixin Flyable {
  void fly() {
    print('Fly');
  }
}

클래스에서 사용한다.

class Player with Flyable {
}

이제:

final player = Player();

player.fly();

mixin은 여러 클래스에 공통 기능을 조합할 때 사용할 수 있다.


Enum

Dart는 enum을 지원한다.

enum State {
  idle,
  move,
  attack,
  dead,
}

사용:

State state = State.idle;

게임 상태, 네트워크 상태, UI 상태처럼 정해진 값 집합을 표현할 때 적합하다.

Dart의 enum은 단순한 숫자 상수 집합보다 기능이 많으며 프로퍼티와 메서드도 정의할 수 있다.


Record

Dart에는 여러 값을 하나의 값으로 묶을 수 있는 Record가 있다.

(String, int) playerInfo = ('Knight', 10);

구조 분해해서 사용할 수도 있다.

final (name, level) = playerInfo;

Named field를 사용할 수도 있다.

({String name, int level}) player = (
  name: 'Knight',
  level: 10,
);

간단한 여러 값의 묶음을 만들 때 별도의 클래스를 만들 필요를 줄일 수 있다.


Extension

기존 클래스에 메서드나 getter를 추가하는 것처럼 사용할 수 있다.

extension StringExtension on String {
  bool get isBlank => trim().isEmpty;
}

이제:

final text = '';

print(text.isBlank);

기존 클래스를 직접 수정하지 않고 편의 기능을 추가할 수 있다.


Generic

Dart도 Generic을 지원한다.

class Box<T> {
  T value;

  Box(this.value);
}

사용:

final intBox = Box<int>(10);
final stringBox = Box<String>('Hello');

컬렉션에서도 사용한다.

List<Player> players = [];
Map<String, Player> players = {};

Generic을 사용하면 여러 타입에서 재사용하면서 타입 안정성을 유지할 수 있다.


Future

Dart의 대표적인 비동기 처리 타입이다.

Future<String> loadData() async {
  return 'Data';
}

Future<T>는 미래의 어느 시점에 T 값을 얻을 수 있다는 의미다.

Future<T> -> 현재는 결과 없음 -> 비동기 작업 -> T 결과

파일, 네트워크, DB 등 시간이 걸리는 작업에서 사용한다.


async와 await

비동기 함수를 만들 때 async를 사용한다.

Future<void> load() async {
  final data = await loadData();

  print(data);
}

await는 Future가 완료될 때까지 해당 비동기 함수의 실행을 잠시 중단한다.

중요한 것은 이것이 일반적인 스레드 블로킹과 같은 개념은 아니라는 점이다.

async function -> await -> 현재 작업 일시 중단 -> 다른 이벤트 처리 가능 -> Future 완료 -> 함수 재개

Dart의 비동기 코드는 Event Loop 기반으로 동작한다.


Stream

Future가 하나의 결과를 비동기적으로 받는 구조라면 Stream은 여러 개의 값을 시간에 따라 받을 수 있다.

Future -> Result 1개
Stream -> Result 여러 개

예를 들어:

Stream<int> count() async* {
  yield 1;
  yield 2;
  yield 3;
}

async*는 Stream을 생성하는 비동기 Generator를 만들 때 사용한다.

사용:

await for (final value in count()) {
  print(value);
}

네트워크 이벤트, 센서 데이터, UI 이벤트 등 지속적으로 데이터가 발생하는 상황에 적합하다.


Isolate

Dart의 병렬 실행 구조에서 중요한 개념이다.

Dart는 일반적인 공유 메모리 기반 Thread 모델 대신 Isolate라는 독립적인 실행 단위를 사용한다.

각 Isolate는 자체적인 메모리 공간과 Event Loop를 가진다.

Isolate A -> Memory + Event Loop
Isolate B -> Memory + Event Loop

Isolate끼리는 일반적으로 메모리를 직접 공유하지 않고 메시지를 주고받는다.

Isolate A -> Message -> Isolate B

CPU를 많이 사용하는 작업을 별도의 Isolate에서 처리하는 방식으로 활용할 수 있다.


Event Loop

Dart의 비동기 실행 구조는 Event Loop를 중심으로 이해할 수 있다.

Event Queue -> Event Loop -> Dart Code

비동기 작업이 완료되면 해당 작업의 결과가 이벤트 처리 흐름에 들어오고 Event Loop가 이를 처리한다.

따라서 네트워크 요청 같은 I/O 작업을 기다리는 동안 프로그램 전체가 멈추는 구조가 아니다.

Flutter에서 UI가 계속 반응할 수 있는 것도 이 비동기 실행 모델과 연결된다.


메모리 관리

Dart는 일반적으로 Garbage Collection을 사용하는 언어다.

개발자가 직접 free() 같은 함수를 호출해 객체의 메모리를 해제하지 않는다.

final player = Player('Knight', 100);

객체가 더 이상 접근 가능한 상태가 아니면 Garbage Collector가 회수할 수 있다.

Object -> Reference 존재 -> 사용 가능
Reference 제거 -> Unreachable -> Garbage Collection

따라서 C++처럼 메모리 해제 시점을 직접 관리하는 것과는 구조가 다르다.


Isolate와 메모리 관리의 관계

Dart의 Isolate는 서로 메모리를 직접 공유하지 않는다.

Isolate A -> Message -> Isolate B

이 구조 덕분에 일반적인 공유 메모리 Thread에서 발생하는 데이터 경쟁 문제를 줄일 수 있다.

대신 Isolate 간 통신 비용과 데이터 전달 방식을 고려해야 한다.


비동기와 병렬 실행은 다르다

Dart를 이해할 때 이 둘을 구분할 필요가 있다.

async / await -> 비동기 작업 -> I/O 대기 동안 다른 작업 수행
Isolate -> 별도의 실행 단위 -> CPU 작업 분산

네트워크 요청을 기다리는 것과 여러 CPU 코어에서 계산을 동시에 수행하는 것은 다른 문제다.

async/await만 사용한다고 CPU 작업이 병렬 실행되는 것은 아니다.


Dart와 Flutter

현재 Dart가 가장 널리 사용되는 영역은 Flutter다.

구조를 보면:

Flutter Application -> Dart Code -> Flutter Framework -> Flutter Engine -> Platform

Flutter에서는 화면의 UI도 Dart 코드로 작성한다.

class PlayerWidget extends StatelessWidget {
  const PlayerWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return const Text('Player');
  }
}

Flutter의 Widget 역시 Dart 객체다.

따라서 Dart의 클래스, 생성자, named parameter, const, collection 문법 등이 Flutter 코드에서 계속 등장한다.


Dart와 Flutter에서 const가 많은 이유

Flutter 코드를 보다 보면 const가 상당히 자주 등장한다.

const Text('Hello');

Flutter에서는 위젯을 불변 객체로 구성하는 패턴이 많기 때문이다.

컴파일 시점에 결정할 수 있는 객체를 const로 만들면 동일한 상수 객체를 재사용할 수 있고, 위젯 트리 구성에서도 불필요한 객체 생성을 줄이는 데 도움이 된다.

따라서 Dart의 const는 단순한 문법 기능이 아니라 Flutter의 선언적 UI 구조와도 연결된다.


Dart의 실행 구조

Dart를 전체적으로 보면 다음과 같이 정리할 수 있다.

Dart Source -> Compiler -> Native / JavaScript / WebAssembly -> Runtime

Flutter에서는:

Dart -> Flutter Framework -> Flutter Engine -> Native Platform

비동기 처리는:

async / await -> Future / Stream -> Event Loop

CPU 작업을 분리할 때는:

Isolate -> 독립 Memory -> 독립 Event Loop -> Message Passing

이렇게 보면 Dart의 주요 기능들이 각각 따로 존재하는 것이 아니라 하나의 실행 모델 안에서 연결되어 있다는 것을 알 수 있다.


Dart의 장점

Dart는 모바일 애플리케이션을 하나의 코드베이스로 개발하기 위한 Flutter와 궁합이 좋다.

언어 자체도 현대적인 앱 개발에 필요한 기능을 상당수 갖추고 있다.

Static Typing / Null Safety / Async / Await / Future / Stream / Generic / Mixin / Extension / Record / Pattern Matching

문법도 Java, C#, Kotlin 계열 개발자에게 크게 낯설지 않다.

특히 Flutter에서는 다음 요소들이 하나의 언어 안에서 연결된다.

Dart -> Object -> Widget -> State -> Async -> UI

Dart의 한계

Dart의 가장 큰 한계는 언어 자체보다는 생태계와 사용 범위에 있다.

Python이나 JavaScript처럼 다양한 분야에서 독립적으로 사용되는 언어는 아니며, 현재 Dart의 대표적인 사용처는 사실상 Flutter 생태계다.

따라서 Dart를 선택한다는 것은 상당 부분 Flutter 생태계까지 함께 선택한다는 의미가 된다.

반대로 Flutter를 사용한다면 Dart는 상당히 잘 맞는다.

언어와 UI 프레임워크가 함께 설계되어 있기 때문에 비동기 처리, 객체 모델, Widget 구성 등이 자연스럽게 연결된다.


정리

Dart를 핵심만 추리면 다음과 같이 정리할 수 있다.

Static Typing -> Type Inference -> Null Safety -> Class / Interface / Mixin -> Generic -> Collection -> Future -> Stream -> Event Loop -> Isolate -> Flutter

Dart에서 특히 기억할 만한 부분은 final, const, Null Safety, named parameter, Future, Stream, Isolate다.

문법 자체는 Java나 C#과 크게 다르지 않다. 오히려 Dart를 제대로 이해하려면 언어 문법보다 실행 모델과 Flutter와의 관계를 보는 편이 낫다.

Dart -> 정적 타입 + Null Safety -> 객체지향 코드 -> Future / Stream -> Event Loop -> Isolate -> Flutter UI

결국 Dart는 Flutter를 중심으로 네이티브 애플리케이션을 만들기 위해 설계된 현대적인 정적 타입 객체지향 언어라고 볼 수 있다.

Flutter에서 UI를 작성하는 것부터 네트워크 요청, 상태 관리, 비동기 처리까지 전부 Dart로 연결되기 때문에 Flutter 개발을 한다면 Widget 문법만 따로 배우는 것보다 Dart의 타입 시스템과 비동기 실행 모델을 먼저 이해하는 편이 장기적으로 훨씬 낫다.